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 type { DeckStore } from "../hooks/deckStore";
|
||||
import {
|
||||
DragDropProvider,
|
||||
DragDropSensors,
|
||||
createSortable,
|
||||
maybeTransformStyle,
|
||||
SortableProvider,
|
||||
closestCenter,
|
||||
} from "@thisbeyond/solid-dnd";
|
||||
import alignLeftIcon from "./icons/align-left.png";
|
||||
import alignCenterIcon from "./icons/align-center.png";
|
||||
import alignRightIcon from "./icons/align-right.png";
|
||||
import type { DeckStore } from "../hooks/deckStore";
|
||||
import { LayerRow } from "./LayerRow";
|
||||
|
||||
export interface LayerEditorPanelProps {
|
||||
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) {
|
||||
const { store } = props;
|
||||
const [openDropdown, setOpenDropdown] = createSignal<string | null>(null);
|
||||
|
|
@ -65,84 +20,46 @@ function LayerEditorPanel(props: LayerEditorPanelProps) {
|
|||
let addMenuRef: HTMLDivElement | undefined;
|
||||
|
||||
const side = () => store.state.activeSide;
|
||||
|
||||
const currentLayerConfigs = () =>
|
||||
const layers = () =>
|
||||
side() === "front"
|
||||
? store.state.frontLayerConfigs
|
||||
: store.state.backLayerConfigs;
|
||||
|
||||
// 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 = () =>
|
||||
const updater = () =>
|
||||
side() === "front"
|
||||
? store.actions.updateFrontLayerConfig
|
||||
: 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,
|
||||
orientation: "n" | "s" | "e" | "w",
|
||||
patch: Partial<import("../types").LayerConfig>,
|
||||
closeDropdown = false,
|
||||
) => {
|
||||
updateFn()(index, { orientation });
|
||||
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);
|
||||
}
|
||||
updater()(index, patch);
|
||||
if (closeDropdown) setOpenDropdown(null);
|
||||
};
|
||||
|
||||
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 (
|
||||
<div class="w-64 flex-shrink-0">
|
||||
<h3 class="font-bold mb-2 mt-0">
|
||||
图层 ({side() === "front" ? "正面" : "背面"})
|
||||
</h3>
|
||||
|
||||
{/* Add layer dropdown */}
|
||||
<div class="relative mb-2" ref={addMenuRef}>
|
||||
<button
|
||||
onClick={() => setAddMenuOpen(!addMenuOpen())}
|
||||
|
|
@ -160,15 +77,15 @@ function LayerEditorPanel(props: LayerEditorPanelProps) {
|
|||
<div class="px-3 py-2 text-sm text-gray-400">所有字段已添加</div>
|
||||
) : (
|
||||
<For each={availableFields()}>
|
||||
{(field) => (
|
||||
{(f) => (
|
||||
<button
|
||||
onClick={() => {
|
||||
store.actions.addLayer(side(), field);
|
||||
store.actions.addLayer(side(), f);
|
||||
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"
|
||||
>
|
||||
{field}
|
||||
{f}
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
|
|
@ -177,32 +94,48 @@ function LayerEditorPanel(props: LayerEditorPanelProps) {
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* Drag and drop list */}
|
||||
<DragDropProvider onDragEnd={onDragEnd} collisionDetector={closestCenter}>
|
||||
<DragDropProvider
|
||||
onDragEnd={({ draggable, droppable }) => {
|
||||
if (droppable)
|
||||
store.actions.reorderLayers(
|
||||
side(),
|
||||
draggable.id as number,
|
||||
droppable.id as number,
|
||||
);
|
||||
}}
|
||||
collisionDetector={closestCenter}
|
||||
>
|
||||
<DragDropSensors>
|
||||
<For each={currentLayerConfigs()}>
|
||||
{(layer, index) => (
|
||||
<LayerRow
|
||||
store={store}
|
||||
index={index()}
|
||||
layer={layer}
|
||||
hovered={hoveredIndex() === index()}
|
||||
setHoveredIndex={setHoveredIndex}
|
||||
openDropdown={openDropdown()}
|
||||
setOpenDropdown={setOpenDropdown}
|
||||
updateOrientation={(o) => updateLayerOrientation(index(), o)}
|
||||
updateFontSize={(fs) => updateLayerFontSize(index(), fs)}
|
||||
updateAlign={(a) => updateLayerAlign(index(), a)}
|
||||
onSelect={() => selectLayer(index())}
|
||||
onRemove={() => store.actions.removeLayer(side(), index())}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
<SortableProvider ids={layers().map((_, i) => i)}>
|
||||
<For each={layers()}>
|
||||
{(layer, index) => (
|
||||
<LayerRow
|
||||
store={store}
|
||||
index={index()}
|
||||
layer={layer}
|
||||
hovered={hoveredIndex() === index()}
|
||||
onHoverChange={setHoveredIndex}
|
||||
openDropdown={openDropdown()}
|
||||
setOpenDropdown={setOpenDropdown}
|
||||
onUpdateOrientation={(o) =>
|
||||
update(index(), { orientation: o }, true)
|
||||
}
|
||||
onUpdateFontSize={(fs) => update(index(), { fontSize: fs })}
|
||||
onUpdateAlign={(a) => update(index(), { align: a }, true)}
|
||||
onSelect={() =>
|
||||
store.actions.setSelectedLayer(
|
||||
store.state.selectedLayer === index() ? null : index(),
|
||||
)
|
||||
}
|
||||
onRemove={() => store.actions.removeLayer(side(), index())}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</SortableProvider>
|
||||
</DragDropSensors>
|
||||
</DragDropProvider>
|
||||
|
||||
<hr class="my-4" />
|
||||
|
||||
<button
|
||||
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"
|
||||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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 { calculateDimensions } from "./dimensions";
|
||||
import { loadCSV, CSV } from "../../utils/csv-loader";
|
||||
import {
|
||||
initLayerConfigs,
|
||||
formatLayers,
|
||||
initLayerConfigsForSide,
|
||||
} from "./layer-parser";
|
||||
import { formatLayers, initLayerConfigsForSide } from "./layer-parser";
|
||||
import * as layerCrud from "./layer-crud";
|
||||
import type {
|
||||
CardData,
|
||||
LayerConfig,
|
||||
|
|
@ -290,53 +287,41 @@ export function createDeckStore(initialSrc: string = ""): DeckStore {
|
|||
};
|
||||
|
||||
const addLayer = (side: CardSide, prop: string) => {
|
||||
const newLayer: LayerConfig = {
|
||||
prop,
|
||||
visible: true,
|
||||
x1: 1,
|
||||
y1: 1,
|
||||
x2: 2,
|
||||
y2: 2,
|
||||
};
|
||||
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 configs = getLayerConfigs(side);
|
||||
setLayerConfigs(
|
||||
side,
|
||||
configs.filter((_, i) => i !== index),
|
||||
);
|
||||
if (state.selectedLayer === index) {
|
||||
setState({ selectedLayer: null });
|
||||
} else if (
|
||||
state.selectedLayer !== null &&
|
||||
(state.selectedLayer as number) > index
|
||||
) {
|
||||
setState({ selectedLayer: (state.selectedLayer as number) - 1 });
|
||||
}
|
||||
setLayerConfigs(side, layerCrud.removeLayer(getLayerConfigs(side), index));
|
||||
setState({
|
||||
selectedLayer: layerCrud.adjustSelectedLayer(
|
||||
state.selectedLayer,
|
||||
"remove",
|
||||
index,
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
const reorderLayers = (
|
||||
side: CardSide,
|
||||
fromIndex: number,
|
||||
toIndex: number,
|
||||
) => {
|
||||
const configs = [...getLayerConfigs(side)];
|
||||
const [item] = configs.splice(fromIndex, 1);
|
||||
configs.splice(toIndex, 0, item);
|
||||
setLayerConfigs(side, configs);
|
||||
if (state.selectedLayer === fromIndex) {
|
||||
setState({ selectedLayer: toIndex });
|
||||
} 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 reorderLayers = (side: CardSide, from: number, to: number) => {
|
||||
setLayerConfigs(
|
||||
side,
|
||||
layerCrud.reorderLayers(getLayerConfigs(side), from, to),
|
||||
);
|
||||
setState({
|
||||
selectedLayer: layerCrud.adjustSelectedLayer(
|
||||
state.selectedLayer,
|
||||
"reorder",
|
||||
from,
|
||||
to,
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
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