Story 3.2 done
This commit is contained in:
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the auto-dismiss timer.
|
||||
* Uses short duration (4000ms) if >=2 applies within 60s window.
|
||||
* Otherwise uses default duration (8000ms).
|
||||
* @private
|
||||
*/
|
||||
_startDismissTimer() {
|
||||
// Clear any existing timer
|
||||
this._clearDismissTimer();
|
||||
|
||||
// Determine duration based on recent activity
|
||||
const now = Date.now();
|
||||
const useShort = this._recentApplyCount >= 2 &&
|
||||
(now - this._lastAppliedTimestamp) < this._RECENT_WINDOW_MS;
|
||||
const duration = useShort ? this._SHORT_DURATION : this._DEFAULT_DURATION;
|
||||
|
||||
this._dismissTimer = setTimeout(() => {
|
||||
this.hide();
|
||||
}, duration);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the active dismiss timer.
|
||||
* @private
|
||||
*/
|
||||
_clearDismissTimer() {
|
||||
if (this._dismissTimer) {
|
||||
clearTimeout(this._dismissTimer);
|
||||
this._dismissTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook handler for scrying-pool:presetApplied events.
|
||||
* Shows the bar, implementing instant-replace rule.
|
||||
* @param {object} payload - The preset applied payload.
|
||||
* @private
|
||||
*/
|
||||
_onPresetApplied(payload) {
|
||||
// Instant-replace: just show again, no crossfade
|
||||
this.show(payload);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -97,6 +97,8 @@ export class PresetImportDialog extends _AppBase {
|
||||
previewItems: this._previewItems,
|
||||
requiresConfirmation: this._requiresConfirmation,
|
||||
selectedFileName: this._selectedFile?.name ?? null,
|
||||
mergeLabel: 'Merge',
|
||||
replaceLabel: 'Replace',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user