Story 3.2 done

This commit is contained in:
2026-05-23 18:23:48 +02:00
parent d175f92806
commit a1e8886fce
66 changed files with 18258 additions and 1650 deletions
+380
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#x27;');
}
/**
* 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);
}
}
+93
View File
@@ -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.
*/
+2
View File
@@ -97,6 +97,8 @@ export class PresetImportDialog extends _AppBase {
previewItems: this._previewItems,
requiresConfirmation: this._requiresConfirmation,
selectedFileName: this._selectedFile?.name ?? null,
mergeLabel: 'Merge',
replaceLabel: 'Replace',
};
}
+176
View File
@@ -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();
}
}
+192
View File
@@ -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();
}
}
+450
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#x27;');
}
}