mirror of
https://github.com/docmost/docmost.git
synced 2026-08-29 10:05:03 +08:00
refactor(base): migrate choice editor reorder from dnd-kit to pragmatic-drag-and-drop
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useCallback, useMemo, useEffect, useRef } from "react";
|
import { useState, useCallback, useMemo, useEffect, useRef, useLayoutEffect } from "react";
|
||||||
import {
|
import {
|
||||||
TextInput,
|
TextInput,
|
||||||
Group,
|
Group,
|
||||||
@@ -16,22 +16,21 @@ import {
|
|||||||
IconGripVertical,
|
IconGripVertical,
|
||||||
IconArrowsSort,
|
IconArrowsSort,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
|
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
|
||||||
import {
|
import {
|
||||||
DndContext,
|
draggable,
|
||||||
closestCenter,
|
dropTargetForElements,
|
||||||
PointerSensor,
|
} from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
|
||||||
useSensor,
|
|
||||||
useSensors,
|
|
||||||
DragEndEvent,
|
|
||||||
} from "@dnd-kit/core";
|
|
||||||
import {
|
import {
|
||||||
SortableContext,
|
attachClosestEdge,
|
||||||
verticalListSortingStrategy,
|
extractClosestEdge,
|
||||||
useSortable,
|
type Edge,
|
||||||
arrayMove,
|
} from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge";
|
||||||
} from "@dnd-kit/sortable";
|
import { getReorderDestinationIndex } from "@atlaskit/pragmatic-drag-and-drop-hitbox/util/get-reorder-destination-index";
|
||||||
import { restrictToVerticalAxis } from "@dnd-kit/modifiers";
|
import { reorder } from "@atlaskit/pragmatic-drag-and-drop/reorder";
|
||||||
import { CSS } from "@dnd-kit/utilities";
|
import { triggerPostMoveFlash } from "@atlaskit/pragmatic-drag-and-drop-flourish/trigger-post-move-flash";
|
||||||
|
import * as liveRegion from "@atlaskit/pragmatic-drag-and-drop-live-region";
|
||||||
|
import { BaseDropEdgeIndicator } from "@/features/base/components/grid/base-drop-edge-indicator";
|
||||||
import { Choice } from "@/features/base/types/base.types";
|
import { Choice } from "@/features/base/types/base.types";
|
||||||
import { choiceColor } from "@/features/base/components/cells/choice-color";
|
import { choiceColor } from "@/features/base/components/cells/choice-color";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -143,23 +142,44 @@ export function ChoiceEditor({
|
|||||||
onClose();
|
onClose();
|
||||||
}, [initialChoices, onDirtyChange, onClose]);
|
}, [initialChoices, onDirtyChange, onClose]);
|
||||||
|
|
||||||
const handleReorder = useCallback((activeId: string, overId: string) => {
|
const handleReorder = useCallback(
|
||||||
setDraft((prev) => {
|
(activeId: string, targetId: string, edge: Edge) => {
|
||||||
const oldIndex = prev.findIndex((c) => c.id === activeId);
|
setDraft((prev) => {
|
||||||
const newIndex = prev.findIndex((c) => c.id === overId);
|
const startIndex = prev.findIndex((c) => c.id === activeId);
|
||||||
if (oldIndex === -1 || newIndex === -1) return prev;
|
const indexOfTarget = prev.findIndex((c) => c.id === targetId);
|
||||||
return arrayMove(prev, oldIndex, newIndex);
|
if (startIndex === -1 || indexOfTarget === -1) return prev;
|
||||||
});
|
const finishIndex = getReorderDestinationIndex({
|
||||||
}, []);
|
startIndex,
|
||||||
|
indexOfTarget,
|
||||||
|
closestEdgeOfTarget: edge,
|
||||||
|
axis: "vertical",
|
||||||
|
});
|
||||||
|
if (finishIndex === startIndex) return prev;
|
||||||
|
return reorder({ list: prev, startIndex, finishIndex });
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
const handleCategoryReorder = useCallback(
|
const handleCategoryReorder = useCallback(
|
||||||
(category: string, activeId: string, overId: string) => {
|
(category: string, activeId: string, targetId: string, edge: Edge) => {
|
||||||
setDraft((prev) => {
|
setDraft((prev) => {
|
||||||
const catChoices = prev.filter((c) => (c.category ?? "todo") === category);
|
const catChoices = prev.filter((c) => (c.category ?? "todo") === category);
|
||||||
const oldIndex = catChoices.findIndex((c) => c.id === activeId);
|
const startIndex = catChoices.findIndex((c) => c.id === activeId);
|
||||||
const newIndex = catChoices.findIndex((c) => c.id === overId);
|
const indexOfTarget = catChoices.findIndex((c) => c.id === targetId);
|
||||||
if (oldIndex === -1 || newIndex === -1) return prev;
|
if (startIndex === -1 || indexOfTarget === -1) return prev;
|
||||||
const reordered = arrayMove(catChoices, oldIndex, newIndex);
|
const finishIndex = getReorderDestinationIndex({
|
||||||
|
startIndex,
|
||||||
|
indexOfTarget,
|
||||||
|
closestEdgeOfTarget: edge,
|
||||||
|
axis: "vertical",
|
||||||
|
});
|
||||||
|
if (finishIndex === startIndex) return prev;
|
||||||
|
const reordered = reorder({
|
||||||
|
list: catChoices,
|
||||||
|
startIndex,
|
||||||
|
finishIndex,
|
||||||
|
});
|
||||||
const result: Choice[] = [];
|
const result: Choice[] = [];
|
||||||
for (const cat of ["todo", "inProgress", "complete"]) {
|
for (const cat of ["todo", "inProgress", "complete"]) {
|
||||||
if (cat === category) {
|
if (cat === category) {
|
||||||
@@ -245,48 +265,25 @@ function FlatChoiceList({
|
|||||||
onColorChange: (id: string, color: string) => void;
|
onColorChange: (id: string, color: string) => void;
|
||||||
onRemove: (id: string) => void;
|
onRemove: (id: string) => void;
|
||||||
onAdd: () => void;
|
onAdd: () => void;
|
||||||
onReorder: (activeId: string, overId: string) => void;
|
onReorder: (activeId: string, targetId: string, edge: Edge) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const choiceIds = useMemo(() => draft.map((c) => c.id), [draft]);
|
|
||||||
|
|
||||||
const sensors = useSensors(
|
|
||||||
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleDragEnd = useCallback(
|
|
||||||
(event: DragEndEvent) => {
|
|
||||||
const { active, over } = event;
|
|
||||||
if (!over || active.id === over.id) return;
|
|
||||||
onReorder(active.id as string, over.id as string);
|
|
||||||
},
|
|
||||||
[onReorder],
|
|
||||||
);
|
|
||||||
|
|
||||||
const modifiers = useMemo(() => [restrictToVerticalAxis], []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap={4}>
|
<Stack gap={4}>
|
||||||
<DndContext
|
{draft.map((choice) => (
|
||||||
sensors={sensors}
|
<SortableChoiceRow
|
||||||
collisionDetection={closestCenter}
|
key={choice.id}
|
||||||
onDragEnd={handleDragEnd}
|
choice={choice}
|
||||||
modifiers={modifiers}
|
dragType="base-choice-flat"
|
||||||
>
|
autoFocus={choice.id === focusChoiceId}
|
||||||
<SortableContext items={choiceIds} strategy={verticalListSortingStrategy}>
|
onFocused={onFocused}
|
||||||
{draft.map((choice) => (
|
onRename={onRename}
|
||||||
<SortableChoiceRow
|
onColorChange={onColorChange}
|
||||||
key={choice.id}
|
onRemove={onRemove}
|
||||||
choice={choice}
|
onReorder={onReorder}
|
||||||
autoFocus={choice.id === focusChoiceId}
|
/>
|
||||||
onFocused={onFocused}
|
))}
|
||||||
onRename={onRename}
|
|
||||||
onColorChange={onColorChange}
|
|
||||||
onRemove={onRemove}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</SortableContext>
|
|
||||||
</DndContext>
|
|
||||||
|
|
||||||
<UnstyledButton
|
<UnstyledButton
|
||||||
onClick={() => onAdd()}
|
onClick={() => onAdd()}
|
||||||
@@ -316,7 +313,7 @@ function StatusChoiceList({
|
|||||||
onColorChange: (id: string, color: string) => void;
|
onColorChange: (id: string, color: string) => void;
|
||||||
onRemove: (id: string) => void;
|
onRemove: (id: string) => void;
|
||||||
onAdd: (category: "todo" | "inProgress" | "complete") => void;
|
onAdd: (category: "todo" | "inProgress" | "complete") => void;
|
||||||
onCategoryReorder: (category: string, activeId: string, overId: string) => void;
|
onCategoryReorder: (category: string, activeId: string, targetId: string, edge: Edge) => void;
|
||||||
}) {
|
}) {
|
||||||
const grouped = useMemo(() => {
|
const grouped = useMemo(() => {
|
||||||
const groups: Record<string, Choice[]> = { todo: [], inProgress: [], complete: [] };
|
const groups: Record<string, Choice[]> = { todo: [], inProgress: [], complete: [] };
|
||||||
@@ -369,52 +366,45 @@ function CategorySection({
|
|||||||
onColorChange: (id: string, color: string) => void;
|
onColorChange: (id: string, color: string) => void;
|
||||||
onRemove: (id: string) => void;
|
onRemove: (id: string) => void;
|
||||||
onAdd: (category: "todo" | "inProgress" | "complete") => void;
|
onAdd: (category: "todo" | "inProgress" | "complete") => void;
|
||||||
onReorder: (category: string, activeId: string, overId: string) => void;
|
onReorder: (
|
||||||
|
category: string,
|
||||||
|
activeId: string,
|
||||||
|
targetId: string,
|
||||||
|
edge: Edge,
|
||||||
|
) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const choiceIds = useMemo(() => choices.map((c) => c.id), [choices]);
|
|
||||||
|
|
||||||
const sensors = useSensors(
|
const handleRowReorder = useCallback(
|
||||||
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
(activeId: string, targetId: string, edge: Edge) => {
|
||||||
);
|
onReorder(category, activeId, targetId, edge);
|
||||||
|
|
||||||
const handleDragEnd = useCallback(
|
|
||||||
(event: DragEndEvent) => {
|
|
||||||
const { active, over } = event;
|
|
||||||
if (!over || active.id === over.id) return;
|
|
||||||
onReorder(category, active.id as string, over.id as string);
|
|
||||||
},
|
},
|
||||||
[category, onReorder],
|
[category, onReorder],
|
||||||
);
|
);
|
||||||
|
|
||||||
const modifiers = useMemo(() => [restrictToVerticalAxis], []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap={4}>
|
<Stack gap={4}>
|
||||||
<Text size="xs" fw={600} c="dimmed">
|
<Text size="xs" fw={600} c="dimmed">
|
||||||
{t(label)}
|
{t(label)}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<DndContext
|
{choices.map((choice) => (
|
||||||
sensors={sensors}
|
<SortableChoiceRow
|
||||||
collisionDetection={closestCenter}
|
key={choice.id}
|
||||||
onDragEnd={handleDragEnd}
|
choice={choice}
|
||||||
modifiers={modifiers}
|
// Per-category drag type isolates drops to within the same category.
|
||||||
>
|
// A drag started in "Todo" reports type "base-choice-status:todo";
|
||||||
<SortableContext items={choiceIds} strategy={verticalListSortingStrategy}>
|
// an "In Progress" row's canDrop matches against its own
|
||||||
{choices.map((choice) => (
|
// "base-choice-status:inProgress" type and rejects.
|
||||||
<SortableChoiceRow
|
dragType={`base-choice-status:${category}`}
|
||||||
key={choice.id}
|
autoFocus={choice.id === focusChoiceId}
|
||||||
choice={choice}
|
onFocused={onFocused}
|
||||||
autoFocus={choice.id === focusChoiceId}
|
onRename={onRename}
|
||||||
onFocused={onFocused}
|
onColorChange={onColorChange}
|
||||||
onRename={onRename}
|
onRemove={onRemove}
|
||||||
onColorChange={onColorChange}
|
onReorder={handleRowReorder}
|
||||||
onRemove={onRemove}
|
/>
|
||||||
/>
|
))}
|
||||||
))}
|
|
||||||
</SortableContext>
|
|
||||||
</DndContext>
|
|
||||||
|
|
||||||
<UnstyledButton
|
<UnstyledButton
|
||||||
onClick={() => onAdd(category)}
|
onClick={() => onAdd(category)}
|
||||||
@@ -429,28 +419,37 @@ function CategorySection({
|
|||||||
|
|
||||||
function SortableChoiceRow({
|
function SortableChoiceRow({
|
||||||
choice,
|
choice,
|
||||||
|
dragType,
|
||||||
autoFocus,
|
autoFocus,
|
||||||
onFocused,
|
onFocused,
|
||||||
onRename,
|
onRename,
|
||||||
onColorChange,
|
onColorChange,
|
||||||
onRemove,
|
onRemove,
|
||||||
|
onReorder,
|
||||||
}: {
|
}: {
|
||||||
choice: Choice;
|
choice: Choice;
|
||||||
|
dragType: string;
|
||||||
autoFocus?: boolean;
|
autoFocus?: boolean;
|
||||||
onFocused?: () => void;
|
onFocused?: () => void;
|
||||||
onRename: (id: string, name: string) => void;
|
onRename: (id: string, name: string) => void;
|
||||||
onColorChange: (id: string, color: string) => void;
|
onColorChange: (id: string, color: string) => void;
|
||||||
onRemove: (id: string) => void;
|
onRemove: (id: string) => void;
|
||||||
|
onReorder: (activeId: string, targetId: string, edge: Edge) => void;
|
||||||
}) {
|
}) {
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const {
|
const rowRef = useRef<HTMLDivElement>(null);
|
||||||
attributes,
|
const handleRef = useRef<HTMLDivElement>(null);
|
||||||
listeners,
|
|
||||||
setNodeRef,
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
transform,
|
const [closestEdge, setClosestEdge] = useState<Edge | null>(null);
|
||||||
transition,
|
|
||||||
isDragging,
|
// Same rationale as grid-header-cell: keep `onReorder` out of the DnD
|
||||||
} = useSortable({ id: choice.id });
|
// effect's deps so we don't tear down the adapter when the parent
|
||||||
|
// re-renders with a new closure.
|
||||||
|
const onReorderRef = useRef(onReorder);
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
onReorderRef.current = onReorder;
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (autoFocus) {
|
if (autoFocus) {
|
||||||
@@ -459,20 +458,65 @@ function SortableChoiceRow({
|
|||||||
}
|
}
|
||||||
}, [autoFocus, onFocused]);
|
}, [autoFocus, onFocused]);
|
||||||
|
|
||||||
const style = {
|
useEffect(() => {
|
||||||
transform: CSS.Transform.toString(transform ? { ...transform, scaleX: 1, scaleY: 1 } : null),
|
const row = rowRef.current;
|
||||||
transition,
|
const handle = handleRef.current;
|
||||||
opacity: isDragging ? 0.5 : 1,
|
if (!row || !handle) return;
|
||||||
zIndex: isDragging ? 10 : undefined,
|
return combine(
|
||||||
};
|
draggable({
|
||||||
|
element: row,
|
||||||
|
// Only the grip icon initiates the drag (preserves text-input clicks
|
||||||
|
// and close-button clicks). The native preview is still derived from
|
||||||
|
// `element` (the full row).
|
||||||
|
dragHandle: handle,
|
||||||
|
getInitialData: () => ({ type: dragType, choiceId: choice.id }),
|
||||||
|
onDragStart: () => setIsDragging(true),
|
||||||
|
onDrop: () => setIsDragging(false),
|
||||||
|
}),
|
||||||
|
dropTargetForElements({
|
||||||
|
element: row,
|
||||||
|
canDrop: ({ source }) =>
|
||||||
|
source.data.type === dragType &&
|
||||||
|
source.data.choiceId !== choice.id,
|
||||||
|
getData: ({ input, element }) =>
|
||||||
|
attachClosestEdge(
|
||||||
|
{ choiceId: choice.id },
|
||||||
|
{ input, element, allowedEdges: ["top", "bottom"] },
|
||||||
|
),
|
||||||
|
onDrag: ({ self }) => setClosestEdge(extractClosestEdge(self.data)),
|
||||||
|
onDragLeave: () => setClosestEdge(null),
|
||||||
|
onDrop: ({ source, self }) => {
|
||||||
|
setClosestEdge(null);
|
||||||
|
const edge = extractClosestEdge(self.data);
|
||||||
|
if (!edge) return;
|
||||||
|
onReorderRef.current(
|
||||||
|
source.data.choiceId as string,
|
||||||
|
choice.id,
|
||||||
|
edge,
|
||||||
|
);
|
||||||
|
triggerPostMoveFlash(row);
|
||||||
|
liveRegion.announce("Moved option");
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}, [choice.id, dragType]);
|
||||||
|
|
||||||
const hasError = !choice.name.trim();
|
const hasError = !choice.name.trim();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Group ref={setNodeRef} style={style} gap={6} wrap="nowrap" align="center">
|
<Group
|
||||||
|
ref={rowRef}
|
||||||
|
gap={6}
|
||||||
|
wrap="nowrap"
|
||||||
|
align="center"
|
||||||
|
style={{
|
||||||
|
position: "relative",
|
||||||
|
opacity: isDragging ? 0.4 : 1,
|
||||||
|
}}
|
||||||
|
data-dragging={isDragging || undefined}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
{...attributes}
|
ref={handleRef}
|
||||||
{...listeners}
|
|
||||||
style={{ flexShrink: 0, cursor: "grab", display: "flex", alignItems: "center" }}
|
style={{ flexShrink: 0, cursor: "grab", display: "flex", alignItems: "center" }}
|
||||||
>
|
>
|
||||||
<IconGripVertical size={14} style={{ opacity: 0.4 }} />
|
<IconGripVertical size={14} style={{ opacity: 0.4 }} />
|
||||||
@@ -488,6 +532,7 @@ function SortableChoiceRow({
|
|||||||
styles={hasError ? { input: { borderColor: "var(--mantine-color-red-6)" } } : undefined}
|
styles={hasError ? { input: { borderColor: "var(--mantine-color-red-6)" } } : undefined}
|
||||||
/>
|
/>
|
||||||
<CloseButton size="sm" onClick={() => onRemove(choice.id)} />
|
<CloseButton size="sm" onClick={() => onRemove(choice.id)} />
|
||||||
|
{closestEdge && <BaseDropEdgeIndicator edge={closestEdge} />}
|
||||||
</Group>
|
</Group>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user