Add multi-selection and copying for model chips in Loaded Models and Metadata

- Enable multi-selection on model chips with click, Ctrl+click (toggle/add), Shift+click (range from anchor), and Ctrl+Shift+click.
- Add keyboard shortcuts: Ctrl+C to copy selected model tokens to clipboard with feedback, Ctrl+A to select all visible chips, and Escape to clear selection.
- Add a subtle green outline and background highlight for selected chips (.is-selected).
- Add a 'Selected' mode button to the copy toolbar that dynamically appears when chips are selected and syncs token count and preview text.
- Stop click event propagation on chips and clean up prior selection instances during tab switching and re-rendering to prevent ghost listeners and bubbling conflicts between tabs.
- Add comprehensive Node test suite covering selection operations, keyboard shortcuts, DOM rendering, and tab transitions.
This commit is contained in:
Dariusz L
2026-10-16 15:54:46 +02:00
parent 4fba50b655
commit 1cdee997d1
7 changed files with 957 additions and 16 deletions
+534
View File
@@ -0,0 +1,534 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { Window } from 'happy-dom';
import {
initLoadedModelSelection,
updateLoadedModelSelectionState,
getVisibleModelChips,
getSelectedModelChips,
getSelectedModelTokens,
clearLoadedModelSelection,
} from '../web/resolver/utils/loaded_model_selection.js';
import {
groupLoadedModelsByCategory,
buildLoadedModelTokenStrings,
} from '../web/resolver/utils/loaded_model_utils.js';
const getSvgIcon = () => '<svg></svg>';
void groupLoadedModelsByCategory;
void buildLoadedModelTokenStrings;
void getSvgIcon;
function setupDomEnvironment() {
const window = new Window();
const document = window.document;
globalThis.window = window;
globalThis.document = document;
try {
Object.defineProperty(globalThis, 'navigator', {
configurable: true,
writable: true,
value: window.navigator,
});
} catch {
// Fallback for environments where navigator cannot be redefined
}
window.MLSetCopyMode = function(sectionId, mode) {
const section = document.getElementById(sectionId);
if (!section) return;
const normalizedMode = ['all', 'active', 'inactive', 'selected'].includes(mode) ? mode : 'all';
if (normalizedMode !== 'selected') {
section.dataset.mlPreviousMode = normalizedMode;
}
const codeEl = section.querySelector('.mr-copy-code');
const labelEl = section.querySelector('.mr-copy-label');
const metaEl = section.querySelector('.mr-copy-meta');
const dataKey = normalizedMode === 'all'
? 'mlAll'
: normalizedMode === 'active'
? 'mlActive'
: normalizedMode === 'inactive'
? 'mlInactive'
: 'mlSelected';
const countKey = normalizedMode === 'all'
? 'mlAllCount'
: normalizedMode === 'active'
? 'mlActiveCount'
: normalizedMode === 'inactive'
? 'mlInactiveCount'
: 'mlSelectedCount';
const labelMap = {
all: 'Copy all',
active: 'Copy active',
inactive: 'Copy inactive',
selected: 'Copy selected'
};
if (codeEl) codeEl.textContent = section.dataset[dataKey] || '';
if (labelEl) labelEl.textContent = labelMap[normalizedMode];
if (metaEl) {
const count = Number(section.dataset[countKey] || 0);
metaEl.textContent = `${count} token${count === 1 ? '' : 's'}`;
}
section.querySelectorAll('.mr-copy-mode').forEach(btn => {
btn.classList.toggle('active', btn.dataset.mlCopyMode === normalizedMode);
});
};
window.MLSetCopyButtonFeedback = function(btn, text) {
if (!btn) return;
btn.textContent = text;
};
return { window, document };
}
function createLoadedModelsMarkup(copySectionId = 'mr-copy-test') {
return `
<div class="mr-loaded-models-header">
<h3 class="mr-loaded-models-title">Loaded Models</h3>
</div>
<div class="mr-models-list">
<div class="mr-model-section" data-ml-category="checkpoints">
<div class="mr-model-group mr-model-group-active">
<div class="mr-model-chip-list">
<span class="mr-model-chip" data-ml-token="<checkpoint:base_model:1.00>" data-ml-loaded-model-key="key-ckpt-1">base_model</span>
</div>
</div>
</div>
<div class="mr-model-section" data-ml-category="loras">
<div class="mr-model-group mr-model-group-active">
<div class="mr-model-chip-list">
<span class="mr-model-chip" data-ml-token="<lora:lora_a:0.80>" data-ml-loaded-model-key="key-lora-1">lora_a<span class="mr-model-chip-strength">0.80</span></span>
<span class="mr-model-chip" data-ml-token="<lora:lora_b:1.00>" data-ml-loaded-model-key="key-lora-2">lora_b<span class="mr-model-chip-strength">1.00</span></span>
<span class="mr-model-chip" data-ml-token="<lora:lora_c:0.50>" data-ml-loaded-model-key="key-lora-3">lora_c<span class="mr-model-chip-strength">0.50</span></span>
</div>
</div>
<div class="mr-model-group mr-model-group-inactive">
<div class="mr-model-chip-list mr-model-chip-list-inactive">
<span class="mr-model-chip" data-ml-token="<lora:lora_d:1.00>" data-ml-loaded-model-key="key-lora-4">lora_d<span class="mr-model-chip-strength">1.00</span></span>
</div>
</div>
</div>
</div>
<div id="${copySectionId}" class="mr-copy-section" data-ml-active="<checkpoint:base_model:1.00> <lora:lora_a:0.80> <lora:lora_b:1.00> <lora:lora_c:0.50>" data-ml-inactive="<lora:lora_d:1.00>" data-ml-all="<checkpoint:base_model:1.00> <lora:lora_a:0.80> <lora:lora_b:1.00> <lora:lora_c:0.50> <lora:lora_d:1.00>" data-ml-selected="" data-ml-active-count="4" data-ml-inactive-count="1" data-ml-all-count="5" data-ml-selected-count="0" data-ml-previous-mode="all">
<div class="mr-copy-toolbar">
<div class="mr-copy-heading">
<div class="mr-copy-label" id="${copySectionId}-label">Copy all</div>
<div class="mr-copy-meta" id="${copySectionId}-meta">5 tokens</div>
</div>
<div class="mr-copy-actions">
<button type="button" class="mr-btn-filter mr-copy-mode active" data-ml-copy-mode="all" onclick="window.MLSetCopyMode('${copySectionId}', 'all', this)">All</button>
<button type="button" class="mr-btn-filter mr-copy-mode" data-ml-copy-mode="active" onclick="window.MLSetCopyMode('${copySectionId}', 'active', this)">Active</button>
<button type="button" class="mr-btn-filter mr-copy-mode" data-ml-copy-mode="inactive" onclick="window.MLSetCopyMode('${copySectionId}', 'inactive', this)">Inactive</button>
<button type="button" class="mr-btn-filter mr-copy-mode mr-copy-mode-selected" data-ml-copy-mode="selected" style="display: none;" onclick="window.MLSetCopyMode('${copySectionId}', 'selected', this)">Selected (<span class="mr-copy-selected-count">0</span>)</button>
<button class="mr-btn-filter mr-copy-main-btn" onclick="window.MLCopyCode('${copySectionId}', this)">Copy</button>
</div>
</div>
<code class="mr-copy-code" id="${copySectionId}-code"><checkpoint:base_model:1.00> <lora:lora_a:0.80> <lora:lora_b:1.00> <lora:lora_c:0.50> <lora:lora_d:1.00></code>
</div>
`;
}
test('single click selects only clicked chip and switches copy toolbar to selected mode', () => {
const { document } = setupDomEnvironment();
const container = document.createElement('div');
container.innerHTML = createLoadedModelsMarkup();
document.body.appendChild(container);
const cleanup = initLoadedModelSelection(container);
const chips = container.querySelectorAll('.mr-model-chip');
chips[1].dispatchEvent(new window.MouseEvent('click', { bubbles: true, cancelable: true }));
assert.equal(chips[1].classList.contains('is-selected'), true);
assert.equal(chips[0].classList.contains('is-selected'), false);
assert.equal(chips[2].classList.contains('is-selected'), false);
const copySection = container.querySelector('.mr-copy-section');
assert.equal(copySection.dataset.mlSelected, '<lora:lora_a:0.80>');
assert.equal(copySection.dataset.mlSelectedCount, '1');
const selectedBtn = copySection.querySelector('.mr-copy-mode-selected');
assert.equal(selectedBtn.style.display, '');
assert.equal(selectedBtn.classList.contains('active'), true);
const codeEl = copySection.querySelector('.mr-copy-code');
assert.equal(codeEl.textContent, '<lora:lora_a:0.80>');
cleanup();
});
test('ctrl+click adds and toggles chips in multi-selection', () => {
const { document } = setupDomEnvironment();
const container = document.createElement('div');
container.innerHTML = createLoadedModelsMarkup();
document.body.appendChild(container);
const cleanup = initLoadedModelSelection(container);
const chips = container.querySelectorAll('.mr-model-chip');
// Select first chip with regular click
chips[0].dispatchEvent(new window.MouseEvent('click', { bubbles: true }));
assert.deepEqual(getSelectedModelTokens(container), ['<checkpoint:base_model:1.00>']);
// Ctrl+click second chip
chips[2].dispatchEvent(new window.MouseEvent('click', { bubbles: true, ctrlKey: true }));
assert.deepEqual(getSelectedModelTokens(container), [
'<checkpoint:base_model:1.00>',
'<lora:lora_b:1.00>',
]);
const copySection = container.querySelector('.mr-copy-section');
assert.equal(copySection.dataset.mlSelected, '<checkpoint:base_model:1.00> <lora:lora_b:1.00>');
assert.equal(copySection.dataset.mlSelectedCount, '2');
// Ctrl+click to toggle off the first chip
chips[0].dispatchEvent(new window.MouseEvent('click', { bubbles: true, ctrlKey: true }));
assert.deepEqual(getSelectedModelTokens(container), ['<lora:lora_b:1.00>']);
assert.equal(copySection.dataset.mlSelected, '<lora:lora_b:1.00>');
cleanup();
});
test('shift+click performs range selection from previous anchor', () => {
const { document } = setupDomEnvironment();
const container = document.createElement('div');
container.innerHTML = createLoadedModelsMarkup();
document.body.appendChild(container);
const cleanup = initLoadedModelSelection(container);
const chips = container.querySelectorAll('.mr-model-chip');
// Click on index 1 (lora_a) as anchor
chips[1].dispatchEvent(new window.MouseEvent('click', { bubbles: true }));
assert.deepEqual(getSelectedModelTokens(container), ['<lora:lora_a:0.80>']);
// Shift+click on index 3 (lora_c) -> range 1..3 selected
chips[3].dispatchEvent(new window.MouseEvent('click', { bubbles: true, shiftKey: true }));
assert.deepEqual(getSelectedModelTokens(container), [
'<lora:lora_a:0.80>',
'<lora:lora_b:1.00>',
'<lora:lora_c:0.50>',
]);
// Shift+click again on index 2 (lora_b) from same anchor -> range 1..2 selected
chips[2].dispatchEvent(new window.MouseEvent('click', { bubbles: true, shiftKey: true }));
assert.deepEqual(getSelectedModelTokens(container), [
'<lora:lora_a:0.80>',
'<lora:lora_b:1.00>',
]);
cleanup();
});
test('ctrl+shift+click expands existing selection with range', () => {
const { document } = setupDomEnvironment();
const container = document.createElement('div');
container.innerHTML = createLoadedModelsMarkup();
document.body.appendChild(container);
const cleanup = initLoadedModelSelection(container);
const chips = container.querySelectorAll('.mr-model-chip');
// Select index 0 (checkpoint)
chips[0].dispatchEvent(new window.MouseEvent('click', { bubbles: true }));
// Click index 2 as anchor with Ctrl
chips[2].dispatchEvent(new window.MouseEvent('click', { bubbles: true, ctrlKey: true }));
assert.deepEqual(getSelectedModelTokens(container), [
'<checkpoint:base_model:1.00>',
'<lora:lora_b:1.00>',
]);
// Ctrl+Shift+click index 4 (inactive lora_d) -> adds 2..4 to selection without removing index 0
chips[4].dispatchEvent(new window.MouseEvent('click', { bubbles: true, ctrlKey: true, shiftKey: true }));
assert.deepEqual(getSelectedModelTokens(container), [
'<checkpoint:base_model:1.00>',
'<lora:lora_b:1.00>',
'<lora:lora_c:0.50>',
'<lora:lora_d:1.00>',
]);
cleanup();
});
test('clicking blank area deselects all chips and reverts copy mode', () => {
const { document } = setupDomEnvironment();
const container = document.createElement('div');
container.innerHTML = createLoadedModelsMarkup();
document.body.appendChild(container);
const cleanup = initLoadedModelSelection(container);
const chips = container.querySelectorAll('.mr-model-chip');
// Select a chip
chips[1].dispatchEvent(new window.MouseEvent('click', { bubbles: true }));
assert.equal(getSelectedModelChips(container).length, 1);
// Click on empty list space
const list = container.querySelector('.mr-models-list');
list.dispatchEvent(new window.MouseEvent('click', { bubbles: true }));
assert.equal(getSelectedModelChips(container).length, 0);
const copySection = container.querySelector('.mr-copy-section');
assert.equal(copySection.dataset.mlSelectedCount, '0');
assert.equal(copySection.querySelector('.mr-copy-mode-selected').style.display, 'none');
// Reverted back to 'all'
const activeModeBtn = copySection.querySelector('.mr-copy-mode.active');
assert.equal(activeModeBtn.dataset.mlCopyMode, 'all');
cleanup();
});
test('escape key clears selection', () => {
const { document } = setupDomEnvironment();
const container = document.createElement('div');
container.innerHTML = createLoadedModelsMarkup();
document.body.appendChild(container);
const cleanup = initLoadedModelSelection(container);
const chips = container.querySelectorAll('.mr-model-chip');
chips[1].dispatchEvent(new window.MouseEvent('click', { bubbles: true }));
assert.equal(getSelectedModelChips(container).length, 1);
document.dispatchEvent(new window.KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
assert.equal(getSelectedModelChips(container).length, 0);
cleanup();
});
test('ctrl+a selects all visible chips', () => {
const { document } = setupDomEnvironment();
const container = document.createElement('div');
container.innerHTML = createLoadedModelsMarkup();
document.body.appendChild(container);
const cleanup = initLoadedModelSelection(container);
const chips = container.querySelectorAll('.mr-model-chip');
// Make inactive section hidden like MLFilterSwitch('active') does
const inactiveGroup = container.querySelector('.mr-model-group-inactive');
inactiveGroup.style.display = 'none';
document.dispatchEvent(new window.KeyboardEvent('keydown', { key: 'a', ctrlKey: true, bubbles: true }));
const selected = getSelectedModelChips(container);
assert.equal(selected.length, 4); // Only the 4 visible active chips
assert.equal(chips[4].classList.contains('is-selected'), false);
cleanup();
});
test('ctrl+c writes selected tokens to clipboard with feedback', async () => {
const { document } = setupDomEnvironment();
const container = document.createElement('div');
container.innerHTML = createLoadedModelsMarkup();
document.body.appendChild(container);
let writtenText = '';
Object.defineProperty(globalThis, 'navigator', {
configurable: true,
writable: true,
value: {
clipboard: {
writeText: async (text) => {
writtenText = text;
}
}
}
});
const notifications = [];
const dialog = {
activeTab: 'loaded',
showNotification: (msg, type) => notifications.push({ msg, type })
};
const cleanup = initLoadedModelSelection(container, dialog);
const chips = container.querySelectorAll('.mr-model-chip');
// Select two chips
chips[1].dispatchEvent(new window.MouseEvent('click', { bubbles: true }));
chips[2].dispatchEvent(new window.MouseEvent('click', { bubbles: true, ctrlKey: true }));
// Press Ctrl+C
document.dispatchEvent(new window.KeyboardEvent('keydown', { key: 'c', ctrlKey: true, bubbles: true }));
await new Promise(resolve => setTimeout(resolve, 10));
assert.equal(writtenText, '<lora:lora_a:0.80> <lora:lora_b:1.00>');
assert.equal(notifications.length, 1);
assert.match(notifications[0].msg, /Copied 2 model tokens to clipboard/);
cleanup();
});
test('displayLoadedModels renders chips with data-ml-token and includes Selected mode button', () => {
const { document } = setupDomEnvironment();
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const source = fs.readFileSync(path.join(__dirname, '../web/resolver/views/tabs_loaded_methods.js'), 'utf-8');
function extractMethod(sourceCode, methodName) {
const regex = new RegExp(`\\n\\s+${methodName}\\s*\\(([^)]*\\{[^}]*\\}|[^)]*)\\)\\s*\\{`);
const match = regex.exec(sourceCode);
assert.ok(match, `Could not find ${methodName}`);
const params = match[1];
const braceStart = match.index + match[0].length - 1;
let depth = 0;
for (let i = braceStart; i < sourceCode.length; i += 1) {
if (sourceCode[i] === '{') depth += 1;
if (sourceCode[i] === '}') depth -= 1;
if (depth === 0) {
return `function ${methodName}(${params}) ${sourceCode.slice(braceStart, i + 1)}`;
}
}
throw new Error(`Unmatched braces for ${methodName}`);
}
const displayLoadedModels = eval(`(${extractMethod(source, 'displayLoadedModels')})`);
const container = document.createElement('div');
document.body.appendChild(container);
const dialog = {
getModelNameAndStrength: model => ({
name: model.name,
strength: model.strength !== null && model.strength !== undefined ? model.strength.toFixed(2) : null
}),
getFilenameFromPath: pathVal => pathVal,
stripModelExtension: name => name,
getCategoryTokenName: cat => cat.replace(/s$/, ''),
getModelToken: (model, cat) => {
const str = model.strength !== null && model.strength !== undefined ? model.strength.toFixed(2) : '1.00';
return `<${cat.replace(/s$/, '')}:${model.name}:${str}>`;
},
getLoadedModelDomKey: model => model.name,
getLoadedModelContext: model => model,
getContextMenuAttrs: () => '',
getModelPreviewTooltipAttrs: () => '',
getCategoryDisplayName: cat => cat,
escapeHtml: str => String(str),
escapeJsString: str => JSON.stringify(str),
bindLoadedModelSelection: () => {}
};
displayLoadedModels.call(dialog, container, {
loaded_models: [
{ category: 'checkpoints', name: 'model_1', active: true, connected: true },
{ category: 'loras', name: 'lora_1', strength: 0.8, active: true, connected: true },
{ category: 'loras', name: 'lora_2', strength: 1.0, active: false, connected: true },
],
total: 3
});
const chips = container.querySelectorAll('.mr-model-chip');
assert.equal(chips.length, 3);
assert.equal(chips[0].getAttribute('data-ml-token'), '<checkpoint:model_1:1.00>');
assert.equal(chips[1].getAttribute('data-ml-token'), '<lora:lora_1:0.80>');
assert.equal(chips[2].getAttribute('data-ml-token'), '<lora:lora_2:1.00>');
const selectedBtn = container.querySelector('.mr-copy-mode-selected');
assert.ok(selectedBtn, 'Selected mode button should be present in DOM');
assert.equal(selectedBtn.getAttribute('data-ml-copy-mode'), 'selected');
});
test('ctrl+click works reliably in metadata tab even when loaded tab was previously bound', () => {
const { document } = setupDomEnvironment();
const contentElement = document.createElement('div');
contentElement.id = 'mr-content-element';
document.body.appendChild(contentElement);
const dialog = {
activeTab: 'loaded',
contentElement,
_mlSelectionCleanup: null,
bindLoadedModelSelection(container) {
if (typeof this._mlSelectionCleanup === 'function') {
this._mlSelectionCleanup();
this._mlSelectionCleanup = null;
}
const cleanup = initLoadedModelSelection(container, this);
this._mlSelectionCleanup = cleanup;
return cleanup;
}
};
// 1. User was first on "Loaded Models" tab
contentElement.innerHTML = createLoadedModelsMarkup('mr-copy-loaded');
dialog.bindLoadedModelSelection(contentElement);
// 2. User switches to "Metadata" tab
dialog.activeTab = 'metadata';
const metadataShell = document.createElement('div');
metadataShell.className = 'mr-image-inspector';
const metadataResult = document.createElement('div');
metadataResult.setAttribute('data-image-inspector-result', '');
const modelContainer = document.createElement('div');
modelContainer.id = 'mr-image-inspector-models-12345';
modelContainer.innerHTML = createLoadedModelsMarkup('mr-copy-metadata');
metadataResult.appendChild(modelContainer);
metadataShell.appendChild(metadataResult);
contentElement.appendChild(metadataShell);
dialog.bindLoadedModelSelection(modelContainer);
// 3. User Ctrl+clicks chips in Metadata tab
const metaChips = modelContainer.querySelectorAll('.mr-model-chip');
assert.equal(metaChips.length, 5);
// Ctrl+click first chip
metaChips[0].dispatchEvent(new window.MouseEvent('click', { bubbles: true, ctrlKey: true }));
assert.equal(metaChips[0].classList.contains('is-selected'), true, 'First chip must remain selected after Ctrl+click in metadata tab');
assert.deepEqual(getSelectedModelTokens(modelContainer), ['<checkpoint:base_model:1.00>']);
// Ctrl+click second chip
metaChips[1].dispatchEvent(new window.MouseEvent('click', { bubbles: true, ctrlKey: true }));
assert.equal(metaChips[1].classList.contains('is-selected'), true, 'Second chip must remain selected after Ctrl+click');
assert.deepEqual(getSelectedModelTokens(modelContainer), [
'<checkpoint:base_model:1.00>',
'<lora:lora_a:0.80>'
]);
// Ctrl+click first chip again to toggle it off
metaChips[0].dispatchEvent(new window.MouseEvent('click', { bubbles: true, ctrlKey: true }));
assert.equal(metaChips[0].classList.contains('is-selected'), false, 'First chip must be toggled off');
assert.deepEqual(getSelectedModelTokens(modelContainer), ['<lora:lora_a:0.80>']);
const copySection = modelContainer.querySelector('.mr-copy-section');
assert.equal(copySection.dataset.mlSelected, '<lora:lora_a:0.80>');
assert.equal(copySection.dataset.mlSelectedCount, '1');
dialog._mlSelectionCleanup?.();
});
test('chip click event propagation is stopped to prevent parent container interference', () => {
const { document } = setupDomEnvironment();
const parentContainer = document.createElement('div');
const childContainer = document.createElement('div');
childContainer.innerHTML = createLoadedModelsMarkup('mr-copy-nested');
parentContainer.appendChild(childContainer);
document.body.appendChild(parentContainer);
let parentClickFired = false;
parentContainer.addEventListener('click', () => {
parentClickFired = true;
});
const cleanup = initLoadedModelSelection(childContainer);
const chip = childContainer.querySelector('.mr-model-chip');
chip.dispatchEvent(new window.MouseEvent('click', { bubbles: true, ctrlKey: true }));
assert.equal(chip.classList.contains('is-selected'), true);
assert.equal(parentClickFired, false, 'Parent container should not receive click event due to stopPropagation');
cleanup();
});
+19
View File
@@ -4305,6 +4305,25 @@
font-family: var(--mr-font-family-mono);
font-size: var(--mr-font-md);
color: var(--mr-text);
cursor: pointer;
user-select: none;
outline: 1.5px solid transparent;
outline-offset: 1px;
transition: outline-color 0.15s ease, background-color 0.15s ease, box-shadow 0.15s ease;
}
.mr-model-chip:hover {
background: #464646;
}
.mr-model-chip.is-selected {
outline-color: var(--mr-accent, #72d38a);
background: #3d453f;
box-shadow: 0 0 0 1px rgba(114, 211, 138, 0.25);
}
.mr-model-chip-list-inactive .mr-model-chip.is-selected {
opacity: 1;
}
.mr-model-chip-category {
+32 -9
View File
@@ -27,16 +27,32 @@ export function registerGlobalHelpers() {
const section = document.getElementById(sectionId);
if (!section) return;
const normalizedMode = ['all', 'active', 'inactive'].includes(mode) ? mode : 'all';
const normalizedMode = ['all', 'active', 'inactive', 'selected'].includes(mode) ? mode : 'all';
if (normalizedMode !== 'selected') {
section.dataset.mlPreviousMode = normalizedMode;
}
const codeEl = section.querySelector('.mr-copy-code');
const labelEl = section.querySelector('.mr-copy-label');
const metaEl = section.querySelector('.mr-copy-meta');
const dataKey = normalizedMode === 'all' ? 'mlAll' : normalizedMode === 'active' ? 'mlActive' : 'mlInactive';
const countKey = normalizedMode === 'all' ? 'mlAllCount' : normalizedMode === 'active' ? 'mlActiveCount' : 'mlInactiveCount';
const dataKey = normalizedMode === 'all'
? 'mlAll'
: normalizedMode === 'active'
? 'mlActive'
: normalizedMode === 'inactive'
? 'mlInactive'
: 'mlSelected';
const countKey = normalizedMode === 'all'
? 'mlAllCount'
: normalizedMode === 'active'
? 'mlActiveCount'
: normalizedMode === 'inactive'
? 'mlInactiveCount'
: 'mlSelectedCount';
const labelMap = {
all: 'Copy all',
active: 'Copy active',
inactive: 'Copy inactive'
inactive: 'Copy inactive',
selected: 'Copy selected'
};
if (codeEl) codeEl.textContent = section.dataset[dataKey] || '';
@@ -61,16 +77,22 @@ export function registerGlobalHelpers() {
}, 1500);
};
window.MLFilterSwitch = function(filter) {
const filterBtn = document.getElementById('filter-' + filter);
window.MLFilterSwitch = function(filter, clickedBtn = null) {
const root = clickedBtn?.closest?.('.mr-loaded-models-header')?.parentNode
|| document;
const filterBtn = clickedBtn || document.getElementById('filter-' + filter);
if (!filterBtn) return;
// Update button states
document.querySelectorAll('.mr-btn-filter').forEach(b => b.classList.remove('active'));
root.querySelectorAll('.mr-btn-filter').forEach(b => {
if (b.id && b.id.startsWith('filter-')) {
b.classList.remove('active');
}
});
filterBtn.classList.add('active');
// Filter model sections
document.querySelectorAll('.mr-model-section').forEach(s => {
root.querySelectorAll('.mr-model-section').forEach(s => {
const hasActive = s.getAttribute('data-ml-active') === 'true';
const hasInactive = s.getAttribute('data-ml-inactive') === 'true';
@@ -92,7 +114,8 @@ export function registerGlobalHelpers() {
}
});
const copySection = document.querySelector('[id^="mr-copy-"]');
const copySection = root.querySelector?.('[id^="mr-copy-"]')
|| document.querySelector('[id^="mr-copy-"]');
if (copySection) {
window.MLSetCopyMode(copySection.id, filter);
}
@@ -114,6 +114,11 @@ export const lifecycleGraphMethods = {
this.backdrop.style.display = "none";
this.element.style.display = "none";
if (typeof this._mlSelectionCleanup === 'function') {
this._mlSelectionCleanup();
this._mlSelectionCleanup = null;
}
if (wasDocked && collapseSidebar) {
this.closeComfySidebar(dockContainer);
}
@@ -0,0 +1,313 @@
/**
* Multi-selection and copying helpers for model chips in Loaded Models and Metadata tabs.
*/
/**
* Checks whether a model chip is currently visible (not hidden by tab filters).
* @param {Element} chip
* @returns {boolean}
*/
export function isChipVisible(chip) {
if (!chip) return false;
if (chip.style?.display === 'none') return false;
const group = chip.closest?.('.mr-model-group');
if (group && group.style?.display === 'none') return false;
const section = chip.closest?.('.mr-model-section');
if (section && section.style?.display === 'none') return false;
return true;
}
/**
* Returns all currently visible model chips in the container.
* @param {Element} container
* @returns {Element[]}
*/
export function getVisibleModelChips(container) {
if (!container || typeof container.querySelectorAll !== 'function') return [];
return Array.from(container.querySelectorAll('.mr-model-chip')).filter(isChipVisible);
}
/**
* Returns all selected model chips in the container.
* @param {Element} container
* @returns {Element[]}
*/
export function getSelectedModelChips(container) {
if (!container || typeof container.querySelectorAll !== 'function') return [];
return Array.from(container.querySelectorAll('.mr-model-chip.is-selected'));
}
/**
* Returns an array of token strings for all selected model chips.
* @param {Element} container
* @returns {string[]}
*/
export function getSelectedModelTokens(container) {
const selected = getSelectedModelChips(container);
return selected.map(chip => chip.dataset?.mlToken || chip.textContent.trim()).filter(Boolean);
}
/**
* Clears selection across all model chips in the container.
* @param {Element} container
*/
export function clearLoadedModelSelection(container) {
if (!container || typeof container.querySelectorAll !== 'function') return;
const chips = container.querySelectorAll('.mr-model-chip.is-selected');
chips.forEach(chip => chip.classList.remove('is-selected'));
if (container._mlSelectionState) {
container._mlSelectionState.anchorChip = null;
}
updateLoadedModelSelectionState(container);
}
/**
* Synchronizes the selection dataset, copy toolbar, and code preview with current chip selection.
* @param {Element} container
*/
export function updateLoadedModelSelectionState(container) {
if (!container || typeof container.querySelector !== 'function') return;
const selectedChips = getSelectedModelChips(container);
const selectedTokens = selectedChips.map(chip => chip.dataset?.mlToken || chip.textContent.trim()).filter(Boolean);
const selectedString = selectedTokens.join(' ');
const selectedCount = selectedTokens.length;
container.dataset.mlSelectedString = selectedString;
const copySection = container.querySelector('.mr-copy-section');
if (!copySection) return;
copySection.dataset.mlSelected = selectedString;
copySection.dataset.mlSelectedCount = String(selectedCount);
const selectedBtn = copySection.querySelector('.mr-copy-mode-selected');
const selectedCountSpan = copySection.querySelector('.mr-copy-selected-count');
if (selectedCountSpan) {
selectedCountSpan.textContent = String(selectedCount);
}
if (selectedBtn) {
selectedBtn.style.display = selectedCount > 0 ? '' : 'none';
}
const activeModeBtn = copySection.querySelector('.mr-copy-mode.active');
const currentMode = activeModeBtn?.dataset?.mlCopyMode;
if (selectedCount > 0) {
if (currentMode !== 'selected') {
if (typeof window !== 'undefined' && typeof window.MLSetCopyMode === 'function') {
window.MLSetCopyMode(copySection.id, 'selected');
}
} else {
const codeEl = copySection.querySelector('.mr-copy-code');
const labelEl = copySection.querySelector('.mr-copy-label');
const metaEl = copySection.querySelector('.mr-copy-meta');
if (codeEl) codeEl.textContent = selectedString;
if (labelEl) labelEl.textContent = 'Copy selected';
if (metaEl) metaEl.textContent = `${selectedCount} token${selectedCount === 1 ? '' : 's'}`;
}
} else {
if (currentMode === 'selected') {
const fallbackMode = copySection.dataset.mlPreviousMode || 'all';
if (typeof window !== 'undefined' && typeof window.MLSetCopyMode === 'function') {
window.MLSetCopyMode(copySection.id, fallbackMode);
}
}
}
}
/**
* Initializes click, shift-click, ctrl-click, and keyboard shortcuts for model chips.
* @param {Element} container
* @param {object} [dialog]
* @returns {Function} cleanup function
*/
export function initLoadedModelSelection(container, dialog = null) {
if (!container || typeof container.addEventListener !== 'function') {
return () => {};
}
if (container._mlSelectionState?.cleanup) {
container._mlSelectionState.cleanup();
}
const state = {
anchorChip: null,
cleanup: null
};
container._mlSelectionState = state;
const handleChipClick = (chip, event) => {
const isCtrl = Boolean(event.ctrlKey || event.metaKey);
const isShift = Boolean(event.shiftKey);
const visibleChips = getVisibleModelChips(container);
if (isShift) {
const currentIndex = visibleChips.indexOf(chip);
const anchorIndex = state.anchorChip && visibleChips.includes(state.anchorChip)
? visibleChips.indexOf(state.anchorChip)
: currentIndex;
const start = Math.min(anchorIndex, currentIndex);
const end = Math.max(anchorIndex, currentIndex);
if (!isCtrl) {
visibleChips.forEach((c, idx) => {
const inRange = idx >= start && idx <= end;
c.classList.toggle('is-selected', inRange);
});
} else {
visibleChips.forEach((c, idx) => {
if (idx >= start && idx <= end) {
c.classList.add('is-selected');
}
});
}
} else if (isCtrl) {
chip.classList.toggle('is-selected');
state.anchorChip = chip;
} else {
const allChips = container.querySelectorAll('.mr-model-chip');
allChips.forEach(c => {
if (c !== chip) c.classList.remove('is-selected');
});
chip.classList.add('is-selected');
state.anchorChip = chip;
}
updateLoadedModelSelectionState(container);
};
const handleContainerClick = (event) => {
if (event.button !== 0) return;
if (dialog?.activeTab) {
if (dialog.activeTab !== 'loaded' && dialog.activeTab !== 'metadata') {
return;
}
if (container === dialog.contentElement && dialog.activeTab !== 'loaded') {
return;
}
}
const chip = event.target?.closest?.('.mr-model-chip');
if (chip && container.contains(chip)) {
event.stopPropagation();
handleChipClick(chip, event);
return;
}
if (event.target?.closest?.('button, a, input, textarea, select, .mr-copy-code, .mr-copy-toolbar')) {
return;
}
if (!event.ctrlKey && !event.metaKey && !event.shiftKey) {
clearLoadedModelSelection(container);
}
};
const handleKeyDown = (event) => {
const activeEl = typeof document !== 'undefined' ? document.activeElement : null;
if (activeEl && (
activeEl.tagName === 'INPUT' ||
activeEl.tagName === 'TEXTAREA' ||
activeEl.tagName === 'SELECT' ||
activeEl.isContentEditable
)) {
return;
}
if (!container.isConnected) return;
if (container.style?.display === 'none') return;
const modal = container.closest?.('#model-resolver-modal');
if (modal && (modal.style?.display === 'none' || modal.classList.contains('mr-hidden'))) {
return;
}
if (dialog?.activeTab) {
if (dialog.activeTab !== 'loaded' && dialog.activeTab !== 'metadata') {
return;
}
if (container === dialog.contentElement && dialog.activeTab !== 'loaded') {
return;
}
}
// Ctrl+C / Cmd+C (Copy selected)
if ((event.key === 'c' || event.key === 'C') && (event.ctrlKey || event.metaKey) && !event.altKey) {
const browserSelection = typeof window !== 'undefined' ? window.getSelection?.()?.toString() : '';
if (browserSelection && browserSelection.trim().length > 0) {
return;
}
const selectedChips = getSelectedModelChips(container);
if (selectedChips.length > 0) {
event.preventDefault();
const copySection = container.querySelector('.mr-copy-section');
const selectedText = copySection?.dataset?.mlSelected
|| selectedChips.map(c => c.dataset?.mlToken || c.textContent.trim()).filter(Boolean).join(' ');
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
navigator.clipboard.writeText(selectedText).then(() => {
const copyBtn = copySection?.querySelector('.mr-copy-main-btn');
if (copyBtn && typeof window !== 'undefined' && typeof window.MLSetCopyButtonFeedback === 'function') {
window.MLSetCopyButtonFeedback(copyBtn, 'Copied');
}
if (typeof dialog?.showNotification === 'function') {
dialog.showNotification(
`Copied ${selectedChips.length} model token${selectedChips.length === 1 ? '' : 's'} to clipboard`,
'info'
);
}
}).catch(err => {
console.error('Model Resolver: clipboard write failed', err);
});
}
return;
}
}
// Ctrl+A / Cmd+A (Select All visible chips)
if ((event.key === 'a' || event.key === 'A') && (event.ctrlKey || event.metaKey) && !event.altKey) {
const visibleChips = getVisibleModelChips(container);
if (visibleChips.length > 0) {
event.preventDefault();
visibleChips.forEach(c => c.classList.add('is-selected'));
if (!state.anchorChip && visibleChips.length) {
state.anchorChip = visibleChips[0];
}
updateLoadedModelSelectionState(container);
return;
}
}
// Escape (Deselect all)
if (event.key === 'Escape') {
const selectedChips = getSelectedModelChips(container);
if (selectedChips.length > 0) {
event.preventDefault();
event.stopPropagation();
clearLoadedModelSelection(container);
}
}
};
container.addEventListener('click', handleContainerClick);
if (typeof document !== 'undefined') {
document.addEventListener('keydown', handleKeyDown);
}
const cleanup = () => {
container.removeEventListener('click', handleContainerClick);
if (typeof document !== 'undefined') {
document.removeEventListener('keydown', handleKeyDown);
}
if (container._mlSelectionState === state) {
container._mlSelectionState = null;
}
};
state.cleanup = cleanup;
return cleanup;
}
@@ -538,6 +538,10 @@ export const imageMetadataMethods = {
},
clearImageMetadata() {
if (typeof this._mlSelectionCleanup === 'function') {
this._mlSelectionCleanup();
this._mlSelectionCleanup = null;
}
const state = getImageInspectorState(this);
state.requestToken = `cleared-${Date.now()}`;
state.metadata = null;
@@ -549,6 +553,10 @@ export const imageMetadataMethods = {
},
renderImageMetadataResult() {
if (typeof this._mlSelectionCleanup === 'function') {
this._mlSelectionCleanup();
this._mlSelectionCleanup = null;
}
const result = this.contentElement?.querySelector('[data-image-inspector-result]');
if (!result) return;
const state = getImageInspectorState(this);
+46 -7
View File
@@ -8,6 +8,10 @@ import {
buildLoadedModelTokenStrings,
groupLoadedModelsByCategory,
} from "../utils/loaded_model_utils.js";
import {
initLoadedModelSelection,
updateLoadedModelSelectionState,
} from "../utils/loaded_model_selection.js";
export const tabsLoadedMethods = {
getTabButton(tab) {
return {
@@ -59,6 +63,12 @@ export const tabsLoadedMethods = {
this._loadedModelsLoadToken = null;
this._loadedModelsProgressToken = null;
}
if (this.activeTab !== 'loaded' && this.activeTab !== 'metadata') {
if (typeof this._mlSelectionCleanup === 'function') {
this._mlSelectionCleanup();
this._mlSelectionCleanup = null;
}
}
this.hideTooltip();
this.animateTabContentTransition();
this.updateTabButtonStates();
@@ -243,7 +253,11 @@ export const tabsLoadedMethods = {
?.dataset?.mlCopyMode || 'all';
const copyCode = copySection.querySelector?.('.mr-copy-code');
if (copyCode) {
copyCode.textContent = strings[activeMode] || strings.all;
if (activeMode === 'selected') {
copyCode.textContent = copySection.dataset.mlSelected || '';
} else {
copyCode.textContent = strings[activeMode] || strings.all;
}
}
}
@@ -321,6 +335,9 @@ export const tabsLoadedMethods = {
if (strengthElement) {
strengthElement.textContent = entry.strength.toFixed(2);
}
if (chip && typeof this.getModelToken === 'function') {
chip.dataset.mlToken = this.getModelToken(model, model.category || 'unknown');
}
updated = true;
}
if (!updated) return false;
@@ -329,6 +346,7 @@ export const tabsLoadedMethods = {
this.contentElement,
this.cachedLoadedModelsData.loaded_models
);
this.updateLoadedModelSelectionState?.(this.contentElement);
const workflow = this.getCurrentWorkflow?.();
const workflowSignature = workflow
? this.getWorkflowSignature?.(workflow)
@@ -386,9 +404,9 @@ export const tabsLoadedMethods = {
<p class="mr-loaded-models-subtitle">${this.escapeHtml(modelSubtitle)}</p>
</div>
<div class="mr-loaded-filter-row">
<button class="mr-btn-filter active" id="filter-all" onclick="window.MLFilterSwitch('all')">All (${activeCount + inactiveCount})</button>
<button class="mr-btn-filter" id="filter-active" onclick="window.MLFilterSwitch('active')">Active (${activeCount})</button>
<button class="mr-btn-filter" id="filter-inactive" onclick="window.MLFilterSwitch('inactive')">Inactive (${inactiveCount})</button>
<button class="mr-btn-filter active" id="filter-all" onclick="window.MLFilterSwitch('all', this)">All (${activeCount + inactiveCount})</button>
<button class="mr-btn-filter" id="filter-active" onclick="window.MLFilterSwitch('active', this)">Active (${activeCount})</button>
<button class="mr-btn-filter" id="filter-inactive" onclick="window.MLFilterSwitch('inactive', this)">Inactive (${inactiveCount})</button>
</div>
</div>
<div class="mr-models-list mr-models-list-pad">
@@ -427,7 +445,8 @@ export const tabsLoadedMethods = {
const { name, strength } = this.getModelNameAndStrength(model);
const fullName = model.original_path || model.name || name;
const modelKey = this.getLoadedModelDomKey(model);
html += `<span class="mr-model-chip" data-ml-loaded-model-key="${this.escapeHtml(modelKey)}"${modelContextAttrs(model)}${this.getModelPreviewTooltipAttrs(model, fullName)}>${this.escapeHtml(name)}${strength !== null ? `<span class="mr-model-chip-strength">${this.escapeHtml(strength)}</span>` : ''}</span>`;
const token = this.getModelToken(model, category);
html += `<span class="mr-model-chip" data-ml-token="${this.escapeHtml(token)}" data-ml-loaded-model-key="${this.escapeHtml(modelKey)}"${modelContextAttrs(model)}${this.getModelPreviewTooltipAttrs(model, fullName)}>${this.escapeHtml(name)}${strength !== null ? `<span class="mr-model-chip-strength">${this.escapeHtml(strength)}</span>` : ''}</span>`;
}
html += `</div></div>`;
}
@@ -446,7 +465,8 @@ export const tabsLoadedMethods = {
const { name, strength } = this.getModelNameAndStrength(model);
const fullName = model.original_path || model.name || name;
const modelKey = this.getLoadedModelDomKey(model);
html += `<span class="mr-model-chip" data-ml-loaded-model-key="${this.escapeHtml(modelKey)}"${modelContextAttrs(model)}${this.getModelPreviewTooltipAttrs(model, fullName)}>${this.escapeHtml(name)}${strength !== null ? `<span class="mr-model-chip-strength">${this.escapeHtml(strength)}</span>` : ''}</span>`;
const token = this.getModelToken(model, category);
html += `<span class="mr-model-chip" data-ml-token="${this.escapeHtml(token)}" data-ml-loaded-model-key="${this.escapeHtml(modelKey)}"${modelContextAttrs(model)}${this.getModelPreviewTooltipAttrs(model, fullName)}>${this.escapeHtml(name)}${strength !== null ? `<span class="mr-model-chip-strength">${this.escapeHtml(strength)}</span>` : ''}</span>`;
}
html += `</div></div>`;
}
@@ -457,7 +477,7 @@ export const tabsLoadedMethods = {
const copySectionId = 'mr-copy-' + Date.now();
html += `
</div>
<div id="${copySectionId}" class="mr-copy-section" data-ml-active="${this.escapeHtml(activeString)}" data-ml-inactive="${this.escapeHtml(inactiveString)}" data-ml-all="${this.escapeHtml(allString)}" data-ml-active-count="${activeCount}" data-ml-inactive-count="${inactiveCount}" data-ml-all-count="${activeCount + inactiveCount}">
<div id="${copySectionId}" class="mr-copy-section" data-ml-active="${this.escapeHtml(activeString)}" data-ml-inactive="${this.escapeHtml(inactiveString)}" data-ml-all="${this.escapeHtml(allString)}" data-ml-selected="" data-ml-active-count="${activeCount}" data-ml-inactive-count="${inactiveCount}" data-ml-all-count="${activeCount + inactiveCount}" data-ml-selected-count="0" data-ml-previous-mode="all">
<div class="mr-copy-toolbar">
<div class="mr-copy-heading">
<div class="mr-copy-label" id="${copySectionId}-label">Copy all</div>
@@ -467,6 +487,7 @@ export const tabsLoadedMethods = {
<button type="button" class="mr-btn-filter mr-copy-mode active" data-ml-copy-mode="all" onclick="window.MLSetCopyMode('${copySectionId}', 'all', this)">All</button>
<button type="button" class="mr-btn-filter mr-copy-mode" data-ml-copy-mode="active" onclick="window.MLSetCopyMode('${copySectionId}', 'active', this)">Active</button>
<button type="button" class="mr-btn-filter mr-copy-mode" data-ml-copy-mode="inactive" onclick="window.MLSetCopyMode('${copySectionId}', 'inactive', this)">Inactive</button>
<button type="button" class="mr-btn-filter mr-copy-mode mr-copy-mode-selected" data-ml-copy-mode="selected" style="display: none;" onclick="window.MLSetCopyMode('${copySectionId}', 'selected', this)">Selected (<span class="mr-copy-selected-count">0</span>)</button>
<button class="mr-btn-filter mr-copy-main-btn" onclick="window.MLCopyCode('${copySectionId}', this)">${copyIcon}<span>Copy</span></button>
</div>
</div>
@@ -480,5 +501,23 @@ export const tabsLoadedMethods = {
container.dataset.mlActiveString = activeString;
container.dataset.mlInactiveString = inactiveString;
container.dataset.mlAllString = allString;
if (typeof container?.querySelector === 'function') {
this.bindLoadedModelSelection?.(container);
}
},
bindLoadedModelSelection(container) {
if (typeof this._mlSelectionCleanup === 'function') {
this._mlSelectionCleanup();
this._mlSelectionCleanup = null;
}
const cleanup = initLoadedModelSelection(container, this);
this._mlSelectionCleanup = cleanup;
return cleanup;
},
updateLoadedModelSelectionState(container) {
return updateLoadedModelSelectionState(container);
}
};