初始化奇妙小屏幕控制器项目
This commit is contained in:
@@ -0,0 +1,213 @@
|
||||
#ui-copy-editor-toggle {
|
||||
position: fixed;
|
||||
z-index: 1400;
|
||||
right: max(16px, env(safe-area-inset-right));
|
||||
bottom: max(16px, env(safe-area-inset-bottom));
|
||||
min-width: 104px;
|
||||
min-height: 48px;
|
||||
border: 1px solid #61d6b4;
|
||||
border-radius: 999px;
|
||||
background: #0f5f50;
|
||||
color: #fff;
|
||||
box-shadow: 0 12px 32px rgb(0 0 0 / 45%);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
#ui-copy-editor-panel {
|
||||
position: fixed;
|
||||
z-index: 1390;
|
||||
inset: 0 0 0 auto;
|
||||
width: min(420px, calc(100vw - 24px));
|
||||
overflow: auto;
|
||||
padding: 18px 18px calc(84px + env(safe-area-inset-bottom));
|
||||
border-left: 1px solid #374151;
|
||||
background: #0d1118;
|
||||
color: #f5f7fb;
|
||||
box-shadow: -16px 0 48px rgb(0 0 0 / 55%);
|
||||
font: 14px/1.45 system-ui, sans-serif;
|
||||
}
|
||||
|
||||
#ui-copy-editor-panel[hidden] { display: none; }
|
||||
|
||||
.ui-copy-editor-header,
|
||||
.ui-copy-editor-modes,
|
||||
.ui-copy-editor-tools,
|
||||
.ui-copy-list-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ui-copy-editor-header { margin-bottom: 12px; }
|
||||
.ui-copy-editor-header strong { display: block; font-size: 18px; }
|
||||
.ui-copy-editor-header span { color: #9ca3af; font-size: 12px; }
|
||||
.ui-copy-editor-header button { min-width: 44px; min-height: 44px; font-size: 24px; }
|
||||
|
||||
.ui-copy-editor-modes { margin-bottom: 10px; }
|
||||
.ui-copy-editor-modes button { flex: 1; min-height: 42px; }
|
||||
.ui-copy-editor-modes button.active { border-color: #61d6b4; background: #173b35; color: #8ff2d6; }
|
||||
|
||||
.ui-copy-save-status {
|
||||
min-height: 38px;
|
||||
margin: 8px 0;
|
||||
padding: 9px 10px;
|
||||
border-radius: 8px;
|
||||
background: #171d27;
|
||||
color: #b9c2d0;
|
||||
}
|
||||
.ui-copy-save-status[data-state="saved"] { color: #72e4c4; }
|
||||
.ui-copy-save-status[data-state="pending"] { color: #ffd27a; }
|
||||
.ui-copy-save-status[data-state="error"],
|
||||
.ui-copy-save-status[data-state="conflict"] { color: #ff9b9b; }
|
||||
|
||||
.ui-copy-editor-tools { margin-bottom: 12px; }
|
||||
.ui-copy-editor-tools button,
|
||||
.ui-copy-editor-tools a {
|
||||
min-height: 40px;
|
||||
padding: 9px 12px;
|
||||
border: 1px solid #394252;
|
||||
border-radius: 8px;
|
||||
background: #171d27;
|
||||
color: #f5f7fb;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.ui-copy-search,
|
||||
.ui-copy-selected-form label { display: grid; gap: 6px; margin: 10px 0; }
|
||||
.ui-copy-search input,
|
||||
.ui-copy-selected-form input,
|
||||
.ui-copy-selected-form textarea,
|
||||
.ui-copy-selected-form select {
|
||||
width: 100%;
|
||||
min-height: 42px;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid #3b4658;
|
||||
border-radius: 8px;
|
||||
background: #111722;
|
||||
color: #fff;
|
||||
padding: 9px 10px;
|
||||
font: inherit;
|
||||
}
|
||||
.ui-copy-selected-form textarea { resize: vertical; }
|
||||
|
||||
.ui-copy-list-heading { margin: 14px 0 8px; color: #cdd5e1; }
|
||||
.ui-copy-list { display: grid; gap: 6px; max-height: 40vh; overflow: auto; padding-right: 4px; }
|
||||
.ui-copy-list-item {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
width: 100%;
|
||||
min-height: 48px;
|
||||
padding: 9px 10px;
|
||||
border: 1px solid #293242;
|
||||
border-radius: 8px;
|
||||
background: #141a24;
|
||||
color: #f5f7fb;
|
||||
text-align: left;
|
||||
}
|
||||
.ui-copy-list-item.active { border-color: #61d6b4; background: #17332f; }
|
||||
.ui-copy-list-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.ui-copy-list-meta { color: #8e9aab; font-size: 11px; }
|
||||
|
||||
.ui-copy-selected-form {
|
||||
margin-top: 14px;
|
||||
padding: 14px;
|
||||
border: 1px solid #344054;
|
||||
border-radius: 10px;
|
||||
background: #141a24;
|
||||
}
|
||||
.ui-copy-selected-form h3 { margin: 0 0 4px; font-size: 16px; overflow-wrap: anywhere; }
|
||||
.ui-copy-selected-form > p { margin: 4px 0 8px; color: #9ca8b8; overflow-wrap: anywhere; }
|
||||
.ui-copy-form-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; }
|
||||
.ui-copy-form-actions > button { flex: 1; min-height: 44px; }
|
||||
.ui-copy-confirm-button {
|
||||
min-height: 46px;
|
||||
border-color: #61d6b4 !important;
|
||||
background: #0f5f50 !important;
|
||||
color: #fff !important;
|
||||
font-weight: 800;
|
||||
}
|
||||
.ui-copy-danger-button {
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
margin-top: 10px;
|
||||
border: 1px solid #ff7777 !important;
|
||||
background: #552126 !important;
|
||||
color: #ffd6d6 !important;
|
||||
font-weight: 800;
|
||||
}
|
||||
.ui-copy-danger-button:disabled { opacity: .6; }
|
||||
.ui-copy-delete-confirmation {
|
||||
margin-top: 10px;
|
||||
padding: 12px;
|
||||
border: 1px solid #ff7777;
|
||||
border-radius: 8px;
|
||||
background: #30171b;
|
||||
}
|
||||
.ui-copy-delete-confirmation[hidden] { display: none; }
|
||||
.ui-copy-delete-confirmation p { margin: 6px 0; color: #ffd6d6; }
|
||||
.ui-copy-form-error {
|
||||
margin: 8px 0 !important;
|
||||
padding: 9px 10px;
|
||||
border-left: 3px solid #ff7777;
|
||||
border-radius: 5px;
|
||||
background: #30171b;
|
||||
color: #ffb2b2 !important;
|
||||
}
|
||||
.ui-copy-form-error[hidden] { display: none; }
|
||||
.ui-copy-checkbox { grid-template-columns: auto 1fr !important; align-items: center; }
|
||||
.ui-copy-checkbox input { width: 22px; min-height: 22px; }
|
||||
.ui-copy-placeholder-note { color: #ffd27a !important; }
|
||||
|
||||
[data-ui-copy-editor-mode="select"] [data-ui-copy-id] { cursor: crosshair !important; }
|
||||
[data-ui-copy-editor-mode="select"] [data-ui-copy-id]:hover,
|
||||
.ui-copy-selected {
|
||||
outline: 3px solid #61d6b4 !important;
|
||||
outline-offset: 3px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.ui-copy-anchor-candidate { outline: 2px dashed #d8a8ff !important; outline-offset: 3px; cursor: crosshair !important; }
|
||||
.ui-copy-anchor-candidate:hover { outline-style: solid !important; background-image: linear-gradient(rgb(126 71 177 / 10%), rgb(126 71 177 / 10%)); }
|
||||
|
||||
.ui-copy-custom-insertion {
|
||||
box-sizing: border-box;
|
||||
max-width: min(100%, 58rem);
|
||||
margin: 10px auto;
|
||||
padding: 10px 12px;
|
||||
border-left: 3px solid #61d6b4;
|
||||
border-radius: 6px;
|
||||
background: #151c26;
|
||||
color: #e7edf6;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
@media (max-width: 40rem) {
|
||||
#ui-copy-editor-panel {
|
||||
inset: auto 0 0 0;
|
||||
width: auto;
|
||||
max-height: min(78vh, 720px);
|
||||
padding: 14px 14px calc(78px + env(safe-area-inset-bottom));
|
||||
border-top: 1px solid #374151;
|
||||
border-left: 0;
|
||||
border-radius: 18px 18px 0 0;
|
||||
box-shadow: 0 -16px 48px rgb(0 0 0 / 55%);
|
||||
}
|
||||
.ui-copy-list { max-height: 24vh; }
|
||||
html[data-ui-copy-editor-selection="false"][data-ui-copy-editor-mode="select"] #ui-copy-editor-panel,
|
||||
html[data-ui-copy-editor-selection="false"][data-ui-copy-editor-mode="insert"] #ui-copy-editor-panel {
|
||||
max-height: 172px;
|
||||
padding-bottom: calc(12px + env(safe-area-inset-bottom));
|
||||
overflow: hidden;
|
||||
}
|
||||
html[data-ui-copy-editor-selection="false"][data-ui-copy-editor-mode="select"] #ui-copy-editor-panel :is(.ui-copy-editor-tools, .ui-copy-search, .ui-copy-list-heading, .ui-copy-list, .ui-copy-selected-form),
|
||||
html[data-ui-copy-editor-selection="false"][data-ui-copy-editor-mode="insert"] #ui-copy-editor-panel :is(.ui-copy-editor-tools, .ui-copy-search, .ui-copy-list-heading, .ui-copy-list, .ui-copy-selected-form) {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
#ui-copy-editor-toggle { transition: transform 120ms ease; }
|
||||
#ui-copy-editor-toggle:active { transform: scale(.97); }
|
||||
}
|
||||
@@ -0,0 +1,605 @@
|
||||
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");
|
||||
Reference in New Issue
Block a user