feat(layer-editor): Stable sortable IDs, drag opacity

- Use createMemo for stable IDs to prevent re-renders
- Add opacity and transition styles during drag
- Remove "所有字段已添加" message when no available fields
This commit is contained in:
hyper 2026-06-30 21:44:59 +08:00
parent bdc06cc49a
commit 186e3c0db6
2 changed files with 174 additions and 50 deletions

View File

@ -3,6 +3,7 @@ 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";
@ -24,6 +25,10 @@ function LayerEditorPanel(props: LayerEditorPanelProps) {
side() === "front" side() === "front"
? store.state.frontLayerConfigs ? store.state.frontLayerConfigs
: store.state.backLayerConfigs; : store.state.backLayerConfigs;
// Stable IDs for SortableProvider — simply the indices
const ids = createMemo(() => layers().map((_, i) => i));
const updater = () => const updater = () =>
side() === "front" side() === "front"
? store.actions.updateFrontLayerConfig ? store.actions.updateFrontLayerConfig
@ -32,8 +37,7 @@ function LayerEditorPanel(props: LayerEditorPanelProps) {
const availableFields = createMemo(() => { const availableFields = createMemo(() => {
const cards = store.state.cards; const cards = store.state.cards;
if (!cards?.length) return []; if (!cards?.length) return [];
const used = new Set(layers().map((l) => l.prop)); return Object.keys(cards[0]).filter((k) => k !== "label");
return Object.keys(cards[0]).filter((k) => k !== "label" && !used.has(k));
}); });
const update = ( const update = (
@ -73,40 +77,37 @@ function LayerEditorPanel(props: LayerEditorPanelProps) {
class="absolute top-full left-0 mt-1 w-full bg-white border border-gray-300 rounded shadow-lg z-20 max-h-48 overflow-y-auto" class="absolute top-full left-0 mt-1 w-full bg-white border border-gray-300 rounded shadow-lg z-20 max-h-48 overflow-y-auto"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
{availableFields().length === 0 ? ( <For each={availableFields()}>
<div class="px-3 py-2 text-sm text-gray-400"></div> {(f) => (
) : ( <button
<For each={availableFields()}> onClick={() => {
{(f) => ( store.actions.addLayer(side(), f);
<button setAddMenuOpen(false);
onClick={() => { }}
store.actions.addLayer(side(), f); class="block w-full text-left px-3 py-1.5 text-sm hover:bg-blue-50 hover:text-blue-600 cursor-pointer"
setAddMenuOpen(false); >
}} {f}
class="block w-full text-left px-3 py-1.5 text-sm hover:bg-blue-50 hover:text-blue-600 cursor-pointer" </button>
> )}
{f} </For>
</button>
)}
</For>
)}
</div> </div>
)} )}
</div> </div>
<DragDropProvider <DragDropProvider
onDragEnd={({ draggable, droppable }) => { onDragEnd={({ draggable, droppable }) => {
if (droppable) if (droppable && draggable.id !== droppable.id) {
store.actions.reorderLayers( store.actions.reorderLayers(
side(), side(),
draggable.id as number, draggable.id as number,
droppable.id as number, droppable.id as number,
); );
}
}} }}
collisionDetector={closestCenter} collisionDetector={closestCenter}
> >
<DragDropSensors> <DragDropSensors>
<SortableProvider ids={layers().map((_, i) => i)}> <SortableProvider ids={ids()}>
<For each={layers()}> <For each={layers()}>
{(layer, index) => ( {(layer, index) => (
<LayerRow <LayerRow

View File

@ -1,5 +1,5 @@
import { For, createSignal, onCleanup, onMount } from "solid-js"; import { For, createSignal, onCleanup, onMount } from "solid-js";
import { createSortable, maybeTransformStyle } from "@thisbeyond/solid-dnd"; import { createSortable, useDragDropContext } from "@thisbeyond/solid-dnd";
import type { DeckStore } from "../hooks/deckStore"; import type { DeckStore } from "../hooks/deckStore";
import type { LayerConfig } from "../types"; import type { LayerConfig } from "../types";
import alignLeftIcon from "./icons/align-left.png"; import alignLeftIcon from "./icons/align-left.png";
@ -38,48 +38,86 @@ const ALIGNS = [
const FONT_PRESETS = [3, 5, 8, 12] as const; const FONT_PRESETS = [3, 5, 8, 12] as const;
function orientChar(v: string) { function orientChar(v: string) {
switch (v) { case "n": return "↑"; case "e": return "→"; case "s": return "↓"; case "w": return "←"; default: return "↑"; } switch (v) {
case "n":
return "↑";
case "e":
return "→";
case "s":
return "↓";
case "w":
return "←";
default:
return "↑";
}
} }
function alignSrc(v: string) { function alignSrc(v: string) {
switch (v) { case "l": return alignLeftIcon; case "r": return alignRightIcon; default: return alignCenterIcon; } switch (v) {
case "l":
return alignLeftIcon;
case "r":
return alignRightIcon;
default:
return alignCenterIcon;
}
} }
export function LayerRow(props: LayerRowProps) { export function LayerRow(props: LayerRowProps) {
const sortable = createSortable(props.index); const sortable = createSortable(props.index);
const [dndState] = useDragDropContext()!;
const [fontOpen, setFontOpen] = createSignal(false); const [fontOpen, setFontOpen] = createSignal(false);
let fontRef: HTMLDivElement | undefined; let fontRef: HTMLDivElement | undefined;
const selected = () => props.store.state.selectedLayer === props.index; const selected = () => props.store.state.selectedLayer === props.index;
onMount(() => { onMount(() => {
const h = (e: MouseEvent) => { if (fontRef && !fontRef.contains(e.target as Node)) setFontOpen(false); }; const h = (e: MouseEvent) => {
if (fontRef && !fontRef.contains(e.target as Node)) setFontOpen(false);
};
document.addEventListener("click", h); document.addEventListener("click", h);
onCleanup(() => document.removeEventListener("click", h)); onCleanup(() => document.removeEventListener("click", h));
}); });
const toggleVisible = () => { const toggleVisible = () => {
const fn = props.store.state.activeSide === "front" const fn =
? props.store.actions.toggleFrontLayerVisible props.store.state.activeSide === "front"
: props.store.actions.toggleBackLayerVisible; ? props.store.actions.toggleFrontLayerVisible
: props.store.actions.toggleBackLayerVisible;
fn(props.index); fn(props.index);
}; };
return ( return (
<div <div
ref={sortable.ref} ref={sortable}
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" : ""}`} 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,
}}
onMouseEnter={() => props.onHoverChange(props.index)} onMouseEnter={() => props.onHoverChange(props.index)}
onMouseLeave={() => props.onHoverChange(null)} 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
{...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 ? "隐藏" : "显示"}> <button
onClick={toggleVisible}
class="shrink-0 text-sm cursor-pointer select-none"
title={props.layer.visible ? "隐藏" : "显示"}
>
{props.layer.visible ? "👁" : "👁‍🗨"} {props.layer.visible ? "👁" : "👁‍🗨"}
</button> </button>
<span class="text-sm flex-1 truncate cursor-pointer hover:text-blue-600 select-none" onClick={props.onSelect}> <span
class="text-sm flex-1 truncate cursor-pointer hover:text-blue-600 select-none"
onClick={props.onSelect}
>
{props.layer.prop} {props.layer.prop}
</span> </span>
@ -87,50 +125,121 @@ export function LayerRow(props: LayerRowProps) {
icon={orientChar(props.layer.orientation || "n")} icon={orientChar(props.layer.orientation || "n")}
visible={props.layer.visible} visible={props.layer.visible}
open={props.openDropdown === `orient-${props.index}`} open={props.openDropdown === `orient-${props.index}`}
onToggle={() => props.setOpenDropdown(props.openDropdown === `orient-${props.index}` ? null : `orient-${props.index}`)} onToggle={() =>
props.setOpenDropdown(
props.openDropdown === `orient-${props.index}`
? null
: `orient-${props.index}`,
)
}
title="方向" title="方向"
> >
<For each={ORIENTATIONS}> <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>} {(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> </For>
</DropdownButton> </DropdownButton>
<DropdownButton <DropdownButton
icon={<img src={alignSrc(props.layer.align || "")} alt="align" class="w-5 h-5 not-prose" />} icon={
<img
src={alignSrc(props.layer.align || "")}
alt="align"
class="w-5 h-5 not-prose"
/>
}
visible={props.layer.visible} visible={props.layer.visible}
open={props.openDropdown === `align-${props.index}`} open={props.openDropdown === `align-${props.index}`}
onToggle={() => props.setOpenDropdown(props.openDropdown === `align-${props.index}` ? null : `align-${props.index}`)} onToggle={() =>
props.setOpenDropdown(
props.openDropdown === `align-${props.index}`
? null
: `align-${props.index}`,
)
}
title="对齐" title="对齐"
> >
<For each={ALIGNS}> <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>} {(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> </For>
</DropdownButton> </DropdownButton>
<div class="relative" ref={fontRef}> <div class="relative" ref={fontRef}>
<button onClick={(e) => { e.stopPropagation(); setFontOpen(!fontOpen()); }} <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" : ""}`} 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> title="字体大小 (mm)"
>
{props.layer.fontSize ?? 3}
</button>
{fontOpen() && ( {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="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"> <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)} <input
class="w-14 text-xs px-1.5 py-1 rounded border border-gray-300" step="0.1" min="0.1" /> 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> <span class="text-xs text-gray-500">mm</span>
</div> </div>
<div class="flex gap-1"> <div class="flex gap-1">
<For each={FONT_PRESETS}> <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>} {(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> </For>
</div> </div>
<button onClick={() => props.onUpdateFontSize(undefined)} class="mt-2 w-full text-xs text-gray-500 hover:text-gray-700 cursor-pointer"></button> <button
onClick={() => props.onUpdateFontSize(undefined)}
class="mt-2 w-full text-xs text-gray-500 hover:text-gray-700 cursor-pointer"
>
</button>
</div> </div>
)} )}
</div> </div>
<button onClick={(e) => { e.stopPropagation(); props.onRemove(); }} <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"}`} 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> title="删除图层"
>
</button>
</div> </div>
); );
} }
@ -145,10 +254,24 @@ function DropdownButton(props: {
}) { }) {
return ( return (
<div class="relative"> <div class="relative">
<button onClick={(e) => { e.stopPropagation(); props.onToggle(); }} <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" : ""}`} 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> title={props.title}
{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>} >
{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> </div>
); );
} }