mirror of
https://github.com/docmost/docmost.git
synced 2026-06-10 01:52:43 +08:00
refactor(base): migrate column reorder from dnd-kit to pragmatic-drag-and-drop
This commit is contained in:
@@ -4,7 +4,7 @@ import { notifications } from "@mantine/notifications";
|
||||
import { useAtom } from "jotai";
|
||||
import { IconDatabase } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { arrayMove } from "@dnd-kit/sortable";
|
||||
import { reorder } from "@atlaskit/pragmatic-drag-and-drop/reorder";
|
||||
import { generateJitteredKeyBetween } from "fractional-indexing-jittered";
|
||||
import { useBaseQuery } from "@/features/base/queries/base-query";
|
||||
import { useBaseSocket } from "@/features/base/hooks/use-base-socket";
|
||||
@@ -207,14 +207,11 @@ export function BaseTable({ pageId, embedded }: BaseTableProps) {
|
||||
}, [pageId, createViewMutation, t]);
|
||||
|
||||
const handleColumnReorder = useCallback(
|
||||
(activeId: string, overId: string) => {
|
||||
const currentOrder = table.getState().columnOrder;
|
||||
const oldIndex = currentOrder.indexOf(activeId);
|
||||
const newIndex = currentOrder.indexOf(overId);
|
||||
if (oldIndex === -1 || newIndex === -1) return;
|
||||
|
||||
const newOrder = arrayMove(currentOrder, oldIndex, newIndex);
|
||||
table.setColumnOrder(newOrder);
|
||||
(columnId: string, finishIndex: number) => {
|
||||
const order = table.getState().columnOrder;
|
||||
const startIndex = order.indexOf(columnId);
|
||||
if (startIndex === -1 || startIndex === finishIndex) return;
|
||||
table.setColumnOrder(reorder({ list: order, startIndex, finishIndex }));
|
||||
persistViewConfig();
|
||||
},
|
||||
[table, persistViewConfig],
|
||||
|
||||
@@ -7,20 +7,6 @@ import {
|
||||
windowScroll,
|
||||
} from "@tanstack/react-virtual";
|
||||
import { useAtom } from "jotai";
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
DragEndEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
SortableContext,
|
||||
horizontalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { restrictToHorizontalAxis } from "@dnd-kit/modifiers";
|
||||
import { IBaseRow, IBaseProperty, EditingCell } from "@/features/base/types/base.types";
|
||||
import { editingCellAtomFamily, activePropertyMenuAtomFamily, propertyMenuDirtyAtomFamily, propertyMenuCloseRequestAtomFamily } from "@/features/base/atoms/base-atoms";
|
||||
import { useColumnResize } from "@/features/base/hooks/use-column-resize";
|
||||
@@ -54,7 +40,7 @@ type GridContainerProps = {
|
||||
onCellUpdate: (rowId: string, propertyId: string, value: unknown) => void;
|
||||
onAddRow?: () => void;
|
||||
pageId: string;
|
||||
onColumnReorder?: (columnId: string, overColumnId: string) => void;
|
||||
onColumnReorder?: (columnId: string, finishIndex: number) => void;
|
||||
onResizeEnd?: () => void;
|
||||
onRowReorder?: (rowId: string, targetRowId: string, position: "above" | "below") => void;
|
||||
hasNextPage?: boolean;
|
||||
@@ -314,63 +300,34 @@ export function GridContainer({
|
||||
});
|
||||
}, []);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: { distance: 8 },
|
||||
}),
|
||||
useSensor(KeyboardSensor),
|
||||
const getColumnOrder = useCallback(
|
||||
() => table.getState().columnOrder,
|
||||
[table],
|
||||
);
|
||||
|
||||
const sortableColumnIds = useMemo(() => {
|
||||
return table
|
||||
.getVisibleLeafColumns()
|
||||
.filter((col) => col.id !== "__row_number")
|
||||
.map((col) => col.id);
|
||||
}, [table, table.getState().columnOrder]);
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
(event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
onColumnReorder?.(active.id as string, over.id as string);
|
||||
},
|
||||
[onColumnReorder],
|
||||
);
|
||||
|
||||
const modifiers = useMemo(() => [restrictToHorizontalAxis], []);
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
modifiers={modifiers}
|
||||
>
|
||||
<div role="grid">
|
||||
<div className={classes.stickyBand}>
|
||||
{stickyBandPrelude}
|
||||
<div
|
||||
className={classes.headerGrid}
|
||||
ref={headerRef}
|
||||
style={{ gridTemplateColumns }}
|
||||
role="row"
|
||||
>
|
||||
<SortableContext
|
||||
items={sortableColumnIds}
|
||||
strategy={horizontalListSortingStrategy}
|
||||
>
|
||||
<GridHeader
|
||||
table={table}
|
||||
pageId={pageId}
|
||||
columnOrder={table.getState().columnOrder}
|
||||
columnVisibility={table.getState().columnVisibility}
|
||||
properties={properties}
|
||||
loadedRowIds={rowIds}
|
||||
onPropertyCreated={handlePropertyCreated}
|
||||
/>
|
||||
</SortableContext>
|
||||
</div>
|
||||
<div role="grid">
|
||||
<div className={classes.stickyBand}>
|
||||
{stickyBandPrelude}
|
||||
<div
|
||||
className={classes.headerGrid}
|
||||
ref={headerRef}
|
||||
style={{ gridTemplateColumns }}
|
||||
role="row"
|
||||
>
|
||||
<GridHeader
|
||||
table={table}
|
||||
pageId={pageId}
|
||||
columnOrder={table.getState().columnOrder}
|
||||
columnVisibility={table.getState().columnVisibility}
|
||||
properties={properties}
|
||||
loadedRowIds={rowIds}
|
||||
onPropertyCreated={handlePropertyCreated}
|
||||
getColumnOrder={getColumnOrder}
|
||||
onColumnReorder={onColumnReorder}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={classes.bodyGrid}
|
||||
ref={bodyRef}
|
||||
@@ -415,7 +372,6 @@ export function GridContainer({
|
||||
<AddRowButton onClick={handleAddRow} />
|
||||
{pageId && <SelectionActionBar pageId={pageId} />}
|
||||
</div>
|
||||
</div>
|
||||
</DndContext>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,27 @@
|
||||
import { memo, useCallback, useEffect, useRef } from "react";
|
||||
import { memo, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Header, flexRender } from "@tanstack/react-table";
|
||||
import { useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { Popover } from "@mantine/core";
|
||||
import { useAtom } from "jotai";
|
||||
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
|
||||
import {
|
||||
draggable,
|
||||
dropTargetForElements,
|
||||
} from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
|
||||
import {
|
||||
attachClosestEdge,
|
||||
extractClosestEdge,
|
||||
type Edge,
|
||||
} from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge";
|
||||
import { getReorderDestinationIndex } from "@atlaskit/pragmatic-drag-and-drop-hitbox/util/get-reorder-destination-index";
|
||||
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 { IBaseRow, IBaseProperty, EditingCell } from "@/features/base/types/base.types";
|
||||
import { activePropertyMenuAtomFamily, propertyMenuDirtyAtomFamily, propertyMenuCloseRequestAtomFamily, editingCellAtomFamily } from "@/features/base/atoms/base-atoms";
|
||||
import {
|
||||
activePropertyMenuAtomFamily,
|
||||
propertyMenuDirtyAtomFamily,
|
||||
propertyMenuCloseRequestAtomFamily,
|
||||
editingCellAtomFamily,
|
||||
} from "@/features/base/atoms/base-atoms";
|
||||
import {
|
||||
IconLetterT,
|
||||
IconHash,
|
||||
@@ -24,9 +40,12 @@ import {
|
||||
} from "@tabler/icons-react";
|
||||
import { PropertyMenuContent } from "@/features/base/components/property/property-menu";
|
||||
import { RowNumberHeaderCell } from "./row-number-header-cell";
|
||||
import { BaseDropEdgeIndicator } from "./base-drop-edge-indicator";
|
||||
import { useRowSelection } from "@/features/base/hooks/use-row-selection";
|
||||
import classes from "@/features/base/styles/grid.module.css";
|
||||
|
||||
const COLUMN_DRAG_TYPE = "base-column";
|
||||
|
||||
const typeIcons: Record<string, typeof IconLetterT> = {
|
||||
text: IconLetterT,
|
||||
number: IconHash,
|
||||
@@ -49,6 +68,8 @@ type GridHeaderCellProps = {
|
||||
property: IBaseProperty | undefined;
|
||||
loadedRowIds: string[];
|
||||
pageId: string;
|
||||
getColumnOrder: () => string[];
|
||||
onColumnReorder?: (columnId: string, finishIndex: number) => void;
|
||||
};
|
||||
|
||||
export const GridHeaderCell = memo(function GridHeaderCell({
|
||||
@@ -56,6 +77,8 @@ export const GridHeaderCell = memo(function GridHeaderCell({
|
||||
property,
|
||||
loadedRowIds,
|
||||
pageId,
|
||||
getColumnOrder,
|
||||
onColumnReorder,
|
||||
}: GridHeaderCellProps) {
|
||||
const isRowNumber = header.column.id === "__row_number";
|
||||
const isPinned = header.column.getIsPinned();
|
||||
@@ -70,31 +93,62 @@ export const GridHeaderCell = memo(function GridHeaderCell({
|
||||
const [closeRequest, setCloseRequest] = useAtom(propertyMenuCloseRequestAtomFamily(pageId)) as unknown as [number, (val: number) => void];
|
||||
const [, setEditingCell] = useAtom(editingCellAtomFamily(pageId)) as unknown as [EditingCell, (val: EditingCell) => void];
|
||||
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [closestEdge, setClosestEdge] = useState<Edge | null>(null);
|
||||
|
||||
const handleDirtyChange = useCallback((dirty: boolean) => {
|
||||
setPropertyMenuDirty(dirty);
|
||||
}, [setPropertyMenuDirty]);
|
||||
|
||||
const isSortableDisabled = isRowNumber || isPinned === "left";
|
||||
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({
|
||||
id: header.column.id,
|
||||
disabled: isSortableDisabled,
|
||||
});
|
||||
|
||||
const combinedRef = useCallback(
|
||||
(node: HTMLDivElement | null) => {
|
||||
setNodeRef(node);
|
||||
(cellRef as React.MutableRefObject<HTMLDivElement | null>).current = node;
|
||||
},
|
||||
[setNodeRef],
|
||||
);
|
||||
useEffect(() => {
|
||||
const el = cellRef.current;
|
||||
if (!el || isSortableDisabled) return;
|
||||
return combine(
|
||||
draggable({
|
||||
element: el,
|
||||
getInitialData: () => ({
|
||||
type: COLUMN_DRAG_TYPE,
|
||||
columnId: header.column.id,
|
||||
}),
|
||||
onDragStart: () => setIsDragging(true),
|
||||
onDrop: () => setIsDragging(false),
|
||||
}),
|
||||
dropTargetForElements({
|
||||
element: el,
|
||||
canDrop: ({ source }) =>
|
||||
source.data.type === COLUMN_DRAG_TYPE &&
|
||||
source.data.columnId !== header.column.id,
|
||||
getData: ({ input, element }) =>
|
||||
attachClosestEdge(
|
||||
{ columnId: header.column.id },
|
||||
{ input, element, allowedEdges: ["left", "right"] },
|
||||
),
|
||||
onDrag: ({ self }) => setClosestEdge(extractClosestEdge(self.data)),
|
||||
onDragLeave: () => setClosestEdge(null),
|
||||
onDrop: ({ source, self }) => {
|
||||
setClosestEdge(null);
|
||||
const edge = extractClosestEdge(self.data);
|
||||
if (!edge) return;
|
||||
const order = getColumnOrder();
|
||||
const startIndex = order.indexOf(source.data.columnId as string);
|
||||
const indexOfTarget = order.indexOf(header.column.id);
|
||||
if (startIndex === -1 || indexOfTarget === -1) return;
|
||||
const finishIndex = getReorderDestinationIndex({
|
||||
startIndex,
|
||||
indexOfTarget,
|
||||
closestEdgeOfTarget: edge,
|
||||
axis: "horizontal",
|
||||
});
|
||||
if (finishIndex === startIndex) return;
|
||||
onColumnReorder?.(source.data.columnId as string, finishIndex);
|
||||
triggerPostMoveFlash(el);
|
||||
liveRegion.announce(`Moved column to position ${finishIndex + 1}`);
|
||||
},
|
||||
}),
|
||||
);
|
||||
}, [header.column.id, isSortableDisabled, onColumnReorder, getColumnOrder]);
|
||||
|
||||
const handleHeaderClick = useCallback(() => {
|
||||
setEditingCell(null);
|
||||
@@ -108,11 +162,6 @@ export const GridHeaderCell = memo(function GridHeaderCell({
|
||||
setActivePropertyMenu(null);
|
||||
}, [setActivePropertyMenu]);
|
||||
|
||||
// Mantine's built-in `closeOnEscape` only fires when focus is inside the
|
||||
// dropdown, but opening the property menu (clicking the header) leaves
|
||||
// focus on the header itself. Mirror the click-outside path: when dirty,
|
||||
// bump `propertyMenuCloseRequestAtomFamily` so property-menu shows its
|
||||
// "Unsaved changes" confirmation panel; otherwise close directly.
|
||||
useEffect(() => {
|
||||
if (!menuOpened) return;
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
@@ -129,33 +178,19 @@ export const GridHeaderCell = memo(function GridHeaderCell({
|
||||
|
||||
const TypeIcon = property ? typeIcons[property.type] : undefined;
|
||||
|
||||
const sortableStyle = transform
|
||||
? {
|
||||
transform: CSS.Transform.toString({
|
||||
...transform,
|
||||
scaleX: 1,
|
||||
scaleY: 1,
|
||||
}),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
zIndex: isDragging ? 10 : undefined,
|
||||
}
|
||||
: {};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={combinedRef}
|
||||
ref={cellRef}
|
||||
className={`${classes.headerCell} ${isPinned ? classes.headerCellPinned : ""} ${hasSelection ? classes.hasSelection : ""}`}
|
||||
style={{
|
||||
...(isPinned
|
||||
? ({ "--pin-offset": `${pinOffset}px` } as React.CSSProperties)
|
||||
: {}),
|
||||
...(isRowNumber ? {} : { cursor: "pointer" }),
|
||||
...sortableStyle,
|
||||
opacity: isDragging ? 0.4 : 1,
|
||||
}}
|
||||
onClick={handleHeaderClick}
|
||||
{...(isSortableDisabled ? {} : attributes)}
|
||||
{...(isSortableDisabled ? {} : listeners)}
|
||||
data-dragging={isDragging || undefined}
|
||||
>
|
||||
{isRowNumber ? (
|
||||
<RowNumberHeaderCell loadedRowIds={loadedRowIds} pageId={pageId} />
|
||||
@@ -186,6 +221,7 @@ export const GridHeaderCell = memo(function GridHeaderCell({
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
)}
|
||||
{closestEdge && <BaseDropEdgeIndicator edge={closestEdge} />}
|
||||
{property && !isRowNumber && (
|
||||
<Popover
|
||||
opened={menuOpened}
|
||||
|
||||
@@ -8,13 +8,13 @@ import classes from "@/features/base/styles/grid.module.css";
|
||||
type GridHeaderProps = {
|
||||
table: Table<IBaseRow>;
|
||||
pageId: string;
|
||||
// Passed explicitly to break memo when columns change
|
||||
// (table ref is stable from useReactTable, so memo won't fire without these)
|
||||
columnOrder: ColumnOrderState;
|
||||
columnVisibility: VisibilityState;
|
||||
properties: IBaseProperty[];
|
||||
loadedRowIds: string[];
|
||||
onPropertyCreated?: () => void;
|
||||
getColumnOrder: () => string[];
|
||||
onColumnReorder?: (columnId: string, finishIndex: number) => void;
|
||||
};
|
||||
|
||||
export const GridHeader = memo(function GridHeader({
|
||||
@@ -27,6 +27,8 @@ export const GridHeader = memo(function GridHeader({
|
||||
properties,
|
||||
loadedRowIds,
|
||||
onPropertyCreated,
|
||||
getColumnOrder,
|
||||
onColumnReorder,
|
||||
}: GridHeaderProps) {
|
||||
const headerGroups = table.getHeaderGroups();
|
||||
const propertyById = useMemo(() => {
|
||||
@@ -44,6 +46,8 @@ export const GridHeader = memo(function GridHeader({
|
||||
property={propertyById.get(header.column.id)}
|
||||
loadedRowIds={loadedRowIds}
|
||||
pageId={pageId}
|
||||
getColumnOrder={getColumnOrder}
|
||||
onColumnReorder={onColumnReorder}
|
||||
/>
|
||||
))}
|
||||
<CreatePropertyPopover
|
||||
|
||||
Reference in New Issue
Block a user