Selaa lähdekoodia

feat: undo/redo for workflow editor (#3927)

Co-authored-by: StyleZhang <jasonapring2015@outlook.com>
Pascal M 10 kuukautta sitten
vanhempi
commit
af9448e6f2
38 muutettua tiedostoa jossa 1493 lisäystä ja 419 poistoa
  1. 3 0
      ArrowUturnLeft.svg
  2. 0 3
      web/app/components/base/icons/assets/vender/line/arrows/flip-backward.svg
  3. 0 5
      web/app/components/base/icons/assets/vender/line/arrows/flip-forward.svg
  4. 0 29
      web/app/components/base/icons/src/vender/line/arrows/FlipBackward.json
  5. 0 16
      web/app/components/base/icons/src/vender/line/arrows/FlipBackward.tsx
  6. 0 39
      web/app/components/base/icons/src/vender/line/arrows/FlipForward.json
  7. 0 16
      web/app/components/base/icons/src/vender/line/arrows/FlipForward.tsx
  8. 0 2
      web/app/components/base/icons/src/vender/line/arrows/index.ts
  9. 7 1
      web/app/components/workflow/candidate-node.tsx
  10. 65 0
      web/app/components/workflow/header/undo-redo.tsx
  11. 273 0
      web/app/components/workflow/header/view-workflow-history.tsx
  12. 1 0
      web/app/components/workflow/hooks/index.ts
  13. 6 2
      web/app/components/workflow/hooks/use-edges-interactions.ts
  14. 76 7
      web/app/components/workflow/hooks/use-nodes-interactions.ts
  15. 150 0
      web/app/components/workflow/hooks/use-workflow-history.ts
  16. 4 1
      web/app/components/workflow/hooks/use-workflow.ts
  17. 32 9
      web/app/components/workflow/index.tsx
  18. 8 2
      web/app/components/workflow/nodes/_base/panel.tsx
  19. 5 1
      web/app/components/workflow/nodes/iteration/add-block.tsx
  20. 6 3
      web/app/components/workflow/note-node/hooks.ts
  21. 5 0
      web/app/components/workflow/note-node/note-editor/editor.tsx
  22. 8 1
      web/app/components/workflow/operator/index.tsx
  23. 120 0
      web/app/components/workflow/workflow-history-store.tsx
  24. 28 0
      web/i18n/de-DE/workflow.ts
  25. 28 0
      web/i18n/en-US/workflow.ts
  26. 28 0
      web/i18n/fr-FR/workflow.ts
  27. 28 0
      web/i18n/hi-IN/workflow.ts
  28. 28 0
      web/i18n/ja-JP/workflow.ts
  29. 28 0
      web/i18n/ko-KR/workflow.ts
  30. 28 0
      web/i18n/pl-PL/workflow.ts
  31. 28 0
      web/i18n/pt-BR/workflow.ts
  32. 28 0
      web/i18n/ro-RO/workflow.ts
  33. 28 0
      web/i18n/uk-UA/workflow.ts
  34. 28 0
      web/i18n/vi-VN/workflow.ts
  35. 28 0
      web/i18n/zh-Hans/workflow.ts
  36. 27 0
      web/i18n/zh-Hant/workflow.ts
  37. 3 1
      web/package.json
  38. 358 281
      web/yarn.lock

+ 3 - 0
ArrowUturnLeft.svg

@@ -0,0 +1,3 @@
+<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
+  <path stroke-linecap="round" stroke-linejoin="round" d="M9 15 3 9m0 0 6-6M3 9h12a6 6 0 0 1 0 12h-3" />
+</svg>

+ 0 - 3
web/app/components/base/icons/assets/vender/line/arrows/flip-backward.svg

@@ -1,3 +0,0 @@
-<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M3 9H16.5C18.9853 9 21 11.0147 21 13.5C21 15.9853 18.9853 18 16.5 18H12M3 9L7 5M3 9L7 13" stroke="black" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
-</svg>

+ 0 - 5
web/app/components/base/icons/assets/vender/line/arrows/flip-forward.svg

@@ -1,5 +0,0 @@
-<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
-<g id="Icon">
-<path id="Icon_2" d="M14 6.00016H5C3.34315 6.00016 2 7.34331 2 9.00016C2 10.657 3.34315 12.0002 5 12.0002H8M14 6.00016L11.3333 3.3335M14 6.00016L11.3333 8.66683" stroke="#667085" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
-</g>
-</svg>

+ 0 - 29
web/app/components/base/icons/src/vender/line/arrows/FlipBackward.json

@@ -1,29 +0,0 @@
-{
-	"icon": {
-		"type": "element",
-		"isRootNode": true,
-		"name": "svg",
-		"attributes": {
-			"width": "24",
-			"height": "24",
-			"viewBox": "0 0 24 24",
-			"fill": "none",
-			"xmlns": "http://www.w3.org/2000/svg"
-		},
-		"children": [
-			{
-				"type": "element",
-				"name": "path",
-				"attributes": {
-					"d": "M3 9H16.5C18.9853 9 21 11.0147 21 13.5C21 15.9853 18.9853 18 16.5 18H12M3 9L7 5M3 9L7 13",
-					"stroke": "currentColor",
-					"stroke-width": "2",
-					"stroke-linecap": "round",
-					"stroke-linejoin": "round"
-				},
-				"children": []
-			}
-		]
-	},
-	"name": "FlipBackward"
-}

+ 0 - 16
web/app/components/base/icons/src/vender/line/arrows/FlipBackward.tsx

@@ -1,16 +0,0 @@
-// GENERATE BY script
-// DON NOT EDIT IT MANUALLY
-
-import * as React from 'react'
-import data from './FlipBackward.json'
-import IconBase from '@/app/components/base/icons/IconBase'
-import type { IconBaseProps, IconData } from '@/app/components/base/icons/IconBase'
-
-const Icon = React.forwardRef<React.MutableRefObject<SVGElement>, Omit<IconBaseProps, 'data'>>((
-  props,
-  ref,
-) => <IconBase {...props} ref={ref} data={data as IconData} />)
-
-Icon.displayName = 'FlipBackward'
-
-export default Icon

+ 0 - 39
web/app/components/base/icons/src/vender/line/arrows/FlipForward.json

@@ -1,39 +0,0 @@
-{
-	"icon": {
-		"type": "element",
-		"isRootNode": true,
-		"name": "svg",
-		"attributes": {
-			"width": "16",
-			"height": "16",
-			"viewBox": "0 0 16 16",
-			"fill": "none",
-			"xmlns": "http://www.w3.org/2000/svg"
-		},
-		"children": [
-			{
-				"type": "element",
-				"name": "g",
-				"attributes": {
-					"id": "Icon"
-				},
-				"children": [
-					{
-						"type": "element",
-						"name": "path",
-						"attributes": {
-							"id": "Icon_2",
-							"d": "M14 6.00016H5C3.34315 6.00016 2 7.34331 2 9.00016C2 10.657 3.34315 12.0002 5 12.0002H8M14 6.00016L11.3333 3.3335M14 6.00016L11.3333 8.66683",
-							"stroke": "currentColor",
-							"stroke-width": "1.5",
-							"stroke-linecap": "round",
-							"stroke-linejoin": "round"
-						},
-						"children": []
-					}
-				]
-			}
-		]
-	},
-	"name": "FlipForward"
-}

+ 0 - 16
web/app/components/base/icons/src/vender/line/arrows/FlipForward.tsx

@@ -1,16 +0,0 @@
-// GENERATE BY script
-// DON NOT EDIT IT MANUALLY
-
-import * as React from 'react'
-import data from './FlipForward.json'
-import IconBase from '@/app/components/base/icons/IconBase'
-import type { IconBaseProps, IconData } from '@/app/components/base/icons/IconBase'
-
-const Icon = React.forwardRef<React.MutableRefObject<SVGElement>, Omit<IconBaseProps, 'data'>>((
-  props,
-  ref,
-) => <IconBase {...props} ref={ref} data={data as IconData} />)
-
-Icon.displayName = 'FlipForward'
-
-export default Icon

+ 0 - 2
web/app/components/base/icons/src/vender/line/arrows/index.ts

@@ -3,8 +3,6 @@ export { default as ArrowUpRight } from './ArrowUpRight'
 export { default as ChevronDownDouble } from './ChevronDownDouble'
 export { default as ChevronRight } from './ChevronRight'
 export { default as ChevronSelectorVertical } from './ChevronSelectorVertical'
-export { default as FlipBackward } from './FlipBackward'
-export { default as FlipForward } from './FlipForward'
 export { default as RefreshCcw01 } from './RefreshCcw01'
 export { default as RefreshCw05 } from './RefreshCw05'
 export { default as ReverseLeft } from './ReverseLeft'

+ 7 - 1
web/app/components/workflow/candidate-node.tsx

@@ -12,7 +12,7 @@ import {
   useStore,
   useWorkflowStore,
 } from './store'
-import { useNodesInteractions } from './hooks'
+import { WorkflowHistoryEvent, useNodesInteractions, useWorkflowHistory } from './hooks'
 import { CUSTOM_NODE } from './constants'
 import CustomNode from './nodes'
 import CustomNoteNode from './note-node'
@@ -26,6 +26,7 @@ const CandidateNode = () => {
   const mousePosition = useStore(s => s.mousePosition)
   const { zoom } = useViewport()
   const { handleNodeSelect } = useNodesInteractions()
+  const { saveStateToHistory } = useWorkflowHistory()
 
   useEventListener('click', (e) => {
     const { candidateNode, mousePosition } = workflowStore.getState()
@@ -53,6 +54,11 @@ const CandidateNode = () => {
         })
       })
       setNodes(newNodes)
+      if (candidateNode.type === CUSTOM_NOTE_NODE)
+        saveStateToHistory(WorkflowHistoryEvent.NoteAdd)
+      else
+        saveStateToHistory(WorkflowHistoryEvent.NodeAdd)
+
       workflowStore.setState({ candidateNode: undefined })
 
       if (candidateNode.type === CUSTOM_NOTE_NODE)

+ 65 - 0
web/app/components/workflow/header/undo-redo.tsx

@@ -0,0 +1,65 @@
+import type { FC } from 'react'
+import { memo, useEffect, useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import {
+  RiArrowGoBackLine,
+  RiArrowGoForwardFill,
+} from '@remixicon/react'
+import TipPopup from '../operator/tip-popup'
+import { useWorkflowHistoryStore } from '../workflow-history-store'
+import { useNodesReadOnly } from '@/app/components/workflow/hooks'
+import ViewWorkflowHistory from '@/app/components/workflow/header/view-workflow-history'
+
+export type UndoRedoProps = { handleUndo: () => void; handleRedo: () => void }
+const UndoRedo: FC<UndoRedoProps> = ({ handleUndo, handleRedo }) => {
+  const { t } = useTranslation()
+  const { store } = useWorkflowHistoryStore()
+  const [buttonsDisabled, setButtonsDisabled] = useState({ undo: true, redo: true })
+
+  useEffect(() => {
+    const unsubscribe = store.temporal.subscribe((state) => {
+      setButtonsDisabled({
+        undo: state.pastStates.length === 0,
+        redo: state.futureStates.length === 0,
+      })
+    })
+    return () => unsubscribe()
+  }, [store])
+
+  const { nodesReadOnly } = useNodesReadOnly()
+
+  return (
+    <div className='flex items-center p-0.5 rounded-lg border-[0.5px] border-gray-100 bg-white shadow-lg text-gray-500'>
+      <TipPopup title={t('workflow.common.undo')!} >
+        <div
+          data-tooltip-id='workflow.undo'
+          className={`
+        flex items-center px-1.5 w-8 h-8 rounded-md text-[13px] font-medium 
+        hover:bg-black/5 hover:text-gray-700 cursor-pointer select-none
+        ${(nodesReadOnly || buttonsDisabled.undo) && 'hover:bg-transparent opacity-50 !cursor-not-allowed'}
+      `}
+          onClick={() => !nodesReadOnly && !buttonsDisabled.undo && handleUndo()}
+        >
+          <RiArrowGoBackLine className='h-4 w-4' />
+        </div>
+      </TipPopup>
+      <TipPopup title={t('workflow.common.redo')!} >
+        <div
+          data-tooltip-id='workflow.redo'
+          className={`
+        flex items-center px-1.5 w-8 h-8 rounded-md text-[13px] font-medium 
+        hover:bg-black/5 hover:text-gray-700 cursor-pointer select-none
+        ${(nodesReadOnly || buttonsDisabled.redo) && 'hover:bg-transparent opacity-50 !cursor-not-allowed'}
+      `}
+          onClick={() => !nodesReadOnly && !buttonsDisabled.redo && handleRedo()}
+        >
+          <RiArrowGoForwardFill className='h-4 w-4' />
+        </div>
+      </TipPopup>
+      <div className="mx-[3px] w-[1px] h-3.5 bg-gray-200"></div>
+      <ViewWorkflowHistory />
+    </div>
+  )
+}
+
+export default memo(UndoRedo)

+ 273 - 0
web/app/components/workflow/header/view-workflow-history.tsx

@@ -0,0 +1,273 @@
+import {
+  memo,
+  useCallback,
+  useMemo,
+  useState,
+} from 'react'
+import cn from 'classnames'
+import {
+  RiCloseLine,
+  RiHistoryLine,
+} from '@remixicon/react'
+import { useTranslation } from 'react-i18next'
+import { useShallow } from 'zustand/react/shallow'
+import { useStoreApi } from 'reactflow'
+import {
+  useNodesReadOnly,
+  useWorkflowHistory,
+} from '../hooks'
+import TipPopup from '../operator/tip-popup'
+import type { WorkflowHistoryState } from '../workflow-history-store'
+import {
+  PortalToFollowElem,
+  PortalToFollowElemContent,
+  PortalToFollowElemTrigger,
+} from '@/app/components/base/portal-to-follow-elem'
+import { useStore as useAppStore } from '@/app/components/app/store'
+
+type ChangeHistoryEntry = {
+  label: string
+  index: number
+  state: Partial<WorkflowHistoryState>
+}
+
+type ChangeHistoryList = {
+  pastStates: ChangeHistoryEntry[]
+  futureStates: ChangeHistoryEntry[]
+  statesCount: number
+}
+
+const ViewWorkflowHistory = () => {
+  const { t } = useTranslation()
+  const [open, setOpen] = useState(false)
+
+  const { nodesReadOnly } = useNodesReadOnly()
+  const { setCurrentLogItem, setShowMessageLogModal } = useAppStore(useShallow(state => ({
+    appDetail: state.appDetail,
+    setCurrentLogItem: state.setCurrentLogItem,
+    setShowMessageLogModal: state.setShowMessageLogModal,
+  })))
+  const reactflowStore = useStoreApi()
+  const { store, getHistoryLabel } = useWorkflowHistory()
+
+  const { pastStates, futureStates, undo, redo, clear } = store.temporal.getState()
+  const [currentHistoryStateIndex, setCurrentHistoryStateIndex] = useState<number>(0)
+
+  const handleClearHistory = useCallback(() => {
+    clear()
+    setCurrentHistoryStateIndex(0)
+  }, [clear])
+
+  const handleSetState = useCallback(({ index }: ChangeHistoryEntry) => {
+    const { setEdges, setNodes } = reactflowStore.getState()
+    const diff = currentHistoryStateIndex + index
+    if (diff === 0)
+      return
+
+    if (diff < 0)
+      undo(diff * -1)
+    else
+      redo(diff)
+
+    const { edges, nodes } = store.getState()
+    if (edges.length === 0 && nodes.length === 0)
+      return
+
+    setEdges(edges)
+    setNodes(nodes)
+  }, [currentHistoryStateIndex, reactflowStore, redo, store, undo])
+
+  const calculateStepLabel = useCallback((index: number) => {
+    if (!index)
+      return
+
+    const count = index < 0 ? index * -1 : index
+    return `${index > 0 ? t('workflow.changeHistory.stepForward', { count }) : t('workflow.changeHistory.stepBackward', { count })}`
+  }
+  , [t])
+
+  const calculateChangeList: ChangeHistoryList = useMemo(() => {
+    const filterList = (list: any, startIndex = 0, reverse = false) => list.map((state: Partial<WorkflowHistoryState>, index: number) => {
+      return {
+        label: state.workflowHistoryEvent && getHistoryLabel(state.workflowHistoryEvent),
+        index: reverse ? list.length - 1 - index - startIndex : index - startIndex,
+        state,
+      }
+    }).filter(Boolean)
+
+    const historyData = {
+      pastStates: filterList(pastStates, pastStates.length).reverse(),
+      futureStates: filterList([...futureStates, (!pastStates.length && !futureStates.length) ? undefined : store.getState()].filter(Boolean), 0, true),
+      statesCount: 0,
+    }
+
+    historyData.statesCount = pastStates.length + futureStates.length
+
+    return {
+      ...historyData,
+      statesCount: pastStates.length + futureStates.length,
+    }
+  }, [futureStates, getHistoryLabel, pastStates, store])
+
+  return (
+    (
+      <PortalToFollowElem
+        placement='bottom-end'
+        offset={{
+          mainAxis: 4,
+          crossAxis: 131,
+        }}
+        open={open}
+        onOpenChange={setOpen}
+      >
+        <PortalToFollowElemTrigger onClick={() => !nodesReadOnly && setOpen(v => !v)}>
+          <TipPopup
+            title={t('workflow.changeHistory.title')}
+          >
+            <div
+              className={`
+                flex items-center justify-center w-8 h-8 rounded-md hover:bg-black/5 cursor-pointer
+                ${open && 'bg-primary-50'} ${nodesReadOnly && 'bg-primary-50 opacity-50 !cursor-not-allowed'}
+              `}
+              onClick={() => {
+                if (nodesReadOnly)
+                  return
+                setCurrentLogItem()
+                setShowMessageLogModal(false)
+              }}
+            >
+              <RiHistoryLine className={`w-4 h-4 hover:bg-black/5 hover:text-gray-700 ${open ? 'text-primary-600' : 'text-gray-500'}`} />
+            </div>
+          </TipPopup>
+        </PortalToFollowElemTrigger>
+        <PortalToFollowElemContent className='z-[12]'>
+          <div
+            className='flex flex-col ml-2 min-w-[240px] max-w-[360px] bg-white border-[0.5px] border-gray-200 shadow-xl rounded-xl overflow-y-auto'
+          >
+            <div className='sticky top-0 bg-white flex items-center justify-between px-4 pt-3 text-base font-semibold text-gray-900'>
+              <div className='grow'>{t('workflow.changeHistory.title')}</div>
+              <div
+                className='shrink-0 flex items-center justify-center w-6 h-6 cursor-pointer'
+                onClick={() => {
+                  setCurrentLogItem()
+                  setShowMessageLogModal(false)
+                  setOpen(false)
+                }}
+              >
+                <RiCloseLine className='w-4 h-4 text-gray-500' />
+              </div>
+            </div>
+            {
+              (
+                <div
+                  className='p-2 overflow-y-auto'
+                  style={{
+                    maxHeight: 'calc(1 / 2 * 100vh)',
+                  }}
+                >
+                  {
+                    !calculateChangeList.statesCount && (
+                      <div className='py-12'>
+                        <RiHistoryLine className='mx-auto mb-2 w-8 h-8 text-gray-300' />
+                        <div className='text-center text-[13px] text-gray-400'>
+                          {t('workflow.changeHistory.placeholder')}
+                        </div>
+                      </div>
+                    )
+                  }
+                  <div className='flex flex-col'>
+                    {
+                      calculateChangeList.futureStates.map((item: ChangeHistoryEntry) => (
+                        <div
+                          key={item?.index}
+                          className={cn(
+                            'flex mb-0.5 px-2 py-[7px] rounded-lg hover:bg-primary-50 cursor-pointer',
+                            item?.index === currentHistoryStateIndex && 'bg-primary-50',
+                          )}
+                          onClick={() => {
+                            handleSetState(item)
+                            setOpen(false)
+                          }}
+                        >
+                          <div>
+                            <div
+                              className={cn(
+                                'flex items-center text-[13px] font-medium leading-[18px]',
+                                item?.index === currentHistoryStateIndex && 'text-primary-600',
+                              )}
+                            >
+                              {item?.label || t('workflow.changeHistory.sessionStart')} ({calculateStepLabel(item?.index)}{item?.index === currentHistoryStateIndex && t('workflow.changeHistory.currentState')})
+                            </div>
+                          </div>
+                        </div>
+                      ))
+                    }
+                    {
+                      calculateChangeList.pastStates.map((item: ChangeHistoryEntry) => (
+                        <div
+                          key={item?.index}
+                          className={cn(
+                            'flex mb-0.5 px-2 py-[7px] rounded-lg hover:bg-primary-50 cursor-pointer',
+                            item?.index === calculateChangeList.statesCount - 1 && 'bg-primary-50',
+                          )}
+                          onClick={() => {
+                            handleSetState(item)
+                            setOpen(false)
+                          }}
+                        >
+                          <div>
+                            <div
+                              className={cn(
+                                'flex items-center text-[13px] font-medium leading-[18px]',
+                                item?.index === calculateChangeList.statesCount - 1 && 'text-primary-600',
+                              )}
+                            >
+                              {item?.label || t('workflow.changeHistory.sessionStart')} ({calculateStepLabel(item?.index)})
+                            </div>
+                          </div>
+                        </div>
+                      ))
+                    }
+                  </div>
+                </div>
+              )
+            }
+            {
+              !!calculateChangeList.statesCount && (
+                <>
+                  <div className="h-[1px] bg-gray-100" />
+                  <div
+                    className={cn(
+                      'flex my-0.5 px-2 py-[7px] rounded-lg cursor-pointer',
+                      'hover:bg-red-50 hover:text-red-600',
+                    )}
+                    onClick={() => {
+                      handleClearHistory()
+                      setOpen(false)
+                    }}
+                  >
+                    <div>
+                      <div
+                        className={cn(
+                          'flex items-center text-[13px] font-medium leading-[18px]',
+                        )}
+                      >
+                        {t('workflow.changeHistory.clearHistory')}
+                      </div>
+                    </div>
+                  </div>
+                </>
+              )
+            }
+            <div className="px-3 w-[240px] py-2 text-xs text-gray-500" >
+              <div className="flex items-center mb-1 h-[22px] font-medium uppercase">{t('workflow.changeHistory.hint')}</div>
+              <div className="mb-1 text-gray-700 leading-[18px]">{t('workflow.changeHistory.hintText')}</div>
+            </div>
+          </div>
+        </PortalToFollowElemContent>
+      </PortalToFollowElem>
+    )
+  )
+}
+
+export default memo(ViewWorkflowHistory)

+ 1 - 0
web/app/components/workflow/hooks/index.ts

@@ -13,3 +13,4 @@ export * from './use-selection-interactions'
 export * from './use-panel-interactions'
 export * from './use-workflow-start-run'
 export * from './use-nodes-layout'
+export * from './use-workflow-history'

+ 6 - 2
web/app/components/workflow/hooks/use-edges-interactions.ts

@@ -13,11 +13,13 @@ import type {
 import { getNodesConnectedSourceOrTargetHandleIdsMap } from '../utils'
 import { useNodesSyncDraft } from './use-nodes-sync-draft'
 import { useNodesReadOnly } from './use-workflow'
+import { WorkflowHistoryEvent, useWorkflowHistory } from './use-workflow-history'
 
 export const useEdgesInteractions = () => {
   const store = useStoreApi()
   const { handleSyncWorkflowDraft } = useNodesSyncDraft()
   const { getNodesReadOnly } = useNodesReadOnly()
+  const { saveStateToHistory } = useWorkflowHistory()
 
   const handleEdgeEnter = useCallback<EdgeMouseHandler>((_, edge) => {
     if (getNodesReadOnly())
@@ -83,7 +85,8 @@ export const useEdgesInteractions = () => {
     })
     setEdges(newEdges)
     handleSyncWorkflowDraft()
-  }, [store, handleSyncWorkflowDraft, getNodesReadOnly])
+    saveStateToHistory(WorkflowHistoryEvent.EdgeDeleteByDeleteBranch)
+  }, [getNodesReadOnly, store, handleSyncWorkflowDraft, saveStateToHistory])
 
   const handleEdgeDelete = useCallback(() => {
     if (getNodesReadOnly())
@@ -123,7 +126,8 @@ export const useEdgesInteractions = () => {
     })
     setEdges(newEdges)
     handleSyncWorkflowDraft()
-  }, [store, getNodesReadOnly, handleSyncWorkflowDraft])
+    saveStateToHistory(WorkflowHistoryEvent.EdgeDelete)
+  }, [getNodesReadOnly, store, handleSyncWorkflowDraft, saveStateToHistory])
 
   const handleEdgesChange = useCallback<OnEdgesChange>((changes) => {
     if (getNodesReadOnly())

+ 76 - 7
web/app/components/workflow/hooks/use-nodes-interactions.ts

@@ -42,18 +42,21 @@ import { CUSTOM_NOTE_NODE } from '../note-node/constants'
 import type { IterationNodeType } from '../nodes/iteration/types'
 import type { VariableAssignerNodeType } from '../nodes/variable-assigner/types'
 import { useNodeIterationInteractions } from '../nodes/iteration/use-interactions'
+import { useWorkflowHistoryStore } from '../workflow-history-store'
 import { useNodesSyncDraft } from './use-nodes-sync-draft'
 import { useHelpline } from './use-helpline'
 import {
   useNodesReadOnly,
   useWorkflow,
 } from './use-workflow'
+import { WorkflowHistoryEvent, useWorkflowHistory } from './use-workflow-history'
 
 export const useNodesInteractions = () => {
   const { t } = useTranslation()
   const store = useStoreApi()
   const workflowStore = useWorkflowStore()
   const reactflow = useReactFlow()
+  const { store: workflowHistoryStore } = useWorkflowHistoryStore()
   const { handleSyncWorkflowDraft } = useNodesSyncDraft()
   const {
     getAfterNodesInSameBranch,
@@ -66,6 +69,8 @@ export const useNodesInteractions = () => {
   } = useNodeIterationInteractions()
   const dragNodeStartPosition = useRef({ x: 0, y: 0 } as { x: number; y: number })
 
+  const { saveStateToHistory, undo, redo } = useWorkflowHistory()
+
   const handleNodeDragStart = useCallback<NodeDragHandler>((_, node) => {
     workflowStore.setState({ nodeAnimation: false })
 
@@ -137,8 +142,13 @@ export const useNodesInteractions = () => {
       setHelpLineHorizontal()
       setHelpLineVertical()
       handleSyncWorkflowDraft()
+
+      if (x !== 0 && y !== 0) {
+        // selecting a note will trigger a drag stop event with x and y as 0
+        saveStateToHistory(WorkflowHistoryEvent.NodeDragStop)
+      }
     }
-  }, [handleSyncWorkflowDraft, workflowStore, getNodesReadOnly])
+  }, [workflowStore, getNodesReadOnly, saveStateToHistory, handleSyncWorkflowDraft])
 
   const handleNodeEnter = useCallback<NodeMouseHandler>((_, node) => {
     if (getNodesReadOnly())
@@ -359,8 +369,10 @@ export const useNodesInteractions = () => {
       return filtered
     })
     setEdges(newEdges)
+
     handleSyncWorkflowDraft()
-  }, [store, handleSyncWorkflowDraft, getNodesReadOnly])
+    saveStateToHistory(WorkflowHistoryEvent.NodeConnect)
+  }, [getNodesReadOnly, store, handleSyncWorkflowDraft, saveStateToHistory])
 
   const handleNodeConnectStart = useCallback<OnConnectStart>((_, { nodeId, handleType, handleId }) => {
     if (getNodesReadOnly())
@@ -544,7 +556,13 @@ export const useNodesInteractions = () => {
     })
     setEdges(newEdges)
     handleSyncWorkflowDraft()
-  }, [store, handleSyncWorkflowDraft, getNodesReadOnly, workflowStore, t])
+
+    if (currentNode.type === 'custom-note')
+      saveStateToHistory(WorkflowHistoryEvent.NoteDelete)
+
+    else
+      saveStateToHistory(WorkflowHistoryEvent.NodeDelete)
+  }, [getNodesReadOnly, store, handleSyncWorkflowDraft, saveStateToHistory, workflowStore, t])
 
   const handleNodeAdd = useCallback<OnNodeAdd>((
     {
@@ -877,7 +895,8 @@ export const useNodesInteractions = () => {
       setEdges(newEdges)
     }
     handleSyncWorkflowDraft()
-  }, [store, workflowStore, handleSyncWorkflowDraft, getAfterNodesInSameBranch, getNodesReadOnly, t])
+    saveStateToHistory(WorkflowHistoryEvent.NodeAdd)
+  }, [getNodesReadOnly, store, t, handleSyncWorkflowDraft, saveStateToHistory, workflowStore, getAfterNodesInSameBranch])
 
   const handleNodeChange = useCallback((
     currentNodeId: string,
@@ -955,7 +974,9 @@ export const useNodesInteractions = () => {
     })
     setEdges(newEdges)
     handleSyncWorkflowDraft()
-  }, [store, handleSyncWorkflowDraft, getNodesReadOnly, t])
+
+    saveStateToHistory(WorkflowHistoryEvent.NodeChange)
+  }, [getNodesReadOnly, store, t, handleSyncWorkflowDraft, saveStateToHistory])
 
   const handleNodeCancelRunningStatus = useCallback(() => {
     const {
@@ -1107,9 +1128,10 @@ export const useNodesInteractions = () => {
       })
 
       setNodes([...nodes, ...nodesToPaste])
+      saveStateToHistory(WorkflowHistoryEvent.NodePaste)
       handleSyncWorkflowDraft()
     }
-  }, [getNodesReadOnly, store, workflowStore, handleSyncWorkflowDraft, reactflow, handleNodeIterationChildrenCopy])
+  }, [getNodesReadOnly, workflowStore, store, reactflow, saveStateToHistory, handleSyncWorkflowDraft, handleNodeIterationChildrenCopy])
 
   const handleNodesDuplicate = useCallback(() => {
     if (getNodesReadOnly())
@@ -1208,7 +1230,52 @@ export const useNodesInteractions = () => {
     })
     setNodes(newNodes)
     handleSyncWorkflowDraft()
-  }, [store, getNodesReadOnly, handleSyncWorkflowDraft])
+    saveStateToHistory(WorkflowHistoryEvent.NodeResize)
+  }, [getNodesReadOnly, store, handleSyncWorkflowDraft, saveStateToHistory])
+
+  const handleHistoryBack = useCallback(() => {
+    if (getNodesReadOnly())
+      return
+
+    const {
+      shortcutsDisabled,
+    } = workflowStore.getState()
+
+    if (shortcutsDisabled)
+      return
+
+    const { setEdges, setNodes } = store.getState()
+    undo()
+
+    const { edges, nodes } = workflowHistoryStore.getState()
+    if (edges.length === 0 && nodes.length === 0)
+      return
+
+    setEdges(edges)
+    setNodes(nodes)
+  }, [store, undo, workflowHistoryStore, workflowStore, getNodesReadOnly])
+
+  const handleHistoryForward = useCallback(() => {
+    if (getNodesReadOnly())
+      return
+
+    const {
+      shortcutsDisabled,
+    } = workflowStore.getState()
+
+    if (shortcutsDisabled)
+      return
+
+    const { setEdges, setNodes } = store.getState()
+    redo()
+
+    const { edges, nodes } = workflowHistoryStore.getState()
+    if (edges.length === 0 && nodes.length === 0)
+      return
+
+    setEdges(edges)
+    setNodes(nodes)
+  }, [redo, store, workflowHistoryStore, workflowStore, getNodesReadOnly])
 
   return {
     handleNodeDragStart,
@@ -1232,5 +1299,7 @@ export const useNodesInteractions = () => {
     handleNodesDuplicate,
     handleNodesDelete,
     handleNodeResize,
+    handleHistoryBack,
+    handleHistoryForward,
   }
 }

