From adaee0ef5fd2abd336530a2546a05a825dd88ede Mon Sep 17 00:00:00 2001 From: Peng Xiao Date: Fri, 7 Feb 2025 15:57:57 +0800 Subject: [PATCH] feat(component): sortable --- .../component/src/ui/dnd/sortable/readme.md | 3 + .../src/ui/dnd/sortable/sortable-context.tsx | 123 ++++++++++++++++++ .../src/ui/dnd/sortable/strategies.ts | 25 ++++ .../component/src/ui/dnd/sortable/types.ts | 32 +++++ .../src/ui/dnd/sortable/utilities.ts | 76 +++++++++++ 5 files changed, 259 insertions(+) create mode 100644 packages/frontend/component/src/ui/dnd/sortable/readme.md create mode 100644 packages/frontend/component/src/ui/dnd/sortable/sortable-context.tsx create mode 100644 packages/frontend/component/src/ui/dnd/sortable/strategies.ts create mode 100644 packages/frontend/component/src/ui/dnd/sortable/types.ts create mode 100644 packages/frontend/component/src/ui/dnd/sortable/utilities.ts diff --git a/packages/frontend/component/src/ui/dnd/sortable/readme.md b/packages/frontend/component/src/ui/dnd/sortable/readme.md new file mode 100644 index 0000000000..cf83635162 --- /dev/null +++ b/packages/frontend/component/src/ui/dnd/sortable/readme.md @@ -0,0 +1,3 @@ +# Sortable + +Migrated from https://github.com/clauderic/dnd-kit diff --git a/packages/frontend/component/src/ui/dnd/sortable/sortable-context.tsx b/packages/frontend/component/src/ui/dnd/sortable/sortable-context.tsx new file mode 100644 index 0000000000..9ca3a749ae --- /dev/null +++ b/packages/frontend/component/src/ui/dnd/sortable/sortable-context.tsx @@ -0,0 +1,123 @@ +import type { ElementDragType } from '@atlaskit/pragmatic-drag-and-drop/types'; +import React, { + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react'; + +import { rectSortingStrategy } from './strategies'; +import type { + ClientRect, + Disabled, + SortingStrategy, + UniqueIdentifier, +} from './types'; +import { + getSortedRects, + itemsEqual, + normalizeDisabled, + useUniqueId, +} from './utilities'; + +export interface Props { + children: React.ReactNode; + items: (UniqueIdentifier | { id: UniqueIdentifier })[]; + strategy?: SortingStrategy; + disabled?: boolean | Disabled; +} + +const ID_PREFIX = 'Sortable'; + +interface ContextDescriptor { + activeIndex: number; + containerId: string; + disableTransforms: boolean; + items: { + id: UniqueIdentifier; + }[]; + overIndex: number; + sortedRects: ClientRect[]; + strategy: SortingStrategy; + disabled: Disabled; +} + +export const Context = React.createContext({ + activeIndex: -1, + containerId: ID_PREFIX, + disableTransforms: false, + items: [], + overIndex: -1, + sortedRects: [], + strategy: rectSortingStrategy, + disabled: { + draggable: false, + droppable: false, + }, +}); + +export function SortableContext({ + children, + items: userDefinedItems, + strategy = rectSortingStrategy, + disabled: disabledProp = false, +}: Props) { + const [active, setActive] = useState(null); + + const { active, droppableRects, over, measureDroppableContainers } = + useDndContext(); + const containerId = useUniqueId(ID_PREFIX, id); + const items = useMemo( + () => + userDefinedItems.map(item => + typeof item === 'object' && 'id' in item ? item.id : item + ), + [userDefinedItems] + ); + const isDragging = active != null; + const activeIndex = active ? items.indexOf(active.id) : -1; + const overIndex = over ? items.indexOf(over.id) : -1; + const previousItemsRef = useRef(items); + const itemsHaveChanged = !itemsEqual(items, previousItemsRef.current); + const disableTransforms = + (overIndex !== -1 && activeIndex === -1) || itemsHaveChanged; + const disabled = normalizeDisabled(disabledProp); + + useLayoutEffect(() => { + if (itemsHaveChanged && isDragging) { + measureDroppableContainers(items); + } + }, [itemsHaveChanged, items, isDragging, measureDroppableContainers]); + + useEffect(() => { + previousItemsRef.current = items; + }, [items]); + + const contextValue = useMemo( + (): ContextDescriptor => ({ + activeIndex, + containerId, + disabled, + disableTransforms, + items, + overIndex, + sortedRects: getSortedRects(items, droppableRects), + strategy, + }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [ + activeIndex, + containerId, + disabled.draggable, + disabled.droppable, + disableTransforms, + items, + overIndex, + droppableRects, + strategy, + ] + ); + + return {children}; +} diff --git a/packages/frontend/component/src/ui/dnd/sortable/strategies.ts b/packages/frontend/component/src/ui/dnd/sortable/strategies.ts new file mode 100644 index 0000000000..0521819aec --- /dev/null +++ b/packages/frontend/component/src/ui/dnd/sortable/strategies.ts @@ -0,0 +1,25 @@ +import type { SortingStrategy } from './types'; +import { arrayMove } from './utilities'; + +export const rectSortingStrategy: SortingStrategy = ({ + rects, + activeIndex, + overIndex, + index, +}) => { + const newRects = arrayMove(rects, overIndex, activeIndex); + + const oldRect = rects[index]; + const newRect = newRects[index]; + + if (!newRect || !oldRect) { + return null; + } + + return { + x: newRect.left - oldRect.left, + y: newRect.top - oldRect.top, + scaleX: newRect.width / oldRect.width, + scaleY: newRect.height / oldRect.height, + }; +}; diff --git a/packages/frontend/component/src/ui/dnd/sortable/types.ts b/packages/frontend/component/src/ui/dnd/sortable/types.ts new file mode 100644 index 0000000000..cb6e29522d --- /dev/null +++ b/packages/frontend/component/src/ui/dnd/sortable/types.ts @@ -0,0 +1,32 @@ +export type Transform = { + x: number; + y: number; + scaleX: number; + scaleY: number; +}; + +export interface ClientRect { + width: number; + height: number; + top: number; + left: number; + right: number; + bottom: number; +} + +export type SortingStrategy = (args: { + activeNodeRect: ClientRect | null; + activeIndex: number; + index: number; + rects: ClientRect[]; + overIndex: number; +}) => Transform | null; + +export type UniqueIdentifier = string | number; + +export type RectMap = Map; + +export interface Disabled { + draggable?: boolean; + droppable?: boolean; +} diff --git a/packages/frontend/component/src/ui/dnd/sortable/utilities.ts b/packages/frontend/component/src/ui/dnd/sortable/utilities.ts new file mode 100644 index 0000000000..21a95fde99 --- /dev/null +++ b/packages/frontend/component/src/ui/dnd/sortable/utilities.ts @@ -0,0 +1,76 @@ +import { useMemo } from 'react'; + +import type { ClientRect, Disabled, RectMap, UniqueIdentifier } from './types'; + +let ids: Record = {}; + +export function useUniqueId(prefix: string, value?: string) { + return useMemo(() => { + if (value) { + return value; + } + + const id = ids[prefix] == null ? 0 : ids[prefix] + 1; + ids[prefix] = id; + + return `${prefix}-${id}`; + }, [prefix, value]); +} + +/** + * Move an array item to a different position. Returns a new array with the item moved to the new position. + */ +export function arrayMove(array: T[], from: number, to: number): T[] { + const newArray = array.slice(); + newArray.splice( + to < 0 ? newArray.length + to : to, + 0, + newArray.splice(from, 1)[0] + ); + + return newArray; +} + +export function getSortedRects(items: UniqueIdentifier[], rects: RectMap) { + return items.reduce( + (accumulator, id, index) => { + const rect = rects.get(id); + + if (rect) { + accumulator[index] = rect; + } + + return accumulator; + }, + Array.from({ length: items.length }) + ); +} + +export function itemsEqual(a: UniqueIdentifier[], b: UniqueIdentifier[]) { + if (a === b) { + return true; + } + + if (a.length !== b.length) { + return false; + } + + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) { + return false; + } + } + + return true; +} + +export function normalizeDisabled(disabled: boolean | Disabled): Disabled { + if (typeof disabled === 'boolean') { + return { + draggable: disabled, + droppable: disabled, + }; + } + + return disabled; +}