feat: add support for stat sheet SVG rendering
Introduces the ability to discover and render `*.sheet.svg` files.
These files can contain `<text>` elements with `${key}` templates
that reactively update based on the current journal stats.
- Add `StatSheet` type and CLI scanning logic
- Implement `SheetView` for rendering SVGs with live bindings
- Add `parseSheet` utility to extract template patterns from SVG
- Update file indexer to include `.svg` files
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* 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);
|
||||
container.appendChild(parsed.svgElement);
|
||||
|
||||
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 flex items-center justify-center p-2">
|
||||
{sheet() ? (
|
||||
<div ref={containerRef} class="w-full h-full" />
|
||||
) : (
|
||||
fallback
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user