refactor(editor-panel): extract LayerRow and layer crud logic

This commit is contained in:
hyper 2026-06-30 21:34:10 +08:00
parent 42c7c9a26e
commit b3d390dfd5
4 changed files with 300 additions and 418 deletions

View File

@ -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 };

View File

@ -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>
);
}

View File

@ -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 });

View File

@ -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;
}