From 2661a6a4ae441f999fb7a9faac41f8992e6e18c4 Mon Sep 17 00:00:00 2001 From: appflowy Date: Sun, 24 Jul 2022 10:14:51 +0800 Subject: [PATCH 01/11] chore: update operation documentation --- frontend/.vscode/settings.json | 10 ++++------ .../flowy_editor/lib/operation/transaction.dart | 12 ++++-------- .../lib/operation/transaction_builder.dart | 14 ++++---------- .../packages/flowy_editor/lib/undo_manager.dart | 2 +- 4 files changed, 13 insertions(+), 25 deletions(-) diff --git a/frontend/.vscode/settings.json b/frontend/.vscode/settings.json index 2f5e152d62..d1732c5231 100644 --- a/frontend/.vscode/settings.json +++ b/frontend/.vscode/settings.json @@ -2,23 +2,21 @@ "[dart]": { "editor.formatOnSave": true, "editor.formatOnType": true, - "editor.rulers": [ - 120 - ], + "editor.rulers": [80], "editor.selectionHighlight": false, "editor.suggest.snippetsPreventQuickSuggestions": false, "editor.suggestSelection": "first", "editor.tabCompletion": "onlySnippets", - "editor.wordBasedSuggestions": false + "editor.wordBasedSuggestions": false, }, "svgviewer.enableautopreview": true, "svgviewer.previewcolumn": "Active", "svgviewer.showzoominout": true, - "editor.wordWrapColumn": 120, + "editor.wordWrapColumn": 80, "editor.minimap.maxColumn": 140, "prettier.printWidth": 140, "editor.wordWrap": "wordWrapColumn", - "dart.lineLength": 120, + "dart.lineLength": 80, "files.associations": { "*.log.*": "log" }, diff --git a/frontend/app_flowy/packages/flowy_editor/lib/operation/transaction.dart b/frontend/app_flowy/packages/flowy_editor/lib/operation/transaction.dart index 3de528e868..85bc43f537 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/operation/transaction.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/operation/transaction.dart @@ -3,19 +3,15 @@ import 'package:flutter/material.dart'; import 'package:flowy_editor/document/selection.dart'; import './operation.dart'; -/// This class to use to store the **changes** -/// will be applied to the editor. +/// A [Transaction] has a list of [Operation] objects that will be applied +/// to the editor. It is an immutable class and used to store and transmit. /// -/// This class is immutable version the the class -/// [[Transaction]]. Is used to stored and -/// transmit. If you want to build the transaction, -/// use [[Transaction]] directly. +/// If you want to build a new [Transaction], use [TransactionBuilder] directly. /// /// There will be several ways to consume the transaction: /// 1. Apply to the state to update the UI. /// 2. Send to the backend to store and do operation transforming. -/// 3. Stored by the UndoManager to implement redo/undo. -/// +/// 3. Used by the UndoManager to implement redo/undo. @immutable class Transaction { final UnmodifiableListView operations; 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 6fca48f230..319002bd45 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 @@ -11,16 +11,10 @@ import 'package:flowy_editor/document/selection.dart'; import './operation.dart'; import './transaction.dart'; -/// -/// This class is used to -/// build the transaction from the state. -/// -/// This class automatically save the -/// cursor from the state. -/// -/// When the transaction is undo, the -/// cursor can be restored. -/// +/// A [TransactionBuilder] is used to build the transaction from the state. +/// It will save make a snapshot of the cursor selection state automatically. +/// The cursor can be resoted if the transaction is undo. + class TransactionBuilder { final List operations = []; EditorState state; 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 d47ec2359a..5b543f03a1 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/undo_manager.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/undo_manager.dart @@ -7,7 +7,7 @@ import 'package:flowy_editor/operation/transaction.dart'; import 'package:flowy_editor/editor_state.dart'; import 'package:flutter/foundation.dart'; -/// This class contains operations committed by users. +/// A [HistoryItem] contains list of 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 { From c72fead19ca9f93bcbe0f58df32805715d35d41e Mon Sep 17 00:00:00 2001 From: Vincent Chan Date: Wed, 27 Jul 2022 13:27:24 +0800 Subject: [PATCH 02/11] feat: operation transforming --- .../flowy_editor/lib/operation/operation.dart | 85 ++++++++++++++++--- .../flowy_editor/test/operation_test.dart | 49 +++++++++++ 2 files changed, 123 insertions(+), 11 deletions(-) create mode 100644 frontend/app_flowy/packages/flowy_editor/test/operation_test.dart diff --git a/frontend/app_flowy/packages/flowy_editor/lib/operation/operation.dart b/frontend/app_flowy/packages/flowy_editor/lib/operation/operation.dart index e3710ddb3c..7534b03427 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/operation/operation.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/operation/operation.dart @@ -1,21 +1,27 @@ -import 'package:flowy_editor/document/path.dart'; -import 'package:flowy_editor/document/node.dart'; -import 'package:flowy_editor/document/text_delta.dart'; import 'package:flowy_editor/document/attributes.dart'; +import 'package:flowy_editor/flowy_editor.dart'; abstract class Operation { + final Path path; + Operation({required this.path}); + Operation copyWithPath(Path path); Operation invert(); } class InsertOperation extends Operation { - final Path path; final Node value; InsertOperation({ - required this.path, + required super.path, required this.value, }); + InsertOperation copyWith({Path? path, Node? value}) => + InsertOperation(path: path ?? this.path, value: value ?? this.value); + + @override + Operation copyWithPath(Path path) => copyWith(path: path); + @override Operation invert() { return DeleteOperation( @@ -26,16 +32,25 @@ class InsertOperation extends Operation { } class UpdateOperation extends Operation { - final Path path; final Attributes attributes; final Attributes oldAttributes; UpdateOperation({ - required this.path, + required super.path, required this.attributes, required this.oldAttributes, }); + UpdateOperation copyWith( + {Path? path, Attributes? attributes, Attributes? oldAttributes}) => + UpdateOperation( + path: path ?? this.path, + attributes: attributes ?? this.attributes, + oldAttributes: oldAttributes ?? this.oldAttributes); + + @override + Operation copyWithPath(Path path) => copyWith(path: path); + @override Operation invert() { return UpdateOperation( @@ -47,14 +62,19 @@ class UpdateOperation extends Operation { } class DeleteOperation extends Operation { - final Path path; final Node removedValue; DeleteOperation({ - required this.path, + required super.path, required this.removedValue, }); + DeleteOperation copyWith({Path? path, Node? removedValue}) => DeleteOperation( + path: path ?? this.path, removedValue: removedValue ?? this.removedValue); + + @override + Operation copyWithPath(Path path) => copyWith(path: path); + @override Operation invert() { return InsertOperation( @@ -65,18 +85,61 @@ class DeleteOperation extends Operation { } class TextEditOperation extends Operation { - final Path path; final Delta delta; final Delta inverted; TextEditOperation({ - required this.path, + required super.path, required this.delta, required this.inverted, }); + TextEditOperation copyWith({Path? path, Delta? delta, Delta? inverted}) => + TextEditOperation( + path: path ?? this.path, + delta: delta ?? this.delta, + inverted: inverted ?? this.inverted); + + @override + Operation copyWithPath(Path path) => copyWith(path: path); + @override Operation invert() { return TextEditOperation(path: path, delta: inverted, inverted: delta); } } + +Path transformPath(Path preInsertPath, Path b, [int delta = 1]) { + if (preInsertPath.length > b.length) { + return b; + } + if (preInsertPath.isEmpty || b.isEmpty) { + return b; + } + // check the prefix + for (var i = 0; i < preInsertPath.length - 1; i++) { + if (preInsertPath[i] != b[i]) { + return b; + } + } + final prefix = preInsertPath.sublist(0, preInsertPath.length - 1); + final suffix = b.sublist(preInsertPath.length); + final preInsertLast = preInsertPath.last; + final bAtIndex = b[preInsertPath.length - 1]; + if (preInsertLast <= bAtIndex) { + prefix.add(bAtIndex + delta); + } + prefix.addAll(suffix); + return prefix; +} + +Operation transformOperation(Operation a, Operation b) { + if (a is InsertOperation) { + final newPath = transformPath(a.path, b.path); + return b.copyWithPath(newPath); + } else if (b is DeleteOperation) { + final newPath = transformPath(a.path, b.path, -1); + return b.copyWithPath(newPath); + } + return b; +} diff --git a/frontend/app_flowy/packages/flowy_editor/test/operation_test.dart b/frontend/app_flowy/packages/flowy_editor/test/operation_test.dart new file mode 100644 index 0000000000..53c2a243b9 --- /dev/null +++ b/frontend/app_flowy/packages/flowy_editor/test/operation_test.dart @@ -0,0 +1,49 @@ +import 'dart:collection'; + +import 'package:flowy_editor/document/node.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flowy_editor/operation/operation.dart'; + +void main() { + group('transform path', () { + test('transform path changed', () { + expect(transformPath([0, 1], [0, 1]), [0, 2]); + expect(transformPath([0, 1], [0, 2]), [0, 3]); + expect(transformPath([0, 1], [0, 2, 7, 8, 9]), [0, 3, 7, 8, 9]); + expect(transformPath([0, 1, 2], [0, 0, 7, 8, 9]), [0, 0, 7, 8, 9]); + }); + test("transform path not changed", () { + expect(transformPath([0, 1, 2], [0, 0, 7, 8, 9]), [0, 0, 7, 8, 9]); + expect(transformPath([0, 1, 2], [0, 1]), [0, 1]); + }); + test("transform path delta", () { + expect(transformPath([0, 1], [0, 1], 5), [0, 6]); + }); + }); + group('transform operation', () { + test('insert + insert', () { + final t = transformOperation( + InsertOperation(path: [ + 0, + 1 + ], value: Node(type: "node", attributes: {}, children: LinkedList())), + InsertOperation( + path: [0, 1], + value: + Node(type: "node", attributes: {}, children: LinkedList()))); + expect(t.path, [0, 2]); + }); + test('delete + delete', () { + final t = transformOperation( + DeleteOperation( + path: [0, 1], + removedValue: + Node(type: "node", attributes: {}, children: LinkedList())), + DeleteOperation( + path: [0, 2], + removedValue: + Node(type: "node", attributes: {}, children: LinkedList()))); + expect(t.path, [0, 1]); + }); + }); +} From 033410aacd23dd8dd5bc896f722858e46de27ca4 Mon Sep 17 00:00:00 2001 From: Vincent Chan Date: Wed, 27 Jul 2022 13:54:46 +0800 Subject: [PATCH 03/11] feat: transform operation in transaction builder --- .../flowy_editor/lib/document/node.dart | 6 +++- .../flowy_editor/lib/operation/operation.dart | 1 + .../lib/operation/transaction_builder.dart | 3 ++ .../flowy_editor/test/operation_test.dart | 33 +++++++++++++++++++ 4 files changed, 42 insertions(+), 1 deletion(-) diff --git a/frontend/app_flowy/packages/flowy_editor/lib/document/node.dart b/frontend/app_flowy/packages/flowy_editor/lib/document/node.dart index 8b80fd0b51..9871bf24ee 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/document/node.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/document/node.dart @@ -31,7 +31,11 @@ class Node extends ChangeNotifier with LinkedListEntry { required this.children, required this.attributes, this.parent, - }); + }) { + for (final child in children) { + child.parent = this; + } + } factory Node.fromJson(Map json) { assert(json['type'] is String); diff --git a/frontend/app_flowy/packages/flowy_editor/lib/operation/operation.dart b/frontend/app_flowy/packages/flowy_editor/lib/operation/operation.dart index 7534b03427..487844af14 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/operation/operation.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/operation/operation.dart @@ -141,5 +141,6 @@ Operation transformOperation(Operation a, Operation b) { final newPath = transformPath(a.path, b.path, -1); return b.copyWithPath(newPath); } + // TODO: transform update and textedit return b; } 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 6fca48f230..b7ae2ac878 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 @@ -96,6 +96,9 @@ class TransactionBuilder { return; } } + for (var i = 0; i < operations.length; i++) { + op = transformOperation(operations[i], op); + } operations.add(op); } diff --git a/frontend/app_flowy/packages/flowy_editor/test/operation_test.dart b/frontend/app_flowy/packages/flowy_editor/test/operation_test.dart index 53c2a243b9..683b6df58e 100644 --- a/frontend/app_flowy/packages/flowy_editor/test/operation_test.dart +++ b/frontend/app_flowy/packages/flowy_editor/test/operation_test.dart @@ -3,6 +3,10 @@ import 'dart:collection'; import 'package:flowy_editor/document/node.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:flowy_editor/operation/operation.dart'; +import 'package:flowy_editor/operation/transaction_builder.dart'; +import 'package:flowy_editor/editor_state.dart'; +import 'package:flowy_editor/document/state_tree.dart'; +import 'package:flowy_editor/render/render_plugins.dart'; void main() { group('transform path', () { @@ -46,4 +50,33 @@ void main() { expect(t.path, [0, 1]); }); }); + test('transform transaction builder', () { + final item1 = Node(type: "node", attributes: {}, children: LinkedList()); + final item2 = Node(type: "node", attributes: {}, children: LinkedList()); + final item3 = Node(type: "node", attributes: {}, children: LinkedList()); + final root = Node( + type: "root", + attributes: {}, + children: LinkedList() + ..addAll([ + item1, + item2, + item3, + ])); + final state = EditorState( + document: StateTree(root: root), renderPlugins: RenderPlugins()); + + expect(item1.path, [0]); + expect(item2.path, [1]); + expect(item3.path, [2]); + + final tb = TransactionBuilder(state); + tb.deleteNode(item1); + tb.deleteNode(item2); + tb.deleteNode(item3); + final transaction = tb.finish(); + expect(transaction.operations[0].path, [0]); + expect(transaction.operations[1].path, [0]); + expect(transaction.operations[2].path, [0]); + }); } From e74f5e84dc0db1293f4a478988a16e589798d0a1 Mon Sep 17 00:00:00 2001 From: Vincent Chan Date: Wed, 27 Jul 2022 15:46:43 +0800 Subject: [PATCH 04/11] feat: handle arrow keys --- .../example/lib/plugin/text_node_widget.dart | 2 +- .../flowy_editor/lib/editor_state.dart | 19 +++++- .../arrow_keys_handler.dart | 59 +++++++++++++++++++ .../lib/service/selection_service.dart | 21 +++---- 4 files changed, 88 insertions(+), 13 deletions(-) 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 a67ebcd2ad..53c33cd295 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 @@ -126,7 +126,7 @@ class __TextNodeWidgetState extends State<_TextNodeWidget> textCapitalization: TextCapitalization.sentences, ), ); - editorState.cursorSelection = _localSelectionToGlobal(node, selection); + editorState.updateCursorSelection(_localSelectionToGlobal(node, selection)); _textInputConnection ?..show() ..setEditingState( 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 cd503843c2..a69b053c90 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/editor_state.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/editor_state.dart @@ -31,7 +31,22 @@ class EditorState { final service = FlowyService(); final UndoManager undoManager = UndoManager(); - Selection? cursorSelection; + Selection? _cursorSelection; + + Selection? get cursorSelection { + return _cursorSelection; + } + + /// add the set reason in the future, don't use setter + updateCursorSelection(Selection? cursorSelection) { + // broadcast to other users here + if (cursorSelection == null) { + service.selectionService.clearSelection(); + } else { + service.selectionService.updateSelection(cursorSelection); + } + _cursorSelection = cursorSelection; + } Timer? _debouncedSealHistoryItemTimer; @@ -58,7 +73,7 @@ class EditorState { for (final op in transaction.operations) { _applyOperation(op); } - cursorSelection = transaction.afterSelection; + updateCursorSelection(transaction.afterSelection); if (options.recordUndo) { final undoItem = undoManager.getUndoHistoryItem(); diff --git a/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/arrow_keys_handler.dart b/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/arrow_keys_handler.dart index 95496db2ea..bdb473042d 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/arrow_keys_handler.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/arrow_keys_handler.dart @@ -1,7 +1,17 @@ +import 'package:flowy_editor/document/node.dart'; +import 'package:flowy_editor/document/position.dart'; import 'package:flowy_editor/service/keyboard_service.dart'; +import 'package:flowy_editor/document/selection.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +int _endOffsetOfNode(Node node) { + if (node is TextNode) { + return node.delta.length; + } + return 0; +} + FlowyKeyEventHandler arrowKeysHandler = (editorState, event) { if (event.logicalKey != LogicalKeyboardKey.arrowUp && event.logicalKey != LogicalKeyboardKey.arrowDown && @@ -10,5 +20,54 @@ FlowyKeyEventHandler arrowKeysHandler = (editorState, event) { return KeyEventResult.ignored; } + final currentSelection = editorState.cursorSelection; + if (currentSelection == null) { + return KeyEventResult.ignored; + } + + if (event.logicalKey == LogicalKeyboardKey.arrowLeft) { + // turn left + if (currentSelection.isCollapsed) { + final end = currentSelection.end; + final offset = end.offset; + if (offset == 0) { + final node = editorState.document.nodeAtPath(end.path)!; + final prevNode = node.previous; + if (prevNode != null) { + editorState.updateCursorSelection(Selection.collapsed(Position( + path: prevNode.path, offset: _endOffsetOfNode(prevNode)))); + } + return KeyEventResult.handled; + } + editorState.updateCursorSelection( + Selection.collapsed(Position(path: end.path, offset: offset - 1))); + } else { + editorState + .updateCursorSelection(currentSelection.collapse(atStart: true)); + } + return KeyEventResult.handled; + } else if (event.logicalKey == LogicalKeyboardKey.arrowRight) { + if (currentSelection.isCollapsed) { + final end = currentSelection.end; + final offset = end.offset; + final node = editorState.document.nodeAtPath(end.path)!; + final lengthOfNode = _endOffsetOfNode(node); + if (offset >= lengthOfNode) { + final nextNode = node.next; + if (nextNode != null) { + editorState.updateCursorSelection( + Selection.collapsed(Position(path: nextNode.path, offset: 0))); + } + return KeyEventResult.handled; + } + + editorState.updateCursorSelection( + Selection.collapsed(Position(path: end.path, offset: offset + 1))); + } else { + editorState.updateCursorSelection(currentSelection.collapse()); + } + return KeyEventResult.handled; + } + return KeyEventResult.ignored; }; diff --git a/frontend/app_flowy/packages/flowy_editor/lib/service/selection_service.dart b/frontend/app_flowy/packages/flowy_editor/lib/service/selection_service.dart index 19604b0227..e3c262a360 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/service/selection_service.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/service/selection_service.dart @@ -1,4 +1,3 @@ -import 'package:flowy_editor/document/path.dart'; import 'package:flowy_editor/document/node.dart'; import 'package:flowy_editor/document/position.dart'; import 'package:flowy_editor/document/selection.dart'; @@ -49,7 +48,7 @@ mixin FlowySelectionService on State { /// [start] is the offset under the global coordinate system. Node? computeNodeInOffset(Node node, Offset offset); - /// Return the [Node]s in multiple selection. Emtpy list would be returned + /// Return the [Node]s in multiple selection. Empty list would be returned /// if no nodes are in range. /// /// [start] is the offset under the global coordinate system. @@ -136,8 +135,8 @@ class _FlowySelectionState extends State TapGestureRecognizer: GestureRecognizerFactoryWithHandlers( () => TapGestureRecognizer(), - (recongizer) { - recongizer.onTapDown = _onTapDown; + (recognizer) { + recognizer.onTapDown = _onTapDown; }, ) }, @@ -167,9 +166,9 @@ class _FlowySelectionState extends State if (end != null) { return computeNodesInRange(editorState.document.root, start, end); } else { - final reuslt = computeNodeInOffset(editorState.document.root, start); - if (reuslt != null) { - return [reuslt]; + final result = computeNodeInOffset(editorState.document.root, start); + if (result != null) { + return [result]; } } return []; @@ -253,8 +252,10 @@ class _FlowySelectionState extends State if (selectable != null) { final position = selectable.getPositionInOffset(tapOffset!); final selection = Selection.collapsed(position); - updateSelection(selection); + editorState.updateCursorSelection(selection); } + } else { + editorState.updateCursorSelection(null); } } @@ -283,7 +284,7 @@ class _FlowySelectionState extends State final selection = Selection( start: isDownward ? start : end, end: isDownward ? end : start); debugPrint('[_onPanUpdate] $selection'); - updateSelection(selection); + editorState.updateCursorSelection(selection); } } @@ -302,7 +303,7 @@ class _FlowySelectionState extends State _cursorOverlays ..forEach((overlay) => overlay.remove()) ..clear(); - // clear floating shortcusts + // clear floating shortcuts editorState.service.floatingShortcutServiceKey.currentState ?.unwrapOrNull() ?.hide(); From 53b982e7c9b50a26612e80d7ab44c444cc465277 Mon Sep 17 00:00:00 2001 From: Vincent Chan Date: Wed, 27 Jul 2022 15:58:25 +0800 Subject: [PATCH 05/11] feat: arrow up and down --- .../example/lib/plugin/image_node_widget.dart | 5 ++ .../lib/plugin/selected_text_node_widget.dart | 5 ++ .../lib/render/selection/cursor_widget.dart | 25 +++++++-- .../lib/render/selection/selectable.dart | 2 + .../arrow_keys_handler.dart | 22 +++++++- .../lib/service/selection_service.dart | 56 +++++++++++++++---- 6 files changed, 98 insertions(+), 17 deletions(-) diff --git a/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/image_node_widget.dart b/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/image_node_widget.dart index c5084df2fb..00a7fce8ad 100644 --- a/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/image_node_widget.dart +++ b/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/image_node_widget.dart @@ -63,6 +63,11 @@ class __ImageNodeWidgetState extends State<_ImageNodeWidget> with Selectable { throw UnimplementedError(); } + @override + Offset localToGlobal(Offset offset) { + throw UnimplementedError(); + } + @override Rect getCursorRectInPosition(Position position) { // TODO: implement getCursorRectInPosition diff --git a/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/selected_text_node_widget.dart b/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/selected_text_node_widget.dart index 894f6b1848..3238decb81 100644 --- a/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/selected_text_node_widget.dart +++ b/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/selected_text_node_widget.dart @@ -70,6 +70,11 @@ class _SelectedTextNodeWidgetState extends State<_SelectedTextNodeWidget> ); } + @override + Offset localToGlobal(Offset offset) { + return _renderParagraph.localToGlobal(offset); + } + @override List getRectsInSelection(Selection selection) { assert(pathEquals(selection.start.path, selection.end.path)); diff --git a/frontend/app_flowy/packages/flowy_editor/lib/render/selection/cursor_widget.dart b/frontend/app_flowy/packages/flowy_editor/lib/render/selection/cursor_widget.dart index 2ba42221f0..3e11073729 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/render/selection/cursor_widget.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/render/selection/cursor_widget.dart @@ -17,10 +17,10 @@ class CursorWidget extends StatefulWidget { final LayerLink layerLink; @override - State createState() => _CursorWidgetState(); + State createState() => CursorWidgetState(); } -class _CursorWidgetState extends State { +class CursorWidgetState extends State { bool showCursor = true; late Timer timer; @@ -28,7 +28,17 @@ class _CursorWidgetState extends State { void initState() { super.initState(); - timer = Timer.periodic( + timer = _initTimer(); + } + + @override + void dispose() { + timer.cancel(); + super.dispose(); + } + + Timer _initTimer() { + return Timer.periodic( Duration(milliseconds: (widget.blinkingInterval * 1000).toInt()), (timer) { setState(() { @@ -37,10 +47,13 @@ class _CursorWidgetState extends State { }); } - @override - void dispose() { + /// force the cursor widget to show for a while + show() { + setState(() { + showCursor = true; + }); timer.cancel(); - super.dispose(); + timer = _initTimer(); } @override diff --git a/frontend/app_flowy/packages/flowy_editor/lib/render/selection/selectable.dart b/frontend/app_flowy/packages/flowy_editor/lib/render/selection/selectable.dart index 4d155972df..df5649e320 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/render/selection/selectable.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/render/selection/selectable.dart @@ -23,6 +23,8 @@ mixin Selectable on State { Position getPositionInOffset(Offset start); Rect getCursorRectInPosition(Position position); + Offset localToGlobal(Offset offset); + Position start(); Position end(); diff --git a/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/arrow_keys_handler.dart b/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/arrow_keys_handler.dart index bdb473042d..30b295765e 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/arrow_keys_handler.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/arrow_keys_handler.dart @@ -2,6 +2,7 @@ import 'package:flowy_editor/document/node.dart'; import 'package:flowy_editor/document/position.dart'; import 'package:flowy_editor/service/keyboard_service.dart'; import 'package:flowy_editor/document/selection.dart'; +import 'package:flowy_editor/extensions/node_extensions.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -26,7 +27,6 @@ FlowyKeyEventHandler arrowKeysHandler = (editorState, event) { } if (event.logicalKey == LogicalKeyboardKey.arrowLeft) { - // turn left if (currentSelection.isCollapsed) { final end = currentSelection.end; final offset = end.offset; @@ -67,6 +67,26 @@ FlowyKeyEventHandler arrowKeysHandler = (editorState, event) { editorState.updateCursorSelection(currentSelection.collapse()); } return KeyEventResult.handled; + } else if (event.logicalKey == LogicalKeyboardKey.arrowUp) { + final rects = editorState.service.selectionService.rects(); + if (rects.isEmpty) { + return KeyEventResult.handled; + } + final first = rects.first; + final firstOffset = Offset(first.left, first.top); + final hitOffset = firstOffset - Offset(0, first.height * 0.5); + editorState.service.selectionService.hit(hitOffset); + return KeyEventResult.handled; + } else if (event.logicalKey == LogicalKeyboardKey.arrowDown) { + final rects = editorState.service.selectionService.rects(); + if (rects.isEmpty) { + return KeyEventResult.handled; + } + final first = rects.last; + final firstOffset = Offset(first.right, first.bottom); + final hitOffset = firstOffset + Offset(0, first.height * 0.5); + editorState.service.selectionService.hit(hitOffset); + return KeyEventResult.handled; } return KeyEventResult.ignored; diff --git a/frontend/app_flowy/packages/flowy_editor/lib/service/selection_service.dart b/frontend/app_flowy/packages/flowy_editor/lib/service/selection_service.dart index e3c262a360..fee31ab4c0 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/service/selection_service.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/service/selection_service.dart @@ -1,6 +1,7 @@ import 'package:flowy_editor/document/node.dart'; import 'package:flowy_editor/document/position.dart'; import 'package:flowy_editor/document/selection.dart'; +import 'package:flowy_editor/render/selection/selectable.dart'; import 'package:flowy_editor/render/selection/cursor_widget.dart'; import 'package:flowy_editor/render/selection/flowy_selection_widget.dart'; import 'package:flowy_editor/extensions/object_extensions.dart'; @@ -26,6 +27,10 @@ mixin FlowySelectionService on State { /// void clearSelection(); + List rects(); + + hit(Offset? offset); + /// List getNodesInSelection(Selection selection); @@ -108,6 +113,8 @@ class _FlowySelectionState extends State /// Tap Offset? tapOffset; + final List _rects = []; + EditorState get editorState => widget.editorState; @override @@ -144,8 +151,13 @@ class _FlowySelectionState extends State ); } + List rects() { + return _rects; + } + @override void updateSelection(Selection selection) { + _rects.clear(); _clearSelection(); // cursor @@ -245,18 +257,29 @@ class _FlowySelectionState extends State tapOffset = details.globalPosition; - final nodes = getNodesInRange(tapOffset!); - if (nodes.isNotEmpty) { - assert(nodes.length == 1); - final selectable = nodes.first.selectable; - if (selectable != null) { - final position = selectable.getPositionInOffset(tapOffset!); - final selection = Selection.collapsed(position); - editorState.updateCursorSelection(selection); - } - } else { + hit(tapOffset); + } + + @override + hit(Offset? offset) { + if (offset == null) { editorState.updateCursorSelection(null); + return; } + final nodes = getNodesInRange(offset); + if (nodes.isEmpty) { + editorState.updateCursorSelection(null); + return; + } + assert(nodes.length == 1); + final selectable = nodes.first.selectable; + if (selectable == null) { + editorState.updateCursorSelection(null); + return; + } + final position = selectable.getPositionInOffset(offset); + final selection = Selection.collapsed(position); + editorState.updateCursorSelection(selection); } void _onPanStart(DragStartDetails details) { @@ -353,6 +376,7 @@ class _FlowySelectionState extends State final rects = selectable.getRectsInSelection(newSelection); for (final rect in rects) { + _rects.add(_transformRectToGlobal(selectable, rect)); final overlay = OverlayEntry( builder: ((context) => SelectionWidget( color: widget.selectionColor, @@ -367,6 +391,11 @@ class _FlowySelectionState extends State Overlay.of(context)?.insertAll(_selectionOverlays); } + Rect _transformRectToGlobal(Selectable selectable, Rect r) { + final Offset topLeft = selectable.localToGlobal(Offset(r.left, r.top)); + return Rect.fromLTWH(topLeft.dx, topLeft.dy, r.width, r.height); + } + void _updateCursor(Position position) { final node = editorState.document.root.childAtPath(position.path); @@ -380,6 +409,7 @@ class _FlowySelectionState extends State final selectable = node.selectable; final rect = selectable?.getCursorRectInPosition(position); if (rect != null) { + _rects.add(_transformRectToGlobal(selectable!, rect)); final cursor = OverlayEntry( builder: ((context) => CursorWidget( key: _cursorKey, @@ -390,9 +420,15 @@ class _FlowySelectionState extends State ); _cursorOverlays.add(cursor); Overlay.of(context)?.insertAll(_cursorOverlays); + _forceShowCursor(); } } + _forceShowCursor() { + final currentState = _cursorKey.currentState as CursorWidgetState?; + currentState?.show(); + } + List _selectedNodesInSelection(Node node, Selection selection) { List result = []; if (node.parent != null) { From 9851b26f2250a7b9e8f4f0835902beaeaa31319a Mon Sep 17 00:00:00 2001 From: Vincent Chan Date: Wed, 27 Jul 2022 19:42:09 +0800 Subject: [PATCH 06/11] fix: transform error for path --- .../packages/flowy_editor/lib/operation/operation.dart | 2 ++ .../app_flowy/packages/flowy_editor/test/operation_test.dart | 1 + 2 files changed, 3 insertions(+) diff --git a/frontend/app_flowy/packages/flowy_editor/lib/operation/operation.dart b/frontend/app_flowy/packages/flowy_editor/lib/operation/operation.dart index 487844af14..eafa4a31da 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/operation/operation.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/operation/operation.dart @@ -128,6 +128,8 @@ Path transformPath(Path preInsertPath, Path b, [int delta = 1]) { final bAtIndex = b[preInsertPath.length - 1]; if (preInsertLast <= bAtIndex) { prefix.add(bAtIndex + delta); + } else { + prefix.add(bAtIndex); } prefix.addAll(suffix); return prefix; diff --git a/frontend/app_flowy/packages/flowy_editor/test/operation_test.dart b/frontend/app_flowy/packages/flowy_editor/test/operation_test.dart index 683b6df58e..176f00b734 100644 --- a/frontend/app_flowy/packages/flowy_editor/test/operation_test.dart +++ b/frontend/app_flowy/packages/flowy_editor/test/operation_test.dart @@ -19,6 +19,7 @@ void main() { test("transform path not changed", () { expect(transformPath([0, 1, 2], [0, 0, 7, 8, 9]), [0, 0, 7, 8, 9]); expect(transformPath([0, 1, 2], [0, 1]), [0, 1]); + expect(transformPath([1, 1], [1, 0]), [1, 0]); }); test("transform path delta", () { expect(transformPath([0, 1], [0, 1], 5), [0, 6]); From 0ba7c53dad5dd1eafd7258b840a2dc33ad2fab3d Mon Sep 17 00:00:00 2001 From: Vincent Chan Date: Thu, 28 Jul 2022 14:41:29 +0800 Subject: [PATCH 07/11] feat: remove unused imports --- .../flowy_editor/lib/render/rich_text/flowy_rich_text.dart | 4 ++++ .../internal_key_event_handlers/arrow_keys_handler.dart | 1 - 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/flowy_rich_text.dart b/frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/flowy_rich_text.dart index 66c87a2dd4..4731542ae2 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/flowy_rich_text.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/flowy_rich_text.dart @@ -255,6 +255,10 @@ class _FlowyRichTextState extends State with Selectable { return Rect.zero; } + Offset localToGlobal(Offset offset) { + return _renderParagraph.localToGlobal(offset); + } + TextSpan get _decorateTextSpanWithGlobalStyle => TextSpan( children: _textSpan.children ?.whereType() diff --git a/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/arrow_keys_handler.dart b/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/arrow_keys_handler.dart index 30b295765e..3bc3f5e0b5 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/arrow_keys_handler.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/arrow_keys_handler.dart @@ -2,7 +2,6 @@ import 'package:flowy_editor/document/node.dart'; import 'package:flowy_editor/document/position.dart'; import 'package:flowy_editor/service/keyboard_service.dart'; import 'package:flowy_editor/document/selection.dart'; -import 'package:flowy_editor/extensions/node_extensions.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; From 883740d79a458a80a5431a4b9866bbf8fbc1b35d Mon Sep 17 00:00:00 2001 From: Vincent Chan Date: Thu, 28 Jul 2022 15:02:14 +0800 Subject: [PATCH 08/11] fix: assets of document --- frontend/app_flowy/packages/flowy_editor/pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/app_flowy/packages/flowy_editor/pubspec.yaml b/frontend/app_flowy/packages/flowy_editor/pubspec.yaml index 08e51118d1..403ee2dddf 100644 --- a/frontend/app_flowy/packages/flowy_editor/pubspec.yaml +++ b/frontend/app_flowy/packages/flowy_editor/pubspec.yaml @@ -24,11 +24,11 @@ dev_dependencies: # The following section is specific to Flutter packages. flutter: - # To add assets to your package, add an assets section, like this: assets: - assets/images/uncheck.svg - assets/images/ + - assets/document.json # - images/a_dot_burr.jpeg # - images/a_dot_ham.jpeg # From 1d3e5a9e8b52a396a50c9ba96284416b728d0fb7 Mon Sep 17 00:00:00 2001 From: Vincent Chan Date: Thu, 28 Jul 2022 15:25:19 +0800 Subject: [PATCH 09/11] feat: handle shift keys --- .../arrow_keys_handler.dart | 97 ++++++++++++------- 1 file changed, 63 insertions(+), 34 deletions(-) diff --git a/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/arrow_keys_handler.dart b/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/arrow_keys_handler.dart index 3bc3f5e0b5..cec123ad87 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/arrow_keys_handler.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/arrow_keys_handler.dart @@ -1,7 +1,5 @@ -import 'package:flowy_editor/document/node.dart'; -import 'package:flowy_editor/document/position.dart'; +import 'package:flowy_editor/flowy_editor.dart'; import 'package:flowy_editor/service/keyboard_service.dart'; -import 'package:flowy_editor/document/selection.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -12,14 +10,65 @@ int _endOffsetOfNode(Node node) { return 0; } -FlowyKeyEventHandler arrowKeysHandler = (editorState, event) { - if (event.logicalKey != LogicalKeyboardKey.arrowUp && - event.logicalKey != LogicalKeyboardKey.arrowDown && - event.logicalKey != LogicalKeyboardKey.arrowLeft && - event.logicalKey != LogicalKeyboardKey.arrowRight) { +KeyEventResult _handleShiftKey(EditorState editorState, RawKeyEvent event) { + final currentSelection = editorState.cursorSelection; + if (currentSelection == null) { return KeyEventResult.ignored; } + if (event.logicalKey == LogicalKeyboardKey.arrowLeft) { + final leftPosition = _leftPosition(editorState, currentSelection.start); + if (leftPosition != null) { + editorState.updateCursorSelection( + Selection(start: leftPosition, end: currentSelection.end)); + } + return KeyEventResult.handled; + } else if (event.logicalKey == LogicalKeyboardKey.arrowRight) { + final rightPosition = _rightPosition(editorState, currentSelection.end); + if (rightPosition != null) { + editorState.updateCursorSelection( + Selection(start: currentSelection.start, end: rightPosition)); + } + return KeyEventResult.handled; + } + return KeyEventResult.ignored; +} + +Position? _leftPosition(EditorState editorState, Position position) { + final offset = position.offset; + if (offset == 0) { + final node = editorState.document.nodeAtPath(position.path)!; + final prevNode = node.previous; + if (prevNode != null) { + editorState.updateCursorSelection(Selection.collapsed( + Position(path: prevNode.path, offset: _endOffsetOfNode(prevNode)))); + } + return null; + } + + return Position(path: position.path, offset: offset - 1); +} + +Position? _rightPosition(EditorState editorState, Position position) { + final offset = position.offset; + final node = editorState.document.nodeAtPath(position.path)!; + final lengthOfNode = _endOffsetOfNode(node); + if (offset >= lengthOfNode) { + final nextNode = node.next; + if (nextNode != null) { + Position(path: nextNode.path, offset: 0); + } + return null; + } + + return Position(path: position.path, offset: offset + 1); +} + +FlowyKeyEventHandler arrowKeysHandler = (editorState, event) { + if (event.isShiftPressed) { + return _handleShiftKey(editorState, event); + } + final currentSelection = editorState.cursorSelection; if (currentSelection == null) { return KeyEventResult.ignored; @@ -27,19 +76,10 @@ FlowyKeyEventHandler arrowKeysHandler = (editorState, event) { if (event.logicalKey == LogicalKeyboardKey.arrowLeft) { if (currentSelection.isCollapsed) { - final end = currentSelection.end; - final offset = end.offset; - if (offset == 0) { - final node = editorState.document.nodeAtPath(end.path)!; - final prevNode = node.previous; - if (prevNode != null) { - editorState.updateCursorSelection(Selection.collapsed(Position( - path: prevNode.path, offset: _endOffsetOfNode(prevNode)))); - } - return KeyEventResult.handled; + final leftPosition = _leftPosition(editorState, currentSelection.start); + if (leftPosition != null) { + editorState.updateCursorSelection(Selection.collapsed(leftPosition)); } - editorState.updateCursorSelection( - Selection.collapsed(Position(path: end.path, offset: offset - 1))); } else { editorState .updateCursorSelection(currentSelection.collapse(atStart: true)); @@ -47,21 +87,10 @@ FlowyKeyEventHandler arrowKeysHandler = (editorState, event) { return KeyEventResult.handled; } else if (event.logicalKey == LogicalKeyboardKey.arrowRight) { if (currentSelection.isCollapsed) { - final end = currentSelection.end; - final offset = end.offset; - final node = editorState.document.nodeAtPath(end.path)!; - final lengthOfNode = _endOffsetOfNode(node); - if (offset >= lengthOfNode) { - final nextNode = node.next; - if (nextNode != null) { - editorState.updateCursorSelection( - Selection.collapsed(Position(path: nextNode.path, offset: 0))); - } - return KeyEventResult.handled; + final rightPosition = _rightPosition(editorState, currentSelection.end); + if (rightPosition != null) { + editorState.updateCursorSelection(Selection.collapsed(rightPosition)); } - - editorState.updateCursorSelection( - Selection.collapsed(Position(path: end.path, offset: offset + 1))); } else { editorState.updateCursorSelection(currentSelection.collapse()); } From b91c5d9c7b3e50b33d212723a53dbddd7ca6a51d Mon Sep 17 00:00:00 2001 From: Vincent Chan Date: Thu, 28 Jul 2022 15:33:42 +0800 Subject: [PATCH 10/11] refactor: add hitTest method for selection service --- .../arrow_keys_handler.dart | 138 ++++++++++-------- .../lib/service/selection_service.dart | 21 +-- 2 files changed, 92 insertions(+), 67 deletions(-) diff --git a/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/arrow_keys_handler.dart b/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/arrow_keys_handler.dart index cec123ad87..7fbdf669b5 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/arrow_keys_handler.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/arrow_keys_handler.dart @@ -10,6 +10,58 @@ int _endOffsetOfNode(Node node) { return 0; } +extension on Position { + Position? goLeft(EditorState editorState) { + if (offset == 0) { + final node = editorState.document.nodeAtPath(path)!; + final prevNode = node.previous; + if (prevNode != null) { + return Position( + path: prevNode.path, offset: _endOffsetOfNode(prevNode)); + } + return null; + } + + return Position(path: path, offset: offset - 1); + } + + Position? goRight(EditorState editorState) { + final node = editorState.document.nodeAtPath(path)!; + final lengthOfNode = _endOffsetOfNode(node); + if (offset >= lengthOfNode) { + final nextNode = node.next; + if (nextNode != null) { + return Position(path: nextNode.path, offset: 0); + } + return null; + } + + return Position(path: path, offset: offset + 1); + } +} + +Position? _goUp(EditorState editorState) { + final rects = editorState.service.selectionService.rects(); + if (rects.isEmpty) { + return null; + } + final first = rects.first; + final firstOffset = Offset(first.left, first.top); + final hitOffset = firstOffset - Offset(0, first.height * 0.5); + return editorState.service.selectionService.hitTest(hitOffset); +} + +Position? _goDown(EditorState editorState) { + final rects = editorState.service.selectionService.rects(); + if (rects.isEmpty) { + return null; + } + final first = rects.last; + final firstOffset = Offset(first.right, first.bottom); + final hitOffset = firstOffset + Offset(0, first.height * 0.5); + return editorState.service.selectionService.hitTest(hitOffset); +} + KeyEventResult _handleShiftKey(EditorState editorState, RawKeyEvent event) { final currentSelection = editorState.cursorSelection; if (currentSelection == null) { @@ -17,53 +69,33 @@ KeyEventResult _handleShiftKey(EditorState editorState, RawKeyEvent event) { } if (event.logicalKey == LogicalKeyboardKey.arrowLeft) { - final leftPosition = _leftPosition(editorState, currentSelection.start); - if (leftPosition != null) { - editorState.updateCursorSelection( - Selection(start: leftPosition, end: currentSelection.end)); - } + final leftPosition = currentSelection.end.goLeft(editorState); + editorState.updateCursorSelection(leftPosition == null + ? null + : Selection(start: currentSelection.start, end: leftPosition)); return KeyEventResult.handled; } else if (event.logicalKey == LogicalKeyboardKey.arrowRight) { - final rightPosition = _rightPosition(editorState, currentSelection.end); - if (rightPosition != null) { - editorState.updateCursorSelection( - Selection(start: currentSelection.start, end: rightPosition)); - } + final rightPosition = currentSelection.start.goRight(editorState); + editorState.updateCursorSelection(rightPosition == null + ? null + : Selection(start: rightPosition, end: currentSelection.end)); + return KeyEventResult.handled; + } else if (event.logicalKey == LogicalKeyboardKey.arrowUp) { + final position = _goUp(editorState); + editorState.updateCursorSelection(position == null + ? null + : Selection(start: position, end: currentSelection.end)); + return KeyEventResult.handled; + } else if (event.logicalKey == LogicalKeyboardKey.arrowDown) { + final position = _goDown(editorState); + editorState.updateCursorSelection(position == null + ? null + : Selection(start: currentSelection.start, end: position)); return KeyEventResult.handled; } return KeyEventResult.ignored; } -Position? _leftPosition(EditorState editorState, Position position) { - final offset = position.offset; - if (offset == 0) { - final node = editorState.document.nodeAtPath(position.path)!; - final prevNode = node.previous; - if (prevNode != null) { - editorState.updateCursorSelection(Selection.collapsed( - Position(path: prevNode.path, offset: _endOffsetOfNode(prevNode)))); - } - return null; - } - - return Position(path: position.path, offset: offset - 1); -} - -Position? _rightPosition(EditorState editorState, Position position) { - final offset = position.offset; - final node = editorState.document.nodeAtPath(position.path)!; - final lengthOfNode = _endOffsetOfNode(node); - if (offset >= lengthOfNode) { - final nextNode = node.next; - if (nextNode != null) { - Position(path: nextNode.path, offset: 0); - } - return null; - } - - return Position(path: position.path, offset: offset + 1); -} - FlowyKeyEventHandler arrowKeysHandler = (editorState, event) { if (event.isShiftPressed) { return _handleShiftKey(editorState, event); @@ -76,7 +108,7 @@ FlowyKeyEventHandler arrowKeysHandler = (editorState, event) { if (event.logicalKey == LogicalKeyboardKey.arrowLeft) { if (currentSelection.isCollapsed) { - final leftPosition = _leftPosition(editorState, currentSelection.start); + final leftPosition = currentSelection.start.goLeft(editorState); if (leftPosition != null) { editorState.updateCursorSelection(Selection.collapsed(leftPosition)); } @@ -87,7 +119,7 @@ FlowyKeyEventHandler arrowKeysHandler = (editorState, event) { return KeyEventResult.handled; } else if (event.logicalKey == LogicalKeyboardKey.arrowRight) { if (currentSelection.isCollapsed) { - final rightPosition = _rightPosition(editorState, currentSelection.end); + final rightPosition = currentSelection.end.goRight(editorState); if (rightPosition != null) { editorState.updateCursorSelection(Selection.collapsed(rightPosition)); } @@ -96,24 +128,14 @@ FlowyKeyEventHandler arrowKeysHandler = (editorState, event) { } return KeyEventResult.handled; } else if (event.logicalKey == LogicalKeyboardKey.arrowUp) { - final rects = editorState.service.selectionService.rects(); - if (rects.isEmpty) { - return KeyEventResult.handled; - } - final first = rects.first; - final firstOffset = Offset(first.left, first.top); - final hitOffset = firstOffset - Offset(0, first.height * 0.5); - editorState.service.selectionService.hit(hitOffset); + final position = _goUp(editorState); + editorState.updateCursorSelection( + position == null ? null : Selection.collapsed(position)); return KeyEventResult.handled; } else if (event.logicalKey == LogicalKeyboardKey.arrowDown) { - final rects = editorState.service.selectionService.rects(); - if (rects.isEmpty) { - return KeyEventResult.handled; - } - final first = rects.last; - final firstOffset = Offset(first.right, first.bottom); - final hitOffset = firstOffset + Offset(0, first.height * 0.5); - editorState.service.selectionService.hit(hitOffset); + final position = _goDown(editorState); + editorState.updateCursorSelection( + position == null ? null : Selection.collapsed(position)); return KeyEventResult.handled; } diff --git a/frontend/app_flowy/packages/flowy_editor/lib/service/selection_service.dart b/frontend/app_flowy/packages/flowy_editor/lib/service/selection_service.dart index c585c13bdd..3cfd1fd3f7 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/service/selection_service.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/service/selection_service.dart @@ -30,7 +30,7 @@ mixin FlowySelectionService on State { List rects(); - hit(Offset? offset); + Position? hitTest(Offset? offset); /// List getNodesInSelection(Selection selection); @@ -285,29 +285,32 @@ class _FlowySelectionState extends State tapOffset = details.globalPosition; - hit(tapOffset); + final position = hitTest(tapOffset); + if (position == null) { + return; + } + final selection = Selection.collapsed(position); + editorState.updateCursorSelection(selection); } @override - hit(Offset? offset) { + Position? hitTest(Offset? offset) { if (offset == null) { editorState.updateCursorSelection(null); - return; + return null; } final nodes = getNodesInRange(offset); if (nodes.isEmpty) { editorState.updateCursorSelection(null); - return; + return null; } assert(nodes.length == 1); final selectable = nodes.first.selectable; if (selectable == null) { editorState.updateCursorSelection(null); - return; + return null; } - final position = selectable.getPositionInOffset(offset); - final selection = Selection.collapsed(position); - editorState.updateCursorSelection(selection); + return selectable.getPositionInOffset(offset); } void _onPanStart(DragStartDetails details) { From 2a09f69bec069cec085db61b99c6c3c04ba0121e Mon Sep 17 00:00:00 2001 From: Vincent Chan Date: Thu, 28 Jul 2022 18:06:54 +0800 Subject: [PATCH 11/11] feat: double tap on text --- .../lib/render/rich_text/flowy_rich_text.dart | 10 ++ .../lib/render/selection/selectable.dart | 4 + .../lib/service/selection_service.dart | 133 +++++++++++++++--- 3 files changed, 125 insertions(+), 22 deletions(-) diff --git a/frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/flowy_rich_text.dart b/frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/flowy_rich_text.dart index 4731542ae2..122b65991e 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/flowy_rich_text.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/flowy_rich_text.dart @@ -108,6 +108,16 @@ class _FlowyRichTextState extends State with Selectable { return Position(path: _textNode.path, offset: baseOffset); } + @override + Selection? getWorldBoundaryInOffset(Offset offset) { + final localOffset = _renderParagraph.globalToLocal(offset); + final textPosition = _renderParagraph.getPositionForOffset(localOffset); + final textRange = _renderParagraph.getWordBoundary(textPosition); + final start = Position(path: _textNode.path, offset: textRange.start); + final end = Position(path: _textNode.path, offset: textRange.end); + return Selection(start: start, end: end); + } + @override List getRectsInSelection(Selection selection) { assert(pathEquals(selection.start.path, selection.end.path) && diff --git a/frontend/app_flowy/packages/flowy_editor/lib/render/selection/selectable.dart b/frontend/app_flowy/packages/flowy_editor/lib/render/selection/selectable.dart index b677b2f47c..bc32706aa0 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/render/selection/selectable.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/render/selection/selectable.dart @@ -21,6 +21,10 @@ mixin Selectable on State { /// /// The return result must be an offset of the local coordinate system. Position getPositionInOffset(Offset start); + Selection? getWorldBoundaryInOffset(Offset start) { + return null; + } + Rect getCursorRectInPosition(Position position); Offset localToGlobal(Offset offset); diff --git a/frontend/app_flowy/packages/flowy_editor/lib/service/selection_service.dart b/frontend/app_flowy/packages/flowy_editor/lib/service/selection_service.dart index 3cfd1fd3f7..43b77baeaf 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/service/selection_service.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/service/selection_service.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flowy_editor/document/node.dart'; import 'package:flowy_editor/document/position.dart'; import 'package:flowy_editor/document/selection.dart'; @@ -6,10 +8,10 @@ import 'package:flowy_editor/render/selection/cursor_widget.dart'; import 'package:flowy_editor/render/selection/flowy_selection_widget.dart'; import 'package:flowy_editor/extensions/object_extensions.dart'; import 'package:flowy_editor/extensions/node_extensions.dart'; +import 'package:flutter/gestures.dart'; import 'package:flowy_editor/service/shortcut_service.dart'; import 'package:flowy_editor/editor_state.dart'; -import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; /// Process selection and cursor @@ -99,6 +101,92 @@ class FlowySelection extends StatefulWidget { State createState() => _FlowySelectionState(); } +/// Because the flutter's [DoubleTapGestureRecognizer] will block the [TapGestureRecognizer] +/// for a while. So we need to implement our own GestureDetector. +@immutable +class _SelectionGestureDetector extends StatefulWidget { + const _SelectionGestureDetector( + {Key? key, + this.child, + this.onTapDown, + this.onDoubleTapDown, + this.onPanStart, + this.onPanUpdate, + this.onPanEnd}) + : super(key: key); + + @override + State<_SelectionGestureDetector> createState() => + _SelectionGestureDetectorState(); + + final Widget? child; + + final GestureTapDownCallback? onTapDown; + final GestureTapDownCallback? onDoubleTapDown; + final GestureDragStartCallback? onPanStart; + final GestureDragUpdateCallback? onPanUpdate; + final GestureDragEndCallback? onPanEnd; +} + +class _SelectionGestureDetectorState extends State<_SelectionGestureDetector> { + bool _isDoubleTap = false; + Timer? _doubleTapTimer; + @override + Widget build(BuildContext context) { + return RawGestureDetector( + behavior: HitTestBehavior.translucent, + gestures: { + PanGestureRecognizer: + GestureRecognizerFactoryWithHandlers( + () => PanGestureRecognizer(), + (recognizer) { + recognizer + ..onStart = widget.onPanStart + ..onUpdate = widget.onPanUpdate + ..onEnd = widget.onPanEnd; + }, + ), + TapGestureRecognizer: + GestureRecognizerFactoryWithHandlers( + () => TapGestureRecognizer(), + (recognizer) { + recognizer.onTapDown = _tapDownDelegate; + }, + ), + }, + child: widget.child, + ); + } + + _tapDownDelegate(TapDownDetails tapDownDetails) { + if (_isDoubleTap) { + _isDoubleTap = false; + _doubleTapTimer?.cancel(); + _doubleTapTimer = null; + if (widget.onDoubleTapDown != null) { + widget.onDoubleTapDown!(tapDownDetails); + } + } else { + if (widget.onTapDown != null) { + widget.onTapDown!(tapDownDetails); + } + + _isDoubleTap = true; + _doubleTapTimer?.cancel(); + _doubleTapTimer = Timer(kDoubleTapTimeout, () { + _isDoubleTap = false; + _doubleTapTimer = null; + }); + } + } + + @override + void dispose() { + _doubleTapTimer?.cancel(); + super.dispose(); + } +} + class _FlowySelectionState extends State with FlowySelectionService, WidgetsBindingObserver { final _cursorKey = GlobalKey(debugLabel: 'cursor'); @@ -152,27 +240,12 @@ class _FlowySelectionState extends State @override Widget build(BuildContext context) { - return RawGestureDetector( - behavior: HitTestBehavior.translucent, - gestures: { - PanGestureRecognizer: - GestureRecognizerFactoryWithHandlers( - () => PanGestureRecognizer(), - (recognizer) { - recognizer - ..onStart = _onPanStart - ..onUpdate = _onPanUpdate - ..onEnd = _onPanEnd; - }, - ), - TapGestureRecognizer: - GestureRecognizerFactoryWithHandlers( - () => TapGestureRecognizer(), - (recognizer) { - recognizer.onTapDown = _onTapDown; - }, - ) - }, + return _SelectionGestureDetector( + onPanStart: _onPanStart, + onPanUpdate: _onPanUpdate, + onPanEnd: _onPanEnd, + onTapDown: _onTapDown, + onDoubleTapDown: _onDoubleTapDown, child: widget.child, ); } @@ -278,6 +351,22 @@ class _FlowySelectionState extends State return false; } + void _onDoubleTapDown(TapDownDetails details) { + final offset = details.globalPosition; + final nodes = getNodesInRange(offset); + if (nodes.isEmpty) { + editorState.updateCursorSelection(null); + return; + } + final selectable = nodes.first.selectable; + if (selectable == null) { + editorState.updateCursorSelection(null); + return; + } + editorState + .updateCursorSelection(selectable.getWorldBoundaryInOffset(offset)); + } + void _onTapDown(TapDownDetails details) { // clear old state. panStartOffset = null;