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/.vscode/launch.json b/frontend/app_flowy/packages/flowy_editor/.vscode/launch.json new file mode 100644 index 0000000000..f27c363a13 --- /dev/null +++ b/frontend/app_flowy/packages/flowy_editor/.vscode/launch.json @@ -0,0 +1,45 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "example", + "cwd": "example", + "request": "launch", + "type": "dart" + }, + { + "name": "example (profile mode)", + "cwd": "example", + "request": "launch", + "type": "dart", + "flutterMode": "profile" + }, + { + "name": "example (release mode)", + "cwd": "example", + "request": "launch", + "type": "dart", + "flutterMode": "release" + }, + { + "name": "flowy_editor", + "request": "launch", + "type": "dart" + }, + { + "name": "flowy_editor (profile mode)", + "request": "launch", + "type": "dart", + "flutterMode": "profile" + }, + { + "name": "flowy_editor (release mode)", + "request": "launch", + "type": "dart", + "flutterMode": "release" + }, + ] +} \ No newline at end of file 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 a57f41cf76..b4a026a9f0 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 e16abaa1aa..94c6e56a5e 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/document/node.dart b/frontend/app_flowy/packages/flowy_editor/lib/document/node.dart index 9760268e80..bdd6da444d 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/document/node.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/document/node.dart @@ -32,7 +32,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/editor_state.dart b/frontend/app_flowy/packages/flowy_editor/lib/editor_state.dart index 82ffc38e6a..02d18455df 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/editor_state.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/editor_state.dart @@ -38,7 +38,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; @@ -74,7 +89,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/operation/operation.dart b/frontend/app_flowy/packages/flowy_editor/lib/operation/operation.dart index e3710ddb3c..eafa4a31da 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,64 @@ 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); + } else { + prefix.add(bAtIndex); + } + 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); + } + // TODO: transform update and textedit + return b; +} 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..fb042fe566 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; @@ -96,6 +90,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/lib/render/rich_text/default_selectable.dart b/frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/default_selectable.dart index fe8ba39730..21cc5108f3 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/default_selectable.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/default_selectable.dart @@ -22,6 +22,11 @@ mixin DefaultSelectable { Selection getSelectionInRange(Offset start, Offset end) => forward.getSelectionInRange(start, end); + Offset localToGlobal(Offset offset) => forward.localToGlobal(offset); + + Selection? getWorldBoundaryInOffset(Offset offset) => + forward.getWorldBoundaryInOffset(offset); + Position start() => forward.start(); Position end() => forward.end(); 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 89ebce3026..d0f96372c3 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 @@ -92,6 +92,16 @@ class _FlowyRichTextState extends State with Selectable { return Position(path: widget.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: widget.textNode.path, offset: textRange.start); + final end = Position(path: widget.textNode.path, offset: textRange.end); + return Selection(start: start, end: end); + } + @override List getRectsInSelection(Selection selection) { assert(pathEquals(selection.start.path, selection.end.path) && @@ -155,6 +165,11 @@ class _FlowyRichTextState extends State with Selectable { ); } + @override + Offset localToGlobal(Offset offset) { + return _renderParagraph.localToGlobal(offset); + } + TextSpan get _textSpan => TextSpan( children: widget.textNode.delta.operations .whereType() diff --git a/frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/quoted_text.dart b/frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/quoted_text.dart index 773fd0debe..b4ee999ce8 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/quoted_text.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/quoted_text.dart @@ -4,7 +4,6 @@ import 'package:flowy_editor/infra/flowy_svg.dart'; import 'package:flowy_editor/render/node_widget_builder.dart'; import 'package:flowy_editor/render/rich_text/default_selectable.dart'; import 'package:flowy_editor/render/rich_text/flowy_rich_text.dart'; -import 'package:flowy_editor/render/rich_text/rich_text_style.dart'; import 'package:flowy_editor/render/selection/selectable.dart'; import 'package:flutter/material.dart'; 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 58d22bec85..6a27eed855 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 8dfea75135..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,8 +21,14 @@ 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); + 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 95496db2ea..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 @@ -1,14 +1,143 @@ +import 'package:flowy_editor/flowy_editor.dart'; import 'package:flowy_editor/service/keyboard_service.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -FlowyKeyEventHandler arrowKeysHandler = (editorState, event) { - if (event.logicalKey != LogicalKeyboardKey.arrowUp && - event.logicalKey != LogicalKeyboardKey.arrowDown && - event.logicalKey != LogicalKeyboardKey.arrowLeft && - event.logicalKey != LogicalKeyboardKey.arrowRight) { +int _endOffsetOfNode(Node node) { + if (node is TextNode) { + return node.delta.length; + } + 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) { return KeyEventResult.ignored; } + if (event.logicalKey == LogicalKeyboardKey.arrowLeft) { + 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 = 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; +} + +FlowyKeyEventHandler arrowKeysHandler = (editorState, event) { + if (event.isShiftPressed) { + return _handleShiftKey(editorState, event); + } + + final currentSelection = editorState.cursorSelection; + if (currentSelection == null) { + return KeyEventResult.ignored; + } + + if (event.logicalKey == LogicalKeyboardKey.arrowLeft) { + if (currentSelection.isCollapsed) { + final leftPosition = currentSelection.start.goLeft(editorState); + if (leftPosition != null) { + editorState.updateCursorSelection(Selection.collapsed(leftPosition)); + } + } else { + editorState + .updateCursorSelection(currentSelection.collapse(atStart: true)); + } + return KeyEventResult.handled; + } else if (event.logicalKey == LogicalKeyboardKey.arrowRight) { + if (currentSelection.isCollapsed) { + final rightPosition = currentSelection.end.goRight(editorState); + if (rightPosition != null) { + editorState.updateCursorSelection(Selection.collapsed(rightPosition)); + } + } else { + editorState.updateCursorSelection(currentSelection.collapse()); + } + return KeyEventResult.handled; + } else if (event.logicalKey == LogicalKeyboardKey.arrowUp) { + final position = _goUp(editorState); + editorState.updateCursorSelection( + position == null ? null : Selection.collapsed(position)); + return KeyEventResult.handled; + } else if (event.logicalKey == LogicalKeyboardKey.arrowDown) { + final position = _goDown(editorState); + editorState.updateCursorSelection( + position == null ? null : Selection.collapsed(position)); + 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 878fac28b4..975677d508 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,15 +1,17 @@ -import 'package:flowy_editor/document/path.dart'; +import 'dart:async'; + 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'; 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 @@ -28,6 +30,10 @@ mixin FlowySelectionService on State { /// void clearSelection(); + List rects(); + + Position? hitTest(Offset? offset); + /// List getNodesInSelection(Selection selection); @@ -50,7 +56,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. @@ -95,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'); @@ -110,6 +202,8 @@ class _FlowySelectionState extends State /// Tap Offset? tapOffset; + final List _rects = []; + EditorState get editorState => widget.editorState; @override @@ -146,33 +240,24 @@ 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, ); } + @override + List rects() { + return _rects; + } + @override void updateSelection(Selection selection) { + _rects.clear(); _clearSelection(); // cursor @@ -267,6 +352,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; @@ -274,16 +375,32 @@ 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); - updateSelection(selection); - } + final position = hitTest(tapOffset); + if (position == null) { + return; } + final selection = Selection.collapsed(position); + editorState.updateCursorSelection(selection); + } + + @override + Position? hitTest(Offset? offset) { + if (offset == null) { + editorState.updateCursorSelection(null); + return null; + } + final nodes = getNodesInRange(offset); + if (nodes.isEmpty) { + editorState.updateCursorSelection(null); + return null; + } + assert(nodes.length == 1); + final selectable = nodes.first.selectable; + if (selectable == null) { + editorState.updateCursorSelection(null); + return null; + } + return selectable.getPositionInOffset(offset); } void _onPanStart(DragStartDetails details) { @@ -314,7 +431,7 @@ class _FlowySelectionState extends State final selection = Selection( start: isDownward ? start : end, end: isDownward ? end : start); debugPrint('[_onPanUpdate] $selection'); - updateSelection(selection); + editorState.updateCursorSelection(selection); } } @@ -385,6 +502,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, @@ -399,6 +517,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); @@ -413,6 +536,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, @@ -423,9 +547,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) { 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 { 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 # 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..176f00b734 --- /dev/null +++ b/frontend/app_flowy/packages/flowy_editor/test/operation_test.dart @@ -0,0 +1,83 @@ +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', () { + 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]); + expect(transformPath([1, 1], [1, 0]), [1, 0]); + }); + 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]); + }); + }); + 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]); + }); +}