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:
parent
4f3b03d082
commit
30d1c5dc1b
|
|
@ -15,6 +15,7 @@ import {
|
||||||
processBlocks,
|
processBlocks,
|
||||||
type ProcessedBlocks,
|
type ProcessedBlocks,
|
||||||
} from "../completions/block-processor.js";
|
} from "../completions/block-processor.js";
|
||||||
|
import type { StatSheet } from "../completions/types.js";
|
||||||
|
|
||||||
interface ContentIndex {
|
interface ContentIndex {
|
||||||
[path: string]: string;
|
[path: string]: string;
|
||||||
|
|
@ -83,6 +84,24 @@ function getBestIP(): string {
|
||||||
return best || "localhost";
|
return best || "localhost";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract the <title> from an SVG string, or null if absent.
|
||||||
|
*/
|
||||||
|
function extractSvgTitle(svg: string): string | null {
|
||||||
|
const m = /<title[^>]*>([\s\S]*?)<\/title>/i.exec(svg);
|
||||||
|
return m ? m[1].trim() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive a human-readable label from a filename.
|
||||||
|
* "character-sheet" -> "Character Sheet"
|
||||||
|
*/
|
||||||
|
function labelFromId(id: string): string {
|
||||||
|
return id
|
||||||
|
.replace(/[-_]/g, " ")
|
||||||
|
.replace(/\b\w/g, (c) => c.toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 扫描目录内的 .md 文件,生成索引
|
* 扫描目录内的 .md 文件,生成索引
|
||||||
*/
|
*/
|
||||||
|
|
@ -95,6 +114,7 @@ export function scanDirectory(dir: string): {
|
||||||
stats: [],
|
stats: [],
|
||||||
statTemplates: [],
|
statTemplates: [],
|
||||||
sparkTables: [],
|
sparkTables: [],
|
||||||
|
statSheets: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
function scan(currentPath: string, relativePath: string) {
|
function scan(currentPath: string, relativePath: string) {
|
||||||
|
|
@ -113,7 +133,8 @@ export function scanDirectory(dir: string): {
|
||||||
} else if (
|
} else if (
|
||||||
entry.endsWith(".md") ||
|
entry.endsWith(".md") ||
|
||||||
entry.endsWith(".csv") ||
|
entry.endsWith(".csv") ||
|
||||||
entry.endsWith(".yarn")
|
entry.endsWith(".yarn") ||
|
||||||
|
entry.endsWith(".svg")
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const content = readFileSync(fullPath, "utf-8");
|
const content = readFileSync(fullPath, "utf-8");
|
||||||
|
|
@ -123,6 +144,17 @@ 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);
|
||||||
blocks.sparkTables.push(...result.blocks.sparkTables);
|
blocks.sparkTables.push(...result.blocks.sparkTables);
|
||||||
|
} 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;
|
||||||
}
|
}
|
||||||
|
|
@ -281,6 +313,7 @@ export function createContentServer(
|
||||||
stats: [],
|
stats: [],
|
||||||
statTemplates: [],
|
statTemplates: [],
|
||||||
sparkTables: [],
|
sparkTables: [],
|
||||||
|
statSheets: [],
|
||||||
};
|
};
|
||||||
let completionsIndex: CompletionsPayload = {
|
let completionsIndex: CompletionsPayload = {
|
||||||
dice: [],
|
dice: [],
|
||||||
|
|
@ -288,13 +321,14 @@ export function createContentServer(
|
||||||
sparkTables: [],
|
sparkTables: [],
|
||||||
stats: [],
|
stats: [],
|
||||||
statTemplates: [],
|
statTemplates: [],
|
||||||
|
statSheets: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Re-scan completions from current content index and collected blocks */
|
/** Re-scan completions from current content index and collected blocks */
|
||||||
function recomputeCompletions(): void {
|
function recomputeCompletions(): void {
|
||||||
completionsIndex = scanCompletions(contentIndex, collectedBlocks);
|
completionsIndex = scanCompletions(contentIndex, collectedBlocks);
|
||||||
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}`,
|
`[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}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -319,7 +353,8 @@ export function createContentServer(
|
||||||
if (
|
if (
|
||||||
path.endsWith(".md") ||
|
path.endsWith(".md") ||
|
||||||
path.endsWith(".csv") ||
|
path.endsWith(".csv") ||
|
||||||
path.endsWith(".yarn")
|
path.endsWith(".yarn") ||
|
||||||
|
path.endsWith(".svg")
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const content = readFileSync(path, "utf-8");
|
const content = readFileSync(path, "utf-8");
|
||||||
|
|
@ -333,6 +368,11 @@ 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;
|
||||||
|
recomputeCompletions();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
console.log(`[新增] ${relPath}`);
|
console.log(`[新增] ${relPath}`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -344,7 +384,8 @@ export function createContentServer(
|
||||||
if (
|
if (
|
||||||
path.endsWith(".md") ||
|
path.endsWith(".md") ||
|
||||||
path.endsWith(".csv") ||
|
path.endsWith(".csv") ||
|
||||||
path.endsWith(".yarn")
|
path.endsWith(".yarn") ||
|
||||||
|
path.endsWith(".svg")
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const content = readFileSync(path, "utf-8");
|
const content = readFileSync(path, "utf-8");
|
||||||
|
|
@ -357,6 +398,11 @@ 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;
|
||||||
|
recomputeCompletions();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
console.log(`[更新] ${relPath}`);
|
console.log(`[更新] ${relPath}`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -368,12 +414,13 @@ export function createContentServer(
|
||||||
if (
|
if (
|
||||||
path.endsWith(".md") ||
|
path.endsWith(".md") ||
|
||||||
path.endsWith(".csv") ||
|
path.endsWith(".csv") ||
|
||||||
path.endsWith(".yarn")
|
path.endsWith(".yarn") ||
|
||||||
|
path.endsWith(".svg")
|
||||||
) {
|
) {
|
||||||
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")) {
|
if (relPath.endsWith(".md") || relPath.endsWith(".sheet.svg")) {
|
||||||
const rescan = scanDirectory(contentDir);
|
const rescan = scanDirectory(contentDir);
|
||||||
collectedBlocks = rescan.blocks;
|
collectedBlocks = rescan.blocks;
|
||||||
recomputeCompletions();
|
recomputeCompletions();
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ import {
|
||||||
resolveBlockAs,
|
resolveBlockAs,
|
||||||
parseSparkTableCsv,
|
parseSparkTableCsv,
|
||||||
} from "./block-scanner.js";
|
} from "./block-scanner.js";
|
||||||
import type { SparkTableCompletion } from "./types.js";
|
import type { SparkTableCompletion, StatSheet } from "./types.js";
|
||||||
|
|
||||||
// Re-export shared pieces for convenience
|
// Re-export shared pieces for convenience
|
||||||
export {
|
export {
|
||||||
|
|
@ -41,6 +41,7 @@ export interface ProcessedBlocks {
|
||||||
stats: StatDef[];
|
stats: StatDef[];
|
||||||
statTemplates: StatTemplate[];
|
statTemplates: StatTemplate[];
|
||||||
sparkTables: SparkTableCompletion[];
|
sparkTables: SparkTableCompletion[];
|
||||||
|
statSheets: StatSheet[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BlockResult {
|
export interface BlockResult {
|
||||||
|
|
@ -81,6 +82,7 @@ export function processBlocks(
|
||||||
stats: [],
|
stats: [],
|
||||||
statTemplates: [],
|
statTemplates: [],
|
||||||
sparkTables: [],
|
sparkTables: [],
|
||||||
|
statSheets: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
const stripped = content.replace(
|
const stripped = content.replace(
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ export type {
|
||||||
SparkTableCompletion,
|
SparkTableCompletion,
|
||||||
StatDef,
|
StatDef,
|
||||||
StatTemplate,
|
StatTemplate,
|
||||||
|
StatSheet,
|
||||||
} from "./types.js";
|
} from "./types.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -33,5 +34,6 @@ export function scanCompletions(
|
||||||
sparkTables: blocks.sparkTables,
|
sparkTables: blocks.sparkTables,
|
||||||
stats: blocks.stats,
|
stats: blocks.stats,
|
||||||
statTemplates: blocks.statTemplates,
|
statTemplates: blocks.statTemplates,
|
||||||
|
statSheets: blocks.statSheets,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,18 @@ 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 */
|
||||||
|
|
@ -46,6 +58,7 @@ export interface CompletionsPayload {
|
||||||
sparkTables: SparkTableCompletion[];
|
sparkTables: SparkTableCompletion[];
|
||||||
stats: StatDef[];
|
stats: StatDef[];
|
||||||
statTemplates: StatTemplate[];
|
statTemplates: StatTemplate[];
|
||||||
|
statSheets: StatSheet[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -1,15 +1,20 @@
|
||||||
/**
|
/**
|
||||||
* StatsView — table view of all stat key-value pairs
|
* StatsView — table view of all stat key-value pairs, plus optional
|
||||||
|
* 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, Show } from "solid-js";
|
import { Component, For, createMemo, createSignal, Show } from "solid-js";
|
||||||
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();
|
||||||
|
|
@ -17,6 +22,11 @@ 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. */
|
||||||
|
const [selectedSheetId, setSelectedSheetId] = createSignal<string | null>(null);
|
||||||
|
const [pickerOpen, setPickerOpen] = createSignal(false);
|
||||||
|
|
||||||
/** Build a lookup: fullKey -> StatDef */
|
/** Build a lookup: fullKey -> StatDef */
|
||||||
const defMap = createMemo(() => {
|
const defMap = createMemo(() => {
|
||||||
|
|
@ -109,7 +119,10 @@ export const StatsView: Component = () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="h-full overflow-y-auto bg-gray-50">
|
<div class="h-full overflow-y-auto bg-gray-50 relative">
|
||||||
|
<Show
|
||||||
|
when={selectedSheetId()}
|
||||||
|
fallback={
|
||||||
<Show
|
<Show
|
||||||
when={statDefs().length > 0}
|
when={statDefs().length > 0}
|
||||||
fallback={
|
fallback={
|
||||||
|
|
@ -229,6 +242,68 @@ export const StatsView: Component = () => {
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
</Show>
|
</Show>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SheetView sheetId={selectedSheetId()!} />
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
{/* Sheet picker — only show if sheets are available */}
|
||||||
|
<Show when={sheets().length > 0}>
|
||||||
|
<div class="absolute bottom-3 right-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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
@ -22,13 +22,14 @@ 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,
|
||||||
parseSparkTableCsv,
|
parseSparkTableCsv,
|
||||||
} from "../../cli/completions/block-scanner";
|
} from "../../cli/completions/block-scanner";
|
||||||
|
|
||||||
export type { StatDef, StatTemplate };
|
export type { StatDef, StatTemplate, StatSheet };
|
||||||
|
|
||||||
// ------------------- Types (mirrors CLI) -------------------
|
// ------------------- Types (mirrors CLI) -------------------
|
||||||
|
|
||||||
|
|
@ -58,6 +59,7 @@ export interface JournalCompletions {
|
||||||
sparkTables: SparkTableCompletion[];
|
sparkTables: SparkTableCompletion[];
|
||||||
stats: StatDef[];
|
stats: StatDef[];
|
||||||
statTemplates: StatTemplate[];
|
statTemplates: StatTemplate[];
|
||||||
|
statSheets: StatSheet[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CompletionsState =
|
export type CompletionsState =
|
||||||
|
|
@ -87,6 +89,7 @@ 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;
|
||||||
|
|
@ -169,7 +172,46 @@ async function scanClientSide(): Promise<JournalCompletions> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { dice, links, sparkTables, stats, statTemplates };
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------- Init (runs eagerly at import time) -------------------
|
// ------------------- Init (runs eagerly at import time) -------------------
|
||||||
|
|
@ -186,7 +228,8 @@ 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();
|
||||||
if (data.dice.length > 0 || data.links.length > 0) {
|
data.statSheets = await scanStatSheets();
|
||||||
|
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" });
|
||||||
|
|
@ -237,6 +280,7 @@ export function useJournalCompletions(): {
|
||||||
sparkTables: [],
|
sparkTables: [],
|
||||||
stats: [],
|
stats: [],
|
||||||
statTemplates: [],
|
statTemplates: [],
|
||||||
|
statSheets: [],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,64 @@
|
||||||
|
/**
|
||||||
|
* Stat sheet template engine — parses *.sheet.svg files and extracts
|
||||||
|
* <text> elements containing ${key} template patterns.
|
||||||
|
*
|
||||||
|
* The SVG element is parsed once via DOMParser. The caller (StatsView)
|
||||||
|
* reactively updates the text nodes whenever the stats store changes.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Regex matching ${key} template patterns (captures the key name). */
|
||||||
|
const TEMPLATE_RE = /\$\{(\w+)\}/g;
|
||||||
|
|
||||||
|
/** A single template binding in a <text> element. */
|
||||||
|
export interface TextTemplate {
|
||||||
|
el: SVGTextElement;
|
||||||
|
template: string;
|
||||||
|
keys: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The result of parsing a stat sheet SVG string. */
|
||||||
|
export interface ParsedSheet {
|
||||||
|
svgElement: SVGSVGElement;
|
||||||
|
templates: TextTemplate[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse an SVG string into a live DOM element and extract all text
|
||||||
|
* elements that contain ${key} template patterns.
|
||||||
|
*/
|
||||||
|
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[] = [];
|
||||||
|
const textEls = svgElement.querySelectorAll("text");
|
||||||
|
|
||||||
|
for (const el of textEls) {
|
||||||
|
const content = el.textContent;
|
||||||
|
if (!content) continue;
|
||||||
|
|
||||||
|
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 SVGTextElement, template: content, keys });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { svgElement, templates };
|
||||||
|
}
|
||||||
|
|
@ -50,7 +50,7 @@ function ensureIndexLoaded(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const context = import.meta.webpackContext("../../content", {
|
const context = import.meta.webpackContext("../../content", {
|
||||||
recursive: true,
|
recursive: true,
|
||||||
regExp: /\.md|\.yarn|\.csv$/i,
|
regExp: /\.md|\.yarn|\.csv|\.svg$/i,
|
||||||
});
|
});
|
||||||
const keys = context.keys();
|
const keys = context.keys();
|
||||||
const index: FileIndex = {};
|
const index: FileIndex = {};
|
||||||
|
|
@ -94,7 +94,7 @@ async function scanDirectory(
|
||||||
prefix = "",
|
prefix = "",
|
||||||
): Promise<FileIndex> {
|
): Promise<FileIndex> {
|
||||||
const index: FileIndex = {};
|
const index: FileIndex = {};
|
||||||
const acceptedExt = /\.(md|yarn|csv)$/i;
|
const acceptedExt = /\.(md|yarn|csv|svg)$/i;
|
||||||
|
|
||||||
for await (const [name, entry] of (handle as any).entries()) {
|
for await (const [name, entry] of (handle as any).entries()) {
|
||||||
if (entry.kind === "directory") {
|
if (entry.kind === "directory") {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue