Files
Dariusz L 1cdee997d1 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.
2026-10-16 15:54:46 +02:00

314 lines
12 KiB
JavaScript

/**
* 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;
}