From 736bcf4bb2fe07fccf641b841a949ce8309173d6 Mon Sep 17 00:00:00 2001 From: hypercross Date: Sun, 12 Jul 2026 13:32:49 +0800 Subject: [PATCH] feat: add ReactiveStatManager for markdown template support Introduce ReactiveStatManager to allow dynamic stat replacement within rendered markdown content. This component scans the article DOM for `${key}` patterns and reactively updates them based on the journal stream. As part of this change, the SVG-based StatSheet system is removed in favor of this more flexible text-based approach. --- src/App.tsx | 2 + src/cli/commands/serve.ts | 47 +-- src/cli/completions/block-processor.ts | 3 - src/cli/completions/index.ts | 2 - src/cli/completions/types.ts | 13 - src/components/index.ts | 1 + .../journal/ReactiveStatManager.tsx | 183 +++++++++++ src/components/journal/SheetView.tsx | 121 ------- src/components/journal/StatsView.tsx | 310 +++++++----------- src/components/journal/completions.ts | 50 +-- src/components/journal/stat-sheet.ts | 89 ----- 11 files changed, 306 insertions(+), 515 deletions(-) create mode 100644 src/components/journal/ReactiveStatManager.tsx delete mode 100644 src/components/journal/SheetView.tsx delete mode 100644 src/components/journal/stat-sheet.ts diff --git a/src/App.tsx b/src/App.tsx index 6871f38..f25fcc8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -18,6 +18,7 @@ import { DocDialog, DataSourceDialog, RevealManager, + ReactiveStatManager, } from "./components"; import { generateToc, type FileNode, type TocNode } from "./data-loader"; import { JournalPanel } from "./components/journal"; @@ -156,6 +157,7 @@ const App: Component = () => { src={currentPath()} > + diff --git a/src/cli/commands/serve.ts b/src/cli/commands/serve.ts index fbedb1a..3c57e69 100644 --- a/src/cli/commands/serve.ts +++ b/src/cli/commands/serve.ts @@ -19,7 +19,6 @@ import { scanDirectives, type DirectiveScanResult, } from "../completions/directive-scanner.js"; -import type { StatSheet } from "../completions/types.js"; interface ContentIndex { [path: string]: string; @@ -88,22 +87,6 @@ function getBestIP(): string { return best || "localhost"; } -/** - * 从 SVG 字符串中提取 内容,无则返回 null - */ -function extractSvgTitle(svg: string): string | null { - const m = /<title[^>]*>([\s\S]*?)<\/title>/i.exec(svg); - return m ? m[1].trim() : null; -} - -/** - * 从文件名生成可读标签 - * 如 "character-sheet" -> "Character Sheet" - */ -function labelFromId(id: string): string { - return id.replace(/[-_]/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()); -} - /** * 扫描目录内的 .md 等文件,生成内容索引与块数据 */ @@ -116,7 +99,6 @@ export function scanDirectory(dir: string): { const blocks: ProcessedBlocks = { stats: [], statTemplates: [], - statSheets: [], }; const directiveResults: DirectiveScanResult[] = []; const mdFiles: { content: string; relPath: string }[] = []; @@ -148,17 +130,6 @@ export function scanDirectory(dir: string): { blocks.stats.push(...result.blocks.stats); blocks.statTemplates.push(...result.blocks.statTemplates); mdFiles.push({ content: result.stripped, relPath: normalizedRelPath }); - } else if (entry.endsWith(".sheet.svg")) { - index[normalizedRelPath] = content; - const id = entry.replace(/\.sheet\.svg$/i, ""); - const title = extractSvgTitle(content); - const sheet: StatSheet = { - id, - label: title || labelFromId(id), - svg: content, - source: normalizedRelPath, - }; - blocks.statSheets.push(sheet); } else { index[normalizedRelPath] = content; } @@ -341,7 +312,6 @@ export function createContentServer( let collectedBlocks: ProcessedBlocks = { stats: [], statTemplates: [], - statSheets: [], }; let directiveResults: DirectiveScanResult[] = []; let completionsIndex: CompletionsPayload = { @@ -350,14 +320,13 @@ export function createContentServer( sparkTables: [], stats: [], statTemplates: [], - statSheets: [], }; /** 从当前内容索引和已收集的块重新扫描补全数据 */ function recomputeCompletions(): void { completionsIndex = scanCompletions(contentIndex, collectedBlocks, directiveResults); console.log( - `[completions] dice=${completionsIndex.dice.length} links=${completionsIndex.links.length} sparkTables=${completionsIndex.sparkTables.length} stats=${completionsIndex.stats.length} templates=${completionsIndex.statTemplates.length} sheets=${completionsIndex.statSheets.length}`, + `[completions] dice=${completionsIndex.dice.length} links=${completionsIndex.links.length} sparkTables=${completionsIndex.sparkTables.length} stats=${completionsIndex.stats.length} templates=${completionsIndex.statTemplates.length}`, ); } @@ -399,12 +368,6 @@ export function createContentServer( recomputeCompletions(); } else { contentIndex[relPath] = content; - if (relPath.endsWith(".sheet.svg")) { - const rescan = scanDirectory(contentDir); - collectedBlocks = rescan.blocks; - directiveResults = rescan.directiveResults; - recomputeCompletions(); - } } console.log(`[新增] ${relPath}`); } catch (e) { @@ -431,12 +394,6 @@ export function createContentServer( recomputeCompletions(); } else { contentIndex[relPath] = content; - if (relPath.endsWith(".sheet.svg")) { - const rescan = scanDirectory(contentDir); - collectedBlocks = rescan.blocks; - directiveResults = rescan.directiveResults; - recomputeCompletions(); - } } console.log(`[更新] ${relPath}`); } catch (e) { @@ -454,7 +411,7 @@ export function createContentServer( const relPath = "/" + relative(contentDir, path).split(sep).join("/"); delete contentIndex[relPath]; console.log(`[删除] ${relPath}`); - if (relPath.endsWith(".md") || relPath.endsWith(".sheet.svg")) { + if (relPath.endsWith(".md")) { const rescan = scanDirectory(contentDir); collectedBlocks = rescan.blocks; directiveResults = rescan.directiveResults; diff --git a/src/cli/completions/block-processor.ts b/src/cli/completions/block-processor.ts index 78ed48a..aba5372 100644 --- a/src/cli/completions/block-processor.ts +++ b/src/cli/completions/block-processor.ts @@ -20,7 +20,6 @@ import { parseBlockAttrs, resolveBlockAs, } from "./block-scanner.js"; -import type { StatSheet } from "./types.js"; // Re-export shared pieces for convenience export { @@ -37,7 +36,6 @@ export { export interface ProcessedBlocks { stats: StatDef[]; statTemplates: StatTemplate[]; - statSheets: StatSheet[]; } export interface BlockResult { @@ -78,7 +76,6 @@ export function processBlocks( const blocks: ProcessedBlocks = { stats: [], statTemplates: [], - statSheets: [], }; const stripped = content.replace( diff --git a/src/cli/completions/index.ts b/src/cli/completions/index.ts index b585b96..5bc5b66 100644 --- a/src/cli/completions/index.ts +++ b/src/cli/completions/index.ts @@ -14,7 +14,6 @@ export type { SparkTableCompletion, StatDef, StatTemplate, - StatSheet, } from "./types.js"; /** @@ -43,6 +42,5 @@ export function scanCompletions( sparkTables, stats: blocks.stats, statTemplates: blocks.statTemplates, - statSheets: blocks.statSheets, }; } diff --git a/src/cli/completions/types.ts b/src/cli/completions/types.ts index ba1c110..b49a419 100644 --- a/src/cli/completions/types.ts +++ b/src/cli/completions/types.ts @@ -6,18 +6,6 @@ import type { StatDef, StatTemplate } from "./stat-parser.js"; export type { StatDef, StatTemplate }; -/** A stat sheet discovered from a *.sheet.svg file */ -export interface StatSheet { - /** Unique id — filename sans .sheet.svg */ - id: string; - /** Display label — from <title> or derived from filename */ - label: string; - /** Raw SVG content */ - svg: string; - /** Source file path for attribution */ - source: string; -} - /** A single dice expression found in a markdown file */ export interface DiceCompletion { /** Display text shown in the dropdown */ @@ -62,7 +50,6 @@ export interface CompletionsPayload { sparkTables: SparkTableCompletion[]; stats: StatDef[]; statTemplates: StatTemplate[]; - statSheets: StatSheet[]; } /** diff --git a/src/components/index.ts b/src/components/index.ts index b7bb825..b455d86 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -15,6 +15,7 @@ import "./md-yarn-spinner"; export { Article } from "./Article"; export type { ArticleProps } from "./Article"; export { RevealManager } from "./RevealManager"; +export { ReactiveStatManager } from "./journal/ReactiveStatManager"; export { MobileSidebar, DesktopSidebar } from "./Sidebar"; export type { SidebarProps } from "./Sidebar"; export { FileTreeNode, HeadingNode } from "./FileTree"; diff --git a/src/components/journal/ReactiveStatManager.tsx b/src/components/journal/ReactiveStatManager.tsx new file mode 100644 index 0000000..02f1777 --- /dev/null +++ b/src/components/journal/ReactiveStatManager.tsx @@ -0,0 +1,183 @@ +/** + * ReactiveStatManager — mounts as a child of <Article> and reactively + * replaces ${key} template patterns in the rendered markdown DOM with + * live stat values from the journal stream. + * + * On mount, walks all text nodes in the article content looking for + * ${key} placeholders. Each match is wrapped in a <span> with a + * data-reactive-stat attribute so that subsequent updates (driven by + * a Solid effect) only touch those spans. + */ + +import { Component, createEffect, onCleanup, createMemo } from "solid-js"; +import { useArticleDom } from "../Article"; +import { useJournalStream } from "../stores/journalStream"; +import { useJournalCompletions } from "../journal/completions"; +import { fullKey } from "../journal/stat-helpers"; +import type { StatDef } from "../journal/completions"; + +const TEMPLATE_RE = /\$\{(\w+)\}/g; + +/** Tag name used for marker spans — must stay in sync with the scan pass. */ +const MARKER = "data-reactive-stat"; + +export const ReactiveStatManager: Component = () => { + const contentDom = useArticleDom(); + const stream = useJournalStream(); + const comp = useJournalCompletions(); + + const statDefs = createMemo(() => comp.data.stats); + const values = createMemo(() => stream.stats); + + /** Build a lookup: bare key -> StatDef (for scope-aware resolution) */ + const bareDefMap = createMemo(() => { + const map = new Map<string, StatDef>(); + for (const def of statDefs()) { + if (!map.has(def.key)) { + map.set(def.key, def); + } + } + return map; + }); + + /** + * Resolve a bare key from markdown template to its runtime value. + * Uses StatDef scoping when available, falls back to player-first, global-second. + */ + function resolveBareKey(bareKey: string): string { + const bareDef = bareDefMap().get(bareKey); + if (bareDef) { + const fk = fullKey(bareDef, stream.myName); + return values()[fk] ?? bareDef.default ?? ""; + } + const pk = `${stream.myName}:${bareKey}`; + if (values()[pk] !== undefined) return values()[pk]; + return values()[bareKey] ?? ""; + } + + // ---- Initial scan: wrap ${key} text in marker spans ---- + + createEffect(() => { + const dom = contentDom(); + if (!dom) return; + + // Only scan once — after the first scan, the spans exist and we + // switch to the reactive update effect below. + if (dom.querySelector(`[${MARKER}]`)) return; + + scanAndWrap(dom); + }); + + onCleanup(() => { + const dom = contentDom(); + if (dom) unwrapAll(dom); + }); + + // ---- Reactive updates: whenever stat values change, update all spans ---- + + createEffect(() => { + const dom = contentDom(); + if (!dom) return; + + const v = values(); + const spans = dom.querySelectorAll<HTMLElement>(`[${MARKER}]`); + + for (const span of spans) { + const template = span.getAttribute(MARKER) ?? ""; + let result = template; + TEMPLATE_RE.lastIndex = 0; + let m: RegExpExecArray | null; + while ((m = TEMPLATE_RE.exec(template)) !== null) { + result = result.replace(`\${${m[1]}}`, resolveBareKey(m[1])); + } + if (span.textContent !== result) { + span.textContent = result; + } + } + }); + + return null; // renders nothing — works purely via DOM manipulation +}; + +// --------------------------------------------------------------------------- +// DOM helpers +// --------------------------------------------------------------------------- + +/** + * Walk all text nodes in a subtree and wrap `${key}` patterns in + * `<span data-reactive-stat="originalText">` elements. + */ +function scanAndWrap(root: Element): void { + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); + const replacements: { node: Text; html: string }[] = []; + + let textNode: Text | null; + while ((textNode = walker.nextNode() as Text | null)) { + const text = textNode.textContent; + if (!text) continue; + + TEMPLATE_RE.lastIndex = 0; + if (!TEMPLATE_RE.test(text)) continue; + + // Build replacement HTML: split on ${key} and wrap each key span + TEMPLATE_RE.lastIndex = 0; + const parts: string[] = []; + let lastIndex = 0; + let m: RegExpExecArray | null; + while ((m = TEMPLATE_RE.exec(text)) !== null) { + if (m.index > lastIndex) { + parts.push(escapeHtml(text.slice(lastIndex, m.index))); + } + parts.push( + `<span ${MARKER}="${escapeAttr(m[0])}">${escapeHtml(m[0])}</span>`, + ); + lastIndex = TEMPLATE_RE.lastIndex; + } + if (lastIndex < text.length) { + parts.push(escapeHtml(text.slice(lastIndex))); + } + + replacements.push({ node: textNode, html: parts.join("") }); + } + + for (const { node, html } of replacements) { + const wrapper = document.createElement("span"); + wrapper.innerHTML = html; + node.replaceWith(wrapper); + } +} + +/** + * Reverse scanAndWrap — remove marker spans, restoring plain text. + * Called on cleanup so subsequent remounts get a clean slate. + */ +function unwrapAll(root: Element): void { + const spans = root.querySelectorAll<HTMLElement>(`[${MARKER}]`); + // Process in reverse to avoid DOM mutation issues + for (let i = spans.length - 1; i >= 0; i--) { + const span = spans[i]; + const parent = span.parentNode; + if (!parent) continue; + const text = span.getAttribute(MARKER) ?? span.textContent ?? ""; + span.replaceWith(document.createTextNode(text)); + } + // Normalize to merge adjacent text nodes + root.normalize(); +} + +function escapeHtml(s: string): string { + return s + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">"); +} + +function escapeAttr(s: string): string { + return s + .replace(/&/g, "&") + .replace(/"/g, """) + .replace(/</g, "<") + .replace(/>/g, ">"); +} + +export default ReactiveStatManager; diff --git a/src/components/journal/SheetView.tsx b/src/components/journal/SheetView.tsx deleted file mode 100644 index eb7f6b0..0000000 --- a/src/components/journal/SheetView.tsx +++ /dev/null @@ -1,121 +0,0 @@ -/** - * SheetView — renders a stat sheet SVG with live reactive text bindings. - * - * Parses the SVG once via DOMParser, then uses a Solid effect to - * update only <text> elements containing ${key} templates whenever - * the stats store changes. - */ - -import { Component, createMemo, createEffect, createRoot, onCleanup } from "solid-js"; -import { useJournalStream } from "../stores/journalStream"; -import { useJournalCompletions } from "./completions"; -import { fullKey } from "./stat-helpers"; -import type { StatDef } from "./completions"; -import { parseSheet } from "./stat-sheet"; - -export const SheetView: Component<{ sheetId: string }> = (props) => { - const stream = useJournalStream(); - const comp = useJournalCompletions(); - - const statDefs = createMemo(() => comp.data.stats); - const values = createMemo(() => stream.stats); - - /** Build a lookup: bare key -> StatDef (for scope-aware template resolution) */ - const bareDefMap = createMemo(() => { - const map = new Map<string, StatDef>(); - for (const def of statDefs()) { - if (!map.has(def.key)) { - map.set(def.key, def); - } - } - return map; - }); - - /** - * Resolve a bare key from an SVG template to its runtime value. - * Uses StatDef scoping when available, falls back to player-first, global-second. - */ - function resolveBareKey(bareKey: string): string { - const bareDef = bareDefMap().get(bareKey); - if (bareDef) { - const fk = fullKey(bareDef, stream.myName); - return values()[fk] ?? bareDef.default ?? ""; - } - const pk = `${stream.myName}:${bareKey}`; - if (values()[pk] !== undefined) return values()[pk]; - return values()[bareKey] ?? ""; - } - - const sheet = createMemo(() => { - const s = comp.data.statSheets.find((sh) => sh.id === props.sheetId); - return s ?? null; - }); - - let containerRef: HTMLDivElement | undefined; - let disposeEffect: (() => void) | undefined; - - createEffect(() => { - const s = sheet(); - const container = containerRef; - - disposeEffect?.(); - disposeEffect = undefined; - - if (!container || !s) return; - - container.innerHTML = ""; - const parsed = parseSheet(s.svg); - // Force full-width, auto-height - parsed.svgElement.setAttribute("width", "100%"); - parsed.svgElement.removeAttribute("height"); - parsed.svgElement.style.display = "block"; - container.appendChild(parsed.svgElement); - - // Center template nodes so text stays centered as values change length - for (const tpl of parsed.templates) { - const el = tpl.el; - try { - const bbox = el.getBBox(); - const cx = bbox.x + bbox.width / 2; - el.setAttribute("text-anchor", "middle"); - el.setAttribute("x", String(cx)); - } catch { - // getBBox may fail if the node has no layout — skip silently - } - } - - if (parsed.templates.length === 0) return; - - const dispose = createRoot((disposer) => { - createEffect(() => { - const v = values(); - for (const tpl of parsed.templates) { - let result = tpl.template; - for (const key of tpl.keys) { - result = result.replace(`\${${key}}`, resolveBareKey(key)); - } - tpl.el.textContent = result; - } - }); - return disposer; - }); - - disposeEffect = dispose; - }); - - onCleanup(() => disposeEffect?.()); - - const fallback = ( - <p class="text-center text-gray-400 text-xs py-8">未找到属性卡: {props.sheetId}</p> - ); - - return ( - <div class="w-full h-full overflow-y-auto p-2"> - {sheet() ? ( - <div ref={containerRef} class="w-full" /> - ) : ( - fallback - )} - </div> - ); -}; \ No newline at end of file diff --git a/src/components/journal/StatsView.tsx b/src/components/journal/StatsView.tsx index bb22dfb..01b3cce 100644 --- a/src/components/journal/StatsView.tsx +++ b/src/components/journal/StatsView.tsx @@ -1,21 +1,15 @@ /** - * StatsView — table view of all stat key-value pairs, plus optional - * stat sheet rendering from *.sheet.svg files. + * StatsView — table view of all stat key-value pairs. * * Groups stats by scope (global, then per-player). Shows computed values * (base + modifiers) and inline roll buttons for stats with roll/table/formula. - * - * A floating button at bottom-right opens a sheet picker dropdown. - * Selecting a sheet delegates to SheetView. */ -import { Component, For, createMemo, createSignal, Show } from "solid-js"; -import { useSearchParams } from "@solidjs/router"; +import { Component, For, createMemo, Show } from "solid-js"; import { useJournalStream } from "../stores/journalStream"; import { useJournalCompletions } from "./completions"; import { fullKey, modifierLabel } from "./stat-helpers"; import type { StatDef } from "./completions"; -import { SheetView } from "./SheetView"; export const StatsView: Component = () => { const stream = useJournalStream(); @@ -23,15 +17,6 @@ export const StatsView: Component = () => { const statDefs = createMemo(() => comp.data.stats); const values = createMemo(() => stream.stats); - const sheets = createMemo(() => comp.data.statSheets); - - /** Currently selected sheet id, or null for table view. Persisted in URL. */ - const [searchParams, setSearchParams] = useSearchParams<{ sheet?: string }>(); - const selectedSheetId = () => searchParams.sheet || null; - const setSelectedSheetId = (id: string | null) => { - setSearchParams({ sheet: id || undefined }); - }; - const [pickerOpen, setPickerOpen] = createSignal(false); /** Build a lookup: fullKey -> StatDef */ const defMap = createMemo(() => { @@ -124,191 +109,126 @@ export const StatsView: Component = () => { }); return ( - <div class="h-full overflow-y-auto bg-gray-50 relative"> + <div class="h-full overflow-y-auto bg-gray-50"> <Show - when={selectedSheetId()} + when={statDefs().length > 0} fallback={ - <Show - when={statDefs().length > 0} - fallback={ - <p class="text-center text-gray-400 text-xs py-8"> - 暂无属性定义。在文档中使用 ```yaml role=stat 代码块定义属性。 - </p> - } - > - <For each={groups()}> - {(group) => ( - <div class="mb-3"> - <div class="sticky top-0 bg-gray-100 px-3 py-1.5 border-b border-gray-200"> - <span class="text-xs font-semibold text-gray-600"> - {group.label} - </span> - </div> - <div class="divide-y divide-gray-100"> - <For each={group.entries}> - {({ fullKey: fk, def }) => { - const val = () => values()[fk]; - const comp = () => computedValue(fk); - const hasModifiers = () => { - for (const [mfk, md] of defMap()) { - if ( - md.type === "modifier" && - md.target && - fullKeyTarget(md.target, md, def) - ) { - return true; - } - } - return false; - }; - const canRoll = () => - def.roll || - def.formula || - def.type === "enum" || - def.type === "template"; - - return ( - <div class="flex items-center px-3 py-1.5 hover:bg-white/50 transition-colors"> - <div class="flex-1 min-w-0 flex items-center gap-1.5"> - <span class="text-sm text-gray-800 truncate"> - {modifierLabel(def, statDefs())} - </span> - <span class="text-[10px] text-gray-400 bg-gray-100 px-1 rounded shrink-0"> - {def.key} - </span> - <Show when={def.type === "modifier"}> - <span class="text-[10px] text-blue-400 bg-blue-50 px-1 rounded shrink-0"> - →{def.target} - </span> - </Show> - </div> - - <div class="flex items-center gap-2 shrink-0"> - <Show - when={val() !== undefined} - fallback={ - <span class="text-sm text-gray-300"> - {def.default ?? "—"} - </span> - } - > - <span class="text-sm font-mono text-gray-700"> - {val()} - </span> - </Show> - - <Show - when={ - hasModifiers() && - comp() !== (val() ?? def.default ?? "") - } - > - <span class="text-xs text-gray-400"> - = {comp()} - </span> - </Show> - - <Show when={canRoll()}> - <button - class="text-[10px] bg-green-100 text-green-700 hover:bg-green-200 px-1.5 py-0.5 rounded transition-colors" - title="掷骰" - onClick={() => { - const textarea = - document.querySelector<HTMLTextAreaElement>( - "#journal-input-textarea", - ); - if (textarea) { - const nativeInputValueSetter = - Object.getOwnPropertyDescriptor( - window.HTMLTextAreaElement.prototype, - "value", - )?.set; - nativeInputValueSetter?.call( - textarea, - `/stat roll ${def.key}`, - ); - textarea.dispatchEvent( - new Event("input", { bubbles: true }), - ); - textarea.focus(); - } - }} - > - 🎲 - </button> - </Show> - </div> - </div> - ); - }} - </For> - </div> - </div> - )} - </For> - </Show> + <p class="text-center text-gray-400 text-xs py-8"> + 暂无属性定义。在文档中使用 ```yaml role=stat 代码块定义属性。 + </p> } > - <SheetView sheetId={selectedSheetId()!} /> - </Show> + <For each={groups()}> + {(group) => ( + <div class="mb-3"> + <div class="sticky top-0 bg-gray-100 px-3 py-1.5 border-b border-gray-200"> + <span class="text-xs font-semibold text-gray-600"> + {group.label} + </span> + </div> + <div class="divide-y divide-gray-100"> + <For each={group.entries}> + {({ fullKey: fk, def }) => { + const val = () => values()[fk]; + const comp = () => computedValue(fk); + const hasModifiers = () => { + for (const [mfk, md] of defMap()) { + if ( + md.type === "modifier" && + md.target && + fullKeyTarget(md.target, md, def) + ) { + return true; + } + } + return false; + }; + const canRoll = () => + def.roll || + def.formula || + def.type === "enum" || + def.type === "template"; - {/* Sheet picker — only show if sheets are available */} - <Show when={sheets().length > 0}> - <div class="sticky bottom-3 flex justify-end pr-3"> - {/* Dropdown trigger */} - <button - class="bg-white border border-gray-300 rounded-full w-8 h-8 flex items-center justify-center shadow-sm hover:bg-gray-50 transition-colors text-gray-500" - onClick={() => setPickerOpen((v) => !v)} - title="选择属性卡" - > - <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> - <rect x="3" y="3" width="18" height="18" rx="2" /> - <line x1="9" y1="3" x2="9" y2="21" /> - </svg> - </button> + return ( + <div class="flex items-center px-3 py-1.5 hover:bg-white/50 transition-colors"> + <div class="flex-1 min-w-0 flex items-center gap-1.5"> + <span class="text-sm text-gray-800 truncate"> + {modifierLabel(def, statDefs())} + </span> + <span class="text-[10px] text-gray-400 bg-gray-100 px-1 rounded shrink-0"> + {def.key} + </span> + <Show when={def.type === "modifier"}> + <span class="text-[10px] text-blue-400 bg-blue-50 px-1 rounded shrink-0"> + →{def.target} + </span> + </Show> + </div> - {/* Dropdown menu */} - <Show when={pickerOpen()}> - <div class="absolute bottom-10 right-0 bg-white border border-gray-200 rounded-lg shadow-lg py-1 min-w-40 z-50"> - <button - class={`w-full text-left px-3 py-1.5 text-xs hover:bg-gray-50 transition-colors ${ - selectedSheetId() === null ? "text-blue-600 font-medium" : "text-gray-700" - }`} - onClick={() => { - setSelectedSheetId(null); - setPickerOpen(false); - }} - > - 📋 表格 - </button> - <div class="border-t border-gray-100 my-1" /> - <For each={sheets()}> - {(sheet) => ( - <button - class={`w-full text-left px-3 py-1.5 text-xs hover:bg-gray-50 transition-colors ${ - selectedSheetId() === sheet.id ? "text-blue-600 font-medium" : "text-gray-700" - }`} - onClick={() => { - setSelectedSheetId(sheet.id); - setPickerOpen(false); - }} - > - {sheet.label} - </button> - )} - </For> + <div class="flex items-center gap-2 shrink-0"> + <Show + when={val() !== undefined} + fallback={ + <span class="text-sm text-gray-300"> + {def.default ?? "—"} + </span> + } + > + <span class="text-sm font-mono text-gray-700"> + {val()} + </span> + </Show> + + <Show + when={ + hasModifiers() && + comp() !== (val() ?? def.default ?? "") + } + > + <span class="text-xs text-gray-400"> + = {comp()} + </span> + </Show> + + <Show when={canRoll()}> + <button + class="text-[10px] bg-green-100 text-green-700 hover:bg-green-200 px-1.5 py-0.5 rounded transition-colors" + title="掷骰" + onClick={() => { + const textarea = + document.querySelector<HTMLTextAreaElement>( + "#journal-input-textarea", + ); + if (textarea) { + const nativeInputValueSetter = + Object.getOwnPropertyDescriptor( + window.HTMLTextAreaElement.prototype, + "value", + )?.set; + nativeInputValueSetter?.call( + textarea, + `/stat roll ${def.key}`, + ); + textarea.dispatchEvent( + new Event("input", { bubbles: true }), + ); + textarea.focus(); + } + }} + > + 🎲 + </button> + </Show> + </div> + </div> + ); + }} + </For> + </div> </div> - </Show> - - {/* Backdrop to close picker */} - <Show when={pickerOpen()}> - <div - class="fixed inset-0 z-40" - onClick={() => setPickerOpen(false)} - /> - </Show> - </div> + )} + </For> </Show> </div> ); -}; \ No newline at end of file +}; diff --git a/src/components/journal/completions.ts b/src/components/journal/completions.ts index 56277b8..fc47fb6 100644 --- a/src/components/journal/completions.ts +++ b/src/components/journal/completions.ts @@ -22,7 +22,6 @@ import { parseStatModifiers, } from "../../cli/completions/stat-parser"; import type { StatDef, StatTemplate } from "../../cli/completions/stat-parser"; -import type { StatSheet } from "../../cli/completions/types"; import { FENCED_BLOCK_RE, parseBlockAttrs, @@ -31,7 +30,7 @@ import { scanDirectives, } from "../../cli/completions/directive-scanner"; -export type { StatDef, StatTemplate, StatSheet }; +export type { StatDef, StatTemplate }; // ------------------- Types (mirrors CLI) ------------------- @@ -63,7 +62,6 @@ export interface JournalCompletions { sparkTables: SparkTableCompletion[]; stats: StatDef[]; statTemplates: StatTemplate[]; - statSheets: StatSheet[]; } export type CompletionsState = @@ -93,7 +91,6 @@ async function tryServer(): Promise<JournalCompletions | null> { statTemplates: Array.isArray(data.statTemplates) ? data.statTemplates : [], - statSheets: Array.isArray(data.statSheets) ? data.statSheets : [], }; } catch { return null; @@ -175,46 +172,7 @@ async function scanClientSide(): Promise<JournalCompletions> { sparkTables.push(...directiveResult.sparkTables); } - return { dice, links, sparkTables, stats, statTemplates, statSheets: [] }; -} - -// ------------------- Stat sheet helpers ------------------- - -function extractSvgTitle(svg: string): string | null { - const m = /<title[^>]*>([\s\S]*?)<\/title>/i.exec(svg); - return m ? m[1].trim() : null; -} - -function labelFromId(id: string): string { - return id - .replace(/[-_]/g, " ") - .replace(/\b\w/g, (c) => c.toUpperCase()); -} - -async function scanStatSheets(): Promise<StatSheet[]> { - const paths = await getPathsByExtension("svg"); - const sheets: StatSheet[] = []; - - for (const filePath of paths) { - if (!/\.sheet\.svg$/i.test(filePath)) continue; - try { - const content = await getIndexedData(filePath); - if (!content) continue; - const fileName = filePath.split("/").filter(Boolean).pop() || filePath; - const id = fileName.replace(/\.sheet\.svg$/i, ""); - const title = extractSvgTitle(content); - sheets.push({ - id, - label: title || labelFromId(id), - svg: content, - source: filePath, - }); - } catch { - // skip unreadable files - } - } - - return sheets; + return { dice, links, sparkTables, stats, statTemplates }; } // ------------------- Init (runs eagerly at import time) ------------------- @@ -231,8 +189,7 @@ const _initPromise: Promise<void> = (async () => { // 2. Fall back to client-side scan (dev/browser mode) try { const data = await scanClientSide(); - data.statSheets = await scanStatSheets(); - if (data.dice.length > 0 || data.links.length > 0 || data.statSheets.length > 0) { + if (data.dice.length > 0 || data.links.length > 0) { setCompletionsState({ status: "loaded", data }); } else { setCompletionsState({ status: "empty" }); @@ -283,7 +240,6 @@ export function useJournalCompletions(): { sparkTables: [], stats: [], statTemplates: [], - statSheets: [], }, }; } diff --git a/src/components/journal/stat-sheet.ts b/src/components/journal/stat-sheet.ts deleted file mode 100644 index 485e767..0000000 --- a/src/components/journal/stat-sheet.ts +++ /dev/null @@ -1,89 +0,0 @@ -/** - * Stat sheet template engine — parses *.sheet.svg files and extracts - * text nodes containing ${key} template patterns. - * - * Walks <text> elements. If a <text> has <tspan> children, we bind to - * each <tspan> individually (preserving their position attributes). - * If a <text> has no <tspan> children, we bind to the <text> directly. - */ - -/** Regex matching ${key} template patterns (captures the key name). */ -const TEMPLATE_RE = /\$\{(\w+)\}/g; - -/** A single template binding targeting a specific text-bearing leaf node. */ -export interface TextTemplate { - /** The DOM node to update — a <text> (no children) or a <tspan>. */ - el: SVGTextContentElement; - /** Original text content with ${key} placeholders. */ - template: string; - /** Bare key names extracted from the template. */ - keys: string[]; -} - -/** The result of parsing a stat sheet SVG string. */ -export interface ParsedSheet { - svgElement: SVGSVGElement; - templates: TextTemplate[]; -} - -/** - * Check if a text node's content has template patterns, and if so, - * register a binding. - */ -function scanNode(el: Element, templates: TextTemplate[]): void { - const content = el.textContent; - if (!content) return; - - TEMPLATE_RE.lastIndex = 0; - const keys: string[] = []; - let m: RegExpExecArray | null; - while ((m = TEMPLATE_RE.exec(content)) !== null) { - keys.push(m[1]); - } - - if (keys.length > 0) { - templates.push({ - el: el as SVGTextContentElement, - template: content, - keys, - }); - } -} - -/** - * Parse an SVG string into a live DOM element and extract all template - * bindings from <text> and <tspan> leaf nodes. - */ -export function parseSheet(svgString: string): ParsedSheet { - const parser = new DOMParser(); - const doc = parser.parseFromString(svgString, "image/svg+xml"); - const svgElement = doc.documentElement as unknown as SVGSVGElement; - - if (!svgElement || svgElement.tagName !== "svg") { - return { - svgElement: document.createElementNS( - "http://www.w3.org/2000/svg", - "svg", - ) as SVGSVGElement, - templates: [], - }; - } - - const templates: TextTemplate[] = []; - - for (const textEl of svgElement.querySelectorAll("text")) { - const tspans = textEl.querySelectorAll("tspan"); - - if (tspans.length > 0) { - // Has children — bind to each <tspan> individually - for (const tspan of tspans) { - scanNode(tspan, templates); - } - } else { - // Leaf <text> — bind directly - scanNode(textEl, templates); - } - } - - return { svgElement, templates }; -} \ No newline at end of file