- 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.
314 lines
12 KiB
JavaScript
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;
|
|
}
|