// @ts-nocheck /* global Dialog */ import { buildBoardContext, resolveToggleTarget } from '../../utils/boardUtils.js'; import { generateOpId } from '../../utils/uuid.js'; 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. /** @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 = {}; constructor(options = {}) { this.options = options; } 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() {} }; /** * Floating GM-only Director's Board window. * Displays all connected participants as a seating-chart grid with per-participant * visibility toggle. Extends ApplicationV2 via HandlebarsApplicationMixin. */ export class DirectorsBoard extends _AppBase { static DEFAULT_OPTIONS = { id: 'scrying-pool-directors-board', classes: ['scrying-pool', 'directors-board'], window: { title: "Director's Board", resizable: true }, position: { width: 400, height: 300 }, }; static PARTS = { board: { template: 'modules/video-view-manager/templates/directors-board.hbs', }, }; /** * @param {object} stateStore * @param {object} controller * @param {object} adapter * @param {import('../core/ScenePresetManager.js').ScenePresetManager} scenePresetManager * @param {import('../../core/PlayerPrivacyManager.js').PlayerPrivacyManager} playerPrivacyManager * @param {object} [options] */ constructor(stateStore, controller, adapter, scenePresetManager, playerPrivacyManager, options = {}) { super(options); this._stateStore = stateStore; this._controller = controller; this._adapter = adapter; this._scenePresetManager = scenePresetManager; this._playerPrivacyManager = playerPrivacyManager; this._hookId = null; /** @type {Map|null} Pre-bulk-action snapshot for single-step undo */ this._undoSnapshot = null; /** @type {Map|null} Pre-spotlight snapshot for restore */ this._spotlightSnapshot = null; /** @type {string|null} Currently keyboard-focused participant userId */ this._focusedUserId = null; /** @type {Function|null} Click handler reference for cleanup */ this._clickHandler = null; /** @type {Function|null} Focusin handler reference for cleanup */ this._focusinHandler = null; /** @type {Function|null} Keydown handler reference for cleanup */ this._keydownHandler = null; /** @type {PresetSaveDialog|null} Reference to open save dialog for cleanup */ this._saveDialog = null; /** @type {PresetLoadDialog|null} Reference to open load dialog for cleanup */ this._loadDialog = null; /** @type {PresetExportDialog|null} Reference to open export dialog for cleanup */ 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 { const saved = game.user?.getFlag('video-view-manager', 'directorsBoardState'); if (saved?.open === true && saved.left != null && saved.top != null) { // Ensure options.position exists and is mutable if (this.options?.position) { // Use Object.assign to avoid TypeError when options is frozen (Foundry ApplicationV2) Object.assign(this.options.position, { left: saved.left, top: saved.top, width: saved.width ?? 400, height: saved.height ?? 300, }); } } } catch (err) { console.error('[ScryingPool] Failed to load directors board position:', err); } } /** Registers the stateChanged hook listener. Call once from module.js ready hook. */ init() { this._hookId = Hooks.on('scrying-pool:stateChanged', (data) => this._onStateChanged(data)); } /** Unregisters the stateChanged hook listener. */ teardown() { if (this._hookId !== null) { 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). */ async toggle() { if (this.rendered) { await this.close(); } else { await this.render({ force: true }); } } /** * Sets all non-ghost participants to `active`. Stores pre-action snapshot for undo. * FR-12: ghost participants excluded. */ async showAll() { await this._executeBulk('active'); } /** * Sets all non-ghost participants to `hidden`. Stores pre-action snapshot for undo. * FR-12: ghost participants excluded. */ async hideAll() { await this._executeBulk('hidden'); } /** * Internal bulk-action executor for showAll/hideAll. * Captures a pre-action snapshot then dispatches per-participant actions. * Uses single getState call per user to avoid race conditions. * @param {'active'|'hidden'} targetState * @private */ async _executeBulk(targetState) { const users = this._adapter.users.all(); // Get all user states in a single pass to avoid race conditions const userStates = new Map(users.map(u => [u.id, this._stateStore.getState(u.id)])); // Filter to non-ghost users and capture snapshot atomically const nonGhost = users.filter(u => userStates.get(u.id) !== 'ghost'); // Capture pre-action snapshot (single-step undo) - use the states we already fetched this._undoSnapshot = new Map(nonGhost.map(u => [u.id, userStates.get(u.id)])); // Bulk supersedes spotlight restore this._spotlightSnapshot = null; const promises = []; for (const u of nonGhost) { if (this._controller.hasPendingOp?.(u.id)) continue; const opId = generateOpId(); const baseRevision = this._controller.getRevision?.(u.id) ?? 0; // Ensure we await controller actions when they return promises and catch errors const p = Promise.resolve(this._controller.action('board', u.id, targetState, opId, baseRevision)) .catch(err => ({ __err: err, userId: u.id })); promises.push(p); } const results = await Promise.all(promises); const failures = results.filter(r => r && r.__err); if (failures.length > 0) { console.error('[ScryingPool] Bulk action encountered failures:', failures); // Keep the undo snapshot so the GM can retry or manually inspect; do not silently clear it. } if (this.rendered) this.render({ force: true }); } /** * Single-step undo: restores participants to their pre-bulk-action states. * No-op if no snapshot exists. Ghost participants are skipped. */ async undo() { if (!this._undoSnapshot) return; const snapshot = this._undoSnapshot; // Do not clear the snapshot until actions have completed successfully const promises = []; for (const [userId, targetState] of snapshot) { // Check current state to avoid restoring ghost users that have transitioned if (this._stateStore.getState(userId) === 'ghost') continue; if (this._controller.hasPendingOp?.(userId)) continue; const opId = generateOpId(); const baseRevision = this._controller.getRevision?.(userId) ?? 0; const p = Promise.resolve(this._controller.action('board', userId, targetState, opId, baseRevision)) .catch(err => ({ __err: err, userId })); promises.push(p); } const results = await Promise.all(promises); const failures = results.filter(r => r && r.__err); if (failures.length > 0) { console.error('[ScryingPool] Undo encountered failures:', failures); // Keep the snapshot in case the GM wants to retry or investigate } else { // All succeeded — clear the undo snapshot this._undoSnapshot = null; } if (this.rendered) this.render({ force: true }); } /** * Spotlights a single participant: sets them `active`, all others `hidden`. * Captures a pre-spotlight snapshot and clears any undo snapshot. * Ghost participants are excluded from all operations. * @param {string} userId - The participant to spotlight */ async spotlight(userId) { // Guard: validate userId exists and is not null/undefined if (!userId) return; const users = this._adapter.users.all(); // Get all user states in a single pass to avoid race conditions const userStates = new Map(users.map(u => [u.id, this._stateStore.getState(u.id)])); // Filter to non-ghost users const nonGhost = users.filter(u => userStates.get(u.id) !== 'ghost'); // Check if the requested userId is valid (exists in non-ghost list) const validUserIds = new Set(nonGhost.map(u => u.id)); if (!validUserIds.has(userId)) { console.warn(`[ScryingPool] spotlight: userId "${userId}" not found or is ghost`); return; } // Capture pre-spotlight snapshot for ALL users (including ghost for completeness) this._spotlightSnapshot = new Map(users.map(u => [u.id, userStates.get(u.id)])); this._undoSnapshot = null; const promises = []; for (const u of nonGhost) { if (this._controller.hasPendingOp?.(u.id)) continue; const targetState = u.id === userId ? 'active' : 'hidden'; const opId = generateOpId(); const baseRevision = this._controller.getRevision?.(u.id) ?? 0; const p = Promise.resolve(this._controller.action('board', u.id, targetState, opId, baseRevision)) .catch(err => ({ __err: err, userId: u.id })); promises.push(p); } const results = await Promise.all(promises); const failures = results.filter(r => r && r.__err); if (failures.length > 0) { console.error('[ScryingPool] Spotlight encountered failures:', failures); // Keep snapshot in case GM wants to retry or investigate } if (this.rendered) this.render({ force: true }); } /** * Restores participants to their pre-spotlight states. * No-op if no spotlight snapshot exists. Ghost participants are skipped. */ async restoreSpotlight() { if (!this._spotlightSnapshot) return; const snapshot = this._spotlightSnapshot; // Do not clear the snapshot until actions succeed const promises = []; for (const [userId, targetState] of snapshot) { // Check current state to avoid restoring ghost users that have transitioned if (this._stateStore.getState(userId) === 'ghost') continue; if (this._controller.hasPendingOp?.(userId)) continue; const opId = generateOpId(); const baseRevision = this._controller.getRevision?.(userId) ?? 0; const p = Promise.resolve(this._controller.action('board', userId, targetState, opId, baseRevision)) .catch(err => ({ __err: err, userId })); promises.push(p); } const results = await Promise.all(promises); const failures = results.filter(r => r && r.__err); if (failures.length > 0) { console.error('[ScryingPool] RestoreSpotlight encountered failures:', failures); // Keep the snapshot in case the GM wants to retry } else { this._spotlightSnapshot = null; } if (this.rendered) this.render({ force: true }); } /** * Spotlights the currently focused participant (keyboard shortcut target). * No-op if no participant is focused. */ spotlightFocused() { if (!this._focusedUserId) return; this.spotlight(this._focusedUserId); } /** @inheritdoc */ async _prepareContext() { const base = buildBoardContext(this._stateStore, this._controller, this._adapter, this._playerPrivacyManager); 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?.() ?? [], }; } /** * ApplicationV2 lifecycle — sets up event delegation on every render. * Removes old listeners first to prevent memory leaks. * @inheritdoc */ _onRender(context, options) { super._onRender?.(context, options); const root = this.element; if (!root) return; // Remove old listeners if they exist (fixes memory leak and broken listeners after reopen) if (this._clickHandler) { root.removeEventListener('click', this._clickHandler); } if (this._focusinHandler) { root.removeEventListener('focusin', this._focusinHandler); } if (this._keydownHandler) { root.removeEventListener('keydown', this._keydownHandler); } // Create new bound handlers this._clickHandler = (e) => { const btn = e.target.closest('[data-action]'); if (!btn) return; e.stopPropagation(); switch (btn.dataset.action) { case 'toggle-participant': this._dispatchToggle(btn.dataset.userId); break; case 'show-all': this.showAll(); break; case 'hide-all': this.hideAll(); break; case 'undo': this.undo(); break; case 'restore-spotlight': this.restoreSpotlight(); break; case 'open-shortcut-panel': this._openShortcutPanel(); break; case 'save-preset': this._onSavePreset(); break; 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) => { const card = e.target.closest('[data-user-id]'); this._focusedUserId = card?.dataset?.userId ?? null; }; this._keydownHandler = (e) => this._onKeydown(e); // Add new listeners 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?.(); } } /** * Keyboard navigation within the participant grid. * ArrowLeft/Right/Up/Down move focus; Space/Enter toggles the focused card. * `?` opens the shortcut panel; Ctrl+Shift+P spotlights focused card. * @param {KeyboardEvent} e */ _onKeydown(e) { const cards = [...(this.element?.querySelectorAll('[data-user-id]') ?? [])]; if (cards.length === 0) return; const current = document.activeElement; const idx = cards.indexOf(current); // Guard against negative index (focus from non-card element) if (idx < 0) return; if (e.key === 'ArrowRight' || e.key === 'ArrowDown') { e.preventDefault(); cards[(idx + 1) % cards.length]?.focus(); } else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') { e.preventDefault(); cards[(idx - 1 + cards.length) % cards.length]?.focus(); } else if ((e.key === 'Enter' || e.key === ' ') && current?.dataset?.userId) { e.preventDefault(); this._dispatchToggle(current.dataset.userId); } else if (e.key === '?') { e.preventDefault(); this._openShortcutPanel(); } else if (e.ctrlKey && e.shiftKey && e.code === 'KeyP') { e.preventDefault(); this.spotlightFocused(); } } /** * Dispatches a visibility toggle for a participant through the controller. * Matches FR-1: always goes through controller.action(), never direct setState. * @param {string} userId */ async _dispatchToggle(userId) { if (!userId) return; if (this._controller.hasPendingOp?.(userId)) return; const currentState = this._stateStore.getState(userId) ?? 'active'; const targetState = resolveToggleTarget(currentState); const opId = generateOpId(); const baseRevision = this._controller.getRevision?.(userId) ?? 0; try { await Promise.resolve(this._controller.action('board', userId, targetState, opId, baseRevision)); } catch (err) { console.error('[ScryingPool] toggle action failed for', userId, err); } finally { if (this.rendered) this.render({ force: true }); } } /** * Hook handler — re-renders the board when a participant state changes. * @param {object} data */ _onStateChanged(data) { // Suppress unused parameter warning - data is intentionally unused void data; if (this.rendered) { this.render({ force: true }); } } /** * Opens a Dialog showing all Director's Board keyboard shortcuts and their current bindings. * Reads from game.keybindings.bindings when available, falling back to defaults. */ _openShortcutPanel() { try { const localize = (key) => game.i18n?.localize(key) ?? key; const getBinding = (actionKey) => { // Check both namespaces due to migration from video-view-manager to scrying-pool const namespaces = ['scrying-pool', 'video-view-manager']; for (const ns of namespaces) { const bindings = game.keybindings?.bindings?.get(`${ns}.${actionKey}`); if (bindings?.[0]) { const b = bindings[0]; const mods = (b.modifiers ?? []).join('+'); return mods ? `${mods}+${b.key}` : b.key; } } return null; }; const shortcuts = [ { label: localize('video-view-manager.directorsBoard.shortcuts.openBoard'), binding: getBinding('openDirectorsBoard') ?? 'Ctrl+Shift+V' }, { label: localize('video-view-manager.directorsBoard.shortcuts.showAll'), binding: getBinding('showAll') ?? 'Ctrl+Shift+S' }, { label: localize('video-view-manager.directorsBoard.shortcuts.hideAll'), binding: getBinding('hideAll') ?? 'Ctrl+Shift+H' }, { label: localize('video-view-manager.directorsBoard.shortcuts.spotlight'), binding: getBinding('spotlightParticipant') ?? 'Ctrl+Shift+P' }, ]; // Escape HTML to prevent injection via localised strings or keybinding labels const escapeHtml = (str) => String(str ?? '').replace(/[&<>"']/g, (c) => ({'&':'&','<':'<','>':'>','"':'"',"'":"'"}[c])); const rows = shortcuts.map(s => `${escapeHtml(s.label)}${escapeHtml(s.binding)}`).join(''); const content = `${rows}
`; if (typeof Dialog !== 'undefined') { new Dialog({ title: localize('video-view-manager.directorsBoard.shortcuts.title'), content, buttons: { close: { label: 'Close' } }, default: 'close', }).render(true); } } catch (err) { console.error('[ScryingPool] Failed to open shortcut panel:', err); } } /** * 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. */ _onSavePreset() { if (!this._scenePresetManager || !this._adapter) { console.error('[ScryingPool] DirectorsBoard: scenePresetManager or adapter not available'); return; } // Close any existing dialog first this._closePresetDialogs(); // Create and render the save dialog this._saveDialog = new PresetSaveDialog(this._scenePresetManager, this._adapter); this._saveDialog.render(true); } /** * Opens the PresetLoadDialog for loading a saved preset. */ _onLoadPreset() { if (!this._scenePresetManager || !this._adapter) { console.error('[ScryingPool] DirectorsBoard: scenePresetManager or adapter not available'); return; } // Close any existing dialog first this._closePresetDialogs(); // Create and render the load dialog this._loadDialog = new PresetLoadDialog(this._scenePresetManager, this._adapter); this._loadDialog.render(true); } /** * Opens the PresetExportDialog for exporting all presets. */ _onExportPresets() { if (!this._scenePresetManager || !this._adapter) { console.error('[ScryingPool] DirectorsBoard: scenePresetManager or adapter not available'); return; } // Close any existing dialog first this._closePresetDialogs(); // Create and render the export dialog this._exportDialog = new PresetExportDialog({ adapter: this._adapter, scenePresetManager: this._scenePresetManager, }); this._exportDialog.render(true); } /** * Opens the PresetImportDialog for importing presets. */ _onImportPresets() { if (!this._scenePresetManager || !this._adapter) { console.error('[ScryingPool] DirectorsBoard: scenePresetManager or adapter not available'); return; } // Close any existing dialog first this._closePresetDialogs(); // Create and render the import dialog this._importDialog = new PresetImportDialog({ adapter: this._adapter, scenePresetManager: this._scenePresetManager, }); this._importDialog.render(true); } /** * Closes any open preset dialogs. * @private */ _closePresetDialogs() { if (this._saveDialog) { try { this._saveDialog.close(); } catch { // Ignore errors during cleanup } this._saveDialog = null; } if (this._loadDialog) { try { this._loadDialog.close(); } catch { // Ignore errors during cleanup } this._loadDialog = null; } if (this._exportDialog) { try { this._exportDialog.close(); } catch { // Ignore errors during cleanup } this._exportDialog = null; } if (this._importDialog) { try { this._importDialog.close(); } catch { // Ignore errors during cleanup } this._importDialog = null; } } /** * ApplicationV2 lifecycle — clean up event listeners when closed. * @inheritdoc */ async _onClose(options) { await super._onClose?.(options); // Close any open preset dialogs this._closePresetDialogs(); // Clean up event listeners to prevent memory leaks if (this._clickHandler) { this.element?.removeEventListener('click', this._clickHandler); this._clickHandler = null; } if (this._focusinHandler) { this.element?.removeEventListener('focusin', this._focusinHandler); this._focusinHandler = null; } if (this._keydownHandler) { this.element?.removeEventListener('keydown', this._keydownHandler); this._keydownHandler = null; } this._savePosition({ open: false }); } /** * ApplicationV2 lifecycle — save window position when repositioned. * @inheritdoc */ _onPosition(position) { super._onPosition?.(position); const { left, top, width, height } = position; this._savePosition({ left, top, width, height, open: true }); } /** * Persists position/open state to GM user flag. * @private * @param {object} state */ _savePosition(state) { try { game.user?.setFlag('video-view-manager', 'directorsBoardState', state); } catch (err) { console.error('[ScryingPool] Failed to save directors board position:', err); } } }