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:
+1
-1
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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 ?? ""],
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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"
|
||||||
>
|
>
|
||||||
<img src={o.icon} alt="" class="w-4 h-4 not-prose max-w-none" />
|
{o.icon.endsWith(".png") ? (
|
||||||
|
<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>
|
||||||
|
|||||||
@@ -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"],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user