// @ts-nocheck /* global Dialog */ import { buildBoardContext, resolveToggleTarget } from '../../utils/boardUtils.js'; import { generateOpId } from '../../utils/uuid.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 = {}; get rendered() { return this._rendered ?? false; } set rendered(v) { this._rendered = v; } get element() { return this._element ?? null; } 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 {object} [options] */ constructor(stateStore, controller, adapter, options = {}) { super(options); this._stateStore = stateStore; this._controller = controller; this._adapter = adapter; 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; // Load saved position from user flags this._loadPosition(); } /** 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) { 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; } } /** 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. */ showAll() { this._executeBulk('active'); } /** * Sets all non-ghost participants to `hidden`. Stores pre-action snapshot for undo. * FR-12: ghost participants excluded. */ hideAll() { 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 */ _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; for (const u of nonGhost) { if (this._controller.hasPendingOp?.(u.id)) continue; const opId = generateOpId(); const baseRevision = this._controller.getRevision?.(u.id) ?? 0; this._controller.action('board', u.id, targetState, opId, baseRevision); } 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. */ undo() { if (!this._undoSnapshot) return; const snapshot = this._undoSnapshot; this._undoSnapshot = null; 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; this._controller.action('board', userId, targetState, opId, baseRevision); } 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 */ 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; 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; this._controller.action('board', u.id, targetState, opId, baseRevision); } 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. */ restoreSpotlight() { if (!this._spotlightSnapshot) return; const snapshot = this._spotlightSnapshot; this._spotlightSnapshot = null; 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; this._controller.action('board', userId, targetState, opId, baseRevision); } 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); return { ...base, hasUndo: this._undoSnapshot !== null, hasRestore: this._spotlightSnapshot !== null, }; } /** * 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; } }; 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); } /** * 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 */ _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; this._controller.action('board', userId, targetState, opId, baseRevision); } /** * 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' }, ]; const rows = shortcuts.map(s => `${s.label}${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); } } /** * ApplicationV2 lifecycle — clean up event listeners when closed. * @inheritdoc */ async _onClose(options) { await super._onClose?.(options); // 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); } } }