diff --git a/locales/de.json b/locales/de.json index 6c92b9f5..d1b739b8 100644 --- a/locales/de.json +++ b/locales/de.json @@ -1190,6 +1190,8 @@ "cancel": "Bearbeitung abbrechen", "save": "Änderungen speichern", "addPlaceholder": "Tippen zum Hinzufügen oder klicken Sie auf Vorschläge unten", + "editWord": "Trigger Word bearbeiten", + "editPlaceholder": "Trigger Word bearbeiten", "copyWord": "Trigger Word kopieren", "deleteWord": "Trigger Word löschen", "suggestions": { diff --git a/locales/en.json b/locales/en.json index ea62d8fe..b4d13ee3 100644 --- a/locales/en.json +++ b/locales/en.json @@ -1190,6 +1190,8 @@ "cancel": "Cancel editing", "save": "Save changes", "addPlaceholder": "Type to add or click suggestions below", + "editWord": "Edit trigger word", + "editPlaceholder": "Edit trigger word", "copyWord": "Copy trigger word", "deleteWord": "Delete trigger word", "suggestions": { diff --git a/locales/es.json b/locales/es.json index eee4bf86..8756d351 100644 --- a/locales/es.json +++ b/locales/es.json @@ -1190,6 +1190,8 @@ "cancel": "Cancelar edición", "save": "Guardar cambios", "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", "deleteWord": "Eliminar palabra clave", "suggestions": { diff --git a/locales/fr.json b/locales/fr.json index ac139d19..93c44db8 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -1190,6 +1190,8 @@ "cancel": "Annuler la modification", "save": "Sauvegarder les modifications", "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é", "deleteWord": "Supprimer le mot-clé", "suggestions": { diff --git a/locales/he.json b/locales/he.json index f1de1aab..5db782c8 100644 --- a/locales/he.json +++ b/locales/he.json @@ -1190,6 +1190,8 @@ "cancel": "בטל עריכה", "save": "שמור שינויים", "addPlaceholder": "הקלד להוספה או לחץ על הצעות למטה", + "editWord": "עריכת מילת טריגר", + "editPlaceholder": "עריכת מילת טריגר", "copyWord": "העתק מילת טריגר", "deleteWord": "מחק מילת טריגר", "suggestions": { diff --git a/locales/ja.json b/locales/ja.json index 4643e63d..c9a16535 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -1190,6 +1190,8 @@ "cancel": "編集をキャンセル", "save": "変更を保存", "addPlaceholder": "入力して追加するか、下の提案をクリック", + "editWord": "トリガーワードを編集", + "editPlaceholder": "トリガーワードを編集", "copyWord": "トリガーワードをコピー", "deleteWord": "トリガーワードを削除", "suggestions": { diff --git a/locales/ko.json b/locales/ko.json index b1f0fcee..e8111c38 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -1190,6 +1190,8 @@ "cancel": "편집 취소", "save": "변경사항 저장", "addPlaceholder": "입력하거나 아래 제안을 클릭하세요", + "editWord": "트리거 단어 편집", + "editPlaceholder": "트리거 단어 편집", "copyWord": "트리거 단어 복사", "deleteWord": "트리거 단어 삭제", "suggestions": { diff --git a/locales/ru.json b/locales/ru.json index 4b0c9140..7971aa54 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -1190,6 +1190,8 @@ "cancel": "Отменить редактирование", "save": "Сохранить изменения", "addPlaceholder": "Введите для добавления или нажмите на предложения ниже", + "editWord": "Редактировать триггерное слово", + "editPlaceholder": "Редактировать триггерное слово", "copyWord": "Копировать триггерное слово", "deleteWord": "Удалить триггерное слово", "suggestions": { diff --git a/locales/zh-CN.json b/locales/zh-CN.json index c9606407..d1722fdc 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -1190,6 +1190,8 @@ "cancel": "取消编辑", "save": "保存更改", "addPlaceholder": "输入或点击下方建议添加", + "editWord": "编辑触发词", + "editPlaceholder": "编辑触发词", "copyWord": "复制触发词", "deleteWord": "删除触发词", "suggestions": { diff --git a/locales/zh-TW.json b/locales/zh-TW.json index 1f19fbfa..2e59a747 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -1190,6 +1190,8 @@ "cancel": "取消編輯", "save": "儲存變更", "addPlaceholder": "輸入或點擊下方建議", + "editWord": "編輯觸發詞", + "editPlaceholder": "編輯觸發詞", "copyWord": "複製觸發詞", "deleteWord": "刪除觸發詞", "suggestions": { diff --git a/static/css/components/lora-modal/triggerwords.css b/static/css/components/lora-modal/triggerwords.css index 443a966c..1c695b08 100644 --- a/static/css/components/lora-modal/triggerwords.css +++ b/static/css/components/lora-modal/triggerwords.css @@ -53,6 +53,10 @@ position: relative; } +.trigger-word-tag:not(.is-editing) { + transition: background-color 0.2s ease, border-color 0.2s ease; +} + .trigger-word-content { color: var(--lora-accent) !important; font-size: 0.85em; @@ -65,6 +69,38 @@ 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 { display: flex; align-items: center; @@ -109,4 +145,4 @@ padding: 2px 5px; border-radius: 8px; white-space: nowrap; -} \ No newline at end of file +} diff --git a/static/js/components/shared/TriggerWords.js b/static/js/components/shared/TriggerWords.js index 4426a508..98eae424 100644 --- a/static/js/components/shared/TriggerWords.js +++ b/static/js/components/shared/TriggerWords.js @@ -297,6 +297,8 @@ export function setupTriggerWordsEditMode() { // Disable click-to-copy and show delete buttons triggerWordTags.forEach(tag => { tag.onclick = null; + tag.addEventListener('click', startEditTriggerWord); + tag.title = translate('modals.model.triggerWords.editWord'); const copyIcon = tag.querySelector('.trigger-word-copy'); const deleteBtn = tag.querySelector('.metadata-delete-btn'); @@ -353,6 +355,7 @@ export function setupTriggerWordsEditMode() { // If canceling, restore original trigger words restoreOriginalTriggerWords(triggerWordsSection, originalTriggerWords); } else { + commitActiveTriggerWordEdit(triggerWordsSection); // If saving, reset UI state on current trigger words resetTriggerWordsUIState(triggerWordsSection); // Reset the skip restore flag @@ -432,15 +435,18 @@ function deleteTriggerWord(e) { * @param {HTMLElement} section - The trigger words section */ function resetTriggerWordsUIState(section) { + commitActiveTriggerWordEdit(section); + const triggerWordTags = section.querySelectorAll('.trigger-word-tag'); triggerWordTags.forEach(tag => { - const word = tag.dataset.word; const copyIcon = tag.querySelector('.trigger-word-copy'); const deleteBtn = tag.querySelector('.metadata-delete-btn'); // Restore click-to-copy functionality + tag.removeEventListener('click', startEditTriggerWord); tag.onclick = () => copyTriggerWord(tag.dataset.word); + tag.title = translate('modals.model.triggerWords.copyWord'); // Show copy icon, hide delete button if (copyIcon) copyIcon.style.display = ''; @@ -474,25 +480,158 @@ function restoreOriginalTriggerWords(section, originalWords) { // Recreate original tags originalWords.forEach(word => { - const tag = document.createElement('div'); - tag.className = 'trigger-word-tag'; - tag.dataset.word = word; - tag.onclick = () => copyTriggerWord(tag.dataset.word); - - const escapedWord = escapeHtml(word); - tag.innerHTML = ` - ${escapedWord} - - - - - `; - tagsContainer.appendChild(tag); + 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'); + tag.className = 'trigger-word-tag'; + tag.dataset.word = word; + tag.title = translate(isEditMode ? 'modals.model.triggerWords.editWord' : 'modals.model.triggerWords.copyWord'); + + const escapedWord = escapeHtml(word); + tag.innerHTML = ` + ${escapedWord} + + + + + `; + + 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')); + } +} + /** * Add a new trigger word * @param {string} word - Trigger word to add @@ -525,12 +664,6 @@ function addNewTriggerWord(word) { 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 const currentTags = tagsContainer.querySelectorAll('.trigger-word-tag'); if (currentTags.length >= MAX_TRIGGER_WORD_GROUPS) { @@ -538,33 +671,9 @@ function addNewTriggerWord(word) { return; } - // Validation: Check for duplicates - 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 = ` - ${escapedWord} - - - `; - - // Add event listener to delete button - const deleteBtn = newTag.querySelector('.metadata-delete-btn'); - deleteBtn.addEventListener('click', deleteTriggerWord); + if (!validateTriggerWord(word, tagsContainer)) return; + const newTag = createTriggerWordTag(word, triggerWordsSection.classList.contains('edit-mode')); tagsContainer.appendChild(newTag); // Update status of items in the trained words dropdown @@ -636,6 +745,8 @@ async function saveTriggerWords() { const filePath = editBtn.dataset.filePath; const triggerWordsSection = editBtn.closest('.trigger-words'); + commitActiveTriggerWordEdit(triggerWordsSection); + // Auto-commit any pending input to prevent data loss const input = triggerWordsSection.querySelector('.metadata-input'); if (input && input.value.trim()) { diff --git a/tests/frontend/components/triggerWords.inlineEdit.test.js b/tests/frontend/components/triggerWords.inlineEdit.test.js new file mode 100644 index 00000000..a6b84552 --- /dev/null +++ b/tests/frontend/components/triggerWords.inlineEdit.test.js @@ -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"]); + }); +});