import { app } from "../../../../../scripts/app.js"; import { $el } from "../../../../../scripts/ui.js"; import { getSvgIcon } from "../../utils/icon_utils.js"; import { isSidebarButtonActive } from "../utils/dom_patch_utils.js"; import { safeStorage } from "../utils/html_utils.js"; const MODAL_RESIZE_EDGES = ['n', 'ne', 'e', 'se', 's', 'sw', 'w', 'nw']; const MODAL_RESIZE_EDGE_SET = new Set(MODAL_RESIZE_EDGES); export const dialogShellMethods = { createHeader() { // Create tabs this.missingTab = $el("button.mr-tab.mr-tab-active", { onclick: () => this.switchTab('missing') }, [$el("span.mr-tab-label", { textContent: "Missing Models" })]); this.loadedTab = $el("button.mr-tab", { onclick: () => this.switchTab('loaded') }, [$el("span.mr-tab-label", { textContent: "Loaded Models" })]); this.metadataTab = $el("button.mr-tab", { onclick: () => this.switchTab('metadata') }, [$el("span.mr-tab-label", { textContent: "Metadata" })]); this.optionsTab = $el("button.mr-tab", { onclick: () => this.switchTab('options') }, [$el("span.mr-tab-label", { textContent: "Options" })]); if (this.activeTab === 'missing') { this.updateTabButtonStates(); } const dragHandle = $el("div", { id: "model-resolver-drag-handle", ondragstart: (e) => e.preventDefault() }, [ $el("span", { textContent: "⠿" }) ]); this.setTooltip(dragHandle, "Drag window"); const fullscreenButton = $el("button", { id: "model-resolver-fullscreen-toggle", className: "mr-window-btn mr-window-btn--fullscreen", innerHTML: getSvgIcon('windowMaximize', 'currentColor', 'mr-window-btn-icon'), ariaLabel: "Toggle full screen", onclick: () => this.toggleFullScreen() }); this.setTooltip(fullscreenButton, "Toggle full screen"); this.dockButton = $el("button", { id: "model-resolver-dock-toggle", className: "mr-window-btn mr-window-btn--dock", innerHTML: getSvgIcon('dock', 'currentColor', 'mr-window-btn-icon'), ariaLabel: "Dock Model Resolver to sidebar", onclick: () => this.dockToSidebar() }); this.setTooltip(this.dockButton, "Dock to sidebar"); this.undockButton = $el("button", { id: "model-resolver-undock-toggle", className: "mr-window-btn mr-window-btn--undock", innerHTML: getSvgIcon('undock', 'currentColor', 'mr-window-btn-icon'), ariaLabel: "Undock Model Resolver", onclick: () => this.undockToFloating() }); this.setTooltip(this.undockButton, "Undock to floating window"); return $el("div.mr-dialog-shell", {}, [ $el("div.mr-dialog-topbar", {}, [ $el("div.mr-dialog-brand", {}, [ dragHandle, $el("div.mr-tabs", {}, [ this.missingTab, this.loadedTab, this.metadataTab, this.optionsTab ]) ]), $el("div.mr-dialog-controls", {}, [ this.dockButton, this.undockButton, fullscreenButton, $el("button", { className: "mr-window-btn mr-window-btn--close", innerHTML: getSvgIcon('x', 'currentColor', 'mr-window-btn-icon'), ariaLabel: "Close Model Resolver", onclick: () => this.close() }) ]) ]) ]); }, captureFloatingRect() { if (!this.element || this.docked || getComputedStyle(this.element).display === 'none') return; const rect = this.element.getBoundingClientRect(); if (!rect.width || !rect.height) return; this._floatingRectBeforeDock = { top: Math.round(rect.top), left: Math.round(rect.left), width: Math.round(rect.width), height: Math.round(rect.height) }; }, clearModalPlacementStyles() { if (!this.element) return; [ 'position', 'top', 'left', 'right', 'bottom', 'inset', 'transform', 'width', 'height', 'maxWidth', 'maxHeight', 'minWidth', 'minHeight', 'borderRadius', 'resize' ].forEach((property) => { this.element.style[property] = ''; }); }, restoreFloatingGeometry() { const el = this.element; if (!el) return; el.style.width = '1100px'; el.style.height = '700px'; el.style.maxWidth = '100vw'; el.style.maxHeight = '100vh'; el.style.minWidth = '640px'; el.style.minHeight = '420px'; el.style.resize = 'none'; el.style.borderRadius = '7px'; const rect = this._floatingRectBeforeDock; if (rect?.width && rect?.height) { el.style.width = `${rect.width}px`; el.style.height = `${rect.height}px`; el.style.top = `${rect.top}px`; el.style.left = `${rect.left}px`; el.style.transform = 'none'; return; } const wh = JSON.parse(safeStorage.getItem('model_resolver_modal_size_before_fs') || 'null'); if (wh?.w && wh?.h) { el.style.width = `${wh.w}px`; el.style.height = `${wh.h}px`; } const pos = JSON.parse(safeStorage.getItem('model_resolver_modal_pos') || 'null'); if (pos && Number.isFinite(pos.top) && Number.isFinite(pos.left)) { el.style.top = `${pos.top}px`; el.style.left = `${pos.left}px`; el.style.transform = 'none'; return; } el.style.top = '50%'; el.style.left = '50%'; el.style.transform = 'translate(-50%, -50%)'; }, installModalResizeHandle() { if (!this.element) return; if (this._modalResizeHandles?.some((handle) => handle?.isConnected)) return; const eventName = typeof PointerEvent === 'function' ? 'pointerdown' : 'mousedown'; const handles = MODAL_RESIZE_EDGES.map((edge) => { const handle = $el(`div.mr-modal-resize-handle.mr-modal-resize-handle--${edge}`, { id: `model-resolver-resize-handle-${edge}`, ariaHidden: "true" }); handle.addEventListener(eventName, (event) => this.startModalResize(event, edge), true); this.element.appendChild(handle); return handle; }); this._modalResizeHandles = handles; this._modalResizeStartEventName = eventName; }, startModalResize(event, edge = 'se') { if (!this.element || this.docked || this.fullscreen) return; if (event.button !== undefined && event.button !== 0) return; if (event.isPrimary === false) return; const resizeEdge = MODAL_RESIZE_EDGE_SET.has(edge) ? edge : 'se'; const resizesLeft = resizeEdge.includes('w'); const resizesRight = resizeEdge.includes('e'); const resizesTop = resizeEdge.includes('n'); const resizesBottom = resizeEdge.includes('s'); this.finishModalResize({ flush: true }); event.preventDefault?.(); event.stopPropagation?.(); const rect = this.element.getBoundingClientRect(); const viewportWidth = window.innerWidth || document.documentElement.clientWidth || rect.right; const viewportHeight = window.innerHeight || document.documentElement.clientHeight || rect.bottom; const computed = typeof getComputedStyle === 'function' ? getComputedStyle(this.element) : null; const minWidth = Math.max(1, Number.parseFloat(computed?.minWidth) || 640); const minHeight = Math.max(1, Number.parseFloat(computed?.minHeight) || 420); const startWidth = Math.max(minWidth, Number(rect.width) || this.element.offsetWidth || minWidth); const startHeight = Math.max(minHeight, Number(rect.height) || this.element.offsetHeight || minHeight); const startLeft = Number.isFinite(Number(rect.left)) ? Number(rect.left) : 0; const startTop = Number.isFinite(Number(rect.top)) ? Number(rect.top) : 0; const startRight = startLeft + startWidth; const startBottom = startTop + startHeight; const viewportPadding = 4; const state = { edge: resizeEdge, startX: Number(event.clientX) || 0, startY: Number(event.clientY) || 0, startLeft, startTop, startRight, startBottom, startWidth, startHeight, minWidth, minHeight, maxWidth: resizesLeft ? Math.max(minWidth, startWidth, startRight - viewportPadding) : resizesRight ? Math.max(minWidth, startWidth, viewportWidth - startLeft - viewportPadding) : startWidth, maxHeight: resizesTop ? Math.max(minHeight, startHeight, startBottom - viewportPadding) : resizesBottom ? Math.max(minHeight, startHeight, viewportHeight - startTop - viewportPadding) : startHeight, pointerId: event.pointerId, moveEventName: typeof PointerEvent === 'function' ? 'pointermove' : 'mousemove', endEventName: typeof PointerEvent === 'function' ? 'pointerup' : 'mouseup', cancelEventName: typeof PointerEvent === 'function' ? 'pointercancel' : null, pendingLeft: startLeft, pendingTop: startTop, pendingWidth: startWidth, pendingHeight: startHeight, animationFrame: null, moveHandler: null, endHandler: null, cancelHandler: null, blurHandler: null }; this.element.style.top = `${Math.round(rect.top)}px`; this.element.style.left = `${Math.round(rect.left)}px`; this.element.style.transform = 'none'; this.element.style.resize = 'none'; this.element.style.willChange = 'top, left, width, height'; this.element.classList.add('is-resizing'); state.moveHandler = (moveEvent) => { if (state.pointerId !== undefined && moveEvent.pointerId !== state.pointerId) return; const clientX = Number(moveEvent.clientX); const clientY = Number(moveEvent.clientY); const deltaX = (Number.isFinite(clientX) ? clientX : state.startX) - state.startX; const deltaY = (Number.isFinite(clientY) ? clientY : state.startY) - state.startY; let nextLeft = state.startLeft; let nextTop = state.startTop; let nextWidth = state.startWidth; let nextHeight = state.startHeight; if (resizesLeft) { nextWidth = state.startWidth - deltaX; } else if (resizesRight) { nextWidth = state.startWidth + deltaX; } if (resizesTop) { nextHeight = state.startHeight - deltaY; } else if (resizesBottom) { nextHeight = state.startHeight + deltaY; } if (resizesLeft || resizesRight) { state.pendingWidth = Math.min(state.maxWidth, Math.max(state.minWidth, nextWidth)); nextLeft = resizesLeft ? state.startRight - state.pendingWidth : state.startLeft; } if (resizesTop || resizesBottom) { state.pendingHeight = Math.min(state.maxHeight, Math.max(state.minHeight, nextHeight)); nextTop = resizesTop ? state.startBottom - state.pendingHeight : state.startTop; } state.pendingLeft = nextLeft; state.pendingTop = nextTop; if (state.animationFrame) return; state.animationFrame = requestAnimationFrame(() => { state.animationFrame = null; this.applyModalResize(state); }); }; state.endHandler = () => this.finishModalResize({ flush: true }); state.cancelHandler = state.endHandler; state.blurHandler = state.endHandler; this._modalResizeState = state; document.addEventListener(state.moveEventName, state.moveHandler, true); document.addEventListener(state.endEventName, state.endHandler, true); if (state.cancelEventName) { document.addEventListener(state.cancelEventName, state.cancelHandler, true); } window.addEventListener('blur', state.blurHandler, { once: true }); }, applyModalResize(state) { if (!state || this._modalResizeState !== state || !this.element) return; this.element.style.left = `${Math.round(state.pendingLeft)}px`; this.element.style.top = `${Math.round(state.pendingTop)}px`; this.element.style.width = `${Math.round(state.pendingWidth)}px`; this.element.style.height = `${Math.round(state.pendingHeight)}px`; }, finishModalResize({ flush = false } = {}) { const state = this._modalResizeState; if (!state) return false; if (state.animationFrame) { cancelAnimationFrame(state.animationFrame); state.animationFrame = null; } if (flush) this.applyModalResize(state); document.removeEventListener(state.moveEventName, state.moveHandler, true); document.removeEventListener(state.endEventName, state.endHandler, true); if (state.cancelEventName) { document.removeEventListener(state.cancelEventName, state.cancelHandler, true); } window.removeEventListener('blur', state.blurHandler); this._modalResizeState = null; this.element?.classList.remove('is-resizing'); if (this.element) { this.element.style.willChange = ''; this.captureFloatingRect(); this.saveModalPosition?.(); } return true; }, rememberSidebarOpenMode(mode) { safeStorage.setItem(this.sidebarOpenModeStorageKey, mode === 'floating' ? 'floating' : 'docked'); }, shouldOpenFromSidebarFloating() { return safeStorage.getItem(this.sidebarOpenModeStorageKey) === 'floating'; }, isVisible() { if (!this.element?.isConnected) return false; return this.element.style.display === 'flex'; }, dockTo(container) { if (!container || !this.element) return; this.rememberDockDropPreviewWidth(container); if (!this.docked) { if (this._pendingDragDockRect) { this._floatingRectBeforeDock = this._pendingDragDockRect; this._pendingDragDockRect = null; } else { this.captureFloatingRect(); } } this.setDockDropPreviewActive(false); this.setUndockDropPreviewActive(false); if (this.fullscreen) { this.setFullScreen(false); } this.docked = true; this.dockContainer = container; this.lastDockContainer = container; this.pendingDockToSidebar = false; this.rememberSidebarOpenMode('docked'); this.backdrop.style.display = 'none'; container.classList.add('mr-sidebar-dock-panel'); this.element.classList.add('mr-is-docked'); this.clearModalPlacementStyles(); this.element.style.display = 'flex'; if (this.element.parentNode !== container) { container.appendChild(this.element); } this.installSidebarSplitterOptimization(); }, installSidebarSplitterOptimization() { if (this._sidebarSplitterPointerDownHandler || this._sidebarSplitterMouseDownHandler) return; this._sidebarSplitterPointerDownHandler = (event) => { if (event.button !== undefined && event.button !== 0) return; const gutter = this.getResolverSidebarGutter(event.target); if (!gutter || typeof gutter.setPointerCapture !== 'function') return; try { gutter.setPointerCapture(event.pointerId); } catch { // Pointer capture is an optional optimization and can fail for synthetic events. } }; this._sidebarSplitterMouseDownHandler = (event) => { if (event.button !== undefined && event.button !== 0) return; const gutter = this.getResolverSidebarGutter(event.target); if (gutter) this.startSidebarSplitterDragOptimization(event, gutter); }; document.addEventListener('pointerdown', this._sidebarSplitterPointerDownHandler, true); document.addEventListener('mousedown', this._sidebarSplitterMouseDownHandler, true); }, removeSidebarSplitterOptimization() { this.finishSidebarSplitterDragOptimization({ flush: true }); if (this._sidebarSplitterPointerDownHandler) { document.removeEventListener('pointerdown', this._sidebarSplitterPointerDownHandler, true); this._sidebarSplitterPointerDownHandler = null; } if (this._sidebarSplitterMouseDownHandler) { document.removeEventListener('mousedown', this._sidebarSplitterMouseDownHandler, true); this._sidebarSplitterMouseDownHandler = null; } }, getResolverSidebarGutter(target) { if (!this.docked || !(this.dockContainer instanceof HTMLElement) || !this.dockContainer.isConnected) { return null; } const element = target instanceof Element ? target : null; const gutter = element?.closest?.('.p-splitter-gutter'); const panel = this.dockContainer.closest?.('[data-pc-name="splitterpanel"]'); if (!(gutter instanceof HTMLElement) || !(panel instanceof HTMLElement)) return null; return gutter.previousElementSibling === panel || gutter.nextElementSibling === panel ? gutter : null; }, findPrimeVueSplitterResizeOwner(gutter) { if (!(gutter instanceof HTMLElement)) return null; let instance = gutter.__vueParentComponent || gutter.parentElement?.__vueParentComponent || null; for (let depth = 0; instance && depth < 10; depth += 1, instance = instance.parent) { const owner = instance.ctx; if ( owner && typeof owner.onResize === 'function' && typeof owner.onResizeStart === 'function' ) { return { owner, proxy: instance.proxy || owner }; } } return null; }, startSidebarSplitterDragOptimization(event, gutter) { if (this._sidebarSplitterDragState) { this.finishSidebarSplitterDragOptimization({ flush: true }); } const resizeTarget = this.findPrimeVueSplitterResizeOwner(gutter); if (!resizeTarget) return false; const { owner, proxy } = resizeTarget; const originalResize = owner.onResize; const state = { owner, proxy, originalResize, wrappedResize: null, gutter, originalTransform: gutter.style.transform, originalWillChange: gutter.style.willChange, pendingArgs: null, animationFrame: null, delayTimer: null, lastLayoutAt: typeof performance === 'object' ? performance.now() : Date.now(), minLayoutInterval: 16, appliedPageX: Number(event.pageX ?? event.clientX) || 0, appliedPageY: Number(event.pageY ?? event.clientY) || 0, vertical: proxy?.horizontal === false, hasMoved: false, mouseUpHandler: null, blurHandler: null }; state.wrappedResize = (...args) => this.queueSidebarSplitterResize(state, args); try { owner.onResize = state.wrappedResize; } catch { return false; } if (owner.onResize !== state.wrappedResize) return false; state.mouseUpHandler = (mouseEvent) => { this.finishSidebarSplitterDragOptimization({ flush: true, finalEvent: mouseEvent }); }; state.blurHandler = () => this.finishSidebarSplitterDragOptimization({ flush: true }); this._sidebarSplitterDragState = state; document.addEventListener('mouseup', state.mouseUpHandler, true); window.addEventListener('blur', state.blurHandler, { once: true }); return true; }, queueSidebarSplitterResize(state, args) { if (!state || this._sidebarSplitterDragState !== state) { return state?.originalResize?.apply(state.proxy, args); } const event = args?.[0]; const pageX = Number(event?.pageX ?? event?.clientX); const pageY = Number(event?.pageY ?? event?.clientY); state.pendingArgs = args; if (!state.hasMoved) { state.hasMoved = true; state.gutter?.classList?.add('is-resizing'); state.gutter?.parentElement?.classList?.add('is-resizing'); } const offset = state.vertical ? pageY - state.appliedPageY : pageX - state.appliedPageX; if (Number.isFinite(offset)) { state.gutter.style.willChange = 'transform'; state.gutter.style.transform = offset ? `translate3d(${state.vertical ? 0 : Math.round(offset)}px, ${state.vertical ? Math.round(offset) : 0}px, 0)` : state.originalTransform; } if (state.animationFrame || state.delayTimer) return; const now = typeof performance === 'object' ? performance.now() : Date.now(); const delay = Math.max(0, state.minLayoutInterval - (now - state.lastLayoutAt)); const requestFlush = () => { state.delayTimer = null; state.animationFrame = requestAnimationFrame(() => { state.animationFrame = null; this.flushSidebarSplitterResize(state); }); }; if (delay > 0) { state.delayTimer = window.setTimeout(requestFlush, delay); } else { requestFlush(); } }, flushSidebarSplitterResize(state) { if (!state?.pendingArgs) return false; const args = state.pendingArgs; state.pendingArgs = null; const event = args[0]; state.originalResize.apply(state.proxy, args); state.appliedPageX = Number(event?.pageX ?? event?.clientX) || state.appliedPageX; state.appliedPageY = Number(event?.pageY ?? event?.clientY) || state.appliedPageY; state.lastLayoutAt = typeof performance === 'object' ? performance.now() : Date.now(); state.gutter.style.transform = state.originalTransform; return true; }, finishSidebarSplitterDragOptimization({ flush = false, finalEvent = null } = {}) { const state = this._sidebarSplitterDragState; if (!state) return false; this._sidebarSplitterDragState = null; if (state.delayTimer) { clearTimeout(state.delayTimer); state.delayTimer = null; } if (state.animationFrame) { cancelAnimationFrame(state.animationFrame); state.animationFrame = null; } if (flush && state.hasMoved && finalEvent) { state.pendingArgs = [finalEvent]; } if (flush) this.flushSidebarSplitterResize(state); if (state.owner.onResize === state.wrappedResize) { state.owner.onResize = state.originalResize; } state.gutter.style.transform = state.originalTransform; state.gutter.style.willChange = state.originalWillChange; state.gutter?.classList?.remove('is-resizing'); state.gutter?.parentElement?.classList?.remove('is-resizing'); document.removeEventListener('mouseup', state.mouseUpHandler, true); window.removeEventListener('blur', state.blurHandler); return true; }, dockToSidebar() { if (this.docked) return; this.rememberSidebarOpenMode('docked'); this.pendingDockToSidebar = true; if (this.isUsableDockContainer(this.lastDockContainer)) { this.dockTo(this.lastDockContainer); return; } this.tryOpenComfySidebarState(); requestAnimationFrame(() => { if (!this.pendingDockToSidebar || this.docked) return; const button = document.querySelector(`.${this.sidebarTabId}-tab-button`); if (button instanceof HTMLElement) { const isActive = isSidebarButtonActive(button); if (!isActive) { button.click(); } } }); }, isUsableDockContainer(container) { if (!(container instanceof HTMLElement) || !container.isConnected) return false; const style = getComputedStyle(container); if (style.display === 'none' || style.visibility === 'hidden') return false; const rect = container.getBoundingClientRect(); return rect.width > 0 && rect.height > 0; }, trySetSidebarStateProperty(target, property, value) { try { if (!(property in target)) return false; return Reflect.set(target, property, value); } catch (_error) { return false; } }, tryOpenComfySidebarState() { const extensionManager = app.extensionManager; let opened = false; const candidates = [ extensionManager?.sidebarTab, extensionManager?.sidebarTabs, extensionManager?.sidebar, extensionManager ].filter(Boolean); for (const candidate of candidates) { try { if (typeof candidate.openSidebar === 'function') { candidate.openSidebar(this.sidebarTabId); opened = true; } if (typeof candidate.openSidebarTab === 'function') { candidate.openSidebarTab(this.sidebarTabId); opened = true; } if (typeof candidate.setActiveSidebarTab === 'function') { candidate.setActiveSidebarTab(this.sidebarTabId); opened = true; } if (typeof candidate.setActiveSidebarTabId === 'function') { candidate.setActiveSidebarTabId(this.sidebarTabId); opened = true; } if (this.trySetSidebarStateProperty(candidate, 'activeSidebarTabId', this.sidebarTabId)) { opened = true; } } catch (_error) { // Sidebar internals differ between ComfyUI versions; unsupported state APIs are ignored. } } return opened; }, undockToFloating({ persist = true, closeSidebar = true } = {}) { if (!this.element) return; this.removeSidebarSplitterOptimization(); this.setDockDropPreviewActive(false); this.setUndockDropPreviewActive(false); this._pendingDragDockRect = null; const wasDocked = this.docked; const dockContainer = this.dockContainer; if (wasDocked) { this.rememberDockDropPreviewWidth(dockContainer); } this.docked = false; this.dockContainer = null; this.element.classList.remove('mr-is-docked'); this.clearModalPlacementStyles(); if (this.element.parentNode !== document.body) { document.body.appendChild(this.element); } this.element.style.display = 'flex'; this.restoreFloatingGeometry(); this.ensureModalHandleInViewport({ persist }); if (wasDocked && closeSidebar) { this.closeComfySidebar(dockContainer); } }, closeComfySidebar(dockContainer = null) { const closedByState = this.tryCloseComfySidebarState(); requestAnimationFrame(() => { if (dockContainer && !dockContainer.isConnected) return; const button = document.querySelector(`.${this.sidebarTabId}-tab-button`); if (!(button instanceof HTMLElement)) return; const isActive = isSidebarButtonActive(button); if (!closedByState || isActive) { button.click(); } }); }, tryCloseComfySidebarState() { const extensionManager = app.extensionManager; let closed = false; const candidates = [ extensionManager?.sidebarTab, extensionManager?.sidebarTabs, extensionManager?.sidebar, extensionManager ].filter(Boolean); for (const candidate of candidates) { try { if (typeof candidate.closeSidebar === 'function') { candidate.closeSidebar(); closed = true; } if (typeof candidate.closeSidebarTab === 'function') { candidate.closeSidebarTab(this.sidebarTabId); closed = true; } if (typeof candidate.setActiveSidebarTab === 'function') { candidate.setActiveSidebarTab(null); closed = true; } if (typeof candidate.setActiveSidebarTabId === 'function') { candidate.setActiveSidebarTabId(null); closed = true; } if ( this.trySetSidebarStateProperty(candidate, 'activeSidebarTabId', null) || this.trySetSidebarStateProperty(candidate, 'activeSidebarTab', null) ) { closed = true; } } catch (_error) { // Sidebar internals differ between ComfyUI versions; unsupported state APIs are ignored. } } return closed; }, // Toggle full screen mode for the dialog toggleFullScreen() { this.setFullScreen(!this.fullscreen); }, setFullScreen(enable) { const shouldReturnToDocked = !enable && this.returnToDockedAfterFullscreen; if (enable && this.docked) { this.returnToDockedAfterFullscreen = true; this.undockToFloating({ persist: false }); } else if (enable) { this.returnToDockedAfterFullscreen = false; } this.fullscreen = !!enable; const el = this.element; if (!el) return; const btn = document.getElementById('model-resolver-fullscreen-toggle'); if (enable) { // Save current size const rect = el.getBoundingClientRect(); safeStorage.setItem('model_resolver_modal_size_before_fs', JSON.stringify({ w: Math.round(rect.width), h: Math.round(rect.height) })); el.style.top = '0'; el.style.left = '0'; el.style.transform = 'none'; el.style.width = '100vw'; el.style.height = '100vh'; el.style.maxWidth = '100vw'; el.style.maxHeight = '100vh'; el.style.borderRadius = '0'; el.style.resize = 'none'; this._modalResizeHandles?.forEach((handle) => handle.style.setProperty('display', 'none')); if (btn) { btn.innerHTML = getSvgIcon('windowRestore', 'currentColor', 'mr-window-btn-icon'); btn.setAttribute('aria-label', 'Exit full screen'); this.setTooltip(btn, 'Exit full screen'); } safeStorage.setItem('model_resolver_modal_fullscreen', '1'); } else { this.returnToDockedAfterFullscreen = false; // Restore centered sizing el.style.maxWidth = '100vw'; el.style.maxHeight = '100vh'; el.style.borderRadius = '8px'; el.style.resize = 'none'; this._modalResizeHandles?.forEach((handle) => handle.style.removeProperty('display')); // Restore saved pre-FS size if available let wh = null; try { wh = JSON.parse(safeStorage.getItem('model_resolver_modal_size_before_fs') || 'null'); } catch (_e) {} if (wh && wh.w && wh.h) { el.style.width = `${wh.w}px`; el.style.height = `${wh.h}px`; } else { el.style.width = '1100px'; el.style.height = '700px'; } // Restore last known position if available, else center const pos = JSON.parse(safeStorage.getItem('model_resolver_modal_pos') || 'null'); if (pos && Number.isFinite(pos.top) && Number.isFinite(pos.left)) { el.style.top = `${pos.top}px`; el.style.left = `${pos.left}px`; el.style.transform = 'none'; } else { el.style.top = '50%'; el.style.left = '50%'; el.style.transform = 'translate(-50%, -50%)'; } if (btn) { btn.innerHTML = getSvgIcon('windowMaximize', 'currentColor', 'mr-window-btn-icon'); btn.setAttribute('aria-label', 'Enter full screen'); this.setTooltip(btn, 'Enter full screen'); } this.ensureModalHandleInViewport({ persist: true }); safeStorage.setItem('model_resolver_modal_fullscreen', '0'); if (shouldReturnToDocked) { this.dockToSidebar(); } } }, getViewportClampedModalPosition(top, left) { const el = this.element; if (!el) return { top, left }; const vw = window.innerWidth || document.documentElement.clientWidth || 0; const vh = window.innerHeight || document.documentElement.clientHeight || 0; const pad = 4; const handle = document.getElementById('model-resolver-drag-handle'); if (handle) { const elRect = el.getBoundingClientRect(); const handleRect = handle.getBoundingClientRect(); const handleOffsetLeft = handleRect.left - elRect.left; const handleOffsetTop = handleRect.top - elRect.top; const handleWidth = handleRect.width || handle.offsetWidth; const handleHeight = handleRect.height || handle.offsetHeight; const minLeft = pad - handleOffsetLeft; const maxLeft = vw - pad - handleOffsetLeft - handleWidth; const minTop = pad - handleOffsetTop; const maxTop = vh - pad - handleOffsetTop - handleHeight; left = Math.max(minLeft, Math.min(maxLeft, left)); top = Math.max(minTop, Math.min(maxTop, top)); } else { const w = el.offsetWidth; const h = el.offsetHeight; left = Math.max(-w + pad, Math.min(vw - pad, left)); top = Math.max(-h + pad, Math.min(vh - pad, top)); } return { top, left }; }, saveModalPosition(position = null) { if (this.docked) return; const el = this.element; if (!el) return; const top = Number(position?.top); const left = Number(position?.left); if (Number.isFinite(top) && Number.isFinite(left)) { safeStorage.setItem('model_resolver_modal_pos', JSON.stringify({ top: Math.round(top), left: Math.round(left) })); return; } const rect = el.getBoundingClientRect(); safeStorage.setItem('model_resolver_modal_pos', JSON.stringify({ top: Math.round(rect.top), left: Math.round(rect.left) })); }, ensureModalHandleInViewport({ persist = false } = {}) { if (this.docked) return; if (this.fullscreen) return; const el = this.element; if (!el || !this.isVisible()) return; const rect = el.getBoundingClientRect(); const { top, left } = this.getViewportClampedModalPosition(rect.top, rect.left); const nextTop = Math.round(top); const nextLeft = Math.round(left); if (Math.round(rect.top) === nextTop && Math.round(rect.left) === nextLeft) return; el.style.top = `${nextTop}px`; el.style.left = `${nextLeft}px`; el.style.transform = 'none'; if (persist) this.saveModalPosition(); }, scheduleModalViewportClamp(persist = false) { if (this._viewportClampFrame) { cancelAnimationFrame(this._viewportClampFrame); } this._viewportClampFrame = requestAnimationFrame(() => { this._viewportClampFrame = null; this.ensureModalHandleInViewport({ persist }); }); }, getDockSnapThreshold() { const viewportWidth = window.innerWidth || document.documentElement.clientWidth || 0; return Math.max(40, Math.min(64, viewportWidth * 0.045)); }, rememberDockDropPreviewWidth(container = this.lastDockContainer) { if (!(container instanceof HTMLElement) || !container.isConnected) return; const width = container.getBoundingClientRect().width; if (Number.isFinite(width) && width > 0) { this._lastDockContainerWidth = Math.round(width); } }, updateDockDropPreviewWidth() { this.rememberDockDropPreviewWidth(); if (!this.dockDropPreview) return; const viewportWidth = window.innerWidth || document.documentElement.clientWidth || 0; const availableWidth = Math.max(0, viewportWidth - 16); const width = Math.min(Number(this._lastDockContainerWidth) || 0, availableWidth); if (width > 0) { this.dockDropPreview.style.width = `${Math.round(width)}px`; } else { this.dockDropPreview.style.removeProperty('width'); } }, setDockDropPreviewActive(active) { const nextActive = Boolean(active && !this.docked && !this.fullscreen); if (this._dragDockCandidate === nextActive) return; this._dragDockCandidate = nextActive; this.dockDropPreview?.classList.toggle('is-active', nextActive); this.dockDropPreview?.setAttribute('aria-hidden', nextActive ? 'false' : 'true'); }, getRememberedFloatingSize() { const rememberedRect = this._floatingRectBeforeDock; if (rememberedRect?.width > 0 && rememberedRect?.height > 0) { return { width: rememberedRect.width, height: rememberedRect.height }; } let storedSize; try { storedSize = JSON.parse(safeStorage.getItem('model_resolver_modal_size_before_fs') || 'null'); } catch { storedSize = null; } return { width: Number(storedSize?.w) || 1100, height: Number(storedSize?.h) || 700 }; }, getDockedDragPreviewRect(clientX, clientY) { const start = this._dockedDragStart; if (!start) return null; const viewportWidth = window.innerWidth || document.documentElement.clientWidth || 0; const viewportHeight = window.innerHeight || document.documentElement.clientHeight || 0; const width = Math.min(Math.max(start.width, 640), viewportWidth); const height = Math.min(Math.max(start.height, 420), viewportHeight); const maxLeft = Math.max(0, viewportWidth - width); const maxTop = Math.max(0, viewportHeight - height); const left = Math.max(0, Math.min(maxLeft, clientX - start.pointerOffsetLeft)); const top = Math.max(0, Math.min(maxTop, clientY - start.pointerOffsetTop)); return { top: Math.round(top), left: Math.round(left), width: Math.round(width), height: Math.round(height) }; }, setUndockDropPreviewActive(active) { const nextActive = Boolean(active && this.docked && !this.fullscreen); if (this._dragUndockCandidate === nextActive) return; this._dragUndockCandidate = nextActive; this.undockDropPreview?.classList.toggle('is-active', nextActive); this.undockDropPreview?.setAttribute('aria-hidden', nextActive ? 'false' : 'true'); }, startDockedDrag(e) { const el = this.element; if (!el || !this.docked || this.fullscreen) return; e.preventDefault?.(); this.setDockDropPreviewActive(false); this.setUndockDropPreviewActive(false); const rect = el.getBoundingClientRect(); const size = this.getRememberedFloatingSize(); this._dockedDragStart = { x: e.clientX, y: e.clientY, pointerOffsetLeft: Math.max(0, e.clientX - rect.left), pointerOffsetTop: Math.max(0, e.clientY - rect.top), width: size.width, height: size.height }; this._dockedDragPendingRect = this.getDockedDragPreviewRect(e.clientX, e.clientY); if (this.undockDropPreview && this._dockedDragPendingRect) { this.undockDropPreview.style.width = `${this._dockedDragPendingRect.width}px`; this.undockDropPreview.style.height = `${this._dockedDragPendingRect.height}px`; this.undockDropPreview.style.transform = `translate3d(${this._dockedDragPendingRect.left}px, ${this._dockedDragPendingRect.top}px, 0)`; this.undockDropPreview.style.willChange = 'transform'; } this._onMouseMove = (ev) => this.onDockedDrag(ev); this._onMouseUp = () => this.endDockedDrag(); document.addEventListener('mousemove', this._onMouseMove); document.addEventListener('mouseup', this._onMouseUp, { once: true }); }, onDockedDrag(e) { const start = this._dockedDragStart; if (!start) return; const dx = e.clientX - start.x; const dy = e.clientY - start.y; if (!this._dragUndockCandidate && Math.hypot(dx, dy) < 5) return; const shouldUndock = e.clientX > this.getDockSnapThreshold(); this.setUndockDropPreviewActive(shouldUndock); if (!shouldUndock) { this._dockedDragPendingRect = null; return; } this._dockedDragPendingRect = this.getDockedDragPreviewRect(e.clientX, e.clientY); if (this._dockedDragAnimationFrame || !this._dockedDragPendingRect) return; this._dockedDragAnimationFrame = requestAnimationFrame(() => { this._dockedDragAnimationFrame = null; if (!this._dragUndockCandidate || !this._dockedDragPendingRect || !this.undockDropPreview) return; const { left, top } = this._dockedDragPendingRect; this.undockDropPreview.style.transform = `translate3d(${left}px, ${top}px, 0)`; }); }, endDockedDrag() { if (!this._dockedDragStart) return; const shouldUndock = this._dragUndockCandidate; const finalRect = shouldUndock ? this._dockedDragPendingRect : null; document.removeEventListener('mousemove', this._onMouseMove); this.setUndockDropPreviewActive(false); if (this._dockedDragAnimationFrame) { cancelAnimationFrame(this._dockedDragAnimationFrame); this._dockedDragAnimationFrame = null; } if (this.undockDropPreview) { this.undockDropPreview.style.willChange = ''; } this._dockedDragStart = null; this._dockedDragPendingRect = null; if (finalRect) { this.undockToFloating({ persist: false }); this.element.style.width = `${finalRect.width}px`; this.element.style.height = `${finalRect.height}px`; this.element.style.top = `${finalRect.top}px`; this.element.style.left = `${finalRect.left}px`; this.element.style.transform = 'none'; this._floatingRectBeforeDock = { ...finalRect }; this.saveModalPosition(finalRect); } }, // Begin window drag startDrag(e) { if (this.docked) { this.startDockedDrag(e); return; } try { const el = this.element; if (!el) return; e.preventDefault?.(); if (this._dragLayerCleanupTimer) { clearTimeout(this._dragLayerCleanupTimer); this._dragLayerCleanupTimer = null; } this._pendingDragDockRect = null; this.setDockDropPreviewActive(false); this.updateDockDropPreviewWidth(); const rect = el.getBoundingClientRect(); const vw = window.innerWidth || document.documentElement.clientWidth || 0; const vh = window.innerHeight || document.documentElement.clientHeight || 0; const pad = 4; const handle = document.getElementById('model-resolver-drag-handle'); if (handle) { const handleRect = handle.getBoundingClientRect(); const handleOffsetLeft = handleRect.left - rect.left; const handleOffsetTop = handleRect.top - rect.top; const handleWidth = handleRect.width || handle.offsetWidth; const handleHeight = handleRect.height || handle.offsetHeight; this._dragBounds = { minLeft: pad - handleOffsetLeft, maxLeft: vw - pad - handleOffsetLeft - handleWidth, minTop: pad - handleOffsetTop, maxTop: vh - pad - handleOffsetTop - handleHeight }; } else { this._dragBounds = { minLeft: -rect.width + pad, maxLeft: vw - pad, minTop: -rect.height + pad, maxTop: vh - pad }; } // Switch to absolute top/left (no transform) before dragging el.style.top = `${rect.top}px`; el.style.left = `${rect.left}px`; el.style.transform = 'none'; el.style.willChange = 'transform'; this._dragging = true; this._dragStart = { x: e.clientX, y: e.clientY, top: rect.top, left: rect.left, width: rect.width, height: rect.height }; this._dragPendingPosition = { top: Math.round(rect.top), left: Math.round(rect.left) }; // Attach listeners this._onMouseMove = (ev) => this.onDrag(ev); this._onMouseUp = () => this.endDrag(); document.addEventListener('mousemove', this._onMouseMove); document.addEventListener('mouseup', this._onMouseUp, { once: true }); } catch (_err) { /* ignore */ } }, onDrag(e) { if (!this._dragging || !this._dragStart) return; const el = this.element; if (!el) return; const dx = e.clientX - this._dragStart.x; const dy = e.clientY - this._dragStart.y; let top = this._dragStart.top + dy; let left = this._dragStart.left + dx; const bounds = this._dragBounds; if (bounds) { left = Math.max(bounds.minLeft, Math.min(bounds.maxLeft, left)); top = Math.max(bounds.minTop, Math.min(bounds.maxTop, top)); } this._dragPendingPosition = { top: Math.round(top), left: Math.round(left) }; this.setDockDropPreviewActive(e.clientX <= this.getDockSnapThreshold()); if (this._dragAnimationFrame) return; this._dragAnimationFrame = requestAnimationFrame(() => { this._dragAnimationFrame = null; if (!this._dragging || !this._dragStart || !this._dragPendingPosition) return; const translateX = this._dragPendingPosition.left - this._dragStart.left; const translateY = this._dragPendingPosition.top - this._dragStart.top; el.style.transform = `translate3d(${translateX}px, ${translateY}px, 0)`; }); }, endDrag() { if (!this._dragging) return; const shouldDock = this._dragDockCandidate; this._dragging = false; document.removeEventListener('mousemove', this._onMouseMove); this.setDockDropPreviewActive(false); if (this._dragAnimationFrame) { cancelAnimationFrame(this._dragAnimationFrame); this._dragAnimationFrame = null; } const el = this.element; const finalPosition = this._dragPendingPosition || this._dragStart; if (shouldDock && finalPosition && this._dragStart) { this._pendingDragDockRect = { top: Math.round(finalPosition.top), left: Math.round(finalPosition.left), width: this._dragStart.width, height: this._dragStart.height }; } if (el && finalPosition) { el.style.top = `${Math.round(finalPosition.top)}px`; el.style.left = `${Math.round(finalPosition.left)}px`; el.style.transform = 'none'; } this._dragStart = null; this._dragBounds = null; this._dragPendingPosition = null; // Persist position this.saveModalPosition(finalPosition); this._dragLayerCleanupTimer = setTimeout(() => { this._dragLayerCleanupTimer = null; if (!this._dragging && this.element) { this.element.style.willChange = ''; } }, 120); if (shouldDock) { this.dockToSidebar(); } }, /** * Simple debounce helper */ debounce(callback, wait = 250) { let t = null; return (...args) => { if (t) clearTimeout(t); t = setTimeout(() => { callback.apply(this, args); }, wait); }; } };