Story 3.2 done
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
// @ts-nocheck
|
||||
import { ScryingPoolStrip } from './gm/ScryingPoolStrip.js';
|
||||
|
||||
/**
|
||||
* Reacts to state changes and applies visual state to AV tiles.
|
||||
* Constructs and manages the GM-only ScryingPoolStrip window.
|
||||
* Subscribes to Foundry Hooks after explicit `init()` call.
|
||||
*/
|
||||
export class RoleRenderer {
|
||||
/**
|
||||
* @param {object} stateStore - StateStore instance
|
||||
* @param {object} controller - ScryingPoolController instance
|
||||
* @param {object} avTileAdapter - AVTileAdapter instance
|
||||
* @param {object} adapter - FoundryAdapter instance
|
||||
*/
|
||||
constructor(stateStore, controller, avTileAdapter, adapter) {
|
||||
this._stateStore = stateStore;
|
||||
this._controller = controller;
|
||||
this._avTileAdapter = avTileAdapter;
|
||||
this._adapter = adapter;
|
||||
/** @type {ScryingPoolStrip|null} */
|
||||
this._strip = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers Hooks listeners. Must be called once during module ready.
|
||||
*/
|
||||
init() {
|
||||
Hooks.on('scrying-pool:stateChanged', data => {
|
||||
if (data.userId) {
|
||||
this._applyAVTileState(data.userId, data.state);
|
||||
}
|
||||
});
|
||||
Hooks.on('scrying-pool:controllerAction', data => {
|
||||
this._onControllerAction(data);
|
||||
});
|
||||
Hooks.on('updateUser', () => {
|
||||
if (this._strip?.rendered) {
|
||||
this._strip.render(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies visual state to an AV tile: state CSS class + lock overlay + portrait fallback.
|
||||
* @param {string} userId
|
||||
* @param {string} state
|
||||
*/
|
||||
_applyAVTileState(userId, state) {
|
||||
this._avTileAdapter.setStateClass(userId, state);
|
||||
|
||||
const HIDDEN = state === 'hidden';
|
||||
const CAMERA_ABSENT = state === 'never-connected' || state === 'cam-lost';
|
||||
|
||||
if (HIDDEN) {
|
||||
const lockEl = document.createElement('div');
|
||||
lockEl.className = 'sp-lock-overlay';
|
||||
lockEl.dataset.spRole = 'lock-overlay';
|
||||
lockEl.title = 'Camera hidden by GM';
|
||||
this._avTileAdapter.mount(userId, lockEl);
|
||||
} else if (CAMERA_ABSENT) {
|
||||
const fallbackEl = document.createElement('div');
|
||||
fallbackEl.className = 'sp-portrait-fallback';
|
||||
fallbackEl.dataset.spRole = 'portrait-fallback';
|
||||
this._avTileAdapter.mount(userId, fallbackEl);
|
||||
} else {
|
||||
this._avTileAdapter.unmount(userId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles controller action events for pending op visual feedback.
|
||||
* @param {{ participantId: string, targetState: string, source: string }} data
|
||||
*/
|
||||
_onControllerAction(data) {
|
||||
if (!data?.participantId) return;
|
||||
if (this._controller.hasPendingOp(data.participantId)) {
|
||||
this._avTileAdapter.setStateClass(data.participantId, 'pending');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the ScryingPoolStrip window (GM only). Constructs lazily on first call.
|
||||
*/
|
||||
openStrip() {
|
||||
if (!this._strip) {
|
||||
this._strip = new ScryingPoolStrip(
|
||||
this._stateStore,
|
||||
this._controller,
|
||||
this._avTileAdapter,
|
||||
this._adapter
|
||||
);
|
||||
}
|
||||
this._strip.render(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the ScryingPoolStrip window if open.
|
||||
*/
|
||||
closeStrip() {
|
||||
if (this._strip) {
|
||||
this._strip.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
/**
|
||||
* ConfirmationBar — Strip-local feedback component for preset apply operations.
|
||||
*
|
||||
* Owns: Displaying preset apply confirmation, Undo button, auto-dismiss timer.
|
||||
* Rendered in StripOverlayLayer at position: absolute; bottom: 0.
|
||||
* Supports instant-replace rule (zero crossfade for consecutive bars).
|
||||
*
|
||||
* Import rule: may import from src/core/, src/contracts/, src/utils/ ONLY.
|
||||
* Constructors are side-effect free — call init() from module.js Hooks.once('ready').
|
||||
*
|
||||
* Story 3.2: Scene Auto-Apply & ConfirmationBar
|
||||
*
|
||||
* @module ui/gm/ConfirmationBar
|
||||
*/
|
||||
|
||||
/**
|
||||
* Strip-local feedback bar for preset apply operations.
|
||||
* Provides immediate visual confirmation with one-click Undo.
|
||||
*/
|
||||
export class ConfirmationBar {
|
||||
/**
|
||||
* @param {import('../../foundry/FoundryAdapter.js').FoundryAdapter} adapter
|
||||
* Injected FoundryAdapter surface.
|
||||
* @param {import('../../core/VisibilityManager.js').VisibilityManager} visibilityManager
|
||||
* Injected VisibilityManager for reverting matrix on undo.
|
||||
* @param {import('../../core/SocketHandler.js').SocketHandler} socketHandler
|
||||
* Injected SocketHandler (for potential future use).
|
||||
* @param {import('../shared/StripOverlayLayer.js').StripOverlayLayer} stripOverlayLayer
|
||||
* Injected StripOverlayLayer for rendering.
|
||||
*/
|
||||
constructor(adapter, visibilityManager, socketHandler, stripOverlayLayer) {
|
||||
this._adapter = adapter;
|
||||
this._visibilityManager = visibilityManager;
|
||||
this._socketHandler = socketHandler;
|
||||
this._stripOverlayLayer = stripOverlayLayer;
|
||||
|
||||
// State
|
||||
/** @type {object|null} */
|
||||
this._previousMatrix = null;
|
||||
/** @type {object|null} */
|
||||
this._lastPayload = null;
|
||||
/** @type {number|null} */
|
||||
this._dismissTimer = null;
|
||||
/** @type {boolean} */
|
||||
this._isVisible = false;
|
||||
/** @type {number} */
|
||||
this._lastAppliedTimestamp = 0;
|
||||
/** @type {number} */
|
||||
this._recentApplyCount = 0;
|
||||
/** @type {boolean} */
|
||||
this._hookRegistered = false;
|
||||
/** @type {Function} */
|
||||
this._hookHandler = this._onPresetApplied.bind(this);
|
||||
/** @type {Function|null} */
|
||||
this._clickHandler = null;
|
||||
|
||||
// Constants
|
||||
/** @type {number} Default bar duration in ms */
|
||||
this._DEFAULT_DURATION = 8000;
|
||||
/** @type {number} Short duration when >=2 applies within 60s */
|
||||
this._SHORT_DURATION = 4000;
|
||||
/** @type {number} Recent apply window in ms */
|
||||
this._RECENT_WINDOW_MS = 60000;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the ConfirmationBar by registering hook listeners.
|
||||
* Side-effect: registers listener for scrying-pool:presetApplied hook.
|
||||
* Idempotent - safe to call multiple times.
|
||||
*/
|
||||
init() {
|
||||
// Only register hook if not already registered
|
||||
if (!this._hookRegistered) {
|
||||
this._adapter.hooks.on('scrying-pool:presetApplied', this._hookHandler);
|
||||
this._hookRegistered = true;
|
||||
}
|
||||
this._setupEventListeners();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up DOM event listeners for the confirmation bar.
|
||||
* Uses event delegation via StripOverlayLayer's element.
|
||||
* @private
|
||||
*/
|
||||
_setupEventListeners() {
|
||||
if (!this._stripOverlayLayer || !this._stripOverlayLayer.element) {
|
||||
return;
|
||||
}
|
||||
|
||||
const element = this._stripOverlayLayer.element;
|
||||
|
||||
// Store bound handler
|
||||
this._clickHandler = (event) => {
|
||||
const target = event.target.closest?.('[data-action="confirmation-bar-undo"]');
|
||||
if (target) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this._onUndo();
|
||||
}
|
||||
};
|
||||
|
||||
// Use event delegation for undo button clicks
|
||||
element.addEventListener('click', this._clickHandler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans up internal state and unregisters listeners.
|
||||
* Safe to call multiple times.
|
||||
*/
|
||||
teardown() {
|
||||
this._adapter.hooks.off('scrying-pool:presetApplied', this._hookHandler);
|
||||
this._hookRegistered = false;
|
||||
this._removeEventListeners();
|
||||
this._clearDismissTimer();
|
||||
this._previousMatrix = null;
|
||||
this._lastPayload = null;
|
||||
this._isVisible = false;
|
||||
this._recentApplyCount = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes DOM event listeners.
|
||||
* @private
|
||||
*/
|
||||
_removeEventListeners() {
|
||||
if (!this._stripOverlayLayer || !this._stripOverlayLayer.element || !this._clickHandler) {
|
||||
return;
|
||||
}
|
||||
|
||||
const element = this._stripOverlayLayer.element;
|
||||
element.removeEventListener('click', this._clickHandler);
|
||||
this._clickHandler = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows the confirmation bar with the given payload.
|
||||
* Captures previous matrix for undo, renders bar, starts dismiss timer.
|
||||
*
|
||||
* @param {object} payload - The preset applied payload.
|
||||
* @param {string} payload.presetName - Name of the applied preset.
|
||||
* @param {object} payload.matrix - The visibility matrix that was applied.
|
||||
* @param {boolean} payload.autoApplied - Whether this was an auto-apply.
|
||||
* @param {boolean} [payload.partialFail] - Whether some participants failed to update.
|
||||
* @param {number} [payload.timestamp] - When the preset was applied.
|
||||
*/
|
||||
show(payload) {
|
||||
if (!payload) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Store previous matrix for undo
|
||||
if (payload.matrix) {
|
||||
this._previousMatrix = payload.matrix;
|
||||
}
|
||||
this._lastPayload = payload;
|
||||
this._lastAppliedTimestamp = payload.timestamp ?? Date.now();
|
||||
|
||||
// Track recent applies for short duration logic
|
||||
const now = Date.now();
|
||||
if (now - this._lastAppliedTimestamp < this._RECENT_WINDOW_MS) {
|
||||
this._recentApplyCount++;
|
||||
} else {
|
||||
this._recentApplyCount = 1;
|
||||
}
|
||||
|
||||
// Render the bar
|
||||
this._render();
|
||||
|
||||
// Start dismiss timer
|
||||
this._startDismissTimer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Hides the confirmation bar.
|
||||
* Clears timer, previous matrix, and updates visibility state.
|
||||
*/
|
||||
hide() {
|
||||
this._clearDismissTimer();
|
||||
this._previousMatrix = null;
|
||||
this._lastPayload = null;
|
||||
this._isVisible = false;
|
||||
this._recentApplyCount = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the Undo button click.
|
||||
* Reverts to previous matrix and hides the bar.
|
||||
* Emits hook for undo notification.
|
||||
* @private
|
||||
*/
|
||||
_onUndo() {
|
||||
if (!this._previousMatrix || !this._visibilityManager) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this._visibilityManager.applyMatrix(this._previousMatrix);
|
||||
|
||||
// Emit undo notification
|
||||
this._adapter.hooks.callAll('scrying-pool:presetUndo', {
|
||||
presetName: this._lastPayload?.presetName ?? 'unknown',
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(
|
||||
'[ScryingPool] ConfirmationBar: failed to undo preset apply',
|
||||
err instanceof Error ? err.message : String(err)
|
||||
);
|
||||
} finally {
|
||||
this.hide();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the confirmation bar in the strip overlay.
|
||||
* Calculates hidden/visible counts from matrix.
|
||||
* Determines variant (default or amber for partial fail).
|
||||
* @private
|
||||
*/
|
||||
_render() {
|
||||
if (!this._lastPayload || !this._stripOverlayLayer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { presetName, partialFail, matrix } = this._lastPayload;
|
||||
const counts = this._calculateCounts(matrix);
|
||||
const variant = partialFail ? 'amber' : 'default';
|
||||
const message = this._buildMessage(presetName, counts, variant);
|
||||
|
||||
// Build HTML content
|
||||
const html = this._buildHtml(message, variant);
|
||||
|
||||
// Render via strip overlay
|
||||
this._stripOverlayLayer.render(html);
|
||||
this._isVisible = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates hidden and visible counts from a visibility matrix.
|
||||
* Only counts states that affect visibility (active, hidden).
|
||||
* @param {object} matrix - The visibility matrix.
|
||||
* @returns {object} Hidden and visible counts.
|
||||
* @private
|
||||
*/
|
||||
_calculateCounts(matrix) {
|
||||
if (!matrix || !matrix.matrix) {
|
||||
return { hidden: 0, visible: 0 };
|
||||
}
|
||||
|
||||
let hidden = 0;
|
||||
let visible = 0;
|
||||
|
||||
for (const [, state] of Object.entries(matrix.matrix)) {
|
||||
if (state === 'hidden') {
|
||||
hidden++;
|
||||
} else if (state === 'active') {
|
||||
visible++;
|
||||
}
|
||||
// Other states (self-muted, offline, etc.) are not counted
|
||||
// as they don't affect the "hidden from table" status
|
||||
}
|
||||
|
||||
return { hidden, visible };
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the display message for the confirmation bar.
|
||||
* @param {string} presetName - Name of the preset.
|
||||
* @param {object} counts - Hidden and visible counts.
|
||||
* @param {string} variant - 'default' or 'amber'.
|
||||
* @returns {string} The formatted message.
|
||||
* @private
|
||||
*/
|
||||
_buildMessage(presetName, counts, variant) {
|
||||
const baseMsg = this._adapter.i18n.localize('video-view-manager.presets.confirmation.applied')
|
||||
.replace('{name}', presetName);
|
||||
const countMsg = this._adapter.i18n.localize('video-view-manager.presets.confirmation.counts')
|
||||
.replace('{hidden}', counts.hidden)
|
||||
.replace('{visible}', counts.visible);
|
||||
|
||||
if (variant === 'amber') {
|
||||
const suffix = this._adapter.i18n.localize('video-view-manager.presets.confirmation.partial-fail');
|
||||
return `${baseMsg} ${countMsg} ${suffix}`;
|
||||
}
|
||||
|
||||
return `${baseMsg} ${countMsg}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the HTML content for the confirmation bar.
|
||||
* Includes message, undo button, accessibility attributes.
|
||||
* Uses data-action pattern for event delegation.
|
||||
* @param {string} message - The display message.
|
||||
* @param {string} variant - 'default' or 'amber'.
|
||||
* @returns {string} HTML string.
|
||||
* @private
|
||||
*/
|
||||
_buildHtml(message, variant) {
|
||||
const variantClass = variant === 'amber' ? 'sp-confirmation-bar--amber' : 'sp-confirmation-bar--default';
|
||||
const undoLabel = this._adapter.i18n.localize('video-view-manager.presets.confirmation.undo');
|
||||
|
||||
// Use data-action for event delegation via StripOverlayLayer
|
||||
// The onclick handler is set up in _setupEventListeners
|
||||
return `
|
||||
<div class="scrying-pool__confirmation-bar ${variantClass}"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-label="${message}. ${undoLabel}">
|
||||
<span class="sp-confirmation-bar__message">${this._escapeHtml(message)}</span>
|
||||
<button class="sp-confirmation-bar__undo-btn"
|
||||
role="button"
|
||||
aria-label="${undoLabel}"
|
||||
data-action="confirmation-bar-undo">
|
||||
${undoLabel}
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Escapes HTML special characters to prevent XSS.
|
||||
* @param {string} str - String to escape.
|
||||
* @returns {string} Escaped string.
|
||||
* @private
|
||||
*/
|
||||
_escapeHtml(str) {
|
||||
if (!str || typeof str !== 'string') {
|
||||
return '';
|
||||
}
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the auto-dismiss timer.
|
||||
* Uses short duration (4000ms) if >=2 applies within 60s window.
|
||||
* Otherwise uses default duration (8000ms).
|
||||
* @private
|
||||
*/
|
||||
_startDismissTimer() {
|
||||
// Clear any existing timer
|
||||
this._clearDismissTimer();
|
||||
|
||||
// Determine duration based on recent activity
|
||||
const now = Date.now();
|
||||
const useShort = this._recentApplyCount >= 2 &&
|
||||
(now - this._lastAppliedTimestamp) < this._RECENT_WINDOW_MS;
|
||||
const duration = useShort ? this._SHORT_DURATION : this._DEFAULT_DURATION;
|
||||
|
||||
this._dismissTimer = setTimeout(() => {
|
||||
this.hide();
|
||||
}, duration);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the active dismiss timer.
|
||||
* @private
|
||||
*/
|
||||
_clearDismissTimer() {
|
||||
if (this._dismissTimer) {
|
||||
clearTimeout(this._dismissTimer);
|
||||
this._dismissTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook handler for scrying-pool:presetApplied events.
|
||||
* Shows the bar, implementing instant-replace rule.
|
||||
* @param {object} payload - The preset applied payload.
|
||||
* @private
|
||||
*/
|
||||
_onPresetApplied(payload) {
|
||||
// Instant-replace: just show again, no crossfade
|
||||
this.show(payload);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { PresetSaveDialog } from './PresetSaveDialog.js';
|
||||
import { PresetLoadDialog } from './PresetLoadDialog.js';
|
||||
import { PresetExportDialog } from './PresetExportDialog.js';
|
||||
import { PresetImportDialog } from './PresetImportDialog.js';
|
||||
import { ScenePresetPanel } from './ScenePresetPanel.js';
|
||||
|
||||
// Conditional base class — test environment lacks foundry globals.
|
||||
// At module load time in tests, foundry is undefined → fallback class is used.
|
||||
@@ -86,11 +87,27 @@ export class DirectorsBoard extends _AppBase {
|
||||
this._exportDialog = null;
|
||||
/** @type {PresetImportDialog|null} Reference to open import dialog for cleanup */
|
||||
this._importDialog = null;
|
||||
/** @type {ScenePresetPanel|null} Reference to scene preset panel for cleanup */
|
||||
this._presetPanel = null;
|
||||
|
||||
// Load saved position from user flags
|
||||
this._loadPosition();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the ScenePresetPanel instance, creating it lazily if needed.
|
||||
* Story 3.2: Scene Auto-Apply & ConfirmationBar
|
||||
* @returns {ScenePresetPanel} The panel instance.
|
||||
* @private
|
||||
*/
|
||||
_getPresetPanel() {
|
||||
if (!this._presetPanel) {
|
||||
this._presetPanel = new ScenePresetPanel(this._adapter, this._scenePresetManager);
|
||||
this._presetPanel.init();
|
||||
}
|
||||
return this._presetPanel;
|
||||
}
|
||||
|
||||
/** Loads saved window position from GM user flag. */
|
||||
_loadPosition() {
|
||||
try {
|
||||
@@ -119,6 +136,12 @@ export class DirectorsBoard extends _AppBase {
|
||||
Hooks.off('scrying-pool:stateChanged', this._hookId);
|
||||
this._hookId = null;
|
||||
}
|
||||
|
||||
// Story 3.2: Tear down ScenePresetPanel
|
||||
if (this._presetPanel) {
|
||||
this._presetPanel.teardown();
|
||||
this._presetPanel = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Opens the board if closed; closes it if open (singleton toggle behaviour). */
|
||||
@@ -316,12 +339,32 @@ export class DirectorsBoard extends _AppBase {
|
||||
async _prepareContext() {
|
||||
const base = buildBoardContext(this._stateStore, this._controller, this._adapter);
|
||||
const presetCount = this._scenePresetManager?.list?.().length ?? 0;
|
||||
|
||||
// Get auto-apply config for current scene (Story 3.2)
|
||||
let autoApplyConfig = { enabled: false, presetName: null, preDelay: 0 };
|
||||
try {
|
||||
const scenes = this._adapter.scenes;
|
||||
const currentScene = scenes?.current?.();
|
||||
if (currentScene) {
|
||||
const flagData = this._scenePresetManager?._getSceneFlagData?.(currentScene);
|
||||
autoApplyConfig = this._scenePresetManager?._getAutoApplyConfig?.(flagData) ?? autoApplyConfig;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[ScryingPool] Failed to get auto-apply config for context:', err);
|
||||
}
|
||||
|
||||
return {
|
||||
...base,
|
||||
hasUndo: this._undoSnapshot !== null,
|
||||
hasRestore: this._spotlightSnapshot !== null,
|
||||
presetCount,
|
||||
hasPresets: presetCount > 0,
|
||||
// Story 3.2: Auto-apply configuration
|
||||
hasScene: !!this._adapter.scenes?.current?.(),
|
||||
autoApplyEnabled: autoApplyConfig.enabled,
|
||||
autoApplyPresetName: autoApplyConfig.presetName,
|
||||
autoApplyPreDelay: autoApplyConfig.preDelay,
|
||||
presets: this._scenePresetManager?.list?.() ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -362,6 +405,8 @@ export class DirectorsBoard extends _AppBase {
|
||||
case 'load-preset': this._onLoadPreset(); break;
|
||||
case 'export-presets': this._onExportPresets(); break;
|
||||
case 'import-presets': this._onImportPresets(); break;
|
||||
// Story 3.2: Scene auto-apply panel toggle
|
||||
case 'toggle-preset-panel': this._togglePresetPanel(); break;
|
||||
}
|
||||
};
|
||||
this._focusinHandler = (e) => {
|
||||
@@ -374,6 +419,43 @@ export class DirectorsBoard extends _AppBase {
|
||||
root.addEventListener('click', this._clickHandler);
|
||||
root.addEventListener('focusin', this._focusinHandler);
|
||||
root.addEventListener('keydown', this._keydownHandler);
|
||||
|
||||
// Story 3.2: Append ScenePresetPanel to DOM and refresh
|
||||
this._appendPresetPanel(root);
|
||||
this._refreshPresetPanel();
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends the ScenePresetPanel to the DirectorsBoard DOM.
|
||||
* Story 3.2: Scene Auto-Apply & ConfirmationBar
|
||||
* @param {HTMLElement} root - The DirectorsBoard root element.
|
||||
* @private
|
||||
*/
|
||||
_appendPresetPanel(root) {
|
||||
const panel = this._getPresetPanel();
|
||||
if (!panel || !panel.element) return;
|
||||
|
||||
// Find where to insert the panel (after content, before footer)
|
||||
const content = root.querySelector('.directors-board__content');
|
||||
if (content) {
|
||||
// Insert after content
|
||||
content.after(panel.element);
|
||||
} else {
|
||||
// Fallback: prepend to root
|
||||
root.prepend(panel.element);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes the ScenePresetPanel content.
|
||||
* Story 3.2: Scene Auto-Apply & ConfirmationBar
|
||||
* @private
|
||||
*/
|
||||
_refreshPresetPanel() {
|
||||
const panel = this._getPresetPanel();
|
||||
if (panel) {
|
||||
panel._refresh?.();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -490,6 +572,17 @@ export class DirectorsBoard extends _AppBase {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles the ScenePresetPanel visibility.
|
||||
* Story 3.2: Scene Auto-Apply & ConfirmationBar
|
||||
*/
|
||||
_togglePresetPanel() {
|
||||
const panel = this._getPresetPanel();
|
||||
if (panel) {
|
||||
panel.toggle();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the PresetSaveDialog for saving the current visibility matrix as a preset.
|
||||
*/
|
||||
|
||||
@@ -97,6 +97,8 @@ export class PresetImportDialog extends _AppBase {
|
||||
previewItems: this._previewItems,
|
||||
requiresConfirmation: this._requiresConfirmation,
|
||||
selectedFileName: this._selectedFile?.name ?? null,
|
||||
mergeLabel: 'Merge',
|
||||
replaceLabel: 'Replace',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
// @ts-nocheck
|
||||
|
||||
// Conditional base class — test environment lacks foundry globals.
|
||||
// At module load time in tests, foundry is undefined → fallback class is used.
|
||||
|
||||
/** @private */
|
||||
const _AppBase =
|
||||
typeof foundry !== 'undefined' &&
|
||||
foundry.applications?.api?.HandlebarsApplicationMixin &&
|
||||
foundry.applications?.api?.ApplicationV2
|
||||
? foundry.applications.api.HandlebarsApplicationMixin(
|
||||
foundry.applications.api.ApplicationV2
|
||||
)
|
||||
: class _FallbackApp {
|
||||
static DEFAULT_OPTIONS = {};
|
||||
static PARTS = {};
|
||||
get rendered() { return this._rendered ?? false; }
|
||||
set rendered(v) { this._rendered = v; }
|
||||
get element() { return this._element ?? null; }
|
||||
set element(v) { this._element = v; }
|
||||
async render() { this._rendered = true; }
|
||||
async close() { this._rendered = false; }
|
||||
async _prepareContext() { return {}; }
|
||||
_onRender() {}
|
||||
_onClose() {}
|
||||
_onPosition() {}
|
||||
};
|
||||
|
||||
/**
|
||||
* Dialog for loading a scene preset.
|
||||
* Extends ApplicationV2 via HandlebarsApplicationMixin.
|
||||
*/
|
||||
export class PresetLoadDialog extends _AppBase {
|
||||
static DEFAULT_OPTIONS = {
|
||||
id: 'scrying-pool-preset-load-dialog',
|
||||
classes: ['scrying-pool', 'preset-load-dialog'],
|
||||
window: { title: 'Load Scene Preset', resizable: false },
|
||||
position: { width: 320, height: 'auto' },
|
||||
};
|
||||
|
||||
static PARTS = {
|
||||
dialog: {
|
||||
template: 'modules/video-view-manager/templates/preset-load-dialog.hbs',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {import('../../core/ScenePresetManager.js').ScenePresetManager} scenePresetManager
|
||||
* Injected ScenePresetManager for preset operations.
|
||||
* @param {import('../../foundry/FoundryAdapter.js').FoundryAdapter} adapter
|
||||
* Injected FoundryAdapter surface.
|
||||
* @param {object} [options]
|
||||
*/
|
||||
constructor(scenePresetManager, adapter, options = {}) {
|
||||
// Validate dependencies
|
||||
if (!scenePresetManager || typeof scenePresetManager !== 'object') {
|
||||
throw new TypeError('PresetLoadDialog: scenePresetManager argument is required and must be an object');
|
||||
}
|
||||
if (!adapter || typeof adapter !== 'object') {
|
||||
throw new TypeError('PresetLoadDialog: adapter argument is required and must be an object');
|
||||
}
|
||||
|
||||
super(options);
|
||||
this._scenePresetManager = scenePresetManager;
|
||||
this._adapter = adapter;
|
||||
/** @type {Array<import('../../contracts/scene-preset.js').ScenePreset>} */
|
||||
this._presets = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepares the template context with i18n labels and preset list.
|
||||
* @returns {Promise<object>} Template context.
|
||||
*/
|
||||
async _prepareContext() {
|
||||
const i18n = this._adapter.i18n;
|
||||
|
||||
// Get list of presets from manager
|
||||
this._presets = this._scenePresetManager.list();
|
||||
|
||||
return {
|
||||
presets: this._presets,
|
||||
hasPresets: this._presets.length > 0,
|
||||
loadLabel: i18n.localize('video-view-manager.presets.load.loadButton'),
|
||||
cancelLabel: i18n.localize('video-view-manager.presets.load.cancelButton'),
|
||||
title: i18n.localize('video-view-manager.presets.load.title'),
|
||||
emptyMessage: i18n.localize('video-view-manager.presets.load.emptyMessage'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up event handlers after rendering.
|
||||
* @param {HTMLElement} element - The dialog element.
|
||||
*/
|
||||
_onRender(element) {
|
||||
// Set up load button handlers for each preset
|
||||
const loadButtons = element.querySelectorAll('[data-action="load"]');
|
||||
loadButtons.forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
const presetName = btn.dataset.presetName;
|
||||
if (presetName) {
|
||||
this._onLoad(presetName);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Cancel button handler
|
||||
const cancelBtn = element.querySelector('[data-action="cancel"]');
|
||||
if (cancelBtn) {
|
||||
cancelBtn.addEventListener('click', () => this._onCancel());
|
||||
}
|
||||
|
||||
// Keyboard support
|
||||
element.addEventListener('keydown', (event) => this._onKeydown(event));
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles keyboard events for dialog navigation.
|
||||
* @param {KeyboardEvent} event - The keyboard event.
|
||||
*/
|
||||
_onKeydown(event) {
|
||||
// Escape key cancels the dialog
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this._onCancel();
|
||||
}
|
||||
// Enter key on a load button triggers load
|
||||
else if (event.key === 'Enter' && event.target.dataset?.action === 'load') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const presetName = event.target.dataset.presetName;
|
||||
if (presetName) {
|
||||
this._onLoad(presetName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles loading a preset by name.
|
||||
* @param {string} presetName - The preset name to load.
|
||||
* @throws {TypeError} If preset name is invalid.
|
||||
*/
|
||||
async _onLoad(presetName) {
|
||||
if (typeof presetName !== 'string' || presetName.length === 0) {
|
||||
throw new TypeError('PresetLoadDialog._onLoad: presetName must be a non-empty string');
|
||||
}
|
||||
|
||||
try {
|
||||
await this._scenePresetManager.load(presetName);
|
||||
|
||||
// Show success notification
|
||||
this._adapter.notifications.info(
|
||||
this._adapter.i18n.localize('video-view-manager.presets.notifications.applied')
|
||||
.replace('{name}', presetName)
|
||||
);
|
||||
|
||||
// Close dialog
|
||||
await this.close();
|
||||
} catch (err) {
|
||||
// Re-throw validation errors from ScenePresetManager
|
||||
if (err instanceof TypeError) {
|
||||
throw err;
|
||||
}
|
||||
// For other errors, log and re-throw
|
||||
console.error('[ScryingPool] PresetLoadDialog: failed to load preset:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles cancel action — closes the dialog.
|
||||
*/
|
||||
_onCancel() {
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
// @ts-nocheck
|
||||
|
||||
// Conditional base class — test environment lacks foundry globals.
|
||||
// At module load time in tests, foundry is undefined → fallback class is used.
|
||||
|
||||
/** @private */
|
||||
const _AppBase =
|
||||
typeof foundry !== 'undefined' &&
|
||||
foundry.applications?.api?.HandlebarsApplicationMixin &&
|
||||
foundry.applications?.api?.ApplicationV2
|
||||
? foundry.applications.api.HandlebarsApplicationMixin(
|
||||
foundry.applications.api.ApplicationV2
|
||||
)
|
||||
: class _FallbackApp {
|
||||
static DEFAULT_OPTIONS = {};
|
||||
static PARTS = {};
|
||||
get rendered() { return this._rendered ?? false; }
|
||||
set rendered(v) { this._rendered = v; }
|
||||
get element() { return this._element ?? null; }
|
||||
set element(v) { this._element = v; }
|
||||
async render() { this._rendered = true; }
|
||||
async close() { this._rendered = false; }
|
||||
async _prepareContext() { return {}; }
|
||||
_onRender() {}
|
||||
_onClose() {}
|
||||
_onPosition() {}
|
||||
};
|
||||
|
||||
/**
|
||||
* Dialog for saving a scene preset.
|
||||
* Extends ApplicationV2 via HandlebarsApplicationMixin.
|
||||
*/
|
||||
export class PresetSaveDialog extends _AppBase {
|
||||
static DEFAULT_OPTIONS = {
|
||||
id: 'scrying-pool-preset-save-dialog',
|
||||
classes: ['scrying-pool', 'preset-save-dialog'],
|
||||
window: { title: 'Save Scene Preset', resizable: false },
|
||||
position: { width: 320, height: 'auto' },
|
||||
};
|
||||
|
||||
static PARTS = {
|
||||
dialog: {
|
||||
template: 'modules/video-view-manager/templates/preset-save-dialog.hbs',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {import('../../core/ScenePresetManager.js').ScenePresetManager} scenePresetManager
|
||||
* Injected ScenePresetManager for preset operations.
|
||||
* @param {import('../../foundry/FoundryAdapter.js').FoundryAdapter} adapter
|
||||
* Injected FoundryAdapter surface.
|
||||
* @param {object} [options]
|
||||
*/
|
||||
constructor(scenePresetManager, adapter, options = {}) {
|
||||
// Validate dependencies
|
||||
if (!scenePresetManager || typeof scenePresetManager !== 'object') {
|
||||
throw new TypeError('PresetSaveDialog: scenePresetManager argument is required and must be an object');
|
||||
}
|
||||
if (!adapter || typeof adapter !== 'object') {
|
||||
throw new TypeError('PresetSaveDialog: adapter argument is required and must be an object');
|
||||
}
|
||||
|
||||
super(options);
|
||||
this._scenePresetManager = scenePresetManager;
|
||||
this._adapter = adapter;
|
||||
/** @type {HTMLElement|null} */
|
||||
this._nameInput = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepares the template context with i18n labels and default values.
|
||||
* @returns {Promise<object>} Template context.
|
||||
*/
|
||||
async _prepareContext() {
|
||||
const i18n = this._adapter.i18n;
|
||||
|
||||
return {
|
||||
defaultName: '',
|
||||
saveLabel: i18n.localize('video-view-manager.presets.save.saveButton'),
|
||||
cancelLabel: i18n.localize('video-view-manager.presets.save.cancelButton'),
|
||||
title: i18n.localize('video-view-manager.presets.save.title'),
|
||||
nameLabel: i18n.localize('video-view-manager.presets.save.nameLabel'),
|
||||
namePlaceholder: i18n.localize('video-view-manager.presets.save.namePlaceholder'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up event handlers after rendering.
|
||||
* @param {HTMLElement} element - The dialog element.
|
||||
*/
|
||||
_onRender(element) {
|
||||
// Cache the name input
|
||||
this._nameInput = element.querySelector('[name="presetName"]');
|
||||
|
||||
// Focus the name input
|
||||
if (this._nameInput) {
|
||||
this._nameInput.focus();
|
||||
}
|
||||
|
||||
// Form submit handler
|
||||
const form = element.querySelector('form');
|
||||
if (form) {
|
||||
form.addEventListener('submit', (event) => this._onSubmit(event));
|
||||
}
|
||||
|
||||
// Cancel button handler
|
||||
const cancelBtn = element.querySelector('[data-action="cancel"]');
|
||||
if (cancelBtn) {
|
||||
cancelBtn.addEventListener('click', () => this._onCancel());
|
||||
}
|
||||
|
||||
// Keyboard support
|
||||
element.addEventListener('keydown', (event) => this._onKeydown(event));
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles keyboard events for dialog navigation.
|
||||
* @param {KeyboardEvent} event - The keyboard event.
|
||||
*/
|
||||
_onKeydown(event) {
|
||||
// Enter key on input field triggers save
|
||||
if (event.key === 'Enter' && event.target.tagName === 'INPUT') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const submitEvent = { preventDefault: () => {}, stopPropagation: () => {}, target: event.target.form };
|
||||
this._onSubmit(submitEvent);
|
||||
}
|
||||
// Escape key cancels the dialog
|
||||
else if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this._onCancel();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles form submission to save a preset.
|
||||
* @param {Event} event - The form submit event.
|
||||
* @throws {TypeError} If name is invalid.
|
||||
*/
|
||||
async _onSubmit(event) {
|
||||
if (!event) {
|
||||
throw new TypeError('PresetSaveDialog._onSubmit: event is required');
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
// Get preset name from form
|
||||
const form = event.target;
|
||||
const nameInput = form.querySelector('[name="presetName"]');
|
||||
if (!nameInput) {
|
||||
throw new TypeError('PresetSaveDialog._onSubmit: preset name input not found');
|
||||
}
|
||||
|
||||
const name = nameInput.value?.trim();
|
||||
|
||||
// Validate name
|
||||
if (typeof name !== 'string' || name.length === 0) {
|
||||
throw new TypeError('PresetSaveDialog._onSubmit: preset name must be a non-empty string');
|
||||
}
|
||||
|
||||
// Save preset via manager
|
||||
try {
|
||||
await this._scenePresetManager.save(name);
|
||||
|
||||
// Show success notification
|
||||
this._adapter.notifications.info(
|
||||
this._adapter.i18n.localize('video-view-manager.presets.notifications.saved')
|
||||
.replace('{name}', name)
|
||||
);
|
||||
|
||||
// Close dialog
|
||||
await this.close();
|
||||
} catch (err) {
|
||||
// Re-throw validation errors from ScenePresetManager
|
||||
if (err instanceof TypeError) {
|
||||
throw err;
|
||||
}
|
||||
// For other errors, log and re-throw
|
||||
console.error('[ScryingPool] PresetSaveDialog: failed to save preset:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles cancel action — closes the dialog.
|
||||
*/
|
||||
_onCancel() {
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,450 @@
|
||||
/**
|
||||
* ScenePresetPanel — Per-scene auto-apply configuration UI.
|
||||
*
|
||||
* Owns: Toggle, preset selector, pre-delay slider for per-scene auto-apply settings.
|
||||
* Embedded in DirectorsBoard as collapsible drawer/tab.
|
||||
*
|
||||
* Import rule: may import from src/core/, src/contracts/, src/utils/ ONLY.
|
||||
* Constructors are side-effect free — call init() from DirectorsBoard.
|
||||
*
|
||||
* Story 3.2: Scene Auto-Apply & ConfirmationBar
|
||||
*
|
||||
* @module ui/gm/ScenePresetPanel
|
||||
*/
|
||||
|
||||
/**
|
||||
* Per-scene auto-apply configuration panel.
|
||||
* Allows GM to configure which preset (if any) auto-applies when a scene activates.
|
||||
*/
|
||||
export class ScenePresetPanel {
|
||||
/**
|
||||
* @param {import('../../foundry/FoundryAdapter.js').FoundryAdapter} adapter
|
||||
* Injected FoundryAdapter surface.
|
||||
* @param {import('../../core/ScenePresetManager.js').ScenePresetManager} scenePresetManager
|
||||
* Injected ScenePresetManager for preset operations.
|
||||
*/
|
||||
constructor(adapter, scenePresetManager) {
|
||||
this._adapter = adapter;
|
||||
this._scenePresetManager = scenePresetManager;
|
||||
|
||||
// State
|
||||
/** @type {HTMLElement|null} */
|
||||
this._element = null;
|
||||
/** @type {boolean} */
|
||||
this._isOpen = false;
|
||||
/** @type {object|null} */
|
||||
this._currentScene = null;
|
||||
/** @type {Function|null} */
|
||||
this._clickHandler = null;
|
||||
/** @type {Function|null} */
|
||||
this._changeHandler = null;
|
||||
/** @type {Function|null} */
|
||||
this._inputHandler = null;
|
||||
|
||||
// Constants
|
||||
/** @type {number} Maximum pre-delay in milliseconds */
|
||||
this._MAX_PREDELAY = 5000;
|
||||
/** @type {number} Minimum pre-delay in milliseconds */
|
||||
this._MIN_PREDELAY = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the panel. Creates DOM element and sets up event listeners.
|
||||
* Called from DirectorsBoard constructor.
|
||||
*/
|
||||
init() {
|
||||
this._createElement();
|
||||
this._setupEventListeners();
|
||||
this._refresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the panel DOM element.
|
||||
* @private
|
||||
*/
|
||||
_createElement() {
|
||||
this._element = document.createElement('div');
|
||||
this._element.className = 'directors-board__preset-panel';
|
||||
this._element.setAttribute('role', 'region');
|
||||
this._element.setAttribute('aria-label', this._adapter.i18n.localize('video-view-manager.scenePresetPanel.title'));
|
||||
this._element.setAttribute('aria-expanded', 'false');
|
||||
|
||||
// Initially hidden
|
||||
this._element.style.display = 'none';
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the panel DOM element.
|
||||
* @returns {HTMLElement|null}
|
||||
*/
|
||||
get element() {
|
||||
return this._element;
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles the panel visibility.
|
||||
*/
|
||||
toggle() {
|
||||
if (this._isOpen) {
|
||||
this.close();
|
||||
} else {
|
||||
this.open();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the panel and refreshes its content.
|
||||
*/
|
||||
open() {
|
||||
if (!this._element) return;
|
||||
|
||||
this._isOpen = true;
|
||||
this._element.style.display = 'block';
|
||||
this._element.setAttribute('aria-expanded', 'true');
|
||||
this._refresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the panel.
|
||||
*/
|
||||
close() {
|
||||
if (!this._element) return;
|
||||
|
||||
this._isOpen = false;
|
||||
this._element.style.display = 'none';
|
||||
this._element.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes the panel content with current scene data.
|
||||
* @private
|
||||
*/
|
||||
async _refresh() {
|
||||
if (!this._element) return;
|
||||
|
||||
const currentScene = this._adapter.scenes.current?.();
|
||||
if (!currentScene) {
|
||||
this._element.innerHTML = this._buildEmptyHtml();
|
||||
return;
|
||||
}
|
||||
|
||||
this._currentScene = currentScene;
|
||||
|
||||
// Get current auto-apply config
|
||||
const autoApplyConfig = this._scenePresetManager._getAutoApplyConfig(
|
||||
this._scenePresetManager._getSceneFlagData(currentScene)
|
||||
);
|
||||
|
||||
// Get available presets
|
||||
const presets = this._scenePresetManager.list();
|
||||
|
||||
this._element.innerHTML = this._buildHtml({
|
||||
enabled: autoApplyConfig.enabled,
|
||||
presetName: autoApplyConfig.presetName,
|
||||
preDelay: autoApplyConfig.preDelay,
|
||||
presets,
|
||||
});
|
||||
|
||||
// Update toggle state
|
||||
const toggle = this._element.querySelector('[data-action="toggle-auto-apply"]');
|
||||
if (toggle) {
|
||||
toggle.setAttribute('aria-pressed', String(autoApplyConfig.enabled));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the HTML for the panel when no scene is active.
|
||||
* @returns {string}
|
||||
* @private
|
||||
*/
|
||||
_buildEmptyHtml() {
|
||||
const message = this._adapter.i18n.localize('video-view-manager.scenePresetPanel.noScene');
|
||||
return `
|
||||
<div class="directors-board__preset-panel-header">
|
||||
<h3 class="directors-board__preset-panel-title">
|
||||
${this._escapeHtml(this._adapter.i18n.localize('video-view-manager.scenePresetPanel.title'))}
|
||||
</h3>
|
||||
</div>
|
||||
<p class="directors-board__preset-panel-message">${this._escapeHtml(message)}</p>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the HTML for the panel.
|
||||
* @param {object} context - Panel context.
|
||||
* @param {boolean} context.enabled - Whether auto-apply is enabled.
|
||||
* @param {string|null} context.presetName - Selected preset name.
|
||||
* @param {number} context.preDelay - Pre-delay in milliseconds.
|
||||
* @param {Array<object>} context.presets - Available presets.
|
||||
* @returns {string}
|
||||
* @private
|
||||
*/
|
||||
_buildHtml(context) {
|
||||
const { enabled, presetName, preDelay, presets } = context;
|
||||
const localize = this._adapter.i18n.localize;
|
||||
|
||||
// Build preset options
|
||||
const presetOptions = presets
|
||||
.map(preset => `
|
||||
<option value="${this._escapeHtml(preset.name)}" ${preset.name === presetName ? 'selected' : ''}>
|
||||
${this._escapeHtml(preset.name)}
|
||||
</option>
|
||||
`)
|
||||
.join('');
|
||||
|
||||
// Add default option
|
||||
const defaultOption = `
|
||||
<option value="" ${!presetName ? 'selected' : ''}>
|
||||
${this._escapeHtml(localize('video-view-manager.scenePresetPanel.selectPreset'))}
|
||||
</option>
|
||||
`;
|
||||
|
||||
return `
|
||||
<div class="directors-board__preset-panel-header">
|
||||
<h3 class="directors-board__preset-panel-title">
|
||||
${this._escapeHtml(localize('video-view-manager.scenePresetPanel.title'))}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div class="directors-board__preset-panel-body">
|
||||
<div class="directors-board__preset-panel-row">
|
||||
<label class="directors-board__preset-panel-label">
|
||||
<input type="checkbox"
|
||||
class="directors-board__preset-panel-toggle"
|
||||
data-action="toggle-auto-apply"
|
||||
${enabled ? 'checked' : ''}
|
||||
role="switch"
|
||||
aria-label="${this._escapeHtml(localize('video-view-manager.scenePresetPanel.enableAutoApply'))}">
|
||||
${this._escapeHtml(localize('video-view-manager.scenePresetPanel.enableAutoApply'))}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="directors-board__preset-panel-row">
|
||||
<label class="directors-board__preset-panel-label">
|
||||
${this._escapeHtml(localize('video-view-manager.scenePresetPanel.preset'))}
|
||||
<select class="directors-board__preset-panel-select"
|
||||
data-action="select-preset"
|
||||
${!presets.length ? 'disabled' : ''}
|
||||
aria-label="${this._escapeHtml(localize('video-view-manager.scenePresetPanel.selectPreset'))}">
|
||||
${defaultOption}
|
||||
${presetOptions}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="directors-board__preset-panel-row">
|
||||
<label class="directors-board__preset-panel-label">
|
||||
${this._escapeHtml(localize('video-view-manager.scenePresetPanel.preDelay'))}
|
||||
<span class="directors-board__preset-panel-delay-value">${preDelay}ms</span>
|
||||
<input type="range"
|
||||
class="directors-board__preset-panel-slider"
|
||||
data-action="change-delay"
|
||||
min="${this._MIN_PREDELAY}"
|
||||
max="${this._MAX_PREDELAY}"
|
||||
value="${preDelay}"
|
||||
step="100"
|
||||
aria-label="${this._escapeHtml(localize('video-view-manager.scenePresetPanel.preDelay'))}"
|
||||
aria-valuemin="${this._MIN_PREDELAY}"
|
||||
aria-valuemax="${this._MAX_PREDELAY}"
|
||||
aria-valuenow="${preDelay}">
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="directors-board__preset-panel-row directors-board__preset-panel-row--hint">
|
||||
<span>${this._escapeHtml(localize('video-view-manager.scenePresetPanel.globalSettingsHint'))}</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up event listeners for the panel.
|
||||
* @private
|
||||
*/
|
||||
_setupEventListeners() {
|
||||
if (!this._element) return;
|
||||
|
||||
// Store bound handlers
|
||||
this._clickHandler = (event) => {
|
||||
const target = event.target.closest?.('[data-action]');
|
||||
if (!target) return;
|
||||
|
||||
const action = target.getAttribute('data-action');
|
||||
switch (action) {
|
||||
case 'toggle-auto-apply':
|
||||
this._onToggleAutoApply(target);
|
||||
break;
|
||||
case 'select-preset':
|
||||
this._onPresetSelected(target);
|
||||
break;
|
||||
case 'change-delay':
|
||||
// Deliberate fallthrough - handled by input handler
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
this._inputHandler = (event) => {
|
||||
const target = event.target.closest?.('[data-action="change-delay"]');
|
||||
if (!target) return;
|
||||
this._onDelayChanged(target);
|
||||
};
|
||||
|
||||
this._element.addEventListener('click', this._clickHandler);
|
||||
this._element.addEventListener('input', this._inputHandler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes event listeners.
|
||||
* @private
|
||||
*/
|
||||
_removeEventListeners() {
|
||||
if (!this._element) return;
|
||||
|
||||
if (this._clickHandler) {
|
||||
this._element.removeEventListener('click', this._clickHandler);
|
||||
this._clickHandler = null;
|
||||
}
|
||||
if (this._inputHandler) {
|
||||
this._element.removeEventListener('input', this._inputHandler);
|
||||
this._inputHandler = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the auto-apply toggle change.
|
||||
* @param {HTMLElement} target - The toggle element.
|
||||
* @private
|
||||
*/
|
||||
async _onToggleAutoApply(target) {
|
||||
const isChecked = target instanceof HTMLInputElement ? target.checked : false;
|
||||
const currentScene = this._adapter.scenes.current?.();
|
||||
if (!currentScene) return;
|
||||
|
||||
try {
|
||||
// Get current config
|
||||
const flagData = this._scenePresetManager._getSceneFlagData(currentScene);
|
||||
const autoApplyConfig = this._scenePresetManager._getAutoApplyConfig(flagData);
|
||||
|
||||
// Update and save
|
||||
await this._scenePresetManager.configureAutoApply(currentScene, {
|
||||
enabled: isChecked,
|
||||
presetName: autoApplyConfig.presetName,
|
||||
preDelay: autoApplyConfig.preDelay,
|
||||
});
|
||||
|
||||
// Update UI state
|
||||
target.setAttribute('aria-pressed', String(isChecked));
|
||||
|
||||
// Notify
|
||||
this._adapter.notifications.info(
|
||||
isChecked
|
||||
? this._adapter.i18n.localize('video-view-manager.scenePresetPanel.notifications.enabled')
|
||||
: this._adapter.i18n.localize('video-view-manager.scenePresetPanel.notifications.disabled')
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('[ScryingPool] ScenePresetPanel: failed to toggle auto-apply', err);
|
||||
// Revert toggle state
|
||||
target.checked = !isChecked;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles preset selection change.
|
||||
* @param {HTMLElement} target - The select element.
|
||||
* @private
|
||||
*/
|
||||
async _onPresetSelected(target) {
|
||||
const presetName = target.value;
|
||||
const currentScene = this._adapter.scenes.current?.();
|
||||
if (!currentScene) return;
|
||||
|
||||
try {
|
||||
// Get current config
|
||||
const flagData = this._scenePresetManager._getSceneFlagData(currentScene);
|
||||
const autoApplyConfig = this._scenePresetManager._getAutoApplyConfig(flagData);
|
||||
|
||||
// Update and save
|
||||
await this._scenePresetManager.configureAutoApply(currentScene, {
|
||||
enabled: autoApplyConfig.enabled,
|
||||
presetName: presetName || null,
|
||||
preDelay: autoApplyConfig.preDelay,
|
||||
});
|
||||
|
||||
// Notify
|
||||
if (presetName) {
|
||||
this._adapter.notifications.info(
|
||||
this._adapter.i18n.localize('video-view-manager.scenePresetPanel.notifications.presetSelected')
|
||||
.replace('{name}', presetName)
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[ScryingPool] ScenePresetPanel: failed to select preset', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles pre-delay slider change.
|
||||
* @param {HTMLElement} target - The slider element.
|
||||
* @private
|
||||
*/
|
||||
async _onDelayChanged(target) {
|
||||
const preDelay = parseInt(target.value, 10) || 0;
|
||||
const currentScene = this._adapter.scenes.current?.();
|
||||
if (!currentScene) return;
|
||||
|
||||
// Update displayed value
|
||||
const valueDisplay = this._element?.querySelector('.directors-board__preset-panel-delay-value');
|
||||
if (valueDisplay) {
|
||||
valueDisplay.textContent = `${preDelay}ms`;
|
||||
valueDisplay.setAttribute('aria-valuenow', String(preDelay));
|
||||
}
|
||||
|
||||
try {
|
||||
// Get current config
|
||||
const flagData = this._scenePresetManager._getSceneFlagData(currentScene);
|
||||
const autoApplyConfig = this._scenePresetManager._getAutoApplyConfig(flagData);
|
||||
|
||||
// Update and save
|
||||
await this._scenePresetManager.configureAutoApply(currentScene, {
|
||||
enabled: autoApplyConfig.enabled,
|
||||
presetName: autoApplyConfig.presetName,
|
||||
preDelay,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[ScryingPool] ScenePresetPanel: failed to change delay', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans up the panel.
|
||||
*/
|
||||
teardown() {
|
||||
this._removeEventListeners();
|
||||
this.close();
|
||||
|
||||
if (this._element && this._element.parentNode) {
|
||||
this._element.parentNode.removeChild(this._element);
|
||||
}
|
||||
this._element = null;
|
||||
this._isOpen = false;
|
||||
this._currentScene = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Escapes HTML special characters to prevent XSS.
|
||||
* @param {string} str - String to escape.
|
||||
* @returns {string} Escaped string.
|
||||
* @private
|
||||
*/
|
||||
_escapeHtml(str) {
|
||||
if (!str || typeof str !== 'string') {
|
||||
return '';
|
||||
}
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,536 @@
|
||||
// @ts-nocheck
|
||||
|
||||
/**
|
||||
* Canonical player-state → display label map.
|
||||
* `active` maps to null — no label is shown when the feed is live.
|
||||
* @type {Readonly<Record<string, string|null>>}
|
||||
*/
|
||||
const PLAYER_STATE_LABELS = Object.freeze({
|
||||
hidden: 'Hidden from table',
|
||||
'self-muted': 'Camera paused',
|
||||
offline: 'Not connected',
|
||||
'cam-lost': 'Camera unavailable',
|
||||
reconnecting: 'Rejoining view',
|
||||
'never-connected': 'Not yet connected',
|
||||
ghost: 'Leaving',
|
||||
active: null,
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// VisibilityDetailsPanel
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Native <dialog>-based panel showing full detail about a player's camera state.
|
||||
* Not exported — internal to the player/ layer.
|
||||
*/
|
||||
class VisibilityDetailsPanel {
|
||||
/**
|
||||
* @param {object|null} controller - ScryingPoolController (may be null if unavailable)
|
||||
*/
|
||||
constructor(controller) {
|
||||
this._controller = controller;
|
||||
/** @type {HTMLDialogElement|null} */
|
||||
this._dialog = null;
|
||||
/** @type {HTMLElement|null} */
|
||||
this._triggerEl = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates and shows the details panel as a modal dialog.
|
||||
* @param {string} state - Current player visibility state
|
||||
* @param {object|null} actor - User object with .name property (or null)
|
||||
* @param {string|null} reason - Reason for state change (e.g., "Hidden by: GM Name")
|
||||
* @param {HTMLElement} triggerEl - Element to return focus to on close
|
||||
*/
|
||||
show(state, actor, reason, triggerEl) {
|
||||
if (this._dialog) return; // already open
|
||||
|
||||
this._triggerEl = triggerEl;
|
||||
const stateLabel = PLAYER_STATE_LABELS[state] ?? state;
|
||||
const isHidden = state === 'hidden';
|
||||
const isDataStale = !this._controller;
|
||||
|
||||
const dialog = document.createElement('dialog');
|
||||
dialog.className = 'sp-visibility-details-panel';
|
||||
dialog.setAttribute('aria-modal', 'true');
|
||||
|
||||
// --- State explanation ---
|
||||
const stateEl = document.createElement('p');
|
||||
stateEl.className = 'sp-visibility-details-panel__state';
|
||||
stateEl.textContent = stateLabel;
|
||||
dialog.appendChild(stateEl);
|
||||
|
||||
// --- Reason/actor display ---
|
||||
if (reason) {
|
||||
const reasonEl = document.createElement('p');
|
||||
reasonEl.className = 'sp-visibility-details-panel__reason';
|
||||
reasonEl.textContent = reason;
|
||||
dialog.appendChild(reasonEl);
|
||||
} else if (actor?.name) {
|
||||
const actorEl = document.createElement('p');
|
||||
actorEl.className = 'sp-visibility-details-panel__actor';
|
||||
actorEl.textContent = `State changed by: ${actor.name}`;
|
||||
dialog.appendChild(actorEl);
|
||||
}
|
||||
|
||||
// --- Audience section ---
|
||||
if (isHidden) {
|
||||
const reassuranceEl = document.createElement('p');
|
||||
reassuranceEl.className = 'sp-visibility-details-panel__reassurance';
|
||||
reassuranceEl.textContent = 'Other players cannot see your feed';
|
||||
dialog.appendChild(reassuranceEl);
|
||||
} else {
|
||||
const audioEl = document.createElement('p');
|
||||
audioEl.className = 'sp-visibility-details-panel__audio-note';
|
||||
audioEl.textContent = 'Your audio is active for all participants.';
|
||||
dialog.appendChild(audioEl);
|
||||
}
|
||||
|
||||
// --- Stale data indicator ---
|
||||
if (isDataStale) {
|
||||
const staleEl = document.createElement('p');
|
||||
staleEl.className = 'sp-visibility-details-panel__stale';
|
||||
staleEl.textContent = 'Data may be outdated';
|
||||
dialog.appendChild(staleEl);
|
||||
}
|
||||
|
||||
// --- Close button ---
|
||||
const closeBtn = document.createElement('button');
|
||||
closeBtn.className = 'sp-visibility-details-panel__close';
|
||||
closeBtn.setAttribute('data-action', 'close-details');
|
||||
closeBtn.textContent = 'Close';
|
||||
closeBtn.addEventListener('click', () => dialog.close());
|
||||
dialog.appendChild(closeBtn);
|
||||
|
||||
// --- Dismiss handlers ---
|
||||
// Backdrop click: only when the click target IS the dialog backdrop
|
||||
dialog.addEventListener('click', e => {
|
||||
if (e.target === dialog) dialog.close();
|
||||
});
|
||||
|
||||
// Clean up on close (native Esc + programmatic close)
|
||||
dialog.addEventListener('close', () => this._onClose());
|
||||
|
||||
document.body.appendChild(dialog);
|
||||
this._dialog = dialog;
|
||||
|
||||
if (typeof dialog.showModal === 'function') {
|
||||
dialog.showModal();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the dialog from the DOM and returns focus to the trigger element.
|
||||
*/
|
||||
_onClose() {
|
||||
if (this._dialog) {
|
||||
this._dialog.remove();
|
||||
this._dialog = null;
|
||||
}
|
||||
this._triggerEl?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FirstEncounterPanel
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Non-modal explanatory panel shown the first time a player's badge updates.
|
||||
* Collapses after a 10s idle timer into a persistent chip.
|
||||
* Not exported — internal to the player/ layer.
|
||||
*/
|
||||
class FirstEncounterPanel {
|
||||
/**
|
||||
* @param {Function} setEncounteredFn - async fn that sets the firstBadgeEncounter flag
|
||||
* @param {Function} openDetailsFn - fn() that opens VisibilityDetailsPanel
|
||||
*/
|
||||
#collapseTimer = null;
|
||||
|
||||
constructor(setEncounteredFn, openDetailsFn) {
|
||||
this._setEncountered = setEncounteredFn;
|
||||
this._openDetails = openDetailsFn;
|
||||
/** @type {HTMLElement|null} */
|
||||
this._panel = null;
|
||||
/** @type {HTMLElement|null} */
|
||||
this._chip = null;
|
||||
/** @type {number} */
|
||||
this._remainingMs = 10_000;
|
||||
/** @type {number|null} */
|
||||
this._timerStartedAt = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates and shows the explanatory panel.
|
||||
* anchorEl is accepted for API consistency but positioning is handled via CSS.
|
||||
* @param {HTMLElement} _anchorEl
|
||||
*/
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
show(_anchorEl) {
|
||||
if (this._panel) return; // already shown
|
||||
|
||||
const panel = document.createElement('div');
|
||||
panel.className = 'sp-first-encounter-panel';
|
||||
panel.setAttribute('role', 'dialog');
|
||||
panel.setAttribute('aria-modal', 'false');
|
||||
panel.setAttribute('aria-label', 'Camera visibility explanation');
|
||||
|
||||
const title = document.createElement('p');
|
||||
title.className = 'sp-first-encounter-panel__title';
|
||||
title.textContent = 'Your camera visibility changed.';
|
||||
panel.appendChild(title);
|
||||
|
||||
const body = document.createElement('p');
|
||||
body.className = 'sp-first-encounter-panel__body';
|
||||
body.textContent = 'Audio continues normally.';
|
||||
panel.appendChild(body);
|
||||
|
||||
const gotItBtn = document.createElement('button');
|
||||
gotItBtn.className = 'sp-first-encounter-panel__got-it';
|
||||
gotItBtn.setAttribute('data-action', 'got-it');
|
||||
gotItBtn.textContent = 'Got it';
|
||||
gotItBtn.addEventListener('click', async () => {
|
||||
await this._onGotIt();
|
||||
});
|
||||
panel.appendChild(gotItBtn);
|
||||
|
||||
// Timer pause/resume on hover/focus
|
||||
panel.addEventListener('mouseenter', () => this._pauseTimer());
|
||||
panel.addEventListener('mouseleave', () => this._resumeTimer());
|
||||
panel.addEventListener('focusin', () => this._pauseTimer());
|
||||
panel.addEventListener('focusout', () => this._resumeTimer());
|
||||
|
||||
document.body.appendChild(panel);
|
||||
this._panel = panel;
|
||||
|
||||
this._startTimer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the collapse timer, recording when it started.
|
||||
*/
|
||||
_startTimer() {
|
||||
this._timerStartedAt = Date.now();
|
||||
this.#collapseTimer = setTimeout(() => this._collapse(), this._remainingMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pauses the collapse timer, storing remaining time.
|
||||
*/
|
||||
_pauseTimer() {
|
||||
if (this.#collapseTimer === null) return;
|
||||
const elapsed = Date.now() - (this._timerStartedAt ?? Date.now());
|
||||
this._remainingMs = Math.max(0, this._remainingMs - elapsed);
|
||||
clearTimeout(this.#collapseTimer);
|
||||
this.#collapseTimer = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resumes the collapse timer with remaining time.
|
||||
*/
|
||||
_resumeTimer() {
|
||||
if (this.#collapseTimer !== null) return; // already running
|
||||
this._timerStartedAt = Date.now();
|
||||
this.#collapseTimer = setTimeout(() => this._collapse(), this._remainingMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* "Got it" handler — clears timer, sets flag, dismisses panel.
|
||||
* Uses async to ensure flag is persisted before dismissing.
|
||||
*/
|
||||
async _onGotIt() {
|
||||
clearTimeout(this.#collapseTimer); // ghost prevention
|
||||
this.#collapseTimer = null;
|
||||
try {
|
||||
await this._setEncountered();
|
||||
} catch (err) {
|
||||
console.error('[ScryingPool] Failed to set firstBadgeEncounter flag:', err);
|
||||
}
|
||||
this._dismiss();
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapses the panel (via CSS class + 300ms timer matching CSS ease-out) and replaces it with a chip.
|
||||
*/
|
||||
_collapse() {
|
||||
// Clear any pending timer from _startTimer before creating new one
|
||||
clearTimeout(this.#collapseTimer);
|
||||
this.#collapseTimer = null;
|
||||
if (!this._panel) return;
|
||||
|
||||
const panel = this._panel;
|
||||
|
||||
// Apply collapse animation via CSS class
|
||||
panel.classList.add('sp-first-encounter-panel--collapsing');
|
||||
|
||||
const activeEl = document.activeElement;
|
||||
const wasInsidePanel = activeEl ? panel.contains(activeEl) : false;
|
||||
|
||||
// Replace after CSS transition duration (300ms ease-out per AC)
|
||||
this.#collapseTimer = setTimeout(() => {
|
||||
this.#collapseTimer = null;
|
||||
if (!this._panel) return;
|
||||
this._panel.remove();
|
||||
this._panel = null;
|
||||
|
||||
const chip = this._createChip();
|
||||
document.body.appendChild(chip);
|
||||
this._chip = chip;
|
||||
|
||||
if (wasInsidePanel) {
|
||||
chip.focus();
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the collapsed chip element.
|
||||
* @returns {HTMLElement}
|
||||
*/
|
||||
_createChip() {
|
||||
const chip = document.createElement('div');
|
||||
chip.className = 'sp-visibility-chip';
|
||||
chip.setAttribute('role', 'button');
|
||||
chip.setAttribute('tabindex', '0');
|
||||
chip.setAttribute('aria-label', 'Camera visibility — click for details');
|
||||
|
||||
chip.addEventListener('click', () => this._openDetails());
|
||||
chip.addEventListener('keydown', e => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
this._openDetails();
|
||||
}
|
||||
});
|
||||
|
||||
return chip;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the panel from the DOM without triggering collapse animation.
|
||||
*/
|
||||
_dismiss() {
|
||||
if (this._panel) {
|
||||
this._panel.remove();
|
||||
this._panel = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Must be called on teardown — clears timer to prevent ghost timers.
|
||||
*/
|
||||
_onClose() {
|
||||
clearTimeout(this.#collapseTimer); // ghost prevention
|
||||
this.#collapseTimer = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes both panel and chip from DOM.
|
||||
*/
|
||||
cleanup() {
|
||||
this._onClose();
|
||||
if (this._panel) {
|
||||
this._panel.remove();
|
||||
this._panel = null;
|
||||
}
|
||||
if (this._chip) {
|
||||
this._chip.remove();
|
||||
this._chip = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// VisibilityBadge
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Player-facing camera visibility badge.
|
||||
* Mounted on the player's own AV tile via AVTileAdapter.
|
||||
* Shows the current visibility state and triggers first-encounter education UI.
|
||||
*
|
||||
* @class
|
||||
*/
|
||||
export class VisibilityBadge {
|
||||
/**
|
||||
* @param {object} stateStore - StateStore instance
|
||||
* @param {object|null} controller - ScryingPoolController (may be null)
|
||||
* @param {object} avTileAdapter - AVTileAdapter instance (shared with RoleRenderer)
|
||||
* @param {object} adapter - FoundryAdapter instance
|
||||
*/
|
||||
constructor(stateStore, controller, avTileAdapter, adapter) {
|
||||
this._stateStore = stateStore;
|
||||
this._controller = controller;
|
||||
this._avTileAdapter = avTileAdapter;
|
||||
this._adapter = adapter;
|
||||
/** @type {string|null} */
|
||||
this._currentUserId = null;
|
||||
/** @type {string} */
|
||||
this._currentState = 'active';
|
||||
/** @type {object|null} */
|
||||
this._currentStateActor = null;
|
||||
/** @type {string|null} */
|
||||
this._currentStateReason = null;
|
||||
/** @type {HTMLElement|null} */
|
||||
this._badgeEl = null;
|
||||
/** @type {FirstEncounterPanel|null} */
|
||||
this._firstEncounterPanel = null;
|
||||
/** @type {Function|null} */
|
||||
this._stateChangedHandler = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialises the badge — resolves the current user, subscribes to state changes,
|
||||
* and mounts the initial badge element.
|
||||
* No-ops if no current user can be resolved.
|
||||
*/
|
||||
init() {
|
||||
const currentUser = this._adapter.users.current?.();
|
||||
if (!currentUser?.id) {
|
||||
this._currentUserId = null;
|
||||
return;
|
||||
}
|
||||
this._currentUserId = currentUser.id;
|
||||
|
||||
// Subscribe to state changes
|
||||
this._stateChangedHandler = data => this._onStateChanged(data);
|
||||
Hooks.on('scrying-pool:stateChanged', this._stateChangedHandler);
|
||||
|
||||
// Mount initial badge
|
||||
const initialState = this._stateStore.getState?.(this._currentUserId) ?? 'active';
|
||||
this._currentState = initialState;
|
||||
this._badgeEl = this._createBadgeElement(initialState);
|
||||
this._avTileAdapter.mount(this._currentUserId, this._badgeEl);
|
||||
|
||||
// Re-mount badge if Foundry re-renders the AV tile
|
||||
this._avTileAdapter.onTileRerender(this._currentUserId, () => {
|
||||
this._mountBadge(this._currentState);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the badge DOM element for the given state.
|
||||
* @param {string} state
|
||||
* @returns {HTMLElement}
|
||||
*/
|
||||
_createBadgeElement(state) {
|
||||
const stateLabel = PLAYER_STATE_LABELS[state] ?? null;
|
||||
const ariaLabel = `Camera visibility: ${stateLabel ?? 'Active'}`;
|
||||
|
||||
const el = document.createElement('div');
|
||||
el.className = 'sp-visibility-badge';
|
||||
el.dataset.spRole = 'visibility-badge';
|
||||
el.setAttribute('role', 'status');
|
||||
el.setAttribute('aria-live', 'polite');
|
||||
el.setAttribute('aria-label', ariaLabel);
|
||||
|
||||
const labelSpan = document.createElement('span');
|
||||
labelSpan.className = 'sp-visibility-badge__label';
|
||||
labelSpan.textContent = stateLabel ?? '';
|
||||
el.appendChild(labelSpan);
|
||||
|
||||
el.addEventListener('click', () => this._openDetailsPanel(el));
|
||||
|
||||
return el;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-mounts the badge at the given state (idempotent via AVTileAdapter).
|
||||
* @param {string} state
|
||||
*/
|
||||
_mountBadge(state) {
|
||||
if (!this._currentUserId) return;
|
||||
this._currentState = state;
|
||||
const stateLabel = PLAYER_STATE_LABELS[state] ?? null;
|
||||
const ariaLabel = `Camera visibility: ${stateLabel ?? 'Active'}`;
|
||||
|
||||
if (!this._badgeEl) {
|
||||
this._badgeEl = this._createBadgeElement(state);
|
||||
} else {
|
||||
this._badgeEl.setAttribute('aria-label', ariaLabel);
|
||||
const labelSpan = this._badgeEl.querySelector('.sp-visibility-badge__label');
|
||||
if (labelSpan) {
|
||||
labelSpan.textContent = stateLabel ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
this._avTileAdapter.mount(this._currentUserId, this._badgeEl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles a `scrying-pool:stateChanged` hook event.
|
||||
* Guards to only process events for the current user.
|
||||
* @param {{ userId: string, state: string, actor?: object, reason?: string }} data
|
||||
*/
|
||||
_onStateChanged(data) {
|
||||
// Validate data shape
|
||||
if (!data || typeof data !== 'object' || !data.userId || !data.state) {
|
||||
return;
|
||||
}
|
||||
if (data.userId !== this._currentUserId) return;
|
||||
|
||||
this._currentState = data.state;
|
||||
this._currentStateActor = data.actor;
|
||||
this._currentStateReason = data.reason;
|
||||
this._mountBadge(data.state);
|
||||
|
||||
// Trigger first-encounter panel if not yet shown
|
||||
if (!this._getFirstBadgeEncountered() && !this._firstEncounterPanel) {
|
||||
this._firstEncounterPanel = new FirstEncounterPanel(
|
||||
() => this._setFirstBadgeEncountered(),
|
||||
() => this._openDetailsPanel(this._badgeEl)
|
||||
);
|
||||
this._firstEncounterPanel.show(this._badgeEl);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the VisibilityDetailsPanel for the current state.
|
||||
* @param {HTMLElement|null} triggerEl - Element to return focus to on close
|
||||
*/
|
||||
_openDetailsPanel(triggerEl) {
|
||||
if (!triggerEl) return;
|
||||
// Use stored actor/reason from state change, or fall back to current user
|
||||
const actor = this._currentStateActor ?? this._adapter.users.current?.() ?? null;
|
||||
const panel = new VisibilityDetailsPanel(this._controller);
|
||||
panel.show(this._currentState, actor, this._currentStateReason, triggerEl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the player has already seen the first-encounter panel.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
_getFirstBadgeEncountered() {
|
||||
return this._adapter.users.current()?.getFlag('video-view-manager', 'firstBadgeEncounter') ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists the firstBadgeEncounter flag via Foundry user flags.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async _setFirstBadgeEncountered() {
|
||||
await this._adapter.users.current()?.setFlag('video-view-manager', 'firstBadgeEncounter', true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tears down badge subscriptions and releases AVTileAdapter observers.
|
||||
* Cleans up DOM elements and event listeners.
|
||||
*/
|
||||
teardown() {
|
||||
if (this._stateChangedHandler) {
|
||||
Hooks.off('scrying-pool:stateChanged', this._stateChangedHandler);
|
||||
this._stateChangedHandler = null;
|
||||
}
|
||||
if (this._firstEncounterPanel) {
|
||||
this._firstEncounterPanel.cleanup();
|
||||
this._firstEncounterPanel = null;
|
||||
}
|
||||
if (this._badgeEl) {
|
||||
this._badgeEl.remove();
|
||||
this._badgeEl = null;
|
||||
}
|
||||
this._avTileAdapter.disconnect();
|
||||
this._currentUserId = null;
|
||||
this._currentState = 'active';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* StripOverlayLayer — Single overlay container for all positioned overlays.
|
||||
*
|
||||
* Owns: DOM element with position: absolute; inset: 0; pointer-events: none; overflow: visible
|
||||
* Children restore pointer-events: auto
|
||||
* Used by: ActionPopover (Story 1.5), ConfirmationBar (Story 3.2)
|
||||
*
|
||||
* Import rule: may import from src/core/, src/contracts/, src/utils/ ONLY.
|
||||
* Constructors are side-effect free — call init() from module.js Hooks.once('ready').
|
||||
*
|
||||
* Story 1.5: Original creation for ActionPopover support
|
||||
* Story 3.2: Extended to support ConfirmationBar
|
||||
*
|
||||
* @module ui/shared/StripOverlayLayer
|
||||
*/
|
||||
|
||||
/**
|
||||
* Single overlay container for all positioned overlays.
|
||||
* Provides a common parent element with pointer-events: none that allows
|
||||
* children to restore pointer-events: auto for specific interactive areas.
|
||||
*/
|
||||
export class StripOverlayLayer {
|
||||
/**
|
||||
* @param {import('../../foundry/FoundryAdapter.js').FoundryAdapter} adapter
|
||||
* Injected FoundryAdapter surface.
|
||||
*/
|
||||
constructor(adapter) {
|
||||
this._adapter = adapter;
|
||||
/** @type {HTMLElement|null} The overlay container element */
|
||||
this._element = null;
|
||||
/** @type {Map<string, HTMLElement>} Track rendered overlays by key */
|
||||
this._overlays = new Map();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the StripOverlayLayer by creating the DOM element.
|
||||
* Side-effect: Creates and appends the overlay container to the ScryingPoolStrip.
|
||||
*/
|
||||
init() {
|
||||
// Create overlay container element
|
||||
this._element = document.createElement('div');
|
||||
this._element.className = 'sp-strip__overlay-layer';
|
||||
this._element.setAttribute('aria-hidden', 'true');
|
||||
|
||||
// Critical styles per UX-DR6
|
||||
this._element.style.cssText = `
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
overflow: visible;
|
||||
`;
|
||||
|
||||
// Try to find the ScryingPoolStrip element to append to
|
||||
// The strip is created in Story 1.5 as a floating ApplicationV2 window
|
||||
const stripElement = document.querySelector?.('.scrying-pool__roster-strip');
|
||||
if (stripElement) {
|
||||
stripElement.appendChild(this._element);
|
||||
} else {
|
||||
// Fallback: if strip not found, append to body (shouldn't happen in normal flow)
|
||||
console.warn('[ScryingPool] StripOverlayLayer: ScryingPoolStrip not found, appending to body');
|
||||
document.body.appendChild(this._element);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the overlay container element.
|
||||
* @returns {HTMLElement|null} The overlay element.
|
||||
*/
|
||||
get element() {
|
||||
return this._element;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders content into the overlay layer.
|
||||
* The content will have pointer-events: auto to allow interaction.
|
||||
*
|
||||
* @param {string|HTMLElement} content - HTML string or DOM element to render.
|
||||
* @param {string} [key] - Optional key to track this overlay for replacement.
|
||||
* @returns {HTMLElement|null} The rendered element, or null if failed.
|
||||
*/
|
||||
render(content, key = null) {
|
||||
if (!this._element) {
|
||||
console.warn('[ScryingPool] StripOverlayLayer: Cannot render, element not initialized');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Remove previous overlay if key is provided
|
||||
if (key && this._overlays.has(key)) {
|
||||
const previous = this._overlays.get(key);
|
||||
if (previous && previous.parentNode) {
|
||||
previous.parentNode.removeChild(previous);
|
||||
}
|
||||
this._overlays.delete(key);
|
||||
}
|
||||
|
||||
// Create container for the content
|
||||
const container = document.createElement('div');
|
||||
container.style.pointerEvents = 'auto';
|
||||
|
||||
// Set content
|
||||
if (typeof content === 'string') {
|
||||
container.innerHTML = content;
|
||||
} else if (content instanceof HTMLElement) {
|
||||
container.appendChild(content);
|
||||
} else {
|
||||
console.warn('[ScryingPool] StripOverlayLayer: Invalid content type');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Append to overlay layer
|
||||
this._element.appendChild(container);
|
||||
|
||||
// Track by key if provided
|
||||
if (key) {
|
||||
this._overlays.set(key, container);
|
||||
}
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes an overlay by key.
|
||||
*
|
||||
* @param {string} key - The key of the overlay to remove.
|
||||
*/
|
||||
remove(key) {
|
||||
if (!this._overlays.has(key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const overlay = this._overlays.get(key);
|
||||
if (overlay && overlay.parentNode) {
|
||||
overlay.parentNode.removeChild(overlay);
|
||||
}
|
||||
this._overlays.delete(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes all overlays from the layer.
|
||||
*/
|
||||
clearAll() {
|
||||
for (const [, overlay] of this._overlays) {
|
||||
if (overlay && overlay.parentNode) {
|
||||
overlay.parentNode.removeChild(overlay);
|
||||
}
|
||||
}
|
||||
this._overlays.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans up the StripOverlayLayer by removing the DOM element.
|
||||
* Safe to call multiple times.
|
||||
*/
|
||||
teardown() {
|
||||
this.clearAll();
|
||||
|
||||
if (this._element && this._element.parentNode) {
|
||||
this._element.parentNode.removeChild(this._element);
|
||||
}
|
||||
this._element = null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user