+ 150 - 0
web/app/components/workflow/hooks/use-workflow-history.ts

@@ -0,0 +1,150 @@
+import {
+  useCallback,
+  useRef, useState,
+} from 'react'
+import { debounce } from 'lodash-es'
+import {
+  useStoreApi,
+} from 'reactflow'
+import { useTranslation } from 'react-i18next'
+import { useWorkflowHistoryStore } from '../workflow-history-store'
+
+/**
+ * All supported Events that create a new history state.
+ * Current limitations:
+ * - InputChange events in Node Panels do not trigger state changes.
+ * - Resizing UI elements does not trigger state changes.
+ */
+export enum WorkflowHistoryEvent {
+  NodeTitleChange = 'NodeTitleChange',
+  NodeDescriptionChange = 'NodeDescriptionChange',
+  NodeDragStop = 'NodeDragStop',
+  NodeChange = 'NodeChange',
+  NodeConnect = 'NodeConnect',
+  NodePaste = 'NodePaste',
+  NodeDelete = 'NodeDelete',
+  EdgeDelete = 'EdgeDelete',
+  EdgeDeleteByDeleteBranch = 'EdgeDeleteByDeleteBranch',
+  NodeAdd = 'NodeAdd',
+  NodeResize = 'NodeResize',
+  NoteAdd = 'NoteAdd',
+  NoteChange = 'NoteChange',
+  NoteDelete = 'NoteDelete',
+  LayoutOrganize = 'LayoutOrganize',
+}
+
+export const useWorkflowHistory = () => {
+  const store = useStoreApi()
+  const { store: workflowHistoryStore } = useWorkflowHistoryStore()
+  const { t } = useTranslation()
+
+  const [undoCallbacks, setUndoCallbacks] = useState<any[]>([])
+  const [redoCallbacks, setRedoCallbacks] = useState<any[]>([])
+
+  const onUndo = useCallback((callback: unknown) => {
+    setUndoCallbacks((prev: any) => [...prev, callback])
+    return () => setUndoCallbacks(prev => prev.filter(cb => cb !== callback))
+  }, [])
+
+  const onRedo = useCallback((callback: unknown) => {
+    setRedoCallbacks((prev: any) => [...prev, callback])
+    return () => setRedoCallbacks(prev => prev.filter(cb => cb !== callback))
+  }, [])
+
+  const undo = useCallback(() => {
+    workflowHistoryStore.temporal.getState().undo()
+    undoCallbacks.forEach(callback => callback())
+  }, [undoCallbacks, workflowHistoryStore.temporal])
+
+  const redo = useCallback(() => {
+    workflowHistoryStore.temporal.getState().redo()
+    redoCallbacks.forEach(callback => callback())
+  }, [redoCallbacks, workflowHistoryStore.temporal])
+
+  // Some events may be triggered multiple times in a short period of time.
+  // We debounce the history state update to avoid creating multiple history states
+  // with minimal changes.
+  const saveStateToHistoryRef = useRef(debounce((event: WorkflowHistoryEvent) => {
+    workflowHistoryStore.setState({
+      workflowHistoryEvent: event,
+      nodes: store.getState().getNodes(),
+      edges: store.getState().edges,
+    })
+  }, 500))
+
+  const saveStateToHistory = useCallback((event: WorkflowHistoryEvent) => {
+    switch (event) {
+      case WorkflowHistoryEvent.NoteChange:
+        // Hint: Note change does not trigger when note text changes,
+        // because the note editors have their own history states.
+        saveStateToHistoryRef.current(event)
+        break
+      case WorkflowHistoryEvent.NodeTitleChange:
+      case WorkflowHistoryEvent.NodeDescriptionChange:
+      case WorkflowHistoryEvent.NodeDragStop:
+      case WorkflowHistoryEvent.NodeChange:
+      case WorkflowHistoryEvent.NodeConnect:
+      case WorkflowHistoryEvent.NodePaste:
+      case WorkflowHistoryEvent.NodeDelete:
+      case WorkflowHistoryEvent.EdgeDelete:
+      case WorkflowHistoryEvent.EdgeDeleteByDeleteBranch:
+      case WorkflowHistoryEvent.NodeAdd:
+      case WorkflowHistoryEvent.NodeResize:
+      case WorkflowHistoryEvent.NoteAdd:
+      case WorkflowHistoryEvent.LayoutOrganize:
+      case WorkflowHistoryEvent.NoteDelete:
+        saveStateToHistoryRef.current(event)
+        break
+      default:
+        // We do not create a history state for every event.
+        // Some events of reactflow may change things the user would not want to undo/redo.
+        // For example: UI state changes like selecting a node.
+        break
+    }
+  }, [])
+
+  const getHistoryLabel = useCallback((event: WorkflowHistoryEvent) => {
+    switch (event) {
+      case WorkflowHistoryEvent.NodeTitleChange:
+        return t('workflow.changeHistory.nodeTitleChange')
+      case WorkflowHistoryEvent.NodeDescriptionChange:
+        return t('workflow.changeHistory.nodeDescriptionChange')
+      case WorkflowHistoryEvent.LayoutOrganize:
+      case WorkflowHistoryEvent.NodeDragStop:
+        return t('workflow.changeHistory.nodeDragStop')
+      case WorkflowHistoryEvent.NodeChange:
+        return t('workflow.changeHistory.nodeChange')
+      case WorkflowHistoryEvent.NodeConnect:
+        return t('workflow.changeHistory.nodeConnect')
+      case WorkflowHistoryEvent.NodePaste:
+        return t('workflow.changeHistory.nodePaste')
+      case WorkflowHistoryEvent.NodeDelete:
+        return t('workflow.changeHistory.nodeDelete')
+      case WorkflowHistoryEvent.NodeAdd:
+        return t('workflow.changeHistory.nodeAdd')
+      case WorkflowHistoryEvent.EdgeDelete:
+      case WorkflowHistoryEvent.EdgeDeleteByDeleteBranch:
+        return t('workflow.changeHistory.edgeDelete')
+      case WorkflowHistoryEvent.NodeResize:
+        return t('workflow.changeHistory.nodeResize')
+      case WorkflowHistoryEvent.NoteAdd:
+        return t('workflow.changeHistory.noteAdd')
+      case WorkflowHistoryEvent.NoteChange:
+        return t('workflow.changeHistory.noteChange')
+      case WorkflowHistoryEvent.NoteDelete:
+        return t('workflow.changeHistory.noteDelete')
+      default:
+        return 'Unknown Event'
+    }
+  }, [t])
+
+  return {
+    store: workflowHistoryStore,
+    saveStateToHistory,
+    getHistoryLabel,
+    undo,
+    redo,
+    onUndo,
+    onRedo,
+  }
+}

