fix(trigger-words): support stable inline editing

This commit is contained in:
Will Miao
2026-04-21 22:18:35 +08:00
parent 1e9a7812d6
commit 3c8acdb65e
13 changed files with 355 additions and 50 deletions

View File

@@ -1190,6 +1190,8 @@
"cancel": "Bearbeitung abbrechen", "cancel": "Bearbeitung abbrechen",
"save": "Änderungen speichern", "save": "Änderungen speichern",
"addPlaceholder": "Tippen zum Hinzufügen oder klicken Sie auf Vorschläge unten", "addPlaceholder": "Tippen zum Hinzufügen oder klicken Sie auf Vorschläge unten",
"editWord": "Trigger Word bearbeiten",
"editPlaceholder": "Trigger Word bearbeiten",
"copyWord": "Trigger Word kopieren", "copyWord": "Trigger Word kopieren",
"deleteWord": "Trigger Word löschen", "deleteWord": "Trigger Word löschen",
"suggestions": { "suggestions": {

View File

@@ -1190,6 +1190,8 @@
"cancel": "Cancel editing", "cancel": "Cancel editing",
"save": "Save changes", "save": "Save changes",
"addPlaceholder": "Type to add or click suggestions below", "addPlaceholder": "Type to add or click suggestions below",
"editWord": "Edit trigger word",
"editPlaceholder": "Edit trigger word",
"copyWord": "Copy trigger word", "copyWord": "Copy trigger word",
"deleteWord": "Delete trigger word", "deleteWord": "Delete trigger word",
"suggestions": { "suggestions": {

View File

@@ -1190,6 +1190,8 @@
"cancel": "Cancelar edición", "cancel": "Cancelar edición",
"save": "Guardar cambios", "save": "Guardar cambios",
"addPlaceholder": "Escribe para añadir o haz clic en sugerencias de abajo", "addPlaceholder": "Escribe para añadir o haz clic en sugerencias de abajo",
"editWord": "Editar palabra de activación",
"editPlaceholder": "Editar palabra de activación",
"copyWord": "Copiar palabra clave", "copyWord": "Copiar palabra clave",
"deleteWord": "Eliminar palabra clave", "deleteWord": "Eliminar palabra clave",
"suggestions": { "suggestions": {

View File

@@ -1190,6 +1190,8 @@
"cancel": "Annuler la modification", "cancel": "Annuler la modification",
"save": "Sauvegarder les modifications", "save": "Sauvegarder les modifications",
"addPlaceholder": "Tapez pour ajouter ou cliquez sur les suggestions ci-dessous", "addPlaceholder": "Tapez pour ajouter ou cliquez sur les suggestions ci-dessous",
"editWord": "Modifier le mot déclencheur",
"editPlaceholder": "Modifier le mot déclencheur",
"copyWord": "Copier le mot-clé", "copyWord": "Copier le mot-clé",
"deleteWord": "Supprimer le mot-clé", "deleteWord": "Supprimer le mot-clé",
"suggestions": { "suggestions": {

View File

@@ -1190,6 +1190,8 @@
"cancel": "בטל עריכה", "cancel": "בטל עריכה",
"save": "שמור שינויים", "save": "שמור שינויים",
"addPlaceholder": "הקלד להוספה או לחץ על הצעות למטה", "addPlaceholder": "הקלד להוספה או לחץ על הצעות למטה",
"editWord": "עריכת מילת טריגר",
"editPlaceholder": "עריכת מילת טריגר",
"copyWord": "העתק מילת טריגר", "copyWord": "העתק מילת טריגר",
"deleteWord": "מחק מילת טריגר", "deleteWord": "מחק מילת טריגר",
"suggestions": { "suggestions": {

View File

@@ -1190,6 +1190,8 @@
"cancel": "編集をキャンセル", "cancel": "編集をキャンセル",
"save": "変更を保存", "save": "変更を保存",
"addPlaceholder": "入力して追加するか、下の提案をクリック", "addPlaceholder": "入力して追加するか、下の提案をクリック",
"editWord": "トリガーワードを編集",
"editPlaceholder": "トリガーワードを編集",
"copyWord": "トリガーワードをコピー", "copyWord": "トリガーワードをコピー",
"deleteWord": "トリガーワードを削除", "deleteWord": "トリガーワードを削除",
"suggestions": { "suggestions": {

View File

@@ -1190,6 +1190,8 @@
"cancel": "편집 취소", "cancel": "편집 취소",
"save": "변경사항 저장", "save": "변경사항 저장",
"addPlaceholder": "입력하거나 아래 제안을 클릭하세요", "addPlaceholder": "입력하거나 아래 제안을 클릭하세요",
"editWord": "트리거 단어 편집",
"editPlaceholder": "트리거 단어 편집",
"copyWord": "트리거 단어 복사", "copyWord": "트리거 단어 복사",
"deleteWord": "트리거 단어 삭제", "deleteWord": "트리거 단어 삭제",
"suggestions": { "suggestions": {

View File

@@ -1190,6 +1190,8 @@
"cancel": "Отменить редактирование", "cancel": "Отменить редактирование",
"save": "Сохранить изменения", "save": "Сохранить изменения",
"addPlaceholder": "Введите для добавления или нажмите на предложения ниже", "addPlaceholder": "Введите для добавления или нажмите на предложения ниже",
"editWord": "Редактировать триггерное слово",
"editPlaceholder": "Редактировать триггерное слово",
"copyWord": "Копировать триггерное слово", "copyWord": "Копировать триггерное слово",
"deleteWord": "Удалить триггерное слово", "deleteWord": "Удалить триггерное слово",
"suggestions": { "suggestions": {

View File

@@ -1190,6 +1190,8 @@
"cancel": "取消编辑", "cancel": "取消编辑",
"save": "保存更改", "save": "保存更改",
"addPlaceholder": "输入或点击下方建议添加", "addPlaceholder": "输入或点击下方建议添加",
"editWord": "编辑触发词",
"editPlaceholder": "编辑触发词",
"copyWord": "复制触发词", "copyWord": "复制触发词",
"deleteWord": "删除触发词", "deleteWord": "删除触发词",
"suggestions": { "suggestions": {

View File

@@ -1190,6 +1190,8 @@
"cancel": "取消編輯", "cancel": "取消編輯",
"save": "儲存變更", "save": "儲存變更",
"addPlaceholder": "輸入或點擊下方建議", "addPlaceholder": "輸入或點擊下方建議",
"editWord": "編輯觸發詞",
"editPlaceholder": "編輯觸發詞",
"copyWord": "複製觸發詞", "copyWord": "複製觸發詞",
"deleteWord": "刪除觸發詞", "deleteWord": "刪除觸發詞",
"suggestions": { "suggestions": {

View File

@@ -53,6 +53,10 @@
position: relative; position: relative;
} }
.trigger-word-tag:not(.is-editing) {
transition: background-color 0.2s ease, border-color 0.2s ease;
}
.trigger-word-content { .trigger-word-content {
color: var(--lora-accent) !important; color: var(--lora-accent) !important;
font-size: 0.85em; font-size: 0.85em;
@@ -65,6 +69,38 @@
border-color: var(--lora-accent); border-color: var(--lora-accent);
} }
.trigger-words.edit-mode .trigger-word-tag {
cursor: text;
}
.trigger-word-tag.is-editing {
align-items: center;
flex: 0 1 min(var(--trigger-word-edit-width, 48ch), 100%);
width: min(var(--trigger-word-edit-width, 48ch), 100%);
height: var(--trigger-word-edit-height, auto);
border-color: var(--lora-accent);
transition: none;
}
.trigger-word-edit-input {
width: 100%;
height: 100%;
min-width: 0;
box-sizing: border-box;
padding: 1px 2px;
border: none;
resize: none;
overflow: auto;
outline: none;
background: transparent;
color: var(--lora-accent);
font: inherit;
font-size: 0.85em;
line-height: 1.4;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.trigger-word-copy { .trigger-word-copy {
display: flex; display: flex;
align-items: center; align-items: center;

View File

@@ -297,6 +297,8 @@ 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; tag.onclick = null;
tag.addEventListener('click', startEditTriggerWord);
tag.title = translate('modals.model.triggerWords.editWord');
const copyIcon = tag.querySelector('.trigger-word-copy'); const copyIcon = tag.querySelector('.trigger-word-copy');
const deleteBtn = tag.querySelector('.metadata-delete-btn'); const deleteBtn = tag.querySelector('.metadata-delete-btn');
@@ -353,6 +355,7 @@ export function setupTriggerWordsEditMode() {
// If canceling, restore original trigger words // If canceling, restore original trigger words
restoreOriginalTriggerWords(triggerWordsSection, originalTriggerWords); restoreOriginalTriggerWords(triggerWordsSection, originalTriggerWords);
} else { } else {
commitActiveTriggerWordEdit(triggerWordsSection);
// If saving, reset UI state on current trigger words // If saving, reset UI state on current trigger words
resetTriggerWordsUIState(triggerWordsSection); resetTriggerWordsUIState(triggerWordsSection);
// Reset the skip restore flag // Reset the skip restore flag
@@ -432,15 +435,18 @@ function deleteTriggerWord(e) {
* @param {HTMLElement} section - The trigger words section * @param {HTMLElement} section - The trigger words section
*/ */
function resetTriggerWordsUIState(section) { function resetTriggerWordsUIState(section) {
commitActiveTriggerWordEdit(section);
const triggerWordTags = section.querySelectorAll('.trigger-word-tag'); const triggerWordTags = section.querySelectorAll('.trigger-word-tag');
triggerWordTags.forEach(tag => { triggerWordTags.forEach(tag => {
const word = tag.dataset.word;
const copyIcon = tag.querySelector('.trigger-word-copy'); const copyIcon = tag.querySelector('.trigger-word-copy');
const deleteBtn = tag.querySelector('.metadata-delete-btn'); const deleteBtn = tag.querySelector('.metadata-delete-btn');
// Restore click-to-copy functionality // Restore click-to-copy functionality
tag.removeEventListener('click', startEditTriggerWord);
tag.onclick = () => copyTriggerWord(tag.dataset.word); tag.onclick = () => copyTriggerWord(tag.dataset.word);
tag.title = translate('modals.model.triggerWords.copyWord');
// Show copy icon, hide delete button // Show copy icon, hide delete button
if (copyIcon) copyIcon.style.display = ''; if (copyIcon) copyIcon.style.display = '';
@@ -474,23 +480,156 @@ function restoreOriginalTriggerWords(section, originalWords) {
// Recreate original tags // Recreate original tags
originalWords.forEach(word => { originalWords.forEach(word => {
tagsContainer.appendChild(createTriggerWordTag(word, false));
});
}
/**
* Create a trigger word tag element
* @param {string} word - Trigger word
* @param {boolean} isEditMode - Whether the tag should be editable
* @returns {HTMLElement} Tag element
*/
function createTriggerWordTag(word, isEditMode = false) {
const tag = document.createElement('div'); const tag = document.createElement('div');
tag.className = 'trigger-word-tag'; tag.className = 'trigger-word-tag';
tag.dataset.word = word; tag.dataset.word = word;
tag.onclick = () => copyTriggerWord(tag.dataset.word); tag.title = translate(isEditMode ? 'modals.model.triggerWords.editWord' : 'modals.model.triggerWords.copyWord');
const escapedWord = escapeHtml(word); const escapedWord = escapeHtml(word);
tag.innerHTML = ` tag.innerHTML = `
<span class="trigger-word-content">${escapedWord}</span> <span class="trigger-word-content">${escapedWord}</span>
<span class="trigger-word-copy"> <span class="trigger-word-copy" style="${isEditMode ? 'display:none;' : ''}">
<i class="fas fa-copy"></i> <i class="fas fa-copy"></i>
</span> </span>
<button class="metadata-delete-btn" style="display:none;" onclick="event.stopPropagation();"> <button class="metadata-delete-btn" style="${isEditMode ? '' : 'display:none;'}" onclick="event.stopPropagation();" title="${translate('modals.model.triggerWords.deleteWord')}">
<i class="fas fa-times"></i> <i class="fas fa-times"></i>
</button> </button>
`; `;
tagsContainer.appendChild(tag);
const deleteBtn = tag.querySelector('.metadata-delete-btn');
deleteBtn.addEventListener('click', deleteTriggerWord);
if (isEditMode) {
tag.addEventListener('click', startEditTriggerWord);
} else {
tag.onclick = () => copyTriggerWord(tag.dataset.word);
}
return tag;
}
/**
* Validate a trigger word against existing tags
* @param {string} word - Trigger word
* @param {HTMLElement} tagsContainer - Tags container
* @param {HTMLElement|null} currentTag - Tag being edited, if any
* @returns {boolean} Whether the word is valid
*/
function validateTriggerWord(word, tagsContainer, currentTag = null) {
if (word.split(/\s+/).length > MAX_WORDS_PER_TRIGGER_GROUP) {
showToast('toast.triggerWords.tooLong', {}, 'error');
return false;
}
const currentTags = tagsContainer.querySelectorAll('.trigger-word-tag');
const existingWords = Array.from(currentTags)
.filter(tag => tag !== currentTag)
.map(tag => tag.dataset.word);
if (existingWords.includes(word)) {
showToast('toast.triggerWords.alreadyExists', {}, 'error');
return false;
}
return true;
}
/**
* Start inline editing for a trigger word tag
* @param {Event} e - Click event
*/
function startEditTriggerWord(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');
if (!tag || !section?.classList.contains('edit-mode') || tag.classList.contains('is-editing')) return;
e.preventDefault();
e.stopPropagation();
commitActiveTriggerWordEdit(section);
const content = tag.querySelector('.trigger-word-content');
const originalWord = tag.dataset.word;
const originalRect = tag.getBoundingClientRect();
if (originalRect.width > 0) {
tag.style.setProperty('--trigger-word-edit-width', `${Math.ceil(originalRect.width)}px`);
}
if (originalRect.height > 0) {
tag.style.setProperty('--trigger-word-edit-height', `${Math.ceil(originalRect.height)}px`);
}
const editor = document.createElement('textarea');
editor.className = 'trigger-word-edit-input';
editor.rows = 1;
editor.value = originalWord;
editor.setAttribute('aria-label', translate('modals.model.triggerWords.editWord'));
editor.placeholder = translate('modals.model.triggerWords.editPlaceholder');
let finished = false;
const finish = (shouldCommit) => {
if (finished) return;
finished = true;
const nextWord = editor.value.trim().replace(/\s*\n+\s*/g, ' ');
if (shouldCommit && nextWord && nextWord !== originalWord) {
const tagsContainer = tag.closest('.trigger-words-tags');
if (tagsContainer && validateTriggerWord(nextWord, tagsContainer, tag)) {
tag.dataset.word = nextWord;
content.textContent = nextWord;
}
}
editor.remove();
content.style.display = '';
tag.classList.remove('is-editing');
tag.style.removeProperty('--trigger-word-edit-width');
tag.style.removeProperty('--trigger-word-edit-height');
updateTrainedWordsDropdown();
};
editor.addEventListener('click', event => event.stopPropagation());
editor.addEventListener('keydown', event => {
if (event.key === 'Enter') {
event.preventDefault();
finish(true);
} else if (event.key === 'Escape') {
event.preventDefault();
finish(false);
}
}); });
editor.addEventListener('blur', () => finish(true));
editor.style.visibility = 'hidden';
content.after(editor);
tag.classList.add('is-editing');
content.style.display = 'none';
editor.style.visibility = '';
editor.focus();
editor.select();
}
/**
* Commit an active inline trigger word edit if one exists
* @param {HTMLElement} section - Trigger words section
*/
function commitActiveTriggerWordEdit(section) {
const input = section.querySelector('.trigger-word-edit-input');
if (input) {
input.dispatchEvent(new FocusEvent('blur'));
}
} }
/** /**
@@ -525,12 +664,6 @@ function addNewTriggerWord(word) {
noTriggerWordsMsg.style.display = 'none'; noTriggerWordsMsg.style.display = 'none';
} }
// Validation: Check length
if (word.split(/\s+/).length > MAX_WORDS_PER_TRIGGER_GROUP) {
showToast('toast.triggerWords.tooLong', {}, 'error');
return;
}
// Validation: Check total number // Validation: Check total number
const currentTags = tagsContainer.querySelectorAll('.trigger-word-tag'); const currentTags = tagsContainer.querySelectorAll('.trigger-word-tag');
if (currentTags.length >= MAX_TRIGGER_WORD_GROUPS) { if (currentTags.length >= MAX_TRIGGER_WORD_GROUPS) {
@@ -538,33 +671,9 @@ function addNewTriggerWord(word) {
return; return;
} }
// Validation: Check for duplicates if (!validateTriggerWord(word, tagsContainer)) return;
const existingWords = Array.from(currentTags).map(tag => tag.dataset.word);
if (existingWords.includes(word)) {
showToast('toast.triggerWords.alreadyExists', {}, 'error');
return;
}
// Create new tag
const newTag = document.createElement('div');
newTag.className = 'trigger-word-tag';
newTag.dataset.word = word;
const escapedWord = escapeHtml(word);
newTag.innerHTML = `
<span class="trigger-word-content">${escapedWord}</span>
<span class="trigger-word-copy" style="display:none;">
<i class="fas fa-copy"></i>
</span>
<button class="metadata-delete-btn" onclick="event.stopPropagation();">
<i class="fas fa-times"></i>
</button>
`;
// Add event listener to delete button
const deleteBtn = newTag.querySelector('.metadata-delete-btn');
deleteBtn.addEventListener('click', deleteTriggerWord);
const newTag = createTriggerWordTag(word, triggerWordsSection.classList.contains('edit-mode'));
tagsContainer.appendChild(newTag); tagsContainer.appendChild(newTag);
// Update status of items in the trained words dropdown // Update status of items in the trained words dropdown
@@ -636,6 +745,8 @@ async function saveTriggerWords() {
const filePath = editBtn.dataset.filePath; const filePath = editBtn.dataset.filePath;
const triggerWordsSection = editBtn.closest('.trigger-words'); const triggerWordsSection = editBtn.closest('.trigger-words');
commitActiveTriggerWordEdit(triggerWordsSection);
// Auto-commit any pending input to prevent data loss // Auto-commit any pending input to prevent data loss
const input = triggerWordsSection.querySelector('.metadata-input'); const input = triggerWordsSection.querySelector('.metadata-input');
if (input && input.value.trim()) { if (input && input.value.trim()) {

View File

@@ -0,0 +1,138 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const {
TRIGGER_WORDS_MODULE,
I18N_HELPERS_MODULE,
UI_HELPERS_MODULE,
} = 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,
}));
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('../../../static/js/api/modelApiFactory.js', () => ({
getModelApiClient: vi.fn(() => ({
saveModelMetadata: vi.fn(),
})),
}));
describe("TriggerWords inline editing", () => {
let renderTriggerWords;
let setupTriggerWordsEditMode;
let showToast;
beforeEach(async () => {
document.body.innerHTML = '';
vi.clearAllMocks();
global.fetch = vi.fn(async () => ({
json: async () => ({
success: true,
trained_words: [],
class_tokens: null,
}),
}));
const module = await import(TRIGGER_WORDS_MODULE);
const uiHelpers = await import(UI_HELPERS_MODULE);
renderTriggerWords = module.renderTriggerWords;
setupTriggerWordsEditMode = module.setupTriggerWordsEditMode;
showToast = uiHelpers.showToast;
});
async function enterEditMode(words = ["alpha", "beta"]) {
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();
});
}
function editFirstTag(nextValue, key = 'Enter') {
const firstTag = document.querySelector('.trigger-word-tag');
firstTag.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
const input = firstTag.querySelector('.trigger-word-edit-input');
input.value = nextValue;
input.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }));
return firstTag;
}
it("updates an existing trigger word in place", async () => {
await enterEditMode();
const firstTag = editFirstTag("gamma");
expect(firstTag.dataset.word).toBe("gamma");
expect(firstTag.querySelector('.trigger-word-content').textContent).toBe("gamma");
expect(document.querySelector('.trigger-word-edit-input')).toBeNull();
});
it("keeps the original word and shows a toast when editing to a duplicate", async () => {
await enterEditMode();
const firstTag = editFirstTag("beta");
expect(firstTag.dataset.word).toBe("alpha");
expect(firstTag.querySelector('.trigger-word-content').textContent).toBe("alpha");
expect(showToast).toHaveBeenCalledWith('toast.triggerWords.alreadyExists', {}, 'error');
});
it("restores the original value when Escape is pressed", async () => {
await enterEditMode();
const firstTag = editFirstTag("gamma", "Escape");
expect(firstTag.dataset.word).toBe("alpha");
expect(firstTag.querySelector('.trigger-word-content').textContent).toBe("alpha");
});
it("preserves the current tag dimensions while editing long trigger words", async () => {
await enterEditMode(["alpha beta gamma delta epsilon zeta eta theta"]);
const firstTag = document.querySelector('.trigger-word-tag');
vi.spyOn(firstTag, 'getBoundingClientRect').mockReturnValue({
width: 320,
height: 44,
top: 0,
right: 320,
bottom: 44,
left: 0,
x: 0,
y: 0,
toJSON: () => ({}),
});
firstTag.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
const editor = firstTag.querySelector('.trigger-word-edit-input');
expect(editor.tagName).toBe('TEXTAREA');
expect(firstTag.style.getPropertyValue('--trigger-word-edit-width')).toBe('320px');
expect(firstTag.style.getPropertyValue('--trigger-word-edit-height')).toBe('44px');
});
it("restores all original trigger words when edit mode is canceled", async () => {
await enterEditMode();
editFirstTag("gamma");
document.querySelector('.edit-trigger-words-btn')
.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
const words = Array.from(document.querySelectorAll('.trigger-word-tag'))
.map(tag => tag.dataset.word);
expect(words).toEqual(["alpha", "beta"]);
});
});