diff --git a/packages/ai-workspace-common/src/components/canvas/index.tsx b/packages/ai-workspace-common/src/components/canvas/index.tsx index 2ae934651..01dfb18df 100644 --- a/packages/ai-workspace-common/src/components/canvas/index.tsx +++ b/packages/ai-workspace-common/src/components/canvas/index.tsx @@ -54,6 +54,7 @@ import { useUserStore } from '@refly-packages/ai-workspace-common/stores/user'; import { useUpdateSettings } from '@refly-packages/ai-workspace-common/queries'; import { IconCreateDocument } from '@refly-packages/ai-workspace-common/components/common/icon'; import { useUploadImage } from '@refly-packages/ai-workspace-common/hooks/use-upload-image'; +import { useCanvasSync } from '@refly-packages/ai-workspace-common/hooks/canvas/use-canvas-sync'; const selectionStyles = ` .react-flow__selection { @@ -501,6 +502,7 @@ const Flow = memo(({ canvasId }: { canvasId: string }) => { ); const { handleUploadImage } = useUploadImage(); + const { undoManager } = useCanvasSync(); // Add drag and drop handlers const handleDragOver = useCallback((event: React.DragEvent) => { @@ -521,6 +523,41 @@ const Flow = memo(({ canvasId }: { canvasId: string }) => { [addNode, reactFlowInstance], ); + const handleKeyDown = (e: KeyboardEvent) => { + const target = e.target as HTMLElement; + + // Ignore input, textarea and contentEditable elements + if ( + target.tagName === 'INPUT' || + target.tagName === 'TEXTAREA' || + target.contentEditable === 'true' + ) { + return; + } + + // Check for mod key (Command on Mac, Ctrl on Windows/Linux) + const isModKey = e.metaKey || e.ctrlKey; + + if (isModKey && e.key.toLowerCase() === 'z') { + e.preventDefault(); + if (e.shiftKey) { + // Mod+Shift+Z for Redo + undoManager.redo(); + } else { + // Mod+Z for Undo + undoManager.undo(); + } + } + }; + + // Set up keyboard shortcuts for undo/redo + useEffect(() => { + if (!undoManager) return; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [undoManager]); + return ( void; onToggleSizeMode: () => void; nodeSizeMode: 'compact' | 'adaptive'; + undoManager: UndoManager; t: TFunction; } @@ -78,8 +81,26 @@ const TooltipButton = memo(({ tooltip, children, ...buttonProps }: TooltipButton // Update component definitions const ActionButtons = memo( - ({ onFitView, onLayout, onToggleSizeMode, nodeSizeMode, t }: ActionButtonsProps) => ( + ({ onFitView, onLayout, onToggleSizeMode, nodeSizeMode, undoManager, t }: ActionButtonsProps) => ( <> + undoManager?.undo()} + className={buttonClass} + > + + + + undoManager?.redo()} + className={buttonClass} + > + + + + + = memo(({ mode, changeM setNodeSizeMode: state.setNodeSizeMode, })); const { updateAllNodesSizeMode } = useNodeOperations(); + const { undoManager } = useCanvasSync(); // Add handler for size mode toggle const handleToggleSizeMode = useCallback(() => { @@ -329,6 +351,7 @@ export const LayoutControl: React.FC = memo(({ mode, changeM onLayout={onLayout} onToggleSizeMode={handleToggleSizeMode} nodeSizeMode={nodeSizeMode} + undoManager={undoManager} t={t} /> diff --git a/packages/ai-workspace-common/src/hooks/canvas/use-canvas-sync.ts b/packages/ai-workspace-common/src/hooks/canvas/use-canvas-sync.ts index ba69ba4b9..78bb40bb9 100644 --- a/packages/ai-workspace-common/src/hooks/canvas/use-canvas-sync.ts +++ b/packages/ai-workspace-common/src/hooks/canvas/use-canvas-sync.ts @@ -3,11 +3,25 @@ import { useCanvasContext } from '../../context/canvas'; import { useThrottledCallback } from 'use-debounce'; import { Edge } from '@xyflow/react'; import { CanvasNode } from '../../components/canvas/nodes'; +import { UndoManager } from 'yjs'; +import { omit } from '@refly/utils'; export const useCanvasSync = () => { const { provider } = useCanvasContext(); const ydoc = provider.document; + const undoManager = useMemo(() => { + if (!ydoc) return null; + + // Create UndoManager tracking title, nodes and edges + return new UndoManager( + [ydoc.getText('title'), ydoc.getArray('nodes'), ydoc.getArray('edges')], + { + captureTimeout: 1000, + }, + ); + }, [ydoc]); + const syncFunctions = useMemo(() => { const syncTitleToYDoc = (title: string) => { ydoc?.transact(() => { @@ -29,7 +43,7 @@ export const useCanvasSync = () => { ydoc?.transact(() => { const yEdges = ydoc?.getArray('edges'); yEdges?.delete(0, yEdges?.length ?? 0); - yEdges?.push(edges); + yEdges?.push(edges.map((edge) => omit(edge, ['style']))); }); }; @@ -54,5 +68,6 @@ export const useCanvasSync = () => { ...syncFunctions, throttledSyncNodesToYDoc, throttledSyncEdgesToYDoc, + undoManager, }; }; diff --git a/packages/i18n/src/en-US/ui.ts b/packages/i18n/src/en-US/ui.ts index 95a60b7b2..475b5b842 100644 --- a/packages/i18n/src/en-US/ui.ts +++ b/packages/i18n/src/en-US/ui.ts @@ -670,6 +670,8 @@ const translations = { mouse: 'Mouse Mode', touchpad: 'Touchpad Mode', tooltip: { + undo: 'Undo', + redo: 'Redo', zoom: 'Zoom Percentage', zoomIn: 'Zoom In', zoomOut: 'Zoom Out', diff --git a/packages/i18n/src/zh-Hans/ui.ts b/packages/i18n/src/zh-Hans/ui.ts index cc8294af4..47ed57b3f 100644 --- a/packages/i18n/src/zh-Hans/ui.ts +++ b/packages/i18n/src/zh-Hans/ui.ts @@ -663,6 +663,8 @@ const translations = { mouse: '鼠标模式', touchpad: '触控板模式', tooltip: { + undo: '撤销', + redo: '重做', zoom: '缩放百分比', zoomIn: '放大', zoomOut: '缩小',