/** * 游玩辅助 Tab:表格 / 开场与角色。 * 隐藏格默认挡住,点字段才露。 * 可改格直接改本地草稿;只有点保存并确认后才写入。 */ import { parsePresentDoc } from "./present-shells.js"; let playAuxTab = "table"; let revealedHidden = new Set(); let secretsOpen = false; let draftsTag = ""; /** @type {Map} */ 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 ?? "") .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """); } function cssAttr(value) { if (typeof CSS !== "undefined" && typeof CSS.escape === "function") { return CSS.escape(value); } return String(value).replace(/["\\]/g, "\\$&"); } 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; } } 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 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 `
${esc(formatTableValue(rows))}
`; const head = cols.map((col) => `${esc(col)}`).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 `${nestedValueHtml(nested, 1)}`; } return `${esc(leafText(raw))}`; }) .join(""); return `${cells}`; }) .join(""); return `
${head}${body}
`; } 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 `{}`; const items = entries .map(([key, child]) => { const nested = coerceTableValue(child); if (nested !== null && typeof nested === "object" && depth < 2) { return `
${esc(key)}${nestedValueHtml(nested, depth + 1)}
`; } return `
${esc(key)}${esc(leafText(nested))}
`; }) .join(""); return `
${items}
`; } function nestedValueHtml(value, depth = 0) { const normalized = coerceTableValue(value); if (normalized === null || normalized === undefined) { return `空`; } if (typeof normalized !== "object") { return `${esc(String(normalized))}`; } if (Array.isArray(normalized)) { if ( normalized.length && normalized.every((item) => item && typeof item === "object" && !Array.isArray(item)) ) { return nestedMatrixHtml(normalized); } return `
${esc(JSON.stringify(normalized, null, 2))}
`; } 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 ``; } if (stacked(row)) { return ``; } const inputMode = typeof baseline === "number" ? ' inputmode="decimal"' : ""; const placeholder = typeof baseline === "number" ? "数字" : "空"; return ``; } 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 `
  • `; } const valueHtml = structured ? nestedValueHtml(row.value) : `${esc(formatTableValue(row.value) || "空")}`; return `
  • ${esc(row.key)} ${valueHtml}
  • `; } function rowHtml(row, { hidden, revealed, busy }) { const shown = !hidden || revealed; if (!shown || !row.editable) return readonlyHtml(row, { hidden, revealed }); const stack = stacked(row); return `
  • ${fieldHtml(row, { busy })}
  • `; } function tablePanelHtml(table, busy) { if (!table?.rows?.length) { return `

    本局没有状态表。

    `; } const visible = table.rows.filter((r) => r.visibility !== "hidden"); const hidden = table.rows.filter((r) => r.visibility === "hidden"); const vis = visible.length > 0 ? `` : `

    没有可见字段。

    `; const hid = hidden.length ? `
    不可见 ${hidden.length}
    ` : ""; const editable = table.rows.some((row) => row.editable); const bar = editable ? `` : ""; return `
    ${vis}${hid}${bar}
    `; } function previewOpening(text) { let source = String(text || "").trim(); if (source.startsWith("{")) { try { const view = parsePresentDoc(JSON.parse(source)); const body = view?.packet?.blocks?.body; if (typeof body === "string" && body.trim()) source = body.trim(); } catch { /* 保持原文 */ } } const flat = source.replace(/\s+/g, " ").trim(); if (flat.length <= 72) return flat; return `${flat.slice(0, 72)}…`; } function openingPanelHtml(aux) { const openings = aux?.openings ?? []; if (!openings.length) { return `

    没有可选开场。使用当前用户角色卡。

    `; } const cards = openings .map((item) => { const selected = item.index === aux.selectedOpeningIndex; const persona = item.persona; return `
    ${item.slot ? `

    ${esc(item.slot)}

    ` : ""}

    ${esc(previewOpening(item.text))}

    ${ persona ? `

    ${esc(persona.name)}${ persona.description ? ` · ${esc(persona.description)}` : "" }

    ` : `

    无预设角色

    ` }
    `; }) .join(""); const usingOpening = aux.openingPersonaChoice === "opening"; return `
    ${cards}
    `; } function tabBtn(id, label, current) { return ``; } export function fillPlayAux(view, handlers = {}, extra = {}) { const rail = document.getElementById("coord-rail"); const drawerBody = document.getElementById("coord-drawer-body"); const title = document.getElementById("coord-rail-title"); const drawerCount = document.getElementById("coord-drawer-count"); if (!rail || !drawerBody) return; rail.hidden = false; capturePlayTableEdits(drawerBody); const aux = view.playAux; const head = rail.querySelector(".coord-rail-head"); if (head) { let tabs = head.querySelector(".play-aux-tabs"); if (!tabs) { tabs = document.createElement("nav"); tabs.className = "play-aux-tabs"; tabs.setAttribute("aria-label", "游玩辅助"); const collapse = head.querySelector(".coord-rail-collapse"); if (collapse) head.insertBefore(tabs, collapse); else head.appendChild(tabs); } tabs.hidden = false; tabs.innerHTML = [ tabBtn("table", "表格", playAuxTab), tabBtn("opening", "开场", playAuxTab), ].join(""); tabs.onclick = (e) => { const btn = e.target.closest("[data-play-aux-tab]"); if (!btn) return; const id = btn.getAttribute("data-play-aux-tab"); if (!id || id === playAuxTab) return; playAuxTab = id; tableFocus = null; fillPlayAux(view, handlers, extra); }; } if (title) title.textContent = "游玩"; if (drawerCount) drawerCount.textContent = ""; 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") { drawerBody.innerHTML = openingPanelHtml(aux); wireOpening(drawerBody, handlers, busy); } } export function currentPlayAuxTab() { return playAuxTab; } export function hidePlayAuxTabs() { const tabs = document.querySelector(".play-aux-tabs"); 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"); 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; } const commit = (e.key === "Enter" && el.tagName !== "TEXTAREA") || (e.key === "Enter" && (e.metaKey || e.ctrlKey)); if (!commit) return; e.preventDefault(); 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?.(); 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) { root.querySelectorAll("[data-opening-index]").forEach((card) => { const go = () => { if (busy) return; const index = Number(card.getAttribute("data-opening-index")); if (!Number.isFinite(index)) return; const hasPersona = Boolean(card.querySelector(".play-open-persona strong")); handlers.onSelectOpening?.(index, hasPersona ? true : undefined); }; card.addEventListener("click", go); card.addEventListener("keydown", (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); go(); } }); }); root.querySelector(".play-open-refuse")?.addEventListener("click", () => { if (busy) return; handlers.onRefuseOpeningPersona?.(); }); }