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:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user