mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-09-21 03:01:27 -03:00
refactor(reorder): drop the Alt + Arrow shortcut, keep drag only
The reorder shortcut cannot be made reliable in this UI. `Alt + Arrow` is the browser's tab-history / back-forward gesture on several platforms, and the modal already binds bare `ArrowLeft`/`ArrowRight` to model navigation, so the binding either did nothing — a keypress with nothing focused never reaches a listener on the tag list — or fought the browser. An affordance that occasionally navigates the page away is worse than having no keyboard path at all, so drop it. Reordering is pointer-only again: drag the chip (tags) or its `⠿` grip (trigger words, whose chip body is click-to-edit). Everything that existed only to serve the shortcut goes with it — the keydown listener, the hover tracking used to resolve the target chip, the aria-live announcements, the per-grip position labels and `moveItemWithinContainer`. The grip becomes a decorative, non-focusable `<span>` (`aria-hidden`, behind a 5px drag threshold) instead of a `<button>`, so it no longer promises a keyboard action it cannot perform. The tooltip and hint drop the shortcut mention in all 10 locales (`common.reorder.dragHandle` = "Drag to reorder" and the localised equivalents); `common.reorder.ariaLabel` and `common.reorder.announcement` are pruned from every locale by the sync script. The i18n guidelines record the decision so no shortcut is re-added without re-adding the keys.
This commit is contained in:
@@ -9,7 +9,7 @@ import { getPriorityTagSuggestions } from '../../utils/priorityTagHelpers.js';
|
||||
import { state } from '../../state/index.js';
|
||||
import { enablePointerSort } from './pointerSort.js';
|
||||
import {
|
||||
createReorderSupport,
|
||||
refreshReorderState,
|
||||
renderReorderHandle,
|
||||
renderReorderHint,
|
||||
} from './reorderSupport.js';
|
||||
@@ -24,7 +24,6 @@ const MODEL_TYPE_SUGGESTION_KEY_MAP = {
|
||||
};
|
||||
const METADATA_ITEM_SELECTOR = '.metadata-item';
|
||||
const METADATA_ITEMS_CONTAINER_SELECTOR = '.metadata-items';
|
||||
const METADATA_DRAG_HANDLE_SELECTOR = '.reorder-handle';
|
||||
|
||||
/**
|
||||
* Tag items have no click action of their own, so the whole chip stays
|
||||
@@ -436,7 +435,7 @@ function createTagEditUI(currentTags, editBtnHTML = '') {
|
||||
<div class="metadata-items">
|
||||
${currentTags.map(tag => `
|
||||
<div class="metadata-item" data-tag="${tag}">
|
||||
${renderReorderHandle(translate('common.reorder.dragHandle'))}
|
||||
${renderReorderHandle(translate('common.reorder.dragHandle', {}, 'Drag to reorder'))}
|
||||
<span class="metadata-item-content">${tag}</span>
|
||||
<button class="metadata-delete-btn">
|
||||
<i class="fas fa-times"></i>
|
||||
@@ -445,7 +444,7 @@ function createTagEditUI(currentTags, editBtnHTML = '') {
|
||||
`).join('')}
|
||||
</div>
|
||||
<div class="metadata-edit-controls">
|
||||
${renderReorderHint(translate('common.reorder.dragHandle'))}
|
||||
${renderReorderHint(translate('common.reorder.dragHandle', {}, 'Drag to reorder'))}
|
||||
<button class="save-tags-btn" title="Save changes">
|
||||
<i class="fas fa-save"></i> Save
|
||||
</button>
|
||||
@@ -561,7 +560,7 @@ function setupDeleteButtons() {
|
||||
const scope = tag?.closest('.model-tags-container');
|
||||
tag.remove();
|
||||
|
||||
scope?._tagReorderSupport?.refresh();
|
||||
refreshTagReorderState(scope);
|
||||
|
||||
// Update status of items in the suggestion dropdown
|
||||
updateSuggestionsDropdown();
|
||||
@@ -582,28 +581,28 @@ function setupTagDragAndDrop(scopeContainer) {
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
enablePointerSort(container, {
|
||||
...TAG_SORT_CONFIG,
|
||||
onSorted: (item) => {
|
||||
onSorted: () => {
|
||||
updateSuggestionsDropdown();
|
||||
support.refresh();
|
||||
support.announce(item);
|
||||
refreshTagReorderState(scope);
|
||||
},
|
||||
});
|
||||
|
||||
support.refresh();
|
||||
refreshTagReorderState(scope);
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the "sortable" flag (and therefore the grip + hint) of a tags section
|
||||
* @param {Element} [tagsSection] - The .model-tags-container element
|
||||
*/
|
||||
function refreshTagReorderState(tagsSection) {
|
||||
refreshReorderState({
|
||||
container: tagsSection?.querySelector(METADATA_ITEMS_CONTAINER_SELECTOR),
|
||||
scope: tagsSection || undefined,
|
||||
itemSelector: METADATA_ITEM_SELECTOR,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -644,7 +643,7 @@ function addNewTag(tag, scopeElement = null) {
|
||||
newTag.className = 'metadata-item';
|
||||
newTag.dataset.tag = tag;
|
||||
newTag.innerHTML = `
|
||||
${renderReorderHandle(translate('common.reorder.dragHandle'))}
|
||||
${renderReorderHandle(translate('common.reorder.dragHandle', {}, 'Drag to reorder'))}
|
||||
<span class="metadata-item-content">${tag}</span>
|
||||
<button class="metadata-delete-btn">
|
||||
<i class="fas fa-times"></i>
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
disablePointerSort,
|
||||
} from './pointerSort.js';
|
||||
import {
|
||||
createReorderSupport,
|
||||
refreshReorderState,
|
||||
renderReorderHandle,
|
||||
renderReorderHint,
|
||||
} from './reorderSupport.js';
|
||||
@@ -26,13 +26,15 @@ 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.
|
||||
* The item body is click-to-edit here, so only the grip starts a drag, and the
|
||||
* small threshold keeps a click on the grip from lifting the tag.
|
||||
*/
|
||||
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',
|
||||
dragThreshold: 5,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -212,7 +214,7 @@ function createSuggestionDropdown(trainedWords, classTokens, existingWords = [])
|
||||
* @returns {string} Handle markup
|
||||
*/
|
||||
function renderTriggerWordDragHandle() {
|
||||
return renderReorderHandle(translate('common.reorder.dragHandle'));
|
||||
return renderReorderHandle(translate('common.reorder.dragHandle', {}, 'Drag to reorder'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -236,7 +238,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'))}
|
||||
${renderReorderHint(translate('common.reorder.dragHandle', {}, 'Drag to reorder'))}
|
||||
<button class="metadata-save-btn" title="${translate('modals.model.triggerWords.save')}">
|
||||
<i class="fas fa-save"></i> ${translate('common.actions.save')}
|
||||
</button>
|
||||
@@ -275,7 +277,7 @@ export function renderTriggerWords(words, filePath) {
|
||||
</div>
|
||||
</div>
|
||||
<div class="metadata-edit-controls" style="display:none;">
|
||||
${renderReorderHint(translate('common.reorder.dragHandle'))}
|
||||
${renderReorderHint(translate('common.reorder.dragHandle', {}, 'Drag to reorder'))}
|
||||
<button class="metadata-save-btn" title="${translate('modals.model.triggerWords.save')}">
|
||||
<i class="fas fa-save"></i> ${translate('common.actions.save')}
|
||||
</button>
|
||||
@@ -543,38 +545,18 @@ 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
|
||||
* Refresh the "sortable" flag (and therefore the grip + hint) of a section.
|
||||
* Reordering is drag-only and only offered while editing: the tag body itself
|
||||
* is click-to-edit, so the grip must not appear in display mode.
|
||||
* @param {HTMLElement} section - The .trigger-words section
|
||||
*/
|
||||
function refreshTriggerWordHandleLabels(section) {
|
||||
getTriggerWordReorder(section)?.refresh();
|
||||
refreshReorderState({
|
||||
container: section.querySelector('.trigger-words-tags'),
|
||||
scope: section,
|
||||
itemSelector: TRIGGER_WORD_DRAG_CONFIG.itemSelector,
|
||||
isActive: () => section.classList.contains('edit-mode'),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -585,14 +567,9 @@ 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);
|
||||
},
|
||||
onSorted: () => refreshTriggerWordHandleLabels(section),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -112,37 +112,6 @@ export function disablePointerSort(container, options = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
@@ -1,38 +1,35 @@
|
||||
/**
|
||||
* reorderSupport.js
|
||||
* Shared keyboard + screen-reader layer for chip lists sorted with pointerSort.
|
||||
* Shared drag affordance 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.
|
||||
* The drag gesture itself lives in pointerSort.js; this module owns the parts
|
||||
* every sortable list needs on top of it:
|
||||
* - the `⠿` grip markup,
|
||||
* - the "sortable" flag that reveals the grip only when reordering is possible.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* Reordering is deliberately pointer-only: a keyboard shortcut would have to
|
||||
* fight the browser's own Alt + Arrow handling and the modal's arrow-key
|
||||
* navigation, so the grip is a plain decorative affordance rather than a
|
||||
* focusable control.
|
||||
*/
|
||||
|
||||
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
|
||||
* Render the shared reorder grip
|
||||
* @param {string} label - Tooltip text
|
||||
* @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>`;
|
||||
return `<span class="reorder-handle" aria-hidden="true" title="${safeLabel}"><i class="fas fa-grip-vertical"></i></span>`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -45,115 +42,23 @@ export function renderReorderHint(label) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* Show or hide the grip and hint of a list.
|
||||
* They are only offered while the list is editable and holds more than one
|
||||
* item, so the UI never shows an affordance that cannot do anything.
|
||||
* @param {Object} options - Options
|
||||
* @param {HTMLElement} options.container - Element holding the items
|
||||
* @param {HTMLElement} options.container - Element holding the sortable 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 {string} options.itemSelector - Selector of the sortable items
|
||||
* @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({
|
||||
export function refreshReorderState({
|
||||
container,
|
||||
scope = container,
|
||||
handleSelector = DEFAULT_HANDLE_SELECTOR,
|
||||
sortConfig,
|
||||
itemSelector,
|
||||
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;
|
||||
if (!container) return;
|
||||
|
||||
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 };
|
||||
const items = container.querySelectorAll(itemSelector);
|
||||
scope.classList.toggle(SORTABLE_CLASS, isActive() && items.length > 1);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user