Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f1ddc8fb8e | ||
|
|
16dfc8a88c | ||
|
|
023d3a0cb9 | ||
|
|
7c4865ba8c |
@@ -423,6 +423,32 @@ layers="字段:起始行,起始列 - 结束列,字体大小"
|
|||||||
|
|
||||||
示例:`title:1,1-5,1f8` 表示 title 字段从第 1 行开始,占据 1-5 列,8mm字体。
|
示例:`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
|
||||||
|
pos: 1,1-5,1
|
||||||
|
font: 12
|
||||||
|
- template: |
|
||||||
|
**{{name}}** — {{type}}
|
||||||
|
{{description}}
|
||||||
|
pos: 1,3-5,8
|
||||||
|
font: 3
|
||||||
|
align: l
|
||||||
|
```
|
||||||
|
````
|
||||||
|
|
||||||
|
每个图层通过 `prop`(读取 CSV 字段)或 `template`(渲染带 `{{变量}}` 替换的 markdown 模板)定义,`pos` 为网格位置 `x1,y1-x2,y2`(1-based,与紧凑 layers 字符串同格式),支持 `font`、`orientation`、`align`。`back_layers` 用于背面图层。
|
||||||
|
|
||||||
### 🧶 叙事线组件 (md-yarn-spinner)
|
### 🧶 叙事线组件 (md-yarn-spinner)
|
||||||
|
|
||||||
用于展示分支叙事结构,支持 Yarn Spinner 格式文件。
|
用于展示分支叙事结构,支持 Yarn Spinner 格式文件。
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ jest.mock("github-slugger", () => {
|
|||||||
|
|
||||||
import { parseDeclareCsv } from "./declare-parser";
|
import { parseDeclareCsv } from "./declare-parser";
|
||||||
import { parseBlockAttrs, resolveBlockAs } from "./block-scanner";
|
import { parseBlockAttrs, resolveBlockAs } from "./block-scanner";
|
||||||
import { evaluateExpression } from "../../components/journal/variable-expression";
|
import { evaluateExpression, exprValueToString } from "../../components/journal/variable-expression";
|
||||||
import {
|
import {
|
||||||
initReactivity,
|
initReactivity,
|
||||||
computeCascade,
|
computeCascade,
|
||||||
@@ -281,14 +281,12 @@ describe("resolveBlockAs", () => {
|
|||||||
describe("evaluateExpression", () => {
|
describe("evaluateExpression", () => {
|
||||||
test("evaluates simple arithmetic", () => {
|
test("evaluates simple arithmetic", () => {
|
||||||
const result = evaluateExpression("2 + 3 * 4", { lookup: () => undefined });
|
const result = evaluateExpression("2 + 3 * 4", { lookup: () => undefined });
|
||||||
expect(result.value).toBe(14);
|
expect(result.value).toEqual({ kind: "number", value: 14 });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("evaluates with parentheses", () => {
|
test("evaluates with parentheses", () => {
|
||||||
const result = evaluateExpression("(2 + 3) * 4", {
|
const result = evaluateExpression("(2 + 3) * 4", { lookup: () => undefined });
|
||||||
lookup: () => undefined,
|
expect(result.value).toEqual({ kind: "number", value: 20 });
|
||||||
});
|
|
||||||
expect(result.value).toBe(20);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("evaluates variable references", () => {
|
test("evaluates variable references", () => {
|
||||||
@@ -299,54 +297,105 @@ describe("evaluateExpression", () => {
|
|||||||
return undefined;
|
return undefined;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(result.value).toBe(80); // 12*5 + 20
|
expect(result.value).toEqual({ kind: "number", value: 80 }); // 12*5 + 20
|
||||||
});
|
});
|
||||||
|
|
||||||
test("returns 0 for undefined variables", () => {
|
test("returns 0 for undefined variables", () => {
|
||||||
const result = evaluateExpression("$unknown + 5", {
|
const result = evaluateExpression("$unknown + 5", {
|
||||||
lookup: () => undefined,
|
lookup: () => undefined,
|
||||||
});
|
});
|
||||||
expect(result.value).toBe(5);
|
expect(result.value).toEqual({ kind: "number", value: 5 });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("throws on tag values in arithmetic", () => {
|
test("throws on type mismatch (tagmap + number)", () => {
|
||||||
expect(() =>
|
expect(() =>
|
||||||
evaluateExpression("$class + 5", {
|
evaluateExpression("$class + 5", {
|
||||||
lookup: (name) => (name === "class" ? "#warrior" : undefined),
|
lookup: (name) => (name === "class" ? "#warrior" : undefined),
|
||||||
}),
|
}),
|
||||||
).toThrow("$class is a tag");
|
).toThrow("Type mismatch");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resolves tagmap variable without arithmetic", () => {
|
||||||
|
const result = evaluateExpression("$class", {
|
||||||
|
lookup: (name) => (name === "class" ? "#warrior:1" : undefined),
|
||||||
|
});
|
||||||
|
expect(result.value).toEqual({ kind: "tagmap", value: { "#warrior": 1 } });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("evaluates tagmap literals", () => {
|
||||||
|
const result = evaluateExpression("#warrior:1", { lookup: () => undefined });
|
||||||
|
expect(result.value).toEqual({ kind: "tagmap", value: { "#warrior": 1 } });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("evaluates bare tag as tagmap", () => {
|
||||||
|
const result = evaluateExpression("#warrior", { lookup: () => undefined });
|
||||||
|
expect(result.value).toEqual({ kind: "tagmap", value: { "#warrior": 1 } });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("evaluates multi-entry tagmap literal", () => {
|
||||||
|
const result = evaluateExpression("#warrior:1;#druid:2", { lookup: () => undefined });
|
||||||
|
expect(result.value).toEqual({ kind: "tagmap", value: { "#warrior": 1, "#druid": 2 } });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("merges tagmaps with +", () => {
|
||||||
|
const result = evaluateExpression("#warrior:1 + #druid:2", { lookup: () => undefined });
|
||||||
|
expect(result.value).toEqual({ kind: "tagmap", value: { "#warrior": 1, "#druid": 2 } });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("adds counts for same tag with +", () => {
|
||||||
|
const result = evaluateExpression("#warrior:1 + #warrior:2", { lookup: () => undefined });
|
||||||
|
expect(result.value).toEqual({ kind: "tagmap", value: { "#warrior": 3 } });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("subtracts tagmaps with -", () => {
|
||||||
|
const result = evaluateExpression("#warrior:3;#druid:2 - #druid:2", { lookup: () => undefined });
|
||||||
|
expect(result.value).toEqual({ kind: "tagmap", value: { "#warrior": 3 } });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("throws on tagmap * tagmap", () => {
|
||||||
|
expect(() =>
|
||||||
|
evaluateExpression("#warrior:1 * #druid:2", { lookup: () => undefined }),
|
||||||
|
).toThrow("Type mismatch");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("throws on tagmap / tagmap", () => {
|
||||||
|
expect(() =>
|
||||||
|
evaluateExpression("#warrior:1 / #druid:2", { lookup: () => undefined }),
|
||||||
|
).toThrow("Type mismatch");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("evaluates floor function", () => {
|
test("evaluates floor function", () => {
|
||||||
const result = evaluateExpression("floor(3.7)", {
|
const result = evaluateExpression("floor(3.7)", { lookup: () => undefined });
|
||||||
lookup: () => undefined,
|
expect(result.value).toEqual({ kind: "number", value: 3 });
|
||||||
});
|
|
||||||
expect(result.value).toBe(3);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("evaluates ceil function", () => {
|
test("evaluates ceil function", () => {
|
||||||
const result = evaluateExpression("ceil(3.2)", { lookup: () => undefined });
|
const result = evaluateExpression("ceil(3.2)", { lookup: () => undefined });
|
||||||
expect(result.value).toBe(4);
|
expect(result.value).toEqual({ kind: "number", value: 4 });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("evaluates round function", () => {
|
test("evaluates round function", () => {
|
||||||
const result = evaluateExpression("round(3.5)", {
|
const result = evaluateExpression("round(3.5)", { lookup: () => undefined });
|
||||||
lookup: () => undefined,
|
expect(result.value).toEqual({ kind: "number", value: 4 });
|
||||||
});
|
|
||||||
expect(result.value).toBe(4);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("evaluates unary minus", () => {
|
test("evaluates unary minus", () => {
|
||||||
const result = evaluateExpression("-5 + 10", { lookup: () => undefined });
|
const result = evaluateExpression("-5 + 10", { lookup: () => undefined });
|
||||||
expect(result.value).toBe(5);
|
expect(result.value).toEqual({ kind: "number", value: 5 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("throws on negating tagmap", () => {
|
||||||
|
expect(() =>
|
||||||
|
evaluateExpression("-#warrior", { lookup: () => undefined }),
|
||||||
|
).toThrow("Type mismatch");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("evaluates dice notation", () => {
|
test("evaluates dice notation", () => {
|
||||||
const result = evaluateExpression("3d6 + 5", { lookup: () => undefined });
|
const result = evaluateExpression("3d6 + 5", { lookup: () => undefined });
|
||||||
expect(typeof result.value).toBe("number");
|
expect(result.value.kind).toBe("number");
|
||||||
// 3d6 is between 3 and 18, +5 gives 8-23
|
// 3d6 is between 3 and 18, +5 gives 8-23
|
||||||
expect(result.value).toBeGreaterThanOrEqual(8);
|
expect(result.value.value).toBeGreaterThanOrEqual(8);
|
||||||
expect(result.value).toBeLessThanOrEqual(23);
|
expect(result.value.value).toBeLessThanOrEqual(23);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("throws on division by zero", () => {
|
test("throws on division by zero", () => {
|
||||||
@@ -369,7 +418,7 @@ describe("evaluateExpression", () => {
|
|||||||
|
|
||||||
test("handles decimal numbers", () => {
|
test("handles decimal numbers", () => {
|
||||||
const result = evaluateExpression("3.5 + 2.5", { lookup: () => undefined });
|
const result = evaluateExpression("3.5 + 2.5", { lookup: () => undefined });
|
||||||
expect(result.value).toBeCloseTo(6);
|
expect(result.value).toEqual({ kind: "number", value: 6 });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("nested function calls", () => {
|
test("nested function calls", () => {
|
||||||
@@ -377,7 +426,7 @@ describe("evaluateExpression", () => {
|
|||||||
lookup: () => undefined,
|
lookup: () => undefined,
|
||||||
});
|
});
|
||||||
// ceil(3.2) = 4, floor(4) = 4
|
// ceil(3.2) = 4, floor(4) = 4
|
||||||
expect(result.value).toBe(4);
|
expect(result.value).toEqual({ kind: "number", value: 4 });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("complex expression with variables and functions", () => {
|
test("complex expression with variables and functions", () => {
|
||||||
@@ -388,7 +437,20 @@ describe("evaluateExpression", () => {
|
|||||||
return undefined;
|
return undefined;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(result.value).toBe(10); // floor(7.5) + 3 = 7 + 3
|
expect(result.value).toEqual({ kind: "number", value: 10 }); // floor(7.5) + 3 = 7 + 3
|
||||||
|
});
|
||||||
|
|
||||||
|
test("exprValueToString serializes number", () => {
|
||||||
|
expect(exprValueToString({ kind: "number", value: 42 })).toBe("42");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("exprValueToString serializes tagmap", () => {
|
||||||
|
expect(exprValueToString({ kind: "tagmap", value: { "#warrior": 1, "#druid": 2 } }))
|
||||||
|
.toBe("#warrior:1;#druid:2");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("exprValueToString returns 0 for empty tagmap", () => {
|
||||||
|
expect(exprValueToString({ kind: "tagmap", value: {} })).toBe("0");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -10,19 +10,12 @@ import { createSignal } from "solid-js";
|
|||||||
import { parseInput } from "./command-parser";
|
import { parseInput } from "./command-parser";
|
||||||
import { resolveRollPayload } from "./types/roll";
|
import { resolveRollPayload } from "./types/roll";
|
||||||
import { resolveSparkPayload } from "./types/spark";
|
import { resolveSparkPayload } from "./types/spark";
|
||||||
import { evaluateExpression } from "./variable-expression";
|
import { evaluateExpression, exprValueToString } from "./variable-expression";
|
||||||
import { computeCascade, getCombined, setBase } from "./var-reactivity";
|
import { computeCascade, getCombined, setBase } from "./var-reactivity";
|
||||||
import type { VarDeclaration, TagModifier } from "./declare-parser";
|
import type { VarDeclaration, TagModifier } from "./declare-parser";
|
||||||
|
|
||||||
// Tagmap pattern: "#warrior:1;#druid:2". Also accepts bare "#tag" as shorthand.
|
// Tagmap pattern: "#warrior:1;#druid:2". Also accepts bare "#tag" as shorthand.
|
||||||
const BARE_TAG_PATTERN = /^#[a-zA-Z_][a-zA-Z0-9_]*$/;
|
const BARE_TAG_PATTERN = /^#[a-zA-Z_][a-zA-Z0-9_]*$/;
|
||||||
const TAGMAP_PATTERN =
|
|
||||||
/^#[a-zA-Z_][a-zA-Z0-9_]*:\d+(?:;#[a-zA-Z_][a-zA-Z0-9_]*:\d+)*$/;
|
|
||||||
|
|
||||||
function isTagMapExpr(expr: string): boolean {
|
|
||||||
const t = expr.trim();
|
|
||||||
return BARE_TAG_PATTERN.test(t) || TAGMAP_PATTERN.test(t);
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeTagMap(expr: string): string {
|
function normalizeTagMap(expr: string): string {
|
||||||
const t = expr.trim();
|
const t = expr.trim();
|
||||||
@@ -140,11 +133,14 @@ export async function dispatchCommand(
|
|||||||
const ev = evaluateExpression(arg, {
|
const ev = evaluateExpression(arg, {
|
||||||
lookup: (name: string) => getCombined("$" + name, ctx.variables),
|
lookup: (name: string) => getCombined("$" + name, ctx.variables),
|
||||||
});
|
});
|
||||||
|
if (ev.value.kind !== "number") {
|
||||||
|
return finish({ ok: false, error: "Roll expression must evaluate to a number" });
|
||||||
|
}
|
||||||
const payload = {
|
const payload = {
|
||||||
notation: arg,
|
notation: arg,
|
||||||
label: arg,
|
label: arg,
|
||||||
result: {
|
result: {
|
||||||
total: ev.value,
|
total: ev.value.value,
|
||||||
detail: "",
|
detail: "",
|
||||||
plainDetail: "",
|
plainDetail: "",
|
||||||
pools: [] as { rolls: number[]; subtotal: number }[],
|
pools: [] as { rolls: number[]; subtotal: number }[],
|
||||||
@@ -205,11 +201,8 @@ function dispatchSet(
|
|||||||
// Rolltag: pick random tag, format as tagmap entry
|
// Rolltag: pick random tag, format as tagmap entry
|
||||||
const idx = Math.floor(Math.random() * p.tags.length);
|
const idx = Math.floor(Math.random() * p.tags.length);
|
||||||
newValue = normalizeTagMap(p.tags[idx]);
|
newValue = normalizeTagMap(p.tags[idx]);
|
||||||
} else if (p.expr && isTagMapExpr(p.expr)) {
|
|
||||||
// Tagmap value (e.g. "#warrior:1;#druid:2" or bare "#warrior")
|
|
||||||
newValue = normalizeTagMap(p.expr);
|
|
||||||
} else if (p.expr) {
|
} else if (p.expr) {
|
||||||
// Numeric expression — evaluate using combined values
|
// Evaluate expression — handles both numeric and tagmap values
|
||||||
const result = evaluateExpression(p.expr, {
|
const result = evaluateExpression(p.expr, {
|
||||||
lookup: (name: string) => {
|
lookup: (name: string) => {
|
||||||
const k = "$" + name;
|
const k = "$" + name;
|
||||||
@@ -217,7 +210,7 @@ function dispatchSet(
|
|||||||
return k === key ? undefined : getCombined(k, ctx.variables);
|
return k === key ? undefined : getCombined(k, ctx.variables);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
newValue = String(result.value);
|
newValue = exprValueToString(result.value);
|
||||||
} else {
|
} else {
|
||||||
return { ok: false, error: "缺少表达式" };
|
return { ok: false, error: "缺少表达式" };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,8 +58,8 @@ export type { CompletionsContext } from "./command-completions";
|
|||||||
export { VariableView } from "./VariableView";
|
export { VariableView } from "./VariableView";
|
||||||
export { parseDeclareCsv } from "./declare-parser";
|
export { parseDeclareCsv } from "./declare-parser";
|
||||||
export type { VarDeclaration, TagModifier } from "./declare-parser";
|
export type { VarDeclaration, TagModifier } from "./declare-parser";
|
||||||
export { evaluateExpression, expressionIsTag } from "./variable-expression";
|
export { evaluateExpression, expressionIsTag, exprValueToString } from "./variable-expression";
|
||||||
export type { EvalContext, EvalResult } from "./variable-expression";
|
export type { EvalContext, EvalResult, ExprValue } from "./variable-expression";
|
||||||
export { initReactivity, computeCascade, getCombined, getMods, getDeclExpr, setBase, extractDependencies, computeInitialValues } from "./var-reactivity";
|
export { initReactivity, computeCascade, getCombined, getMods, getDeclExpr, setBase, extractDependencies, computeInitialValues } from "./var-reactivity";
|
||||||
export type { VarReactivityState, VariableStore } from "./var-reactivity";
|
export type { VarReactivityState, VariableStore } from "./var-reactivity";
|
||||||
export { JournalContext, useJournalContext } from "./JournalContext";
|
export { JournalContext, useJournalContext } from "./JournalContext";
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { VarDeclaration, TagModifier } from "./declare-parser";
|
import type { VarDeclaration, TagModifier } from "./declare-parser";
|
||||||
import { evaluateExpression } from "./variable-expression";
|
import { evaluateExpression, exprValueToString } from "./variable-expression";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Types
|
// Types
|
||||||
@@ -349,7 +349,7 @@ export function computeInitialValues(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const rawValue = String(result.value);
|
const rawValue = exprValueToString(result.value);
|
||||||
baseValues.set(key, rawValue);
|
baseValues.set(key, rawValue);
|
||||||
|
|
||||||
// Check if this is a tagmap value — if so, activate matching modifiers
|
// Check if this is a tagmap value — if so, activate matching modifiers
|
||||||
@@ -422,7 +422,13 @@ function applyTagMapActivations(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const value = evalResult.value;
|
// Modifier expressions must evaluate to a number
|
||||||
|
if (evalResult.value.kind !== "number") {
|
||||||
|
throw new Error(
|
||||||
|
`Modifier expression "${mod.expression}" must evaluate to a number`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const value = evalResult.value.value;
|
||||||
const targetIsTagMap = isTagMapValue(baseValues.get(mod.target));
|
const targetIsTagMap = isTagMapValue(baseValues.get(mod.target));
|
||||||
|
|
||||||
if (targetIsTagMap) {
|
if (targetIsTagMap) {
|
||||||
@@ -571,7 +577,7 @@ function reevaluateDependents(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const rawValue = String(result.value);
|
const rawValue = exprValueToString(result.value);
|
||||||
|
|
||||||
// Check for tagmap transition on this declared variable
|
// Check for tagmap transition on this declared variable
|
||||||
const oldCombined = getCombined(key, fallback);
|
const oldCombined = getCombined(key, fallback);
|
||||||
|
|||||||
@@ -4,14 +4,15 @@
|
|||||||
*
|
*
|
||||||
* Supports:
|
* Supports:
|
||||||
* - Number literals (integer or decimal)
|
* - Number literals (integer or decimal)
|
||||||
* - $var references (resolved via lookup, must be numeric)
|
* - Tagmap literals: #warrior:1;#druid:2 (bare #warrior → #warrior:1)
|
||||||
|
* - $var references (resolved via lookup; auto-detects number vs tagmap)
|
||||||
* - Dice patterns: 3d6, 2d8kh1, etc. (delegates to rollFormula)
|
* - Dice patterns: 3d6, 2d8kh1, etc. (delegates to rollFormula)
|
||||||
* - Arithmetic: + - * /
|
* - Arithmetic: + - * / (type-checked via registry)
|
||||||
* - Functions: floor(x), ceil(x), round(x)
|
* - Functions: floor(x), ceil(x), round(x) (numbers only)
|
||||||
* - Parentheses for grouping
|
* - Parentheses for grouping
|
||||||
*
|
*
|
||||||
* Throws on:
|
* Throws on:
|
||||||
* - Type mismatch (e.g. $var resolves to a tag value like "#warrior")
|
* - Type mismatch (e.g. number + tagmap, tagmap * number)
|
||||||
* - Circular variable references (detected by caller)
|
* - Circular variable references (detected by caller)
|
||||||
* - Division by zero
|
* - Division by zero
|
||||||
* - Unknown functions
|
* - Unknown functions
|
||||||
@@ -24,14 +25,19 @@ import { rollFormula } from "../md-commander/hooks";
|
|||||||
// Types
|
// Types
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** A value produced by the expression evaluator. */
|
||||||
|
export type ExprValue =
|
||||||
|
| { kind: "number"; value: number }
|
||||||
|
| { kind: "tagmap"; value: Record<string, number> };
|
||||||
|
|
||||||
export interface EvalContext {
|
export interface EvalContext {
|
||||||
/** Resolve $var → numeric string, or a tag string like "#warrior".
|
/** Resolve $var → string (numeric or tagmap serialized form).
|
||||||
* Return undefined if the variable doesn't exist. */
|
* Return undefined if the variable doesn't exist. */
|
||||||
lookup: (varName: string) => string | undefined;
|
lookup: (varName: string) => string | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EvalResult {
|
export interface EvalResult {
|
||||||
value: number;
|
value: ExprValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -40,8 +46,7 @@ export interface EvalResult {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Evaluate an expression string.
|
* Evaluate an expression string.
|
||||||
* Throws if any variable resolves to a non-numeric (tag) value,
|
* Throws if the expression is malformed or contains a type mismatch.
|
||||||
* or if the expression is malformed.
|
|
||||||
*/
|
*/
|
||||||
export function evaluateExpression(
|
export function evaluateExpression(
|
||||||
expr: string,
|
expr: string,
|
||||||
@@ -63,19 +68,109 @@ export function expressionIsTag(expr: string): boolean {
|
|||||||
return trimmed.startsWith("#");
|
return trimmed.startsWith("#");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Serialize an ExprValue back to a string (for var-reactivity integration). */
|
||||||
|
export function exprValueToString(v: ExprValue): string {
|
||||||
|
if (v.kind === "number") return String(v.value);
|
||||||
|
// tagmap
|
||||||
|
const entries = Object.entries(v.value).filter(([, c]) => c > 0);
|
||||||
|
if (entries.length === 0) return "0";
|
||||||
|
return entries.map(([tag, count]) => `${tag}:${count}`).join(";");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Binary operation registry
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
type BinaryOp = (a: ExprValue, b: ExprValue) => ExprValue;
|
||||||
|
|
||||||
|
/* Helpers that narrow ExprValue to specific kinds for use in registry callbacks. */
|
||||||
|
const num = (a: ExprValue, b: ExprValue): [number, number] =>
|
||||||
|
[a.value as number, b.value as number];
|
||||||
|
const tmap = (a: ExprValue, b: ExprValue): [Record<string, number>, Record<string, number>] =>
|
||||||
|
[a.value as Record<string, number>, b.value as Record<string, number>];
|
||||||
|
|
||||||
|
/** Registry: binaryOps[leftKind][rightKind][operator] → implementation.
|
||||||
|
* Any undefined combination throws a type-mismatch error. */
|
||||||
|
const binaryOps: Record<
|
||||||
|
string,
|
||||||
|
Record<string, Record<string, BinaryOp>>
|
||||||
|
> = {
|
||||||
|
number: {
|
||||||
|
number: {
|
||||||
|
"+": (a, b) => {
|
||||||
|
const [l, r] = num(a, b);
|
||||||
|
return { kind: "number", value: l + r };
|
||||||
|
},
|
||||||
|
"-": (a, b) => {
|
||||||
|
const [l, r] = num(a, b);
|
||||||
|
return { kind: "number", value: l - r };
|
||||||
|
},
|
||||||
|
"*": (a, b) => {
|
||||||
|
const [l, r] = num(a, b);
|
||||||
|
return { kind: "number", value: l * r };
|
||||||
|
},
|
||||||
|
"/": (a, b) => {
|
||||||
|
const [l, r] = num(a, b);
|
||||||
|
if (r === 0) throw new Error("Division by zero");
|
||||||
|
return { kind: "number", value: l / r };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tagmap: {
|
||||||
|
tagmap: {
|
||||||
|
"+": (a, b) => {
|
||||||
|
const [leftMap, rightMap] = tmap(a, b);
|
||||||
|
const result: Record<string, number> = { ...leftMap };
|
||||||
|
for (const [tag, count] of Object.entries(rightMap)) {
|
||||||
|
result[tag] = (result[tag] ?? 0) + count;
|
||||||
|
}
|
||||||
|
return { kind: "tagmap", value: result };
|
||||||
|
},
|
||||||
|
"-": (a, b) => {
|
||||||
|
const [leftMap, rightMap] = tmap(a, b);
|
||||||
|
const result: Record<string, number> = { ...leftMap };
|
||||||
|
for (const [tag, count] of Object.entries(rightMap)) {
|
||||||
|
result[tag] = (result[tag] ?? 0) - count;
|
||||||
|
if (result[tag] <= 0) delete result[tag];
|
||||||
|
}
|
||||||
|
return { kind: "tagmap", value: result };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function getBinaryOp(
|
||||||
|
left: ExprValue,
|
||||||
|
right: ExprValue,
|
||||||
|
op: string,
|
||||||
|
): BinaryOp {
|
||||||
|
const opFn = binaryOps[left.kind]?.[right.kind]?.[op];
|
||||||
|
if (!opFn) {
|
||||||
|
throw new Error(
|
||||||
|
`Type mismatch: cannot ${op} ${left.kind} with ${right.kind}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return opFn;
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Tokenizer
|
// Tokenizer
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
interface Token {
|
interface Token {
|
||||||
kind: "number" | "var" | "ident" | "op" | "lparen" | "rparen" | "comma";
|
kind: "number" | "tagmap" | "var" | "ident" | "op" | "lparen" | "rparen" | "comma";
|
||||||
value: string;
|
value: string;
|
||||||
raw: string;
|
raw: string;
|
||||||
|
/** Pre-parsed tagmap data (only set when kind === "tagmap") */
|
||||||
|
tagmap?: Record<string, number>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Dice pattern: e.g. "3d6", "2d8kh1", "d20" */
|
/** Dice pattern: e.g. "3d6", "2d8kh1", "d20" */
|
||||||
const DICE_RE = /^\d*d\d+(?:[kdh]\d+)*$/i;
|
const DICE_RE = /^\d*d\d+(?:[kdh]\d+)*$/i;
|
||||||
|
|
||||||
|
/** Single tagmap entry: "#warrior" or "#warrior:1" */
|
||||||
|
const TAGMAP_ENTRY_RE = /^#([a-zA-Z_][a-zA-Z0-9_]*)(?::(\d+))?/;
|
||||||
|
|
||||||
function tokenize(input: string): Token[] {
|
function tokenize(input: string): Token[] {
|
||||||
const tokens: Token[] = [];
|
const tokens: Token[] = [];
|
||||||
let i = 0;
|
let i = 0;
|
||||||
@@ -114,6 +209,50 @@ function tokenize(input: string): Token[] {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tagmap literal: #warrior:1;#druid:2 or bare #warrior
|
||||||
|
if (ch === "#") {
|
||||||
|
let raw = "";
|
||||||
|
const map: Record<string, number> = {};
|
||||||
|
|
||||||
|
while (i < input.length && input[i] === "#") {
|
||||||
|
// Match one entry from the current position
|
||||||
|
const remaining = input.slice(i);
|
||||||
|
const m = TAGMAP_ENTRY_RE.exec(remaining);
|
||||||
|
if (!m) {
|
||||||
|
throw new Error(`Invalid tagmap entry at position ${i}: "${remaining.slice(0, 20)}..."`);
|
||||||
|
}
|
||||||
|
const matched = m[0];
|
||||||
|
raw += (raw ? ";" : "") + matched;
|
||||||
|
const tag = "#" + m[1];
|
||||||
|
const count = m[2] !== undefined ? parseInt(m[2], 10) : 1;
|
||||||
|
if (count > 0) {
|
||||||
|
map[tag] = (map[tag] ?? 0) + count;
|
||||||
|
}
|
||||||
|
i += matched.length;
|
||||||
|
|
||||||
|
// Skip whitespace after the entry
|
||||||
|
while (i < input.length && input[i] === " ") i++;
|
||||||
|
|
||||||
|
// Check for semicolon separator (continue to next entry)
|
||||||
|
if (i < input.length && input[i] === ";") {
|
||||||
|
raw += ";";
|
||||||
|
i++;
|
||||||
|
// Skip whitespace after semicolon
|
||||||
|
while (i < input.length && input[i] === " ") i++;
|
||||||
|
// If the next char is not '#', we're done with the tagmap
|
||||||
|
if (i >= input.length || input[i] !== "#") break;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(map).length === 0) {
|
||||||
|
throw new Error(`Empty tagmap: "${raw}"`);
|
||||||
|
}
|
||||||
|
tokens.push({ kind: "tagmap", value: raw, raw, tagmap: map });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// Variable reference: $var
|
// Variable reference: $var
|
||||||
if (ch === "$") {
|
if (ch === "$") {
|
||||||
let ident = "$";
|
let ident = "$";
|
||||||
@@ -186,7 +325,7 @@ function rollDice(notation: string): number {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
interface ParseResult {
|
interface ParseResult {
|
||||||
value: number;
|
value: ExprValue;
|
||||||
next: number; // index of next unconsumed token
|
next: number; // index of next unconsumed token
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,11 +342,8 @@ function parseExpression(
|
|||||||
const tok = tokens[pos];
|
const tok = tokens[pos];
|
||||||
if (tok.kind === "op" && (tok.value === "+" || tok.value === "-")) {
|
if (tok.kind === "op" && (tok.value === "+" || tok.value === "-")) {
|
||||||
const right = parseTerm(tokens, pos + 1, ctx);
|
const right = parseTerm(tokens, pos + 1, ctx);
|
||||||
if (tok.value === "+") {
|
const opFn = getBinaryOp(result.value, right.value, tok.value);
|
||||||
result = { value: result.value + right.value, next: right.next };
|
result = { value: opFn(result.value, right.value), next: right.next };
|
||||||
} else {
|
|
||||||
result = { value: result.value - right.value, next: right.next };
|
|
||||||
}
|
|
||||||
pos = result.next;
|
pos = result.next;
|
||||||
} else {
|
} else {
|
||||||
break;
|
break;
|
||||||
@@ -230,12 +366,8 @@ function parseTerm(
|
|||||||
const tok = tokens[pos];
|
const tok = tokens[pos];
|
||||||
if (tok.kind === "op" && (tok.value === "*" || tok.value === "/")) {
|
if (tok.kind === "op" && (tok.value === "*" || tok.value === "/")) {
|
||||||
const right = parseFactor(tokens, pos + 1, ctx);
|
const right = parseFactor(tokens, pos + 1, ctx);
|
||||||
if (tok.value === "*") {
|
const opFn = getBinaryOp(result.value, right.value, tok.value);
|
||||||
result = { value: result.value * right.value, next: right.next };
|
result = { value: opFn(result.value, right.value), next: right.next };
|
||||||
} else {
|
|
||||||
if (right.value === 0) throw new Error("Division by zero");
|
|
||||||
result = { value: result.value / right.value, next: right.next };
|
|
||||||
}
|
|
||||||
pos = result.next;
|
pos = result.next;
|
||||||
} else {
|
} else {
|
||||||
break;
|
break;
|
||||||
@@ -245,7 +377,7 @@ function parseTerm(
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** factor := number | "$" ident | ident "(" expr ")" | "(" expr ")" | "-" factor */
|
/** factor := number | tagmap | "$" ident | ident "(" expr ")" | "(" expr ")" | "-" factor */
|
||||||
function parseFactor(
|
function parseFactor(
|
||||||
tokens: Token[],
|
tokens: Token[],
|
||||||
pos: number,
|
pos: number,
|
||||||
@@ -257,15 +389,23 @@ function parseFactor(
|
|||||||
|
|
||||||
const tok = tokens[pos];
|
const tok = tokens[pos];
|
||||||
|
|
||||||
// Unary minus
|
// Unary minus (numbers only)
|
||||||
if (tok.kind === "op" && tok.value === "-") {
|
if (tok.kind === "op" && tok.value === "-") {
|
||||||
const inner = parseFactor(tokens, pos + 1, ctx);
|
const inner = parseFactor(tokens, pos + 1, ctx);
|
||||||
return { value: -inner.value, next: inner.next };
|
if (inner.value.kind !== "number") {
|
||||||
|
throw new Error(`Type mismatch: cannot negate ${inner.value.kind}`);
|
||||||
|
}
|
||||||
|
return { value: { kind: "number", value: -inner.value.value }, next: inner.next };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Number literal (including already-rolled dice patterns)
|
// Number literal (including already-rolled dice patterns)
|
||||||
if (tok.kind === "number") {
|
if (tok.kind === "number") {
|
||||||
return { value: parseFloat(tok.value), next: pos + 1 };
|
return { value: { kind: "number", value: parseFloat(tok.value) }, next: pos + 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tagmap literal
|
||||||
|
if (tok.kind === "tagmap") {
|
||||||
|
return { value: { kind: "tagmap", value: { ...tok.tagmap! } }, next: pos + 1 };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Variable reference: $var
|
// Variable reference: $var
|
||||||
@@ -273,21 +413,25 @@ function parseFactor(
|
|||||||
const varName = tok.value; // includes $ prefix
|
const varName = tok.value; // includes $ prefix
|
||||||
const resolved = ctx.lookup(varName.slice(1)); // strip $ for lookup
|
const resolved = ctx.lookup(varName.slice(1)); // strip $ for lookup
|
||||||
if (resolved === undefined) {
|
if (resolved === undefined) {
|
||||||
return { value: 0, next: pos + 1 };
|
return { value: { kind: "number", value: 0 }, next: pos + 1 };
|
||||||
}
|
}
|
||||||
// Tag values cannot be used in arithmetic
|
// Auto-detect: tagmap or numeric
|
||||||
if (resolved.startsWith("#")) {
|
if (resolved.startsWith("#")) {
|
||||||
|
const map = parseTagMapValue(resolved);
|
||||||
|
if (!map) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Type mismatch: ${varName} is a tag ("${resolved}"), not a number`,
|
`Type mismatch: ${varName} is not a valid tagmap ("${resolved}")`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
return { value: { kind: "tagmap", value: map }, next: pos + 1 };
|
||||||
|
}
|
||||||
const num = parseFloat(resolved);
|
const num = parseFloat(resolved);
|
||||||
if (isNaN(num)) {
|
if (isNaN(num)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Type mismatch: ${varName} is not numeric ("${resolved}")`,
|
`Type mismatch: ${varName} is not numeric ("${resolved}")`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return { value: num, next: pos + 1 };
|
return { value: { kind: "number", value: num }, next: pos + 1 };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parenthesized expression
|
// Parenthesized expression
|
||||||
@@ -319,15 +463,43 @@ function parseFactor(
|
|||||||
throw new Error(`Unexpected token: "${tok.raw}"`);
|
throw new Error(`Unexpected token: "${tok.raw}"`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyFunction(name: string, arg: number): number {
|
function applyFunction(name: string, arg: ExprValue): ExprValue {
|
||||||
|
if (arg.kind !== "number") {
|
||||||
|
throw new Error(`Type mismatch: ${name}() requires a number, got ${arg.kind}`);
|
||||||
|
}
|
||||||
switch (name.toLowerCase()) {
|
switch (name.toLowerCase()) {
|
||||||
case "floor":
|
case "floor":
|
||||||
return Math.floor(arg);
|
return { kind: "number", value: Math.floor(arg.value) };
|
||||||
case "ceil":
|
case "ceil":
|
||||||
return Math.ceil(arg);
|
return { kind: "number", value: Math.ceil(arg.value) };
|
||||||
case "round":
|
case "round":
|
||||||
return Math.round(arg);
|
return { kind: "number", value: Math.round(arg.value) };
|
||||||
default:
|
default:
|
||||||
throw new Error(`Unknown function: ${name}`);
|
throw new Error(`Unknown function: ${name}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tagmap parsing (shared with var-reactivity, duplicated to avoid circular deps)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const TAGMAP_RE = /^#([a-zA-Z_][a-zA-Z0-9_]*):(\d+)$/;
|
||||||
|
|
||||||
|
function parseTagMapValue(value: string): Record<string, number> | null {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed.startsWith("#")) return null;
|
||||||
|
|
||||||
|
const parts = trimmed.split(";");
|
||||||
|
const map: Record<string, number> = {};
|
||||||
|
|
||||||
|
for (const part of parts) {
|
||||||
|
const m = TAGMAP_RE.exec(part.trim());
|
||||||
|
if (!m) return null;
|
||||||
|
const tag = "#" + m[1];
|
||||||
|
const count = parseInt(m[2], 10);
|
||||||
|
if (count <= 0) continue;
|
||||||
|
map[tag] = (map[tag] ?? 0) + count;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.keys(map).length > 0 ? map : null;
|
||||||
|
}
|
||||||
|
|||||||
@@ -89,7 +89,9 @@ export function CardLayer(props: CardLayerProps) {
|
|||||||
"font-size": `${layer.fontSize || 3}mm`,
|
"font-size": `${layer.fontSize || 3}mm`,
|
||||||
"text-align": getAlignStyle(layer.align),
|
"text-align": getAlignStyle(layer.align),
|
||||||
}}
|
}}
|
||||||
innerHTML={renderLayerContent(props.cardData[layer.prop])}
|
innerHTML={renderLayerContent(
|
||||||
|
layer.template ?? props.cardData[layer.prop ?? ""],
|
||||||
|
)}
|
||||||
onClick={(e) => handleLayerClick(index(), e)}
|
onClick={(e) => handleLayerClick(index(), e)}
|
||||||
/>
|
/>
|
||||||
<Show when={isSelected() && isEditing()}>
|
<Show when={isSelected() && isEditing()}>
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
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", pos: "1,1-5,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}}**", pos: "1,3-5,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("defaults placement when pos is missing or invalid", () => {
|
||||||
|
const layers = layersToConfigs([{ prop: "x" }, { prop: "y", pos: "bogus" }]);
|
||||||
|
expect(layers[0]).toMatchObject({ x1: 1, y1: 1, x2: 2, y2: 2 });
|
||||||
|
expect(layers[1]).toMatchObject({ x1: 1, y1: 1, x2: 2, y2: 2 });
|
||||||
|
});
|
||||||
|
|
||||||
|
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", pos: "1,1-5,1" },
|
||||||
|
{ template: "{{body}}", pos: "1,2-5,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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
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;
|
||||||
|
/** Grid placement "x1,y1-x2,y2" (1-based), same shape as the compact layers string. */
|
||||||
|
pos?: string;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse a "x1,y1-x2,y2" placement string into grid coordinates. */
|
||||||
|
export function parsePos(
|
||||||
|
pos?: string,
|
||||||
|
): { x1: number; y1: number; x2: number; y2: number } | undefined {
|
||||||
|
if (!pos) return undefined;
|
||||||
|
const m = /^(\d+)\s*,\s*(\d+)\s*-\s*(\d+)\s*,\s*(\d+)$/.exec(pos.trim());
|
||||||
|
if (!m) return undefined;
|
||||||
|
return {
|
||||||
|
x1: Number(m[1]),
|
||||||
|
y1: Number(m[2]),
|
||||||
|
x2: Number(m[3]),
|
||||||
|
y2: Number(m[4]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map a structured YAML layer to a LayerConfig. */
|
||||||
|
function layerYamlToConfig(l: DeckLayerYaml): LayerConfig {
|
||||||
|
const pos = parsePos(l.pos);
|
||||||
|
return {
|
||||||
|
prop: l.prop,
|
||||||
|
template: l.template,
|
||||||
|
visible: l.visible ?? true,
|
||||||
|
x1: pos?.x1 ?? 1,
|
||||||
|
y1: pos?.y1 ?? 1,
|
||||||
|
x2: pos?.x2 ?? 2,
|
||||||
|
y2: pos?.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),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -120,7 +120,7 @@ export function LayerRow(props: LayerRowProps) {
|
|||||||
class="text-sm flex-1 truncate cursor-pointer hover:text-blue-600 select-none"
|
class="text-sm flex-1 truncate cursor-pointer hover:text-blue-600 select-none"
|
||||||
onClick={props.onSelect}
|
onClick={props.onSelect}
|
||||||
>
|
>
|
||||||
{props.layer.prop}
|
{props.layer.prop || (props.layer.template ? "(模板)" : "")}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<DropdownButton
|
<DropdownButton
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { createStore } from "solid-js/store";
|
import { createStore } from "solid-js/store";
|
||||||
|
import yaml from "js-yaml";
|
||||||
import { calculateDimensions } from "./dimensions";
|
import { calculateDimensions } from "./dimensions";
|
||||||
import { loadCSV, CSV } from "../../utils/csv-loader";
|
import { loadCSV, CSV } from "../../utils/csv-loader";
|
||||||
import { formatLayers, initLayerConfigsForSide } from "./layer-parser";
|
import { formatLayers } from "./layer-parser";
|
||||||
import * as layerCrud from "./layer-crud";
|
import * as layerCrud from "./layer-crud";
|
||||||
import type {
|
import type {
|
||||||
CardData,
|
CardData,
|
||||||
@@ -41,6 +42,8 @@ export interface DeckState {
|
|||||||
cornerRadius: number;
|
cornerRadius: number;
|
||||||
shape: CardShape;
|
shape: CardShape;
|
||||||
fixed: boolean;
|
fixed: boolean;
|
||||||
|
/** True when the deck was configured via a yaml/tag codeblock (data-config). */
|
||||||
|
isYamlBlock: boolean;
|
||||||
src: string;
|
src: string;
|
||||||
rawSrc: string;
|
rawSrc: string;
|
||||||
|
|
||||||
@@ -85,6 +88,7 @@ export interface DeckActions {
|
|||||||
setPadding: (padding: number) => void;
|
setPadding: (padding: number) => void;
|
||||||
setCornerRadius: (cornerRadius: number) => void;
|
setCornerRadius: (cornerRadius: number) => void;
|
||||||
setShape: (shape: CardShape) => void;
|
setShape: (shape: CardShape) => void;
|
||||||
|
setIsYamlBlock: (isYamlBlock: boolean) => void;
|
||||||
|
|
||||||
setCards: (cards: CSV<CardData>) => void;
|
setCards: (cards: CSV<CardData>) => void;
|
||||||
setActiveTab: (index: number) => void;
|
setActiveTab: (index: number) => void;
|
||||||
@@ -138,8 +142,8 @@ export interface DeckActions {
|
|||||||
loadCardsFromPath: (
|
loadCardsFromPath: (
|
||||||
path: string,
|
path: string,
|
||||||
rawSrc: string,
|
rawSrc: string,
|
||||||
layersStr?: string,
|
frontLayers?: LayerConfig[],
|
||||||
backLayersStr?: string,
|
backLayers?: LayerConfig[],
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
setError: (error: string | null) => void;
|
setError: (error: string | null) => void;
|
||||||
clearError: () => void;
|
clearError: () => void;
|
||||||
@@ -176,6 +180,7 @@ export function createDeckStore(initialSrc: string = ""): DeckStore {
|
|||||||
cornerRadius: DECK_DEFAULTS.CORNER_RADIUS,
|
cornerRadius: DECK_DEFAULTS.CORNER_RADIUS,
|
||||||
shape: "rectangle",
|
shape: "rectangle",
|
||||||
fixed: false,
|
fixed: false,
|
||||||
|
isYamlBlock: false,
|
||||||
src: initialSrc,
|
src: initialSrc,
|
||||||
rawSrc: initialSrc,
|
rawSrc: initialSrc,
|
||||||
dimensions: null,
|
dimensions: null,
|
||||||
@@ -244,6 +249,9 @@ export function createDeckStore(initialSrc: string = ""): DeckStore {
|
|||||||
const setShape = (shape: CardShape) => {
|
const setShape = (shape: CardShape) => {
|
||||||
setState({ shape });
|
setState({ shape });
|
||||||
};
|
};
|
||||||
|
const setIsYamlBlock = (isYamlBlock: boolean) => {
|
||||||
|
setState({ isYamlBlock });
|
||||||
|
};
|
||||||
|
|
||||||
const setCards = (cards: CSV<CardData>) => setState({ cards, activeTab: 0 });
|
const setCards = (cards: CSV<CardData>) => setState({ cards, activeTab: 0 });
|
||||||
const setActiveTab = (index: number) => setState({ activeTab: index });
|
const setActiveTab = (index: number) => setState({ activeTab: index });
|
||||||
@@ -442,8 +450,8 @@ export function createDeckStore(initialSrc: string = ""): DeckStore {
|
|||||||
const loadCardsFromPath = async (
|
const loadCardsFromPath = async (
|
||||||
path: string,
|
path: string,
|
||||||
rawSrc: string,
|
rawSrc: string,
|
||||||
layersStr: string = "",
|
frontLayers: LayerConfig[] = [],
|
||||||
backLayersStr: string = "",
|
backLayers: LayerConfig[] = [],
|
||||||
) => {
|
) => {
|
||||||
if (!path) {
|
if (!path) {
|
||||||
setState({ error: "未指定 CSV 文件路径" });
|
setState({ error: "未指定 CSV 文件路径" });
|
||||||
@@ -466,12 +474,8 @@ export function createDeckStore(initialSrc: string = ""): DeckStore {
|
|||||||
setState({
|
setState({
|
||||||
cards: data,
|
cards: data,
|
||||||
activeTab: 0,
|
activeTab: 0,
|
||||||
frontLayerConfigs: layerCrud.withKeys(
|
frontLayerConfigs: layerCrud.withKeys(frontLayers),
|
||||||
initLayerConfigsForSide(data, layersStr),
|
backLayerConfigs: layerCrud.withKeys(backLayers),
|
||||||
),
|
|
||||||
backLayerConfigs: layerCrud.withKeys(
|
|
||||||
initLayerConfigsForSide(data, backLayersStr),
|
|
||||||
),
|
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
});
|
});
|
||||||
updateDimensions();
|
updateDimensions();
|
||||||
@@ -487,6 +491,9 @@ export function createDeckStore(initialSrc: string = ""): DeckStore {
|
|||||||
const clearError = () => setState({ error: null });
|
const clearError = () => setState({ error: null });
|
||||||
|
|
||||||
const generateCode = (backLayersStr?: string) => {
|
const generateCode = (backLayersStr?: string) => {
|
||||||
|
if (state.isYamlBlock) {
|
||||||
|
return generateYamlCode();
|
||||||
|
}
|
||||||
const frontLayersStr = formatLayers(state.frontLayerConfigs);
|
const frontLayersStr = formatLayers(state.frontLayerConfigs);
|
||||||
const backLayersString =
|
const backLayersString =
|
||||||
backLayersStr || formatLayers(state.backLayerConfigs);
|
backLayersStr || formatLayers(state.backLayerConfigs);
|
||||||
@@ -514,6 +521,49 @@ export function createDeckStore(initialSrc: string = ""): DeckStore {
|
|||||||
return parts.join("");
|
return parts.join("");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Serialize the deck back to a ```yaml/tag codeblock (round-trips templates). */
|
||||||
|
const generateYamlCode = () => {
|
||||||
|
const toYamlLayer = (l: LayerConfig) => {
|
||||||
|
const out: Record<string, unknown> = {};
|
||||||
|
if (l.template) {
|
||||||
|
out.template = l.template;
|
||||||
|
} else {
|
||||||
|
out.prop = l.prop ?? "";
|
||||||
|
}
|
||||||
|
out.pos = `${l.x1},${l.y1}-${l.x2},${l.y2}`;
|
||||||
|
if (l.fontSize) out.font = l.fontSize;
|
||||||
|
if (l.orientation && l.orientation !== "n") out.orientation = l.orientation;
|
||||||
|
if (l.align) out.align = l.align;
|
||||||
|
if (!l.visible) out.visible = false;
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
|
||||||
|
const config: Record<string, unknown> = {
|
||||||
|
size: `${state.sizeW}x${state.sizeH}`,
|
||||||
|
grid: `${state.gridW}x${state.gridH}`,
|
||||||
|
layers: state.frontLayerConfigs.map(toYamlLayer),
|
||||||
|
};
|
||||||
|
if (state.bleed !== DECK_DEFAULTS.BLEED) config.bleed = state.bleed;
|
||||||
|
if (state.padding !== DECK_DEFAULTS.PADDING) config.padding = state.padding;
|
||||||
|
if (state.shape !== "rectangle") config.shape = state.shape;
|
||||||
|
if (state.backLayerConfigs.length > 0) {
|
||||||
|
config.back_layers = state.backLayerConfigs.map(toYamlLayer);
|
||||||
|
}
|
||||||
|
|
||||||
|
const doc = {
|
||||||
|
tag: "md-deck",
|
||||||
|
body: state.rawSrc || state.src,
|
||||||
|
"data-config": config,
|
||||||
|
};
|
||||||
|
const yamlStr = yaml.dump(doc, {
|
||||||
|
indent: 2,
|
||||||
|
lineWidth: -1,
|
||||||
|
noRefs: true,
|
||||||
|
});
|
||||||
|
const fence = "```";
|
||||||
|
return `${fence}yaml/tag\n${yamlStr}${fence}`;
|
||||||
|
};
|
||||||
|
|
||||||
const copyCode = async (fallback?: (code: string) => void) => {
|
const copyCode = async (fallback?: (code: string) => void) => {
|
||||||
const code = generateCode();
|
const code = generateCode();
|
||||||
try {
|
try {
|
||||||
@@ -573,6 +623,7 @@ export function createDeckStore(initialSrc: string = ""): DeckStore {
|
|||||||
setPadding,
|
setPadding,
|
||||||
setCornerRadius,
|
setCornerRadius,
|
||||||
setShape,
|
setShape,
|
||||||
|
setIsYamlBlock,
|
||||||
setCards,
|
setCards,
|
||||||
setActiveTab,
|
setActiveTab,
|
||||||
updateCardData,
|
updateCardData,
|
||||||
|
|||||||
@@ -35,8 +35,10 @@ export function parseLayers(layersStr: string): Layer[] {
|
|||||||
* 格式化 layers 为字符串
|
* 格式化 layers 为字符串
|
||||||
*/
|
*/
|
||||||
export function formatLayers(layers: LayerConfig[]): string {
|
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
|
return layers
|
||||||
.filter((l) => l.visible)
|
.filter((l) => l.visible && l.prop)
|
||||||
.map((l) => {
|
.map((l) => {
|
||||||
let str = `${l.prop}:${l.x1},${l.y1}-${l.x2},${l.y2}`;
|
let str = `${l.prop}:${l.x1},${l.y1}-${l.x2},${l.y2}`;
|
||||||
if (l.fontSize) {
|
if (l.fontSize) {
|
||||||
|
|||||||
@@ -2,9 +2,10 @@ import { customElement, noShadowDOM } from "solid-element";
|
|||||||
import { Show, onCleanup } from "solid-js";
|
import { Show, onCleanup } from "solid-js";
|
||||||
import { resolvePath } from "../utils/path";
|
import { resolvePath } from "../utils/path";
|
||||||
import { v4 as uuidv4 } from "uuid";
|
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 { 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 { DeckHeader } from "./DeckHeader";
|
||||||
import { CardList } from "./CardList";
|
import { CardList } from "./CardList";
|
||||||
import { DeckContent } from "./DeckContent";
|
import { DeckContent } from "./DeckContent";
|
||||||
@@ -68,49 +69,77 @@ customElement<DeckProps>(
|
|||||||
const deckId = `deck-${uuidv4()}`;
|
const deckId = `deck-${uuidv4()}`;
|
||||||
registerDeck(deckId, store, resolvedSrc, csvPath);
|
registerDeck(deckId, store, resolvedSrc, csvPath);
|
||||||
|
|
||||||
|
// 读取 data-config(yaml/tag 代码块方式):结构化配置优先
|
||||||
|
let config:
|
||||||
|
| ReturnType<typeof normalizeDeckConfig>
|
||||||
|
| undefined;
|
||||||
|
const dataConfigAttr = element?.getAttribute("data-config");
|
||||||
|
if (dataConfigAttr) {
|
||||||
|
try {
|
||||||
|
config = normalizeDeckConfig(JSON.parse(dataConfigAttr));
|
||||||
|
// 记录来源,复制代码时输出 yaml/tag 代码块
|
||||||
|
store.actions.setIsYamlBlock(true);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Invalid data-config on md-deck:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 解析 size 属性(支持旧格式 "54x86" 和新格式)
|
// 解析 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);
|
const [w, h] = props.size.split("x").map(Number);
|
||||||
store.actions.setSizeW(w);
|
store.actions.setSizeW(w);
|
||||||
store.actions.setSizeH(h);
|
store.actions.setSizeH(h);
|
||||||
} else {
|
} else {
|
||||||
store.actions.setSizeW(props.sizeW ?? 54);
|
store.actions.setSizeW(props.sizeW ?? DECK_DEFAULTS.SIZE_W);
|
||||||
store.actions.setSizeH(props.sizeH ?? 86);
|
store.actions.setSizeH(props.sizeH ?? DECK_DEFAULTS.SIZE_H);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 解析 grid 属性(支持旧格式 "5x8" 和新格式)
|
// 解析 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);
|
const [w, h] = props.grid.split("x").map(Number);
|
||||||
store.actions.setGridW(w);
|
store.actions.setGridW(w);
|
||||||
store.actions.setGridH(h);
|
store.actions.setGridH(h);
|
||||||
} else {
|
} else {
|
||||||
store.actions.setGridW(props.gridW ?? 5);
|
store.actions.setGridW(props.gridW ?? DECK_DEFAULTS.GRID_W);
|
||||||
store.actions.setGridH(props.gridH ?? 8);
|
store.actions.setGridH(props.gridH ?? DECK_DEFAULTS.GRID_H);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 解析 bleed 和 padding(支持旧字符串格式和新数字格式)
|
// 解析 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));
|
store.actions.setBleed(Number(props.bleed));
|
||||||
} else {
|
} 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));
|
store.actions.setPadding(Number(props.padding));
|
||||||
} else {
|
} 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 数据
|
// 加载 CSV 数据
|
||||||
store.actions.loadCardsFromPath(
|
store.actions.loadCardsFromPath(resolvedSrc, csvPath, frontLayers, backLayers);
|
||||||
resolvedSrc,
|
|
||||||
csvPath,
|
|
||||||
(props.layers as string) || "",
|
|
||||||
(props.backLayers as string) || "",
|
|
||||||
);
|
|
||||||
|
|
||||||
// 清理函数
|
// 清理函数
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
|
|||||||
@@ -7,7 +7,10 @@ export type CardSide = "front" | "back";
|
|||||||
export type { CardShape } from "../../plotcutter/contour";
|
export type { CardShape } from "../../plotcutter/contour";
|
||||||
|
|
||||||
export interface Layer {
|
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;
|
x1: number;
|
||||||
y1: number;
|
y1: number;
|
||||||
x2: number;
|
x2: number;
|
||||||
@@ -18,7 +21,10 @@ export interface Layer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface LayerConfig {
|
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;
|
visible: boolean;
|
||||||
x1: number;
|
x1: number;
|
||||||
y1: number;
|
y1: number;
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ customElement("md-embed", { headingBase: 0 }, (props, { element }) => {
|
|||||||
<Show when={!content.loading && !content.error && content()}>
|
<Show when={!content.loading && !content.error && content()}>
|
||||||
{/* biome-ignore lint/security/noDangerouslySetInnerHtml: markdown render */}
|
{/* biome-ignore lint/security/noDangerouslySetInnerHtml: markdown render */}
|
||||||
<div
|
<div
|
||||||
class="prose"
|
class="prose text-black prose-sm"
|
||||||
innerHTML={parseMarkdown(content()!, resolvedPath)}
|
innerHTML={parseMarkdown(content()!, resolvedPath)}
|
||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ title: 卡牌组件
|
|||||||
|
|
||||||
将 CSV 数据渲染为可打印的卡牌布局,支持自定义尺寸、网格、图层和双面排版。
|
将 CSV 数据渲染为可打印的卡牌布局,支持自定义尺寸、网格、图层和双面排版。
|
||||||
|
|
||||||
**语法:** `:md-deck[./cards.csv]{选项}`
|
**语法:** `:md-deck[./cards.csv]{选项}` 或 ```yaml/tag 代码块
|
||||||
|
|
||||||
**基础卡牌:**
|
**基础卡牌:**
|
||||||
:md-deck[./spells.csv]{grid="3x3"}
|
:md-deck[./spells.csv]{grid="3x3"}
|
||||||
@@ -16,6 +16,32 @@ title: 卡牌组件
|
|||||||
|
|
||||||
CSV 包含数据字段列,通过图层定义控制各字段的位置、大小和对齐。
|
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
|
||||||
|
pos: 1,1-5,1
|
||||||
|
font: 12
|
||||||
|
- template: |
|
||||||
|
**{{name}}** — {{type}}
|
||||||
|
{{description}}
|
||||||
|
pos: 1,3-5,8
|
||||||
|
font: 3
|
||||||
|
align: l
|
||||||
|
```
|
||||||
|
````
|
||||||
|
|
||||||
|
`pos` 为网格位置 `x1,y1-x2,y2`(1-based,与紧凑 layers 字符串同格式)。
|
||||||
|
|
||||||
## 图层格式
|
## 图层格式
|
||||||
|
|
||||||
`layers` 属性格式为 `字段:起始行,起始列-结束列,字号`,多个图层用空格分隔:
|
`layers` 属性格式为 `字段:起始行,起始列-结束列,字号`,多个图层用空格分隔:
|
||||||
|
|||||||
@@ -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("<md-deck size=\"54x86\">./cards.csv</md-deck>");
|
||||||
|
});
|
||||||
|
|
||||||
|
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("<md-deck data-config=");
|
||||||
|
expect(html).toContain("./cards.csv</md-deck>");
|
||||||
|
// 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("<tag-unknown class=\"foo\"></tag-unknown>");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -33,6 +33,18 @@ export default function markedCodeBlockYamlTag(): MarkedExtension {
|
|||||||
delete (rest as Record<string, unknown>).body;
|
delete (rest as Record<string, unknown>).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<string, unknown>)["data-config"];
|
||||||
|
if (rawConfig !== undefined && rawConfig !== null) {
|
||||||
|
const json = JSON.stringify(rawConfig).replace(/"/g, """);
|
||||||
|
configAttr = ` data-config="${json}"`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const propsStr = Object.entries(rest)
|
const propsStr = Object.entries(rest)
|
||||||
.map(([key, value]) => {
|
.map(([key, value]) => {
|
||||||
const strValue = String(value);
|
const strValue = String(value);
|
||||||
@@ -48,13 +60,15 @@ export default function markedCodeBlockYamlTag(): MarkedExtension {
|
|||||||
raw: match[0],
|
raw: match[0],
|
||||||
tagName,
|
tagName,
|
||||||
props: propsStr,
|
props: propsStr,
|
||||||
|
config: configAttr,
|
||||||
content,
|
content,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
renderer(token: any) {
|
renderer(token: any) {
|
||||||
const propsAttr = token.props ? ` ${token.props}` : "";
|
const propsAttr = token.props ? ` ${token.props}` : "";
|
||||||
return `<${token.tagName}${propsAttr}>${token.content || ""}</${token.tagName}>\n`;
|
const configAttr = token.config || "";
|
||||||
|
return `<${token.tagName}${propsAttr}${configAttr}>${token.content || ""}</${token.tagName}>\n`;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
Reference in New Issue
Block a user