5b421d6d49
- Fixed TypeError: Cannot assign to read only property 'position' of object - Changed _loadPosition() to use Object.assign() instead of direct assignment - Added null check for this.options?.position to handle both Foundry and test environments - Updated fallback _AppBase class to store options in constructor for test compatibility - Added comprehensive tests for _loadPosition() method The error occurred because in FoundryVTT v14, ApplicationV2 freezes the options object, making direct assignment to this.options.position impossible. Using Object.assign() merges the properties instead, which works with both frozen and unfrozen objects. Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
758 lines
26 KiB
JavaScript
758 lines
26 KiB
JavaScript
// @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<string, string>|null} Pre-bulk-action snapshot for single-step undo */
|
|
this._undoSnapshot = null;
|
|
/** @type {Map<string, string>|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 => `<tr><td>${escapeHtml(s.label)}</td><td><kbd>${escapeHtml(s.binding)}</kbd></td></tr>`).join('');
|
|
const content = `<table class="directors-board__shortcuts-table"><tbody>${rows}</tbody></table>`;
|
|
|
|
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);
|
|
}
|
|
}
|
|
}
|