mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-09-20 18:51:26 -03:00
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user