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

Co-authored-by: StyleZhang <jasonapring2015@outlook.com>
This commit is contained in:
Pascal M
2024-06-26 08:37:12 +02:00
committed by GitHub
parent d0fe56a98e
commit af9448e6f2
38 changed files with 2049 additions and 778 deletions

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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'

View File

@@ -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())

View File

@@ -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,
}
}

View File

@@ -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,
}
}

View File

@@ -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 {

View File

@@ -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>
)
})

View File

@@ -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(

View File

@@ -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 (

View File

@@ -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,

View File

@@ -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}

View File

@@ -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>
</>

View File

@@ -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
}