diff --git a/docs/markdown.md b/docs/markdown.md index 95ee1e7..eb43cfa 100644 --- a/docs/markdown.md +++ b/docs/markdown.md @@ -423,6 +423,38 @@ layers="字段:起始行,起始列 - 结束列,字体大小" 示例:`title:1,1-5,1f8` 表示 title 字段从第 1 行开始,占据 1-5 列,8mm字体。 +**结构化配置(yaml/tag 代码块):** + +可以使用 ```yaml/tag 代码块,通过 `body` 指定 CSV 路径,`data-config` 提供结构化配置(图层可以是字段或模板): + +````markdown +```yaml/tag +tag: md-deck +body: ./cards.csv +data-config: + size: 63x88 + grid: 5x5 + layers: + - prop: title + x1: 1 + y1: 1 + x2: 5 + y2: 1 + font: 12 + - template: | + **{{name}}** — {{type}} + {{description}} + x1: 1 + y1: 3 + x2: 5 + y2: 8 + font: 3 + align: l +``` +```` + +每个图层通过 `prop`(读取 CSV 字段)或 `template`(渲染带 `{{变量}}` 替换的 markdown 模板)定义,支持 `x1/y1/x2/y2`、`font`、`orientation`、`align`。`back_layers` 用于背面图层。 + ### 🧶 叙事线组件 (md-yarn-spinner) 用于展示分支叙事结构,支持 Yarn Spinner 格式文件。 diff --git a/src/components/md-deck/CardLayer.tsx b/src/components/md-deck/CardLayer.tsx index c1e249e..e00afb3 100644 --- a/src/components/md-deck/CardLayer.tsx +++ b/src/components/md-deck/CardLayer.tsx @@ -89,7 +89,9 @@ export function CardLayer(props: CardLayerProps) { "font-size": `${layer.fontSize || 3}mm`, "text-align": getAlignStyle(layer.align), }} - innerHTML={renderLayerContent(props.cardData[layer.prop])} + innerHTML={renderLayerContent( + layer.template ?? props.cardData[layer.prop ?? ""], + )} onClick={(e) => handleLayerClick(index(), e)} /> diff --git a/src/components/md-deck/config.test.ts b/src/components/md-deck/config.test.ts new file mode 100644 index 0000000..314d558 --- /dev/null +++ b/src/components/md-deck/config.test.ts @@ -0,0 +1,106 @@ +import { layersToConfigs, normalizeDeckConfig } from "./config"; + +describe("layersToConfigs", () => { + test("parses compact string format (legacy)", () => { + const layers = layersToConfigs("title:1,1-5,1f8 body:1,5-8,8f3"); + expect(layers).toHaveLength(2); + expect(layers[0]).toMatchObject({ + prop: "title", + visible: true, + x1: 1, + y1: 1, + x2: 5, + y2: 1, + fontSize: 8, + }); + expect(layers[1].prop).toBe("body"); + }); + + test("parses structured list with prop layers", () => { + const layers = layersToConfigs([ + { prop: "name", x1: 1, y1: 1, x2: 5, y2: 2, font: 12 }, + ]); + expect(layers).toHaveLength(1); + expect(layers[0]).toMatchObject({ + prop: "name", + template: undefined, + visible: true, + x1: 1, + y1: 1, + x2: 5, + y2: 2, + fontSize: 12, + }); + }); + + test("parses structured list with template layers", () => { + const layers = layersToConfigs([ + { template: "**{{name}}**", x1: 1, y1: 3, x2: 5, y2: 8, align: "l" }, + ]); + expect(layers).toHaveLength(1); + expect(layers[0]).toMatchObject({ + prop: undefined, + template: "**{{name}}**", + visible: true, + x1: 1, + y1: 3, + x2: 5, + y2: 8, + align: "l", + }); + }); + + test("returns [] for absent/empty input", () => { + expect(layersToConfigs()).toEqual([]); + expect(layersToConfigs("")).toEqual([]); + expect(layersToConfigs([])).toEqual([]); + }); +}); + +describe("normalizeDeckConfig", () => { + test("parses string dimensions", () => { + const cfg = normalizeDeckConfig({ size: "54x86", grid: "5x8" }); + expect(cfg.sizeW).toBe(54); + expect(cfg.sizeH).toBe(86); + expect(cfg.gridW).toBe(5); + expect(cfg.gridH).toBe(8); + }); + + test("parses array dimensions", () => { + const cfg = normalizeDeckConfig({ size: [63, 88] }); + expect(cfg.sizeW).toBe(63); + expect(cfg.sizeH).toBe(88); + }); + + test("coerces numeric strings for bleed/padding", () => { + const cfg = normalizeDeckConfig({ bleed: "2", padding: "3" }); + expect(cfg.bleed).toBe(2); + expect(cfg.padding).toBe(3); + }); + + test("normalizes layers and back_layers", () => { + const cfg = normalizeDeckConfig({ + layers: [ + { prop: "name", x1: 1, y1: 1, x2: 5, y2: 1 }, + { template: "{{body}}", x1: 1, y1: 2, x2: 5, y2: 8 }, + ], + back_layers: "logo:1,1-2,2", + }); + expect(cfg.frontLayers).toHaveLength(2); + expect(cfg.frontLayers[1].template).toBe("{{body}}"); + expect(cfg.backLayers).toHaveLength(1); + expect(cfg.backLayers[0].prop).toBe("logo"); + }); + + test("missing fields stay undefined", () => { + const cfg = normalizeDeckConfig({}); + expect(cfg.sizeW).toBeUndefined(); + expect(cfg.sizeH).toBeUndefined(); + expect(cfg.gridW).toBeUndefined(); + expect(cfg.gridH).toBeUndefined(); + expect(cfg.bleed).toBeUndefined(); + expect(cfg.padding).toBeUndefined(); + expect(cfg.frontLayers).toEqual([]); + expect(cfg.backLayers).toEqual([]); + }); +}); diff --git a/src/components/md-deck/config.ts b/src/components/md-deck/config.ts new file mode 100644 index 0000000..6013707 --- /dev/null +++ b/src/components/md-deck/config.ts @@ -0,0 +1,134 @@ +import type { CardShape, LayerConfig } from "./types"; +import { parseLayers } from "./hooks/layer-parser"; + +/** + * YAML/JSON shape of an `md-deck` `data-config`. + * + * Mirrors the frontmatter `deck:` block and the `:md-deck` directive attrs, + * but allows `layers`/`back_layers` as structured lists where each layer is + * either a CSV `prop` or a markdown `template`. + */ +export interface DeckConfigYaml { + size?: string | [number, number]; + grid?: string | [number, number]; + bleed?: number | string; + padding?: number | string; + shape?: CardShape; + fixed?: boolean; + layers?: string | DeckLayerYaml[]; + back_layers?: string | DeckLayerYaml[]; +} + +export interface DeckLayerYaml { + prop?: string; + template?: string; + x1?: number; + y1?: number; + x2?: number; + y2?: number; + font?: number; + fontSize?: number; + orientation?: "n" | "s" | "e" | "w"; + align?: "l" | "c" | "r"; + visible?: boolean; +} + +export interface NormalizedDeckConfig { + sizeW?: number; + sizeH?: number; + gridW?: number; + gridH?: number; + bleed?: number; + padding?: number; + shape?: CardShape; + fixed?: boolean; + frontLayers: LayerConfig[]; + backLayers: LayerConfig[]; +} + +/** Parse a "54x86" string or [54, 86] array into [w, h]. */ +function parseDimension( + v?: string | [number, number], +): [number, number] | undefined { + if (!v) return undefined; + if (Array.isArray(v)) { + const [w, h] = v; + if (typeof w === "number" && typeof h === "number") return [w, h]; + return undefined; + } + const parts = String(v) + .toLowerCase() + .split("x") + .map((n) => Number(n.trim())); + if ( + parts.length === 2 && + Number.isFinite(parts[0]) && + Number.isFinite(parts[1]) + ) { + return [parts[0], parts[1]]; + } + return undefined; +} + +/** Map a structured YAML layer to a LayerConfig. */ +function layerYamlToConfig(l: DeckLayerYaml): LayerConfig { + return { + prop: l.prop, + template: l.template, + visible: l.visible ?? true, + x1: l.x1 ?? 1, + y1: l.y1 ?? 1, + x2: l.x2 ?? 2, + y2: l.y2 ?? 2, + orientation: l.orientation, + fontSize: l.fontSize ?? l.font, + align: l.align, + }; +} + +/** + * Normalize a `layers`/`back_layers` value (compact string or structured list) + * into LayerConfig[]. Empty/absent → []. + */ +export function layersToConfigs( + layers?: string | DeckLayerYaml[], +): LayerConfig[] { + if (!layers) return []; + if (typeof layers === "string") { + return parseLayers(layers).map((l) => ({ + prop: l.prop, + visible: true, + x1: l.x1, + y1: l.y1, + x2: l.x2, + y2: l.y2, + orientation: l.orientation, + fontSize: l.fontSize, + align: l.align, + })); + } + return layers.map(layerYamlToConfig); +} + +/** + * Normalize a parsed `data-config` object into concrete numeric config + + * ready-to-use layer lists. Missing fields are left undefined so callers can + * apply defaults. + */ +export function normalizeDeckConfig(cfg: DeckConfigYaml): NormalizedDeckConfig { + const size = parseDimension(cfg.size); + const grid = parseDimension(cfg.grid); + return { + sizeW: size?.[0], + sizeH: size?.[1], + gridW: grid?.[0], + gridH: grid?.[1], + bleed: typeof cfg.bleed === "string" ? Number(cfg.bleed) : cfg.bleed, + padding: + typeof cfg.padding === "string" ? Number(cfg.padding) : cfg.padding, + shape: cfg.shape, + fixed: cfg.fixed, + frontLayers: layersToConfigs(cfg.layers), + backLayers: layersToConfigs(cfg.back_layers), + }; +} diff --git a/src/components/md-deck/editor-panel/LayerRow.tsx b/src/components/md-deck/editor-panel/LayerRow.tsx index d1ba4c8..2ee50de 100644 --- a/src/components/md-deck/editor-panel/LayerRow.tsx +++ b/src/components/md-deck/editor-panel/LayerRow.tsx @@ -120,7 +120,7 @@ export function LayerRow(props: LayerRowProps) { class="text-sm flex-1 truncate cursor-pointer hover:text-blue-600 select-none" onClick={props.onSelect} > - {props.layer.prop} + {props.layer.prop || (props.layer.template ? "(模板)" : "")} Promise; setError: (error: string | null) => void; clearError: () => void; @@ -442,8 +442,8 @@ export function createDeckStore(initialSrc: string = ""): DeckStore { const loadCardsFromPath = async ( path: string, rawSrc: string, - layersStr: string = "", - backLayersStr: string = "", + frontLayers: LayerConfig[] = [], + backLayers: LayerConfig[] = [], ) => { if (!path) { setState({ error: "未指定 CSV 文件路径" }); @@ -466,12 +466,8 @@ export function createDeckStore(initialSrc: string = ""): DeckStore { setState({ cards: data, activeTab: 0, - frontLayerConfigs: layerCrud.withKeys( - initLayerConfigsForSide(data, layersStr), - ), - backLayerConfigs: layerCrud.withKeys( - initLayerConfigsForSide(data, backLayersStr), - ), + frontLayerConfigs: layerCrud.withKeys(frontLayers), + backLayerConfigs: layerCrud.withKeys(backLayers), isLoading: false, }); updateDimensions(); diff --git a/src/components/md-deck/hooks/layer-parser.ts b/src/components/md-deck/hooks/layer-parser.ts index 7f50642..492c106 100644 --- a/src/components/md-deck/hooks/layer-parser.ts +++ b/src/components/md-deck/hooks/layer-parser.ts @@ -35,8 +35,10 @@ export function parseLayers(layersStr: string): Layer[] { * 格式化 layers 为字符串 */ export function formatLayers(layers: LayerConfig[]): string { + // Template-only layers have no prop and can't be represented in the + // compact string format, so they are skipped here. return layers - .filter((l) => l.visible) + .filter((l) => l.visible && l.prop) .map((l) => { let str = `${l.prop}:${l.x1},${l.y1}-${l.x2},${l.y2}`; if (l.fontSize) { diff --git a/src/components/md-deck/index.tsx b/src/components/md-deck/index.tsx index 3d87f4c..7219622 100644 --- a/src/components/md-deck/index.tsx +++ b/src/components/md-deck/index.tsx @@ -2,9 +2,10 @@ import { customElement, noShadowDOM } from "solid-element"; import { Show, onCleanup } from "solid-js"; import { resolvePath } from "../utils/path"; import { v4 as uuidv4 } from "uuid"; -import { createDeckStore } from "./hooks/deckStore"; +import { createDeckStore, DECK_DEFAULTS } from "./hooks/deckStore"; import { registerDeck, unregisterDeck } from "./hooks/deck-registry"; -import type { CardShape } from "./types"; +import type { CardShape, LayerConfig } from "./types"; +import { normalizeDeckConfig, layersToConfigs } from "./config"; import { DeckHeader } from "./DeckHeader"; import { CardList } from "./CardList"; import { DeckContent } from "./DeckContent"; @@ -68,49 +69,75 @@ customElement( const deckId = `deck-${uuidv4()}`; registerDeck(deckId, store, resolvedSrc, csvPath); + // 读取 data-config(yaml/tag 代码块方式):结构化配置优先 + let config: + | ReturnType + | undefined; + const dataConfigAttr = element?.getAttribute("data-config"); + if (dataConfigAttr) { + try { + config = normalizeDeckConfig(JSON.parse(dataConfigAttr)); + } catch (e) { + console.error("Invalid data-config on md-deck:", e); + } + } + // 解析 size 属性(支持旧格式 "54x86" 和新格式) - if (props.size && props.size.includes("x")) { + if (config?.sizeW !== undefined && config.sizeH !== undefined) { + store.actions.setSizeW(config.sizeW); + store.actions.setSizeH(config.sizeH); + } else if (props.size && props.size.includes("x")) { const [w, h] = props.size.split("x").map(Number); store.actions.setSizeW(w); store.actions.setSizeH(h); } else { - store.actions.setSizeW(props.sizeW ?? 54); - store.actions.setSizeH(props.sizeH ?? 86); + store.actions.setSizeW(props.sizeW ?? DECK_DEFAULTS.SIZE_W); + store.actions.setSizeH(props.sizeH ?? DECK_DEFAULTS.SIZE_H); } // 解析 grid 属性(支持旧格式 "5x8" 和新格式) - if (props.grid && props.grid.includes("x")) { + if (config?.gridW !== undefined && config.gridH !== undefined) { + store.actions.setGridW(config.gridW); + store.actions.setGridH(config.gridH); + } else if (props.grid && props.grid.includes("x")) { const [w, h] = props.grid.split("x").map(Number); store.actions.setGridW(w); store.actions.setGridH(h); } else { - store.actions.setGridW(props.gridW ?? 5); - store.actions.setGridH(props.gridH ?? 8); + store.actions.setGridW(props.gridW ?? DECK_DEFAULTS.GRID_W); + store.actions.setGridH(props.gridH ?? DECK_DEFAULTS.GRID_H); } // 解析 bleed 和 padding(支持旧字符串格式和新数字格式) - if (typeof props.bleed === "string") { + if (config?.bleed !== undefined) { + store.actions.setBleed(config.bleed); + } else if (typeof props.bleed === "string") { store.actions.setBleed(Number(props.bleed)); } else { - store.actions.setBleed(props.bleed ?? 1); + store.actions.setBleed(props.bleed ?? DECK_DEFAULTS.BLEED); } - if (typeof props.padding === "string") { + if (config?.padding !== undefined) { + store.actions.setPadding(config.padding); + } else if (typeof props.padding === "string") { store.actions.setPadding(Number(props.padding)); } else { - store.actions.setPadding(props.padding ?? 2); + store.actions.setPadding(props.padding ?? DECK_DEFAULTS.PADDING); } // 设置形状 - store.actions.setShape(props.shape ?? "rectangle"); + store.actions.setShape(config?.shape ?? props.shape ?? "rectangle"); + + // 确定前后图层(data-config 优先,回退旧 layers 字符串) + const frontLayers: LayerConfig[] = config + ? config.frontLayers + : layersToConfigs((props.layers as string) || ""); + const backLayers: LayerConfig[] = config + ? config.backLayers + : layersToConfigs((props.backLayers as string) || ""); // 加载 CSV 数据 - store.actions.loadCardsFromPath( - resolvedSrc, - csvPath, - (props.layers as string) || "", - (props.backLayers as string) || "", - ); + store.actions.loadCardsFromPath(resolvedSrc, csvPath, frontLayers, backLayers); // 清理函数 onCleanup(() => { diff --git a/src/components/md-deck/types.ts b/src/components/md-deck/types.ts index 6cb6485..b9d3f17 100644 --- a/src/components/md-deck/types.ts +++ b/src/components/md-deck/types.ts @@ -7,7 +7,10 @@ export type CardSide = "front" | "back"; export type { CardShape } from "../../plotcutter/contour"; export interface Layer { - prop: string; + /** CSV column the layer reads, when it renders a prop value. */ + prop?: string; + /** Markdown template (with {{var}} substitution) when the layer renders a template. */ + template?: string; x1: number; y1: number; x2: number; @@ -18,12 +21,15 @@ export interface Layer { } export interface LayerConfig { - prop: string; + /** CSV column the layer reads, when it renders a prop value. */ + prop?: string; + /** Markdown template (with {{var}} substitution) when the layer renders a template. */ + template?: string; visible: boolean; - x1: number; - y1: number; - x2: number; - y2: number; + x1?: number; + y1?: number; + x2?: number; + y2?: number; orientation?: "n" | "s" | "e" | "w"; fontSize?: number; align?: "l" | "c" | "r"; diff --git a/src/doc-entries/md-deck.md b/src/doc-entries/md-deck.md index 35bae8a..e4c41b6 100644 --- a/src/doc-entries/md-deck.md +++ b/src/doc-entries/md-deck.md @@ -6,7 +6,7 @@ title: 卡牌组件 将 CSV 数据渲染为可打印的卡牌布局,支持自定义尺寸、网格、图层和双面排版。 -**语法:** `:md-deck[./cards.csv]{选项}` +**语法:** `:md-deck[./cards.csv]{选项}` 或 ```yaml/tag 代码块 **基础卡牌:** :md-deck[./spells.csv]{grid="3x3"} @@ -16,6 +16,36 @@ title: 卡牌组件 CSV 包含数据字段列,通过图层定义控制各字段的位置、大小和对齐。 +## 结构化配置 (yaml/tag) + +```yaml/tag 代码块可以用 `body` 指定 CSV 路径,`data-config` 提供结构化配置,图层可以是字段(`prop`)或 markdown 模板(`template`,支持 `{{变量}}` 替换): + +````markdown +```yaml/tag +tag: md-deck +body: ./cards.csv +data-config: + size: 63x88 + grid: 5x5 + layers: + - prop: title + x1: 1 + y1: 1 + x2: 5 + y2: 1 + font: 12 + - template: | + **{{name}}** — {{type}} + {{description}} + x1: 1 + y1: 3 + x2: 5 + y2: 8 + font: 3 + align: l +``` +```` + ## 图层格式 `layers` 属性格式为 `字段:起始行,起始列-结束列,字号`,多个图层用空格分隔: diff --git a/src/markdown/code-block-yaml-tag.test.ts b/src/markdown/code-block-yaml-tag.test.ts new file mode 100644 index 0000000..ea8fa75 --- /dev/null +++ b/src/markdown/code-block-yaml-tag.test.ts @@ -0,0 +1,60 @@ +import markedCodeBlockYamlTag from "./code-block-yaml-tag"; + +const ext = markedCodeBlockYamlTag().extensions?.[0] as unknown as { + tokenizer: (src: string) => any; + renderer: (token: any) => string; +}; + +function render(src: string): string { + const token = ext.tokenizer(src); + return ext.renderer(token); +} + +describe("code-block-yaml-tag", () => { + test("renders body and scalar props as attributes", () => { + const html = render( + "```yaml/tag\ntag: md-deck\nbody: ./cards.csv\nsize: 54x86\n```", + ); + expect(html).toContain("./cards.csv"); + }); + + test("serializes data-config to a JSON attribute", () => { + const html = render( + [ + "```yaml/tag", + "tag: md-deck", + "body: ./cards.csv", + "data-config:", + " size: 63x88", + " layers:", + " - prop: title", + " x1: 1", + " y1: 1", + " x2: 5", + " y2: 1", + " font: 12", + "```", + ].join("\n"), + ); + expect(html).toContain(""); + // data-config must be valid JSON with escaped quotes for the attribute + const m = html.match(/data-config="([^"]*)"/); + expect(m).not.toBeNull(); + const decoded = (m![1] || "").replace(/"/g, '"'); + const config = JSON.parse(decoded); + expect(config.size).toBe("63x88"); + expect(config.layers).toHaveLength(1); + expect(config.layers[0]).toMatchObject({ + prop: "title", + x1: 1, + y1: 1, + font: 12, + }); + }); + + test("handles missing tag and body", () => { + const html = render("```yaml/tag\nclass: foo\n```"); + expect(html).toContain(""); + }); +}); diff --git a/src/markdown/code-block-yaml-tag.ts b/src/markdown/code-block-yaml-tag.ts index 4f57f04..fe2be91 100644 --- a/src/markdown/code-block-yaml-tag.ts +++ b/src/markdown/code-block-yaml-tag.ts @@ -33,6 +33,18 @@ export default function markedCodeBlockYamlTag(): MarkedExtension { delete (rest as Record).body; } + // A structured `data-config` object is serialized to JSON and + // passed as a single attribute (e.g. md-deck layers/templates). + let configAttr = ""; + if ("data-config" in rest) { + const rawConfig = rest["data-config"]; + delete (rest as Record)["data-config"]; + if (rawConfig !== undefined && rawConfig !== null) { + const json = JSON.stringify(rawConfig).replace(/"/g, """); + configAttr = ` data-config="${json}"`; + } + } + const propsStr = Object.entries(rest) .map(([key, value]) => { const strValue = String(value); @@ -48,13 +60,15 @@ export default function markedCodeBlockYamlTag(): MarkedExtension { raw: match[0], tagName, props: propsStr, + config: configAttr, content, }; } }, renderer(token: any) { const propsAttr = token.props ? ` ${token.props}` : ""; - return `<${token.tagName}${propsAttr}>${token.content || ""}\n`; + const configAttr = token.config || ""; + return `<${token.tagName}${propsAttr}${configAttr}>${token.content || ""}\n`; }, }, ],