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.

  1. 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:

IntroductionDescriptionFree LayoutFixed Layout
Undo/Redo ShortcutUse Cmd/Ctrl + Z to trigger Undo
Use Cmd/Ctrl + Shift + Z to trigger Redo
Canvas node operation supports undo/redoAdd/Delete node
Add/Delete line
Move node
Add/Delete branch
Move branch
Add group
Cancel group
Canvas batch operationDelete 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

  1. 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'
       }
   })
}
  1. 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

  1. 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();
}
  1. 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

  1. 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>
  );
}
  1. 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

3.1. OperationMeta

OperationMeta, used to define an operation

3.2. Operation

Operation data, associated with OperationMeta through type

3.3. OperationService

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

3.4. HistoryService

The core API of the History module exposed Service

3.5. UndoRedoService

The service that manages the UndoRedo stack

3.6. HistoryStack

History stack, listen to all push undo redo operations, and record them in the stack

3.7. HistoryDatabase

Persistence database operations