feat: undo/redo for workflow editor (#3927)
Co-authored-by: StyleZhang <jasonapring2015@outlook.com>pull/5623/head
parent
d0fe56a98e
commit
af9448e6f2
@ -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>
|
||||||
|
After Width: | Height: | Size: 242 B |
@ -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>
|
|
||||||
|
Before Width: | Height: | Size: 283 B |
@ -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>
|
|
||||||
|
Before Width: | Height: | Size: 369 B |
@ -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"
|
|
||||||
}
|
|
||||||
@ -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
|
|
||||||
@ -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"
|
|
||||||
}
|
|
||||||
@ -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,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)
|
||||||
@ -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)
|
||||||
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue