Allow md-deck layers to render markdown templates via {{var}}
substitution instead of only CSV props. Add config.ts to normalize
a JSON data-config attribute (size, grid, shape, layers) emitted by
the yaml code-block tag, taking precedence over legacy string props.
280 lines
8.5 KiB
TypeScript
280 lines
8.5 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 { 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?: "l" | "c" | "r") => 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: "" as const, icon: alignCenterIcon },
|
|
{ value: "l" as const, icon: alignLeftIcon },
|
|
{ value: "c" as const, icon: alignCenterIcon },
|
|
{ value: "r" as const, icon: alignRightIcon },
|
|
];
|
|
|
|
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 alignSrc(v: string) {
|
|
switch (v) {
|
|
case "l":
|
|
return alignLeftIcon;
|
|
case "r":
|
|
return alignRightIcon;
|
|
default:
|
|
return alignCenterIcon;
|
|
}
|
|
}
|
|
|
|
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={
|
|
<img
|
|
src={alignSrc(props.layer.align || "")}
|
|
alt="align"
|
|
class="w-5 h-5 not-prose"
|
|
/>
|
|
}
|
|
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"
|
|
>
|
|
<img src={o.icon} alt="" class="w-4 h-4 not-prose max-w-none" />
|
|
</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>
|
|
);
|
|
}
|