fix: drag

This commit is contained in:
linonetwo 2025-12-12 01:32:41 +08:00
parent 302deb213f
commit d767c9bce1
4 changed files with 185 additions and 65 deletions

View file

@ -9,6 +9,14 @@ interface ItemMoveCallbacks {
onMoveDown: () => void;
}
/** Counter for generating unique IDs */
let itemIdCounter = 0;
/** Generate a unique stable ID for an array item */
function generateItemId(): string {
return `item-${Date.now()}-${itemIdCounter++}`;
}
/**
* Store for managing array field state
* Tracks expanded state and ordering for each array field instance
@ -18,10 +26,14 @@ interface ArrayFieldState {
expandedStates: Record<string, boolean[]>;
/** Map of field path -> array of item data (for stable rendering) */
itemsData: Record<string, unknown[]>;
/** Map of field path -> array order (indices) */
/** Map of field path -> array order (indices) - used for optimistic rendering during drag */
itemsOrder: Record<string, number[]>;
/** Map of field path -> index -> move callbacks */
moveCallbacks: Record<string, Record<number, ItemMoveCallbacks>>;
/** Map of field path -> array of stable unique IDs for dnd-kit */
stableItemIds: Record<string, string[]>;
/** Map of field path -> whether there's a pending reorder (optimistic update in progress) */
pendingReorder: Record<string, boolean>;
}
interface ArrayFieldActions {
@ -29,9 +41,9 @@ interface ArrayFieldActions {
setItemExpanded: (fieldPath: string, itemIndex: number, expanded: boolean) => void;
/** Initialize array field state */
initializeField: (fieldPath: string, itemCount: number, itemsData: unknown[]) => void;
/** Move item to new position */
/** Move item to new position (optimistic update for drag-and-drop) */
moveItem: (fieldPath: string, oldIndex: number, newIndex: number) => void;
/** Update items data when form data changes */
/** Update items data when form data changes (from RJSF) */
updateItemsData: (fieldPath: string, itemsData: unknown[]) => void;
/** Clean up field state when unmounted */
cleanupField: (fieldPath: string) => void;
@ -46,6 +58,8 @@ const initialState: ArrayFieldState = {
itemsData: {},
itemsOrder: {},
moveCallbacks: {},
stableItemIds: {},
pendingReorder: {},
};
export const useArrayFieldStore = create<ArrayFieldState & ArrayFieldActions>()(
@ -67,6 +81,7 @@ export const useArrayFieldStore = create<ArrayFieldState & ArrayFieldActions>()(
if (!state.expandedStates[fieldPath] || state.expandedStates[fieldPath].length !== itemCount) {
state.expandedStates[fieldPath] = Array.from({ length: itemCount }, () => false);
state.itemsOrder[fieldPath] = Array.from({ length: itemCount }, (_, index) => index);
state.stableItemIds[fieldPath] = Array.from({ length: itemCount }, () => generateItemId());
}
state.itemsData[fieldPath] = itemsData;
});
@ -76,7 +91,11 @@ export const useArrayFieldStore = create<ArrayFieldState & ArrayFieldActions>()(
set((state) => {
const order = state.itemsOrder[fieldPath];
const expanded = state.expandedStates[fieldPath];
if (!order || !expanded) return;
const stableIds = state.stableItemIds[fieldPath];
if (!order || !expanded || !stableIds) return;
// Mark that we have a pending reorder (optimistic update)
state.pendingReorder[fieldPath] = true;
// Move in order array
const [movedOrderItem] = order.splice(oldIndex, 1);
@ -85,6 +104,10 @@ export const useArrayFieldStore = create<ArrayFieldState & ArrayFieldActions>()(
// Move in expanded states
const [movedExpandedItem] = expanded.splice(oldIndex, 1);
expanded.splice(newIndex, 0, movedExpandedItem);
// Move in stable IDs array
const [movedStableId] = stableIds.splice(oldIndex, 1);
stableIds.splice(newIndex, 0, movedStableId);
});
},
@ -92,10 +115,11 @@ export const useArrayFieldStore = create<ArrayFieldState & ArrayFieldActions>()(
set((state) => {
const previousLength = state.itemsData[fieldPath]?.length ?? 0;
const newLength = itemsData.length;
const hasPendingReorder = state.pendingReorder[fieldPath] ?? false;
state.itemsData[fieldPath] = itemsData;
// Adjust order and expanded states if length changed
// Adjust order, expanded states, and stable IDs if length changed
if (newLength !== previousLength) {
if (newLength > previousLength) {
// Items added
@ -106,9 +130,13 @@ export const useArrayFieldStore = create<ArrayFieldState & ArrayFieldActions>()(
if (!state.expandedStates[fieldPath]) {
state.expandedStates[fieldPath] = [];
}
if (!state.stableItemIds[fieldPath]) {
state.stableItemIds[fieldPath] = [];
}
for (let addedIndex = 0; addedIndex < addedCount; addedIndex++) {
state.itemsOrder[fieldPath].push(previousLength + addedIndex);
state.expandedStates[fieldPath].push(false);
state.stableItemIds[fieldPath].push(generateItemId());
}
} else {
// Items removed
@ -120,7 +148,17 @@ export const useArrayFieldStore = create<ArrayFieldState & ArrayFieldActions>()(
if (state.expandedStates[fieldPath]) {
state.expandedStates[fieldPath].splice(newLength);
}
if (state.stableItemIds[fieldPath]) {
state.stableItemIds[fieldPath].splice(newLength);
}
}
// Clear pending reorder flag on length change
state.pendingReorder[fieldPath] = false;
} else if (hasPendingReorder && state.itemsOrder[fieldPath]) {
// Length is same and we had a pending reorder - RJSF has re-rendered with updated data
// Reset itemsOrder to natural order since items array now reflects the new order
state.itemsOrder[fieldPath] = Array.from({ length: newLength }, (_, index) => index);
state.pendingReorder[fieldPath] = false;
}
});
},
@ -139,6 +177,12 @@ export const useArrayFieldStore = create<ArrayFieldState & ArrayFieldActions>()(
state.moveCallbacks = Object.fromEntries(
Object.entries(state.moveCallbacks).filter(([key]) => key !== fieldPath),
);
state.stableItemIds = Object.fromEntries(
Object.entries(state.stableItemIds).filter(([key]) => key !== fieldPath),
);
state.pendingReorder = Object.fromEntries(
Object.entries(state.pendingReorder).filter(([key]) => key !== fieldPath),
);
});
},

View file

@ -1,4 +1,5 @@
import { useSortable } from '@dnd-kit/sortable';
import { defaultAnimateLayoutChanges, useSortable } from '@dnd-kit/sortable';
import type { AnimateLayoutChanges } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import DragHandleIcon from '@mui/icons-material/DragHandle';
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
@ -10,6 +11,12 @@ import { useTranslation } from 'react-i18next';
import { ArrayItemProvider, useArrayItemContext } from '../context/ArrayItemContext';
import { useArrayFieldStore } from '../store/arrayFieldStore';
/**
* Custom animateLayoutChanges that always animates when wasDragging is true.
* This ensures smooth transitions after drag ends when items are reordered.
*/
const animateLayoutChanges: AnimateLayoutChanges = (arguments_) => defaultAnimateLayoutChanges({ ...arguments_, wasDragging: true });
/**
* Custom Array Field Item Template with collapse and dnd-kit drag-and-drop support
* Uses zustand store for state management to avoid re-render flashing
@ -32,6 +39,10 @@ export function ArrayFieldItemTemplate<T = unknown, S extends RJSFSchema = RJSFS
const expanded = useArrayFieldStore(
useCallback((state) => state.expandedStates[fieldPath]?.[index] ?? false, [fieldPath, index]),
);
// Get stable item ID from store for dnd-kit
const stableItemId = useArrayFieldStore(
useCallback((state) => state.stableItemIds[fieldPath]?.[index] ?? `item-${index}`, [fieldPath, index]),
);
const setItemExpanded = useArrayFieldStore((state) => state.setItemExpanded);
const registerMoveCallbacks = useArrayFieldStore((state) => state.registerMoveCallbacks);
@ -45,10 +56,10 @@ export function ArrayFieldItemTemplate<T = unknown, S extends RJSFSchema = RJSFS
}
}, [fieldPath, index, buttonsProps.onMoveUpItem, buttonsProps.onMoveDownItem, buttonsProps.hasMoveUp, buttonsProps.hasMoveDown, registerMoveCallbacks]);
// Use dnd-kit sortable
const sortableId = `item-${index}`;
// Use dnd-kit sortable with stable ID from store
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: sortableId,
id: stableItemId,
animateLayoutChanges,
});
const handleToggleExpanded = useCallback(() => {
@ -80,7 +91,7 @@ export function ArrayFieldItemTemplate<T = unknown, S extends RJSFSchema = RJSFS
return (
<Box
id={sortableId}
id={stableItemId}
ref={setNodeRef}
style={style}
sx={{

View file

@ -32,6 +32,11 @@ export const ArrayFieldTemplate: React.FC<ArrayFieldTemplateProps> = (props) =>
const initializeField = useArrayFieldStore((state) => state.initializeField);
const updateItemsData = useArrayFieldStore((state) => state.updateItemsData);
const cleanupField = useArrayFieldStore((state) => state.cleanupField);
const moveItem = useArrayFieldStore((state) => state.moveItem);
// Get stable item IDs from store - these persist across re-renders
const stableItemIds = useArrayFieldStore((state) => state.stableItemIds[fieldPath] ?? []);
// Get items order from store - used for optimistic rendering during drag
const itemsOrder = useArrayFieldStore((state) => state.itemsOrder[fieldPath] ?? []);
// Track active drag item for overlay
const [activeId, setActiveId] = useState<string | null>(null);
@ -42,7 +47,7 @@ export const ArrayFieldTemplate: React.FC<ArrayFieldTemplateProps> = (props) =>
initializeField(fieldPath, items.length, itemsData);
}, [fieldPath, items.length, initializeField]);
// Update store when formData changes
// Update store when formData changes (from RJSF)
useEffect(() => {
const itemsData = Array.isArray(formData) ? formData : [];
updateItemsData(fieldPath, itemsData);
@ -64,10 +69,14 @@ export const ArrayFieldTemplate: React.FC<ArrayFieldTemplateProps> = (props) =>
}),
);
// Generate stable IDs for sortable items
// Use stable IDs from store, fallback to index-based IDs if store not initialized yet
const itemIds = useMemo(() => {
if (stableItemIds.length === items.length) {
return stableItemIds;
}
// Fallback during initialization
return items.map((_, index) => `item-${index}`);
}, [items.length]);
}, [stableItemIds, items.length]);
const handleDragStart = useCallback((event: DragStartEvent) => {
setActiveId(String(event.active.id));
@ -77,19 +86,33 @@ export const ArrayFieldTemplate: React.FC<ArrayFieldTemplateProps> = (props) =>
const { active, over } = event;
setActiveId(null);
if (!over || active.id === over.id) return;
if (!over || active.id === over.id) {
return;
}
const oldIndex = Number.parseInt(String(active.id).replace('item-', ''), 10);
const newIndex = Number.parseInt(String(over.id).replace('item-', ''), 10);
// Find indices by looking up the stable IDs
const oldIndex = itemIds.indexOf(String(active.id));
const newIndex = itemIds.indexOf(String(over.id));
if (Number.isNaN(oldIndex) || Number.isNaN(newIndex)) return;
if (!formData || !formContext?.onFormDataChange || !formContext.rootFormData) return;
if (oldIndex === -1 || newIndex === -1) {
return;
}
if (!formData || !formContext?.onFormDataChange || !formContext.rootFormData) {
return;
}
// Use arrayMove from dnd-kit to reorder the array
// IMPORTANT: Update store state FIRST (synchronously) for optimistic rendering
// This moves stableItemIds, expandedStates, and itemsOrder together
// The component will immediately re-render with the new order, avoiding the
// ~500-700ms delay while waiting for RJSF to update formData
moveItem(fieldPath, oldIndex, newIndex);
// Create the new array data with reordered items
const newArrayData = arrayMove([...formData], oldIndex, newIndex);
// Update the root form data with the reordered array
// Navigate to the array location using fieldPathId.path
// This triggers RJSF to re-render, but the UI already shows the new order
// thanks to the optimistic update above
const path = fieldPathId?.path;
if (!path || path.length === 0) {
// If no path, this array is the root (unlikely but handle it)
@ -112,19 +135,19 @@ export const ArrayFieldTemplate: React.FC<ArrayFieldTemplateProps> = (props) =>
current[finalKey] = newArrayData;
formContext.onFormDataChange(newRootData as never);
}, [formData, formContext, fieldPathId]);
}, [formData, formContext, fieldPathId, itemIds, moveItem, fieldPath]);
const handleDragCancel = useCallback(() => {
setActiveId(null);
}, []);
// Find active item for drag overlay
// Find active item for drag overlay using stable IDs
const activeItem = useMemo(() => {
if (!activeId) return null;
const activeIndex = Number.parseInt(activeId.replace('item-', ''), 10);
if (Number.isNaN(activeIndex)) return null;
const activeIndex = itemIds.indexOf(activeId);
if (activeIndex === -1) return null;
return items[activeIndex];
}, [activeId, items]);
}, [activeId, items, itemIds]);
return (
<ArrayContainer>
@ -163,16 +186,22 @@ export const ArrayFieldTemplate: React.FC<ArrayFieldTemplateProps> = (props) =>
>
<SortableContext items={itemIds} strategy={verticalListSortingStrategy}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{items.map((item, index) => {
const itemData = formData?.[index];
{itemsOrder.map((originalIndex, renderPosition) => {
// itemsOrder[renderPosition] tells us which original item should be at this position
// This enables optimistic rendering - store updates immediately, RJSF updates later
const item = items[originalIndex];
const itemData = formData?.[originalIndex];
const stableId = itemIds[renderPosition];
if (!item) return null;
return (
<ArrayItemProvider
key={itemIds[index]}
key={stableId}
isInArrayItem
arrayItemCollapsible
itemData={itemData}
itemIndex={index}
itemIndex={renderPosition}
arrayFieldPath={fieldPath}
>
{item}
@ -181,7 +210,7 @@ export const ArrayFieldTemplate: React.FC<ArrayFieldTemplateProps> = (props) =>
})}
</Box>
</SortableContext>
<DragOverlay>
<DragOverlay dropAnimation={null}>
{activeItem
? (
<Box

View file

@ -1,12 +1,11 @@
import { DndContext, PointerSensor, useSensor, useSensors } from '@dnd-kit/core';
import { DndContext, DragEndEvent, PointerSensor, useSensor, useSensors } from '@dnd-kit/core';
import { restrictToVerticalAxis } from '@dnd-kit/modifiers';
import { arrayMove, SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
import { useMemo } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { PageType } from '@/constants/pageTypes';
import { WindowNames } from '@services/windows/WindowProperties';
import { IWorkspace, IWorkspaceWithMetadata } from '@services/workspaces/interface';
import { workspaceSorter } from '@services/workspaces/utilities';
import { SortableWorkspaceSelectorButton } from './SortableWorkspaceSelectorButton';
export interface ISortableListProps {
@ -26,53 +25,90 @@ export function SortableWorkspaceSelectorList({ workspacesList, showSideBarText,
const isMiniWindow = window.meta().windowName === WindowNames.tidgiMiniWindow;
// Optimistic order state - stores workspace IDs in the order they should be displayed
// This updates immediately on drag end, before the backend confirms the change
const [optimisticOrder, setOptimisticOrder] = useState<string[] | null>(null);
// Track if we're waiting for backend to confirm the reorder
const pendingReorderReference = useRef<boolean>(false);
// Filter out 'add' workspace in mini window
const filteredWorkspacesList = useMemo(() => {
const baseFilteredList = useMemo(() => {
if (isMiniWindow) {
return workspacesList.filter((workspace) => workspace.pageType !== PageType.add);
}
return workspacesList;
}, [isMiniWindow, workspacesList]);
// Apply optimistic order if present, otherwise use natural order from props
const filteredWorkspacesList = useMemo(() => {
if (optimisticOrder === null) {
// No optimistic order, sort by order property
return [...baseFilteredList].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
}
// Apply optimistic order
const orderMap = new Map(optimisticOrder.map((id, index) => [id, index]));
return [...baseFilteredList].sort((a, b) => {
const orderA = orderMap.get(a.id) ?? a.order ?? 0;
const orderB = orderMap.get(b.id) ?? b.order ?? 0;
return orderA - orderB;
});
}, [baseFilteredList, optimisticOrder]);
// When workspacesList updates from backend, clear optimistic order if pending
useEffect(() => {
if (pendingReorderReference.current) {
pendingReorderReference.current = false;
setOptimisticOrder(null);
}
}, [workspacesList]);
const workspaceIDs = filteredWorkspacesList.map((workspace) => workspace.id);
const handleDragEnd = useCallback(async (event: DragEndEvent) => {
const { active, over } = event;
if (over === null || active.id === over.id) return;
const activeId = String(active.id);
const overId = String(over.id);
const oldIndex = filteredWorkspacesList.findIndex(workspace => workspace.id === activeId);
const newIndex = filteredWorkspacesList.findIndex(workspace => workspace.id === overId);
if (oldIndex === -1 || newIndex === -1) return;
// OPTIMISTIC UPDATE: Immediately update the display order
const newOrderedList = arrayMove(filteredWorkspacesList, oldIndex, newIndex);
const newOrder = newOrderedList.map(w => w.id);
setOptimisticOrder(newOrder);
pendingReorderReference.current = true;
// Prepare data for backend update
const newWorkspaces: Record<string, IWorkspace> = {};
newOrderedList.forEach((workspace, index) => {
newWorkspaces[workspace.id] = { ...workspace };
newWorkspaces[workspace.id].order = index;
});
// Update backend (this will eventually trigger workspacesList update via Observable)
await window.service.workspace.setWorkspaces(newWorkspaces);
}, [filteredWorkspacesList]);
return (
<DndContext
sensors={dndSensors}
modifiers={[restrictToVerticalAxis]}
onDragEnd={async ({ active, over }) => {
if (over === null || active.id === over.id) return;
const activeId = String(active.id);
const overId = String(over.id);
const oldIndex = workspacesList.findIndex(workspace => workspace.id === activeId);
const newIndex = workspacesList.findIndex(workspace => workspace.id === overId);
if (oldIndex === -1 || newIndex === -1) return;
const newWorkspacesList = arrayMove(workspacesList, oldIndex, newIndex);
const newWorkspaces: Record<string, IWorkspace> = {};
newWorkspacesList.forEach((workspace, index) => {
newWorkspaces[workspace.id] = workspace;
newWorkspaces[workspace.id].order = index;
});
await window.service.workspace.setWorkspaces(newWorkspaces);
}}
onDragEnd={handleDragEnd}
>
<SortableContext items={workspaceIDs} strategy={verticalListSortingStrategy}>
{filteredWorkspacesList
.sort(workspaceSorter)
.map((workspace, index) => (
<SortableWorkspaceSelectorButton
key={`item-${workspace.id}`}
index={index}
workspace={workspace}
showSidebarTexts={showSideBarText}
showSideBarIcon={showSideBarIcon}
/>
))}
{filteredWorkspacesList.map((workspace, index) => (
<SortableWorkspaceSelectorButton
key={`item-${workspace.id}`}
index={index}
workspace={workspace}
showSidebarTexts={showSideBarText}
showSideBarIcon={showSideBarIcon}
/>
))}
</SortableContext>
</DndContext>
);