diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/document/selection.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/document/selection.dart index 99a81c9273..986dd37468 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/document/selection.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/document/selection.dart @@ -46,9 +46,9 @@ class Selection { (start.path <= end.path && !pathEquals(start.path, end.path)) || (isSingle && start.offset < end.offset); - Selection normalize() { + Selection get normalize { if (isForward) { - return Selection(start: end, end: start); + return reversed; } return this; } diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/default_selectable.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/default_selectable.dart index 91b9cbf981..c477478deb 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/default_selectable.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/default_selectable.dart @@ -32,7 +32,8 @@ mixin DefaultSelectable { Selection getSelectionInRange(Offset start, Offset end) => forward.getSelectionInRange(start, end); - Offset localToGlobal(Offset offset) => forward.localToGlobal(offset); + Offset localToGlobal(Offset offset) => + forward.localToGlobal(offset) - baseOffset; Selection? getWorldBoundaryInOffset(Offset offset) => forward.getWorldBoundaryInOffset(offset); diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/flowy_rich_text.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/flowy_rich_text.dart index 3489c2bb52..9d1b7f119e 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/flowy_rich_text.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/flowy_rich_text.dart @@ -132,17 +132,24 @@ class _FlowyRichTextState extends State with Selectable { @override List getRectsInSelection(Selection selection) { - assert(pathEquals(selection.start.path, selection.end.path) && + assert(selection.isSingle && pathEquals(selection.start.path, widget.textNode.path)); final textSelection = TextSelection( baseOffset: selection.start.offset, extentOffset: selection.end.offset, ); - return _renderParagraph + final rects = _renderParagraph .getBoxesForSelection(textSelection, boxHeightStyle: BoxHeightStyle.max) .map((box) => box.toRect()) - .toList(); + .toList(growable: false); + if (rects.isEmpty) { + // If the rich text widget does not contain any text, + // there will be no selection boxes, + // so we need to return to the default selection. + return [Rect.fromLTWH(0, 0, 0, _renderParagraph.size.height)]; + } + return rects; } @override diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/arrow_keys_handler.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/arrow_keys_handler.dart index ecaf3325e4..a19541da0f 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/arrow_keys_handler.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/arrow_keys_handler.dart @@ -1,26 +1,225 @@ import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_editor/src/extensions/node_extensions.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -int _endOffsetOfNode(Node node) { - if (node is TextNode) { - return node.delta.length; +AppFlowyKeyEventHandler arrowKeysHandler = (editorState, event) { + if (!_arrowKeys.contains(event.logicalKey)) { + return KeyEventResult.ignored; } - return 0; + + if (event.isMetaPressed && event.isShiftPressed) { + return _arrowKeysWithMetaAndShift(editorState, event); + } else if (event.isMetaPressed) { + return _arrowKeysWithMeta(editorState, event); + } else if (event.isShiftPressed) { + return _arrowKeysWithShift(editorState, event); + } else { + return _arrowKeysOnly(editorState, event); + } +}; + +final _arrowKeys = [ + LogicalKeyboardKey.arrowLeft, + LogicalKeyboardKey.arrowRight, + LogicalKeyboardKey.arrowUp, + LogicalKeyboardKey.arrowDown +]; + +KeyEventResult _arrowKeysWithMetaAndShift( + EditorState editorState, RawKeyEvent event) { + if (!event.isMetaPressed || + !event.isShiftPressed || + !_arrowKeys.contains(event.logicalKey)) { + assert(false); + return KeyEventResult.ignored; + } + + final nodes = editorState.service.selectionService.currentSelectedNodes; + final selection = editorState.service.selectionService.currentSelection.value; + if (nodes.isEmpty || selection == null) { + return KeyEventResult.ignored; + } + + var start = selection.start; + var end = selection.end; + if (event.logicalKey == LogicalKeyboardKey.arrowLeft) { + final position = nodes.first.selectable?.start(); + if (position != null) { + end = position; + } + } else if (event.logicalKey == LogicalKeyboardKey.arrowRight) { + final position = nodes.first.selectable?.end(); + if (position != null) { + end = position; + } + } else if (event.logicalKey == LogicalKeyboardKey.arrowUp) { + final position = editorState.document.root.children + .whereType() + .first + .selectable + ?.start(); + if (position != null) { + end = position; + } + } else if (event.logicalKey == LogicalKeyboardKey.arrowDown) { + final position = editorState.document.root.children + .whereType() + .last + .selectable + ?.end(); + if (position != null) { + end = position; + } + } + editorState.service.selectionService.updateSelection( + selection.copyWith(start: start, end: end), + ); + return KeyEventResult.handled; +} + +// Move the cursor to top, bottom, left and right of the document. +KeyEventResult _arrowKeysWithMeta(EditorState editorState, RawKeyEvent event) { + if (!event.isMetaPressed || + event.isShiftPressed || + !_arrowKeys.contains(event.logicalKey)) { + assert(false); + return KeyEventResult.ignored; + } + + final nodes = editorState.service.selectionService.currentSelectedNodes; + if (nodes.isEmpty) { + return KeyEventResult.ignored; + } + Position? position; + if (event.logicalKey == LogicalKeyboardKey.arrowLeft) { + position = nodes.first.selectable?.start(); + } else if (event.logicalKey == LogicalKeyboardKey.arrowRight) { + position = nodes.last.selectable?.end(); + } else if (event.logicalKey == LogicalKeyboardKey.arrowUp) { + position = editorState.document.root.children + .whereType() + .first + .selectable + ?.start(); + } else if (event.logicalKey == LogicalKeyboardKey.arrowDown) { + position = editorState.document.root.children + .whereType() + .last + .selectable + ?.end(); + } + if (position == null) { + return KeyEventResult.ignored; + } + editorState.service.selectionService.updateSelection( + Selection.collapsed(position), + ); + return KeyEventResult.handled; +} + +KeyEventResult _arrowKeysWithShift(EditorState editorState, RawKeyEvent event) { + if (event.isMetaPressed || + !event.isShiftPressed || + !_arrowKeys.contains(event.logicalKey)) { + assert(false); + return KeyEventResult.ignored; + } + + final nodes = editorState.service.selectionService.currentSelectedNodes; + final selection = editorState.service.selectionService.currentSelection.value; + if (nodes.isEmpty || selection == null) { + return KeyEventResult.ignored; + } + Position? end; + if (event.logicalKey == LogicalKeyboardKey.arrowLeft) { + end = selection.end.goLeft(editorState); + } else if (event.logicalKey == LogicalKeyboardKey.arrowRight) { + end = selection.end.goRight(editorState); + } else if (event.logicalKey == LogicalKeyboardKey.arrowUp) { + end = _goUp(editorState); + } else if (event.logicalKey == LogicalKeyboardKey.arrowDown) { + end = _goDown(editorState); + } + if (end == null) { + return KeyEventResult.ignored; + } + editorState.service.selectionService + .updateSelection(selection.copyWith(end: end)); + return KeyEventResult.handled; +} + +KeyEventResult _arrowKeysOnly(EditorState editorState, RawKeyEvent event) { + if (event.isMetaPressed || + event.isShiftPressed || + !_arrowKeys.contains(event.logicalKey)) { + assert(false); + return KeyEventResult.ignored; + } + + final nodes = editorState.service.selectionService.currentSelectedNodes; + final selection = + editorState.service.selectionService.currentSelection.value?.normalize; + if (nodes.isEmpty || selection == null) { + return KeyEventResult.ignored; + } + if (event.logicalKey == LogicalKeyboardKey.arrowLeft) { + if (selection.isCollapsed) { + final leftPosition = selection.start.goLeft(editorState); + if (leftPosition != null) { + editorState.service.selectionService.updateSelection( + Selection.collapsed(leftPosition), + ); + } + } else { + editorState.service.selectionService.updateSelection( + Selection.collapsed(selection.start), + ); + } + return KeyEventResult.handled; + } else if (event.logicalKey == LogicalKeyboardKey.arrowRight) { + if (selection.isCollapsed) { + final rightPosition = selection.start.goRight(editorState); + if (rightPosition != null) { + editorState.service.selectionService.updateSelection( + Selection.collapsed(rightPosition), + ); + } + } else { + editorState.service.selectionService.updateSelection( + Selection.collapsed(selection.end), + ); + } + return KeyEventResult.handled; + } else if (event.logicalKey == LogicalKeyboardKey.arrowUp) { + final upPosition = _goUp(editorState); + editorState.updateCursorSelection( + upPosition == null ? null : Selection.collapsed(upPosition), + ); + return KeyEventResult.handled; + } else if (event.logicalKey == LogicalKeyboardKey.arrowDown) { + final downPosition = _goDown(editorState); + editorState.updateCursorSelection( + downPosition == null ? null : Selection.collapsed(downPosition), + ); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; } extension on Position { Position? goLeft(EditorState editorState) { - final node = editorState.document.nodeAtPath(path)!; + final node = editorState.document.nodeAtPath(path); + if (node == null) { + return null; + } if (offset == 0) { - final prevNode = node.previous; - if (prevNode != null) { - return Position( - path: prevNode.path, offset: _endOffsetOfNode(prevNode)); + final previousEnd = node.previous?.selectable?.end(); + if (previousEnd != null) { + return previousEnd; } return null; } - if (node is TextNode) { return Position(path: path, offset: node.delta.prevRunePosition(offset)); } else { @@ -29,16 +228,18 @@ extension on Position { } 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); + final node = editorState.document.nodeAtPath(path); + if (node == null) { + return null; + } + final end = node.selectable?.end(); + if (end != null && offset >= end.offset) { + final nextStart = node.next?.selectable?.start(); + if (nextStart != null) { + return nextStart; } return null; } - if (node is TextNode) { return Position(path: path, offset: node.delta.nextRunePosition(offset)); } else { @@ -48,106 +249,43 @@ extension on Position { } Position? _goUp(EditorState editorState) { + final selection = editorState.service.selectionService.currentSelection.value; final rects = editorState.service.selectionService.selectionRects; - if (rects.isEmpty) { + if (rects.isEmpty || selection == null) { 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.getPositionInOffset(hitOffset); + Offset offset; + if (selection.isBackward) { + final rect = rects.reduce( + (current, next) => current.bottom >= next.bottom ? current : next, + ); + offset = rect.topRight.translate(0, -rect.height); + } else { + final rect = rects.reduce( + (current, next) => current.top <= next.top ? current : next, + ); + offset = rect.topLeft.translate(0, -rect.height); + } + return editorState.service.selectionService.getPositionInOffset(offset); } Position? _goDown(EditorState editorState) { + final selection = editorState.service.selectionService.currentSelection.value; final rects = editorState.service.selectionService.selectionRects; - if (rects.isEmpty) { + if (rects.isEmpty || selection == null) { 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.getPositionInOffset(hitOffset); + Offset offset; + if (selection.isBackward) { + final rect = rects.reduce( + (current, next) => current.bottom >= next.bottom ? current : next, + ); + offset = rect.bottomRight.translate(0, rect.height); + } else { + final rect = rects.reduce( + (current, next) => current.top <= next.top ? current : next, + ); + offset = rect.bottomLeft.translate(0, rect.height); + } + return editorState.service.selectionService.getPositionInOffset(offset); } - -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; -} - -AppFlowyKeyEventHandler 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: currentSelection.isBackward), - ); - } - 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(atStart: !currentSelection.isBackward), - ); - } - 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/appflowy_editor/lib/src/service/internal_key_event_handlers/copy_paste_handler.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/copy_paste_handler.dart index 3f49a4b566..f6dba97c49 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/copy_paste_handler.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/copy_paste_handler.dart @@ -1,13 +1,12 @@ import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor/src/infra/html_converter.dart'; import 'package:appflowy_editor/src/document/node_iterator.dart'; -import 'package:appflowy_editor/src/infra/log.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:rich_clipboard/rich_clipboard.dart'; _handleCopy(EditorState editorState) async { - final selection = editorState.cursorSelection?.normalize(); + final selection = editorState.cursorSelection?.normalize; if (selection == null || selection.isCollapsed) { return; } @@ -43,7 +42,7 @@ _handleCopy(EditorState editorState) async { } _pasteHTML(EditorState editorState, String html) { - final selection = editorState.cursorSelection?.normalize(); + final selection = editorState.cursorSelection?.normalize; if (selection == null) { return; } @@ -191,7 +190,7 @@ Delta _lineContentToDelta(String lineContent) { } _handlePastePlainText(EditorState editorState, String plainText) { - final selection = editorState.cursorSelection?.normalize(); + final selection = editorState.cursorSelection?.normalize; if (selection == null) { return; } @@ -256,7 +255,7 @@ _handleCut(EditorState editorState) { } _deleteSelectedContent(EditorState editorState) { - final selection = editorState.cursorSelection?.normalize(); + final selection = editorState.cursorSelection?.normalize; if (selection == null || selection.isCollapsed) { return; } diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/default_key_event_handlers.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/default_key_event_handlers.dart index a8cbdee3ab..f8c7fa71cb 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/default_key_event_handlers.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/default_key_event_handlers.dart @@ -1,7 +1,7 @@ -import 'package:appflowy_editor/src/service/internal_key_event_handlers/arrow_keys_handler.dart'; import 'package:appflowy_editor/src/service/internal_key_event_handlers/copy_paste_handler.dart'; import 'package:appflowy_editor/src/service/internal_key_event_handlers/backspace_handler.dart'; import 'package:appflowy_editor/src/service/internal_key_event_handlers/enter_without_shift_in_text_node_handler.dart'; +import 'package:appflowy_editor/src/service/internal_key_event_handlers/arrow_keys_handler.dart'; import 'package:appflowy_editor/src/service/internal_key_event_handlers/redo_undo_handler.dart'; import 'package:appflowy_editor/src/service/internal_key_event_handlers/slash_handler.dart'; import 'package:appflowy_editor/src/service/internal_key_event_handlers/update_text_style_by_command_x_handler.dart'; @@ -13,6 +13,7 @@ import 'package:appflowy_editor/src/service/keyboard_service.dart'; List defaultKeyEventHandlers = [ deleteTextHandler, slashShortcutHandler, + // arrowKeysHandler, arrowKeysHandler, copyPasteKeysHandler, redoUndoKeysHandler, diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/legacy/arrow_keys_handler.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/legacy/arrow_keys_handler.dart new file mode 100644 index 0000000000..ecaf3325e4 --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/legacy/arrow_keys_handler.dart @@ -0,0 +1,153 @@ +import 'package:appflowy_editor/appflowy_editor.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; +} + +extension on Position { + Position? goLeft(EditorState editorState) { + final node = editorState.document.nodeAtPath(path)!; + if (offset == 0) { + final prevNode = node.previous; + if (prevNode != null) { + return Position( + path: prevNode.path, offset: _endOffsetOfNode(prevNode)); + } + return null; + } + + if (node is TextNode) { + return Position(path: path, offset: node.delta.prevRunePosition(offset)); + } else { + return Position(path: path, offset: offset); + } + } + + 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; + } + + if (node is TextNode) { + return Position(path: path, offset: node.delta.nextRunePosition(offset)); + } else { + return Position(path: path, offset: offset); + } + } +} + +Position? _goUp(EditorState editorState) { + final rects = editorState.service.selectionService.selectionRects; + 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.getPositionInOffset(hitOffset); +} + +Position? _goDown(EditorState editorState) { + final rects = editorState.service.selectionService.selectionRects; + 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.getPositionInOffset(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; +} + +AppFlowyKeyEventHandler 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: currentSelection.isBackward), + ); + } + 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(atStart: !currentSelection.isBackward), + ); + } + 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/appflowy_editor/lib/src/service/internal_key_event_handlers/slash_handler.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/slash_handler.dart index 32e27a808a..79b7ee6579 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/slash_handler.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/slash_handler.dart @@ -25,10 +25,6 @@ AppFlowyKeyEventHandler slashShortcutHandler = (editorState, event) { if (selection == null || context == null || selectable == null) { return KeyEventResult.ignored; } - final selectionRects = editorState.service.selectionService.selectionRects; - if (selectionRects.isEmpty) { - return KeyEventResult.ignored; - } TransactionBuilder(editorState) ..replaceText(textNode, selection.start.offset, selection.end.offset - selection.start.offset, event.character ?? '') diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/selection_service.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/selection_service.dart index 6f6897596f..ca442f4ff9 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/selection_service.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/selection_service.dart @@ -348,10 +348,8 @@ class _AppFlowySelectionState extends State final backwardNodes = selection.isBackward ? nodes : nodes.reversed.toList(growable: false); - final backwardSelection = selection.isBackward - ? selection - : selection.copyWith(start: selection.end, end: selection.start); - assert(backwardSelection.isBackward); + final normalizedSelection = selection.normalize; + assert(normalizedSelection.isBackward); for (var i = 0; i < backwardNodes.length; i++) { final node = backwardNodes[i]; @@ -360,7 +358,7 @@ class _AppFlowySelectionState extends State continue; } - var newSelection = backwardSelection.copy(); + var newSelection = normalizedSelection.copy(); /// In the case of multiple selections, /// we need to return a new selection for each selected node individually. @@ -370,7 +368,7 @@ class _AppFlowySelectionState extends State /// text: ghijkl /// text: mn>opqr /// - if (!backwardSelection.isSingle) { + if (!normalizedSelection.isSingle) { if (i == 0) { newSelection = newSelection.copyWith(end: selectable.end()); } else if (i == nodes.length - 1) { diff --git a/frontend/app_flowy/packages/appflowy_editor/test/infra/test_raw_key_event.dart b/frontend/app_flowy/packages/appflowy_editor/test/infra/test_raw_key_event.dart index 2450c4e6db..47cacc18b1 100644 --- a/frontend/app_flowy/packages/appflowy_editor/test/infra/test_raw_key_event.dart +++ b/frontend/app_flowy/packages/appflowy_editor/test/infra/test_raw_key_event.dart @@ -103,6 +103,9 @@ extension on LogicalKeyboardKey { if (this == LogicalKeyboardKey.slash) { return PhysicalKeyboardKey.slash; } + if (this == LogicalKeyboardKey.arrowUp) { + return PhysicalKeyboardKey.arrowUp; + } if (this == LogicalKeyboardKey.arrowDown) { return PhysicalKeyboardKey.arrowDown; } diff --git a/frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/arrow_keys_handler_test.dart b/frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/arrow_keys_handler_test.dart index e4631b56ad..ebbd71a392 100644 --- a/frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/arrow_keys_handler_test.dart +++ b/frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/arrow_keys_handler_test.dart @@ -58,6 +58,219 @@ void main() async { (tester) async { await _testPressArrowKeyInNotCollapsedSelection(tester, false); }); + + testWidgets('Presses arrow left/right + shift in collapsed selection', + (tester) async { + const text = 'Welcome to Appflowy'; + final editor = tester.editor + ..insertTextNode(text) + ..insertTextNode(text); + await editor.startTesting(); + const offset = 8; + final selection = Selection.single(path: [1], startOffset: offset); + await editor.updateSelection(selection); + for (var i = offset - 1; i >= 0; i--) { + await editor.pressLogicKey( + LogicalKeyboardKey.arrowLeft, + isShiftPressed: true, + ); + expect( + editor.documentSelection, + selection.copyWith( + end: Position(path: [1], offset: i), + ), + ); + } + for (var i = text.length; i >= 0; i--) { + await editor.pressLogicKey( + LogicalKeyboardKey.arrowLeft, + isShiftPressed: true, + ); + expect( + editor.documentSelection, + selection.copyWith( + end: Position(path: [0], offset: i), + ), + ); + } + for (var i = 1; i <= text.length; i++) { + await editor.pressLogicKey( + LogicalKeyboardKey.arrowRight, + isShiftPressed: true, + ); + expect( + editor.documentSelection, + selection.copyWith( + end: Position(path: [0], offset: i), + ), + ); + } + for (var i = 0; i < text.length; i++) { + await editor.pressLogicKey( + LogicalKeyboardKey.arrowRight, + isShiftPressed: true, + ); + expect( + editor.documentSelection, + selection.copyWith( + end: Position(path: [1], offset: i), + ), + ); + } + }); + + testWidgets( + 'Presses arrow left/right + shift in not collapsed and backward selection', + (tester) async { + const text = 'Welcome to Appflowy'; + final editor = tester.editor + ..insertTextNode(text) + ..insertTextNode(text); + await editor.startTesting(); + const start = 8; + const end = 12; + final selection = Selection.single( + path: [0], + startOffset: start, + endOffset: end, + ); + await editor.updateSelection(selection); + for (var i = end + 1; i <= text.length; i++) { + await editor.pressLogicKey( + LogicalKeyboardKey.arrowRight, + isShiftPressed: true, + ); + expect( + editor.documentSelection, + selection.copyWith( + end: Position(path: [0], offset: i), + ), + ); + } + for (var i = text.length - 1; i >= 0; i--) { + await editor.pressLogicKey( + LogicalKeyboardKey.arrowLeft, + isShiftPressed: true, + ); + expect( + editor.documentSelection, + selection.copyWith( + end: Position(path: [0], offset: i), + ), + ); + } + }); + + testWidgets( + 'Presses arrow left/right + command in not collapsed and forward selection', + (tester) async { + const text = 'Welcome to Appflowy'; + final editor = tester.editor + ..insertTextNode(text) + ..insertTextNode(text); + await editor.startTesting(); + const start = 12; + const end = 8; + final selection = Selection.single( + path: [0], + startOffset: start, + endOffset: end, + ); + await editor.updateSelection(selection); + for (var i = end - 1; i >= 0; i--) { + await editor.pressLogicKey( + LogicalKeyboardKey.arrowLeft, + isShiftPressed: true, + ); + expect( + editor.documentSelection, + selection.copyWith( + end: Position(path: [0], offset: i), + ), + ); + } + for (var i = 1; i <= text.length; i++) { + await editor.pressLogicKey( + LogicalKeyboardKey.arrowRight, + isShiftPressed: true, + ); + expect( + editor.documentSelection, + selection.copyWith( + end: Position(path: [0], offset: i), + ), + ); + } + }); + + testWidgets('Presses arrow left/right/up/down + meta in collapsed selection', + (tester) async { + await _testPressArrowKeyWithMetaInSelection(tester, true, false); + }); + + testWidgets( + 'Presses arrow left/right/up/down + meta in not collapsed and backward selection', + (tester) async { + await _testPressArrowKeyWithMetaInSelection(tester, false, true); + }); + + testWidgets( + 'Presses arrow left/right/up/down + meta in not collapsed and forward selection', + (tester) async { + await _testPressArrowKeyWithMetaInSelection(tester, false, false); + }); + + testWidgets('Presses arrow up/down + shift in not collapsed selection', + (tester) async { + const text = 'Welcome to Appflowy 😁'; + final editor = tester.editor + ..insertTextNode(text) + ..insertTextNode(text) + ..insertTextNode(null) + ..insertTextNode(text) + ..insertTextNode(null) + ..insertTextNode(text) + ..insertTextNode(text); + await editor.startTesting(); + final selection = Selection.single(path: [3], startOffset: 8); + await editor.updateSelection(selection); + for (int i = 0; i < 3; i++) { + await editor.pressLogicKey( + LogicalKeyboardKey.arrowUp, + isShiftPressed: true, + ); + } + expect( + editor.documentSelection, + selection.copyWith( + end: Position(path: [0], offset: 0), + ), + ); + for (int i = 0; i < 7; i++) { + await editor.pressLogicKey( + LogicalKeyboardKey.arrowDown, + isShiftPressed: true, + ); + } + expect( + editor.documentSelection, + selection.copyWith( + end: Position(path: [6], offset: 0), + ), + ); + for (int i = 0; i < 3; i++) { + await editor.pressLogicKey( + LogicalKeyboardKey.arrowUp, + isShiftPressed: true, + ); + } + expect( + editor.documentSelection, + selection.copyWith( + end: Position(path: [3], offset: 0), + ), + ); + }); } Future _testPressArrowKeyInNotCollapsedSelection( @@ -82,3 +295,72 @@ Future _testPressArrowKeyInNotCollapsedSelection( await editor.pressLogicKey(LogicalKeyboardKey.arrowRight); expect(editor.documentSelection?.end, end); } + +Future _testPressArrowKeyWithMetaInSelection( + WidgetTester tester, + bool isSingle, + bool isBackward, +) async { + const text = 'Welcome to Appflowy'; + final editor = tester.editor + ..insertTextNode(text) + ..insertTextNode(text); + await editor.startTesting(); + Selection selection; + if (isSingle) { + selection = Selection.single( + path: [0], + startOffset: 8, + ); + } else { + if (isBackward) { + selection = Selection.single( + path: [0], + startOffset: 8, + endOffset: text.length, + ); + } else { + selection = Selection.single( + path: [0], + startOffset: text.length, + endOffset: 8, + ); + } + } + await editor.updateSelection(selection); + await editor.pressLogicKey( + LogicalKeyboardKey.arrowLeft, + isMetaPressed: true, + ); + expect( + editor.documentSelection, + Selection.single(path: [0], startOffset: 0), + ); + + await editor.pressLogicKey( + LogicalKeyboardKey.arrowRight, + isMetaPressed: true, + ); + expect( + editor.documentSelection, + Selection.single(path: [0], startOffset: text.length), + ); + + await editor.pressLogicKey( + LogicalKeyboardKey.arrowUp, + isMetaPressed: true, + ); + expect( + editor.documentSelection, + Selection.single(path: [0], startOffset: 0), + ); + + await editor.pressLogicKey( + LogicalKeyboardKey.arrowDown, + isMetaPressed: true, + ); + expect( + editor.documentSelection, + Selection.single(path: [1], startOffset: text.length), + ); +}