fix(trigger-words): edit tag on double click

This commit is contained in:
Will Miao
2026-04-21 22:31:56 +08:00
parent 3c8acdb65e
commit 7fa40023b0
3 changed files with 113 additions and 6 deletions

View File

@@ -10,6 +10,7 @@ import { escapeAttribute, escapeHtml } from './utils.js';
const MAX_WORDS_PER_TRIGGER_GROUP = 500; const MAX_WORDS_PER_TRIGGER_GROUP = 500;
const MAX_TRIGGER_WORD_GROUPS = 100; const MAX_TRIGGER_WORD_GROUPS = 100;
const TRIGGER_WORD_CLICK_DELAY_MS = 220;
/** /**
* Fetch trained words for a model * Fetch trained words for a model
@@ -226,7 +227,7 @@ export function renderTriggerWords(words, filePath) {
const escapedWord = escapeHtml(word); const escapedWord = escapeHtml(word);
const escapedAttr = escapeAttribute(word); const escapedAttr = escapeAttribute(word);
return ` return `
<div class="trigger-word-tag" data-word="${escapedAttr}" onclick="copyTriggerWord(this.dataset.word)" title="${translate('modals.model.triggerWords.copyWord')}"> <div class="trigger-word-tag" data-word="${escapedAttr}" title="${translate('modals.model.triggerWords.copyWord')}">
<span class="trigger-word-content">${escapedWord}</span> <span class="trigger-word-content">${escapedWord}</span>
<span class="trigger-word-copy"> <span class="trigger-word-copy">
<i class="fas fa-copy"></i> <i class="fas fa-copy"></i>
@@ -264,6 +265,8 @@ export function setupTriggerWordsEditMode() {
const editBtn = document.querySelector('.edit-trigger-words-btn'); const editBtn = document.querySelector('.edit-trigger-words-btn');
if (!editBtn) return; if (!editBtn) return;
document.querySelectorAll('.trigger-word-tag').forEach(setupDisplayTriggerWordTag);
editBtn.addEventListener('click', async function () { editBtn.addEventListener('click', async function () {
const triggerWordsSection = this.closest('.trigger-words'); const triggerWordsSection = this.closest('.trigger-words');
const isEditMode = triggerWordsSection.classList.toggle('edit-mode'); const isEditMode = triggerWordsSection.classList.toggle('edit-mode');
@@ -296,7 +299,7 @@ export function setupTriggerWordsEditMode() {
// Disable click-to-copy and show delete buttons // Disable click-to-copy and show delete buttons
triggerWordTags.forEach(tag => { triggerWordTags.forEach(tag => {
tag.onclick = null; teardownDisplayTriggerWordTag(tag);
tag.addEventListener('click', startEditTriggerWord); tag.addEventListener('click', startEditTriggerWord);
tag.title = translate('modals.model.triggerWords.editWord'); tag.title = translate('modals.model.triggerWords.editWord');
const copyIcon = tag.querySelector('.trigger-word-copy'); const copyIcon = tag.querySelector('.trigger-word-copy');
@@ -342,6 +345,12 @@ export function setupTriggerWordsEditMode() {
// Focus the input // Focus the input
addForm.querySelector('input').focus(); addForm.querySelector('input').focus();
const pendingEditTag = triggerWordsSection._pendingTriggerWordEditTag;
delete triggerWordsSection._pendingTriggerWordEditTag;
if (pendingEditTag && document.contains(pendingEditTag)) {
startEditTriggerWord.call(pendingEditTag, { target: pendingEditTag, preventDefault() { }, stopPropagation() { } });
}
} else { } else {
this.innerHTML = '<i class="fas fa-pencil-alt"></i>'; // Change back to edit icon this.innerHTML = '<i class="fas fa-pencil-alt"></i>'; // Change back to edit icon
this.title = translate('modals.model.triggerWords.edit'); this.title = translate('modals.model.triggerWords.edit');
@@ -445,7 +454,7 @@ function resetTriggerWordsUIState(section) {
// Restore click-to-copy functionality // Restore click-to-copy functionality
tag.removeEventListener('click', startEditTriggerWord); tag.removeEventListener('click', startEditTriggerWord);
tag.onclick = () => copyTriggerWord(tag.dataset.word); setupDisplayTriggerWordTag(tag);
tag.title = translate('modals.model.triggerWords.copyWord'); tag.title = translate('modals.model.triggerWords.copyWord');
// Show copy icon, hide delete button // Show copy icon, hide delete button
@@ -513,12 +522,90 @@ function createTriggerWordTag(word, isEditMode = false) {
if (isEditMode) { if (isEditMode) {
tag.addEventListener('click', startEditTriggerWord); tag.addEventListener('click', startEditTriggerWord);
} else { } else {
tag.onclick = () => copyTriggerWord(tag.dataset.word); setupDisplayTriggerWordTag(tag);
} }
return 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 * Validate a trigger word against existing tags
* @param {string} word - Trigger word * @param {string} word - Trigger word

View File

@@ -49,7 +49,7 @@ describe("TriggerWords HTML Escaping", () => {
expect(html).toContain('data-word="word&#39;with&#39;quotes"'); expect(html).toContain('data-word="word&#39;with&#39;quotes"');
expect(html).toContain('data-word="&lt;tag&gt;"'); expect(html).toContain('data-word="&lt;tag&gt;"');
// Check for the onclick handler // Copy/edit handlers are attached by setupTriggerWordsEditMode, not inline HTML.
expect(html).toContain('onclick="copyTriggerWord(this.dataset.word)"'); expect(html).not.toContain('onclick="copyTriggerWord');
}); });
}); });

View File

@@ -29,6 +29,7 @@ describe("TriggerWords inline editing", () => {
let renderTriggerWords; let renderTriggerWords;
let setupTriggerWordsEditMode; let setupTriggerWordsEditMode;
let showToast; let showToast;
let copyToClipboard;
beforeEach(async () => { beforeEach(async () => {
document.body.innerHTML = ''; document.body.innerHTML = '';
@@ -46,6 +47,7 @@ describe("TriggerWords inline editing", () => {
renderTriggerWords = module.renderTriggerWords; renderTriggerWords = module.renderTriggerWords;
setupTriggerWordsEditMode = module.setupTriggerWordsEditMode; setupTriggerWordsEditMode = module.setupTriggerWordsEditMode;
showToast = uiHelpers.showToast; showToast = uiHelpers.showToast;
copyToClipboard = uiHelpers.copyToClipboard;
}); });
async function enterEditMode(words = ["alpha", "beta"]) { async function enterEditMode(words = ["alpha", "beta"]) {
@@ -81,6 +83,24 @@ describe("TriggerWords inline editing", () => {
expect(document.querySelector('.trigger-word-edit-input')).toBeNull(); 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 () => { it("keeps the original word and shows a toast when editing to a duplicate", async () => {
await enterEditMode(); await enterEditMode();