From 1cdee997d16fb4c7b04a3c43289141f6cdb28949 Mon Sep 17 00:00:00 2001 From: Dariusz L Date: Fri, 16 Oct 2026 15:54:46 +0200 Subject: [PATCH] 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. --- tests/test_loaded_model_selection.mjs | 534 ++++++++++++++++++ web/css/resolver-main.css | 19 + web/resolver/globals.js | 41 +- web/resolver/shell/lifecycle_graph_methods.js | 5 + web/resolver/utils/loaded_model_selection.js | 313 ++++++++++ .../views/tabs_image_metadata_methods.js | 8 + web/resolver/views/tabs_loaded_methods.js | 53 +- 7 files changed, 957 insertions(+), 16 deletions(-) create mode 100644 tests/test_loaded_model_selection.mjs create mode 100644 web/resolver/utils/loaded_model_selection.js diff --git a/tests/test_loaded_model_selection.mjs b/tests/test_loaded_model_selection.mjs new file mode 100644 index 0000000..ab9a21c --- /dev/null +++ b/tests/test_loaded_model_selection.mjs @@ -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 = () => ''; +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 ` +
+

Loaded Models

+
+
+
+
+
+ base_model +
+
+
+
+
+
+ lora_a0.80 + lora_b1.00 + lora_c0.50 +
+
+
+
+ lora_d1.00 +
+
+
+
+
+
+
+
Copy all
+
5 tokens
+
+
+ + + + + +
+
+ +
+ `; +} + +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, ''); + 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, ''); + + 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), ['']); + + // Ctrl+click second chip + chips[2].dispatchEvent(new window.MouseEvent('click', { bubbles: true, ctrlKey: true })); + assert.deepEqual(getSelectedModelTokens(container), [ + '', + '', + ]); + + const copySection = container.querySelector('.mr-copy-section'); + assert.equal(copySection.dataset.mlSelected, ' '); + 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), ['']); + assert.equal(copySection.dataset.mlSelected, ''); + + 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), ['']); + + // 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), [ + '', + '', + '', + ]); + + // 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), [ + '', + '', + ]); + + 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), [ + '', + '', + ]); + + // 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), [ + '', + '', + '', + '', + ]); + + 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, ' '); + 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'), ''); + assert.equal(chips[1].getAttribute('data-ml-token'), ''); + assert.equal(chips[2].getAttribute('data-ml-token'), ''); + + 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), ['']); + + // 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), [ + '', + '' + ]); + + // 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), ['']); + + const copySection = modelContainer.querySelector('.mr-copy-section'); + assert.equal(copySection.dataset.mlSelected, ''); + 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(); +}); + diff --git a/web/css/resolver-main.css b/web/css/resolver-main.css index 03d5360..4d5585a 100644 --- a/web/css/resolver-main.css +++ b/web/css/resolver-main.css @@ -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 { diff --git a/web/resolver/globals.js b/web/resolver/globals.js index f8c5dbd..7ac7579 100644 --- a/web/resolver/globals.js +++ b/web/resolver/globals.js @@ -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); } diff --git a/web/resolver/shell/lifecycle_graph_methods.js b/web/resolver/shell/lifecycle_graph_methods.js index 6f2d0a1..de2315c 100644 --- a/web/resolver/shell/lifecycle_graph_methods.js +++ b/web/resolver/shell/lifecycle_graph_methods.js @@ -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); } diff --git a/web/resolver/utils/loaded_model_selection.js b/web/resolver/utils/loaded_model_selection.js new file mode 100644 index 0000000..bd0cf64 --- /dev/null +++ b/web/resolver/utils/loaded_model_selection.js @@ -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; +} diff --git a/web/resolver/views/tabs_image_metadata_methods.js b/web/resolver/views/tabs_image_metadata_methods.js index d4f2302..488a5c2 100644 --- a/web/resolver/views/tabs_image_metadata_methods.js +++ b/web/resolver/views/tabs_image_metadata_methods.js @@ -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); diff --git a/web/resolver/views/tabs_loaded_methods.js b/web/resolver/views/tabs_loaded_methods.js index 650a508..1b58a70 100644 --- a/web/resolver/views/tabs_loaded_methods.js +++ b/web/resolver/views/tabs_loaded_methods.js @@ -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 = {

${this.escapeHtml(modelSubtitle)}

- - - + + +
@@ -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 += `${this.escapeHtml(name)}${strength !== null ? `${this.escapeHtml(strength)}` : ''}`; + const token = this.getModelToken(model, category); + html += `${this.escapeHtml(name)}${strength !== null ? `${this.escapeHtml(strength)}` : ''}`; } html += `
`; } @@ -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 += `${this.escapeHtml(name)}${strength !== null ? `${this.escapeHtml(strength)}` : ''}`; + const token = this.getModelToken(model, category); + html += `${this.escapeHtml(name)}${strength !== null ? `${this.escapeHtml(strength)}` : ''}`; } html += ``; } @@ -457,7 +477,7 @@ export const tabsLoadedMethods = { const copySectionId = 'mr-copy-' + Date.now(); html += ` -
+
Copy all
@@ -467,6 +487,7 @@ export const tabsLoadedMethods = { +
@@ -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); } };