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

841 lines
30 KiB
JavaScript

/**
* 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 { 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";
import { getComfyApp } from "../../app/comfyApiBridge.js";
// Lazy-loaded modules — loaded on first use to avoid blocking startup.
/** @type {typeof import("./FloatingViewer.js").FloatingViewer | null} */
let _FloatingViewerClass = null;
let _floatingViewerLoadPromise = null;
async function _loadFloatingViewer() {
if (_FloatingViewerClass) return _FloatingViewerClass;
if (!_floatingViewerLoadPromise) {
_floatingViewerLoadPromise = import("./FloatingViewer.js").then((m) => {
_FloatingViewerClass = m.FloatingViewer;
return _FloatingViewerClass;
});
}
return _floatingViewerLoadPromise;
}
/** @type {((active: boolean) => void) | null} */
let _setControllerNodeStreamActive = null;
let _nodeStreamModPromise = null;
async function _loadNodeStreamController() {
if (_setControllerNodeStreamActive) return;
if (!_nodeStreamModPromise) {
_nodeStreamModPromise = import("./nodeStream/NodeStreamController.js").then((m) => {
_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" });
// ── Module state ──────────────────────────────────────────────────────────────
/** @type {FloatingViewer | null} */
let _instance = 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 _selectionListenerBound = false;
let _fetchAC = 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) => {
if (!_instance?.isVisible) return;
if (mode === MFV_MODES.SIMPLE) return;
_syncCurrentGridSelection();
},
handleForwardedKeydown: (event) => _onGlobalKeydown(event),
},
});
appendFloatingViewerNode(_instance.render());
}
}
try {
const node = _instance?.element || null;
if (node?.isConnected === false) {
appendFloatingViewerNode(node);
}
} catch (e) {
console.debug?.(e);
}
return _instance;
}
function _cancelFetch() {
try {
_fetchAC?.abort();
} catch (e) {
console.debug?.(e);
}
_fetchAC = null;
}
function _getSelectionSourceGrid() {
try {
const lastGrid = window.__MJR_LAST_SELECTION_GRID__;
if (lastGrid?.isConnected) return lastGrid;
} catch (e) {
console.debug?.(e);
}
return getActiveGridContainer();
}
function _disposeInstance() {
if (!_instance) return;
try {
_instance.dispose?.();
} catch (e) {
console.debug?.(e);
}
_instance = null;
}
function _emitVisibilityChanged(visible) {
window.dispatchEvent(
new CustomEvent(EVENTS.MFV_VISIBILITY_CHANGED, {
detail: { visible: Boolean(visible) },
}),
);
}
function _syncViewerControls(inst) {
if (!inst) return;
inst.setLiveActive(_liveActive);
inst.setPreviewActive(_previewActive);
inst.setNodeStreamActive?.(NODE_STREAM_FEATURE_ENABLED ? _nodeStreamActive : false);
}
/**
* 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) {
try {
const grid = _getSelectionSourceGrid();
if (!grid) return null;
const cards = Array.from(grid.querySelectorAll("[data-mjr-asset-id]"));
const idx = cards.findIndex((c) => 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) {
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) {
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) => !pins.has(s));
let ai = 0;
for (const slot of freeSlots) {
if (ai < assets.length) slotMedia[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) {
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) {
console.debug?.("[MFV] Error reading current grid selection", e);
}
}
// ── Selection listener (active while MFV is visible) ─────────────────────────
function _onSelectionChanged(e) {
if (!_instance?.isVisible) return;
const selectedIds = Array.isArray(e?.detail?.selectedIds)
? e.detail.selectedIds.map(String).filter(Boolean)
: [];
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) {
console.debug?.("[MFV] selection fallback failed", err);
}
}
function _bindSelectionListener() {
if (_selectionListenerBound) return;
window.addEventListener(EVENTS.SELECTION_CHANGED, _onSelectionChanged);
_selectionListenerBound = true;
}
function _unbindSelectionListener() {
window.removeEventListener(EVENTS.SELECTION_CHANGED, _onSelectionChanged);
_selectionListenerBound = false;
_cancelFetch();
}
// ── Node selection hooks (canvas → sidebar sync) ─────────────────────────────
let _nodeSelectionBound = false;
let _origOnNodeSelected = null;
let _origOnSelectionChange = null;
let _origOnNodeDeselected = null;
let _canvasPointerupHandler = null;
let _sidebarRefreshTimer = 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) {
console.debug?.(e);
}
_sidebarRefreshTimer = null;
_sidebarRefreshTimerKind = "";
}
function _bindNodeSelectionListener() {
if (_nodeSelectionBound) return;
try {
const app = getComfyApp();
const canvas = app?.canvas;
if (!canvas) return;
_origOnNodeSelected = canvas.onNodeSelected ?? null;
_origOnSelectionChange = canvas.onSelectionChange ?? null;
_origOnNodeDeselected = canvas.onNodeDeselected ?? null;
canvas.onNodeSelected = function (node) {
_origOnNodeSelected?.call(this, node);
_onCanvasNodeSelection();
};
canvas.onSelectionChange = function (selectedNodes) {
_origOnSelectionChange?.call(this, selectedNodes);
_onCanvasNodeSelection();
};
// Needed for deselect-all (click on empty canvas) — LiteGraph calls
// onNodeDeselected for each previously-selected node when clearing.
canvas.onNodeDeselected = function (node) {
_origOnNodeDeselected?.call(this, node);
_onCanvasNodeSelection();
};
// DOM-level fallback: catches cases that bypass LiteGraph callbacks
// (e.g. keyboard-only selection, custom ComfyUI selection code).
const canvasDomEl = canvas.canvas;
if (canvasDomEl?.addEventListener) {
_canvasPointerupHandler = _scheduleCanvasNodeSelection;
canvasDomEl.addEventListener("pointerup", _canvasPointerupHandler);
}
_nodeSelectionBound = true;
} catch (e) {
console.debug?.("[MFV] _bindNodeSelectionListener error", e);
}
}
function _unbindNodeSelectionListener() {
if (!_nodeSelectionBound) return;
_clearScheduledCanvasNodeSelection();
try {
const app = getComfyApp();
const canvas = app?.canvas;
if (canvas) {
if (_origOnNodeSelected !== null) canvas.onNodeSelected = _origOnNodeSelected;
if (_origOnSelectionChange !== null) canvas.onSelectionChange = _origOnSelectionChange;
if (_origOnNodeDeselected !== null) canvas.onNodeDeselected = _origOnNodeDeselected;
if (_canvasPointerupHandler && canvas.canvas?.removeEventListener) {
canvas.canvas.removeEventListener("pointerup", _canvasPointerupHandler);
}
}
} catch (e) {
console.debug?.("[MFV] _unbindNodeSelectionListener error", e);
}
_origOnNodeSelected = null;
_origOnSelectionChange = null;
_origOnNodeDeselected = null;
_canvasPointerupHandler = null;
_nodeSelectionBound = false;
}
// ── Public API ────────────────────────────────────────────────────────────────
export const floatingViewerManager = {
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();
_emitVisibilityChanged(true);
},
close() {
if (_instance) {
try {
if (_instance.isPopped) _instance.popIn();
_instance.hide();
} catch (e) {
console.debug?.(e);
}
}
_unbindSelectionListener();
_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[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) {
const inst = await _getInstance();
const wasVisible = Boolean(inst.isVisible);
inst.show();
_syncViewerControls(inst);
_bindSelectionListener();
const mode = inst._mode;
const inCompare =
mode === MFV_MODES.AB || mode === MFV_MODES.SIDE || mode === MFV_MODES.GRID;
if (inCompare) {
// In compare/grid mode: route the live stream to the first non-pinned slot.
const pins = inst.getPinnedSlots();
if (mode === MFV_MODES.GRID) {
const slotMedia = {
A: inst._mediaA,
B: inst._mediaB,
C: inst._mediaC,
D: inst._mediaD,
};
const freeSlot = ["A", "B", "C", "D"].find((s) => !pins.has(s)) || "A";
slotMedia[freeSlot] = fileData;
inst.loadMediaQuad(slotMedia.A, slotMedia.B, slotMedia.C, slotMedia.D);
} else if (pins.has("B")) {
inst.loadMediaPair(fileData, inst._mediaB); // B pinned — stream to A
} else {
inst.loadMediaPair(inst._mediaA, fileData); // A pinned (or no pin) — stream to B
}
} else {
inst.loadMediaA(fileData, { autoMode: true });
}
if (!wasVisible) _emitVisibilityChanged(true);
},
setLiveActive(active) {
_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) {
_previewActive = Boolean(active);
_instance?.setPreviewActive(_previewActive);
},
getPreviewActive() {
return _previewActive;
},
/**
* 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, opts = {}) {
if (!_previewActive) return;
const inst = await _getInstance();
const wasVisible = Boolean(inst.isVisible);
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) {
if (!NODE_STREAM_FEATURE_ENABLED) {
void active;
_nodeStreamActive = false;
if (_setControllerNodeStreamActive) _setControllerNodeStreamActive(false);
_instance?.setNodeStreamActive?.(false);
return;
}
_nodeStreamActive = Boolean(active);
// Lazy-load NodeStreamController then apply state.
void _loadNodeStreamController().then(() => {
if (_setControllerNodeStreamActive) _setControllerNodeStreamActive(_nodeStreamActive);
});
_instance?.setNodeStreamActive?.(_nodeStreamActive);
},
getNodeStreamActive() {
return NODE_STREAM_FEATURE_ENABLED ? _nodeStreamActive : false;
},
/**
* 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) {
if (!NODE_STREAM_FEATURE_ENABLED) {
void fileData;
return;
}
if (!_nodeStreamActive) return;
const inst = await _getInstance();
const wasVisible = Boolean(inst.isVisible);
if (!inst.isVisible) {
inst.show();
_bindSelectionListener();
}
_syncViewerControls(inst);
const mode = inst._mode;
const inCompare =
mode === MFV_MODES.AB || mode === MFV_MODES.SIDE || mode === MFV_MODES.GRID;
if (inCompare) {
const pins = inst.getPinnedSlots();
if (mode === MFV_MODES.GRID) {
const slotMedia = {
A: inst._mediaA,
B: inst._mediaB,
C: inst._mediaC,
D: inst._mediaD,
};
const freeSlot = ["A", "B", "C", "D"].find((s) => !pins.has(s)) || "A";
slotMedia[freeSlot] = fileData;
inst.loadMediaQuad(slotMedia.A, slotMedia.B, slotMedia.C, slotMedia.D);
} else if (pins.has("B")) {
inst.loadMediaPair(fileData, inst._mediaB);
} else {
inst.loadMediaPair(inst._mediaA, fileData);
}
} else {
inst.loadMediaA(fileData, { autoMode: true });
}
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) => {
const key = String(event?.detail?.key || "");
if (
!key ||
key === "viewer" ||
key === "viewer.mfvLiveDefault" ||
key === "viewer.mfvPreviewDefault"
) {
_syncToggleDefaultsFromConfig();
}
};
const _onGlobalKeydown = (event) => {
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) 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() {
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() {
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 } = {}) {
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) {
console.debug?.(e);
}
removeFloatingViewerGlobalHandlers();
_unbindSelectionListener();
_cancelFetch();
_loadSeq += 1;
_liveActive = _getDefaultLiveActive();
_previewActive = _getDefaultPreviewActive();
_nodeStreamActive = false;
try {
if (_setControllerNodeStreamActive) _setControllerNodeStreamActive(false);
} catch (e) {
console.debug?.(e);
}
_disposeInstance();
if (wasVisible) _emitVisibilityChanged(false);
if (reinstallGlobalHandlers) {
installFloatingViewerGlobalHandlers();
}
}