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