1053 lines
35 KiB
JavaScript
1053 lines
35 KiB
JavaScript
/**
|
||
* FloatingViewer (MFV) - Majoor Viewer Lite
|
||
*
|
||
* 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,
|
||
buildFloatingViewerGenDropdown,
|
||
buildFloatingViewerGenInfoDOM,
|
||
buildFloatingViewerHeader,
|
||
buildFloatingViewerToolbar,
|
||
closeFloatingViewerGenDropdown,
|
||
getFloatingViewerGenFields,
|
||
isFloatingViewerGenDropdownOpen,
|
||
openFloatingViewerGenDropdown,
|
||
rebindFloatingViewerControlHandlers,
|
||
renderFloatingViewer,
|
||
resetFloatingViewerGenDropdownForCurrentDocument,
|
||
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";
|
||
|
||
function _hasSimplePlayerControls(mediaEl) {
|
||
try {
|
||
return !!mediaEl?.classList?.contains("mjr-mfv-simple-player");
|
||
} catch (e) {
|
||
console.debug?.(e);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
export { MFV_MODES } from "./floatingViewerConstants.js";
|
||
|
||
let _mfvInstanceSeq = 0;
|
||
|
||
export class FloatingViewer {
|
||
constructor({ controller = null } = {}) {
|
||
this._instanceId = ++_mfvInstanceSeq;
|
||
this._controller =
|
||
controller && typeof controller === "object" ? { ...controller } : null;
|
||
this.element = null;
|
||
this.isVisible = false;
|
||
this._contentEl = null;
|
||
this._closeBtn = null;
|
||
this._modeBtn = null;
|
||
this._pinGroup = null;
|
||
this._pinBtns = null;
|
||
this._liveBtn = null;
|
||
this._genBtn = null;
|
||
this._genDropdown = null;
|
||
this._captureBtn = null;
|
||
this._genInfoSelections = new Set(["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
|
||
|
||
// 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.
|
||
this._nodeStreamBtn = null;
|
||
this._nodeStreamActive = false;
|
||
|
||
// 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;
|
||
}
|
||
|
||
_dispatchControllerAction(methodName, fallbackEventType) {
|
||
try {
|
||
const handler = this._controller?.[methodName];
|
||
if (typeof handler === "function") {
|
||
return handler();
|
||
}
|
||
} catch (e) {
|
||
console.debug?.(e);
|
||
}
|
||
if (!fallbackEventType) return undefined;
|
||
try {
|
||
window.dispatchEvent(new Event(fallbackEventType));
|
||
} catch (e) {
|
||
console.debug?.(e);
|
||
}
|
||
return undefined;
|
||
}
|
||
|
||
_forwardKeydownToController(event) {
|
||
try {
|
||
const handler = this._controller?.handleForwardedKeydown;
|
||
if (typeof handler === "function") {
|
||
handler(event);
|
||
return;
|
||
}
|
||
} catch (e) {
|
||
console.debug?.(e);
|
||
}
|
||
try {
|
||
window.dispatchEvent(
|
||
new KeyboardEvent("keydown", {
|
||
key: event?.key,
|
||
code: event?.code,
|
||
keyCode: event?.keyCode,
|
||
ctrlKey: event?.ctrlKey,
|
||
shiftKey: event?.shiftKey,
|
||
altKey: event?.altKey,
|
||
metaKey: event?.metaKey,
|
||
}),
|
||
);
|
||
} catch (e) {
|
||
console.debug?.(e);
|
||
}
|
||
}
|
||
|
||
// ── Build DOM ─────────────────────────────────────────────────────────────
|
||
|
||
render() {
|
||
return renderFloatingViewer(this);
|
||
}
|
||
|
||
_buildHeader() {
|
||
return buildFloatingViewerHeader(this);
|
||
}
|
||
|
||
_buildToolbar() {
|
||
return buildFloatingViewerToolbar(this);
|
||
}
|
||
|
||
_rebindControlHandlers() {
|
||
return rebindFloatingViewerControlHandlers(this);
|
||
}
|
||
|
||
_updateSettingsBtnState(active) {
|
||
return updateFloatingViewerSettingsBtnState(this, active);
|
||
}
|
||
|
||
_applySidebarPosition() {
|
||
return applyFloatingViewerSidebarPosition(this);
|
||
}
|
||
|
||
refreshSidebar() {
|
||
this._sidebar?.refresh();
|
||
}
|
||
|
||
_resetGenDropdownForCurrentDocument() {
|
||
return resetFloatingViewerGenDropdownForCurrentDocument(this);
|
||
}
|
||
|
||
_bindDocumentUiHandlers() {
|
||
return bindFloatingViewerDocumentUiHandlers(this);
|
||
}
|
||
|
||
_unbindDocumentUiHandlers() {
|
||
return unbindFloatingViewerDocumentUiHandlers(this);
|
||
}
|
||
|
||
_isGenDropdownOpen() {
|
||
return isFloatingViewerGenDropdownOpen(this);
|
||
}
|
||
|
||
_openGenDropdown() {
|
||
return openFloatingViewerGenDropdown(this);
|
||
}
|
||
|
||
_closeGenDropdown() {
|
||
return closeFloatingViewerGenDropdown(this);
|
||
}
|
||
|
||
_updateGenBtnUI() {
|
||
return updateFloatingViewerGenButtonUI(this);
|
||
}
|
||
|
||
_buildGenDropdown() {
|
||
return buildFloatingViewerGenDropdown(this);
|
||
}
|
||
|
||
_getGenFields(fileData) {
|
||
return getFloatingViewerGenFields(this, fileData);
|
||
}
|
||
|
||
_buildGenInfoDOM(fileData) {
|
||
return buildFloatingViewerGenInfoDOM(this, fileData);
|
||
}
|
||
|
||
_notifyModeChanged() {
|
||
return notifyFloatingViewerModeChanged(this);
|
||
}
|
||
|
||
_cycleMode() {
|
||
return cycleFloatingViewerMode(this);
|
||
}
|
||
|
||
setMode(mode) {
|
||
return setFloatingViewerMode(this, mode);
|
||
}
|
||
|
||
getPinnedSlots() {
|
||
return getFloatingViewerPinnedSlots(this);
|
||
}
|
||
|
||
_updatePinUI() {
|
||
return updateFloatingViewerPinUI(this);
|
||
}
|
||
|
||
_updateModeBtnUI() {
|
||
return updateFloatingViewerModeButtonUI(this);
|
||
}
|
||
|
||
setLiveActive(active) {
|
||
return setFloatingViewerLiveActive(this, active);
|
||
}
|
||
|
||
setPreviewActive(active) {
|
||
return setFloatingViewerPreviewActive(this, active);
|
||
}
|
||
|
||
loadPreviewBlob(blob, opts = {}) {
|
||
return loadFloatingViewerPreviewBlob(this, blob, opts);
|
||
}
|
||
|
||
_revokePreviewBlob() {
|
||
return revokeFloatingViewerPreviewBlob(this);
|
||
}
|
||
|
||
setNodeStreamActive(active) {
|
||
return setFloatingViewerNodeStreamActive(this, active);
|
||
}
|
||
|
||
loadMediaA(fileData, { autoMode = false } = {}) {
|
||
return loadFloatingViewerMediaA(this, fileData, { autoMode });
|
||
}
|
||
|
||
/**
|
||
* Load two assets for compare modes.
|
||
* Auto-switches from SIMPLE → AB on first call.
|
||
*/
|
||
loadMediaPair(a, b) {
|
||
return loadFloatingViewerMediaPair(this, a, b);
|
||
}
|
||
|
||
/**
|
||
* Load up to 4 assets for grid compare mode.
|
||
* Auto-switches to GRID mode if not already.
|
||
*/
|
||
loadMediaQuad(a, b, c, d) {
|
||
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",
|
||
);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Set zoom, optionally centered at (clientX, clientY).
|
||
* Keeps the image point under the cursor stationary.
|
||
*/
|
||
_setMfvZoom(next, clientX, clientY) {
|
||
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();
|
||
}
|
||
|
||
/** Bind wheel + pointer events to the clip viewport element. */
|
||
_initPanZoom(contentEl) {
|
||
this._destroyPanZoom();
|
||
if (!contentEl) return;
|
||
this._panzoomAC = new AbortController();
|
||
const sig = { signal: this._panzoomAC.signal };
|
||
|
||
// Wheel → zoom centered at cursor
|
||
contentEl.addEventListener(
|
||
"wheel",
|
||
(e) => {
|
||
if (e.target?.closest?.("audio")) return;
|
||
if (e.target?.closest?.(".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) => {
|
||
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-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) {
|
||
console.debug?.(e);
|
||
}
|
||
this._applyTransform();
|
||
},
|
||
sig,
|
||
);
|
||
|
||
contentEl.addEventListener(
|
||
"pointermove",
|
||
(e) => {
|
||
if (!panActive) return;
|
||
this._panX = startPanX + (e.clientX - startX);
|
||
this._panY = startPanY + (e.clientY - startY);
|
||
this._applyTransform();
|
||
},
|
||
sig,
|
||
);
|
||
|
||
const endPan = (e) => {
|
||
if (!panActive) return;
|
||
panActive = false;
|
||
this._dragging = false;
|
||
try {
|
||
contentEl.releasePointerCapture(e.pointerId);
|
||
} catch (e) {
|
||
console.debug?.(e);
|
||
}
|
||
this._applyTransform();
|
||
};
|
||
contentEl.addEventListener("pointerup", endPan, sig);
|
||
contentEl.addEventListener("pointercancel", endPan, sig);
|
||
|
||
// Double-click → zoom to 4× at cursor, or reset to fit
|
||
contentEl.addEventListener(
|
||
"dblclick",
|
||
(e) => {
|
||
if (e.target?.closest?.("video")) return;
|
||
if (e.target?.closest?.("audio")) return;
|
||
if (e.target?.closest?.(".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) {
|
||
console.debug?.(e);
|
||
}
|
||
this._panzoomAC = null;
|
||
this._dragging = false;
|
||
}
|
||
|
||
_destroyCompareSync() {
|
||
try {
|
||
this._compareSyncAC?.abort?.();
|
||
} catch (e) {
|
||
console.debug?.(e);
|
||
}
|
||
this._compareSyncAC = null;
|
||
}
|
||
|
||
_initCompareSync() {
|
||
this._destroyCompareSync();
|
||
if (!this._contentEl) return;
|
||
if (this._mode === MFV_MODES.SIMPLE) return;
|
||
try {
|
||
const playables = Array.from(this._contentEl.querySelectorAll("video, audio"));
|
||
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) {
|
||
console.debug?.(e);
|
||
}
|
||
}
|
||
|
||
// ── Render ────────────────────────────────────────────────────────────────
|
||
|
||
_refresh() {
|
||
if (!this._contentEl) return;
|
||
// Tear down previous panzoom bindings before clearing DOM.
|
||
this._destroyPanZoom();
|
||
this._destroyCompareSync();
|
||
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;
|
||
}
|
||
|
||
if (this._mediaProgressEl) {
|
||
this._contentEl.appendChild(this._mediaProgressEl);
|
||
}
|
||
|
||
this._applyTransform();
|
||
this._initPanZoom(this._contentEl);
|
||
this._initCompareSync();
|
||
}
|
||
|
||
_renderSimple() {
|
||
if (!this._mediaA) {
|
||
this._contentEl.appendChild(_makeEmptyState());
|
||
return;
|
||
}
|
||
const mediaKind = _mediaKind(this._mediaA);
|
||
const mediaEl = _buildMediaEl(this._mediaA);
|
||
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);
|
||
// Keep native audio controls unobstructed.
|
||
if (mediaKind !== "audio") {
|
||
const infoFrag = this._buildGenInfoDOM(this._mediaA);
|
||
if (infoFrag) {
|
||
const ol = document.createElement("div");
|
||
ol.className = "mjr-mfv-geninfo";
|
||
if (_hasSimplePlayerControls(mediaEl)) {
|
||
ol.classList.add("mjr-mfv-geninfo--above-player");
|
||
}
|
||
ol.appendChild(infoFrag);
|
||
wrap.appendChild(ol);
|
||
}
|
||
}
|
||
this._contentEl.appendChild(wrap);
|
||
}
|
||
|
||
_renderAB() {
|
||
const elA = this._mediaA ? _buildMediaEl(this._mediaA, { fill: true }) : null;
|
||
const elB = this._mediaB ? _buildMediaEl(this._mediaB, { fill: true }) : null;
|
||
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 = 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 = 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 = 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) => {
|
||
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 elA = this._mediaA ? _buildMediaEl(this._mediaA) : null;
|
||
const elB = this._mediaB ? _buildMediaEl(this._mediaB) : null;
|
||
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) => 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 el = _buildMediaEl(media);
|
||
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) {
|
||
return setFloatingViewerDesktopExpanded(this, active);
|
||
}
|
||
|
||
_activateDesktopExpandedFallback(error) {
|
||
return activateFloatingViewerDesktopExpandedFallback(this, error);
|
||
}
|
||
|
||
_tryElectronPopupFallback(el, w, h, reason) {
|
||
return tryFloatingViewerElectronPopupFallback(this, el, w, h, reason);
|
||
}
|
||
|
||
popOut() {
|
||
return popOutFloatingViewer(this);
|
||
}
|
||
|
||
_fallbackPopout(el, w, h) {
|
||
return fallbackPopoutFloatingViewer(this, el, w, h);
|
||
}
|
||
|
||
_clearPopoutCloseWatch() {
|
||
return clearFloatingViewerPopoutCloseWatch(this);
|
||
}
|
||
|
||
_startPopoutCloseWatch() {
|
||
return startFloatingViewerPopoutCloseWatch(this);
|
||
}
|
||
|
||
_schedulePopInFromPopupClose() {
|
||
return scheduleFloatingViewerPopInFromPopupClose(this);
|
||
}
|
||
|
||
_installPopoutStyles(doc) {
|
||
return installFloatingViewerPopoutStyles(this, doc);
|
||
}
|
||
|
||
popIn(options) {
|
||
return popInFloatingViewer(this, options);
|
||
}
|
||
|
||
_updatePopoutBtnUI() {
|
||
return updateFloatingViewerPopoutButtonUI(this);
|
||
}
|
||
|
||
get isPopped() {
|
||
return this._isPopped || this._desktopExpanded;
|
||
}
|
||
|
||
_resizeCursorForDirection(dir) {
|
||
return getFloatingViewerResizeCursor(dir);
|
||
}
|
||
|
||
_getResizeDirectionFromPoint(clientX, clientY, rect) {
|
||
return getFloatingViewerResizeDirectionFromPoint(clientX, clientY, rect);
|
||
}
|
||
|
||
_stopEdgeResize() {
|
||
return stopFloatingViewerEdgeResize(this);
|
||
}
|
||
|
||
_bindPanelInteractions() {
|
||
return bindFloatingViewerPanelInteractions(this);
|
||
}
|
||
|
||
_initEdgeResize(el) {
|
||
return initFloatingViewerEdgeResize(this, el);
|
||
}
|
||
|
||
_initDrag(handle) {
|
||
return initFloatingViewerDrag(this, handle);
|
||
}
|
||
|
||
async _drawMediaFit(ctx, fileData, ox, oy, w, h, preferredVideo) {
|
||
return drawFloatingViewerMediaFit(this, ctx, fileData, ox, oy, w, h, preferredVideo);
|
||
}
|
||
|
||
_estimateGenInfoOverlayHeight(ctx, fileData, regionWidth) {
|
||
return estimateFloatingViewerGenInfoOverlayHeight(this, ctx, fileData, regionWidth);
|
||
}
|
||
|
||
_drawGenInfoOverlay(ctx, fileData, ox, oy, w, h) {
|
||
return drawFloatingViewerGenInfoOverlay(this, ctx, fileData, ox, oy, w, h);
|
||
}
|
||
|
||
async _captureView() {
|
||
return captureFloatingViewerView(this);
|
||
}
|
||
|
||
dispose() {
|
||
disposeFloatingViewerProgressBar(this);
|
||
this._destroyPanZoom();
|
||
this._destroyCompareSync();
|
||
this._stopEdgeResize();
|
||
this._clearPopoutCloseWatch();
|
||
try {
|
||
this._panelAC?.abort();
|
||
this._panelAC = null;
|
||
} catch (e) {
|
||
console.debug?.(e);
|
||
}
|
||
// Abort all button click listeners in one call (NM-1).
|
||
try {
|
||
this._btnAC?.abort();
|
||
this._btnAC = null;
|
||
} catch (e) {
|
||
console.debug?.(e);
|
||
}
|
||
// Abort document-level and pop-out window listeners atomically.
|
||
try {
|
||
this._docAC?.abort();
|
||
this._docAC = null;
|
||
} catch (e) {
|
||
console.debug?.(e);
|
||
}
|
||
try {
|
||
this._popoutAC?.abort();
|
||
this._popoutAC = null;
|
||
} catch (e) {
|
||
console.debug?.(e);
|
||
}
|
||
// Abort panzoom and compare-sync AbortControllers (belt-and-suspenders).
|
||
try {
|
||
this._panzoomAC?.abort();
|
||
this._panzoomAC = null;
|
||
} catch (e) {
|
||
console.debug?.(e);
|
||
}
|
||
try {
|
||
this._compareSyncAC?.abort?.();
|
||
this._compareSyncAC = null;
|
||
} catch (e) {
|
||
console.debug?.(e);
|
||
}
|
||
// Pop-in before disposing so the element returns to the main document.
|
||
try {
|
||
if (this._isPopped) this.popIn();
|
||
} catch (e) {
|
||
console.debug?.(e);
|
||
}
|
||
this._revokePreviewBlob();
|
||
if (this._onSidebarPosChanged) {
|
||
window.removeEventListener("mjr-settings-changed", this._onSidebarPosChanged);
|
||
this._onSidebarPosChanged = null;
|
||
}
|
||
try {
|
||
this.element?.remove();
|
||
} catch (e) {
|
||
console.debug?.(e);
|
||
}
|
||
this.element = null;
|
||
this._contentEl = 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) {
|
||
console.debug?.(e);
|
||
}
|
||
this._mediaA = null;
|
||
this._mediaB = null;
|
||
this._mediaC = null;
|
||
this._mediaD = null;
|
||
this.isVisible = false;
|
||
}
|
||
}
|