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..1e6e404385 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,34 @@ -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 { + /// This flag indicates that + /// whether the transaction should be recorded into + /// the undo stack. + final bool recordUndo; + const ApplyOptions({ + this.recordUndo = true, + }); +} 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 +41,38 @@ 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.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(); + } } - 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..d01ffa351a --- /dev/null +++ b/frontend/app_flowy/packages/flowy_editor/lib/undo_manager.dart @@ -0,0 +1,114 @@ +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'; + +/// 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 { + final List operations = []; + Selection? beforeSelection; + Selection? afterSelection; + bool _sealed = false; + + HistoryItem(); + + seal() { + _sealed = true; + } + + bool get sealed => _sealed; + + add(Operation op) { + operations.add(op); + } + + addAll(Iterable iterable) { + operations.addAll(iterable); + } + + 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 => _list.last; + + bool get isEmpty => _list.isEmpty; + + bool get isNonEmpty => _list.isNotEmpty; +} + +class UndoManager { + 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(); + undoStack.push(item); + return item; + } + final last = undoStack.last; + if (last.sealed) { + final item = HistoryItem(); + undoStack.push(item); + return item; + } + return last; + } + + undo() { + final s = state; + if (s == null) { + return; + } + final historyItem = undoStack.pop(); + if (historyItem == null) { + return; + } + final transaction = historyItem.toTransaction(s); + s.apply(transaction, const ApplyOptions(recordUndo: false)); + } +}