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" 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

View File

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

View File

@ -9,6 +9,7 @@ import alignRightIcon from "./icons/align-right.png";
export interface LayerRowProps {
store: DeckStore;
index: number;
sortableId: string;
layer: LayerConfig;
hovered: boolean;
onHoverChange: (idx: number | null) => void;
@ -64,7 +65,7 @@ function alignSrc(v: string) {
}
export function LayerRow(props: LayerRowProps) {
const sortable = createSortable(props.index);
const sortable = createSortable(props.sortableId);
const [dndState] = useDragDropContext()!;
const [fontOpen, setFontOpen] = createSignal(false);
let fontRef: HTMLDivElement | undefined;
@ -89,11 +90,12 @@ export function LayerRow(props: LayerRowProps) {
return (
<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" : ""}`}
classList={{
"opacity-25": sortable.isActiveDraggable,
"transition-transform": !!dndState.active.draggable,
"transition-transform":
!!dndState.active.draggable && !sortable.isActiveDraggable,
}}
onMouseEnter={() => props.onHoverChange(props.index)}
onMouseLeave={() => props.onHoverChange(null)}

View File

@ -463,8 +463,12 @@ export function createDeckStore(initialSrc: string = ""): DeckStore {
setState({
cards: data,
activeTab: 0,
frontLayerConfigs: initLayerConfigsForSide(data, layersStr),
backLayerConfigs: initLayerConfigsForSide(data, backLayersStr),
frontLayerConfigs: layerCrud.withKeys(
initLayerConfigsForSide(data, layersStr),
),
backLayerConfigs: layerCrud.withKeys(
initLayerConfigsForSide(data, backLayersStr),
),
isLoading: false,
});
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 {
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. */
@ -11,18 +22,33 @@ export function addLayer(configs: LayerConfig[], prop: string): LayerConfig[] {
}
/** 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);
}
/** 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 [item] = next.splice(from, 1);
next.splice(to, 0, item);
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).
* Returns the new selectedLayer index, or null.
@ -42,11 +68,12 @@ export function adjustSelectedLayer(
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;
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;
}

View File

@ -2,9 +2,9 @@ export interface CardData {
[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 {
prop: string;
@ -12,9 +12,9 @@ export interface Layer {
y1: number;
x2: number;
y2: number;
orientation?: 'n' | 's' | 'e' | 'w';
orientation?: "n" | "s" | "e" | "w";
fontSize?: number;
align?: 'l' | 'c' | 'r';
align?: "l" | "c" | "r";
}
export interface LayerConfig {
@ -24,9 +24,10 @@ export interface LayerConfig {
y1: number;
x2: number;
y2: number;
orientation?: 'n' | 's' | 'e' | 'w';
orientation?: "n" | "s" | "e" | "w";
fontSize?: number;
align?: 'l' | 'c' | 'r';
align?: "l" | "c" | "r";
_key?: number;
}
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;
}
}
}