+ 4 - 1
web/app/components/workflow/hooks/use-workflow.ts

@@ -42,6 +42,7 @@ import { findUsedVarNodes, getNodeOutputVars, updateNodeVars } from '../nodes/_b
 import { useNodesExtraData } from './use-nodes-data'
 import { useWorkflowTemplate } from './use-workflow-template'
 import { useNodesSyncDraft } from './use-nodes-sync-draft'
+import { WorkflowHistoryEvent, useWorkflowHistory } from './use-workflow-history'
 import { useStore as useAppStore } from '@/app/components/app/store'
 import {
   fetchNodesDefaultConfigs,
@@ -71,6 +72,7 @@ export const useWorkflow = () => {
   const workflowStore = useWorkflowStore()
   const nodesExtraData = useNodesExtraData()
   const { handleSyncWorkflowDraft } = useNodesSyncDraft()
+  const { saveStateToHistory } = useWorkflowHistory()
 
   const setPanelWidth = useCallback((width: number) => {
     localStorage.setItem('workflow-node-panel-width', `${width}`)
@@ -122,10 +124,11 @@ export const useWorkflow = () => {
       y: 0,
       zoom,
     })
+    saveStateToHistory(WorkflowHistoryEvent.LayoutOrganize)
     setTimeout(() => {
       handleSyncWorkflowDraft()
     })
-  }, [store, reactflow, handleSyncWorkflowDraft, workflowStore])
+  }, [workflowStore, store, reactflow, saveStateToHistory, handleSyncWorkflowDraft])
 
   const getTreeLeafNodes = useCallback((nodeId: string) => {
     const {

+ 32 - 9
web/app/components/workflow/index.tsx

@@ -76,6 +76,7 @@ import {
   ITERATION_CHILDREN_Z_INDEX,
   WORKFLOW_DATA_UPDATE,
 } from './constants'
+import { WorkflowHistoryProvider, useWorkflowHistoryStore } from './workflow-history-store'
 import Loading from '@/app/components/base/loading'
 import { FeaturesProvider } from '@/app/components/base/features'
 import type { Features as FeaturesData } from '@/app/components/base/features/types'
@@ -181,6 +182,8 @@ const Workflow: FC<WorkflowProps> = memo(({
   useEventListener('keydown', (e) => {
     if ((e.key === 'd' || e.key === 'D') && (e.ctrlKey || e.metaKey))
       e.preventDefault()
+    if ((e.key === 'z' || e.key === 'Z') && (e.ctrlKey || e.metaKey))
+      e.preventDefault()
   })
   useEventListener('mousemove', (e) => {
     const containerClientRect = workflowContainerRef.current?.getBoundingClientRect()
@@ -212,6 +215,8 @@ const Workflow: FC<WorkflowProps> = memo(({
     handleNodesPaste,
     handleNodesDuplicate,
     handleNodesDelete,
+    handleHistoryBack,
+    handleHistoryForward,
   } = useNodesInteractions()
   const {
     handleEdgeEnter,
@@ -242,6 +247,8 @@ const Workflow: FC<WorkflowProps> = memo(({
     },
   })
 
+  const { shortcutsEnabled: workflowHistoryShortcutsEnabled } = useWorkflowHistoryStore()
+
   useKeyPress('delete', handleNodesDelete)
   useKeyPress(['delete', 'backspace'], handleEdgeDelete)
   useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.c`, (e) => {
@@ -258,6 +265,18 @@ const Workflow: FC<WorkflowProps> = memo(({
   }, { exactMatch: true, useCapture: true })
   useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.d`, handleNodesDuplicate, { exactMatch: true, useCapture: true })
   useKeyPress(`${getKeyboardKeyCodeBySystem('alt')}.r`, handleStartWorkflowRun, { exactMatch: true, useCapture: true })
+  useKeyPress(`${getKeyboardKeyCodeBySystem('alt')}.r`, handleStartWorkflowRun, { exactMatch: true, useCapture: true })
+  useKeyPress(
+    `${getKeyboardKeyCodeBySystem('ctrl')}.z`,
+    () => workflowHistoryShortcutsEnabled && handleHistoryBack(),
+    { exactMatch: true, useCapture: true },
+  )
+
+  useKeyPress(
+    [`${getKeyboardKeyCodeBySystem('ctrl')}.y`, `${getKeyboardKeyCodeBySystem('ctrl')}.shift.z`],
+    () => workflowHistoryShortcutsEnabled && handleHistoryForward(),
+    { exactMatch: true, useCapture: true },
+  )
 
   return (
     <div
@@ -271,9 +290,9 @@ const Workflow: FC<WorkflowProps> = memo(({
     >
       <SyncingDataModal />
       <CandidateNode />
-      <Header />
+      <Header/>
       <Panel />
-      <Operator />
+      <Operator handleRedo={handleHistoryForward} handleUndo={handleHistoryBack} />
       {
         showFeaturesPanel && <Features />
       }
@@ -403,13 +422,17 @@ const WorkflowWrap = memo(() => {
 
   return (
     <ReactFlowProvider>
-      <FeaturesProvider features={initialFeatures}>
-        <Workflow
-          nodes={nodesData}
-          edges={edgesData}
-          viewport={data?.graph.viewport}
-        />
-      </FeaturesProvider>
+      <WorkflowHistoryProvider
+        nodes={nodesData}
+        edges={edgesData} >
+        <FeaturesProvider features={initialFeatures}>
+          <Workflow
+            nodes={nodesData}
+            edges={edgesData}
+            viewport={data?.graph.viewport}
+          />
+        </FeaturesProvider>
+      </WorkflowHistoryProvider>
     </ReactFlowProvider>
   )
 })

+ 8 - 2
web/app/components/workflow/nodes/_base/panel.tsx

@@ -24,6 +24,7 @@ import {
 import { useResizePanel } from './hooks/use-resize-panel'
 import BlockIcon from '@/app/components/workflow/block-icon'
 import {
+  WorkflowHistoryEvent,
   useAvailableBlocks,
   useNodeDataUpdate,
   useNodesInteractions,
@@ -31,6 +32,7 @@ import {
   useNodesSyncDraft,
   useToolIcon,
   useWorkflow,
+  useWorkflowHistory,
 } from '@/app/components/workflow/hooks'
 import { canRunBySingle } from '@/app/components/workflow/utils'
 import TooltipPlus from '@/app/components/base/tooltip-plus'
@@ -77,6 +79,8 @@ const BasePanel: FC<BasePanelProps> = ({
     onResize: handleResize,
   })
 
+  const { saveStateToHistory } = useWorkflowHistory()
+
   const {
     handleNodeDataUpdate,
     handleNodeDataUpdateWithSyncDraft,
@@ -84,10 +88,12 @@ const BasePanel: FC<BasePanelProps> = ({
 
   const handleTitleBlur = useCallback((title: string) => {
     handleNodeDataUpdateWithSyncDraft({ id, data: { title } })
-  }, [handleNodeDataUpdateWithSyncDraft, id])
+    saveStateToHistory(WorkflowHistoryEvent.NodeTitleChange)
+  }, [handleNodeDataUpdateWithSyncDraft, id, saveStateToHistory])
   const handleDescriptionChange = useCallback((desc: string) => {
     handleNodeDataUpdateWithSyncDraft({ id, data: { desc } })
-  }, [handleNodeDataUpdateWithSyncDraft, id])
+    saveStateToHistory(WorkflowHistoryEvent.NodeDescriptionChange)
+  }, [handleNodeDataUpdateWithSyncDraft, id, saveStateToHistory])
 
   return (
     <div className={cn(

+ 5 - 1
web/app/components/workflow/nodes/iteration/add-block.tsx

@@ -13,8 +13,10 @@ import {
   generateNewNode,
 } from '../../utils'
 import {
+  WorkflowHistoryEvent,
   useAvailableBlocks,
   useNodesReadOnly,
+  useWorkflowHistory,
 } from '../../hooks'
 import { NODES_INITIAL_DATA } from '../../constants'
 import InsertBlock from './insert-block'
@@ -42,6 +44,7 @@ const AddBlock = ({
   const { nodesReadOnly } = useNodesReadOnly()
   const { availableNextBlocks } = useAvailableBlocks(BlockEnum.Start, true)
   const { availablePrevBlocks } = useAvailableBlocks(iterationNodeData.startNodeType, true)
+  const { saveStateToHistory } = useWorkflowHistory()
 
   const handleSelect = useCallback<OnSelectBlock>((type, toolDefaultValue) => {
     const {
@@ -78,7 +81,8 @@ const AddBlock = ({
       draft.push(newNode)
     })
     setNodes(newNodes)
-  }, [store, t, iterationNodeId])
+    saveStateToHistory(WorkflowHistoryEvent.NodeAdd)
+  }, [store, t, iterationNodeId, saveStateToHistory])
 
   const renderTriggerElement = useCallback((open: boolean) => {
     return (

+ 6 - 3
web/app/components/workflow/note-node/hooks.ts

@@ -1,14 +1,16 @@
 import { useCallback } from 'react'
 import type { EditorState } from 'lexical'
-import { useNodeDataUpdate } from '../hooks'
+import { WorkflowHistoryEvent, useNodeDataUpdate, useWorkflowHistory } from '../hooks'
 import type { NoteTheme } from './types'
 
 export const useNote = (id: string) => {
   const { handleNodeDataUpdateWithSyncDraft } = useNodeDataUpdate()
+  const { saveStateToHistory } = useWorkflowHistory()
 
   const handleThemeChange = useCallback((theme: NoteTheme) => {
     handleNodeDataUpdateWithSyncDraft({ id, data: { theme } })
-  }, [handleNodeDataUpdateWithSyncDraft, id])
+    saveStateToHistory(WorkflowHistoryEvent.NoteChange)
+  }, [handleNodeDataUpdateWithSyncDraft, id, saveStateToHistory])
 
   const handleEditorChange = useCallback((editorState: EditorState) => {
     if (!editorState?.isEmpty())
@@ -19,7 +21,8 @@ export const useNote = (id: string) => {
 
   const handleShowAuthorChange = useCallback((showAuthor: boolean) => {
     handleNodeDataUpdateWithSyncDraft({ id, data: { showAuthor } })
-  }, [handleNodeDataUpdateWithSyncDraft, id])
+    saveStateToHistory(WorkflowHistoryEvent.NoteChange)
+  }, [handleNodeDataUpdateWithSyncDraft, id, saveStateToHistory])
 
   return {
     handleThemeChange,

+ 5 - 0
web/app/components/workflow/note-node/note-editor/editor.tsx

@@ -13,6 +13,7 @@ import { ListPlugin } from '@lexical/react/LexicalListPlugin'
 import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary'
 import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin'
 import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin'
+import { useWorkflowHistoryStore } from '../../workflow-history-store'
 import LinkEditorPlugin from './plugins/link-editor-plugin'
 import FormatDetectorPlugin from './plugins/format-detector-plugin'
 // import TreeView from '@/app/components/base/prompt-editor/plugins/tree-view'
@@ -32,12 +33,16 @@ const Editor = ({
     onChange?.(editorState)
   }, [onChange])
 
+  const { setShortcutsEnabled } = useWorkflowHistoryStore()
+
   return (
     <div className='relative'>
       <RichTextPlugin
         contentEditable={
           <div>
             <ContentEditable
+              onFocus={() => setShortcutsEnabled(false)}
+              onBlur={() => setShortcutsEnabled(true)}
               spellCheck={false}
               className='w-full h-full outline-none caret-primary-600'
               placeholder={placeholder}

+ 8 - 1
web/app/components/workflow/operator/index.tsx

@@ -1,9 +1,15 @@
 import { memo } from 'react'
 import { MiniMap } from 'reactflow'
+import UndoRedo from '../header/undo-redo'
 import ZoomInOut from './zoom-in-out'
 import Control from './control'
 
-const Operator = () => {
+export type OperatorProps = {
+  handleUndo: () => void
+  handleRedo: () => void
+}
+
+const Operator = ({ handleUndo, handleRedo }: OperatorProps) => {
   return (
     <>
       <MiniMap
@@ -15,6 +21,7 @@ const Operator = () => {
       />
       <div className='flex items-center mt-1 gap-2 absolute left-4 bottom-4 z-[9]'>
         <ZoomInOut />
+        <UndoRedo handleUndo={handleUndo} handleRedo={handleRedo} />
         <Control />
       </div>
     </>

+ 120 - 0
web/app/components/workflow/workflow-history-store.tsx

@@ -0,0 +1,120 @@
+import { type ReactNode, createContext, useContext, useMemo, useState } from 'react'
+import { type StoreApi, create } from 'zustand'
+import { type TemporalState, temporal } from 'zundo'
+import isDeepEqual from 'fast-deep-equal'
+import type { Edge, Node } from './types'
+import type { WorkflowHistoryEvent } from './hooks'
+
+export const WorkflowHistoryStoreContext = createContext<WorkflowHistoryStoreContextType>({ store: null, shortcutsEnabled: true, setShortcutsEnabled: () => {} })
+export const Provider = WorkflowHistoryStoreContext.Provider
+
+export function WorkflowHistoryProvider({
+  nodes,
+  edges,
+  children,
+}: WorkflowWithHistoryProviderProps) {
+  const [shortcutsEnabled, setShortcutsEnabled] = useState(true)
+  const [store] = useState(() =>
+    createStore({
+      nodes,
+      edges,
+    }),
+  )
+
+  const contextValue = {
+    store,
+    shortcutsEnabled,
+    setShortcutsEnabled,
+  }
+
+  return (
+    <Provider value={contextValue}>
+      {children}
+    </Provider>
+  )
+}
+
+export function useWorkflowHistoryStore() {
+  const {
+    store,
+    shortcutsEnabled,
+    setShortcutsEnabled,
+  } = useContext(WorkflowHistoryStoreContext)
+  if (store === null)
+    throw new Error('useWorkflowHistoryStoreApi must be used within a WorkflowHistoryProvider')
+
+  return {
+    store: useMemo(
+      () => ({
+        getState: store.getState,
+        setState: (state: WorkflowHistoryState) => {
+          store.setState({
+            workflowHistoryEvent: state.workflowHistoryEvent,
+            nodes: state.nodes.map((node: Node) => ({ ...node, data: { ...node.data, selected: false } })),
+            edges: state.edges.map((edge: Edge) => ({ ...edge, selected: false }) as Edge),
+          })
+        },
+        subscribe: store.subscribe,
+        temporal: store.temporal,
+      }),
+      [store],
+    ),
+    shortcutsEnabled,
+    setShortcutsEnabled,
+  }
+}
+
+function createStore({
+  nodes: storeNodes,
+  edges: storeEdges,
+}: {
+  nodes: Node[]
+  edges: Edge[]
+}): WorkflowHistoryStoreApi {
+  const store = create(temporal<WorkflowHistoryState>(
+    (set, get) => {
+      return {
+        workflowHistoryEvent: undefined,
+        nodes: storeNodes,
+        edges: storeEdges,
+        getNodes: () => get().nodes,
+        setNodes: (nodes: Node[]) => set({ nodes }),
+        setEdges: (edges: Edge[]) => set({ edges }),
+      }
+    },
+    {
+      equality: (pastState, currentState) =>
+        isDeepEqual(pastState, currentState),
+    },
+  ),
+  )
+
+  return store
+}
+
+export type WorkflowHistoryStore = {
+  nodes: Node[]
+  edges: Edge[]
+  workflowHistoryEvent: WorkflowHistoryEvent | undefined
+}
+
+export type WorkflowHistoryActions = {
+  setNodes?: (nodes: Node[]) => void
+  setEdges?: (edges: Edge[]) => void
+}
+
+export type WorkflowHistoryState = WorkflowHistoryStore & WorkflowHistoryActions
+
+type WorkflowHistoryStoreContextType = {
+  store: ReturnType<typeof createStore> | null
+  shortcutsEnabled: boolean
+  setShortcutsEnabled: (enabled: boolean) => void
+}
+
+export type WorkflowHistoryStoreApi = StoreApi<WorkflowHistoryState> & { temporal: StoreApi<TemporalState<WorkflowHistoryState>> }
+
+export type WorkflowWithHistoryProviderProps = {
+  nodes: Node[]
+  edges: Edge[]
+  children: ReactNode
+}

+ 28 - 0
web/i18n/de-DE/workflow.ts

@@ -1,5 +1,7 @@
 const translation = {
   common: {
+    undo: 'Rückgängig',
+    redo: 'Wiederholen',
     editing: 'Bearbeitung',
     autoSaved: 'Automatisch gespeichert',
     unpublished: 'Unveröffentlicht',
@@ -68,6 +70,32 @@ const translation = {
     workflowAsToolTip: 'Nach dem Workflow-Update ist eine Neukonfiguration des Tools erforderlich.',
     viewDetailInTracingPanel: 'Details anzeigen',
   },
+  changeHistory: {
+    title: 'Änderungsverlauf',
+    placeholder: 'Du hast noch nichts geändert',
+    clearHistory: 'Änderungsverlauf löschen',
+    hint: 'Hinweis',
+    hintText: 'Änderungen werden im Änderungsverlauf aufgezeichnet, der für die Dauer dieser Sitzung auf Ihrem Gerät gespeichert wird. Dieser Verlauf wird gelöscht, wenn Sie den Editor verlassen.',
+    stepBackward_one: '{{count}} Schritt zurück',
+    stepBackward_other: '{{count}} Schritte zurück',
+    stepForward_one: '{{count}} Schritt vorwärts',
+    stepForward_other: '{{count}} Schritte vorwärts',
+    sessionStart: 'Sitzungsstart',
+    currentState: 'Aktueller Zustand',
+    nodeTitleChange: 'Blocktitel geändert',
+    nodeDescriptionChange: 'Blockbeschreibung geändert',
+    nodeDragStop: 'Block verschoben',
+    nodeChange: 'Block geändert',
+    nodeConnect: 'Block verbunden',
+    nodePaste: 'Block eingefügt',
+    nodeDelete: 'Block gelöscht',
+    nodeAdd: 'Block hinzugefügt',
+    nodeResize: 'Blockgröße geändert',
+    noteAdd: 'Notiz hinzugefügt',
+    noteChange: 'Notiz geändert',
+    noteDelete: 'Notiz gelöscht',
+    edgeDelete: 'Block getrennt',
+  },
   errorMsg: {
     fieldRequired: '{{field}} ist erforderlich',
     authRequired: 'Autorisierung ist erforderlich',

+ 28 - 0
web/i18n/en-US/workflow.ts

@@ -1,5 +1,7 @@
 const translation = {
   common: {
+    undo: 'Undo',
+    redo: 'Redo',
     editing: 'Editing',
     autoSaved: 'Auto-Saved',
     unpublished: 'Unpublished',
@@ -76,6 +78,32 @@ const translation = {
     importFailure: 'Import failure',
     importSuccess: 'Import success',
   },
+  changeHistory: {
+    title: 'Change History',
+    placeholder: 'You haven\'t changed anything yet',
+    clearHistory: 'Clear History',
+    hint: 'Hint',
+    hintText: 'Your editing actions are tracked in a change history, which is stored on your device for the duration of this session. This history will be cleared when you leave the editor.',
+    stepBackward_one: '{{count}} step backward',
+    stepBackward_other: '{{count}} steps backward',
+    stepForward_one: '{{count}} step forward',
+    stepForward_other: '{{count}} steps forward',
+    sessionStart: 'Session Start',
+    currentState: 'Current State',
+    nodeTitleChange: 'Block title changed',
+    nodeDescriptionChange: 'Block description changed',
+    nodeDragStop: 'Block moved',
+    nodeChange: 'Block changed',
+    nodeConnect: 'Block connected',
+    nodePaste: 'Block pasted',
+    nodeDelete: 'Block deleted',
+    nodeAdd: 'Block added',
+    nodeResize: 'Block resized',
+    noteAdd: 'Note added',
+    noteChange: 'Note changed',
+    noteDelete: 'Note deleted',
+    edgeDelete: 'Block disconnected',
+  },
   errorMsg: {
     fieldRequired: '{{field}} is required',
     authRequired: 'Authorization is required',

+ 28 - 0
web/i18n/fr-FR/workflow.ts

@@ -1,5 +1,7 @@
 const translation = {
   common: {
+    undo: 'Défaire',
+    redo: 'Réexécuter',
     editing: 'Édition',
     autoSaved: 'Sauvegardé automatiquement',
     unpublished: 'Non publié',
@@ -68,6 +70,32 @@ const translation = {
     workflowAsToolTip: 'Reconfiguration de l\'outil requise après la mise à jour du flux de travail.',
     viewDetailInTracingPanel: 'Voir les détails',
   },
+  changeHistory: {
+    title: 'Historique des modifications',
+    placeholder: 'Vous n\'avez encore rien modifié',
+    clearHistory: 'Effacer l\'historique',
+    hint: 'Conseil',
+    hintText: 'Vos actions d\'édition sont suivies dans un historique des modifications, qui est stocké sur votre appareil pour la durée de cette session. Cet historique sera effacé lorsque vous quitterez l\'éditeur.',
+    stepBackward_one: '{{count}} pas en arrière',
+    stepBackward_other: '{{count}} pas en arrière',
+    stepForward_one: '{{count}} pas en avant',
+    stepForward_other: '{{count}} pas en avant',
+    sessionStart: 'Début de la session',
+    currentState: 'État actuel',
+    nodeTitleChange: 'Titre du bloc modifié',
+    nodeDescriptionChange: 'Description du bloc modifiée',
+    nodeDragStop: 'Bloc déplacé',
+    nodeChange: 'Bloc modifié',
+    nodeConnect: 'Bloc connecté',
+    nodePaste: 'Bloc collé',
+    nodeDelete: 'Bloc supprimé',
+    nodeAdd: 'Bloc ajouté',
+    nodeResize: 'Bloc redimensionné',
+    noteAdd: 'Note ajoutée',
+    noteChange: 'Note modifiée',
+    noteDelete: 'Note supprimée',
+    edgeDelete: 'Bloc déconnecté',
+  },
   errorMsg: {
     fieldRequired: '{{field}} est requis',
     authRequired: 'Autorisation requise',

+ 28 - 0
web/i18n/hi-IN/workflow.ts

@@ -1,5 +1,7 @@
 const translation = {
   common: {
+    undo: 'पूर्ववत करें',
+    redo: 'फिर से करें',
     editing: 'संपादन',
     autoSaved: 'स्वतः सहेजा गया',
     unpublished: 'अप्रकाशित',
@@ -72,6 +74,32 @@ const translation = {
     viewDetailInTracingPanel: 'विवरण देखें',
     syncingData: 'डेटा सिंक हो रहा है, बस कुछ सेकंड।',
   },
+  changeHistory: {
+    title: 'परिवर्तन इतिहास',
+    placeholder: 'आपने अभी तक कुछ भी नहीं बदला है',
+    clearHistory: 'इतिहास साफ़ करें',
+    hint: 'संकेत',
+    hintText: 'आपके संपादन क्रियाओं को परिवर्तन इतिहास में ट्रैक किया जाता है, जो इस सत्र के दौरान आपके डिवाइस पर संग्रहीत होता है। जब आप संपादक छोड़ेंगे तो यह इतिहास साफ़ हो जाएगा।',
+    stepBackward_one: '{{count}} कदम पीछे',
+    stepBackward_other: '{{count}} कदम पीछे',
+    stepForward_one: '{{count}} कदम आगे',
+    stepForward_other: '{{count}} कदम आगे',
+    sessionStart: 'सत्र प्रारंभ',
+    currentState: 'वर्तमान स्थिति',
+    nodeTitleChange: 'ब्लॉक शीर्षक बदला गया',
+    nodeDescriptionChange: 'ब्लॉक विवरण बदला गया',
+    nodeDragStop: 'ब्लॉक स्थानांतरित किया गया',
+    nodeChange: 'ब्लॉक बदला गया',
+    nodeConnect: 'ब्लॉक कनेक्ट किया गया',
+    nodePaste: 'ब्लॉक पेस्ट किया गया',
+    nodeDelete: 'ब्लॉक हटाया गया',
+    nodeAdd: 'ब्लॉक जोड़ा गया',
+    nodeResize: 'ब्लॉक का आकार बदला गया',
+    noteAdd: 'नोट जोड़ा गया',
+    noteChange: 'नोट बदला गया',
+    noteDelete: 'नोट हटाया गया',
+    edgeDelete: 'ब्लॉक डिस्कनेक्ट किया गया',
+  },
   errorMsg: {
     fieldRequired: '{{field}} आवश्यक है',
     authRequired: 'प्राधिकरण आवश्यक है',

+ 28 - 0
web/i18n/ja-JP/workflow.ts

@@ -1,5 +1,7 @@
 const translation = {
   common: {
+    undo: '元に戻す',
+    redo: 'やり直し',
     editing: '編集中',
     autoSaved: '自動保存済み',
     unpublished: '未公開',
@@ -68,6 +70,32 @@ const translation = {
     workflowAsToolTip: 'ワークフローの更新後、ツールの再設定が必要です。',
     viewDetailInTracingPanel: '詳細を表示',
   },
+  changeHistory: {
+    title: '変更履歴',
+    placeholder: 'まだ何も変更していません',
+    clearHistory: '履歴をクリア',
+    hint: 'ヒント',
+    hintText: '編集アクションは変更履歴に記録され、このセッションの間にデバイスに保存されます。エディターを終了すると、この履歴は消去されます。',
+    stepBackward_one: '{{count}} ステップ後退',
+    stepBackward_other: '{{count}} ステップ後退',
+    stepForward_one: '{{count}} ステップ前進',
+    stepForward_other: '{{count}} ステップ前進',
+    sessionStart: 'セッション開始',
+    currentState: '現在の状態',
+    nodeTitleChange: 'ブロックのタイトルが変更されました',
+    nodeDescriptionChange: 'ブロックの説明が変更されました',
+    nodeDragStop: 'ブロックが移動されました',
+    nodeChange: 'ブロックが変更されました',
+    nodeConnect: 'ブロックが接続されました',
+    nodePaste: 'ブロックが貼り付けられました',
+    nodeDelete: 'ブロックが削除されました',
+    nodeAdd: 'ブロックが追加されました',
+    nodeResize: 'ブロックがリサイズされました',
+    noteAdd: 'ノートが追加されました',
+    noteChange: 'ノートが変更されました',
+    noteDelete: 'ノートが削除されました',
+    edgeDelete: 'ブロックが切断されました',
+  },
   errorMsg: {
     fieldRequired: '{{field}}は必須です',
     authRequired: '認証が必要です',

+ 28 - 0
web/i18n/ko-KR/workflow.ts

@@ -1,5 +1,7 @@
 const translation = {
   common: {
+    undo: '실행 취소',
+    redo: '다시 실행',
     editing: '편집 중',
     autoSaved: '자동 저장됨',
     unpublished: '미발행',
@@ -68,6 +70,32 @@ const translation = {
     workflowAsToolTip: '워크플로우 업데이트 후 도구 재구성이 필요합니다.',
     viewDetailInTracingPanel: '세부 정보 보기',
   },
+  changeHistory: {
+    title: '변경 기록',
+    placeholder: '아직 아무 것도 변경하지 않았습니다',
+    clearHistory: '기록 지우기',
+    hint: '힌트',
+    hintText: '편집 작업이 변경 기록에 추적되며, 이 세션 동안 기기에 저장됩니다. 편집기를 떠나면 이 기록이 지워집니다.',
+    stepBackward_one: '{{count}} 단계 뒤로',
+    stepBackward_other: '{{count}} 단계 뒤로',
+    stepForward_one: '{{count}} 단계 앞으로',
+    stepForward_other: '{{count}} 단계 앞으로',
+    sessionStart: '세션 시작',
+    currentState: '현재 상태',
+    nodeTitleChange: '블록 제목 변경됨',
+    nodeDescriptionChange: '블록 설명 변경됨',
+    nodeDragStop: '블록 이동됨',
+    nodeChange: '블록 변경됨',
+    nodeConnect: '블록 연결됨',
+    nodePaste: '블록 붙여넣기됨',
+    nodeDelete: '블록 삭제됨',
+    nodeAdd: '블록 추가됨',
+    nodeResize: '블록 크기 조정됨',
+    noteAdd: '노트 추가됨',
+    noteChange: '노트 변경됨',
+    noteDelete: '노트 삭제됨',
+    edgeDelete: '블록 연결 해제됨',
+  },
   errorMsg: {
     fieldRequired: '{{field}}가 필요합니다',
     authRequired: '인증이 필요합니다',

+ 28 - 0
web/i18n/pl-PL/workflow.ts

@@ -1,5 +1,7 @@
 const translation = {
   common: {
+    undo: 'Cofnij',
+    redo: 'Ponów',
     editing: 'Edytowanie',
     autoSaved: 'Automatycznie zapisane',
     unpublished: 'Nieopublikowane',
@@ -68,6 +70,32 @@ const translation = {
     workflowAsToolTip: 'Wymagana rekonfiguracja narzędzia po aktualizacji przepływu pracy.',
     viewDetailInTracingPanel: 'Zobacz szczegóły',
   },
+  changeHistory: {
+    title: 'Historia Zmian',
+    placeholder: 'Nie dokonano jeszcze żadnych zmian',
+    clearHistory: 'Wyczyść Historię',
+    hint: 'Wskazówka',
+    hintText: 'Działania edycji są śledzone w historii zmian, która jest przechowywana na urządzeniu przez czas trwania tej sesji. Ta historia zostanie usunięta po opuszczeniu edytora.',
+    stepBackward_one: '{{count}} krok do tyłu',
+    stepBackward_other: '{{count}} kroki do tyłu',
+    stepForward_one: '{{count}} krok do przodu',
+    stepForward_other: '{{count}} kroki do przodu',
+    sessionStart: 'Początek sesji',
+    currentState: 'Aktualny stan',
+    nodeTitleChange: 'Tytuł bloku zmieniony',
+    nodeDescriptionChange: 'Opis bloku zmieniony',
+    nodeDragStop: 'Blok przeniesiony',
+    nodeChange: 'Blok zmieniony',
+    nodeConnect: 'Blok połączony',
+    nodePaste: 'Blok wklejony',
+    nodeDelete: 'Blok usunięty',
+    nodeAdd: 'Blok dodany',
+    nodeResize: 'Notatka zmieniona',
+    noteAdd: 'Notatka dodana',
+    noteChange: 'Notatka zmieniona',
+    noteDelete: 'Notatka usunięta',
+    edgeDelete: 'Blok rozłączony',
+  },
   errorMsg: {
     fieldRequired: '{{field}} jest wymagane',
     authRequired: 'Wymagana autoryzacja',

+ 28 - 0
web/i18n/pt-BR/workflow.ts

@@ -1,5 +1,7 @@
 const translation = {
   common: {
+    undo: 'Desfazer',
+    redo: 'Refazer',
     editing: 'Editando',
     autoSaved: 'Salvo automaticamente',
     unpublished: 'Não publicado',
@@ -68,6 +70,32 @@ const translation = {
     workflowAsToolTip: 'É necessária a reconfiguração da ferramenta após a atualização do fluxo de trabalho.',
     viewDetailInTracingPanel: 'Ver detalhes',
   },
+  changeHistory: {
+    title: 'Histórico de alterações',
+    placeholder: 'Você ainda não alterou nada',
+    clearHistory: 'Limpar histórico',
+    hint: 'Dica',
+    hintText: 'As ações de edição são rastreadas em um histórico de alterações, que é armazenado em seu dispositivo para a duração desta sessão. Este histórico será apagado quando você sair do editor.',
+    stepBackward_one: '{{count}} passo para trás',
+    stepBackward_other: '{{count}} passos para trás',
+    stepForward_one: '{{count}} passo para frente',
+    stepForward_other: '{{count}} passos para frente',
+    sessionStart: 'Início da sessão',
+    currentState: 'Estado atual',
+    nodeTitleChange: 'Título do bloco alterado',
+    nodeDescriptionChange: 'Descrição do bloco alterada',
+    nodeDragStop: 'Bloco movido',
+    nodeChange: 'Bloco alterado',
+    nodeConnect: 'Bloco conectado',
+    nodePaste: 'Bloco colado',
+    nodeDelete: 'Bloco excluído',
+    nodeAdd: 'Bloco adicionado',
+    nodeResize: 'Nota redimensionada',
+    noteAdd: 'Nota adicionada',
+    noteChange: 'Nota alterada',
+    noteDelete: 'Conexão excluída',
+    edgeDelete: 'Bloco desconectado',
+  },
   errorMsg: {
     fieldRequired: '{{field}} é obrigatório',
     authRequired: 'Autorização é necessária',

+ 28 - 0
web/i18n/ro-RO/workflow.ts

@@ -1,5 +1,7 @@
 const translation = {
   common: {
+    undo: 'Anulează',
+    redo: 'Refă',
     editing: 'Editare',
     autoSaved: 'Salvat automat',
     unpublished: 'Nepublicat',
@@ -68,6 +70,32 @@ const translation = {
     workflowAsToolTip: 'Reconfigurarea instrumentului este necesară după actualizarea fluxului de lucru.',
     viewDetailInTracingPanel: 'Vezi detalii',
   },
+  changeHistory: {
+    title: 'Istoric modificări',
+    placeholder: 'Nu ați schimbat nimic încă',
+    clearHistory: 'Șterge istoricul',
+    hint: 'Sfat',
+    hintText: 'Acțiunile dvs. de editare sunt urmărite într-un istoric al modificărilor, care este stocat pe dispozitivul dvs. pe durata acestei sesiuni. Acest istoric va fi șters când veți părăsi editorul.',
+    stepBackward_one: '{{count}} pas înapoi',
+    stepBackward_other: '{{count}} pași înapoi',
+    stepForward_one: '{{count}} pas înainte',
+    stepForward_other: '{{count}} pași înainte',
+    sessionStart: 'Începutul sesiuni',
+    currentState: 'Stare actuală',
+    nodeTitleChange: 'Titlul blocului a fost schimbat',
+    nodeDescriptionChange: 'Descrierea blocului a fost schimbată',
+    nodeDragStop: 'Bloc mutat',
+    nodeChange: 'Bloc schimbat',
+    nodeConnect: 'Bloc conectat',
+    nodePaste: 'Bloc lipit',
+    nodeDelete: 'Bloc șters',
+    nodeAdd: 'Bloc adăugat',
+    nodeResize: 'Bloc redimensionat',
+    noteAdd: 'Notă adăugată',
+    noteChange: 'Notă modificată',
+    noteDelete: 'Notă ștearsă',
+    edgeDelete: 'Bloc deconectat',
+  },
   errorMsg: {
     fieldRequired: '{{field}} este obligatoriu',
     authRequired: 'Autorizarea este necesară',

+ 28 - 0
web/i18n/uk-UA/workflow.ts

@@ -1,5 +1,7 @@
 const translation = {
   common: {
+    undo: 'Скасувати',
+    redo: 'Повторити',
     editing: 'Редагування',
     autoSaved: 'Автоматично збережено',
     unpublished: 'Неопубліковано',
@@ -68,6 +70,32 @@ const translation = {
     workflowAsToolTip: 'Після оновлення робочого потоку необхідна переконфігурація інструменту.',
     viewDetailInTracingPanel: 'Переглянути деталі',
   },
+  changeHistory: {
+    title: 'Історія змін',
+    placeholder: 'Ви ще нічого не змінили',
+    clearHistory: 'Очистити історію',
+    hint: 'Підказка',
+    hintText: 'Дії редагування відстежуються в історії змін, яка зберігається на вашому пристрої протягом цієї сесії. Ця історія буде видалена після виходу з редактора.',
+    stepBackward_one: '{{count}} крок назад',
+    stepBackward_other: '{{count}} кроки назад',
+    stepForward_one: '{{count}} крок вперед',
+    stepForward_other: '{{count}} кроки вперед',
+    sessionStart: 'Початок сесії',
+    currentState: 'Поточний стан',
+    nodeTitleChange: 'Назву блоку змінено',
+    nodeDescriptionChange: 'Опис блоку змінено',
+    nodeDragStop: 'Блок переміщено',
+    nodeChange: 'Блок змінено',
+    nodeConnect: 'Блок підключено',
+    nodePaste: 'Блок вставлено',
+    nodeDelete: 'Блок видалено',
+    nodeAdd: 'Блок додано',
+    nodeResize: 'Розмір блоку змінено',
+    noteAdd: 'Додано нотатку',
+    noteChange: 'Нотатку змінено',
+    noteDelete: 'Нотатку видалено',
+    edgeDelete: 'Блок відключено',
+  },
   errorMsg: {
     fieldRequired: '{{field}} є обов\'язковим',
     authRequired: 'Потрібна авторизація',

+ 28 - 0
web/i18n/vi-VN/workflow.ts

@@ -1,5 +1,33 @@
 const translation = {
   common: {
+    undo: 'Hoàn tác',
+    redo: 'Làm lại',
+    changeHistory: {
+      title: 'Lịch sử thay đổi',
+      placeholder: 'Bạn chưa thay đổi gì cả',
+      clearHistory: 'Xóa lịch sử',
+      hint: 'Gợi ý',
+      hintText: 'Các hành động chỉnh sửa của bạn được theo dõi trong lịch sử thay đổi, được lưu trên thiết bị của bạn trong suốt phiên làm việc này. Lịch sử này sẽ bị xóa khi bạn thoát khỏi trình soạn thảo.',
+      stepBackward_one: '{{count}} bước lùi',
+      stepBackward_other: '{{count}} bước lùi',
+      stepForward_one: '{{count}} bước tiến',
+      stepForward_other: '{{count}} bước tiến',
+      sessionStart: 'Bắt đầu phiên',
+      currentState: 'Trạng thái hiện tại',
+      nodeTitleChange: 'Tiêu đề khối đã thay đổi',
+      nodeDescriptionChange: 'Mô tả khối đã thay đổi',
+      nodeDragStop: 'Khối đã di chuyển',
+      nodeChange: 'Khối đã thay đổi',
+      nodeConnect: 'Khối đã kết nối',
+      nodePaste: 'Khối đã dán',
+      nodeDelete: 'Khối đã xóa',
+      nodeAdd: 'Khối đã thêm',
+      nodeResize: 'Khối đã thay đổi kích thước',
+      noteAdd: 'Ghi chú đã thêm',
+      noteChange: 'Ghi chú đã thay đổi',
+      noteDelete: 'Ghi chú đã xóa',
+      edgeDelete: 'Khối đã ngắt kết nối',
+    },
     editing: 'Chỉnh sửa',
     autoSaved: 'Tự động lưu',
     unpublished: 'Chưa xuất bản',

+ 28 - 0
web/i18n/zh-Hans/workflow.ts

@@ -1,5 +1,7 @@
 const translation = {
   common: {
+    undo: '撤销',
+    redo: '重做',
     editing: '编辑中',
     autoSaved: '自动保存',
     unpublished: '未发布',
@@ -76,6 +78,32 @@ const translation = {
     importFailure: '导入失败',
     importSuccess: '导入成功',
   },
+  changeHistory: {
+    title: '变更历史',
+    placeholder: '尚未更改任何内容',
+    clearHistory: '清除历史记录',
+    hint: '提示',
+    hintText: '您的编辑操作将被跟踪并存储在您的设备上,直到您离开编辑器。此历史记录将在您离开编辑器时被清除。',
+    stepBackward_one: '{{count}} 步后退',
+    stepBackward_other: '{{count}} 步后退',
+    stepForward_one: '{{count}} 步前进',
+    stepForward_other: '{{count}} 步前进',
+    sessionStart: '会话开始',
+    currentState: '当前状态',
+    nodeTitleChange: '块标题已更改',
+    nodeDescriptionChange: '块描述已更改',
+    nodeDragStop: '块已移动',
+    nodeChange: '块已更改',
+    nodeConnect: '块已连接',
+    nodePaste: '块已粘贴',
+    nodeDelete: '块已删除',
+    nodeAdd: '块已添加',
+    nodeResize: '块已调整大小',
+    noteAdd: '注释已添加',
+    noteChange: '注释已更改',
+    noteDelete: '注释已删除',
+    edgeDelete: '块已断开连接',
+  },
   errorMsg: {
     fieldRequired: '{{field}} 不能为空',
     authRequired: '请先授权',

+ 27 - 0
web/i18n/zh-Hant/workflow.ts

@@ -1,5 +1,7 @@
 const translation = {
   common: {
+    undo: '復原',
+    redo: '重做',
     editing: '編輯中',
     autoSaved: '自動保存',
     unpublished: '未發佈',
@@ -68,6 +70,31 @@ const translation = {
     workflowAsToolTip: '工作流更新後需要重新配置工具參數',
     viewDetailInTracingPanel: '查看詳細信息',
   },
+  changeHistory: {
+    title: '變更履歷',
+    placeholder: '尚未更改任何內容',
+    clearHistory: '清除歷史記錄',
+    hint: '提示',
+    hintText: '您的編輯操作將被跟踪並存儲在您的設備上,直到您離開編輯器。此歷史記錄將在您離開編輯器時被清除。',
+    stepBackward_one: '{{count}} 步後退',
+    stepBackward_other: '{{count}} 步後退',
+    stepForward_one: '{{count}} 步前進',
+    stepForward_other: '{{count}} 步前進',
+    sessionStart: '會話開始',
+    currentState: '當前狀態',
+    nodeTitleChange: '區塊標題已更改',
+    nodeDescriptionChange: '區塊描述已更改',
+    nodeDragStop: '區塊已移動',
+    nodeChange: '區塊已更改',
+    nodeConnect: '區塊已連接',
+    nodePaste: '區塊已粘貼',
+    nodeDelete: '區塊已刪除',
+    nodeAdd: '區塊已添加',
+    nodeResize: '區塊已調整大小',
+    noteAdd: '註釋已添加',
+    noteChange: '註釋已更改',
+    edgeDelete: '區塊已斷開連接',
+  },
   errorMsg: {
     fieldRequired: '{{field}} 不能為空',
     authRequired: '請先授權',

+ 3 - 1
web/package.json

@@ -42,6 +42,7 @@
     "echarts": "^5.4.1",
     "echarts-for-react": "^3.0.2",
     "emoji-mart": "^5.5.2",
+    "fast-deep-equal": "^3.1.3",
     "i18next": "^22.4.13",
     "i18next-resources-to-backend": "^1.1.3",
     "immer": "^9.0.19",
@@ -89,7 +90,8 @@
     "use-context-selector": "^1.4.1",
     "uuid": "^9.0.1",
     "zod": "^3.23.6",
-    "zustand": "^4.5.1"
+    "zundo": "^2.1.0",
+    "zustand": "^4.5.2"
   },
   "devDependencies": {
     "@antfu/eslint-config": "^0.36.0",

Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 358 - 281
web/yarn.lock


Kaikkia tiedostoja ei voida näyttää, sillä liian monta tiedostoa muuttui tässä diffissä