feat(md-deck): add vertical alignment options to layers

Expand align from l/c/r to a 3x3 grid with vertical prefixes:
tl/tc/tr/bl/bc/br. Vertical position renders via flex
justify-content; compact layers strings and YAML both parse the
new values, and the layer editor dropdown gains corner/edge
options.
This commit is contained in:
2026-09-03 10:17:44 +08:00
parent d75df5280f
commit 5fc2bc5262
7 changed files with 102 additions and 35 deletions
+1 -1
View File
@@ -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) ### 🧶 叙事线组件 (md-yarn-spinner)
+18 -7
View File
@@ -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 { parseMarkdown } from "../../markdown";
import { getLayerStyle } from "./hooks/dimensions"; 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 { DeckStore } from "./hooks/deckStore";
import { processVariables } from "../utils/csv-loader"; import { processVariables } from "../utils/csv-loader";
import type { LayerInteractionHandlers } from "./hooks/useLayerInteraction"; import type { LayerInteractionHandlers } from "./hooks/useLayerInteraction";
@@ -31,10 +31,21 @@ export function CardLayer(props: CardLayerProps) {
) as string; ) as string;
} }
const getAlignStyle = (align?: "l" | "c" | "r") => { const getAlignStyle = (align?: Align) => {
if (align === "l") return "left"; const horizontal = align?.includes("l")
if (align === "r") return "right"; ? "left"
return "center"; : 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) => const isLayerSelected = (layerIndex: number) =>
@@ -87,7 +98,7 @@ export function CardLayer(props: CardLayerProps) {
style={{ style={{
...getLayerStyle(layer, dimensions()), ...getLayerStyle(layer, dimensions()),
"font-size": `${layer.fontSize || 3}mm`, "font-size": `${layer.fontSize || 3}mm`,
"text-align": getAlignStyle(layer.align), ...getAlignStyle(layer.align),
}} }}
innerHTML={renderLayerContent( innerHTML={renderLayerContent(
layer.template ?? props.cardData[layer.prop ?? ""], layer.template ?? props.cardData[layer.prop ?? ""],
+19
View File
@@ -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", () => { test("defaults placement when pos is missing or invalid", () => {
const layers = layersToConfigs([{ prop: "x" }, { prop: "y", pos: "bogus" }]); const layers = layersToConfigs([{ prop: "x" }, { prop: "y", pos: "bogus" }]);
expect(layers[0]).toMatchObject({ x1: 1, y1: 1, x2: 2, y2: 2 }); expect(layers[0]).toMatchObject({ x1: 1, y1: 1, x2: 2, y2: 2 });
+2 -2
View File
@@ -1,4 +1,4 @@
import type { CardShape, LayerConfig } from "./types"; import type { Align, CardShape, LayerConfig } from "./types";
import { parseLayers } from "./hooks/layer-parser"; import { parseLayers } from "./hooks/layer-parser";
/** /**
@@ -27,7 +27,7 @@ export interface DeckLayerYaml {
font?: number; font?: number;
fontSize?: number; fontSize?: number;
orientation?: "n" | "s" | "e" | "w"; orientation?: "n" | "s" | "e" | "w";
align?: "l" | "c" | "r"; align?: Align;
visible?: boolean; visible?: boolean;
} }
@@ -1,7 +1,7 @@
import { For, createSignal, onCleanup, onMount } from "solid-js"; import { For, createSignal, onCleanup, onMount } from "solid-js";
import { createSortable, useDragDropContext } from "@thisbeyond/solid-dnd"; import { createSortable, useDragDropContext } from "@thisbeyond/solid-dnd";
import type { DeckStore } from "../hooks/deckStore"; 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 alignLeftIcon from "./icons/align-left.png";
import alignCenterIcon from "./icons/align-center.png"; import alignCenterIcon from "./icons/align-center.png";
import alignRightIcon from "./icons/align-right.png"; import alignRightIcon from "./icons/align-right.png";
@@ -17,7 +17,7 @@ export interface LayerRowProps {
setOpenDropdown: (val: string | null) => void; setOpenDropdown: (val: string | null) => void;
onUpdateOrientation: (o: "n" | "s" | "e" | "w") => void; onUpdateOrientation: (o: "n" | "s" | "e" | "w") => void;
onUpdateFontSize: (fs?: number) => void; onUpdateFontSize: (fs?: number) => void;
onUpdateAlign: (a?: "l" | "c" | "r") => void; onUpdateAlign: (a?: Align) => void;
onSelect: () => void; onSelect: () => void;
onRemove: () => void; onRemove: () => void;
} }
@@ -29,11 +29,17 @@ const ORIENTATIONS = [
{ value: "w" as const, label: "← 西" }, { value: "w" as const, label: "← 西" },
]; ];
const ALIGNS = [ const ALIGNS: { value: Align | ""; icon: string }[] = [
{ value: "" as const, icon: alignCenterIcon }, { value: "", icon: alignCenterIcon },
{ value: "l" as const, icon: alignLeftIcon }, { value: "l", icon: alignLeftIcon },
{ value: "c" as const, icon: alignCenterIcon }, { value: "c", icon: alignCenterIcon },
{ value: "r" as const, icon: alignRightIcon }, { 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; 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) { switch (v) {
case "tl":
return "↖";
case "tc":
return "↑";
case "tr":
return "↗";
case "bl":
return "↙";
case "bc":
return "↓";
case "br":
return "↘";
case "l": case "l":
return alignLeftIcon; return <img src={alignLeftIcon} alt="align" class="w-5 h-5 not-prose" />;
case "r": case "r":
return alignRightIcon; return <img src={alignRightIcon} alt="align" class="w-5 h-5 not-prose" />;
default: default:
return alignCenterIcon; return (
<img src={alignCenterIcon} alt="align" class="w-5 h-5 not-prose" />
);
} }
} }
@@ -149,13 +169,7 @@ export function LayerRow(props: LayerRowProps) {
</DropdownButton> </DropdownButton>
<DropdownButton <DropdownButton
icon={ icon={alignIcon(props.layer.align || "")}
<img
src={alignSrc(props.layer.align || "")}
alt="align"
class="w-5 h-5 not-prose"
/>
}
visible={props.layer.visible} visible={props.layer.visible}
open={props.openDropdown === `align-${props.index}`} open={props.openDropdown === `align-${props.index}`}
onToggle={() => onToggle={() =>
@@ -173,7 +187,13 @@ export function LayerRow(props: LayerRowProps) {
onClick={() => props.onUpdateAlign(o.value || undefined)} 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" 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") ? (
<img src={o.icon} alt="" class="w-4 h-4 not-prose max-w-none" /> <img src={o.icon} alt="" class="w-4 h-4 not-prose max-w-none" />
) : (
<span class="w-4 h-4 flex items-center justify-center not-prose">
{o.icon}
</span>
)}
</button> </button>
)} )}
</For> </For>
+5 -4
View File
@@ -4,15 +4,16 @@ import { CSV } from "../../utils/csv-loader";
/** /**
* 解析 layers 字符串 * 解析 layers 字符串
* 格式:body:1,7-5,8 title:1,1-4,1f6.6sl * 格式: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[] { export function parseLayers(layersStr: string): Layer[] {
if (!layersStr) return []; if (!layersStr) return [];
const layers: Layer[] = []; const layers: Layer[] = [];
// 匹配:prop:x1,y1-x2,y2[ffontSize][direction][align] // 匹配:prop:x1,y1-x2,y2[ffontSize][direction][[t|b]align]
const regex = const regex =
/([^: ]+):(\d+),(\d+)-(\d+),(\d+)(?:f([\d.]+))?([nsew])?([lcr])?/g; /([^: ]+):(\d+),(\d+)-(\d+),(\d+)(?:f([\d.]+))?([nsew])?([tb])?([lcr])?/g;
let match; let match;
while ((match = regex.exec(layersStr)) !== null) { while ((match = regex.exec(layersStr)) !== null) {
@@ -24,7 +25,7 @@ export function parseLayers(layersStr: string): Layer[] {
y2: parseInt(match[5]), y2: parseInt(match[5]),
fontSize: match[6] ? parseFloat(match[6]) : undefined, fontSize: match[6] ? parseFloat(match[6]) : undefined,
orientation: match[7] as "n" | "s" | "e" | "w" | 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"],
}); });
} }
+18 -2
View File
@@ -4,6 +4,22 @@ export interface CardData {
export type CardSide = "front" | "back"; 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 type { CardShape } from "../../plotcutter/contour";
export interface Layer { export interface Layer {
@@ -17,7 +33,7 @@ export interface Layer {
y2: number; y2: number;
orientation?: "n" | "s" | "e" | "w"; orientation?: "n" | "s" | "e" | "w";
fontSize?: number; fontSize?: number;
align?: "l" | "c" | "r"; align?: Align;
} }
export interface LayerConfig { export interface LayerConfig {
@@ -32,7 +48,7 @@ export interface LayerConfig {
y2: number; y2: number;
orientation?: "n" | "s" | "e" | "w"; orientation?: "n" | "s" | "e" | "w";
fontSize?: number; fontSize?: number;
align?: "l" | "c" | "r"; align?: Align;
_key?: number; _key?: number;
} }