把正文组成收成数据契约,程序投影与旁观维护不再靠模型临场猜。
有变量真值时固定开旁观填表,开场与游玩只填模型生成区。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+615
-119
@@ -1,12 +1,20 @@
|
||||
/**
|
||||
* 游玩辅助 Tab:表格 / 开场与角色 / 对话。
|
||||
* 隐藏格默认挡住,点字段才露;手改带格子 rev。
|
||||
* 游玩辅助 Tab:表格 / 开场与角色。
|
||||
* 隐藏格默认挡住,点字段才露。
|
||||
* 可改格直接改本地草稿;只有点保存并确认后才写入。
|
||||
*/
|
||||
import { parsePresentDoc } from "./present-shells.js";
|
||||
|
||||
let playAuxTab = "table";
|
||||
let revealedHidden = new Set();
|
||||
let editingKey = null;
|
||||
let secretsOpen = false;
|
||||
let draftsTag = "";
|
||||
/** @type {Map<string, string>} */
|
||||
let tableDrafts = new Map();
|
||||
/** @type {{ key: string, start: number | null, end: number | null } | null} */
|
||||
let tableFocus = null;
|
||||
let tableSaving = false;
|
||||
let ignoreNextCapture = false;
|
||||
|
||||
function esc(s) {
|
||||
return String(s ?? "")
|
||||
@@ -16,30 +24,445 @@ function esc(s) {
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function formatValue(value) {
|
||||
if (value === null || value === undefined) return "";
|
||||
if (typeof value === "object") {
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
function cssAttr(value) {
|
||||
if (typeof CSS !== "undefined" && typeof CSS.escape === "function") {
|
||||
return CSS.escape(value);
|
||||
}
|
||||
return String(value);
|
||||
return String(value).replace(/["\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function coerceValue(prev, raw) {
|
||||
const text = String(raw ?? "").trim();
|
||||
if (typeof prev === "boolean") {
|
||||
if (text === "true" || text === "是" || text === "1") return true;
|
||||
if (text === "false" || text === "否" || text === "0") return false;
|
||||
return prev;
|
||||
export function coerceTableValue(value) {
|
||||
if (typeof value !== "string") return value;
|
||||
const t = value.trim();
|
||||
if (t.length < 2) return value;
|
||||
const start = t[0];
|
||||
const end = t[t.length - 1];
|
||||
if ((start === "{" && end === "}") || (start === "[" && end === "]")) {
|
||||
try {
|
||||
return JSON.parse(t);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
if (typeof prev === "number") {
|
||||
const n = Number(text);
|
||||
return Number.isFinite(n) ? n : prev;
|
||||
return value;
|
||||
}
|
||||
|
||||
export function formatTableValue(value) {
|
||||
const normalized = coerceTableValue(value);
|
||||
if (normalized === null || normalized === undefined) return "";
|
||||
if (typeof normalized === "object") {
|
||||
try {
|
||||
const compact = JSON.stringify(normalized);
|
||||
if (compact.length <= 42) return compact;
|
||||
return JSON.stringify(normalized, null, 2);
|
||||
} catch {
|
||||
return String(normalized);
|
||||
}
|
||||
}
|
||||
return text;
|
||||
return String(normalized);
|
||||
}
|
||||
|
||||
function previewChange(value) {
|
||||
const flat = formatTableValue(value).replace(/\s+/g, " ").trim();
|
||||
if (!flat) return "空";
|
||||
if (flat.length <= 42) return flat;
|
||||
return `${flat.slice(0, 42)}…`;
|
||||
}
|
||||
|
||||
function sameValue(a, b) {
|
||||
const left = coerceTableValue(a);
|
||||
const right = coerceTableValue(b);
|
||||
if (Object.is(left, right)) return true;
|
||||
if (typeof left === "object" || typeof right === "object") {
|
||||
try {
|
||||
return JSON.stringify(left) === JSON.stringify(right);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** @returns {{ ok: true, value: unknown } | { ok: false, error: string }} */
|
||||
export function parseTableDraft(prev, raw) {
|
||||
const text = String(raw ?? "");
|
||||
const trimmed = text.trim();
|
||||
const baseline = coerceTableValue(prev);
|
||||
if (typeof baseline === "boolean") {
|
||||
if (trimmed === "true" || trimmed === "是" || trimmed === "1") return { ok: true, value: true };
|
||||
if (trimmed === "false" || trimmed === "否" || trimmed === "0") return { ok: true, value: false };
|
||||
return { ok: false, error: "填是或否" };
|
||||
}
|
||||
if (typeof baseline === "number") {
|
||||
if (!trimmed) return { ok: false, error: "需要数字" };
|
||||
const n = Number(trimmed);
|
||||
if (!Number.isFinite(n)) return { ok: false, error: "不是数字" };
|
||||
return { ok: true, value: n };
|
||||
}
|
||||
if (baseline !== null && typeof baseline === "object") {
|
||||
if (!trimmed) return { ok: false, error: "需要 JSON" };
|
||||
try {
|
||||
return { ok: true, value: JSON.parse(trimmed) };
|
||||
} catch {
|
||||
return { ok: false, error: "JSON 不合法" };
|
||||
}
|
||||
}
|
||||
// 原文是 JSON 字符串时,保存为结构化对象,避免继续塞一坨字
|
||||
if (typeof prev === "string" && coerceTableValue(prev) !== prev) {
|
||||
if (!trimmed) return { ok: false, error: "需要 JSON" };
|
||||
try {
|
||||
return { ok: true, value: JSON.parse(trimmed) };
|
||||
} catch {
|
||||
return { ok: false, error: "JSON 不合法" };
|
||||
}
|
||||
}
|
||||
return { ok: true, value: trimmed };
|
||||
}
|
||||
|
||||
function rowOpen(row) {
|
||||
return row.visibility !== "hidden" || revealedHidden.has(row.key);
|
||||
}
|
||||
|
||||
function isStructuredValue(value) {
|
||||
const normalized = coerceTableValue(value);
|
||||
return normalized !== null && typeof normalized === "object";
|
||||
}
|
||||
|
||||
function stacked(row) {
|
||||
if (typeof coerceTableValue(row.value) === "boolean") return false;
|
||||
if (isStructuredValue(row.value)) return true;
|
||||
const text = formatTableValue(row.value);
|
||||
return text.includes("\n") || [...text].length > 28;
|
||||
}
|
||||
|
||||
function draftText(row) {
|
||||
if (tableDrafts.has(row.key)) return tableDrafts.get(row.key) ?? "";
|
||||
const normalized = coerceTableValue(row.value);
|
||||
if (typeof normalized === "boolean") return normalized ? "true" : "false";
|
||||
return formatTableValue(row.value);
|
||||
}
|
||||
|
||||
function leafText(value) {
|
||||
if (value === null || value === undefined) return "空";
|
||||
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
|
||||
return String(value);
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function matrixColumns(rows) {
|
||||
const cols = [];
|
||||
const seen = new Set();
|
||||
for (const row of rows) {
|
||||
if (!row || typeof row !== "object" || Array.isArray(row)) continue;
|
||||
for (const key of Object.keys(row)) {
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
cols.push(key);
|
||||
}
|
||||
}
|
||||
return cols;
|
||||
}
|
||||
|
||||
function nestedMatrixHtml(rows, nameKey) {
|
||||
const cols = matrixColumns(rows);
|
||||
if (!cols.length) return `<pre class="play-table-json">${esc(formatTableValue(rows))}</pre>`;
|
||||
const head = cols.map((col) => `<th>${esc(col)}</th>`).join("");
|
||||
const body = rows
|
||||
.map((row) => {
|
||||
const cells = cols
|
||||
.map((col) => {
|
||||
const raw = row && typeof row === "object" ? row[col] : "";
|
||||
const nested = coerceTableValue(raw);
|
||||
if (nested !== null && typeof nested === "object") {
|
||||
return `<td>${nestedValueHtml(nested, 1)}</td>`;
|
||||
}
|
||||
return `<td>${esc(leafText(raw))}</td>`;
|
||||
})
|
||||
.join("");
|
||||
return `<tr>${cells}</tr>`;
|
||||
})
|
||||
.join("");
|
||||
return `<div class="play-table-nested"${nameKey ? ` data-matrix="${esc(nameKey)}"` : ""}><table class="play-nested-table"><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table></div>`;
|
||||
}
|
||||
|
||||
function namedMatrixHtml(map) {
|
||||
const rows = Object.entries(map).map(([name, stats]) => {
|
||||
if (stats && typeof stats === "object" && !Array.isArray(stats)) {
|
||||
return { name, ...stats };
|
||||
}
|
||||
return { name, value: stats };
|
||||
});
|
||||
return nestedMatrixHtml(rows);
|
||||
}
|
||||
|
||||
function objectTreeHtml(value, depth = 0) {
|
||||
const entries = Object.entries(value);
|
||||
if (!entries.length) return `<span class="play-table-val">{}</span>`;
|
||||
const items = entries
|
||||
.map(([key, child]) => {
|
||||
const nested = coerceTableValue(child);
|
||||
if (nested !== null && typeof nested === "object" && depth < 2) {
|
||||
return `<div class="play-nested-pair"><span class="play-nested-key">${esc(key)}</span>${nestedValueHtml(nested, depth + 1)}</div>`;
|
||||
}
|
||||
return `<div class="play-nested-pair"><span class="play-nested-key">${esc(key)}</span><span class="play-nested-leaf">${esc(leafText(nested))}</span></div>`;
|
||||
})
|
||||
.join("");
|
||||
return `<div class="play-table-tree">${items}</div>`;
|
||||
}
|
||||
|
||||
function nestedValueHtml(value, depth = 0) {
|
||||
const normalized = coerceTableValue(value);
|
||||
if (normalized === null || normalized === undefined) {
|
||||
return `<span class="play-table-val">空</span>`;
|
||||
}
|
||||
if (typeof normalized !== "object") {
|
||||
return `<span class="play-table-val">${esc(String(normalized))}</span>`;
|
||||
}
|
||||
if (Array.isArray(normalized)) {
|
||||
if (
|
||||
normalized.length &&
|
||||
normalized.every((item) => item && typeof item === "object" && !Array.isArray(item))
|
||||
) {
|
||||
return nestedMatrixHtml(normalized);
|
||||
}
|
||||
return `<pre class="play-table-json">${esc(JSON.stringify(normalized, null, 2))}</pre>`;
|
||||
}
|
||||
const entries = Object.entries(normalized);
|
||||
if (
|
||||
entries.length &&
|
||||
entries.every(([, child]) => child && typeof child === "object" && !Array.isArray(child))
|
||||
) {
|
||||
return namedMatrixHtml(normalized);
|
||||
}
|
||||
return objectTreeHtml(normalized, depth);
|
||||
}
|
||||
|
||||
function caretOf(el) {
|
||||
try {
|
||||
if (typeof el.selectionStart !== "number" || typeof el.selectionEnd !== "number") {
|
||||
return { start: null, end: null };
|
||||
}
|
||||
return { start: el.selectionStart, end: el.selectionEnd };
|
||||
} catch {
|
||||
return { start: null, end: null };
|
||||
}
|
||||
}
|
||||
|
||||
function readControl(el) {
|
||||
if (el instanceof HTMLInputElement && el.type === "checkbox") {
|
||||
return el.checked ? "true" : "false";
|
||||
}
|
||||
return el.value;
|
||||
}
|
||||
|
||||
function bindTableTag(tag) {
|
||||
const next = tag || "";
|
||||
if (draftsTag === next) return;
|
||||
draftsTag = next;
|
||||
tableDrafts = new Map();
|
||||
tableFocus = null;
|
||||
tableSaving = false;
|
||||
}
|
||||
|
||||
function pruneDrafts(table, focusedKey) {
|
||||
const keys = new Set((table?.rows ?? []).map((row) => row.key));
|
||||
for (const key of [...tableDrafts.keys()]) {
|
||||
if (!keys.has(key)) tableDrafts.delete(key);
|
||||
}
|
||||
for (const row of table?.rows ?? []) {
|
||||
if (!tableDrafts.has(row.key)) continue;
|
||||
const text = tableDrafts.get(row.key) ?? "";
|
||||
const baseline = coerceTableValue(row.value);
|
||||
const canonical =
|
||||
typeof baseline === "boolean" ? (baseline ? "true" : "false") : formatTableValue(row.value);
|
||||
if (text === canonical) {
|
||||
tableDrafts.delete(row.key);
|
||||
continue;
|
||||
}
|
||||
if (row.key === focusedKey) continue;
|
||||
const parsed = parseTableDraft(row.value, text);
|
||||
if (parsed.ok && sameValue(parsed.value, row.value)) tableDrafts.delete(row.key);
|
||||
}
|
||||
}
|
||||
|
||||
/** 整页重绘会先清掉侧栏。丢掉 DOM 之前把草稿和光标记下来。 */
|
||||
export function capturePlayTableEdits(root) {
|
||||
if (ignoreNextCapture) {
|
||||
ignoreNextCapture = false;
|
||||
return;
|
||||
}
|
||||
const scope = root ?? document.getElementById("coord-drawer-body");
|
||||
if (!scope) return;
|
||||
const secrets = scope.querySelector(".play-table-secrets");
|
||||
if (secrets) secretsOpen = secrets.open;
|
||||
const fields = scope.querySelectorAll("[data-table-input]");
|
||||
// 侧栏已经被清掉时不要抹掉上一轮记下来的光标
|
||||
if (!fields.length) return;
|
||||
const active = document.activeElement;
|
||||
let sawField = false;
|
||||
fields.forEach((el) => {
|
||||
const key = el.getAttribute("data-table-input");
|
||||
if (!key) return;
|
||||
tableDrafts.set(key, readControl(el));
|
||||
if (el === active) {
|
||||
sawField = true;
|
||||
tableFocus = { key, ...caretOf(el) };
|
||||
}
|
||||
});
|
||||
if (!sawField && active && !scope.contains(active)) tableFocus = null;
|
||||
}
|
||||
|
||||
function restoreTableFocus(root) {
|
||||
if (!tableFocus) return;
|
||||
const active = document.activeElement;
|
||||
const focusFree =
|
||||
!active ||
|
||||
active === document.body ||
|
||||
active === document.documentElement ||
|
||||
root.contains(active);
|
||||
if (!focusFree) return;
|
||||
const saved = tableFocus;
|
||||
const el = root.querySelector(`[data-table-input="${cssAttr(saved.key)}"]`);
|
||||
if (!el) return;
|
||||
el.focus({ preventScroll: true });
|
||||
if (typeof el.setSelectionRange === "function" && saved.start != null && saved.end != null) {
|
||||
try {
|
||||
el.setSelectionRange(saved.start, saved.end);
|
||||
} catch {
|
||||
/* 某些控件没有可选区 */
|
||||
}
|
||||
}
|
||||
tableFocus = saved;
|
||||
}
|
||||
|
||||
function fitTextarea(el) {
|
||||
el.style.height = "auto";
|
||||
const cap = el.classList.contains("is-code") ? 280 : 148;
|
||||
const next = Math.min(Math.max(el.scrollHeight, 32), cap);
|
||||
el.style.height = `${next}px`;
|
||||
}
|
||||
|
||||
function fitInput(el) {
|
||||
if (el.tagName !== "INPUT" || el.type === "checkbox") return;
|
||||
const row = el.closest(".play-table-row");
|
||||
if (!row || row.classList.contains("is-stack")) return;
|
||||
row.classList.remove("is-wide");
|
||||
const sample = el.value || el.placeholder || "";
|
||||
const len = Math.min(28, Math.max(3, [...sample].length));
|
||||
el.style.width = `${len + 1.5}ch`;
|
||||
if (el.scrollWidth > el.clientWidth + 1) {
|
||||
row.classList.add("is-wide");
|
||||
el.style.width = "100%";
|
||||
}
|
||||
}
|
||||
|
||||
function noteTitle(row) {
|
||||
const note = String(row.note ?? "").trim();
|
||||
return note ? ` title="${esc(note)}"` : "";
|
||||
}
|
||||
|
||||
function fieldHtml(row, { busy }) {
|
||||
const text = draftText(row);
|
||||
const disabled = busy ? "disabled" : "";
|
||||
const structured = isStructuredValue(row.value);
|
||||
const code = structured ? " is-code" : "";
|
||||
const baseline = coerceTableValue(row.value);
|
||||
if (typeof baseline === "boolean") {
|
||||
const checked = text === "true" || text === "是" || text === "1" ? "checked" : "";
|
||||
return `<label class="play-table-field">
|
||||
<span class="play-table-key"${noteTitle(row)}>${esc(row.key)}</span>
|
||||
<input type="checkbox" class="play-table-input" data-table-input="${esc(row.key)}" ${checked} ${disabled} />
|
||||
<span class="play-table-field-error" data-table-error hidden></span>
|
||||
</label>`;
|
||||
}
|
||||
if (stacked(row)) {
|
||||
return `<label class="play-table-field">
|
||||
<span class="play-table-key"${noteTitle(row)}>${esc(row.key)}</span>
|
||||
<textarea class="play-table-input${code}" data-table-input="${esc(row.key)}" rows="1" spellcheck="false" autocomplete="off" ${disabled}>${esc(text)}</textarea>
|
||||
<span class="play-table-field-error" data-table-error hidden></span>
|
||||
</label>`;
|
||||
}
|
||||
const inputMode = typeof baseline === "number" ? ' inputmode="decimal"' : "";
|
||||
const placeholder = typeof baseline === "number" ? "数字" : "空";
|
||||
return `<label class="play-table-field">
|
||||
<span class="play-table-key"${noteTitle(row)}>${esc(row.key)}</span>
|
||||
<input type="text" class="play-table-input" data-table-input="${esc(row.key)}" value="${esc(text)}" placeholder="${placeholder}" spellcheck="false" autocomplete="off"${inputMode} ${disabled} />
|
||||
<span class="play-table-field-error" data-table-error hidden></span>
|
||||
</label>`;
|
||||
}
|
||||
|
||||
function readonlyHtml(row, { hidden, revealed }) {
|
||||
const shown = !hidden || revealed;
|
||||
const structured = shown && isStructuredValue(row.value);
|
||||
const stack = shown && (structured || stacked(row));
|
||||
if (hidden && !revealed) {
|
||||
return `<li class="play-table-row is-secret" data-table-key="${esc(row.key)}" data-hidden="1" data-editing="0">
|
||||
<button type="button" class="play-table-reveal" data-table-reveal="${esc(row.key)}" aria-label="显示 ${esc(row.key)}">
|
||||
<span class="play-table-key">${esc(row.key)}</span>
|
||||
<span class="play-table-val">••••</span>
|
||||
</button>
|
||||
</li>`;
|
||||
}
|
||||
const valueHtml = structured
|
||||
? nestedValueHtml(row.value)
|
||||
: `<span class="play-table-val">${esc(formatTableValue(row.value) || "空")}</span>`;
|
||||
return `<li class="play-table-row${stack ? " is-stack" : " is-inline"}${hidden ? " is-secret is-open" : ""}" data-table-key="${esc(row.key)}" data-hidden="${hidden ? "1" : "0"}" data-editing="0">
|
||||
<div class="play-table-field">
|
||||
<span class="play-table-key"${noteTitle(row)}>${esc(row.key)}</span>
|
||||
${valueHtml}
|
||||
</div>
|
||||
</li>`;
|
||||
}
|
||||
|
||||
function rowHtml(row, { hidden, revealed, busy }) {
|
||||
const shown = !hidden || revealed;
|
||||
if (!shown || !row.editable) return readonlyHtml(row, { hidden, revealed });
|
||||
const stack = stacked(row);
|
||||
return `<li class="play-table-row${stack ? " is-stack" : " is-inline"}${hidden ? " is-secret is-open" : ""}" data-table-key="${esc(row.key)}" data-hidden="${hidden ? "1" : "0"}" data-editing="1">
|
||||
${fieldHtml(row, { busy })}
|
||||
</li>`;
|
||||
}
|
||||
|
||||
function tablePanelHtml(table, busy) {
|
||||
if (!table?.rows?.length) {
|
||||
return `<p class="play-aux-empty">本局没有状态表。</p>`;
|
||||
}
|
||||
const visible = table.rows.filter((r) => r.visibility !== "hidden");
|
||||
const hidden = table.rows.filter((r) => r.visibility === "hidden");
|
||||
const vis =
|
||||
visible.length > 0
|
||||
? `<ul class="play-table-list">${visible
|
||||
.map((row) => rowHtml(row, { hidden: false, revealed: true, busy }))
|
||||
.join("")}</ul>`
|
||||
: `<p class="play-aux-empty">没有可见字段。</p>`;
|
||||
const hid = hidden.length
|
||||
? `<details class="play-table-secrets"${secretsOpen ? " open" : ""}>
|
||||
<summary>不可见 ${hidden.length}</summary>
|
||||
<ul class="play-table-list">${hidden
|
||||
.map((row) =>
|
||||
rowHtml(row, {
|
||||
hidden: true,
|
||||
revealed: revealedHidden.has(row.key),
|
||||
busy,
|
||||
}),
|
||||
)
|
||||
.join("")}</ul>
|
||||
</details>`
|
||||
: "";
|
||||
const editable = table.rows.some((row) => row.editable);
|
||||
const bar = editable
|
||||
? `<div class="play-table-bar" hidden>
|
||||
<span class="play-table-bar-note" data-table-dirty-note></span>
|
||||
<button type="button" class="btn-sm" data-table-revert>还原</button>
|
||||
<button type="button" class="btn-sm btn-primary" data-table-save>保存</button>
|
||||
</div>`
|
||||
: "";
|
||||
return `<div class="play-table" data-table-tag="${esc(table.tag)}">${vis}${hid}${bar}</div>`;
|
||||
}
|
||||
|
||||
function previewOpening(text) {
|
||||
@@ -58,68 +481,6 @@ function previewOpening(text) {
|
||||
return `${flat.slice(0, 72)}…`;
|
||||
}
|
||||
|
||||
function rowHtml(row, { hidden, revealed, editing, busy }) {
|
||||
const shown = !hidden || revealed;
|
||||
const val = shown ? formatValue(row.value) : "••••••";
|
||||
const edit =
|
||||
shown && row.editable && editing
|
||||
? `<form class="play-table-edit" data-table-edit="${esc(row.key)}">
|
||||
<input type="text" name="value" value="${esc(formatValue(row.value))}" ${busy ? "disabled" : ""} />
|
||||
<button type="submit" class="btn-sm btn-primary" ${busy ? "disabled" : ""}>保存</button>
|
||||
<button type="button" class="btn-sm" data-table-cancel>取消</button>
|
||||
</form>`
|
||||
: "";
|
||||
const action =
|
||||
shown && row.editable && !editing
|
||||
? `<button type="button" class="play-table-fix" data-table-fix="${esc(row.key)}" ${busy ? "disabled" : ""}>改</button>`
|
||||
: "";
|
||||
return `<li class="play-table-row${hidden ? " is-secret" : ""}${revealed ? " is-open" : ""}" data-table-key="${esc(row.key)}" data-hidden="${hidden ? "1" : "0"}">
|
||||
<button type="button" class="play-table-hit" data-table-reveal="${esc(row.key)}" ${hidden && !revealed ? "" : hidden ? "" : "tabindex='-1'"}>
|
||||
<span class="play-table-key">${esc(row.key)}</span>
|
||||
<span class="play-table-val">${esc(val)}</span>
|
||||
</button>
|
||||
${action}
|
||||
${edit}
|
||||
</li>`;
|
||||
}
|
||||
|
||||
function tablePanelHtml(table, busy) {
|
||||
if (!table?.rows?.length) {
|
||||
return `<p class="play-aux-empty">本局没有状态表。</p>`;
|
||||
}
|
||||
const visible = table.rows.filter((r) => r.visibility !== "hidden");
|
||||
const hidden = table.rows.filter((r) => r.visibility === "hidden");
|
||||
const vis =
|
||||
visible.length > 0
|
||||
? `<ul class="play-table-list">${visible
|
||||
.map((row) =>
|
||||
rowHtml(row, {
|
||||
hidden: false,
|
||||
revealed: true,
|
||||
editing: editingKey === row.key,
|
||||
busy,
|
||||
}),
|
||||
)
|
||||
.join("")}</ul>`
|
||||
: `<p class="play-aux-empty">没有可见字段。</p>`;
|
||||
const hid = hidden.length
|
||||
? `<details class="play-table-secrets">
|
||||
<summary>不可见 ${hidden.length}</summary>
|
||||
<ul class="play-table-list">${hidden
|
||||
.map((row) =>
|
||||
rowHtml(row, {
|
||||
hidden: true,
|
||||
revealed: revealedHidden.has(row.key),
|
||||
editing: editingKey === row.key,
|
||||
busy,
|
||||
}),
|
||||
)
|
||||
.join("")}</ul>
|
||||
</details>`
|
||||
: "";
|
||||
return `<div class="play-table" data-table-tag="${esc(table.tag)}">${vis}${hid}</div>`;
|
||||
}
|
||||
|
||||
function openingPanelHtml(aux) {
|
||||
const openings = aux?.openings ?? [];
|
||||
if (!openings.length) {
|
||||
@@ -162,6 +523,7 @@ export function fillPlayAux(view, handlers = {}, extra = {}) {
|
||||
const drawerCount = document.getElementById("coord-drawer-count");
|
||||
if (!rail || !drawerBody) return;
|
||||
rail.hidden = false;
|
||||
capturePlayTableEdits(drawerBody);
|
||||
|
||||
const aux = view.playAux;
|
||||
|
||||
@@ -187,6 +549,7 @@ export function fillPlayAux(view, handlers = {}, extra = {}) {
|
||||
const id = btn.getAttribute("data-play-aux-tab");
|
||||
if (!id || id === playAuxTab) return;
|
||||
playAuxTab = id;
|
||||
tableFocus = null;
|
||||
fillPlayAux(view, handlers, extra);
|
||||
};
|
||||
}
|
||||
@@ -195,10 +558,14 @@ export function fillPlayAux(view, handlers = {}, extra = {}) {
|
||||
|
||||
const busy = Boolean(extra.busy);
|
||||
if (playAuxTab === "table") {
|
||||
bindTableTag(aux?.table?.tag || "");
|
||||
pruneDrafts(aux?.table, tableFocus?.key);
|
||||
tableSaving = false;
|
||||
drawerBody.innerHTML = tablePanelHtml(aux?.table, busy);
|
||||
wireTable(drawerBody, aux?.table, handlers, busy, () =>
|
||||
fillPlayAux(view, handlers, extra),
|
||||
);
|
||||
restoreTableFocus(drawerBody);
|
||||
return;
|
||||
}
|
||||
if (playAuxTab === "opening") {
|
||||
@@ -216,48 +583,177 @@ export function hidePlayAuxTabs() {
|
||||
if (tabs) tabs.hidden = true;
|
||||
}
|
||||
|
||||
function syncDirtyUi(root, table, busy) {
|
||||
let dirty = 0;
|
||||
let invalid = 0;
|
||||
let firstInvalid = null;
|
||||
for (const row of table?.rows ?? []) {
|
||||
if (!row.editable || !rowOpen(row)) continue;
|
||||
const li = root.querySelector(`[data-table-key="${cssAttr(row.key)}"]`);
|
||||
if (!li || li.getAttribute("data-editing") !== "1") continue;
|
||||
const text = draftText(row);
|
||||
const canonical =
|
||||
typeof row.value === "boolean" ? (row.value ? "true" : "false") : formatTableValue(row.value);
|
||||
const changed = text !== canonical;
|
||||
const parsed = changed ? parseTableDraft(row.value, text) : { ok: true, value: row.value };
|
||||
const bad = changed && !parsed.ok;
|
||||
li.classList.toggle("is-dirty", changed);
|
||||
li.classList.toggle("is-invalid", bad);
|
||||
const err = li.querySelector("[data-table-error]");
|
||||
if (err) {
|
||||
err.hidden = !bad;
|
||||
err.textContent = bad ? parsed.error : "";
|
||||
}
|
||||
if (changed) dirty += 1;
|
||||
if (bad) {
|
||||
invalid += 1;
|
||||
firstInvalid ??= li.querySelector("[data-table-input]");
|
||||
}
|
||||
}
|
||||
const bar = root.querySelector(".play-table-bar");
|
||||
if (!bar) return { dirty, invalid, firstInvalid };
|
||||
bar.hidden = dirty === 0;
|
||||
const note = bar.querySelector("[data-table-dirty-note]");
|
||||
if (note) {
|
||||
note.textContent = invalid ? `${invalid} 项还不能保存` : `${dirty} 项未保存`;
|
||||
}
|
||||
const save = bar.querySelector("[data-table-save]");
|
||||
if (save) save.disabled = busy || tableSaving || dirty === 0 || invalid > 0;
|
||||
const revert = bar.querySelector("[data-table-revert]");
|
||||
if (revert) revert.disabled = busy || tableSaving || dirty === 0;
|
||||
return { dirty, invalid, firstInvalid };
|
||||
}
|
||||
|
||||
function wireTable(root, table, handlers, busy, rerender) {
|
||||
root.querySelectorAll("textarea.play-table-input").forEach((el) => fitTextarea(el));
|
||||
root.querySelectorAll("input.play-table-input").forEach((el) => fitInput(el));
|
||||
root.querySelector(".play-table-secrets")?.addEventListener("toggle", (e) => {
|
||||
secretsOpen = Boolean(e.currentTarget?.open);
|
||||
});
|
||||
root.querySelectorAll("[data-table-reveal]").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const key = btn.getAttribute("data-table-reveal");
|
||||
const row = btn.closest(".play-table-row");
|
||||
if (!key || !row) return;
|
||||
if (row.getAttribute("data-hidden") === "1" && !revealedHidden.has(key)) {
|
||||
revealedHidden.add(key);
|
||||
rerender?.();
|
||||
if (!key || revealedHidden.has(key)) return;
|
||||
revealedHidden.add(key);
|
||||
secretsOpen = true;
|
||||
tableFocus = { key, start: null, end: null };
|
||||
rerender?.();
|
||||
});
|
||||
});
|
||||
|
||||
const remember = (el) => {
|
||||
const key = el.getAttribute("data-table-input");
|
||||
if (!key) return;
|
||||
tableDrafts.set(key, readControl(el));
|
||||
tableFocus = { key, ...caretOf(el) };
|
||||
if (el.tagName === "TEXTAREA") fitTextarea(el);
|
||||
else fitInput(el);
|
||||
syncDirtyUi(root, table, busy);
|
||||
};
|
||||
|
||||
root.querySelectorAll("[data-table-input]").forEach((el) => {
|
||||
el.addEventListener("input", () => remember(el));
|
||||
el.addEventListener("change", () => remember(el));
|
||||
el.addEventListener("focus", () => remember(el));
|
||||
el.addEventListener("keydown", (e) => {
|
||||
if (e.isComposing || e.keyCode === 229) return;
|
||||
const key = el.getAttribute("data-table-input");
|
||||
if (!key) return;
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
const row = table?.rows?.find((item) => item.key === key);
|
||||
const canonical = row
|
||||
? typeof row.value === "boolean"
|
||||
? row.value
|
||||
? "true"
|
||||
: "false"
|
||||
: formatTableValue(row.value)
|
||||
: "";
|
||||
tableDrafts.delete(key);
|
||||
if (el.type === "checkbox") el.checked = canonical === "true";
|
||||
else el.value = canonical;
|
||||
tableFocus = { key, start: canonical.length, end: canonical.length };
|
||||
syncDirtyUi(root, table, busy);
|
||||
return;
|
||||
}
|
||||
});
|
||||
});
|
||||
root.querySelectorAll("[data-table-fix]").forEach((btn) => {
|
||||
btn.addEventListener("click", (e) => {
|
||||
const commit =
|
||||
(e.key === "Enter" && el.tagName !== "TEXTAREA") ||
|
||||
(e.key === "Enter" && (e.metaKey || e.ctrlKey));
|
||||
if (!commit) return;
|
||||
e.preventDefault();
|
||||
editingKey = btn.getAttribute("data-table-fix");
|
||||
requestSave();
|
||||
});
|
||||
});
|
||||
|
||||
root.querySelector("[data-table-revert]")?.addEventListener("click", () => {
|
||||
if (busy || tableSaving) return;
|
||||
ignoreNextCapture = true;
|
||||
tableDrafts = new Map();
|
||||
tableFocus = null;
|
||||
rerender?.();
|
||||
});
|
||||
root.querySelector("[data-table-save]")?.addEventListener("click", () => {
|
||||
requestSave();
|
||||
});
|
||||
syncDirtyUi(root, table, busy);
|
||||
|
||||
function requestSave() {
|
||||
if (busy || tableSaving || !table) return;
|
||||
capturePlayTableEdits(root);
|
||||
const pending = [];
|
||||
let firstInvalid = null;
|
||||
for (const row of table.rows) {
|
||||
if (!row.editable || !rowOpen(row)) continue;
|
||||
const text = draftText(row);
|
||||
const canonical =
|
||||
typeof row.value === "boolean" ? (row.value ? "true" : "false") : formatTableValue(row.value);
|
||||
if (text === canonical) continue;
|
||||
const parsed = parseTableDraft(row.value, text);
|
||||
if (!parsed.ok) {
|
||||
firstInvalid ??= row.key;
|
||||
continue;
|
||||
}
|
||||
if (sameValue(parsed.value, row.value)) {
|
||||
tableDrafts.delete(row.key);
|
||||
continue;
|
||||
}
|
||||
pending.push({ row, value: parsed.value });
|
||||
}
|
||||
const state = syncDirtyUi(root, table, busy);
|
||||
if (state.invalid > 0 || firstInvalid) {
|
||||
const el =
|
||||
state.firstInvalid ||
|
||||
root.querySelector(`[data-table-input="${cssAttr(firstInvalid || "")}"]`);
|
||||
el?.focus();
|
||||
return;
|
||||
}
|
||||
if (!pending.length) {
|
||||
ignoreNextCapture = true;
|
||||
rerender?.();
|
||||
});
|
||||
});
|
||||
root.querySelectorAll("[data-table-cancel]").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
editingKey = null;
|
||||
rerender?.();
|
||||
});
|
||||
});
|
||||
root.querySelectorAll("[data-table-edit]").forEach((form) => {
|
||||
form.addEventListener("submit", (e) => {
|
||||
e.preventDefault();
|
||||
if (busy) return;
|
||||
const key = form.getAttribute("data-table-edit");
|
||||
const row = table?.rows?.find((r) => r.key === key);
|
||||
if (!key || !row) return;
|
||||
const next = coerceValue(
|
||||
row.value,
|
||||
form.querySelector("input[name=value]")?.value,
|
||||
);
|
||||
editingKey = null;
|
||||
handlers.onPatchTable?.(table.tag, [
|
||||
{ key, value: next, expectedRev: row.rev },
|
||||
]);
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
const lines = pending.map(
|
||||
(item) =>
|
||||
`${item.row.key}:${previewChange(item.row.value)} → ${previewChange(item.value)}`,
|
||||
);
|
||||
const shown = lines.slice(0, 8);
|
||||
if (lines.length > 8) shown.push(`另有 ${lines.length - 8} 项`);
|
||||
const head =
|
||||
pending.length === 1
|
||||
? `保存对「${pending[0].row.key}」的修改?`
|
||||
: `保存 ${pending.length} 项修改?`;
|
||||
if (!confirm(`${head}\n\n${shown.join("\n")}`)) return;
|
||||
tableSaving = true;
|
||||
syncDirtyUi(root, table, busy);
|
||||
handlers.onPatchTable?.(
|
||||
table.tag,
|
||||
pending.map((item) => ({
|
||||
key: item.row.key,
|
||||
value: item.value,
|
||||
expectedRev: item.row.rev,
|
||||
})),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function wireOpening(root, handlers, busy) {
|
||||
|
||||
+393
-8
@@ -32,15 +32,17 @@ export function previewPacket(sample) {
|
||||
return { schema: "present.v1", blocks: sample };
|
||||
}
|
||||
|
||||
export function queuePresentFrame(bundle, packet) {
|
||||
export function queuePresentFrame(bundle, packet, slots) {
|
||||
const id = `pf${seq.toString(36)}${(seq += 1).toString(36)}`;
|
||||
frames.set(id, { bundle, packet });
|
||||
frames.set(id, { bundle, packet, slots: Array.isArray(slots) ? slots : [] });
|
||||
return id;
|
||||
}
|
||||
|
||||
export function presentFrameSlotHtml(id) {
|
||||
export function presentFrameSlotHtml(id, minHeight) {
|
||||
const safe = String(id).replace(/[^a-zA-Z0-9_-]/g, "");
|
||||
return `<div class="present-frame-host" data-present-id="${safe}"></div>`;
|
||||
const h = Number(minHeight);
|
||||
const style = Number.isFinite(h) && h > 8 ? ` style="min-height:${Math.ceil(h)}px"` : "";
|
||||
return `<div class="present-frame-host" data-present-id="${safe}"${style}></div>`;
|
||||
}
|
||||
|
||||
function neutralizeClosing(source, tag) {
|
||||
@@ -54,23 +56,31 @@ function embedJson(value) {
|
||||
.replace(/\u2029/g, "\\u2029");
|
||||
}
|
||||
|
||||
export function buildPresentSrcdoc({ html = "", css = "", js = "", packet, frameId }) {
|
||||
function flattenFrameCss(css) {
|
||||
return String(css ?? "")
|
||||
.replace(/\b100(?:dvh|svh|lvh|vh)\b/g, "auto")
|
||||
.replace(/overflow(-[xy])?\s*:\s*(auto|scroll)\b/g, "overflow$1: visible");
|
||||
}
|
||||
|
||||
export function buildPresentSrcdoc({ html = "", css = "", js = "", packet, slots, frameId }) {
|
||||
const safeId = String(frameId).replace(/[^a-zA-Z0-9_-]/g, "");
|
||||
const cssSafe = neutralizeClosing(css, "style");
|
||||
const cssSafe = neutralizeClosing(flattenFrameCss(css), "style");
|
||||
const htmlSafe = String(html).replace(/<\/(body|html|head)\s*>/gi, "");
|
||||
const jsSafe = neutralizeClosing(js, "script");
|
||||
const packetJson = embedJson(packet ?? { schema: "present.v1", blocks: {} });
|
||||
const slotsJson = embedJson(Array.isArray(slots) ? slots : []);
|
||||
return `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; img-src data: blob:; font-src data:; script-src 'unsafe-inline';">
|
||||
<style>html,body{margin:0}</style>
|
||||
<style>${cssSafe}</style>
|
||||
<style>html,body{margin:0!important;height:auto!important;min-height:0!important;max-height:none!important;overflow:hidden!important}[data-block] p,article p,#body p,#piece p{margin:0 0 .75em}[data-block] p:last-child,article p:last-child,#body p:last-child,#piece p:last-child{margin-bottom:0}[data-block] table.present-data-table{width:100%;border-collapse:collapse;font-size:inherit;margin:0 0 .75em}[data-block] table.present-data-table th,[data-block] table.present-data-table td{border:1px solid color-mix(in srgb,currentColor 22%,transparent);padding:.28em .45em;text-align:left;vertical-align:top}[data-block] table.present-data-table th{font-weight:600;opacity:.85}[data-block] .present-kv-nested{margin:.15em 0 .35em .6em;padding-left:.55em;border-left:2px solid color-mix(in srgb,currentColor 18%,transparent);font-size:.95em;white-space:pre-wrap}</style>
|
||||
<script>
|
||||
(function () {
|
||||
var FRAME_ID = ${JSON.stringify(safeId)};
|
||||
var PACKET = ${packetJson};
|
||||
var SLOTS = ${slotsJson};
|
||||
var failed = false;
|
||||
function post(type, extra) {
|
||||
var msg = { source: "present-frame", type: type, id: FRAME_ID };
|
||||
@@ -85,6 +95,8 @@ export function buildPresentSrcdoc({ html = "", css = "", js = "", packet, frame
|
||||
}
|
||||
function markdown(raw) {
|
||||
var s = String(raw == null ? "" : raw)
|
||||
.replace(/\\r\\n/g, "\\n")
|
||||
.replace(/\\r/g, "\\n")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
@@ -94,17 +106,364 @@ export function buildPresentSrcdoc({ html = "", css = "", js = "", packet, frame
|
||||
return "<p>" + part.replace(/\\n/g, "<br>") + "</p>";
|
||||
}).join("");
|
||||
}
|
||||
function slotType(id) {
|
||||
for (var i = 0; i < SLOTS.length; i++) {
|
||||
if (SLOTS[i] && SLOTS[i].id === id && SLOTS[i].type) return SLOTS[i].type;
|
||||
}
|
||||
return "text";
|
||||
}
|
||||
function normalizeType(type) {
|
||||
if (type === "safe_markdown") return "markdown";
|
||||
return type || "text";
|
||||
}
|
||||
function isProgramOwned(type) {
|
||||
var t = normalizeType(type);
|
||||
return t === "markdown" || t === "text" || t === "list" || t === "kv" || t === "table" || t === "topology";
|
||||
}
|
||||
function isEmpty(value) {
|
||||
if (value == null) return true;
|
||||
if (typeof value === "string") return !value.trim();
|
||||
if (Array.isArray(value)) return value.length === 0;
|
||||
if (typeof value === "object") return Object.keys(value).length === 0;
|
||||
return false;
|
||||
}
|
||||
function listItemText(item) {
|
||||
if (item == null) return "";
|
||||
if (typeof item === "string") {
|
||||
var s = item.trim();
|
||||
if (s.charAt(0) === "{" && s.indexOf('"text"') !== -1) {
|
||||
try {
|
||||
var parsed = JSON.parse(s);
|
||||
if (parsed && typeof parsed === "object") return listItemText(parsed);
|
||||
} catch (err) { /* keep raw */ }
|
||||
}
|
||||
return item;
|
||||
}
|
||||
if (typeof item === "object") {
|
||||
if (typeof item.text === "string") return item.text;
|
||||
if (typeof item.label === "string") return item.label;
|
||||
if (typeof item.title === "string") return item.title;
|
||||
if (typeof item.content === "string") return item.content;
|
||||
if (typeof item.name === "string" && typeof item.value === "string") {
|
||||
return item.name + ":" + item.value;
|
||||
}
|
||||
}
|
||||
return typeof item === "string" ? item : JSON.stringify(item);
|
||||
}
|
||||
function isBlockId(id) {
|
||||
if (!id || typeof id !== "string") return false;
|
||||
if (id.length > 128) return false;
|
||||
if (/["'<>\\\\]|[\\x00-\\x1f]/.test(id)) return false;
|
||||
return true;
|
||||
}
|
||||
function blockSelector(id) {
|
||||
if (typeof CSS !== "undefined" && typeof CSS.escape === "function") {
|
||||
return '[data-block="' + CSS.escape(id) + '"]';
|
||||
}
|
||||
return '[data-block="' + String(id).replace(/\\\\/g, "\\\\\\\\").replace(/"/g, '\\\\"') + '"]';
|
||||
}
|
||||
function tryParseJson(value) {
|
||||
if (typeof value !== "string") return value;
|
||||
var t = value.trim();
|
||||
if (!t) return value;
|
||||
var start = t.charAt(0);
|
||||
var end = t.charAt(t.length - 1);
|
||||
if (!((start === "{" && end === "}") || (start === "[" && end === "]"))) return value;
|
||||
try { return JSON.parse(t); } catch (err) { return value; }
|
||||
}
|
||||
function cellText(cell) {
|
||||
if (cell == null) return "";
|
||||
if (typeof cell === "string" || typeof cell === "number" || typeof cell === "boolean") {
|
||||
return String(cell);
|
||||
}
|
||||
try { return JSON.stringify(cell); } catch (err) { return String(cell); }
|
||||
}
|
||||
function isPlainObject(value) {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
function writeDataTable(el, rows, columns) {
|
||||
if (!rows || !rows.length) return false;
|
||||
var cols = columns && columns.length ? columns.slice() : [];
|
||||
if (!cols.length) {
|
||||
var seen = {};
|
||||
rows.forEach(function (row) {
|
||||
if (!isPlainObject(row)) return;
|
||||
Object.keys(row).forEach(function (key) {
|
||||
if (!seen[key]) {
|
||||
seen[key] = true;
|
||||
cols.push(key);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
if (!cols.length) return false;
|
||||
var table = document.createElement("table");
|
||||
table.className = "present-data-table";
|
||||
var thead = document.createElement("thead");
|
||||
var headRow = document.createElement("tr");
|
||||
cols.forEach(function (col) {
|
||||
var th = document.createElement("th");
|
||||
th.textContent = col;
|
||||
headRow.appendChild(th);
|
||||
});
|
||||
thead.appendChild(headRow);
|
||||
table.appendChild(thead);
|
||||
var tbody = document.createElement("tbody");
|
||||
rows.forEach(function (row) {
|
||||
var tr = document.createElement("tr");
|
||||
cols.forEach(function (col) {
|
||||
var td = document.createElement("td");
|
||||
td.textContent = isPlainObject(row) ? cellText(row[col]) : cellText(row);
|
||||
tr.appendChild(td);
|
||||
});
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
table.appendChild(tbody);
|
||||
el.appendChild(table);
|
||||
return true;
|
||||
}
|
||||
function matrixMapToRows(map) {
|
||||
return Object.keys(map).map(function (name) {
|
||||
var stats = map[name];
|
||||
if (isPlainObject(stats)) {
|
||||
var row = { name: name };
|
||||
Object.keys(stats).forEach(function (key) { row[key] = stats[key]; });
|
||||
return row;
|
||||
}
|
||||
return { name: name, value: stats };
|
||||
});
|
||||
}
|
||||
function writeNestedKv(el, value, depth) {
|
||||
var data = tryParseJson(value);
|
||||
if (Array.isArray(data)) {
|
||||
if (data.length && data.every(isPlainObject)) {
|
||||
writeDataTable(el, data);
|
||||
return;
|
||||
}
|
||||
var pre = document.createElement("pre");
|
||||
pre.className = "present-kv-nested";
|
||||
pre.textContent = JSON.stringify(data, null, 2);
|
||||
el.appendChild(pre);
|
||||
return;
|
||||
}
|
||||
if (!isPlainObject(data)) {
|
||||
var plain = document.createElement("p");
|
||||
plain.textContent = cellText(data);
|
||||
el.appendChild(plain);
|
||||
return;
|
||||
}
|
||||
var keys = Object.keys(data);
|
||||
var objectEntries = [];
|
||||
var otherEntries = [];
|
||||
keys.forEach(function (key) {
|
||||
var cell = tryParseJson(data[key]);
|
||||
if (isPlainObject(cell)) objectEntries.push([key, cell]);
|
||||
else otherEntries.push([key, cell]);
|
||||
});
|
||||
if (
|
||||
objectEntries.length >= 1 &&
|
||||
objectEntries.length === keys.length &&
|
||||
objectEntries.every(function (pair) { return isPlainObject(pair[1]); })
|
||||
) {
|
||||
writeDataTable(el, matrixMapToRows(data));
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(data.rows)) {
|
||||
writeDataTable(el, data.rows, Array.isArray(data.columns) ? data.columns : null);
|
||||
return;
|
||||
}
|
||||
otherEntries.concat(objectEntries).forEach(function (pair) {
|
||||
var key = pair[0];
|
||||
var cell = pair[1];
|
||||
if (isPlainObject(cell) || Array.isArray(cell)) {
|
||||
var label = document.createElement("p");
|
||||
label.textContent = key + ":";
|
||||
el.appendChild(label);
|
||||
if (depth < 3) {
|
||||
var nest = document.createElement("div");
|
||||
nest.className = "present-kv-nested";
|
||||
writeNestedKv(nest, cell, depth + 1);
|
||||
el.appendChild(nest);
|
||||
} else {
|
||||
var deep = document.createElement("pre");
|
||||
deep.className = "present-kv-nested";
|
||||
deep.textContent = JSON.stringify(cell, null, 2);
|
||||
el.appendChild(deep);
|
||||
}
|
||||
return;
|
||||
}
|
||||
var row = document.createElement("p");
|
||||
row.textContent = key + ":" + cellText(cell);
|
||||
el.appendChild(row);
|
||||
});
|
||||
}
|
||||
function writeBlock(el, value, type) {
|
||||
var t = normalizeType(type);
|
||||
if (t === "list" && Array.isArray(value)) {
|
||||
var ul = document.createElement("ul");
|
||||
value.forEach(function (item) {
|
||||
var li = document.createElement("li");
|
||||
li.textContent = listItemText(item);
|
||||
ul.appendChild(li);
|
||||
});
|
||||
el.appendChild(ul);
|
||||
return;
|
||||
}
|
||||
if (t === "table") {
|
||||
var tableValue = tryParseJson(value);
|
||||
if (Array.isArray(tableValue)) {
|
||||
if (tableValue.length && tableValue.every(isPlainObject)) {
|
||||
writeDataTable(el, tableValue);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (isPlainObject(tableValue)) {
|
||||
if (Array.isArray(tableValue.rows)) {
|
||||
writeDataTable(
|
||||
el,
|
||||
tableValue.rows,
|
||||
Array.isArray(tableValue.columns) ? tableValue.columns : null
|
||||
);
|
||||
return;
|
||||
}
|
||||
var onlyKeys = Object.keys(tableValue);
|
||||
if (onlyKeys.length === 1) {
|
||||
var only = tryParseJson(tableValue[onlyKeys[0]]);
|
||||
if (Array.isArray(only) && only.length && only.every(isPlainObject)) {
|
||||
writeDataTable(el, only);
|
||||
return;
|
||||
}
|
||||
if (isPlainObject(only)) {
|
||||
var nestedKeys = Object.keys(only);
|
||||
if (nestedKeys.length && nestedKeys.every(function (k) { return isPlainObject(only[k]); })) {
|
||||
writeDataTable(el, matrixMapToRows(only));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
writeNestedKv(el, tableValue, 0);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if ((t === "kv" || t === "topology") && value && typeof value === "object") {
|
||||
writeNestedKv(el, value, 0);
|
||||
return;
|
||||
}
|
||||
if (t === "markdown" || t === "text" || typeof value === "string") {
|
||||
el.innerHTML = markdown(typeof value === "string" ? value : JSON.stringify(value));
|
||||
return;
|
||||
}
|
||||
el.textContent = typeof value === "string" ? value : JSON.stringify(value);
|
||||
}
|
||||
function alreadyVisible(value) {
|
||||
var sample = "";
|
||||
if (typeof value === "string") sample = value.trim().slice(0, 24);
|
||||
else if (Array.isArray(value) && typeof value[0] === "string") sample = value[0].trim().slice(0, 24);
|
||||
if (sample.length < 8) return false;
|
||||
return (document.body && document.body.innerText ? document.body.innerText : "").indexOf(sample) !== -1;
|
||||
}
|
||||
function fillSlots() {
|
||||
var blocks = PACKET && PACKET.blocks ? PACKET.blocks : {};
|
||||
Object.keys(blocks).forEach(function (id) {
|
||||
if (!isBlockId(id)) return;
|
||||
var value = blocks[id];
|
||||
var type = slotType(id);
|
||||
var el = document.querySelector(blockSelector(id)) || document.getElementById(id);
|
||||
if (el) {
|
||||
if (isEmpty(value)) return;
|
||||
var empty = !el.textContent.trim() && !el.children.length;
|
||||
if (isProgramOwned(type) || empty) {
|
||||
while (el.firstChild) el.removeChild(el.firstChild);
|
||||
writeBlock(el, value, type);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (isEmpty(value) || alreadyVisible(value)) return;
|
||||
var host = document.createElement("section");
|
||||
host.setAttribute("data-block", id);
|
||||
writeBlock(host, value, type);
|
||||
document.body.appendChild(host);
|
||||
});
|
||||
}
|
||||
var renderCb = null;
|
||||
function pathOf(el) {
|
||||
var parts = [];
|
||||
var node = el;
|
||||
while (node && node !== document.documentElement) {
|
||||
var parent = node.parentElement;
|
||||
if (!parent) break;
|
||||
parts.push(Array.prototype.indexOf.call(parent.children, node));
|
||||
node = parent;
|
||||
}
|
||||
return parts.reverse().join("/");
|
||||
}
|
||||
function nodeAt(path) {
|
||||
var parts = String(path || "").split("/");
|
||||
var node = document.documentElement;
|
||||
for (var i = 0; i < parts.length; i++) {
|
||||
if (!node || !node.children) return null;
|
||||
node = node.children[Number(parts[i])];
|
||||
}
|
||||
return node;
|
||||
}
|
||||
function captureScrolls() {
|
||||
var list = [];
|
||||
var nodes = document.querySelectorAll("*");
|
||||
for (var i = 0; i < nodes.length; i++) {
|
||||
var n = nodes[i];
|
||||
if (!n.scrollTop && !n.scrollLeft) continue;
|
||||
list.push({ el: n, path: pathOf(n), top: n.scrollTop, left: n.scrollLeft });
|
||||
}
|
||||
list.push({
|
||||
path: "window",
|
||||
top: window.scrollY || 0,
|
||||
left: window.scrollX || 0
|
||||
});
|
||||
return list;
|
||||
}
|
||||
function restoreScrolls(list) {
|
||||
for (var i = 0; i < list.length; i++) {
|
||||
var row = list[i];
|
||||
if (row.path === "window") {
|
||||
window.scrollTo(row.left || 0, row.top || 0);
|
||||
continue;
|
||||
}
|
||||
var el = row.el && row.el.isConnected ? row.el : nodeAt(row.path);
|
||||
if (!el) continue;
|
||||
el.scrollTop = row.top;
|
||||
el.scrollLeft = row.left;
|
||||
}
|
||||
}
|
||||
function applyPacket(next) {
|
||||
var snaps = captureScrolls();
|
||||
if (next && next.blocks) PACKET = next;
|
||||
if (renderCb) {
|
||||
try { renderCb(PACKET); }
|
||||
catch (err) { reportError(err); }
|
||||
}
|
||||
try { fillSlots(); }
|
||||
catch (err) { reportError(err); }
|
||||
restoreScrolls(snaps);
|
||||
}
|
||||
window.present = {
|
||||
onData: function (cb) {
|
||||
renderCb = cb;
|
||||
try { cb(PACKET); }
|
||||
catch (err) { reportError(err); }
|
||||
},
|
||||
markdown: markdown
|
||||
};
|
||||
window.addEventListener("message", function (event) {
|
||||
var data = event.data;
|
||||
if (!data || data.source !== "present-host" || data.type !== "packet") return;
|
||||
applyPacket(data.packet);
|
||||
});
|
||||
window.addEventListener("error", function (e) {
|
||||
reportError(e.error || e.message || "脚本错误");
|
||||
});
|
||||
window.present.__seal = function () {
|
||||
try { fillSlots(); }
|
||||
catch (err) { reportError(err); }
|
||||
if (failed) return;
|
||||
post("ready");
|
||||
var sendHeight = function () {
|
||||
@@ -147,12 +506,18 @@ function mountOne(host, id, payload) {
|
||||
frame.className = "present-preview-frame";
|
||||
frame.setAttribute("sandbox", PRESENT_FRAME_SANDBOX);
|
||||
frame.setAttribute("referrerpolicy", "no-referrer");
|
||||
frame.setAttribute("scrolling", "no");
|
||||
frame.title = "版式预览";
|
||||
const hinted = Number.parseFloat(host.style.minHeight);
|
||||
if (Number.isFinite(hinted) && hinted > 8) {
|
||||
frame.style.height = `${Math.ceil(hinted)}px`;
|
||||
}
|
||||
const srcdoc = buildPresentSrcdoc({
|
||||
html: payload.bundle.html,
|
||||
css: payload.bundle.css,
|
||||
js: payload.bundle.js,
|
||||
packet: payload.packet,
|
||||
slots: payload.slots,
|
||||
frameId: id,
|
||||
});
|
||||
const section = host.closest(".present-preview");
|
||||
@@ -191,7 +556,13 @@ function mountOne(host, id, payload) {
|
||||
if (data.type === "height" && !frame.hidden) {
|
||||
const height = Number(data.height);
|
||||
if (height > 0) {
|
||||
frame.style.height = `${Math.min(960, Math.max(180, Math.ceil(height)))}px`;
|
||||
const next = Math.max(1, Math.ceil(height));
|
||||
const prev = Math.ceil(Number.parseFloat(frame.style.height) || 0);
|
||||
frame.style.height = `${next}px`;
|
||||
host.style.minHeight = "";
|
||||
if (Math.abs(next - prev) >= 2) {
|
||||
frame.dispatchEvent(new CustomEvent("present-frame-resize", { bubbles: true }));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (data.type === "ready") {
|
||||
@@ -214,6 +585,20 @@ function mountOne(host, id, payload) {
|
||||
frame.srcdoc = srcdoc;
|
||||
}
|
||||
|
||||
export function updatePresentFramePacket(frame, packet) {
|
||||
const win = frame?.contentWindow;
|
||||
if (!win) return false;
|
||||
try {
|
||||
win.postMessage(
|
||||
{ source: "present-host", type: "packet", packet: previewPacket(packet) },
|
||||
"*",
|
||||
);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function mountPresentFrames(root) {
|
||||
if (!root || typeof root.querySelectorAll !== "function") return;
|
||||
root.querySelectorAll(".present-frame-host[data-present-id]").forEach((host) => {
|
||||
|
||||
@@ -101,8 +101,10 @@ export function stripPresentSourceFences(raw) {
|
||||
function normalizeBlocks(raw) {
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
|
||||
const out = {};
|
||||
for (const k of ["monitor", "header", "body", "footer", "aside", "hidden"]) {
|
||||
if (raw[k] !== undefined) out[k] = raw[k];
|
||||
for (const [key, value] of Object.entries(raw)) {
|
||||
if (key === "正文" || key === "监控") continue;
|
||||
if (value == null) continue;
|
||||
out[key] = value;
|
||||
}
|
||||
if (out.body === undefined && raw.正文 !== undefined) out.body = raw.正文;
|
||||
if (out.monitor === undefined && raw.监控 !== undefined) out.monitor = raw.监控;
|
||||
|
||||
Reference in New Issue
Block a user