fix(layer-editor): use stable _key for drag-and-drop sorting

Add a unique `_key` to each layer config to maintain stable sortable IDs
across reorder operations. Update LayerRow to use the sortableId prop
and the `use:sortable` directive. Refactor layer-crud utilities to
generate and propagate `_key` values on creation and initialization.
This commit is contained in:
hyper 2026-07-03 21:49:18 +08:00
parent 186e3c0db6
commit 0add3f27d5
7 changed files with 86 additions and 30 deletions

View File

@ -2,6 +2,8 @@
:md-deck[./names.csv]{size="54x86" grid="5x8" bleed="1" padding="2" layers="name1:1,1-2,2f12 name2:4,7-5,8f12s num1:1,3-2,4f12 num2:4,5-5,6f12s"} :md-deck[./names.csv]{size="54x86" grid="5x8" bleed="1" padding="2" layers="name1:1,1-2,2f12 name2:4,7-5,8f12s num1:1,3-2,4f12 num2:4,5-5,6f12s"}
:md-deck[./names.csv]{size="54x86" grid="5x8" layers="num1:1,1-2,2 num2:4,5-5,6f12s name1:1,1-2,2 name2:1,1-2,2 name1:1,1-2,2" }
## 使用 yaml/tag 语法创建 md-deck ## 使用 yaml/tag 语法创建 md-deck
```yaml/tag ```yaml/tag
@ -12,4 +14,4 @@ grid: 5x8
bleed: 1 bleed: 1
padding: 2 padding: 2
layers: name1:1,1-2,2f12 name2:4,7-5,8f12s num1:1,3-2,4f12 num2:4,5-5,6f12s layers: name1:1,1-2,2f12 name2:4,7-5,8f12s num1:1,3-2,4f12 num2:4,5-5,6f12s
``` ```

View File

