mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-09-21 11:11:26 -03:00
e09fe5888b
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.
232 lines
8.2 KiB
JavaScript
232 lines
8.2 KiB
JavaScript
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 });
|
|
}
|
|
|
|
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("keeps the grip a decorative drag affordance, not a keyboard control", async () => {
|
|
await enterEditMode();
|
|
|
|
const grip = handles()[0];
|
|
expect(grip.tagName).toBe('SPAN');
|
|
expect(grip.getAttribute('aria-hidden')).toBe('true');
|
|
expect(grip.hasAttribute('tabindex')).toBe(false);
|
|
expect(grip.getAttribute('title')).toBe('Drag to reorder');
|
|
});
|
|
|
|
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("treats a click on the grip without movement as a click, not a drag", async () => {
|
|
await enterEditMode();
|
|
|
|
const grip = handles()[0];
|
|
firePointer('pointerdown', grip, { clientY: 10 });
|
|
firePointer('pointermove', grip, { clientY: 12 });
|
|
firePointer('pointerup', grip, { clientY: 12 });
|
|
grip.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
|
|
|
|
expect(order()).toEqual(["alpha", "beta", "gamma"]);
|
|
expect(document.querySelector('.trigger-word-edit-input')).toBeNull();
|
|
});
|
|
|
|
it("saves the new order after a drag", async () => {
|
|
await enterEditMode();
|
|
|
|
dragToEnd(handles()[0]);
|
|
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("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);
|
|
});
|
|
});
|