Files
MajoorWaldi-ComfyUI-Majoor-…/ui/features/viewer/floatingViewerManager.ts
T

970 lines
34 KiB
TypeScript

/**
* floatingViewerManager - singleton controller for the Majoor Floating Viewer (MFV).
*
* Responsibilities:
* - Instantiate/reuse the FloatingViewer DOM element (lazy).
* - Expose install/remove helpers for MFV global events; Vue now owns when
* those handlers are active.
* - On open: immediately load the currently-selected assets from the grid.
* - Subscribe to window "mjr:selection-changed" and update the viewer when open.
* - Expose `upsertWithContent(fileData)` for LiveStreamTracker.
*/
import { EVENTS } from "../../app/events.js";
import { APP_CONFIG } from "../../app/config.js";
import { getAssetsBatch } from "../../api/client.js";
import { observeHostCanvasSelection } from "../../app/hostAdapter.js";
import { getActiveGridContainer } from "../panel/panelRuntimeRefs.js";
import { getSelectedIdSet } from "../grid/GridSelectionManager.js";
import { getHotkeysState, isHotkeysSuspended } from "../panel/controllers/hotkeysState.js";
import { reportError } from "../../utils/logging.js";
import { NODE_STREAM_FEATURE_ENABLED } from "./nodeStream/nodeStreamFeatureFlag.js";
import { appendFloatingViewerNode } from "./viewerRuntimeHosts.js";
// Lazy-loaded modules - loaded on first use to avoid blocking startup.
/** @type {typeof import("./FloatingViewer.js").FloatingViewer | null} */
let _FloatingViewerClass: any = null;
let _floatingViewerLoadPromise: any = null;
async function _loadFloatingViewer() {
if (_FloatingViewerClass) return _FloatingViewerClass;
if (!_floatingViewerLoadPromise) {
_floatingViewerLoadPromise = import("./FloatingViewer.js").then((m: any) => {
_FloatingViewerClass = m.FloatingViewer;
return _FloatingViewerClass;
});
}
return _floatingViewerLoadPromise;
}
/** @type {((active: boolean) => void) | null} */
let _setControllerNodeStreamActive: any = null;
let _nodeStreamModPromise: any = null;
async function _loadNodeStreamController() {
if (_setControllerNodeStreamActive) return;
if (!_nodeStreamModPromise) {
_nodeStreamModPromise = import("./nodeStream/NodeStreamController.js").then((m: any) => {
_setControllerNodeStreamActive = m.setNodeStreamActive;
});
}
return _nodeStreamModPromise;
}
// Inline MFV mode constants (avoids eager import of FloatingViewer.js).
const MFV_MODES = Object.freeze({
SIMPLE: "simple",
AB: "ab",
SIDE: "side",
GRID: "grid",
GRAPH: "graph",
});
function _normalizeRequestedMode(mode: any) {
const normalized = String(mode || "")
.trim()
.toLowerCase();
if (normalized === "sidebyside") return MFV_MODES.SIDE;
if (Object.values(MFV_MODES).includes(normalized as any)) return normalized as any;
return "";
}
// -- Module state --------------------------------------------------------------
/** @type {FloatingViewer | null} */
let _instance: any = null;
function _getDefaultLiveActive() {
return APP_CONFIG.MFV_LIVE_DEFAULT !== false;
}
function _getDefaultPreviewActive() {
return APP_CONFIG.MFV_PREVIEW_DEFAULT !== false;
}
let _liveActive = _getDefaultLiveActive();
let _previewActive = _getDefaultPreviewActive();
let _nodeStreamActive = false;
let _nodeStreamSelectionState: any = null;
let _selectionListenerBound = false;
let _fetchAC: any = null; // AbortController for the latest in-flight batch fetch
let _loadSeq = 0; // Sequence counter to discard stale _loadFromIds responses
function _syncToggleDefaultsFromConfig() {
_liveActive = _getDefaultLiveActive();
_previewActive = _getDefaultPreviewActive();
_instance?.setLiveActive(_liveActive);
_instance?.setPreviewActive(_previewActive);
}
// -- Internal helpers ----------------------------------------------------------
async function _getInstance() {
if (!_instance) {
const FV = await _loadFloatingViewer();
if (!_instance) {
// re-check after await
_instance = new FV({
controller: {
close: () => floatingViewerManager.close(),
toggle: () => floatingViewerManager.toggle(),
toggleLive: () => floatingViewerManager.toggleLive(),
togglePreview: () => floatingViewerManager.togglePreview(),
toggleNodeStream: () => floatingViewerManager.toggleNodeStream(),
popOut: () => floatingViewerManager.popOut(),
onModeChanged: (mode: any) => {
if (!_instance?.isVisible) return;
if (mode === MFV_MODES.SIMPLE) return;
_syncCurrentGridSelection();
},
handleForwardedKeydown: (event: any) => _onGlobalKeydown(event),
},
});
appendFloatingViewerNode(_instance.render());
}
}
try {
const node = _instance?.element || null;
if (node?.isConnected === false) {
appendFloatingViewerNode(node);
}
} catch (e: any) {
console.debug?.(e);
}
return _instance;
}
function _cancelFetch() {
try {
_fetchAC?.abort();
} catch (e: any) {
console.debug?.(e);
}
_fetchAC = null;
}
function _getSelectionSourceGrid() {
try {
const lastGrid = (window as any).__MJR_LAST_SELECTION_GRID__;
if (lastGrid?.isConnected) return lastGrid;
} catch (e: any) {
console.debug?.(e);
}
return getActiveGridContainer();
}
function _disposeInstance() {
if (!_instance) return;
try {
_instance.dispose?.();
} catch (e: any) {
console.debug?.(e);
}
_instance = null;
}
function _emitVisibilityChanged(visible: any) {
if (typeof window === "undefined") return;
window.dispatchEvent(
new CustomEvent(EVENTS.MFV_VISIBILITY_CHANGED, {
detail: { visible: Boolean(visible) },
}),
);
}
function _syncViewerControls(inst: any) {
if (!inst) return;
inst.setLiveActive(_liveActive);
inst.setPreviewActive(_previewActive);
inst.setNodeStreamActive?.(NODE_STREAM_FEATURE_ENABLED ? _nodeStreamActive : false);
}
function _applyNodeStreamSelection(inst: any) {
if (!inst?.setNodeStreamSelection) return;
inst.setNodeStreamSelection(_nodeStreamSelectionState || null);
}
function _streamAssetIntoCurrentViewerSlots(inst: any, fileData: any) {
if (!inst) return;
const mode = inst._mode;
const inCompare = mode === MFV_MODES.AB || mode === MFV_MODES.SIDE || mode === MFV_MODES.GRID;
if (!inCompare) {
inst.loadMediaA(fileData, { autoMode: true });
return;
}
const pins = inst.getPinnedSlots();
if (mode === MFV_MODES.GRID) {
const freeSlot = ["A", "B", "C", "D"].find((slot: any) => !pins.has(slot));
if (!freeSlot) return;
const slotMedia = {
A: inst._mediaA,
B: inst._mediaB,
C: inst._mediaC,
D: inst._mediaD,
};
(slotMedia as Record<string, any>)[freeSlot] = fileData;
inst.loadMediaQuad(slotMedia.A, slotMedia.B, slotMedia.C, slotMedia.D);
return;
}
const pinA = pins.has("A");
const pinB = pins.has("B");
// Both slots pinned -> no free slot, regardless of whether media has loaded yet.
if (pinA && pinB) return;
if (pinB) {
inst.loadMediaPair(fileData, inst._mediaB);
return;
}
inst.loadMediaPair(inst._mediaA, fileData);
}
/**
* When only 1 asset is selected and the MFV is in a compare mode,
* look up the adjacent card in the rendered grid DOM to use as slot B.
* This provides a "compare with next" fallback without requiring Ctrl+click.
* @param {string} selectedId
* @returns {string | null}
*/
function _findAdjacentGridId(selectedId: any) {
try {
const grid = _getSelectionSourceGrid();
if (!grid) return null;
const cards = Array.from(grid.querySelectorAll("[data-mjr-asset-id]")) as HTMLElement[];
const idx = cards.findIndex((c: any) => c.dataset.mjrAssetId === String(selectedId));
if (idx < 0) return null;
// Prefer the next card; fall back to the previous one
const adjacent = cards[idx + 1] ?? cards[idx - 1] ?? null;
const adjId = adjacent?.dataset?.mjrAssetId ?? null;
return adjId && adjId !== String(selectedId) ? adjId : null;
} catch (e: any) {
console.debug?.("[MFV] _findAdjacentGridId error", e);
return null;
}
}
/**
* Fetch up to 4 assets by ID and load them into the viewer.
* In grid mode, up to 4 are loaded; in compare modes up to 2.
* If only 1 ID is provided and the viewer is in a compare mode,
* the adjacent grid asset is automatically used as slot B.
* @param {string[]} selectedIds
*/
async function _loadFromIds(selectedIds: any) {
if (!selectedIds.length || !_instance) return;
_cancelFetch();
const seq = ++_loadSeq;
const ac = typeof AbortController !== "undefined" ? new AbortController() : null;
_fetchAC = ac;
try {
const pins = _instance.getPinnedSlots();
const hasPins = pins.size > 0;
const mode = _instance._mode;
const isGrid = mode === MFV_MODES.GRID;
const isCompare = mode === MFV_MODES.AB || mode === MFV_MODES.SIDE;
const maxSlots = isGrid ? 4 : 2;
let ids = selectedIds.slice(0, maxSlots);
if (hasPins && (isCompare || isGrid)) {
// With pinned slots, only load new assets into non-pinned slots.
const freeCount = maxSlots - pins.size;
ids = ids.slice(0, Math.max(1, freeCount));
} else if (ids.length === 1 && isCompare) {
// Compare-mode fallback: if only 1 asset, auto-pick the adjacent grid item for slot B.
const adjId = _findAdjacentGridId(ids[0]);
if (adjId) ids = [ids[0], adjId];
}
const result = await getAssetsBatch(ids, ac ? { signal: ac.signal } : {});
if (ac?.signal.aborted) return;
if (_loadSeq !== seq) return; // stale - a newer _loadFromIds call was made
if (!result?.ok || !Array.isArray(result.data) || !result.data.length) return;
if (!_instance) return; // disposed while fetching
const assets = result.data;
// Grid mode: load up to 4 assets
if (isGrid) {
if (hasPins) {
// Keep all pinned slots, fill others from new assets
const slotMedia = {
A: _instance._mediaA,
B: _instance._mediaB,
C: _instance._mediaC,
D: _instance._mediaD,
};
const freeSlots = ["A", "B", "C", "D"].filter((s: any) => !pins.has(s));
let ai = 0;
for (const slot of freeSlots) {
if (ai < assets.length) (slotMedia as Record<string, any>)[slot] = assets[ai++];
}
_instance.loadMediaQuad(slotMedia.A, slotMedia.B, slotMedia.C, slotMedia.D);
} else if (assets.length >= 3) {
_instance.loadMediaQuad(assets[0], assets[1], assets[2], assets[3] || null);
} else if (assets.length >= 2) {
_instance.loadMediaPair(assets[0], assets[1]);
} else {
_instance.loadMediaA(assets[0], { autoMode: true });
}
return;
}
// AB / Side / Simple modes
if (pins.has("A") && pins.has("B") && _instance._mediaA && _instance._mediaB) {
// Both pinned - nothing to replace
return;
} else if (pins.has("A") && _instance._mediaA) {
_instance.loadMediaPair(_instance._mediaA, assets[0]);
} else if (pins.has("B") && _instance._mediaB) {
_instance.loadMediaPair(assets[0], _instance._mediaB);
} else if (ids.length >= 2 && assets.length >= 2) {
_instance.loadMediaPair(assets[0], assets[1]);
} else {
_instance.loadMediaA(assets[0], { autoMode: true });
}
} catch (e: any) {
if (e?.name !== "AbortError") {
reportError(e, "floatingViewerManager._loadFromIds");
}
} finally {
if (_fetchAC === ac) _fetchAC = null;
}
}
/**
* Read the current grid selection and immediately populate the viewer.
* Called when the MFV opens so the user sees content right away.
*/
function _syncCurrentGridSelection() {
try {
const grid = _getSelectionSourceGrid();
if (!grid) return;
const selected = getSelectedIdSet(grid);
if (!selected.size) return;
void _loadFromIds(Array.from(selected));
} catch (e: any) {
console.debug?.("[MFV] Error reading current grid selection", e);
}
}
// -- Selection listener (active while MFV is visible) -------------------------
function _onSelectionChanged(e: any) {
if (!_instance?.isVisible) return;
if (_instance._mode === MFV_MODES.GRAPH) return;
// Filter out folder cards - they have no previewable media.
const selectedAssets = Array.isArray(e?.detail?.selectedAssets) ? e.detail.selectedAssets : [];
const folderIds = new Set(
selectedAssets
.filter((a: any) => String(a?.kind || "").toLowerCase() === "folder")
.map((a: any) => String(a?.id || ""))
.filter(Boolean),
);
const selectedIds = Array.isArray(e?.detail?.selectedIds)
? e.detail.selectedIds.map(String).filter((id: any) => Boolean(id) && !folderIds.has(id))
: [];
if (selectedIds.length) {
void _loadFromIds(selectedIds);
return;
}
// Fallback: if payload is missing/empty, read latest selection directly from grid dataset.
try {
const grid = _getSelectionSourceGrid();
if (!grid) return;
const ids = Array.from(getSelectedIdSet(grid)).map(String).filter(Boolean);
if (!ids.length) return;
void _loadFromIds(ids);
} catch (err: any) {
console.debug?.("[MFV] selection fallback failed", err);
}
}
function _bindSelectionListener() {
if (_selectionListenerBound) return;
if (typeof window === "undefined") return;
window.addEventListener(EVENTS.SELECTION_CHANGED, _onSelectionChanged);
_selectionListenerBound = true;
}
function _unbindSelectionListener() {
if (typeof window !== "undefined") {
window.removeEventListener(EVENTS.SELECTION_CHANGED, _onSelectionChanged);
}
_selectionListenerBound = false;
_cancelFetch();
}
// -- Node selection hooks (canvas -> sidebar sync) -----------------------------
let _nodeSelectionBound = false;
let _disposeNodeSelectionBinding: any = null;
let _sidebarRefreshTimer: any = null;
let _sidebarRefreshTimerKind = "";
function _onCanvasNodeSelection() {
// Refresh the sidebar when canvas node selection changes
if (_instance?.isVisible) _instance.refreshSidebar?.();
}
// Debounced version for DOM-level fallback - avoids double-refresh when
// LiteGraph callbacks already fired for the same interaction.
function _scheduleCanvasNodeSelection() {
_clearScheduledCanvasNodeSelection();
const host = typeof window !== "undefined" ? window : globalThis;
if (typeof host.requestAnimationFrame === "function") {
_sidebarRefreshTimerKind = "raf";
_sidebarRefreshTimer = host.requestAnimationFrame(() => {
_sidebarRefreshTimer = null;
_sidebarRefreshTimerKind = "";
_onCanvasNodeSelection();
});
return;
}
_sidebarRefreshTimerKind = "timeout";
_sidebarRefreshTimer = host.setTimeout(() => {
_sidebarRefreshTimer = null;
_sidebarRefreshTimerKind = "";
_onCanvasNodeSelection();
}, 16);
}
function _clearScheduledCanvasNodeSelection() {
if (_sidebarRefreshTimer == null) return;
const host = typeof window !== "undefined" ? window : globalThis;
try {
if (_sidebarRefreshTimerKind === "raf" && typeof host.cancelAnimationFrame === "function") {
host.cancelAnimationFrame(_sidebarRefreshTimer);
} else if (typeof host.clearTimeout === "function") {
host.clearTimeout(_sidebarRefreshTimer);
}
} catch (e: any) {
console.debug?.(e);
}
_sidebarRefreshTimer = null;
_sidebarRefreshTimerKind = "";
}
function _bindNodeSelectionListener() {
if (_nodeSelectionBound) return;
try {
_disposeNodeSelectionBinding = observeHostCanvasSelection(_scheduleCanvasNodeSelection, {
includePointerFallback: true,
});
_nodeSelectionBound = typeof _disposeNodeSelectionBinding === "function";
} catch (e: any) {
console.debug?.("[MFV] _bindNodeSelectionListener error", e);
}
}
function _unbindNodeSelectionListener() {
if (!_nodeSelectionBound) return;
_clearScheduledCanvasNodeSelection();
try {
_disposeNodeSelectionBinding?.();
} catch (e: any) {
console.debug?.("[MFV] _unbindNodeSelectionListener error", e);
}
_disposeNodeSelectionBinding = null;
_nodeSelectionBound = false;
}
// -- Public API ----------------------------------------------------------------
export const floatingViewerManager = {
isGraphModeVisible() {
return Boolean(_instance?.isVisible && _instance?._mode === MFV_MODES.GRAPH);
},
/**
* Open the MFV with explicit assets instead of syncing from the current grid selection.
* Useful for context-menu actions where the right-clicked asset is the target.
*/
async openAssets({ assets = [] as any[], asset = null as any, index = 0, mode = "" } = {}) {
const list = Array.isArray(assets) ? assets.filter(Boolean) : asset ? [asset] : [];
if (!list.length) return false;
const inst = await _getInstance();
const wasVisible = Boolean(inst.isVisible);
const safeIndex = Math.max(0, Math.min(Number(index) || 0, list.length - 1));
const requestedMode = _normalizeRequestedMode(mode);
if (requestedMode) inst.setMode(requestedMode);
inst.show();
_syncViewerControls(inst);
_bindSelectionListener();
_bindNodeSelectionListener();
const currentMode = inst._mode;
if (currentMode === MFV_MODES.GRID && list.length >= 3) {
inst.loadMediaQuad(list[0], list[1], list[2], list[3] || null);
} else if (
(currentMode === MFV_MODES.AB || currentMode === MFV_MODES.SIDE) &&
list.length >= 2
) {
inst.loadMediaPair(list[0], list[1]);
} else {
inst.loadMediaA(list[safeIndex], { autoMode: false });
}
if (!wasVisible) _emitVisibilityChanged(true);
return true;
},
async open() {
const inst = await _getInstance();
inst.show();
_syncViewerControls(inst);
_bindSelectionListener();
_bindNodeSelectionListener();
// If canvas wasn't ready yet (ComfyUI still loading), retry once
// via rAF so the node selection listener is installed as soon as possible.
if (!_nodeSelectionBound) {
requestAnimationFrame(() => _bindNodeSelectionListener());
}
// KEY FIX: immediately show whatever is selected in the grid.
_syncCurrentGridSelection();
// Restore node stream overlay if a node was already selected before opening.
if (NODE_STREAM_FEATURE_ENABLED && _nodeStreamActive) {
_applyNodeStreamSelection(inst);
}
_emitVisibilityChanged(true);
},
close() {
if (_instance) {
try {
if (_instance.isPopped) _instance.popIn();
_instance.hide();
} catch (e: any) {
console.debug?.(e);
}
}
_unbindSelectionListener();
_instance?.setNodeStreamSelection?.(null);
_unbindNodeSelectionListener();
_emitVisibilityChanged(false);
},
async toggle() {
if (_instance?.isVisible) {
floatingViewerManager.close();
} else {
await floatingViewerManager.open();
}
},
toggleLive() {
floatingViewerManager.setLiveActive(!_liveActive);
},
togglePreview() {
floatingViewerManager.setPreviewActive(!_previewActive);
},
async toggleCompareAB() {
const inst = await _getInstance();
const wasVisible = Boolean(inst.isVisible);
if (!wasVisible) {
inst.setMode(MFV_MODES.AB);
inst.show();
_syncViewerControls(inst);
_bindSelectionListener();
_syncCurrentGridSelection();
_emitVisibilityChanged(true);
return;
}
// Cycle: AB -> Side -> Grid -> Simple -> AB
const cycle = {
[MFV_MODES.AB]: MFV_MODES.SIDE,
[MFV_MODES.SIDE]: MFV_MODES.SIMPLE,
[MFV_MODES.GRID]: MFV_MODES.SIMPLE,
[MFV_MODES.SIMPLE]: MFV_MODES.AB,
};
const next = (cycle as Record<string, any>)[inst._mode] || MFV_MODES.AB;
inst.setMode(next);
// Re-sync grid selection when entering a multi-asset mode so the viewer
// is populated with the current selection (not stale from a previous mode).
if (next !== MFV_MODES.SIMPLE) {
_syncCurrentGridSelection();
}
},
/**
* Open the MFV (if needed) and load a single file/asset.
* Used by LiveStreamTracker for the live generation feed.
* @param {object} fileData Raw output { filename, subfolder, type } or full asset object.
*/
async upsertWithContent(fileData: any) {
const inst = await _getInstance();
const wasVisible = Boolean(inst.isVisible);
if (!wasVisible && APP_CONFIG.MFV_LIVE_AUTO_OPEN === false) return;
inst.show();
_syncViewerControls(inst);
_bindSelectionListener();
_streamAssetIntoCurrentViewerSlots(inst, fileData);
if (!wasVisible) _emitVisibilityChanged(true);
},
setLiveActive(active: any) {
_liveActive = Boolean(active);
_instance?.setLiveActive(_liveActive);
},
getLiveActive() {
return _liveActive;
},
/**
* Toggle the viewer between the expanded dialog overlay and the floating panel.
* If the viewer isn't open yet, it is opened first so there's something to see.
*/
async popOut() {
const inst = await _getInstance();
if (inst.isPopped) {
inst.popIn();
} else {
// Ensure the viewer is visible and loaded before popping out
if (!inst.isVisible) {
await floatingViewerManager.open();
}
inst.popOut();
}
},
// -- Preview stream (KSampler denoising steps) -------------------------
setPreviewActive(active: any) {
_previewActive = Boolean(active);
_instance?.setPreviewActive(_previewActive);
},
getPreviewActive() {
return _previewActive;
},
canAcceptPreviewBlob() {
if (!_previewActive) return false;
if (_instance?.isVisible) return true;
return APP_CONFIG.MFV_PREVIEW_AUTO_OPEN !== false;
},
/**
* Feed a preview blob from the KSampler WebSocket into the viewer.
* If preview mode is off or the viewer is not visible, the blob is ignored.
* @param {Blob} blob JPEG/PNG Blob from the ComfyUI `b_preview` event.
*/
async feedPreviewBlob(blob: any, opts = {}) {
if (!floatingViewerManager.canAcceptPreviewBlob()) return;
const inst = await _getInstance();
const wasVisible = Boolean(inst.isVisible);
if (!wasVisible && APP_CONFIG.MFV_PREVIEW_AUTO_OPEN === false) return;
if (!inst.isVisible) {
inst.show();
}
_syncViewerControls(inst);
inst.loadPreviewBlob(blob, ...(Object.keys(opts).length ? [opts] : []));
if (!wasVisible) _emitVisibilityChanged(true);
},
// -- Node Stream (intermediate node outputs) ---------------------------
toggleNodeStream() {
if (!NODE_STREAM_FEATURE_ENABLED) return;
floatingViewerManager.setNodeStreamActive(!_nodeStreamActive);
},
setNodeStreamActive(active: any) {
if (!NODE_STREAM_FEATURE_ENABLED) {
void active;
_nodeStreamActive = false;
_nodeStreamSelectionState = null;
if (_setControllerNodeStreamActive) _setControllerNodeStreamActive(false);
_instance?.setNodeStreamActive?.(false);
return;
}
_nodeStreamActive = Boolean(active);
if (!_nodeStreamActive) {
_nodeStreamSelectionState = null;
}
// Lazy-load NodeStreamController then apply state.
void _loadNodeStreamController().then(() => {
if (_setControllerNodeStreamActive) _setControllerNodeStreamActive(_nodeStreamActive);
});
_instance?.setNodeStreamActive?.(_nodeStreamActive);
if (!_nodeStreamActive) {
_instance?.setNodeStreamSelection?.(null);
} else if (_instance) {
_applyNodeStreamSelection(_instance);
}
},
getNodeStreamActive() {
return NODE_STREAM_FEATURE_ENABLED ? _nodeStreamActive : false;
},
/**
* Forward the canvas-selected node info to the viewer overlay.
* Called by NodeStreamController.onStatus when the selection changes.
* Only takes effect when the feature flag is on.
* @param {string} nodeId
* @param {string} classType
* @param {string} [title]
*/
setNodeStreamSelection(nodeId: any, classType: any, title: any) {
if (!NODE_STREAM_FEATURE_ENABLED) return;
_nodeStreamSelectionState =
nodeId == null || nodeId === "" ? null : { nodeId, classType, title };
const inst = _instance;
if (!inst) return;
_applyNodeStreamSelection(inst);
},
/**
* Feed an intermediate node output into the viewer.
* Called by the NodeStreamController when a watched node produces output.
* @param {object} fileData { filename, subfolder, type, kind?, _nodeId?, _classType? }
*/
async feedNodeStream(fileData: any) {
if (!NODE_STREAM_FEATURE_ENABLED) {
void fileData;
return;
}
if (!_nodeStreamActive) return;
const inst = await _getInstance();
const wasVisible = Boolean(inst.isVisible);
if (!wasVisible && APP_CONFIG.MFV_NODE_STREAM_AUTO_OPEN === false) return;
if (!inst.isVisible) {
inst.show();
_bindSelectionListener();
}
_syncViewerControls(inst);
_applyNodeStreamSelection(inst);
_streamAssetIntoCurrentViewerSlots(inst, fileData);
if (!wasVisible) _emitVisibilityChanged(true);
},
};
// -- Global event wiring (NM-3: named references so teardown can remove them) --
// Using named handler functions prevents duplicate listeners from accumulating
// on hot-reload. entry.js calls teardownFloatingViewerManager() in its cleanup
// path before re-registering, mirroring the pattern used for API handlers.
let _globalHandlersInstalled = false;
const _onMfvOpen = () => floatingViewerManager.open();
const _onMfvClose = () => floatingViewerManager.close();
const _onMfvToggle = () => floatingViewerManager.toggle();
const _onMfvLiveToggle = () => floatingViewerManager.toggleLive();
const _onMfvPreviewToggle = () => floatingViewerManager.togglePreview();
const _onMfvNodeStreamToggle = () => floatingViewerManager.toggleNodeStream();
const _onMfvPopout = () => floatingViewerManager.popOut();
const _onBeforeUnload = () => {
try {
if (_instance?.isPopped) _instance.popIn();
} catch {
/* noop */
}
};
const _onSettingsChanged = (event: any) => {
const key = String(event?.detail?.key || "");
if (
!key ||
key === "viewer" ||
key === "viewer.mfvLiveDefault" ||
key === "viewer.mfvPreviewDefault"
) {
_syncToggleDefaultsFromConfig();
}
};
function _isMfvPlayerShortcut(event: any) {
const key = String(event?.key || "");
return key === " " || key === "Spacebar" || key === "ArrowLeft" || key === "ArrowRight";
}
function _handleMfvPlayerShortcut(event: any) {
if (!_instance?.isVisible || !_isMfvPlayerShortcut(event)) return false;
try {
const target = event?.target;
const instanceEl = _instance?.element || null;
const targetInsideViewer = Boolean(instanceEl?.contains?.(target));
// 1) Simple player (GIF / image / animated-webp) keydown forwarding.
const targetPlayer = target?.closest?.(".mjr-mfv-simple-player") || null;
const player =
targetPlayer ||
(targetInsideViewer || target == null
? instanceEl?.querySelector?.(".mjr-mfv-simple-player")
: null);
const handler = player?._mjrSimplePlayerHandleKeydown;
if (typeof handler === "function") {
handler(event);
return Boolean(event?.defaultPrevented);
}
// 2) Full video/audio controls host (.mjr-mfv-player-host).
const targetHost = target?.closest?.(".mjr-mfv-player-host") || null;
const host =
targetHost ||
(targetInsideViewer || target == null
? instanceEl?.querySelector?.(".mjr-mfv-player-host")
: null);
const controls = host?._mjrMediaControlsHandle;
if (controls && typeof controls === "object") {
const key = String(event?.key || "");
const consume = () => {
event.preventDefault?.();
event.stopPropagation?.();
event.stopImmediatePropagation?.();
};
if ((key === " " || key === "Spacebar") && typeof controls.togglePlay === "function") {
controls.togglePlay();
consume();
return true;
}
if (key === "ArrowLeft" && typeof controls.stepFrames === "function") {
controls.stepFrames(-1);
consume();
return true;
}
if (key === "ArrowRight" && typeof controls.stepFrames === "function") {
controls.stepFrames(1);
consume();
return true;
}
}
return false;
} catch (e: any) {
console.debug?.(e);
return false;
}
}
const _onGlobalKeydown = (event: any) => {
if (!_instance?.isVisible) return;
if (isHotkeysSuspended()) return;
if (getHotkeysState().scope === "viewer") return;
const lower = event?.key?.toLowerCase?.() || "";
const isTypingTarget =
event?.target?.isContentEditable ||
event?.target?.closest?.("input, textarea, select, [contenteditable='true']");
if (isTypingTarget && _handleMfvPlayerShortcut(event)) return;
if (isTypingTarget) return;
if (_handleMfvPlayerShortcut(event)) return;
const consume = () => {
event.preventDefault?.();
event.stopPropagation?.();
event.stopImmediatePropagation?.();
};
if (!event?.ctrlKey && !event?.metaKey && !event?.altKey && !event?.shiftKey) {
if (lower === "v") {
consume();
floatingViewerManager.toggle();
return;
}
if (lower === "k") {
consume();
floatingViewerManager.togglePreview();
return;
}
if (lower === "l") {
consume();
floatingViewerManager.toggleLive();
return;
}
if (NODE_STREAM_FEATURE_ENABLED && lower === "n") {
consume();
floatingViewerManager.toggleNodeStream();
return;
}
if (lower === "c") {
consume();
floatingViewerManager.toggleCompareAB();
}
return;
}
};
export function installFloatingViewerGlobalHandlers(): void {
if (_globalHandlersInstalled) return;
if (typeof window === "undefined" || !window?.addEventListener) return;
window.addEventListener(EVENTS.MFV_OPEN, _onMfvOpen);
window.addEventListener(EVENTS.MFV_CLOSE, _onMfvClose);
window.addEventListener(EVENTS.MFV_TOGGLE, _onMfvToggle);
window.addEventListener(EVENTS.MFV_LIVE_TOGGLE, _onMfvLiveToggle);
window.addEventListener(EVENTS.MFV_PREVIEW_TOGGLE, _onMfvPreviewToggle);
if (NODE_STREAM_FEATURE_ENABLED) {
window.addEventListener(EVENTS.MFV_NODESTREAM_TOGGLE, _onMfvNodeStreamToggle);
}
window.addEventListener(EVENTS.MFV_POPOUT, _onMfvPopout);
window.addEventListener(EVENTS.SETTINGS_CHANGED, _onSettingsChanged);
window.addEventListener("keydown", _onGlobalKeydown, true);
window.addEventListener("beforeunload", _onBeforeUnload);
_globalHandlersInstalled = true;
}
export function removeFloatingViewerGlobalHandlers(): void {
if (typeof window === "undefined" || !window?.removeEventListener) {
_globalHandlersInstalled = false;
return;
}
window.removeEventListener(EVENTS.MFV_OPEN, _onMfvOpen);
window.removeEventListener(EVENTS.MFV_CLOSE, _onMfvClose);
window.removeEventListener(EVENTS.MFV_TOGGLE, _onMfvToggle);
window.removeEventListener(EVENTS.MFV_LIVE_TOGGLE, _onMfvLiveToggle);
window.removeEventListener(EVENTS.MFV_PREVIEW_TOGGLE, _onMfvPreviewToggle);
if (NODE_STREAM_FEATURE_ENABLED) {
window.removeEventListener(EVENTS.MFV_NODESTREAM_TOGGLE, _onMfvNodeStreamToggle);
}
window.removeEventListener(EVENTS.MFV_POPOUT, _onMfvPopout);
window.removeEventListener(EVENTS.SETTINGS_CHANGED, _onSettingsChanged);
window.removeEventListener("keydown", _onGlobalKeydown, true);
window.removeEventListener("beforeunload", _onBeforeUnload);
_globalHandlersInstalled = false;
}
/**
* Fully tear down the singleton and its global listeners.
* Called from entry.js during hot-reload cleanup so the next module instance
* starts from a clean slate.
*/
export function teardownFloatingViewerManager({ reinstallGlobalHandlers = false }: { reinstallGlobalHandlers?: boolean } = {}): void {
const wasVisible = Boolean(_instance?.isVisible);
// If the viewer is popped out to a separate window, bring it back first
try {
if (_instance?.isPopped) _instance.popIn();
} catch (e: any) {
console.debug?.(e);
}
removeFloatingViewerGlobalHandlers();
_unbindSelectionListener();
_unbindNodeSelectionListener();
_cancelFetch();
_loadSeq += 1;
_liveActive = _getDefaultLiveActive();
_previewActive = _getDefaultPreviewActive();
_nodeStreamActive = false;
try {
if (_setControllerNodeStreamActive) _setControllerNodeStreamActive(false);
} catch (e: any) {
console.debug?.(e);
}
_disposeInstance();
if (wasVisible) _emitVisibilityChanged(false);
if (reinstallGlobalHandlers) {
installFloatingViewerGlobalHandlers();
}
}