Story 3.2 done

This commit is contained in:
2026-05-23 18:23:48 +02:00
parent d175f92806
commit a1e8886fce
66 changed files with 18258 additions and 1650 deletions
+380
View File
@@ -0,0 +1,380 @@
/**
* ConfirmationBar — Strip-local feedback component for preset apply operations.
*
* Owns: Displaying preset apply confirmation, Undo button, auto-dismiss timer.
* Rendered in StripOverlayLayer at position: absolute; bottom: 0.
* Supports instant-replace rule (zero crossfade for consecutive bars).
*
* Import rule: may import from src/core/, src/contracts/, src/utils/ ONLY.
* Constructors are side-effect free — call init() from module.js Hooks.once('ready').
*
* Story 3.2: Scene Auto-Apply & ConfirmationBar
*
* @module ui/gm/ConfirmationBar
*/
/**
* Strip-local feedback bar for preset apply operations.
* Provides immediate visual confirmation with one-click Undo.
*/
export class ConfirmationBar {
/**
* @param {import('../../foundry/FoundryAdapter.js').FoundryAdapter} adapter
* Injected FoundryAdapter surface.
* @param {import('../../core/VisibilityManager.js').VisibilityManager} visibilityManager
* Injected VisibilityManager for reverting matrix on undo.
* @param {import('../../core/SocketHandler.js').SocketHandler} socketHandler
* Injected SocketHandler (for potential future use).
* @param {import('../shared/StripOverlayLayer.js').StripOverlayLayer} stripOverlayLayer
* Injected StripOverlayLayer for rendering.
*/
constructor(adapter, visibilityManager, socketHandler, stripOverlayLayer) {
this._adapter = adapter;
this._visibilityManager = visibilityManager;
this._socketHandler = socketHandler;
this._stripOverlayLayer = stripOverlayLayer;
// State
/** @type {object|null} */
this._previousMatrix = null;
/** @type {object|null} */
this._lastPayload = null;
/** @type {number|null} */
this._dismissTimer = null;
/** @type {boolean} */
this._isVisible = false;
/** @type {number} */
this._lastAppliedTimestamp = 0;
/** @type {number} */
this._recentApplyCount = 0;
/** @type {boolean} */
this._hookRegistered = false;
/** @type {Function} */
this._hookHandler = this._onPresetApplied.bind(this);
/** @type {Function|null} */
this._clickHandler = null;
// Constants
/** @type {number} Default bar duration in ms */
this._DEFAULT_DURATION = 8000;
/** @type {number} Short duration when >=2 applies within 60s */
this._SHORT_DURATION = 4000;
/** @type {number} Recent apply window in ms */
this._RECENT_WINDOW_MS = 60000;
}
/**
* Initializes the ConfirmationBar by registering hook listeners.
* Side-effect: registers listener for scrying-pool:presetApplied hook.
* Idempotent - safe to call multiple times.
*/
init() {
// Only register hook if not already registered
if (!this._hookRegistered) {
this._adapter.hooks.on('scrying-pool:presetApplied', this._hookHandler);
this._hookRegistered = true;
}
this._setupEventListeners();
}
/**
* Sets up DOM event listeners for the confirmation bar.
* Uses event delegation via StripOverlayLayer's element.
* @private
*/
_setupEventListeners() {
if (!this._stripOverlayLayer || !this._stripOverlayLayer.element) {
return;
}
const element = this._stripOverlayLayer.element;
// Store bound handler
this._clickHandler = (event) => {
const target = event.target.closest?.('[data-action="confirmation-bar-undo"]');
if (target) {
event.preventDefault();
event.stopPropagation();
this._onUndo();
}
};
// Use event delegation for undo button clicks
element.addEventListener('click', this._clickHandler);
}
/**
* Cleans up internal state and unregisters listeners.
* Safe to call multiple times.
*/
teardown() {
this._adapter.hooks.off('scrying-pool:presetApplied', this._hookHandler);
this._hookRegistered = false;
this._removeEventListeners();
this._clearDismissTimer();
this._previousMatrix = null;
this._lastPayload = null;
this._isVisible = false;
this._recentApplyCount = 0;
}
/**
* Removes DOM event listeners.
* @private
*/
_removeEventListeners() {
if (!this._stripOverlayLayer || !this._stripOverlayLayer.element || !this._clickHandler) {
return;
}
const element = this._stripOverlayLayer.element;
element.removeEventListener('click', this._clickHandler);
this._clickHandler = null;
}
/**
* Shows the confirmation bar with the given payload.
* Captures previous matrix for undo, renders bar, starts dismiss timer.
*
* @param {object} payload - The preset applied payload.
* @param {string} payload.presetName - Name of the applied preset.
* @param {object} payload.matrix - The visibility matrix that was applied.
* @param {boolean} payload.autoApplied - Whether this was an auto-apply.
* @param {boolean} [payload.partialFail] - Whether some participants failed to update.
* @param {number} [payload.timestamp] - When the preset was applied.
*/
show(payload) {
if (!payload) {
return;
}
// Store previous matrix for undo
if (payload.matrix) {
this._previousMatrix = payload.matrix;
}
this._lastPayload = payload;
this._lastAppliedTimestamp = payload.timestamp ?? Date.now();
// Track recent applies for short duration logic
const now = Date.now();
if (now - this._lastAppliedTimestamp < this._RECENT_WINDOW_MS) {
this._recentApplyCount++;
} else {
this._recentApplyCount = 1;
}
// Render the bar
this._render();
// Start dismiss timer
this._startDismissTimer();
}
/**
* Hides the confirmation bar.
* Clears timer, previous matrix, and updates visibility state.
*/
hide() {
this._clearDismissTimer();
this._previousMatrix = null;
this._lastPayload = null;
this._isVisible = false;
this._recentApplyCount = 0;
}
/**
* Handles the Undo button click.
* Reverts to previous matrix and hides the bar.
* Emits hook for undo notification.
* @private
*/
_onUndo() {
if (!this._previousMatrix || !this._visibilityManager) {
return;
}
try {
this._visibilityManager.applyMatrix(this._previousMatrix);
// Emit undo notification
this._adapter.hooks.callAll('scrying-pool:presetUndo', {
presetName: this._lastPayload?.presetName ?? 'unknown',
timestamp: Date.now(),
});
} catch (err) {
console.error(
'[ScryingPool] ConfirmationBar: failed to undo preset apply',
err instanceof Error ? err.message : String(err)
);
} finally {
this.hide();
}
}
/**
* Renders the confirmation bar in the strip overlay.
* Calculates hidden/visible counts from matrix.
* Determines variant (default or amber for partial fail).
* @private
*/
_render() {
if (!this._lastPayload || !this._stripOverlayLayer) {
return;
}
const { presetName, partialFail, matrix } = this._lastPayload;
const counts = this._calculateCounts(matrix);
const variant = partialFail ? 'amber' : 'default';
const message = this._buildMessage(presetName, counts, variant);
// Build HTML content
const html = this._buildHtml(message, variant);
// Render via strip overlay
this._stripOverlayLayer.render(html);
this._isVisible = true;
}
/**
* Calculates hidden and visible counts from a visibility matrix.
* Only counts states that affect visibility (active, hidden).
* @param {object} matrix - The visibility matrix.
* @returns {object} Hidden and visible counts.
* @private
*/
_calculateCounts(matrix) {
if (!matrix || !matrix.matrix) {
return { hidden: 0, visible: 0 };
}
let hidden = 0;
let visible = 0;
for (const [, state] of Object.entries(matrix.matrix)) {
if (state === 'hidden') {
hidden++;
} else if (state === 'active') {
visible++;
}
// Other states (self-muted, offline, etc.) are not counted
// as they don't affect the "hidden from table" status
}
return { hidden, visible };
}
/**
* Builds the display message for the confirmation bar.
* @param {string} presetName - Name of the preset.
* @param {object} counts - Hidden and visible counts.
* @param {string} variant - 'default' or 'amber'.
* @returns {string} The formatted message.
* @private
*/
_buildMessage(presetName, counts, variant) {
const baseMsg = this._adapter.i18n.localize('video-view-manager.presets.confirmation.applied')
.replace('{name}', presetName);
const countMsg = this._adapter.i18n.localize('video-view-manager.presets.confirmation.counts')
.replace('{hidden}', counts.hidden)
.replace('{visible}', counts.visible);
if (variant === 'amber') {
const suffix = this._adapter.i18n.localize('video-view-manager.presets.confirmation.partial-fail');
return `${baseMsg} ${countMsg} ${suffix}`;
}
return `${baseMsg} ${countMsg}`;
}
/**
* Builds the HTML content for the confirmation bar.
* Includes message, undo button, accessibility attributes.
* Uses data-action pattern for event delegation.
* @param {string} message - The display message.
* @param {string} variant - 'default' or 'amber'.
* @returns {string} HTML string.
* @private
*/
_buildHtml(message, variant) {
const variantClass = variant === 'amber' ? 'sp-confirmation-bar--amber' : 'sp-confirmation-bar--default';
const undoLabel = this._adapter.i18n.localize('video-view-manager.presets.confirmation.undo');
// Use data-action for event delegation via StripOverlayLayer
// The onclick handler is set up in _setupEventListeners
return `
<div class="scrying-pool__confirmation-bar ${variantClass}"
role="status"
aria-live="polite"
aria-label="${message}. ${undoLabel}">
<span class="sp-confirmation-bar__message">${this._escapeHtml(message)}</span>
<button class="sp-confirmation-bar__undo-btn"
role="button"
aria-label="${undoLabel}"
data-action="confirmation-bar-undo">
${undoLabel}
</button>
</div>
`;
}
/**
* Escapes HTML special characters to prevent XSS.
* @param {string} str - String to escape.
* @returns {string} Escaped string.
* @private
*/
_escapeHtml(str) {
if (!str || typeof str !== 'string') {
return '';
}
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#x27;');
}
/**
* Starts the auto-dismiss timer.
* Uses short duration (4000ms) if >=2 applies within 60s window.
* Otherwise uses default duration (8000ms).
* @private
*/
_startDismissTimer() {
// Clear any existing timer
this._clearDismissTimer();
// Determine duration based on recent activity
const now = Date.now();
const useShort = this._recentApplyCount >= 2 &&
(now - this._lastAppliedTimestamp) < this._RECENT_WINDOW_MS;
const duration = useShort ? this._SHORT_DURATION : this._DEFAULT_DURATION;
this._dismissTimer = setTimeout(() => {
this.hide();
}, duration);
}
/**
* Clears the active dismiss timer.
* @private
*/
_clearDismissTimer() {
if (this._dismissTimer) {
clearTimeout(this._dismissTimer);
this._dismissTimer = null;
}
}
/**
* Hook handler for scrying-pool:presetApplied events.
* Shows the bar, implementing instant-replace rule.
* @param {object} payload - The preset applied payload.
* @private
*/
_onPresetApplied(payload) {
// Instant-replace: just show again, no crossfade
this.show(payload);
}
}