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 align; case "r": - return alignRightIcon; + return align; default: - return alignCenterIcon; + return ( + align + ); } } @@ -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; }