feat(frontend): one grip reorder affordance for tags and trigger words

Model tags could already be reordered by dragging a chip, but the only
hint was a `cursor: grab` on `.metadata-item` — a hover-only, mouse-only
signal that also leaked into the bulk add-tags modal, where the chips are
not sortable at all. Trigger words could not be reordered, and their
order matters: "Copy Trigger Words" and the insert-into-node action join
the array as-is to build a prompt.

Both editors now share one vocabulary: a `⠿` grip that appears whenever
the list has something to order, plus `Alt + arrow` keyboard moves with
an aria-live announcement. Whether the chip body is draggable is a
property of the item rather than of the feature:

- tags have no click action of their own, so the whole chip stays
  draggable (`handleSelector: null`), with a 5px threshold so a click on
  the grip only focuses it
- trigger words keep click-to-edit on the body, so a drag starts from the
  grip only
- the grip is the element that opts out of touch scrolling
  (`touch-action: none`), so touch users drag by the grip in both editors
- reordering is offered only while editing: trigger words reveal the grip
  from `.edit-mode`, tags from the edit container, which stays hidden
  outside edit mode

The drag engine moves out of ModelTags.js into shared/pointerSort.js,
which now marks sortable containers with `pointer-sort-enabled` so only
lists that really sort show the grab cursor. Labels, the sortable flag,
the keyboard handler and the live region live in
shared/reorderSupport.js, and both editors render the same three
`common.reorder.*` keys (translations follow in the next commit).

