History
Undo/Redo is a plugin of FlowGram.AI, which is provided in both @flowgram.ai/fixed-layout-editor and @flowgram.ai/free-layout-editor.
1. Quick Start
> Demo Detail
1.1. Enable history
Before using the Undo/Redo feature, you need to introduce the editor, using the fixed layout editor as an example.
- Add dependencies in package.json
use-editor-props.tsx
export function useEditorProps() {
return useMemo(
() => ({
history: {
enable: true,
enableChangeNode: true // Listen Node engine data change
}
})
)
}
After enabling, you will get the following capabilities:
Introduction | Description | Free Layout | Fixed Layout |
Undo/Redo Shortcut | Use Cmd/Ctrl + Z to trigger Undo | ✅ | ✅ |
Use Cmd/Ctrl + Shift + Z to trigger Redo | ✅ | ✅ |
Canvas node operation supports undo/redo | Add/Delete node | ✅ | ✅ |
Add/Delete line | ✅ | ❌ |
Move node | ✅ | ✅ |
Add/Delete branch | ❌ | ✅ |
Move branch | ❌ | ✅ |
Add group | ❌ | ✅ |
Cancel group | ❌ | ✅ |
Canvas batch operation | Delete node | ✅ | ✅ |
Move node | ✅ | ✅ |
1.2. Disable history
If some data changes triggered by the system do not want to be monitored by undo/redo, you can actively stop the history service and restart it after the data operation is completed
const { history } = useClientContext();
history.stop()
// Do some operations that do not want to be captured, these changes will not be recorded in the operation stack
...
history.start()
1.3. History Undo/Redo merge
const { history } = useClientContext();
history.startTransaction();
// Any operations here will be merged into one
...
history.endTransaction();
1.4. Undo/Redo Call
Undo/Redo is generally provided with two button entries on the interface, clicking which can trigger Undo and Redo, and the buttons themselves need to have the status of whether Undo/Redo is possible.
export function useUndoRedo(): UndoRedo {
const { history } = useClientContext();
const [canUndo, setCanUndo] = useState(false);
const [canRedo, setCanRedo] = useState(false);
useEffect(() => {
const toDispose = history.undoRedoService.onChange(() => {
setCanUndo(history.canUndo());
setCanRedo(history.canRedo());
});
return () => {
toDispose.dispose();
};
}, []);
return {
canUndo,
canRedo,
undo: () => history.undo(),
redo: () => history.redo(),
};
}
2. Extension Function
2.1. Operation Registration
Operations are registered through operationMetas
use-editor-props.tsx
...
history={{
enable: true,
operationMetas: [
{
type: 'addNode',
apply: () => { console.log('addNode')},
inverse: (op) => ({ type: 'deleteNode', value: op.value })
}
]
}}
OperationMeta
Core Definition:
type
is the unique identifier of the operation
inverse
is a function, which returns the inverse operation of the current operation
apply
is the logic executed when the operation is triggered
export interface OperationMeta {
/**
* Operation type, needs to be unique
*/
type: string;
/**
* Convert an operation to another inverse operation, such as insert to delete
* @param op Operation
* @returns Inverse operation
*/
inverse: (op: Operation) => Operation;
/**
* Execute operation
* @param operation Operation
*/
apply(operation: Operation, source: any): void | Promise<void>;
}
Suppose I want to add a function to support Undo/Redo for adding and deleting nodes, I need to add two operations
{
type: 'addNode',
inverse: op => ({ ...op, type: 'deleteNode' }),
apply(op, ctx) {
document = ctx.get(Document)
document.addNode(op.value)
},
}
{
type: 'deleteNode',
inverse: op => ({ ...op, type: 'addNode' }),
apply(op, ctx) {
document = ctx.get(Document)
document.deleteNode(op.value.id)
},
}
2.2. Operation Merge
operationMeta supports shouldMerge to customize the merge strategy, if frequent operations can be merged
shouldMerge returns
- Return false means not merged
- Return true means merged into one operation stack element
- Return Operation means merged into one operation
The following example is a merge of operations that edit the same field within 500ms
{
type: 'changeData',
inverse: op => ({ ...op, type: 'changeData' }),
apply(op, ctx) {},
shouldMerge: (op, prev, element) => {
// Merge operations within 500ms
if (Date.now() - element.getTimestamp() < 500) {
if (
op.type === prev.type && // Same type
op.value.id === prev.value.id && // Same node
op.value?.path === prev.value?.path // Same path
) {
return {
type: op.type,
value: {
...op.value,
value: op.value.value,
oldValue: prev.value.oldValue,
},
};
}
}
return false;
}
}
2.3. Operation Execution
- Single operation execution
Trigger through pushOperation, the following example uses the operation defined in the business
function handleAddNode () {
const { history } = useClientContext()
history.pushOperation({
type: 'addNode',
value: {
name: 'xx'
id: 'xxx'
}
})
}
- Batch execution
All operations executed in the function called by transact will be merged into one stack element, and will be executed together when undo/redo
The following is an example of implementing a batch delete:
function deleteNodes(nodes: FlowNodeEntity[]) {
const { history } = useClientContext()
history.transact(() => {
nodes.forEach(node => {
history.pushOperation({
type: OperationType.deleteNode,
value: {
fromId: fromNode.id,
data: node.data,
},
});
});
});
}
2.4. Undo/Redo
- Undo/Redo
Undo execution history.undo method
Redo execution history.redo method
function undo() {
const { history } = useClientContext();
history.undo();
}
function redo() {
const { history } = useClientContext();
history.redo();
}
- Listen Undo/Redo
Listen to the onChange event of undoRedoService.onChange
The following is an example of triggering the uri of the corresponding operation after undo/redo (selecting the corresponding node or form item)
function listenHistoryChange() {
const { history } = useClientContext();
history.undoRedoService.onChange(
({ type, element }) => {
if (type === UndoRedoChangeType.PUSH) {
return;
}
const op = element.getLastOperation();
if (!op) {
return;
}
if (op.uri) {
// goto somewhere
}
},
)
}
2.5. Operation History
- View refresh
You can get the history record through HistoryStack.items, and refresh the interface by listening to HistoryStack.onChange
import React from 'react';
export function HistoryList() {
const { historyStack } = useService<HistoryManager>(HistoryManager)
const { refresh } = useRefresh()
let items = historyManager.historyStack.items;
useEffect(() => {
const disposable = historyStack.onChange(() => {
refresh()
])
return () => {
disposable.dispose()
}
}, [])
return (
<ul>
{items.map((item, index) => (
<li key={index}>
<div>
{item.type}({item.id}):
{item.operations.map((o, index) => (
<Tooltip
key={index}
title={(o.description || '') + `----uri: ${o.uri?.displayName}`}
>
{o.label || o.type}
</Tooltip>
))}
</div>
</li>
))}
</ul>
);
}
- Persistence
Persistence is implemented through the history-storage plugin
- databaseName: database name
- resourceStorageLimit: resource storage limit number
After introducing the @flowgram.ai/history-storage package, the plugin can be used
import { createHistoryStoragePlugin } from '@flowgram.ai/history-storage';
createHistoryStoragePlugin({
databaseName: 'your-history',
resourceStorageLimit: 50,
}),
Query the database list through useStorageHistoryItems
import {
useStorageHistoryItems,
} from '@flowgram.ai/history-storage';
export const HistoryList = () => {
const { uri } = useCurrentWidget();
const { items } = useStorageHistoryItems(
storage,
uri.withoutQuery().toString(),
);
return <>
{ JSON.stringify(items) }
</>
}
3. API List
OperationMeta, used to define an operation
Operation data, associated with OperationMeta through type
onApply
Use onApply to listen to a triggered operation
useService(OperationService).onApply((op: Operation) => {
console.log(op)
// Here you can execute your own business logic according to type
})
The core API of the History module exposed Service
The service that manages the UndoRedo stack
History stack, listen to all push undo redo operations, and record them in the stack
Persistence database operations