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