Files
ttrpg-tools/src/components/md-deck/editor-panel/LayerRow.tsx
T
hypercross 5fc2bc5262 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.
2026-09-03 10:17:44 +08:00

300 lines
9.2 KiB
TypeScript

import { For, createSignal, onCleanup, onMount } from "solid-js";
import { createSortable, useDragDropContext } from "@thisbeyond/solid-dnd";
import type { DeckStore } from "../hooks/deckStore";
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";
export interface LayerRowProps {
store: DeckStore;
index: number;
sortableId: string;
layer: LayerConfig;
hovered: boolean;
onHoverChange: (idx: number | null) => void;
openDropdown: string | null;
setOpenDropdown: (val: string | null) => void;
onUpdateOrientation: (o: "n" | "s" | "e" | "w") => void;
onUpdateFontSize: (fs?: number) => void;
onUpdateAlign: (a?: Align) => void;
onSelect: () => void;
onRemove: () => void;
}
const ORIENTATIONS = [
{ value: "n" as const, label: "↑ 北" },
{ value: "e" as const, label: "→ 东" },
{ value: "s" as const, label: "↓ 南" },
{ value: "w" as const, label: "← 西" },
];
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;
function orientChar(v: string) {
switch (v) {
case "n":
return "↑";
case "e":
return "→";
case "s":
return "↓";
case "w":
return "←";
default:
return "↑";
}
}
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 <img src={alignLeftIcon} alt="align" class="w-5 h-5 not-prose" />;
case "r":
return <img src={alignRightIcon} alt="align" class="w-5 h-5 not-prose" />;
default:
return (
<img src={alignCenterIcon} alt="align" class="w-5 h-5 not-prose" />
);
}
}
export function LayerRow(props: LayerRowProps) {
const sortable = createSortable(props.sortableId);
const [dndState] = useDragDropContext()!;
const [fontOpen, setFontOpen] = createSignal(false);
let fontRef: HTMLDivElement | undefined;
const selected = () => props.store.state.selectedLayer === props.index;
onMount(() => {
const h = (e: MouseEvent) => {
if (fontRef && !fontRef.contains(e.target as Node)) setFontOpen(false);
};
document.addEventListener("click", h);
onCleanup(() => document.removeEventListener("click", h));
});
const toggleVisible = () => {
const fn =
props.store.state.activeSide === "front"
? props.store.actions.toggleFrontLayerVisible
: props.store.actions.toggleBackLayerVisible;
fn(props.index);
};
return (
<div
use:sortable={sortable}
class={`flex items-center gap-1 py-1.5 px-1 relative group border-b border-gray-100 ${selected() ? "bg-blue-50" : ""}`}
classList={{
"opacity-25": sortable.isActiveDraggable,
"transition-transform":
!!dndState.active.draggable && !sortable.isActiveDraggable,
}}
onMouseEnter={() => props.onHoverChange(props.index)}
onMouseLeave={() => props.onHoverChange(null)}
>
<button
{...sortable.dragActivators}
class="cursor-grab text-gray-400 hover:text-gray-600 text-sm px-0.5 select-none shrink-0"
title="拖动排序"
>
</button>
<button
onClick={toggleVisible}
class="shrink-0 text-sm cursor-pointer select-none"
title={props.layer.visible ? "隐藏" : "显示"}
>
{props.layer.visible ? "👁" : "👁‍🗨"}
</button>
<span
class="text-sm flex-1 truncate cursor-pointer hover:text-blue-600 select-none"
onClick={props.onSelect}
>
{props.layer.prop || (props.layer.template ? "(模板)" : "")}
</span>
<DropdownButton
icon={orientChar(props.layer.orientation || "n")}
visible={props.layer.visible}
open={props.openDropdown === `orient-${props.index}`}
onToggle={() =>
props.setOpenDropdown(
props.openDropdown === `orient-${props.index}`
? null
: `orient-${props.index}`,
)
}
title="方向"
>
<For each={ORIENTATIONS}>
{(o) => (
<button
onClick={() => props.onUpdateOrientation(o.value)}
class="block w-full text-left px-3 py-1.5 text-sm hover:bg-gray-100 cursor-pointer whitespace-nowrap"
>
{o.label}
</button>
)}
</For>
</DropdownButton>
<DropdownButton
icon={alignIcon(props.layer.align || "")}
visible={props.layer.visible}
open={props.openDropdown === `align-${props.index}`}
onToggle={() =>
props.setOpenDropdown(
props.openDropdown === `align-${props.index}`
? null
: `align-${props.index}`,
)
}
title="对齐"
>
<For each={ALIGNS}>
{(o) => (
<button
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") ? (
<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>
)}
</For>
</DropdownButton>
<div class="relative" ref={fontRef}>
<button
onClick={(e) => {
e.stopPropagation();
setFontOpen(!fontOpen());
}}
class={`w-7 h-7 text-xs rounded cursor-pointer flex items-center justify-center bg-gray-200 text-gray-700 hover:bg-gray-300 ${!props.layer.visible ? "invisible pointer-events-none" : ""}`}
title="字体大小 (mm)"
>
{props.layer.fontSize ?? 3}
</button>
{fontOpen() && (
<div
class="absolute top-full right-0 mt-1 bg-white border border-gray-300 rounded shadow-lg z-10 p-2"
onClick={(e) => e.stopPropagation()}
>
<div class="flex items-center gap-1 mb-2">
<input
type="number"
value={props.layer.fontSize ?? 3}
onChange={(e) =>
props.onUpdateFontSize(
e.target.value ? Number(e.target.value) : undefined,
)
}
class="w-14 text-xs px-1.5 py-1 rounded border border-gray-300"
step="0.1"
min="0.1"
/>
<span class="text-xs text-gray-500">mm</span>
</div>
<div class="flex gap-1">
<For each={FONT_PRESETS}>
{(p) => (
<button
onClick={() => props.onUpdateFontSize(p)}
class={`px-2 py-1 text-xs rounded cursor-pointer ${(props.layer.fontSize ?? 3) === p ? "bg-blue-500 text-white" : "bg-gray-100 hover:bg-gray-200 text-gray-700"}`}
>
{p}
</button>
)}
</For>
</div>
<button
onClick={() => props.onUpdateFontSize(undefined)}
class="mt-2 w-full text-xs text-gray-500 hover:text-gray-700 cursor-pointer"
>
重置
</button>
</div>
)}
</div>
<button
onClick={(e) => {
e.stopPropagation();
props.onRemove();
}}
class={`shrink-0 w-5 h-5 text-xs rounded cursor-pointer flex items-center justify-center text-red-400 hover:text-red-600 hover:bg-red-50 transition-opacity ${props.hovered ? "opacity-100" : "opacity-0"}`}
title="删除图层"
>
</button>
</div>
);
}
function DropdownButton(props: {
icon: import("solid-js").JSX.Element;
visible: boolean;
open: boolean;
onToggle: () => void;
title: string;
children: import("solid-js").JSX.Element;
}) {
return (
<div class="relative">
<button
onClick={(e) => {
e.stopPropagation();
props.onToggle();
}}
class={`w-7 h-7 rounded cursor-pointer flex items-center justify-center bg-gray-200 hover:bg-gray-300 ${!props.visible ? "invisible pointer-events-none" : ""}`}
title={props.title}
>
{props.icon}
</button>
{props.open && (
<div
class="absolute top-full right-0 mt-1 bg-white border border-gray-300 rounded shadow-lg z-10"
onClick={(e) => e.stopPropagation()}
>
{props.children}
</div>
)}
</div>
);
}