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.
This commit is contained in:
hypercross 2026-07-12 13:32:49 +08:00
parent 722a8110e6
commit 736bcf4bb2
11 changed files with 306 additions and 515 deletions

View File

@ -18,6 +18,7 @@ import {
DocDialog, DocDialog,
DataSourceDialog, DataSourceDialog,
RevealManager, RevealManager,
ReactiveStatManager,
} from "./components"; } from "./components";
import { generateToc, type FileNode, type TocNode } from "./data-loader"; import { generateToc, type FileNode, type TocNode } from "./data-loader";
import { JournalPanel } from "./components/journal"; import { JournalPanel } from "./components/journal";
@ -156,6 +157,7 @@ const App: Component = () => {
src={currentPath()} src={currentPath()}
> >
<RevealManager /> <RevealManager />
<ReactiveStatManager />
</Article> </Article>
</div> </div>
</main> </main>

View File

@ -19,7 +19,6 @@ import {
scanDirectives, scanDirectives,
type DirectiveScanResult, type DirectiveScanResult,
} from "../completions/directive-scanner.js"; } from "../completions/directive-scanner.js";
import type { StatSheet } from "../completions/types.js";
interface ContentIndex { interface ContentIndex {
[path: string]: string; [path: string]: string;
@ -88,22 +87,6 @@ function getBestIP(): string {
return best || "localhost"; return best || "localhost";
} }
/**
* SVG <title> 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 * .md
*/ */
@ -116,7 +99,6 @@ export function scanDirectory(dir: string): {
const blocks: ProcessedBlocks = { const blocks: ProcessedBlocks = {
stats: [], stats: [],
statTemplates: [], statTemplates: [],
statSheets: [],
}; };
const directiveResults: DirectiveScanResult[] = []; const directiveResults: DirectiveScanResult[] = [];
const mdFiles: { content: string; relPath: string }[] = []; const mdFiles: { content: string; relPath: string }[] = [];
@ -148,17 +130,6 @@ export function scanDirectory(dir: string): {
blocks.stats.push(...result.blocks.stats); blocks.stats.push(...result.blocks.stats);
blocks.statTemplates.push(...result.blocks.statTemplates); blocks.statTemplates.push(...result.blocks.statTemplates);
mdFiles.push({ content: result.stripped, relPath: normalizedRelPath }); 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 { } else {
index[normalizedRelPath] = content; index[normalizedRelPath] = content;
} }
@ -341,7 +312,6 @@ export function createContentServer(
let collectedBlocks: ProcessedBlocks = { let collectedBlocks: ProcessedBlocks = {
stats: [], stats: [],
statTemplates: [], statTemplates: [],
statSheets: [],
}; };
let directiveResults: DirectiveScanResult[] = []; let directiveResults: DirectiveScanResult[] = [];
let completionsIndex: CompletionsPayload = { let completionsIndex: CompletionsPayload = {
@ -350,14 +320,13 @@ export function createContentServer(
sparkTables: [], sparkTables: [],
stats: [], stats: [],
statTemplates: [], statTemplates: [],
statSheets: [],
}; };
/** 从当前内容索引和已收集的块重新扫描补全数据 */ /** 从当前内容索引和已收集的块重新扫描补全数据 */
function recomputeCompletions(): void { function recomputeCompletions(): void {
completionsIndex = scanCompletions(contentIndex, collectedBlocks, directiveResults); completionsIndex = scanCompletions(contentIndex, collectedBlocks, directiveResults);
console.log( 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(); recomputeCompletions();
} else { } else {
contentIndex[relPath] = content; contentIndex[relPath] = content;
if (relPath.endsWith(".sheet.svg")) {
const rescan = scanDirectory(contentDir);
collectedBlocks = rescan.blocks;
directiveResults = rescan.directiveResults;
recomputeCompletions();
}
} }
console.log(`[新增] ${relPath}`); console.log(`[新增] ${relPath}`);
} catch (e) { } catch (e) {
@ -431,12 +394,6 @@ export function createContentServer(
recomputeCompletions(); recomputeCompletions();
} else { } else {
contentIndex[relPath] = content; contentIndex[relPath] = content;
if (relPath.endsWith(".sheet.svg")) {
const rescan = scanDirectory(contentDir);
collectedBlocks = rescan.blocks;
directiveResults = rescan.directiveResults;
recomputeCompletions();
}
} }
console.log(`[更新] ${relPath}`); console.log(`[更新] ${relPath}`);
} catch (e) { } catch (e) {
@ -454,7 +411,7 @@ export function createContentServer(
const relPath = "/" + relative(contentDir, path).split(sep).join("/"); const relPath = "/" + relative(contentDir, path).split(sep).join("/");
delete contentIndex[relPath]; delete contentIndex[relPath];
console.log(`[删除] ${relPath}`); console.log(`[删除] ${relPath}`);
if (relPath.endsWith(".md") || relPath.endsWith(".sheet.svg")) { if (relPath.endsWith(".md")) {
const rescan = scanDirectory(contentDir); const rescan = scanDirectory(contentDir);
collectedBlocks = rescan.blocks; collectedBlocks = rescan.blocks;
directiveResults = rescan.directiveResults; directiveResults = rescan.directiveResults;

View File

@ -20,7 +20,6 @@ import {
parseBlockAttrs, parseBlockAttrs,
resolveBlockAs, resolveBlockAs,
} from "./block-scanner.js"; } from "./block-scanner.js";
import type { StatSheet } from "./types.js";
// Re-export shared pieces for convenience // Re-export shared pieces for convenience
export { export {
@ -37,7 +36,6 @@ export {
export interface ProcessedBlocks { export interface ProcessedBlocks {
stats: StatDef[]; stats: StatDef[];
statTemplates: StatTemplate[]; statTemplates: StatTemplate[];
statSheets: StatSheet[];
} }
export interface BlockResult { export interface BlockResult {
@ -78,7 +76,6 @@ export function processBlocks(
const blocks: ProcessedBlocks = { const blocks: ProcessedBlocks = {
stats: [], stats: [],
statTemplates: [], statTemplates: [],
statSheets: [],
}; };
const stripped = content.replace( const stripped = content.replace(

View File

@ -14,7 +14,6 @@ export type {
SparkTableCompletion, SparkTableCompletion,
StatDef, StatDef,
StatTemplate, StatTemplate,
StatSheet,
} from "./types.js"; } from "./types.js";
/** /**
@ -43,6 +42,5 @@ export function scanCompletions(
sparkTables, sparkTables,
stats: blocks.stats, stats: blocks.stats,
statTemplates: blocks.statTemplates, statTemplates: blocks.statTemplates,
statSheets: blocks.statSheets,
}; };
} }

View File

@ -6,18 +6,6 @@ import type { StatDef, StatTemplate } from "./stat-parser.js";
export type { StatDef, StatTemplate }; 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 */ /** A single dice expression found in a markdown file */
export interface DiceCompletion { export interface DiceCompletion {
/** Display text shown in the dropdown */ /** Display text shown in the dropdown */
@ -62,7 +50,6 @@ export interface CompletionsPayload {
sparkTables: SparkTableCompletion[]; sparkTables: SparkTableCompletion[];
stats: StatDef[]; stats: StatDef[];
statTemplates: StatTemplate[]; statTemplates: StatTemplate[];
statSheets: StatSheet[];
} }
/** /**

View File

@ -15,6 +15,7 @@ import "./md-yarn-spinner";
export { Article } from "./Article"; export { Article } from "./Article";
export type { ArticleProps } from "./Article"; export type { ArticleProps } from "./Article";
export { RevealManager } from "./RevealManager"; export { RevealManager } from "./RevealManager";
export { ReactiveStatManager } from "./journal/ReactiveStatManager";
export { MobileSidebar, DesktopSidebar } from "./Sidebar"; export { MobileSidebar, DesktopSidebar } from "./Sidebar";
export type { SidebarProps } from "./Sidebar"; export type { SidebarProps } from "./Sidebar";
export { FileTreeNode, HeadingNode } from "./FileTree"; export { FileTreeNode, HeadingNode } from "./FileTree";

View File

@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
function escapeAttr(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/"/g, "&quot;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
export default ReactiveStatManager;

View File

@ -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>
);
};

View File

@ -1,21 +1,15 @@
/** /**
* StatsView table view of all stat key-value pairs, plus optional * StatsView table view of all stat key-value pairs.
* stat sheet rendering from *.sheet.svg files.
* *
* Groups stats by scope (global, then per-player). Shows computed values * Groups stats by scope (global, then per-player). Shows computed values
* (base + modifiers) and inline roll buttons for stats with roll/table/formula. * (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 { Component, For, createMemo, Show } from "solid-js";
import { useSearchParams } from "@solidjs/router";
import { useJournalStream } from "../stores/journalStream"; import { useJournalStream } from "../stores/journalStream";
import { useJournalCompletions } from "./completions"; import { useJournalCompletions } from "./completions";
import { fullKey, modifierLabel } from "./stat-helpers"; import { fullKey, modifierLabel } from "./stat-helpers";
import type { StatDef } from "./completions"; import type { StatDef } from "./completions";
import { SheetView } from "./SheetView";
export const StatsView: Component = () => { export const StatsView: Component = () => {
const stream = useJournalStream(); const stream = useJournalStream();
@ -23,15 +17,6 @@ export const StatsView: Component = () => {
const statDefs = createMemo(() => comp.data.stats); const statDefs = createMemo(() => comp.data.stats);
const values = createMemo(() => stream.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 */ /** Build a lookup: fullKey -> StatDef */
const defMap = createMemo(() => { const defMap = createMemo(() => {
@ -124,190 +109,125 @@ export const StatsView: Component = () => {
}); });
return ( return (
<div class="h-full overflow-y-auto bg-gray-50 relative"> <div class="h-full overflow-y-auto bg-gray-50">
<Show <Show
when={selectedSheetId()} when={statDefs().length > 0}
fallback={ fallback={
<Show <p class="text-center text-gray-400 text-xs py-8">
when={statDefs().length > 0} 使 ```yaml role=stat 代码块定义属性。
fallback={ </p>
<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>
} }
> >
<SheetView sheetId={selectedSheetId()!} /> <For each={groups()}>
</Show> {(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 */} return (
<Show when={sheets().length > 0}> <div class="flex items-center px-3 py-1.5 hover:bg-white/50 transition-colors">
<div class="sticky bottom-3 flex justify-end pr-3"> <div class="flex-1 min-w-0 flex items-center gap-1.5">
{/* Dropdown trigger */} <span class="text-sm text-gray-800 truncate">
<button {modifierLabel(def, statDefs())}
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" </span>
onClick={() => setPickerOpen((v) => !v)} <span class="text-[10px] text-gray-400 bg-gray-100 px-1 rounded shrink-0">
title="选择属性卡" {def.key}
> </span>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <Show when={def.type === "modifier"}>
<rect x="3" y="3" width="18" height="18" rx="2" /> <span class="text-[10px] text-blue-400 bg-blue-50 px-1 rounded shrink-0">
<line x1="9" y1="3" x2="9" y2="21" /> {def.target}
</svg> </span>
</button> </Show>
</div>
{/* Dropdown menu */} <div class="flex items-center gap-2 shrink-0">
<Show when={pickerOpen()}> <Show
<div class="absolute bottom-10 right-0 bg-white border border-gray-200 rounded-lg shadow-lg py-1 min-w-40 z-50"> when={val() !== undefined}
<button fallback={
class={`w-full text-left px-3 py-1.5 text-xs hover:bg-gray-50 transition-colors ${ <span class="text-sm text-gray-300">
selectedSheetId() === null ? "text-blue-600 font-medium" : "text-gray-700" {def.default ?? "—"}
}`} </span>
onClick={() => { }
setSelectedSheetId(null); >
setPickerOpen(false); <span class="text-sm font-mono text-gray-700">
}} {val()}
> </span>
📋 </Show>
</button>
<div class="border-t border-gray-100 my-1" /> <Show
<For each={sheets()}> when={
{(sheet) => ( hasModifiers() &&
<button comp() !== (val() ?? def.default ?? "")
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" >
}`} <span class="text-xs text-gray-400">
onClick={() => { = {comp()}
setSelectedSheetId(sheet.id); </span>
setPickerOpen(false); </Show>
}}
> <Show when={canRoll()}>
{sheet.label} <button
</button> class="text-[10px] bg-green-100 text-green-700 hover:bg-green-200 px-1.5 py-0.5 rounded transition-colors"
)} title="掷骰"
</For> 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> </div>
</Show> )}
</For>
{/* Backdrop to close picker */}
<Show when={pickerOpen()}>
<div
class="fixed inset-0 z-40"
onClick={() => setPickerOpen(false)}
/>
</Show>
</div>
</Show> </Show>
</div> </div>
); );

View File

@ -22,7 +22,6 @@ import {
parseStatModifiers, parseStatModifiers,
} from "../../cli/completions/stat-parser"; } from "../../cli/completions/stat-parser";
import type { StatDef, StatTemplate } from "../../cli/completions/stat-parser"; import type { StatDef, StatTemplate } from "../../cli/completions/stat-parser";
import type { StatSheet } from "../../cli/completions/types";
import { import {
FENCED_BLOCK_RE, FENCED_BLOCK_RE,
parseBlockAttrs, parseBlockAttrs,
@ -31,7 +30,7 @@ import {
scanDirectives, scanDirectives,
} from "../../cli/completions/directive-scanner"; } from "../../cli/completions/directive-scanner";
export type { StatDef, StatTemplate, StatSheet }; export type { StatDef, StatTemplate };
// ------------------- Types (mirrors CLI) ------------------- // ------------------- Types (mirrors CLI) -------------------
@ -63,7 +62,6 @@ export interface JournalCompletions {
sparkTables: SparkTableCompletion[]; sparkTables: SparkTableCompletion[];
stats: StatDef[]; stats: StatDef[];
statTemplates: StatTemplate[]; statTemplates: StatTemplate[];
statSheets: StatSheet[];
} }
export type CompletionsState = export type CompletionsState =
@ -93,7 +91,6 @@ async function tryServer(): Promise<JournalCompletions | null> {
statTemplates: Array.isArray(data.statTemplates) statTemplates: Array.isArray(data.statTemplates)
? data.statTemplates ? data.statTemplates
: [], : [],
statSheets: Array.isArray(data.statSheets) ? data.statSheets : [],
}; };
} catch { } catch {
return null; return null;
@ -175,46 +172,7 @@ async function scanClientSide(): Promise<JournalCompletions> {
sparkTables.push(...directiveResult.sparkTables); sparkTables.push(...directiveResult.sparkTables);
} }
return { dice, links, sparkTables, stats, statTemplates, statSheets: [] }; return { dice, links, sparkTables, stats, statTemplates };
}
// ------------------- 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;
} }
// ------------------- Init (runs eagerly at import time) ------------------- // ------------------- 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) // 2. Fall back to client-side scan (dev/browser mode)
try { try {
const data = await scanClientSide(); const data = await scanClientSide();
data.statSheets = await scanStatSheets(); if (data.dice.length > 0 || data.links.length > 0) {
if (data.dice.length > 0 || data.links.length > 0 || data.statSheets.length > 0) {
setCompletionsState({ status: "loaded", data }); setCompletionsState({ status: "loaded", data });
} else { } else {
setCompletionsState({ status: "empty" }); setCompletionsState({ status: "empty" });
@ -283,7 +240,6 @@ export function useJournalCompletions(): {
sparkTables: [], sparkTables: [],
stats: [], stats: [],
statTemplates: [], statTemplates: [],
statSheets: [],
}, },
}; };
} }

View File

@ -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 };
}