diff --git a/src/components/md-deck/editor-panel/LayerEditorPanel.tsx b/src/components/md-deck/editor-panel/LayerEditorPanel.tsx index 97a4ca5..8575dad 100644 --- a/src/components/md-deck/editor-panel/LayerEditorPanel.tsx +++ b/src/components/md-deck/editor-panel/LayerEditorPanel.tsx @@ -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(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, + 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 (

图层 ({side() === "front" ? "正面" : "背面"})

- {/* Add layer dropdown */}
)} @@ -177,32 +94,48 @@ function LayerEditorPanel(props: LayerEditorPanelProps) { )}
- {/* Drag and drop list */} - + { + if (droppable) + store.actions.reorderLayers( + side(), + draggable.id as number, + droppable.id as number, + ); + }} + collisionDetector={closestCenter} + > - - {(layer, index) => ( - updateLayerOrientation(index(), o)} - updateFontSize={(fs) => updateLayerFontSize(index(), fs)} - updateAlign={(a) => updateLayerAlign(index(), a)} - onSelect={() => selectLayer(index())} - onRemove={() => store.actions.removeLayer(side(), index())} - /> - )} - + i)}> + + {(layer, index) => ( + + 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())} + /> + )} + +
- - - {/* Visibility toggle */} - - - {/* Layer name */} - - {props.layer.prop} - - - {/* Orientation */} -
- - {props.openDropdown === `orient-${props.index}` && ( -
e.stopPropagation()} - > - - {(opt) => ( - - )} - -
- )} -
- - {/* Align */} -
- - {props.openDropdown === `align-${props.index}` && ( -
e.stopPropagation()} - > - - {(opt) => ( - - )} - -
- )} -
- - {/* Font size */} -
- - {fontOpen() && ( -
e.stopPropagation()} - > -
- { - 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" - /> - mm -
-
- - {(preset) => ( - - )} - -
- -
- )} -
- - {/* Delete (visible on hover) */} - -
- ); -} - export { LayerEditorPanel }; diff --git a/src/components/md-deck/editor-panel/LayerRow.tsx b/src/components/md-deck/editor-panel/LayerRow.tsx new file mode 100644 index 0000000..0ad10b3 --- /dev/null +++ b/src/components/md-deck/editor-panel/LayerRow.tsx @@ -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 ( +
props.onHoverChange(props.index)} + onMouseLeave={() => props.onHoverChange(null)} + > + + + + + + {props.layer.prop} + + + props.setOpenDropdown(props.openDropdown === `orient-${props.index}` ? null : `orient-${props.index}`)} + title="方向" + > + + {(o) => } + + + + } + visible={props.layer.visible} + open={props.openDropdown === `align-${props.index}`} + onToggle={() => props.setOpenDropdown(props.openDropdown === `align-${props.index}` ? null : `align-${props.index}`)} + title="对齐" + > + + {(o) => } + + + +
+ + {fontOpen() && ( +
e.stopPropagation()}> +
+ 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" /> + mm +
+
+ + {(p) => } + +
+ +
+ )} +
+ + +
+ ); +} + +function DropdownButton(props: { + icon: import("solid-js").JSX.Element; + visible: boolean; + open: boolean; + onToggle: () => void; + title: string; + children: import("solid-js").JSX.Element; +}) { + return ( +
+ + {props.open &&
e.stopPropagation()}>{props.children}
} +
+ ); +} diff --git a/src/components/md-deck/hooks/deckStore.ts b/src/components/md-deck/hooks/deckStore.ts index ba64335..e99455a 100644 --- a/src/components/md-deck/hooks/deckStore.ts +++ b/src/components/md-deck/hooks/deckStore.ts @@ -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 }); diff --git a/src/components/md-deck/hooks/layer-crud.ts b/src/components/md-deck/hooks/layer-crud.ts new file mode 100644 index 0000000..0b03d8e --- /dev/null +++ b/src/components/md-deck/hooks/layer-crud.ts @@ -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; +}