Improve missing model display and node location in linker UI

- Add getMissingNodeDisplay() helper to centralize node label formatting
- Make missing list rows keyboard-accessible (Enter/Space selection)
- Add clickable locate buttons for missing nodes to center in graph
- Style focus states for accessibility (outline, box-shadow)
- Add refreshCw icon to icon_utils.js for combo refresh buttons
- Refactor row HTML structure for better semantics
This commit is contained in:
Dariusz L
2026-05-27 17:04:32 +02:00
parent 34d4139ec6
commit 732b45a211
3 changed files with 136 additions and 26 deletions
+70
View File
@@ -342,6 +342,10 @@
#model-linker-modal .ml-missing-list-row:hover {
background: rgba(255,255,255,0.032);
}
#model-linker-modal .ml-missing-list-row:focus-visible {
outline: none;
box-shadow: inset 3px 0 0 rgba(114,211,138,0.72), inset 0 0 0 1px rgba(114,211,138,0.16);
}
#model-linker-modal .ml-missing-list-row.is-selected {
background: linear-gradient(90deg, rgba(114,211,138,0.12) 0%, rgba(114,211,138,0.055) 42%, rgba(114,211,138,0.018) 100%);
box-shadow: inset 3px 0 0 rgba(114,211,138,0.92), inset 0 0 0 1px rgba(114,211,138,0.12);
@@ -384,6 +388,12 @@
color: var(--ml-text-muted);
font-size: 10px;
}
.ml-missing-row-node-label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ml-missing-row-type {
justify-self: start;
max-width: 100%;
@@ -776,6 +786,37 @@
height: var(--ml-spacing-12);
display: block;
}
#model-linker-modal .ml-missing-row-locate {
justify-self: start;
max-width: 100%;
min-width: 0;
min-height: 18px;
margin: 0;
padding: 1px 6px 1px 5px;
gap: 3px;
background: rgba(255,255,255,0.026);
border-color: rgba(255,255,255,0.065);
color: rgba(255,255,255,0.72);
font-family: inherit;
font-size: 10px;
appearance: none;
flex-shrink: 1;
}
#model-linker-modal .ml-missing-row-locate:hover {
background: rgba(255,255,255,0.04);
border-color: rgba(255,255,255,0.11);
color: rgba(255,255,255,0.9);
}
#model-linker-modal .ml-missing-row-locate:focus-visible {
outline: none;
border-color: rgba(114,211,138,0.24);
box-shadow: 0 0 0 1px rgba(114,211,138,0.10);
}
#model-linker-modal .ml-missing-row-locate .ml-node-chip-icon,
#model-linker-modal .ml-missing-row-locate .ml-node-chip-icon svg {
width: 10px;
height: 10px;
}
.ml-category-chip {
display: inline-flex;
padding: var(--ml-spacing-2) var(--ml-spacing-7);
@@ -3299,6 +3340,35 @@
border-color: rgba(114,211,138,0.34);
box-shadow: 0 0 0 2px rgba(114,211,138,0.08);
}
#model-linker-modal .ml-missing-detail-pane .ml-combo-refresh-btn {
flex: 0 0 38px;
width: 38px;
min-width: 38px;
height: 38px;
min-height: 38px;
padding: 0;
aspect-ratio: auto;
background: rgba(255,255,255,0.035);
border-color: rgba(255,255,255,0.10);
border-radius: 6px;
box-shadow: none !important;
color: #d6d6d6;
}
#model-linker-modal .ml-missing-detail-pane .ml-combo-refresh-btn:hover:not(:disabled) {
background: rgba(255,255,255,0.055);
border-color: rgba(255,255,255,0.16);
color: #ffffff;
}
#model-linker-modal .ml-missing-detail-pane .ml-combo-refresh-btn:focus-visible {
outline: none;
border-color: rgba(114,211,138,0.30);
box-shadow: 0 0 0 2px rgba(114,211,138,0.08) !important;
}
#model-linker-modal .ml-missing-detail-pane .ml-combo-refresh-icon {
width: 15px;
height: 15px;
display: block;
}
#model-linker-modal .ml-missing-detail-pane .ml-download-section {
padding: 0;
background: transparent;
+65 -26
View File
@@ -4074,6 +4074,31 @@ class LinkerManagerDialog extends ComfyDialog {
return missing.original_path?.split('/').pop()?.split('\\').pop() || missing.name || 'Missing model';
}
getMissingNodeDisplay(missing = {}) {
const isSubgraphNode = missing.node_type && missing.node_type.match(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i);
let nodeLabel;
if (missing.subgraph_name) {
nodeLabel = missing.subgraph_name;
} else if (isSubgraphNode) {
nodeLabel = 'Subgraph';
} else {
nodeLabel = missing.node_type || 'Node';
}
const nodeId = missing.node_id ?? '';
const customNodeTitle = String(missing.node_title || '').trim();
const hasCustomNodeTitle = customNodeTitle && customNodeTitle !== nodeLabel;
const text = hasCustomNodeTitle
? `${nodeLabel} #${nodeId} · ${customNodeTitle}`
: `${nodeLabel} #${nodeId}`;
return {
label: nodeLabel,
text,
canLocate: missing.is_top_level !== false && nodeId !== ''
};
}
getBestLocalMatch(missing = {}, minConfidence = 0) {
const matches = Array.isArray(missing.matches) ? missing.matches : [];
return matches
@@ -4221,12 +4246,12 @@ class LinkerManagerDialog extends ComfyDialog {
let typePx = 66;
for (const missing of missingModels) {
const filename = this.getMissingFilename(missing);
const nodeLabel = `${missing.subgraph_name || missing.node_type || 'Node'} #${missing.node_id || ''}`;
const nodeLabel = this.getMissingNodeDisplay(missing).text;
const typeLabel = missing.category ? this.getCategoryDisplayName(missing.category) : 'unknown';
modelPx = Math.max(
modelPx,
textWidth(filename, 7.2) + 12,
textWidth(nodeLabel, 5.5) + 12
textWidth(nodeLabel, 5.5) + 34
);
typePx = Math.max(typePx, textWidth(typeLabel, 5.8) + 18);
}
@@ -4283,16 +4308,20 @@ class LinkerManagerDialog extends ComfyDialog {
const matchDisplay = matchName || 'No local match';
const matchClass = confidence === 100 ? 'exact' : (bestMatch ? 'partial' : 'none');
const typeLabel = missing.category ? this.getCategoryDisplayName(missing.category) : 'unknown';
const nodeLabel = missing.subgraph_name || missing.node_type || 'Node';
const nodeDisplay = this.getMissingNodeDisplay(missing);
const nodeId = missing.node_id ?? '';
const rowNodeHtml = nodeDisplay.canLocate
? `<button type="button" class="ml-node-chip is-locatable ml-missing-row-node ml-missing-row-locate" data-node-id="${this.escapeHtml(String(nodeId))}" data-tooltip="Center this node in the ComfyUI graph." aria-label="Center ${this.escapeHtml(nodeDisplay.text)} in the ComfyUI graph">${this.getLocateIconHtml()}<span class="ml-missing-row-node-label">${this.escapeHtml(nodeDisplay.text)}</span></button>`
: `<span class="ml-missing-row-node">${this.escapeHtml(nodeDisplay.text)}</span>`;
html += `
<button type="button"
<div role="button" tabindex="0"
class="ml-missing-list-row ${isSelected ? 'is-selected' : ''}"
data-missing-key="${this.escapeHtml(key)}">
<span class="ml-missing-row-index">${index + 1}</span>
<span class="ml-missing-row-model">
<span class="ml-missing-row-name" data-tooltip="${this.escapeHtml(filename)}">${this.escapeHtml(formattedFilename.display)}</span>
<span class="ml-missing-row-node">${this.escapeHtml(nodeLabel)} #${this.escapeHtml(String(missing.node_id || ''))}</span>
${rowNodeHtml}
</span>
<span class="ml-missing-row-type">${this.escapeHtml(typeLabel)}</span>
<span class="ml-missing-row-best" data-tooltip="${this.escapeHtml(matchDisplay)}">
@@ -4302,7 +4331,7 @@ class LinkerManagerDialog extends ComfyDialog {
<strong>${bestMatch ? `${confidence.toFixed(confidence % 1 ? 1 : 0)}%` : '--'}</strong>
</span>
<span class="ml-missing-row-sources">${this.renderMissingSourcesSummary(missing)}</span>
</button>
</div>
`;
});
@@ -4318,12 +4347,34 @@ class LinkerManagerDialog extends ComfyDialog {
}
wireMissingModelsBrowser(container, data, sortedMissingModels) {
const selectRow = (row) => {
const key = row.dataset.missingKey;
if (!key || key === this.selectedMissingModelKey) return;
this.selectedMissingModelKey = key;
this.displayMissingModels(container, data);
};
container.querySelectorAll('.ml-missing-list-row').forEach(row => {
row.addEventListener('click', () => {
const key = row.dataset.missingKey;
if (!key || key === this.selectedMissingModelKey) return;
this.selectedMissingModelKey = key;
this.displayMissingModels(container, data);
row.addEventListener('click', (event) => {
const clickedLocate = event.target instanceof Element && event.target.closest('.ml-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('.ml-missing-row-locate').forEach(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);
});
});
@@ -4542,20 +4593,8 @@ class LinkerManagerDialog extends ComfyDialog {
const missingFilename = this.formatFilename(missing.original_path, 60);
// Determine node info for the chip
const isSubgraphNode = missing.node_type && missing.node_type.match(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i);
let nodeLabel;
if (missing.subgraph_name) {
nodeLabel = missing.subgraph_name;
} else if (isSubgraphNode) {
nodeLabel = 'Subgraph';
} else {
nodeLabel = missing.node_type || 'Node';
}
const customNodeTitle = String(missing.node_title || '').trim();
const hasCustomNodeTitle = customNodeTitle && customNodeTitle !== nodeLabel;
const nodeChipText = this.escapeHtml(hasCustomNodeTitle
? `${nodeLabel} #${missing.node_id} · ${customNodeTitle}`
: `${nodeLabel} #${missing.node_id}`);
const nodeDisplay = this.getMissingNodeDisplay(missing);
const nodeChipText = this.escapeHtml(nodeDisplay.text);
// Start card
let html = `<div class="ml-card">`;
@@ -4638,7 +4677,7 @@ class LinkerManagerDialog extends ComfyDialog {
html += `<div class="ml-combo-row">`;
html += `<label class="ml-combo-label">Model</label>`;
html += `<input id="combo-input-${comboId}" class="ml-combo-input" type="text" placeholder="Type to filter local models...">`;
html += `<button id="combo-refresh-${comboId}" data-tooltip="Reload local model list" class="ml-btn ml-btn-secondary ml-btn-sm ml-btn-icon-only">⟳</button>`;
html += `<button id="combo-refresh-${comboId}" type="button" aria-label="Reload local model list" data-tooltip="Reload local model list" class="ml-btn ml-btn-secondary ml-btn-sm ml-btn-icon-only ml-combo-refresh-btn">${getSvgIcon('refreshCw', 'currentColor', 'ml-combo-refresh-icon')}</button>`;
html += `</div>`;
html += `<div id="combo-list-${comboId}" class="ml-combo-list"></div>`;
html += `</div>`;
+1
View File
@@ -3,6 +3,7 @@ import { app } from "../../../scripts/app.js";
const SVG_ICONS = {
search: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="{color}" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="6.5"></circle><path d="M16 16l5 5"></path></svg>`,
refreshCw: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="{color}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12a9 9 0 1 1-2.64-6.36"></path><path d="M21 3v6h-6"></path></svg>`,
locate: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="{color}" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2 12h3"></path><path d="M19 12h3"></path><path d="M12 2v3"></path><path d="M12 19v3"></path><circle cx="12" cy="12" r="7"></circle><circle cx="12" cy="12" r="2.5"></circle></svg>`,
download: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="{color}" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3v12"></path><path d="m7 10 5 5 5-5"></path><path d="M5 21h14"></path></svg>`,
externalLink: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="{color}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M15 3h6v6"></path><path d="M10 14 21 3"></path><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path></svg>`,