diff --git a/docs/markdown.md b/docs/markdown.md
index 446644d..189acde 100644
--- a/docs/markdown.md
+++ b/docs/markdown.md
@@ -447,7 +447,7 @@ data-config:
```
````
-每个图层通过 `prop`(读取 CSV 字段)或 `template`(渲染带 `{{变量}}` 替换的 markdown 模板)定义,`pos` 为网格位置 `x1,y1-x2,y2`(1-based,与紧凑 layers 字符串同格式),支持 `font`、`orientation`、`align`。`back_layers` 用于背面图层。
+每个图层通过 `prop`(读取 CSV 字段)或 `template`(渲染带 `{{变量}}` 替换的 markdown 模板)定义,`pos` 为网格位置 `x1,y1-x2,y2`(1-based,与紧凑 layers 字符串同格式),支持 `font`、`orientation`、`align`(水平 `l`/`c`/`r`,可加垂直前缀 `t`/`b` 组成 `tl`/`tc`/`tr`/`bl`/`bc`/`br`,如 `align: tl` 表示左上对齐)。`back_layers` 用于背面图层。
### 🧶 叙事线组件 (md-yarn-spinner)
diff --git a/src/components/md-deck/CardLayer.tsx b/src/components/md-deck/CardLayer.tsx
index e00afb3..b0fb2e9 100644
--- a/src/components/md-deck/CardLayer.tsx
+++ b/src/components/md-deck/CardLayer.tsx
@@ -1,7 +1,7 @@
-import { createMemo, For, Show } from "solid-js";
+import { createMemo, For, Show, type JSX } from "solid-js";
import { parseMarkdown } from "../../markdown";
import { getLayerStyle } from "./hooks/dimensions";
-import type { CardData, CardSide, LayerConfig } from "./types";
+import type { Align, CardData, CardSide, LayerConfig } from "./types";
import { DeckStore } from "./hooks/deckStore";
import { processVariables } from "../utils/csv-loader";
import type { LayerInteractionHandlers } from "./hooks/useLayerInteraction";
@@ -31,10 +31,21 @@ export function CardLayer(props: CardLayerProps) {
) as string;
}
- const getAlignStyle = (align?: "l" | "c" | "r") => {
- if (align === "l") return "left";
- if (align === "r") return "right";
- return "center";
+ const getAlignStyle = (align?: Align) => {
+ const horizontal = align?.includes("l")
+ ? "left"
+ : align?.includes("r")
+ ? "right"
+ : "center";
+ const vertical = align?.includes("t")
+ ? "flex-start"
+ : align?.includes("b")
+ ? "flex-end"
+ : "center";
+ return {
+ "text-align": horizontal,
+ "justify-content": vertical,
+ } as JSX.CSSProperties;
};
const isLayerSelected = (layerIndex: number) =>
@@ -87,7 +98,7 @@ export function CardLayer(props: CardLayerProps) {
style={{
...getLayerStyle(layer, dimensions()),
"font-size": `${layer.fontSize || 3}mm`,
- "text-align": getAlignStyle(layer.align),
+ ...getAlignStyle(layer.align),
}}
innerHTML={renderLayerContent(
layer.template ?? props.cardData[layer.prop ?? ""],
diff --git a/src/components/md-deck/config.test.ts b/src/components/md-deck/config.test.ts
index 5e7f30c..93a3564 100644
--- a/src/components/md-deck/config.test.ts
+++ b/src/components/md-deck/config.test.ts
@@ -50,6 +50,25 @@ describe("layersToConfigs", () => {
});
});
+ test("parses combined vertical+horizontal align", () => {
+ const layers = layersToConfigs([
+ { prop: "a", pos: "1,1-2,2", align: "tl" },
+ { prop: "b", pos: "1,1-2,2", align: "br" },
+ { prop: "c", pos: "1,1-2,2", align: "tc" },
+ { prop: "d", pos: "1,1-2,2", align: "bc" },
+ ]);
+ expect(layers[0].align).toBe("tl");
+ expect(layers[1].align).toBe("br");
+ expect(layers[2].align).toBe("tc");
+ expect(layers[3].align).toBe("bc");
+ });
+
+ test("parses vertical+horizontal align in compact layers string", () => {
+ const layers = layersToConfigs("title:1,1-4,1tl body:1,2-5,8bc");
+ expect(layers[0].align).toBe("tl");
+ expect(layers[1].align).toBe("bc");
+ });
+
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 });
diff --git a/src/components/md-deck/config.ts b/src/components/md-deck/config.ts
index 2ffd8d1..06c6b8b 100644
--- a/src/components/md-deck/config.ts
+++ b/src/components/md-deck/config.ts
@@ -1,4 +1,4 @@
-import type { CardShape, LayerConfig } from "./types";
+import type { Align, CardShape, LayerConfig } from "./types";
import { parseLayers } from "./hooks/layer-parser";
/**
@@ -27,7 +27,7 @@ export interface DeckLayerYaml {
font?: number;
fontSize?: number;
orientation?: "n" | "s" | "e" | "w";
- align?: "l" | "c" | "r";
+ align?: Align;
visible?: boolean;
}
diff --git a/src/components/md-deck/editor-panel/LayerRow.tsx b/src/components/md-deck/editor-panel/LayerRow.tsx
index 2ee50de..ac2f994 100644
--- a/src/components/md-deck/editor-panel/LayerRow.tsx
+++ b/src/components/md-deck/editor-panel/LayerRow.tsx
@@ -1,7 +1,7 @@
import { For, createSignal, onCleanup, onMount } from "solid-js";
import { createSortable, useDragDropContext } from "@thisbeyond/solid-dnd";
import type { DeckStore } from "../hooks/deckStore";
-import type { LayerConfig } from "../types";
+import type { Align, LayerConfig } from "../types";
import alignLeftIcon from "./icons/align-left.png";
import alignCenterIcon from "./icons/align-center.png";
import alignRightIcon from "./icons/align-right.png";
@@ -17,7 +17,7 @@ export interface LayerRowProps {
setOpenDropdown: (val: string | null) => void;
onUpdateOrientation: (o: "n" | "s" | "e" | "w") => void;
onUpdateFontSize: (fs?: number) => void;
- onUpdateAlign: (a?: "l" | "c" | "r") => void;
+ onUpdateAlign: (a?: Align) => void;
onSelect: () => void;
onRemove: () => void;
}
@@ -29,11 +29,17 @@ const ORIENTATIONS = [
{ value: "w" as const, label: "← 西" },
];
-const ALIGNS = [
- { value: "" as const, icon: alignCenterIcon },
- { value: "l" as const, icon: alignLeftIcon },
- { value: "c" as const, icon: alignCenterIcon },
- { value: "r" as const, icon: alignRightIcon },
+const ALIGNS: { value: Align | ""; icon: string }[] = [
+ { value: "", icon: alignCenterIcon },
+ { value: "l", icon: alignLeftIcon },
+ { value: "c", icon: alignCenterIcon },
+ { value: "r", icon: alignRightIcon },
+ { value: "tl", icon: "↖" },
+ { value: "tc", icon: "↑" },
+ { value: "tr", icon: "↗" },
+ { value: "bl", icon: "↙" },
+ { value: "bc", icon: "↓" },
+ { value: "br", icon: "↘" },
];
const FONT_PRESETS = [3, 5, 8, 12] as const;
@@ -53,14 +59,28 @@ function orientChar(v: string) {
}
}
-function alignSrc(v: string) {
+function alignIcon(v: string): import("solid-js").JSX.Element {
switch (v) {
+ case "tl":
+ return "↖";
+ case "tc":
+ return "↑";
+ case "tr":
+ return "↗";
+ case "bl":
+ return "↙";
+ case "bc":
+ return "↓";
+ case "br":
+ return "↘";
case "l":
- return alignLeftIcon;
+ return
;
case "r":
- return alignRightIcon;
+ return
;
default:
- return alignCenterIcon;
+ return (
+
+ );
}
}
@@ -149,13 +169,7 @@ export function LayerRow(props: LayerRowProps) {
- }
+ icon={alignIcon(props.layer.align || "")}
visible={props.layer.visible}
open={props.openDropdown === `align-${props.index}`}
onToggle={() =>
@@ -173,7 +187,13 @@ export function LayerRow(props: LayerRowProps) {
onClick={() => props.onUpdateAlign(o.value || undefined)}
class="flex items-center gap-2 w-full px-3 py-1.5 text-sm hover:bg-gray-100 cursor-pointer whitespace-nowrap"
>
-
+ {o.icon.endsWith(".png") ? (
+
+ ) : (
+
+ {o.icon}
+
+ )}
)}
diff --git a/src/components/md-deck/hooks/layer-parser.ts b/src/components/md-deck/hooks/layer-parser.ts
index 492c106..79b7a66 100644
--- a/src/components/md-deck/hooks/layer-parser.ts
+++ b/src/components/md-deck/hooks/layer-parser.ts
@@ -4,15 +4,16 @@ import { CSV } from "../../utils/csv-loader";
/**
* 解析 layers 字符串
* 格式:body:1,7-5,8 title:1,1-4,1f6.6sl
- * f[fontSize] 表示字体大小(可选),方向字母(可选),对齐字母 l/c/r(可选)
+ * f[fontSize] 表示字体大小(可选),方向字母(可选),
+ * 对齐字母(可选):水平 l/c/r,可加垂直前缀 t/b 组成 tl/tr/bl/br
*/
export function parseLayers(layersStr: string): Layer[] {
if (!layersStr) return [];
const layers: Layer[] = [];
- // 匹配:prop:x1,y1-x2,y2[ffontSize][direction][align]
+ // 匹配:prop:x1,y1-x2,y2[ffontSize][direction][[t|b]align]
const regex =
- /([^: ]+):(\d+),(\d+)-(\d+),(\d+)(?:f([\d.]+))?([nsew])?([lcr])?/g;
+ /([^: ]+):(\d+),(\d+)-(\d+),(\d+)(?:f([\d.]+))?([nsew])?([tb])?([lcr])?/g;
let match;
while ((match = regex.exec(layersStr)) !== null) {
@@ -24,7 +25,7 @@ export function parseLayers(layersStr: string): Layer[] {
y2: parseInt(match[5]),
fontSize: match[6] ? parseFloat(match[6]) : undefined,
orientation: match[7] as "n" | "s" | "e" | "w" | undefined,
- align: match[8] as "l" | "c" | "r" | undefined,
+ align: [match[8], match[9]].filter(Boolean).join("") as Layer["align"],
});
}
diff --git a/src/components/md-deck/types.ts b/src/components/md-deck/types.ts
index 8cf418e..050e778 100644
--- a/src/components/md-deck/types.ts
+++ b/src/components/md-deck/types.ts
@@ -4,6 +4,22 @@ export interface CardData {
export type CardSide = "front" | "back";
+/**
+ * Layer alignment: horizontal (l/c/r) optionally combined with vertical
+ * (t/b). "tl" = top-left, "br" = bottom-right, "tc" = top-center,
+ * "bc" = bottom-center, etc. Plain "l"/"c"/"r" means vertically centered.
+ */
+export type Align =
+ | "l"
+ | "c"
+ | "r"
+ | "tl"
+ | "tc"
+ | "tr"
+ | "bl"
+ | "bc"
+ | "br";
+
export type { CardShape } from "../../plotcutter/contour";
export interface Layer {
@@ -17,7 +33,7 @@ export interface Layer {
y2: number;
orientation?: "n" | "s" | "e" | "w";
fontSize?: number;
- align?: "l" | "c" | "r";
+ align?: Align;
}
export interface LayerConfig {
@@ -32,7 +48,7 @@ export interface LayerConfig {
y2: number;
orientation?: "n" | "s" | "e" | "w";
fontSize?: number;
- align?: "l" | "c" | "r";
+ align?: Align;
_key?: number;
}