refactor(editor-panel): extract LayerRow and layer crud logic
This commit is contained in:
parent
42c7c9a26e
commit
b3d390dfd5
|
|
@ -1,62 +1,17 @@
|
||||||
import { For, createSignal, createMemo, onCleanup, onMount } from "solid-js";
|
import { For, createSignal, createMemo, onCleanup, onMount } from "solid-js";
|
||||||
import type { DeckStore } from "../hooks/deckStore";
|
|
||||||
import {
|
import {
|
||||||
DragDropProvider,
|
DragDropProvider,
|
||||||
DragDropSensors,
|
DragDropSensors,
|
||||||
createSortable,
|
SortableProvider,
|
||||||
maybeTransformStyle,
|
|
||||||
closestCenter,
|
closestCenter,
|
||||||
} from "@thisbeyond/solid-dnd";
|
} from "@thisbeyond/solid-dnd";
|
||||||
import alignLeftIcon from "./icons/align-left.png";
|
import type { DeckStore } from "../hooks/deckStore";
|
||||||
import alignCenterIcon from "./icons/align-center.png";
|
import { LayerRow } from "./LayerRow";
|
||||||
import alignRightIcon from "./icons/align-right.png";
|
|
||||||
|
|
||||||
export interface LayerEditorPanelProps {
|
export interface LayerEditorPanelProps {
|
||||||
store: DeckStore;
|
store: DeckStore;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ORIENTATION_OPTIONS = [
|
|
||||||
{ value: "n", label: "↑ 北" },
|
|
||||||
{ value: "e", label: "→ 东" },
|
|
||||||
{ value: "s", label: "↓ 南" },
|
|
||||||
{ value: "w", label: "← 西" },
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
const ALIGN_OPTIONS = [
|
|
||||||
{ value: "", label: "默认", icon: alignCenterIcon },
|
|
||||||
{ value: "l", label: "左对齐", icon: alignLeftIcon },
|
|
||||||
{ value: "c", label: "居中", icon: alignCenterIcon },
|
|
||||||
{ value: "r", label: "右对齐", icon: alignRightIcon },
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
const FONT_PRESETS = [3, 5, 8, 12] as const;
|
|
||||||
|
|
||||||
function OrientationIcon(value: string): string {
|
|
||||||
switch (value) {
|
|
||||||
case "n":
|
|
||||||
return "↑";
|
|
||||||
case "e":
|
|
||||||
return "→";
|
|
||||||
case "s":
|
|
||||||
return "↓";
|
|
||||||
case "w":
|
|
||||||
return "←";
|
|
||||||
default:
|
|
||||||
return "↑";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function AlignIconSrc(value: string): string {
|
|
||||||
switch (value) {
|
|
||||||
case "l":
|
|
||||||
return alignLeftIcon;
|
|
||||||
case "r":
|
|
||||||
return alignRightIcon;
|
|
||||||
default:
|
|
||||||
return alignCenterIcon;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function LayerEditorPanel(props: LayerEditorPanelProps) {
|
function LayerEditorPanel(props: LayerEditorPanelProps) {
|
||||||
const { store } = props;
|
const { store } = props;
|
||||||
const [openDropdown, setOpenDropdown] = createSignal<string | null>(null);
|
const [openDropdown, setOpenDropdown] = createSignal<string | null>(null);
|
||||||
|
|
@ -65,84 +20,46 @@ function LayerEditorPanel(props: LayerEditorPanelProps) {
|
||||||
let addMenuRef: HTMLDivElement | undefined;
|
let addMenuRef: HTMLDivElement | undefined;
|
||||||
|
|
||||||
const side = () => store.state.activeSide;
|
const side = () => store.state.activeSide;
|
||||||
|
const layers = () =>
|
||||||
const currentLayerConfigs = () =>
|
|
||||||
side() === "front"
|
side() === "front"
|
||||||
? store.state.frontLayerConfigs
|
? store.state.frontLayerConfigs
|
||||||
: store.state.backLayerConfigs;
|
: store.state.backLayerConfigs;
|
||||||
|
const updater = () =>
|
||||||
// Available CSV fields not yet used in the current side
|
|
||||||
const availableFields = createMemo(() => {
|
|
||||||
const cards = store.state.cards;
|
|
||||||
if (!cards || cards.length === 0) return [];
|
|
||||||
const usedProps = new Set(currentLayerConfigs().map((l) => l.prop));
|
|
||||||
return Object.keys(cards[0]).filter(
|
|
||||||
(k) => k !== "label" && !usedProps.has(k),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const updateFn = () =>
|
|
||||||
side() === "front"
|
side() === "front"
|
||||||
? store.actions.updateFrontLayerConfig
|
? store.actions.updateFrontLayerConfig
|
||||||
: store.actions.updateBackLayerConfig;
|
: store.actions.updateBackLayerConfig;
|
||||||
|
|
||||||
const updateLayerOrientation = (
|
const availableFields = createMemo(() => {
|
||||||
|
const cards = store.state.cards;
|
||||||
|
if (!cards?.length) return [];
|
||||||
|
const used = new Set(layers().map((l) => l.prop));
|
||||||
|
return Object.keys(cards[0]).filter((k) => k !== "label" && !used.has(k));
|
||||||
|
});
|
||||||
|
|
||||||
|
const update = (
|
||||||
index: number,
|
index: number,
|
||||||
orientation: "n" | "s" | "e" | "w",
|
patch: Partial<import("../types").LayerConfig>,
|
||||||
|
closeDropdown = false,
|
||||||
) => {
|
) => {
|
||||||
updateFn()(index, { orientation });
|
updater()(index, patch);
|
||||||
setOpenDropdown(null);
|
if (closeDropdown) setOpenDropdown(null);
|
||||||
};
|
|
||||||
|
|
||||||
const updateLayerFontSize = (index: number, fontSize?: number) => {
|
|
||||||
updateFn()(index, { fontSize });
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateLayerAlign = (index: number, align?: "l" | "c" | "r") => {
|
|
||||||
updateFn()(index, { align });
|
|
||||||
setOpenDropdown(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const selectLayer = (index: number) => {
|
|
||||||
store.actions.setSelectedLayer(
|
|
||||||
store.state.selectedLayer === index ? null : index,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleClickOutside = (e: MouseEvent) => {
|
|
||||||
if (addMenuRef && !addMenuRef.contains(e.target as Node)) {
|
|
||||||
setAddMenuOpen(false);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
document.addEventListener("click", handleClickOutside);
|
const h = (e: MouseEvent) => {
|
||||||
|
if (addMenuRef && !addMenuRef.contains(e.target as Node))
|
||||||
|
setAddMenuOpen(false);
|
||||||
|
};
|
||||||
|
document.addEventListener("click", h);
|
||||||
|
onCleanup(() => document.removeEventListener("click", h));
|
||||||
});
|
});
|
||||||
|
|
||||||
onCleanup(() => {
|
|
||||||
document.removeEventListener("click", handleClickOutside);
|
|
||||||
});
|
|
||||||
|
|
||||||
const onDragEnd: import("@thisbeyond/solid-dnd").DragEventHandler = ({
|
|
||||||
draggable,
|
|
||||||
droppable,
|
|
||||||
}) => {
|
|
||||||
if (droppable) {
|
|
||||||
const fromIndex = draggable.id as number;
|
|
||||||
const toIndex = droppable.id as number;
|
|
||||||
if (fromIndex !== toIndex) {
|
|
||||||
store.actions.reorderLayers(side(), fromIndex, toIndex);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="w-64 flex-shrink-0">
|
<div class="w-64 flex-shrink-0">
|
||||||
<h3 class="font-bold mb-2 mt-0">
|
<h3 class="font-bold mb-2 mt-0">
|
||||||
图层 ({side() === "front" ? "正面" : "背面"})
|
图层 ({side() === "front" ? "正面" : "背面"})
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
{/* Add layer dropdown */}
|
|
||||||
<div class="relative mb-2" ref={addMenuRef}>
|
<div class="relative mb-2" ref={addMenuRef}>
|
||||||
<button
|
<button
|
||||||
onClick={() => setAddMenuOpen(!addMenuOpen())}
|
onClick={() => setAddMenuOpen(!addMenuOpen())}
|
||||||
|
|
@ -160,15 +77,15 @@ function LayerEditorPanel(props: LayerEditorPanelProps) {
|
||||||
<div class="px-3 py-2 text-sm text-gray-400">所有字段已添加</div>
|
<div class="px-3 py-2 text-sm text-gray-400">所有字段已添加</div>
|
||||||
) : (
|
) : (
|
||||||
<For each={availableFields()}>
|
<For each={availableFields()}>
|
||||||
{(field) => (
|
{(f) => (
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
store.actions.addLayer(side(), field);
|
store.actions.addLayer(side(), f);
|
||||||
setAddMenuOpen(false);
|
setAddMenuOpen(false);
|
||||||
}}
|
}}
|
||||||
class="block w-full text-left px-3 py-1.5 text-sm hover:bg-blue-50 hover:text-blue-600 cursor-pointer"
|
class="block w-full text-left px-3 py-1.5 text-sm hover:bg-blue-50 hover:text-blue-600 cursor-pointer"
|
||||||
>
|
>
|
||||||
{field}
|
{f}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
|
|
@ -177,32 +94,48 @@ function LayerEditorPanel(props: LayerEditorPanelProps) {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Drag and drop list */}
|
<DragDropProvider
|
||||||
<DragDropProvider onDragEnd={onDragEnd} collisionDetector={closestCenter}>
|
onDragEnd={({ draggable, droppable }) => {
|
||||||
|
if (droppable)
|
||||||
|
store.actions.reorderLayers(
|
||||||
|
side(),
|
||||||
|
draggable.id as number,
|
||||||
|
droppable.id as number,
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
collisionDetector={closestCenter}
|
||||||
|
>
|
||||||
<DragDropSensors>
|
<DragDropSensors>
|
||||||
<For each={currentLayerConfigs()}>
|
<SortableProvider ids={layers().map((_, i) => i)}>
|
||||||
{(layer, index) => (
|
<For each={layers()}>
|
||||||
<LayerRow
|
{(layer, index) => (
|
||||||
store={store}
|
<LayerRow
|
||||||
index={index()}
|
store={store}
|
||||||
layer={layer}
|
index={index()}
|
||||||
hovered={hoveredIndex() === index()}
|
layer={layer}
|
||||||
setHoveredIndex={setHoveredIndex}
|
hovered={hoveredIndex() === index()}
|
||||||
openDropdown={openDropdown()}
|
onHoverChange={setHoveredIndex}
|
||||||
setOpenDropdown={setOpenDropdown}
|
openDropdown={openDropdown()}
|
||||||
updateOrientation={(o) => updateLayerOrientation(index(), o)}
|
setOpenDropdown={setOpenDropdown}
|
||||||
updateFontSize={(fs) => updateLayerFontSize(index(), fs)}
|
onUpdateOrientation={(o) =>
|
||||||
updateAlign={(a) => updateLayerAlign(index(), a)}
|
update(index(), { orientation: o }, true)
|
||||||
onSelect={() => selectLayer(index())}
|
}
|
||||||
onRemove={() => store.actions.removeLayer(side(), index())}
|
onUpdateFontSize={(fs) => update(index(), { fontSize: fs })}
|
||||||
/>
|
onUpdateAlign={(a) => update(index(), { align: a }, true)}
|
||||||
)}
|
onSelect={() =>
|
||||||
</For>
|
store.actions.setSelectedLayer(
|
||||||
|
store.state.selectedLayer === index() ? null : index(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onRemove={() => store.actions.removeLayer(side(), index())}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</SortableProvider>
|
||||||
</DragDropSensors>
|
</DragDropSensors>
|
||||||
</DragDropProvider>
|
</DragDropProvider>
|
||||||
|
|
||||||
<hr class="my-4" />
|
<hr class="my-4" />
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => store.actions.copyCode()}
|
onClick={() => store.actions.copyCode()}
|
||||||
class="w-full bg-blue-600 hover:bg-blue-700 text-white px-3 py-2 rounded text-sm font-medium cursor-pointer flex items-center gap-2 justify-center"
|
class="w-full bg-blue-600 hover:bg-blue-700 text-white px-3 py-2 rounded text-sm font-medium cursor-pointer flex items-center gap-2 justify-center"
|
||||||
|
|
@ -214,246 +147,4 @@ function LayerEditorPanel(props: LayerEditorPanelProps) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Sortable layer row ---
|
|
||||||
|
|
||||||
interface LayerRowProps {
|
|
||||||
store: DeckStore;
|
|
||||||
index: number;
|
|
||||||
layer: import("../types").LayerConfig;
|
|
||||||
hovered: boolean;
|
|
||||||
setHoveredIndex: (idx: number | null) => void;
|
|
||||||
openDropdown: string | null;
|
|
||||||
setOpenDropdown: (val: string | null) => void;
|
|
||||||
updateOrientation: (o: "n" | "s" | "e" | "w") => void;
|
|
||||||
updateFontSize: (fs?: number) => void;
|
|
||||||
updateAlign: (a?: "l" | "c" | "r") => void;
|
|
||||||
onSelect: () => void;
|
|
||||||
onRemove: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function LayerRow(props: LayerRowProps) {
|
|
||||||
const sortable = createSortable(props.index);
|
|
||||||
const [fontOpen, setFontOpen] = createSignal(false);
|
|
||||||
let fontDropdownRef: HTMLDivElement | undefined;
|
|
||||||
|
|
||||||
const selected = () => props.store.state.selectedLayer === props.index;
|
|
||||||
|
|
||||||
const handleFontClickOutside = (e: MouseEvent) => {
|
|
||||||
if (fontDropdownRef && !fontDropdownRef.contains(e.target as Node)) {
|
|
||||||
setFontOpen(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
document.addEventListener("click", handleFontClickOutside);
|
|
||||||
});
|
|
||||||
|
|
||||||
onCleanup(() => {
|
|
||||||
document.removeEventListener("click", handleFontClickOutside);
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={sortable.ref}
|
|
||||||
style={maybeTransformStyle(sortable.transform)}
|
|
||||||
class={`flex items-center gap-1 py-1.5 px-1 relative group border-b border-gray-100 ${
|
|
||||||
selected() ? "bg-blue-50" : ""
|
|
||||||
}`}
|
|
||||||
onMouseEnter={() => props.setHoveredIndex(props.index)}
|
|
||||||
onMouseLeave={() => props.setHoveredIndex(null)}
|
|
||||||
>
|
|
||||||
{/* Drag handle */}
|
|
||||||
<button
|
|
||||||
{...sortable.dragActivators}
|
|
||||||
class="cursor-grab text-gray-400 hover:text-gray-600 text-sm px-0.5 select-none shrink-0"
|
|
||||||
title="拖动排序"
|
|
||||||
>
|
|
||||||
⠿
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Visibility toggle */}
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
const toggleFn =
|
|
||||||
props.store.state.activeSide === "front"
|
|
||||||
? props.store.actions.toggleFrontLayerVisible
|
|
||||||
: props.store.actions.toggleBackLayerVisible;
|
|
||||||
toggleFn(props.index);
|
|
||||||
}}
|
|
||||||
class="shrink-0 text-sm cursor-pointer select-none"
|
|
||||||
title={props.layer.visible ? "隐藏" : "显示"}
|
|
||||||
>
|
|
||||||
{props.layer.visible ? "👁" : "👁🗨"}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Layer name */}
|
|
||||||
<span
|
|
||||||
class="text-sm flex-1 truncate cursor-pointer hover:text-blue-600 select-none"
|
|
||||||
onClick={props.onSelect}
|
|
||||||
>
|
|
||||||
{props.layer.prop}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{/* Orientation */}
|
|
||||||
<div class="relative">
|
|
||||||
<button
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
props.setOpenDropdown(
|
|
||||||
props.openDropdown === `orient-${props.index}`
|
|
||||||
? null
|
|
||||||
: `orient-${props.index}`,
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
class={`w-7 h-7 text-sm 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="方向"
|
|
||||||
>
|
|
||||||
{OrientationIcon(props.layer.orientation || "n")}
|
|
||||||
</button>
|
|
||||||
{props.openDropdown === `orient-${props.index}` && (
|
|
||||||
<div
|
|
||||||
class="absolute top-full right-0 mt-1 bg-white border border-gray-300 rounded shadow-lg z-10"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<For each={ORIENTATION_OPTIONS}>
|
|
||||||
{(opt) => (
|
|
||||||
<button
|
|
||||||
onClick={() => props.updateOrientation(opt.value)}
|
|
||||||
class="block w-full text-left px-3 py-1.5 text-sm hover:bg-gray-100 cursor-pointer whitespace-nowrap"
|
|
||||||
>
|
|
||||||
{opt.label}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Align */}
|
|
||||||
<div class="relative">
|
|
||||||
<button
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
props.setOpenDropdown(
|
|
||||||
props.openDropdown === `align-${props.index}`
|
|
||||||
? null
|
|
||||||
: `align-${props.index}`,
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
class={`w-7 h-7 rounded cursor-pointer flex items-center justify-center bg-gray-200 hover:bg-gray-300 ${
|
|
||||||
!props.layer.visible ? "invisible pointer-events-none" : ""
|
|
||||||
}`}
|
|
||||||
title="对齐"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
src={AlignIconSrc(props.layer.align || "")}
|
|
||||||
alt="align"
|
|
||||||
class="w-5 h-5 not-prose"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
{props.openDropdown === `align-${props.index}` && (
|
|
||||||
<div
|
|
||||||
class="absolute top-full right-0 mt-1 bg-white border border-gray-300 rounded shadow-lg z-10"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<For each={ALIGN_OPTIONS}>
|
|
||||||
{(opt) => (
|
|
||||||
<button
|
|
||||||
onClick={() =>
|
|
||||||
props.updateAlign(
|
|
||||||
(opt.value as "l" | "c" | "r" | undefined) || 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={opt.icon}
|
|
||||||
alt=""
|
|
||||||
class="w-4 h-4 not-prose max-w-none"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Font size */}
|
|
||||||
<div class="relative" ref={fontDropdownRef}>
|
|
||||||
<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) => {
|
|
||||||
const value = e.target.value;
|
|
||||||
props.updateFontSize(value ? Number(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}>
|
|
||||||
{(preset) => (
|
|
||||||
<button
|
|
||||||
onClick={() => props.updateFontSize(preset)}
|
|
||||||
class={`px-2 py-1 text-xs rounded cursor-pointer ${
|
|
||||||
(props.layer.fontSize ?? 3) === preset
|
|
||||||
? "bg-blue-500 text-white"
|
|
||||||
: "bg-gray-100 hover:bg-gray-200 text-gray-700"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{preset}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => props.updateFontSize(undefined)}
|
|
||||||
class="mt-2 w-full text-xs text-gray-500 hover:text-gray-700 cursor-pointer"
|
|
||||||
>
|
|
||||||
重置
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Delete (visible on hover) */}
|
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export { LayerEditorPanel };
|
export { LayerEditorPanel };
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,154 @@
|
||||||
|
import { For, createSignal, onCleanup, onMount } from "solid-js";
|
||||||
|
import { createSortable, maybeTransformStyle } 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;
|
||||||
|
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.index);
|
||||||
|
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
|
||||||
|
ref={sortable.ref}
|
||||||
|
style={maybeTransformStyle(sortable.transform)}
|
||||||
|
class={`flex items-center gap-1 py-1.5 px-1 relative group border-b border-gray-100 ${selected() ? "bg-blue-50" : ""}`}
|
||||||
|
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}
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,11 +1,8 @@
|
||||||
import { createStore } from "solid-js/store";
|
import { createStore } from "solid-js/store";
|
||||||
import { calculateDimensions } from "./dimensions";
|
import { calculateDimensions } from "./dimensions";
|
||||||
import { loadCSV, CSV } from "../../utils/csv-loader";
|
import { loadCSV, CSV } from "../../utils/csv-loader";
|
||||||
import {
|
import { formatLayers, initLayerConfigsForSide } from "./layer-parser";
|
||||||
initLayerConfigs,
|
import * as layerCrud from "./layer-crud";
|
||||||
formatLayers,
|
|
||||||
initLayerConfigsForSide,
|
|
||||||
} from "./layer-parser";
|
|
||||||
import type {
|
import type {
|
||||||
CardData,
|
CardData,
|
||||||
LayerConfig,
|
LayerConfig,
|
||||||
|
|
@ -290,53 +287,41 @@ export function createDeckStore(initialSrc: string = ""): DeckStore {
|
||||||
};
|
};
|
||||||
|
|
||||||
const addLayer = (side: CardSide, prop: string) => {
|
const addLayer = (side: CardSide, prop: string) => {
|
||||||
const newLayer: LayerConfig = {
|
|
||||||
prop,
|
|
||||||
visible: true,
|
|
||||||
x1: 1,
|
|
||||||
y1: 1,
|
|
||||||
x2: 2,
|
|
||||||
y2: 2,
|
|
||||||
};
|
|
||||||
const configs = getLayerConfigs(side);
|
const configs = getLayerConfigs(side);
|
||||||
setLayerConfigs(side, [...configs, newLayer]);
|
setLayerConfigs(side, layerCrud.addLayer(configs, prop));
|
||||||
|
setState({
|
||||||
|
selectedLayer: layerCrud.adjustSelectedLayer(
|
||||||
|
state.selectedLayer,
|
||||||
|
"add",
|
||||||
|
configs.length,
|
||||||
|
),
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeLayer = (side: CardSide, index: number) => {
|
const removeLayer = (side: CardSide, index: number) => {
|
||||||
const configs = getLayerConfigs(side);
|
setLayerConfigs(side, layerCrud.removeLayer(getLayerConfigs(side), index));
|
||||||
setLayerConfigs(
|
setState({
|
||||||
side,
|
selectedLayer: layerCrud.adjustSelectedLayer(
|
||||||
configs.filter((_, i) => i !== index),
|
state.selectedLayer,
|
||||||
);
|
"remove",
|
||||||
if (state.selectedLayer === index) {
|
index,
|
||||||
setState({ selectedLayer: null });
|
),
|
||||||
} else if (
|
});
|
||||||
state.selectedLayer !== null &&
|
|
||||||
(state.selectedLayer as number) > index
|
|
||||||
) {
|
|
||||||
setState({ selectedLayer: (state.selectedLayer as number) - 1 });
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const reorderLayers = (
|
const reorderLayers = (side: CardSide, from: number, to: number) => {
|
||||||
side: CardSide,
|
setLayerConfigs(
|
||||||
fromIndex: number,
|
side,
|
||||||
toIndex: number,
|
layerCrud.reorderLayers(getLayerConfigs(side), from, to),
|
||||||
) => {
|
);
|
||||||
const configs = [...getLayerConfigs(side)];
|
setState({
|
||||||
const [item] = configs.splice(fromIndex, 1);
|
selectedLayer: layerCrud.adjustSelectedLayer(
|
||||||
configs.splice(toIndex, 0, item);
|
state.selectedLayer,
|
||||||
setLayerConfigs(side, configs);
|
"reorder",
|
||||||
if (state.selectedLayer === fromIndex) {
|
from,
|
||||||
setState({ selectedLayer: toIndex });
|
to,
|
||||||
} else if (state.selectedLayer !== null) {
|
),
|
||||||
const sel = state.selectedLayer as number;
|
});
|
||||||
if (fromIndex < toIndex && sel > fromIndex && sel <= toIndex) {
|
|
||||||
setState({ selectedLayer: sel - 1 });
|
|
||||||
} else if (fromIndex > toIndex && sel >= toIndex && sel < fromIndex) {
|
|
||||||
setState({ selectedLayer: sel + 1 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const setIsEditing = (editing: boolean) => setState({ isEditing: editing });
|
const setIsEditing = (editing: boolean) => setState({ isEditing: editing });
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
import type { CardSide, LayerConfig } from "../types";
|
||||||
|
|
||||||
|
/** Creates a new LayerConfig with default placement. */
|
||||||
|
export function createDefaultLayer(prop: string): LayerConfig {
|
||||||
|
return { prop, visible: true, x1: 1, y1: 1, x2: 2, y2: 2 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Insert a layer and return the new array. */
|
||||||
|
export function addLayer(configs: LayerConfig[], prop: string): LayerConfig[] {
|
||||||
|
return [...configs, createDefaultLayer(prop)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove a layer by index and return the new array. */
|
||||||
|
export function removeLayer(configs: LayerConfig[], index: number): LayerConfig[] {
|
||||||
|
return configs.filter((_, i) => i !== index);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reorder a layer within the array. Returns new array. */
|
||||||
|
export function reorderLayers(configs: LayerConfig[], from: number, to: number): LayerConfig[] {
|
||||||
|
const next = [...configs];
|
||||||
|
const [item] = next.splice(from, 1);
|
||||||
|
next.splice(to, 0, item);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recalculate selectedLayer after a structural change (add/remove/reorder).
|
||||||
|
* Returns the new selectedLayer index, or null.
|
||||||
|
*/
|
||||||
|
export function adjustSelectedLayer(
|
||||||
|
currentSelected: number | null,
|
||||||
|
action: "add" | "remove" | "reorder",
|
||||||
|
from: number,
|
||||||
|
to?: number,
|
||||||
|
): number | null {
|
||||||
|
if (currentSelected === null) return null;
|
||||||
|
|
||||||
|
if (action === "remove") {
|
||||||
|
if (currentSelected === from) return null;
|
||||||
|
return currentSelected > from ? currentSelected - 1 : currentSelected;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === "reorder" && to !== undefined) {
|
||||||
|
if (currentSelected === from) return to;
|
||||||
|
if (from < to && currentSelected > from && currentSelected <= to) return currentSelected - 1;
|
||||||
|
if (from > to && currentSelected >= to && currentSelected < from) return currentSelected + 1;
|
||||||
|
return currentSelected;
|
||||||
|
}
|
||||||
|
|
||||||
|
// "add" — doesn't affect existing indices
|
||||||
|
return currentSelected;
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue