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

@@ -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;
}
}

View File

@@ -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 = `
<span class="trigger-word-content">${escapedWord}</span>
<span class="trigger-word-copy">
<i class="fas fa-copy"></i>
</span>
<button class="metadata-delete-btn" style="display:none;" onclick="event.stopPropagation();">
<i class="fas fa-times"></i>
</button>
`;
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 = `
<span class="trigger-word-content">${escapedWord}</span>
<span class="trigger-word-copy" style="${isEditMode ? 'display:none;' : ''}">
<i class="fas fa-copy"></i>
</span>
<button class="metadata-delete-btn" style="${isEditMode ? '' : 'display:none;'}" onclick="event.stopPropagation();" title="${translate('modals.model.triggerWords.deleteWord')}">
<i class="fas fa-times"></i>
</button>
`;
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 = `
<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);
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()) {