From f4bbe776122bb173adb9604ecc47a0b5397337cb Mon Sep 17 00:00:00 2001 From: Vincent Chan Date: Wed, 20 Jul 2022 16:04:35 +0800 Subject: [PATCH 1/3] 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)); + } +} From 0626912a4c2b2ab7c6cdcc10e42064e585d6bf59 Mon Sep 17 00:00:00 2001 From: Vincent Chan Date: Thu, 21 Jul 2022 10:57:48 +0800 Subject: [PATCH 2/3] feat: add comment and fix issues --- .../flowy_editor/lib/editor_state.dart | 28 +++++++------- .../flowy_editor/lib/undo_manager.dart | 37 ++++++++++--------- 2 files changed, 34 insertions(+), 31 deletions(-) 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 44c36f7d16..1e6e404385 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/editor_state.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/editor_state.dart @@ -6,9 +6,12 @@ import 'package:flutter/material.dart'; import './document/selection.dart'; class ApplyOptions { - final bool noLog; + /// This flag indicates that + /// whether the transaction should be recorded into + /// the undo stack. + final bool recordUndo; const ApplyOptions({ - this.noLog = false, + this.recordUndo = true, }); } @@ -45,19 +48,16 @@ class EditorState { } cursorSelection = transaction.afterSelection; - if (options.noLog) { - return; + if (options.recordUndo) { + final undoItem = undoManager.getUndoHistoryItem(); + undoItem.addAll(transaction.operations); + if (undoItem.beforeSelection == null && + transaction.beforeSelection != null) { + undoItem.beforeSelection = transaction.beforeSelection; + } + undoItem.afterSelection = transaction.afterSelection; + _debouncedSealHistoryItem(); } - - final undoItem = undoManager.getUndoHistoryItem(); - undoItem.addAll(transaction.operations); - if (undoItem.beforeSelection == null && - transaction.beforeSelection != null) { - undoItem.beforeSelection = transaction.beforeSelection; - } - undoItem.afterSelection = transaction.afterSelection; - - _debouncedSealHistoryItem(); } _debouncedSealHistoryItem() { diff --git a/frontend/app_flowy/packages/flowy_editor/lib/undo_manager.dart b/frontend/app_flowy/packages/flowy_editor/lib/undo_manager.dart index b523ba54a7..e85cebe54a 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/undo_manager.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/undo_manager.dart @@ -6,6 +6,9 @@ import 'package:flowy_editor/operation/transaction_builder.dart'; import 'package:flowy_editor/operation/transaction.dart'; import 'package:flowy_editor/editor_state.dart'; +/// This class contains operations to committed by users. +/// If a [HistoryItem] is not sealed, operations can be added sequentially. +/// Otherwise, the operations should be added to a new [HistoryItem]. class HistoryItem extends LinkedListEntry { final List operations = []; Selection? beforeSelection; @@ -18,6 +21,8 @@ class HistoryItem extends LinkedListEntry { _sealed = true; } + bool get sealed => _sealed; + add(Operation op) { operations.add(op); } @@ -26,10 +31,6 @@ class HistoryItem extends LinkedListEntry { 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--) { @@ -67,24 +68,22 @@ class FixedSizeStack { return last; } - HistoryItem get last { - return _list.last; - } + HistoryItem get last => _list.last; - bool get isEmpty { - return _list.isEmpty; - } + bool get isEmpty => _list.isEmpty; - bool get isNonEmpty { - return _list.isNotEmpty; - } + bool get isNonEmpty => _list.isNotEmpty; } class UndoManager { - final undoStack = FixedSizeStack(20); - final redoStack = FixedSizeStack(20); + final FixedSizeStack undoStack; + final FixedSizeStack redoStack; EditorState? state; + UndoManager([int stackSize = 20]) + : undoStack = FixedSizeStack(stackSize), + redoStack = FixedSizeStack(stackSize); + HistoryItem getUndoHistoryItem() { if (undoStack.isEmpty) { final item = HistoryItem(); @@ -101,11 +100,15 @@ class UndoManager { } undo() { + final s = state; + if (s == null) { + return; + } final historyItem = undoStack.pop(); if (historyItem == null) { return; } - final transaction = historyItem.toTransaction(state!); - state!.apply(transaction, const ApplyOptions(noLog: true)); + final transaction = historyItem.toTransaction(s); + s.apply(transaction, const ApplyOptions(recordUndo: false)); } } From 7ae153f5daf627cc8d83d29926c5420d4cdd793a Mon Sep 17 00:00:00 2001 From: Vincent Chan Date: Thu, 21 Jul 2022 11:47:41 +0800 Subject: [PATCH 3/3] fix(typo): comments of UndoManager --- frontend/app_flowy/packages/flowy_editor/lib/undo_manager.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/app_flowy/packages/flowy_editor/lib/undo_manager.dart b/frontend/app_flowy/packages/flowy_editor/lib/undo_manager.dart index e85cebe54a..d01ffa351a 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/undo_manager.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/undo_manager.dart @@ -6,7 +6,7 @@ import 'package:flowy_editor/operation/transaction_builder.dart'; import 'package:flowy_editor/operation/transaction.dart'; import 'package:flowy_editor/editor_state.dart'; -/// This class contains operations to committed by users. +/// This class contains operations committed by users. /// If a [HistoryItem] is not sealed, operations can be added sequentially. /// Otherwise, the operations should be added to a new [HistoryItem]. class HistoryItem extends LinkedListEntry {