- FloatingViewer.impl.js: add missing _resetMfvZoom() method (was called
from floatingViewerLoader/Mode but never defined on the class)
- viewerShell.js: return genInfoTitle + genInfoTitleLeft from createViewerStageShell
- ViewerRuntime.js: destructure genInfoTitle + genInfoTitleLeft (were undefined
=> gen info body was never populated)
- floatingViewerPopout.js: inject document.documentElement.style.cssText as
:root{...} in popup head so ComfyUI CSS vars are available; set body
background to #0d0d0d; remove broken history.replaceState (Chrome blocks
it from about:blank)
- __init__.py: remove unused FailureSeverity/StageStatus imports
2731 lines
92 KiB
JavaScript
2731 lines
92 KiB
JavaScript
/**
|
|
* Asset Viewer Component
|
|
* Supports Single, A/B Compare, and Side-by-Side modes
|
|
*/
|
|
|
|
import { buildAssetViewURL } from "../api/endpoints.js";
|
|
import {
|
|
updateAssetRating,
|
|
getAssetMetadata,
|
|
getAssetsBatch,
|
|
getViewerInfo,
|
|
getFileMetadataScoped,
|
|
} from "../api/client.js";
|
|
import { ASSET_RATING_CHANGED_EVENT, ASSET_TAGS_CHANGED_EVENT } from "../app/events.js";
|
|
import {
|
|
bindViewerContextMenu,
|
|
unbindViewerContextMenu,
|
|
} from "../features/viewer/ViewerContextMenu.js";
|
|
import { createFileBadge, createRatingBadge, createTagsBadge } from "./Badges.js";
|
|
import { APP_CONFIG } from "../app/config.js";
|
|
import { safeDispatchCustomEvent } from "../utils/events.js";
|
|
import {
|
|
isPlayableViewerKind,
|
|
collectPlayableMediaElements,
|
|
pickPrimaryPlayableMedia,
|
|
mountUnifiedMediaControls,
|
|
} from "../features/viewer/mediaPlayer.js";
|
|
import {
|
|
createDefaultViewerState,
|
|
loadViewerPrefs,
|
|
saveViewerPrefs,
|
|
} from "../features/viewer/ViewerState.js";
|
|
import {
|
|
createViewerLifecycle,
|
|
destroyMediaProcessorsIn,
|
|
safeAddListener,
|
|
safeCall,
|
|
} from "../features/viewer/lifecycle.js";
|
|
import { createViewerToolbar } from "../features/viewer/ViewerToolbar.js";
|
|
import { installViewerKeyboard } from "../features/viewer/ViewerKeyboard.js";
|
|
import { installFollowerVideoSync } from "../features/viewer/videoSync.js";
|
|
import { createViewerGrid } from "../features/viewer/grid.js";
|
|
import { installViewerProbe } from "../features/viewer/probe.js";
|
|
import { createViewerLoupe } from "../features/viewer/loupe.js";
|
|
import { createViewerMetadataHydrator } from "../features/viewer/metadata.js";
|
|
import { createViewerPanZoom, createViewerMediaFactory } from "../features/viewer/ViewerCanvas.js";
|
|
import { createImagePreloader } from "../features/viewer/imagePreloader.js";
|
|
|
|
// ─── Lazy-loaded viewer sub-modules (separate bundle chunks) ─────────────────
|
|
// Pre-warmed when the first viewer instance is created so the browser fetches
|
|
// them in parallel, reducing the initial viewer chunk size by ~140 KB.
|
|
let _mod_abCompare = null;
|
|
let _mod_sideBySide = null;
|
|
let _mod_model3d = null;
|
|
let _mod_scopes = null;
|
|
let _mod_genInfo = null;
|
|
let _mod_frameExport = null;
|
|
|
|
function _prewarmViewerModules() {
|
|
if (!_mod_abCompare)
|
|
import("../features/viewer/abCompare.js").then((m) => { _mod_abCompare = m; });
|
|
if (!_mod_sideBySide)
|
|
import("../features/viewer/sideBySide.js").then((m) => { _mod_sideBySide = m; });
|
|
if (!_mod_model3d)
|
|
import("../features/viewer/model3dRenderer.js").then((m) => { _mod_model3d = m; });
|
|
if (!_mod_scopes)
|
|
import("../features/viewer/scopes.js").then((m) => { _mod_scopes = m; });
|
|
if (!_mod_genInfo)
|
|
import("../features/viewer/genInfo.js").then((m) => { _mod_genInfo = m; });
|
|
if (!_mod_frameExport)
|
|
import("../features/viewer/frameExport.js").then((m) => { _mod_frameExport = m; });
|
|
}
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
import { getViewerInstance as _getViewerInstance } from "../features/viewer/viewerInstanceManager.js";
|
|
import { createPlayerBarManager } from "../features/viewer/playerBarManager.js";
|
|
import { getHotkeysState, setHotkeysScope } from "../features/panel/controllers/hotkeysState.js";
|
|
import {
|
|
VIEWER_INFO_PANEL_RESERVE,
|
|
ensureViewerThemeStyles,
|
|
} from "../features/viewer/viewerThemeStyles.js";
|
|
import {
|
|
createViewerOverlayRoot,
|
|
createViewerStageShell,
|
|
} from "../features/viewer/viewerShell.js";
|
|
import { installViewerLightDismiss } from "../features/viewer/viewerOverlayDismiss.js";
|
|
|
|
/**
|
|
* Viewer modes
|
|
*/
|
|
const VIEWER_MODES = {
|
|
SINGLE: "single",
|
|
AB_COMPARE: "ab",
|
|
SIDE_BY_SIDE: "sidebyside",
|
|
};
|
|
|
|
/**
|
|
* Create the main viewer overlay
|
|
*/
|
|
function createViewer() {
|
|
_prewarmViewerModules();
|
|
ensureViewerThemeStyles();
|
|
const overlay = createViewerOverlayRoot();
|
|
|
|
const lifecycle = createViewerLifecycle(overlay);
|
|
const lifecycleUnsubs = lifecycle.unsubs || [];
|
|
|
|
const state = createDefaultViewerState();
|
|
state.mode = VIEWER_MODES.SINGLE;
|
|
try {
|
|
const prefs = loadViewerPrefs();
|
|
if (prefs && typeof prefs === "object") {
|
|
if (typeof prefs.analysisMode === "string")
|
|
state.analysisMode = prefs.analysisMode || "none";
|
|
if (typeof prefs.loupeEnabled === "boolean") state.loupeEnabled = prefs.loupeEnabled;
|
|
if (typeof prefs.probeEnabled === "boolean") state.probeEnabled = prefs.probeEnabled;
|
|
if (typeof prefs.hudEnabled === "boolean") state.hudEnabled = prefs.hudEnabled;
|
|
if (typeof prefs.genInfoOpen === "boolean") state.genInfoOpen = prefs.genInfoOpen;
|
|
if (typeof prefs.audioVisualizerMode === "string")
|
|
state.audioVisualizerMode = prefs.audioVisualizerMode || "artistic";
|
|
if (
|
|
typeof prefs.abWipePercent === "number" &&
|
|
Number.isFinite(prefs.abWipePercent) &&
|
|
prefs.abWipePercent >= 0 &&
|
|
prefs.abWipePercent <= 100
|
|
)
|
|
state._abWipePercent = prefs.abWipePercent;
|
|
}
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
const IMAGE_PRELOAD_EXTENSIONS = new Set([
|
|
"png",
|
|
"jpg",
|
|
"jpeg",
|
|
"webp",
|
|
"gif",
|
|
"bmp",
|
|
"tiff",
|
|
"avif",
|
|
"heic",
|
|
"hdr",
|
|
"svg",
|
|
"apng",
|
|
]);
|
|
|
|
let panzoom = null;
|
|
let mediaFactory = null;
|
|
|
|
function mediaTransform() {
|
|
try {
|
|
return panzoom?.mediaTransform?.() || "";
|
|
} catch {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
function clampPanToBounds() {
|
|
try {
|
|
panzoom?.clampPanToBounds?.();
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
}
|
|
|
|
function applyTransform() {
|
|
try {
|
|
panzoom?.applyTransform?.();
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
}
|
|
|
|
function setZoom(next, opts) {
|
|
try {
|
|
panzoom?.setZoom?.(next, opts);
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
}
|
|
|
|
function updatePanCursor() {
|
|
try {
|
|
panzoom?.updatePanCursor?.();
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
}
|
|
|
|
function getPrimaryMedia() {
|
|
try {
|
|
return panzoom?.getPrimaryMedia?.() || null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function getMediaNaturalSize(mediaEl) {
|
|
try {
|
|
return panzoom?.getMediaNaturalSize?.(mediaEl) || { w: 0, h: 0 };
|
|
} catch {
|
|
return { w: 0, h: 0 };
|
|
}
|
|
}
|
|
|
|
function getViewportRect() {
|
|
try {
|
|
return panzoom?.getViewportRect?.() || null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function computeOneToOneZoom() {
|
|
try {
|
|
return panzoom?.computeOneToOneZoom?.() ?? null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function updateMediaNaturalSize() {
|
|
try {
|
|
panzoom?.updateMediaNaturalSize?.();
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
}
|
|
|
|
function createMediaElement(asset, url) {
|
|
try {
|
|
return mediaFactory?.createMediaElement?.(asset, url) || document.createElement("div");
|
|
} catch {
|
|
return document.createElement("div");
|
|
}
|
|
}
|
|
|
|
function createCompareMediaElement(asset, url) {
|
|
try {
|
|
return (
|
|
mediaFactory?.createCompareMediaElement?.(asset, url) ||
|
|
document.createElement("div")
|
|
);
|
|
} catch {
|
|
return document.createElement("div");
|
|
}
|
|
}
|
|
|
|
function updateVisibleAudioVisualizerMode() {
|
|
let updated = false;
|
|
try {
|
|
const activeRoot =
|
|
state.mode === VIEWER_MODES.AB_COMPARE
|
|
? abView
|
|
: state.mode === VIEWER_MODES.SIDE_BY_SIDE
|
|
? sideView
|
|
: singleView;
|
|
const canvases = Array.from(activeRoot?.querySelectorAll?.(".mjr-viewer-audio-viz") || []);
|
|
for (const canvas of canvases) {
|
|
try {
|
|
const proc = canvas?._mjrProc || null;
|
|
if (!proc?.setMode) continue;
|
|
proc.setMode(state.audioVisualizerMode);
|
|
updated = true;
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
return updated;
|
|
}
|
|
|
|
let toolbar = null;
|
|
// Late-bound close handler: default to "soft close" until API exists, then upgrade close button to full dispose.
|
|
let _requestCloseFromButton = () => closeViewer();
|
|
|
|
// Header (toolbar)
|
|
let header = document.createElement("div");
|
|
header.className = "mjr-viewer-header";
|
|
header.style.cssText = `
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
padding: 12px 20px;
|
|
background: var(--mjr-surface-0, rgba(0, 0, 0, 0.8));
|
|
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
|
color: white;
|
|
box-sizing: border-box;
|
|
`;
|
|
let filename = document.createElement("span");
|
|
filename.className = "mjr-viewer-filename";
|
|
filename.style.cssText =
|
|
"font-size: 14px; font-weight: 500; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;";
|
|
let badgesBar = document.createElement("div");
|
|
badgesBar.className = "mjr-viewer-badges";
|
|
badgesBar.style.cssText = "display:flex; gap:8px; align-items:center; flex-wrap:wrap;";
|
|
let filenameRight = null;
|
|
let badgesBarRight = null;
|
|
let rightMeta = null;
|
|
let rightArea = null;
|
|
let leftArea = null;
|
|
let leftMeta = null;
|
|
let centerArea = null;
|
|
let titleLine = null;
|
|
let titleWrap = null;
|
|
let modeButtonsEl = null;
|
|
let headerTop = null;
|
|
try {
|
|
header.appendChild(filename);
|
|
header.appendChild(badgesBar);
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
|
|
try {
|
|
toolbar = createViewerToolbar({
|
|
VIEWER_MODES,
|
|
state,
|
|
lifecycle,
|
|
getCanAB: () => canAB(),
|
|
onToggleFullscreen: () => {
|
|
try {
|
|
if (!document.fullscreenElement) {
|
|
try {
|
|
overlay.requestFullscreen();
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
} else {
|
|
try {
|
|
document.exitFullscreen();
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
},
|
|
// Close button should fully tear down the viewer (remove overlay + listeners) to avoid leaks/ghost UI.
|
|
onClose: () => _requestCloseFromButton?.(),
|
|
onMode: (mode) => {
|
|
try {
|
|
if (mode === VIEWER_MODES.AB_COMPARE && !canAB()) return;
|
|
if (mode === VIEWER_MODES.SIDE_BY_SIDE && !canSide()) return;
|
|
state.mode = mode;
|
|
updateUI();
|
|
try {
|
|
toolbar?.syncToolsUIFromState?.();
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
},
|
|
onZoomIn: () => {
|
|
try {
|
|
setZoom((Number(state.zoom) || 1) + 0.25, {
|
|
clientX: state._lastPointerX,
|
|
clientY: state._lastPointerY,
|
|
});
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
},
|
|
onZoomOut: () => {
|
|
try {
|
|
setZoom((Number(state.zoom) || 1) - 0.25, {
|
|
clientX: state._lastPointerX,
|
|
clientY: state._lastPointerY,
|
|
});
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
},
|
|
onZoomReset: () => {
|
|
try {
|
|
setZoom(1);
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
},
|
|
onZoomOneToOne: () => {
|
|
try {
|
|
const tryApply = () => {
|
|
const z = computeOneToOneZoom();
|
|
if (z == null) return false;
|
|
const near = Math.abs((Number(state.zoom) || 1) - z) < 0.01;
|
|
setZoom(near ? 1 : z, {
|
|
clientX: state._lastPointerX,
|
|
clientY: state._lastPointerY,
|
|
});
|
|
return true;
|
|
};
|
|
|
|
// Sometimes processors haven't set canvas natural size yet; retry once after a frame.
|
|
if (tryApply()) return;
|
|
try {
|
|
requestAnimationFrame(() => {
|
|
try {
|
|
updateMediaNaturalSize();
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
try {
|
|
tryApply();
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
});
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
},
|
|
onCompareModeChanged: () => {
|
|
try {
|
|
if (state.mode === VIEWER_MODES.AB_COMPARE) {
|
|
renderAsset();
|
|
// Rebind the player bar to the newly created video elements (wipe/difference re-renders AB view).
|
|
syncPlayerBar();
|
|
}
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
},
|
|
onExportFrame: () => {
|
|
try {
|
|
void exportCurrentFrame({ toClipboard: false });
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
},
|
|
onCopyFrame: () => {
|
|
try {
|
|
void exportCurrentFrame({ toClipboard: true });
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
},
|
|
onAudioVizModeChanged: () => {
|
|
try {
|
|
const current = state.assets[state.currentIndex];
|
|
if (String(current?.kind || "") !== "audio") return;
|
|
if (!updateVisibleAudioVisualizerMode()) {
|
|
renderAsset();
|
|
syncPlayerBar();
|
|
}
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
},
|
|
onToolsChanged: () => {
|
|
try {
|
|
toolbar?.syncToolsUIFromState?.();
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
try {
|
|
saveViewerPrefs(state);
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
try {
|
|
applyDistractionFreeUI();
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
try {
|
|
if (state.mode === VIEWER_MODES.AB_COMPARE) {
|
|
const m = String(state.abCompareMode || "wipe");
|
|
if (m !== "wipe" && m !== "wipeV") {
|
|
abView?._mjrDiffRequest?.();
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
try {
|
|
if (!state.probeEnabled) probeTooltip.style.display = "none";
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
try {
|
|
if (!state.loupeEnabled) loupeWrap.style.display = "none";
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
try {
|
|
scheduleOverlayRedraw();
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
try {
|
|
scheduleApplyGrade?.();
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
try {
|
|
void renderGenInfoPanel();
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
},
|
|
});
|
|
if (toolbar?.headerEl) header = toolbar.headerEl;
|
|
if (toolbar?.headerTopEl) headerTop = toolbar.headerTopEl;
|
|
if (toolbar?.filenameEl) filename = toolbar.filenameEl;
|
|
if (toolbar?.badgesBarEl) badgesBar = toolbar.badgesBarEl;
|
|
if (toolbar?.filenameRightEl) filenameRight = toolbar.filenameRightEl;
|
|
if (toolbar?.badgesBarRightEl) badgesBarRight = toolbar.badgesBarRightEl;
|
|
if (toolbar?.leftAreaEl) leftArea = toolbar.leftAreaEl;
|
|
if (toolbar?.leftMetaEl) leftMeta = toolbar.leftMetaEl;
|
|
if (toolbar?.centerAreaEl) centerArea = toolbar.centerAreaEl;
|
|
if (toolbar?.rightMetaEl) rightMeta = toolbar.rightMetaEl;
|
|
if (toolbar?.rightAreaEl) rightArea = toolbar.rightAreaEl;
|
|
if (toolbar?.titleLineEl) titleLine = toolbar.titleLineEl;
|
|
if (toolbar?.titleWrapEl) titleWrap = toolbar.titleWrapEl;
|
|
if (toolbar?.modeButtonsEl) modeButtonsEl = toolbar.modeButtonsEl;
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
|
|
const {
|
|
contentRow,
|
|
content,
|
|
singleView,
|
|
abView,
|
|
sideView,
|
|
overlayLayer,
|
|
gridCanvas,
|
|
probeTooltip,
|
|
loupeWrap,
|
|
loupeCanvas,
|
|
genInfoOverlay,
|
|
genInfoTitle,
|
|
genInfoBody,
|
|
genInfoOverlayLeft,
|
|
genInfoTitleLeft,
|
|
genInfoBodyLeft,
|
|
footer,
|
|
prevBtn,
|
|
indexInfo,
|
|
nextBtn,
|
|
navBar,
|
|
playerBarHost,
|
|
filmstrip,
|
|
} = createViewerStageShell({
|
|
state,
|
|
buildAssetViewURL,
|
|
onNavigate: (idx) => {
|
|
try {
|
|
if (state.compareAsset != null) {
|
|
state.compareAsset = null;
|
|
state.mode = VIEWER_MODES.SINGLE;
|
|
}
|
|
state.currentIndex = idx;
|
|
updateUI();
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
},
|
|
onCompare: (idx) => {
|
|
try {
|
|
const assets = Array.isArray(state.assets) ? state.assets : [];
|
|
const chosen = assets[idx];
|
|
if (!chosen) return;
|
|
if (chosen === assets[state.currentIndex]) return;
|
|
if (chosen === state.compareAsset) {
|
|
state.compareAsset = null;
|
|
state.mode = VIEWER_MODES.SINGLE;
|
|
updateUI();
|
|
return;
|
|
}
|
|
if (assets.length === 2) {
|
|
state.compareAsset = assets[1 - state.currentIndex];
|
|
state.mode = compareIncludes3D()
|
|
? VIEWER_MODES.SIDE_BY_SIDE
|
|
: VIEWER_MODES.AB_COMPARE;
|
|
} else {
|
|
state.compareAsset = chosen;
|
|
state.mode = canSide() ? VIEWER_MODES.SIDE_BY_SIDE : VIEWER_MODES.AB_COMPARE;
|
|
}
|
|
updateUI();
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
},
|
|
});
|
|
|
|
overlay.appendChild(header);
|
|
overlay.appendChild(contentRow);
|
|
|
|
function getOtherComparedAsset() {
|
|
try {
|
|
if (state.compareAsset) return state.compareAsset;
|
|
const assets = Array.isArray(state.assets) ? state.assets : [];
|
|
if (assets.length === 2) {
|
|
return assets[1 - (state.currentIndex || 0)] || null;
|
|
}
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function compareIncludes3D() {
|
|
try {
|
|
const current = state.assets?.[state.currentIndex] || null;
|
|
return (
|
|
(_mod_model3d?.isModel3DAsset?.(current) ?? false) ||
|
|
(_mod_model3d?.isModel3DAsset?.(getOtherComparedAsset()) ?? false)
|
|
);
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
overlay.appendChild(filmstrip.el);
|
|
overlay.appendChild(footer);
|
|
overlay.appendChild(genInfoOverlay);
|
|
overlay.appendChild(genInfoOverlayLeft);
|
|
|
|
installViewerLightDismiss({
|
|
overlay,
|
|
requestClose: () => _requestCloseFromButton(),
|
|
});
|
|
|
|
const metadataHydrator = createViewerMetadataHydrator({
|
|
state,
|
|
VIEWER_MODES,
|
|
APP_CONFIG,
|
|
getAssetMetadata,
|
|
getAssetsBatch,
|
|
});
|
|
const VIEWER_INFO_CACHE_TTL_MS = 5 * 60 * 1000;
|
|
const VIEWER_INFO_CACHE_MAX = 256;
|
|
const _viewerInfoCache = new Map();
|
|
const _viewerInfoCachePrune = () => {
|
|
try {
|
|
const now = Date.now();
|
|
for (const [k, entry] of _viewerInfoCache.entries()) {
|
|
const ts = Number(entry?.at) || 0;
|
|
if (!ts || now - ts > VIEWER_INFO_CACHE_TTL_MS) _viewerInfoCache.delete(k);
|
|
}
|
|
if (_viewerInfoCache.size <= VIEWER_INFO_CACHE_MAX) return;
|
|
const ordered = Array.from(_viewerInfoCache.entries()).sort(
|
|
(a, b) => (Number(a?.[1]?.at) || 0) - (Number(b?.[1]?.at) || 0),
|
|
);
|
|
const overflow = _viewerInfoCache.size - VIEWER_INFO_CACHE_MAX;
|
|
for (let i = 0; i < overflow; i += 1) {
|
|
const key = ordered?.[i]?.[0];
|
|
if (key != null) _viewerInfoCache.delete(key);
|
|
}
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
};
|
|
const _viewerInfoCacheGet = (id) => {
|
|
try {
|
|
const key = String(id ?? "");
|
|
if (!key) return null;
|
|
const entry = _viewerInfoCache.get(key);
|
|
if (!entry || typeof entry !== "object") return null;
|
|
const ts = Number(entry?.at) || 0;
|
|
if (!ts || Date.now() - ts > VIEWER_INFO_CACHE_TTL_MS) {
|
|
_viewerInfoCache.delete(key);
|
|
return null;
|
|
}
|
|
return entry?.data || null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
};
|
|
const _viewerInfoCacheSet = (id, data) => {
|
|
try {
|
|
const key = String(id ?? "");
|
|
if (!key || !data) return;
|
|
_viewerInfoCache.set(key, { data, at: Date.now() });
|
|
_viewerInfoCachePrune();
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
};
|
|
|
|
const hydrateVisibleMetadata = async () => {
|
|
try {
|
|
await metadataHydrator?.hydrateVisibleMetadata?.();
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
};
|
|
|
|
try {
|
|
panzoom = createViewerPanZoom({
|
|
overlay,
|
|
content,
|
|
singleView,
|
|
abView,
|
|
sideView,
|
|
state,
|
|
VIEWER_MODES,
|
|
scheduleOverlayRedraw,
|
|
lifecycle,
|
|
});
|
|
} catch {
|
|
panzoom = null;
|
|
}
|
|
|
|
const clearCanvas = (ctx, w, h) => {
|
|
try {
|
|
ctx.clearRect(0, 0, w, h);
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
};
|
|
|
|
// Overlay drawing (grid + probe + loupe)
|
|
let _overlayRAF = null;
|
|
function scheduleOverlayRedraw(immediate) {
|
|
try {
|
|
if (overlay.style.display === "none") return;
|
|
if (immediate === true) {
|
|
if (_overlayRAF != null) {
|
|
cancelAnimationFrame(_overlayRAF);
|
|
_overlayRAF = null;
|
|
}
|
|
try {
|
|
redrawOverlays();
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
return;
|
|
}
|
|
if (_overlayRAF != null) return;
|
|
_overlayRAF = requestAnimationFrame(() => {
|
|
_overlayRAF = null;
|
|
try {
|
|
redrawOverlays();
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
});
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
}
|
|
|
|
const getOverlayMedia = () => {
|
|
try {
|
|
if (state?.mode === VIEWER_MODES.SINGLE) {
|
|
return singleView?.querySelector?.(".mjr-viewer-media") || null;
|
|
}
|
|
if (state?.mode === VIEWER_MODES.AB_COMPARE) {
|
|
return abView?.querySelector?.(".mjr-viewer-media") || null;
|
|
}
|
|
if (state?.mode === VIEWER_MODES.SIDE_BY_SIDE) {
|
|
return sideView?.querySelector?.(".mjr-viewer-media") || null;
|
|
}
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
return null;
|
|
};
|
|
|
|
const grid = createViewerGrid({
|
|
gridCanvas,
|
|
content,
|
|
state,
|
|
VIEWER_MODES,
|
|
getPrimaryMedia: getOverlayMedia,
|
|
getViewportRect,
|
|
clearCanvas,
|
|
});
|
|
|
|
const redrawOverlays = () => {
|
|
const hasPanHint = (() => {
|
|
try {
|
|
const at = Number(state?._panHintAt) || 0;
|
|
return at > 0 && Date.now() - at < 900;
|
|
} catch {
|
|
return false;
|
|
}
|
|
})();
|
|
try {
|
|
const showHud = state?.mode === VIEWER_MODES.SINGLE && Boolean(state?.hudEnabled);
|
|
const showScopes = String(state?.scopesMode || "off") !== "off";
|
|
const showMask = Boolean(state?.overlayMaskEnabled);
|
|
gridCanvas.style.display =
|
|
state.gridMode === 0 && !showMask && !hasPanHint && !showHud && !showScopes
|
|
? "none"
|
|
: "";
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
|
|
const size = grid.ensureCanvasSize();
|
|
if (!(size.w > 0 && size.h > 0)) return;
|
|
const wantGridOrMask = (() => {
|
|
const showHud = state?.mode === VIEWER_MODES.SINGLE && Boolean(state?.hudEnabled);
|
|
return (
|
|
(Number(state.gridMode) || 0) !== 0 || Boolean(state?.overlayMaskEnabled) || showHud
|
|
);
|
|
})();
|
|
if (wantGridOrMask) {
|
|
grid.redrawGrid(size);
|
|
} else {
|
|
try {
|
|
const ctx = gridCanvas.getContext("2d");
|
|
if (ctx) clearCanvas(ctx, size.w, size.h);
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
}
|
|
|
|
if (hasPanHint) {
|
|
try {
|
|
const ctx = gridCanvas.getContext("2d");
|
|
if (ctx) {
|
|
const rect = content?.getBoundingClientRect?.();
|
|
const x0 = Number(state?._panHintX);
|
|
const y0 = Number(state?._panHintY);
|
|
const x = rect && Number.isFinite(x0) ? x0 - rect.left : size.w / 2;
|
|
const y = rect && Number.isFinite(y0) ? y0 - rect.top : size.h * 0.78;
|
|
const pad = 10;
|
|
const tx = Math.max(pad, Math.min(size.w - pad, x));
|
|
const ty = Math.max(pad, Math.min(size.h - pad, y));
|
|
ctx.save();
|
|
ctx.font = "12px var(--comfy-font, ui-sans-serif, system-ui)";
|
|
ctx.textAlign = "center";
|
|
ctx.textBaseline = "middle";
|
|
const msg = "Zoom in to pan";
|
|
const metrics = ctx.measureText(msg);
|
|
const w = Math.min(size.w - 2 * pad, Math.max(140, metrics.width + 26));
|
|
const h = 26;
|
|
ctx.fillStyle = "rgba(0,0,0,0.65)";
|
|
ctx.strokeStyle = "rgba(255,255,255,0.18)";
|
|
ctx.lineWidth = 1;
|
|
ctx.beginPath();
|
|
const rx = tx - w / 2;
|
|
const ry = ty - h / 2;
|
|
const r = 10;
|
|
ctx.moveTo(rx + r, ry);
|
|
ctx.arcTo(rx + w, ry, rx + w, ry + h, r);
|
|
ctx.arcTo(rx + w, ry + h, rx, ry + h, r);
|
|
ctx.arcTo(rx, ry + h, rx, ry, r);
|
|
ctx.arcTo(rx, ry, rx + w, ry, r);
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
ctx.stroke();
|
|
ctx.fillStyle = "rgba(255,255,255,0.92)";
|
|
ctx.fillText(msg, tx, ty);
|
|
ctx.restore();
|
|
}
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
}
|
|
|
|
// HUD: bbox + image WxH label (drawn in `grid.js`). No filename overlay here.
|
|
|
|
// Scopes (optional): RGB histogram + luma waveform (downscaled, best-effort).
|
|
try {
|
|
const scopesMode = String(state?.scopesMode || "off");
|
|
if (scopesMode !== "off") {
|
|
const ctx = gridCanvas.getContext("2d");
|
|
if (ctx) {
|
|
let root = singleView;
|
|
if (state?.mode === VIEWER_MODES.AB_COMPARE) root = abView;
|
|
else if (state?.mode === VIEWER_MODES.SIDE_BY_SIDE) root = sideView;
|
|
const any =
|
|
root?.querySelector?.("canvas.mjr-viewer-media") ||
|
|
overlay?.querySelector?.("canvas.mjr-viewer-media");
|
|
if (any && any instanceof HTMLCanvasElement) {
|
|
_mod_scopes?.drawScopesLight?.(ctx, { w: size.w, h: size.h }, any, {
|
|
mode: scopesMode,
|
|
channel: state?.channel,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
|
|
if (state.mode !== VIEWER_MODES.SINGLE) {
|
|
try {
|
|
probeTooltip.style.display = "none";
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
try {
|
|
loupeWrap.style.display = "none";
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
}
|
|
};
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// Generation Info side panel (prompt/model/etc)
|
|
// ----------------------------------------------------------------------------
|
|
|
|
const clearGenInfoBody = (target) => {
|
|
if (!target) return;
|
|
try {
|
|
for (const child of Array.from(target.childNodes || [])) {
|
|
try {
|
|
child?._mjrDispose?.();
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
try {
|
|
target.replaceChildren();
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
};
|
|
|
|
const stopGenInfoFetch = () => {
|
|
try {
|
|
state._genInfoAbort?.abort?.();
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
state._genInfoAbort = null;
|
|
try {
|
|
state._genInfoReqId = (Number(state._genInfoReqId) || 0) + 1;
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
};
|
|
|
|
const ensureGenInfoAsset = async (asset, { signal } = {}) => {
|
|
try {
|
|
return await _mod_genInfo?.ensureViewerMetadataAsset?.(asset, {
|
|
getAssetMetadata,
|
|
getFileMetadataScoped,
|
|
metadataCache: metadataHydrator,
|
|
signal,
|
|
});
|
|
} catch {
|
|
return asset;
|
|
}
|
|
};
|
|
|
|
const shouldShowGenInfoLoading = (asset) => {
|
|
try {
|
|
if (!asset || typeof asset !== "object") return false;
|
|
if (asset?.geninfo || asset?.prompt || asset?.workflow || asset?.metadata) return false;
|
|
const mime = String(asset?.mime || asset?.mimetype || asset?.type || "").toLowerCase();
|
|
if (mime.startsWith("video/")) return true;
|
|
const p = String(
|
|
asset?.filepath || asset?.path || asset?.filename || asset?.name || "",
|
|
).toLowerCase();
|
|
const ext = p.split(".").pop() || "";
|
|
if (["mp4", "webm", "mov", "mkv", "avi", "m4v", "gif"].includes(ext)) return true;
|
|
// If we have nothing at all, show a loading hint while hydration runs.
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
};
|
|
|
|
const renderGenInfoPanel = async () => {
|
|
const canABMode = canAB();
|
|
const canSideMode = canSide();
|
|
const mode = state.mode;
|
|
const open = Boolean(state?.genInfoOpen) && !state?.distractionFree;
|
|
|
|
// Determine if we should show split panels
|
|
const isDual =
|
|
open &&
|
|
((mode === VIEWER_MODES.AB_COMPARE && canABMode) ||
|
|
(mode === VIEWER_MODES.SIDE_BY_SIDE && canSideMode));
|
|
// Grid mode: 3-4 assets in 2x2 grid (no filmstrip compare)
|
|
const isGrid =
|
|
isDual &&
|
|
mode === VIEWER_MODES.SIDE_BY_SIDE &&
|
|
!state?.compareAsset &&
|
|
(state?.assets?.length ?? 0) > 2;
|
|
|
|
try {
|
|
genInfoOverlay.style.display = open ? "flex" : "none";
|
|
genInfoOverlayLeft.style.display = isDual ? "flex" : "none";
|
|
// Reserve space so the viewer shrinks.
|
|
overlay.style.paddingRight = open ? VIEWER_INFO_PANEL_RESERVE : "0px";
|
|
overlay.style.paddingLeft = isDual ? VIEWER_INFO_PANEL_RESERVE : "0px";
|
|
|
|
if (!open) {
|
|
stopGenInfoFetch();
|
|
try {
|
|
clearGenInfoBody(genInfoBody);
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
try {
|
|
clearGenInfoBody(genInfoBodyLeft);
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
return;
|
|
}
|
|
} catch {
|
|
return;
|
|
}
|
|
|
|
stopGenInfoFetch();
|
|
const reqId = (Number(state?._genInfoReqId) || 0) + 1;
|
|
try {
|
|
state._genInfoReqId = reqId;
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
const ac = new AbortController();
|
|
state._genInfoAbort = ac;
|
|
|
|
const renderNow = ({ left, leftExtra, right, rightExtra, single }) => {
|
|
try {
|
|
clearGenInfoBody(genInfoBody);
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
try {
|
|
clearGenInfoBody(genInfoBodyLeft);
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
|
|
const onRetry = () => {
|
|
try {
|
|
if (!state?.genInfoOpen) state.genInfoOpen = true;
|
|
void renderGenInfoPanel();
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
};
|
|
|
|
const addBlockTo = (targetBody, title, assetObj, loading) => {
|
|
if (!targetBody) return;
|
|
try {
|
|
try {
|
|
const _blocks = _mod_genInfo?.buildViewerMetadataBlocks?.({
|
|
title,
|
|
asset: assetObj,
|
|
ui: { loading: Boolean(loading), onRetry },
|
|
});
|
|
if (_blocks) {
|
|
targetBody.appendChild(_blocks);
|
|
return;
|
|
}
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
|
|
// Fallback manual rendering
|
|
const block = document.createElement("div");
|
|
block.style.cssText =
|
|
"display:flex; flex-direction:column; gap:10px; margin-bottom: 14px;";
|
|
|
|
if (title) {
|
|
const h = document.createElement("div");
|
|
h.textContent = title;
|
|
h.style.cssText =
|
|
"font-size: 12px; font-weight: 600; letter-spacing: 0.02em; color: rgba(255,255,255,0.86);";
|
|
block.appendChild(h);
|
|
}
|
|
|
|
const empty = document.createElement("div");
|
|
empty.style.cssText =
|
|
"padding: 10px 12px; border-radius: 10px; border: 1px solid rgba(255,255,255,0.12); background: rgba(255,255,255,0.06); color: rgba(255,255,255,0.72);";
|
|
empty.textContent = "No generation data found for this file.";
|
|
block.appendChild(empty);
|
|
|
|
try {
|
|
const raw = assetObj?.metadata_raw;
|
|
if (raw != null) {
|
|
const details = document.createElement("details");
|
|
details.style.cssText =
|
|
"border: 1px solid rgba(255,255,255,0.10); border-radius: 10px; background: rgba(255,255,255,0.04); overflow: hidden;";
|
|
const summary = document.createElement("summary");
|
|
summary.textContent = "Raw metadata";
|
|
summary.style.cssText =
|
|
"cursor: pointer; padding: 10px 12px; color: rgba(255,255,255,0.78); user-select: none;";
|
|
const pre = document.createElement("pre");
|
|
pre.style.cssText =
|
|
"margin:0; padding: 10px 12px; max-height: 280px; overflow:auto; font-size: 11px; line-height: 1.35; color: rgba(255,255,255,0.86);";
|
|
let txt = "";
|
|
try {
|
|
if (typeof raw === "string") txt = raw;
|
|
else txt = JSON.stringify(raw, null, 2);
|
|
} catch {
|
|
txt = String(raw);
|
|
}
|
|
if (txt.length > 40_000) txt = `${txt.slice(0, 40_000)}\n…(truncated)…`;
|
|
pre.textContent = txt;
|
|
details.appendChild(summary);
|
|
details.appendChild(pre);
|
|
block.appendChild(details);
|
|
}
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
targetBody.appendChild(block);
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
};
|
|
|
|
if (isDual) {
|
|
if (left) {
|
|
genInfoTitleLeft.textContent = left.title || "Asset A";
|
|
addBlockTo(
|
|
genInfoBodyLeft,
|
|
leftExtra ? "Asset A" : "",
|
|
left.asset,
|
|
left.loading,
|
|
);
|
|
}
|
|
if (leftExtra) {
|
|
addBlockTo(genInfoBodyLeft, "Asset C", leftExtra.asset, leftExtra.loading);
|
|
}
|
|
if (right) {
|
|
genInfoTitle.textContent = right.title || "Asset B";
|
|
addBlockTo(
|
|
genInfoBody,
|
|
rightExtra ? "Asset B" : "",
|
|
right.asset,
|
|
right.loading,
|
|
);
|
|
}
|
|
if (rightExtra) {
|
|
addBlockTo(genInfoBody, "Asset D", rightExtra.asset, rightExtra.loading);
|
|
}
|
|
} else {
|
|
// Single mode
|
|
if (single) {
|
|
genInfoTitle.textContent = single.title || "Generation Info";
|
|
addBlockTo(genInfoBody, "", single.asset, single.loading);
|
|
}
|
|
}
|
|
};
|
|
|
|
// Resolve assets and perform initial render
|
|
try {
|
|
const current = state?.assets?.[state?.currentIndex] || null;
|
|
if (!current) {
|
|
renderNow({});
|
|
return;
|
|
}
|
|
|
|
let assetLeft = null;
|
|
let assetRight = null;
|
|
let assetC = null;
|
|
let assetD = null;
|
|
let assetSingle = null;
|
|
|
|
if (isDual) {
|
|
// Determine A and B
|
|
if (mode === VIEWER_MODES.SIDE_BY_SIDE) {
|
|
// Side-by-side can be fixed-order (2-up set) or filmstrip compare (A=current, B=chosen).
|
|
if (state?.compareAsset) {
|
|
assetLeft = current;
|
|
assetRight = state.compareAsset;
|
|
} else {
|
|
assetLeft = state.assets[0] || null;
|
|
assetRight = state.assets[1] || null;
|
|
if (isGrid) {
|
|
assetC = state.assets[2] || null;
|
|
assetD = state.assets[3] || null;
|
|
}
|
|
}
|
|
} else {
|
|
// AB_COMPARE: Swappable roles (Current vs Other)
|
|
assetLeft = current;
|
|
const other =
|
|
state?.compareAsset ||
|
|
(state.assets.length === 2 ? state.assets[1 - state.currentIndex] : null);
|
|
assetRight = other;
|
|
}
|
|
} else {
|
|
assetSingle = current;
|
|
}
|
|
|
|
// Initial render (cache)
|
|
const getCached = (a) => (a ? metadataHydrator?.getCached?.(a.id)?.data || a : null);
|
|
|
|
renderNow({
|
|
left: isDual
|
|
? {
|
|
title: isGrid ? "Assets A & C" : "Asset A",
|
|
asset: getCached(assetLeft),
|
|
loading: shouldShowGenInfoLoading(getCached(assetLeft)),
|
|
}
|
|
: null,
|
|
leftExtra:
|
|
isGrid && assetC
|
|
? {
|
|
asset: getCached(assetC),
|
|
loading: shouldShowGenInfoLoading(getCached(assetC)),
|
|
}
|
|
: null,
|
|
right: isDual
|
|
? {
|
|
title: isGrid ? "Assets B & D" : "Asset B",
|
|
asset: getCached(assetRight),
|
|
loading: shouldShowGenInfoLoading(getCached(assetRight)),
|
|
}
|
|
: null,
|
|
rightExtra:
|
|
isGrid && assetD
|
|
? {
|
|
asset: getCached(assetD),
|
|
loading: shouldShowGenInfoLoading(getCached(assetD)),
|
|
}
|
|
: null,
|
|
single: !isDual
|
|
? {
|
|
title: "Generation Info",
|
|
asset: getCached(assetSingle),
|
|
loading: shouldShowGenInfoLoading(getCached(assetSingle)),
|
|
}
|
|
: null,
|
|
});
|
|
|
|
// Hydrate logic
|
|
if (state._genInfoReqId !== reqId) return;
|
|
|
|
if (isDual) {
|
|
const lFull = assetLeft
|
|
? await ensureGenInfoAsset(assetLeft, { signal: ac.signal })
|
|
: null;
|
|
const rFull = assetRight
|
|
? await ensureGenInfoAsset(assetRight, { signal: ac.signal })
|
|
: null;
|
|
const cFull = assetC
|
|
? await ensureGenInfoAsset(assetC, { signal: ac.signal })
|
|
: null;
|
|
const dFull = assetD
|
|
? await ensureGenInfoAsset(assetD, { signal: ac.signal })
|
|
: null;
|
|
if (state._genInfoReqId !== reqId) return;
|
|
|
|
renderNow({
|
|
left: {
|
|
title: isGrid ? "Assets A & C" : "Asset A",
|
|
asset: lFull,
|
|
loading: false,
|
|
},
|
|
leftExtra: isGrid && cFull ? { asset: cFull, loading: false } : null,
|
|
right: {
|
|
title: isGrid ? "Assets B & D" : "Asset B",
|
|
asset: rFull,
|
|
loading: false,
|
|
},
|
|
rightExtra: isGrid && dFull ? { asset: dFull, loading: false } : null,
|
|
});
|
|
} else {
|
|
const sFull = assetSingle
|
|
? await ensureGenInfoAsset(assetSingle, { signal: ac.signal })
|
|
: null;
|
|
if (state._genInfoReqId !== reqId) return;
|
|
|
|
renderNow({
|
|
single: {
|
|
title: "Generation Info",
|
|
asset: sFull,
|
|
loading: false,
|
|
},
|
|
});
|
|
}
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
};
|
|
|
|
// No close button in panel (toggle via toolbar / shortcut).
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// Export frame (PNG download + clipboard copy)
|
|
// ----------------------------------------------------------------------------
|
|
|
|
// Frame exporter: lazily instantiated on first use since the module is
|
|
// dynamically loaded. By the time the user clicks "Export frame" the module
|
|
// will almost always have finished loading.
|
|
let _frameExporterInst = null;
|
|
function exportCurrentFrame(opts) {
|
|
try {
|
|
if (!_frameExporterInst && _mod_frameExport) {
|
|
_frameExporterInst = _mod_frameExport.createFrameExporter({
|
|
state,
|
|
VIEWER_MODES,
|
|
singleView,
|
|
abView,
|
|
sideView,
|
|
});
|
|
}
|
|
return _frameExporterInst?.exportCurrentFrame?.(opts);
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
}
|
|
|
|
// Smooth animation loop using requestAnimationFrame (only when needed)
|
|
// REMOVED: Animation loop (requestAnimationFrame) for performance
|
|
// Direct transform updates only
|
|
|
|
const renderBadges = () => {
|
|
const clear = (el) => {
|
|
try {
|
|
if (el) el.replaceChildren();
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
};
|
|
clear(badgesBar);
|
|
clear(badgesBarRight);
|
|
|
|
const makePill = (asset, { showName } = {}) => {
|
|
if (!asset) return null;
|
|
const pill = document.createElement("div");
|
|
pill.className = "mjr-viewer-asset-pill";
|
|
pill.style.cssText = `
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
padding: 2px 8px;
|
|
border-radius: 999px;
|
|
border: 1px solid rgba(255,255,255,0.14);
|
|
background: rgba(255,255,255,0.08);
|
|
font-size: 12px;
|
|
max-width: 360px;
|
|
overflow: hidden;
|
|
`;
|
|
|
|
const name = document.createElement("span");
|
|
name.textContent = String(asset.filename || "");
|
|
name.style.cssText =
|
|
"max-width:200px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; opacity:0.95;";
|
|
|
|
const extBadge = createFileBadge(
|
|
asset.filename,
|
|
asset.kind,
|
|
!!asset?._mjrNameCollision,
|
|
);
|
|
try {
|
|
extBadge.style.position = "static";
|
|
extBadge.style.top = "";
|
|
extBadge.style.left = "";
|
|
extBadge.style.padding = "2px 6px";
|
|
extBadge.style.fontSize = "10px";
|
|
extBadge.style.borderRadius = "6px";
|
|
extBadge.style.pointerEvents = "none";
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
|
|
const stars = createRatingBadge(asset.rating || 0);
|
|
if (stars) {
|
|
try {
|
|
stars.style.position = "static";
|
|
stars.style.top = "";
|
|
stars.style.right = "";
|
|
stars.style.padding = "2px 6px";
|
|
stars.style.fontSize = "12px";
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
}
|
|
|
|
const tags = createTagsBadge(Array.isArray(asset.tags) ? asset.tags : []);
|
|
if (tags) {
|
|
try {
|
|
tags.style.position = "static";
|
|
tags.style.bottom = "";
|
|
tags.style.left = "";
|
|
tags.style.maxWidth = "220px";
|
|
tags.style.pointerEvents = "none";
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
}
|
|
|
|
pill.appendChild(extBadge);
|
|
if (showName) pill.appendChild(name);
|
|
if (stars) pill.appendChild(stars);
|
|
if (tags && tags.style.display !== "none") pill.appendChild(tags);
|
|
|
|
try {
|
|
if (asset.filepath) pill.title = String(asset.filepath);
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
return pill;
|
|
};
|
|
|
|
const isSingle = state.mode === VIEWER_MODES.SINGLE;
|
|
const isAB = state.mode === VIEWER_MODES.AB_COMPARE && canAB();
|
|
const isSide = state.mode === VIEWER_MODES.SIDE_BY_SIDE && canSide();
|
|
|
|
// A/B and Side-by-side: when we have a right badge bar, split per side to keep the header readable.
|
|
if ((isAB || isSide) && badgesBarRight) {
|
|
const current = state.assets?.[state.currentIndex] || null;
|
|
const sideUsesCompare = isSide && state.compareAsset != null;
|
|
const leftAsset = isAB
|
|
? state.compareAsset != null
|
|
? current
|
|
: state.assets?.[0] || null
|
|
: sideUsesCompare
|
|
? current
|
|
: state.assets?.[0] || null;
|
|
const rightAsset = isAB
|
|
? state.compareAsset != null
|
|
? state.compareAsset
|
|
: state.assets?.[1] || null
|
|
: sideUsesCompare
|
|
? state.compareAsset
|
|
: state.assets?.[Math.max(0, (state.assets?.length || 1) - 1)] || null;
|
|
const leftPill = makePill(leftAsset, { showName: false });
|
|
const rightPill = makePill(rightAsset, { showName: false });
|
|
try {
|
|
if (leftPill) badgesBar.appendChild(leftPill);
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
try {
|
|
if (rightPill) badgesBarRight.appendChild(rightPill);
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
return;
|
|
}
|
|
|
|
const items = isSingle
|
|
? [state.assets[state.currentIndex]].filter(Boolean)
|
|
: Array.isArray(state.assets)
|
|
? state.assets.slice(0, 4)
|
|
: [];
|
|
|
|
for (const a of items) {
|
|
const pill = makePill(a, { showName: !isSingle });
|
|
if (!pill) continue;
|
|
try {
|
|
badgesBar.appendChild(pill);
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
}
|
|
};
|
|
|
|
function canAB() {
|
|
return (state.assets.length === 2 || state.compareAsset != null) && !compareIncludes3D();
|
|
}
|
|
|
|
function canSide() {
|
|
const n = state.assets.length;
|
|
return (n >= 2 && n <= 4) || (n >= 1 && state.compareAsset != null);
|
|
}
|
|
|
|
function applyDistractionFreeUI() {
|
|
const on = Boolean(state?.distractionFree);
|
|
try {
|
|
header.style.display = on ? "none" : "";
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
try {
|
|
footer.style.display = on ? "none" : "";
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
try {
|
|
overlay.classList.toggle("mjr-viewer-focus", on);
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
try {
|
|
if (on) {
|
|
overlay.style.paddingRight = "0px";
|
|
overlay.style.paddingLeft = "0px";
|
|
genInfoOverlay.style.display = "none";
|
|
genInfoOverlayLeft.style.display = "none";
|
|
}
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
}
|
|
|
|
// Update UI based on state
|
|
function updateUI() {
|
|
// FORCE RESET: Always reset zoom/pan when changing images
|
|
state.zoom = 1;
|
|
state.panX = 0;
|
|
state.panY = 0;
|
|
state.targetZoom = 1;
|
|
|
|
// Clear filmstrip compare asset when leaving compare modes
|
|
try {
|
|
if (
|
|
state.mode !== VIEWER_MODES.AB_COMPARE &&
|
|
state.mode !== VIEWER_MODES.SIDE_BY_SIDE &&
|
|
state.compareAsset != null
|
|
) {
|
|
state.compareAsset = null;
|
|
}
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
|
|
// Update filename
|
|
const current = state.assets[state.currentIndex];
|
|
const isAB = state.mode === VIEWER_MODES.AB_COMPARE && canAB();
|
|
const isSide = state.mode === VIEWER_MODES.SIDE_BY_SIDE && canSide();
|
|
// In filmstrip-compare AB mode, A = current navigated asset, B = compareAsset.
|
|
// In traditional AB mode (exactly 2 assets), A = assets[0], B = assets[1].
|
|
const isFilmCompareAB = isAB && state.compareAsset != null;
|
|
const isFilmCompareSide = isSide && state.compareAsset != null;
|
|
const leftAsset = isAB
|
|
? (isFilmCompareAB ? current : state.assets?.[0]) || null
|
|
: isSide
|
|
? (isFilmCompareSide ? current : state.assets?.[0]) || null
|
|
: current || null;
|
|
const rightAsset = isAB
|
|
? (isFilmCompareAB ? state.compareAsset : state.assets?.[1]) || null
|
|
: isSide
|
|
? isFilmCompareSide
|
|
? state.compareAsset
|
|
: Array.isArray(state.assets) && state.assets.length >= 2
|
|
? state.assets[state.assets.length - 1]
|
|
: null
|
|
: null;
|
|
try {
|
|
filename.textContent = leftAsset?.filename || "";
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
try {
|
|
if (rightMeta && filenameRight && rightAsset && rightAsset !== leftAsset) {
|
|
rightMeta.style.display = "flex";
|
|
if (rightArea) rightArea.style.display = "flex";
|
|
filenameRight.textContent = rightAsset?.filename || "";
|
|
// Compare mode: move titleLine to leftArea for proper 3-column layout
|
|
if (leftArea && leftMeta && titleLine) {
|
|
leftArea.style.display = "flex";
|
|
leftMeta.appendChild(titleLine);
|
|
titleLine.style.justifyContent = "flex-start";
|
|
}
|
|
if (headerTop) {
|
|
headerTop.style.justifyContent = "center";
|
|
headerTop.style.paddingLeft = "84px";
|
|
}
|
|
if (centerArea) centerArea.style.flex = "0 0 auto";
|
|
if (titleWrap) titleWrap.style.flex = "0 0 auto";
|
|
if (filename) filename.style.textAlign = "left";
|
|
} else if (rightMeta && filenameRight) {
|
|
rightMeta.style.display = "none";
|
|
if (rightArea) rightArea.style.display = "none";
|
|
filenameRight.textContent = "";
|
|
// Single mode: restore titleLine into titleWrap (before modeButtons)
|
|
if (leftArea) leftArea.style.display = "none";
|
|
if (titleWrap && titleLine && modeButtonsEl) {
|
|
titleWrap.insertBefore(titleLine, modeButtonsEl);
|
|
titleLine.style.justifyContent = "center";
|
|
}
|
|
if (headerTop) {
|
|
headerTop.style.justifyContent = "center";
|
|
headerTop.style.paddingLeft = "12px";
|
|
}
|
|
if (centerArea) centerArea.style.flex = "1 1 auto";
|
|
if (titleWrap) titleWrap.style.flex = "";
|
|
if (filename) filename.style.textAlign = "center";
|
|
}
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
// Update index
|
|
if (state.mode === VIEWER_MODES.AB_COMPARE && canAB()) {
|
|
indexInfo.textContent = "2 selected";
|
|
} else if (state.mode === VIEWER_MODES.SIDE_BY_SIDE && canSide()) {
|
|
indexInfo.textContent =
|
|
state.compareAsset != null ? "2 selected" : `${state.assets.length} selected`;
|
|
} else {
|
|
indexInfo.textContent = `${state.currentIndex + 1} / ${state.assets.length}`;
|
|
}
|
|
|
|
if (state.mode === VIEWER_MODES.AB_COMPARE && !canAB()) {
|
|
state.mode = canSide() ? VIEWER_MODES.SIDE_BY_SIDE : VIEWER_MODES.SINGLE;
|
|
}
|
|
if (state.mode === VIEWER_MODES.SIDE_BY_SIDE && !canSide())
|
|
state.mode = VIEWER_MODES.SINGLE;
|
|
try {
|
|
toolbar?.syncModeButtons?.({ canAB, canSide });
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
|
|
// Show/hide views
|
|
singleView.style.display = state.mode === VIEWER_MODES.SINGLE ? "flex" : "none";
|
|
abView.style.display = state.mode === VIEWER_MODES.AB_COMPARE ? "block" : "none";
|
|
sideView.style.display = state.mode === VIEWER_MODES.SIDE_BY_SIDE ? "flex" : "none";
|
|
|
|
// Cleanup hidden views to prevent processor loops and listener buildup.
|
|
try {
|
|
if (state.mode !== VIEWER_MODES.SINGLE) {
|
|
destroyMediaProcessorsIn(singleView);
|
|
singleView.replaceChildren();
|
|
}
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
try {
|
|
if (state.mode !== VIEWER_MODES.AB_COMPARE) {
|
|
destroyMediaProcessorsIn(abView);
|
|
abView.replaceChildren();
|
|
}
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
try {
|
|
if (state.mode !== VIEWER_MODES.SIDE_BY_SIDE) {
|
|
destroyMediaProcessorsIn(sideView);
|
|
sideView.replaceChildren();
|
|
}
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
|
|
renderBadges();
|
|
|
|
// Hide navigation for compare modes (selected-set compare).
|
|
const hideNav =
|
|
(state.mode === VIEWER_MODES.AB_COMPARE && canAB()) ||
|
|
(state.mode === VIEWER_MODES.SIDE_BY_SIDE && canSide());
|
|
try {
|
|
prevBtn.style.display = hideNav ? "none" : "";
|
|
nextBtn.style.display = hideNav ? "none" : "";
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
|
|
// Render current asset
|
|
renderAsset();
|
|
preloadAdjacentAssets(state.assets, state.currentIndex);
|
|
// Mount/unmount the video player bar depending on current media.
|
|
syncPlayerBar();
|
|
try {
|
|
toolbar?.syncToolsUIFromState?.();
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
try {
|
|
applyDistractionFreeUI();
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
try {
|
|
scheduleApplyGrade?.();
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
scheduleOverlayRedraw();
|
|
try {
|
|
void renderGenInfoPanel();
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
|
|
// Hydrate rating/tags for visible assets and refresh the badge bar.
|
|
try {
|
|
void hydrateVisibleMetadata().then(() => {
|
|
try {
|
|
renderBadges();
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
});
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
|
|
// Sync filmstrip highlight and scroll position.
|
|
try {
|
|
const isSingle = state.mode === VIEWER_MODES.SINGLE;
|
|
filmstrip.sync({ isSingle });
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
}
|
|
|
|
// Render the current asset based on mode
|
|
function renderAsset() {
|
|
const current = state.assets[state.currentIndex];
|
|
if (!current) return;
|
|
|
|
const viewUrl = buildAssetViewURL(current);
|
|
if (!viewUrl) {
|
|
try {
|
|
destroyMediaProcessorsIn(singleView);
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
try {
|
|
singleView.replaceChildren();
|
|
const err = document.createElement("div");
|
|
err.className = "mjr-viewer-media";
|
|
err.style.cssText =
|
|
"color:#ff9a9a; font-size:13px; padding:16px; text-align:center;";
|
|
err.textContent = "Cannot open asset: missing or invalid filename/path.";
|
|
singleView.appendChild(err);
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (state.mode === VIEWER_MODES.SINGLE) {
|
|
try {
|
|
destroyMediaProcessorsIn(singleView);
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
singleView.innerHTML = "";
|
|
state._mediaW = 0;
|
|
state._mediaH = 0;
|
|
const media = createMediaElement(current, viewUrl);
|
|
singleView.appendChild(media);
|
|
} else if (state.mode === VIEWER_MODES.AB_COMPARE) {
|
|
if (canAB()) {
|
|
_mod_abCompare?.renderABCompareView?.({
|
|
abView,
|
|
state,
|
|
currentAsset: current,
|
|
viewUrl,
|
|
buildAssetViewURL,
|
|
createCompareMediaElement,
|
|
destroyMediaProcessorsIn,
|
|
});
|
|
}
|
|
} else if (state.mode === VIEWER_MODES.SIDE_BY_SIDE) {
|
|
if (canSide()) {
|
|
_mod_sideBySide?.renderSideBySideView?.({
|
|
sideView,
|
|
state,
|
|
currentAsset: current,
|
|
viewUrl,
|
|
buildAssetViewURL,
|
|
createMediaElement,
|
|
destroyMediaProcessorsIn,
|
|
});
|
|
}
|
|
}
|
|
applyTransform();
|
|
updatePanCursor();
|
|
}
|
|
|
|
const {
|
|
preloadAdjacentAssets,
|
|
preloadImageForAsset: _preloadImageForAsset,
|
|
trackPreloadRef: _trackPreloadRef,
|
|
} = createImagePreloader({
|
|
buildAssetViewURL,
|
|
IMAGE_PRELOAD_EXTENSIONS,
|
|
state,
|
|
});
|
|
|
|
const { destroyPlayerBar, syncPlayerBar: _syncPlayerBarImpl } = createPlayerBarManager({
|
|
state,
|
|
APP_CONFIG,
|
|
VIEWER_MODES,
|
|
overlay,
|
|
navBar,
|
|
playerBarHost,
|
|
singleView,
|
|
abView,
|
|
sideView,
|
|
metadataHydrator,
|
|
isPlayableViewerKind,
|
|
collectPlayableMediaElements,
|
|
pickPrimaryPlayableMedia,
|
|
mountUnifiedMediaControls,
|
|
installFollowerVideoSync,
|
|
getViewerInfo,
|
|
scheduleOverlayRedraw,
|
|
viewerInfoCacheGet: _viewerInfoCacheGet,
|
|
viewerInfoCacheSet: _viewerInfoCacheSet,
|
|
});
|
|
const syncPlayerBar = () => _syncPlayerBarImpl();
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// Image processing (Nuke-like): exposure, gamma, channels, false color, zebra
|
|
// ----------------------------------------------------------------------------
|
|
|
|
const MAX_PROC_PIXELS = APP_CONFIG.VIEWER_MAX_PROC_PIXELS ?? 12_000_000;
|
|
|
|
const getGradeParams = () => ({
|
|
exposureEV: Number(state.exposureEV) || 0,
|
|
gamma: Math.max(0.1, Math.min(3, Number(state.gamma) || 1)),
|
|
channel: state.channel || "rgb",
|
|
analysisMode: state.analysisMode || "none",
|
|
zebraThreshold: Math.max(0, Math.min(1, Number(state.zebraThreshold) || 0.95)),
|
|
});
|
|
|
|
const applyGradeToVisibleMedia = () => {
|
|
const params = getGradeParams();
|
|
try {
|
|
const els = overlay.querySelectorAll(".mjr-viewer-media");
|
|
for (const el of els) {
|
|
try {
|
|
const proc = el?._mjrProc;
|
|
if (proc?.setParams) proc.setParams(params);
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
// If A/B compare is showing a computed diff canvas, force a refresh after grade changes.
|
|
try {
|
|
if (state?.mode === VIEWER_MODES.AB_COMPARE) {
|
|
abView?._mjrDiffRequest?.();
|
|
}
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
};
|
|
|
|
const isDefaultGrade = (params) => {
|
|
try {
|
|
if (!params) return true;
|
|
const exposureEV = Number(params.exposureEV) || 0;
|
|
const gamma = Number(params.gamma) || 1;
|
|
const channel = String(params.channel || "rgb");
|
|
const analysisMode = String(params.analysisMode || "none");
|
|
return (
|
|
Math.abs(exposureEV) < 0.0001 &&
|
|
Math.abs(gamma - 1) < 0.0001 &&
|
|
channel === "rgb" &&
|
|
analysisMode === "none"
|
|
);
|
|
} catch {
|
|
return true;
|
|
}
|
|
};
|
|
|
|
const MAX_PROC_PIXELS_VIDEO = APP_CONFIG.VIEWER_MAX_PROC_PIXELS_VIDEO ?? 3_000_000;
|
|
const VIDEO_GRADE_THROTTLE_FPS = APP_CONFIG.VIEWER_VIDEO_GRADE_THROTTLE_FPS ?? 15;
|
|
|
|
try {
|
|
mediaFactory = createViewerMediaFactory({
|
|
overlay,
|
|
state,
|
|
mediaTransform,
|
|
updateMediaNaturalSize,
|
|
clampPanToBounds,
|
|
applyTransform,
|
|
scheduleOverlayRedraw,
|
|
getGradeParams,
|
|
isDefaultGrade,
|
|
tonemap: null,
|
|
maxProcPixels: MAX_PROC_PIXELS,
|
|
maxProcPixelsVideo: MAX_PROC_PIXELS_VIDEO,
|
|
disableWebGL: !!APP_CONFIG.VIEWER_DISABLE_WEBGL_VIDEO,
|
|
videoGradeThrottleFps: VIDEO_GRADE_THROTTLE_FPS,
|
|
safeAddListener,
|
|
safeCall,
|
|
});
|
|
} catch {
|
|
mediaFactory = null;
|
|
}
|
|
|
|
// Navigation
|
|
lifecycleUnsubs.push(
|
|
safeAddListener(prevBtn, "click", () => {
|
|
if (state.currentIndex > 0) {
|
|
state.currentIndex--;
|
|
updateUI();
|
|
}
|
|
}),
|
|
);
|
|
|
|
lifecycleUnsubs.push(
|
|
safeAddListener(nextBtn, "click", () => {
|
|
if (state.currentIndex < state.assets.length - 1) {
|
|
state.currentIndex++;
|
|
updateUI();
|
|
}
|
|
}),
|
|
);
|
|
|
|
// Nuke-like processing controls (images only): channel/exposure/gamma/analysis.
|
|
let _gradeRAF = null;
|
|
const scheduleApplyGrade = () => {
|
|
try {
|
|
if (_gradeRAF != null) return;
|
|
_gradeRAF = requestAnimationFrame(() => {
|
|
_gradeRAF = null;
|
|
try {
|
|
applyGradeToVisibleMedia();
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
});
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
};
|
|
|
|
const syncToolsUIFromState = () => {
|
|
try {
|
|
toolbar?.syncToolsUIFromState?.();
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
};
|
|
|
|
// Mouse wheel zoom (trackpad-friendly). Capture + preventDefault so ComfyUI canvas doesn't zoom underneath.
|
|
const navigateViewerAssets = (direction) => {
|
|
if (!Array.isArray(state.assets) || state.assets.length === 0) {
|
|
return false;
|
|
}
|
|
const nextIndex = state.currentIndex + direction;
|
|
if (nextIndex < 0 || nextIndex >= state.assets.length) {
|
|
return false;
|
|
}
|
|
state.currentIndex = nextIndex;
|
|
updateUI();
|
|
return true;
|
|
};
|
|
|
|
const onWheelZoom = (e) => {
|
|
if (overlay.style.display === "none") return;
|
|
try {
|
|
const t = e.target;
|
|
if (
|
|
t &&
|
|
(t.tagName === "INPUT" ||
|
|
t.tagName === "TEXTAREA" ||
|
|
t.tagName === "SELECT" ||
|
|
t.isContentEditable)
|
|
) {
|
|
return;
|
|
}
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
|
|
// Only zoom when hovering the viewer content area.
|
|
try {
|
|
if (!content.contains(e.target)) return;
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
try {
|
|
if (_mod_model3d?.isModel3DInteractionTarget?.(e?.target)) return;
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
|
|
try {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
e.stopImmediatePropagation?.();
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
|
|
// Shift+wheel or horizontal scroll (trackpad) = navigate assets
|
|
const dx = Number(e.deltaX) || 0;
|
|
const dy = Number(e.deltaY) || 0;
|
|
|
|
if (e.shiftKey && dy) {
|
|
const direction = dy > 0 ? 1 : -1;
|
|
if (navigateViewerAssets(direction)) return;
|
|
}
|
|
|
|
// Horizontal scroll (trackpad swipe) navigates between assets
|
|
if (Math.abs(dx) > Math.abs(dy) && Math.abs(dx) > 30) {
|
|
const direction = dx > 0 ? 1 : -1;
|
|
if (navigateViewerAssets(direction)) return;
|
|
}
|
|
|
|
if (!dy) return;
|
|
|
|
// Smooth scaling: wheel down -> zoom out, wheel up -> zoom in.
|
|
const factor = Math.exp(-dy * 0.0015);
|
|
const next = (Number(state.zoom) || 1) * factor;
|
|
setZoom(next, { clientX: e.clientX, clientY: e.clientY });
|
|
// setZoom now handles transform application directly
|
|
};
|
|
|
|
// Pixel probe + loupe sampling (Radiance-inspired).
|
|
|
|
const positionOverlayBox = (el, clientX, clientY, { offsetX = 16, offsetY = 16 } = {}) => {
|
|
try {
|
|
const v = getViewportRect();
|
|
if (!v) return;
|
|
const vp = content.getBoundingClientRect();
|
|
const x0 = (Number(clientX) || 0) - vp.left;
|
|
const y0 = (Number(clientY) || 0) - vp.top;
|
|
const w = Number(el.offsetWidth) || 0;
|
|
const h = Number(el.offsetHeight) || 0;
|
|
let x = x0 + offsetX;
|
|
let y = y0 + offsetY;
|
|
const pad = 10;
|
|
x = Math.max(pad, Math.min(x, v.width - w - pad));
|
|
y = Math.max(pad, Math.min(y, v.height - h - pad));
|
|
el.style.left = `${Math.round(x)}px`;
|
|
el.style.top = `${Math.round(y)}px`;
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
};
|
|
|
|
const loupe = createViewerLoupe({
|
|
state,
|
|
loupeCanvas,
|
|
loupeWrap,
|
|
getMediaNaturalSize,
|
|
positionOverlayBox,
|
|
});
|
|
|
|
const probe = installViewerProbe({
|
|
overlay,
|
|
content,
|
|
state,
|
|
VIEWER_MODES,
|
|
getPrimaryMedia,
|
|
getMediaNaturalSize,
|
|
getViewportRect,
|
|
positionOverlayBox,
|
|
probeTooltip,
|
|
loupeWrap,
|
|
onLoupeRedraw: loupe.redraw,
|
|
lifecycle,
|
|
});
|
|
|
|
// Resize observer so grid canvas stays crisp and overlays remain aligned.
|
|
try {
|
|
if (!content._mjrOverlayResizeBound && "ResizeObserver" in window) {
|
|
try {
|
|
overlay._mjrResizeObserver?.disconnect?.();
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
const ro = new ResizeObserver(() => {
|
|
try {
|
|
state._viewportCache = null;
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
scheduleOverlayRedraw();
|
|
});
|
|
try {
|
|
ro.observe(content);
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
overlay._mjrResizeObserver = ro;
|
|
lifecycleUnsubs.push(() => {
|
|
try {
|
|
ro.disconnect();
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
});
|
|
content._mjrOverlayResizeBound = true;
|
|
}
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
|
|
// Keyboard shortcuts (installed on capture phase to avoid ComfyUI/global handlers eating events).
|
|
const keyboard = installViewerKeyboard({
|
|
overlay,
|
|
content,
|
|
singleView,
|
|
state,
|
|
VIEWER_MODES,
|
|
computeOneToOneZoom,
|
|
setZoom,
|
|
scheduleOverlayRedraw,
|
|
scheduleApplyGrade,
|
|
syncToolsUIFromState,
|
|
applyDistractionFreeUI,
|
|
navigateViewerAssets,
|
|
closeViewer,
|
|
renderBadges,
|
|
updateAssetRating,
|
|
safeDispatchCustomEvent,
|
|
ASSET_RATING_CHANGED_EVENT,
|
|
probeTooltip,
|
|
loupeWrap,
|
|
renderGenInfoPanel,
|
|
getVideoControls: () => {
|
|
try {
|
|
return state?._videoControlsMounted || null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
},
|
|
lifecycle,
|
|
});
|
|
|
|
// Bind/unbind global listeners only while the viewer is visible.
|
|
// Prevents hotkeys/scroll from leaking to ComfyUI/browser when the viewer is closed.
|
|
let _openUnsubs = [];
|
|
const disposeOpenListeners = () => {
|
|
try {
|
|
for (const u of _openUnsubs) safeCall(u);
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
_openUnsubs = [];
|
|
try {
|
|
keyboard?.unbind?.();
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
};
|
|
const bindOpenListeners = () => {
|
|
disposeOpenListeners();
|
|
try {
|
|
_openUnsubs.push(
|
|
safeAddListener(overlay, "click", (e) => {
|
|
try {
|
|
if (e.target !== overlay) return;
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
closeViewer();
|
|
}),
|
|
);
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
try {
|
|
_openUnsubs.push(
|
|
safeAddListener(content, "wheel", onWheelZoom, { passive: false, capture: true }),
|
|
);
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
// Touch swipe navigation (swipe left/right to navigate assets)
|
|
try {
|
|
let _touchStart = null;
|
|
const SWIPE_THRESHOLD = 60; // minimum px to trigger swipe
|
|
const SWIPE_MAX_VERTICAL = 80; // ignore if too much vertical movement
|
|
|
|
_openUnsubs.push(
|
|
safeAddListener(content, "touchstart", (e) => {
|
|
try {
|
|
if (e.touches?.length !== 1) return;
|
|
const t = e.touches[0];
|
|
_touchStart = { x: t.clientX, y: t.clientY, t: Date.now() };
|
|
} catch (ex) {
|
|
console.debug?.(ex);
|
|
}
|
|
}, { passive: true }),
|
|
);
|
|
|
|
_openUnsubs.push(
|
|
safeAddListener(content, "touchend", (e) => {
|
|
try {
|
|
if (!_touchStart) return;
|
|
if (e.changedTouches?.length !== 1) {
|
|
_touchStart = null;
|
|
return;
|
|
}
|
|
const t = e.changedTouches[0];
|
|
const dx = t.clientX - _touchStart.x;
|
|
const dy = t.clientY - _touchStart.y;
|
|
const elapsed = Date.now() - _touchStart.t;
|
|
_touchStart = null;
|
|
|
|
// Ignore if too slow (>600ms) or too much vertical movement
|
|
if (elapsed > 600 || Math.abs(dy) > SWIPE_MAX_VERTICAL) return;
|
|
|
|
// Check swipe direction
|
|
if (Math.abs(dx) >= SWIPE_THRESHOLD) {
|
|
const direction = dx < 0 ? 1 : -1; // swipe left = next
|
|
navigateViewerAssets(direction);
|
|
}
|
|
} catch (ex) {
|
|
console.debug?.(ex);
|
|
}
|
|
}, { passive: true }),
|
|
);
|
|
|
|
_openUnsubs.push(
|
|
safeAddListener(content, "touchcancel", () => {
|
|
_touchStart = null;
|
|
}, { passive: true }),
|
|
);
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
try {
|
|
_openUnsubs.push(
|
|
safeAddListener(
|
|
content,
|
|
"mousemove",
|
|
(e) => {
|
|
try {
|
|
state._lastPointerX = e.clientX;
|
|
state._lastPointerY = e.clientY;
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
},
|
|
{ passive: true, capture: true },
|
|
),
|
|
);
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
try {
|
|
keyboard?.bind?.();
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
};
|
|
|
|
// Keep viewer badges in sync when tags/ratings change elsewhere (sidebar, panel hotkeys, etc.).
|
|
try {
|
|
if (!overlay._mjrBadgeSyncBound) {
|
|
const onRatingSync = (e) => {
|
|
try {
|
|
const id = e?.detail?.assetId;
|
|
const rating = e?.detail?.rating;
|
|
if (id == null) return;
|
|
for (const a of state.assets || []) {
|
|
if (a?.id != null && String(a.id) === String(id)) {
|
|
a.rating = rating;
|
|
}
|
|
}
|
|
try {
|
|
metadataHydrator?.deleteCached?.(id);
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
renderBadges();
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
};
|
|
const onTagsSync = (e) => {
|
|
try {
|
|
const id = e?.detail?.assetId;
|
|
const tags = e?.detail?.tags;
|
|
if (id == null) return;
|
|
for (const a of state.assets || []) {
|
|
if (a?.id != null && String(a.id) === String(id)) {
|
|
a.tags = tags;
|
|
}
|
|
}
|
|
try {
|
|
metadataHydrator?.deleteCached?.(id);
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
renderBadges();
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
};
|
|
lifecycleUnsubs.push(
|
|
safeAddListener(window, ASSET_RATING_CHANGED_EVENT, onRatingSync, {
|
|
passive: true,
|
|
}),
|
|
);
|
|
lifecycleUnsubs.push(
|
|
safeAddListener(window, ASSET_TAGS_CHANGED_EVENT, onTagsSync, { passive: true }),
|
|
);
|
|
overlay._mjrBadgeSyncBound = true;
|
|
}
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
|
|
function closeViewer() {
|
|
try {
|
|
state.distractionFree = false;
|
|
applyDistractionFreeUI();
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
try {
|
|
metadataHydrator?.abort?.();
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
try {
|
|
destroyPlayerBar();
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
try {
|
|
state._scopesVideoAbort?.abort?.();
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
state._scopesVideoAbort = null;
|
|
// Clear any pending hint timers (avoid callbacks after close/dispose).
|
|
try {
|
|
if (state._panHintTimer) clearTimeout(state._panHintTimer);
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
state._panHintTimer = null;
|
|
try {
|
|
state._panHintAt = 0;
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
|
|
// Abort compare-mode background work (sync listeners, diff loops).
|
|
try {
|
|
abView?._mjrSyncAbort?.abort?.();
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
try {
|
|
abView?._mjrDiffAbort?.abort?.();
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
try {
|
|
abView._mjrSyncAbort = null;
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
try {
|
|
abView._mjrDiffAbort = null;
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
try {
|
|
sideView?._mjrSyncAbort?.abort?.();
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
try {
|
|
sideView._mjrSyncAbort = null;
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
try {
|
|
abView?._mjrSliderAbort?.abort?.();
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
try {
|
|
abView._mjrSliderAbort = null;
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
// Ensure media playback is fully stopped when closing the overlay.
|
|
// Hiding the overlay alone does not stop HTMLMediaElement audio in all browsers.
|
|
try {
|
|
const mediaEls = overlay.querySelectorAll?.("video, audio");
|
|
if (mediaEls && mediaEls.length) {
|
|
for (const el of mediaEls) {
|
|
try {
|
|
el.muted = true;
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
try {
|
|
el.pause?.();
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
try {
|
|
el.currentTime = 0;
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
try {
|
|
// Remove all <source> children (if any) and unload the element.
|
|
const sources = el.querySelectorAll?.("source");
|
|
if (sources && sources.length) {
|
|
for (const s of sources) {
|
|
try {
|
|
s.remove();
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
try {
|
|
el.removeAttribute?.("src");
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
try {
|
|
// Some browsers keep playing unless we force a reload after removing src.
|
|
el.load?.();
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
|
|
// Free DOM resources (and ensure playback stops even if `pause()` was ignored).
|
|
try {
|
|
destroyMediaProcessorsIn(singleView);
|
|
singleView.replaceChildren();
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
try {
|
|
destroyMediaProcessorsIn(abView);
|
|
abView.replaceChildren();
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
try {
|
|
destroyMediaProcessorsIn(sideView);
|
|
sideView.replaceChildren();
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
|
|
try {
|
|
state.genInfoOpen = false;
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
try {
|
|
stopGenInfoFetch();
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
try {
|
|
genInfoOverlay.style.display = "none";
|
|
clearGenInfoBody(genInfoBody);
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
try {
|
|
genInfoOverlayLeft.style.display = "none";
|
|
clearGenInfoBody(genInfoBodyLeft);
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
|
|
overlay.style.display = "none";
|
|
overlay.style.pointerEvents = "none";
|
|
disposeOpenListeners();
|
|
try {
|
|
document.body.style.overflow = state._prevBodyOverflow ?? "";
|
|
} catch {
|
|
document.body.style.overflow = "";
|
|
}
|
|
|
|
// FIX: Restore focus to previously focused element for accessibility
|
|
try {
|
|
if (
|
|
state._prevFocusedElement &&
|
|
typeof state._prevFocusedElement.focus === "function"
|
|
) {
|
|
state._prevFocusedElement.focus();
|
|
}
|
|
state._prevFocusedElement = null;
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
|
|
// Restore previous hotkeys scope if available.
|
|
const prevScope = state?._prevHotkeyScope;
|
|
setHotkeysScope(prevScope || "panel");
|
|
state._prevHotkeyScope = null;
|
|
}
|
|
|
|
// Public API
|
|
const api = {
|
|
open(assets, startIndex = 0, compareAsset = null) {
|
|
bindOpenListeners();
|
|
state.assets = Array.isArray(assets) ? assets : [assets];
|
|
state.currentIndex = Math.max(0, Math.min(startIndex, state.assets.length - 1));
|
|
state.distractionFree = false;
|
|
// Rebuild the filmstrip thumbnail strip for the new asset list.
|
|
try {
|
|
filmstrip.rebuild();
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
state.zoom = 1;
|
|
state.panX = 0;
|
|
state.panY = 0;
|
|
state.targetZoom = 1;
|
|
state._userInteracted = false;
|
|
state._panHintAt = 0;
|
|
try {
|
|
if (state._panHintTimer) clearTimeout(state._panHintTimer);
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
state._panHintTimer = null;
|
|
state._lastPointerX = null;
|
|
state._lastPointerY = null;
|
|
state._mediaW = 0;
|
|
state._mediaH = 0;
|
|
state.compareAsset = compareAsset;
|
|
state.gridMode = 0;
|
|
stopGenInfoFetch();
|
|
state._probe = null;
|
|
try {
|
|
probeTooltip.style.display = "none";
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
try {
|
|
loupeWrap.style.display = "none";
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
|
|
overlay.style.display = "flex";
|
|
overlay.style.pointerEvents = "auto";
|
|
|
|
// FIX: Store previously focused element and set focus to overlay for accessibility
|
|
try {
|
|
state._prevFocusedElement = document.activeElement;
|
|
} catch {
|
|
state._prevFocusedElement = null;
|
|
}
|
|
|
|
// Focus the overlay so screen readers announce it as a dialog
|
|
overlay.focus();
|
|
|
|
try {
|
|
state._prevBodyOverflow = document.body.style.overflow;
|
|
} catch {
|
|
state._prevBodyOverflow = "";
|
|
}
|
|
document.body.style.overflow = "hidden";
|
|
|
|
// Set hotkeys scope to viewer (takes priority over panel)
|
|
state._prevHotkeyScope = getHotkeysState().scope || null;
|
|
setHotkeysScope("viewer");
|
|
|
|
updateUI();
|
|
try {
|
|
syncToolsUIFromState();
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
try {
|
|
scheduleApplyGrade();
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
scheduleOverlayRedraw();
|
|
// No animation loop needed - transforms are applied directly
|
|
},
|
|
|
|
close() {
|
|
closeViewer();
|
|
// No animation loop to stop
|
|
},
|
|
|
|
setMode(mode) {
|
|
if (Object.values(VIEWER_MODES).includes(mode)) {
|
|
state.mode = mode;
|
|
updateUI();
|
|
}
|
|
},
|
|
|
|
setCompareAsset(asset) {
|
|
state.compareAsset = asset;
|
|
updateUI();
|
|
},
|
|
|
|
dispose() {
|
|
// Best-effort: never throw to UI.
|
|
try {
|
|
closeViewer();
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
try {
|
|
_viewerInfoCache.clear();
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
|
|
try {
|
|
destroyMediaProcessorsIn(singleView);
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
try {
|
|
destroyMediaProcessorsIn(abView);
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
try {
|
|
destroyMediaProcessorsIn(sideView);
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
|
|
try {
|
|
if (_overlayRAF != null) cancelAnimationFrame(_overlayRAF);
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
try {
|
|
if (_gradeRAF != null) cancelAnimationFrame(_gradeRAF);
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
try {
|
|
probe?.dispose?.();
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
try {
|
|
keyboard?.dispose?.();
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
|
|
try {
|
|
overlay._mjrResizeObserver?.disconnect?.();
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
try {
|
|
overlay._mjrResizeObserver = null;
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
try {
|
|
metadataHydrator?.dispose?.();
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
try {
|
|
unbindViewerContextMenu(overlay);
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
|
|
try {
|
|
for (const u of overlay._mjrViewerUnsubs || []) safeCall(u);
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
try {
|
|
overlay._mjrViewerUnsubs = [];
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
try {
|
|
state._preloadRefs?.clear?.();
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
try {
|
|
state._preloadedAssetKeys?.clear?.();
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
|
|
try {
|
|
overlay.remove?.();
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
},
|
|
};
|
|
|
|
// Keep toolbar close non-destructive so the singleton viewer can reopen reliably.
|
|
try {
|
|
_requestCloseFromButton = () => api.close();
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
|
|
overlay._mjrViewerAPI = api;
|
|
|
|
// Right-click context menu (viewer): tags/rating/open-in-folder/copy path.
|
|
try {
|
|
bindViewerContextMenu({
|
|
overlayEl: overlay,
|
|
getCurrentAsset: () => state.assets[state.currentIndex],
|
|
getCurrentViewUrl: (asset) => buildAssetViewURL(asset),
|
|
onAssetChanged: () => {
|
|
try {
|
|
renderBadges();
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
},
|
|
});
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
|
|
return overlay;
|
|
}
|
|
|
|
/**
|
|
* Helper to create icon button
|
|
*/
|
|
|
|
/**
|
|
* Get or create global viewer instance
|
|
*/
|
|
export function getViewerInstance() {
|
|
return _getViewerInstance(createViewer);
|
|
}
|