Two inherited engine bugs are fixed on the way: the drop position was
only settled when an animation frame was still pending, so a fast drag
(or one that started by crossing the threshold) fell back into its
original slot; and the guard that stops a drop from triggering the chip's
own click handler was removed on a timer, swallowing unrelated clicks
until the next task.
This commit is contained in:
Will Miao
2026-09-16 08:22:46 +08:00
parent c6c44b741a
commit 01137eed88
18 changed files with 1552 additions and 210 deletions
+5
View File
@@ -2,6 +2,11 @@
"common": {
"cancel": "Abbrechen",
"confirm": "Bestätigen",
"reorder": {
"dragHandle": "[TODO: Translate] Drag to reorder (Alt + ↑/↓)",
"ariaLabel": "[TODO: Translate] Reorder {item}, position {position} of {total}",
"announcement": "[TODO: Translate] Moved to position {position} of {total}"
},
"actions": {
"save": "Speichern",
"cancel": "Abbrechen",
+5
View File
@@ -2,6 +2,11 @@
"common": {
"cancel": "Cancel",
"confirm": "Confirm",
"reorder": {
"dragHandle": "Drag to reorder (Alt + ↑/↓)",
"ariaLabel": "Reorder {item}, position {position} of {total}",
"announcement": "Moved to position {position} of {total}"
},
"actions": {
"save": "Save",
"cancel": "Cancel",
+5
View File
@@ -2,6 +2,11 @@
"common": {
"cancel": "Cancelar",
"confirm": "Confirmar",
"reorder": {
"dragHandle": "[TODO: Translate] Drag to reorder (Alt + ↑/↓)",
"ariaLabel": "[TODO: Translate] Reorder {item}, position {position} of {total}",
"announcement": "[TODO: Translate] Moved to position {position} of {total}"
},
"actions": {
"save": "Guardar",
"cancel": "Cancelar",
+5
View File
@@ -2,6 +2,11 @@
"common": {
"cancel": "Annuler",
"confirm": "Confirmer",
"reorder": {
"dragHandle": "[TODO: Translate] Drag to reorder (Alt + ↑/↓)",
"ariaLabel": "[TODO: Translate] Reorder {item}, position {position} of {total}",
"announcement": "[TODO: Translate] Moved to position {position} of {total}"
},
"actions": {
"save": "Enregistrer",
"cancel": "Annuler",
+5
View File
@@ -2,6 +2,11 @@
"common": {
"cancel": "ביטול",
"confirm": "אישור",
"reorder": {
"dragHandle": "[TODO: Translate] Drag to reorder (Alt + ↑/↓)",
"ariaLabel": "[TODO: Translate] Reorder {item}, position {position} of {total}",
"announcement": "[TODO: Translate] Moved to position {position} of {total}"
},
"actions": {
"save": "שמירה",
"cancel": "ביטול",
+5
View File
@@ -2,6 +2,11 @@
"common": {
"cancel": "キャンセル",
"confirm": "確認",
"reorder": {
"dragHandle": "[TODO: Translate] Drag to reorder (Alt + ↑/↓)",
"ariaLabel": "[TODO: Translate] Reorder {item}, position {position} of {total}",
"announcement": "[TODO: Translate] Moved to position {position} of {total}"
},
"actions": {
"save": "保存",
"cancel": "キャンセル",
+5
View File
@@ -2,6 +2,11 @@
"common": {
"cancel": "취소",
"confirm": "확인",
"reorder": {
"dragHandle": "[TODO: Translate] Drag to reorder (Alt + ↑/↓)",
"ariaLabel": "[TODO: Translate] Reorder {item}, position {position} of {total}",
"announcement": "[TODO: Translate] Moved to position {position} of {total}"
},
"actions": {
"save": "저장",
"cancel": "취소",
+5
View File
@@ -2,6 +2,11 @@
"common": {
"cancel": "Отмена",
"confirm": "Подтвердить",
"reorder": {
"dragHandle": "[TODO: Translate] Drag to reorder (Alt + ↑/↓)",
"ariaLabel": "[TODO: Translate] Reorder {item}, position {position} of {total}",
"announcement": "[TODO: Translate] Moved to position {position} of {total}"
},
"actions": {
"save": "Сохранить",
"cancel": "Отмена",
+5
View File
@@ -2,6 +2,11 @@
"common": {
"cancel": "取消",
"confirm": "确认",
"reorder": {
"dragHandle": "[TODO: Translate] Drag to reorder (Alt + ↑/↓)",
"ariaLabel": "[TODO: Translate] Reorder {item}, position {position} of {total}",
"announcement": "[TODO: Translate] Moved to position {position} of {total}"
},
"actions": {
"save": "保存",
"cancel": "取消",
+5
View File
@@ -2,6 +2,11 @@
"common": {
"cancel": "取消",
"confirm": "確認",
"reorder": {
"dragHandle": "[TODO: Translate] Drag to reorder (Alt + ↑/↓)",
"ariaLabel": "[TODO: Translate] Reorder {item}, position {position} of {total}",
"announcement": "[TODO: Translate] Moved to position {position} of {total}"
},
"actions": {
"save": "儲存",
"cancel": "取消",
+95 -10
View File
@@ -92,41 +92,126 @@
border-radius: var(--border-radius-xs);
padding: 4px 8px;
position: relative;
cursor: grab;
transition: transform 0.18s ease;
}
.metadata-item:active {
/* --- Shared chip reordering (tags + trigger words) ------------------------ */
/* Chips in a list that is actually sortable advertise the grab gesture only
then, so lists that cannot be reordered never lie about it. */
.metadata-items.pointer-sort-enabled .metadata-item {
cursor: grab;
}
.metadata-items.pointer-sort-enabled .metadata-item:active {
cursor: grabbing;
}
.metadata-item-dragging {
/* Grip handle: always in the DOM, revealed when the list is sortable */
.reorder-handle {
display: none;
align-items: center;
justify-content: center;
flex-shrink: 0;
padding: 0;
margin-left: -2px;
border: none;
background: transparent;
color: var(--text-color);
opacity: 0.4;
font-size: 0.8em;
line-height: 1;
cursor: grab;
/* Keep a touch drag on the handle from scrolling the surrounding panel */
touch-action: none;
transition: opacity 0.2s ease, color 0.2s ease;
}
.has-sortable-words .reorder-handle {
display: inline-flex;
}
/* Tag chips have no flex gap (unlike trigger word tags), so the grip needs its
own spacing before the tag text */
.metadata-item .reorder-handle {
margin-right: 4px;
}
.reorder-handle:hover,
.reorder-handle:focus-visible {
opacity: 0.9;
color: var(--lora-accent);
}
.reorder-handle:focus-visible {
outline: 2px solid var(--lora-accent);
outline-offset: 1px;
border-radius: 2px;
}
.reorder-handle:active {
cursor: grabbing;
}
/* Hint shown in the edit controls row while reordering is available */
.reorder-hint {
display: none;
align-items: center;
gap: 4px;
margin-right: auto;
font-size: 0.75em;
color: var(--text-color);
opacity: 0.6;
white-space: nowrap;
}
.has-sortable-words .reorder-hint {
display: inline-flex;
}
/* Snapped-to-grid transition for the remaining chips while dragging */
.reorder-sorting > * {
transition: transform 0.18s ease;
}
/* The lifted chip that follows the pointer */
.reorder-dragging {
box-shadow: var(--shadow-dialog);
cursor: grabbing;
opacity: 0.95;
transition: none;
}
.metadata-item-placeholder {
/* Drop target left behind by the lifted chip */
.reorder-placeholder {
border: 1px dashed var(--lora-accent);
border-radius: var(--border-radius-xs);
background: rgba(255, 255, 255, 0.1);
pointer-events: none;
}
.metadata-items-sorting .metadata-item {
transition: transform 0.18s ease;
}
body.metadata-drag-active {
body.reorder-drag-active {
user-select: none;
cursor: grabbing;
}
body.metadata-drag-active * {
body.reorder-drag-active * {
cursor: grabbing !important;
}
/* Screen-reader-only live region announcing reorder moves */
.reorder-sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.metadata-item-content {
color: var(--lora-accent) !important;
font-size: 0.85em;
+44 -198
View File
@@ -7,6 +7,12 @@ import { getModelApiClient } from '../../api/modelApiFactory.js';
import { translate } from '../../utils/i18nHelpers.js';
import { getPriorityTagSuggestions } from '../../utils/priorityTagHelpers.js';
import { state } from '../../state/index.js';
import { enablePointerSort } from './pointerSort.js';
import {
createReorderSupport,
renderReorderHandle,
renderReorderHint,
} from './reorderSupport.js';
const MODEL_TYPE_SUGGESTION_KEY_MAP = {
loras: 'lora',
@@ -18,16 +24,23 @@ const MODEL_TYPE_SUGGESTION_KEY_MAP = {
};
const METADATA_ITEM_SELECTOR = '.metadata-item';
const METADATA_ITEMS_CONTAINER_SELECTOR = '.metadata-items';
const METADATA_ITEM_DRAGGING_CLASS = 'metadata-item-dragging';
const METADATA_ITEM_PLACEHOLDER_CLASS = 'metadata-item-placeholder';
const METADATA_ITEMS_SORTING_CLASS = 'metadata-items-sorting';
const BODY_DRAGGING_CLASS = 'metadata-drag-active';
const METADATA_DRAG_HANDLE_SELECTOR = '.reorder-handle';
/**
* Tag items have no click action of their own, so the whole chip stays
* draggable (handleSelector is null); the small threshold keeps a click on the
* grip from starting a drag (it just focuses the grip). Touch users drag by the
* grip, which is the element that opts out of scrolling via touch-action.
*/
const TAG_SORT_CONFIG = {
itemSelector: METADATA_ITEM_SELECTOR,
dragThreshold: 5,
};
let activeModelTypeKey = '';
let priorityTagSuggestions = [];
let priorityTagSuggestionsLoaded = false;
let priorityTagSuggestionsPromise = null;
let activeTagDragState = null;
// Configurable options for tag editing (set by setupTagEditMode)
let tagEditOptions = {
@@ -423,6 +436,7 @@ function createTagEditUI(currentTags, editBtnHTML = '') {
<div class="metadata-items">
${currentTags.map(tag => `
<div class="metadata-item" data-tag="${tag}">
${renderReorderHandle(translate('common.reorder.dragHandle'))}
<span class="metadata-item-content">${tag}</span>
<button class="metadata-delete-btn">
<i class="fas fa-times"></i>
@@ -431,6 +445,7 @@ function createTagEditUI(currentTags, editBtnHTML = '') {
`).join('')}
</div>
<div class="metadata-edit-controls">
${renderReorderHint(translate('common.reorder.dragHandle'))}
<button class="save-tags-btn" title="Save changes">
<i class="fas fa-save"></i> Save
</button>
@@ -543,8 +558,11 @@ function setupDeleteButtons() {
btn.addEventListener('click', function(e) {
e.stopPropagation();
const tag = this.closest('.metadata-item');
const scope = tag?.closest('.model-tags-container');
tag.remove();
scope?._tagReorderSupport?.refresh();
// Update status of items in the suggestion dropdown
updateSuggestionsDropdown();
});
@@ -563,202 +581,29 @@ function setupTagDragAndDrop(scopeContainer) {
return;
}
container.querySelectorAll(METADATA_ITEM_SELECTOR).forEach((item) => {
item.removeAttribute('draggable');
if (item.classList.contains(METADATA_ITEM_PLACEHOLDER_CLASS)) {
return;
}
if (item.dataset.pointerDragInit === 'true') {
return;
}
const scope = container.closest('.model-tags-container') || container;
let support = scope._tagReorderSupport;
if (!support || scope._tagReorderContainer !== container) {
support = createReorderSupport({
container,
scope,
handleSelector: METADATA_DRAG_HANDLE_SELECTOR,
sortConfig: TAG_SORT_CONFIG,
});
scope._tagReorderSupport = support;
scope._tagReorderContainer = container;
}
item.addEventListener('pointerdown', handleTagPointerDown);
item.dataset.pointerDragInit = 'true';
enablePointerSort(container, {
...TAG_SORT_CONFIG,
onSorted: (item) => {
updateSuggestionsDropdown();
support.refresh();
support.announce(item);
},
});
}
function handleTagPointerDown(event) {
if (event.button !== 0) {
return;
}
if (event.target.closest('.metadata-delete-btn')) {
return;
}
const item = event.currentTarget;
const container = item?.closest(METADATA_ITEMS_CONTAINER_SELECTOR);
if (!item || !container) {
return;
}
event.preventDefault();
startPointerDrag({ item, container, startEvent: event });
}
function startPointerDrag({ item, container, startEvent }) {
if (activeTagDragState) {
finishPointerDrag();
}
const itemRect = item.getBoundingClientRect();
const placeholder = document.createElement('div');
placeholder.className = `metadata-item ${METADATA_ITEM_PLACEHOLDER_CLASS}`;
placeholder.style.width = `${itemRect.width}px`;
placeholder.style.height = `${itemRect.height}px`;
container.insertBefore(placeholder, item);
item.classList.add(METADATA_ITEM_DRAGGING_CLASS);
item.style.width = `${itemRect.width}px`;
item.style.height = `${itemRect.height}px`;
item.style.position = 'fixed';
item.style.left = `${itemRect.left}px`;
item.style.top = `${itemRect.top}px`;
item.style.pointerEvents = 'none';
item.style.zIndex = '1000';
container.classList.add(METADATA_ITEMS_SORTING_CLASS);
if (document.body) {
document.body.classList.add(BODY_DRAGGING_CLASS);
}
const dragState = {
container,
item,
placeholder,
offsetX: startEvent.clientX - itemRect.left,
offsetY: startEvent.clientY - itemRect.top,
lastKnownPointer: { x: startEvent.clientX, y: startEvent.clientY },
rafId: null,
};
activeTagDragState = dragState;
document.addEventListener('pointermove', handlePointerMove);
document.addEventListener('pointerup', handlePointerUp);
document.addEventListener('pointercancel', handlePointerUp);
}
function handlePointerMove(event) {
if (!activeTagDragState) {
return;
}
activeTagDragState.lastKnownPointer = { x: event.clientX, y: event.clientY };
if (activeTagDragState.rafId !== null) {
return;
}
activeTagDragState.rafId = requestAnimationFrame(() => {
if (!activeTagDragState) {
return;
}
activeTagDragState.rafId = null;
updateDraggingItemPosition();
updatePlaceholderPosition();
});
}
function handlePointerUp() {
finishPointerDrag();
}
function updateDraggingItemPosition() {
if (!activeTagDragState) {
return;
}
const { item, offsetX, offsetY, lastKnownPointer } = activeTagDragState;
const left = lastKnownPointer.x - offsetX;
const top = lastKnownPointer.y - offsetY;
item.style.left = `${left}px`;
item.style.top = `${top}px`;
}
function updatePlaceholderPosition() {
if (!activeTagDragState) {
return;
}
const { container, placeholder, item, lastKnownPointer } = activeTagDragState;
const siblings = Array.from(
container.querySelectorAll(
`${METADATA_ITEM_SELECTOR}:not(.${METADATA_ITEM_PLACEHOLDER_CLASS})`
)
).filter((element) => element !== item);
let insertAfter = null;
for (const sibling of siblings) {
const rect = sibling.getBoundingClientRect();
if (lastKnownPointer.y < rect.top) {
container.insertBefore(placeholder, sibling);
return;
}
if (lastKnownPointer.y <= rect.bottom) {
if (lastKnownPointer.x < rect.left + rect.width / 2) {
container.insertBefore(placeholder, sibling);
return;
}
insertAfter = sibling;
continue;
}
insertAfter = sibling;
}
if (!insertAfter) {
container.insertBefore(placeholder, container.firstElementChild);
return;
}
container.insertBefore(placeholder, insertAfter.nextSibling);
}
function finishPointerDrag() {
if (!activeTagDragState) {
return;
}
const { container, item, placeholder, rafId } = activeTagDragState;
document.removeEventListener('pointermove', handlePointerMove);
document.removeEventListener('pointerup', handlePointerUp);
document.removeEventListener('pointercancel', handlePointerUp);
container.classList.remove(METADATA_ITEMS_SORTING_CLASS);
if (document.body) {
document.body.classList.remove(BODY_DRAGGING_CLASS);
}
if (rafId !== null) {
cancelAnimationFrame(rafId);
activeTagDragState.rafId = null;
updateDraggingItemPosition();
updatePlaceholderPosition();
}
if (placeholder && placeholder.parentNode === container) {
container.insertBefore(item, placeholder);
container.removeChild(placeholder);
}
item.classList.remove(METADATA_ITEM_DRAGGING_CLASS);
item.style.position = '';
item.style.width = '';
item.style.height = '';
item.style.left = '';
item.style.top = '';
item.style.pointerEvents = '';
item.style.zIndex = '';
activeTagDragState = null;
updateSuggestionsDropdown();
support.refresh();
}
/**
@@ -799,6 +644,7 @@ function addNewTag(tag, scopeElement = null) {
newTag.className = 'metadata-item';
newTag.dataset.tag = tag;
newTag.innerHTML = `
${renderReorderHandle(translate('common.reorder.dragHandle'))}
<span class="metadata-item-content">${tag}</span>
<button class="metadata-delete-btn">
<i class="fas fa-times"></i>
+128 -1
View File
@@ -7,10 +7,33 @@ import { showToast, copyToClipboard } from '../../utils/uiHelpers.js';
import { translate } from '../../utils/i18nHelpers.js';
import { getModelApiClient } from '../../api/modelApiFactory.js';
import { escapeAttribute, escapeHtml } from './utils.js';
import {
enablePointerSort,
disablePointerSort,
} from './pointerSort.js';
import {
createReorderSupport,
renderReorderHandle,
renderReorderHint,
} from './reorderSupport.js';
const MAX_WORDS_PER_TRIGGER_GROUP = 500;
const MAX_TRIGGER_WORD_GROUPS = 100;
const TRIGGER_WORD_CLICK_DELAY_MS = 220;
const TRIGGER_WORD_DRAG_HANDLE_SELECTOR = '.reorder-handle';
/**
* Drag-to-reorder configuration for trigger word tags.
* Handlers are installed when entering edit mode and removed again on exit, so
* display mode keeps its click-to-copy / double-click-to-edit behaviour.
* The item body is click-to-edit here, so only the grip starts a drag.
*/
const TRIGGER_WORD_DRAG_CONFIG = {
itemSelector: '.trigger-word-tag',
handleSelector: TRIGGER_WORD_DRAG_HANDLE_SELECTOR,
ignoreSelector: '.metadata-delete-btn, .trigger-word-edit-input',
blockedItemSelector: '.is-editing',
};
/**
* Fetch trained words for a model
@@ -182,6 +205,16 @@ function createSuggestionDropdown(trainedWords, classTokens, existingWords = [])
return dropdown;
}
/**
* Render the drag handle of a trigger word tag.
* The handle is always in the DOM but only visible (and clickable) in edit mode,
* so switching modes never has to rebuild the tag markup.
* @returns {string} Handle markup
*/
function renderTriggerWordDragHandle() {
return renderReorderHandle(translate('common.reorder.dragHandle'));
}
/**
* Render trigger words
* @param {Array} words - Array of trigger words
@@ -203,6 +236,7 @@ export function renderTriggerWords(words, filePath) {
<div class="trigger-words-tags" style="display:none;"></div>
</div>
<div class="metadata-edit-controls" style="display:none;">
${renderReorderHint(translate('common.reorder.dragHandle'))}
<button class="metadata-save-btn" title="${translate('modals.model.triggerWords.save')}">
<i class="fas fa-save"></i> ${translate('common.actions.save')}
</button>
@@ -228,6 +262,7 @@ export function renderTriggerWords(words, filePath) {
const escapedAttr = escapeAttribute(word);
return `
<div class="trigger-word-tag" data-word="${escapedAttr}" title="${translate('modals.model.triggerWords.copyOrEditWord')}">
${renderTriggerWordDragHandle()}
<span class="trigger-word-content">${escapedWord}</span>
<span class="trigger-word-copy">
<i class="fas fa-copy"></i>
@@ -240,6 +275,7 @@ export function renderTriggerWords(words, filePath) {
</div>
</div>
<div class="metadata-edit-controls" style="display:none;">
${renderReorderHint(translate('common.reorder.dragHandle'))}
<button class="metadata-save-btn" title="${translate('modals.model.triggerWords.save')}">
<i class="fas fa-save"></i> ${translate('common.actions.save')}
</button>
@@ -316,6 +352,10 @@ export function setupTriggerWordsEditMode() {
}
});
// Enable drag-to-reorder (grip handle) for the current words
enableTriggerWordSort(triggerWordsSection);
refreshTriggerWordHandleLabels(triggerWordsSection);
// Load trained words and display dropdown when entering edit mode
// Add loading indicator
const loadingIndicator = document.createElement('div');
@@ -379,6 +419,10 @@ export function setupTriggerWordsEditMode() {
if (tagsContainer) tagsContainer.style.display = 'none';
}
// Leaving edit mode: tags are no longer reorderable
disableTriggerWordSort(triggerWordsSection);
refreshTriggerWordHandleLabels(triggerWordsSection);
// Remove dropdown if present
const dropdown = triggerWordsSection.querySelector('.metadata-suggestions-dropdown');
if (dropdown) dropdown.remove();
@@ -433,8 +477,13 @@ export function setupTriggerWordsEditMode() {
function deleteTriggerWord(e) {
e.stopPropagation();
const tag = this.closest('.trigger-word-tag');
const section = tag?.closest('.trigger-words');
tag.remove();
if (section) {
refreshTriggerWordHandleLabels(section);
}
// Update status of items in the trained words dropdown
updateTrainedWordsDropdown();
}
@@ -493,6 +542,72 @@ function restoreOriginalTriggerWords(section, originalWords) {
});
}
/**
* Get (or lazily create) the reorder support of a section.
* Reordering is only allowed while the section is in edit mode, because the tag
* body itself is click-to-edit and the grip must not appear in display mode.
* @param {HTMLElement} section - The .trigger-words section
* @returns {{refresh: Function, announce: Function}|null} Reorder support
*/
function getTriggerWordReorder(section) {
const tagsContainer = section.querySelector('.trigger-words-tags');
if (!tagsContainer) return null;
let support = section._triggerWordReorderSupport;
if (!support || section._triggerWordReorderContainer !== tagsContainer) {
support = createReorderSupport({
container: tagsContainer,
scope: section,
handleSelector: TRIGGER_WORD_DRAG_HANDLE_SELECTOR,
sortConfig: TRIGGER_WORD_DRAG_CONFIG,
isActive: () => section.classList.contains('edit-mode'),
});
section._triggerWordReorderSupport = support;
section._triggerWordReorderContainer = tagsContainer;
}
return support;
}
/**
* Refresh the handle labels and the "sortable" flag of a section
* @param {HTMLElement} section - The .trigger-words section
*/
function refreshTriggerWordHandleLabels(section) {
getTriggerWordReorder(section)?.refresh();
}
/**
* Enable drag-to-reorder for the tags of a section (edit mode only)
* @param {HTMLElement} section - The .trigger-words section
*/
function enableTriggerWordSort(section) {
const tagsContainer = section.querySelector('.trigger-words-tags');
if (!tagsContainer) return;
const support = getTriggerWordReorder(section);
enablePointerSort(tagsContainer, {
...TRIGGER_WORD_DRAG_CONFIG,
onSorted: (item) => {
support?.refresh();
support?.announce(item);
},
});
}
/**
* Remove drag-to-reorder handlers when leaving edit mode
* @param {HTMLElement} section - The .trigger-words section
*/
function disableTriggerWordSort(section) {
const tagsContainer = section.querySelector('.trigger-words-tags');
if (!tagsContainer) return;
disablePointerSort(tagsContainer, TRIGGER_WORD_DRAG_CONFIG);
refreshTriggerWordHandleLabels(section);
}
/**
* Create a trigger word tag element
* @param {string} word - Trigger word
@@ -507,6 +622,7 @@ function createTriggerWordTag(word, isEditMode = false) {
const escapedWord = escapeHtml(word);
tag.innerHTML = `
${renderTriggerWordDragHandle()}
<span class="trigger-word-content">${escapedWord}</span>
<span class="trigger-word-copy" style="${isEditMode ? 'display:none;' : ''}">
<i class="fas fa-copy"></i>
@@ -637,7 +753,7 @@ function validateTriggerWord(word, tagsContainer, currentTag = null) {
* @param {Event} e - Click event
*/
function startEditTriggerWord(e) {
if (e.target.closest('.metadata-delete-btn') || e.target.closest('.trigger-word-edit-input')) return;
if (e.target.closest('.metadata-delete-btn') || e.target.closest('.trigger-word-edit-input') || e.target.closest(TRIGGER_WORD_DRAG_HANDLE_SELECTOR)) return;
const tag = this.closest('.trigger-word-tag');
const section = tag?.closest('.trigger-words');
@@ -684,6 +800,11 @@ function startEditTriggerWord(e) {
tag.classList.remove('is-editing');
tag.style.removeProperty('--trigger-word-edit-width');
tag.style.removeProperty('--trigger-word-edit-height');
if (section) {
refreshTriggerWordHandleLabels(section);
}
updateTrainedWordsDropdown();
};
@@ -763,6 +884,12 @@ function addNewTriggerWord(word) {
const newTag = createTriggerWordTag(word, triggerWordsSection.classList.contains('edit-mode'));
tagsContainer.appendChild(newTag);
if (triggerWordsSection.classList.contains('edit-mode')) {
// Wire the freshly added tag for reordering too
enableTriggerWordSort(triggerWordsSection);
refreshTriggerWordHandleLabels(triggerWordsSection);
}
// Update status of items in the trained words dropdown
updateTrainedWordsDropdown();
}
+398
View File
@@ -0,0 +1,398 @@
/**
* pointerSort.js
* Shared pointer-based drag-and-drop sorting for wrapped item lists
* (model tags, trigger words, ...).
*
* The engine lifts the dragged item into a fixed-position "ghost", leaves a
* correctly sized placeholder behind, and moves that placeholder around based
* on the pointer position. Because the dragged node is re-inserted where the
* placeholder ended up, the resulting DOM order *is* the new sort order the
* save path simply reads the items in DOM order.
*/
const DEFAULT_OPTIONS = {
// Selector of the sortable items inside the container.
itemSelector: '.metadata-item',
// When set, a drag can only start from inside this element (a handle).
// When null the whole item is draggable.
handleSelector: null,
// Elements inside an item that must never start a drag.
ignoreSelector: '.metadata-delete-btn',
// Items matching this selector cannot be dragged (e.g. while being edited).
blockedItemSelector: null,
draggingClass: 'reorder-dragging',
placeholderClass: 'reorder-placeholder',
containerSortingClass: 'reorder-sorting',
// Added to <body> while dragging to disable text selection globally.
bodySortingClass: 'reorder-drag-active',
// Pointer travel (px) required before a drag starts. 0 = start on pointerdown.
dragThreshold: 0,
// Called after a successful drop with (item, container).
onSorted: null,
};
// Marks a container whose items are actually sortable, so styles can offer the
// grab affordance only where dragging really works.
const CONTAINER_ENABLED_CLASS = 'pointer-sort-enabled';
let activeDragState = null;
let pendingDragState = null;
function resolveConfig(options = {}) {
return { ...DEFAULT_OPTIONS, ...options };
}
function itemInitKey(config) {
// Any option that changes how a pointerdown is interpreted is part of the
// key, so re-enabling a container with new options replaces the handler
// instead of silently keeping the old one.
return [
config.itemSelector,
config.handleSelector || '',
config.ignoreSelector || '',
config.blockedItemSelector || '',
config.dragThreshold,
].join('|');
}
/**
* Make the items of a container draggable within it.
* Safe to call repeatedly (e.g. after adding an item): already-configured items
* are skipped, and newly added items get wired up.
* @param {HTMLElement} container - Element holding the sortable items
* @param {Object} [options] - See DEFAULT_OPTIONS
*/
export function enablePointerSort(container, options = {}) {
if (!container) return;
const config = resolveConfig(options);
const initKey = itemInitKey(config);
container.__pointerSortConfig = config;
container.classList.add(CONTAINER_ENABLED_CLASS);
container.querySelectorAll(config.itemSelector).forEach((item) => {
item.removeAttribute('draggable');
if (item.classList.contains(config.placeholderClass)) return;
if (item.__pointerSortKey === initKey) return;
if (item.__pointerSortHandler) {
item.removeEventListener('pointerdown', item.__pointerSortHandler);
}
const handler = (event) => handlePointerDown(event, item, container, config);
item.addEventListener('pointerdown', handler);
item.__pointerSortKey = initKey;
item.__pointerSortHandler = handler;
});
}
/**
* Remove drag handlers previously installed by enablePointerSort().
* @param {HTMLElement} container - Element holding the sortable items
* @param {Object} [options] - Used when the container has no stored config
*/
export function disablePointerSort(container, options = {}) {
if (!container) return;
const config = resolveConfig(container.__pointerSortConfig || options);
container.querySelectorAll(config.itemSelector).forEach((item) => {
if (item.__pointerSortHandler) {
item.removeEventListener('pointerdown', item.__pointerSortHandler);
}
delete item.__pointerSortHandler;
delete item.__pointerSortKey;
});
delete container.__pointerSortConfig;
container.classList.remove(CONTAINER_ENABLED_CLASS);
cancelPendingDrag(container);
if (activeDragState && activeDragState.container === container) {
finishPointerDrag();
}
}
/**
* Move an item by `offset` positions inside its container.
* Shared by the keyboard interaction so it matches drag ordering exactly.
* @param {HTMLElement} item - Item to move
* @param {number} offset - Negative moves earlier, positive moves later
* @param {Object} [options] - Same options as enablePointerSort(); use
* `options.container` when the item is not attached to its list yet
* @returns {{index: number, total: number}|null} New position, or null when out of range
*/
export function moveItemWithinContainer(item, offset, options = {}) {
if (!item || !offset) return null;
const config = resolveConfig(options);
const container = options.container || item.parentElement;
if (!container) return null;
const items = Array.from(container.querySelectorAll(config.itemSelector)).filter(
(element) => !element.classList.contains(config.placeholderClass),
);
const index = items.indexOf(item);
if (index === -1) return null;
const target = index + offset;
if (target < 0 || target >= items.length) return null;
const reference = offset < 0 ? items[target] : items[target].nextSibling;
container.insertBefore(item, reference);
return { index: target, total: items.length };
}
function handlePointerDown(event, item, container, config) {
if (activeDragState || pendingDragState) return;
if (typeof event.button === 'number' && event.button !== 0) return;
if (config.ignoreSelector && event.target.closest(config.ignoreSelector)) return;
if (config.handleSelector && !event.target.closest(config.handleSelector)) return;
if (config.blockedItemSelector && item.matches(config.blockedItemSelector)) return;
if (item.classList.contains(config.placeholderClass)) return;
if (config.dragThreshold > 0) {
startPendingDrag({ item, container, config, startEvent: event });
return;
}
// Prevent the browser's native text selection / image drag from kicking in.
event.preventDefault();
startPointerDrag({ item, container, config, startEvent: event });
}
function startPendingDrag({ item, container, config, startEvent }) {
const state = {
item,
container,
config,
startX: startEvent.clientX,
startY: startEvent.clientY,
};
state.onMove = (event) => {
const dx = event.clientX - state.startX;
const dy = event.clientY - state.startY;
if (Math.hypot(dx, dy) < config.dragThreshold) return;
cleanupPendingDrag();
event.preventDefault();
clearTextSelection();
startPointerDrag({ item, container, config, startEvent: event });
};
state.onUp = () => cleanupPendingDrag();
pendingDragState = state;
document.addEventListener('pointermove', state.onMove);
document.addEventListener('pointerup', state.onUp);
document.addEventListener('pointercancel', state.onUp);
}
function cleanupPendingDrag() {
if (!pendingDragState) return;
const { onMove, onUp } = pendingDragState;
document.removeEventListener('pointermove', onMove);
document.removeEventListener('pointerup', onUp);
document.removeEventListener('pointercancel', onUp);
pendingDragState = null;
}
function cancelPendingDrag(container) {
if (pendingDragState && (!container || pendingDragState.container === container)) {
cleanupPendingDrag();
}
}
function clearTextSelection() {
if (typeof window === 'undefined' || !window.getSelection) return;
const selection = window.getSelection();
if (selection && selection.removeAllRanges) selection.removeAllRanges();
}
function startPointerDrag({ item, container, config, startEvent }) {
if (activeDragState) finishPointerDrag();
const itemRect = item.getBoundingClientRect();
const placeholder = document.createElement('div');
const placeholderClasses = Array.from(item.classList).filter(
(name) => name !== config.draggingClass && name !== config.placeholderClass,
);
placeholderClasses.push(config.placeholderClass);
placeholder.className = placeholderClasses.join(' ');
placeholder.style.width = `${itemRect.width}px`;
placeholder.style.height = `${itemRect.height}px`;
container.insertBefore(placeholder, item);
item.classList.add(config.draggingClass);
item.style.width = `${itemRect.width}px`;
item.style.height = `${itemRect.height}px`;
item.style.position = 'fixed';
item.style.left = `${itemRect.left}px`;
item.style.top = `${itemRect.top}px`;
item.style.pointerEvents = 'none';
item.style.zIndex = '1000';
container.classList.add(config.containerSortingClass);
if (config.bodySortingClass && document.body) {
document.body.classList.add(config.bodySortingClass);
}
// Swallow the click generated by this pointer sequence so dropping an item
// never triggers its own click handler (copy-to-clipboard, inline editing).
// Scoped to the dragged container so unrelated clicks are never affected.
const swallowClick = (event) => {
if (event.target !== container && !container.contains(event.target)) {
return;
}
event.preventDefault();
event.stopPropagation();
document.removeEventListener('click', swallowClick, true);
};
document.addEventListener('click', swallowClick, true);
activeDragState = {
container,
item,
placeholder,
config,
offsetX: startEvent.clientX - itemRect.left,
offsetY: startEvent.clientY - itemRect.top,
lastKnownPointer: { x: startEvent.clientX, y: startEvent.clientY },
rafId: null,
swallowClick,
};
document.addEventListener('pointermove', handlePointerMove);
document.addEventListener('pointerup', handlePointerUp);
document.addEventListener('pointercancel', handlePointerUp);
}
function handlePointerMove(event) {
if (!activeDragState) return;
activeDragState.lastKnownPointer = { x: event.clientX, y: event.clientY };
if (activeDragState.rafId !== null) return;
activeDragState.rafId = requestAnimationFrame(() => {
if (!activeDragState) return;
activeDragState.rafId = null;
updateDraggingItemPosition();
updatePlaceholderPosition();
});
}
function handlePointerUp() {
finishPointerDrag();
}
function updateDraggingItemPosition() {
if (!activeDragState) return;
const { item, offsetX, offsetY, lastKnownPointer } = activeDragState;
const left = lastKnownPointer.x - offsetX;
const top = lastKnownPointer.y - offsetY;
item.style.left = `${left}px`;
item.style.top = `${top}px`;
}
function updatePlaceholderPosition() {
if (!activeDragState) return;
const { container, placeholder, item, config, lastKnownPointer } = activeDragState;
const siblings = Array.from(
container.querySelectorAll(
`${config.itemSelector}:not(.${config.placeholderClass})`,
),
).filter((element) => element !== item);
let insertAfter = null;
for (const sibling of siblings) {
const rect = sibling.getBoundingClientRect();
if (lastKnownPointer.y < rect.top) {
container.insertBefore(placeholder, sibling);
return;
}
if (lastKnownPointer.y <= rect.bottom) {
if (lastKnownPointer.x < rect.left + rect.width / 2) {
container.insertBefore(placeholder, sibling);
return;
}
insertAfter = sibling;
continue;
}
insertAfter = sibling;
}
if (!insertAfter) {
container.insertBefore(placeholder, container.firstElementChild);
return;
}
container.insertBefore(placeholder, insertAfter.nextSibling);
}
function finishPointerDrag() {
if (!activeDragState) return;
const { container, item, placeholder, config, rafId, swallowClick } = activeDragState;
document.removeEventListener('pointermove', handlePointerMove);
document.removeEventListener('pointerup', handlePointerUp);
document.removeEventListener('pointercancel', handlePointerUp);
container.classList.remove(config.containerSortingClass);
if (config.bodySortingClass && document.body) {
document.body.classList.remove(config.bodySortingClass);
}
if (rafId !== null) {
cancelAnimationFrame(rafId);
activeDragState.rafId = null;
}
// Always settle the placeholder from the last known pointer: the drop must
// reflect the final pointer position even when no animation frame ran
// (fast drags, or drags that started from the threshold-crossing move).
updateDraggingItemPosition();
updatePlaceholderPosition();
if (placeholder && placeholder.parentNode === container) {
container.insertBefore(item, placeholder);
container.removeChild(placeholder);
}
item.classList.remove(config.draggingClass);
item.style.position = '';
item.style.width = '';
item.style.height = '';
item.style.left = '';
item.style.top = '';
item.style.pointerEvents = '';
item.style.zIndex = '';
activeDragState = null;
if (typeof config.onSorted === 'function') {
config.onSorted(item, container);
}
cleanupSwallowClick(swallowClick);
}
/**
* The click that follows a drop is dispatched right after pointerup, so the
* guard has to survive until the next macrotask.
* @param {Function} handler - Capture-phase click handler to remove
*/
function cleanupSwallowClick(handler) {
if (!handler) return;
setTimeout(() => document.removeEventListener('click', handler, true), 0);
}
@@ -0,0 +1,159 @@
/**
* reorderSupport.js
* Shared keyboard + screen-reader layer for chip lists sorted with pointerSort.
*
* Drag gestures are handled by pointerSort.js; this module adds the parts every
* sortable list needs on top of it:
* - the `` grip affordance (markup + labels),
* - the "sortable" flag that reveals the grip only when reordering is possible,
* - Alt + Arrow keyboard reordering with aria-live announcements.
*
* Convention used by both callers: a list always shows the grip while it is
* sortable. Whether the item *body* is draggable as well depends on the item:
* - body has no click action (model/recipe tags) -> whole item is draggable,
* - body is click-to-edit (trigger words) -> only the grip starts a drag.
*/
import { translate } from '../../utils/i18nHelpers.js';
import { escapeAttribute, escapeHtml } from './utils.js';
import { moveItemWithinContainer } from './pointerSort.js';
const SORTABLE_CLASS = 'has-sortable-words';
const LIVE_REGION_CLASS = 'reorder-live-region';
const SR_ONLY_CLASS = 'reorder-sr-only';
const DEFAULT_HANDLE_SELECTOR = '.reorder-handle';
const DEFAULT_ARIA_LABEL_KEY = 'common.reorder.ariaLabel';
const DEFAULT_ANNOUNCEMENT_KEY = 'common.reorder.announcement';
/**
* Render the shared reorder grip button
* @param {string} label - Tooltip / accessible label
* @returns {string} Handle markup
*/
export function renderReorderHandle(label) {
const safeLabel = escapeAttribute(label || '');
return `<button type="button" class="reorder-handle" title="${safeLabel}" aria-label="${safeLabel}"><i class="fas fa-grip-vertical"></i></button>`;
}
/**
* Render the shared reorder hint shown in an edit controls row
* @param {string} label - Hint text
* @returns {string} Hint markup
*/
export function renderReorderHint(label) {
return `<span class="reorder-hint"><i class="fas fa-grip-vertical"></i> ${escapeHtml(label || '')}</span>`;
}
/**
* Map a keydown event to a reorder offset
* @param {KeyboardEvent} event - Keydown event
* @returns {number} -1 (earlier), 1 (later) or 0 when it is not a reorder shortcut
*/
function getReorderOffset(event) {
if (!event.altKey || event.ctrlKey || event.metaKey) return 0;
if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') return -1;
if (event.key === 'ArrowRight' || event.key === 'ArrowDown') return 1;
return 0;
}
/**
* Create the keyboard / label support of a sortable list
* @param {Object} options - Options
* @param {HTMLElement} options.container - Element holding the items
* @param {HTMLElement} [options.scope] - Element that receives the sortable flag
* @param {string} [options.handleSelector] - Grip selector inside an item
* @param {Object} options.sortConfig - Same config passed to enablePointerSort()
* @param {Function} [options.isActive] - Whether reordering is currently allowed
* @param {Function} [options.getItemLabel] - (item) => label used in messages
* @param {Object} [options.i18n] - { ariaLabel, announcement } translation keys
* @returns {{refresh: Function, announce: Function, handleKeydown: Function}}
*/
export function createReorderSupport({
container,
scope = container,
handleSelector = DEFAULT_HANDLE_SELECTOR,
sortConfig,
isActive = () => true,
getItemLabel = (item) => item.dataset.word || item.dataset.tag || item.textContent.trim(),
i18n = {},
}) {
const itemSelector = sortConfig.itemSelector;
const ariaLabelKey = i18n.ariaLabel || DEFAULT_ARIA_LABEL_KEY;
const announcementKey = i18n.announcement || DEFAULT_ANNOUNCEMENT_KEY;
const getItems = () => Array.from(container.querySelectorAll(itemSelector));
function refresh() {
const items = getItems();
scope.classList.toggle(SORTABLE_CLASS, isActive() && items.length > 1);
items.forEach((item, index) => {
const handle = item.querySelector(handleSelector);
if (!handle) return;
const label = getItemLabel(item);
handle.setAttribute('aria-label', translate(
ariaLabelKey,
{ item: label, position: index + 1, total: items.length },
`Reorder ${label}, position ${index + 1} of ${items.length}`,
));
});
}
function ensureLiveRegion() {
let liveRegion = scope.querySelector(`.${LIVE_REGION_CLASS}`);
if (liveRegion) return liveRegion;
liveRegion = document.createElement('div');
liveRegion.className = `${LIVE_REGION_CLASS} ${SR_ONLY_CLASS}`;
liveRegion.setAttribute('role', 'status');
liveRegion.setAttribute('aria-live', 'polite');
scope.appendChild(liveRegion);
return liveRegion;
}
function announce(item) {
const items = getItems();
const index = items.indexOf(item);
if (index === -1) return;
ensureLiveRegion().textContent = translate(
announcementKey,
{ position: index + 1, total: items.length },
`Moved to position ${index + 1} of ${items.length}`,
);
}
function handleKeydown(event) {
const handle = event.target.closest(handleSelector);
if (!handle || !isActive()) return;
const offset = getReorderOffset(event);
if (!offset) return;
// Swallow the shortcut even at the ends of the list: Alt + Left/Right
// would otherwise trigger the browser's back/forward navigation.
event.preventDefault();
event.stopPropagation();
const item = handle.closest(itemSelector);
if (!item) return;
const result = moveItemWithinContainer(item, offset, sortConfig);
if (!result) return;
refresh();
announce(item);
const nextHandle = item.querySelector(handleSelector);
if (nextHandle) nextHandle.focus();
}
if (!container.__reorderKeyboardAttached) {
container.__reorderKeyboardAttached = true;
container.addEventListener('keydown', handleKeydown);
}
return { refresh, announce, handleKeydown };
}
@@ -0,0 +1,216 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const {
MODEL_TAGS_MODULE,
I18N_HELPERS_MODULE,
UI_HELPERS_MODULE,
MODEL_API_MODULE,
PRIORITY_TAGS_MODULE,
STATE_MODULE,
saveModelMetadataMock,
} = vi.hoisted(() => ({
MODEL_TAGS_MODULE: new URL('../../../static/js/components/shared/ModelTags.js', import.meta.url).pathname,
I18N_HELPERS_MODULE: new URL('../../../static/js/utils/i18nHelpers.js', import.meta.url).pathname,
UI_HELPERS_MODULE: new URL('../../../static/js/utils/uiHelpers.js', import.meta.url).pathname,
MODEL_API_MODULE: new URL('../../../static/js/api/modelApiFactory.js', import.meta.url).pathname,
PRIORITY_TAGS_MODULE: new URL('../../../static/js/utils/priorityTagHelpers.js', import.meta.url).pathname,
STATE_MODULE: new URL('../../../static/js/state/index.js', import.meta.url).pathname,
saveModelMetadataMock: vi.fn(),
}));
vi.mock(I18N_HELPERS_MODULE, () => ({
translate: vi.fn((key, params, fallback) => fallback || key),
}));
vi.mock(UI_HELPERS_MODULE, () => ({
showToast: vi.fn(),
copyToClipboard: vi.fn(),
}));
vi.mock(MODEL_API_MODULE, () => ({
getModelApiClient: vi.fn(() => ({
saveModelMetadata: saveModelMetadataMock,
})),
}));
vi.mock(PRIORITY_TAGS_MODULE, () => ({
getPriorityTagSuggestions: vi.fn(async () => []),
}));
vi.mock(STATE_MODULE, () => ({
state: { currentPageType: 'loras' },
}));
const TAG_SECTION_HTML = (tags) => `
<div class="model-tags-container">
<div class="model-tags-header">
<div class="model-tags-compact">
${tags.map((tag) => `<span class="model-tag-compact">${tag}</span>`).join('')}
</div>
<button class="edit-tags-btn" data-file-path="test.safetensors" title="Edit tags">
<i class="fas fa-pencil-alt"></i>
</button>
</div>
<div class="model-tags-tooltip">
<div class="tooltip-content">
${tags.map((tag) => `<span class="tooltip-tag">${tag}</span>`).join('')}
</div>
</div>
</div>
`;
describe("ModelTags reordering", () => {
let setupTagEditMode;
beforeEach(async () => {
document.body.innerHTML = '';
vi.clearAllMocks();
saveModelMetadataMock.mockResolvedValue({});
const module = await import(MODEL_TAGS_MODULE);
setupTagEditMode = module.setupTagEditMode;
});
function section() {
return document.querySelector('.model-tags-container');
}
function items() {
return Array.from(document.querySelectorAll('.metadata-item'));
}
function order() {
return items().map((item) => item.dataset.tag);
}
function handles() {
return Array.from(document.querySelectorAll('.reorder-handle'));
}
function firePointer(type, target, init = {}) {
target.dispatchEvent(new PointerEvent(type, {
bubbles: true,
cancelable: true,
...init,
}));
}
function dragToEnd(target) {
firePointer('pointerdown', target, { clientY: 10 });
firePointer('pointermove', target, { clientY: 999 });
firePointer('pointerup', target, { clientY: 999 });
}
function pressKey(target, key, init = {}) {
const event = new KeyboardEvent('keydown', {
key,
bubbles: true,
cancelable: true,
...init,
});
target.dispatchEvent(event);
return event;
}
async function enterEditMode(tags = ['alpha', 'beta', 'gamma']) {
document.body.innerHTML = TAG_SECTION_HTML(tags);
setupTagEditMode('loras');
document.querySelector('.edit-tags-btn')
.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
await vi.waitFor(() => {
expect(document.querySelector('.metadata-edit-container')).toBeTruthy();
});
}
it("renders a grip handle per tag and flags the section as sortable", async () => {
await enterEditMode(['alpha', 'beta']);
expect(items()).toHaveLength(2);
expect(handles()).toHaveLength(2);
expect(section().classList.contains('has-sortable-words')).toBe(true);
expect(
document.querySelector('.metadata-items').classList.contains('pointer-sort-enabled'),
).toBe(true);
});
it("does not offer reordering for a single tag", async () => {
await enterEditMode(['alpha']);
expect(handles()).toHaveLength(1);
expect(section().classList.contains('has-sortable-words')).toBe(false);
});
it("keeps the whole chip draggable, not just the grip", async () => {
await enterEditMode();
// Drag from the chip body (no handle involved)
dragToEnd(items()[0].querySelector('.metadata-item-content'));
expect(order()).toEqual(['beta', 'gamma', 'alpha']);
});
it("reorders by dragging the grip", async () => {
await enterEditMode();
dragToEnd(handles()[0]);
expect(order()).toEqual(['beta', 'gamma', 'alpha']);
});
it("does not reorder when the grip is only clicked", async () => {
await enterEditMode();
const handle = handles()[0];
firePointer('pointerdown', handle, { clientY: 10 });
firePointer('pointermove', handle, { clientY: 12 });
firePointer('pointerup', handle, { clientY: 12 });
expect(order()).toEqual(['alpha', 'beta', 'gamma']);
});
it("saves the new order after a keyboard reorder", async () => {
await enterEditMode();
pressKey(handles()[0], 'ArrowRight', { altKey: true });
expect(order()).toEqual(['beta', 'alpha', 'gamma']);
const liveRegion = section().querySelector('.reorder-live-region');
expect(liveRegion.getAttribute('aria-live')).toBe('polite');
expect(liveRegion.textContent).toBe('Moved to position 2 of 3');
expect(handles()[0].getAttribute('aria-label'))
.toBe('Reorder beta, position 1 of 3');
document.querySelector('.save-tags-btn')
.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
await vi.waitFor(() => {
expect(saveModelMetadataMock).toHaveBeenCalled();
});
expect(saveModelMetadataMock).toHaveBeenCalledWith('test.safetensors', {
tags: ['beta', 'alpha', 'gamma'],
});
});
it("swallows the reorder shortcut at the ends of the list", async () => {
await enterEditMode();
const event = pressKey(handles()[0], 'ArrowLeft', { altKey: true });
expect(event.defaultPrevented).toBe(true);
expect(order()).toEqual(['alpha', 'beta', 'gamma']);
});
it("updates the sortable flag when tags are deleted", async () => {
await enterEditMode(['alpha', 'beta']);
items()[1].querySelector('.metadata-delete-btn')
.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
expect(order()).toEqual(['alpha']);
expect(section().classList.contains('has-sortable-words')).toBe(false);
});
});
@@ -0,0 +1,196 @@
import { beforeEach, describe, expect, it } from "vitest";
const POINTER_SORT_MODULE = new URL(
'../../../static/js/components/shared/pointerSort.js',
import.meta.url,
).pathname;
describe("pointerSort", () => {
let enablePointerSort;
let disablePointerSort;
let moveItemWithinContainer;
beforeEach(async () => {
document.body.innerHTML = '';
const module = await import(POINTER_SORT_MODULE);
enablePointerSort = module.enablePointerSort;
disablePointerSort = module.disablePointerSort;
moveItemWithinContainer = module.moveItemWithinContainer;
});
function buildList(words) {
document.body.innerHTML = `
<div class="list">
${words.map((word) => `
<div class="item" data-id="${word}">
<span class="handle">::</span>
<span class="label">${word}</span>
</div>
`).join('')}
</div>
`;
return {
container: document.querySelector('.list'),
items: Array.from(document.querySelectorAll('.item')),
};
}
/**
* Explicit class names so the assertions test the configurable engine
* rather than the metadata-* defaults used by the tag editor.
*/
function sortOptions(extra = {}) {
return {
itemSelector: '.item',
draggingClass: 'item-dragging',
placeholderClass: 'item-placeholder',
containerSortingClass: 'list-sorting',
bodySortingClass: 'drag-active',
...extra,
};
}
function order() {
return Array.from(document.querySelectorAll('.item')).map((el) => el.dataset.id);
}
function firePointer(type, target, init = {}) {
target.dispatchEvent(new PointerEvent(type, {
bubbles: true,
cancelable: true,
...init,
}));
}
/**
* jsdom reports zero-sized rects for every element, which makes the engine
* treat a high clientY as "past the last item".
*/
function dragToEnd(target) {
firePointer('pointerdown', target);
firePointer('pointermove', target, { clientY: 999 });
firePointer('pointerup', target, { clientY: 999 });
}
it("reorders items when the whole item is draggable", () => {
const { container } = buildList(['a', 'b', 'c']);
enablePointerSort(container, sortOptions());
const first = document.querySelector('.item');
dragToEnd(first);
expect(order()).toEqual(['b', 'c', 'a']);
});
it("keeps the metadata-* defaults the tag editor relies on", () => {
document.body.innerHTML = `
<div class="metadata-items">
<div class="metadata-item" data-id="a">a</div>
<div class="metadata-item" data-id="b">b</div>
</div>
`;
const container = document.querySelector('.metadata-items');
// No options at all: ModelTags.js calls the engine exactly like this
enablePointerSort(container);
const first = container.querySelector('.metadata-item');
firePointer('pointerdown', first);
firePointer('pointermove', first, { clientY: 999 });
firePointer('pointerup', first, { clientY: 999 });
expect(
Array.from(container.querySelectorAll('.metadata-item')).map((el) => el.dataset.id),
).toEqual(['b', 'a']);
expect(container.classList.contains('pointer-sort-enabled')).toBe(true);
expect(document.querySelector('.reorder-placeholder')).toBeNull();
});
it("only starts a drag from the configured handle", () => {
const { container } = buildList(['a', 'b', 'c']);
enablePointerSort(container, sortOptions({ handleSelector: '.handle' }));
// Pressing the item body must not start a drag
dragToEnd(document.querySelector('.item .label'));
expect(order()).toEqual(['a', 'b', 'c']);
expect(document.querySelector('.item-dragging')).toBeNull();
// Pressing the handle does
dragToEnd(document.querySelector('.item .handle'));
expect(order()).toEqual(['b', 'c', 'a']);
});
it("never drags items matching the blocked selector or the ignore selector", () => {
const { container } = buildList(['a', 'b']);
enablePointerSort(container, sortOptions({ blockedItemSelector: '.locked' }));
document.querySelector('.item').classList.add('locked');
dragToEnd(document.querySelector('.item'));
expect(order()).toEqual(['a', 'b']);
document.querySelector('.item').classList.remove('locked');
enablePointerSort(container, sortOptions({ ignoreSelector: '.label' }));
dragToEnd(document.querySelector('.item .label'));
expect(order()).toEqual(['a', 'b']);
});
it("waits for the drag threshold before lifting an item", () => {
const { container } = buildList(['a', 'b']);
enablePointerSort(container, sortOptions({ dragThreshold: 10 }));
const first = document.querySelector('.item');
firePointer('pointerdown', first, { clientX: 10, clientY: 10 });
firePointer('pointermove', first, { clientX: 15, clientY: 10 });
expect(document.querySelector('.item-dragging')).toBeNull();
firePointer('pointermove', first, { clientX: 60, clientY: 10 });
expect(document.querySelector('.item-dragging')).not.toBeNull();
firePointer('pointerup', first, { clientX: 60, clientY: 10 });
expect(order()).toEqual(['b', 'a']);
});
it("calls onSorted once a drop completes", () => {
const { container } = buildList(['a', 'b']);
const onSorted = [];
enablePointerSort(container, sortOptions({
onSorted: (item) => onSorted.push(item.dataset.id),
}));
dragToEnd(document.querySelector('.item'));
expect(onSorted).toEqual(['a']);
});
it("stops handling drags after disablePointerSort", () => {
const { container } = buildList(['a', 'b']);
enablePointerSort(container, sortOptions());
disablePointerSort(container, sortOptions());
dragToEnd(document.querySelector('.item'));
expect(order()).toEqual(['a', 'b']);
});
it("moveItemWithinContainer moves items within bounds only", () => {
const { container } = buildList(['a', 'b', 'c']);
const items = Array.from(document.querySelectorAll('.item'));
expect(moveItemWithinContainer(items[0], 1, sortOptions()))
.toEqual({ index: 1, total: 3 });
expect(order()).toEqual(['b', 'a', 'c']);
expect(moveItemWithinContainer(items[2], -1, sortOptions()))
.toEqual({ index: 1, total: 3 });
expect(order()).toEqual(['b', 'c', 'a']);
// Out of range / unknown item moves are refused
const currentFirst = document.querySelector('.item');
expect(moveItemWithinContainer(currentFirst, -1, sortOptions())).toBeNull();
expect(moveItemWithinContainer(currentFirst, 3, sortOptions())).toBeNull();
expect(moveItemWithinContainer(document.createElement('div'), 1, {
...sortOptions(),
container,
})).toBeNull();
});
});
@@ -0,0 +1,265 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const {
TRIGGER_WORDS_MODULE,
I18N_HELPERS_MODULE,
UI_HELPERS_MODULE,
MODEL_API_MODULE,
saveModelMetadataMock,
} = vi.hoisted(() => ({
TRIGGER_WORDS_MODULE: new URL('../../../static/js/components/shared/TriggerWords.js', import.meta.url).pathname,
I18N_HELPERS_MODULE: new URL('../../../static/js/utils/i18nHelpers.js', import.meta.url).pathname,
UI_HELPERS_MODULE: new URL('../../../static/js/utils/uiHelpers.js', import.meta.url).pathname,
MODEL_API_MODULE: new URL('../../../static/js/api/modelApiFactory.js', import.meta.url).pathname,
saveModelMetadataMock: vi.fn(),
}));
vi.mock(I18N_HELPERS_MODULE, () => ({
translate: vi.fn((key, params, fallback) => fallback || key),
}));
vi.mock(UI_HELPERS_MODULE, () => ({
showToast: vi.fn(),
copyToClipboard: vi.fn(),
}));
vi.mock(MODEL_API_MODULE, () => ({
getModelApiClient: vi.fn(() => ({
saveModelMetadata: saveModelMetadataMock,
})),
}));
describe("TriggerWords reordering", () => {
let renderTriggerWords;
let setupTriggerWordsEditMode;
beforeEach(async () => {
document.body.innerHTML = '';
vi.clearAllMocks();
saveModelMetadataMock.mockResolvedValue({});
global.fetch = vi.fn(async () => ({
json: async () => ({
success: true,
trained_words: [],
class_tokens: null,
}),
}));
const module = await import(TRIGGER_WORDS_MODULE);
renderTriggerWords = module.renderTriggerWords;
setupTriggerWordsEditMode = module.setupTriggerWordsEditMode;
});
function section() {
return document.querySelector('.trigger-words');
}
function order() {
return Array.from(document.querySelectorAll('.trigger-word-tag'))
.map((tag) => tag.dataset.word);
}
function handles() {
return Array.from(document.querySelectorAll('.reorder-handle'));
}
function firePointer(type, target, init = {}) {
target.dispatchEvent(new PointerEvent(type, {
bubbles: true,
cancelable: true,
...init,
}));
}
function dragToEnd(handle) {
firePointer('pointerdown', handle);
firePointer('pointermove', handle, { clientY: 999 });
firePointer('pointerup', handle, { clientY: 999 });
}
function pressKey(handle, key, init = {}) {
const event = new KeyboardEvent('keydown', {
key,
bubbles: true,
cancelable: true,
...init,
});
handle.dispatchEvent(event);
return event;
}
async function enterEditMode(words = ["alpha", "beta", "gamma"]) {
document.body.innerHTML = renderTriggerWords(words, "test.safetensors");
setupTriggerWordsEditMode();
document.querySelector('.edit-trigger-words-btn')
.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
await vi.waitFor(() => {
expect(document.querySelector('.metadata-suggestions-dropdown')).toBeTruthy();
});
}
it("renders a handle per word but only offers reordering for 2+ words", async () => {
await enterEditMode(["alpha", "beta"]);
expect(handles()).toHaveLength(2);
expect(section().classList.contains('has-sortable-words')).toBe(true);
});
it("does not offer reordering when there is a single word", async () => {
await enterEditMode(["alpha"]);
expect(handles()).toHaveLength(1);
expect(section().classList.contains('has-sortable-words')).toBe(false);
});
it("reorders a word by dragging its handle and swallows the follow-up click", async () => {
await enterEditMode();
dragToEnd(handles()[0]);
expect(order()).toEqual(["beta", "gamma", "alpha"]);
// The click generated by the drag must not open the inline editor
const movedTag = document.querySelector('.trigger-word-tag[data-word="alpha"]');
const clickEvent = new MouseEvent('click', { bubbles: true, cancelable: true });
movedTag.dispatchEvent(clickEvent);
expect(clickEvent.defaultPrevented).toBe(true);
expect(movedTag.querySelector('.trigger-word-edit-input')).toBeNull();
});
it("does not start a drag from the tag body", async () => {
await enterEditMode();
const content = document.querySelector('.trigger-word-content');
firePointer('pointerdown', content);
expect(document.body.classList.contains('reorder-drag-active')).toBe(false);
firePointer('pointermove', content, { clientY: 999 });
firePointer('pointerup', content, { clientY: 999 });
expect(order()).toEqual(["alpha", "beta", "gamma"]);
expect(document.querySelector('.reorder-dragging')).toBeNull();
});
it("reorders with the keyboard and saves the new order", async () => {
await enterEditMode();
pressKey(handles()[0], 'ArrowRight', { altKey: true });
expect(order()).toEqual(["beta", "alpha", "gamma"]);
pressKey(handles()[2], 'ArrowUp', { altKey: true });
expect(order()).toEqual(["beta", "gamma", "alpha"]);
document.querySelector('.metadata-save-btn')
.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
await vi.waitFor(() => {
expect(saveModelMetadataMock).toHaveBeenCalled();
});
expect(saveModelMetadataMock).toHaveBeenCalledWith("test.safetensors", {
civitai: { trainedWords: ["beta", "gamma", "alpha"] },
});
});
it("swallows the reorder shortcut at the ends of the list", async () => {
await enterEditMode();
const event = pressKey(handles()[0], 'ArrowLeft', { altKey: true });
expect(event.defaultPrevented).toBe(true);
expect(order()).toEqual(["alpha", "beta", "gamma"]);
});
it("ignores the reorder shortcut without the Alt modifier", async () => {
await enterEditMode();
pressKey(handles()[0], 'ArrowRight');
expect(order()).toEqual(["alpha", "beta", "gamma"]);
});
it("announces moved words for screen readers", async () => {
await enterEditMode();
pressKey(handles()[0], 'ArrowRight', { altKey: true });
const liveRegion = section().querySelector('.reorder-live-region');
expect(liveRegion.getAttribute('aria-live')).toBe('polite');
expect(liveRegion.textContent).toBe(
'Moved to position 2 of 3',
);
});
it("updates handle labels with the current position", async () => {
await enterEditMode();
expect(handles()[0].getAttribute('aria-label'))
.toBe('Reorder alpha, position 1 of 3');
pressKey(handles()[0], 'ArrowRight', { altKey: true });
expect(handles()[0].getAttribute('aria-label'))
.toBe('Reorder beta, position 1 of 3');
expect(handles()[1].getAttribute('aria-label'))
.toBe('Reorder alpha, position 2 of 3');
});
it("restores the original order when edit mode is canceled", async () => {
await enterEditMode();
dragToEnd(handles()[0]);
expect(order()).toEqual(["beta", "gamma", "alpha"]);
document.querySelector('.edit-trigger-words-btn')
.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
expect(order()).toEqual(["alpha", "beta", "gamma"]);
});
it("wires reordering for words added while editing", async () => {
await enterEditMode(["alpha", "beta"]);
const input = document.querySelector('.metadata-input');
input.value = 'gamma';
input.dispatchEvent(new KeyboardEvent('keydown', {
key: 'Enter',
bubbles: true,
cancelable: true,
}));
expect(order()).toEqual(["alpha", "beta", "gamma"]);
expect(handles()).toHaveLength(3);
expect(section().classList.contains('has-sortable-words')).toBe(true);
// The freshly added word is draggable too
const newHandle = document.querySelector(
'.trigger-word-tag[data-word="gamma"] .reorder-handle',
);
firePointer('pointerdown', newHandle);
firePointer('pointermove', newHandle, { clientY: -999 });
firePointer('pointerup', newHandle, { clientY: -999 });
expect(order()).toEqual(["gamma", "alpha", "beta"]);
});
it("stops handling drags once edit mode is left", async () => {
await enterEditMode();
document.querySelector('.edit-trigger-words-btn')
.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
dragToEnd(handles()[0]);
expect(order()).toEqual(["alpha", "beta", "gamma"]);
expect(document.body.classList.contains('reorder-drag-active')).toBe(false);
// The grip must not be offered anymore outside edit mode
expect(section().classList.contains('has-sortable-words')).toBe(false);
expect(
document.querySelector('.trigger-words-tags').classList.contains('pointer-sort-enabled'),
).toBe(false);
});
});