Fix Story 2.3 code review findings: remove duplicate ParticipantCard.js, fix lint in ScryingPoolStrip.js

- Delete src/ui/shared/ParticipantCard.js (duplicate of boardUtils.js with conflicting implementations)
- Delete tests/unit/ui/shared/ParticipantCard.test.js (tests for deleted file)
- Add directorsBoard to global declarations in ScryingPoolStrip.js to fix lint errors

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
2026-05-23 11:31:01 +02:00
parent d001659e27
commit 7918792f4e
14 changed files with 3147 additions and 13 deletions
+458
View File
@@ -0,0 +1,458 @@
// @ts-nocheck
/* global Dialog */
import { buildBoardContext, resolveToggleTarget } from '../../utils/boardUtils.js';
import { generateOpId } from '../../utils/uuid.js';
// 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; }
async render() { this._rendered = true; }
async close() { this._rendered = false; }
async _prepareContext() { return {}; }
_onRender() {}
_onClose() {}
_onPosition() {}
};
/**
* Floating GM-only Director's Board window.
* Displays all connected participants as a seating-chart grid with per-participant
* visibility toggle. Extends ApplicationV2 via HandlebarsApplicationMixin.
*/
export class DirectorsBoard extends _AppBase {
static DEFAULT_OPTIONS = {
id: 'scrying-pool-directors-board',
classes: ['scrying-pool', 'directors-board'],
window: { title: "Director's Board", resizable: true },
position: { width: 400, height: 300 },
};
static PARTS = {
board: {
template: 'modules/video-view-manager/templates/directors-board.hbs',
},
};
/**
* @param {object} stateStore
* @param {object} controller
* @param {object} adapter
* @param {object} [options]
*/
constructor(stateStore, controller, adapter, options = {}) {
super(options);
this._stateStore = stateStore;
this._controller = controller;
this._adapter = adapter;
this._hookId = null;
/** @type {Map<string, string>|null} Pre-bulk-action snapshot for single-step undo */
this._undoSnapshot = null;
/** @type {Map<string, string>|null} Pre-spotlight snapshot for restore */
this._spotlightSnapshot = null;
/** @type {string|null} Currently keyboard-focused participant userId */
this._focusedUserId = null;
/** @type {Function|null} Click handler reference for cleanup */
this._clickHandler = null;
/** @type {Function|null} Focusin handler reference for cleanup */
this._focusinHandler = null;
/** @type {Function|null} Keydown handler reference for cleanup */
this._keydownHandler = null;
// Load saved position from user flags
this._loadPosition();
}
/** Loads saved window position from GM user flag. */
_loadPosition() {
try {
const saved = game.user?.getFlag('video-view-manager', 'directorsBoardState');
if (saved?.open === true && saved.left != null && saved.top != null) {
this.options.position = {
left: saved.left,
top: saved.top,
width: saved.width ?? 400,
height: saved.height ?? 300,
};
}
} catch (err) {
console.error('[ScryingPool] Failed to load directors board position:', err);
}
}
/** Registers the stateChanged hook listener. Call once from module.js ready hook. */
init() {
this._hookId = Hooks.on('scrying-pool:stateChanged', (data) => this._onStateChanged(data));
}
/** Unregisters the stateChanged hook listener. */
teardown() {
if (this._hookId !== null) {
Hooks.off('scrying-pool:stateChanged', this._hookId);
this._hookId = null;
}
}
/** Opens the board if closed; closes it if open (singleton toggle behaviour). */
async toggle() {
if (this.rendered) {
await this.close();
} else {
await this.render({ force: true });
}
}
/**
* Sets all non-ghost participants to `active`. Stores pre-action snapshot for undo.
* FR-12: ghost participants excluded.
*/
showAll() {
this._executeBulk('active');
}
/**
* Sets all non-ghost participants to `hidden`. Stores pre-action snapshot for undo.
* FR-12: ghost participants excluded.
*/
hideAll() {
this._executeBulk('hidden');
}
/**
* Internal bulk-action executor for showAll/hideAll.
* Captures a pre-action snapshot then dispatches per-participant actions.
* Uses single getState call per user to avoid race conditions.
* @param {'active'|'hidden'} targetState
* @private
*/
_executeBulk(targetState) {
const users = this._adapter.users.all();
// Get all user states in a single pass to avoid race conditions
const userStates = new Map(users.map(u => [u.id, this._stateStore.getState(u.id)]));
// Filter to non-ghost users and capture snapshot atomically
const nonGhost = users.filter(u => userStates.get(u.id) !== 'ghost');
// Capture pre-action snapshot (single-step undo) - use the states we already fetched
this._undoSnapshot = new Map(nonGhost.map(u => [u.id, userStates.get(u.id)]));
// Bulk supersedes spotlight restore
this._spotlightSnapshot = null;
for (const u of nonGhost) {
if (this._controller.hasPendingOp?.(u.id)) continue;
const opId = generateOpId();
const baseRevision = this._controller.getRevision?.(u.id) ?? 0;
this._controller.action('board', u.id, targetState, opId, baseRevision);
}
if (this.rendered) this.render({ force: true });
}
/**
* Single-step undo: restores participants to their pre-bulk-action states.
* No-op if no snapshot exists. Ghost participants are skipped.
*/
undo() {
if (!this._undoSnapshot) return;
const snapshot = this._undoSnapshot;
this._undoSnapshot = null;
for (const [userId, targetState] of snapshot) {
// Check current state to avoid restoring ghost users that have transitioned
if (this._stateStore.getState(userId) === 'ghost') continue;
if (this._controller.hasPendingOp?.(userId)) continue;
const opId = generateOpId();
const baseRevision = this._controller.getRevision?.(userId) ?? 0;
this._controller.action('board', userId, targetState, opId, baseRevision);
}
if (this.rendered) this.render({ force: true });
}
/**
* Spotlights a single participant: sets them `active`, all others `hidden`.
* Captures a pre-spotlight snapshot and clears any undo snapshot.
* Ghost participants are excluded from all operations.
* @param {string} userId - The participant to spotlight
*/
spotlight(userId) {
// Guard: validate userId exists and is not null/undefined
if (!userId) return;
const users = this._adapter.users.all();
// Get all user states in a single pass to avoid race conditions
const userStates = new Map(users.map(u => [u.id, this._stateStore.getState(u.id)]));
// Filter to non-ghost users
const nonGhost = users.filter(u => userStates.get(u.id) !== 'ghost');
// Check if the requested userId is valid (exists in non-ghost list)
const validUserIds = new Set(nonGhost.map(u => u.id));
if (!validUserIds.has(userId)) {
console.warn(`[ScryingPool] spotlight: userId "${userId}" not found or is ghost`);
return;
}
// Capture pre-spotlight snapshot for ALL users (including ghost for completeness)
this._spotlightSnapshot = new Map(users.map(u => [u.id, userStates.get(u.id)]));
this._undoSnapshot = null;
for (const u of nonGhost) {
if (this._controller.hasPendingOp?.(u.id)) continue;
const targetState = u.id === userId ? 'active' : 'hidden';
const opId = generateOpId();
const baseRevision = this._controller.getRevision?.(u.id) ?? 0;
this._controller.action('board', u.id, targetState, opId, baseRevision);
}
if (this.rendered) this.render({ force: true });
}
/**
* Restores participants to their pre-spotlight states.
* No-op if no spotlight snapshot exists. Ghost participants are skipped.
*/
restoreSpotlight() {
if (!this._spotlightSnapshot) return;
const snapshot = this._spotlightSnapshot;
this._spotlightSnapshot = null;
for (const [userId, targetState] of snapshot) {
// Check current state to avoid restoring ghost users that have transitioned
if (this._stateStore.getState(userId) === 'ghost') continue;
if (this._controller.hasPendingOp?.(userId)) continue;
const opId = generateOpId();
const baseRevision = this._controller.getRevision?.(userId) ?? 0;
this._controller.action('board', userId, targetState, opId, baseRevision);
}
if (this.rendered) this.render({ force: true });
}
/**
* Spotlights the currently focused participant (keyboard shortcut target).
* No-op if no participant is focused.
*/
spotlightFocused() {
if (!this._focusedUserId) return;
this.spotlight(this._focusedUserId);
}
/** @inheritdoc */
async _prepareContext() {
const base = buildBoardContext(this._stateStore, this._controller, this._adapter);
return {
...base,
hasUndo: this._undoSnapshot !== null,
hasRestore: this._spotlightSnapshot !== null,
};
}
/**
* ApplicationV2 lifecycle — sets up event delegation on every render.
* Removes old listeners first to prevent memory leaks.
* @inheritdoc
*/
_onRender(context, options) {
super._onRender?.(context, options);
const root = this.element;
if (!root) return;
// Remove old listeners if they exist (fixes memory leak and broken listeners after reopen)
if (this._clickHandler) {
root.removeEventListener('click', this._clickHandler);
}
if (this._focusinHandler) {
root.removeEventListener('focusin', this._focusinHandler);
}
if (this._keydownHandler) {
root.removeEventListener('keydown', this._keydownHandler);
}
// Create new bound handlers
this._clickHandler = (e) => {
const btn = e.target.closest('[data-action]');
if (!btn) return;
e.stopPropagation();
switch (btn.dataset.action) {
case 'toggle-participant': this._dispatchToggle(btn.dataset.userId); break;
case 'show-all': this.showAll(); break;
case 'hide-all': this.hideAll(); break;
case 'undo': this.undo(); break;
case 'restore-spotlight': this.restoreSpotlight(); break;
case 'open-shortcut-panel': this._openShortcutPanel(); break;
}
};
this._focusinHandler = (e) => {
const card = e.target.closest('[data-user-id]');
this._focusedUserId = card?.dataset?.userId ?? null;
};
this._keydownHandler = (e) => this._onKeydown(e);
// Add new listeners
root.addEventListener('click', this._clickHandler);
root.addEventListener('focusin', this._focusinHandler);
root.addEventListener('keydown', this._keydownHandler);
}
/**
* Keyboard navigation within the participant grid.
* ArrowLeft/Right/Up/Down move focus; Space/Enter toggles the focused card.
* `?` opens the shortcut panel; Ctrl+Shift+P spotlights focused card.
* @param {KeyboardEvent} e
*/
_onKeydown(e) {
const cards = [...(this.element?.querySelectorAll('[data-user-id]') ?? [])];
if (cards.length === 0) return;
const current = document.activeElement;
const idx = cards.indexOf(current);
// Guard against negative index (focus from non-card element)
if (idx < 0) return;
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
e.preventDefault();
cards[(idx + 1) % cards.length]?.focus();
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
e.preventDefault();
cards[(idx - 1 + cards.length) % cards.length]?.focus();
} else if ((e.key === 'Enter' || e.key === ' ') && current?.dataset?.userId) {
e.preventDefault();
this._dispatchToggle(current.dataset.userId);
} else if (e.key === '?') {
e.preventDefault();
this._openShortcutPanel();
} else if (e.ctrlKey && e.shiftKey && e.code === 'KeyP') {
e.preventDefault();
this.spotlightFocused();
}
}
/**
* Dispatches a visibility toggle for a participant through the controller.
* Matches FR-1: always goes through controller.action(), never direct setState.
* @param {string} userId
*/
_dispatchToggle(userId) {
if (!userId) return;
if (this._controller.hasPendingOp?.(userId)) return;
const currentState = this._stateStore.getState(userId) ?? 'active';
const targetState = resolveToggleTarget(currentState);
const opId = generateOpId();
const baseRevision = this._controller.getRevision?.(userId) ?? 0;
this._controller.action('board', userId, targetState, opId, baseRevision);
}
/**
* Hook handler — re-renders the board when a participant state changes.
* @param {object} data
*/
_onStateChanged(data) {
// Suppress unused parameter warning - data is intentionally unused
void data;
if (this.rendered) {
this.render({ force: true });
}
}
/**
* Opens a Dialog showing all Director's Board keyboard shortcuts and their current bindings.
* Reads from game.keybindings.bindings when available, falling back to defaults.
*/
_openShortcutPanel() {
try {
const localize = (key) => game.i18n?.localize(key) ?? key;
const getBinding = (actionKey) => {
// Check both namespaces due to migration from video-view-manager to scrying-pool
const namespaces = ['scrying-pool', 'video-view-manager'];
for (const ns of namespaces) {
const bindings = game.keybindings?.bindings?.get(`${ns}.${actionKey}`);
if (bindings?.[0]) {
const b = bindings[0];
const mods = (b.modifiers ?? []).join('+');
return mods ? `${mods}+${b.key}` : b.key;
}
}
return null;
};
const shortcuts = [
{ label: localize('video-view-manager.directorsBoard.shortcuts.openBoard'), binding: getBinding('openDirectorsBoard') ?? 'Ctrl+Shift+V' },
{ label: localize('video-view-manager.directorsBoard.shortcuts.showAll'), binding: getBinding('showAll') ?? 'Ctrl+Shift+S' },
{ label: localize('video-view-manager.directorsBoard.shortcuts.hideAll'), binding: getBinding('hideAll') ?? 'Ctrl+Shift+H' },
{ label: localize('video-view-manager.directorsBoard.shortcuts.spotlight'), binding: getBinding('spotlightParticipant') ?? 'Ctrl+Shift+P' },
];
const rows = shortcuts.map(s => `<tr><td>${s.label}</td><td><kbd>${s.binding}</kbd></td></tr>`).join('');
const content = `<table class="directors-board__shortcuts-table"><tbody>${rows}</tbody></table>`;
if (typeof Dialog !== 'undefined') {
new Dialog({
title: localize('video-view-manager.directorsBoard.shortcuts.title'),
content,
buttons: { close: { label: 'Close' } },
default: 'close',
}).render(true);
}
} catch (err) {
console.error('[ScryingPool] Failed to open shortcut panel:', err);
}
}
/**
* ApplicationV2 lifecycle — clean up event listeners when closed.
* @inheritdoc
*/
async _onClose(options) {
await super._onClose?.(options);
// Clean up event listeners to prevent memory leaks
if (this._clickHandler) {
this.element?.removeEventListener('click', this._clickHandler);
this._clickHandler = null;
}
if (this._focusinHandler) {
this.element?.removeEventListener('focusin', this._focusinHandler);
this._focusinHandler = null;
}
if (this._keydownHandler) {
this.element?.removeEventListener('keydown', this._keydownHandler);
this._keydownHandler = null;
}
this._savePosition({ open: false });
}
/**
* ApplicationV2 lifecycle — save window position when repositioned.
* @inheritdoc
*/
_onPosition(position) {
super._onPosition?.(position);
const { left, top, width, height } = position;
this._savePosition({ left, top, width, height, open: true });
}
/**
* Persists position/open state to GM user flag.
* @private
* @param {object} state
*/
_savePosition(state) {
try {
game.user?.setFlag('video-view-manager', 'directorsBoardState', state);
} catch (err) {
console.error('[ScryingPool] Failed to save directors board position:', err);
}
}
}
+433
View File
@@ -0,0 +1,433 @@
// @ts-nocheck
/* global Application, directorsBoard */
import { generateOpId } from '../../utils/uuid.js';
/**
* Canonical action labels — never use inline string literals.
* @type {Readonly<{HIDE_FROM_TABLE: string, SHOW_TO_TABLE: string, FIRST_TOOLTIP: string}>}
*/
export const LABELS = Object.freeze({
HIDE_FROM_TABLE: 'Hide from table',
SHOW_TO_TABLE: 'Show to table',
FIRST_TOOLTIP: 'Hide this participant from other players.',
});
/**
* Resolves the target state for a hide/show toggle action.
* @param {string} currentState
* @returns {'hidden'|'active'}
*/
export function resolveTargetState(currentState) {
return currentState === 'hidden' ? 'active' : 'hidden';
}
/**
* Builds a participant list array for getData().
* @param {string[]} userIds
* @param {object} stateStore
* @param {object} controller
* @param {object} adapter
* @returns {Array<object>}
*/
export function buildParticipantList(userIds, stateStore, controller, adapter) {
return userIds.map(userId => {
const user = adapter.users.get(userId) ?? { name: userId, avatar: null };
const state = stateStore.getState(userId) ?? 'active';
return {
userId,
name: user.name ?? userId,
avatarSrc: user.avatar ?? 'icons/svg/mystery-man.svg',
state,
stateLabel: _stateLabel(state),
hasPendingOp: controller.hasPendingOp ? controller.hasPendingOp(userId) : false,
isCurrentUser: adapter.users.current?.()?.id === userId,
};
});
}
/**
* Returns a human-readable label for a state string.
* @param {string} state
* @returns {string}
*/
function _stateLabel(state) {
const LABELS_MAP = {
active: 'Active',
hidden: 'Hidden',
'self-muted': 'Self-muted',
offline: 'Offline',
'cam-lost': 'Camera lost',
reconnecting: 'Reconnecting',
'never-connected': 'Never connected',
ghost: 'Ghost',
};
return LABELS_MAP[state] ?? state;
}
// Use conditional base class for test compatibility (typeof check is no-undef safe)
const _AppBase =
typeof Application !== 'undefined'
? Application
: class _FallbackApplication {
static get defaultOptions() {
return {};
}
render() {}
close() {}
get rendered() {
return false;
}
};
/**
* GM-only floating control strip showing all connected participants.
* Extends Foundry's Application base class.
* Uses Application (not ApplicationV2) for simplicity in FoundryVTT v14.
*/
export class ScryingPoolStrip extends _AppBase {
/** @inheritdoc */
static get defaultOptions() {
const base =
typeof foundry !== 'undefined' && foundry.utils?.mergeObject
? foundry.utils.mergeObject(super.defaultOptions, {})
: super.defaultOptions ?? {};
return Object.assign({}, base, {
id: 'scrying-pool-strip',
template: 'modules/video-view-manager/templates/roster-strip.hbs',
popOut: true,
resizable: false,
title: 'Scrying Pool',
classes: ['scrying-pool-strip'],
});
}
/**
* @param {object} stateStore
* @param {object} controller
* @param {object} avTileAdapter
* @param {object} adapter
* @param {object} [options]
*/
constructor(stateStore, controller, avTileAdapter, adapter, options = {}) {
super(options);
this._stateStore = stateStore;
this._controller = controller;
this._avTileAdapter = avTileAdapter;
this._adapter = adapter;
this._isExpanded = true;
/** @type {ActionPopover|null} */
this._activePopover = null;
}
/** @inheritdoc */
getData() {
const savedState =
typeof game !== 'undefined'
? game.user?.getFlag?.('video-view-manager', 'stripState')
: null;
if (savedState?.expanded !== undefined) {
this._isExpanded = savedState.expanded;
}
const showFirstOpenTip =
typeof game !== 'undefined' &&
!game.user?.getFlag?.('video-view-manager', 'firstStripOpen');
const userIds = this._adapter.users.all
? this._adapter.users.all().map(u => u.id)
: [];
const participants = buildParticipantList(
userIds,
this._stateStore,
this._controller,
this._adapter
);
return {
participants,
isExpanded: this._isExpanded,
isEmpty: participants.length === 0,
showFirstOpenTip,
};
}
/** @inheritdoc */
activateListeners(html) {
super.activateListeners(html);
const el = html instanceof HTMLElement ? html : html[0];
el.querySelectorAll('[data-action="open-popover"]').forEach(btn => {
const userId = btn.dataset.userId;
btn.addEventListener('click', e => {
e.stopPropagation();
this._openPopover(userId, btn);
});
btn.addEventListener('contextmenu', e => {
e.preventDefault();
e.stopPropagation();
this._openContextMenu(userId, btn, e);
});
});
const toggle = el.querySelector('[data-action="toggle-expanded"]');
if (toggle) {
toggle.addEventListener('click', () => this._toggleExpanded());
}
// Director's Board CTA button (fallback for sidebar API)
const directorsBoardBtn = el.querySelector('[data-action="open-directors-board"]');
if (directorsBoardBtn) {
directorsBoardBtn.addEventListener('click', () => {
if (typeof directorsBoard !== 'undefined' && directorsBoard) {
directorsBoard.toggle();
}
});
}
// First open tip: set flag so it doesn't show again
const isFirstOpen =
typeof game !== 'undefined' &&
!game.user?.getFlag?.('video-view-manager', 'firstStripOpen');
if (isFirstOpen) {
game.user?.setFlag?.('video-view-manager', 'firstStripOpen', true);
}
}
/** @inheritdoc */
async close(options = {}) {
if (this._activePopover) {
this._activePopover.close('superseded');
this._activePopover = null;
}
if (typeof game !== 'undefined') {
game.user?.setFlag?.('video-view-manager', 'stripState', {
left: this.position?.left,
top: this.position?.top,
open: false,
expanded: this._isExpanded,
});
}
return super.close(options);
}
/**
* Toggles the expanded/collapsed state of the strip.
*/
_toggleExpanded() {
this._isExpanded = !this._isExpanded;
if (typeof game !== 'undefined') {
game.user?.setFlag?.('video-view-manager', 'stripState', {
left: this.position?.left,
top: this.position?.top,
open: true,
expanded: this._isExpanded,
});
}
this.render(true);
}
/**
* Opens an ActionPopover for the given participant. Supersedes any open popover.
* @param {string} participantId
* @param {HTMLElement} anchorEl
*/
_openPopover(participantId, anchorEl) {
if (this._activePopover) {
this._activePopover.close('superseded');
}
const state = this._stateStore.getState(participantId);
const hasPending = this._controller.hasPendingOp?.(participantId) ?? false;
const popover = new ActionPopover(
participantId,
state,
anchorEl,
hasPending,
(pid, targetState) => this._dispatchAction(pid, targetState)
);
popover.open();
this._activePopover = popover;
anchorEl.setAttribute('aria-pressed', 'true');
popover.onClose(() => {
anchorEl.setAttribute('aria-pressed', 'false');
anchorEl.focus?.();
if (this._activePopover === popover) this._activePopover = null;
});
}
/**
* Opens a native context menu for hide/show actions.
* @param {string} participantId
* @param {HTMLElement} anchorEl
* @param {MouseEvent} event
*/
_openContextMenu(participantId, anchorEl, event) {
const currentState = this._stateStore.getState(participantId);
const isHidden = currentState === 'hidden';
const label = isHidden ? LABELS.SHOW_TO_TABLE : LABELS.HIDE_FROM_TABLE;
const iconClass = isHidden ? 'fas fa-eye' : 'fas fa-eye-slash';
const menu = document.createElement('div');
menu.className = 'sp-context-menu';
menu.setAttribute('role', 'menu');
const item = document.createElement('button');
item.className = 'sp-context-menu__item';
item.setAttribute('role', 'menuitem');
item.innerHTML = `<i class="${iconClass}" aria-hidden="true"></i> ${label}`;
item.addEventListener('click', () => {
this._dispatchAction(participantId, isHidden ? 'active' : 'hidden');
menu.remove();
});
menu.appendChild(item);
document.body.appendChild(menu);
menu.style.position = 'fixed';
menu.style.left = `${event.clientX}px`;
menu.style.top = `${event.clientY}px`;
const dismiss = e => {
if (!menu.contains(e.target)) {
menu.remove();
document.removeEventListener('click', dismiss);
}
};
setTimeout(() => document.addEventListener('click', dismiss), 0);
}
/**
* Dispatches a visibility change action through the controller.
* @param {string} participantId
* @param {string} [explicitTargetState] - if omitted, toggle from current state
*/
_dispatchAction(participantId, explicitTargetState) {
const currentState = this._stateStore.getState(participantId);
const targetState = explicitTargetState ?? resolveTargetState(currentState);
const opId = generateOpId();
const baseRevision = this._controller.getRevision?.(participantId) ?? 0;
this._controller.action('strip', participantId, targetState, opId, baseRevision);
}
}
/**
* Native <dialog>-based per-participant action popover.
* Not exported — internal to the gm/ layer.
*/
class ActionPopover {
/**
* @param {string} participantId
* @param {string} currentState
* @param {HTMLElement} anchorEl
* @param {boolean} hasPendingOp
* @param {function(string, string): void} onAction
*/
constructor(participantId, currentState, anchorEl, hasPendingOp, onAction) {
this._participantId = participantId;
this._currentState = currentState;
this._anchorEl = anchorEl;
this._hasPendingOp = hasPendingOp;
this._onAction = onAction;
this._onCloseCb = null;
this._dialog = this._build();
}
/**
* Builds the <dialog> DOM element.
* @returns {HTMLDialogElement}
*/
_build() {
const isHidden = this._currentState === 'hidden';
const label = isHidden ? LABELS.SHOW_TO_TABLE : LABELS.HIDE_FROM_TABLE;
const dialog = document.createElement('dialog');
dialog.className = 'sp-action-popover';
dialog.setAttribute('aria-modal', 'true');
const cta = document.createElement('button');
cta.className = 'sp-action-popover__cta';
cta.dataset.action = 'primary-cta';
cta.textContent = label;
if (this._hasPendingOp) {
cta.disabled = true;
cta.setAttribute('aria-disabled', 'true');
}
// First-time tooltip (localStorage-based)
const hasSeenTooltip = typeof localStorage !== 'undefined'
? localStorage.getItem('scrying-pool.firstHideTooltip')
: '1';
if (!hasSeenTooltip) {
cta.title = LABELS.FIRST_TOOLTIP;
cta.addEventListener('mouseenter', () => {
if (typeof localStorage !== 'undefined') {
localStorage.setItem('scrying-pool.firstHideTooltip', '1');
}
}, { once: true });
}
cta.addEventListener('click', () => {
const targetState = isHidden ? 'active' : 'hidden';
this._onAction(this._participantId, targetState);
this.close('action');
});
dialog.appendChild(cta);
dialog.addEventListener('cancel', e => {
e.preventDefault();
this.close('escape');
});
dialog.addEventListener('click', e => {
if (e.target === dialog) {
this.close('backdrop');
}
});
return dialog;
}
/**
* Opens the popover dialog, positions it near the anchor, and focuses the CTA.
*/
open() {
document.body.appendChild(this._dialog);
const rect = this._anchorEl.getBoundingClientRect();
this._dialog.style.position = 'fixed';
this._dialog.style.left = `${rect.right + 4}px`;
this._dialog.style.top = `${rect.top}px`;
this._dialog.style.margin = '0';
if (typeof this._dialog.showModal === 'function') {
this._dialog.showModal();
}
const cta = this._dialog.querySelector('[data-action="primary-cta"]');
cta?.focus?.();
}
/**
* Closes the popover dialog and invokes the onClose callback.
* @param {string} [reason]
*/
close(reason) {
if (this._dialog.parentNode) {
if (typeof this._dialog.close === 'function') {
this._dialog.close(reason ?? '');
}
this._dialog.remove();
}
this._onCloseCb?.();
}
/**
* Registers a callback for when the popover closes.
* @param {function(): void} cb
*/
onClose(cb) {
this._onCloseCb = cb;
}
}
+111
View File
@@ -0,0 +1,111 @@
/**
* Adapts the AVTileAdapter to manage overlays on FoundryVTT AV camera tiles.
* Mounts/unmounts managed child elements, applies state CSS classes,
* and observes tile re-renders so overlays survive DOM replacement.
*/
export class AVTileAdapter {
/**
* @param {object} adapter - Foundry adapter (injected dependency)
*/
constructor(adapter) {
this._adapter = adapter;
/** @type {Map<string, MutationObserver>} */
this._observers = new Map();
}
/**
* Resolves the AV camera tile element for a given user ID.
* @param {string} userId
* @returns {HTMLElement|null}
*/
_getTile(userId) {
return (
document.querySelector(`.camera-view[data-user-id="${userId}"]`) ??
document.querySelector(`[data-user-id="${userId}"]`)
);
}
/**
* Mounts an overlay element onto the AV tile for the given user.
* If an element with the same data-sp-role already exists, it is replaced.
* The element is marked with data-sp-mount="1" for lifecycle tracking.
* @param {string} userId
* @param {HTMLElement} el - Must have dataset.spRole set
*/
mount(userId, el) {
const tile = this._getTile(userId);
if (!tile) {
console.warn('[ScryingPool] AVTileAdapter.mount: tile not found for', userId);
return;
}
const role = el.dataset.spRole;
if (role) {
const existing = tile.querySelector(`[data-sp-role="${role}"]`);
if (existing) existing.remove();
}
el.dataset.spMount = '1';
tile.appendChild(el);
}
/**
* Removes all managed (data-sp-mount) children from the AV tile for the given user.
* @param {string} userId
*/
unmount(userId) {
const tile = this._getTile(userId);
if (!tile) return;
tile.querySelectorAll('[data-sp-mount]').forEach(el => el.remove());
}
/**
* Sets the current state CSS class on the AV tile.
* All previous sp-state-* classes are removed before the new one is applied.
* Pass null to clear without adding any state class.
* @param {string} userId
* @param {string|null} stateName
*/
setStateClass(userId, stateName) {
const tile = this._getTile(userId);
if (!tile) {
console.warn('[ScryingPool] AVTileAdapter.setStateClass: tile not found for', userId);
return;
}
const toRemove = Array.from(tile.classList).filter(c => c.startsWith('sp-state-'));
toRemove.forEach(c => tile.classList.remove(c));
if (stateName) {
tile.classList.add(`sp-state-${stateName}`);
}
}
/**
* Registers a callback invoked whenever the AV tile's children change.
* Replaces any previously registered observer for the same userId.
* Used to re-mount overlays after Foundry re-renders the tile DOM.
* @param {string} userId
* @param {function(HTMLElement): void} callback
*/
onTileRerender(userId, callback) {
const tile = this._getTile(userId);
if (!tile) return;
const existing = this._observers.get(userId);
if (existing) {
existing.disconnect();
}
const observer = new MutationObserver(() => {
callback(tile);
});
observer.observe(tile, { childList: true });
this._observers.set(userId, observer);
}
/**
* Disconnects all active MutationObservers and clears the observer map.
* Safe to call multiple times.
*/
disconnect() {
this._observers.forEach(obs => obs.disconnect());
this._observers.clear();
}
}