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();