From d767c9bce1b4e51b1bc1319ec2ddfa816de3bcd3 Mon Sep 17 00:00:00 2001 From: linonetwo Date: Fri, 12 Dec 2025 01:32:41 +0800 Subject: [PATCH] fix: drag --- .../PromptConfigForm/store/arrayFieldStore.ts | 54 ++++++++- .../templates/ArrayFieldItemTemplate.tsx | 21 +++- .../templates/ArrayFieldTemplate.tsx | 69 ++++++++---- .../SortableWorkspaceSelectorList.tsx | 106 ++++++++++++------ 4 files changed, 185 insertions(+), 65 deletions(-) diff --git a/src/pages/ChatTabContent/components/PromptPreviewDialog/PromptConfigForm/store/arrayFieldStore.ts b/src/pages/ChatTabContent/components/PromptPreviewDialog/PromptConfigForm/store/arrayFieldStore.ts index f6636b31..8d7b8f86 100644 --- a/src/pages/ChatTabContent/components/PromptPreviewDialog/PromptConfigForm/store/arrayFieldStore.ts +++ b/src/pages/ChatTabContent/components/PromptPreviewDialog/PromptConfigForm/store/arrayFieldStore.ts @@ -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; /** Map of field path -> array of item data (for stable rendering) */ itemsData: Record; - /** Map of field path -> array order (indices) */ + /** Map of field path -> array order (indices) - used for optimistic rendering during drag */ itemsOrder: Record; /** Map of field path -> index -> move callbacks */ moveCallbacks: Record>; + /** Map of field path -> array of stable unique IDs for dnd-kit */ + stableItemIds: Record; + /** Map of field path -> whether there's a pending reorder (optimistic update in progress) */ + pendingReorder: Record; } 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()( @@ -67,6 +81,7 @@ export const useArrayFieldStore = create()( 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()( 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()( // 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()( 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()( 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()( 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()( 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), + ); }); }, diff --git a/src/pages/ChatTabContent/components/PromptPreviewDialog/PromptConfigForm/templates/ArrayFieldItemTemplate.tsx b/src/pages/ChatTabContent/components/PromptPreviewDialog/PromptConfigForm/templates/ArrayFieldItemTemplate.tsx index 7eff588c..df036a6e 100644 --- a/src/pages/ChatTabContent/components/PromptPreviewDialog/PromptConfigForm/templates/ArrayFieldItemTemplate.tsx +++ b/src/pages/ChatTabContent/components/PromptPreviewDialog/PromptConfigForm/templates/ArrayFieldItemTemplate.tsx @@ -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 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 { @@ -80,7 +91,7 @@ export function ArrayFieldItemTemplate = (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(null); @@ -42,7 +47,7 @@ export const ArrayFieldTemplate: React.FC = (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 = (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 = (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 = (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 ( @@ -163,16 +186,22 @@ export const ArrayFieldTemplate: React.FC = (props) => > - {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 ( {item} @@ -181,7 +210,7 @@ export const ArrayFieldTemplate: React.FC = (props) => })} - + {activeItem ? ( (null); + // Track if we're waiting for backend to confirm the reorder + const pendingReorderReference = useRef(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 = {}; + 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 ( { - 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 = {}; - newWorkspacesList.forEach((workspace, index) => { - newWorkspaces[workspace.id] = workspace; - newWorkspaces[workspace.id].order = index; - }); - - await window.service.workspace.setWorkspaces(newWorkspaces); - }} + onDragEnd={handleDragEnd} > - {filteredWorkspacesList - .sort(workspaceSorter) - .map((workspace, index) => ( - - ))} + {filteredWorkspacesList.map((workspace, index) => ( + + ))} );