Fianlize deck strip and management
CI / ci (push) Failing after 6s

This commit is contained in:
2026-05-25 00:51:46 +02:00
parent 5dc9b3b8d4
commit 7b56d62563
12 changed files with 755 additions and 141 deletions
+174 -66
View File
@@ -1,5 +1,5 @@
// @ts-nocheck
/* global Application, directorsBoard */
/* global directorsBoard */
import { generateOpId } from '../../utils/uuid.js';
import { StripOverlayLayer } from '../shared/StripOverlayLayer.js';
@@ -29,16 +29,24 @@ export function resolveTargetState(currentState) {
* @param {object} controller
* @param {object} adapter
* @param {boolean} hasStreamAccess - Whether stream access is available for video replacement
* @param {object|null} [portraitFallbackHandler] - PortraitFallbackHandler for custom portrait lookup
* @returns {Array<object>}
*/
export function buildParticipantList(userIds, stateStore, controller, adapter, hasStreamAccess = false) {
export function buildParticipantList(userIds, stateStore, controller, adapter, hasStreamAccess = false, portraitFallbackHandler = null) {
return userIds.map(userId => {
const user = adapter.users.get(userId) ?? { name: userId, avatar: null };
const state = stateStore.getState(userId) ?? 'active';
// Priority: custom fallback portrait → user.avatar → character portrait → mystery-man
const avatarSrc = portraitFallbackHandler?.getFallbackImageURL(userId)
|| user.avatar
|| user.character?.img
|| 'icons/svg/mystery-man.svg';
return {
userId,
name: user.name ?? userId,
avatarSrc: user.avatar ?? 'icons/svg/mystery-man.svg',
avatarSrc,
state,
stateLabel: _stateLabel(state),
hasPendingOp: controller.hasPendingOp ? controller.hasPendingOp(userId) : false,
@@ -67,42 +75,53 @@ function _stateLabel(state) {
return LABELS_MAP[state] ?? state;
}
// Use conditional base class for test compatibility (typeof check is no-undef safe)
// 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 Application !== 'undefined'
? Application
: class _FallbackApplication {
static get defaultOptions() {
return {};
}
render() {}
close() {}
get rendered() {
return false;
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 = {};
constructor(options = {}) {
this.options = options;
this.position = { left: 0, top: 0 };
}
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() {}
setPosition() {}
};
/**
* GM-only floating control strip showing all connected participants.
* Extends Foundry's Application base class.
* Uses Application (not ApplicationV2) for simplicity in FoundryVTT v14.
* Extends ApplicationV2 via HandlebarsApplicationMixin.
*/
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',
static DEFAULT_OPTIONS = {
id: 'scrying-pool-strip',
classes: ['scrying-pool-strip'],
window: { title: 'Scrying Pool', resizable: false },
position: { width: 240 },
};
static PARTS = {
strip: {
template: 'modules/scrying-pool/templates/roster-strip.hbs',
popOut: true,
resizable: false,
title: 'Scrying Pool',
classes: ['scrying-pool-strip'],
});
}
},
};
/**
* @param {object} stateStore
@@ -112,16 +131,32 @@ export class ScryingPoolStrip extends _AppBase {
* @param {object} [options]
*/
constructor(stateStore, controller, avTileAdapter, adapter, options = {}) {
super(options);
const { portraitFallbackHandler = null, ...appOptions } = options;
super(appOptions);
this._stateStore = stateStore;
this._controller = controller;
this._avTileAdapter = avTileAdapter;
this._adapter = adapter;
this._isExpanded = true;
this._portraitFallbackHandler = portraitFallbackHandler;
/** @type {ActionPopover|null} */
this._activePopover = null;
/** @type {StripOverlayLayer|null} */
this._stripOverlayLayer = null;
// Load saved position from user flags
this._loadPosition();
}
/** Loads saved window position from GM user flag. */
_loadPosition() {
try {
const saved = game.user?.getFlag?.('scrying-pool', 'stripState');
if (saved?.left != null && saved?.top != null) {
if (this.options?.position) {
Object.assign(this.options.position, { left: saved.left, top: saved.top });
}
}
} catch (_e) { /* no-op in test environment */ }
}
/**
@@ -137,15 +172,7 @@ export class ScryingPoolStrip extends _AppBase {
}
/** @inheritdoc */
getData() {
const savedState =
typeof game !== 'undefined'
? game.user?.getFlag?.('scrying-pool', 'stripState')
: null;
if (savedState?.expanded !== undefined) {
this._isExpanded = savedState.expanded;
}
async _prepareContext(options) {
const showFirstOpenTip =
typeof game !== 'undefined' &&
!game.user?.getFlag?.('scrying-pool', 'firstStripOpen');
@@ -169,22 +196,43 @@ export class ScryingPoolStrip extends _AppBase {
this._stateStore,
this._controller,
this._adapter,
hasStreamAccess
hasStreamAccess,
this._portraitFallbackHandler
);
// Remove hidden participants from the strip — they are managed via the Directors Board.
const visibleParticipants = participants.filter(p => p.state !== 'hidden');
// Dock layout: world setting gives direction+canonical size; client override only applies if user toggled
const rawLayout = this._adapter.settings?.get?.('dockLayout');
const baseLayout = typeof rawLayout === 'string' ? rawLayout : 'vertical-sm';
const sizeOverride = this._adapter.settings?.get?.('dockLayoutExpanded'); // '' | 'sm' | 'md'
const parts = baseLayout.split('-');
const dir = parts.slice(0, -1).join('-');
const canonicalSize = parts[parts.length - 1]; // size from world setting (GM's choice)
const effectiveSize = (sizeOverride === 'sm' || sizeOverride === 'md') ? sizeOverride : canonicalSize;
const dockLayout = `${dir}-${effectiveSize}`;
const isExpanded = dockLayout === 'vertical-md';
const showName = dockLayout.endsWith('-md');
const isGM = this._adapter.users.isGM?.() ?? false;
return {
participants,
isExpanded: this._isExpanded,
isEmpty: participants.length === 0,
showFirstOpenTip,
participants: visibleParticipants,
isExpanded,
showName,
dockLayout,
isEmpty: visibleParticipants.length === 0,
showFirstOpenTip: showFirstOpenTip && isGM,
hasStreamAccess,
isGM,
};
}
/** @inheritdoc */
activateListeners(html) {
super.activateListeners(html);
const el = html instanceof HTMLElement ? html : html?.[0];
_onRender(context, options) {
super._onRender?.(context, options);
const el = this.element;
if (!el) return;
el.querySelectorAll('[data-action="open-popover"]').forEach(btn => {
@@ -259,11 +307,70 @@ export class ScryingPoolStrip extends _AppBase {
this._attachVideoStreams(el);
}
// Sync the outer Application window width with the expanded/collapsed state.
// The LESS max-width only applies to the inner template div (.scrying-pool.scrying-pool-strip);
// the outer window must be explicitly resized so it doesn't clip the expanded content.
// Sync the outer Application window width with the selected dock layout.
if (typeof this.setPosition === 'function') {
this.setPosition({ width: this._isExpanded ? 240 : 44, height: 'auto' });
const layout = context?.dockLayout ?? 'vertical-sm';
const n = context?.participants?.length ?? 0;
const width = this._computeStripWidth(layout, n);
const height = this._computeStripHeight(layout, n);
this.setPosition(height === 'auto' ? { width, height: 'auto' } : { width, height });
}
}
/**
* Computes the strip window width for a given dock layout and participant count.
* @param {string} layout - The dock layout key.
* @param {number} participantCount - Number of visible participants.
* @returns {number} Width in pixels.
*/
_computeStripWidth(layout, participantCount) {
const n = Math.max(1, participantCount);
const GAP = 4, PAD = 8, BORDER_W = 3; // 2px real border + 1px safety
const maxCols = 4;
const rowWidth = (tileSize, cols) =>
cols * tileSize + Math.max(0, cols - 1) * GAP + PAD + BORDER_W;
switch (layout) {
case 'vertical-sm': return 85; // 83px tile + 2px border
case 'vertical-md': return 242; // 240px strip + 2px border
case 'horizontal-sm': return rowWidth(83, Math.min(maxCols, n));
case 'horizontal-md': return rowWidth(150, Math.min(maxCols, n));
case 'mosaic-sm': return rowWidth(83, Math.min(maxCols, Math.ceil(Math.sqrt(n))));
case 'mosaic-md': return rowWidth(150, Math.min(maxCols, Math.ceil(Math.sqrt(n))));
default: return 85;
}
}
/**
* Computes the strip window height for horizontal and mosaic layouts so the
* window snaps exactly to its content (grip + toolbar + tile grid).
* Returns 'auto' for vertical layouts where CSS handles the height naturally.
*
* Measurements from CSS:
* .sp-strip__grip → 16px
* .sp-strip__toolbar → 28px
* .sp-strip__participants padding: 4px each side → 8px
* tile gap: 4px
*
* @param {string} layout - The dock layout key.
* @param {number} participantCount - Number of visible participants.
* @returns {number|'auto'} Height in pixels, or 'auto' for vertical layouts.
*/
_computeStripHeight(layout, participantCount) {
const n = Math.max(1, participantCount);
const CHROME = 16 + 29; // grip 16px + toolbar 28px content + 1px border-bottom
const BORDER_H = 2; // 1px border top + 1px border bottom on app element
const GAP = 4, TILE_PAD = 8; // 4px padding each side in .sp-strip__participants
const maxCols = 4;
const gridHeight = (tileSize, cols) => {
const rows = Math.ceil(n / cols);
return rows * tileSize + Math.max(0, rows - 1) * GAP + TILE_PAD;
};
switch (layout) {
case 'horizontal-sm': return CHROME + gridHeight(83, Math.min(maxCols, n)) + BORDER_H;
case 'horizontal-md': return CHROME + gridHeight(150, Math.min(maxCols, n)) + BORDER_H;
case 'mosaic-sm': return CHROME + gridHeight(83, Math.min(maxCols, Math.ceil(Math.sqrt(n)))) + BORDER_H;
case 'mosaic-md': return CHROME + gridHeight(150, Math.min(maxCols, Math.ceil(Math.sqrt(n)))) + BORDER_H;
default: return 'auto';
}
}
@@ -282,7 +389,6 @@ export class ScryingPoolStrip extends _AppBase {
left: this.position?.left,
top: this.position?.top,
open: false,
expanded: this._isExpanded,
});
}
return super.close(options);
@@ -307,19 +413,21 @@ export class ScryingPoolStrip extends _AppBase {
}
/**
* Toggles the expanded/collapsed state of the strip.
* Toggles between small and large size for the current dock layout direction.
* Writes to the client-scoped dockLayoutExpanded setting ('' | 'sm' | 'md') so
* players can toggle without needing world-setting write permission.
* '' means no personal override — follow the GM's world setting.
*/
_toggleExpanded() {
this._isExpanded = !this._isExpanded;
if (typeof game !== 'undefined') {
game.user?.setFlag?.('scrying-pool', 'stripState', {
left: this.position?.left,
top: this.position?.top,
open: true,
expanded: this._isExpanded,
});
}
this.render(true);
const raw = this._adapter.settings?.get?.('dockLayout') ?? 'vertical-sm';
const canonicalSize = typeof raw === 'string' ? raw.split('-').pop() : 'sm';
const sizeOverride = this._adapter.settings?.get?.('dockLayoutExpanded'); // '' | 'sm' | 'md'
const currentSize = (sizeOverride === 'sm' || sizeOverride === 'md') ? sizeOverride : canonicalSize;
const newSize = currentSize === 'md' ? 'sm' : 'md';
this._adapter.settings?.set?.('dockLayoutExpanded', newSize).catch(err => {
console.error('[ScryingPool] Failed to toggle dock layout:', err);
});
// Strip re-renders via the onChange callback registered in module.js
}
/**
@@ -509,7 +617,7 @@ export class ScryingPoolStrip extends _AppBase {
// Re-render to ensure DOM is up to date
if (typeof this.render === 'function') {
this.render(false);
this.render({ force: false });
}
}
}