@ -3,10 +3,10 @@ import {
DragDropProvider, DragDropProvider,
DragDropSensors, DragDropSensors,
SortableProvider, SortableProvider,
useDragDropContext,
closestCenter, closestCenter,
} from "@thisbeyond/solid-dnd"; } from "@thisbeyond/solid-dnd";
import type { DeckStore } from "../hooks/deckStore"; import type { DeckStore } from "../hooks/deckStore";
import { toSortableId } from "../hooks/layer-crud";
import { LayerRow } from "./LayerRow"; import { LayerRow } from "./LayerRow";
export interface LayerEditorPanelProps { export interface LayerEditorPanelProps {
@ -26,8 +26,16 @@ function LayerEditorPanel(props: LayerEditorPanelProps) {
? store.state.frontLayerConfigs ? store.state.frontLayerConfigs
: store.state.backLayerConfigs; : store.state.backLayerConfigs;
// Stable IDs for SortableProvider — simply the indices // Stable IDs from _key — don't change on reorder
const ids = createMemo(() => layers().map((_, i) => i)); const sortableIds = createMemo(() =>
layers().map((l, i) => toSortableId(l, i)),
);
// Lookup: sortable string ID → current array index
const idToIndex = createMemo(() => {
const map = new Map<string, number>();
layers().forEach((l, i) => map.set(toSortableId(l, i), i));
return map;
});
const updater = () => const updater = () =>
side() === "front" side() === "front"
@ -96,23 +104,23 @@ function LayerEditorPanel(props: LayerEditorPanelProps) {
<DragDropProvider <DragDropProvider
onDragEnd={({ draggable, droppable }) => { onDragEnd={({ draggable, droppable }) => {
if (droppable && draggable.id !== droppable.id) { if (!droppable) return;
store.actions.reorderLayers( const from = idToIndex().get(draggable.id as string);
side(), const to = idToIndex().get(droppable.id as string);
draggable.id as number, if (from !== undefined && to !== undefined && from !== to) {
droppable.id as number, store.actions.reorderLayers(side(), from, to);
);
} }
}} }}
collisionDetector={closestCenter} collisionDetector={closestCenter}
> >
<DragDropSensors> <DragDropSensors>
<SortableProvider ids={ids()}> <SortableProvider ids={sortableIds()}>
<For each={layers()}> <For each={layers()}>
{(layer, index) => ( {(layer, index) => (
<LayerRow <LayerRow
store={store} store={store}
index={index()} index={index()}
sortableId={toSortableId(layer, index())}
layer={layer} layer={layer}
hovered={hoveredIndex() === index()} hovered={hoveredIndex() === index()}
onHoverChange={setHoveredIndex} onHoverChange={setHoveredIndex}

View File

@ -9,6 +9,7 @@ import alignRightIcon from "./icons/align-right.png";
export interface LayerRowProps { export interface LayerRowProps {
store: DeckStore; store: DeckStore;
index: number; index: number;
sortableId: string;
layer: LayerConfig; layer: LayerConfig;
hovered: boolean; hovered: boolean;
onHoverChange: (idx: number | null) => void; onHoverChange: (idx: number | null) => void;
@ -64,7 +65,7 @@ function alignSrc(v: string) {
} }
export function LayerRow(props: LayerRowProps) { export function LayerRow(props: LayerRowProps) {
const sortable = createSortable(props.index); const sortable = createSortable(props.sortableId);
const [dndState] = useDragDropContext()!; const [dndState] = useDragDropContext()!;
const [fontOpen, setFontOpen] = createSignal(false); const [fontOpen, setFontOpen] = createSignal(false);
let fontRef: HTMLDivElement | undefined; let fontRef: HTMLDivElement | undefined;
@ -89,11 +90,12 @@ export function LayerRow(props: LayerRowProps) {
return ( return (
<div <div
ref={sortable} use:sortable={sortable}
class={`flex items-center gap-1 py-1.5 px-1 relative group border-b border-gray-100 ${selected() ? "bg-blue-50" : ""}`} class={`flex items-center gap-1 py-1.5 px-1 relative group border-b border-gray-100 ${selected() ? "bg-blue-50" : ""}`}
classList={{ classList={{
"opacity-25": sortable.isActiveDraggable, "opacity-25": sortable.isActiveDraggable,
"transition-transform": !!dndState.active.draggable, "transition-transform":
!!dndState.active.draggable && !sortable.isActiveDraggable,
}} }}
onMouseEnter={() => props.onHoverChange(props.index)} onMouseEnter={() => props.onHoverChange(props.index)}
onMouseLeave={() => props.onHoverChange(null)} onMouseLeave={() => props.onHoverChange(null)}

View File

@ -463,8 +463,12 @@ export function createDeckStore(initialSrc: string = ""): DeckStore {
setState({ setState({
cards: data, cards: data,
activeTab: 0, activeTab: 0,
frontLayerConfigs: initLayerConfigsForSide(data, layersStr), frontLayerConfigs: layerCrud.withKeys(
backLayerConfigs: initLayerConfigsForSide(data, backLayersStr), initLayerConfigsForSide(data, layersStr),
),
backLayerConfigs: layerCrud.withKeys(
initLayerConfigsForSide(data, backLayersStr),
),
isLoading: false, isLoading: false,
}); });
updateDimensions(); updateDimensions();

View File

@ -1,8 +1,19 @@
import type { CardSide, LayerConfig } from "../types"; import type { LayerConfig } from "../types";
/** Creates a new LayerConfig with default placement. */ let nextKey = 1;
export function resetKeyCounter(value = 1) {
nextKey = value;
}
/** Creates a new LayerConfig with default placement and a stable _key. */
export function createDefaultLayer(prop: string): LayerConfig { export function createDefaultLayer(prop: string): LayerConfig {
return { prop, visible: true, x1: 1, y1: 1, x2: 2, y2: 2 }; return { prop, visible: true, x1: 1, y1: 1, x2: 2, y2: 2, _key: nextKey++ };
}
/** Assign stable _key to layers that don't have one (e.g. parsed from string). */
export function withKeys(layers: LayerConfig[]): LayerConfig[] {
return layers.map((l) => (l._key ? l : { ...l, _key: nextKey++ }));
} }
/** Insert a layer and return the new array. */ /** Insert a layer and return the new array. */
@ -11,18 +22,33 @@ export function addLayer(configs: LayerConfig[], prop: string): LayerConfig[] {
} }
/** Remove a layer by index and return the new array. */ /** Remove a layer by index and return the new array. */
export function removeLayer(configs: LayerConfig[], index: number): LayerConfig[] { export function removeLayer(
configs: LayerConfig[],
index: number,
): LayerConfig[] {
return configs.filter((_, i) => i !== index); return configs.filter((_, i) => i !== index);
} }
/** Reorder a layer within the array. Returns new array. */ /** Reorder a layer within the array. Returns new array. */
export function reorderLayers(configs: LayerConfig[], from: number, to: number): LayerConfig[] { export function reorderLayers(
configs: LayerConfig[],
from: number,
to: number,
): LayerConfig[] {
const next = [...configs]; const next = [...configs];
const [item] = next.splice(from, 1); const [item] = next.splice(from, 1);
next.splice(to, 0, item); next.splice(to, 0, item);
return next; return next;
} }
/**
* Convert layer _key to flake ID (unique string) for SortableProvider.
* Allows multiple instances of the same field name.
*/
export function toSortableId(layer: LayerConfig, index: number): string {
return layer._key ? `layer-${layer._key}` : `layer-idx-${index}`;
}
/** /**
* Recalculate selectedLayer after a structural change (add/remove/reorder). * Recalculate selectedLayer after a structural change (add/remove/reorder).
* Returns the new selectedLayer index, or null. * Returns the new selectedLayer index, or null.
@ -42,11 +68,12 @@ export function adjustSelectedLayer(
if (action === "reorder" && to !== undefined) { if (action === "reorder" && to !== undefined) {
if (currentSelected === from) return to; if (currentSelected === from) return to;
if (from < to && currentSelected > from && currentSelected <= to) return currentSelected - 1; if (from < to && currentSelected > from && currentSelected <= to)
if (from > to && currentSelected >= to && currentSelected < from) return currentSelected + 1; return currentSelected - 1;
if (from > to && currentSelected >= to && currentSelected < from)
return currentSelected + 1;
return currentSelected; return currentSelected;
} }
// "add" — doesn't affect existing indices
return currentSelected; return currentSelected;
} }

View File

@ -2,9 +2,9 @@ export interface CardData {
[key: string]: string; [key: string]: string;
} }
export type CardSide = 'front' | 'back'; export type CardSide = "front" | "back";
export type { CardShape } from '../../plotcutter/contour'; export type { CardShape } from "../../plotcutter/contour";
export interface Layer { export interface Layer {
prop: string; prop: string;
@ -12,9 +12,9 @@ export interface Layer {
y1: number; y1: number;
x2: number; x2: number;
y2: number; y2: number;
orientation?: 'n' | 's' | 'e' | 'w'; orientation?: "n" | "s" | "e" | "w";
fontSize?: number; fontSize?: number;
align?: 'l' | 'c' | 'r'; align?: "l" | "c" | "r";
} }
export interface LayerConfig { export interface LayerConfig {
@ -24,9 +24,10 @@ export interface LayerConfig {
y1: number; y1: number;
x2: number; x2: number;
y2: number; y2: number;
orientation?: 'n' | 's' | 'e' | 'w'; orientation?: "n" | "s" | "e" | "w";
fontSize?: number; fontSize?: number;
align?: 'l' | 'c' | 'r'; align?: "l" | "c" | "r";
_key?: number;
} }
export interface Dimensions { export interface Dimensions {

12
src/solid-dnd.directives.d.ts vendored Normal file
View File

@ -0,0 +1,12 @@
import "solid-js";
declare module "solid-js" {
namespace JSX {
interface Directives {
sortable: (
el: HTMLElement,
accessor: () => boolean | { skipTransform?: boolean } | undefined,
) => void;
}
}
}