1940 lines
70 KiB
TypeScript
1940 lines
70 KiB
TypeScript
/**
|
|
* FloatingViewer (MFV) - Majoor Floating Viewer
|
|
*
|
|
* Lightweight floating panel: drag, CSS resize, 3 modes (Simple / A/B / Side-by-Side),
|
|
* Live Stream toggle, and mouse-wheel zoom + click-drag pan.
|
|
* Styled via theme-comfy.css (.mjr-mfv scope).
|
|
*/
|
|
|
|
import { installFollowerVideoSync } from "./videoSync.js";
|
|
import { isModel3DInteractionTarget } from "./model3dRenderer.js";
|
|
import {
|
|
MFV_MODES,
|
|
MFV_ZOOM_FACTOR,
|
|
MFV_ZOOM_MAX,
|
|
MFV_ZOOM_MIN,
|
|
} from "./floatingViewerConstants.js";
|
|
import {
|
|
buildFloatingViewerMediaElement as _buildMediaEl,
|
|
canFloatingViewerWheelScroll as _canWheelScrollElement,
|
|
createFloatingViewerEmptyState as _makeEmptyState,
|
|
createFloatingViewerSlotLabel as _makeLabel,
|
|
findFloatingViewerScrollableAncestor as _findScrollableAncestor,
|
|
getFloatingViewerMediaKind as _mediaKind,
|
|
pauseFloatingViewerMediaIn as _pauseMediaIn,
|
|
} from "./floatingViewerMedia.js";
|
|
import {
|
|
applyFloatingViewerSidebarPosition,
|
|
bindFloatingViewerDocumentUiHandlers,
|
|
buildFloatingViewerGenInfoDOM,
|
|
buildFloatingViewerHeader,
|
|
buildFloatingViewerToolbar,
|
|
getFloatingViewerGenFields,
|
|
rebindFloatingViewerControlHandlers,
|
|
renderFloatingViewer,
|
|
unbindFloatingViewerDocumentUiHandlers,
|
|
updateFloatingViewerGenButtonUI,
|
|
updateFloatingViewerSettingsBtnState,
|
|
} from "./floatingViewerUi.js";
|
|
import {
|
|
cycleFloatingViewerMode,
|
|
getFloatingViewerPinnedSlots,
|
|
loadFloatingViewerPreviewBlob,
|
|
notifyFloatingViewerModeChanged,
|
|
revokeFloatingViewerPreviewBlob,
|
|
setFloatingViewerLiveActive,
|
|
setFloatingViewerMode,
|
|
setFloatingViewerNodeStreamActive,
|
|
setFloatingViewerPreviewActive,
|
|
updateFloatingViewerModeButtonUI,
|
|
updateFloatingViewerPinUI,
|
|
} from "./floatingViewerMode.js";
|
|
import {
|
|
activateFloatingViewerDesktopExpandedFallback,
|
|
clearFloatingViewerPopoutCloseWatch,
|
|
fallbackPopoutFloatingViewer,
|
|
installFloatingViewerPopoutStyles,
|
|
popInFloatingViewer,
|
|
popOutFloatingViewer,
|
|
scheduleFloatingViewerPopInFromPopupClose,
|
|
setFloatingViewerDesktopExpanded,
|
|
startFloatingViewerPopoutCloseWatch,
|
|
tryFloatingViewerElectronPopupFallback,
|
|
updateFloatingViewerPopoutButtonUI,
|
|
} from "./floatingViewerPopout.js";
|
|
import {
|
|
bindFloatingViewerPanelInteractions,
|
|
getFloatingViewerResizeCursor,
|
|
getFloatingViewerResizeDirectionFromPoint,
|
|
initFloatingViewerDrag,
|
|
initFloatingViewerEdgeResize,
|
|
stopFloatingViewerEdgeResize,
|
|
} from "./floatingViewerPanel.js";
|
|
import {
|
|
captureFloatingViewerView,
|
|
drawFloatingViewerGenInfoOverlay,
|
|
drawFloatingViewerMediaFit,
|
|
estimateFloatingViewerGenInfoOverlayHeight,
|
|
} from "./floatingViewerCapture.js";
|
|
import {
|
|
loadFloatingViewerMediaA,
|
|
loadFloatingViewerMediaPair,
|
|
loadFloatingViewerMediaQuad,
|
|
} from "./floatingViewerLoader.js";
|
|
import { disposeFloatingViewerProgressBar } from "./floatingViewerProgress.js";
|
|
import { WorkflowGraphMapPanel } from "./workflowGraphMap/WorkflowGraphMapPanel.js";
|
|
import { buildGenerationSectionState } from "../../vue/components/panel/sidebar/generationSectionState.js";
|
|
|
|
function _hasSimplePlayerControls(mediaEl: any) {
|
|
try {
|
|
return (
|
|
!!mediaEl?.classList?.contains("mjr-mfv-simple-player") ||
|
|
!!mediaEl?.classList?.contains("mjr-mfv-player-host") ||
|
|
!!mediaEl?.querySelector?.(".mjr-video-controls, .mjr-mfv-simple-player-controls")
|
|
);
|
|
} catch (e: any) {
|
|
console.debug?.(e);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export { MFV_MODES } from "./floatingViewerConstants.js";
|
|
|
|
let _mfvInstanceSeq = 0;
|
|
|
|
export class FloatingViewer {
|
|
[key: string]: any;
|
|
constructor({ controller = null } = {}) {
|
|
this._instanceId = ++_mfvInstanceSeq;
|
|
this._controller = controller && typeof controller === "object" ? { ...(controller as Record<string, any>) } : null;
|
|
this.element = null;
|
|
this.isVisible = false;
|
|
this._contentEl = null;
|
|
this._genSidebarEl = null;
|
|
this._closeBtn = null;
|
|
this._modeBtn = null;
|
|
this._pinGroup = null;
|
|
this._pinBtns = null;
|
|
this._liveBtn = null;
|
|
this._genBtn = null;
|
|
this._genDropdown = null;
|
|
this._genSidebarEnabled = true;
|
|
this._captureBtn = null;
|
|
this._genInfoSelections = new Set([
|
|
"prompt",
|
|
"seed",
|
|
"model",
|
|
"lora",
|
|
"sampler",
|
|
"scheduler",
|
|
"cfg",
|
|
"step",
|
|
"genTime",
|
|
]);
|
|
this._mode = MFV_MODES.SIMPLE;
|
|
this._mediaA = null;
|
|
this._mediaB = null;
|
|
this._mediaC = null;
|
|
this._mediaD = null;
|
|
this._pinnedSlots = new Set();
|
|
this._abDividerX = 0.5; // 0..1
|
|
|
|
// Persisted media playback state - intentionally NOT reset on navigation so
|
|
// mute/speed survive moving between assets with the arrow keys.
|
|
this._mfvMuted = false;
|
|
this._mfvPlaybackRate = 1;
|
|
|
|
// Pan/zoom state
|
|
this._zoom = 1;
|
|
this._panX = 0;
|
|
this._panY = 0;
|
|
this._panzoomAC = null; // AbortController for event cleanup
|
|
this._dragging = false;
|
|
this._compareSyncAC = null;
|
|
|
|
// AbortController for toolbar/header button click listeners (NM-1).
|
|
// Aborted in dispose() so listeners are cleaned up without needing named references.
|
|
this._btnAC = null;
|
|
// Generation counter: incremented on every loadMediaA/loadMediaPair call so
|
|
// stale async metadata enrichment results can be discarded (NM-2).
|
|
this._refreshGen = 0;
|
|
|
|
// Pop-out state: external window reference and button.
|
|
this._popoutWindow = null;
|
|
this._popoutBtn = null;
|
|
this._isPopped = false;
|
|
this._desktopExpanded = false;
|
|
this._desktopExpandRestore = null;
|
|
this._desktopPopoutUnsupported = false;
|
|
this._popoutCloseHandler = null;
|
|
this._popoutKeydownHandler = null;
|
|
this._popoutCloseTimer = null;
|
|
this._popoutRestoreGuard = false;
|
|
|
|
// Preview stream state: button ref + last blob URL for cleanup.
|
|
this._previewBtn = null;
|
|
this._previewBlobUrl = null;
|
|
this._previewActive = false;
|
|
|
|
// Node stream state: button ref + currently selected node overlay.
|
|
this._nodeStreamBtn = null;
|
|
this._nodeStreamActive = false;
|
|
/** @type {{ nodeId: string, classType: string, title?: string } | null} */
|
|
this._nodeStreamSelection = null;
|
|
this._nodeStreamOverlayEl = null;
|
|
|
|
// Master AbortController for document-level UI handlers (e.g. click-outside).
|
|
// Aborted in dispose() to guarantee all listeners are removed atomically.
|
|
this._docAC = new AbortController();
|
|
// AbortController for pop-out window listeners (beforeunload, keydown, etc.).
|
|
this._popoutAC = null;
|
|
|
|
// Panel-level listeners and edge-resize state.
|
|
this._panelAC = new AbortController();
|
|
this._resizeState = null;
|
|
this._titleId = `mjr-mfv-title-${this._instanceId}`;
|
|
this._genDropdownId = `mjr-mfv-gen-dropdown-${this._instanceId}`;
|
|
this._progressEl = null;
|
|
this._progressNodesEl = null;
|
|
this._progressStepsEl = null;
|
|
this._progressTextEl = null;
|
|
this._mediaProgressEl = null;
|
|
this._mediaProgressTextEl = null;
|
|
this._progressUpdateHandler = null;
|
|
this._progressCurrentNodeId = null;
|
|
this._docClickHost = null;
|
|
this._handleDocClick = null;
|
|
this._mediaControlHandles = [];
|
|
this._layoutObserver = null;
|
|
this._channel = "rgb";
|
|
this._exposureEV = 0;
|
|
this._gridMode = 0;
|
|
this._overlayMaskEnabled = false;
|
|
this._overlayMaskOpacity = 0.65;
|
|
this._overlayFormat = "image";
|
|
this._graphMapPanel = new WorkflowGraphMapPanel({ large: true });
|
|
}
|
|
|
|
_dispatchControllerAction(methodName: any, fallbackEventType: any) {
|
|
try {
|
|
const handler = this._controller?.[methodName];
|
|
if (typeof handler === "function") {
|
|
return handler();
|
|
}
|
|
} catch (e: any) {
|
|
console.debug?.(e);
|
|
}
|
|
if (!fallbackEventType) return undefined;
|
|
try {
|
|
window.dispatchEvent(new Event(fallbackEventType));
|
|
} catch (e: any) {
|
|
console.debug?.(e);
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
_forwardKeydownToController(event: any) {
|
|
let forwardedEvent: KeyboardEvent | null = null;
|
|
try {
|
|
const KeyboardEventCtor =
|
|
(typeof window !== "undefined" && (window as any).KeyboardEvent) ||
|
|
(typeof globalThis !== "undefined" && (globalThis as any).KeyboardEvent);
|
|
if (typeof KeyboardEventCtor !== "function") {
|
|
throw new Error("KeyboardEvent unavailable");
|
|
}
|
|
forwardedEvent = new KeyboardEventCtor("keydown", {
|
|
key: event?.key,
|
|
code: event?.code,
|
|
keyCode: event?.keyCode,
|
|
ctrlKey: event?.ctrlKey,
|
|
shiftKey: event?.shiftKey,
|
|
altKey: event?.altKey,
|
|
metaKey: event?.metaKey,
|
|
repeat: Boolean(event?.repeat),
|
|
bubbles: true,
|
|
cancelable: true,
|
|
});
|
|
const dispatched = window.dispatchEvent(forwardedEvent);
|
|
if (!dispatched || forwardedEvent.defaultPrevented) {
|
|
event?.preventDefault?.();
|
|
event?.stopPropagation?.();
|
|
event?.stopImmediatePropagation?.();
|
|
}
|
|
return;
|
|
} catch (e: any) {
|
|
console.debug?.(e);
|
|
}
|
|
try {
|
|
const handler = this._controller?.handleForwardedKeydown;
|
|
if (typeof handler === "function") {
|
|
handler(event);
|
|
}
|
|
} catch (e: any) {
|
|
console.debug?.(e);
|
|
}
|
|
}
|
|
|
|
// -- Build DOM -------------------------------------------------------------
|
|
|
|
render() {
|
|
return renderFloatingViewer(this);
|
|
}
|
|
|
|
_buildHeader() {
|
|
return buildFloatingViewerHeader(this);
|
|
}
|
|
|
|
_buildToolbar() {
|
|
return buildFloatingViewerToolbar(this);
|
|
}
|
|
|
|
_rebindControlHandlers() {
|
|
return rebindFloatingViewerControlHandlers(this);
|
|
}
|
|
|
|
_updateSettingsBtnState(active: any) {
|
|
return updateFloatingViewerSettingsBtnState(this, active);
|
|
}
|
|
|
|
_applySidebarPosition() {
|
|
return applyFloatingViewerSidebarPosition(this);
|
|
}
|
|
|
|
refreshSidebar() {
|
|
this._sidebar?.refresh();
|
|
}
|
|
|
|
_resetGenDropdownForCurrentDocument() {
|
|
return undefined;
|
|
}
|
|
|
|
_bindDocumentUiHandlers() {
|
|
return bindFloatingViewerDocumentUiHandlers(this);
|
|
}
|
|
|
|
_unbindDocumentUiHandlers() {
|
|
return unbindFloatingViewerDocumentUiHandlers(this);
|
|
}
|
|
|
|
_isGenDropdownOpen() {
|
|
return false;
|
|
}
|
|
|
|
_openGenDropdown() {
|
|
return undefined;
|
|
}
|
|
|
|
_closeGenDropdown() {
|
|
return undefined;
|
|
}
|
|
|
|
_updateGenBtnUI() {
|
|
return updateFloatingViewerGenButtonUI(this);
|
|
}
|
|
|
|
_buildGenDropdown() {
|
|
return null;
|
|
}
|
|
|
|
_getGenFields(fileData: any) {
|
|
return getFloatingViewerGenFields(this, fileData);
|
|
}
|
|
|
|
_buildGenInfoDOM(fileData: any) {
|
|
return buildFloatingViewerGenInfoDOM(this, fileData);
|
|
}
|
|
|
|
_notifyModeChanged() {
|
|
return notifyFloatingViewerModeChanged(this);
|
|
}
|
|
|
|
_cycleMode() {
|
|
return cycleFloatingViewerMode(this);
|
|
}
|
|
|
|
setMode(mode: any) {
|
|
return setFloatingViewerMode(this, mode);
|
|
}
|
|
|
|
getPinnedSlots() {
|
|
return getFloatingViewerPinnedSlots(this);
|
|
}
|
|
|
|
_updatePinUI() {
|
|
return updateFloatingViewerPinUI(this);
|
|
}
|
|
|
|
_updateModeBtnUI() {
|
|
return updateFloatingViewerModeButtonUI(this);
|
|
}
|
|
|
|
setLiveActive(active: any) {
|
|
return setFloatingViewerLiveActive(this, active);
|
|
}
|
|
|
|
setPreviewActive(active: any) {
|
|
return setFloatingViewerPreviewActive(this, active);
|
|
}
|
|
|
|
loadPreviewBlob(blob: any, opts = {}) {
|
|
return loadFloatingViewerPreviewBlob(this, blob, opts);
|
|
}
|
|
|
|
_revokePreviewBlob() {
|
|
return revokeFloatingViewerPreviewBlob(this);
|
|
}
|
|
|
|
setNodeStreamActive(active: any) {
|
|
return setFloatingViewerNodeStreamActive(this, active);
|
|
}
|
|
|
|
/**
|
|
* Update the "currently streamed node" overlay.
|
|
* Pass `null` to hide the overlay (no node selected).
|
|
* The overlay is purely informational and independent of media rendering:
|
|
* if the selected node has no streamable preview, the existing media stays
|
|
* but the overlay is still shown.
|
|
* @param {{ nodeId: string|number, classType?: string, title?: string } | null} selection
|
|
*/
|
|
setNodeStreamSelection(selection: any) {
|
|
if (selection && (selection.nodeId != null || selection.classType)) {
|
|
this._nodeStreamSelection = {
|
|
nodeId: String(selection.nodeId ?? ""),
|
|
classType: String(selection.classType || ""),
|
|
title: selection.title ? String(selection.title) : "",
|
|
};
|
|
} else {
|
|
this._nodeStreamSelection = null;
|
|
}
|
|
this._updateNodeStreamOverlay();
|
|
}
|
|
|
|
_updateNodeStreamOverlay() {
|
|
const host = this._contentEl;
|
|
if (!host) return;
|
|
const sel = this._nodeStreamSelection;
|
|
if (!sel) {
|
|
if (this._nodeStreamOverlayEl) {
|
|
this._nodeStreamOverlayEl.remove();
|
|
this._nodeStreamOverlayEl = null;
|
|
}
|
|
return;
|
|
}
|
|
if (!this._nodeStreamOverlayEl || !this._nodeStreamOverlayEl.isConnected) {
|
|
const el = document.createElement("div");
|
|
el.className = "mjr-mfv-node-overlay";
|
|
el.setAttribute("aria-live", "polite");
|
|
this._nodeStreamOverlayEl = el;
|
|
}
|
|
if (this._nodeStreamOverlayEl.parentNode !== host) {
|
|
host.appendChild(this._nodeStreamOverlayEl);
|
|
}
|
|
const idPart = sel.nodeId ? `#${sel.nodeId}` : "";
|
|
const classPart = sel.classType || "Node";
|
|
const titlePart = sel.title && sel.title !== sel.classType ? ` - ${sel.title}` : "";
|
|
this._nodeStreamOverlayEl.textContent = `${idPart} - ${classPart}${titlePart}`.trim();
|
|
}
|
|
|
|
loadMediaA(fileData: any, { autoMode = false } = {}) {
|
|
return loadFloatingViewerMediaA(this, fileData, { autoMode });
|
|
}
|
|
|
|
/**
|
|
* Load two assets for compare modes.
|
|
* Auto-switches from SIMPLE -> AB on first call.
|
|
*/
|
|
loadMediaPair(a: any, b: any) {
|
|
return loadFloatingViewerMediaPair(this, a, b);
|
|
}
|
|
|
|
/**
|
|
* Load up to 4 assets for grid compare mode.
|
|
* Auto-switches to GRID mode if not already.
|
|
*/
|
|
loadMediaQuad(a: any, b: any, c: any, d: any) {
|
|
return loadFloatingViewerMediaQuad(this, a, b, c, d);
|
|
}
|
|
|
|
/** Apply current zoom+pan state to all media elements (img/video only - divider/overlays unaffected). */
|
|
_applyTransform() {
|
|
if (!this._contentEl) return;
|
|
const z = Math.max(MFV_ZOOM_MIN, Math.min(MFV_ZOOM_MAX, this._zoom));
|
|
const vw = this._contentEl.clientWidth || 0;
|
|
const vh = this._contentEl.clientHeight || 0;
|
|
const maxX = Math.max(0, ((z - 1) * vw) / 2);
|
|
const maxY = Math.max(0, ((z - 1) * vh) / 2);
|
|
this._panX = Math.max(-maxX, Math.min(maxX, this._panX));
|
|
this._panY = Math.max(-maxY, Math.min(maxY, this._panY));
|
|
const t = `translate(${this._panX}px,${this._panY}px) scale(${z})`;
|
|
for (const el of this._contentEl.querySelectorAll(".mjr-mfv-media")) {
|
|
if (el?._mjrDisableViewerTransform) continue;
|
|
el.style.transform = t;
|
|
el.style.transformOrigin = "center";
|
|
}
|
|
// Cursor feedback - use CSS classes
|
|
this._contentEl.classList.remove("mjr-mfv-content--grab", "mjr-mfv-content--grabbing");
|
|
if (z > 1.01) {
|
|
this._contentEl.classList.add(
|
|
this._dragging ? "mjr-mfv-content--grabbing" : "mjr-mfv-content--grab",
|
|
);
|
|
}
|
|
this._applyMediaToneControls();
|
|
this._redrawOverlayGuides();
|
|
}
|
|
|
|
_ensureToneFilterDefs() {
|
|
if (this._toneFilterDefsEl?.isConnected) return this._toneFilterDefsEl;
|
|
const svgNs = "http://www.w3.org/2000/svg";
|
|
const svg = document.createElementNS(svgNs, "svg");
|
|
svg.setAttribute("aria-hidden", "true");
|
|
svg.style.position = "absolute";
|
|
svg.style.width = "0";
|
|
svg.style.height = "0";
|
|
svg.style.pointerEvents = "none";
|
|
const defs = document.createElementNS(svgNs, "defs");
|
|
const filters = [
|
|
["mjr-mfv-ch-r", "1 0 0 0 0 1 0 0 0 0 1 0 0 0 0 0 0 0 1 0"],
|
|
["mjr-mfv-ch-g", "0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0 0 0 1 0"],
|
|
["mjr-mfv-ch-b", "0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0 0 1 0"],
|
|
["mjr-mfv-ch-a", "0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0"],
|
|
[
|
|
"mjr-mfv-ch-l",
|
|
"0.2126 0.7152 0.0722 0 0 0.2126 0.7152 0.0722 0 0 0.2126 0.7152 0.0722 0 0 0 0 0 1 0",
|
|
],
|
|
];
|
|
for (const [id, values] of filters) {
|
|
const filter = document.createElementNS(svgNs, "filter");
|
|
filter.setAttribute("id", id);
|
|
const matrix = document.createElementNS(svgNs, "feColorMatrix");
|
|
matrix.setAttribute("type", "matrix");
|
|
matrix.setAttribute("values", values);
|
|
filter.appendChild(matrix);
|
|
defs.appendChild(filter);
|
|
}
|
|
svg.appendChild(defs);
|
|
this.element?.appendChild(svg);
|
|
this._toneFilterDefsEl = svg;
|
|
return svg;
|
|
}
|
|
|
|
_applyMediaToneControls() {
|
|
this._ensureToneFilterDefs();
|
|
if (!this._contentEl) return;
|
|
const channel = String(this._channel || "rgb");
|
|
const exposureScale = Math.pow(2, Number(this._exposureEV) || 0);
|
|
const channelFilter = channel === "rgb" ? "" : `url(#mjr-mfv-ch-${channel})`;
|
|
const brightnessFilter =
|
|
Math.abs(exposureScale - 1) < 0.0001 ? "" : `brightness(${exposureScale})`;
|
|
const filterValue = [channelFilter, brightnessFilter].filter(Boolean).join(" ").trim();
|
|
const mediaEls = this._contentEl.querySelectorAll?.(".mjr-mfv-media") || [];
|
|
for (const el of mediaEls) {
|
|
try {
|
|
el.style.filter = filterValue || "";
|
|
} catch (e: any) {
|
|
console.debug?.(e);
|
|
}
|
|
}
|
|
}
|
|
|
|
_getOverlayAspect(format: any, mediaEl: any, panelRect: any) {
|
|
try {
|
|
const f = String(format || "image");
|
|
if (f === "image") {
|
|
const nw =
|
|
Number(mediaEl?.videoWidth) ||
|
|
Number(mediaEl?.naturalWidth) ||
|
|
Number(panelRect?.width) ||
|
|
1;
|
|
const nh =
|
|
Number(mediaEl?.videoHeight) ||
|
|
Number(mediaEl?.naturalHeight) ||
|
|
Number(panelRect?.height) ||
|
|
1;
|
|
const a = nw / nh;
|
|
return Number.isFinite(a) && a > 0 ? a : 1;
|
|
}
|
|
if (f === "16:9") return 16 / 9;
|
|
if (f === "9:16") return 9 / 16;
|
|
if (f === "1:1") return 1;
|
|
if (f === "4:3") return 4 / 3;
|
|
if (f === "2.39") return 2.39;
|
|
} catch (e: any) {
|
|
console.debug?.(e);
|
|
}
|
|
return 1;
|
|
}
|
|
|
|
_fitAspectInBox(boxW: any, boxH: any, aspect: any) {
|
|
try {
|
|
const bw = Number(boxW) || 0;
|
|
const bh = Number(boxH) || 0;
|
|
const a = Number(aspect) || 1;
|
|
if (!(bw > 0 && bh > 0 && a > 0)) return { x: 0, y: 0, w: bw, h: bh };
|
|
const boxA = bw / bh;
|
|
let w = bw;
|
|
let h = bh;
|
|
if (a >= boxA) {
|
|
h = bw / a;
|
|
} else {
|
|
w = bh * a;
|
|
}
|
|
return { x: (bw - w) / 2, y: (bh - h) / 2, w, h };
|
|
} catch (e: any) {
|
|
console.debug?.(e);
|
|
return { x: 0, y: 0, w: Number(boxW) || 0, h: Number(boxH) || 0 };
|
|
}
|
|
}
|
|
|
|
_drawMaskOutside(ctx: any, canvas: any, rects: any, alpha: any) {
|
|
try {
|
|
const a = Math.max(0, Math.min(0.92, Number(alpha) || 0));
|
|
if (!(a > 0)) return;
|
|
ctx.save();
|
|
ctx.fillStyle = `rgba(0,0,0,${a})`;
|
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
ctx.globalCompositeOperation = "destination-out";
|
|
for (const r of rects) {
|
|
if (!r || !(r.w > 1 && r.h > 1)) continue;
|
|
ctx.fillRect(r.x, r.y, r.w, r.h);
|
|
}
|
|
ctx.restore();
|
|
} catch (e: any) {
|
|
console.debug?.(e);
|
|
}
|
|
}
|
|
|
|
_redrawOverlayGuides() {
|
|
const canvas = this._overlayCanvas;
|
|
const host = this._contentEl;
|
|
if (!canvas || !host) return;
|
|
const ctx = canvas.getContext?.("2d");
|
|
if (!ctx) return;
|
|
|
|
const dpr = Math.max(1, Math.min(3, Number(window.devicePixelRatio) || 1));
|
|
const cssW = host.clientWidth || 0;
|
|
const cssH = host.clientHeight || 0;
|
|
canvas.width = Math.max(1, Math.floor(cssW * dpr));
|
|
canvas.height = Math.max(1, Math.floor(cssH * dpr));
|
|
canvas.style.width = `${cssW}px`;
|
|
canvas.style.height = `${cssH}px`;
|
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
|
|
if (!(this._gridMode || this._overlayMaskEnabled)) return;
|
|
|
|
const hostRect = host.getBoundingClientRect?.();
|
|
if (!hostRect) return;
|
|
const panels = Array.from(
|
|
host.querySelectorAll?.(
|
|
".mjr-mfv-simple-container, .mjr-mfv-side-panel, .mjr-mfv-grid-cell, .mjr-mfv-ab-layer",
|
|
) || [],
|
|
);
|
|
const panelEls = panels.length ? panels : [host];
|
|
const formatRects = [];
|
|
|
|
for (const panelEl of panelEls) {
|
|
const mediaEl = panelEl.querySelector?.(".mjr-mfv-media");
|
|
if (!mediaEl) continue;
|
|
const panelRect = panelEl.getBoundingClientRect?.();
|
|
if (!panelRect?.width || !panelRect?.height) continue;
|
|
const baseW = Number(panelRect.width) || 0;
|
|
const baseH = Number(panelRect.height) || 0;
|
|
const aspect = this._getOverlayAspect(this._overlayFormat, mediaEl, panelRect);
|
|
const fit = this._fitAspectInBox(baseW, baseH, aspect);
|
|
const centerX = panelRect.left - hostRect.left + baseW / 2;
|
|
const centerY = panelRect.top - hostRect.top + baseH / 2;
|
|
const z = Math.max(0.1, Math.min(16, Number(this._zoom) || 1));
|
|
const rectCss = {
|
|
x: centerX + fit.x * z - (baseW * z) / 2 + (Number(this._panX) || 0),
|
|
y: centerY + fit.y * z - (baseH * z) / 2 + (Number(this._panY) || 0),
|
|
w: fit.w * z,
|
|
h: fit.h * z,
|
|
};
|
|
formatRects.push({
|
|
x: rectCss.x * dpr,
|
|
y: rectCss.y * dpr,
|
|
w: rectCss.w * dpr,
|
|
h: rectCss.h * dpr,
|
|
});
|
|
}
|
|
|
|
if (!formatRects.length) return;
|
|
|
|
if (this._overlayMaskEnabled) {
|
|
this._drawMaskOutside(ctx, canvas, formatRects, this._overlayMaskOpacity);
|
|
ctx.save();
|
|
ctx.setLineDash?.([Math.max(2, 4 * dpr), Math.max(2, 3 * dpr)]);
|
|
ctx.strokeStyle = "rgba(255,255,255,0.22)";
|
|
ctx.lineWidth = Math.max(1, Math.floor(dpr));
|
|
for (const r of formatRects) {
|
|
ctx.strokeRect(r.x + 0.5, r.y + 0.5, r.w - 1, r.h - 1);
|
|
}
|
|
ctx.restore();
|
|
}
|
|
|
|
if (this._mode !== MFV_MODES.SIMPLE || !this._gridMode) return;
|
|
const rect = formatRects[0];
|
|
if (!rect) return;
|
|
ctx.save();
|
|
ctx.translate(rect.x, rect.y);
|
|
ctx.strokeStyle = "rgba(255,255,255,0.22)";
|
|
ctx.lineWidth = Math.max(2, Math.round(1.25 * dpr));
|
|
const drawLine = (x1: any, y1: any, x2: any, y2: any) => {
|
|
ctx.beginPath();
|
|
ctx.moveTo(Math.round(x1) + 0.5, Math.round(y1) + 0.5);
|
|
ctx.lineTo(Math.round(x2) + 0.5, Math.round(y2) + 0.5);
|
|
ctx.stroke();
|
|
};
|
|
if (this._gridMode === 1) {
|
|
drawLine(rect.w / 3, 0, rect.w / 3, rect.h);
|
|
drawLine((2 * rect.w) / 3, 0, (2 * rect.w) / 3, rect.h);
|
|
drawLine(0, rect.h / 3, rect.w, rect.h / 3);
|
|
drawLine(0, (2 * rect.h) / 3, rect.w, (2 * rect.h) / 3);
|
|
} else if (this._gridMode === 2) {
|
|
drawLine(rect.w / 2, 0, rect.w / 2, rect.h);
|
|
drawLine(0, rect.h / 2, rect.w, rect.h / 2);
|
|
} else if (this._gridMode === 3) {
|
|
ctx.strokeRect(
|
|
rect.w * 0.1 + 0.5,
|
|
rect.h * 0.1 + 0.5,
|
|
rect.w * 0.8 - 1,
|
|
rect.h * 0.8 - 1,
|
|
);
|
|
ctx.strokeRect(
|
|
rect.w * 0.05 + 0.5,
|
|
rect.h * 0.05 + 0.5,
|
|
rect.w * 0.9 - 1,
|
|
rect.h * 0.9 - 1,
|
|
);
|
|
}
|
|
ctx.restore();
|
|
}
|
|
|
|
/**
|
|
* Set zoom, optionally centered at (clientX, clientY).
|
|
* Keeps the image point under the cursor stationary.
|
|
*/
|
|
_setMfvZoom(next: any, clientX: any, clientY: any) {
|
|
const prev = Math.max(MFV_ZOOM_MIN, Math.min(MFV_ZOOM_MAX, this._zoom));
|
|
const z = Math.max(MFV_ZOOM_MIN, Math.min(MFV_ZOOM_MAX, Number(next) || 1));
|
|
if (clientX != null && clientY != null && this._contentEl) {
|
|
const r = z / prev;
|
|
const rect = this._contentEl.getBoundingClientRect();
|
|
const ux = clientX - (rect.left + rect.width / 2);
|
|
const uy = clientY - (rect.top + rect.height / 2);
|
|
this._panX = this._panX * r + (1 - r) * ux;
|
|
this._panY = this._panY * r + (1 - r) * uy;
|
|
}
|
|
this._zoom = z;
|
|
// Snap back to exact fit to avoid drift.
|
|
if (Math.abs(z - 1) < 0.001) {
|
|
this._zoom = 1;
|
|
this._panX = 0;
|
|
this._panY = 0;
|
|
}
|
|
this._applyTransform();
|
|
}
|
|
|
|
/** Reset zoom and pan to the default 1:1 fit. Called when new media is loaded. */
|
|
_resetMfvZoom() {
|
|
this._zoom = 1;
|
|
this._panX = 0;
|
|
this._panY = 0;
|
|
this._applyTransform();
|
|
}
|
|
|
|
_bindLayoutObserver() {
|
|
this._unbindLayoutObserver();
|
|
const target = this._contentEl;
|
|
if (!target || typeof ResizeObserver === "undefined") return;
|
|
try {
|
|
this._layoutObserver = new ResizeObserver(() => {
|
|
this._applyTransform();
|
|
});
|
|
this._layoutObserver.observe(target);
|
|
} catch (e: any) {
|
|
console.debug?.(e);
|
|
this._layoutObserver = null;
|
|
}
|
|
}
|
|
|
|
_unbindLayoutObserver() {
|
|
try {
|
|
this._layoutObserver?.disconnect?.();
|
|
} catch (e: any) {
|
|
console.debug?.(e);
|
|
}
|
|
this._layoutObserver = null;
|
|
}
|
|
|
|
/** Bind wheel + pointer events to the clip viewport element. */
|
|
_initPanZoom(contentEl: any) {
|
|
this._destroyPanZoom();
|
|
if (!contentEl) return;
|
|
this._panzoomAC = new AbortController();
|
|
const sig = { signal: this._panzoomAC.signal };
|
|
|
|
// Wheel -> zoom centered at cursor
|
|
contentEl.addEventListener(
|
|
"wheel",
|
|
(e: any) => {
|
|
if (e.target?.closest?.("audio")) return;
|
|
if (e.target?.closest?.(".mjr-video-controls, .mjr-mfv-simple-player-controls"))
|
|
return;
|
|
if (isModel3DInteractionTarget(e.target)) return;
|
|
const scrollableAncestor = _findScrollableAncestor(e.target, contentEl);
|
|
if (
|
|
scrollableAncestor &&
|
|
_canWheelScrollElement(
|
|
scrollableAncestor,
|
|
Number(e.deltaX || 0),
|
|
Number(e.deltaY || 0),
|
|
)
|
|
) {
|
|
return;
|
|
}
|
|
e.preventDefault();
|
|
const delta = e.deltaY || e.deltaX || 0;
|
|
const factor = 1 - delta * MFV_ZOOM_FACTOR;
|
|
this._setMfvZoom(this._zoom * factor, e.clientX, e.clientY);
|
|
},
|
|
{ ...sig, passive: false },
|
|
);
|
|
|
|
// Pointer drag -> pan (left or middle button, when zoomed in)
|
|
let panActive = false;
|
|
let startX = 0,
|
|
startY = 0,
|
|
startPanX = 0,
|
|
startPanY = 0;
|
|
|
|
contentEl.addEventListener(
|
|
"pointerdown",
|
|
(e: any) => {
|
|
if (e.button !== 0 && e.button !== 1) return;
|
|
if (this._zoom <= 1.01) return;
|
|
// Let native video controls and the AB divider handle their own events.
|
|
if (e.target?.closest?.("video")) return;
|
|
if (e.target?.closest?.("audio")) return;
|
|
if (e.target?.closest?.(".mjr-video-controls, .mjr-mfv-simple-player-controls"))
|
|
return;
|
|
if (e.target?.closest?.(".mjr-mfv-ab-divider")) return;
|
|
if (isModel3DInteractionTarget(e.target)) return;
|
|
e.preventDefault();
|
|
panActive = true;
|
|
this._dragging = true;
|
|
startX = e.clientX;
|
|
startY = e.clientY;
|
|
startPanX = this._panX;
|
|
startPanY = this._panY;
|
|
try {
|
|
contentEl.setPointerCapture(e.pointerId);
|
|
} catch (e: any) {
|
|
console.debug?.(e);
|
|
}
|
|
this._applyTransform();
|
|
},
|
|
sig,
|
|
);
|
|
|
|
contentEl.addEventListener(
|
|
"pointermove",
|
|
(e: any) => {
|
|
if (!panActive) return;
|
|
this._panX = startPanX + (e.clientX - startX);
|
|
this._panY = startPanY + (e.clientY - startY);
|
|
this._applyTransform();
|
|
},
|
|
sig,
|
|
);
|
|
|
|
const endPan = (e: any) => {
|
|
if (!panActive) return;
|
|
panActive = false;
|
|
this._dragging = false;
|
|
try {
|
|
contentEl.releasePointerCapture(e.pointerId);
|
|
} catch (e: any) {
|
|
console.debug?.(e);
|
|
}
|
|
this._applyTransform();
|
|
};
|
|
contentEl.addEventListener("pointerup", endPan, sig);
|
|
contentEl.addEventListener("pointercancel", endPan, sig);
|
|
|
|
// Double-click -> zoom to 4x at cursor, or reset to fit
|
|
contentEl.addEventListener(
|
|
"dblclick",
|
|
(e: any) => {
|
|
if (e.target?.closest?.("video")) return;
|
|
if (e.target?.closest?.("audio")) return;
|
|
if (e.target?.closest?.(".mjr-video-controls, .mjr-mfv-simple-player-controls"))
|
|
return;
|
|
if (isModel3DInteractionTarget(e.target)) return;
|
|
const isNearFit = Math.abs(this._zoom - 1) < 0.05;
|
|
this._setMfvZoom(isNearFit ? Math.min(4, this._zoom * 4) : 1, e.clientX, e.clientY);
|
|
},
|
|
sig,
|
|
);
|
|
}
|
|
|
|
/** Remove all pan/zoom event listeners. */
|
|
_destroyPanZoom() {
|
|
try {
|
|
this._panzoomAC?.abort();
|
|
} catch (e: any) {
|
|
console.debug?.(e);
|
|
}
|
|
this._panzoomAC = null;
|
|
this._dragging = false;
|
|
}
|
|
|
|
_destroyCompareSync() {
|
|
try {
|
|
this._compareSyncAC?.abort?.();
|
|
} catch (e: any) {
|
|
console.debug?.(e);
|
|
}
|
|
this._compareSyncAC = null;
|
|
}
|
|
|
|
_destroyMediaControls() {
|
|
const handles = Array.isArray(this._mediaControlHandles) ? this._mediaControlHandles : [];
|
|
for (const handle of handles) {
|
|
try {
|
|
handle?.destroy?.();
|
|
} catch (e: any) {
|
|
console.debug?.(e);
|
|
}
|
|
}
|
|
this._mediaControlHandles = [];
|
|
}
|
|
|
|
_trackMediaControls(rootEl: any) {
|
|
try {
|
|
const handle = rootEl?._mjrMediaControlsHandle || null;
|
|
if (handle?.destroy) this._mediaControlHandles.push(handle);
|
|
} catch (e: any) {
|
|
console.debug?.(e);
|
|
}
|
|
return rootEl;
|
|
}
|
|
|
|
// Keep mute/speed in sync as the user changes them on the primary media
|
|
// element, so the next navigated-to asset can be built with the same values.
|
|
_bindMfvPersistence(rootEl: any) {
|
|
try {
|
|
const mediaEl = rootEl?.matches?.("video, audio")
|
|
? rootEl
|
|
: rootEl?.querySelector?.("video, audio");
|
|
if (!mediaEl) return rootEl;
|
|
mediaEl.addEventListener("volumechange", () => {
|
|
try {
|
|
this._mfvMuted = !!mediaEl.muted;
|
|
} catch (e: any) {
|
|
console.debug?.(e);
|
|
}
|
|
});
|
|
mediaEl.addEventListener("ratechange", () => {
|
|
try {
|
|
const rate = Number(mediaEl.playbackRate);
|
|
if (Number.isFinite(rate) && rate > 0) this._mfvPlaybackRate = rate;
|
|
} catch (e: any) {
|
|
console.debug?.(e);
|
|
}
|
|
});
|
|
} catch (e: any) {
|
|
console.debug?.(e);
|
|
}
|
|
return rootEl;
|
|
}
|
|
|
|
_initCompareSync() {
|
|
this._destroyCompareSync();
|
|
if (!this._contentEl) return;
|
|
if (this._mode === MFV_MODES.SIMPLE) return;
|
|
try {
|
|
const playables = Array.from(this._contentEl.querySelectorAll("video"));
|
|
if (playables.length < 2) return;
|
|
const leader = playables[0] || null;
|
|
const followers = playables.slice(1);
|
|
if (!leader || !followers.length) return;
|
|
this._compareSyncAC = installFollowerVideoSync(leader, followers, { threshold: 0.08 });
|
|
} catch (e: any) {
|
|
console.debug?.(e);
|
|
}
|
|
}
|
|
|
|
// -- Render ----------------------------------------------------------------
|
|
|
|
_refresh() {
|
|
if (!this._contentEl) return;
|
|
this._sidebar?.setAsset?.(this._mediaA || null);
|
|
// Tear down previous panzoom bindings before clearing DOM.
|
|
this._destroyPanZoom();
|
|
this._destroyCompareSync();
|
|
this._destroyMediaControls();
|
|
const overlayCanvas = this._overlayCanvas || null;
|
|
this._contentEl.replaceChildren();
|
|
this._contentEl.style.overflow = "hidden";
|
|
|
|
switch (this._mode) {
|
|
case MFV_MODES.SIMPLE:
|
|
this._renderSimple();
|
|
break;
|
|
case MFV_MODES.AB:
|
|
this._renderAB();
|
|
break;
|
|
case MFV_MODES.SIDE:
|
|
this._renderSide();
|
|
break;
|
|
case MFV_MODES.GRID:
|
|
this._renderGrid();
|
|
break;
|
|
case MFV_MODES.GRAPH:
|
|
this._renderGraphMap();
|
|
break;
|
|
}
|
|
|
|
if (overlayCanvas) {
|
|
this._contentEl.appendChild(overlayCanvas);
|
|
}
|
|
|
|
// The Node Stream overlay lives inside the content area (top-left)
|
|
// and must be re-attached after each refresh since replaceChildren()
|
|
// above removes it.
|
|
if (this._nodeStreamSelection) {
|
|
this._updateNodeStreamOverlay();
|
|
}
|
|
|
|
if (this._mediaProgressEl) {
|
|
this._contentEl.appendChild(this._mediaProgressEl);
|
|
}
|
|
|
|
this._applyMediaToneControls();
|
|
this._applyTransform();
|
|
if (this._mode !== MFV_MODES.GRAPH) {
|
|
this._initPanZoom(this._contentEl);
|
|
}
|
|
this._initCompareSync();
|
|
this._renderGenInfoSidebar();
|
|
}
|
|
|
|
_renderGenInfoSidebar() {
|
|
const sidebar = this._genSidebarEl;
|
|
if (!sidebar) return;
|
|
sidebar.replaceChildren();
|
|
|
|
const slots = this._getGenInfoSidebarSlots();
|
|
|
|
if (
|
|
this._mode === MFV_MODES.GRAPH ||
|
|
!this._genSidebarEnabled ||
|
|
slots.length === 0
|
|
) {
|
|
sidebar.classList.remove("open");
|
|
sidebar.setAttribute("hidden", "");
|
|
this._updateGenBtnUI?.();
|
|
return;
|
|
}
|
|
|
|
const title = document.createElement("div");
|
|
title.className = "mjr-mfv-gen-sidebar-title";
|
|
title.textContent = "Gen Info";
|
|
sidebar.appendChild(title);
|
|
|
|
let rendered = 0;
|
|
for (const slot of slots) {
|
|
if (_mediaKind(slot.media) === "audio") continue;
|
|
const richContent = this._buildGenInfoSidebarContent(slot.media);
|
|
if (!richContent) continue;
|
|
const section = document.createElement("section");
|
|
section.className = "mjr-mfv-gen-sidebar-section";
|
|
const heading = document.createElement("div");
|
|
heading.className = "mjr-mfv-gen-sidebar-heading";
|
|
heading.textContent = slots.length > 1 ? `Asset ${slot.label}` : "Current Asset";
|
|
const body = document.createElement("div");
|
|
body.className = "mjr-mfv-gen-sidebar-body";
|
|
body.appendChild(richContent);
|
|
section.appendChild(heading);
|
|
section.appendChild(body);
|
|
sidebar.appendChild(section);
|
|
rendered += 1;
|
|
}
|
|
|
|
if (!rendered) {
|
|
sidebar.classList.remove("open");
|
|
sidebar.setAttribute("hidden", "");
|
|
return;
|
|
}
|
|
sidebar.removeAttribute("hidden");
|
|
sidebar.classList.add("open");
|
|
this._updateGenBtnUI?.();
|
|
}
|
|
|
|
_getGenInfoSidebarSlots() {
|
|
const slotA = { media: this._mediaA, label: "A" };
|
|
const slotB = { media: this._mediaB, label: "B" };
|
|
const slotC = { media: this._mediaC, label: "C" };
|
|
const slotD = { media: this._mediaD, label: "D" };
|
|
const slots =
|
|
this._mode === MFV_MODES.GRID
|
|
? [slotA, slotB, slotC, slotD]
|
|
: this._mode === MFV_MODES.AB || this._mode === MFV_MODES.SIDE
|
|
? [slotA, slotB]
|
|
: [slotA];
|
|
return slots.filter((slot: any) => slot.media);
|
|
}
|
|
|
|
_buildGenInfoSidebarContent(asset: any) {
|
|
const genTime = (this._getGenFields(asset) as any)?.genTime || "";
|
|
let state: any = null;
|
|
try {
|
|
state = buildGenerationSectionState(asset);
|
|
} catch (e: any) {
|
|
console.debug?.(e);
|
|
}
|
|
if (state && state.kind !== "empty") {
|
|
const root = document.createDocumentFragment();
|
|
if (genTime) {
|
|
root.appendChild(this._buildGenTimeBadge(genTime));
|
|
}
|
|
if (state.workflowType) {
|
|
root.appendChild(
|
|
this._buildGenInfoCard({
|
|
title: "Workflow",
|
|
accent: "#2196F3",
|
|
value: [state.workflowLabel || state.workflowType, state.workflowBadge]
|
|
.filter(Boolean)
|
|
.join(" | "),
|
|
compact: true,
|
|
}),
|
|
);
|
|
}
|
|
if (state.positivePrompt) {
|
|
root.appendChild(
|
|
this._buildGenInfoCard({
|
|
title: "Positive Prompt",
|
|
accent: "#4CAF50",
|
|
value: state.positivePrompt,
|
|
multiline: true,
|
|
}),
|
|
);
|
|
}
|
|
if (state.negativePrompt) {
|
|
root.appendChild(
|
|
this._buildGenInfoCard({
|
|
title: "Negative Prompt",
|
|
accent: "#F44336",
|
|
value: state.negativePrompt,
|
|
multiline: true,
|
|
}),
|
|
);
|
|
}
|
|
for (const tab of state.promptTabs || []) {
|
|
const value = [tab.positive, tab.negative ? `Negative:\n${tab.negative}` : ""]
|
|
.filter(Boolean)
|
|
.join("\n\n");
|
|
root.appendChild(
|
|
this._buildGenInfoCard({
|
|
title: tab.label || "Prompt",
|
|
accent: "#4CAF50",
|
|
value,
|
|
multiline: true,
|
|
}),
|
|
);
|
|
}
|
|
if (state.ideogram) {
|
|
this._appendIdeogramGenInfoCards(root, state.ideogram);
|
|
}
|
|
const modelBranches = (state.branchCards || []).filter(
|
|
(item: any) => item?.modelFields?.length || item?.loras?.length,
|
|
);
|
|
const samplingBranches = (state.branchCards || []).filter(
|
|
(item: any) => item?.samplingFields?.length,
|
|
);
|
|
if (modelBranches.length) {
|
|
const card = this._buildGenInfoBranchGroupsCard(
|
|
"Models",
|
|
"#9C27B0",
|
|
modelBranches,
|
|
"model",
|
|
);
|
|
if (card) root.appendChild(card);
|
|
} else if (state.modelFields?.length) {
|
|
const card = this._buildGenInfoFieldsCard(
|
|
"Model & LoRA",
|
|
"#9C27B0",
|
|
state.modelFields,
|
|
);
|
|
if (card) root.appendChild(card);
|
|
}
|
|
if (!modelBranches.length) for (const group of state.modelGroups || []) {
|
|
const fields = [
|
|
{ label: "UNet", value: group.model || "-" },
|
|
...(group.loras || []).map((value: any, index: any) => ({
|
|
label: index === 0 ? "LoRA" : `LoRA ${index + 1}`,
|
|
value,
|
|
})),
|
|
];
|
|
const card = this._buildGenInfoFieldsCard(
|
|
group.label || "Model Branch",
|
|
"#AB47BC",
|
|
fields,
|
|
);
|
|
if (card) root.appendChild(card);
|
|
}
|
|
if (samplingBranches.length) {
|
|
const card = this._buildGenInfoBranchGroupsCard(
|
|
"Sampling",
|
|
"#FF9800",
|
|
samplingBranches,
|
|
"sampling",
|
|
);
|
|
if (card) root.appendChild(card);
|
|
} else if (state.pipelineTabs?.length) {
|
|
for (const tab of state.pipelineTabs) {
|
|
const card = this._buildGenInfoFieldsCard(
|
|
tab.label || "Generation Pipeline",
|
|
"#FF9800",
|
|
tab.fields || [],
|
|
);
|
|
if (card) root.appendChild(card);
|
|
}
|
|
} else if (state.samplingFields?.length) {
|
|
const card = this._buildGenInfoFieldsCard("Sampling", "#FF9800", state.samplingFields);
|
|
if (card) root.appendChild(card);
|
|
}
|
|
for (const module of state.moduleBlocks || []) {
|
|
const key = String(module?.key || "").toLowerCase();
|
|
if (key === "workflow_engine") continue;
|
|
const card = this._buildGenInfoFieldsCard(
|
|
module.title || "Generation Module",
|
|
module.accent || "#2196F3",
|
|
module.fields || [],
|
|
);
|
|
if (card) root.appendChild(card);
|
|
}
|
|
if (state.seed !== null && state.seed !== undefined && state.seed !== "") {
|
|
root.appendChild(
|
|
this._buildGenInfoCard({
|
|
title: "Seed",
|
|
accent: "#E91E63",
|
|
value: String(state.seed),
|
|
seed: true,
|
|
}),
|
|
);
|
|
}
|
|
if (state.mediaOnlyMessage) {
|
|
root.appendChild(
|
|
this._buildGenInfoCard({
|
|
title: "Generation Data",
|
|
accent: "#9E9E9E",
|
|
value: state.mediaOnlyMessage,
|
|
multiline: true,
|
|
}),
|
|
);
|
|
}
|
|
if (root.childNodes.length) return root;
|
|
}
|
|
const fallback = this._buildGenInfoDOM(asset);
|
|
if (!genTime) return fallback;
|
|
const root = document.createDocumentFragment();
|
|
root.appendChild(this._buildGenTimeBadge(genTime));
|
|
if (fallback) root.appendChild(fallback);
|
|
return root;
|
|
}
|
|
|
|
_buildGenTimeBadge(value: any) {
|
|
const badge = document.createElement("div");
|
|
badge.className = "mjr-mfv-gen-time-badge";
|
|
this._bindGenInfoCopy(badge, () => String(value || ""));
|
|
const label = document.createElement("span");
|
|
label.className = "mjr-mfv-gen-time-label";
|
|
label.textContent = "Generation Time";
|
|
const time = document.createElement("span");
|
|
time.className = "mjr-mfv-gen-time-value";
|
|
time.textContent = String(value || "");
|
|
badge.appendChild(label);
|
|
badge.appendChild(time);
|
|
return badge;
|
|
}
|
|
|
|
_buildGenInfoCard({ title, accent, value, multiline = false, compact = false, seed = false }: { title: any; accent: any; value: any; multiline?: any; compact?: any; seed?: any }) {
|
|
const card = document.createElement("div");
|
|
card.className = `mjr-mfv-gen-card${seed ? " mjr-mfv-gen-card--seed" : ""}${compact ? " mjr-mfv-gen-card--compact" : ""}`;
|
|
const titleKey = String(title || "")
|
|
.toLowerCase()
|
|
.replace(/&/g, "and")
|
|
.replace(/[^a-z0-9]+/g, "-")
|
|
.replace(/^-+|-+$/g, "");
|
|
if (titleKey) card.classList.add(`mjr-mfv-gen-card--${titleKey}`);
|
|
card.style.setProperty("--mjr-mfv-gen-accent", accent || "#2196F3");
|
|
this._bindGenInfoCopy(card, () => String(value ?? ""));
|
|
const heading = document.createElement("div");
|
|
heading.className = "mjr-mfv-gen-card-title";
|
|
heading.textContent = title || "";
|
|
const content = document.createElement("div");
|
|
content.className = `mjr-mfv-gen-card-value${multiline ? " is-multiline" : ""}${compact ? " is-compact" : ""}`;
|
|
content.textContent = String(value ?? "");
|
|
card.appendChild(heading);
|
|
card.appendChild(content);
|
|
return card;
|
|
}
|
|
|
|
_buildGenInfoFieldsCard(title: any, accent: any, fields: any) {
|
|
const card = this._buildGenInfoCard({ title, accent, value: "" });
|
|
this._bindGenInfoCopy(card, () =>
|
|
(fields || [])
|
|
.map((field: any) => {
|
|
const label = String(field?.label || "").trim();
|
|
const text = String(field?.value ?? "").trim();
|
|
return label && text && text !== "-" ? `${label}: ${text}` : "";
|
|
})
|
|
.filter(Boolean)
|
|
.join("\n"),
|
|
);
|
|
const value = card.querySelector(".mjr-mfv-gen-card-value") as any;
|
|
if (!value) return null;
|
|
value.replaceChildren();
|
|
value.classList.add("is-fields");
|
|
for (const field of fields || []) {
|
|
const label = String(field?.label || "").trim();
|
|
const text = String(field?.value ?? "").trim();
|
|
if (!label || !text || text === "-") continue;
|
|
const row = document.createElement("div");
|
|
row.className = "mjr-mfv-gen-field";
|
|
const key = document.createElement("span");
|
|
key.className = "mjr-mfv-gen-field-label";
|
|
key.textContent = label;
|
|
const val = document.createElement("span");
|
|
val.className = "mjr-mfv-gen-field-value";
|
|
val.textContent = text;
|
|
row.appendChild(key);
|
|
row.appendChild(val);
|
|
value.appendChild(row);
|
|
}
|
|
return value.childNodes.length ? card : null;
|
|
}
|
|
|
|
_appendIdeogramGenInfoCards(root: any, ideogram: any) {
|
|
if (!root || !ideogram) return;
|
|
if (ideogram.highLevelDescription) {
|
|
root.appendChild(
|
|
this._buildGenInfoCard({
|
|
title: "High Level Description",
|
|
accent: "#4CAF50",
|
|
value: ideogram.highLevelDescription,
|
|
multiline: true,
|
|
}),
|
|
);
|
|
}
|
|
if (ideogram.background) {
|
|
root.appendChild(
|
|
this._buildGenInfoCard({
|
|
title: "Background",
|
|
accent: "#2196F3",
|
|
value: ideogram.background,
|
|
multiline: true,
|
|
}),
|
|
);
|
|
}
|
|
for (const element of ideogram.elements || []) {
|
|
const value = [
|
|
element?.description,
|
|
element?.text ? `Text: ${element.text}` : "",
|
|
element?.bbox ? `bbox ${element.bbox}` : "",
|
|
]
|
|
.filter(Boolean)
|
|
.join("\n");
|
|
if (!value) continue;
|
|
root.appendChild(
|
|
this._buildGenInfoCard({
|
|
title: element.label || "Element",
|
|
accent: "#FFB300",
|
|
value,
|
|
multiline: true,
|
|
}),
|
|
);
|
|
}
|
|
if (ideogram.json) {
|
|
root.appendChild(
|
|
this._buildGenInfoCard({
|
|
title: "JSON sent to text encoder",
|
|
accent: "#FFB300",
|
|
value: ideogram.json,
|
|
multiline: true,
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
|
|
_buildGenInfoBranchGroupsCard(title: any, accent: any, branches: any, kind: any) {
|
|
const card = this._buildGenInfoCard({ title, accent, value: "" });
|
|
this._bindGenInfoCopy(card, () =>
|
|
(branches || [])
|
|
.map((branch: any) => {
|
|
const lines = [String(branch?.label || "").trim()];
|
|
const fields =
|
|
kind === "sampling" ? branch?.samplingFields || [] : branch?.modelFields || [];
|
|
for (const field of fields) {
|
|
const label = String(field?.label || "").trim();
|
|
const text = String(field?.value ?? "").trim();
|
|
if (label && text && text !== "-") lines.push(`${label}: ${text}`);
|
|
}
|
|
if (kind !== "sampling") {
|
|
for (const lora of branch?.loras || []) {
|
|
const text = String(lora ?? "").trim();
|
|
if (text) lines.push(`LoRA: ${text}`);
|
|
}
|
|
}
|
|
return lines.filter(Boolean).join("\n");
|
|
})
|
|
.filter(Boolean)
|
|
.join("\n\n"),
|
|
);
|
|
const value = card.querySelector(".mjr-mfv-gen-card-value") as any;
|
|
if (!value) return null;
|
|
value.replaceChildren();
|
|
value.classList.add("is-fields", "is-branch-groups");
|
|
for (const branch of branches || []) {
|
|
const group = document.createElement("div");
|
|
group.className = "mjr-mfv-gen-branch-group";
|
|
const heading = document.createElement("div");
|
|
heading.className = "mjr-mfv-gen-branch-title";
|
|
heading.textContent = String(branch?.label || "").trim() || title || "";
|
|
group.appendChild(heading);
|
|
const fields =
|
|
kind === "sampling" ? branch?.samplingFields || [] : branch?.modelFields || [];
|
|
for (const field of fields) {
|
|
const row = this._buildGenInfoFieldRow(field);
|
|
if (row) group.appendChild(row);
|
|
}
|
|
if (kind !== "sampling") {
|
|
for (const lora of branch?.loras || []) {
|
|
const row = this._buildGenInfoFieldRow({ label: "LoRA", value: lora });
|
|
if (row) group.appendChild(row);
|
|
}
|
|
}
|
|
if (group.childNodes.length > 1) value.appendChild(group);
|
|
}
|
|
return value.childNodes.length ? card : null;
|
|
}
|
|
|
|
_buildGenInfoFieldRow(field: any) {
|
|
const label = String(field?.label || "").trim();
|
|
const text = String(field?.value ?? "").trim();
|
|
if (!label || !text || text === "-") return null;
|
|
const row = document.createElement("div");
|
|
row.className = "mjr-mfv-gen-field";
|
|
const key = document.createElement("span");
|
|
key.className = "mjr-mfv-gen-field-label";
|
|
key.textContent = label;
|
|
const val = document.createElement("span");
|
|
val.className = "mjr-mfv-gen-field-value";
|
|
val.textContent = text;
|
|
row.appendChild(key);
|
|
row.appendChild(val);
|
|
return row;
|
|
}
|
|
|
|
_bindGenInfoCopy(el: any, resolveText: any) {
|
|
if (!el || typeof resolveText !== "function") return;
|
|
el.title = "Click to copy";
|
|
el.addEventListener("click", async (event: any) => {
|
|
event.stopPropagation();
|
|
const text = String(resolveText() || "").trim();
|
|
if (!text) return;
|
|
try {
|
|
await navigator.clipboard?.writeText?.(text);
|
|
el.classList.add("mjr-mfv-gen-copy-flash");
|
|
setTimeout(() => el.classList.remove("mjr-mfv-gen-copy-flash"), 450);
|
|
} catch (e: any) {
|
|
console.debug?.(e);
|
|
}
|
|
});
|
|
}
|
|
|
|
_renderGraphMap() {
|
|
this._contentEl.style.overflow = "hidden";
|
|
this._graphMapPanel.setAsset(this._mediaA || null);
|
|
this._contentEl.appendChild(this._graphMapPanel.el);
|
|
this._graphMapPanel.refresh();
|
|
}
|
|
|
|
_renderSimple() {
|
|
if (!this._mediaA) {
|
|
this._contentEl.appendChild(_makeEmptyState());
|
|
return;
|
|
}
|
|
const rawMediaEl = _buildMediaEl(this._mediaA, {
|
|
initialMuted: this._mfvMuted,
|
|
initialPlaybackRate: this._mfvPlaybackRate,
|
|
});
|
|
const mediaEl = this._trackMediaControls?.(rawMediaEl) || rawMediaEl;
|
|
this._bindMfvPersistence?.(mediaEl);
|
|
if (!mediaEl) {
|
|
this._contentEl.appendChild(_makeEmptyState("Could not load media"));
|
|
return;
|
|
}
|
|
const wrap = document.createElement("div");
|
|
wrap.className = "mjr-mfv-simple-container";
|
|
wrap.appendChild(mediaEl);
|
|
this._contentEl.appendChild(wrap);
|
|
}
|
|
|
|
_renderAB() {
|
|
const rawElA = this._mediaA
|
|
? _buildMediaEl(this._mediaA, {
|
|
fill: true,
|
|
initialMuted: this._mfvMuted,
|
|
initialPlaybackRate: this._mfvPlaybackRate,
|
|
})
|
|
: null;
|
|
const rawElB = this._mediaB
|
|
? _buildMediaEl(this._mediaB, { fill: true, controls: false, initialMuted: true })
|
|
: null;
|
|
const elA = this._trackMediaControls?.(rawElA) || rawElA;
|
|
const elB = this._trackMediaControls?.(rawElB) || rawElB;
|
|
this._bindMfvPersistence?.(elA);
|
|
const kindA = this._mediaA ? _mediaKind(this._mediaA) : "";
|
|
const kindB = this._mediaB ? _mediaKind(this._mediaB) : "";
|
|
|
|
if (!elA && !elB) {
|
|
this._contentEl.appendChild(_makeEmptyState("Select 2 assets for A/B compare"));
|
|
return;
|
|
}
|
|
if (!elB) {
|
|
// Only one asset - render as simple
|
|
this._renderSimple();
|
|
return;
|
|
}
|
|
// Audio does not map well to clipped A/B mode; use side-by-side players instead.
|
|
if (kindA === "audio" || kindB === "audio" || kindA === "model3d" || kindB === "model3d") {
|
|
this._renderSide();
|
|
return;
|
|
}
|
|
|
|
const container = document.createElement("div");
|
|
container.className = "mjr-mfv-ab-container";
|
|
|
|
// Layer A - full-size backdrop
|
|
const layerA = document.createElement("div");
|
|
layerA.className = "mjr-mfv-ab-layer";
|
|
if (elA) layerA.appendChild(elA);
|
|
|
|
// Layer B - clipped from the left edge to the divider
|
|
const layerB = document.createElement("div");
|
|
layerB.className = "mjr-mfv-ab-layer mjr-mfv-ab-layer--b";
|
|
const pct = Math.round(this._abDividerX * 100);
|
|
layerB.style.clipPath = `inset(0 0 0 ${pct}%)`;
|
|
layerB.appendChild(elB);
|
|
|
|
// Draggable divider bar
|
|
const divider = document.createElement("div");
|
|
divider.className = "mjr-mfv-ab-divider";
|
|
divider.style.left = `${pct}%`;
|
|
|
|
// Gen info overlays are placed at the container level (outside the clipped
|
|
// layers) so they are never truncated by layerB's clip-path. Each overlay
|
|
// is bounded to its own half, mirroring the canvas capture layout.
|
|
const fragA = this._buildGenInfoDOM(this._mediaA);
|
|
let genInfoAEl: any = null;
|
|
if (fragA) {
|
|
genInfoAEl = document.createElement("div");
|
|
genInfoAEl.className = "mjr-mfv-geninfo-a";
|
|
if (_hasSimplePlayerControls(elA)) {
|
|
genInfoAEl.classList.add("mjr-mfv-geninfo--above-player");
|
|
}
|
|
genInfoAEl.appendChild(fragA);
|
|
// Limit right edge to divider so it doesn't bleed into B side.
|
|
genInfoAEl.style.right = `calc(${100 - pct}% + 8px)`;
|
|
}
|
|
const fragB = this._buildGenInfoDOM(this._mediaB);
|
|
let genInfoBEl: any = null;
|
|
if (fragB) {
|
|
genInfoBEl = document.createElement("div");
|
|
genInfoBEl.className = "mjr-mfv-geninfo-b";
|
|
if (_hasSimplePlayerControls(elB)) {
|
|
genInfoBEl.classList.add("mjr-mfv-geninfo--above-player");
|
|
}
|
|
genInfoBEl.appendChild(fragB);
|
|
// Start at the divider - overrides CSS left:8px so it is never
|
|
// clipped by layerB's clip-path.
|
|
genInfoBEl.style.left = `calc(${pct}% + 8px)`;
|
|
}
|
|
|
|
let _abDivAC: any = null;
|
|
divider.addEventListener(
|
|
"pointerdown",
|
|
(e) => {
|
|
e.preventDefault();
|
|
divider.setPointerCapture(e.pointerId);
|
|
// Abort any previous drag listeners to prevent accumulation.
|
|
try {
|
|
_abDivAC?.abort();
|
|
} catch {}
|
|
_abDivAC = new AbortController();
|
|
const sig = _abDivAC.signal;
|
|
const rect = container.getBoundingClientRect();
|
|
|
|
const onMove = (me: any) => {
|
|
const x = Math.max(0.02, Math.min(0.98, (me.clientX - rect.left) / rect.width));
|
|
this._abDividerX = x;
|
|
const p = Math.round(x * 100);
|
|
layerB.style.clipPath = `inset(0 0 0 ${p}%)`;
|
|
divider.style.left = `${p}%`;
|
|
if (genInfoAEl) genInfoAEl.style.right = `calc(${100 - p}% + 8px)`;
|
|
if (genInfoBEl) genInfoBEl.style.left = `calc(${p}% + 8px)`;
|
|
};
|
|
const onUp = () => {
|
|
try {
|
|
_abDivAC?.abort();
|
|
} catch {}
|
|
};
|
|
divider.addEventListener("pointermove", onMove, { signal: sig });
|
|
divider.addEventListener("pointerup", onUp, { signal: sig });
|
|
},
|
|
this._panelAC?.signal ? { signal: this._panelAC.signal } : undefined,
|
|
);
|
|
|
|
container.appendChild(layerA);
|
|
container.appendChild(layerB);
|
|
container.appendChild(divider);
|
|
if (genInfoAEl) container.appendChild(genInfoAEl);
|
|
if (genInfoBEl) container.appendChild(genInfoBEl);
|
|
container.appendChild(_makeLabel("A", "left"));
|
|
container.appendChild(_makeLabel("B", "right"));
|
|
this._contentEl.appendChild(container);
|
|
}
|
|
|
|
_renderSide() {
|
|
const rawElA = this._mediaA
|
|
? _buildMediaEl(this._mediaA, {
|
|
initialMuted: this._mfvMuted,
|
|
initialPlaybackRate: this._mfvPlaybackRate,
|
|
})
|
|
: null;
|
|
const rawElB = this._mediaB
|
|
? _buildMediaEl(this._mediaB, { controls: false, initialMuted: true })
|
|
: null;
|
|
const elA = this._trackMediaControls?.(rawElA) || rawElA;
|
|
const elB = this._trackMediaControls?.(rawElB) || rawElB;
|
|
this._bindMfvPersistence?.(elA);
|
|
const kindA = this._mediaA ? _mediaKind(this._mediaA) : "";
|
|
const kindB = this._mediaB ? _mediaKind(this._mediaB) : "";
|
|
|
|
if (!elA && !elB) {
|
|
this._contentEl.appendChild(_makeEmptyState("Select 2 assets for Side-by-Side"));
|
|
return;
|
|
}
|
|
|
|
const container = document.createElement("div");
|
|
container.className = "mjr-mfv-side-container";
|
|
|
|
const sideA = document.createElement("div");
|
|
sideA.className = "mjr-mfv-side-panel";
|
|
if (elA) sideA.appendChild(elA);
|
|
else sideA.appendChild(_makeEmptyState(" - "));
|
|
sideA.appendChild(_makeLabel("A", "left"));
|
|
|
|
// Gen info overlay for left
|
|
const fragSideA = kindA === "audio" ? null : this._buildGenInfoDOM(this._mediaA);
|
|
if (fragSideA) {
|
|
const oa = document.createElement("div");
|
|
oa.className = "mjr-mfv-geninfo-a";
|
|
if (_hasSimplePlayerControls(elA)) {
|
|
oa.classList.add("mjr-mfv-geninfo--above-player");
|
|
}
|
|
oa.appendChild(fragSideA);
|
|
sideA.appendChild(oa);
|
|
}
|
|
|
|
const sideB = document.createElement("div");
|
|
sideB.className = "mjr-mfv-side-panel";
|
|
if (elB) sideB.appendChild(elB);
|
|
else sideB.appendChild(_makeEmptyState(" - "));
|
|
sideB.appendChild(_makeLabel("B", "right"));
|
|
|
|
// Gen info overlay for right
|
|
const fragSideB = kindB === "audio" ? null : this._buildGenInfoDOM(this._mediaB);
|
|
if (fragSideB) {
|
|
const ob = document.createElement("div");
|
|
ob.className = "mjr-mfv-geninfo-b";
|
|
if (_hasSimplePlayerControls(elB)) {
|
|
ob.classList.add("mjr-mfv-geninfo--above-player");
|
|
}
|
|
ob.appendChild(fragSideB);
|
|
sideB.appendChild(ob);
|
|
}
|
|
|
|
container.appendChild(sideA);
|
|
container.appendChild(sideB);
|
|
this._contentEl.appendChild(container);
|
|
}
|
|
|
|
_renderGrid() {
|
|
const slots = [
|
|
{ media: this._mediaA, label: "A" },
|
|
{ media: this._mediaB, label: "B" },
|
|
{ media: this._mediaC, label: "C" },
|
|
{ media: this._mediaD, label: "D" },
|
|
];
|
|
const filled = slots.filter((s: any) => s.media);
|
|
if (!filled.length) {
|
|
this._contentEl.appendChild(_makeEmptyState("Select up to 4 assets for Grid Compare"));
|
|
return;
|
|
}
|
|
|
|
const container = document.createElement("div");
|
|
container.className = "mjr-mfv-grid-container";
|
|
|
|
for (const { media, label } of slots) {
|
|
const cell = document.createElement("div");
|
|
cell.className = "mjr-mfv-grid-cell";
|
|
if (media) {
|
|
const kind = _mediaKind(media);
|
|
const isPrimary = label === "A";
|
|
const rawEl = _buildMediaEl(media, {
|
|
controls: isPrimary,
|
|
initialMuted: isPrimary ? this._mfvMuted : true,
|
|
initialPlaybackRate: isPrimary ? this._mfvPlaybackRate : 1,
|
|
});
|
|
const el = this._trackMediaControls?.(rawEl) || rawEl;
|
|
if (isPrimary) this._bindMfvPersistence?.(el);
|
|
if (el) cell.appendChild(el);
|
|
else cell.appendChild(_makeEmptyState(" - "));
|
|
cell.appendChild(
|
|
_makeLabel(label, label === "A" || label === "C" ? "left" : "right"),
|
|
);
|
|
if (kind !== "audio") {
|
|
const frag = this._buildGenInfoDOM(media);
|
|
if (frag) {
|
|
const overlay = document.createElement("div");
|
|
overlay.className = `mjr-mfv-geninfo-${label.toLowerCase()}`;
|
|
if (_hasSimplePlayerControls(el)) {
|
|
overlay.classList.add("mjr-mfv-geninfo--above-player");
|
|
}
|
|
overlay.appendChild(frag);
|
|
cell.appendChild(overlay);
|
|
}
|
|
}
|
|
} else {
|
|
cell.appendChild(_makeEmptyState(" - "));
|
|
cell.appendChild(
|
|
_makeLabel(label, label === "A" || label === "C" ? "left" : "right"),
|
|
);
|
|
}
|
|
container.appendChild(cell);
|
|
}
|
|
|
|
this._contentEl.appendChild(container);
|
|
}
|
|
|
|
// -- Visibility ------------------------------------------------------------
|
|
|
|
show() {
|
|
if (!this.element) return;
|
|
this._bindDocumentUiHandlers();
|
|
this.element.classList.add("is-visible");
|
|
this.element.setAttribute("aria-hidden", "false");
|
|
this.isVisible = true;
|
|
}
|
|
|
|
hide() {
|
|
if (!this.element) return;
|
|
// Destroy pan/zoom so _dragging and pointer-capture state are reset cleanly
|
|
// even if hide() is called mid-drag (NM-5).
|
|
this._destroyPanZoom();
|
|
this._destroyCompareSync();
|
|
this._stopEdgeResize();
|
|
this._closeGenDropdown();
|
|
_pauseMediaIn(this.element);
|
|
this.element.classList.remove("is-visible");
|
|
this.element.setAttribute("aria-hidden", "true");
|
|
this.isVisible = false;
|
|
}
|
|
|
|
// -- Pop-out / Pop-in (separate OS window for second monitor) ------------
|
|
|
|
_setDesktopExpanded(active: any) {
|
|
return setFloatingViewerDesktopExpanded(this, active);
|
|
}
|
|
|
|
_activateDesktopExpandedFallback(error: any) {
|
|
return activateFloatingViewerDesktopExpandedFallback(this, error);
|
|
}
|
|
|
|
_tryElectronPopupFallback(el: any, w: any, h: any, reason: any) {
|
|
return tryFloatingViewerElectronPopupFallback(this, el, w, h, reason);
|
|
}
|
|
|
|
popOut() {
|
|
return popOutFloatingViewer(this);
|
|
}
|
|
|
|
_fallbackPopout(el: any, w: any, h: any) {
|
|
return fallbackPopoutFloatingViewer(this, el, w, h);
|
|
}
|
|
|
|
_clearPopoutCloseWatch() {
|
|
return clearFloatingViewerPopoutCloseWatch(this);
|
|
}
|
|
|
|
_startPopoutCloseWatch() {
|
|
return startFloatingViewerPopoutCloseWatch(this);
|
|
}
|
|
|
|
_schedulePopInFromPopupClose() {
|
|
return scheduleFloatingViewerPopInFromPopupClose(this);
|
|
}
|
|
|
|
_installPopoutStyles(doc: any) {
|
|
return installFloatingViewerPopoutStyles(this, doc);
|
|
}
|
|
|
|
popIn(options?: any) {
|
|
return popInFloatingViewer(this, options);
|
|
}
|
|
|
|
_updatePopoutBtnUI() {
|
|
return updateFloatingViewerPopoutButtonUI(this);
|
|
}
|
|
|
|
get isPopped() {
|
|
return this._isPopped || this._desktopExpanded;
|
|
}
|
|
|
|
_resizeCursorForDirection(dir: any) {
|
|
return getFloatingViewerResizeCursor(dir);
|
|
}
|
|
|
|
_getResizeDirectionFromPoint(clientX: any, clientY: any, rect: any) {
|
|
return getFloatingViewerResizeDirectionFromPoint(clientX, clientY, rect);
|
|
}
|
|
|
|
_stopEdgeResize() {
|
|
return stopFloatingViewerEdgeResize(this);
|
|
}
|
|
|
|
_bindPanelInteractions() {
|
|
return bindFloatingViewerPanelInteractions(this);
|
|
}
|
|
|
|
_initEdgeResize(el: any) {
|
|
return initFloatingViewerEdgeResize(this, el);
|
|
}
|
|
|
|
_initDrag(handle: any) {
|
|
return initFloatingViewerDrag(this, handle);
|
|
}
|
|
|
|
async _drawMediaFit(ctx: any, fileData: any, ox: any, oy: any, w: any, h: any, preferredVideo: any) {
|
|
return drawFloatingViewerMediaFit(this, ctx, fileData, ox, oy, w, h, preferredVideo);
|
|
}
|
|
|
|
_estimateGenInfoOverlayHeight(ctx: any, fileData: any, regionWidth: any) {
|
|
return estimateFloatingViewerGenInfoOverlayHeight(this, ctx, fileData, regionWidth);
|
|
}
|
|
|
|
_drawGenInfoOverlay(ctx: any, fileData: any, ox: any, oy: any, w: any, h: any) {
|
|
return drawFloatingViewerGenInfoOverlay(this, ctx, fileData, ox, oy, w, h);
|
|
}
|
|
|
|
async _captureView() {
|
|
return captureFloatingViewerView(this);
|
|
}
|
|
|
|
dispose() {
|
|
disposeFloatingViewerProgressBar(this);
|
|
this._destroyPanZoom();
|
|
this._destroyCompareSync();
|
|
this._destroyMediaControls();
|
|
this._unbindLayoutObserver();
|
|
this._stopEdgeResize();
|
|
this._clearPopoutCloseWatch();
|
|
try {
|
|
this._panelAC?.abort();
|
|
this._panelAC = null;
|
|
} catch (e: any) {
|
|
console.debug?.(e);
|
|
}
|
|
// Abort all button click listeners in one call (NM-1).
|
|
try {
|
|
this._btnAC?.abort();
|
|
this._btnAC = null;
|
|
} catch (e: any) {
|
|
console.debug?.(e);
|
|
}
|
|
// Abort document-level and pop-out window listeners atomically.
|
|
try {
|
|
this._docAC?.abort();
|
|
this._docAC = null;
|
|
} catch (e: any) {
|
|
console.debug?.(e);
|
|
}
|
|
try {
|
|
this._popoutAC?.abort();
|
|
this._popoutAC = null;
|
|
} catch (e: any) {
|
|
console.debug?.(e);
|
|
}
|
|
// Abort panzoom and compare-sync AbortControllers (belt-and-suspenders).
|
|
try {
|
|
this._panzoomAC?.abort();
|
|
this._panzoomAC = null;
|
|
} catch (e: any) {
|
|
console.debug?.(e);
|
|
}
|
|
try {
|
|
this._compareSyncAC?.abort?.();
|
|
this._compareSyncAC = null;
|
|
} catch (e: any) {
|
|
console.debug?.(e);
|
|
}
|
|
// Pop-in before disposing so the element returns to the main document.
|
|
try {
|
|
if (this._isPopped) this.popIn();
|
|
} catch (e: any) {
|
|
console.debug?.(e);
|
|
}
|
|
this._revokePreviewBlob();
|
|
if (this._onSidebarPosChanged) {
|
|
window.removeEventListener("mjr-settings-changed", this._onSidebarPosChanged);
|
|
this._onSidebarPosChanged = null;
|
|
}
|
|
try {
|
|
this.element?.remove();
|
|
} catch (e: any) {
|
|
console.debug?.(e);
|
|
}
|
|
try {
|
|
this._graphMapPanel?.dispose?.();
|
|
} catch (e: any) {
|
|
console.debug?.(e);
|
|
}
|
|
this._graphMapPanel = null;
|
|
this.element = null;
|
|
this._contentEl = null;
|
|
this._genSidebarEl = null;
|
|
this._closeBtn = null;
|
|
this._modeBtn = null;
|
|
this._pinGroup = null;
|
|
this._pinBtns = null;
|
|
this._liveBtn = null;
|
|
this._nodeStreamBtn = null;
|
|
this._popoutBtn = null;
|
|
this._captureBtn = null;
|
|
this._unbindDocumentUiHandlers();
|
|
try {
|
|
this._genDropdown?.remove();
|
|
} catch (e: any) {
|
|
console.debug?.(e);
|
|
}
|
|
this._mediaA = null;
|
|
this._mediaB = null;
|
|
this._mediaC = null;
|
|
this._mediaD = null;
|
|
this.isVisible = false;
|
|
}
|
|
}
|