diff --git a/locales/de.json b/locales/de.json index 1aaa8393..3a0b60f9 100644 --- a/locales/de.json +++ b/locales/de.json @@ -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", diff --git a/locales/en.json b/locales/en.json index 19988d29..ca3d2ffd 100644 --- a/locales/en.json +++ b/locales/en.json @@ -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", diff --git a/locales/es.json b/locales/es.json index 30c11a2a..f53b282c 100644 --- a/locales/es.json +++ b/locales/es.json @@ -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", diff --git a/locales/fr.json b/locales/fr.json index e746e497..94166bfe 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -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", diff --git a/locales/he.json b/locales/he.json index 36a106a1..c7a59c2d 100644 --- a/locales/he.json +++ b/locales/he.json @@ -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": "ביטול", diff --git a/locales/ja.json b/locales/ja.json index e76ab85b..13b1ea6c 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -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": "キャンセル", diff --git a/locales/ko.json b/locales/ko.json index 73acbb39..74830c2f 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -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": "취소", diff --git a/locales/ru.json b/locales/ru.json index 279207a0..d7f6060d 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -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": "Отмена", diff --git a/locales/zh-CN.json b/locales/zh-CN.json index f9a631d6..4f2145be 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -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": "取消", diff --git a/locales/zh-TW.json b/locales/zh-TW.json index 72d7b48b..079e9212 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -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": "取消", diff --git a/static/css/components/shared/edit-metadata.css b/static/css/components/shared/edit-metadata.css index 0c2a578c..ad790d55 100644 --- a/static/css/components/shared/edit-metadata.css +++ b/static/css/components/shared/edit-metadata.css @@ -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; diff --git a/static/js/components/shared/ModelTags.js b/static/js/components/shared/ModelTags.js index b6226626..4b6691ec 100644 --- a/static/js/components/shared/ModelTags.js +++ b/static/js/components/shared/ModelTags.js @@ -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 = '') {
${currentTags.map(tag => `
+ ${renderReorderHandle(translate('common.reorder.dragHandle'))}
+ ${renderReorderHint(translate('common.reorder.dragHandle'))} @@ -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'))}