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
@@ -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);
});
});