From f4bbe776122bb173adb9604ecc47a0b5397337cb Mon Sep 17 00:00:00 2001 From: Vincent Chan Date: Wed, 20 Jul 2022 16:04:35 +0800 Subject: [PATCH] feat: undo manager --- .../example/lib/plugin/text_node_widget.dart | 11 ++ .../flowy_editor/lib/editor_state.dart | 55 +++++++-- .../lib/operation/transaction_builder.dart | 4 +- .../flowy_editor/lib/undo_manager.dart | 111 ++++++++++++++++++ 4 files changed, 169 insertions(+), 12 deletions(-) create mode 100644 frontend/app_flowy/packages/flowy_editor/lib/undo_manager.dart diff --git a/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/text_node_widget.dart b/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/text_node_widget.dart index 0077707fe8..b81ffca0ab 100644 --- a/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/text_node_widget.dart +++ b/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/text_node_widget.dart @@ -42,6 +42,7 @@ class __TextNodeWidgetState extends State<_TextNodeWidget> implements DeltaTextInputClient { TextNode get node => widget.node as TextNode; EditorState get editorState => widget.editorState; + bool _metaKeyDown = false; TextInputConnection? _textInputConnection; @@ -86,6 +87,16 @@ class __TextNodeWidgetState extends State<_TextNodeWidget> } else if (event.logicalKey == LogicalKeyboardKey.delete) { _forwardDeleteTextAtSelection(sel); return KeyEventResult.handled; + } else if (event.logicalKey == LogicalKeyboardKey.metaLeft || + event.logicalKey == LogicalKeyboardKey.metaRight) { + _metaKeyDown = true; + } else if (event.logicalKey == LogicalKeyboardKey.keyZ && _metaKeyDown) { + editorState.undoManager.undo(); + } + } else if (event is RawKeyUpEvent) { + if (event.logicalKey == LogicalKeyboardKey.metaLeft || + event.logicalKey == LogicalKeyboardKey.metaRight) { + _metaKeyDown = false; } } return KeyEventResult.ignored; diff --git a/frontend/app_flowy/packages/flowy_editor/lib/editor_state.dart b/frontend/app_flowy/packages/flowy_editor/lib/editor_state.dart index 521231a495..44c36f7d16 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/editor_state.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/editor_state.dart @@ -1,23 +1,31 @@ -import 'package:flowy_editor/document/node.dart'; -import 'package:flowy_editor/operation/operation.dart'; -import 'package:flowy_editor/document/attributes.dart'; +import 'dart:async'; +import 'package:flowy_editor/flowy_editor.dart'; +import 'package:flowy_editor/undo_manager.dart'; import 'package:flutter/material.dart'; -import './document/state_tree.dart'; import './document/selection.dart'; -import './operation/operation.dart'; -import './operation/transaction.dart'; -import './render/render_plugins.dart'; + +class ApplyOptions { + final bool noLog; + const ApplyOptions({ + this.noLog = false, + }); +} class EditorState { final StateTree document; final RenderPlugins renderPlugins; + final UndoManager undoManager = UndoManager(); Selection? cursorSelection; + Timer? _debouncedSealHistoryItemTimer; + EditorState({ required this.document, required this.renderPlugins, - }); + }) { + undoManager.state = this; + } /// TODO: move to a better place. Widget build(BuildContext context) { @@ -30,14 +38,41 @@ class EditorState { ); } - void apply(Transaction transaction) { + apply(Transaction transaction, + [ApplyOptions options = const ApplyOptions()]) { for (final op in transaction.operations) { _applyOperation(op); } cursorSelection = transaction.afterSelection; + + if (options.noLog) { + return; + } + + final undoItem = undoManager.getUndoHistoryItem(); + undoItem.addAll(transaction.operations); + if (undoItem.beforeSelection == null && + transaction.beforeSelection != null) { + undoItem.beforeSelection = transaction.beforeSelection; + } + undoItem.afterSelection = transaction.afterSelection; + + _debouncedSealHistoryItem(); } - void _applyOperation(Operation op) { + _debouncedSealHistoryItem() { + _debouncedSealHistoryItemTimer?.cancel(); + _debouncedSealHistoryItemTimer = + Timer(const Duration(milliseconds: 1000), () { + if (undoManager.undoStack.isNonEmpty) { + debugPrint('Seal history item'); + final last = undoManager.undoStack.last; + last.seal(); + } + }); + } + + _applyOperation(Operation op) { if (op is InsertOperation) { document.insert(op.path, op.value); } else if (op is UpdateOperation) { diff --git a/frontend/app_flowy/packages/flowy_editor/lib/operation/transaction_builder.dart b/frontend/app_flowy/packages/flowy_editor/lib/operation/transaction_builder.dart index ec088dc25d..6fca48f230 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/operation/transaction_builder.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/operation/transaction_builder.dart @@ -31,7 +31,7 @@ class TransactionBuilder { /// Commit the operations to the state commit() { - final transaction = _finish(); + final transaction = finish(); state.apply(transaction); } @@ -99,7 +99,7 @@ class TransactionBuilder { operations.add(op); } - Transaction _finish() { + Transaction finish() { return Transaction( operations: UnmodifiableListView(operations), beforeSelection: beforeSelection, diff --git a/frontend/app_flowy/packages/flowy_editor/lib/undo_manager.dart b/frontend/app_flowy/packages/flowy_editor/lib/undo_manager.dart new file mode 100644 index 0000000000..b523ba54a7 --- /dev/null +++ b/frontend/app_flowy/packages/flowy_editor/lib/undo_manager.dart @@ -0,0 +1,111 @@ +import 'dart:collection'; + +import 'package:flowy_editor/document/selection.dart'; +import 'package:flowy_editor/operation/operation.dart'; +import 'package:flowy_editor/operation/transaction_builder.dart'; +import 'package:flowy_editor/operation/transaction.dart'; +import 'package:flowy_editor/editor_state.dart'; + +class HistoryItem extends LinkedListEntry { + final List operations = []; + Selection? beforeSelection; + Selection? afterSelection; + bool _sealed = false; + + HistoryItem(); + + seal() { + _sealed = true; + } + + add(Operation op) { + operations.add(op); + } + + addAll(Iterable iterable) { + operations.addAll(iterable); + } + + bool get sealed { + return _sealed; + } + + Transaction toTransaction(EditorState state) { + final builder = TransactionBuilder(state); + for (var i = operations.length - 1; i >= 0; i--) { + final operation = operations[i]; + final inverted = operation.invert(); + builder.add(inverted); + } + builder.afterSelection = beforeSelection; + builder.beforeSelection = afterSelection; + return builder.finish(); + } +} + +class FixedSizeStack { + final _list = LinkedList(); + final int maxSize; + + FixedSizeStack(this.maxSize); + + push(HistoryItem stackItem) { + if (_list.length >= maxSize) { + _list.remove(_list.first); + } + _list.add(stackItem); + } + + HistoryItem? pop() { + if (_list.isEmpty) { + return null; + } + final last = _list.last; + + _list.remove(last); + + return last; + } + + HistoryItem get last { + return _list.last; + } + + bool get isEmpty { + return _list.isEmpty; + } + + bool get isNonEmpty { + return _list.isNotEmpty; + } +} + +class UndoManager { + final undoStack = FixedSizeStack(20); + final redoStack = FixedSizeStack(20); + EditorState? state; + + HistoryItem getUndoHistoryItem() { + if (undoStack.isEmpty) { + final item = HistoryItem(); + undoStack.push(item); + return item; + } + final last = undoStack.last; + if (last.sealed) { + final item = HistoryItem(); + undoStack.push(item); + return item; + } + return last; + } + + undo() { + final historyItem = undoStack.pop(); + if (historyItem == null) { + return; + } + final transaction = historyItem.toTransaction(state!); + state!.apply(transaction, const ApplyOptions(noLog: true)); + } +}