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