722 lines
33 KiB
JavaScript
722 lines
33 KiB
JavaScript
import { app } from "../../scripts/app.js";
|
|
import {
|
|
characterPreviewUrl,
|
|
loadCharacterAliases,
|
|
resetCharacterAliases,
|
|
saveCharacterAliases,
|
|
} from "./character_alias_manager_api.js";
|
|
import { ensureCharacterAliasManagerStyles } from "./character_alias_manager_styles.js";
|
|
|
|
const THEME_KEY = "ttsAudioSuite.characterAliasManager.theme";
|
|
let activeOverlay = null;
|
|
let nextRowId = 1;
|
|
|
|
const aliasKey = value => String(value || "").trim().toLocaleLowerCase();
|
|
const cloneRecord = record => ({
|
|
id: nextRowId++,
|
|
alias: String(record.alias || ""),
|
|
target: String(record.target || ""),
|
|
language: String(record.language || ""),
|
|
source: record.source === "user" ? "user" : "inherited",
|
|
sourceLabel: String(record.source || "inherited"),
|
|
groupId: String(record.groupId || ""),
|
|
group: String(record.group || "Ungrouped"),
|
|
groupNotes: Array.isArray(record.groupNotes) ? record.groupNotes.map(String) : [],
|
|
});
|
|
|
|
function userSnapshot(state) {
|
|
return JSON.stringify(state.userGroups.map(group => ({
|
|
name: group.name.trim(),
|
|
notes: group.notes,
|
|
aliases: state.userRows.filter(row => row.groupId === group.id).map(({ alias, target, language }) => ({
|
|
alias: alias.trim(), target: target.trim(), language: language.trim().toLowerCase(),
|
|
})),
|
|
})));
|
|
}
|
|
|
|
function effectiveRows(state) {
|
|
const userKeys = new Set(state.userRows.map(row => aliasKey(row.alias)).filter(Boolean));
|
|
return [...state.inheritedRows.filter(row => !userKeys.has(aliasKey(row.alias))), ...state.userRows];
|
|
}
|
|
|
|
function validate(state) {
|
|
const errors = [];
|
|
const seen = new Set();
|
|
for (const row of state.userRows) {
|
|
const key = aliasKey(row.alias);
|
|
if (!key || !row.target.trim()) errors.push("Every user alias needs a name and character voice.");
|
|
if (key && seen.has(key)) errors.push(`Duplicate user alias: ${row.alias.trim()}`);
|
|
seen.add(key);
|
|
}
|
|
const groupNames = new Set();
|
|
for (const group of state.userGroups) {
|
|
const key = aliasKey(group.name);
|
|
if (!key) errors.push("Every user group needs a name.");
|
|
if (key && groupNames.has(key)) errors.push(`Duplicate user group: ${group.name.trim()}`);
|
|
groupNames.add(key);
|
|
}
|
|
return [...new Set(errors)];
|
|
}
|
|
|
|
function makeButton(label, className = "") {
|
|
const button = document.createElement("button");
|
|
button.type = "button";
|
|
button.className = `tts-alias-button ${className}`.trim();
|
|
button.textContent = label;
|
|
return button;
|
|
}
|
|
|
|
function setPayload(state, payload) {
|
|
state.characters = Array.isArray(payload.characters) ? payload.characters : [];
|
|
state.characterDetails = new Map(
|
|
Object.entries(payload.characterDetails || {}).map(([name, details]) => [aliasKey(name), details]),
|
|
);
|
|
state.languages = Array.isArray(payload.languages) ? payload.languages : [];
|
|
state.inheritedRows = (payload.inheritedAliases || []).map(cloneRecord);
|
|
state.userGroups = (payload.userGroups || []).map(group => ({
|
|
id: `group-${nextRowId++}`,
|
|
name: String(group.name || "Ungrouped"),
|
|
notes: Array.isArray(group.notes) ? group.notes.map(String) : [],
|
|
}));
|
|
state.userRows = [];
|
|
for (const group of state.userGroups) {
|
|
const payloadGroup = payload.userGroups[state.userGroups.indexOf(group)];
|
|
state.userRows.push(...(payloadGroup.aliases || []).map(record => cloneRecord({
|
|
...record, source: "user", groupId: group.id, group: group.name,
|
|
})));
|
|
}
|
|
if (!state.userGroups.length) {
|
|
const records = (payload.aliases || []).filter(record => record.source === "user");
|
|
if (records.length) {
|
|
const group = { id: `group-${nextRowId++}`, name: "Ungrouped", notes: [] };
|
|
state.userGroups.push(group);
|
|
state.userRows = records.map(record => cloneRecord({ ...record, groupId: group.id }));
|
|
}
|
|
}
|
|
state.baseline = userSnapshot(state);
|
|
state.userFile = payload.userFile || "";
|
|
}
|
|
|
|
function stopPreview(state) {
|
|
if (state.previewAudio) {
|
|
state.previewAudio.pause();
|
|
state.previewAudio.removeAttribute("src");
|
|
state.previewAudio.load();
|
|
}
|
|
state.previewAudio = null;
|
|
state.previewRowId = null;
|
|
}
|
|
|
|
function togglePreview(state, row, render) {
|
|
if (!aliasKey(row.target)) return;
|
|
state.error = "";
|
|
if (state.previewRowId === row.id) {
|
|
stopPreview(state);
|
|
render();
|
|
return;
|
|
}
|
|
|
|
stopPreview(state);
|
|
const audio = new Audio(characterPreviewUrl(row.target));
|
|
state.previewAudio = audio;
|
|
state.previewRowId = row.id;
|
|
audio.addEventListener("ended", () => {
|
|
if (state.previewAudio !== audio) return;
|
|
stopPreview(state);
|
|
render();
|
|
}, { once: true });
|
|
audio.addEventListener("error", () => {
|
|
if (state.previewAudio !== audio) return;
|
|
stopPreview(state);
|
|
state.error = `Could not preview character voice: ${row.target}`;
|
|
render();
|
|
}, { once: true });
|
|
render();
|
|
audio.play().catch(error => {
|
|
if (state.previewAudio !== audio) return;
|
|
stopPreview(state);
|
|
state.error = `Could not preview character voice: ${error.message}`;
|
|
render();
|
|
});
|
|
}
|
|
|
|
function moveUserRow(state, rowId, groupId, beforeRowId = null) {
|
|
const index = state.userRows.findIndex(row => row.id === rowId);
|
|
if (index < 0) return;
|
|
const [row] = state.userRows.splice(index, 1);
|
|
row.groupId = groupId;
|
|
const beforeIndex = beforeRowId == null ? -1 : state.userRows.findIndex(item => item.id === beforeRowId);
|
|
if (beforeIndex >= 0) state.userRows.splice(beforeIndex, 0, row);
|
|
else {
|
|
const groupRows = state.userRows.map((item, itemIndex) => ({ item, itemIndex }))
|
|
.filter(({ item }) => item.groupId === groupId);
|
|
const insertAt = groupRows.length ? groupRows.at(-1).itemIndex + 1 : state.userRows.length;
|
|
state.userRows.splice(insertAt, 0, row);
|
|
}
|
|
}
|
|
|
|
function clearDragFeedback() {
|
|
activeOverlay?.querySelectorAll(".drag-over-before, .drag-over-after, .drag-over-group")
|
|
.forEach(element => element.classList.remove("drag-over-before", "drag-over-after", "drag-over-group"));
|
|
}
|
|
|
|
function makeRowDragImage(element, event) {
|
|
const ghost = element.cloneNode(true);
|
|
const bounds = element.getBoundingClientRect();
|
|
ghost.classList.remove("dragging");
|
|
ghost.classList.add("tts-alias-drag-ghost");
|
|
ghost.style.width = `${bounds.width}px`;
|
|
ghost.style.height = `${bounds.height}px`;
|
|
(activeOverlay?.querySelector(".tts-alias-sheet") || document.body).appendChild(ghost);
|
|
event.dataTransfer.setDragImage(ghost, Math.min(24, event.offsetX), bounds.height / 2);
|
|
requestAnimationFrame(() => ghost.remove());
|
|
}
|
|
|
|
function createSelect(values, value, placeholder) {
|
|
const select = document.createElement("select");
|
|
const options = [...new Set(values)];
|
|
if (value && !options.some(option => option.toLocaleLowerCase() === value.toLocaleLowerCase())) {
|
|
options.unshift(value);
|
|
}
|
|
const empty = document.createElement("option");
|
|
empty.value = "";
|
|
empty.textContent = placeholder;
|
|
select.appendChild(empty);
|
|
for (const optionValue of options) {
|
|
const option = document.createElement("option");
|
|
option.value = optionValue;
|
|
option.textContent = optionValue;
|
|
select.appendChild(option);
|
|
}
|
|
select.value = value;
|
|
return select;
|
|
}
|
|
|
|
function createRow(state, row, render) {
|
|
const element = document.createElement("div");
|
|
const isUser = row.source === "user";
|
|
const missingTarget = row.target && !state.characterKeys.has(aliasKey(row.target));
|
|
element.className = `tts-alias-row ${isUser ? "user" : "inherited"}${missingTarget ? " invalid" : ""}`;
|
|
element.dataset.rowId = String(row.id);
|
|
|
|
const grip = document.createElement("span");
|
|
grip.className = `tts-alias-grip${isUser ? " active" : ""}`;
|
|
grip.textContent = isUser ? "⠿" : "";
|
|
if (isUser) {
|
|
grip.draggable = true;
|
|
grip.title = "Drag to reorder or move to another group";
|
|
grip.addEventListener("dragstart", event => {
|
|
event.dataTransfer.effectAllowed = "move";
|
|
event.dataTransfer.setData("text/x-tts-alias-row", String(row.id));
|
|
makeRowDragImage(element, event);
|
|
element.classList.add("dragging");
|
|
});
|
|
grip.addEventListener("dragend", () => {
|
|
element.classList.remove("dragging");
|
|
clearDragFeedback();
|
|
});
|
|
element.addEventListener("dragover", event => {
|
|
if (!event.dataTransfer.types.includes("text/x-tts-alias-row")) return;
|
|
event.preventDefault();
|
|
event.dataTransfer.dropEffect = "move";
|
|
const after = event.clientY > element.getBoundingClientRect().top + element.offsetHeight / 2;
|
|
clearDragFeedback();
|
|
element.classList.add(after ? "drag-over-after" : "drag-over-before");
|
|
element.dataset.dropPosition = after ? "after" : "before";
|
|
});
|
|
element.addEventListener("dragleave", event => {
|
|
if (!element.contains(event.relatedTarget)) {
|
|
element.classList.remove("drag-over-before", "drag-over-after");
|
|
}
|
|
});
|
|
element.addEventListener("drop", event => {
|
|
const draggedId = Number(event.dataTransfer.getData("text/x-tts-alias-row"));
|
|
if (!draggedId || draggedId === row.id) return;
|
|
event.preventDefault();
|
|
const groupRows = state.userRows.filter(item => item.groupId === row.groupId && item.id !== draggedId);
|
|
const rowIndex = groupRows.findIndex(item => item.id === row.id);
|
|
const beforeRowId = element.dataset.dropPosition === "after"
|
|
? groupRows[rowIndex + 1]?.id ?? null
|
|
: row.id;
|
|
clearDragFeedback();
|
|
moveUserRow(state, draggedId, row.groupId, beforeRowId);
|
|
render();
|
|
});
|
|
}
|
|
|
|
const aliasControl = isUser ? document.createElement("input") : document.createElement("span");
|
|
if (isUser) {
|
|
aliasControl.dataset.aliasControl = "alias";
|
|
aliasControl.value = row.alias;
|
|
aliasControl.placeholder = "Character alias";
|
|
aliasControl.addEventListener("input", () => {
|
|
row.alias = aliasControl.value;
|
|
render();
|
|
});
|
|
aliasControl.addEventListener("keydown", event => {
|
|
if (event.key === "Enter") aliasControl.blur();
|
|
});
|
|
} else {
|
|
aliasControl.className = "tts-alias-readonly";
|
|
aliasControl.textContent = row.alias;
|
|
aliasControl.title = row.alias;
|
|
}
|
|
|
|
let targetControl;
|
|
if (isUser) {
|
|
targetControl = createSelect(state.characters, row.target, "Select character voice…");
|
|
targetControl.dataset.aliasControl = "target";
|
|
targetControl.title = missingTarget ? "This character voice is not currently available" : row.target;
|
|
targetControl.addEventListener("change", () => {
|
|
row.target = targetControl.value;
|
|
targetControl.blur();
|
|
render();
|
|
});
|
|
} else {
|
|
targetControl = document.createElement("span");
|
|
targetControl.className = "tts-alias-readonly";
|
|
targetControl.textContent = row.target;
|
|
targetControl.title = missingTarget ? `${row.target} (not currently available)` : row.target;
|
|
}
|
|
const voiceCell = document.createElement("div");
|
|
voiceCell.className = "tts-alias-voice-cell";
|
|
const details = state.characterDetails.get(aliasKey(row.target));
|
|
voiceCell.appendChild(targetControl);
|
|
if (row.target && details && !details.hasReferenceText) {
|
|
const transcriptWarning = document.createElement("span");
|
|
transcriptWarning.className = "tts-alias-transcript-warning";
|
|
transcriptWarning.title = "Audio available — reference transcript missing";
|
|
transcriptWarning.setAttribute("role", "img");
|
|
transcriptWarning.setAttribute("aria-label", "Reference transcript missing");
|
|
voiceCell.appendChild(transcriptWarning);
|
|
}
|
|
if (row.target && details?.hasAudio) {
|
|
const preview = document.createElement("button");
|
|
const isPlaying = state.previewRowId === row.id;
|
|
preview.type = "button";
|
|
preview.className = `tts-alias-preview${isPlaying ? " playing" : ""}`;
|
|
preview.title = isPlaying ? `Stop ${row.target} preview` : `Preview ${row.target}`;
|
|
preview.setAttribute("aria-label", preview.title);
|
|
preview.onclick = event => {
|
|
event.stopPropagation();
|
|
togglePreview(state, row, render);
|
|
};
|
|
voiceCell.appendChild(preview);
|
|
}
|
|
|
|
let languageControl;
|
|
if (isUser) {
|
|
languageControl = createSelect(state.languages, row.language, "—");
|
|
languageControl.dataset.aliasControl = "language";
|
|
languageControl.addEventListener("change", () => {
|
|
row.language = languageControl.value;
|
|
languageControl.blur();
|
|
render();
|
|
});
|
|
} else {
|
|
languageControl = document.createElement("span");
|
|
languageControl.className = "tts-alias-readonly";
|
|
languageControl.textContent = row.language || "—";
|
|
}
|
|
|
|
const source = document.createElement("span");
|
|
source.className = `tts-alias-source ${isUser ? "user" : "inherited"}`;
|
|
source.textContent = isUser ? "USER" : "INHERITED";
|
|
source.title = isUser ? state.userFile : `Loaded from ${row.sourceLabel}`;
|
|
|
|
const action = document.createElement("button");
|
|
action.type = "button";
|
|
action.className = `tts-alias-link${isUser ? " tts-alias-remove" : ""}`;
|
|
action.textContent = isUser ? "Remove" : "Override";
|
|
action.onclick = () => {
|
|
if (isUser) state.userRows = state.userRows.filter(candidate => candidate.id !== row.id);
|
|
else {
|
|
let group = state.userGroups[0];
|
|
if (!group) {
|
|
group = { id: `group-${nextRowId++}`, name: "Ungrouped", notes: [] };
|
|
state.userGroups.push(group);
|
|
}
|
|
state.userRows.push(cloneRecord({ ...row, source: "user", groupId: group.id }));
|
|
}
|
|
render();
|
|
};
|
|
|
|
element.append(grip, aliasControl, voiceCell, languageControl, source, action);
|
|
return element;
|
|
}
|
|
|
|
export async function openCharacterAliasManager({ onUpdated } = {}) {
|
|
if (activeOverlay) {
|
|
activeOverlay.querySelector(".tts-alias-sheet")?.focus({ preventScroll: true });
|
|
return;
|
|
}
|
|
|
|
ensureCharacterAliasManagerStyles();
|
|
const overlay = document.createElement("div");
|
|
overlay.className = "tts-alias-overlay";
|
|
const panel = document.createElement("section");
|
|
panel.className = "tts-alias-sheet";
|
|
panel.dataset.theme = localStorage.getItem(THEME_KEY) === "dark" ? "dark" : "paper";
|
|
panel.tabIndex = -1;
|
|
panel.setAttribute("role", "dialog");
|
|
panel.setAttribute("aria-modal", "true");
|
|
panel.setAttribute("aria-label", "Character Aliases");
|
|
overlay.appendChild(panel);
|
|
document.body.appendChild(overlay);
|
|
activeOverlay = overlay;
|
|
|
|
const state = {
|
|
characters: [], characterDetails: new Map(), languages: [], inheritedRows: [],
|
|
userGroups: [], userRows: [], baseline: "[]", previewAudio: null, previewRowId: null,
|
|
userFile: "", filter: "all", search: "", busy: true, error: "", toast: "",
|
|
};
|
|
|
|
const isDirty = () => userSnapshot(state) !== state.baseline;
|
|
const close = async (force = false) => {
|
|
if (!force && isDirty()) {
|
|
const discard = await app.extensionManager.dialog.confirm({
|
|
title: "Discard alias changes?",
|
|
message: "Your unsaved character alias changes will be lost.",
|
|
});
|
|
if (!discard) return;
|
|
}
|
|
stopPreview(state);
|
|
overlay.remove();
|
|
activeOverlay = null;
|
|
};
|
|
|
|
const render = () => {
|
|
const oldContent = panel.querySelector(".tts-alias-content");
|
|
const activeControl = document.activeElement?.closest?.("[data-alias-control]");
|
|
const activeRow = activeControl?.closest?.("[data-row-id]");
|
|
const activeGroup = activeControl?.closest?.("[data-group-id]");
|
|
const view = {
|
|
scrollTop: oldContent?.scrollTop || 0,
|
|
scrollLeft: oldContent?.scrollLeft || 0,
|
|
rowId: activeRow?.dataset.rowId || "",
|
|
groupId: activeGroup?.dataset.groupId || "",
|
|
control: activeControl?.dataset.aliasControl || "",
|
|
cursor: activeControl instanceof HTMLInputElement ? activeControl.selectionStart : null,
|
|
};
|
|
state.characterKeys = new Set(state.characters.map(aliasKey));
|
|
const rows = effectiveRows(state);
|
|
const userCount = state.userRows.length;
|
|
const inheritedCount = rows.filter(row => row.source !== "user").length;
|
|
const filterCounts = { all: rows.length, user: userCount, inherited: inheritedCount };
|
|
const query = state.search.trim().toLocaleLowerCase();
|
|
const visibleRows = rows.filter(row => {
|
|
const matchesFilter = state.filter === "all" || row.source === state.filter;
|
|
const matchesSearch = !query || `${row.alias} ${row.target} ${row.language}`.toLocaleLowerCase().includes(query);
|
|
return matchesFilter && matchesSearch;
|
|
});
|
|
const errors = validate(state);
|
|
const missingCount = rows.filter(row => row.target && !state.characterKeys.has(aliasKey(row.target))).length;
|
|
|
|
panel.replaceChildren();
|
|
const header = document.createElement("header");
|
|
header.className = "tts-alias-header";
|
|
const heading = document.createElement("div");
|
|
const title = document.createElement("h2");
|
|
title.className = "tts-alias-title";
|
|
title.textContent = "Character Aliases";
|
|
const subtitle = document.createElement("div");
|
|
subtitle.className = "tts-alias-subtitle";
|
|
subtitle.textContent = "Friendly names for voices used in [Character] tags";
|
|
heading.append(title, subtitle);
|
|
const headerActions = document.createElement("div");
|
|
headerActions.className = "tts-alias-header-actions";
|
|
const theme = document.createElement("button");
|
|
theme.type = "button";
|
|
theme.className = "tts-alias-icon-button";
|
|
theme.textContent = panel.dataset.theme === "dark" ? "☀" : "☾";
|
|
theme.title = panel.dataset.theme === "dark" ? "Switch to parchment" : "Switch to dark folio";
|
|
theme.onclick = () => {
|
|
panel.dataset.theme = panel.dataset.theme === "dark" ? "paper" : "dark";
|
|
localStorage.setItem(THEME_KEY, panel.dataset.theme);
|
|
render();
|
|
};
|
|
const closeButton = document.createElement("button");
|
|
closeButton.type = "button";
|
|
closeButton.className = "tts-alias-icon-button tts-alias-close";
|
|
closeButton.textContent = "X";
|
|
closeButton.title = "Close";
|
|
closeButton.onclick = () => close();
|
|
headerActions.append(theme, closeButton);
|
|
header.append(heading, headerActions);
|
|
|
|
const toolbar = document.createElement("div");
|
|
toolbar.className = "tts-alias-toolbar";
|
|
const searchWrap = document.createElement("label");
|
|
searchWrap.className = "tts-alias-search";
|
|
const search = document.createElement("input");
|
|
search.type = "search";
|
|
search.dataset.aliasControl = "search";
|
|
search.placeholder = "Search aliases or voices…";
|
|
search.value = state.search;
|
|
search.oninput = () => {
|
|
state.search = search.value;
|
|
render();
|
|
};
|
|
searchWrap.appendChild(search);
|
|
const filters = document.createElement("div");
|
|
filters.className = "tts-alias-filter-group";
|
|
for (const [filter, label] of [["all", "All"], ["user", "User"], ["inherited", "Inherited"]]) {
|
|
const button = document.createElement("button");
|
|
button.type = "button";
|
|
button.className = `tts-alias-filter${state.filter === filter ? " active" : ""}`;
|
|
button.textContent = `${label} ${filterCounts[filter]}`;
|
|
button.onclick = () => { state.filter = filter; render(); };
|
|
filters.appendChild(button);
|
|
}
|
|
const add = makeButton("+ Add alias", "tts-alias-add");
|
|
add.disabled = state.busy;
|
|
add.onclick = () => {
|
|
let group = state.userGroups[0];
|
|
if (!group) {
|
|
group = { id: `group-${nextRowId++}`, name: "Ungrouped", notes: [] };
|
|
state.userGroups.push(group);
|
|
}
|
|
const row = cloneRecord({ alias: "", target: "", language: "", source: "user", groupId: group.id });
|
|
state.userRows.push(row);
|
|
state.filter = "all";
|
|
state.search = "";
|
|
render();
|
|
panel.querySelector(`[data-row-id="${row.id}"] input`)?.focus();
|
|
};
|
|
const addGroup = makeButton("+ Group", "tts-alias-add-group");
|
|
addGroup.disabled = state.busy;
|
|
addGroup.onclick = () => {
|
|
const group = { id: `group-${nextRowId++}`, name: "New Group", notes: [] };
|
|
state.userGroups.push(group);
|
|
state.filter = "user";
|
|
state.search = "";
|
|
render();
|
|
const input = panel.querySelector(`[data-group-id="${group.id}"] input`);
|
|
input?.focus();
|
|
input?.select();
|
|
};
|
|
toolbar.append(searchWrap, filters, addGroup, add);
|
|
|
|
const content = document.createElement("div");
|
|
content.className = "tts-alias-content";
|
|
if (state.busy) {
|
|
const loading = document.createElement("div");
|
|
loading.className = "tts-alias-loading";
|
|
loading.textContent = "Opening the character registry…";
|
|
content.appendChild(loading);
|
|
} else {
|
|
const table = document.createElement("div");
|
|
table.className = "tts-alias-table";
|
|
const tableHead = document.createElement("div");
|
|
tableHead.className = "tts-alias-table-head";
|
|
for (const label of ["", "Alias", "Character voice", "Language", "Source", ""]) {
|
|
const cell = document.createElement("span");
|
|
cell.textContent = label;
|
|
tableHead.appendChild(cell);
|
|
}
|
|
table.appendChild(tableHead);
|
|
const appendGroup = (group, groupRows, source, sourceLabel = "") => {
|
|
if (!groupRows.length && source !== "user") return;
|
|
const section = document.createElement("section");
|
|
section.className = `tts-alias-group ${source}`;
|
|
section.dataset.groupId = group.id || "";
|
|
const groupHeader = document.createElement("div");
|
|
groupHeader.className = "tts-alias-group-header";
|
|
const ornament = document.createElement("span");
|
|
ornament.className = "tts-alias-group-ornament";
|
|
ornament.textContent = "";
|
|
if (source === "user") {
|
|
const name = document.createElement("input");
|
|
name.value = group.name;
|
|
name.dataset.aliasControl = "group-name";
|
|
name.placeholder = "Group name";
|
|
name.oninput = () => { group.name = name.value; render(); };
|
|
name.onkeydown = event => {
|
|
if (event.key === "Enter") name.blur();
|
|
};
|
|
const moveUp = makeButton("↑", "tts-alias-group-action");
|
|
const moveDown = makeButton("↓", "tts-alias-group-action");
|
|
const removeGroup = makeButton("✕", "tts-alias-group-action tts-alias-group-remove");
|
|
const groupIndex = state.userGroups.indexOf(group);
|
|
moveUp.title = "Move group up";
|
|
moveDown.title = "Move group down";
|
|
removeGroup.title = groupRows.length ? "Move or remove aliases before deleting this group" : "Delete empty group";
|
|
moveUp.disabled = groupIndex === 0;
|
|
moveDown.disabled = groupIndex === state.userGroups.length - 1;
|
|
removeGroup.disabled = groupRows.length > 0;
|
|
moveUp.onclick = () => {
|
|
[state.userGroups[groupIndex - 1], state.userGroups[groupIndex]] = [group, state.userGroups[groupIndex - 1]];
|
|
render();
|
|
};
|
|
moveDown.onclick = () => {
|
|
[state.userGroups[groupIndex], state.userGroups[groupIndex + 1]] = [state.userGroups[groupIndex + 1], group];
|
|
render();
|
|
};
|
|
removeGroup.onclick = () => {
|
|
state.userGroups = state.userGroups.filter(item => item.id !== group.id);
|
|
render();
|
|
};
|
|
groupHeader.append(ornament, name, moveUp, moveDown, removeGroup);
|
|
groupHeader.addEventListener("dragover", event => {
|
|
if (!event.dataTransfer.types.includes("text/x-tts-alias-row")) return;
|
|
event.preventDefault();
|
|
event.dataTransfer.dropEffect = "move";
|
|
clearDragFeedback();
|
|
groupHeader.classList.add("drag-over-group");
|
|
});
|
|
groupHeader.addEventListener("dragleave", event => {
|
|
if (!groupHeader.contains(event.relatedTarget)) {
|
|
groupHeader.classList.remove("drag-over-group");
|
|
}
|
|
});
|
|
groupHeader.addEventListener("drop", event => {
|
|
const draggedId = Number(event.dataTransfer.getData("text/x-tts-alias-row"));
|
|
if (!draggedId) return;
|
|
event.preventDefault();
|
|
clearDragFeedback();
|
|
moveUserRow(state, draggedId, group.id);
|
|
render();
|
|
});
|
|
} else {
|
|
const name = document.createElement("strong");
|
|
name.textContent = group.name;
|
|
const origin = document.createElement("span");
|
|
origin.className = "tts-alias-group-origin";
|
|
origin.textContent = sourceLabel;
|
|
groupHeader.append(ornament, name, origin);
|
|
}
|
|
section.appendChild(groupHeader);
|
|
if (group.notes?.length) {
|
|
const notes = document.createElement("div");
|
|
notes.className = "tts-alias-group-notes";
|
|
notes.textContent = group.notes.map(note => `# ${note}`).join(" ");
|
|
section.appendChild(notes);
|
|
}
|
|
groupRows.forEach(row => section.appendChild(createRow(state, row, render)));
|
|
table.appendChild(section);
|
|
};
|
|
|
|
const inheritedSections = new Map();
|
|
for (const row of visibleRows.filter(row => row.source !== "user")) {
|
|
const key = `${row.sourceLabel}\u0000${row.group}`;
|
|
if (!inheritedSections.has(key)) inheritedSections.set(key, []);
|
|
inheritedSections.get(key).push(row);
|
|
}
|
|
for (const [key, sectionRows] of inheritedSections) {
|
|
const [sourceLabel, name] = key.split("\u0000");
|
|
appendGroup({ name, notes: sectionRows[0]?.groupNotes || [] }, sectionRows, "inherited", sourceLabel);
|
|
}
|
|
for (const group of state.userGroups) {
|
|
const groupRows = visibleRows.filter(row => row.source === "user" && row.groupId === group.id);
|
|
if (state.filter !== "inherited" && (!query || groupRows.length)) appendGroup(group, groupRows, "user");
|
|
}
|
|
if (!visibleRows.length) {
|
|
const empty = document.createElement("div");
|
|
empty.className = "tts-alias-empty";
|
|
empty.textContent = rows.length ? "No aliases match this view." : "No character aliases yet. Add the first one.";
|
|
table.appendChild(empty);
|
|
}
|
|
content.appendChild(table);
|
|
}
|
|
|
|
const message = document.createElement("div");
|
|
message.className = `tts-alias-message${state.error || errors.length ? " error" : ""}`;
|
|
if (state.error) message.textContent = state.error;
|
|
else if (errors.length) message.textContent = errors[0];
|
|
else if (missingCount) message.textContent = `${missingCount} alias${missingCount === 1 ? "" : "es"} point to character voices that are not currently available.`;
|
|
else message.textContent = "Inherited aliases stay untouched. Editing one creates a user override.";
|
|
|
|
const footer = document.createElement("footer");
|
|
footer.className = "tts-alias-footer";
|
|
const status = document.createElement("div");
|
|
status.className = `tts-alias-status${isDirty() ? " dirty" : ""}`;
|
|
status.textContent = isDirty() ? `${state.userRows.length} user alias${state.userRows.length === 1 ? "" : "es"} · unsaved changes` : `${filterCounts.all} aliases · ${userCount} user overrides`;
|
|
const reset = makeButton("Reset user overrides", "tts-alias-reset");
|
|
reset.disabled = state.busy || (!state.userRows.length && !state.userGroups.length);
|
|
reset.onclick = async () => {
|
|
const confirmed = await app.extensionManager.dialog.confirm({
|
|
title: "Reset user aliases?",
|
|
message: "This removes every user override and reveals the inherited alias map. Shipped and model-folder files will not be changed.",
|
|
});
|
|
if (!confirmed) return;
|
|
state.busy = true;
|
|
render();
|
|
try {
|
|
const payload = await resetCharacterAliases();
|
|
setPayload(state, payload);
|
|
state.toast = "User overrides reset.";
|
|
state.error = "";
|
|
onUpdated?.(payload);
|
|
} catch (error) {
|
|
state.error = error.message;
|
|
} finally {
|
|
state.busy = false;
|
|
render();
|
|
}
|
|
};
|
|
const cancel = makeButton("Cancel");
|
|
cancel.onclick = () => close();
|
|
const save = makeButton("Save changes", "tts-alias-save");
|
|
save.disabled = state.busy || !isDirty() || errors.length > 0;
|
|
save.onclick = async () => {
|
|
state.busy = true;
|
|
state.error = "";
|
|
render();
|
|
try {
|
|
const groups = state.userGroups.map(group => ({
|
|
name: group.name,
|
|
notes: group.notes,
|
|
aliases: state.userRows.filter(row => row.groupId === group.id)
|
|
.map(({ alias, target, language }) => ({ alias, target, language })),
|
|
}));
|
|
const payload = await saveCharacterAliases(null, groups);
|
|
setPayload(state, payload);
|
|
state.toast = "Character aliases saved.";
|
|
onUpdated?.(payload);
|
|
} catch (error) {
|
|
state.error = error.message;
|
|
} finally {
|
|
state.busy = false;
|
|
render();
|
|
}
|
|
};
|
|
footer.append(status, reset, cancel, save);
|
|
panel.append(header, toolbar, content, message, footer);
|
|
if (state.toast) {
|
|
const toast = document.createElement("div");
|
|
toast.className = "tts-alias-toast";
|
|
toast.textContent = state.toast;
|
|
panel.appendChild(toast);
|
|
const shownToast = state.toast;
|
|
window.setTimeout(() => {
|
|
if (state.toast === shownToast) { state.toast = ""; render(); }
|
|
}, 2200);
|
|
}
|
|
content.scrollTop = view.scrollTop;
|
|
content.scrollLeft = view.scrollLeft;
|
|
const replacement = view.rowId
|
|
? panel.querySelector(`[data-row-id="${view.rowId}"] [data-alias-control="${view.control}"]`)
|
|
: view.groupId
|
|
? panel.querySelector(`[data-group-id="${view.groupId}"] [data-alias-control="${view.control}"]`)
|
|
: panel.querySelector(`[data-alias-control="${view.control}"]`);
|
|
replacement?.focus({ preventScroll: true });
|
|
if (replacement instanceof HTMLInputElement && Number.isInteger(view.cursor)) {
|
|
replacement.setSelectionRange(view.cursor, view.cursor);
|
|
}
|
|
};
|
|
|
|
overlay.addEventListener("keydown", event => {
|
|
if (event.key === "Escape") close();
|
|
});
|
|
render();
|
|
panel.focus({ preventScroll: true });
|
|
try {
|
|
setPayload(state, await loadCharacterAliases());
|
|
} catch (error) {
|
|
state.error = error.message;
|
|
} finally {
|
|
state.busy = false;
|
|
render();
|
|
}
|
|
}
|