From fdaa00265ad5f9a4eba213d431a39ac6559e4d50 Mon Sep 17 00:00:00 2001 From: Dariusz L Date: Tue, 4 Aug 2026 15:19:26 +0200 Subject: [PATCH] feat(frontend): add virtualized missing browser and custom modal resize - Replace full missing-models list rendering with virtual scroll container - Improve splitter responsiveness by lowering layout frame stride to 1 - Add custom modal resize handle with pointer-event driven sizing - Reduce live layout throttle from 40ms to 16ms - Update related route assertions for new layout behavior --- tests/test_downloads_tab_workflow_route.mjs | 4 +- web/css/resolver-main.css | 19 + web/css/resolver-shell.css | 37 +- web/resolver/resolver_dialog.js | 1 + web/resolver/shell/dialog_shell_methods.js | 127 +++++- web/resolver/views/missing_browser_methods.js | 425 ++++++++++++------ 6 files changed, 481 insertions(+), 132 deletions(-) diff --git a/tests/test_downloads_tab_workflow_route.mjs b/tests/test_downloads_tab_workflow_route.mjs index dd0c2b4..32bec14 100644 --- a/tests/test_downloads_tab_workflow_route.mjs +++ b/tests/test_downloads_tab_workflow_route.mjs @@ -413,7 +413,7 @@ test('missing browser splitter keeps a compositor preview between live layout fr } assert.match(missingBrowserMethodsSource, /dragThreshold:\s*4/); - assert.match(missingBrowserMethodsSource, /layoutFrameStride:\s*3/); + assert.match(missingBrowserMethodsSource, /layoutFrameStride:\s*1/); assert.doesNotMatch(missingBrowserMethodsSource, /translate3d\(/); assert.match( resolverMainCssSource, @@ -507,7 +507,7 @@ test('docked ComfyUI splitter coalesces live layout while its gutter stays respo globalThis.window = previousWindow; } - assert.match(dialogShellMethodsSource, /minLayoutInterval:\s*40/); + assert.match(dialogShellMethodsSource, /minLayoutInterval:\s*16/); assert.match(dialogShellMethodsSource, /setPointerCapture\(event\.pointerId\)/); assert.match(dialogShellMethodsSource, /translate3d\(/); assert.match(dialogShellMethodsSource, /requestAnimationFrame\(/); diff --git a/web/css/resolver-main.css b/web/css/resolver-main.css index 7246e43..7514882 100644 --- a/web/css/resolver-main.css +++ b/web/css/resolver-main.css @@ -706,6 +706,8 @@ } .mr-missing-list { + display: flex; + flex-direction: column; min-height: 0; overflow-y: auto; scrollbar-gutter: stable; @@ -713,6 +715,23 @@ background: var(--mr-missing-bg); } +.mr-missing-list-virtual-scroll { + position: relative; + flex: 0 0 auto; + width: 100%; + min-width: 0; + contain: layout paint style; +} + +.mr-missing-list-virtual-rows { + position: absolute; + top: 0; + right: 0; + left: 0; + min-width: 0; + will-change: transform; +} + .mr-missing-browser.is-resizing::after { content: ""; position: absolute; diff --git a/web/css/resolver-shell.css b/web/css/resolver-shell.css index 48ae36c..1a34eaf 100644 --- a/web/css/resolver-shell.css +++ b/web/css/resolver-shell.css @@ -110,7 +110,7 @@ display: none; flex-direction: column; overflow: hidden; - resize: both; + resize: none; z-index: var(--mr-z-dialog); color: var(--mr-text, #f3f3f3); background: linear-gradient(180deg, var(--mr-bg-soft) 0%, var(--mr-bg) 100%); @@ -120,6 +120,41 @@ box-shadow: var(--mr-shadow-dialog); } +.mr-modal-resize-handle { + position: absolute; + right: 0; + bottom: 0; + z-index: 2; + width: 18px; + height: 18px; + cursor: nwse-resize; + touch-action: none; + user-select: none; +} + +.mr-modal-resize-handle::after { + content: ""; + position: absolute; + right: 4px; + bottom: 4px; + width: 8px; + height: 8px; + opacity: 0.58; + background: linear-gradient(135deg, transparent 42%, var(--mr-text-muted, #b1b1b1) 43% 50%, transparent 51% 64%, var(--mr-text-muted, #b1b1b1) 65% 72%, transparent 73%); +} + +.model-resolver-modal.is-resizing { + user-select: none; +} + +.model-resolver-modal.is-resizing .mr-modal-resize-handle::after { + opacity: 1; +} + +#model-resolver-modal.mr-is-docked .mr-modal-resize-handle { + display: none; +} + .mr-context-menu { position: fixed; display: none; diff --git a/web/resolver/resolver_dialog.js b/web/resolver/resolver_dialog.js index 47e3e3e..646188c 100644 --- a/web/resolver/resolver_dialog.js +++ b/web/resolver/resolver_dialog.js @@ -266,6 +266,7 @@ export class ResolverManagerDialog extends ComfyDialog { this.createContent(), this.createFooter() ]); + this.installModalResizeHandle(); this.tooltipObserver = new MutationObserver((mutations) => { for (const mutation of mutations) { diff --git a/web/resolver/shell/dialog_shell_methods.js b/web/resolver/shell/dialog_shell_methods.js index f26f642..7cb3325 100644 --- a/web/resolver/shell/dialog_shell_methods.js +++ b/web/resolver/shell/dialog_shell_methods.js @@ -128,7 +128,7 @@ export const dialogShellMethods = { el.style.maxHeight = '100vh'; el.style.minWidth = '640px'; el.style.minHeight = '420px'; - el.style.resize = 'both'; + el.style.resize = 'none'; el.style.borderRadius = '7px'; const rect = this._floatingRectBeforeDock; @@ -160,6 +160,125 @@ export const dialogShellMethods = { el.style.transform = 'translate(-50%, -50%)'; }, + installModalResizeHandle() { + if (!this.element || this._modalResizeHandle?.isConnected) return; + + const handle = $el("div.mr-modal-resize-handle", { + id: "model-resolver-resize-handle", + ariaHidden: "true" + }); + const eventName = typeof PointerEvent === 'function' ? 'pointerdown' : 'mousedown'; + this._modalResizeStartHandler = (event) => this.startModalResize(event); + handle.addEventListener(eventName, this._modalResizeStartHandler, true); + this._modalResizeHandle = handle; + this._modalResizeStartEventName = eventName; + this.element.appendChild(handle); + }, + + startModalResize(event) { + if (!this.element || this.docked || this.fullscreen) return; + if (event.button !== undefined && event.button !== 0) return; + if (event.isPrimary === false) return; + + 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 state = { + startX: Number(event.clientX) || 0, + startY: Number(event.clientY) || 0, + startWidth, + startHeight, + minWidth, + minHeight, + maxWidth: Math.max(minWidth, startWidth, viewportWidth - rect.left), + maxHeight: Math.max(minHeight, startHeight, viewportHeight - rect.top), + pointerId: event.pointerId, + moveEventName: typeof PointerEvent === 'function' ? 'pointermove' : 'mousemove', + endEventName: typeof PointerEvent === 'function' ? 'pointerup' : 'mouseup', + cancelEventName: typeof PointerEvent === 'function' ? 'pointercancel' : null, + pendingWidth: Number(rect.width) || minWidth, + pendingHeight: Number(rect.height) || minHeight, + 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 = 'width, height'; + this.element.classList.add('is-resizing'); + + state.moveHandler = (moveEvent) => { + if (state.pointerId !== undefined && moveEvent.pointerId !== state.pointerId) return; + const nextWidth = state.startWidth + ((Number(moveEvent.clientX) || state.startX) - state.startX); + const nextHeight = state.startHeight + ((Number(moveEvent.clientY) || state.startY) - state.startY); + state.pendingWidth = Math.min(state.maxWidth, Math.max(state.minWidth, nextWidth)); + state.pendingHeight = Math.min(state.maxHeight, Math.max(state.minHeight, nextHeight)); + 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.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'); }, @@ -301,7 +420,7 @@ export const dialogShellMethods = { animationFrame: null, delayTimer: null, lastLayoutAt: typeof performance === 'object' ? performance.now() : Date.now(), - minLayoutInterval: 40, + minLayoutInterval: 16, appliedPageX: Number(event.pageX ?? event.clientX) || 0, appliedPageY: Number(event.pageY ?? event.clientY) || 0, vertical: proxy?.horizontal === false, @@ -641,6 +760,7 @@ export const dialogShellMethods = { el.style.maxHeight = '100vh'; el.style.borderRadius = '0'; el.style.resize = 'none'; + this._modalResizeHandle?.style.setProperty('display', 'none'); if (btn) { btn.innerHTML = getSvgIcon('windowRestore', 'currentColor', 'mr-window-btn-icon'); btn.setAttribute('aria-label', 'Exit full screen'); @@ -653,7 +773,8 @@ export const dialogShellMethods = { el.style.maxWidth = '100vw'; el.style.maxHeight = '100vh'; el.style.borderRadius = '8px'; - el.style.resize = 'both'; + el.style.resize = 'none'; + this._modalResizeHandle?.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) {} diff --git a/web/resolver/views/missing_browser_methods.js b/web/resolver/views/missing_browser_methods.js index e0e4864..6f712c2 100644 --- a/web/resolver/views/missing_browser_methods.js +++ b/web/resolver/views/missing_browser_methods.js @@ -5,6 +5,10 @@ import { getCivitaiModelUrl } from "../globals.js"; import { safeStorage, normalizePathIdentity } from "../utils/html_utils.js"; import { matchesWorkflowModelReference } from "../node_context_menu.js"; +const MISSING_ROW_FALLBACK_HEIGHT = 70; +const MISSING_VIRTUAL_INITIAL_ROWS = 24; +const MISSING_VIRTUAL_OVERSCAN_ROWS = 6; + export const missingBrowserMethods = { getMissingFilename(missing = {}) { return this.getFilenameFromPath(missing.original_path) || missing.name || 'Missing model'; @@ -434,9 +438,12 @@ export const missingBrowserMethods = { } requestAnimationFrame(() => { - const row = Array.from( + let row = Array.from( this.contentElement?.querySelectorAll?.('.mr-missing-list-row') || [] ).find(item => item.dataset.missingKey === this.selectedMissingModelKey); + if (!row) { + row = this.scrollMissingModelIntoView(this.selectedMissingModelKey); + } row?.scrollIntoView?.({ block: 'center', inline: 'nearest' }); }); return selected; @@ -502,6 +509,177 @@ export const missingBrowserMethods = { requestAnimationFrame(applyScroll); }, + refreshMissingModelsVirtualizer(container, force = false) { + const list = container?.querySelector?.('.mr-missing-list'); + list?._mrMissingVirtualState?.update?.(force); + }, + + getMissingBrowserVirtualRowHeight(list = null) { + const target = list instanceof HTMLElement + ? list + : this.contentElement?.querySelector?.('.mr-missing-list'); + if (target instanceof HTMLElement && typeof getComputedStyle === 'function') { + const rawHeight = getComputedStyle(target) + .getPropertyValue('--mr-missing-row-height') + .trim(); + const parsedHeight = Number.parseFloat(rawHeight); + if (Number.isFinite(parsedHeight) && parsedHeight > 0) return parsedHeight; + } + return MISSING_ROW_FALLBACK_HEIGHT; + }, + + renderMissingModelListRow(missing, index, totalCount, selectedKey) { + const key = this.getMissingModelKey(missing); + const isSelected = key === selectedKey; + const isBatchSelected = this.batchSelectedMissingKeys?.has(key); + const isResolved = this.isMissingModelResolved(missing); + const filename = this.getMissingFilename(missing); + const { bestMatch, confidence, matchDisplay, matchClass } = this.getLocalMatchDisplayInfo(missing); + const typeLabel = missing.category ? this.getCategoryDisplayName(missing.category) : 'unknown'; + const typeColorClass = this.getModelTypeColorClass(missing.category || typeLabel); + const nodeDisplay = this.getMissingNodeDisplay(missing); + const locateTarget = nodeDisplay.locateTarget || this.getMissingLocateTarget(missing); + const nodeId = locateTarget.nodeId ?? ''; + const rowNodeHtml = nodeDisplay.canLocate + ? `` + : `${this.escapeHtml(nodeDisplay.text)}`; + + return ` +
+ + + + ${index + 1} + + ${isResolved ? `${getSvgIcon('circleCheckBig')}` : ''} + + ${this.escapeHtml(filename)} + + ${rowNodeHtml} + ${this.renderMissingAutoDownloadBadge(missing, { compact: true })} + + + + ${this.escapeHtml(typeLabel)} + + ${bestMatch ? this.escapeHtml(matchDisplay) : '-- No local match'} + + + ${bestMatch ? `${confidence.toFixed(confidence % 1 ? 1 : 0)}%` : '--'} + + ${this.renderMissingSourcesSummary(missing)} +
+ `; + }, + + setupMissingModelsVirtualizer(container, data, sortedMissingModels) { + const list = container?.querySelector?.('.mr-missing-list'); + const virtualScroll = list?.querySelector?.('.mr-missing-list-virtual-scroll'); + const rowsHost = virtualScroll?.querySelector?.('.mr-missing-list-virtual-rows'); + if (!(list instanceof HTMLElement) || !(virtualScroll instanceof HTMLElement) || !(rowsHost instanceof HTMLElement)) { + return; + } + + const existingState = list._mrMissingVirtualState; + const state = existingState && existingState.rowsHost === rowsHost + ? existingState + : { + list, + virtualScroll, + rowsHost, + models: sortedMissingModels, + data, + rowHeight: this.getMissingBrowserVirtualRowHeight(list), + start: -1, + end: -1, + resizeObserver: null, + onScroll: null, + update: null + }; + + state.models = sortedMissingModels; + state.data = data; + state.rowHeight = this.getMissingBrowserVirtualRowHeight(list); + state.virtualScroll.style.height = `${Math.max(0, sortedMissingModels.length * state.rowHeight)}px`; + + state.update = (force = false) => { + if (!list.isConnected || !rowsHost.isConnected) return; + + const rowHeight = state.rowHeight || MISSING_ROW_FALLBACK_HEIGHT; + const header = list.querySelector('.mr-missing-list-head'); + const headerHeight = Number(header?.offsetHeight) || 0; + const viewportHeight = Math.max( + rowHeight, + Number(list.clientHeight) || MISSING_VIRTUAL_INITIAL_ROWS * rowHeight + ); + const viewportTop = Math.max(0, Number(list.scrollTop) - headerHeight); + const viewportBottom = Math.max(viewportTop, Number(list.scrollTop) + viewportHeight - headerHeight); + const nextStart = Math.max( + 0, + Math.floor(viewportTop / rowHeight) - MISSING_VIRTUAL_OVERSCAN_ROWS + ); + const nextEnd = Math.min( + state.models.length, + Math.max( + nextStart, + Math.ceil(viewportBottom / rowHeight) + MISSING_VIRTUAL_OVERSCAN_ROWS + ) + ); + + if (!force && nextStart === state.start && nextEnd === state.end) return; + + state.start = nextStart; + state.end = nextEnd; + rowsHost.style.transform = `translateY(${Math.round(nextStart * rowHeight)}px)`; + rowsHost.innerHTML = state.models + .slice(nextStart, nextEnd) + .map((missing, offset) => this.renderMissingModelListRow( + missing, + nextStart + offset, + state.models.length, + this.selectedMissingModelKey + )) + .join(''); + this.wireVisibleMissingModelRows(container, state.data, state.models); + }; + + if (!existingState) { + state.onScroll = () => state.update(false); + list.addEventListener('scroll', state.onScroll, { passive: true }); + if (typeof ResizeObserver === 'function') { + state.resizeObserver = new ResizeObserver(() => state.update(false)); + state.resizeObserver.observe(list); + } + list._mrMissingVirtualState = state; + } + + state.update(true); + }, + + scrollMissingModelIntoView(missingKey) { + const list = this.contentElement?.querySelector?.('.mr-missing-list'); + const state = list?._mrMissingVirtualState; + if (!(list instanceof HTMLElement) || !state) return null; + + const index = state.models.findIndex(missing => this.getMissingModelKey(missing) === missingKey); + if (index < 0) return null; + + const header = list.querySelector('.mr-missing-list-head'); + const headerHeight = Number(header?.offsetHeight) || 0; + const rowHeight = state.rowHeight || MISSING_ROW_FALLBACK_HEIGHT; + const targetTop = headerHeight + index * rowHeight; + const targetScrollTop = targetTop - Math.max(0, (list.clientHeight - rowHeight) / 2); + list.scrollTop = Math.max(0, targetScrollTop); + state.update(true); + return Array.from(list.querySelectorAll('.mr-missing-list-row')) + .find(row => row.dataset.missingKey === missingKey) || null; + }, + renderMissingModelsBrowser(missingModels, selectedKey, totalMissing, activeCount, hasAny100Match, options = {}) { const hiddenResolvedCount = Number(options.hiddenResolvedCount || 0); const hiddenAutoDownloadCount = Number(options.hiddenAutoDownloadCount || 0); @@ -618,51 +796,23 @@ export const missingBrowserMethods = { `; - missingModels.forEach((missing, index) => { - const key = this.getMissingModelKey(missing); - const isSelected = key === selectedKey; - const isBatchSelected = this.batchSelectedMissingKeys?.has(key); - const isResolved = this.isMissingModelResolved(missing); - const filename = this.getMissingFilename(missing); - const { bestMatch, confidence, matchDisplay, matchClass } = this.getLocalMatchDisplayInfo(missing); - const typeLabel = missing.category ? this.getCategoryDisplayName(missing.category) : 'unknown'; - const typeColorClass = this.getModelTypeColorClass(missing.category || typeLabel); - const nodeDisplay = this.getMissingNodeDisplay(missing); - const locateTarget = nodeDisplay.locateTarget || this.getMissingLocateTarget(missing); - const nodeId = locateTarget.nodeId ?? ''; - const rowNodeHtml = nodeDisplay.canLocate - ? `` - : `${this.escapeHtml(nodeDisplay.text)}`; - - html += ` -
- - - - ${index + 1} - - ${isResolved ? `${getSvgIcon('circleCheckBig')}` : ''} - - ${this.escapeHtml(filename)} - - ${rowNodeHtml} - ${this.renderMissingAutoDownloadBadge(missing, { compact: true })} - - - - ${this.escapeHtml(typeLabel)} - - ${bestMatch ? this.escapeHtml(matchDisplay) : '-- No local match'} - - - ${bestMatch ? `${confidence.toFixed(confidence % 1 ? 1 : 0)}%` : '--'} - - ${this.renderMissingSourcesSummary(missing)} -
- `; - }); + const initialRowCount = Math.min(missingModels.length, MISSING_VIRTUAL_INITIAL_ROWS); + const initialRowsHtml = missingModels + .slice(0, initialRowCount) + .map((missing, index) => this.renderMissingModelListRow( + missing, + index, + missingModels.length, + selectedKey + )) + .join(''); + html += ` +
+
+ ${initialRowsHtml} +
+
+ `; html += ` @@ -700,19 +850,30 @@ export const missingBrowserMethods = { currentHead.replaceWith(nextHead); } + const currentVirtualScroll = currentList.querySelector('.mr-missing-list-virtual-scroll'); + const nextVirtualScroll = nextList.querySelector('.mr-missing-list-virtual-scroll'); + if (currentVirtualScroll && nextVirtualScroll) { + currentVirtualScroll.style.height = nextVirtualScroll.style.height; + } + const currentRowsHost = currentVirtualScroll?.querySelector('.mr-missing-list-virtual-rows') || currentList; + const nextRowsHost = nextVirtualScroll?.querySelector('.mr-missing-list-virtual-rows') || nextList; const currentRows = new Map( - Array.from(currentList.querySelectorAll('.mr-missing-list-row')) + Array.from(currentRowsHost.querySelectorAll('.mr-missing-list-row')) .map(row => [row.dataset.missingKey || '', row]) .filter(([key]) => key) ); const retainedRows = new Set(); - nextList.querySelectorAll('.mr-missing-list-row').forEach((nextRow) => { + nextRowsHost.querySelectorAll('.mr-missing-list-row').forEach((nextRow) => { const currentRow = currentRows.get(nextRow.dataset.missingKey || ''); const row = currentRow?.outerHTML === nextRow.outerHTML ? currentRow : nextRow; retainedRows.add(row); - currentList.appendChild(row); + if (currentRowsHost === currentList) { + currentList.appendChild(row); + } else { + currentRowsHost.appendChild(row); + } }); currentRows.forEach((row) => { if (!retainedRows.has(row)) row.remove(); @@ -737,16 +898,90 @@ export const missingBrowserMethods = { return true; }, - wireMissingModelsBrowser(container, data, sortedMissingModels) { - this.wireMissingBrowserSplitter(container); - - const browser = container.querySelector('.mr-missing-browser'); + wireVisibleMissingModelRows(container, data, sortedMissingModels) { const getCurrentData = () => this.cachedAnalysisData || data; const getCurrentMissingModels = () => ( Array.isArray(this.missingModels) ? this.missingModels : sortedMissingModels ); + const selectRow = (row) => { + const key = row.dataset.missingKey; + if (!key || key === this.selectedMissingModelKey) return; + this.selectedMissingModelKey = key; + this.displayMissingModels( + container, + getCurrentData(), + { selectionOnly: true } + ); + }; + + container.querySelectorAll('.mr-missing-list-row').forEach((row) => { + if (this._wiredMissingModelRows.has(row)) return; + this._wiredMissingModelRows.add(row); + const checkbox = row.querySelector('.mr-missing-row-check'); + if (checkbox) { + checkbox.addEventListener('click', (event) => { + event.stopPropagation(); + checkbox._missingShiftClick = event.shiftKey; + }); + checkbox.addEventListener('change', (event) => { + const key = row.dataset.missingKey; + if (!key) return; + const selected = checkbox.checked; + const isShiftRange = event.shiftKey || checkbox._missingShiftClick === true; + const currentMissingModels = getCurrentMissingModels(); + + if (isShiftRange && this.lastBatchSelectedMissingKey) { + this.applyBatchSelectionRange( + currentMissingModels, + this.lastBatchSelectedMissingKey, + key, + selected + ); + } else { + this.setBatchSelectionForKey(key, selected); + } + + this.lastBatchSelectedMissingKey = key; + this.refreshBatchSelectionUi(); + this.updateBatchFooterButtons(); + }); + } + + row.addEventListener('click', (event) => { + const clickedLocate = event.target instanceof Element && event.target.closest('.mr-missing-row-locate'); + if (clickedLocate) return; + selectRow(row); + }); + + row.addEventListener('keydown', (event) => { + if (event.target !== row || (event.key !== 'Enter' && event.key !== ' ')) return; + event.preventDefault(); + selectRow(row); + }); + }); + + container.querySelectorAll('.mr-missing-row-locate').forEach((button) => { + if (this._wiredMissingLocateButtons.has(button)) return; + this._wiredMissingLocateButtons.add(button); + button.addEventListener('click', (event) => { + event.preventDefault(); + event.stopPropagation(); + const rawNodeId = button.dataset.nodeId; + const numericNodeId = Number(rawNodeId); + this.locateNodeInGraph(Number.isNaN(numericNodeId) ? rawNodeId : numericNodeId, { + subgraphId: button.dataset.subgraphId || '', + isTopLevel: button.dataset.isTopLevel !== 'false' + }); + }); + }); + }, + + wireMissingModelsBrowser(container, data, sortedMissingModels) { + this.wireMissingBrowserSplitter(container); + + const browser = container.querySelector('.mr-missing-browser'); if (!(this._wiredMissingModelRows instanceof WeakSet)) { this._wiredMissingModelRows = new WeakSet(); } @@ -856,19 +1091,14 @@ export const missingBrowserMethods = { }); } - const selectRow = (row) => { - const key = row.dataset.missingKey; - if (!key || key === this.selectedMissingModelKey) return; - this.selectedMissingModelKey = key; - this.displayMissingModels( - container, - getCurrentData(), - { selectionOnly: true } - ); - }; - + const getCurrentMissingModels = () => ( + Array.isArray(this.missingModels) + ? this.missingModels + : sortedMissingModels + ); const selectAllCheckbox = container.querySelector('.mr-missing-select-all-check'); - if (selectAllCheckbox) { + if (selectAllCheckbox && selectAllCheckbox.dataset.mlSelectAllBound !== 'true') { + selectAllCheckbox.dataset.mlSelectAllBound = 'true'; this.updateBatchSelectAllCheckbox(); selectAllCheckbox.addEventListener('click', (event) => { event.stopPropagation(); @@ -884,66 +1114,8 @@ export const missingBrowserMethods = { }); } - container.querySelectorAll('.mr-missing-list-row').forEach(row => { - if (this._wiredMissingModelRows.has(row)) return; - this._wiredMissingModelRows.add(row); - const checkbox = row.querySelector('.mr-missing-row-check'); - if (checkbox) { - checkbox.addEventListener('click', (event) => { - event.stopPropagation(); - checkbox._missingShiftClick = event.shiftKey; - }); - checkbox.addEventListener('change', (event) => { - const key = row.dataset.missingKey; - if (!key) return; - const selected = checkbox.checked; - const isShiftRange = event.shiftKey || checkbox._missingShiftClick === true; - const currentMissingModels = getCurrentMissingModels(); - - if (isShiftRange && this.lastBatchSelectedMissingKey) { - this.applyBatchSelectionRange( - currentMissingModels, - this.lastBatchSelectedMissingKey, - key, - selected - ); - } else { - this.setBatchSelectionForKey(key, selected); - } - - this.lastBatchSelectedMissingKey = key; - this.refreshBatchSelectionUi(); - this.updateBatchFooterButtons(); - }); - } - - row.addEventListener('click', (event) => { - const clickedLocate = event.target instanceof Element && event.target.closest('.mr-missing-row-locate'); - if (clickedLocate) return; - selectRow(row); - }); - - row.addEventListener('keydown', (event) => { - if (event.target !== row || (event.key !== 'Enter' && event.key !== ' ')) return; - event.preventDefault(); - selectRow(row); - }); - }); - - container.querySelectorAll('.mr-missing-row-locate').forEach(button => { - if (this._wiredMissingLocateButtons.has(button)) return; - this._wiredMissingLocateButtons.add(button); - button.addEventListener('click', (event) => { - event.preventDefault(); - event.stopPropagation(); - const rawNodeId = button.dataset.nodeId; - const numericNodeId = Number(rawNodeId); - this.locateNodeInGraph(Number.isNaN(numericNodeId) ? rawNodeId : numericNodeId, { - subgraphId: button.dataset.subgraphId || '', - isTopLevel: button.dataset.isTopLevel !== 'false' - }); - }); - }); + this.wireVisibleMissingModelRows(container, data, sortedMissingModels); + this.setupMissingModelsVirtualizer(container, data, sortedMissingModels); const selectedMissing = sortedMissingModels.find(missing => this.getMissingModelKey(missing) === this.selectedMissingModelKey); const detailPreserved = this._missingBrowserDetailPreserved === true; @@ -1241,7 +1413,7 @@ export const missingBrowserMethods = { startWidth: detailWidth, bounds, dragThreshold: 4, - layoutFrameStride: 3, + layoutFrameStride: 1, onPreview: (pendingWidth) => { this._pendingMissingBrowserSplitWidth = pendingWidth; this.activateMissingBrowserSplitUi(); @@ -2283,6 +2455,7 @@ export const missingBrowserMethods = { } this.wireMissingModelsBrowser(container, data, sortedMissingModels); this.restoreMissingListScroll(container, listScrollSnapshot); + this.refreshMissingModelsVirtualizer(container, true); this.scheduleInitialUrnLocalMatchRefresh(sortedMissingModels, container, data); this.reconnectActiveSearchProgress(sortedMissingModels); this.updateBatchFooterButtons();