From 7fa40023b05726d4ea9a9400beecaf2e5a2dc41d Mon Sep 17 00:00:00 2001 From: Will Miao Date: Tue, 21 Apr 2026 22:31:56 +0800 Subject: [PATCH] fix(trigger-words): edit tag on double click --- static/js/components/shared/TriggerWords.js | 95 ++++++++++++++++++- .../components/triggerWords.escaping.test.js | 4 +- .../triggerWords.inlineEdit.test.js | 20 ++++ 3 files changed, 113 insertions(+), 6 deletions(-) diff --git a/static/js/components/shared/TriggerWords.js b/static/js/components/shared/TriggerWords.js index 98eae424..65accb53 100644 --- a/static/js/components/shared/TriggerWords.js +++ b/static/js/components/shared/TriggerWords.js @@ -10,6 +10,7 @@ import { escapeAttribute, escapeHtml } from './utils.js'; const MAX_WORDS_PER_TRIGGER_GROUP = 500; const MAX_TRIGGER_WORD_GROUPS = 100; +const TRIGGER_WORD_CLICK_DELAY_MS = 220; /** * Fetch trained words for a model @@ -226,7 +227,7 @@ export function renderTriggerWords(words, filePath) { const escapedWord = escapeHtml(word); const escapedAttr = escapeAttribute(word); return ` -
+
${escapedWord} @@ -264,6 +265,8 @@ export function setupTriggerWordsEditMode() { const editBtn = document.querySelector('.edit-trigger-words-btn'); if (!editBtn) return; + document.querySelectorAll('.trigger-word-tag').forEach(setupDisplayTriggerWordTag); + editBtn.addEventListener('click', async function () { const triggerWordsSection = this.closest('.trigger-words'); const isEditMode = triggerWordsSection.classList.toggle('edit-mode'); @@ -296,7 +299,7 @@ export function setupTriggerWordsEditMode() { // Disable click-to-copy and show delete buttons triggerWordTags.forEach(tag => { - tag.onclick = null; + teardownDisplayTriggerWordTag(tag); tag.addEventListener('click', startEditTriggerWord); tag.title = translate('modals.model.triggerWords.editWord'); const copyIcon = tag.querySelector('.trigger-word-copy'); @@ -342,6 +345,12 @@ export function setupTriggerWordsEditMode() { // Focus the input addForm.querySelector('input').focus(); + const pendingEditTag = triggerWordsSection._pendingTriggerWordEditTag; + delete triggerWordsSection._pendingTriggerWordEditTag; + if (pendingEditTag && document.contains(pendingEditTag)) { + startEditTriggerWord.call(pendingEditTag, { target: pendingEditTag, preventDefault() { }, stopPropagation() { } }); + } + } else { this.innerHTML = ''; // Change back to edit icon this.title = translate('modals.model.triggerWords.edit'); @@ -445,7 +454,7 @@ function resetTriggerWordsUIState(section) { // Restore click-to-copy functionality tag.removeEventListener('click', startEditTriggerWord); - tag.onclick = () => copyTriggerWord(tag.dataset.word); + setupDisplayTriggerWordTag(tag); tag.title = translate('modals.model.triggerWords.copyWord'); // Show copy icon, hide delete button @@ -513,12 +522,90 @@ function createTriggerWordTag(word, isEditMode = false) { if (isEditMode) { tag.addEventListener('click', startEditTriggerWord); } else { - tag.onclick = () => copyTriggerWord(tag.dataset.word); + setupDisplayTriggerWordTag(tag); } return tag; } +/** + * Set up display-mode click-to-copy and double-click-to-edit behavior + * @param {HTMLElement} tag - Trigger word tag + */ +function setupDisplayTriggerWordTag(tag) { + teardownDisplayTriggerWordTag(tag); + + tag.addEventListener('click', handleDisplayTriggerWordClick); + tag.addEventListener('dblclick', handleDisplayTriggerWordDoubleClick); + tag.title = translate('modals.model.triggerWords.copyWord'); +} + +/** + * Remove display-mode handlers and pending copy action + * @param {HTMLElement} tag - Trigger word tag + */ +function teardownDisplayTriggerWordTag(tag) { + if (tag.dataset.copyTimerId) { + clearTimeout(Number(tag.dataset.copyTimerId)); + delete tag.dataset.copyTimerId; + } + tag.onclick = null; + tag.removeEventListener('click', handleDisplayTriggerWordClick); + tag.removeEventListener('dblclick', handleDisplayTriggerWordDoubleClick); +} + +/** + * Copy trigger word after a short delay so dblclick can cancel it + * @param {MouseEvent} e - Click event + */ +function handleDisplayTriggerWordClick(e) { + if (e.target.closest('.metadata-delete-btn') || e.target.closest('.trigger-word-edit-input')) return; + + const tag = this.closest('.trigger-word-tag'); + if (!tag || tag.closest('.trigger-words')?.classList.contains('edit-mode')) return; + + e.stopPropagation(); + + if (tag.dataset.copyTimerId) { + clearTimeout(Number(tag.dataset.copyTimerId)); + } + + const timerId = window.setTimeout(() => { + delete tag.dataset.copyTimerId; + copyTriggerWord(tag.dataset.word); + }, TRIGGER_WORD_CLICK_DELAY_MS); + tag.dataset.copyTimerId = String(timerId); +} + +/** + * Enter edit mode and start editing the double-clicked trigger word + * @param {MouseEvent} e - Double-click event + */ +function handleDisplayTriggerWordDoubleClick(e) { + if (e.target.closest('.metadata-delete-btn') || e.target.closest('.trigger-word-edit-input')) return; + + const tag = this.closest('.trigger-word-tag'); + const section = tag?.closest('.trigger-words'); + const editBtn = section?.querySelector('.edit-trigger-words-btn'); + if (!tag || !section || !editBtn) return; + + e.preventDefault(); + e.stopPropagation(); + + if (tag.dataset.copyTimerId) { + clearTimeout(Number(tag.dataset.copyTimerId)); + delete tag.dataset.copyTimerId; + } + + if (!section.classList.contains('edit-mode')) { + section._pendingTriggerWordEditTag = tag; + editBtn.click(); + return; + } + + startEditTriggerWord.call(tag, e); +} + /** * Validate a trigger word against existing tags * @param {string} word - Trigger word diff --git a/tests/frontend/components/triggerWords.escaping.test.js b/tests/frontend/components/triggerWords.escaping.test.js index 199304e2..51904b1a 100644 --- a/tests/frontend/components/triggerWords.escaping.test.js +++ b/tests/frontend/components/triggerWords.escaping.test.js @@ -49,7 +49,7 @@ describe("TriggerWords HTML Escaping", () => { expect(html).toContain('data-word="word'with'quotes"'); expect(html).toContain('data-word="<tag>"'); - // Check for the onclick handler - expect(html).toContain('onclick="copyTriggerWord(this.dataset.word)"'); + // Copy/edit handlers are attached by setupTriggerWordsEditMode, not inline HTML. + expect(html).not.toContain('onclick="copyTriggerWord'); }); }); diff --git a/tests/frontend/components/triggerWords.inlineEdit.test.js b/tests/frontend/components/triggerWords.inlineEdit.test.js index a6b84552..a6fa77b1 100644 --- a/tests/frontend/components/triggerWords.inlineEdit.test.js +++ b/tests/frontend/components/triggerWords.inlineEdit.test.js @@ -29,6 +29,7 @@ describe("TriggerWords inline editing", () => { let renderTriggerWords; let setupTriggerWordsEditMode; let showToast; + let copyToClipboard; beforeEach(async () => { document.body.innerHTML = ''; @@ -46,6 +47,7 @@ describe("TriggerWords inline editing", () => { renderTriggerWords = module.renderTriggerWords; setupTriggerWordsEditMode = module.setupTriggerWordsEditMode; showToast = uiHelpers.showToast; + copyToClipboard = uiHelpers.copyToClipboard; }); async function enterEditMode(words = ["alpha", "beta"]) { @@ -81,6 +83,24 @@ describe("TriggerWords inline editing", () => { expect(document.querySelector('.trigger-word-edit-input')).toBeNull(); }); + it("enters edit mode and edits the double-clicked tag from display mode without copying", async () => { + document.body.innerHTML = renderTriggerWords(["alpha", "beta"], "test.safetensors"); + setupTriggerWordsEditMode(); + + const firstTag = document.querySelector('.trigger-word-tag'); + firstTag.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); + firstTag.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); + firstTag.dispatchEvent(new MouseEvent('dblclick', { bubbles: true, cancelable: true })); + + await vi.waitFor(() => { + expect(document.querySelector('.trigger-words').classList.contains('edit-mode')).toBe(true); + expect(firstTag.querySelector('.trigger-word-edit-input')).toBeTruthy(); + }); + + await new Promise(resolve => setTimeout(resolve, 260)); + expect(copyToClipboard).not.toHaveBeenCalled(); + }); + it("keeps the original word and shows a toast when editing to a duplicate", async () => { await enterEditMode();