/** * 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; } }