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