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:
@@ -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\(/);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {}
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user