/** * 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 `
${this._escapeHtml(message)}
`; } /** * 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, '''); } /** * 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); } }