Files
diodiogod-TTS-Audio-Suite/web/omnivoice_instruction_builder_shared.js

3116 lines
119 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const NODE_CLASS = "OmniVoiceInstructionBuilderNode";
const PANEL_MIN_WIDTH = 700;
const PANEL_MIN_HEIGHT = 500;
const PANEL_WIDGET_MIN_HEIGHT = 392;
const PANEL_BOTTOM_PADDING = 12;
const RESTING_OFFSET_LIMIT = 26;
const DRAG_START_THRESHOLD = 4;
const PATH_BASE_STROKE_WIDTH = 2.2;
const TEMP_TEXT_PRESET_ID = "temp_text";
const OMNIVOICE_PRESET_ID = "omnivoice";
const OMNIVOICE_PRESET_LIBRARY_URL = "/api/tts-audio-suite/omnivoice-presets";
const EN_TO_ZH = {
"male": "男",
"female": "女",
"child": "儿童",
"teenager": "少年",
"young adult": "青年",
"middle-aged": "中年",
"elderly": "老年",
"very low pitch": "极低音调",
"low pitch": "低音调",
"moderate pitch": "中音调",
"high pitch": "高音调",
"very high pitch": "极高音调",
"whisper": "耳语",
};
const UI_TEXT = {
en: {
previewLabel: "OUT >",
emptyPreview: "Select attributes or type comma-separated tags",
localeEn: "EN",
localeZh: "中文",
editPreset: "Edit",
presetName: "Preset Name",
addColumn: "+ Column",
addSwitch: "+ Switch",
addMode: "+ Mode",
remove: "Remove",
save: "Save",
cancel: "Cancel",
deletePreset: "Delete",
columnTitle: "Column Title",
columnType: "Column Type",
modeTitle: "Mode Title",
options: "Options",
optionsHint: "One option per line. Use label=value or just value.",
single: "Single",
switch: "Switch",
editPresetTitle: "Edit Preset",
createPresetTitle: "Create Preset",
},
zh: {
previewLabel: "输出 >",
emptyPreview: "选择属性或输入逗号分隔标签",
localeEn: "EN",
localeZh: "中文",
editPreset: "编辑",
presetName: "预设名称",
addColumn: "+ 列",
addSwitch: "+ 切换列",
addMode: "+ 模式",
remove: "删除",
save: "保存",
cancel: "取消",
deletePreset: "删除",
columnTitle: "列标题",
columnType: "列类型",
modeTitle: "模式标题",
options: "选项",
optionsHint: "每行一个选项。可用 label=value 或只写 value。",
single: "单列",
switch: "切换列",
editPresetTitle: "编辑预设",
createPresetTitle: "创建预设",
},
};
const ZH_UI_LABELS = {
...EN_TO_ZH,
"american accent": "美式口音",
"british accent": "英式口音",
"australian accent": "澳式口音",
"canadian accent": "加拿大口音",
"indian accent": "印度口音",
"chinese accent": "中式口音",
"korean accent": "韩式口音",
"japanese accent": "日式口音",
"portuguese accent": "葡萄牙口音",
"russian accent": "俄式口音",
"河南话": "河南话",
"陕西话": "陕西话",
"四川话": "四川话",
"贵州话": "贵州话",
"云南话": "云南话",
"桂林话": "桂林话",
"济南话": "济南话",
"石家庄话": "石家庄话",
"甘肃话": "甘肃话",
"宁夏话": "宁夏话",
"青岛话": "青岛话",
"东北话": "东北话",
};
const OMNIVOICE_PRESET = {
id: OMNIVOICE_PRESET_ID,
name: "OmniVoice",
outputMode: "omnivoice",
columns: [
{
id: "gender",
title: "Gender",
zhTitle: "性别",
type: "single",
options: [
{ id: "male", label: "Male", zhLabel: "男", value: "male", title: "male" },
{ id: "female", label: "Female", zhLabel: "女", value: "female", title: "female" },
],
},
{
id: "age",
title: "Age",
zhTitle: "年龄",
type: "single",
options: [
{ id: "child", label: "Child", zhLabel: "儿童", value: "child", title: "child" },
{ id: "teen", label: "Teen", zhLabel: "少年", value: "teenager", title: "teenager" },
{ id: "young_adult", label: "Young Adult", zhLabel: "青年", value: "young adult", title: "young adult" },
{ id: "middle_aged", label: "Middle-aged", zhLabel: "中年", value: "middle-aged", title: "middle-aged" },
{ id: "elderly", label: "Elderly", zhLabel: "老年", value: "elderly", title: "elderly" },
],
},
{
id: "pitch",
title: "Pitch",
zhTitle: "音调",
type: "single",
options: [
{ id: "very_low", label: "Very Low", zhLabel: "极低音调", value: "very low pitch", title: "very low pitch" },
{ id: "low", label: "Low", zhLabel: "低音调", value: "low pitch", title: "low pitch" },
{ id: "moderate", label: "Moderate", zhLabel: "中音调", value: "moderate pitch", title: "moderate pitch" },
{ id: "high", label: "High", zhLabel: "高音调", value: "high pitch", title: "high pitch" },
{ id: "very_high", label: "Very High", zhLabel: "极高音调", value: "very high pitch", title: "very high pitch" },
],
},
{
id: "style",
title: "Style",
zhTitle: "风格",
type: "single",
options: [
{ id: "whisper", label: "Whisper", zhLabel: "耳语", value: "whisper", title: "whisper" },
],
},
{
id: "language",
title: "Language",
zhTitle: "语言",
type: "switch",
modes: [
{
id: "accent",
title: "Accent",
zhTitle: "口音",
options: [
{ id: "us", label: "US", value: "american accent", title: "american accent", zhTitle: "美式口音" },
{ id: "uk", label: "UK", value: "british accent", title: "british accent", zhTitle: "英式口音" },
{ id: "au", label: "AU", value: "australian accent", title: "australian accent", zhTitle: "澳式口音" },
{ id: "ca", label: "CA", value: "canadian accent", title: "canadian accent", zhTitle: "加拿大口音" },
{ id: "in", label: "IN", value: "indian accent", title: "indian accent", zhTitle: "印度口音" },
{ id: "cn", label: "CN", value: "chinese accent", title: "chinese accent", zhTitle: "中式口音" },
{ id: "kr", label: "KR", value: "korean accent", title: "korean accent", zhTitle: "韩式口音" },
{ id: "jp", label: "JP", value: "japanese accent", title: "japanese accent", zhTitle: "日式口音" },
{ id: "pt", label: "PT", value: "portuguese accent", title: "portuguese accent", zhTitle: "葡萄牙口音" },
{ id: "ru", label: "RU", value: "russian accent", title: "russian accent", zhTitle: "俄式口音" },
],
},
{
id: "dialect",
title: "Dialect",
zhTitle: "方言",
options: [
{ id: "henan", label: "Henan", value: "河南话", title: "河南话", zhLabel: "河南话" },
{ id: "shaanxi", label: "Shaanxi", value: "陕西话", title: "陕西话", zhLabel: "陕西话" },
{ id: "sichuan", label: "Sichuan", value: "四川话", title: "四川话", zhLabel: "四川话" },
{ id: "guizhou", label: "Guizhou", value: "贵州话", title: "贵州话", zhLabel: "贵州话" },
{ id: "yunnan", label: "Yunnan", value: "云南话", title: "云南话", zhLabel: "云南话" },
{ id: "guilin", label: "Guilin", value: "桂林话", title: "桂林话", zhLabel: "桂林话" },
{ id: "jinan", label: "Jinan", value: "济南话", title: "济南话", zhLabel: "济南话" },
{ id: "shijiazhuang", label: "Shijiazhuang", value: "石家庄话", title: "石家庄话", zhLabel: "石家庄话" },
{ id: "gansu", label: "Gansu", value: "甘肃话", title: "甘肃话", zhLabel: "甘肃话" },
{ id: "ningxia", label: "Ningxia", value: "宁夏话", title: "宁夏话", zhLabel: "宁夏话" },
{ id: "qingdao", label: "Qingdao", value: "青岛话", title: "青岛话", zhLabel: "青岛话" },
{ id: "northeast", label: "Northeast", value: "东北话", title: "东北话", zhLabel: "东北话" },
],
},
],
},
],
};
function cloneJson(value) {
return value ? JSON.parse(JSON.stringify(value)) : value;
}
function slugifyId(value, fallback = "preset") {
const slug = String(value || "")
.toLowerCase()
.replace(/[^a-z0-9]+/g, "_")
.replace(/^_+|_+$/g, "");
return slug || fallback;
}
function normalizeWidgetValue(value) {
const text = String(value ?? "").trim();
return !text || text === "None" ? "" : text;
}
function normalizePresetStateSnapshot(snapshot) {
const values = {};
const switchModes = {};
if (snapshot?.values && typeof snapshot.values === "object") {
for (const [key, value] of Object.entries(snapshot.values)) {
const normalizedValue = normalizeWidgetValue(value);
if (normalizedValue) {
values[String(key)] = normalizedValue;
}
}
}
if (snapshot?.switchModes && typeof snapshot.switchModes === "object") {
for (const [key, value] of Object.entries(snapshot.switchModes)) {
const normalizedValue = normalizeWidgetValue(value);
if (normalizedValue) {
switchModes[String(key)] = normalizedValue;
}
}
}
return { values, switchModes };
}
function hasMeaningfulPresetState(snapshot) {
const normalized = normalizePresetStateSnapshot(snapshot);
return Boolean(
Object.keys(normalized.values).length
|| Object.keys(normalized.switchModes).length
);
}
function normalizeColumnOrder(order, presetColumnIds) {
const filtered = Array.isArray(order)
? order.filter((columnId) => presetColumnIds.includes(columnId))
: [];
return filtered.length === presetColumnIds.length ? filtered : [...presetColumnIds];
}
function normalizeColumnOffset(value) {
if (typeof value === "number") {
return { x: Number(value) || 0, y: 0 };
}
if (value && typeof value === "object") {
return {
x: Number(value.x) || 0,
y: Number(value.y) || 0,
};
}
return { x: 0, y: 0 };
}
function createEl(tag, className, text) {
const el = document.createElement(tag);
if (className) {
el.className = className;
}
if (text !== undefined) {
el.textContent = text;
}
return el;
}
function isBuilderNode(node) {
return node?.comfyClass === NODE_CLASS;
}
function findWidgetByName(node, name) {
return node.widgets ? node.widgets.find((widget) => widget.name === name) : null;
}
function hideWidget(widget) {
if (!widget) {
return;
}
if (widget.__omnivoiceInstructionOriginalType === undefined) {
widget.__omnivoiceInstructionOriginalType = widget.type;
}
widget.type = "hidden";
widget.hidden = true;
widget.computeSize = () => [0, -4];
widget.draw = () => {};
if (widget.element) {
widget.element.style.display = "none";
}
}
function setWidgetValue(widget, value, emptyValue = "None") {
if (!widget) {
return;
}
const nextValue = value || emptyValue;
if (widget.value === nextValue) {
return;
}
widget.value = nextValue;
if (typeof widget.callback === "function") {
widget.callback(nextValue);
}
}
function getBuiltinPresetById(presetId) {
return presetId === OMNIVOICE_PRESET_ID ? OMNIVOICE_PRESET : null;
}
function normalizePresetOption(option, index = 0) {
const fallbackValue = `option_${index + 1}`;
const rawValue = option?.value ?? option?.label ?? option?.title ?? fallbackValue;
const value = normalizeWidgetValue(rawValue);
const label = String(option?.label ?? value ?? `Option ${index + 1}`);
return {
id: slugifyId(option?.id || value || label, `option_${index + 1}`),
label,
zhLabel: option?.zhLabel ? String(option.zhLabel) : undefined,
value,
title: String(option?.title ?? value ?? label),
zhTitle: option?.zhTitle ? String(option.zhTitle) : undefined,
};
}
function normalizePresetMode(mode, index = 0) {
const options = Array.isArray(mode?.options)
? mode.options.map((option, optionIndex) => normalizePresetOption(option, optionIndex)).filter((option) => option.value)
: [];
return {
id: slugifyId(mode?.id || mode?.title || `mode_${index + 1}`, `mode_${index + 1}`),
title: String(mode?.title || `Mode ${index + 1}`),
zhTitle: mode?.zhTitle ? String(mode.zhTitle) : undefined,
options,
};
}
function normalizePresetColumn(column, index = 0) {
const type = column?.type === "switch" ? "switch" : "single";
const normalized = {
id: slugifyId(column?.id || column?.title || `column_${index + 1}`, `column_${index + 1}`),
title: String(column?.title || ""),
zhTitle: column?.zhTitle ? String(column.zhTitle) : undefined,
type,
};
if (type === "switch") {
normalized.modes = Array.isArray(column?.modes)
? column.modes.map((mode, modeIndex) => normalizePresetMode(mode, modeIndex)).filter((mode) => mode.options.length)
: [];
} else {
normalized.options = Array.isArray(column?.options)
? column.options.map((option, optionIndex) => normalizePresetOption(option, optionIndex)).filter((option) => option.value)
: [];
}
return normalized;
}
function normalizePreset(preset, fallbackId = "custom_preset") {
const columns = Array.isArray(preset?.columns)
? preset.columns.map((column, index) => normalizePresetColumn(column, index)).filter((column) => {
if (column.type === "switch") {
return column.modes?.length > 0;
}
return column.options?.length > 0;
})
: [];
const presetColumnIds = columns.map((column) => column.id);
return {
id: slugifyId(preset?.id || preset?.name || fallbackId, fallbackId),
name: String(preset?.name || "Preset"),
outputMode: preset?.outputMode === "omnivoice" ? "omnivoice" : "plain",
columns,
columnOrder: normalizeColumnOrder(preset?.columnOrder, presetColumnIds),
lastState: normalizePresetStateSnapshot(preset?.lastState),
};
}
function getPresetColumnIds(preset) {
return Array.isArray(preset?.columns) ? preset.columns.map((column) => column.id) : [];
}
function getColumnById(preset, columnId) {
return preset?.columns?.find((column) => column.id === columnId) || null;
}
function getModeById(column, modeId) {
return column?.modes?.find((mode) => mode.id === modeId) || column?.modes?.[0] || null;
}
function isOmniVoicePreset(preset) {
return preset?.outputMode === "omnivoice";
}
function parsePresetOptionLines(text) {
return String(text || "")
.split("\n")
.map((line) => line.trim())
.filter(Boolean)
.flatMap((line) => {
const parts = line.split("=");
if (parts.length >= 2) {
const label = parts.shift().trim();
const value = parts.join("=").trim();
return [normalizePresetOption({ label, value, title: value })];
}
if (line.includes(",")) {
return line
.split(",")
.map((item) => item.trim())
.filter(Boolean)
.map((item) => normalizePresetOption({ label: item, value: item, title: item }));
}
return [normalizePresetOption({ label: line, value: line, title: line })];
})
.filter((option) => option.value);
}
function createTemporaryPresetFromText(text) {
const tokens = String(text || "")
.split(/[,\uFF0C]/)
.map((token) => token.trim())
.filter(Boolean);
if (!tokens.length) {
return null;
}
return normalizePreset({
id: TEMP_TEXT_PRESET_ID,
name: "Temporary Text",
outputMode: "plain",
columns: tokens.map((token, index) => ({
id: `tag_${index + 1}`,
title: "",
type: "single",
options: [{ id: `tag_${index + 1}_option`, label: token, value: token, title: token }],
})),
}, TEMP_TEXT_PRESET_ID);
}
function hasTrailingSeparator(text) {
return /[,\uFF0C]\s*$/.test(String(text || ""));
}
function createEmptyBuilderState() {
return {
selectedPresetId: OMNIVOICE_PRESET_ID,
output_language: "English",
customPresets: [],
temporaryPreset: null,
workflowPresetSnapshot: null,
presetStates: {},
};
}
function getPresetStateBucket(state, presetId) {
state.presetStates = state.presetStates || {};
if (!state.presetStates[presetId]) {
state.presetStates[presetId] = {
values: {},
switchModes: {},
};
}
state.presetStates[presetId].values = state.presetStates[presetId].values || {};
state.presetStates[presetId].switchModes = state.presetStates[presetId].switchModes || {};
return state.presetStates[presetId];
}
function getAvailablePresets(state) {
const presets = [OMNIVOICE_PRESET];
for (const preset of state.customPresets || []) {
presets.push(normalizePreset(preset, preset?.id || "custom_preset"));
}
if (state.workflowPresetSnapshot?.columns?.length) {
const workflowPreset = normalizePreset(state.workflowPresetSnapshot, state.workflowPresetSnapshot?.id || "workflow_preset");
if (!presets.some((preset) => preset.id === workflowPreset.id)) {
presets.push(workflowPreset);
}
}
if (state.temporaryPreset?.columns?.length) {
presets.push(normalizePreset(state.temporaryPreset, TEMP_TEXT_PRESET_ID));
}
return presets;
}
function getActivePreset(state) {
const presets = getAvailablePresets(state);
return presets.find((preset) => preset.id === state.selectedPresetId) || presets[0];
}
function getPresetOutputLocale(preset, state) {
return isOmniVoicePreset(preset) && normalizeWidgetValue(state.output_language) === "Chinese" ? "zh" : "en";
}
function getColumnTitleForLocale(column, locale) {
return locale === "zh" ? (column.zhTitle || column.title || "") : (column.title || "");
}
function getModeTitleForLocale(mode, locale) {
return locale === "zh" ? (mode.zhTitle || mode.title || "") : (mode.title || "");
}
function getOptionLabelForLocale(option, locale) {
return locale === "zh" ? (option.zhLabel || option.label || option.value) : (option.label || option.value);
}
function getOptionTitleForLocale(option, locale) {
return locale === "zh" ? (option.zhTitle || option.zhLabel || option.title || option.value) : (option.title || option.value);
}
function buildLegacyOmniVoicePresetState(node) {
const accent = normalizeWidgetValue(findWidgetByName(node, "accent")?.value);
const dialect = normalizeWidgetValue(findWidgetByName(node, "dialect")?.value);
return {
values: {
gender: normalizeWidgetValue(findWidgetByName(node, "gender")?.value),
age: normalizeWidgetValue(findWidgetByName(node, "age")?.value),
pitch: normalizeWidgetValue(findWidgetByName(node, "pitch")?.value),
style: normalizeWidgetValue(findWidgetByName(node, "style")?.value),
language: accent || dialect,
},
switchModes: {
language: dialect ? "dialect" : "accent",
},
};
}
function getCachedPresetLibrary() {
return Array.isArray(window.__ttsAudioSuiteOmniVoicePresetLibrary)
? cloneJson(window.__ttsAudioSuiteOmniVoicePresetLibrary)
: [];
}
function setCachedPresetLibrary(presets) {
window.__ttsAudioSuiteOmniVoicePresetLibrary = cloneJson(presets || []);
}
function getCachedBuiltinPresetStates() {
return window.__ttsAudioSuiteOmniVoiceBuiltinPresetStates
&& typeof window.__ttsAudioSuiteOmniVoiceBuiltinPresetStates === "object"
? cloneJson(window.__ttsAudioSuiteOmniVoiceBuiltinPresetStates)
: {};
}
function setCachedBuiltinPresetStates(states) {
window.__ttsAudioSuiteOmniVoiceBuiltinPresetStates = cloneJson(states || {});
}
function getCachedBuiltinPresetLayouts() {
return window.__ttsAudioSuiteOmniVoiceBuiltinPresetLayouts
&& typeof window.__ttsAudioSuiteOmniVoiceBuiltinPresetLayouts === "object"
? cloneJson(window.__ttsAudioSuiteOmniVoiceBuiltinPresetLayouts)
: {};
}
function setCachedBuiltinPresetLayouts(layouts) {
window.__ttsAudioSuiteOmniVoiceBuiltinPresetLayouts = cloneJson(layouts || {});
}
function getStoredColumnOrderForPreset(node, preset) {
const presetColumnIds = getPresetColumnIds(preset);
const workflowOrder = node?.properties?.omnivoiceInstructionLayout?.layouts?.[preset.id]?.columnOrder;
if (Array.isArray(workflowOrder) && workflowOrder.length) {
return normalizeColumnOrder(workflowOrder, presetColumnIds);
}
if (preset.id === OMNIVOICE_PRESET_ID) {
const builtinOrder = node?.__omnivoiceInstructionBuiltinPresetLayouts?.[preset.id]?.columnOrder;
return normalizeColumnOrder(builtinOrder, presetColumnIds);
}
return normalizeColumnOrder(preset?.columnOrder, presetColumnIds);
}
function buildPresetLibraryPayload(state, node) {
return (state.customPresets || []).map((preset, index) => {
const normalized = normalizePreset(preset, preset?.id || `custom_preset_${index + 1}`);
return {
...normalized,
columnOrder: getStoredColumnOrderForPreset(node, normalized),
lastState: normalizePresetStateSnapshot(state?.presetStates?.[normalized.id] || normalized.lastState),
};
});
}
function buildBuiltinPresetStatePayload(state) {
const builtinStates = {};
for (const presetId of [OMNIVOICE_PRESET_ID]) {
builtinStates[presetId] = normalizePresetStateSnapshot(state?.presetStates?.[presetId]);
}
return builtinStates;
}
function buildBuiltinPresetLayoutPayload(node) {
const builtinLayouts = {};
const omnivoiceOrder = getStoredColumnOrderForPreset(node, OMNIVOICE_PRESET);
builtinLayouts[OMNIVOICE_PRESET_ID] = {
columnOrder: [...omnivoiceOrder],
};
return builtinLayouts;
}
async function fetchPresetLibraryFromBackend() {
const response = await fetch(OMNIVOICE_PRESET_LIBRARY_URL, {
method: "GET",
cache: "no-store",
});
if (!response.ok) {
throw new Error(`Preset library fetch failed: ${response.status}`);
}
const data = await response.json();
const presets = Array.isArray(data?.presets) ? data.presets : [];
const builtinStates = data?.builtinStates && typeof data.builtinStates === "object" ? data.builtinStates : {};
const builtinLayouts = data?.builtinLayouts && typeof data.builtinLayouts === "object" ? data.builtinLayouts : {};
setCachedPresetLibrary(presets);
setCachedBuiltinPresetStates(builtinStates);
setCachedBuiltinPresetLayouts(builtinLayouts);
return { presets, builtinStates, builtinLayouts };
}
async function savePresetLibraryToBackend(presets, builtinStates = {}, builtinLayouts = {}) {
const response = await fetch(OMNIVOICE_PRESET_LIBRARY_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ presets, builtinStates, builtinLayouts }),
});
if (!response.ok) {
throw new Error(`Preset library save failed: ${response.status}`);
}
setCachedPresetLibrary(presets);
setCachedBuiltinPresetStates(builtinStates);
setCachedBuiltinPresetLayouts(builtinLayouts);
return response.json();
}
function getWorkflowPresetSnapshotFromRaw(rawState) {
const directSnapshot = rawState?.workflowPresetSnapshot?.columns?.length
? normalizePreset(rawState.workflowPresetSnapshot, rawState.workflowPresetSnapshot?.id || "workflow_preset")
: null;
if (directSnapshot) {
return directSnapshot;
}
const selectedPresetId = rawState?.selectedPresetId;
const legacyCustomPresets = Array.isArray(rawState?.customPresets) ? rawState.customPresets : [];
const matchedLegacyPreset = legacyCustomPresets.find((preset) => slugifyId(preset?.id || preset?.name || "", "") === selectedPresetId);
return matchedLegacyPreset?.columns?.length
? normalizePreset(matchedLegacyPreset, matchedLegacyPreset?.id || "workflow_preset")
: null;
}
function buildWorkflowSafePresetState(state) {
const activePreset = getActivePreset(state);
return {
selectedPresetId: state.selectedPresetId,
output_language: state.output_language,
temporaryPreset: state.temporaryPreset?.columns?.length ? cloneJson(state.temporaryPreset) : null,
workflowPresetSnapshot: (
activePreset
&& activePreset.id !== OMNIVOICE_PRESET_ID
&& activePreset.id !== TEMP_TEXT_PRESET_ID
&& activePreset.columns?.length
) ? cloneJson(activePreset) : null,
presetStates: cloneJson(state.presetStates || {}),
};
}
function syncStateFromWidgets(node) {
const rawWorkflowState = cloneJson(node.properties?.omnivoiceInstructionPresetState || {});
const state = {
...createEmptyBuilderState(),
...rawWorkflowState,
};
state.customPresets = Array.isArray(node.__omnivoiceInstructionLibraryPresets)
? node.__omnivoiceInstructionLibraryPresets.map((preset, index) => normalizePreset(preset, `custom_preset_${index + 1}`))
: [];
state.workflowPresetSnapshot = getWorkflowPresetSnapshotFromRaw(rawWorkflowState);
state.temporaryPreset = state.temporaryPreset?.columns?.length
? normalizePreset(state.temporaryPreset, TEMP_TEXT_PRESET_ID)
: null;
state.presetStates = state.presetStates && typeof state.presetStates === "object" ? state.presetStates : {};
const builtinPresetStates = node.__omnivoiceInstructionBuiltinPresetStates || {};
for (const [presetId, snapshot] of Object.entries(builtinPresetStates)) {
const normalizedSnapshot = normalizePresetStateSnapshot(snapshot);
const hasWorkflowState = hasMeaningfulPresetState(state.presetStates[presetId]);
const hasLibraryState = Object.keys(normalizedSnapshot.values).length || Object.keys(normalizedSnapshot.switchModes).length;
if (!hasWorkflowState && hasLibraryState) {
state.presetStates[presetId] = normalizedSnapshot;
}
}
for (const preset of state.customPresets || []) {
const snapshot = normalizePresetStateSnapshot(preset?.lastState);
const hasWorkflowState = hasMeaningfulPresetState(state.presetStates[preset.id]);
const hasLibraryState = Object.keys(snapshot.values).length || Object.keys(snapshot.switchModes).length;
if (!hasWorkflowState && hasLibraryState) {
state.presetStates[preset.id] = snapshot;
}
}
state.output_language = normalizeWidgetValue(state.output_language || findWidgetByName(node, "output_language")?.value) || "English";
if (!state.selectedPresetId) {
state.selectedPresetId = state.temporaryPreset?.id || OMNIVOICE_PRESET_ID;
}
const omniBucket = getPresetStateBucket(state, OMNIVOICE_PRESET_ID);
if (!Object.keys(omniBucket.values || {}).length) {
state.presetStates[OMNIVOICE_PRESET_ID] = buildLegacyOmniVoicePresetState(node);
}
const activePreset = getActivePreset(state);
getPresetStateBucket(state, activePreset.id);
return state;
}
function buildPreviewFromState(state, preset = getActivePreset(state), columnOrder = getPresetColumnIds(preset)) {
const locale = getPresetOutputLocale(preset, state);
const bucket = getPresetStateBucket(state, preset.id);
const ordered = [];
for (const columnId of columnOrder) {
const column = getColumnById(preset, columnId);
if (!column) {
continue;
}
if (column.type === "switch") {
const mode = getModeById(column, bucket.switchModes[columnId]);
const value = normalizeWidgetValue(bucket.values[columnId]);
const option = mode?.options?.find((item) => item.value === value);
if (option?.value) {
ordered.push(option.value);
}
continue;
}
const value = normalizeWidgetValue(bucket.values[columnId]);
if (value) {
ordered.push(value);
}
}
if (!ordered.length) {
return "";
}
if (locale === "zh" && isOmniVoicePreset(preset)) {
return ordered.map((item) => EN_TO_ZH[item] || item).join(",");
}
return ordered.join(", ");
}
function setPresetColumnValue(state, preset, columnId, value, toggle = true, modeId = "") {
const bucket = getPresetStateBucket(state, preset.id);
const column = getColumnById(preset, columnId);
if (!column) {
return state;
}
const currentValue = normalizeWidgetValue(bucket.values[columnId]);
if (column.type === "switch") {
const nextMode = modeId || bucket.switchModes[columnId] || column.modes?.[0]?.id || "";
bucket.switchModes[columnId] = nextMode;
}
bucket.values[columnId] = toggle && currentValue === value ? "" : normalizeWidgetValue(value);
return state;
}
function setPresetMode(state, preset, columnId, modeId) {
const bucket = getPresetStateBucket(state, preset.id);
bucket.switchModes[columnId] = modeId;
bucket.values[columnId] = "";
return state;
}
function createPresetFromDraft(draft, fallbackId = "custom_preset") {
return normalizePreset({
id: draft.id || slugifyId(draft.name || fallbackId, fallbackId),
name: draft.name || "Preset",
outputMode: draft.outputMode || "plain",
columns: draft.columns || [],
}, fallbackId);
}
function createPresetDraftFromPreset(preset, preserveId = false) {
const draft = cloneJson(preset) || {};
draft.name = draft.name || "Preset";
draft.outputMode = preset?.outputMode === "omnivoice" ? "omnivoice" : "plain";
draft.id = preserveId ? draft.id : "";
draft.columns = Array.isArray(draft.columns) ? draft.columns : [];
return draft;
}
function createEmptySingleColumn(index = 0) {
return {
id: `column_${index + 1}`,
title: "",
type: "single",
options: [{ id: "option_1", label: "", value: "", title: "" }],
};
}
function createEmptySwitchColumn(index = 0) {
return {
id: `switch_${index + 1}`,
title: "",
type: "switch",
modes: [
{
id: "mode_1",
title: "Mode 1",
options: [{ id: "option_1", label: "", value: "", title: "" }],
},
{
id: "mode_2",
title: "Mode 2",
options: [{ id: "option_1", label: "", value: "", title: "" }],
},
],
};
}
function seedBucketFromPreset(state, preset) {
const bucket = getPresetStateBucket(state, preset.id);
bucket.values = {};
bucket.switchModes = {};
for (const column of preset.columns || []) {
if (column.type === "switch") {
const mode = column.modes?.[0];
if (mode?.id) {
bucket.switchModes[column.id] = mode.id;
if (mode.options?.[0]?.value) {
bucket.values[column.id] = mode.options[0].value;
}
}
continue;
}
if (column.options?.[0]?.value) {
bucket.values[column.id] = column.options[0].value;
}
}
}
function applyPreviewTextToState(state, text) {
const temporaryPreset = createTemporaryPresetFromText(text);
if (!temporaryPreset) {
state.temporaryPreset = null;
if (state.selectedPresetId === TEMP_TEXT_PRESET_ID) {
state.selectedPresetId = OMNIVOICE_PRESET_ID;
}
delete state.presetStates[TEMP_TEXT_PRESET_ID];
return state;
}
state.temporaryPreset = temporaryPreset;
state.selectedPresetId = temporaryPreset.id;
seedBucketFromPreset(state, temporaryPreset);
return state;
}
function persistPresetState(node, state) {
node.properties = node.properties || {};
node.properties.omnivoiceInstructionPresetState = buildWorkflowSafePresetState(state);
}
function cachePresetLibraryOnNode(node, presets, builtinStates = null, builtinLayouts = null) {
const normalizedPresets = cloneJson(presets || []);
node.__omnivoiceInstructionLibraryPresets = normalizedPresets;
node.__omnivoiceInstructionBuiltinPresetStates = cloneJson(builtinStates || getCachedBuiltinPresetStates());
node.__omnivoiceInstructionBuiltinPresetLayouts = cloneJson(builtinLayouts || getCachedBuiltinPresetLayouts());
node.__omnivoiceInstructionLastLibraryPayload = JSON.stringify(normalizedPresets);
node.__omnivoiceInstructionLastBuiltinStatesPayload = JSON.stringify(node.__omnivoiceInstructionBuiltinPresetStates || {});
node.__omnivoiceInstructionLastBuiltinLayoutsPayload = JSON.stringify(node.__omnivoiceInstructionBuiltinPresetLayouts || {});
setCachedPresetLibrary(normalizedPresets);
setCachedBuiltinPresetStates(node.__omnivoiceInstructionBuiltinPresetStates);
setCachedBuiltinPresetLayouts(node.__omnivoiceInstructionBuiltinPresetLayouts);
}
async function persistPresetLibraryNow(node, state) {
node.__omnivoiceInstructionLibraryReady = true;
const payload = buildPresetLibraryPayload(state, node);
const builtinStates = buildBuiltinPresetStatePayload(state);
const builtinLayouts = buildBuiltinPresetLayoutPayload(node);
const serialized = JSON.stringify(payload);
const builtinSerialized = JSON.stringify(builtinStates);
const builtinLayoutSerialized = JSON.stringify(builtinLayouts);
if (
serialized === (node.__omnivoiceInstructionLastLibraryPayload || "")
&& builtinSerialized === (node.__omnivoiceInstructionLastBuiltinStatesPayload || "")
&& builtinLayoutSerialized === (node.__omnivoiceInstructionLastBuiltinLayoutsPayload || "")
) {
cachePresetLibraryOnNode(node, payload, builtinStates, builtinLayouts);
return;
}
await savePresetLibraryToBackend(payload, builtinStates, builtinLayouts);
cachePresetLibraryOnNode(node, payload, builtinStates, builtinLayouts);
}
function schedulePresetLibraryPersist(node, state) {
if (!node.__omnivoiceInstructionLibraryReady) {
return;
}
clearTimeout(node.__omnivoiceInstructionLibraryPersistTimer);
node.__omnivoiceInstructionLibraryPersistTimer = window.setTimeout(async () => {
try {
const payload = buildPresetLibraryPayload(state, node);
const builtinStates = buildBuiltinPresetStatePayload(state);
const builtinLayouts = buildBuiltinPresetLayoutPayload(node);
const serialized = JSON.stringify(payload);
const builtinSerialized = JSON.stringify(builtinStates);
const builtinLayoutSerialized = JSON.stringify(builtinLayouts);
if (
serialized === (node.__omnivoiceInstructionLastLibraryPayload || "")
&& builtinSerialized === (node.__omnivoiceInstructionLastBuiltinStatesPayload || "")
&& builtinLayoutSerialized === (node.__omnivoiceInstructionLastBuiltinLayoutsPayload || "")
) {
cachePresetLibraryOnNode(node, payload, builtinStates, builtinLayouts);
return;
}
await savePresetLibraryToBackend(payload, builtinStates, builtinLayouts);
cachePresetLibraryOnNode(node, payload, builtinStates, builtinLayouts);
} catch (error) {
console.warn("Could not persist OmniVoice preset library state:", error);
}
}, 220);
}
async function loadPresetLibraryIntoNode(node) {
try {
const { presets, builtinStates, builtinLayouts } = await fetchPresetLibraryFromBackend();
cachePresetLibraryOnNode(node, Array.isArray(presets) ? presets : [], builtinStates, builtinLayouts);
} catch (error) {
console.warn("Could not load OmniVoice preset library:", error);
cachePresetLibraryOnNode(node, getCachedPresetLibrary(), getCachedBuiltinPresetStates(), getCachedBuiltinPresetLayouts());
} finally {
node.__omnivoiceInstructionLibraryReady = true;
}
}
function writeBuilderStateToWidgets(node, state, ui = null) {
const preset = getActivePreset(state);
const bucket = getPresetStateBucket(state, preset.id);
const columnOrder = ui?.columnOrder || getPresetColumnIds(preset);
setWidgetValue(findWidgetByName(node, "output_language"), state.output_language || "English");
setWidgetValue(
findWidgetByName(node, "instruct_text"),
buildPreviewFromState(state, preset, columnOrder),
"",
);
const syncOmniVoice = isOmniVoicePreset(preset) && preset.id === OMNIVOICE_PRESET_ID;
const languageMode = bucket.switchModes.language || "accent";
const languageValue = normalizeWidgetValue(bucket.values.language);
setWidgetValue(findWidgetByName(node, "gender"), syncOmniVoice ? bucket.values.gender : "");
setWidgetValue(findWidgetByName(node, "age"), syncOmniVoice ? bucket.values.age : "");
setWidgetValue(findWidgetByName(node, "pitch"), syncOmniVoice ? bucket.values.pitch : "");
setWidgetValue(findWidgetByName(node, "style"), syncOmniVoice ? bucket.values.style : "");
setWidgetValue(findWidgetByName(node, "accent"), syncOmniVoice && languageMode === "accent" ? languageValue : "");
setWidgetValue(findWidgetByName(node, "dialect"), syncOmniVoice && languageMode === "dialect" ? languageValue : "");
}
function applyStateToWidgets(node, state, ui = null) {
persistPresetState(node, state);
writeBuilderStateToWidgets(node, state, ui);
schedulePresetLibraryPersist(node, state);
}
function ensureStyles(panel) {
if (panel.__omnivoiceInstructionStylesInjected) {
return;
}
panel.__omnivoiceInstructionStylesInjected = true;
const style = document.createElement("style");
style.textContent = `
.omnivoice-instruction-builder-panel {
width: 100%;
height: 100%;
color: #e3e2e6;
font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
box-sizing: border-box;
overflow: visible;
padding: 2px 2px 0 2px;
}
.ovib-shell {
display: flex;
flex-direction: column;
height: 100%;
border: 1px solid rgba(152, 203, 255, 0.12);
border-radius: 10px;
overflow: hidden;
background: #17191d;
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.32);
}
.ovib-topline {
height: 2px;
background: linear-gradient(90deg, rgba(152, 203, 255, 0.95) 0%, rgba(0, 163, 255, 0.86) 60%, rgba(111, 251, 190, 0.72) 100%);
}
.ovib-header {
display: flex;
align-items: center;
gap: 8px;
padding: 9px 12px;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
background: rgba(23, 25, 29, 0.96);
}
.ovib-header-preview {
min-width: 0;
display: flex;
align-items: center;
gap: 7px;
flex: 1 1 auto;
overflow: hidden;
}
.ovib-header-controls {
display: flex;
align-items: center;
gap: 8px;
flex: 0 0 auto;
}
.ovib-preset-select {
min-height: 24px;
max-width: 138px;
padding: 4px 8px;
border-radius: 7px;
border: 1px solid rgba(121, 134, 155, 0.34);
background: rgba(34, 37, 43, 0.92);
color: #d9e8ff;
font-size: 8px;
font-weight: 700;
letter-spacing: 0.03em;
outline: none;
}
.ovib-icon-button {
min-height: 24px;
padding: 4px 8px;
border-radius: 7px;
border: 1px solid rgba(121, 134, 155, 0.34);
background: rgba(34, 37, 43, 0.92);
color: #9fb0c6;
font-size: 8px;
font-weight: 700;
letter-spacing: 0.04em;
cursor: pointer;
transition: border-color 0.18s ease, background 0.18s ease, color 0.18s ease, box-shadow 0.18s ease;
}
.ovib-icon-button:hover {
border-color: rgba(152, 203, 255, 0.42);
color: #d9e8ff;
}
.ovib-locale-switch {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
width: 84px;
flex: 0 0 auto;
}
.ovib-locale-chip {
min-height: 24px;
padding: 4px 6px;
border-radius: 7px;
border: 1px solid rgba(121, 134, 155, 0.34);
background: rgba(34, 37, 43, 0.92);
color: #9fb0c6;
font-size: 8px;
font-weight: 700;
letter-spacing: 0.04em;
cursor: pointer;
transition: border-color 0.18s ease, background 0.18s ease, color 0.18s ease, box-shadow 0.18s ease;
}
.ovib-locale-chip:hover {
border-color: rgba(152, 203, 255, 0.42);
color: #d9e8ff;
}
.ovib-locale-chip.is-active {
border-color: rgba(152, 203, 255, 0.82);
background: linear-gradient(180deg, rgba(40, 61, 84, 0.8) 0%, rgba(28, 43, 62, 0.78) 100%);
color: #9ed0ff;
box-shadow: 0 0 14px rgba(64, 167, 255, 0.18);
}
.ovib-body {
position: relative;
flex: 1 1 auto;
min-height: 312px;
padding: 14px 12px 10px 12px;
background:
linear-gradient(rgba(255, 255, 255, 0.03) 1px, transparent 1px),
linear-gradient(90deg, rgba(255, 255, 255, 0.03) 1px, transparent 1px),
radial-gradient(circle at top left, rgba(152, 203, 255, 0.08) 0%, transparent 34%),
#17191d;
background-size: 18px 18px, 18px 18px, auto, auto;
}
.ovib-svg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 3;
}
.ovib-grid {
position: relative;
z-index: 1;
display: grid;
grid-auto-columns: minmax(0, 1fr);
grid-auto-flow: column;
gap: 12px;
align-items: start;
height: 100%;
}
.ovib-column {
display: flex;
flex-direction: column;
gap: 7px;
min-width: 0;
position: relative;
transition: transform 0.18s ease, z-index 0.18s ease;
will-change: transform;
}
.ovib-column.is-draggable .ovib-chip.is-active {
cursor: grab;
}
.ovib-column.is-dragging .ovib-chip.is-active {
cursor: grabbing;
}
.ovib-column.is-dragging {
z-index: 5;
transition: none;
}
.ovib-column-title,
.ovib-subtitle {
margin: 0;
text-align: center;
color: #90a0b7;
font-size: 9px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.ovib-column-title.is-empty,
.ovib-subtitle.is-empty {
min-height: 0;
margin: 0;
opacity: 0;
}
.ovib-subtitle {
font-size: 8px;
color: #79869b;
margin-bottom: 2px;
}
.ovib-chip-list {
display: flex;
flex-direction: column;
gap: 7px;
}
.ovib-chip {
position: relative;
display: flex;
align-items: center;
justify-content: center;
min-height: 30px;
padding: 7px 8px;
margin: 0 auto;
border-radius: 8px;
border: 1px solid rgba(121, 134, 155, 0.38);
background: rgba(41, 43, 49, 0.94);
color: #e6edf8;
font-size: 9px;
font-weight: 600;
text-align: center;
line-height: 1.2;
letter-spacing: 0.01em;
user-select: none;
box-sizing: border-box;
cursor: pointer;
text-rendering: geometricPrecision;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
transition: border-color 0.18s ease, background 0.18s ease, color 0.18s ease, box-shadow 0.18s ease, opacity 0.18s ease, transform 0.18s ease;
}
.ovib-column:not(.ovib-language-column) .ovib-chip {
width: min(100%, 84px);
padding-left: 6px;
padding-right: 6px;
}
.ovib-language-column .ovib-chip {
width: min(100%, 108px);
}
.ovib-chip:hover:not(.is-disabled) {
border-color: rgba(152, 203, 255, 0.44);
background: rgba(50, 54, 62, 0.96);
color: #ffffff;
transform: translateY(-1px);
}
.ovib-chip.is-active {
border-color: rgba(152, 203, 255, 0.98);
background: linear-gradient(180deg, rgba(47, 68, 92, 0.78) 0%, rgba(35, 52, 74, 0.68) 100%);
color: #b7ddff;
box-shadow: 0 0 0 1px rgba(152, 203, 255, 0.16) inset, 0 0 12px rgba(64, 167, 255, 0.2);
}
.ovib-chip.is-disabled {
opacity: 0.2;
pointer-events: none;
}
.ovib-chip::before,
.ovib-chip::after {
content: "";
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 3px;
height: 3px;
border-radius: 50%;
background: rgba(111, 126, 147, 0.38);
opacity: 0;
transition: opacity 0.18s ease, background 0.18s ease, box-shadow 0.18s ease;
}
.ovib-chip::before {
left: -3px;
}
.ovib-chip::after {
right: -3px;
}
.ovib-chip.is-active::before,
.ovib-chip.is-active::after {
opacity: 1;
background: #98cbff;
box-shadow: 0 0 8px rgba(152, 203, 255, 0.7);
}
.ovib-column:first-child .ovib-chip::before {
display: none;
}
.ovib-language-column .ovib-chip::after {
display: none;
}
.ovib-language-stack {
display: flex;
flex-direction: column;
gap: 8px;
}
.ovib-mode-switch {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
margin-bottom: 2px;
}
.ovib-mode-chip {
min-height: 24px;
padding: 5px 6px;
border-radius: 7px;
border: 1px solid rgba(121, 134, 155, 0.34);
background: rgba(34, 37, 43, 0.92);
color: #9fb0c6;
font-size: 8px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
cursor: pointer;
transition: border-color 0.18s ease, background 0.18s ease, color 0.18s ease, box-shadow 0.18s ease;
}
.ovib-mode-chip:hover {
border-color: rgba(152, 203, 255, 0.42);
color: #d9e8ff;
}
.ovib-mode-chip.is-active {
border-color: rgba(152, 203, 255, 0.82);
background: linear-gradient(180deg, rgba(40, 61, 84, 0.8) 0%, rgba(28, 43, 62, 0.78) 100%);
color: #9ed0ff;
box-shadow: 0 0 14px rgba(64, 167, 255, 0.18);
}
.ovib-language-group {
display: flex;
flex-direction: column;
gap: 6px;
transition: opacity 0.2s ease;
}
.ovib-language-group .ovib-chip-list {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 7px;
}
.ovib-language-group.is-hidden {
display: none;
}
.ovib-preview-label {
color: #8492a7;
font-size: 8px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
white-space: nowrap;
flex: 0 0 auto;
}
.ovib-preview-input {
width: 100%;
min-width: 0;
border: none;
outline: none;
background: transparent;
color: #98cbff;
font-family: "JetBrains Mono", "SFMono-Regular", Consolas, monospace;
font-size: 9px;
line-height: 1.35;
padding: 0;
}
.ovib-preview-input::placeholder {
color: #64748b;
}
.ovib-path {
fill: none;
stroke: url(#ovibPathGradient);
stroke-linecap: round;
stroke-linejoin: round;
filter: drop-shadow(0 0 5px rgba(152, 203, 255, 0.58));
opacity: 0.92;
}
.ovib-modal-backdrop {
position: absolute;
inset: 0;
z-index: 20;
display: none;
align-items: center;
justify-content: center;
padding: 16px;
background: rgba(5, 7, 10, 0.74);
backdrop-filter: blur(8px);
}
.ovib-modal-backdrop.is-open {
display: flex;
}
.ovib-modal {
width: min(860px, 100%);
max-height: min(84vh, 720px);
overflow: auto;
border-radius: 12px;
border: 1px solid rgba(121, 134, 155, 0.34);
background: #17191d;
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.42);
padding: 14px;
display: flex;
flex-direction: column;
gap: 12px;
}
.ovib-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.ovib-modal-title {
color: #f3f8ff;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.03em;
}
.ovib-modal-actions {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.ovib-form-label {
color: #9fb0c6;
font-size: 9px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.ovib-form-hint {
color: #7b8aa1;
font-size: 8px;
line-height: 1.4;
}
.ovib-form-input,
.ovib-form-textarea,
.ovib-form-select {
width: 100%;
border-radius: 8px;
border: 1px solid rgba(121, 134, 155, 0.34);
background: rgba(34, 37, 43, 0.92);
color: #d9e8ff;
font-size: 10px;
padding: 8px 10px;
box-sizing: border-box;
outline: none;
}
.ovib-form-textarea {
min-height: 90px;
resize: vertical;
font-family: "JetBrains Mono", "SFMono-Regular", Consolas, monospace;
line-height: 1.45;
}
.ovib-columns-editor {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 10px;
align-items: start;
}
.ovib-column-card,
.ovib-mode-card {
border-radius: 10px;
border: 1px solid rgba(121, 134, 155, 0.22);
background: rgba(24, 27, 32, 0.94);
padding: 12px;
display: flex;
flex-direction: column;
gap: 10px;
}
.ovib-mode-card {
background: rgba(27, 31, 36, 0.96);
}
.ovib-column-card-header,
.ovib-mode-card-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.ovib-column-card-title,
.ovib-mode-card-title {
color: #f3f8ff;
font-size: 10px;
font-weight: 700;
}
.ovib-two-col {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.ovib-card-topline {
display: flex;
align-items: center;
gap: 8px;
}
.ovib-card-topline .ovib-form-select {
flex: 0 0 110px;
width: 110px;
}
.ovib-card-topline .ovib-form-input {
flex: 1 1 auto;
}
.ovib-stack {
display: flex;
flex-direction: column;
gap: 6px;
}
.ovib-mode-list {
display: flex;
flex-direction: column;
gap: 8px;
}
`;
panel.appendChild(style);
}
function createChip(option, columnId, modeId = "") {
const button = createEl("button", "ovib-chip", option.label);
button.type = "button";
button.dataset.columnId = columnId;
button.dataset.modeId = modeId;
button.dataset.value = option.value;
button.dataset.labelEn = option.label;
button.dataset.labelZh = option.zhLabel || ZH_UI_LABELS[option.value] || option.label;
button.dataset.titleEn = option.title || option.value;
button.dataset.titleZh = option.zhTitle || option.zhLabel || ZH_UI_LABELS[option.value] || option.title || option.value;
return button;
}
function createPanelDom() {
const panel = createEl("div", "omnivoice-instruction-builder-panel");
ensureStyles(panel);
const shell = createEl("div", "ovib-shell");
panel.appendChild(shell);
shell.appendChild(createEl("div", "ovib-topline"));
const header = createEl("div", "ovib-header");
const headerPreview = createEl("div", "ovib-header-preview");
const previewLabel = createEl("span", "ovib-preview-label", UI_TEXT.en.previewLabel);
const previewInput = createEl("input", "ovib-preview-input");
previewInput.type = "text";
previewInput.placeholder = UI_TEXT.en.emptyPreview;
headerPreview.append(previewLabel, previewInput);
header.appendChild(headerPreview);
const headerControls = createEl("div", "ovib-header-controls");
const presetSelect = createEl("select", "ovib-preset-select");
const editPresetButton = createEl("button", "ovib-icon-button", UI_TEXT.en.editPreset);
editPresetButton.type = "button";
headerControls.append(presetSelect, editPresetButton);
header.appendChild(headerControls);
const localeSwitch = createEl("div", "ovib-locale-switch");
const localeEnglishButton = createEl("button", "ovib-locale-chip is-active", UI_TEXT.en.localeEn);
localeEnglishButton.type = "button";
const localeChineseButton = createEl("button", "ovib-locale-chip", UI_TEXT.en.localeZh);
localeChineseButton.type = "button";
localeSwitch.append(localeEnglishButton, localeChineseButton);
header.appendChild(localeSwitch);
shell.appendChild(header);
const body = createEl("div", "ovib-body");
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.classList.add("ovib-svg");
svg.innerHTML = `
<defs>
<linearGradient id="ovibPathGradient" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#98cbff"></stop>
<stop offset="58%" stop-color="#00a3ff"></stop>
<stop offset="100%" stop-color="#6ffbbe"></stop>
</linearGradient>
</defs>
<path class="ovib-path" d=""></path>
`;
body.appendChild(svg);
const grid = createEl("div", "ovib-grid");
body.appendChild(grid);
shell.appendChild(body);
const modalBackdrop = createEl("div", "ovib-modal-backdrop");
const modal = createEl("div", "ovib-modal");
const modalHeader = createEl("div", "ovib-modal-header");
const modalTitle = createEl("div", "ovib-modal-title", UI_TEXT.en.editPresetTitle);
const modalHeaderActions = createEl("div", "ovib-modal-actions");
const modalDeleteButton = createEl("button", "ovib-icon-button", UI_TEXT.en.deletePreset);
modalDeleteButton.type = "button";
const modalSaveButton = createEl("button", "ovib-icon-button", UI_TEXT.en.save);
modalSaveButton.type = "button";
const modalCancelButton = createEl("button", "ovib-icon-button", UI_TEXT.en.cancel);
modalCancelButton.type = "button";
modalHeaderActions.append(modalDeleteButton, modalSaveButton, modalCancelButton);
modalHeader.append(modalTitle, modalHeaderActions);
modal.appendChild(modalHeader);
const presetNameStack = createEl("div", "ovib-stack");
const presetNameLabel = createEl("label", "ovib-form-label", UI_TEXT.en.presetName);
const presetNameInput = createEl("input", "ovib-form-input");
presetNameInput.type = "text";
presetNameStack.append(presetNameLabel, presetNameInput);
modal.appendChild(presetNameStack);
const columnsEditor = createEl("div", "ovib-columns-editor");
modal.appendChild(columnsEditor);
const modalFooter = createEl("div", "ovib-modal-actions");
const addSingleColumnButton = createEl("button", "ovib-icon-button", UI_TEXT.en.addColumn);
addSingleColumnButton.type = "button";
const addSwitchColumnButton = createEl("button", "ovib-icon-button", UI_TEXT.en.addSwitch);
addSwitchColumnButton.type = "button";
modalFooter.append(addSingleColumnButton, addSwitchColumnButton);
modal.appendChild(modalFooter);
modalBackdrop.appendChild(modal);
panel.appendChild(modalBackdrop);
return {
panel,
body,
grid,
svg,
path: svg.querySelector(".ovib-path"),
previewLabel,
previewInput,
presetSelect,
editPresetButton,
localeEnglishButton,
localeChineseButton,
localeSwitch,
modalBackdrop,
modalTitle,
presetNameLabel,
presetNameInput,
columnsEditor,
modalDeleteButton,
addSingleColumnButton,
addSwitchColumnButton,
modalSaveButton,
modalCancelButton,
chipButtons: new Map(),
columns: new Map(),
columnTitles: new Map(),
switchGroups: new Map(),
switchModeButtons: new Map(),
columnDefsById: new Map(),
renderedColumnIds: [],
renderedPresetId: "",
renderedPresetSignature: "",
columnOrder: [],
columnOffsets: {},
previewCommitTimer: null,
previewInputDirty: false,
dragState: null,
suppressNextClick: false,
pendingClickToken: null,
modalDraft: null,
modalLocale: "en",
modalModeSelection: {},
};
}
function getCanvasViewportRect() {
const canvasEl = window.app?.canvas?.canvas || window.app?.canvas?.canvasEl;
if (canvasEl?.getBoundingClientRect) {
return canvasEl.getBoundingClientRect();
}
return null;
}
function getCanvasScale() {
const scale = Number(
window.app?.canvas?.ds?.scale
|| window.app?.graph?.canvas?.ds?.scale
|| 1
);
return Number.isFinite(scale) && scale > 0 ? scale : 1;
}
function getMovementBoundsRect(ui) {
const bodyRect = ui.body.getBoundingClientRect();
const canvasRect = getCanvasViewportRect();
if (!canvasRect) {
return bodyRect;
}
const left = Math.max(bodyRect.left, canvasRect.left);
const top = Math.max(bodyRect.top, canvasRect.top);
const right = Math.min(bodyRect.right, canvasRect.right);
const bottom = Math.min(bodyRect.bottom, canvasRect.bottom);
if (right <= left || bottom <= top) {
return bodyRect;
}
return { left, top, right, bottom };
}
function getColumnOffset(ui, columnId) {
return normalizeColumnOffset(ui.columnOffsets?.[columnId]);
}
function setColumnOffset(ui, columnId, offset) {
ui.columnOffsets[columnId] = normalizeColumnOffset(offset);
}
function applyColumnLayout(ui) {
for (const [columnId, column] of ui.columns.entries()) {
const slotIndex = ui.columnOrder.indexOf(columnId);
if (slotIndex >= 0) {
column.style.order = String(slotIndex);
}
const offset = getColumnOffset(ui, columnId);
column.style.transform = `translate(${Math.round(offset.x)}px, ${Math.round(offset.y)}px)`;
column.classList.toggle("is-dragging", ui.dragState?.active && ui.dragState.columnId === columnId);
}
}
function getColumnCenterX(column) {
const rect = column.getBoundingClientRect();
return rect.left + rect.width / 2;
}
function clampColumnOffsetX(ui, columnId, desiredX) {
const column = ui.columns.get(columnId);
if (!column) {
return desiredX;
}
const boundsRect = getMovementBoundsRect(ui);
const rect = column.getBoundingClientRect();
const currentOffset = getColumnOffset(ui, columnId);
const scale = getCanvasScale();
const naturalLeft = rect.left - (currentOffset.x * scale);
const naturalRight = rect.right - (currentOffset.x * scale);
const padding = 8;
const minX = (boundsRect.left + padding - naturalLeft) / scale;
const maxX = (boundsRect.right - padding - naturalRight) / scale;
return Math.max(minX, Math.min(maxX, desiredX));
}
function clampColumnOffsetY(ui, columnId, desiredY) {
const column = ui.columns.get(columnId);
if (!column) {
return desiredY;
}
const boundsRect = getMovementBoundsRect(ui);
const rect = column.getBoundingClientRect();
const currentOffset = getColumnOffset(ui, columnId);
const scale = getCanvasScale();
const naturalTop = rect.top - (currentOffset.y * scale);
const naturalBottom = rect.bottom - (currentOffset.y * scale);
const padding = 8;
const minY = (boundsRect.top + padding - naturalTop) / scale;
const maxY = (boundsRect.bottom - padding - naturalBottom) / scale;
return Math.max(minY, Math.min(maxY, desiredY));
}
function queueSuppressNextClick(ui) {
ui.suppressNextClick = true;
setTimeout(() => {
ui.suppressNextClick = false;
}, 0);
}
function normalizeLayoutForPreset(stored, preset) {
const presetColumnIds = getPresetColumnIds(preset);
const completeOrder = normalizeColumnOrder(stored?.columnOrder, presetColumnIds);
const columnOffsets = {};
for (const columnId of presetColumnIds) {
columnOffsets[columnId] = { x: 0, y: 0 };
}
return {
columnOrder: completeOrder,
columnOffsets,
};
}
function createNodeLayoutAdapter(node) {
return {
load(preset) {
const workflowLayoutStore = node.properties?.omnivoiceInstructionLayout?.layouts?.[preset.id];
if (Array.isArray(workflowLayoutStore?.columnOrder) && workflowLayoutStore.columnOrder.length) {
return normalizeLayoutForPreset(workflowLayoutStore, preset);
}
if (preset.id === OMNIVOICE_PRESET_ID) {
const builtinLayoutStore = node.__omnivoiceInstructionBuiltinPresetLayouts?.[preset.id];
return normalizeLayoutForPreset(builtinLayoutStore, preset);
}
return normalizeLayoutForPreset({ columnOrder: preset.columnOrder }, preset);
},
save(preset, ui) {
node.properties = node.properties || {};
node.properties.omnivoiceInstructionLayout = node.properties.omnivoiceInstructionLayout || { layouts: {} };
node.properties.omnivoiceInstructionLayout.layouts[preset.id] = {
columnOrder: [...ui.columnOrder],
};
},
};
}
function createMemoryLayoutAdapter(initialLayout = {}, initialPresetId = OMNIVOICE_PRESET_ID) {
const store = {
layouts: {
[initialPresetId]: {
columnOrder: Array.isArray(initialLayout.columnOrder) ? [...initialLayout.columnOrder] : undefined,
columnOffsets: cloneJson(initialLayout.columnOffsets || {}),
},
},
};
return {
load(preset) {
return normalizeLayoutForPreset(store.layouts[preset.id], preset);
},
save(preset, ui) {
store.layouts[preset.id] = {
columnOrder: [...ui.columnOrder],
};
},
};
}
function syncLayoutForPreset(ui, preset) {
if (!ui.layoutAdapter) {
return;
}
const presetColumnIds = getPresetColumnIds(preset);
const currentIds = Array.isArray(ui.renderedColumnIds) ? ui.renderedColumnIds : [];
const sameIds = currentIds.length === presetColumnIds.length && currentIds.every((id, index) => id === presetColumnIds[index]);
if (ui.activeLayoutPresetId === preset.id && sameIds) {
return;
}
const layout = ui.layoutAdapter.load(preset);
ui.columnOrder = layout.columnOrder;
ui.columnOffsets = layout.columnOffsets;
ui.activeLayoutPresetId = preset.id;
}
function persistCurrentLayout(ui, state) {
if (!ui.layoutAdapter) {
return;
}
const preset = getActivePreset(state);
ui.layoutAdapter.save(preset, ui);
}
function populatePresetSelect(ui, state) {
const presets = getAvailablePresets(state);
ui.presetSelect.innerHTML = "";
for (const preset of presets) {
const option = document.createElement("option");
option.value = preset.id;
option.textContent = preset.name;
option.selected = preset.id === state.selectedPresetId;
ui.presetSelect.appendChild(option);
}
}
function applyLocalizedUiText(ui, state, preset) {
const locale = getPresetOutputLocale(preset, state);
const text = UI_TEXT[locale];
ui.previewLabel.textContent = text.previewLabel;
ui.previewInput.placeholder = text.emptyPreview;
ui.editPresetButton.textContent = text.editPreset;
ui.presetNameLabel.textContent = text.presetName;
ui.localeEnglishButton.textContent = text.localeEn;
ui.localeChineseButton.textContent = text.localeZh;
ui.addSingleColumnButton.textContent = text.addColumn;
ui.addSwitchColumnButton.textContent = text.addSwitch;
ui.modalSaveButton.textContent = text.save;
ui.modalCancelButton.textContent = text.cancel;
ui.localeEnglishButton.classList.toggle("is-active", locale === "en");
ui.localeChineseButton.classList.toggle("is-active", locale === "zh");
ui.localeSwitch.style.display = isOmniVoicePreset(preset) ? "grid" : "none";
for (const [columnId, titleEl] of ui.columnTitles.entries()) {
const column = ui.columnDefsById.get(columnId);
const title = getColumnTitleForLocale(column, locale);
titleEl.textContent = title || "";
titleEl.classList.toggle("is-empty", !title);
}
for (const [key, group] of ui.switchGroups.entries()) {
const [columnId, modeId] = key.split(":");
const column = ui.columnDefsById.get(columnId);
const mode = getModeById(column, modeId);
if (mode) {
const title = getModeTitleForLocale(mode, locale);
group.subtitle.textContent = title || "";
group.subtitle.classList.toggle("is-empty", !title);
}
}
for (const [key, button] of ui.switchModeButtons.entries()) {
const [columnId, modeId] = key.split(":");
const column = ui.columnDefsById.get(columnId);
const mode = getModeById(column, modeId);
if (mode) {
button.textContent = getModeTitleForLocale(mode, locale);
}
}
for (const button of ui.chipButtons.values()) {
button.textContent = getOptionLabelForLocale({
label: button.dataset.labelEn,
zhLabel: button.dataset.labelZh,
value: button.dataset.value,
}, locale);
button.title = getOptionTitleForLocale({
title: button.dataset.titleEn,
zhTitle: button.dataset.titleZh,
zhLabel: button.dataset.labelZh,
value: button.dataset.value,
}, locale);
}
}
function rebuildPresetColumns(ui, preset, state) {
syncLayoutForPreset(ui, preset);
const locale = getPresetOutputLocale(preset, state);
const presetColumnIds = getPresetColumnIds(preset);
const presetSignature = JSON.stringify(preset.columns || []);
const sameIds = ui.renderedPresetId === preset.id
&& ui.renderedPresetSignature === presetSignature
&& ui.renderedColumnIds.length === presetColumnIds.length
&& ui.renderedColumnIds.every((id, index) => id === presetColumnIds[index]);
if (sameIds) {
return;
}
ui.grid.innerHTML = "";
ui.chipButtons = new Map();
ui.columns = new Map();
ui.columnTitles = new Map();
ui.switchGroups = new Map();
ui.switchModeButtons = new Map();
ui.columnDefsById = new Map();
for (const column of preset.columns) {
ui.columnDefsById.set(column.id, column);
const columnEl = createEl("div", `ovib-column${column.type === "switch" ? " ovib-language-column" : ""}`);
columnEl.dataset.columnId = column.id;
ui.columns.set(column.id, columnEl);
const title = getColumnTitleForLocale(column, locale);
const titleEl = createEl("div", "ovib-column-title", title);
titleEl.classList.toggle("is-empty", !title);
columnEl.appendChild(titleEl);
ui.columnTitles.set(column.id, titleEl);
if (column.type === "switch") {
const stack = createEl("div", "ovib-language-stack");
const modeSwitch = createEl("div", "ovib-mode-switch");
for (const mode of column.modes) {
const modeButton = createEl("button", "ovib-mode-chip", getModeTitleForLocale(mode, locale));
modeButton.type = "button";
modeButton.dataset.columnId = column.id;
modeButton.dataset.modeId = mode.id;
ui.switchModeButtons.set(`${column.id}:${mode.id}`, modeButton);
modeSwitch.appendChild(modeButton);
}
stack.appendChild(modeSwitch);
for (const mode of column.modes) {
const group = createEl("div", "ovib-language-group");
group.dataset.columnId = column.id;
group.dataset.modeId = mode.id;
const subtitle = createEl("div", "ovib-subtitle", getModeTitleForLocale(mode, locale));
group.appendChild(subtitle);
const list = createEl("div", "ovib-chip-list");
for (const option of mode.options) {
const chip = createChip(option, column.id, mode.id);
ui.chipButtons.set(`${column.id}:${mode.id}:${option.value}`, chip);
list.appendChild(chip);
}
group.append(list);
stack.appendChild(group);
ui.switchGroups.set(`${column.id}:${mode.id}`, { element: group, subtitle });
}
columnEl.appendChild(stack);
} else {
const list = createEl("div", "ovib-chip-list");
for (const option of column.options) {
const chip = createChip(option, column.id);
ui.chipButtons.set(`${column.id}:${option.value}`, chip);
list.appendChild(chip);
}
columnEl.appendChild(list);
}
ui.grid.appendChild(columnEl);
}
ui.renderedPresetId = preset.id;
ui.renderedPresetSignature = presetSignature;
ui.renderedColumnIds = [...presetColumnIds];
applyColumnLayout(ui);
}
function renderState(node, ui, state) {
const preset = getActivePreset(state);
const bucket = getPresetStateBucket(state, preset.id);
rebuildPresetColumns(ui, preset, state);
populatePresetSelect(ui, state);
applyLocalizedUiText(ui, state, preset);
for (const button of ui.chipButtons.values()) {
const columnId = button.dataset.columnId;
const modeId = button.dataset.modeId;
const isActive = modeId
? bucket.switchModes[columnId] === modeId && normalizeWidgetValue(bucket.values[columnId]) === button.dataset.value
: normalizeWidgetValue(bucket.values[columnId]) === button.dataset.value;
button.classList.toggle("is-active", isActive);
button.classList.remove("is-disabled");
}
for (const columnId of getPresetColumnIds(preset)) {
const column = ui.columns.get(columnId);
if (!column) {
continue;
}
const isDraggable = Boolean(normalizeWidgetValue(bucket.values[columnId]));
column.classList.toggle("is-draggable", isDraggable);
if (!isDraggable && !ui.dragState?.active) {
setColumnOffset(ui, columnId, { x: 0, y: 0 });
}
}
for (const column of preset.columns) {
if (column.type !== "switch") {
continue;
}
const activeMode = getModeById(column, bucket.switchModes[column.id])?.id || column.modes?.[0]?.id || "";
bucket.switchModes[column.id] = activeMode;
for (const mode of column.modes) {
const group = ui.switchGroups.get(`${column.id}:${mode.id}`);
const modeButton = ui.switchModeButtons.get(`${column.id}:${mode.id}`);
if (group) {
group.element.classList.toggle("is-hidden", mode.id !== activeMode);
}
if (modeButton) {
modeButton.classList.toggle("is-active", mode.id === activeMode);
}
}
}
const preview = buildPreviewFromState(state, preset, ui.columnOrder);
if (!ui.previewInputDirty && ui.previewInput.value !== preview) {
ui.previewInput.value = preview;
}
}
function drawPath(ui, state) {
const preset = getActivePreset(state);
const bucket = getPresetStateBucket(state, preset.id);
const scale = getCanvasScale();
ui.path.style.strokeWidth = `${PATH_BASE_STROKE_WIDTH * scale}px`;
const bodyRect = ui.body.getBoundingClientRect();
const svgWidth = Math.max(1, Math.round(bodyRect.width));
const svgHeight = Math.max(1, Math.round(bodyRect.height));
ui.svg.setAttribute("viewBox", `0 0 ${svgWidth} ${svgHeight}`);
ui.svg.setAttribute("width", String(svgWidth));
ui.svg.setAttribute("height", String(svgHeight));
ui.svg.setAttribute("preserveAspectRatio", "none");
const selected = [];
for (const columnId of ui.columnOrder) {
const column = getColumnById(preset, columnId);
if (!column) {
continue;
}
if (column.type === "switch") {
const activeMode = getModeById(column, bucket.switchModes[columnId])?.id || "";
const value = normalizeWidgetValue(bucket.values[columnId]);
if (activeMode && value) {
const button = ui.chipButtons.get(`${columnId}:${activeMode}:${value}`);
if (button?.getClientRects().length > 0) {
selected.push(button);
}
}
continue;
}
const value = normalizeWidgetValue(bucket.values[columnId]);
if (!value) {
continue;
}
const button = ui.chipButtons.get(`${columnId}:${value}`);
if (button?.getClientRects().length > 0) {
selected.push(button);
}
}
if (selected.length < 2) {
ui.path.setAttribute("d", "");
return;
}
const segments = [];
for (let index = 0; index < selected.length - 1; index += 1) {
const sourceRect = selected[index].getBoundingClientRect();
const targetRect = selected[index + 1].getBoundingClientRect();
segments.push({
x1: sourceRect.right - bodyRect.left + 3,
y1: sourceRect.top - bodyRect.top + sourceRect.height / 2,
x2: targetRect.left - bodyRect.left - 3,
y2: targetRect.top - bodyRect.top + targetRect.height / 2,
});
}
let pathData = "";
for (const segment of segments) {
const dx = segment.x2 - segment.x1;
const minHandle = 28 * scale;
const maxHandle = 64 * scale;
const handle = Math.max(minHandle, Math.min(maxHandle, Math.abs(dx) * 0.35));
const control1X = segment.x1 + handle;
const control2X = segment.x2 - handle;
pathData += `M ${segment.x1} ${segment.y1} C ${control1X} ${segment.y1}, ${control2X} ${segment.y2}, ${segment.x2} ${segment.y2} `;
}
ui.path.setAttribute("d", pathData.trim());
}
function setWidgetHeightSafe(widget, height) {
if (!widget) {
return;
}
try {
widget.height = height;
} catch {
// Some ComfyUI builds expose BaseWidget.height as getter-only.
}
try {
widget.computedHeight = height;
} catch {
// Ignore readonly implementations.
}
if (widget.element) {
widget.element.style.height = `${height}px`;
widget.element.style.minHeight = `${height}px`;
widget.element.style.maxHeight = `${height}px`;
}
}
function updateBasePanelHeight(node, panelWidget, force = false) {
if (!force && Number(node.__omnivoiceInstructionBasePanelHeight || 0) > 0) {
return;
}
const nodeHeight = Math.max(PANEL_MIN_HEIGHT, Number(node?.size?.[1] || 0));
const layoutY = Math.max(0, Number(panelWidget?.last_y || 0));
const extraHeight = Math.max(0, nodeHeight - PANEL_MIN_HEIGHT);
const measuredHeight = Math.max(PANEL_WIDGET_MIN_HEIGHT, Math.round(nodeHeight - layoutY - PANEL_BOTTOM_PADDING));
const baselineHeight = Math.max(PANEL_WIDGET_MIN_HEIGHT, measuredHeight - extraHeight);
node.__omnivoiceInstructionBasePanelHeight = baselineHeight;
}
function getRequiredNodeMinHeight(node) {
const layoutY = Math.max(0, Number(node.__omnivoiceInstructionLastY || 0));
return Math.max(
PANEL_WIDGET_MIN_HEIGHT + PANEL_BOTTOM_PADDING,
Math.round(layoutY + PANEL_WIDGET_MIN_HEIGHT + PANEL_BOTTOM_PADDING),
);
}
function getTargetPanelHeight(node) {
const nodeHeight = Math.max(getRequiredNodeMinHeight(node), Number(node?.size?.[1] || 0));
const layoutY = Math.max(0, Number(node.__omnivoiceInstructionLastY || 0));
return Math.max(PANEL_WIDGET_MIN_HEIGHT, Math.round(nodeHeight - layoutY - PANEL_BOTTOM_PADDING));
}
function resizePanel(node, ui, panelWidget) {
if (!panelWidget) {
return;
}
requestAnimationFrame(() => {
const targetPanelHeight = getTargetPanelHeight(node);
const panelHeightChanged = Math.abs(Number(node.__omnivoiceInstructionAppliedPanelHeight || 0) - targetPanelHeight) > 1;
if (panelHeightChanged) {
setWidgetHeightSafe(panelWidget, PANEL_WIDGET_MIN_HEIGHT);
}
if (panelWidget.element) {
panelWidget.element.style.width = "100%";
panelWidget.element.style.maxWidth = "100%";
panelWidget.element.style.minWidth = "100%";
panelWidget.element.style.height = `${targetPanelHeight}px`;
panelWidget.element.style.minHeight = `${targetPanelHeight}px`;
panelWidget.element.style.maxHeight = `${targetPanelHeight}px`;
panelWidget.element.style.overflow = "visible";
panelWidget.element.style.display = "block";
panelWidget.element.style.position = "relative";
panelWidget.element.style.boxSizing = "border-box";
panelWidget.element.style.margin = "0";
panelWidget.element.style.padding = "0";
panelWidget.element.style.alignSelf = "stretch";
}
node.__omnivoiceInstructionAppliedPanelHeight = targetPanelHeight;
if (panelHeightChanged) {
node.graph?.setDirtyCanvas?.(true, true);
drawPath(ui, node.__omnivoiceInstructionState || {});
}
});
}
function swapColumns(ui, sourceIndex, targetIndex) {
if (sourceIndex < 0 || targetIndex < 0 || sourceIndex >= ui.columnOrder.length || targetIndex >= ui.columnOrder.length) {
return false;
}
const sourceId = ui.columnOrder[sourceIndex];
const draggedColumn = ui.columns.get(sourceId);
if (!draggedColumn) {
return false;
}
const oldCenter = getColumnCenterX(draggedColumn);
const nextOrder = [...ui.columnOrder];
const [moved] = nextOrder.splice(sourceIndex, 1);
nextOrder.splice(targetIndex, 0, moved);
ui.columnOrder = nextOrder;
applyColumnLayout(ui);
const newCenter = getColumnCenterX(draggedColumn);
const offset = getColumnOffset(ui, sourceId);
const scale = getCanvasScale();
setColumnOffset(ui, sourceId, {
x: clampColumnOffsetX(ui, sourceId, offset.x + ((oldCenter - newCenter) / scale)),
y: clampColumnOffsetY(ui, sourceId, offset.y),
});
applyColumnLayout(ui);
return true;
}
function beginColumnDrag(ui, event, columnId, selectionTarget = null, selectionChangedOnPointerDown = false) {
ui.dragState = {
active: true,
pointerId: event.pointerId,
columnId,
startX: event.clientX,
startY: event.clientY,
moved: false,
baseOffsetX: getColumnOffset(ui, columnId).x,
baseOffsetY: getColumnOffset(ui, columnId).y,
selectionTarget,
selectionChangedOnPointerDown,
toggleOffOnPointerEnd: false,
};
event.currentTarget?.setPointerCapture?.(event.pointerId);
event.preventDefault();
}
function applyDragSelection(node, state, selectionTarget) {
if (!selectionTarget) {
return state;
}
const preset = getActivePreset(state);
setPresetColumnValue(
state,
preset,
selectionTarget.columnId,
selectionTarget.value,
false,
selectionTarget.modeId,
);
applyStateToWidgets(node, state, node.__omnivoiceInstructionUi || null);
node.__omnivoiceInstructionState = state;
return state;
}
function setupColumnDragging(node, ui) {
const handlePointerMove = (event) => {
const dragState = ui.dragState;
if (!dragState?.active || event.pointerId !== dragState.pointerId) {
return;
}
const scale = getCanvasScale();
const deltaX = (event.clientX - dragState.startX) / scale;
const deltaY = (event.clientY - dragState.startY) / scale;
if (!dragState.moved && Math.hypot(deltaX, deltaY) < DRAG_START_THRESHOLD) {
return;
}
dragState.moved = true;
const columnId = dragState.columnId;
const column = ui.columns.get(columnId);
if (!column) {
return;
}
setColumnOffset(ui, columnId, {
x: clampColumnOffsetX(ui, columnId, dragState.baseOffsetX + deltaX),
y: clampColumnOffsetY(ui, columnId, dragState.baseOffsetY + deltaY),
});
applyColumnLayout(ui);
const currentIndex = ui.columnOrder.indexOf(columnId);
const currentCenter = getColumnCenterX(column);
let swapped = false;
if (currentIndex > 0) {
const leftId = ui.columnOrder[currentIndex - 1];
const leftColumn = ui.columns.get(leftId);
if (leftColumn && currentCenter < getColumnCenterX(leftColumn)) {
swapped = swapColumns(ui, currentIndex, currentIndex - 1);
}
}
const nextIndex = ui.columnOrder.indexOf(columnId);
if (!swapped && nextIndex < ui.columnOrder.length - 1) {
const rightId = ui.columnOrder[nextIndex + 1];
const rightColumn = ui.columns.get(rightId);
if (rightColumn && currentCenter > getColumnCenterX(rightColumn)) {
swapped = swapColumns(ui, nextIndex, nextIndex + 1);
}
}
if (swapped) {
dragState.startX = event.clientX;
dragState.startY = event.clientY;
dragState.baseOffsetX = getColumnOffset(ui, columnId).x;
dragState.baseOffsetY = getColumnOffset(ui, columnId).y;
}
applyColumnLayout(ui);
drawPath(ui, node.__omnivoiceInstructionState || syncStateFromWidgets(node));
};
const handlePointerEnd = (event) => {
const dragState = ui.dragState;
if (!dragState?.active || event.pointerId !== dragState.pointerId) {
return;
}
const columnId = dragState.columnId;
let state = node.__omnivoiceInstructionState || syncStateFromWidgets(node);
if (dragState.moved) {
state = applyDragSelection(node, state, dragState.selectionTarget);
} else if (dragState.toggleOffOnPointerEnd && dragState.selectionTarget) {
const preset = getActivePreset(state);
setPresetColumnValue(
state,
preset,
dragState.selectionTarget.columnId,
dragState.selectionTarget.value,
true,
dragState.selectionTarget.modeId,
);
applyStateToWidgets(node, state, node.__omnivoiceInstructionUi || null);
node.__omnivoiceInstructionState = state;
}
const preset = getActivePreset(state);
const bucket = getPresetStateBucket(state, preset.id);
const stillSelected = Boolean(normalizeWidgetValue(bucket.values[columnId]));
const currentOffset = getColumnOffset(ui, columnId);
const clampedOffset = stillSelected
? {
x: clampColumnOffsetX(
ui,
columnId,
Math.max(-RESTING_OFFSET_LIMIT, Math.min(RESTING_OFFSET_LIMIT, currentOffset.x)),
),
y: clampColumnOffsetY(ui, columnId, currentOffset.y),
}
: { x: 0, y: 0 };
setColumnOffset(ui, columnId, clampedOffset);
ui.dragState = null;
applyColumnLayout(ui);
drawPath(ui, state);
if (dragState.moved || dragState.selectionChangedOnPointerDown || dragState.toggleOffOnPointerEnd) {
queueSuppressNextClick(ui);
}
persistCurrentLayout(ui, state);
node.__omnivoiceInstructionRefresh?.();
};
ui.handlePointerMove = handlePointerMove;
ui.handlePointerEnd = handlePointerEnd;
window.addEventListener("pointermove", handlePointerMove);
window.addEventListener("pointerup", handlePointerEnd);
window.addEventListener("pointercancel", handlePointerEnd);
}
function buildModeEditor(mode, columnIndex, modeIndex, locale) {
const text = UI_TEXT[locale];
const card = createEl("div", "ovib-mode-card");
const header = createEl("div", "ovib-mode-card-header");
header.appendChild(createEl("div", "ovib-mode-card-title", `${text.modeTitle} ${modeIndex + 1}`));
const removeButton = createEl("button", "ovib-icon-button", text.remove);
removeButton.type = "button";
removeButton.dataset.action = "remove-mode";
removeButton.dataset.columnIndex = String(columnIndex);
removeButton.dataset.modeIndex = String(modeIndex);
header.appendChild(removeButton);
card.appendChild(header);
const titleStack = createEl("div", "ovib-stack");
titleStack.appendChild(createEl("label", "ovib-form-label", text.modeTitle));
const titleInput = createEl("input", "ovib-form-input");
titleInput.type = "text";
titleInput.dataset.action = "mode-title";
titleInput.dataset.columnIndex = String(columnIndex);
titleInput.dataset.modeIndex = String(modeIndex);
titleInput.value = mode.title || "";
titleStack.appendChild(titleInput);
card.appendChild(titleStack);
const optionsStack = createEl("div", "ovib-stack");
optionsStack.appendChild(createEl("label", "ovib-form-label", text.options));
const optionsHint = createEl("div", "ovib-form-hint", text.optionsHint);
const optionsInput = createEl("textarea", "ovib-form-textarea");
optionsInput.dataset.action = "mode-options";
optionsInput.dataset.columnIndex = String(columnIndex);
optionsInput.dataset.modeIndex = String(modeIndex);
optionsInput.value = (mode.options || []).map((option) => (
option.label && option.value && option.label !== option.value
? `${option.label}=${option.value}`
: (option.value || option.label || "")
)).filter(Boolean).join("\n");
optionsStack.append(optionsHint, optionsInput);
card.appendChild(optionsStack);
return card;
}
function getActiveModalModeIndex(ui, columnIndex, modes = []) {
if (!modes.length) {
return 0;
}
const requested = Number(ui.modalModeSelection?.[columnIndex]);
if (Number.isInteger(requested) && requested >= 0 && requested < modes.length) {
return requested;
}
return 0;
}
function renderPresetEditor(ui, state) {
const locale = normalizeWidgetValue(state.output_language) === "Chinese" ? "zh" : "en";
const text = UI_TEXT[locale];
const draft = ui.modalDraft;
ui.modalLocale = locale;
ui.modalTitle.textContent = ui.modalEditingPresetId ? text.editPresetTitle : text.createPresetTitle;
ui.presetNameLabel.textContent = text.presetName;
ui.modalDeleteButton.textContent = text.deletePreset;
ui.modalSaveButton.textContent = text.save;
ui.modalCancelButton.textContent = text.cancel;
ui.addSingleColumnButton.textContent = text.addColumn;
ui.addSwitchColumnButton.textContent = text.addSwitch;
ui.modalDeleteButton.style.display = ui.modalEditingPresetId ? "" : "none";
ui.presetNameInput.value = draft?.name || "";
ui.columnsEditor.innerHTML = "";
(draft?.columns || []).forEach((column, columnIndex) => {
const card = createEl("div", "ovib-column-card");
const header = createEl("div", "ovib-column-card-header");
header.appendChild(createEl("div", "ovib-column-card-title", column.title || text.columnTitle));
const removeButton = createEl("button", "ovib-icon-button", text.remove);
removeButton.type = "button";
removeButton.dataset.action = "remove-column";
removeButton.dataset.columnIndex = String(columnIndex);
header.appendChild(removeButton);
card.appendChild(header);
const titleStack = createEl("div", "ovib-stack");
const topRow = createEl("div", "ovib-card-topline");
const typeSelect = createEl("select", "ovib-form-select");
typeSelect.dataset.action = "column-type";
typeSelect.dataset.columnIndex = String(columnIndex);
const singleOption = document.createElement("option");
singleOption.value = "single";
singleOption.textContent = text.single;
const switchOption = document.createElement("option");
switchOption.value = "switch";
switchOption.textContent = text.switch;
typeSelect.append(singleOption, switchOption);
typeSelect.value = column.type === "switch" ? "switch" : "single";
const titleInput = createEl("input", "ovib-form-input");
titleInput.type = "text";
titleInput.dataset.action = "column-title";
titleInput.dataset.columnIndex = String(columnIndex);
titleInput.placeholder = text.columnTitle;
titleInput.value = column.title || "";
topRow.append(typeSelect, titleInput);
titleStack.appendChild(topRow);
card.appendChild(titleStack);
if (column.type === "switch") {
const modes = column.modes || [];
const activeModeIndex = getActiveModalModeIndex(ui, columnIndex, modes);
if (modes.length) {
const modeSwitch = createEl("div", "ovib-mode-switch");
modes.forEach((mode, modeIndex) => {
const modeButton = createEl("button", `ovib-mode-chip${modeIndex === activeModeIndex ? " is-active" : ""}`, mode.title || `${text.modeTitle} ${modeIndex + 1}`);
modeButton.type = "button";
modeButton.dataset.action = "select-mode";
modeButton.dataset.columnIndex = String(columnIndex);
modeButton.dataset.modeIndex = String(modeIndex);
modeSwitch.appendChild(modeButton);
});
card.appendChild(modeSwitch);
card.appendChild(buildModeEditor(modes[activeModeIndex], columnIndex, activeModeIndex, locale));
}
const addModeButton = createEl("button", "ovib-icon-button", text.addMode);
addModeButton.type = "button";
addModeButton.dataset.action = "add-mode";
addModeButton.dataset.columnIndex = String(columnIndex);
card.appendChild(addModeButton);
} else {
const optionsStack = createEl("div", "ovib-stack");
optionsStack.appendChild(createEl("label", "ovib-form-label", text.options));
optionsStack.appendChild(createEl("div", "ovib-form-hint", text.optionsHint));
const optionsInput = createEl("textarea", "ovib-form-textarea");
optionsInput.dataset.action = "column-options";
optionsInput.dataset.columnIndex = String(columnIndex);
optionsInput.value = (column.options || []).map((option) => (
option.label && option.value && option.label !== option.value
? `${option.label}=${option.value}`
: (option.value || option.label || "")
)).filter(Boolean).join("\n");
optionsStack.appendChild(optionsInput);
card.appendChild(optionsStack);
}
ui.columnsEditor.appendChild(card);
});
}
function openPresetEditor(node, ui) {
const state = node.__omnivoiceInstructionState || syncStateFromWidgets(node);
const activePreset = getActivePreset(state);
const editableExisting = activePreset.id !== OMNIVOICE_PRESET_ID && activePreset.id !== TEMP_TEXT_PRESET_ID;
ui.modalEditingPresetId = editableExisting ? activePreset.id : "";
ui.modalDraft = createPresetDraftFromPreset(
activePreset,
editableExisting,
);
if (!editableExisting) {
ui.modalDraft.name = activePreset.id === TEMP_TEXT_PRESET_ID ? "Temporary Text" : `${activePreset.name} Copy`;
ui.modalDraft.id = "";
ui.modalDraft.outputMode = "plain";
}
ui.modalModeSelection = {};
renderPresetEditor(ui, state);
ui.modalBackdrop.classList.add("is-open");
}
function closePresetEditor(ui) {
ui.modalBackdrop.classList.remove("is-open");
ui.modalDraft = null;
ui.modalEditingPresetId = "";
ui.modalModeSelection = {};
}
function updateDraftFromInput(ui, target) {
const draft = ui.modalDraft;
if (!draft) {
return;
}
const columnIndex = Number(target.dataset.columnIndex);
const modeIndex = Number(target.dataset.modeIndex);
if (target.dataset.action === "column-title" && draft.columns[columnIndex]) {
draft.columns[columnIndex].title = target.value;
} else if (target.dataset.action === "column-type" && draft.columns[columnIndex]) {
const currentColumn = draft.columns[columnIndex];
if (target.value === "switch") {
draft.columns[columnIndex] = {
id: currentColumn.id || slugifyId(currentColumn.title || `switch_${columnIndex + 1}`, `switch_${columnIndex + 1}`),
title: currentColumn.title || "",
type: "switch",
modes: currentColumn.type === "switch" && currentColumn.modes?.length
? currentColumn.modes
: createEmptySwitchColumn(columnIndex).modes,
};
} else {
draft.columns[columnIndex] = {
id: currentColumn.id || slugifyId(currentColumn.title || `column_${columnIndex + 1}`, `column_${columnIndex + 1}`),
title: currentColumn.title || "",
type: "single",
options: currentColumn.type === "single" && currentColumn.options?.length
? currentColumn.options
: [{ id: "option_1", label: "", value: "", title: "" }],
};
}
delete ui.modalModeSelection[columnIndex];
renderPresetEditor(ui, {
output_language: ui.modalLocale === "zh" ? "Chinese" : "English",
});
} else if (target.dataset.action === "column-options" && draft.columns[columnIndex]) {
draft.columns[columnIndex].options = parsePresetOptionLines(target.value);
} else if (target.dataset.action === "mode-title" && draft.columns[columnIndex]?.modes?.[modeIndex]) {
draft.columns[columnIndex].modes[modeIndex].title = target.value;
} else if (target.dataset.action === "mode-options" && draft.columns[columnIndex]?.modes?.[modeIndex]) {
draft.columns[columnIndex].modes[modeIndex].options = parsePresetOptionLines(target.value);
}
}
function handleDraftButtonAction(ui, target, state) {
const draft = ui.modalDraft;
if (!draft) {
return;
}
const columnIndex = Number(target.dataset.columnIndex);
const modeIndex = Number(target.dataset.modeIndex);
if (target.dataset.action === "remove-column") {
draft.columns.splice(columnIndex, 1);
ui.modalModeSelection = {};
renderPresetEditor(ui, state);
} else if (target.dataset.action === "select-mode" && draft.columns[columnIndex]?.modes?.[modeIndex]) {
ui.modalModeSelection[columnIndex] = modeIndex;
renderPresetEditor(ui, state);
} else if (target.dataset.action === "add-mode" && draft.columns[columnIndex]) {
draft.columns[columnIndex].modes = draft.columns[columnIndex].modes || [];
draft.columns[columnIndex].modes.push({
id: `mode_${draft.columns[columnIndex].modes.length + 1}`,
title: `Mode ${draft.columns[columnIndex].modes.length + 1}`,
options: [{ id: "option_1", label: "", value: "", title: "" }],
});
ui.modalModeSelection[columnIndex] = draft.columns[columnIndex].modes.length - 1;
renderPresetEditor(ui, state);
} else if (target.dataset.action === "remove-mode" && draft.columns[columnIndex]?.modes?.length) {
draft.columns[columnIndex].modes.splice(modeIndex, 1);
if (!draft.columns[columnIndex].modes.length) {
draft.columns[columnIndex].modes.push({
id: "mode_1",
title: "Mode 1",
options: [{ id: "option_1", label: "", value: "", title: "" }],
});
ui.modalModeSelection[columnIndex] = 0;
} else {
ui.modalModeSelection[columnIndex] = Math.max(0, Math.min(modeIndex - 1, draft.columns[columnIndex].modes.length - 1));
}
renderPresetEditor(ui, state);
}
}
async function savePresetEditor(node, ui) {
const draft = ui.modalDraft;
if (!draft) {
return;
}
const state = node.__omnivoiceInstructionState || syncStateFromWidgets(node);
draft.name = ui.presetNameInput.value.trim() || draft.name || "Preset";
const normalized = createPresetFromDraft(draft, ui.modalEditingPresetId || "custom_preset");
if (!normalized.columns.length) {
window.alert("Preset needs at least one valid column.");
return;
}
const previousId = ui.modalEditingPresetId || "";
state.customPresets = (state.customPresets || []).filter((preset) => preset.id !== previousId && preset.id !== normalized.id);
state.customPresets.push(normalized);
if (previousId && previousId !== normalized.id) {
if (state.presetStates?.[previousId] && !state.presetStates[normalized.id]) {
state.presetStates[normalized.id] = state.presetStates[previousId];
}
delete state.presetStates?.[previousId];
}
if (!state.presetStates[normalized.id]) {
seedBucketFromPreset(state, normalized);
}
state.selectedPresetId = normalized.id;
await persistPresetLibraryNow(node, state);
state.customPresets = Array.isArray(node.__omnivoiceInstructionLibraryPresets)
? node.__omnivoiceInstructionLibraryPresets.map((preset, index) => normalizePreset(preset, `custom_preset_${index + 1}`))
: state.customPresets;
applyStateToWidgets(node, state, ui);
node.__omnivoiceInstructionState = state;
closePresetEditor(ui);
node.__omnivoiceInstructionRefresh?.();
}
async function deletePresetEditor(node, ui) {
if (!ui.modalEditingPresetId) {
return;
}
if (!window.confirm("Delete this preset?")) {
return;
}
const state = node.__omnivoiceInstructionState || syncStateFromWidgets(node);
const presetId = ui.modalEditingPresetId;
state.customPresets = (state.customPresets || []).filter((preset) => preset.id !== presetId);
delete state.presetStates?.[presetId];
delete node.properties?.omnivoiceInstructionLayout?.layouts?.[presetId];
if (state.selectedPresetId === presetId) {
state.selectedPresetId = state.temporaryPreset?.id || OMNIVOICE_PRESET_ID;
}
await persistPresetLibraryNow(node, state);
state.customPresets = Array.isArray(node.__omnivoiceInstructionLibraryPresets)
? node.__omnivoiceInstructionLibraryPresets.map((preset, index) => normalizePreset(preset, `custom_preset_${index + 1}`))
: state.customPresets;
applyStateToWidgets(node, state, ui);
node.__omnivoiceInstructionState = state;
closePresetEditor(ui);
node.__omnivoiceInstructionRefresh?.();
}
function bindSharedInteractions(node, ui, refresh, panelWidget = null) {
ui.previewInput.addEventListener("input", () => {
ui.previewInputDirty = true;
clearTimeout(ui.previewCommitTimer);
if (hasTrailingSeparator(ui.previewInput.value)) {
return;
}
ui.previewCommitTimer = setTimeout(() => {
const state = node.__omnivoiceInstructionState || syncStateFromWidgets(node);
applyPreviewTextToState(state, ui.previewInput.value);
ui.previewInputDirty = false;
applyStateToWidgets(node, state, ui);
node.__omnivoiceInstructionState = state;
refresh();
}, 350);
});
ui.previewInput.addEventListener("blur", () => {
if (!ui.previewInputDirty) {
return;
}
clearTimeout(ui.previewCommitTimer);
const state = node.__omnivoiceInstructionState || syncStateFromWidgets(node);
applyPreviewTextToState(state, ui.previewInput.value);
ui.previewInputDirty = false;
applyStateToWidgets(node, state, ui);
node.__omnivoiceInstructionState = state;
refresh();
});
ui.presetSelect.addEventListener("change", () => {
const state = node.__omnivoiceInstructionState || syncStateFromWidgets(node);
state.selectedPresetId = ui.presetSelect.value || OMNIVOICE_PRESET_ID;
const preset = getActivePreset(state);
getPresetStateBucket(state, preset.id);
applyStateToWidgets(node, state, ui);
node.__omnivoiceInstructionState = state;
refresh();
});
ui.editPresetButton.addEventListener("click", () => {
openPresetEditor(node, ui);
});
ui.modalCancelButton.addEventListener("click", () => {
closePresetEditor(ui);
});
ui.modalSaveButton.addEventListener("click", async () => {
try {
await savePresetEditor(node, ui);
} catch (error) {
console.error("Failed to save OmniVoice preset library:", error);
window.alert("Could not save preset library.");
}
});
ui.modalDeleteButton.addEventListener("click", async () => {
try {
await deletePresetEditor(node, ui);
} catch (error) {
console.error("Failed to delete OmniVoice preset from library:", error);
window.alert("Could not delete preset from library.");
}
});
ui.addSingleColumnButton.addEventListener("click", () => {
ui.modalDraft.columns.push(createEmptySingleColumn(ui.modalDraft.columns.length));
renderPresetEditor(ui, node.__omnivoiceInstructionState || syncStateFromWidgets(node));
});
ui.addSwitchColumnButton.addEventListener("click", () => {
ui.modalDraft.columns.push(createEmptySwitchColumn(ui.modalDraft.columns.length));
renderPresetEditor(ui, node.__omnivoiceInstructionState || syncStateFromWidgets(node));
});
ui.columnsEditor.addEventListener("input", (event) => {
const target = event.target;
if (!(target instanceof HTMLElement)) {
return;
}
updateDraftFromInput(ui, target);
});
ui.columnsEditor.addEventListener("change", (event) => {
const target = event.target;
if (!(target instanceof HTMLElement)) {
return;
}
updateDraftFromInput(ui, target);
});
ui.columnsEditor.addEventListener("click", (event) => {
const target = event.target;
if (!(target instanceof HTMLElement)) {
return;
}
if (!target.dataset.action) {
return;
}
handleDraftButtonAction(ui, target, node.__omnivoiceInstructionState || syncStateFromWidgets(node));
});
ui.modalBackdrop.addEventListener("click", (event) => {
if (event.target === ui.modalBackdrop) {
closePresetEditor(ui);
}
});
ui.localeEnglishButton.addEventListener("click", () => {
const state = node.__omnivoiceInstructionState || syncStateFromWidgets(node);
state.output_language = "English";
applyStateToWidgets(node, state, ui);
node.__omnivoiceInstructionState = state;
refresh();
});
ui.localeChineseButton.addEventListener("click", () => {
const state = node.__omnivoiceInstructionState || syncStateFromWidgets(node);
state.output_language = "Chinese";
applyStateToWidgets(node, state, ui);
node.__omnivoiceInstructionState = state;
refresh();
});
ui.grid.addEventListener("pointerdown", (event) => {
const chip = event.target instanceof Element ? event.target.closest(".ovib-chip") : null;
if (!(chip instanceof HTMLElement)) {
return;
}
const state = node.__omnivoiceInstructionState || syncStateFromWidgets(node);
const preset = getActivePreset(state);
const columnId = chip.dataset.columnId;
const modeId = chip.dataset.modeId || "";
const bucket = getPresetStateBucket(state, preset.id);
const currentValue = normalizeWidgetValue(bucket.values[columnId]);
let selectionChangedOnPointerDown = false;
if (currentValue !== chip.dataset.value || (modeId && bucket.switchModes[columnId] !== modeId)) {
setPresetColumnValue(state, preset, columnId, chip.dataset.value, false, modeId);
applyStateToWidgets(node, state, ui);
node.__omnivoiceInstructionState = state;
refresh();
selectionChangedOnPointerDown = true;
ui.pendingClickToken = `${preset.id}:${columnId}:${modeId}:${chip.dataset.value}`;
} else {
ui.pendingClickToken = null;
}
beginColumnDrag(ui, event, columnId, {
columnId,
modeId,
value: chip.dataset.value,
}, selectionChangedOnPointerDown);
if (ui.dragState) {
ui.dragState.toggleOffOnPointerEnd = !selectionChangedOnPointerDown;
}
});
ui.grid.addEventListener("click", (event) => {
const target = event.target instanceof Element ? event.target.closest(".ovib-chip, .ovib-mode-chip") : null;
if (!(target instanceof HTMLElement)) {
return;
}
if (ui.suppressNextClick) {
return;
}
const state = node.__omnivoiceInstructionState || syncStateFromWidgets(node);
const preset = getActivePreset(state);
if (target.classList.contains("ovib-mode-chip")) {
setPresetMode(state, preset, target.dataset.columnId, target.dataset.modeId);
applyStateToWidgets(node, state, ui);
node.__omnivoiceInstructionState = state;
refresh();
return;
}
const token = `${preset.id}:${target.dataset.columnId}:${target.dataset.modeId || ""}:${target.dataset.value}`;
if (ui.pendingClickToken === token) {
ui.pendingClickToken = null;
return;
}
setPresetColumnValue(
state,
preset,
target.dataset.columnId,
target.dataset.value,
true,
target.dataset.modeId || "",
);
applyStateToWidgets(node, state, ui);
node.__omnivoiceInstructionState = state;
refresh();
});
setupColumnDragging(node, ui);
const resizeObserver = new ResizeObserver(() => {
drawPath(ui, node.__omnivoiceInstructionState || syncStateFromWidgets(node));
resizePanel(node, ui, panelWidget);
});
resizeObserver.observe(ui.body);
const handleWindowResize = () => {
drawPath(ui, node.__omnivoiceInstructionState || syncStateFromWidgets(node));
resizePanel(node, ui, panelWidget);
};
window.addEventListener("resize", handleWindowResize);
const handleWheel = (event) => {
if (event.defaultPrevented || ui.dragState?.active) {
return;
}
if (event.target instanceof Element && event.target.closest(".ovib-modal")) {
return;
}
const wheelCallback = window.app?.canvas?._mousewheel_callback;
if (typeof wheelCallback !== "function") {
return;
}
event.preventDefault();
event.stopPropagation();
wheelCallback.call(window.app.canvas, event);
};
ui.panel.addEventListener("wheel", handleWheel, { passive: false });
return {
resizeObserver,
handleWindowResize,
handleWheel,
};
}
function createBuilder(node) {
if (!isBuilderNode(node) || typeof node.addDOMWidget !== "function") {
return false;
}
if (node.__omnivoiceInstructionUi) {
return true;
}
const relevantNames = new Set(["gender", "age", "pitch", "style", "accent", "dialect", "output_language", "instruct_text"]);
for (const widget of node.widgets || []) {
if (relevantNames.has(widget.name)) {
hideWidget(widget);
}
}
const ui = createPanelDom();
ui.layoutAdapter = createNodeLayoutAdapter(node);
cachePresetLibraryOnNode(node, getCachedPresetLibrary());
node.__omnivoiceInstructionLibraryReady = false;
node.__omnivoiceInstructionAppliedPanelHeight = PANEL_WIDGET_MIN_HEIGHT;
node.__omnivoiceInstructionBasePanelHeight = PANEL_WIDGET_MIN_HEIGHT;
node.__omnivoiceInstructionLastY = 0;
const panelWidget = node.addDOMWidget("omnivoice_instruction_builder_panel", "div", ui.panel, {
serialize: false,
hideOnZoom: true,
getMinHeight() {
return PANEL_WIDGET_MIN_HEIGHT;
},
getHeight() {
return PANEL_WIDGET_MIN_HEIGHT;
},
});
panelWidget.computeSize = (inputWidth) => {
const width = Array.isArray(inputWidth) ? inputWidth[0] : inputWidth;
return [Math.max(PANEL_MIN_WIDTH, width || PANEL_MIN_WIDTH), PANEL_WIDGET_MIN_HEIGHT];
};
panelWidget.getHeight = () => PANEL_WIDGET_MIN_HEIGHT;
panelWidget.options = panelWidget.options || {};
panelWidget.options.minNodeSize = [PANEL_MIN_WIDTH, getRequiredNodeMinHeight(node)];
panelWidget.draw = function (_ctx, currentNode, _widgetWidth, y) {
const activeNode = currentNode || node;
this.last_y = y;
activeNode.__omnivoiceInstructionLastY = y;
panelWidget.options.minNodeSize = [
PANEL_MIN_WIDTH,
getRequiredNodeMinHeight(activeNode),
];
updateBasePanelHeight(activeNode, panelWidget);
resizePanel(activeNode, ui, panelWidget);
};
setWidgetHeightSafe(panelWidget, PANEL_WIDGET_MIN_HEIGHT);
const refresh = () => {
const state = syncStateFromWidgets(node);
node.__omnivoiceInstructionState = state;
writeBuilderStateToWidgets(node, state, ui);
renderState(node, ui, state);
applyColumnLayout(ui);
drawPath(ui, state);
resizePanel(node, ui, panelWidget);
persistCurrentLayout(ui, state);
schedulePresetLibraryPersist(node, state);
requestAnimationFrame(() => {
drawPath(ui, node.__omnivoiceInstructionState || state);
});
};
const bound = bindSharedInteractions(node, ui, refresh, panelWidget);
node.__omnivoiceInstructionUi = ui;
node.__omnivoiceInstructionPanelWidget = panelWidget;
node.__omnivoiceInstructionRefresh = refresh;
node.__omnivoiceInstructionCleanup = () => {
clearTimeout(ui.previewCommitTimer);
clearTimeout(node.__omnivoiceInstructionLibraryPersistTimer);
bound.resizeObserver.disconnect();
window.removeEventListener("resize", bound.handleWindowResize);
ui.panel.removeEventListener("wheel", bound.handleWheel);
if (ui.handlePointerMove) {
window.removeEventListener("pointermove", ui.handlePointerMove);
}
if (ui.handlePointerEnd) {
window.removeEventListener("pointerup", ui.handlePointerEnd);
window.removeEventListener("pointercancel", ui.handlePointerEnd);
}
};
if (typeof node.setSize === "function") {
node.setSize([
Math.max(Number(node.size?.[0] || 0), PANEL_MIN_WIDTH),
Math.max(Number(node.size?.[1] || 0), PANEL_MIN_HEIGHT),
]);
}
updateBasePanelHeight(node, panelWidget, true);
refresh();
loadPresetLibraryIntoNode(node).then(() => {
node.__omnivoiceInstructionRefresh?.();
});
return true;
}
function createPrototypeState(initialState = {}) {
const state = createEmptyBuilderState();
state.output_language = normalizeWidgetValue(initialState.output_language) || "English";
const bucket = getPresetStateBucket(state, OMNIVOICE_PRESET_ID);
bucket.values.gender = normalizeWidgetValue(initialState.gender);
bucket.values.age = normalizeWidgetValue(initialState.age);
bucket.values.pitch = normalizeWidgetValue(initialState.pitch);
bucket.values.style = normalizeWidgetValue(initialState.style);
bucket.values.language = normalizeWidgetValue(initialState.accent || initialState.dialect);
bucket.switchModes.language = normalizeWidgetValue(initialState.dialect) ? "dialect" : "accent";
return state;
}
function createPrototypeController(container, initialState = {}, initialLayout = {}) {
const ui = createPanelDom();
ui.layoutAdapter = createMemoryLayoutAdapter(initialLayout, OMNIVOICE_PRESET_ID);
const prototypeNode = {
comfyClass: NODE_CLASS,
widgets: [],
properties: {},
graph: {
setDirtyCanvas() {},
},
};
prototypeNode.__omnivoiceInstructionState = createPrototypeState(initialState);
const refresh = () => {
const state = prototypeNode.__omnivoiceInstructionState;
renderState(prototypeNode, ui, state);
applyColumnLayout(ui);
drawPath(ui, state);
requestAnimationFrame(() => {
drawPath(ui, prototypeNode.__omnivoiceInstructionState || state);
});
};
prototypeNode.__omnivoiceInstructionRefresh = refresh;
const bound = bindSharedInteractions(prototypeNode, ui, refresh, null);
container.style.height = `${PANEL_WIDGET_MIN_HEIGHT}px`;
container.style.minHeight = `${PANEL_WIDGET_MIN_HEIGHT}px`;
container.style.maxHeight = `${PANEL_WIDGET_MIN_HEIGHT}px`;
container.style.display = "block";
container.innerHTML = "";
container.appendChild(ui.panel);
prototypeNode.__omnivoiceInstructionUi = ui;
refresh();
return {
ui,
state: prototypeNode.__omnivoiceInstructionState,
refresh,
destroy() {
clearTimeout(ui.previewCommitTimer);
bound.resizeObserver.disconnect();
window.removeEventListener("resize", bound.handleWindowResize);
ui.panel.removeEventListener("wheel", bound.handleWheel);
if (ui.handlePointerMove) {
window.removeEventListener("pointermove", ui.handlePointerMove);
}
if (ui.handlePointerEnd) {
window.removeEventListener("pointerup", ui.handlePointerEnd);
window.removeEventListener("pointercancel", ui.handlePointerEnd);
}
},
};
}
export function getOmniVoiceInstructionBuilderDefaults() {
return {
nodeWidth: PANEL_MIN_WIDTH,
nodeHeight: PANEL_MIN_HEIGHT,
widgetHeight: PANEL_WIDGET_MIN_HEIGHT,
};
}
export function installOmniVoiceInstructionBuilderExtension(app) {
app.registerExtension({
name: "tts-audio-suite.omnivoice-instruction-builder",
beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData.name !== NODE_CLASS) {
return;
}
const originalNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
const result = originalNodeCreated ? originalNodeCreated.apply(this, arguments) : undefined;
createBuilder(this);
return result;
};
const originalOnConfigure = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = function (info) {
const result = originalOnConfigure ? originalOnConfigure.apply(this, arguments) : undefined;
this.properties = this.properties || {};
if (info?.properties?.omnivoiceInstructionLayout) {
this.properties.omnivoiceInstructionLayout = info.properties.omnivoiceInstructionLayout;
}
if (info?.properties?.omnivoiceInstructionPresetState) {
this.properties.omnivoiceInstructionPresetState = info.properties.omnivoiceInstructionPresetState;
}
createBuilder(this);
this.__omnivoiceInstructionRefresh?.();
return result;
};
const originalOnResize = nodeType.prototype.onResize;
nodeType.prototype.onResize = function () {
const result = originalOnResize ? originalOnResize.apply(this, arguments) : undefined;
this.__omnivoiceInstructionRefresh?.();
return result;
};
const originalOnSerialize = nodeType.prototype.onSerialize;
nodeType.prototype.onSerialize = function (info) {
if (originalOnSerialize) {
originalOnSerialize.apply(this, arguments);
}
info.properties = info.properties || {};
if (this.properties?.omnivoiceInstructionLayout) {
info.properties.omnivoiceInstructionLayout = this.properties.omnivoiceInstructionLayout;
}
if (this.properties?.omnivoiceInstructionPresetState) {
info.properties.omnivoiceInstructionPresetState = this.properties.omnivoiceInstructionPresetState;
}
};
const originalOnRemoved = nodeType.prototype.onRemoved;
nodeType.prototype.onRemoved = function () {
this.__omnivoiceInstructionCleanup?.();
return originalOnRemoved ? originalOnRemoved.apply(this, arguments) : undefined;
};
},
nodeCreated(node) {
createBuilder(node);
node.__omnivoiceInstructionRefresh?.();
},
});
}
export function mountOmniVoiceInstructionBuilderPrototype(container, options = {}) {
return createPrototypeController(
container,
options.initialState || {},
options.initialLayout || {},
);
}