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 = `
文案编辑器输入不会立即改变页面
草稿已载入,等待操作
导出草稿
已登记文案与新增提示
`; 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");