Files
matrix-screen-controller/核桃派软件源代码/app/ui_copy_editor/static/editor.js
T

606 lines
26 KiB
JavaScript
Raw 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 bootstrap = globalThis.__UI_COPY_EDITOR_BUILD__;
if (!bootstrap) {
throw new Error("UI copy editor bootstrap is missing");
}
const runtime = await import(bootstrap.runtime_url);
const placeholderPattern = /\{([a-zA-Z][a-zA-Z0-9_]*)\}/g;
const functionalValuePattern = /^[+\-]?(?:\d+(?:\.\d+)?|#[0-9a-fA-F]{3,8})(?:\s*(?:%|°|Hz|ms|px|V))?$/;
const [catalogResponse, draftResponse] = await Promise.all([
fetch(bootstrap.catalog_url, { cache: "no-store" }),
fetch(bootstrap.api_url, { cache: "no-store" }),
]);
if (!catalogResponse.ok || !draftResponse.ok) {
throw new Error("无法载入文案目录或草稿");
}
const catalog = await catalogResponse.json();
let draft = await draftResponse.json();
let etag = draftResponse.headers.get("ETag") || `"${draft.revision}"`;
let mode = "browse";
let selectedCopyId = null;
let selectedAnchorId = null;
let selectedInsertionId = null;
let saveBusy = false;
const history = [];
const staticTextNodes = new Map();
const toggle = document.createElement("button");
toggle.id = "ui-copy-editor-toggle";
toggle.type = "button";
toggle.textContent = "文案编辑";
toggle.setAttribute("aria-controls", "ui-copy-editor-panel");
toggle.setAttribute("aria-expanded", "false");
const panel = document.createElement("aside");
panel.id = "ui-copy-editor-panel";
panel.hidden = true;
panel.setAttribute("aria-label", "文案编辑器");
panel.innerHTML = `
<div class="ui-copy-editor-header">
<div><strong>文案编辑器</strong><span>输入不会立即改变页面</span></div>
<button id="ui-copy-editor-close" type="button" aria-label="关闭文案编辑器">×</button>
</div>
<div class="ui-copy-editor-modes" role="group" aria-label="编辑模式">
<button type="button" data-mode="browse">浏览页面</button>
<button type="button" data-mode="select">选择文字</button>
<button type="button" data-mode="insert">添加提示</button>
</div>
<div id="ui-copy-save-status" class="ui-copy-save-status" role="status" aria-live="polite">草稿已载入,等待操作</div>
<div class="ui-copy-editor-tools">
<button id="ui-copy-undo" type="button" disabled>撤销上次确认</button>
<a href="${bootstrap.export_url}" download>导出草稿</a>
</div>
<label class="ui-copy-search">搜索全部程序文案
<input id="ui-copy-search-input" type="search" autocomplete="off" placeholder="输入文字或 copy_id">
</label>
<div class="ui-copy-list-heading"><span>已登记文案与新增提示</span><span id="ui-copy-list-count"></span></div>
<div id="ui-copy-list" class="ui-copy-list"></div>
<form id="ui-copy-selected-form" class="ui-copy-selected-form" hidden novalidate>
<h3 id="ui-copy-selected-title"></h3>
<p id="ui-copy-selected-source"></p>
<label>文字内容<textarea id="ui-copy-text" rows="4" maxlength="2000"></textarea></label>
<p id="ui-copy-placeholder-note" class="ui-copy-placeholder-note" hidden></p>
<label id="ui-copy-font-label">字号(8–64 CSS px)
<input id="ui-copy-font-size" type="number" min="8" max="64" step="1" placeholder="保持默认字号">
</label>
<p id="ui-copy-form-error" class="ui-copy-form-error" role="alert" hidden></p>
<div class="ui-copy-form-actions">
<button id="ui-copy-confirm" class="ui-copy-confirm-button" type="submit">确定修改</button>
<button id="ui-copy-restore" type="button">恢复默认</button>
</div>
<button id="ui-copy-delete" class="ui-copy-danger-button" type="button">删除这行文字</button>
<div id="ui-copy-delete-confirmation" class="ui-copy-delete-confirmation" hidden>
<strong>确定隐藏这行文字?</strong>
<p>只隐藏文字,不会删除按钮、输入框或功能区域。</p>
<div class="ui-copy-form-actions">
<button id="ui-copy-delete-confirm" class="ui-copy-danger-button" type="button">确认删除文字</button>
<button id="ui-copy-delete-cancel" type="button">取消</button>
</div>
</div>
</form>
<form id="ui-copy-insertion-form" class="ui-copy-selected-form" hidden novalidate>
<h3 id="ui-copy-insertion-title">新增提示文字</h3>
<p id="ui-copy-insertion-source"></p>
<label>文字内容<textarea id="ui-copy-insertion-text" rows="4" maxlength="2000"></textarea></label>
<label>字号(8–64 CSS px)<input id="ui-copy-insertion-size" type="number" min="8" max="64" value="16" required></label>
<label>插入位置<select id="ui-copy-insertion-placement"><option value="before">锚点上方</option><option value="after">锚点下方</option></select></label>
<p id="ui-copy-insertion-error" class="ui-copy-form-error" role="alert" hidden></p>
<button class="ui-copy-confirm-button" type="submit">确定修改</button>
<button id="ui-copy-insertion-delete" class="ui-copy-danger-button" type="button" hidden>删除这条新增提示</button>
<div id="ui-copy-insertion-delete-confirmation" class="ui-copy-delete-confirmation" hidden>
<strong>确定删除这条新增提示?</strong>
<div class="ui-copy-form-actions">
<button id="ui-copy-insertion-delete-confirm" class="ui-copy-danger-button" type="button">确认删除提示</button>
<button id="ui-copy-insertion-delete-cancel" type="button">取消</button>
</div>
</div>
</form>
`;
document.body.append(toggle, panel);
const elements = {
status: panel.querySelector("#ui-copy-save-status"),
list: panel.querySelector("#ui-copy-list"),
count: panel.querySelector("#ui-copy-list-count"),
search: panel.querySelector("#ui-copy-search-input"),
undo: panel.querySelector("#ui-copy-undo"),
copyForm: panel.querySelector("#ui-copy-selected-form"),
copyTitle: panel.querySelector("#ui-copy-selected-title"),
copySource: panel.querySelector("#ui-copy-selected-source"),
text: panel.querySelector("#ui-copy-text"),
font: panel.querySelector("#ui-copy-font-size"),
fontLabel: panel.querySelector("#ui-copy-font-label"),
placeholderNote: panel.querySelector("#ui-copy-placeholder-note"),
formError: panel.querySelector("#ui-copy-form-error"),
restore: panel.querySelector("#ui-copy-restore"),
delete: panel.querySelector("#ui-copy-delete"),
deleteConfirmation: panel.querySelector("#ui-copy-delete-confirmation"),
insertionForm: panel.querySelector("#ui-copy-insertion-form"),
insertionTitle: panel.querySelector("#ui-copy-insertion-title"),
insertionSource: panel.querySelector("#ui-copy-insertion-source"),
insertionText: panel.querySelector("#ui-copy-insertion-text"),
insertionSize: panel.querySelector("#ui-copy-insertion-size"),
insertionPlacement: panel.querySelector("#ui-copy-insertion-placement"),
insertionError: panel.querySelector("#ui-copy-insertion-error"),
insertionDelete: panel.querySelector("#ui-copy-insertion-delete"),
insertionDeleteConfirmation: panel.querySelector("#ui-copy-insertion-delete-confirmation"),
};
function normalizedText(value) {
return String(value).replace(/\s+/g, " ").trim();
}
function editableTextNodes(element) {
return Array.from(element.childNodes).filter((node) => {
if (node.nodeType !== Node.TEXT_NODE) return false;
const text = normalizedText(node.nodeValue || "");
return text && text !== "-" && !functionalValuePattern.test(text);
});
}
function wrapTextNode(node, text) {
const span = document.createElement("span");
span.className = "ui-copy-editable-text";
span.textContent = text;
const raw = node.nodeValue || "";
const leading = raw.match(/^\s*/)?.[0] || "";
const trailing = raw.match(/\s*$/)?.[0] || "";
node.replaceWith(document.createTextNode(leading), span, document.createTextNode(trailing));
return span;
}
function bindStaticCatalog() {
for (const [copyId, item] of Object.entries(catalog.items)) {
const binding = item.render;
if (!binding || binding.type === "dynamic_template") continue;
let host;
try {
host = document.querySelector(binding.selector);
} catch (_error) {
continue;
}
if (!host || panel.contains(host)) continue;
if (binding.type === "attribute") {
runtime.registerStaticUiCopy(copyId, host, host, item.text, item.kind, binding.attribute);
continue;
}
if (!staticTextNodes.has(binding.selector)) {
staticTextNodes.set(binding.selector, editableTextNodes(host));
}
const node = staticTextNodes.get(binding.selector)[binding.text_index];
let target;
if (node?.isConnected) target = host.tagName === "OPTION" ? host : wrapTextNode(node, item.text);
else if (binding.default_hidden) {
target = document.createElement("span");
target.className = "ui-copy-editable-text";
host.append(target);
} else continue;
runtime.registerStaticUiCopy(copyId, target, host, item.text, item.kind, null, binding.default_hidden);
}
}
function bindAnchors() {
for (const [anchorId, anchor] of Object.entries(catalog.anchors)) {
let element;
try {
element = document.querySelector(anchor.selector);
} catch (_error) {
continue;
}
if (element && !panel.contains(element)) element.dataset.uiCopyAnchorId = anchorId;
}
}
function setStatus(text, state = "idle") {
elements.status.textContent = text;
elements.status.dataset.state = state;
}
function showError(element, text = "") {
element.textContent = text;
element.hidden = !text;
}
function clone(value) {
return JSON.parse(JSON.stringify(value));
}
function writableDocument(value) {
return {
schema_version: value.schema_version,
base_catalog_digest: value.base_catalog_digest,
revision: value.revision,
updated_at: value.updated_at,
overrides: clone(value.overrides),
insertions: clone(value.insertions),
};
}
function placeholders(value) {
return Array.from(String(value).matchAll(placeholderPattern), (match) => match[1]).sort();
}
function samePlaceholders(left, right) {
return JSON.stringify(placeholders(left)) === JSON.stringify(placeholders(right));
}
function effectiveText(copyId) {
return draft.overrides[copyId]?.text ?? catalog.items[copyId].text;
}
function insertionElementId(id) {
return `ui-copy-insertion-${id}`;
}
function applyInsertions() {
document.querySelectorAll(".ui-copy-custom-insertion").forEach((element) => element.remove());
for (const insertion of draft.insertions) {
const anchor = document.querySelector(`[data-ui-copy-anchor-id="${CSS.escape(insertion.anchor_id)}"]`);
if (!anchor) continue;
const element = document.createElement("p");
element.id = insertionElementId(insertion.id);
element.className = "ui-copy-custom-insertion";
element.dataset.uiCopyInsertionId = insertion.id;
element.textContent = insertion.text;
element.style.fontSize = `${insertion.font_size_px}px`;
anchor.insertAdjacentElement(insertion.placement === "before" ? "beforebegin" : "afterend", element);
}
}
function updateSelectionOutline() {
document.querySelectorAll(".ui-copy-selected").forEach((element) => element.classList.remove("ui-copy-selected"));
if (selectedCopyId) {
runtime.uiCopyRecords(selectedCopyId).forEach((record) => record.target.classList.add("ui-copy-selected"));
} else if (selectedInsertionId) {
document.getElementById(insertionElementId(selectedInsertionId))?.classList.add("ui-copy-selected");
}
}
function renderList() {
const query = normalizedText(elements.search.value).toLocaleLowerCase();
const entries = Object.entries(catalog.items)
.filter(([copyId, item]) => `${copyId} ${item.text} ${effectiveText(copyId)} ${item.scope}`.toLocaleLowerCase().includes(query))
.map(([copyId, item]) => ({ type: "copy", id: copyId, text: effectiveText(copyId), meta: `${item.scope} · ${item.render.type}` }));
for (const insertion of draft.insertions) {
if (`${insertion.id} ${insertion.text}`.toLocaleLowerCase().includes(query)) {
entries.push({ type: "insertion", id: insertion.id, text: insertion.text, meta: "新增提示" });
}
}
elements.list.replaceChildren();
for (const entry of entries) {
const button = document.createElement("button");
button.type = "button";
button.className = "ui-copy-list-item";
button.classList.toggle("active", entry.id === selectedCopyId || entry.id === selectedInsertionId);
const text = document.createElement("span");
text.className = "ui-copy-list-text";
text.textContent = entry.text || "(已隐藏)";
const meta = document.createElement("span");
meta.className = "ui-copy-list-meta";
meta.textContent = entry.type === "copy" && !runtime.uiCopyRecords(entry.id).length
? `${entry.meta} · 当前未显示`
: entry.meta;
button.append(text, meta);
button.addEventListener("click", () => entry.type === "copy" ? selectCopy(entry.id) : selectInsertion(entry.id));
elements.list.append(button);
}
elements.count.textContent = String(entries.length);
}
function resetCopyFields(copyId) {
const item = catalog.items[copyId];
const override = draft.overrides[copyId] || {};
elements.text.value = override.text ?? item.text;
elements.font.value = override.font_size_px ?? "";
const effectiveHidden = Object.hasOwn(override, "hidden") ? override.hidden : Boolean(item.render.default_hidden);
elements.delete.textContent = effectiveHidden ? "恢复显示这行文字" : "删除这行文字";
elements.delete.disabled = item.kind === "html_attribute";
elements.restore.disabled = !draft.overrides[copyId];
elements.fontLabel.hidden = item.kind === "html_attribute";
elements.delete.hidden = item.kind === "html_attribute";
const tokens = placeholders(item.text);
elements.placeholderNote.hidden = tokens.length === 0;
elements.placeholderNote.textContent = tokens.length ? `必须原样保留变量:${tokens.map((token) => `{${token}}`).join("、")}` : "";
elements.deleteConfirmation.hidden = true;
showError(elements.formError);
}
function selectCopy(copyId) {
if (!catalog.items[copyId]) return;
selectedCopyId = copyId;
selectedInsertionId = null;
selectedAnchorId = null;
const item = catalog.items[copyId];
elements.copyTitle.textContent = copyId;
elements.copySource.textContent = `${item.scope} · ${item.source}`;
resetCopyFields(copyId);
elements.copyForm.hidden = false;
elements.insertionForm.hidden = true;
document.documentElement.dataset.uiCopyEditorSelection = "true";
updateSelectionOutline();
renderList();
elements.text.focus();
}
function selectInsertion(insertionId) {
const insertion = draft.insertions.find((item) => item.id === insertionId);
if (!insertion) return;
selectedCopyId = null;
selectedAnchorId = insertion.anchor_id;
selectedInsertionId = insertionId;
elements.insertionTitle.textContent = "修改新增提示";
elements.insertionSource.textContent = `${insertion.workspace_id} · ${insertion.anchor_id}`;
elements.insertionText.value = insertion.text;
elements.insertionSize.value = insertion.font_size_px;
elements.insertionPlacement.value = insertion.placement;
elements.insertionDelete.hidden = false;
elements.insertionDeleteConfirmation.hidden = true;
showError(elements.insertionError);
elements.copyForm.hidden = true;
elements.insertionForm.hidden = false;
document.documentElement.dataset.uiCopyEditorSelection = "true";
updateSelectionOutline();
renderList();
}
function selectAnchor(anchorId) {
const anchor = catalog.anchors[anchorId];
if (!anchor) return;
selectedCopyId = null;
selectedInsertionId = null;
selectedAnchorId = anchorId;
elements.insertionTitle.textContent = "新增提示文字";
elements.insertionSource.textContent = `${anchor.scope} · ${anchor.selector}`;
elements.insertionText.value = "";
elements.insertionSize.value = "16";
elements.insertionPlacement.value = "after";
elements.insertionDelete.hidden = true;
elements.insertionDeleteConfirmation.hidden = true;
showError(elements.insertionError);
elements.copyForm.hidden = true;
elements.insertionForm.hidden = false;
document.documentElement.dataset.uiCopyEditorSelection = "true";
updateSelectionOutline();
renderList();
elements.insertionText.focus();
}
function setMode(nextMode) {
mode = nextMode;
document.documentElement.dataset.uiCopyEditorMode = mode;
document.documentElement.dataset.uiCopyEditorSelection = String(Boolean(selectedCopyId || selectedInsertionId || selectedAnchorId));
panel.querySelectorAll("[data-mode]").forEach((button) => button.classList.toggle("active", button.dataset.mode === mode));
document.querySelectorAll("[data-ui-copy-anchor-id]").forEach((element) => {
element.classList.toggle("ui-copy-anchor-candidate", mode === "insert");
});
if (mode === "select") setStatus("点击页面中带轮廓的程序文案;原控件不会被触发");
else if (mode === "insert") setStatus("点击页面中的虚线区域,选择新增提示位置");
else setStatus("浏览模式:页面功能保持可用");
}
async function commitCandidate(candidate, { recordHistory = true, afterSave = null } = {}) {
if (saveBusy) return false;
saveBusy = true;
setStatus("正在保存,页面尚未改变…", "saving");
const previous = writableDocument(draft);
try {
const response = await fetch(bootstrap.api_url, {
method: "PUT",
headers: { "Content-Type": "application/json", "If-Match": etag },
body: JSON.stringify(writableDocument(candidate)),
});
if (!response.ok) {
let message = `保存失败(${response.status})`;
try {
const detail = await response.json();
if (detail?.detail) message = detail.detail;
} catch (_error) {}
if (response.status === 409) setStatus(`草稿冲突:${message}。页面和输入内容均未改变,请刷新后重试。`, "conflict");
else setStatus(`${message}。页面和输入内容均未改变,可直接重试。`, "error");
return false;
}
const saved = await response.json();
etag = response.headers.get("ETag") || `"${saved.revision}"`;
if (recordHistory) history.push(previous);
draft = saved;
runtime.setUiCopyDraft(draft);
applyInsertions();
afterSave?.();
elements.undo.disabled = history.length === 0;
updateSelectionOutline();
renderList();
setStatus(`已保存修订 ${draft.revision},页面已更新`, "saved");
return true;
} catch (error) {
setStatus(`保存失败:${error.message}。页面和输入内容均未改变,可直接重试。`, "error");
return false;
} finally {
saveBusy = false;
}
}
function copyCandidate(copyId, override) {
const candidate = writableDocument(draft);
if (Object.keys(override).length) candidate.overrides[copyId] = override;
else delete candidate.overrides[copyId];
return candidate;
}
elements.copyForm.addEventListener("input", () => {
showError(elements.formError);
setStatus("修改暂存在编辑框中;点击“确定修改”前页面不会改变", "pending");
});
elements.copyForm.addEventListener("submit", async (event) => {
event.preventDefault();
const copyId = selectedCopyId;
if (!copyId) return;
const item = catalog.items[copyId];
const prior = draft.overrides[copyId] || {};
const text = elements.text.value;
if (!text.trim()) {
resetCopyFields(copyId);
showError(elements.formError, "文字内容不能留空。已恢复到本次编辑前的文字,没有发送保存请求。");
setStatus("未保存:空文字已恢复,页面没有改变", "error");
return;
}
if (!samePlaceholders(text, item.text)) {
showError(elements.formError, "变量占位符不能删除、增加或改名。");
return;
}
const override = {};
if (text !== item.text) override.text = text;
if (item.kind !== "html_attribute" && elements.font.value !== "") {
const size = Number(elements.font.value);
if (!Number.isInteger(size) || size < 8 || size > 64) {
showError(elements.formError, "字号必须是 8 到 64 之间的整数。");
return;
}
override.font_size_px = size;
}
if (Object.hasOwn(prior, "hidden")) override.hidden = prior.hidden;
await commitCandidate(copyCandidate(copyId, override), { afterSave: () => resetCopyFields(copyId) });
});
elements.restore.addEventListener("click", async () => {
if (!selectedCopyId) return;
const copyId = selectedCopyId;
await commitCandidate(copyCandidate(copyId, {}), { afterSave: () => resetCopyFields(copyId) });
});
elements.delete.addEventListener("click", () => {
if (!selectedCopyId) return;
const item = catalog.items[selectedCopyId];
const current = draft.overrides[selectedCopyId] || {};
const effectiveHidden = Object.hasOwn(current, "hidden") ? current.hidden : Boolean(item.render.default_hidden);
if (effectiveHidden) {
const override = { ...current, hidden: false };
void commitCandidate(copyCandidate(selectedCopyId, override), { afterSave: () => resetCopyFields(selectedCopyId) });
return;
}
elements.deleteConfirmation.hidden = false;
elements.deleteConfirmation.querySelector("#ui-copy-delete-confirm").focus();
});
panel.querySelector("#ui-copy-delete-cancel").addEventListener("click", () => { elements.deleteConfirmation.hidden = true; });
panel.querySelector("#ui-copy-delete-confirm").addEventListener("click", async () => {
if (!selectedCopyId) return;
const copyId = selectedCopyId;
const override = { ...(draft.overrides[copyId] || {}), hidden: true };
await commitCandidate(copyCandidate(copyId, override), { afterSave: () => resetCopyFields(copyId) });
});
elements.insertionForm.addEventListener("input", () => {
showError(elements.insertionError);
setStatus("新增提示的修改尚未确认;页面不会改变", "pending");
});
elements.insertionForm.addEventListener("submit", async (event) => {
event.preventDefault();
if (!selectedAnchorId) return;
const text = elements.insertionText.value;
if (!text.trim()) {
const existing = draft.insertions.find((item) => item.id === selectedInsertionId);
if (existing) elements.insertionText.value = existing.text;
showError(elements.insertionError, existing
? "文字内容不能留空。已恢复到本次编辑前的文字,没有发送保存请求。"
: "文字内容不能留空,没有发送保存请求。");
return;
}
const size = Number(elements.insertionSize.value);
if (!Number.isInteger(size) || size < 8 || size > 64) {
showError(elements.insertionError, "字号必须是 8 到 64 之间的整数。");
return;
}
const anchor = catalog.anchors[selectedAnchorId];
const insertion = {
id: selectedInsertionId || crypto.randomUUID(),
workspace_id: anchor.scope,
anchor_id: selectedAnchorId,
placement: elements.insertionPlacement.value,
text,
font_size_px: size,
};
const candidate = writableDocument(draft);
const index = candidate.insertions.findIndex((item) => item.id === insertion.id);
if (index >= 0) candidate.insertions[index] = insertion;
else candidate.insertions.push(insertion);
await commitCandidate(candidate, { afterSave: () => selectInsertion(insertion.id) });
});
elements.insertionDelete.addEventListener("click", () => {
elements.insertionDeleteConfirmation.hidden = false;
panel.querySelector("#ui-copy-insertion-delete-confirm").focus();
});
panel.querySelector("#ui-copy-insertion-delete-cancel").addEventListener("click", () => { elements.insertionDeleteConfirmation.hidden = true; });
panel.querySelector("#ui-copy-insertion-delete-confirm").addEventListener("click", async () => {
if (!selectedInsertionId) return;
const candidate = writableDocument(draft);
candidate.insertions = candidate.insertions.filter((item) => item.id !== selectedInsertionId);
await commitCandidate(candidate, {
afterSave: () => {
selectedInsertionId = null;
selectedAnchorId = null;
elements.insertionForm.hidden = true;
document.documentElement.dataset.uiCopyEditorSelection = "false";
},
});
});
elements.undo.addEventListener("click", async () => {
if (!history.length) return;
const target = clone(history[history.length - 1]);
target.revision = draft.revision;
target.updated_at = draft.updated_at;
target.base_catalog_digest = draft.base_catalog_digest;
const saved = await commitCandidate(target, {
recordHistory: false,
afterSave: () => {
history.pop();
if (selectedCopyId) resetCopyFields(selectedCopyId);
if (selectedInsertionId && !draft.insertions.some((item) => item.id === selectedInsertionId)) {
selectedInsertionId = null;
elements.insertionForm.hidden = true;
}
},
});
if (saved) elements.undo.disabled = history.length === 0;
});
panel.querySelectorAll("[data-mode]").forEach((button) => button.addEventListener("click", () => setMode(button.dataset.mode)));
elements.search.addEventListener("input", renderList);
document.addEventListener("click", (event) => {
if (panel.contains(event.target) || toggle.contains(event.target)) return;
if (mode === "select") {
const target = event.target.closest?.("[data-ui-copy-id]");
if (!target) return;
event.preventDefault();
event.stopImmediatePropagation();
selectCopy(target.dataset.uiCopyId);
} else if (mode === "insert") {
const anchor = event.target.closest?.("[data-ui-copy-anchor-id]");
if (!anchor) return;
event.preventDefault();
event.stopImmediatePropagation();
selectAnchor(anchor.dataset.uiCopyAnchorId);
}
}, true);
function setPanelOpen(open) {
panel.hidden = !open;
toggle.setAttribute("aria-expanded", String(open));
if (open) panel.querySelector("[data-mode].active")?.focus();
}
toggle.addEventListener("click", () => setPanelOpen(panel.hidden));
panel.querySelector("#ui-copy-editor-close").addEventListener("click", () => setPanelOpen(false));
bindStaticCatalog();
bindAnchors();
runtime.setUiCopyDraft(draft);
applyInsertions();
renderList();
setMode("browse");