Story 3.2 done
This commit is contained in:
@@ -235,12 +235,20 @@ export class PresetImportExportManager {
|
||||
const validPresets = extractionResults.filter(r => r.error === null);
|
||||
const errors = extractionResults.filter(r => r.error !== null).map(r => r.error);
|
||||
|
||||
// Check if we would exceed max presets in merge mode
|
||||
const existingCount = this._scenePresetManager.list().length;
|
||||
const newCount = validPresets.length;
|
||||
// Get existing preset names for duplicate detection and limit checking
|
||||
const existingPresetNames = new Set(this._scenePresetManager.list().map(p => p.name));
|
||||
const existingCount = existingPresetNames.size;
|
||||
|
||||
// Count how many presets would actually be added (excluding duplicates in merge mode)
|
||||
let netNewCount = validPresets.length;
|
||||
if (mode === 'merge') {
|
||||
// Count only presets that don't already exist
|
||||
netNewCount = validPresets.filter(r => !existingPresetNames.has(r.name)).length;
|
||||
}
|
||||
|
||||
if (mode === 'merge' && existingCount + newCount > MAX_PRESETS_PER_WORLD) {
|
||||
errors.push(`Import would exceed maximum of ${MAX_PRESETS_PER_WORLD} presets (currently ${existingCount}, adding ${newCount})`);
|
||||
// Check preset limits
|
||||
if (mode === 'merge' && existingCount + netNewCount > MAX_PRESETS_PER_WORLD) {
|
||||
errors.push(`Import would exceed maximum of ${MAX_PRESETS_PER_WORLD} presets (currently ${existingCount}, adding ${netNewCount} new)`);
|
||||
return {
|
||||
success: false,
|
||||
message: 'Import cancelled: would exceed preset limit',
|
||||
@@ -251,6 +259,18 @@ export class PresetImportExportManager {
|
||||
};
|
||||
}
|
||||
|
||||
if (mode === 'replace' && validPresets.length > MAX_PRESETS_PER_WORLD) {
|
||||
errors.push(`Import file contains ${validPresets.length} presets, exceeding maximum of ${MAX_PRESETS_PER_WORLD}`);
|
||||
return {
|
||||
success: false,
|
||||
message: 'Import cancelled: file exceeds preset limit',
|
||||
added: 0,
|
||||
replaced: 0,
|
||||
skipped: 0,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
|
||||
// Process based on mode
|
||||
if (mode === 'replace') {
|
||||
const result = await this._replacePresets(data, validPresets, existingCount);
|
||||
@@ -258,7 +278,7 @@ export class PresetImportExportManager {
|
||||
result.errors = [...errors, ...result.errors];
|
||||
return result;
|
||||
}
|
||||
const result = await this._mergePresets(data, validPresets);
|
||||
const result = await this._mergePresets(data, validPresets, existingPresetNames);
|
||||
// Merge extraction errors with merge errors
|
||||
result.errors = [...errors, ...result.errors];
|
||||
return result;
|
||||
@@ -269,11 +289,11 @@ export class PresetImportExportManager {
|
||||
*
|
||||
* @param {ExportData} data - Validated import data.
|
||||
* @param {Array<{name: string, preset: import('../contracts/scene-preset.js').ScenePreset|null, error: string|null}>} validPresets - Validated presets to import.
|
||||
* @param {Set<string>} existingPresetNames - Set of existing preset names for duplicate detection.
|
||||
* @returns {Promise<ImportResult>} Result of the merge operation.
|
||||
* @private
|
||||
*/
|
||||
async _mergePresets(data, validPresets) {
|
||||
const existingPresetNames = new Set(this._scenePresetManager.list().map(p => p.name));
|
||||
async _mergePresets(data, validPresets, existingPresetNames) {
|
||||
let added = 0;
|
||||
let skipped = 0;
|
||||
const errors = [];
|
||||
|
||||
@@ -0,0 +1,654 @@
|
||||
/**
|
||||
* ScenePresetManager — Manages scene preset CRUD operations.
|
||||
*
|
||||
* Owns: preset creation, loading, deletion, renaming, listing.
|
||||
* Story 3.2: Added auto-apply logic with per-scene configuration.
|
||||
* Persists presets to Scene document flags.
|
||||
* Emits socket messages for preset apply operations.
|
||||
*
|
||||
* Import rule: may only import from src/contracts/ and src/utils/.
|
||||
* Constructors are side-effect free — call init() from module.js Hooks.once('ready').
|
||||
*
|
||||
* @module core/ScenePresetManager
|
||||
*/
|
||||
|
||||
import { createScenePreset, isValidScenePreset, MAX_PRESETS_PER_WORLD, SCENE_PRESET_VERSION } from '../contracts/scene-preset.js';
|
||||
import { SOCKET_EVENTS } from '../contracts/socket-message.js';
|
||||
|
||||
// Story 3.2: Auto-apply constants
|
||||
/** @type {number} Maximum pre-delay in milliseconds */
|
||||
const MAX_PREDELAY_MS = 5000;
|
||||
/** @type {number} Minimum pre-delay in milliseconds */
|
||||
const MIN_PREDELAY_MS = 0;
|
||||
|
||||
/**
|
||||
* Manages scene preset CRUD operations.
|
||||
* Persists presets to Scene document flags.
|
||||
* Emits socket messages for preset apply operations.
|
||||
* Story 3.2: Extended with auto-apply on scene activation.
|
||||
*/
|
||||
export class ScenePresetManager {
|
||||
/**
|
||||
* @param {import('../foundry/FoundryAdapter.js').FoundryAdapter} adapter
|
||||
* Injected FoundryAdapter surface.
|
||||
* @param {import('./StateStore.js').StateStore} stateStore
|
||||
* Injected StateStore for visibility matrix access.
|
||||
* @param {import('./SocketHandler.js').SocketHandler} socketHandler
|
||||
* Injected SocketHandler for socket message emission.
|
||||
* @param {import('./VisibilityManager.js').VisibilityManager|null} visibilityManager
|
||||
* Injected VisibilityManager for applying visibility matrix (Story 3.2).
|
||||
*/
|
||||
constructor(adapter, stateStore, socketHandler, visibilityManager = null) {
|
||||
// Validate dependencies
|
||||
if (!adapter || typeof adapter !== 'object') {
|
||||
throw new TypeError('ScenePresetManager: adapter argument is required and must be an object');
|
||||
}
|
||||
if (!stateStore || typeof stateStore !== 'object') {
|
||||
throw new TypeError('ScenePresetManager: stateStore argument is required and must be an object');
|
||||
}
|
||||
if (!socketHandler || typeof socketHandler !== 'object') {
|
||||
throw new TypeError('ScenePresetManager: socketHandler argument is required and must be an object');
|
||||
}
|
||||
|
||||
this._adapter = adapter;
|
||||
this._stateStore = stateStore;
|
||||
this._socketHandler = socketHandler;
|
||||
this._visibilityManager = visibilityManager;
|
||||
/** @type {Map<string, import('../contracts/scene-preset.js').ScenePreset>} name → ScenePreset */
|
||||
this._presetsCache = new Map();
|
||||
/** @type {Map<string, number>} sceneId → timeoutId for active pre-delay timers */
|
||||
this._activeTimers = new Map();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the manager by loading presets from the current scene.
|
||||
* No hooks registered here for Story 3.1 — hooks for auto-apply come in Story 3.2.
|
||||
* Side-effect: loads presets into _presetsCache.
|
||||
*/
|
||||
init() {
|
||||
this._loadCurrentScenePresets();
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans up internal state.
|
||||
* Safe to call multiple times.
|
||||
*/
|
||||
teardown() {
|
||||
this._presetsCache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the current Visibility Matrix as a named preset.
|
||||
*
|
||||
* @param {string} name - Preset name (non-empty string).
|
||||
* @returns {Promise<import('../contracts/scene-preset.js').ScenePreset>} The created preset.
|
||||
* @throws {TypeError} If name is invalid, duplicate, or max presets reached.
|
||||
*/
|
||||
async save(name) {
|
||||
// Validate name
|
||||
if (typeof name !== 'string' || name.length === 0) {
|
||||
throw new TypeError('ScenePresetManager.save: name must be a non-empty string');
|
||||
}
|
||||
|
||||
// Get current scene
|
||||
const currentScene = this._adapter.scenes.current?.();
|
||||
if (!currentScene) {
|
||||
throw new TypeError('ScenePresetManager.save: no active scene');
|
||||
}
|
||||
|
||||
// Check max presets limit
|
||||
if (this._presetsCache.size >= MAX_PRESETS_PER_WORLD) {
|
||||
throw new TypeError(
|
||||
`ScenePresetManager.save: maximum of ${MAX_PRESETS_PER_WORLD} presets reached. Delete an existing preset to save a new one.`
|
||||
);
|
||||
}
|
||||
|
||||
// Check for duplicate name
|
||||
if (this._presetsCache.has(name)) {
|
||||
throw new TypeError(`ScenePresetManager.save: a preset with name "${name}" already exists`);
|
||||
}
|
||||
|
||||
// Get current visibility matrix
|
||||
const matrixData = this._stateStore.getMatrix();
|
||||
const matrix = matrixData.matrix;
|
||||
|
||||
// Create preset
|
||||
const preset = createScenePreset(name, matrix);
|
||||
|
||||
// Add to cache
|
||||
this._presetsCache.set(name, preset);
|
||||
|
||||
// Persist to scene flag
|
||||
await this._saveScenePresets();
|
||||
|
||||
// Emit notification
|
||||
this._adapter.notifications.info(
|
||||
this._adapter.i18n.localize('video-view-manager.presets.notifications.saved')
|
||||
.replace('{name}', name)
|
||||
);
|
||||
|
||||
return preset;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads a preset by name, applying its matrix to the current visibility state.
|
||||
*
|
||||
* @param {string} name - Preset name to load.
|
||||
* @param {object} options - Options object.
|
||||
* @param {boolean} [options.emitSocket=true] - Whether to emit socket message.
|
||||
* Set to false when called in response to a socket message to prevent loops.
|
||||
* @returns {Promise<void>}
|
||||
* @throws {TypeError} If name is invalid or preset not found.
|
||||
*/
|
||||
async load(name, options = {}) {
|
||||
const { emitSocket = true } = options;
|
||||
// Validate name
|
||||
if (typeof name !== 'string' || name.length === 0) {
|
||||
throw new TypeError('ScenePresetManager.load: name must be a non-empty string');
|
||||
}
|
||||
|
||||
// Get preset from cache (loaded via init or save)
|
||||
const preset = this._presetsCache.get(name);
|
||||
if (!preset) {
|
||||
throw new TypeError(`ScenePresetManager.load: preset "${name}" not found`);
|
||||
}
|
||||
|
||||
// Validate preset structure
|
||||
isValidScenePreset(preset);
|
||||
|
||||
// Apply via VisibilityManager if available (Story 3.2), otherwise fall back to StateStore
|
||||
if (this._visibilityManager) {
|
||||
await this._visibilityManager.applyMatrix(preset.matrix);
|
||||
} else {
|
||||
await this._stateStore.setMatrix({
|
||||
_version: preset._version,
|
||||
matrix: { ...preset.matrix },
|
||||
});
|
||||
}
|
||||
|
||||
// Emit socket message only if requested (prevents loops when called from socket handler)
|
||||
if (emitSocket) {
|
||||
const now = Date.now();
|
||||
this._socketHandler.emit(SOCKET_EVENTS.PRESET_APPLIED, {
|
||||
presetName: name,
|
||||
timestamp: now,
|
||||
});
|
||||
}
|
||||
|
||||
// Emit notification
|
||||
this._adapter.notifications.info(
|
||||
this._adapter.i18n.localize('video-view-manager.presets.notifications.applied')
|
||||
.replace('{name}', name)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a preset by name.
|
||||
*
|
||||
* @param {string} name - Preset name to delete.
|
||||
* @returns {Promise<void>}
|
||||
* @throws {TypeError} If name is invalid or preset not found.
|
||||
*/
|
||||
async delete(name) {
|
||||
// Validate name
|
||||
if (typeof name !== 'string' || name.length === 0) {
|
||||
throw new TypeError('ScenePresetManager.delete: name must be a non-empty string');
|
||||
}
|
||||
|
||||
// Check if preset exists
|
||||
if (!this._presetsCache.has(name)) {
|
||||
throw new TypeError(`ScenePresetManager.delete: preset "${name}" not found`);
|
||||
}
|
||||
|
||||
// Delete from cache
|
||||
this._presetsCache.delete(name);
|
||||
|
||||
// Persist changes
|
||||
await this._saveScenePresets();
|
||||
}
|
||||
|
||||
/**
|
||||
* Renames a preset.
|
||||
*
|
||||
* @param {string} oldName - Current preset name.
|
||||
* @param {string} newName - New preset name.
|
||||
* @returns {Promise<import('../contracts/scene-preset.js').ScenePreset>} The renamed preset.
|
||||
* @throws {TypeError} If names are invalid, oldName not found, or newName conflicts.
|
||||
*/
|
||||
async rename(oldName, newName) {
|
||||
// Validate oldName
|
||||
if (typeof oldName !== 'string' || oldName.length === 0) {
|
||||
throw new TypeError('ScenePresetManager.rename: oldName must be a non-empty string');
|
||||
}
|
||||
|
||||
// Validate newName
|
||||
if (typeof newName !== 'string' || newName.length === 0) {
|
||||
throw new TypeError('ScenePresetManager.rename: newName must be a non-empty string');
|
||||
}
|
||||
|
||||
// Check if oldName exists
|
||||
if (!this._presetsCache.has(oldName)) {
|
||||
throw new TypeError(`ScenePresetManager.rename: preset "${oldName}" not found`);
|
||||
}
|
||||
|
||||
// Check if newName conflicts
|
||||
if (oldName !== newName && this._presetsCache.has(newName)) {
|
||||
throw new TypeError(
|
||||
`ScenePresetManager.rename: a preset with name "${newName}" already exists`
|
||||
);
|
||||
}
|
||||
|
||||
// Get preset
|
||||
const preset = this._presetsCache.get(oldName);
|
||||
|
||||
// Delete old entry
|
||||
this._presetsCache.delete(oldName);
|
||||
|
||||
// Update preset name and timestamps
|
||||
const now = Date.now();
|
||||
const renamedPreset = {
|
||||
...preset,
|
||||
name: newName,
|
||||
updatedAt: now,
|
||||
};
|
||||
// Validate the renamed preset structure
|
||||
isValidScenePreset(/** @type {import('../contracts/scene-preset.js').ScenePreset} */ (renamedPreset));
|
||||
|
||||
// Add with new name
|
||||
this._presetsCache.set(newName, /** @type {import('../contracts/scene-preset.js').ScenePreset} */ (renamedPreset));
|
||||
|
||||
// Persist changes
|
||||
await this._saveScenePresets();
|
||||
|
||||
return /** @type {import('../contracts/scene-preset.js').ScenePreset} */ (renamedPreset);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all presets for the current scene.
|
||||
*
|
||||
* @returns {Array<import('../contracts/scene-preset.js').ScenePreset>} Array of preset objects.
|
||||
*/
|
||||
list() {
|
||||
return Array.from(this._presetsCache.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a specific preset by name, or null if not found.
|
||||
*
|
||||
* @param {string} name - Preset name.
|
||||
* @returns {import('../contracts/scene-preset.js').ScenePreset|null}
|
||||
*/
|
||||
get(name) {
|
||||
return this._presetsCache.get(name) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads presets from the current scene's flag.
|
||||
* @private
|
||||
*/
|
||||
_loadCurrentScenePresets() {
|
||||
this._presetsCache.clear();
|
||||
|
||||
const currentScene = this._adapter.scenes.current?.();
|
||||
if (!currentScene) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const flagData = /** @type {object & { getFlag?: (scope: string, key: string) => unknown }} */ (currentScene).getFlag?.('video-view-manager', 'presets');
|
||||
if (!flagData || typeof flagData !== 'object') {
|
||||
return; // No presets or invalid format
|
||||
}
|
||||
|
||||
// Type assert flagData as having _version and presets
|
||||
const flag = /** @type {{ _version: number; presets?: Record<string, unknown> }} */ (flagData);
|
||||
if (flag._version !== SCENE_PRESET_VERSION) {
|
||||
console.warn(
|
||||
`[ScryingPool] ScenePresetManager: unsupported presets schema version ${flag._version}, expected ${SCENE_PRESET_VERSION}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const { presets } = flag;
|
||||
if (!presets || typeof presets !== 'object') {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [name, presetData] of Object.entries(presets)) {
|
||||
try {
|
||||
const validated = isValidScenePreset(presetData);
|
||||
this._presetsCache.set(name, validated);
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
`[ScryingPool] ScenePresetManager: invalid preset "${name}" in scene flag, skipping: ${err instanceof Error ? err.message : String(err)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
'[ScryingPool] ScenePresetManager: failed to load scene presets',
|
||||
err instanceof Error ? err.message : String(err)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves presets to the current scene's flag.
|
||||
* @private
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async _saveScenePresets() {
|
||||
const currentScene = this._adapter.scenes.current?.();
|
||||
if (!currentScene) {
|
||||
throw new TypeError('ScenePresetManager._saveScenePresets: no active scene');
|
||||
}
|
||||
|
||||
/** @type {Record<string, import('../contracts/scene-preset.js').ScenePreset>} */
|
||||
const presetsObj = {};
|
||||
for (const [name, preset] of this._presetsCache) {
|
||||
presetsObj[name] = preset;
|
||||
}
|
||||
|
||||
// Get existing flag data to preserve autoApply config
|
||||
const existingFlag = this._getSceneFlagData(currentScene);
|
||||
const autoApply = existingFlag?.autoApply ?? null;
|
||||
|
||||
const flagData = {
|
||||
_version: SCENE_PRESET_VERSION,
|
||||
presets: presetsObj,
|
||||
...(autoApply && { autoApply }),
|
||||
};
|
||||
|
||||
try {
|
||||
await /** @type {object & { setFlag?: (scope: string, key: string, value: unknown) => Promise<unknown> }} */ (currentScene).setFlag?.('video-view-manager', 'presets', flagData);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
'[ScryingPool] ScenePresetManager: failed to save scene presets',
|
||||
err instanceof Error ? err.message : String(err)
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Story 3.2: Auto-Apply Methods
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Handles scene activation for auto-apply functionality.
|
||||
* Checks global enable, per-scene config, and applies preset after pre-delay.
|
||||
*
|
||||
* @param {object} scene - The activated FoundryVTT Scene document.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async onSceneActivate(scene) {
|
||||
// Check if auto-apply is globally enabled
|
||||
const globalEnabled = this._adapter.settings.get?.('video-view-manager.autoApplyEnabled') ?? true;
|
||||
if (!globalEnabled) {
|
||||
return; // Global disable
|
||||
}
|
||||
|
||||
// Get current scene's flag data
|
||||
const flagData = this._getSceneFlagData(scene);
|
||||
if (!flagData) {
|
||||
return; // No preset data for this scene
|
||||
}
|
||||
|
||||
// Get auto-apply config (with defaults)
|
||||
const autoApplyConfig = this._getAutoApplyConfig(flagData);
|
||||
if (!autoApplyConfig.enabled) {
|
||||
return; // Per-scene disable
|
||||
}
|
||||
|
||||
// Check if preset exists
|
||||
const preset = this._presetsCache.get(autoApplyConfig.presetName);
|
||||
if (!preset) {
|
||||
console.warn(
|
||||
`[ScryingPool] ScenePresetManager.onSceneActivate: preset "${autoApplyConfig.presetName}" not found in cache`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear ALL pending timers when any scene is activated (prevents old scene timer from firing)
|
||||
this._clearAllTimers();
|
||||
|
||||
// Apply preset after pre-delay
|
||||
await this._applyWithDelay(scene, autoApplyConfig.presetName, autoApplyConfig.preDelay);
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a preset with optional auto-apply flag.
|
||||
* Story 3.2: Extended to support auto-applied presets.
|
||||
*
|
||||
* @param {string} presetName - Name of the preset to apply.
|
||||
* @param {object} options - Options object.
|
||||
* @param {boolean} options.autoApplied - Whether this is an auto-apply operation.
|
||||
* @returns {Promise<void>}
|
||||
* @throws {TypeError} If preset not found.
|
||||
*/
|
||||
async applyPreset(presetName, options = {}) {
|
||||
const { autoApplied = false } = options;
|
||||
|
||||
// Validate presetName
|
||||
if (typeof presetName !== 'string' || presetName.length === 0) {
|
||||
throw new TypeError('ScenePresetManager.applyPreset: presetName must be a non-empty string');
|
||||
}
|
||||
|
||||
// Get preset from cache
|
||||
const preset = this._presetsCache.get(presetName);
|
||||
if (!preset) {
|
||||
throw new TypeError(`ScenePresetManager.applyPreset: preset "${presetName}" not found`);
|
||||
}
|
||||
|
||||
// Validate preset structure
|
||||
isValidScenePreset(preset);
|
||||
|
||||
// Apply via VisibilityManager if available (Story 3.2), otherwise fall back to StateStore
|
||||
if (this._visibilityManager) {
|
||||
await this._visibilityManager.applyMatrix(preset.matrix);
|
||||
} else {
|
||||
await this._stateStore.setMatrix({
|
||||
_version: preset._version,
|
||||
matrix: { ...preset.matrix },
|
||||
});
|
||||
}
|
||||
|
||||
// Emit socket message for preset apply
|
||||
const now = Date.now();
|
||||
this._socketHandler.emit(SOCKET_EVENTS.PRESET_APPLIED, {
|
||||
presetName,
|
||||
timestamp: now,
|
||||
autoApplied,
|
||||
});
|
||||
|
||||
// Also emit hook for local confirmation (ConfirmationBar)
|
||||
this._adapter.hooks.callAll('scrying-pool:presetApplied', {
|
||||
presetName,
|
||||
matrix: preset.matrix,
|
||||
autoApplied,
|
||||
timestamp: now,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Configures auto-apply settings for a scene.
|
||||
*
|
||||
* @param {object} scene - The FoundryVTT Scene document.
|
||||
* @param {object} config - Auto-apply configuration.
|
||||
* @param {boolean} config.enabled - Whether auto-apply is enabled.
|
||||
* @param {string|null} config.presetName - Name of preset to auto-apply.
|
||||
* @param {number} config.preDelay - Pre-delay in milliseconds (0-5000).
|
||||
* @returns {Promise<void>}
|
||||
* @throws {TypeError} If validation fails.
|
||||
*/
|
||||
async configureAutoApply(scene, config) {
|
||||
// Validate scene
|
||||
if (!scene || typeof scene !== 'object') {
|
||||
throw new TypeError('ScenePresetManager.configureAutoApply: scene argument is required and must be an object');
|
||||
}
|
||||
|
||||
// Validate config
|
||||
if (!config || typeof config !== 'object') {
|
||||
throw new TypeError('ScenePresetManager.configureAutoApply: config argument is required and must be an object');
|
||||
}
|
||||
|
||||
const { enabled, presetName, preDelay } = config;
|
||||
|
||||
// Validate enabled
|
||||
if (typeof enabled !== 'boolean') {
|
||||
throw new TypeError('ScenePresetManager.configureAutoApply: enabled must be a boolean');
|
||||
}
|
||||
|
||||
// Validate presetName
|
||||
if (presetName !== null && typeof presetName !== 'string') {
|
||||
throw new TypeError('ScenePresetManager.configureAutoApply: presetName must be a string or null');
|
||||
}
|
||||
|
||||
// Validate preDelay
|
||||
if (typeof preDelay !== 'number' || preDelay < MIN_PREDELAY_MS || preDelay > MAX_PREDELAY_MS) {
|
||||
throw new TypeError(
|
||||
`ScenePresetManager.configureAutoApply: preDelay must be a number between ${MIN_PREDELAY_MS} and ${MAX_PREDELAY_MS}`
|
||||
);
|
||||
}
|
||||
|
||||
// Get current flag data
|
||||
const currentFlag = this._getSceneFlagData(scene);
|
||||
const currentPresets = currentFlag?.presets ?? {};
|
||||
|
||||
// Build new flag data
|
||||
const newFlagData = {
|
||||
_version: SCENE_PRESET_VERSION,
|
||||
presets: currentPresets,
|
||||
autoApply: {
|
||||
enabled,
|
||||
presetName: presetName ?? null,
|
||||
preDelay,
|
||||
},
|
||||
};
|
||||
|
||||
// Persist to scene flag
|
||||
try {
|
||||
await /** @type {object & { setFlag?: (scope: string, key: string, value: unknown) => Promise<unknown> }} */ (scene).setFlag?.('video-view-manager', 'presets', newFlagData);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
'[ScryingPool] ScenePresetManager: failed to configure auto-apply',
|
||||
err instanceof Error ? err.message : String(err)
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets auto-apply configuration from scene flag data.
|
||||
* Returns defaults if autoApply field is missing.
|
||||
*
|
||||
* @param {object} flagData - The scene flag data.
|
||||
* @returns {object} Auto-apply configuration with defaults.
|
||||
* @private
|
||||
*/
|
||||
_getAutoApplyConfig(flagData) {
|
||||
if (!flagData || typeof flagData !== 'object') {
|
||||
return { enabled: false, presetName: null, preDelay: 0 };
|
||||
}
|
||||
|
||||
const { autoApply } = flagData;
|
||||
if (!autoApply || typeof autoApply !== 'object') {
|
||||
return { enabled: false, presetName: null, preDelay: 0 };
|
||||
}
|
||||
|
||||
return {
|
||||
enabled: Boolean(autoApply.enabled),
|
||||
presetName: typeof autoApply.presetName === 'string' ? autoApply.presetName : null,
|
||||
preDelay: typeof autoApply.preDelay === 'number' ? Math.max(MIN_PREDELAY_MS, Math.min(autoApply.preDelay, MAX_PREDELAY_MS)) : 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a preset after a specified delay.
|
||||
*
|
||||
* @param {object} scene - The FoundryVTT Scene document.
|
||||
* @param {string} presetName - Name of the preset to apply.
|
||||
* @param {number} delayMs - Delay in milliseconds.
|
||||
* @returns {number} Timeout ID for potential cancellation.
|
||||
* @private
|
||||
*/
|
||||
_applyWithDelay(scene, presetName, delayMs) {
|
||||
const timerId = setTimeout(async () => {
|
||||
try {
|
||||
await this.applyPreset(presetName, { autoApplied: true });
|
||||
|
||||
// Notify via ui.notifications
|
||||
this._adapter.notifications.info(
|
||||
this._adapter.i18n.localize('video-view-manager.presets.notifications.scene-applied')
|
||||
.replace('{name}', presetName)
|
||||
);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
'[ScryingPool] ScenePresetManager: failed to apply preset on scene activation',
|
||||
err instanceof Error ? err.message : String(err)
|
||||
);
|
||||
} finally {
|
||||
// Clean up timer reference
|
||||
this._activeTimers.delete(scene.id);
|
||||
}
|
||||
}, delayMs);
|
||||
|
||||
// Store timer reference for cancellation
|
||||
if (scene?.id) {
|
||||
this._activeTimers.set(scene.id, timerId);
|
||||
}
|
||||
|
||||
return timerId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the active timer for a scene.
|
||||
*
|
||||
* @param {object} scene - The FoundryVTT Scene document.
|
||||
* @private
|
||||
*/
|
||||
_clearSceneTimer(scene) {
|
||||
if (!scene?.id) return;
|
||||
|
||||
const timerId = this._activeTimers.get(scene.id);
|
||||
if (timerId) {
|
||||
clearTimeout(timerId);
|
||||
this._activeTimers.delete(scene.id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears ALL active timers.
|
||||
* Called when a new scene is activated to prevent old scene timers from firing.
|
||||
* @private
|
||||
*/
|
||||
_clearAllTimers() {
|
||||
for (const [, timerId] of this._activeTimers) {
|
||||
clearTimeout(timerId);
|
||||
}
|
||||
this._activeTimers.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the complete flag data from a scene.
|
||||
*
|
||||
* @param {object} scene - The FoundryVTT Scene document.
|
||||
* @returns {object|null} The flag data or null.
|
||||
* @private
|
||||
*/
|
||||
_getSceneFlagData(scene) {
|
||||
try {
|
||||
const flagData = /** @type {object & { getFlag?: (scope: string, key: string) => unknown }} */ (scene).getFlag?.('video-view-manager', 'presets');
|
||||
if (!flagData || typeof flagData !== 'object') {
|
||||
return null;
|
||||
}
|
||||
return /** @type {object} */ (flagData);
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
'[ScryingPool] ScenePresetManager: failed to get scene flag data',
|
||||
err instanceof Error ? err.message : String(err)
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
/**
|
||||
* ScryingPoolController — Orchestrates GM visibility actions with optimistic state updates.
|
||||
*
|
||||
* Handles: GM authorization, latest-revision-wins guard, last-intent guard, PendingOp
|
||||
* lifecycle, optimistic setVisibility, socket emit, and echo reconciliation.
|
||||
*
|
||||
* Import rule: may only import from src/contracts/ and src/utils/.
|
||||
* Constructors are side-effect free — call init() from module.js Hooks.once('ready').
|
||||
*
|
||||
* @module core/ScryingPoolController
|
||||
*/
|
||||
|
||||
import { createPendingOp } from '../contracts/pending-op.js';
|
||||
import { createSocketIntentMessage, SOCKET_EVENTS } from '../contracts/socket-message.js';
|
||||
import { VISIBILITY_STATES } from '../contracts/visibility-matrix.js';
|
||||
|
||||
/**
|
||||
* Orchestrates GM visibility actions: auth, optimistic state, socket emit, echo reconciliation.
|
||||
*/
|
||||
export class ScryingPoolController {
|
||||
/**
|
||||
* @param {import('./StateStore.js').StateStore} stateStore
|
||||
* @param {{ emit(event: string, payload: object): void, registerPendingOp(op: object, event: string, payload: object): void, confirmPendingOp(opId: string): void, setReady(handler: object): void }} socketHandler
|
||||
* @param {{ users: { isGM(): boolean }, socket: { on(event: string, handler: (...args: unknown[]) => void): void }, hooks: { on(event: string, handler: (...args: unknown[]) => void): void, callAll(event: string, data: unknown): void } }} adapter
|
||||
*/
|
||||
constructor(stateStore, socketHandler, adapter) {
|
||||
this._stateStore = stateStore;
|
||||
this._socketHandler = socketHandler;
|
||||
this._adapter = adapter;
|
||||
/** @type {Map<string, import('../contracts/pending-op.js').PendingOp>} participantId → PendingOp */
|
||||
this._pendingOps = new Map();
|
||||
/** @type {Map<string, number>} participantId → last-confirmed revision */
|
||||
this._revisions = new Map();
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers the socket echo listener.
|
||||
* Called from module.js Hooks.once('ready') — NOT from constructor.
|
||||
*/
|
||||
init() {
|
||||
const echoHandler = (payload) => this._onEcho(/** @type {any} */ (payload));
|
||||
this._echoHandler = echoHandler;
|
||||
this._adapter.socket.on(SOCKET_EVENTS.VISIBILITY_UPDATED, echoHandler);
|
||||
|
||||
// Clean up stale _revisions when a participant disconnects (T-06 deferred debt)
|
||||
this._disconnectHookId = this._adapter.hooks.on('userConnected', (user, connected) => {
|
||||
if (!connected && user?.id) this.cleanupParticipant(user.id);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregisters listeners and clears all in-flight state.
|
||||
* Safe to call multiple times.
|
||||
*/
|
||||
teardown() {
|
||||
if (this._echoHandler) {
|
||||
this._adapter.socket.off(SOCKET_EVENTS.VISIBILITY_UPDATED, this._echoHandler);
|
||||
this._echoHandler = null;
|
||||
}
|
||||
if (this._disconnectHookId != null) {
|
||||
this._adapter.hooks.off('userConnected', this._disconnectHookId);
|
||||
this._disconnectHookId = null;
|
||||
}
|
||||
this.cleanupAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the last confirmed revision for a participant (0 if unknown).
|
||||
* @param {string} participantId
|
||||
* @returns {number}
|
||||
*/
|
||||
getRevision(participantId) {
|
||||
return this._revisions.get(participantId) ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if a pending op is currently in-flight for the given participant.
|
||||
* @param {string} participantId
|
||||
* @returns {boolean}
|
||||
*/
|
||||
hasPendingOp(participantId) {
|
||||
return this._pendingOps.has(participantId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans up a pending operation by userId.
|
||||
* Called by SocketHandler timeout callback via composite handler in module.js.
|
||||
* Also cleans up the revision tracking for this user (T-06).
|
||||
* @param {string} userId - The user ID to clean up
|
||||
*/
|
||||
cleanupPendingOp(userId) {
|
||||
this._pendingOps.delete(userId);
|
||||
this._revisions.delete(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans up state for a disconnected participant.
|
||||
* Called when a participant disconnects to prevent memory leaks (T-06).
|
||||
* @param {string} userId - The user ID to clean up
|
||||
*/
|
||||
cleanupParticipant(userId) {
|
||||
this._pendingOps.delete(userId);
|
||||
this._revisions.delete(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans up all state. Useful for module reload.
|
||||
*/
|
||||
cleanupAll() {
|
||||
this._pendingOps.clear();
|
||||
this._revisions.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes a GM visibility toggle request.
|
||||
* Guards: isGM, latest-revision-wins, last-intent (idempotent).
|
||||
*
|
||||
* @param {string} source - Who triggered the action (e.g. 'ui', 'preset').
|
||||
* @param {string} participantId - Target userId.
|
||||
* @param {string} targetState - Desired VisibilityState.
|
||||
* @param {string} opId - Unique operation ID (supplied by caller — Story 1.5 UI).
|
||||
* @param {number} baseRevision - StateStore revision at time of intent.
|
||||
*/
|
||||
action(source, participantId, targetState, opId, baseRevision) {
|
||||
// 0. Input validation
|
||||
if (!participantId || typeof participantId !== 'string') {
|
||||
console.warn('[ScryingPool]', 'ScryingPoolController.action: invalid participantId');
|
||||
return;
|
||||
}
|
||||
if (!targetState || typeof targetState !== 'string') {
|
||||
console.warn('[ScryingPool]', 'ScryingPoolController.action: invalid targetState');
|
||||
return;
|
||||
}
|
||||
if (!opId || typeof opId !== 'string') {
|
||||
console.warn('[ScryingPool]', 'ScryingPoolController.action: invalid opId');
|
||||
return;
|
||||
}
|
||||
if (typeof baseRevision !== 'number' || !Number.isFinite(baseRevision) || baseRevision < 0) {
|
||||
console.warn('[ScryingPool]', 'ScryingPoolController.action: invalid baseRevision');
|
||||
return;
|
||||
}
|
||||
|
||||
// 0b. Validate targetState against known states (T-05)
|
||||
if (!VISIBILITY_STATES.includes(targetState)) {
|
||||
console.warn('[ScryingPool]', `ScryingPoolController.action: invalid targetState '${targetState}'`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. Authorization
|
||||
if (!this._adapter.users.isGM()) {
|
||||
console.warn('[ScryingPool]', 'ScryingPoolController.action: non-GM call rejected');
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Atomic pending op check + race condition guard (T-01, T-08)
|
||||
if (this._pendingOps.has(participantId)) {
|
||||
console.warn('[ScryingPool]', `ScryingPoolController.action: pending op already exists for ${participantId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Latest-revision-wins guard (T-04: now strict equality)
|
||||
const currentRevision = this._revisions.get(participantId) ?? 0;
|
||||
if (baseRevision !== currentRevision) return;
|
||||
|
||||
// 4. Last-intent guard (idempotent)
|
||||
let currentState;
|
||||
try {
|
||||
currentState = this._stateStore.getState(participantId);
|
||||
} catch (err) {
|
||||
console.error('[ScryingPool] ScryingPoolController.action: getState failed', err);
|
||||
return;
|
||||
}
|
||||
if (currentState === targetState) return;
|
||||
|
||||
// 5. Register PendingOp
|
||||
const previousState = currentState ?? 'never-connected';
|
||||
const pendingOp = createPendingOp(opId, participantId, targetState, previousState);
|
||||
this._pendingOps.set(participantId, pendingOp);
|
||||
|
||||
// 6. Optimistic state update (T-07: wrapped in try-catch)
|
||||
try {
|
||||
this._stateStore.setVisibility(participantId, targetState);
|
||||
} catch (err) {
|
||||
this._pendingOps.delete(participantId);
|
||||
console.error('[ScryingPool] ScryingPoolController.action: setVisibility failed', err);
|
||||
return;
|
||||
}
|
||||
|
||||
// 7. Socket emit
|
||||
const msg = createSocketIntentMessage(opId, participantId, targetState, baseRevision);
|
||||
this._socketHandler.emit(msg.event, msg.payload);
|
||||
|
||||
// 8. Start acknowledgement timer
|
||||
this._socketHandler.registerPendingOp(pendingOp, msg.event, msg.payload);
|
||||
|
||||
// 9. Notify UI subscribers
|
||||
try {
|
||||
this._adapter.hooks.callAll('scrying-pool:controllerAction', { participantId, targetState, source, opId });
|
||||
} catch (hookErr) {
|
||||
console.error('[ScryingPool] ScryingPoolController.action: hook emission failed', hookErr);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes an authoritative echo from the socket server.
|
||||
* Confirms the pending op, updates revision, and sets the authoritative state.
|
||||
* @private
|
||||
* @param {{ opId: string, userId: string, state: string, revision?: number }} payload
|
||||
*/
|
||||
_onEcho(payload) {
|
||||
// Validate payload fields
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
console.warn('[ScryingPool]', 'ScryingPoolController._onEcho: invalid payload');
|
||||
return;
|
||||
}
|
||||
const { opId, userId, state, revision } = payload;
|
||||
if (!opId || typeof opId !== 'string') {
|
||||
console.warn('[ScryingPool]', 'ScryingPoolController._onEcho: missing or invalid opId');
|
||||
return;
|
||||
}
|
||||
if (!userId || typeof userId !== 'string') {
|
||||
console.warn('[ScryingPool]', 'ScryingPoolController._onEcho: missing or invalid userId');
|
||||
return;
|
||||
}
|
||||
if (!state || typeof state !== 'string') {
|
||||
console.warn('[ScryingPool]', 'ScryingPoolController._onEcho: missing or invalid state');
|
||||
return;
|
||||
}
|
||||
|
||||
// T-02: Verify pending op exists before confirming (T-03: use consistent participantId key)
|
||||
const pendingOp = this._pendingOps.get(userId);
|
||||
if (!pendingOp || pendingOp.opId !== opId) {
|
||||
console.warn('[ScryingPool]', `ScryingPoolController._onEcho: no matching pending op for ${userId}, opId=${opId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
this._socketHandler.confirmPendingOp(opId);
|
||||
|
||||
// Validate revision is a finite number
|
||||
const validatedRevision = (typeof revision === 'number' && Number.isFinite(revision)) ? revision : 0;
|
||||
this._revisions.set(userId, validatedRevision);
|
||||
this._pendingOps.delete(userId);
|
||||
|
||||
try {
|
||||
this._stateStore.setVisibility(userId, state);
|
||||
} catch (err) {
|
||||
console.error('[ScryingPool] ScryingPoolController._onEcho: setVisibility failed', err);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this._adapter.hooks.callAll('scrying-pool:controllerAction', {
|
||||
participantId: userId,
|
||||
targetState: state,
|
||||
source: 'echo',
|
||||
opId,
|
||||
});
|
||||
} catch (hookErr) {
|
||||
console.error('[ScryingPool] ScryingPoolController._onEcho: hook emission failed', hookErr);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* VisibilityManager — WebRTC strategy applier and SocketHandler revert handler.
|
||||
*
|
||||
* Listens to `scrying-pool:stateChanged` hook events (emitted by StateStore) and
|
||||
* applies the appropriate webrtcMode strategy:
|
||||
* - 'track-disable' + non-null adapter.webrtc → call disableTrack / enableTrack
|
||||
* - 'css-fallback' / 'unsupported' / null webrtc → no-op (CSS handled by RoleRenderer)
|
||||
*
|
||||
* Also implements onRevert(pendingOp) for SocketHandler timeout callbacks.
|
||||
*
|
||||
* Import rule: may only import from src/contracts/ and src/utils/.
|
||||
* Constructors are side-effect free — call init() from module.js Hooks.once('ready').
|
||||
*
|
||||
* @module core/VisibilityManager
|
||||
*/
|
||||
|
||||
/**
|
||||
* Applies webrtcMode strategy on state changes and reverts failed operations.
|
||||
*/
|
||||
export class VisibilityManager {
|
||||
/**
|
||||
* @param {import('./StateStore.js').StateStore} stateStore
|
||||
* @param {{ settings: { get(key: string): unknown }, webrtc: { disableTrack(userId: string): void, enableTrack(userId: string): void } | null, notifications: { warn(msg: string): void }, hooks: { on(event: string, handler: (...args: unknown[]) => void): void } }} adapter
|
||||
*/
|
||||
constructor(stateStore, adapter) {
|
||||
this._stateStore = stateStore;
|
||||
this._adapter = adapter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers the Hooks.on('scrying-pool:stateChanged') listener.
|
||||
* Called from module.js Hooks.once('ready') — NOT from constructor.
|
||||
*/
|
||||
init() {
|
||||
this._stateChangedHookId = this._adapter.hooks.on(
|
||||
'scrying-pool:stateChanged',
|
||||
(data) => this._onStateChanged(/** @type {any} */ (data))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregisters the state-changed listener.
|
||||
* Safe to call before init() or multiple times.
|
||||
*/
|
||||
teardown() {
|
||||
if (this._stateChangedHookId != null) {
|
||||
this._adapter.hooks.off('scrying-pool:stateChanged', this._stateChangedHookId);
|
||||
this._stateChangedHookId = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles a state change by applying the webrtcMode strategy.
|
||||
* css-fallback / unsupported → no-op (CSS applied by RoleRenderer in Story 1.5).
|
||||
* track-disable + non-null webrtc → disable/enable the participant's track.
|
||||
* Always safe with null adapter.webrtc (OQ-1 spike result for v14).
|
||||
*
|
||||
* @private
|
||||
* @param {{ userId: string, state: string }} data
|
||||
*/
|
||||
_onStateChanged(data) {
|
||||
const { userId, state } = data;
|
||||
// Input validation
|
||||
if (!userId || typeof userId !== 'string') {
|
||||
console.warn('[ScryingPool]', 'VisibilityManager._onStateChanged: invalid userId');
|
||||
return;
|
||||
}
|
||||
if (!state || typeof state !== 'string') {
|
||||
console.warn('[ScryingPool]', 'VisibilityManager._onStateChanged: invalid state');
|
||||
return;
|
||||
}
|
||||
|
||||
// T-11: Validate mode is a string
|
||||
const mode = this._adapter.settings.get('webrtcMode');
|
||||
if (typeof mode !== 'string') {
|
||||
console.warn('[ScryingPool]', 'VisibilityManager._onStateChanged: webrtcMode is not a string');
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode !== 'track-disable' || !this._adapter.webrtc) return;
|
||||
|
||||
// T-10: Validate webrtc methods exist before calling
|
||||
if (typeof this._adapter.webrtc.disableTrack !== 'function' ||
|
||||
typeof this._adapter.webrtc.enableTrack !== 'function') {
|
||||
console.warn('[ScryingPool]', 'VisibilityManager._onStateChanged: webrtc missing disableTrack/enableTrack methods');
|
||||
return;
|
||||
}
|
||||
|
||||
// T-09: Handle all visibility states properly, not just binary hidden/active
|
||||
if (state === 'hidden' || state === 'offline' || state === 'cam-lost' || state === 'ghost') {
|
||||
this._adapter.webrtc.disableTrack(userId);
|
||||
} else {
|
||||
this._adapter.webrtc.enableTrack(userId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called by SocketHandler after retry exhaustion — reverts the optimistic state
|
||||
* and notifies the GM that the operation could not be confirmed.
|
||||
*
|
||||
* @param {{ userId: string, previousState: string, opId: string }} pendingOp
|
||||
*/
|
||||
onRevert(pendingOp) {
|
||||
// Input validation
|
||||
if (!pendingOp || typeof pendingOp !== 'object') {
|
||||
console.warn('[ScryingPool]', 'VisibilityManager.onRevert: invalid pendingOp');
|
||||
return;
|
||||
}
|
||||
const { userId, previousState } = pendingOp;
|
||||
if (!userId || typeof userId !== 'string') {
|
||||
console.warn('[ScryingPool]', 'VisibilityManager.onRevert: invalid userId in pendingOp');
|
||||
return;
|
||||
}
|
||||
if (!previousState || typeof previousState !== 'string') {
|
||||
console.warn('[ScryingPool]', 'VisibilityManager.onRevert: invalid previousState in pendingOp');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this._stateStore.setVisibility(userId, previousState);
|
||||
} catch (err) {
|
||||
console.error('[ScryingPool] VisibilityManager.onRevert: setVisibility failed', err);
|
||||
}
|
||||
|
||||
try {
|
||||
this._adapter.notifications.warn(
|
||||
`[ScryingPool] Visibility change for ${userId} could not be confirmed — reverting to ${previousState}`
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('[ScryingPool] VisibilityManager.onRevert: notification failed', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a full visibility matrix to all participants.
|
||||
* Story 3.2: Added for preset auto-apply support.
|
||||
*
|
||||
* @param {object} matrix - The visibility matrix to apply.
|
||||
* @param {object} matrix.matrix - The matrix object with userId → state mappings.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async applyMatrix(matrix) {
|
||||
// Validate matrix
|
||||
if (!matrix || typeof matrix !== 'object') {
|
||||
throw new TypeError('VisibilityManager.applyMatrix: matrix argument is required and must be an object');
|
||||
}
|
||||
if (!matrix.matrix || typeof matrix.matrix !== 'object') {
|
||||
throw new TypeError('VisibilityManager.applyMatrix: matrix.matrix is required and must be an object');
|
||||
}
|
||||
|
||||
// Apply each participant's state from the matrix
|
||||
for (const [userId, state] of Object.entries(matrix.matrix)) {
|
||||
try {
|
||||
await this._stateStore.setVisibility(userId, state);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`[ScryingPool] VisibilityManager.applyMatrix: failed to set visibility for ${userId}:`,
|
||||
err instanceof Error ? err.message : String(err)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -145,6 +145,22 @@ export class FoundryAdapter {
|
||||
return false;
|
||||
},
|
||||
};
|
||||
|
||||
/** i18n surface — wraps game.i18n for localization. */
|
||||
this.i18n = {
|
||||
/**
|
||||
* Localize a string using the module's translation keys.
|
||||
* @param {string} key - The translation key (e.g., 'video-view-manager.notifications.gmHid')
|
||||
* @param {object} [data] - Optional data for string interpolation
|
||||
* @returns {string} The localized string
|
||||
*/
|
||||
localize: (key, data) => {
|
||||
if (g.i18n && typeof g.i18n.localize === 'function') {
|
||||
return g.i18n.localize(key, data);
|
||||
}
|
||||
return key; // Fallback: return the key if i18n not available
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* NotificationBus — coalesced toast layer above ui.notifications.
|
||||
*
|
||||
* Subscribes to `scrying-pool:stateChanged` and coalesces rapid GM visibility
|
||||
* changes for the same participant into a single toast per 3-second window.
|
||||
*
|
||||
* Verbosity rules (AC-4, AC-5):
|
||||
* - 'all' → every client sees every general notification
|
||||
* - 'gm-only' → only the GM + the affected participant are notified
|
||||
* - 'silent' → only the affected participant is notified (personal only)
|
||||
*
|
||||
* Personal notification (own camera changed) always fires — never suppressed
|
||||
* by verbosity setting (AC-2).
|
||||
*
|
||||
* Import boundary: src/notifications/ → src/core/, src/contracts/, src/utils/ ONLY.
|
||||
* Uses Hooks as a FoundryVTT global (same pattern as StateStore).
|
||||
*
|
||||
* Constructors are side-effect-free. Call init() from module.js Hooks.once('ready').
|
||||
*
|
||||
* @module notifications/NotificationBus
|
||||
*/
|
||||
|
||||
/** Coalescing window in milliseconds. Matches AC-3 ("within 3 seconds"). */
|
||||
const COALESCE_WINDOW_MS = 3_000;
|
||||
|
||||
/**
|
||||
* Coalesced toast notification layer over ui.notifications.
|
||||
*
|
||||
* Subscribes to `scrying-pool:stateChanged` and debounces GM visibility
|
||||
* changes per participant into a single toast per 3-second window.
|
||||
*/
|
||||
export class NotificationBus {
|
||||
/**
|
||||
* @param {{ notifications: {info(m:string):void, warn(m:string):void, error(m:string):void},
|
||||
* users: {get(id:string):{id:string,name:string}|null, current():{id:string}|null, isGM():boolean},
|
||||
* settings: {get(key:string):unknown},
|
||||
* i18n: {localize(key:string, data?:object):string} }} adapter
|
||||
*/
|
||||
constructor(adapter) {
|
||||
this._adapter = adapter;
|
||||
/** @type {Map<string, {timer: ReturnType<typeof setTimeout>|null, prevState: string, lastState: string, changeCount: number}>} */
|
||||
this._coalesceMap = new Map();
|
||||
this._hookId = null;
|
||||
this._disconnectHookId = null;
|
||||
}
|
||||
|
||||
/** Register hook listener. Call from module.js Hooks.once('ready'). */
|
||||
init() {
|
||||
// Prevent multiple init calls without teardown
|
||||
if (this._hookId != null) {
|
||||
console.warn('[ScryingPool] NotificationBus.init: already initialized, call teardown first');
|
||||
return;
|
||||
}
|
||||
this._hookId = Hooks.on('scrying-pool:stateChanged', (data) => this._onStateChanged(data));
|
||||
|
||||
// Clean up coalesceMap entries for disconnected users
|
||||
this._disconnectHookId = this._adapter.hooks.on('userConnected', (user, connected) => {
|
||||
if (!connected && user?.id) {
|
||||
const entry = this._coalesceMap.get(user.id);
|
||||
if (entry) {
|
||||
clearTimeout(entry.timer);
|
||||
this._coalesceMap.delete(user.id);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Unregister listeners and cancel all pending timers. Safe to call before init(). */
|
||||
teardown() {
|
||||
if (this._hookId != null) {
|
||||
Hooks.off('scrying-pool:stateChanged', this._hookId);
|
||||
this._hookId = null;
|
||||
}
|
||||
if (this._disconnectHookId != null) {
|
||||
this._adapter.hooks.off('userConnected', this._disconnectHookId);
|
||||
this._disconnectHookId = null;
|
||||
}
|
||||
for (const entry of this._coalesceMap.values()) {
|
||||
clearTimeout(entry.timer);
|
||||
}
|
||||
this._coalesceMap.clear();
|
||||
}
|
||||
|
||||
// ── Private ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Handler for `scrying-pool:stateChanged` hook.
|
||||
* @param {{ userId?: string, state: string, previousState: string }} data
|
||||
*/
|
||||
_onStateChanged(data) {
|
||||
const { userId, state: newState, previousState } = data ?? {};
|
||||
if (!userId) return;
|
||||
if (typeof newState !== 'string') return;
|
||||
|
||||
const currentUserId = this._adapter.users.current()?.id ?? null;
|
||||
|
||||
// AC-2: Personal notification — fires immediately, never suppressed by verbosity
|
||||
if (userId === currentUserId) {
|
||||
this._notifyPersonal(newState);
|
||||
return;
|
||||
}
|
||||
|
||||
// AC-4/AC-5: Verbosity gate for non-personal notifications
|
||||
const verbosity = this._adapter.settings.get('notificationVerbosity') ?? 'all';
|
||||
// Validate verbosity is one of the allowed choices
|
||||
const validVerbosity = ['all', 'gm-only', 'silent'].includes(verbosity) ? verbosity : 'all';
|
||||
if (validVerbosity === 'silent') return;
|
||||
if (validVerbosity === 'gm-only' && !this._adapter.users.isGM()) return;
|
||||
|
||||
this._enqueue(userId, newState, previousState);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire an immediate personal notification for the current user's own camera change.
|
||||
* @param {string} newState
|
||||
*/
|
||||
_notifyPersonal(newState) {
|
||||
const key = newState === 'hidden'
|
||||
? 'video-view-manager.notifications.personalHidden'
|
||||
: 'video-view-manager.notifications.personalShowed';
|
||||
const msg = this._adapter.i18n.localize(key);
|
||||
this._adapter.notifications.info(msg);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add or update a coalescing entry for the given participant.
|
||||
* Resets the 3-second debounce window on each call.
|
||||
* @param {string} userId
|
||||
* @param {string} newState
|
||||
* @param {string} prevState
|
||||
*/
|
||||
_enqueue(userId, newState, prevState) {
|
||||
// Validate required parameters
|
||||
if (typeof userId !== 'string' || !userId) return;
|
||||
if (typeof newState !== 'string' || typeof prevState !== 'string') return;
|
||||
|
||||
const existing = this._coalesceMap.get(userId);
|
||||
if (existing) {
|
||||
clearTimeout(existing.timer);
|
||||
existing.lastState = newState;
|
||||
existing.changeCount += 1;
|
||||
} else {
|
||||
this._coalesceMap.set(userId, {
|
||||
timer: null,
|
||||
prevState,
|
||||
lastState: newState,
|
||||
changeCount: 1,
|
||||
});
|
||||
}
|
||||
const entry = this._coalesceMap.get(userId);
|
||||
entry.timer = setTimeout(() => this._flush(userId), COALESCE_WINDOW_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire the coalesced notification for a participant, then remove the entry.
|
||||
* Net-zero suppression: if final state equals original state, no notification fires.
|
||||
* @param {string} userId
|
||||
*/
|
||||
_flush(userId) {
|
||||
const entry = this._coalesceMap.get(userId);
|
||||
if (!entry) return; // Entry may have been deleted by teardown or disconnect cleanup
|
||||
this._coalesceMap.delete(userId);
|
||||
|
||||
// AC-3: Net-zero suppression
|
||||
if (entry.lastState === entry.prevState) return;
|
||||
|
||||
// Additional safety: ensure we have valid timer to prevent stale closure issues
|
||||
if (entry.timer == null) return;
|
||||
|
||||
const name = this._adapter.users.get(userId)?.name ?? userId;
|
||||
const count = entry.changeCount > 1 ? ` (${entry.changeCount} changes)` : '';
|
||||
const key = entry.lastState === 'hidden'
|
||||
? 'video-view-manager.notifications.gmHid'
|
||||
: 'video-view-manager.notifications.gmShowed';
|
||||
// Note: changeCount is included in the message suffix for AC-3 compliance
|
||||
const msg = this._adapter.i18n.localize(key, { name }) + count;
|
||||
|
||||
this._adapter.notifications.info(msg);
|
||||
}
|
||||
}
|
||||
Vendored
+7
@@ -18,3 +18,10 @@ declare const ui: {
|
||||
error(msg: string): void;
|
||||
};
|
||||
};
|
||||
|
||||
declare const game: {
|
||||
user?: {
|
||||
getFlag?(scope: string, key: string): unknown;
|
||||
setFlag?(scope: string, key: string, value: unknown): Promise<void>;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
// @ts-nocheck
|
||||
import { ScryingPoolStrip } from './gm/ScryingPoolStrip.js';
|
||||
|
||||
/**
|
||||
* Reacts to state changes and applies visual state to AV tiles.
|
||||
* Constructs and manages the GM-only ScryingPoolStrip window.
|
||||
* Subscribes to Foundry Hooks after explicit `init()` call.
|
||||
*/
|
||||
export class RoleRenderer {
|
||||
/**
|
||||
* @param {object} stateStore - StateStore instance
|
||||
* @param {object} controller - ScryingPoolController instance
|
||||
* @param {object} avTileAdapter - AVTileAdapter instance
|
||||
* @param {object} adapter - FoundryAdapter instance
|
||||
*/
|
||||
constructor(stateStore, controller, avTileAdapter, adapter) {
|
||||
this._stateStore = stateStore;
|
||||
this._controller = controller;
|
||||
this._avTileAdapter = avTileAdapter;
|
||||
this._adapter = adapter;
|
||||
/** @type {ScryingPoolStrip|null} */
|
||||
this._strip = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers Hooks listeners. Must be called once during module ready.
|
||||
*/
|
||||
init() {
|
||||
Hooks.on('scrying-pool:stateChanged', data => {
|
||||
if (data.userId) {
|
||||
this._applyAVTileState(data.userId, data.state);
|
||||
}
|
||||
});
|
||||
Hooks.on('scrying-pool:controllerAction', data => {
|
||||
this._onControllerAction(data);
|
||||
});
|
||||
Hooks.on('updateUser', () => {
|
||||
if (this._strip?.rendered) {
|
||||
this._strip.render(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies visual state to an AV tile: state CSS class + lock overlay + portrait fallback.
|
||||
* @param {string} userId
|
||||
* @param {string} state
|
||||
*/
|
||||
_applyAVTileState(userId, state) {
|
||||
this._avTileAdapter.setStateClass(userId, state);
|
||||
|
||||
const HIDDEN = state === 'hidden';
|
||||
const CAMERA_ABSENT = state === 'never-connected' || state === 'cam-lost';
|
||||
|
||||
if (HIDDEN) {
|
||||
const lockEl = document.createElement('div');
|
||||
lockEl.className = 'sp-lock-overlay';
|
||||
lockEl.dataset.spRole = 'lock-overlay';
|
||||
lockEl.title = 'Camera hidden by GM';
|
||||
this._avTileAdapter.mount(userId, lockEl);
|
||||
} else if (CAMERA_ABSENT) {
|
||||
const fallbackEl = document.createElement('div');
|
||||
fallbackEl.className = 'sp-portrait-fallback';
|
||||
fallbackEl.dataset.spRole = 'portrait-fallback';
|
||||
this._avTileAdapter.mount(userId, fallbackEl);
|
||||
} else {
|
||||
this._avTileAdapter.unmount(userId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles controller action events for pending op visual feedback.
|
||||
* @param {{ participantId: string, targetState: string, source: string }} data
|
||||
*/
|
||||
_onControllerAction(data) {
|
||||
if (!data?.participantId) return;
|
||||
if (this._controller.hasPendingOp(data.participantId)) {
|
||||
this._avTileAdapter.setStateClass(data.participantId, 'pending');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the ScryingPoolStrip window (GM only). Constructs lazily on first call.
|
||||
*/
|
||||
openStrip() {
|
||||
if (!this._strip) {
|
||||
this._strip = new ScryingPoolStrip(
|
||||
this._stateStore,
|
||||
this._controller,
|
||||
this._avTileAdapter,
|
||||
this._adapter
|
||||
);
|
||||
}
|
||||
this._strip.render(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the ScryingPoolStrip window if open.
|
||||
*/
|
||||
closeStrip() {
|
||||
if (this._strip) {
|
||||
this._strip.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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, ''');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,536 @@
|
||||
// @ts-nocheck
|
||||
|
||||
/**
|
||||
* Canonical player-state → display label map.
|
||||
* `active` maps to null — no label is shown when the feed is live.
|
||||
* @type {Readonly<Record<string, string|null>>}
|
||||
*/
|
||||
const PLAYER_STATE_LABELS = Object.freeze({
|
||||
hidden: 'Hidden from table',
|
||||
'self-muted': 'Camera paused',
|
||||
offline: 'Not connected',
|
||||
'cam-lost': 'Camera unavailable',
|
||||
reconnecting: 'Rejoining view',
|
||||
'never-connected': 'Not yet connected',
|
||||
ghost: 'Leaving',
|
||||
active: null,
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// VisibilityDetailsPanel
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Native <dialog>-based panel showing full detail about a player's camera state.
|
||||
* Not exported — internal to the player/ layer.
|
||||
*/
|
||||
class VisibilityDetailsPanel {
|
||||
/**
|
||||
* @param {object|null} controller - ScryingPoolController (may be null if unavailable)
|
||||
*/
|
||||
constructor(controller) {
|
||||
this._controller = controller;
|
||||
/** @type {HTMLDialogElement|null} */
|
||||
this._dialog = null;
|
||||
/** @type {HTMLElement|null} */
|
||||
this._triggerEl = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates and shows the details panel as a modal dialog.
|
||||
* @param {string} state - Current player visibility state
|
||||
* @param {object|null} actor - User object with .name property (or null)
|
||||
* @param {string|null} reason - Reason for state change (e.g., "Hidden by: GM Name")
|
||||
* @param {HTMLElement} triggerEl - Element to return focus to on close
|
||||
*/
|
||||
show(state, actor, reason, triggerEl) {
|
||||
if (this._dialog) return; // already open
|
||||
|
||||
this._triggerEl = triggerEl;
|
||||
const stateLabel = PLAYER_STATE_LABELS[state] ?? state;
|
||||
const isHidden = state === 'hidden';
|
||||
const isDataStale = !this._controller;
|
||||
|
||||
const dialog = document.createElement('dialog');
|
||||
dialog.className = 'sp-visibility-details-panel';
|
||||
dialog.setAttribute('aria-modal', 'true');
|
||||
|
||||
// --- State explanation ---
|
||||
const stateEl = document.createElement('p');
|
||||
stateEl.className = 'sp-visibility-details-panel__state';
|
||||
stateEl.textContent = stateLabel;
|
||||
dialog.appendChild(stateEl);
|
||||
|
||||
// --- Reason/actor display ---
|
||||
if (reason) {
|
||||
const reasonEl = document.createElement('p');
|
||||
reasonEl.className = 'sp-visibility-details-panel__reason';
|
||||
reasonEl.textContent = reason;
|
||||
dialog.appendChild(reasonEl);
|
||||
} else if (actor?.name) {
|
||||
const actorEl = document.createElement('p');
|
||||
actorEl.className = 'sp-visibility-details-panel__actor';
|
||||
actorEl.textContent = `State changed by: ${actor.name}`;
|
||||
dialog.appendChild(actorEl);
|
||||
}
|
||||
|
||||
// --- Audience section ---
|
||||
if (isHidden) {
|
||||
const reassuranceEl = document.createElement('p');
|
||||
reassuranceEl.className = 'sp-visibility-details-panel__reassurance';
|
||||
reassuranceEl.textContent = 'Other players cannot see your feed';
|
||||
dialog.appendChild(reassuranceEl);
|
||||
} else {
|
||||
const audioEl = document.createElement('p');
|
||||
audioEl.className = 'sp-visibility-details-panel__audio-note';
|
||||
audioEl.textContent = 'Your audio is active for all participants.';
|
||||
dialog.appendChild(audioEl);
|
||||
}
|
||||
|
||||
// --- Stale data indicator ---
|
||||
if (isDataStale) {
|
||||
const staleEl = document.createElement('p');
|
||||
staleEl.className = 'sp-visibility-details-panel__stale';
|
||||
staleEl.textContent = 'Data may be outdated';
|
||||
dialog.appendChild(staleEl);
|
||||
}
|
||||
|
||||
// --- Close button ---
|
||||
const closeBtn = document.createElement('button');
|
||||
closeBtn.className = 'sp-visibility-details-panel__close';
|
||||
closeBtn.setAttribute('data-action', 'close-details');
|
||||
closeBtn.textContent = 'Close';
|
||||
closeBtn.addEventListener('click', () => dialog.close());
|
||||
dialog.appendChild(closeBtn);
|
||||
|
||||
// --- Dismiss handlers ---
|
||||
// Backdrop click: only when the click target IS the dialog backdrop
|
||||
dialog.addEventListener('click', e => {
|
||||
if (e.target === dialog) dialog.close();
|
||||
});
|
||||
|
||||
// Clean up on close (native Esc + programmatic close)
|
||||
dialog.addEventListener('close', () => this._onClose());
|
||||
|
||||
document.body.appendChild(dialog);
|
||||
this._dialog = dialog;
|
||||
|
||||
if (typeof dialog.showModal === 'function') {
|
||||
dialog.showModal();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the dialog from the DOM and returns focus to the trigger element.
|
||||
*/
|
||||
_onClose() {
|
||||
if (this._dialog) {
|
||||
this._dialog.remove();
|
||||
this._dialog = null;
|
||||
}
|
||||
this._triggerEl?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FirstEncounterPanel
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Non-modal explanatory panel shown the first time a player's badge updates.
|
||||
* Collapses after a 10s idle timer into a persistent chip.
|
||||
* Not exported — internal to the player/ layer.
|
||||
*/
|
||||
class FirstEncounterPanel {
|
||||
/**
|
||||
* @param {Function} setEncounteredFn - async fn that sets the firstBadgeEncounter flag
|
||||
* @param {Function} openDetailsFn - fn() that opens VisibilityDetailsPanel
|
||||
*/
|
||||
#collapseTimer = null;
|
||||
|
||||
constructor(setEncounteredFn, openDetailsFn) {
|
||||
this._setEncountered = setEncounteredFn;
|
||||
this._openDetails = openDetailsFn;
|
||||
/** @type {HTMLElement|null} */
|
||||
this._panel = null;
|
||||
/** @type {HTMLElement|null} */
|
||||
this._chip = null;
|
||||
/** @type {number} */
|
||||
this._remainingMs = 10_000;
|
||||
/** @type {number|null} */
|
||||
this._timerStartedAt = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates and shows the explanatory panel.
|
||||
* anchorEl is accepted for API consistency but positioning is handled via CSS.
|
||||
* @param {HTMLElement} _anchorEl
|
||||
*/
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
show(_anchorEl) {
|
||||
if (this._panel) return; // already shown
|
||||
|
||||
const panel = document.createElement('div');
|
||||
panel.className = 'sp-first-encounter-panel';
|
||||
panel.setAttribute('role', 'dialog');
|
||||
panel.setAttribute('aria-modal', 'false');
|
||||
panel.setAttribute('aria-label', 'Camera visibility explanation');
|
||||
|
||||
const title = document.createElement('p');
|
||||
title.className = 'sp-first-encounter-panel__title';
|
||||
title.textContent = 'Your camera visibility changed.';
|
||||
panel.appendChild(title);
|
||||
|
||||
const body = document.createElement('p');
|
||||
body.className = 'sp-first-encounter-panel__body';
|
||||
body.textContent = 'Audio continues normally.';
|
||||
panel.appendChild(body);
|
||||
|
||||
const gotItBtn = document.createElement('button');
|
||||
gotItBtn.className = 'sp-first-encounter-panel__got-it';
|
||||
gotItBtn.setAttribute('data-action', 'got-it');
|
||||
gotItBtn.textContent = 'Got it';
|
||||
gotItBtn.addEventListener('click', async () => {
|
||||
await this._onGotIt();
|
||||
});
|
||||
panel.appendChild(gotItBtn);
|
||||
|
||||
// Timer pause/resume on hover/focus
|
||||
panel.addEventListener('mouseenter', () => this._pauseTimer());
|
||||
panel.addEventListener('mouseleave', () => this._resumeTimer());
|
||||
panel.addEventListener('focusin', () => this._pauseTimer());
|
||||
panel.addEventListener('focusout', () => this._resumeTimer());
|
||||
|
||||
document.body.appendChild(panel);
|
||||
this._panel = panel;
|
||||
|
||||
this._startTimer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the collapse timer, recording when it started.
|
||||
*/
|
||||
_startTimer() {
|
||||
this._timerStartedAt = Date.now();
|
||||
this.#collapseTimer = setTimeout(() => this._collapse(), this._remainingMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pauses the collapse timer, storing remaining time.
|
||||
*/
|
||||
_pauseTimer() {
|
||||
if (this.#collapseTimer === null) return;
|
||||
const elapsed = Date.now() - (this._timerStartedAt ?? Date.now());
|
||||
this._remainingMs = Math.max(0, this._remainingMs - elapsed);
|
||||
clearTimeout(this.#collapseTimer);
|
||||
this.#collapseTimer = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resumes the collapse timer with remaining time.
|
||||
*/
|
||||
_resumeTimer() {
|
||||
if (this.#collapseTimer !== null) return; // already running
|
||||
this._timerStartedAt = Date.now();
|
||||
this.#collapseTimer = setTimeout(() => this._collapse(), this._remainingMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* "Got it" handler — clears timer, sets flag, dismisses panel.
|
||||
* Uses async to ensure flag is persisted before dismissing.
|
||||
*/
|
||||
async _onGotIt() {
|
||||
clearTimeout(this.#collapseTimer); // ghost prevention
|
||||
this.#collapseTimer = null;
|
||||
try {
|
||||
await this._setEncountered();
|
||||
} catch (err) {
|
||||
console.error('[ScryingPool] Failed to set firstBadgeEncounter flag:', err);
|
||||
}
|
||||
this._dismiss();
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapses the panel (via CSS class + 300ms timer matching CSS ease-out) and replaces it with a chip.
|
||||
*/
|
||||
_collapse() {
|
||||
// Clear any pending timer from _startTimer before creating new one
|
||||
clearTimeout(this.#collapseTimer);
|
||||
this.#collapseTimer = null;
|
||||
if (!this._panel) return;
|
||||
|
||||
const panel = this._panel;
|
||||
|
||||
// Apply collapse animation via CSS class
|
||||
panel.classList.add('sp-first-encounter-panel--collapsing');
|
||||
|
||||
const activeEl = document.activeElement;
|
||||
const wasInsidePanel = activeEl ? panel.contains(activeEl) : false;
|
||||
|
||||
// Replace after CSS transition duration (300ms ease-out per AC)
|
||||
this.#collapseTimer = setTimeout(() => {
|
||||
this.#collapseTimer = null;
|
||||
if (!this._panel) return;
|
||||
this._panel.remove();
|
||||
this._panel = null;
|
||||
|
||||
const chip = this._createChip();
|
||||
document.body.appendChild(chip);
|
||||
this._chip = chip;
|
||||
|
||||
if (wasInsidePanel) {
|
||||
chip.focus();
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the collapsed chip element.
|
||||
* @returns {HTMLElement}
|
||||
*/
|
||||
_createChip() {
|
||||
const chip = document.createElement('div');
|
||||
chip.className = 'sp-visibility-chip';
|
||||
chip.setAttribute('role', 'button');
|
||||
chip.setAttribute('tabindex', '0');
|
||||
chip.setAttribute('aria-label', 'Camera visibility — click for details');
|
||||
|
||||
chip.addEventListener('click', () => this._openDetails());
|
||||
chip.addEventListener('keydown', e => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
this._openDetails();
|
||||
}
|
||||
});
|
||||
|
||||
return chip;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the panel from the DOM without triggering collapse animation.
|
||||
*/
|
||||
_dismiss() {
|
||||
if (this._panel) {
|
||||
this._panel.remove();
|
||||
this._panel = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Must be called on teardown — clears timer to prevent ghost timers.
|
||||
*/
|
||||
_onClose() {
|
||||
clearTimeout(this.#collapseTimer); // ghost prevention
|
||||
this.#collapseTimer = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes both panel and chip from DOM.
|
||||
*/
|
||||
cleanup() {
|
||||
this._onClose();
|
||||
if (this._panel) {
|
||||
this._panel.remove();
|
||||
this._panel = null;
|
||||
}
|
||||
if (this._chip) {
|
||||
this._chip.remove();
|
||||
this._chip = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// VisibilityBadge
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Player-facing camera visibility badge.
|
||||
* Mounted on the player's own AV tile via AVTileAdapter.
|
||||
* Shows the current visibility state and triggers first-encounter education UI.
|
||||
*
|
||||
* @class
|
||||
*/
|
||||
export class VisibilityBadge {
|
||||
/**
|
||||
* @param {object} stateStore - StateStore instance
|
||||
* @param {object|null} controller - ScryingPoolController (may be null)
|
||||
* @param {object} avTileAdapter - AVTileAdapter instance (shared with RoleRenderer)
|
||||
* @param {object} adapter - FoundryAdapter instance
|
||||
*/
|
||||
constructor(stateStore, controller, avTileAdapter, adapter) {
|
||||
this._stateStore = stateStore;
|
||||
this._controller = controller;
|
||||
this._avTileAdapter = avTileAdapter;
|
||||
this._adapter = adapter;
|
||||
/** @type {string|null} */
|
||||
this._currentUserId = null;
|
||||
/** @type {string} */
|
||||
this._currentState = 'active';
|
||||
/** @type {object|null} */
|
||||
this._currentStateActor = null;
|
||||
/** @type {string|null} */
|
||||
this._currentStateReason = null;
|
||||
/** @type {HTMLElement|null} */
|
||||
this._badgeEl = null;
|
||||
/** @type {FirstEncounterPanel|null} */
|
||||
this._firstEncounterPanel = null;
|
||||
/** @type {Function|null} */
|
||||
this._stateChangedHandler = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialises the badge — resolves the current user, subscribes to state changes,
|
||||
* and mounts the initial badge element.
|
||||
* No-ops if no current user can be resolved.
|
||||
*/
|
||||
init() {
|
||||
const currentUser = this._adapter.users.current?.();
|
||||
if (!currentUser?.id) {
|
||||
this._currentUserId = null;
|
||||
return;
|
||||
}
|
||||
this._currentUserId = currentUser.id;
|
||||
|
||||
// Subscribe to state changes
|
||||
this._stateChangedHandler = data => this._onStateChanged(data);
|
||||
Hooks.on('scrying-pool:stateChanged', this._stateChangedHandler);
|
||||
|
||||
// Mount initial badge
|
||||
const initialState = this._stateStore.getState?.(this._currentUserId) ?? 'active';
|
||||
this._currentState = initialState;
|
||||
this._badgeEl = this._createBadgeElement(initialState);
|
||||
this._avTileAdapter.mount(this._currentUserId, this._badgeEl);
|
||||
|
||||
// Re-mount badge if Foundry re-renders the AV tile
|
||||
this._avTileAdapter.onTileRerender(this._currentUserId, () => {
|
||||
this._mountBadge(this._currentState);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the badge DOM element for the given state.
|
||||
* @param {string} state
|
||||
* @returns {HTMLElement}
|
||||
*/
|
||||
_createBadgeElement(state) {
|
||||
const stateLabel = PLAYER_STATE_LABELS[state] ?? null;
|
||||
const ariaLabel = `Camera visibility: ${stateLabel ?? 'Active'}`;
|
||||
|
||||
const el = document.createElement('div');
|
||||
el.className = 'sp-visibility-badge';
|
||||
el.dataset.spRole = 'visibility-badge';
|
||||
el.setAttribute('role', 'status');
|
||||
el.setAttribute('aria-live', 'polite');
|
||||
el.setAttribute('aria-label', ariaLabel);
|
||||
|
||||
const labelSpan = document.createElement('span');
|
||||
labelSpan.className = 'sp-visibility-badge__label';
|
||||
labelSpan.textContent = stateLabel ?? '';
|
||||
el.appendChild(labelSpan);
|
||||
|
||||
el.addEventListener('click', () => this._openDetailsPanel(el));
|
||||
|
||||
return el;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-mounts the badge at the given state (idempotent via AVTileAdapter).
|
||||
* @param {string} state
|
||||
*/
|
||||
_mountBadge(state) {
|
||||
if (!this._currentUserId) return;
|
||||
this._currentState = state;
|
||||
const stateLabel = PLAYER_STATE_LABELS[state] ?? null;
|
||||
const ariaLabel = `Camera visibility: ${stateLabel ?? 'Active'}`;
|
||||
|
||||
if (!this._badgeEl) {
|
||||
this._badgeEl = this._createBadgeElement(state);
|
||||
} else {
|
||||
this._badgeEl.setAttribute('aria-label', ariaLabel);
|
||||
const labelSpan = this._badgeEl.querySelector('.sp-visibility-badge__label');
|
||||
if (labelSpan) {
|
||||
labelSpan.textContent = stateLabel ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
this._avTileAdapter.mount(this._currentUserId, this._badgeEl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles a `scrying-pool:stateChanged` hook event.
|
||||
* Guards to only process events for the current user.
|
||||
* @param {{ userId: string, state: string, actor?: object, reason?: string }} data
|
||||
*/
|
||||
_onStateChanged(data) {
|
||||
// Validate data shape
|
||||
if (!data || typeof data !== 'object' || !data.userId || !data.state) {
|
||||
return;
|
||||
}
|
||||
if (data.userId !== this._currentUserId) return;
|
||||
|
||||
this._currentState = data.state;
|
||||
this._currentStateActor = data.actor;
|
||||
this._currentStateReason = data.reason;
|
||||
this._mountBadge(data.state);
|
||||
|
||||
// Trigger first-encounter panel if not yet shown
|
||||
if (!this._getFirstBadgeEncountered() && !this._firstEncounterPanel) {
|
||||
this._firstEncounterPanel = new FirstEncounterPanel(
|
||||
() => this._setFirstBadgeEncountered(),
|
||||
() => this._openDetailsPanel(this._badgeEl)
|
||||
);
|
||||
this._firstEncounterPanel.show(this._badgeEl);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the VisibilityDetailsPanel for the current state.
|
||||
* @param {HTMLElement|null} triggerEl - Element to return focus to on close
|
||||
*/
|
||||
_openDetailsPanel(triggerEl) {
|
||||
if (!triggerEl) return;
|
||||
// Use stored actor/reason from state change, or fall back to current user
|
||||
const actor = this._currentStateActor ?? this._adapter.users.current?.() ?? null;
|
||||
const panel = new VisibilityDetailsPanel(this._controller);
|
||||
panel.show(this._currentState, actor, this._currentStateReason, triggerEl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the player has already seen the first-encounter panel.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
_getFirstBadgeEncountered() {
|
||||
return this._adapter.users.current()?.getFlag('video-view-manager', 'firstBadgeEncounter') ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists the firstBadgeEncounter flag via Foundry user flags.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async _setFirstBadgeEncountered() {
|
||||
await this._adapter.users.current()?.setFlag('video-view-manager', 'firstBadgeEncounter', true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tears down badge subscriptions and releases AVTileAdapter observers.
|
||||
* Cleans up DOM elements and event listeners.
|
||||
*/
|
||||
teardown() {
|
||||
if (this._stateChangedHandler) {
|
||||
Hooks.off('scrying-pool:stateChanged', this._stateChangedHandler);
|
||||
this._stateChangedHandler = null;
|
||||
}
|
||||
if (this._firstEncounterPanel) {
|
||||
this._firstEncounterPanel.cleanup();
|
||||
this._firstEncounterPanel = null;
|
||||
}
|
||||
if (this._badgeEl) {
|
||||
this._badgeEl.remove();
|
||||
this._badgeEl = null;
|
||||
}
|
||||
this._avTileAdapter.disconnect();
|
||||
this._currentUserId = null;
|
||||
this._currentState = 'active';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* StripOverlayLayer — Single overlay container for all positioned overlays.
|
||||
*
|
||||
* Owns: DOM element with position: absolute; inset: 0; pointer-events: none; overflow: visible
|
||||
* Children restore pointer-events: auto
|
||||
* Used by: ActionPopover (Story 1.5), ConfirmationBar (Story 3.2)
|
||||
*
|
||||
* 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 1.5: Original creation for ActionPopover support
|
||||
* Story 3.2: Extended to support ConfirmationBar
|
||||
*
|
||||
* @module ui/shared/StripOverlayLayer
|
||||
*/
|
||||
|
||||
/**
|
||||
* Single overlay container for all positioned overlays.
|
||||
* Provides a common parent element with pointer-events: none that allows
|
||||
* children to restore pointer-events: auto for specific interactive areas.
|
||||
*/
|
||||
export class StripOverlayLayer {
|
||||
/**
|
||||
* @param {import('../../foundry/FoundryAdapter.js').FoundryAdapter} adapter
|
||||
* Injected FoundryAdapter surface.
|
||||
*/
|
||||
constructor(adapter) {
|
||||
this._adapter = adapter;
|
||||
/** @type {HTMLElement|null} The overlay container element */
|
||||
this._element = null;
|
||||
/** @type {Map<string, HTMLElement>} Track rendered overlays by key */
|
||||
this._overlays = new Map();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the StripOverlayLayer by creating the DOM element.
|
||||
* Side-effect: Creates and appends the overlay container to the ScryingPoolStrip.
|
||||
*/
|
||||
init() {
|
||||
// Create overlay container element
|
||||
this._element = document.createElement('div');
|
||||
this._element.className = 'sp-strip__overlay-layer';
|
||||
this._element.setAttribute('aria-hidden', 'true');
|
||||
|
||||
// Critical styles per UX-DR6
|
||||
this._element.style.cssText = `
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
overflow: visible;
|
||||
`;
|
||||
|
||||
// Try to find the ScryingPoolStrip element to append to
|
||||
// The strip is created in Story 1.5 as a floating ApplicationV2 window
|
||||
const stripElement = document.querySelector?.('.scrying-pool__roster-strip');
|
||||
if (stripElement) {
|
||||
stripElement.appendChild(this._element);
|
||||
} else {
|
||||
// Fallback: if strip not found, append to body (shouldn't happen in normal flow)
|
||||
console.warn('[ScryingPool] StripOverlayLayer: ScryingPoolStrip not found, appending to body');
|
||||
document.body.appendChild(this._element);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the overlay container element.
|
||||
* @returns {HTMLElement|null} The overlay element.
|
||||
*/
|
||||
get element() {
|
||||
return this._element;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders content into the overlay layer.
|
||||
* The content will have pointer-events: auto to allow interaction.
|
||||
*
|
||||
* @param {string|HTMLElement} content - HTML string or DOM element to render.
|
||||
* @param {string} [key] - Optional key to track this overlay for replacement.
|
||||
* @returns {HTMLElement|null} The rendered element, or null if failed.
|
||||
*/
|
||||
render(content, key = null) {
|
||||
if (!this._element) {
|
||||
console.warn('[ScryingPool] StripOverlayLayer: Cannot render, element not initialized');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Remove previous overlay if key is provided
|
||||
if (key && this._overlays.has(key)) {
|
||||
const previous = this._overlays.get(key);
|
||||
if (previous && previous.parentNode) {
|
||||
previous.parentNode.removeChild(previous);
|
||||
}
|
||||
this._overlays.delete(key);
|
||||
}
|
||||
|
||||
// Create container for the content
|
||||
const container = document.createElement('div');
|
||||
container.style.pointerEvents = 'auto';
|
||||
|
||||
// Set content
|
||||
if (typeof content === 'string') {
|
||||
container.innerHTML = content;
|
||||
} else if (content instanceof HTMLElement) {
|
||||
container.appendChild(content);
|
||||
} else {
|
||||
console.warn('[ScryingPool] StripOverlayLayer: Invalid content type');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Append to overlay layer
|
||||
this._element.appendChild(container);
|
||||
|
||||
// Track by key if provided
|
||||
if (key) {
|
||||
this._overlays.set(key, container);
|
||||
}
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes an overlay by key.
|
||||
*
|
||||
* @param {string} key - The key of the overlay to remove.
|
||||
*/
|
||||
remove(key) {
|
||||
if (!this._overlays.has(key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const overlay = this._overlays.get(key);
|
||||
if (overlay && overlay.parentNode) {
|
||||
overlay.parentNode.removeChild(overlay);
|
||||
}
|
||||
this._overlays.delete(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes all overlays from the layer.
|
||||
*/
|
||||
clearAll() {
|
||||
for (const [, overlay] of this._overlays) {
|
||||
if (overlay && overlay.parentNode) {
|
||||
overlay.parentNode.removeChild(overlay);
|
||||
}
|
||||
}
|
||||
this._overlays.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans up the StripOverlayLayer by removing the DOM element.
|
||||
* Safe to call multiple times.
|
||||
*/
|
||||
teardown() {
|
||||
this.clearAll();
|
||||
|
||||
if (this._element && this._element.parentNode) {
|
||||
this._element.parentNode.removeChild(this._element);
|
||||
}
|
||||
this._element = null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user