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
This commit is contained in:
Dariusz L
2026-08-04 15:19:26 +02:00
parent 37728ea198
commit fdaa00265a
6 changed files with 481 additions and 132 deletions
+2 -2
View File
@@ -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\(/);
+19
View File
@@ -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;
+36 -1
View File
@@ -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;
+1
View File
@@ -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) {
+124 -3
View File
@@ -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) {}
+299 -126
View File
@@ -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
? `<button type="button" class="mr-node-chip is-locatable mr-missing-row-node mr-missing-row-locate" data-node-id="${this.escapeHtml(String(nodeId))}" data-subgraph-id="${this.escapeHtml(String(locateTarget.subgraphId || ''))}" data-is-top-level="${locateTarget.isTopLevel ? 'true' : 'false'}" data-tooltip="${this.escapeHtml(nodeDisplay.locateTooltip)}" aria-label="Center ${this.escapeHtml(nodeDisplay.text)} in the ComfyUI graph">${this.getLocateIconHtml()}<span class="mr-missing-row-node-label">${this.escapeHtml(nodeDisplay.text)}</span></button>`
: `<span class="mr-missing-row-node">${this.escapeHtml(nodeDisplay.text)}</span>`;
return `
<div role="button" tabindex="0"
class="mr-missing-list-row ${isSelected ? 'is-selected' : ''} ${isBatchSelected ? 'is-batch-selected' : ''} ${isResolved ? 'is-resolved' : ''}"
data-missing-key="${this.escapeHtml(key)}"
data-missing-index="${index}"
aria-posinset="${index + 1}"
aria-setsize="${totalCount}">
<span class="mr-missing-row-select">
<input type="checkbox" class="mr-missing-row-check" data-ml-no-drag="1" aria-label="Select ${this.escapeHtml(filename)}" ${isBatchSelected ? 'checked' : ''}>
</span>
<span class="mr-missing-row-index">${index + 1}</span>
<span class="mr-missing-row-model ${isResolved ? 'is-resolved' : ''}">
${isResolved ? `<span class="mr-missing-row-resolved-icon" data-tooltip="Model resolved">${getSvgIcon('circleCheckBig')}</span>` : ''}
<span class="mr-missing-row-model-details">
<span class="mr-missing-row-name" data-tooltip="${this.escapeHtml(filename)}">${this.escapeHtml(filename)}</span>
<span class="mr-missing-row-meta-line">
${rowNodeHtml}
${this.renderMissingAutoDownloadBadge(missing, { compact: true })}
</span>
</span>
</span>
<span class="mr-missing-row-type ${typeColorClass}">${this.escapeHtml(typeLabel)}</span>
<span class="mr-missing-row-best" data-tooltip="${this.escapeHtml(matchDisplay)}">
${bestMatch ? this.escapeHtml(matchDisplay) : '<span class="mr-missing-row-none">-- No local match</span>'}
</span>
<span class="mr-missing-row-match mr-missing-row-match-${matchClass}">
<strong>${bestMatch ? `${confidence.toFixed(confidence % 1 ? 1 : 0)}%` : '--'}</strong>
</span>
<span class="mr-missing-row-sources">${this.renderMissingSourcesSummary(missing)}</span>
</div>
`;
},
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 = {
</div>
`;
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
? `<button type="button" class="mr-node-chip is-locatable mr-missing-row-node mr-missing-row-locate" data-node-id="${this.escapeHtml(String(nodeId))}" data-subgraph-id="${this.escapeHtml(String(locateTarget.subgraphId || ''))}" data-is-top-level="${locateTarget.isTopLevel ? 'true' : 'false'}" data-tooltip="${this.escapeHtml(nodeDisplay.locateTooltip)}" aria-label="Center ${this.escapeHtml(nodeDisplay.text)} in the ComfyUI graph">${this.getLocateIconHtml()}<span class="mr-missing-row-node-label">${this.escapeHtml(nodeDisplay.text)}</span></button>`
: `<span class="mr-missing-row-node">${this.escapeHtml(nodeDisplay.text)}</span>`;
html += `
<div role="button" tabindex="0"
class="mr-missing-list-row ${isSelected ? 'is-selected' : ''} ${isBatchSelected ? 'is-batch-selected' : ''} ${isResolved ? 'is-resolved' : ''}"
data-missing-key="${this.escapeHtml(key)}">
<span class="mr-missing-row-select">
<input type="checkbox" class="mr-missing-row-check" data-ml-no-drag="1" aria-label="Select ${this.escapeHtml(filename)}" ${isBatchSelected ? 'checked' : ''}>
</span>
<span class="mr-missing-row-index">${index + 1}</span>
<span class="mr-missing-row-model ${isResolved ? 'is-resolved' : ''}">
${isResolved ? `<span class="mr-missing-row-resolved-icon" data-tooltip="Model resolved">${getSvgIcon('circleCheckBig')}</span>` : ''}
<span class="mr-missing-row-model-details">
<span class="mr-missing-row-name" data-tooltip="${this.escapeHtml(filename)}">${this.escapeHtml(filename)}</span>
<span class="mr-missing-row-meta-line">
${rowNodeHtml}
${this.renderMissingAutoDownloadBadge(missing, { compact: true })}
</span>
</span>
</span>
<span class="mr-missing-row-type ${typeColorClass}">${this.escapeHtml(typeLabel)}</span>
<span class="mr-missing-row-best" data-tooltip="${this.escapeHtml(matchDisplay)}">
${bestMatch ? this.escapeHtml(matchDisplay) : '<span class="mr-missing-row-none">-- No local match</span>'}
</span>
<span class="mr-missing-row-match mr-missing-row-match-${matchClass}">
<strong>${bestMatch ? `${confidence.toFixed(confidence % 1 ? 1 : 0)}%` : '--'}</strong>
</span>
<span class="mr-missing-row-sources">${this.renderMissingSourcesSummary(missing)}</span>
</div>
`;
});
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 += `
<div class="mr-missing-list-virtual-scroll" data-mr-missing-virtual-scroll style="height:${Math.max(0, missingModels.length * MISSING_ROW_FALLBACK_HEIGHT)}px">
<div class="mr-missing-list-virtual-rows" data-mr-missing-virtual-rows>
${initialRowsHtml}
</div>
</div>
`;
html += `
</div>
@@ -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();