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:
parent
bdc06cc49a
commit
186e3c0db6
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue