Add re-order, spotlight/focus, and auto-position-snapshots features

- HTML5 drag-and-drop reordering of strip participants (per-GM flag)
- Shift+click toggles spotlight focus on a participant (gold ring indicator)
- Escape exits focus mode
- Auto-save strip position on drag end + every 30s with viewport validation
- Reset strip position button in Director's Board
- French locale strings for reset button
This commit is contained in:
2026-05-27 11:44:24 +02:00
parent 816b7951fb
commit 9e80c2c028
44 changed files with 1770 additions and 11 deletions
+31 -2
View File
@@ -382,13 +382,29 @@ export class DirectorsBoard extends _AppBase {
// Defaults match the settings registration in module.js
const widgetWidthSm = this._adapter.settings?.get?.('widgetWidthSm') ?? '83';
const widgetWidthMd = this._adapter.settings?.get?.('widgetWidthMd') ?? '150';
const WIDTH_OPTIONS = [
const SM_WIDTH_OPTIONS = [
{ value: '60', label: '60px' },
{ value: '70', label: '70px' },
{ value: '80', label: '80px' },
{ value: '90', label: '90px' },
{ value: '100', label: '100px' },
{ value: '120', label: '120px' },
{ value: '140', label: '140px' },
{ value: '160', label: '160px' },
{ value: '180', label: '180px' },
{ value: '200', label: '200px' },
];
const MD_WIDTH_OPTIONS = [
{ value: '60', label: '60px' },
{ value: '80', label: '80px' },
{ value: '100', label: '100px' },
{ value: '120', label: '120px' },
{ value: '150', label: '150px' },
{ value: '200', label: '200px' },
{ value: '250', label: '250px' },
{ value: '300', label: '300px' },
{ value: '350', label: '350px' },
{ value: '400', label: '400px' },
];
// Tile shape selector
@@ -433,7 +449,8 @@ export class DirectorsBoard extends _AppBase {
tileBorderColor: currentTileBorderColor,
tileBorderWidths: TILE_BORDER_WIDTHS,
// Story 5.2: Video widget width customization
widthOptions: WIDTH_OPTIONS,
smWidthOptions: SM_WIDTH_OPTIONS,
mdWidthOptions: MD_WIDTH_OPTIONS,
widgetWidthSm,
widgetWidthMd,
};
@@ -496,6 +513,7 @@ export class DirectorsBoard extends _AppBase {
case 'set-dock-layout': this._onSetDockLayout(btn.dataset.layout); break;
case 'set-tile-shape': this._onSetTileShape(btn.dataset.shape); break;
case 'set-tile-border-width': this._onSetTileBorderWidth(parseInt(btn.dataset.width, 10)); break;
case 'reset-strip-position': this._onResetStripPosition(); break;
case 'close': this.close(); break;
}
};
@@ -840,6 +858,17 @@ export class DirectorsBoard extends _AppBase {
if (this.rendered) this.render({ force: true });
}
/**
* Clears the saved strip position flag so the strip defaults to its original position.
*/
_onResetStripPosition() {
try {
game.user?.setFlag?.('scrying-pool', 'stripState', null);
} catch (err) {
console.error('[ScryingPool] Failed to reset strip position:', err);
}
}
/**
* Opens the PresetSaveDialog for saving the current visibility matrix as a preset.
*/
+238 -3
View File
@@ -149,9 +149,26 @@ export class ScryingPoolStrip extends _AppBase {
this._healthCheckInterval = null;
/** @type {number|string|null} */
this._userConnectedHookId = null;
/** @type {string|null} */
this._focusedUserId = null;
/** @type {number|null} */
this._positionSaveTimer = null;
// Load saved position from user flags
this._loadPosition();
/** Bound keydown handler for Escape-to-exit-focus */
this._onDocumentKeydown = (e) => {
if (e.key === 'Escape' && this._focusedUserId) {
this._focusedUserId = null;
if (typeof this.render === 'function') {
this.render({ force: false });
}
}
};
if (typeof document?.addEventListener === 'function') {
document.addEventListener('keydown', this._onDocumentKeydown);
}
}
/** Loads saved window position from GM user flag. */
@@ -159,8 +176,13 @@ export class ScryingPoolStrip extends _AppBase {
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 });
if (saved.left < 0 || saved.top < 0) return;
const screenW = typeof window !== 'undefined' ? (window.screen?.availWidth ?? Infinity) : Infinity;
const screenH = typeof window !== 'undefined' ? (window.screen?.availHeight ?? Infinity) : Infinity;
if (saved.left < screenW - 50 && saved.top < screenH - 50) {
if (this.options?.position) {
Object.assign(this.options.position, { left: saved.left, top: saved.top });
}
}
}
} catch (_e) { /* no-op in test environment */ }
@@ -195,6 +217,22 @@ export class ScryingPoolStrip extends _AppBase {
? userIds
: userIds.filter(id => id !== currentUserId);
// Re-order participants per GM's saved order (drag-and-drop)
try {
const savedOrder = game.user?.getFlag?.('scrying-pool', 'participantOrder');
if (Array.isArray(savedOrder) && savedOrder.length > 0) {
const orderMap = new Map(savedOrder.map((id, idx) => [id, idx]));
filteredUserIds.sort((a, b) => {
const ai = orderMap.get(a);
const bi = orderMap.get(b);
if (ai !== undefined && bi !== undefined) return ai - bi;
if (ai !== undefined) return -1;
if (bi !== undefined) return 1;
return 0;
});
}
} catch (_e) { /* no-op in test environment */ }
// Check if we have stream access for video replacement (full AV replacement mode)
const hasStreamAccess = this._adapter.webrtc?.getMediaStreamForUser !== undefined;
@@ -207,8 +245,16 @@ export class ScryingPoolStrip extends _AppBase {
this._portraitFallbackHandler
);
// Mark focused participant for gold ring visual
participants.forEach(p => { p.isFocused = this._focusedUserId === p.userId; });
// Remove hidden participants from the strip — they are managed via the Directors Board.
const visibleParticipants = participants.filter(p => p.state !== 'hidden');
let visibleParticipants = participants.filter(p => p.state !== 'hidden');
// Spotlight: if a participant is focused, only show that one
if (this._focusedUserId) {
visibleParticipants = visibleParticipants.filter(p => p.userId === this._focusedUserId);
}
// Dock layout: world setting gives direction+canonical size; client override only applies if user toggled
const rawLayout = this._adapter.settings?.get?.('dockLayout');
@@ -266,6 +312,11 @@ export class ScryingPoolStrip extends _AppBase {
el.querySelectorAll('[data-action="open-popover"]').forEach(btn => {
const userId = btn.dataset.userId;
btn.addEventListener('click', e => {
if (e.shiftKey) {
e.stopPropagation();
this._toggleFocus(userId);
return;
}
e.stopPropagation();
this._openPopover(userId, btn);
});
@@ -276,6 +327,33 @@ export class ScryingPoolStrip extends _AppBase {
});
});
// Drag-and-drop reordering of participants
// Remove old DnD listeners first to prevent duplicates on re-render
el.querySelectorAll('.sp-strip__participant-item').forEach(item => {
const existing = item._dndHandlers;
if (existing) {
item.removeEventListener('dragstart', existing.dragstart);
item.removeEventListener('dragover', existing.dragover);
item.removeEventListener('drop', existing.drop);
item.removeEventListener('dragend', existing.dragend);
item.removeEventListener('dragleave', existing.dragleave);
}
const handlers = {
dragstart: e => this._onDragStart(e),
dragover: e => this._onDragOver(e),
drop: e => this._onDrop(e),
dragend: e => this._onDragEnd(e),
dragleave: e => this._onDragLeave(e),
};
item._dndHandlers = handlers;
item.draggable = true;
item.addEventListener('dragstart', handlers.dragstart);
item.addEventListener('dragover', handlers.dragover);
item.addEventListener('drop', handlers.drop);
item.addEventListener('dragend', handlers.dragend);
item.addEventListener('dragleave', handlers.dragleave);
});
const toggle = el.querySelector('[data-action="toggle-expanded"]');
if (toggle) {
toggle.addEventListener('click', () => this._toggleExpanded());
@@ -298,8 +376,13 @@ export class ScryingPoolStrip extends _AppBase {
}
// Drag grip — custom drag implementation (Foundry v14 ApplicationV1 does not expose its drag handler)
// Double-click grip resets participant order to connection order
const grip = el.querySelector('[data-action="drag-grip"]');
if (grip) {
grip.addEventListener('dblclick', e => {
e.preventDefault();
this._resetParticipantOrder();
});
grip.addEventListener('mousedown', e => {
if (e.button !== 0) return;
e.preventDefault();
@@ -316,6 +399,7 @@ export class ScryingPoolStrip extends _AppBase {
const onUp = () => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
this._savePosition();
};
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
@@ -445,6 +529,11 @@ export class ScryingPoolStrip extends _AppBase {
this._activePopover = null;
}
// Remove document-level keydown listener
if (typeof document?.removeEventListener === 'function') {
document.removeEventListener('keydown', this._onDocumentKeydown);
}
// Tear down stream monitoring
this._teardownStreamMonitoring();
@@ -708,6 +797,9 @@ export class ScryingPoolStrip extends _AppBase {
// Periodic health check every 15 seconds
this._healthCheckInterval = setInterval(() => this._checkVideoStreamHealth(), 15000);
// Auto-save strip position every 30 seconds
this._positionSaveTimer = setInterval(() => this._savePosition(), 30000);
// Watch for user connection changes to refresh streams
if (typeof Hooks !== 'undefined') {
this._userConnectedHookId = Hooks.on('userConnected', (userId, connected) => {
@@ -728,6 +820,12 @@ export class ScryingPoolStrip extends _AppBase {
this._healthCheckInterval = null;
}
// Clear position save timer
if (this._positionSaveTimer !== null) {
clearInterval(this._positionSaveTimer);
this._positionSaveTimer = null;
}
// Remove userConnected hook
if (this._userConnectedHookId !== null && typeof Hooks !== 'undefined') {
Hooks.off('userConnected', this._userConnectedHookId);
@@ -905,6 +1003,143 @@ export class ScryingPoolStrip extends _AppBase {
});
}
// ── Re-order participants (Drag & Drop) ─────────────────────────────────
/**
* Handles drag start on a participant item.
* @param {DragEvent} e
*/
_onDragStart(e) {
const item = e.target.closest('.sp-strip__participant-item');
if (!item) return;
const userId = item.querySelector('[data-user-id]')?.dataset?.userId;
if (!userId) return;
e.dataTransfer.setData('text/plain', userId);
e.dataTransfer.effectAllowed = 'move';
item.classList.add('sp-dragging');
}
/**
* Handles drag over on a participant item — shows drop indicator.
* @param {DragEvent} e
*/
_onDragOver(e) {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
const item = e.target.closest('.sp-strip__participant-item');
if (!item) return;
item.classList.add('sp-drag-over');
}
/**
* Handles drop — reorders participants and persists the order.
* @param {DragEvent} e
*/
_onDrop(e) {
e.preventDefault();
const fromUserId = e.dataTransfer.getData('text/plain');
const toItem = e.target.closest('.sp-strip__participant-item');
if (!toItem || !fromUserId) return;
const toUserId = toItem.querySelector('[data-user-id]')?.dataset?.userId;
if (!toUserId || fromUserId === toUserId) return;
const el = this.element;
if (!el) return;
const items = [...el.querySelectorAll('.sp-strip__participant-item')];
const userIds = items.map(item => item.querySelector('[data-user-id]')?.dataset?.userId).filter(Boolean);
const fromIdx = userIds.indexOf(fromUserId);
const toIdx = userIds.indexOf(toUserId);
if (fromIdx === -1 || toIdx === -1) return;
userIds.splice(fromIdx, 1);
const adjustedTo = fromIdx < toIdx ? toIdx - 1 : toIdx;
userIds.splice(adjustedTo, 0, fromUserId);
this._saveParticipantOrder(userIds);
if (typeof this.render === 'function') {
this.render({ force: false });
}
}
/**
* Handles drag end — removes visual indicators.
*/
_onDragEnd() {
this.element?.querySelectorAll('.sp-strip__participant-item')?.forEach(item => {
item.classList.remove('sp-dragging', 'sp-drag-over');
});
}
/**
* Handles drag leave — removes drop indicator from the exited item.
* @param {DragEvent} e
*/
_onDragLeave(e) {
const item = e.target.closest('.sp-strip__participant-item');
if (item) item.classList.remove('sp-drag-over');
}
/**
* Persists participant order to GM user flag.
* @param {string[]} orderedUserIds
*/
_saveParticipantOrder(orderedUserIds) {
if (typeof game === 'undefined') return;
try {
game.user?.setFlag?.('scrying-pool', 'participantOrder', orderedUserIds);
} catch (_e) { /* no-op */ }
}
/**
* Clears saved order — next render uses connection order.
*/
_resetParticipantOrder() {
if (typeof game === 'undefined') return;
try {
game.user?.unsetFlag?.('scrying-pool', 'participantOrder');
} catch (_e) { /* no-op */ }
if (typeof this.render === 'function') {
this.render({ force: false });
}
}
// ── Spotlight / Focus ────────────────────────────────────────────────────
/**
* Toggles focus on a participant. If already focused, exits focus mode.
* @param {string} userId
*/
_toggleFocus(userId) {
if (this._focusedUserId === userId) {
this._focusedUserId = null;
} else {
this._focusedUserId = userId;
}
if (typeof this.render === 'function') {
this.render({ force: false });
}
}
// ── Auto-save position ───────────────────────────────────────────────────
/**
* Saves current strip position to GM user flag.
*/
_savePosition() {
if (typeof game === 'undefined') return;
try {
const pos = this.position;
if (pos?.left == null || pos?.top == null) return;
game.user?.setFlag?.('scrying-pool', 'stripState', {
left: pos.left,
top: pos.top,
width: pos.width ?? 240,
height: pos.height ?? 'auto',
savedAt: Date.now(),
});
} catch (_e) { /* no-op */ }
}
/**
* Shows a notification with i18n support and safe fallback for test environments.
* @param {'info'|'warn'|'error'} level