1370 lines
54 KiB
JavaScript
1370 lines
54 KiB
JavaScript
/**
|
|
* 🏷️ Widget Event Handlers
|
|
* All event handler attachments for the editor
|
|
* Extracted for modularity - preserves 100% of original logic
|
|
*/
|
|
|
|
import { TagUtilities } from "./tag-utilities.js";
|
|
import { isLanguageCode } from "./language-constants.js";
|
|
|
|
export function attachAllEventHandlers(
|
|
editor, state, widget, storageKey, getPlainText, setEditorText, getCaretPos, setCaretPos, getSelectionRange,
|
|
undoBtn, redoBtn, historyStatus, charSelect, charInput, addCharBtn, langSelect, addLangBtn,
|
|
paramTypeSelect, paramInputWrapper, addParamBtn, presetButtons, presetTitles, updatePresetGlows,
|
|
formatBtn, validateBtn, fontFamilySelect, fontSizeInput, fontSizeDisplay, setFontSize, setFontFamily,
|
|
showNotification, resizeDivider, sidebar, setSidebarWidth, setUIScale, setSidebarResizeActive,
|
|
inlineTagControls,
|
|
openFindReplace, focusNextFindMatch, focusPreviousFindMatch
|
|
) {
|
|
const {
|
|
inlineEngineSelect,
|
|
step: {
|
|
paraSelect, paraIterSlider, addParaBtn,
|
|
emotionSelect, emotionIterSlider, addEmotionBtn,
|
|
styleSelect, styleIterSlider, addStyleBtn,
|
|
speedSelect, speedIterSlider, addSpeedBtn,
|
|
restorePassSlider, restoreRefInput, addRestoreBtn,
|
|
},
|
|
higgs: {
|
|
emotionSelect: higgsEmotionSelect,
|
|
addEmotionBtn: addHiggsEmotionBtn,
|
|
styleSelect: higgsStyleSelect,
|
|
addStyleBtn: addHiggsStyleBtn,
|
|
prosodySelect: higgsProsodySelect,
|
|
addProsodyBtn: addHiggsProsodyBtn,
|
|
sfxSelect: higgsSfxSelect,
|
|
addSfxBtn: addHiggsSfxBtn,
|
|
},
|
|
cosy: {
|
|
singleTagSelect: cosySingleTagSelect,
|
|
addSingleTagBtn,
|
|
wrapperTagSelect: cosyWrapperTagSelect,
|
|
addWrapperTagBtn,
|
|
},
|
|
omnivoice: {
|
|
tagSelect: omnivoiceTagSelect,
|
|
addTagBtn: addOmniVoiceTagBtn,
|
|
},
|
|
indexTTS: {
|
|
vectorModeSelect: indexTTSVectorModeSelect,
|
|
addVectorBtn: addIndexTTSVectorBtn,
|
|
namedEmotionSelect: indexTTSNamedEmotionSelect,
|
|
namedOperationSelect: indexTTSNamedOperationSelect,
|
|
namedValueInput: indexTTSNamedValueInput,
|
|
addNamedEmotionBtn: addIndexTTSNamedEmotionBtn,
|
|
presetSelect: indexTTSPresetSelect,
|
|
addTextPresetBtn: addIndexTTSTextPresetBtn,
|
|
emotionTextInput: indexTTSEmotionTextInput,
|
|
addEmotionTextBtn: addIndexTTSEmotionTextBtn,
|
|
},
|
|
} = inlineTagControls;
|
|
|
|
// Block ComfyUI shortcuts when editor is focused, but allow Enter, Alt, and Ctrl combinations
|
|
editor.addEventListener("keydown", (e) => {
|
|
// Don't block Enter, Alt, or Ctrl key combinations (allow copy/paste/cut)
|
|
if (e.key !== "Enter" && !e.altKey && !e.ctrlKey && !e.metaKey) {
|
|
e.stopPropagation();
|
|
e.stopImmediatePropagation();
|
|
}
|
|
}, true); // Use capture phase to intercept before other handlers
|
|
|
|
inlineEngineSelect?.addEventListener("change", () => {
|
|
const plainText = getPlainText();
|
|
setEditorText(plainText);
|
|
});
|
|
|
|
// Manually handle Enter key to insert newline
|
|
editor.addEventListener("keydown", (e) => {
|
|
if (e.key === "Enter" && document.activeElement === editor) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
const plainText = getPlainText();
|
|
const selection = getSelectionRange();
|
|
const start = selection?.start ?? getCaretPos();
|
|
const end = selection?.end ?? start;
|
|
setEditorText(plainText.slice(0, start) + "\n" + plainText.slice(end));
|
|
setCaretPos(start + 1);
|
|
editor.dispatchEvent(new InputEvent("input", {
|
|
bubbles: true,
|
|
inputType: "insertLineBreak",
|
|
data: null,
|
|
}));
|
|
}
|
|
});
|
|
|
|
// Editor input - add to history with smart debouncing
|
|
let historyDebounceTimer = null;
|
|
let lastHistoryText = state.history[state.historyIndex]?.text ?? getPlainText();
|
|
|
|
const flushHistory = () => {
|
|
const plainText = getPlainText();
|
|
const currentHistoryText = state.history[state.historyIndex]?.text;
|
|
|
|
if (plainText !== lastHistoryText && plainText !== currentHistoryText) {
|
|
const caretPos = getCaretPos();
|
|
state.addToHistory(plainText, caretPos);
|
|
}
|
|
|
|
lastHistoryText = plainText;
|
|
};
|
|
|
|
const flushPendingHistory = () => {
|
|
if (historyDebounceTimer !== null) {
|
|
clearTimeout(historyDebounceTimer);
|
|
historyDebounceTimer = null;
|
|
}
|
|
|
|
flushHistory();
|
|
historyStatus.textContent = state.getHistoryStatus();
|
|
};
|
|
|
|
const getClampedCaretPos = (targetText, preferredPos, fallbackPos = 0) => {
|
|
const basePos = preferredPos ?? fallbackPos ?? 0;
|
|
return Math.max(0, Math.min(basePos, targetText.length));
|
|
};
|
|
|
|
const mapCaretPosBetweenTexts = (fromText, toText, caretPos, fallbackPos = 0) => {
|
|
const safeCaretPos = getClampedCaretPos(fromText, caretPos, fallbackPos);
|
|
|
|
if (fromText === toText) {
|
|
return getClampedCaretPos(toText, safeCaretPos, fallbackPos);
|
|
}
|
|
|
|
const maxPrefix = Math.min(fromText.length, toText.length);
|
|
let prefixLength = 0;
|
|
while (prefixLength < maxPrefix && fromText[prefixLength] === toText[prefixLength]) {
|
|
prefixLength++;
|
|
}
|
|
|
|
let fromSuffixIndex = fromText.length;
|
|
let toSuffixIndex = toText.length;
|
|
while (
|
|
fromSuffixIndex > prefixLength &&
|
|
toSuffixIndex > prefixLength &&
|
|
fromText[fromSuffixIndex - 1] === toText[toSuffixIndex - 1]
|
|
) {
|
|
fromSuffixIndex--;
|
|
toSuffixIndex--;
|
|
}
|
|
|
|
const suffixLength = fromText.length - fromSuffixIndex;
|
|
const fromChangeEnd = fromText.length - suffixLength;
|
|
const toChangeEnd = toText.length - suffixLength;
|
|
|
|
if (safeCaretPos <= prefixLength) {
|
|
return safeCaretPos;
|
|
}
|
|
|
|
if (safeCaretPos >= fromChangeEnd) {
|
|
const suffixOffset = safeCaretPos - fromChangeEnd;
|
|
return getClampedCaretPos(toText, toChangeEnd + suffixOffset, fallbackPos);
|
|
}
|
|
|
|
const changedRegionOffset = safeCaretPos - prefixLength;
|
|
return getClampedCaretPos(toText, prefixLength + changedRegionOffset, fallbackPos);
|
|
};
|
|
|
|
const restoreEditorHistoryEntry = (entry, preferredCaretPos = null) => {
|
|
const restoredCaretPos = getClampedCaretPos(entry.text, preferredCaretPos, entry.caretPos);
|
|
setEditorText(entry.text);
|
|
lastHistoryText = entry.text;
|
|
setTimeout(() => {
|
|
editor.focus();
|
|
setCaretPos(restoredCaretPos);
|
|
}, 0);
|
|
state.saveToLocalStorage(storageKey);
|
|
widget.callback?.(widget.value);
|
|
historyStatus.textContent = state.getHistoryStatus();
|
|
};
|
|
|
|
const commitEditorTextChange = (newText, newCaretPos, { focusEditor = true } = {}) => {
|
|
setEditorText(newText);
|
|
state.text = newText;
|
|
state.addToHistory(newText, newCaretPos);
|
|
state.saveToLocalStorage(storageKey);
|
|
lastHistoryText = newText;
|
|
widget.value = newText;
|
|
widget.callback?.(newText);
|
|
historyStatus.textContent = state.getHistoryStatus();
|
|
|
|
setTimeout(() => {
|
|
if (focusEditor) {
|
|
editor.focus();
|
|
}
|
|
setCaretPos(newCaretPos);
|
|
}, 0);
|
|
};
|
|
|
|
editor.addEventListener("input", (e) => {
|
|
const plainText = getPlainText();
|
|
// Update state.text immediately so it's current when saved to localStorage
|
|
state.text = plainText;
|
|
state.saveToLocalStorage(storageKey);
|
|
widget.callback?.(widget.value);
|
|
|
|
if (historyDebounceTimer !== null) {
|
|
clearTimeout(historyDebounceTimer);
|
|
}
|
|
|
|
historyDebounceTimer = setTimeout(() => {
|
|
flushHistory();
|
|
historyStatus.textContent = state.getHistoryStatus();
|
|
historyDebounceTimer = null;
|
|
}, 500);
|
|
});
|
|
|
|
editor.addEventListener("copy", (e) => {
|
|
// Allow copy to work, but prevent ComfyUI from receiving the event
|
|
// Critical for ComfyUI v0.3.75+ which intercepts clipboard events
|
|
e.stopPropagation();
|
|
|
|
const selection = getSelectionRange();
|
|
if (!selection?.text || !e.clipboardData) {
|
|
return;
|
|
}
|
|
|
|
e.preventDefault();
|
|
e.clipboardData.setData("text/plain", selection.text);
|
|
});
|
|
|
|
editor.addEventListener("cut", (e) => {
|
|
// Allow cut to work, but prevent ComfyUI from receiving the event
|
|
e.stopPropagation();
|
|
|
|
const selection = getSelectionRange();
|
|
if (!selection?.text || !e.clipboardData) {
|
|
setTimeout(() => {
|
|
flushHistory();
|
|
historyStatus.textContent = state.getHistoryStatus();
|
|
}, 0);
|
|
return;
|
|
}
|
|
|
|
e.preventDefault();
|
|
e.clipboardData.setData("text/plain", selection.text);
|
|
|
|
const plainText = getPlainText();
|
|
const newText = plainText.substring(0, selection.start) + plainText.substring(selection.end);
|
|
commitEditorTextChange(newText, selection.start);
|
|
});
|
|
|
|
editor.addEventListener("paste", (e) => {
|
|
// Stop propagation AFTER paste completes to prevent ComfyUI from pasting nodes
|
|
e.stopPropagation();
|
|
|
|
if (!e.clipboardData) {
|
|
return;
|
|
}
|
|
|
|
const pastedText = e.clipboardData.getData("text/plain");
|
|
if (typeof pastedText !== "string") {
|
|
return;
|
|
}
|
|
|
|
e.preventDefault();
|
|
|
|
const selection = getSelectionRange();
|
|
const plainText = getPlainText();
|
|
const insertStart = selection ? selection.start : getCaretPos();
|
|
const insertEnd = selection ? selection.end : insertStart;
|
|
const normalizedText = pastedText.replace(/\r\n?/g, "\n");
|
|
const newText = plainText.substring(0, insertStart) + normalizedText + plainText.substring(insertEnd);
|
|
const newCaretPos = insertStart + normalizedText.length;
|
|
|
|
commitEditorTextChange(newText, newCaretPos);
|
|
});
|
|
|
|
const rememberCaretPosition = () => {
|
|
const caretPos = getCaretPos();
|
|
state.lastCursorPosition = caretPos;
|
|
|
|
if (state.historyIndex >= 0 && state.history[state.historyIndex]) {
|
|
state.history[state.historyIndex].caretPos = caretPos;
|
|
}
|
|
};
|
|
|
|
editor.addEventListener("mouseup", rememberCaretPosition);
|
|
editor.addEventListener("keyup", rememberCaretPosition);
|
|
editor.addEventListener("focus", rememberCaretPosition);
|
|
|
|
const applyHistoryStep = (direction) => {
|
|
flushPendingHistory();
|
|
const currentText = getPlainText();
|
|
const currentCaretPos = getCaretPos();
|
|
const targetEntry = direction === "redo" ? state.redo() : state.undo();
|
|
const mappedCaretPos = mapCaretPosBetweenTexts(currentText, targetEntry.text, currentCaretPos, targetEntry.caretPos);
|
|
restoreEditorHistoryEntry(targetEntry, mappedCaretPos);
|
|
};
|
|
|
|
// Undo/Redo buttons
|
|
undoBtn.addEventListener("click", () => {
|
|
applyHistoryStep("undo");
|
|
});
|
|
|
|
redoBtn.addEventListener("click", () => {
|
|
applyHistoryStep("redo");
|
|
});
|
|
|
|
// Keyboard shortcuts for undo/redo and tag/preset insertion
|
|
editor.addEventListener("keydown", (e) => {
|
|
if (document.activeElement !== editor) return;
|
|
|
|
if (e.altKey && !e.ctrlKey && !e.metaKey && !e.shiftKey) {
|
|
// Alt+L: Add Language Tag
|
|
if (e.key === "l" || e.key === "L") {
|
|
e.preventDefault();
|
|
addLangBtn.click();
|
|
}
|
|
// Alt+C: Add Character
|
|
else if (e.key === "c" || e.key === "C") {
|
|
e.preventDefault();
|
|
addCharBtn.click();
|
|
}
|
|
// Alt+P: Add Parameter
|
|
else if (e.key === "p" || e.key === "P") {
|
|
e.preventDefault();
|
|
addParamBtn.click();
|
|
}
|
|
// Alt+1/2/3: Load Preset
|
|
else if (e.key === "1") {
|
|
e.preventDefault();
|
|
presetButtons.preset_1?.load?.click?.();
|
|
}
|
|
else if (e.key === "2") {
|
|
e.preventDefault();
|
|
presetButtons.preset_2?.load?.click?.();
|
|
}
|
|
else if (e.key === "3") {
|
|
e.preventDefault();
|
|
presetButtons.preset_3?.load?.click?.();
|
|
}
|
|
}
|
|
const isPrimaryModifier = (e.ctrlKey || e.metaKey) && !e.altKey;
|
|
if (isPrimaryModifier) {
|
|
if (e.key === "f" || e.key === "F") {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
e.stopImmediatePropagation();
|
|
openFindReplace("find");
|
|
return;
|
|
}
|
|
|
|
if (e.key === "h" || e.key === "H") {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
e.stopImmediatePropagation();
|
|
openFindReplace("replace");
|
|
return;
|
|
}
|
|
|
|
const isUndo = (e.key === "z" || e.key === "Z") && !e.shiftKey;
|
|
const isRedo = (e.key === "y" || e.key === "Y") || ((e.key === "z" || e.key === "Z") && e.shiftKey);
|
|
|
|
if (isUndo || isRedo) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
e.stopImmediatePropagation();
|
|
applyHistoryStep(isRedo ? "redo" : "undo");
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Alt+Z: Undo, Alt+Shift+Z: Redo (also allow with shift)
|
|
if (e.altKey && !e.ctrlKey && !e.metaKey && (e.key === "z" || e.key === "Z")) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
e.stopImmediatePropagation();
|
|
applyHistoryStep(e.shiftKey ? "redo" : "undo");
|
|
return;
|
|
}
|
|
|
|
if (e.key === "F3") {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
e.stopImmediatePropagation();
|
|
if (e.shiftKey) {
|
|
focusPreviousFindMatch();
|
|
} else {
|
|
focusNextFindMatch();
|
|
}
|
|
}
|
|
});
|
|
|
|
// Ctrl+scroll to change font size
|
|
editor.addEventListener("wheel", (e) => {
|
|
if ((e.ctrlKey || e.metaKey)) {
|
|
e.preventDefault();
|
|
const delta = e.deltaY > 0 ? -1 : 1;
|
|
let newSize = state.fontSize + delta;
|
|
newSize = Math.max(2, Math.min(120, newSize));
|
|
setFontSize(newSize);
|
|
fontSizeInput.value = newSize;
|
|
if (fontSizeDisplay) {
|
|
fontSizeDisplay.textContent = newSize + "px";
|
|
}
|
|
}
|
|
});
|
|
|
|
// Font family selector change
|
|
fontFamilySelect.addEventListener("change", () => {
|
|
setFontFamily(fontFamilySelect.value);
|
|
});
|
|
|
|
// Font size input change
|
|
fontSizeInput.addEventListener("change", () => {
|
|
let newSize = parseInt(fontSizeInput.value) || 14;
|
|
newSize = Math.max(2, Math.min(120, newSize));
|
|
setFontSize(newSize);
|
|
fontSizeInput.value = newSize;
|
|
});
|
|
|
|
// Font size input live preview
|
|
fontSizeInput.addEventListener("input", () => {
|
|
let newSize = parseInt(fontSizeInput.value) || state.fontSize;
|
|
newSize = Math.max(2, Math.min(120, newSize));
|
|
editor.style.fontSize = newSize + "px";
|
|
if (fontSizeDisplay) {
|
|
fontSizeDisplay.textContent = newSize + "px";
|
|
}
|
|
});
|
|
|
|
// Character select dropdown
|
|
charSelect.addEventListener("change", () => {
|
|
if (charSelect.value) {
|
|
charInput.value = charSelect.value;
|
|
state.lastCharacter = charSelect.value;
|
|
state.saveToLocalStorage(storageKey);
|
|
}
|
|
});
|
|
|
|
// Helper to get selected text and its position
|
|
const getSelection = () => {
|
|
const selection = getSelectionRange();
|
|
if (!selection?.text) {
|
|
return null;
|
|
}
|
|
|
|
return selection;
|
|
};
|
|
|
|
const replaceCharacterComponentInColonTag = (text, caretPos, character) => {
|
|
const tagStart = text.lastIndexOf("[", caretPos);
|
|
const tagEnd = text.indexOf("]", caretPos);
|
|
if (tagStart < 0 || tagEnd < caretPos) return null;
|
|
|
|
const content = text.slice(tagStart + 1, tagEnd);
|
|
const colonIndex = content.indexOf(":");
|
|
if (colonIndex <= 0) return null;
|
|
|
|
const first = content.slice(0, colonIndex).trim();
|
|
const secondEndRelative = content.indexOf("|", colonIndex);
|
|
const secondEnd = secondEndRelative >= 0 ? secondEndRelative : content.length;
|
|
const second = content.slice(colonIndex + 1, secondEnd).trim();
|
|
const parameterKey = /^(seed|temp|temperature|cfg|cfg_weight|emotion_alpha|vector|emotion|speed|top_p|top_k|steps|instruction|quality|sound_event|ambient_sound)$/i;
|
|
if (parameterKey.test(first) || /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)$/.test(second)) return null;
|
|
|
|
const caretRelative = caretPos - (tagStart + 1);
|
|
const replacingFirst = caretRelative <= colonIndex;
|
|
const componentStart = replacingFirst ? 0 : colonIndex + 1;
|
|
const componentEnd = replacingFirst ? colonIndex : secondEnd;
|
|
const newContent = content.slice(0, componentStart) + character + content.slice(componentEnd);
|
|
return {
|
|
newText: text.slice(0, tagStart + 1) + newContent + text.slice(tagEnd),
|
|
newCaretPos: tagStart + 1 + componentStart + character.length,
|
|
};
|
|
};
|
|
|
|
// Add character button
|
|
addCharBtn.addEventListener("click", () => {
|
|
const char = charInput.value.trim() || charSelect.value;
|
|
if (!char) return;
|
|
|
|
state.lastCharacter = char;
|
|
const text = getPlainText();
|
|
|
|
// Check if text is selected
|
|
const selection = getSelection();
|
|
let caretPos;
|
|
if (selection && selection.text.match(/^\s*\[/)) {
|
|
// Selected text starts with a tag - find position right after the opening bracket
|
|
const leadingWhitespace = selection.text.match(/^\s*/)[0].length;
|
|
caretPos = selection.start + leadingWhitespace + 1; // position after [
|
|
} else {
|
|
caretPos = selection ? selection.start : getCaretPos();
|
|
}
|
|
|
|
const colonComponentResult = replaceCharacterComponentInColonTag(text, caretPos, char);
|
|
if (colonComponentResult) {
|
|
setEditorText(colonComponentResult.newText);
|
|
setTimeout(() => {
|
|
setCaretPos(colonComponentResult.newCaretPos);
|
|
state.addToHistory(colonComponentResult.newText, colonComponentResult.newCaretPos);
|
|
state.saveToLocalStorage(storageKey);
|
|
editor.focus();
|
|
}, 0);
|
|
widget.callback?.(widget.value);
|
|
historyStatus.textContent = state.getHistoryStatus();
|
|
return;
|
|
}
|
|
|
|
const result = TagUtilities.modifyTagContent(text, caretPos, (tagContent) => {
|
|
const parts = tagContent.split("|");
|
|
const firstPart = parts[0];
|
|
|
|
// Check if first part has a colon
|
|
if (firstPart.includes(":")) {
|
|
const colonIndex = firstPart.indexOf(":");
|
|
const beforeColon = firstPart.substring(0, colonIndex);
|
|
|
|
// Check if it's a language code (not a parameter)
|
|
if (isLanguageCode(beforeColon)) {
|
|
// It's a language:character format, replace the character part
|
|
const langCode = beforeColon;
|
|
parts[0] = `${langCode}:${char}`;
|
|
} else {
|
|
// It's a parameter (like seed:5), prepend character
|
|
parts.unshift(char);
|
|
}
|
|
} else {
|
|
// No colon, just a character name - replace it
|
|
parts[0] = char;
|
|
}
|
|
return parts.join("|");
|
|
});
|
|
|
|
if (result) {
|
|
setEditorText(result.newText);
|
|
setTimeout(() => {
|
|
setCaretPos(result.newCaretPos);
|
|
state.addToHistory(result.newText, result.newCaretPos);
|
|
state.saveToLocalStorage(storageKey);
|
|
}, 0);
|
|
} else {
|
|
// No existing tag found - create new one
|
|
const charTag = `[${char}]`;
|
|
let newText, newCaretPos;
|
|
|
|
if (selection) {
|
|
// Selected text: insert tag at beginning of selection
|
|
newText = text.substring(0, selection.start) + charTag + " " + text.substring(selection.start);
|
|
newCaretPos = selection.start + charTag.length + 1;
|
|
} else {
|
|
// No selection: insert at caret position
|
|
newText = text.substring(0, caretPos) + charTag + " " + text.substring(caretPos);
|
|
newCaretPos = caretPos + charTag.length + 1;
|
|
}
|
|
|
|
setEditorText(newText);
|
|
setTimeout(() => {
|
|
setCaretPos(newCaretPos);
|
|
state.addToHistory(newText, newCaretPos);
|
|
state.saveToLocalStorage(storageKey);
|
|
editor.focus();
|
|
}, 0);
|
|
}
|
|
widget.callback?.(widget.value);
|
|
historyStatus.textContent = state.getHistoryStatus();
|
|
});
|
|
|
|
// Add language tag button
|
|
addLangBtn.addEventListener("click", () => {
|
|
const lang = langSelect.value;
|
|
if (!lang) return;
|
|
|
|
state.lastLanguage = lang;
|
|
const text = getPlainText();
|
|
|
|
// Check if text is selected
|
|
const selection = getSelection();
|
|
let caretPos;
|
|
if (selection && selection.text.match(/^\s*\[/)) {
|
|
// Selected text starts with a tag - find position right after the opening bracket
|
|
const leadingWhitespace = selection.text.match(/^\s*/)[0].length;
|
|
caretPos = selection.start + leadingWhitespace + 1; // position after [
|
|
} else {
|
|
caretPos = selection ? selection.start : getCaretPos();
|
|
}
|
|
|
|
// Try to modify existing tag
|
|
const result = TagUtilities.modifyTagContent(text, caretPos, (tagContent) => {
|
|
// Check if already has language code (contains colon in first part before pipe)
|
|
const pipeIndex = tagContent.indexOf("|");
|
|
const firstPart = pipeIndex === -1 ? tagContent : tagContent.substring(0, pipeIndex);
|
|
|
|
if (firstPart.includes(":")) {
|
|
const colonIndex = firstPart.indexOf(":");
|
|
const beforeColon = firstPart.substring(0, colonIndex);
|
|
|
|
// Check if it's a language code using the supported languages list
|
|
if (isLanguageCode(beforeColon)) {
|
|
// It's already a language tag - only update if language is different
|
|
if (beforeColon === lang) {
|
|
// Same language already exists, return unchanged
|
|
return tagContent;
|
|
} else {
|
|
// Replace the language part with new language
|
|
const charPart = firstPart.substring(colonIndex + 1);
|
|
const rest = pipeIndex === -1 ? "" : tagContent.substring(pipeIndex);
|
|
return `${lang}:${charPart}${rest}`;
|
|
}
|
|
} else {
|
|
// It's a parameter tag (like seed:5), insert language with pipe separator: [de:|seed:5|steps:3]
|
|
return `${lang}:|${tagContent}`;
|
|
}
|
|
} else {
|
|
// No colon at all, just a character name - prepend language
|
|
return `${lang}:${tagContent}`;
|
|
}
|
|
});
|
|
|
|
if (result) {
|
|
// Modified existing tag
|
|
setEditorText(result.newText);
|
|
setTimeout(() => {
|
|
setCaretPos(result.newCaretPos);
|
|
state.addToHistory(result.newText, result.newCaretPos);
|
|
state.saveToLocalStorage(storageKey);
|
|
}, 0);
|
|
} else {
|
|
// Create new language tag
|
|
const langTag = `[${lang}:]`;
|
|
let newText, newCaretPos;
|
|
|
|
if (selection) {
|
|
// Selected text: insert tag at beginning of selection
|
|
newText = text.substring(0, selection.start) + langTag + " " + text.substring(selection.start);
|
|
newCaretPos = selection.start + langTag.length + 1;
|
|
} else {
|
|
// No selection: insert at caret position
|
|
newText = text.substring(0, caretPos) + langTag + " " + text.substring(caretPos);
|
|
newCaretPos = caretPos + langTag.length + 1;
|
|
}
|
|
|
|
setEditorText(newText);
|
|
setTimeout(() => {
|
|
setCaretPos(newCaretPos);
|
|
state.addToHistory(newText, newCaretPos);
|
|
state.saveToLocalStorage(storageKey);
|
|
editor.focus();
|
|
}, 0);
|
|
}
|
|
widget.callback?.(widget.value);
|
|
historyStatus.textContent = state.getHistoryStatus();
|
|
});
|
|
|
|
// Format button
|
|
formatBtn.addEventListener("click", () => {
|
|
let text = getPlainText();
|
|
// Pause/wait/stop are timeline tags, not character parameters.
|
|
text = TagUtilities.normalizeStandalonePauseTags(text);
|
|
// Normalize spacing around brackets: multiple spaces/tabs become single space
|
|
text = text.replace(/[ \t]+\[/g, " [").replace(/\[[ \t]+/g, "[");
|
|
// Remove spaces/tabs before ], but keep newlines
|
|
text = text.replace(/[ \t]+\]/g, "]").replace(/\][ \t]+/g, "]");
|
|
// Add space before tags only if not after newline or space
|
|
text = text.replace(/([^\n\s\[])\[/g, "$1 [");
|
|
// Add space after closing bracket if followed by non-space/non-newline
|
|
text = text.replace(/\]([^\s\n])/g, "] $1");
|
|
// Trim end of each line but preserve newlines
|
|
text = text.split("\n").map(line => line.trimEnd()).join("\n");
|
|
|
|
setEditorText(text);
|
|
setTimeout(() => {
|
|
state.addToHistory(text, 0);
|
|
state.saveToLocalStorage(storageKey);
|
|
setCaretPos(0);
|
|
}, 0);
|
|
widget.callback?.(widget.value);
|
|
historyStatus.textContent = state.getHistoryStatus();
|
|
});
|
|
|
|
// Validate button
|
|
validateBtn.addEventListener("click", () => {
|
|
const text = getPlainText();
|
|
const bracketValidation = TagUtilities.validateTagSyntax(text);
|
|
if (!bracketValidation.valid) {
|
|
showNotification("❌ " + bracketValidation.error);
|
|
return;
|
|
}
|
|
|
|
const inlineSyntaxValidation = TagUtilities.validateInlineSyntax(text);
|
|
if (!inlineSyntaxValidation.valid) {
|
|
showNotification("❌ " + inlineSyntaxValidation.error, 3000);
|
|
return;
|
|
}
|
|
|
|
const targetEngine = inlineEngineSelect?.value || state.activeInlineTagEngine || "step_audio_editx";
|
|
const inlineValidation = TagUtilities.validateInlineTags(text, targetEngine);
|
|
|
|
if (inlineValidation.unknownTags.length > 0) {
|
|
showNotification(`❌ Found ${inlineValidation.unknownTags.length} unrecognized inline tag(s)`, 3000);
|
|
return;
|
|
}
|
|
|
|
if (inlineValidation.foreignTags.length > 0) {
|
|
const engineLabel = inlineEngineSelect?.selectedOptions?.[0]?.textContent || targetEngine;
|
|
const convertibleCount = inlineValidation.convertibleTags.length;
|
|
const skippedCount = inlineValidation.foreignTags.length - convertibleCount;
|
|
|
|
const baseMessage = `Found ${inlineValidation.foreignTags.length} inline tag(s) that do not belong to ${engineLabel}.`;
|
|
|
|
if (convertibleCount > 0) {
|
|
let message = baseMessage;
|
|
message += `\n\nConvert ${convertibleCount} safe tag(s) now?`;
|
|
if (skippedCount > 0) {
|
|
message += `\n${skippedCount} tag(s) have no safe equivalent and will stay unchanged.`;
|
|
}
|
|
if (window.confirm(message)) {
|
|
const conversion = TagUtilities.convertInlineTagsForEngine(text, targetEngine);
|
|
if (conversion.converted > 0 && conversion.text !== text) {
|
|
commitEditorTextChange(conversion.text, getCaretPos());
|
|
showNotification(`✓ Converted ${conversion.converted} inline tag(s)${conversion.skipped ? `, skipped ${conversion.skipped}` : ""}`, 3000);
|
|
return;
|
|
}
|
|
}
|
|
|
|
showNotification(`⚠️ Found ${inlineValidation.foreignTags.length} foreign inline tag(s) for ${engineLabel}. ${convertibleCount} can be auto-converted, ${skippedCount} cannot.`, 4000);
|
|
return;
|
|
}
|
|
|
|
showNotification(`⚠️ Found ${inlineValidation.foreignTags.length} foreign inline tag(s) for ${engineLabel}; no safe auto-conversion is defined yet`, 4000);
|
|
return;
|
|
}
|
|
|
|
showNotification("✅ Tags match the selected engine!", 2000);
|
|
});
|
|
|
|
// Preset buttons
|
|
Object.entries(presetButtons).forEach(([presetKey, buttons]) => {
|
|
const presetNum = presetKey.split("_")[1];
|
|
|
|
buttons.save.addEventListener("click", () => {
|
|
// First check if user selected text in editor (like [de:Alice|seed:42|temp:0.8])
|
|
const selection = getSelectionRange();
|
|
let selectedText = "";
|
|
|
|
if (selection?.text?.length > 0) {
|
|
selectedText = selection.text;
|
|
// Store the selected text as the preset
|
|
state.presets[presetKey] = {
|
|
tag: selectedText,
|
|
isComplexTag: true
|
|
};
|
|
state.saveToLocalStorage(storageKey);
|
|
showNotification(`✅ Preset ${presetNum} saved from selection`);
|
|
updatePresetGlows();
|
|
return;
|
|
}
|
|
|
|
// Otherwise save character + language preset
|
|
const currentTag = charInput.value.trim() || charSelect.value;
|
|
if (currentTag) {
|
|
state.presets[presetKey] = {
|
|
tag: currentTag,
|
|
parameters: {
|
|
language: langSelect.value,
|
|
lastSeed: state.lastSeed,
|
|
lastTemperature: state.lastTemperature
|
|
}
|
|
};
|
|
state.saveToLocalStorage(storageKey);
|
|
showNotification(`✅ Preset ${presetNum} saved: ${currentTag}`);
|
|
updatePresetGlows();
|
|
} else {
|
|
showNotification("⚠️ Select text or enter character", 2500);
|
|
}
|
|
});
|
|
|
|
buttons.load.addEventListener("click", () => {
|
|
if (presetKey in state.presets) {
|
|
const preset = state.presets[presetKey];
|
|
const currentCaretPos = getCaretPos(); // Get current caret position when button is clicked
|
|
const text = getPlainText();
|
|
|
|
// Always insert the preset tag at the current caret position
|
|
const newText = text.substring(0, currentCaretPos) + preset.tag + " " + text.substring(currentCaretPos);
|
|
setEditorText(newText);
|
|
|
|
// Move caret to after the inserted text
|
|
const newCaretPos = currentCaretPos + preset.tag.length + 1; // +1 for the space after tag
|
|
setTimeout(() => {
|
|
setCaretPos(newCaretPos);
|
|
state.addToHistory(newText, newCaretPos);
|
|
state.saveToLocalStorage(storageKey);
|
|
editor.focus();
|
|
}, 0);
|
|
widget.callback?.(widget.value);
|
|
historyStatus.textContent = state.getHistoryStatus();
|
|
|
|
// If it's a simple character preset, also update the sidebar for convenience
|
|
if (!preset.isComplexTag && preset.tag) {
|
|
charInput.value = preset.tag;
|
|
state.lastCharacter = preset.tag;
|
|
|
|
if (preset.parameters) {
|
|
if (preset.parameters.language) langSelect.value = preset.parameters.language;
|
|
if (preset.parameters.lastSeed) state.lastSeed = preset.parameters.lastSeed;
|
|
if (preset.parameters.lastTemperature) state.lastTemperature = preset.parameters.lastTemperature;
|
|
}
|
|
}
|
|
|
|
showNotification(`✅ Preset ${presetNum} inserted at cursor`);
|
|
} else {
|
|
showNotification("⚠️ Preset is empty", 2000);
|
|
}
|
|
});
|
|
|
|
buttons.del.addEventListener("click", () => {
|
|
if (presetKey in state.presets) {
|
|
delete state.presets[presetKey];
|
|
state.saveToLocalStorage(storageKey);
|
|
showNotification(`✅ Preset ${presetNum} deleted`);
|
|
updatePresetGlows();
|
|
} else {
|
|
showNotification("⚠️ Preset already empty", 2000);
|
|
}
|
|
});
|
|
});
|
|
|
|
// Divider resize
|
|
let isResizing = false;
|
|
let lastClickTime = 0;
|
|
let lastClickX = 0;
|
|
let initialMouseX = 0;
|
|
let initialSidebarWidth = 0;
|
|
|
|
resizeDivider.addEventListener("mousedown", (e) => {
|
|
const currentTime = Date.now();
|
|
if (currentTime - lastClickTime < 300 && Math.abs(e.clientX - lastClickX) < 5) {
|
|
setSidebarResizeActive(false);
|
|
setSidebarWidth(220);
|
|
setUIScale(1.0);
|
|
setFontSize(14);
|
|
showNotification("🔄 Reset: Sidebar width, UI scale, and font size to defaults");
|
|
lastClickTime = 0;
|
|
return;
|
|
}
|
|
lastClickTime = currentTime;
|
|
lastClickX = e.clientX;
|
|
|
|
initialMouseX = e.clientX;
|
|
initialSidebarWidth = state.sidebarWidth;
|
|
isResizing = true;
|
|
setSidebarResizeActive(true);
|
|
e.preventDefault();
|
|
});
|
|
|
|
document.addEventListener("mousemove", (e) => {
|
|
if (!isResizing) return;
|
|
const delta = e.clientX - initialMouseX;
|
|
const newWidth = initialSidebarWidth + delta;
|
|
setSidebarWidth(newWidth);
|
|
});
|
|
|
|
document.addEventListener("mouseup", () => {
|
|
if (isResizing) {
|
|
setSidebarResizeActive(false);
|
|
}
|
|
isResizing = false;
|
|
});
|
|
|
|
// Ctrl+wheel on sidebar to change UI scale
|
|
sidebar.addEventListener("wheel", (e) => {
|
|
if ((e.ctrlKey || e.metaKey)) {
|
|
e.preventDefault();
|
|
const delta = e.deltaY > 0 ? -0.1 : 0.1;
|
|
const newScale = state.uiScale + delta;
|
|
setUIScale(newScale);
|
|
}
|
|
});
|
|
|
|
// Pointer events for canvas interaction
|
|
editor.addEventListener("pointermove", (e) => {
|
|
if ((e.buttons & 4) === 4) {
|
|
window.app.canvas.processMouseMove(e);
|
|
}
|
|
});
|
|
|
|
editor.addEventListener("pointerup", (e) => {
|
|
if (e.button === 1) {
|
|
window.app.canvas.processMouseUp(e);
|
|
}
|
|
});
|
|
|
|
// ==================== INLINE EDIT TAG INSERTION ====================
|
|
|
|
// Helper to modify inline edit tag content (similar to TagUtilities.modifyTagContent but for <...>)
|
|
const modifyInlineEditTag = (text, caretPos, newTagPart) => {
|
|
// Check if caret is right after an inline edit tag >
|
|
let isRightAfterTag = caretPos > 0 && text[caretPos - 1] === ">";
|
|
let spaceAfterTag = false;
|
|
if (!isRightAfterTag && caretPos > 1 && text[caretPos - 1] === " " && text[caretPos - 2] === ">") {
|
|
isRightAfterTag = true;
|
|
spaceAfterTag = true;
|
|
}
|
|
|
|
// Check if caret is INSIDE an inline edit tag (between < and >)
|
|
let isInsideTag = false;
|
|
let tagStart = -1;
|
|
let tagEnd = -1;
|
|
|
|
if (!isRightAfterTag) {
|
|
// Look backwards for < without hitting >
|
|
let bracketDepth = 0;
|
|
for (let i = caretPos - 1; i >= 0; i--) {
|
|
if (text[i] === ">") {
|
|
bracketDepth++;
|
|
} else if (text[i] === "<") {
|
|
if (bracketDepth === 0) {
|
|
tagStart = i;
|
|
// Find matching >
|
|
for (let j = i + 1; j < text.length; j++) {
|
|
if (text[j] === ">") {
|
|
tagEnd = j;
|
|
if (tagEnd >= caretPos) {
|
|
isInsideTag = true;
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
break;
|
|
} else {
|
|
bracketDepth--;
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
// Right after tag - find the tag we're right after
|
|
let searchPos = spaceAfterTag ? caretPos - 2 : caretPos - 1;
|
|
let bracketDepth = 0;
|
|
for (let i = searchPos - 1; i >= 0; i--) {
|
|
if (text[i] === ">") {
|
|
bracketDepth++;
|
|
} else if (text[i] === "<") {
|
|
if (bracketDepth === 0) {
|
|
tagStart = i;
|
|
tagEnd = searchPos;
|
|
isInsideTag = false; // We're after it, not inside
|
|
break;
|
|
} else {
|
|
bracketDepth--;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (tagStart !== -1 && tagEnd !== -1) {
|
|
// Found a tag - modify it
|
|
const tagContent = text.substring(tagStart + 1, tagEnd);
|
|
const parts = tagContent.split("|");
|
|
|
|
// Check if this tag type already exists
|
|
const tagType = newTagPart.split(":")[0];
|
|
const existingIndex = parts.findIndex(part => part.startsWith(tagType + ":") || part === tagType);
|
|
|
|
let newContent;
|
|
if (existingIndex !== -1) {
|
|
// Replace existing tag of same type
|
|
parts[existingIndex] = newTagPart;
|
|
newContent = parts.join("|");
|
|
} else {
|
|
// Add new tag with pipe separator
|
|
newContent = tagContent + "|" + newTagPart;
|
|
}
|
|
|
|
const newTag = `<${newContent}>`;
|
|
const newText = text.substring(0, tagStart) + newTag + text.substring(tagEnd + 1);
|
|
const newCaretPos = tagStart + newTag.length;
|
|
|
|
return { newText, newCaretPos, modified: true };
|
|
}
|
|
|
|
// No tag found - insert new tag
|
|
return { modified: false };
|
|
};
|
|
|
|
const insertTextSnippet = (snippet, {
|
|
caretOffset = snippet.length,
|
|
notification = `✓ Inserted: ${snippet}`,
|
|
} = {}) => {
|
|
const selection = getSelection();
|
|
const plainText = getPlainText();
|
|
const insertStart = selection ? selection.start : getCaretPos();
|
|
const insertEnd = selection ? selection.end : insertStart;
|
|
const newText = plainText.substring(0, insertStart) + snippet + plainText.substring(insertEnd);
|
|
commitEditorTextChange(newText, insertStart + caretOffset);
|
|
showNotification(notification, 1500);
|
|
};
|
|
|
|
const wrapSelectionWithTag = (tagName) => {
|
|
const selection = getSelection();
|
|
if (selection) {
|
|
const wrappedText = `<${tagName}>${selection.text}</${tagName}>`;
|
|
const plainText = getPlainText();
|
|
const newText = plainText.substring(0, selection.start) + wrappedText + plainText.substring(selection.end);
|
|
commitEditorTextChange(newText, selection.start + wrappedText.length);
|
|
showNotification(`✓ Wrapped with <${tagName}>`, 1500);
|
|
return;
|
|
}
|
|
|
|
const placeholder = "text";
|
|
const snippet = `<${tagName}>${placeholder}</${tagName}>`;
|
|
const caretOffset = tagName.length + 2;
|
|
insertTextSnippet(snippet, {
|
|
caretOffset,
|
|
notification: `✓ Inserted: ${snippet}`,
|
|
});
|
|
};
|
|
|
|
const replaceTagAroundCaret = (text, caretPos, matcher, replacement) => {
|
|
matcher.lastIndex = 0;
|
|
let match;
|
|
while ((match = matcher.exec(text)) !== null) {
|
|
const start = match.index;
|
|
const end = start + match[0].length;
|
|
if (caretPos < start || caretPos > end) {
|
|
continue;
|
|
}
|
|
|
|
return {
|
|
modified: true,
|
|
newText: text.substring(0, start) + replacement + text.substring(end),
|
|
newCaretPos: start + replacement.length,
|
|
};
|
|
}
|
|
return { modified: false };
|
|
};
|
|
|
|
const insertOrReplaceHiggsTag = (category, value) => {
|
|
const caretPos = getCaretPos();
|
|
const plainText = getPlainText();
|
|
const replacement = `<|${category}:${value}|>`;
|
|
|
|
const result = replaceTagAroundCaret(
|
|
plainText,
|
|
caretPos,
|
|
new RegExp(
|
|
`<\\|(?:emotion|style|prosody|sfx):[^|>]+\\|>|<(?:emotion|style|prosody|sfx):[^>]+>|<(?:Laughter|Breathing|Sigh|Uhm|Surprise-oh|Surprise-ah|Surprise-wa|Confirmation-en|Question-ei|Dissatisfaction-hnn)(?::\\d+)?>`,
|
|
"g"
|
|
),
|
|
replacement
|
|
);
|
|
|
|
if (result.modified) {
|
|
commitEditorTextChange(result.newText, result.newCaretPos);
|
|
showNotification("✓ Updated inline tag", 1500);
|
|
return;
|
|
}
|
|
|
|
insertTextSnippet(replacement, {
|
|
notification: `✓ Inserted: ${replacement}`,
|
|
});
|
|
};
|
|
|
|
// Helper function to insert Step Audio EditX inline tag (with pipe-separator support)
|
|
const insertStepInlineTag = (tagPart) => {
|
|
const caretPos = getCaretPos();
|
|
const plainText = getPlainText();
|
|
|
|
// Try to modify existing inline edit tag
|
|
const result = modifyInlineEditTag(plainText, caretPos, tagPart);
|
|
|
|
if (result.modified) {
|
|
// Modified existing tag
|
|
setEditorText(result.newText);
|
|
state.text = result.newText;
|
|
state.addToHistory(result.newText, result.newCaretPos);
|
|
state.saveToLocalStorage(storageKey);
|
|
widget.callback?.(widget.value);
|
|
historyStatus.textContent = state.getHistoryStatus();
|
|
|
|
setTimeout(() => {
|
|
editor.focus();
|
|
setCaretPos(result.newCaretPos);
|
|
}, 0);
|
|
showNotification(`✓ Updated inline tag`, 1500);
|
|
} else {
|
|
// Create new tag
|
|
const newTag = `<${tagPart}>`;
|
|
const before = plainText.substring(0, caretPos);
|
|
const after = plainText.substring(caretPos);
|
|
const newText = before + newTag + after;
|
|
|
|
setEditorText(newText);
|
|
state.text = newText;
|
|
state.addToHistory(newText, caretPos + newTag.length);
|
|
state.saveToLocalStorage(storageKey);
|
|
widget.callback?.(widget.value);
|
|
historyStatus.textContent = state.getHistoryStatus();
|
|
|
|
setTimeout(() => {
|
|
editor.focus();
|
|
setCaretPos(caretPos + newTag.length);
|
|
}, 0);
|
|
showNotification(`✓ Inserted: ${newTag}`, 1500);
|
|
}
|
|
};
|
|
|
|
// Paralinguistic tag insertion
|
|
addParaBtn.addEventListener("click", () => {
|
|
const type = paraSelect.value;
|
|
if (!type) {
|
|
showNotification("⚠️ Select a paralinguistic type first", 2000);
|
|
return;
|
|
}
|
|
|
|
const iterations = paraIterSlider.value;
|
|
const tagPart = iterations === "1" ? type : `${type}:${iterations}`;
|
|
insertStepInlineTag(tagPart);
|
|
});
|
|
|
|
// Emotion tag insertion
|
|
addEmotionBtn.addEventListener("click", () => {
|
|
const emotion = emotionSelect.value;
|
|
if (!emotion) {
|
|
showNotification("⚠️ Select an emotion first", 2000);
|
|
return;
|
|
}
|
|
|
|
const iterations = emotionIterSlider.value;
|
|
const tagPart = iterations === "1" ? `emotion:${emotion}` : `emotion:${emotion}:${iterations}`;
|
|
insertStepInlineTag(tagPart);
|
|
});
|
|
|
|
// Style tag insertion
|
|
addStyleBtn.addEventListener("click", () => {
|
|
const style = styleSelect.value;
|
|
if (!style) {
|
|
showNotification("⚠️ Select a style first", 2000);
|
|
return;
|
|
}
|
|
|
|
const iterations = styleIterSlider.value;
|
|
const tagPart = iterations === "1" ? `style:${style}` : `style:${style}:${iterations}`;
|
|
insertStepInlineTag(tagPart);
|
|
});
|
|
|
|
// Speed tag insertion
|
|
addSpeedBtn.addEventListener("click", () => {
|
|
const speed = speedSelect.value;
|
|
if (!speed) {
|
|
showNotification("⚠️ Select a speed first", 2000);
|
|
return;
|
|
}
|
|
|
|
const iterations = speedIterSlider.value;
|
|
const tagPart = iterations === "1" ? `speed:${speed}` : `speed:${speed}:${iterations}`;
|
|
insertStepInlineTag(tagPart);
|
|
});
|
|
|
|
// Restore tag insertion
|
|
addRestoreBtn.addEventListener("click", () => {
|
|
const passes = restorePassSlider.value;
|
|
const refIter = restoreRefInput.value.trim();
|
|
|
|
let tagPart;
|
|
if (refIter) {
|
|
// Format: restore:N@M where N=passes, M=reference iteration
|
|
tagPart = `restore:${passes}@${refIter}`;
|
|
} else if (passes === "1") {
|
|
// Simple restore
|
|
tagPart = "restore";
|
|
} else {
|
|
// Multiple passes
|
|
tagPart = `restore:${passes}`;
|
|
}
|
|
|
|
insertStepInlineTag(tagPart);
|
|
});
|
|
|
|
const bindHiggsTagInsert = (category, select, button, missingMessage) => {
|
|
button.addEventListener("click", () => {
|
|
const value = select.value;
|
|
if (!value) {
|
|
showNotification(missingMessage, 2000);
|
|
return;
|
|
}
|
|
|
|
insertOrReplaceHiggsTag(category, value);
|
|
});
|
|
};
|
|
|
|
bindHiggsTagInsert("emotion", higgsEmotionSelect, addHiggsEmotionBtn, "⚠️ Select a Higgs emotion first");
|
|
bindHiggsTagInsert("style", higgsStyleSelect, addHiggsStyleBtn, "⚠️ Select a Higgs style first");
|
|
bindHiggsTagInsert("prosody", higgsProsodySelect, addHiggsProsodyBtn, "⚠️ Select a Higgs prosody tag first");
|
|
bindHiggsTagInsert("sfx", higgsSfxSelect, addHiggsSfxBtn, "⚠️ Select a Higgs SFX tag first");
|
|
|
|
addSingleTagBtn.addEventListener("click", () => {
|
|
const tagName = cosySingleTagSelect.value;
|
|
if (!tagName) {
|
|
showNotification("⚠️ Select a CosyVoice3 tag first", 2000);
|
|
return;
|
|
}
|
|
|
|
const tag = `<${tagName}>`;
|
|
insertTextSnippet(tag);
|
|
});
|
|
|
|
addWrapperTagBtn.addEventListener("click", () => {
|
|
const tagName = cosyWrapperTagSelect.value;
|
|
if (!tagName) {
|
|
showNotification("⚠️ Select a CosyVoice3 wrapper tag first", 2000);
|
|
return;
|
|
}
|
|
|
|
wrapSelectionWithTag(tagName);
|
|
});
|
|
|
|
addOmniVoiceTagBtn.addEventListener("click", () => {
|
|
const tagName = omnivoiceTagSelect.value;
|
|
if (!tagName) {
|
|
showNotification("⚠️ Select an OmniVoice tag first", 2000);
|
|
return;
|
|
}
|
|
|
|
const tag = `<${tagName}>`;
|
|
insertTextSnippet(tag);
|
|
});
|
|
|
|
const isIndexTTSEmotionTag = (tag) => {
|
|
if (/^\[(?:vector|emotion):/i.test(tag)) return true;
|
|
const content = tag.slice(1, -1);
|
|
const emotionNames = "happy|angry|sad|afraid|disgusted|melancholic|surprised|calm";
|
|
return content.split("|").every(part => new RegExp(`^(?:${emotionNames}):[+-]?(?:\\d+(?:\\.\\d*)?|\\.\\d+)$`, "i").test(part.trim()));
|
|
};
|
|
|
|
const appendIndexTTSParameterToTag = (existingTag, replacement) => {
|
|
if (!/^\[[^\[\]]+\]$/.test(existingTag) || isIndexTTSEmotionTag(existingTag)) return null;
|
|
|
|
const parameter = replacement.slice(1, -1).trim();
|
|
const parameterKey = parameter.split(":", 1)[0].trim().toLowerCase();
|
|
if (!/^(?:vector|emotion|happy|angry|sad|afraid|disgusted|melancholic|surprised|calm)$/.test(parameterKey)) {
|
|
return null;
|
|
}
|
|
|
|
const parts = existingTag.slice(1, -1).split("|").map(part => part.trim()).filter(Boolean);
|
|
const existingIndex = parts.findIndex((part, index) => {
|
|
if (index === 0 || !part.includes(":")) return false;
|
|
return part.split(":", 1)[0].trim().toLowerCase() === parameterKey;
|
|
});
|
|
if (existingIndex >= 0) parts[existingIndex] = parameter;
|
|
else parts.push(parameter);
|
|
return `[${parts.join("|")}]`;
|
|
};
|
|
|
|
const insertOrReplaceIndexTTSTag = (replacement) => {
|
|
const text = getPlainText();
|
|
const caretPos = getCaretPos();
|
|
const start = text.lastIndexOf("[", caretPos);
|
|
const closingIndex = text.indexOf("]", caretPos);
|
|
if (start >= 0 && closingIndex >= caretPos) {
|
|
const existing = text.slice(start, closingIndex + 1);
|
|
if (isIndexTTSEmotionTag(existing)) {
|
|
const newText = text.slice(0, start) + replacement + text.slice(closingIndex + 1);
|
|
commitEditorTextChange(newText, start + replacement.length);
|
|
showNotification(`✓ Updated: ${replacement}`, 1500);
|
|
return;
|
|
}
|
|
|
|
const composed = appendIndexTTSParameterToTag(existing, replacement);
|
|
if (composed) {
|
|
const newText = text.slice(0, start) + composed + text.slice(closingIndex + 1);
|
|
commitEditorTextChange(newText, start + composed.length);
|
|
showNotification(`✓ Added emotion parameter: ${composed}`, 1500);
|
|
return;
|
|
}
|
|
}
|
|
insertTextSnippet(replacement);
|
|
};
|
|
|
|
addIndexTTSVectorBtn.addEventListener("click", () => {
|
|
const relative = indexTTSVectorModeSelect.value === "delta";
|
|
const values = Array(8).fill(relative ? "+0" : "0");
|
|
insertOrReplaceIndexTTSTag(`[vector:${values.join(",")}]`);
|
|
});
|
|
|
|
addIndexTTSNamedEmotionBtn.addEventListener("click", () => {
|
|
const emotion = indexTTSNamedEmotionSelect.value;
|
|
const magnitude = Math.max(0, Math.min(1.2, Number(indexTTSNamedValueInput.value) || 0));
|
|
const operation = indexTTSNamedOperationSelect.value;
|
|
const prefix = operation === "positive" ? "+" : operation === "negative" ? "-" : "";
|
|
insertOrReplaceIndexTTSTag(`[${emotion}:${prefix}${magnitude}]`);
|
|
});
|
|
|
|
addIndexTTSTextPresetBtn.addEventListener("click", () => {
|
|
const preset = indexTTSPresetSelect.value;
|
|
if (!preset) {
|
|
showNotification("⚠️ Select an IndexTTS text preset first", 2000);
|
|
return;
|
|
}
|
|
const selectedOption = indexTTSPresetSelect.selectedOptions[0];
|
|
if (selectedOption?.dataset.presetType === "vector") {
|
|
const values = JSON.parse(selectedOption.dataset.vectorValues || "[]");
|
|
if (values.length !== 8) {
|
|
showNotification("⚠️ This vector preset is invalid", 2000);
|
|
return;
|
|
}
|
|
insertOrReplaceIndexTTSTag(`[vector:${values.join(",")}]`);
|
|
return;
|
|
}
|
|
insertOrReplaceIndexTTSTag(`[emotion:${preset}]`);
|
|
});
|
|
|
|
addIndexTTSEmotionTextBtn.addEventListener("click", () => {
|
|
const description = indexTTSEmotionTextInput.value.trim();
|
|
if (!description) {
|
|
showNotification("⚠️ Enter an emotion description first", 2000);
|
|
return;
|
|
}
|
|
const quoted = !description.includes('"')
|
|
? `"${description}"`
|
|
: !description.includes("'")
|
|
? `'${description}'`
|
|
: `"${description.replaceAll('"', '”')}"`;
|
|
insertOrReplaceIndexTTSTag(`[emotion:${quoted}]`);
|
|
});
|
|
|
|
return {
|
|
beginExternalTransaction() {
|
|
flushPendingHistory();
|
|
return {
|
|
originalText: getPlainText(),
|
|
originalCaretPos: getCaretPos(),
|
|
};
|
|
},
|
|
previewExternalTransaction(transaction, newText, caretPos) {
|
|
setEditorText(newText);
|
|
state.text = newText;
|
|
widget.value = newText;
|
|
widget.callback?.(newText);
|
|
if (Number.isFinite(caretPos)) state.lastCursorPosition = caretPos;
|
|
},
|
|
cancelExternalTransaction(transaction) {
|
|
const originalText = transaction.originalText;
|
|
const caretPos = getClampedCaretPos(originalText, transaction.originalCaretPos, 0);
|
|
setEditorText(originalText);
|
|
state.text = originalText;
|
|
widget.value = originalText;
|
|
widget.callback?.(originalText);
|
|
lastHistoryText = originalText;
|
|
state.saveToLocalStorage(storageKey);
|
|
historyStatus.textContent = state.getHistoryStatus();
|
|
setTimeout(() => setCaretPos(caretPos), 0);
|
|
},
|
|
commitExternalTransaction(transaction, finalText, caretPos) {
|
|
const finalCaretPos = getClampedCaretPos(finalText, caretPos, transaction.originalCaretPos);
|
|
setEditorText(finalText);
|
|
state.text = finalText;
|
|
if (finalText !== transaction.originalText) {
|
|
state.addToHistory(finalText, finalCaretPos);
|
|
}
|
|
widget.value = finalText;
|
|
widget.callback?.(finalText);
|
|
lastHistoryText = finalText;
|
|
state.saveToLocalStorage(storageKey);
|
|
historyStatus.textContent = state.getHistoryStatus();
|
|
setTimeout(() => setCaretPos(finalCaretPos), 0);
|
|
},
|
|
};
|
|
}
|