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:
parent
722a8110e6
commit
736bcf4bb2
|
|
@ -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()}
|
||||
>
|
||||
<RevealManager />
|
||||
<ReactiveStatManager />
|
||||
</Article>
|
||||
</div>
|
||||
</main>
|
||||
|
|
|
|||
|
|
@ -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 字符串中提取 <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 等文件,生成内容索引与块数据
|
||||
*/
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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,10 +109,7 @@ export const StatsView: Component = () => {
|
|||
});
|
||||
|
||||
return (
|
||||
<div class="h-full overflow-y-auto bg-gray-50 relative">
|
||||
<Show
|
||||
when={selectedSheetId()}
|
||||
fallback={
|
||||
<div class="h-full overflow-y-auto bg-gray-50">
|
||||
<Show
|
||||
when={statDefs().length > 0}
|
||||
fallback={
|
||||
|
|
@ -247,68 +229,6 @@ export const StatsView: Component = () => {
|
|||
)}
|
||||
</For>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<SheetView sheetId={selectedSheetId()!} />
|
||||
</Show>
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* 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>
|
||||
</Show>
|
||||
|
||||
{/* Backdrop to close picker */}
|
||||
<Show when={pickerOpen()}>
|
||||
<div
|
||||
class="fixed inset-0 z-40"
|
||||
onClick={() => setPickerOpen(false)}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -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: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
Loading…
Reference in New Issue