From 67b76eb00b8ba819893113f4e85d408d8d43ea0e Mon Sep 17 00:00:00 2001 From: Sean Riley Hawkins Date: Thu, 28 Sep 2023 13:23:42 +0200 Subject: [PATCH 01/49] feat: vim mode key binding - The initial start of developing the vim mode key bindings --- .../standard_block_components.dart | 3 ++ .../service/editor_service.dart | 9 ++++ .../command_shortcut_events/vim.dart | 42 +++++++++++++++++++ 3 files changed, 54 insertions(+) create mode 100644 lib/src/editor/editor_component/service/shortcuts/command_shortcut_events/vim.dart diff --git a/lib/src/editor/block_component/standard_block_components.dart b/lib/src/editor/block_component/standard_block_components.dart index b3165e2c5..4ad10944a 100644 --- a/lib/src/editor/block_component/standard_block_components.dart +++ b/lib/src/editor/block_component/standard_block_components.dart @@ -1,4 +1,5 @@ import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_editor/src/editor/editor_component/service/shortcuts/command_shortcut_events/vim.dart'; import 'package:flutter/material.dart'; const standardBlockComponentConfiguration = BlockComponentConfiguration(); @@ -141,4 +142,6 @@ final List standardCommandShortcutEvents = [ copyCommand, ...pasteCommands, cutCommand, + + moveMentCommand, ]; diff --git a/lib/src/editor/editor_component/service/editor_service.dart b/lib/src/editor/editor_component/service/editor_service.dart index 99fb3627e..d232c5536 100644 --- a/lib/src/editor/editor_component/service/editor_service.dart +++ b/lib/src/editor/editor_component/service/editor_service.dart @@ -17,6 +17,15 @@ class EditorService { AppFlowyKeyboardService? get keyboardService { if (keyboardServiceKey.currentState != null && keyboardServiceKey.currentState is AppFlowyKeyboardService) { + print('Enter normal mode'); + // print(selectionService.currentSelection.value); + // Selection? select = selectionService.currentSelection.value; + //NOTE: This causes the editor to freeze up and eats memory alot... + // keyboardService?.enableKeyBoard(select!); + //NOTE: Would need to display cursor after closing keyboard + // print(selectionService.currentSelection); + //NOTE: Causes and infinite loop & hangs + // keyboardService?.enable(); return keyboardServiceKey.currentState! as AppFlowyKeyboardService; } return null; diff --git a/lib/src/editor/editor_component/service/shortcuts/command_shortcut_events/vim.dart b/lib/src/editor/editor_component/service/shortcuts/command_shortcut_events/vim.dart new file mode 100644 index 000000000..f4ca2350c --- /dev/null +++ b/lib/src/editor/editor_component/service/shortcuts/command_shortcut_events/vim.dart @@ -0,0 +1,42 @@ +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/material.dart'; + +final CommandShortcutEvent moveMentCommand = CommandShortcutEvent( + key: 'move down with j', + command: 'j', + handler: _moveMentCommand, +); + +CommandShortcutEventHandler _moveMentCommand = (editorState) { + final keyboardServiceKey = editorState.service.keyboardServiceKey; + final selection = editorState.selection; + print('Passed through event handler'); + /* + if (selection == null) { + return KeyEventResult.ignored; + } + */ + if (keyboardServiceKey.currentState != null && + keyboardServiceKey.currentState is AppFlowyKeyboardService) { + // editorState.service.keyboardService?.enableKeyBoard(selection!); + // final downPos = selection?.end.moveVertical(editorState, upwards: false); + if (selection == null) { + //NOTE: This works fine + // editorState.scrollService?.jumpToBottom(); + final s = editorState.service.selectionService.currentSelection.value; + editorState.service.keyboardService?.enableKeyBoard(s!); + + return KeyEventResult.handled; + } + + return KeyEventResult.ignored; + /* + editorState.updateSelectionWithReason( + downPos == null ? null : Selection.collapsed(downPos), + reason: SelectionUpdateReason.uiEvent, + ); + */ + } + // editorState.scrollService?.disable(); + return KeyEventResult.ignored; +}; From afc444521d231ca3f36bea4d936efa1d8aa6c797 Mon Sep 17 00:00:00 2001 From: Sean Riley Hawkins Date: Sat, 14 Oct 2023 16:16:45 +0200 Subject: [PATCH 02/49] feat: enable insert on new line in vim mode --- .../standard_block_components.dart | 3 +- .../command_shortcut_events/vim.dart | 85 ++++++++++++++++++- 2 files changed, 84 insertions(+), 4 deletions(-) diff --git a/lib/src/editor/block_component/standard_block_components.dart b/lib/src/editor/block_component/standard_block_components.dart index 4ad10944a..e87d23f19 100644 --- a/lib/src/editor/block_component/standard_block_components.dart +++ b/lib/src/editor/block_component/standard_block_components.dart @@ -142,6 +142,5 @@ final List standardCommandShortcutEvents = [ copyCommand, ...pasteCommands, cutCommand, - - moveMentCommand, + ...vimKeyModes ]; diff --git a/lib/src/editor/editor_component/service/shortcuts/command_shortcut_events/vim.dart b/lib/src/editor/editor_component/service/shortcuts/command_shortcut_events/vim.dart index f4ca2350c..aee9a80fa 100644 --- a/lib/src/editor/editor_component/service/shortcuts/command_shortcut_events/vim.dart +++ b/lib/src/editor/editor_component/service/shortcuts/command_shortcut_events/vim.dart @@ -1,6 +1,11 @@ import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter/material.dart'; +final List vimKeyModes = [ + insertOnNewLineCommand, + jumpDownCommand, +]; +/* final CommandShortcutEvent moveMentCommand = CommandShortcutEvent( key: 'move down with j', command: 'j', @@ -23,8 +28,8 @@ CommandShortcutEventHandler _moveMentCommand = (editorState) { if (selection == null) { //NOTE: This works fine // editorState.scrollService?.jumpToBottom(); - final s = editorState.service.selectionService.currentSelection.value; - editorState.service.keyboardService?.enableKeyBoard(s!); + // final s = editorState.service.selectionService.currentSelection.value; + //editorState.service.keyboardService?.enableKeyBoard(s!); return KeyEventResult.handled; } @@ -40,3 +45,79 @@ CommandShortcutEventHandler _moveMentCommand = (editorState) { // editorState.scrollService?.disable(); return KeyEventResult.ignored; }; +*/ + +final CommandShortcutEvent insertOnNewLineCommand = CommandShortcutEvent( + key: 'insert new line with "o"', + command: 'o', + handler: _insertOnNewLineCommandHandler, +); + +CommandShortcutEventHandler _insertOnNewLineCommandHandler = (editorState) { + final afKeyboard = editorState.service.keyboardServiceKey; + + if (afKeyboard.currentState != null && + afKeyboard.currentState is AppFlowyKeyboardService) { + if (editorState.selection == null) { + //NOTE: Force selection at the last node + final end = editorState.document.last; + Position pos = Position(path: end!.path, offset: end.delta!.length); + Selection sel = Selection(start: pos, end: pos); + editorState.selection = sel; + editorState.insertNewLine(position: sel.start); + editorState.selectionService.updateSelection(editorState.selection); + + return KeyEventResult.handled; + } else { + //NOTE: Do Nothing + return KeyEventResult.ignored; + } + /* + // editorState.service.keyboardService?.enableKeyBoard(); + //NOTE: This would work if the selection was not null + final currentSelection = editorState.selection; + editorState.insertNewLine(position: currentSelection?.end); + editorState.selectionService.updateSelection(editorState.selection); + */ + } + return KeyEventResult.ignored; +}; + +final CommandShortcutEvent jumpDownCommand = CommandShortcutEvent( + key: 'move the cursor downward', + command: 'j', + handler: _jumpDownCommandHandler, +); + +CommandShortcutEventHandler _jumpDownCommandHandler = (editorState) { + final afKeyboard = editorState.service.keyboardServiceKey; + if (afKeyboard.currentState != null && + afKeyboard.currentState is AppFlowyKeyboardService) { + // editorState.scrollService!.goBallistic(4); + if (editorState.selection == null) { + int scroll = 4; + //TODO: Figure out a way to jump line by line + editorState.scrollService?.jumpTo(scroll++); + return KeyEventResult.handled; + } else { + return KeyEventResult.ignored; + } + //NOTE: This caused selection to be null + /* + final selection = editorState.selection; + if (selection == null) { + return KeyEventResult.ignored; + } + + final downPosition = + selection.end.moveVertical(editorState, upwards: false); + editorState.updateSelectionWithReason( + downPosition == null ? null : Selection.collapsed(downPosition), + reason: SelectionUpdateReason.uiEvent, + ); + */ + + return KeyEventResult.ignored; + } + return KeyEventResult.ignored; +}; From aa78f50bc948e26d40606cf3d8f69a1610e016cc Mon Sep 17 00:00:00 2001 From: Sean Riley Hawkins Date: Tue, 17 Oct 2023 21:06:59 +0200 Subject: [PATCH 03/49] feat: add more insert modes Enable 'o', 'a', 'i' for insert mode with vim --- .../escape_command.dart | 1 + .../command_shortcut_events/vim.dart | 72 +++++++++++-------- lib/src/editor_state.dart | 12 ++++ 3 files changed, 57 insertions(+), 28 deletions(-) diff --git a/lib/src/editor/editor_component/service/shortcuts/command_shortcut_events/escape_command.dart b/lib/src/editor/editor_component/service/shortcuts/command_shortcut_events/escape_command.dart index 0c95486c5..8c7c30e8d 100644 --- a/lib/src/editor/editor_component/service/shortcuts/command_shortcut_events/escape_command.dart +++ b/lib/src/editor/editor_component/service/shortcuts/command_shortcut_events/escape_command.dart @@ -14,6 +14,7 @@ final CommandShortcutEvent exitEditingCommand = CommandShortcutEvent( ); CommandShortcutEventHandler _exitEditingCommandHandler = (editorState) { + editorState.prevSelection = editorState.selection; editorState.selection = null; editorState.service.keyboardService?.closeKeyboard(); return KeyEventResult.handled; diff --git a/lib/src/editor/editor_component/service/shortcuts/command_shortcut_events/vim.dart b/lib/src/editor/editor_component/service/shortcuts/command_shortcut_events/vim.dart index aee9a80fa..1b4ca3f64 100644 --- a/lib/src/editor/editor_component/service/shortcuts/command_shortcut_events/vim.dart +++ b/lib/src/editor/editor_component/service/shortcuts/command_shortcut_events/vim.dart @@ -3,6 +3,8 @@ import 'package:flutter/material.dart'; final List vimKeyModes = [ insertOnNewLineCommand, + insertInlineCommand, + insertNextInlineCommand, jumpDownCommand, ]; /* @@ -48,7 +50,7 @@ CommandShortcutEventHandler _moveMentCommand = (editorState) { */ final CommandShortcutEvent insertOnNewLineCommand = CommandShortcutEvent( - key: 'insert new line with "o"', + key: 'insert new line below previous selection', command: 'o', handler: _insertOnNewLineCommandHandler, ); @@ -59,26 +61,13 @@ CommandShortcutEventHandler _insertOnNewLineCommandHandler = (editorState) { if (afKeyboard.currentState != null && afKeyboard.currentState is AppFlowyKeyboardService) { if (editorState.selection == null) { - //NOTE: Force selection at the last node - final end = editorState.document.last; - Position pos = Position(path: end!.path, offset: end.delta!.length); - Selection sel = Selection(start: pos, end: pos); - editorState.selection = sel; - editorState.insertNewLine(position: sel.start); + editorState.selection = editorState.prevSelection; + editorState.insertNewLine(); editorState.selectionService.updateSelection(editorState.selection); - return KeyEventResult.handled; } else { - //NOTE: Do Nothing return KeyEventResult.ignored; } - /* - // editorState.service.keyboardService?.enableKeyBoard(); - //NOTE: This would work if the selection was not null - final currentSelection = editorState.selection; - editorState.insertNewLine(position: currentSelection?.end); - editorState.selectionService.updateSelection(editorState.selection); - */ } return KeyEventResult.ignored; }; @@ -102,22 +91,49 @@ CommandShortcutEventHandler _jumpDownCommandHandler = (editorState) { } else { return KeyEventResult.ignored; } - //NOTE: This caused selection to be null - /* - final selection = editorState.selection; - if (selection == null) { + } + return KeyEventResult.ignored; +}; + +final CommandShortcutEvent insertInlineCommand = CommandShortcutEvent( + key: 'enter insert mode from previous selection', + command: 'i', + handler: _insertInlineCommandHandler, +); + +CommandShortcutEventHandler _insertInlineCommandHandler = (editorState) { + final afKeyboard = editorState.service.keyboardServiceKey; + if (afKeyboard.currentState != null && + afKeyboard.currentState is AppFlowyKeyboardService) { + if (editorState.selection == null) { + editorState.selection = editorState.prevSelection; + editorState.selectionService.updateSelection(editorState.selection); + return KeyEventResult.handled; + } else { return KeyEventResult.ignored; } + } + return KeyEventResult.ignored; +}; - final downPosition = - selection.end.moveVertical(editorState, upwards: false); - editorState.updateSelectionWithReason( - downPosition == null ? null : Selection.collapsed(downPosition), - reason: SelectionUpdateReason.uiEvent, - ); - */ +final CommandShortcutEvent insertNextInlineCommand = CommandShortcutEvent( + key: 'enter insert mode on next character', + command: 'a', + handler: _insertNextInlineCommandHandler, +); - return KeyEventResult.ignored; +CommandShortcutEventHandler _insertNextInlineCommandHandler = (editorState) { + final afKeyboard = editorState.service.keyboardServiceKey; + if (afKeyboard.currentState != null && + afKeyboard.currentState is AppFlowyKeyboardService) { + if (editorState.selection == null) { + editorState.selection = editorState.prevSelection; + editorState.moveCursor(SelectionMoveDirection.backward); + editorState.selectionService.updateSelection(editorState.selection); + return KeyEventResult.handled; + } else { + return KeyEventResult.ignored; + } } return KeyEventResult.ignored; }; diff --git a/lib/src/editor_state.dart b/lib/src/editor_state.dart index 33da0ee56..1d1ec1c33 100644 --- a/lib/src/editor_state.dart +++ b/lib/src/editor_state.dart @@ -95,14 +95,26 @@ class EditorState { final PropertyValueNotifier selectionNotifier = PropertyValueNotifier(null); + /// The previous selection notifier of the editor. + final PropertyValueNotifier prevSelectionNotifier = + PropertyValueNotifier(null); + /// The selection of the editor. Selection? get selection => selectionNotifier.value; + /// The previous selection of the editor. + Selection? get prevSelection => prevSelectionNotifier.value; + /// Sets the selection of the editor. set selection(Selection? value) { selectionNotifier.value = value; } + /// Sets the previous selection of the editor. + set prevSelection(Selection? value) { + prevSelectionNotifier.value = value; + } + SelectionType? selectionType; SelectionUpdateReason _selectionUpdateReason = SelectionUpdateReason.uiEvent; From 8493a17afb0f9558046437f6ef572b22629f0e93 Mon Sep 17 00:00:00 2001 From: Sean Riley Hawkins Date: Wed, 18 Oct 2023 09:58:31 +0200 Subject: [PATCH 04/49] chore: remove previous selection after assigning selection --- .../command_shortcut_events/vim.dart | 49 ++++++++++--------- 1 file changed, 27 insertions(+), 22 deletions(-) diff --git a/lib/src/editor/editor_component/service/shortcuts/command_shortcut_events/vim.dart b/lib/src/editor/editor_component/service/shortcuts/command_shortcut_events/vim.dart index 1b4ca3f64..3f76e9ff0 100644 --- a/lib/src/editor/editor_component/service/shortcuts/command_shortcut_events/vim.dart +++ b/lib/src/editor/editor_component/service/shortcuts/command_shortcut_events/vim.dart @@ -49,6 +49,7 @@ CommandShortcutEventHandler _moveMentCommand = (editorState) { }; */ +/// Insert trigger keys final CommandShortcutEvent insertOnNewLineCommand = CommandShortcutEvent( key: 'insert new line below previous selection', command: 'o', @@ -64,6 +65,7 @@ CommandShortcutEventHandler _insertOnNewLineCommandHandler = (editorState) { editorState.selection = editorState.prevSelection; editorState.insertNewLine(); editorState.selectionService.updateSelection(editorState.selection); + editorState.prevSelection = null; return KeyEventResult.handled; } else { return KeyEventResult.ignored; @@ -72,21 +74,20 @@ CommandShortcutEventHandler _insertOnNewLineCommandHandler = (editorState) { return KeyEventResult.ignored; }; -final CommandShortcutEvent jumpDownCommand = CommandShortcutEvent( - key: 'move the cursor downward', - command: 'j', - handler: _jumpDownCommandHandler, +final CommandShortcutEvent insertInlineCommand = CommandShortcutEvent( + key: 'enter insert mode from previous selection', + command: 'i', + handler: _insertInlineCommandHandler, ); -CommandShortcutEventHandler _jumpDownCommandHandler = (editorState) { +CommandShortcutEventHandler _insertInlineCommandHandler = (editorState) { final afKeyboard = editorState.service.keyboardServiceKey; if (afKeyboard.currentState != null && afKeyboard.currentState is AppFlowyKeyboardService) { - // editorState.scrollService!.goBallistic(4); if (editorState.selection == null) { - int scroll = 4; - //TODO: Figure out a way to jump line by line - editorState.scrollService?.jumpTo(scroll++); + editorState.selection = editorState.prevSelection; + editorState.selectionService.updateSelection(editorState.selection); + editorState.prevSelection = null; return KeyEventResult.handled; } else { return KeyEventResult.ignored; @@ -95,19 +96,21 @@ CommandShortcutEventHandler _jumpDownCommandHandler = (editorState) { return KeyEventResult.ignored; }; -final CommandShortcutEvent insertInlineCommand = CommandShortcutEvent( - key: 'enter insert mode from previous selection', - command: 'i', - handler: _insertInlineCommandHandler, +final CommandShortcutEvent insertNextInlineCommand = CommandShortcutEvent( + key: 'enter insert mode on next character', + command: 'a', + handler: _insertNextInlineCommandHandler, ); -CommandShortcutEventHandler _insertInlineCommandHandler = (editorState) { +CommandShortcutEventHandler _insertNextInlineCommandHandler = (editorState) { final afKeyboard = editorState.service.keyboardServiceKey; if (afKeyboard.currentState != null && afKeyboard.currentState is AppFlowyKeyboardService) { if (editorState.selection == null) { editorState.selection = editorState.prevSelection; + editorState.moveCursor(SelectionMoveDirection.backward); editorState.selectionService.updateSelection(editorState.selection); + editorState.prevSelection = null; return KeyEventResult.handled; } else { return KeyEventResult.ignored; @@ -116,20 +119,22 @@ CommandShortcutEventHandler _insertInlineCommandHandler = (editorState) { return KeyEventResult.ignored; }; -final CommandShortcutEvent insertNextInlineCommand = CommandShortcutEvent( - key: 'enter insert mode on next character', - command: 'a', - handler: _insertNextInlineCommandHandler, +/// Motion Keys +final CommandShortcutEvent jumpDownCommand = CommandShortcutEvent( + key: 'move the cursor downward', + command: 'j', + handler: _jumpDownCommandHandler, ); -CommandShortcutEventHandler _insertNextInlineCommandHandler = (editorState) { +CommandShortcutEventHandler _jumpDownCommandHandler = (editorState) { final afKeyboard = editorState.service.keyboardServiceKey; if (afKeyboard.currentState != null && afKeyboard.currentState is AppFlowyKeyboardService) { + // editorState.scrollService!.goBallistic(4); if (editorState.selection == null) { - editorState.selection = editorState.prevSelection; - editorState.moveCursor(SelectionMoveDirection.backward); - editorState.selectionService.updateSelection(editorState.selection); + int scroll = 4; + //TODO: Figure out a way to jump line by line + editorState.scrollService?.jumpTo(scroll++); return KeyEventResult.handled; } else { return KeyEventResult.ignored; From b77a1009a0fa5ff02737a3aa9e2501eb7e58216f Mon Sep 17 00:00:00 2001 From: Sean Riley Hawkins Date: Thu, 19 Oct 2023 21:22:48 +0200 Subject: [PATCH 05/49] feat: add block cursor style --- lib/src/render/selection/cursor.dart | 6 ++++++ lib/src/render/selection/cursor_widget.dart | 6 ++++++ lib/src/render/selection/selectable.dart | 1 + 3 files changed, 13 insertions(+) diff --git a/lib/src/render/selection/cursor.dart b/lib/src/render/selection/cursor.dart index 6d9fe076e..3b8fed57e 100644 --- a/lib/src/render/selection/cursor.dart +++ b/lib/src/render/selection/cursor.dart @@ -82,6 +82,12 @@ class CursorState extends State { border: Border.all(color: color, width: 2), ), ); + case CursorStyle.block: + return Container( + decoration: BoxDecoration( + border: Border.all(color: color, width: 6), + ), + ); case CursorStyle.cover: final size = widget.rect.size; return Container( diff --git a/lib/src/render/selection/cursor_widget.dart b/lib/src/render/selection/cursor_widget.dart index a49f8344d..e5ee5ab88 100644 --- a/lib/src/render/selection/cursor_widget.dart +++ b/lib/src/render/selection/cursor_widget.dart @@ -91,6 +91,12 @@ class CursorWidgetState extends State { border: Border.all(color: color, width: 2), ), ); + case CursorStyle.block: + return Container( + decoration: BoxDecoration( + border: Border.all(color: color, width: 8), + ), + ); case CursorStyle.cover: final size = widget.rect.size; return Container( diff --git a/lib/src/render/selection/selectable.dart b/lib/src/render/selection/selectable.dart index fb5abc552..f9e2cf55c 100644 --- a/lib/src/render/selection/selectable.dart +++ b/lib/src/render/selection/selectable.dart @@ -6,6 +6,7 @@ enum CursorStyle { verticalLine, borderLine, cover, + block } /// [SelectableMixin] is used for the editor to calculate the position From d81cc79889d649dbfe9dc0c0c718412180510e13 Mon Sep 17 00:00:00 2001 From: Sean Riley Hawkins Date: Thu, 19 Oct 2023 21:23:02 +0200 Subject: [PATCH 06/49] feat: add Vim Modes enum --- .../selection/block_selection_area.dart | 27 ++++++++++++++++--- .../selection/desktop_selection_service.dart | 19 +++++++++---- .../escape_command.dart | 1 + .../command_shortcut_events/vim.dart | 4 +++ lib/src/editor_state.dart | 8 ++++++ 5 files changed, 51 insertions(+), 8 deletions(-) diff --git a/lib/src/editor/block_component/base_component/selection/block_selection_area.dart b/lib/src/editor/block_component/base_component/selection/block_selection_area.dart index 3332f4770..b13b284b0 100644 --- a/lib/src/editor/block_component/base_component/selection/block_selection_area.dart +++ b/lib/src/editor/block_component/base_component/selection/block_selection_area.dart @@ -91,16 +91,36 @@ class _BlockSelectionAreaState extends State { builder: ((context, value, child) { final sizedBox = child ?? const SizedBox.shrink(); final selection = value?.normalized; - + final editorState = context.watch(); + final rect = prevCursorRect ?? Rect.zero; if (selection == null) { + //NOTE: This just makes every node that can be selected return sizedBox; } final path = widget.node.path; + if (editorState.mode == VimModes.normalMode) { + if (!widget.supportTypes.contains(BlockSelectionType.selection) || + prevSelectionRects == null || + prevSelectionRects!.isEmpty) { + return sizedBox; + } + + final cursor = Cursor( + key: cursorKey, + rect: rect, + shouldBlink: false, + cursorStyle: CursorStyle.block, + color: Colors.blue, + ); + cursorKey.currentState?.unwrapOrNull()?.show(); + return cursor; + } + if (!path.inSelection(selection)) { return sizedBox; } - + //NOTE: Include this in Insert Mode? if (context.read().selectionType == SelectionType.block) { if (!widget.supportTypes.contains(BlockSelectionType.block) || !path.equals(selection.start.path) || @@ -118,7 +138,8 @@ class _BlockSelectionAreaState extends State { ); } // show the cursor when the selection is collapsed - else if (selection.isCollapsed) { + else if (selection.isCollapsed && + editorState.mode == VimModes.insertMode) { if (!widget.supportTypes.contains(BlockSelectionType.cursor) || prevCursorRect == null) { return sizedBox; diff --git a/lib/src/editor/editor_component/service/selection/desktop_selection_service.dart b/lib/src/editor/editor_component/service/selection/desktop_selection_service.dart index 7ee97cea1..57f5c70ca 100644 --- a/lib/src/editor/editor_component/service/selection/desktop_selection_service.dart +++ b/lib/src/editor/editor_component/service/selection/desktop_selection_service.dart @@ -221,12 +221,21 @@ class _DesktopSelectionServiceWidgetState selection = Selection(start: start, end: end); } } else { - selection = selectable.cursorStyle == CursorStyle.verticalLine - ? Selection.collapsed(selectable.getPositionInOffset(offset)) - : Selection(start: selectable.start(), end: selectable.end()); + if (editorState.mode == VimModes.normalMode) { + print('Normal mode'); + selectable.cursorStyle; + selection = Selection.collapsed(selectable.getPositionInOffset(offset)); - // Reset old start offset - _panStartOffset = offset; + // Reset old start offset + _panStartOffset = offset; + } else { + selection = selectable.cursorStyle == CursorStyle.verticalLine + ? Selection.collapsed(selectable.getPositionInOffset(offset)) + : Selection(start: selectable.start(), end: selectable.end()); + + // Reset old start offset + _panStartOffset = offset; + } } updateSelection(selection); diff --git a/lib/src/editor/editor_component/service/shortcuts/command_shortcut_events/escape_command.dart b/lib/src/editor/editor_component/service/shortcuts/command_shortcut_events/escape_command.dart index 8c7c30e8d..3cac91d29 100644 --- a/lib/src/editor/editor_component/service/shortcuts/command_shortcut_events/escape_command.dart +++ b/lib/src/editor/editor_component/service/shortcuts/command_shortcut_events/escape_command.dart @@ -17,5 +17,6 @@ CommandShortcutEventHandler _exitEditingCommandHandler = (editorState) { editorState.prevSelection = editorState.selection; editorState.selection = null; editorState.service.keyboardService?.closeKeyboard(); + editorState.mode = VimModes.normalMode; return KeyEventResult.handled; }; diff --git a/lib/src/editor/editor_component/service/shortcuts/command_shortcut_events/vim.dart b/lib/src/editor/editor_component/service/shortcuts/command_shortcut_events/vim.dart index 3f76e9ff0..ff644f1de 100644 --- a/lib/src/editor/editor_component/service/shortcuts/command_shortcut_events/vim.dart +++ b/lib/src/editor/editor_component/service/shortcuts/command_shortcut_events/vim.dart @@ -62,6 +62,7 @@ CommandShortcutEventHandler _insertOnNewLineCommandHandler = (editorState) { if (afKeyboard.currentState != null && afKeyboard.currentState is AppFlowyKeyboardService) { if (editorState.selection == null) { + editorState.mode = VimModes.insertMode; editorState.selection = editorState.prevSelection; editorState.insertNewLine(); editorState.selectionService.updateSelection(editorState.selection); @@ -85,6 +86,7 @@ CommandShortcutEventHandler _insertInlineCommandHandler = (editorState) { if (afKeyboard.currentState != null && afKeyboard.currentState is AppFlowyKeyboardService) { if (editorState.selection == null) { + editorState.mode = VimModes.insertMode; editorState.selection = editorState.prevSelection; editorState.selectionService.updateSelection(editorState.selection); editorState.prevSelection = null; @@ -107,6 +109,7 @@ CommandShortcutEventHandler _insertNextInlineCommandHandler = (editorState) { if (afKeyboard.currentState != null && afKeyboard.currentState is AppFlowyKeyboardService) { if (editorState.selection == null) { + editorState.mode = VimModes.insertMode; editorState.selection = editorState.prevSelection; editorState.moveCursor(SelectionMoveDirection.backward); editorState.selectionService.updateSelection(editorState.selection); @@ -132,6 +135,7 @@ CommandShortcutEventHandler _jumpDownCommandHandler = (editorState) { afKeyboard.currentState is AppFlowyKeyboardService) { // editorState.scrollService!.goBallistic(4); if (editorState.selection == null) { + editorState.mode = VimModes.normalMode; int scroll = 4; //TODO: Figure out a way to jump line by line editorState.scrollService?.jumpTo(scroll++); diff --git a/lib/src/editor_state.dart b/lib/src/editor_state.dart index 4eb7a072c..74d892263 100644 --- a/lib/src/editor_state.dart +++ b/lib/src/editor_state.dart @@ -31,6 +31,12 @@ enum SelectionUpdateReason { searchHighlight, // Highlighting search results } +//Enum for VIM Mode +enum VimModes { + insertMode, + normalMode, +} + enum SelectionType { inline, block, @@ -105,6 +111,8 @@ class EditorState { /// The previous selection of the editor. Selection? get prevSelection => prevSelectionNotifier.value; + var mode = VimModes.normalMode; + /// Sets the selection of the editor. set selection(Selection? value) { // clear the toggled style when the selection is changed. From bb6e5bccb236d5706c91252991042e4608f51122 Mon Sep 17 00:00:00 2001 From: Sean Riley Hawkins Date: Tue, 24 Oct 2023 18:44:12 +0200 Subject: [PATCH 07/49] feat: enable block cursor & movement keys in normal mode - Allow h, j, k, l, keys for vim like movement in normal mode. - Update cursor on vim movement. - Display block style cursor when in normal mode. - Allow block cursor selection with mouse. - Ensure insert works with the current selection --- .../selection/block_selection_area.dart | 9 +- .../selection/desktop_selection_service.dart | 5 +- .../escape_command.dart | 15 +- .../command_shortcut_events/vim.dart | 158 ++++++++++++------ lib/src/render/selection/selectable.dart | 8 +- 5 files changed, 125 insertions(+), 70 deletions(-) diff --git a/lib/src/editor/block_component/base_component/selection/block_selection_area.dart b/lib/src/editor/block_component/base_component/selection/block_selection_area.dart index b13b284b0..6e2f650e6 100644 --- a/lib/src/editor/block_component/base_component/selection/block_selection_area.dart +++ b/lib/src/editor/block_component/base_component/selection/block_selection_area.dart @@ -92,23 +92,23 @@ class _BlockSelectionAreaState extends State { final sizedBox = child ?? const SizedBox.shrink(); final selection = value?.normalized; final editorState = context.watch(); - final rect = prevCursorRect ?? Rect.zero; if (selection == null) { - //NOTE: This just makes every node that can be selected return sizedBox; } final path = widget.node.path; - if (editorState.mode == VimModes.normalMode) { + if (editorState.mode == VimModes.normalMode && + editorState.selection != null) { if (!widget.supportTypes.contains(BlockSelectionType.selection) || prevSelectionRects == null || prevSelectionRects!.isEmpty) { return sizedBox; } + final rect = widget.delegate.getCursorRectInPosition(selection.start); final cursor = Cursor( key: cursorKey, - rect: rect, + rect: rect!, shouldBlink: false, cursorStyle: CursorStyle.block, color: Colors.blue, @@ -121,6 +121,7 @@ class _BlockSelectionAreaState extends State { return sizedBox; } //NOTE: Include this in Insert Mode? + //NOTE: Box decoration for selection if (context.read().selectionType == SelectionType.block) { if (!widget.supportTypes.contains(BlockSelectionType.block) || !path.equals(selection.start.path) || diff --git a/lib/src/editor/editor_component/service/selection/desktop_selection_service.dart b/lib/src/editor/editor_component/service/selection/desktop_selection_service.dart index 57f5c70ca..2f9885b23 100644 --- a/lib/src/editor/editor_component/service/selection/desktop_selection_service.dart +++ b/lib/src/editor/editor_component/service/selection/desktop_selection_service.dart @@ -222,9 +222,10 @@ class _DesktopSelectionServiceWidgetState } } else { if (editorState.mode == VimModes.normalMode) { - print('Normal mode'); - selectable.cursorStyle; + //NOTE: It throws a transaction error when I mimic the else statement for selection + //NOTE: So settled for a single selection selection = Selection.collapsed(selectable.getPositionInOffset(offset)); + editorState.prevSelection = selection; // Reset old start offset _panStartOffset = offset; diff --git a/lib/src/editor/editor_component/service/shortcuts/command_shortcut_events/escape_command.dart b/lib/src/editor/editor_component/service/shortcuts/command_shortcut_events/escape_command.dart index 3cac91d29..bf0771552 100644 --- a/lib/src/editor/editor_component/service/shortcuts/command_shortcut_events/escape_command.dart +++ b/lib/src/editor/editor_component/service/shortcuts/command_shortcut_events/escape_command.dart @@ -14,9 +14,14 @@ final CommandShortcutEvent exitEditingCommand = CommandShortcutEvent( ); CommandShortcutEventHandler _exitEditingCommandHandler = (editorState) { - editorState.prevSelection = editorState.selection; - editorState.selection = null; - editorState.service.keyboardService?.closeKeyboard(); - editorState.mode = VimModes.normalMode; - return KeyEventResult.handled; + if (editorState.mode == VimModes.insertMode && editorState.editable == true) { + editorState.prevSelection = editorState.selection; + editorState.selection = null; + editorState.mode = VimModes.normalMode; + editorState.service.keyboardService?.closeKeyboard(); + editorState.editable = false; + editorState.selection = editorState.prevSelection; + return KeyEventResult.handled; + } + return KeyEventResult.ignored; }; diff --git a/lib/src/editor/editor_component/service/shortcuts/command_shortcut_events/vim.dart b/lib/src/editor/editor_component/service/shortcuts/command_shortcut_events/vim.dart index ff644f1de..4c2ed6a5c 100644 --- a/lib/src/editor/editor_component/service/shortcuts/command_shortcut_events/vim.dart +++ b/lib/src/editor/editor_component/service/shortcuts/command_shortcut_events/vim.dart @@ -5,49 +5,11 @@ final List vimKeyModes = [ insertOnNewLineCommand, insertInlineCommand, insertNextInlineCommand, + jumpUpCommand, jumpDownCommand, + jumpLeftCommand, + jumpRightCommand, ]; -/* -final CommandShortcutEvent moveMentCommand = CommandShortcutEvent( - key: 'move down with j', - command: 'j', - handler: _moveMentCommand, -); - -CommandShortcutEventHandler _moveMentCommand = (editorState) { - final keyboardServiceKey = editorState.service.keyboardServiceKey; - final selection = editorState.selection; - print('Passed through event handler'); - /* - if (selection == null) { - return KeyEventResult.ignored; - } - */ - if (keyboardServiceKey.currentState != null && - keyboardServiceKey.currentState is AppFlowyKeyboardService) { - // editorState.service.keyboardService?.enableKeyBoard(selection!); - // final downPos = selection?.end.moveVertical(editorState, upwards: false); - if (selection == null) { - //NOTE: This works fine - // editorState.scrollService?.jumpToBottom(); - // final s = editorState.service.selectionService.currentSelection.value; - //editorState.service.keyboardService?.enableKeyBoard(s!); - - return KeyEventResult.handled; - } - - return KeyEventResult.ignored; - /* - editorState.updateSelectionWithReason( - downPos == null ? null : Selection.collapsed(downPos), - reason: SelectionUpdateReason.uiEvent, - ); - */ - } - // editorState.scrollService?.disable(); - return KeyEventResult.ignored; -}; -*/ /// Insert trigger keys final CommandShortcutEvent insertOnNewLineCommand = CommandShortcutEvent( @@ -61,9 +23,11 @@ CommandShortcutEventHandler _insertOnNewLineCommandHandler = (editorState) { if (afKeyboard.currentState != null && afKeyboard.currentState is AppFlowyKeyboardService) { - if (editorState.selection == null) { + if (editorState.selection == null || editorState.prevSelection != null) { + //NOTE: Call editable first before changing mode + editorState.editable = true; editorState.mode = VimModes.insertMode; - editorState.selection = editorState.prevSelection; + editorState.selection = editorState.selection; editorState.insertNewLine(); editorState.selectionService.updateSelection(editorState.selection); editorState.prevSelection = null; @@ -85,9 +49,11 @@ CommandShortcutEventHandler _insertInlineCommandHandler = (editorState) { final afKeyboard = editorState.service.keyboardServiceKey; if (afKeyboard.currentState != null && afKeyboard.currentState is AppFlowyKeyboardService) { - if (editorState.selection == null) { + if (editorState.selection == null || editorState.prevSelection != null) { + //NOTE: Call editable first before changing mode + editorState.editable = true; editorState.mode = VimModes.insertMode; - editorState.selection = editorState.prevSelection; + editorState.selection = editorState.selection; editorState.selectionService.updateSelection(editorState.selection); editorState.prevSelection = null; return KeyEventResult.handled; @@ -108,9 +74,11 @@ CommandShortcutEventHandler _insertNextInlineCommandHandler = (editorState) { final afKeyboard = editorState.service.keyboardServiceKey; if (afKeyboard.currentState != null && afKeyboard.currentState is AppFlowyKeyboardService) { - if (editorState.selection == null) { + if (editorState.selection == null || editorState.prevSelection != null) { + //NOTE: Call editable first before changing mode + editorState.editable = true; editorState.mode = VimModes.insertMode; - editorState.selection = editorState.prevSelection; + editorState.selection = editorState.selection; editorState.moveCursor(SelectionMoveDirection.backward); editorState.selectionService.updateSelection(editorState.selection); editorState.prevSelection = null; @@ -124,21 +92,105 @@ CommandShortcutEventHandler _insertNextInlineCommandHandler = (editorState) { /// Motion Keys final CommandShortcutEvent jumpDownCommand = CommandShortcutEvent( - key: 'move the cursor downward', + key: 'move the cursor downward in normal mode', command: 'j', handler: _jumpDownCommandHandler, ); CommandShortcutEventHandler _jumpDownCommandHandler = (editorState) { + final afKeyboard = editorState.service.keyboardServiceKey; + if (afKeyboard.currentState != null && + afKeyboard.currentState is AppFlowyKeyboardService) { + if (editorState.selection != null && + editorState.mode == VimModes.normalMode) { + final selection = editorState.selection; + final downPosition = + selection?.end.moveVertical(editorState, upwards: false); + editorState.updateSelectionWithReason( + downPosition == null ? null : Selection.collapsed(downPosition), + reason: SelectionUpdateReason.uiEvent); + return KeyEventResult.handled; + } else { + return KeyEventResult.ignored; + } + } + return KeyEventResult.ignored; +}; + +final CommandShortcutEvent jumpUpCommand = CommandShortcutEvent( + key: 'move the cursor upward in normal mode', + command: 'k', + handler: _jumpUpCommandHandler, +); + +CommandShortcutEventHandler _jumpUpCommandHandler = (editorState) { final afKeyboard = editorState.service.keyboardServiceKey; if (afKeyboard.currentState != null && afKeyboard.currentState is AppFlowyKeyboardService) { // editorState.scrollService!.goBallistic(4); - if (editorState.selection == null) { - editorState.mode = VimModes.normalMode; - int scroll = 4; - //TODO: Figure out a way to jump line by line - editorState.scrollService?.jumpTo(scroll++); + if (editorState.selection != null && + editorState.mode == VimModes.normalMode) { + final selection = editorState.selection; + final downPosition = + selection?.end.moveVertical(editorState, upwards: true); + editorState.updateSelectionWithReason( + downPosition == null ? null : Selection.collapsed(downPosition), + reason: SelectionUpdateReason.uiEvent, + ); + return KeyEventResult.handled; + } else { + return KeyEventResult.ignored; + } + } + return KeyEventResult.ignored; +}; + +final CommandShortcutEvent jumpLeftCommand = CommandShortcutEvent( + key: 'move the cursor to the left', + command: 'h', + handler: _jumpLeftCommandHandler, +); + +CommandShortcutEventHandler _jumpLeftCommandHandler = (editorState) { + final afKeyboard = editorState.service.keyboardServiceKey; + if (afKeyboard.currentState != null && + afKeyboard.currentState is AppFlowyKeyboardService) { + if (editorState.selection != null && + editorState.mode == VimModes.normalMode) { + final selection = editorState.selection; + final downPosition = + selection?.end.moveHorizontal(editorState, forward: true); + editorState.updateSelectionWithReason( + downPosition == null ? null : Selection.collapsed(downPosition), + reason: SelectionUpdateReason.uiEvent, + ); + return KeyEventResult.handled; + } else { + return KeyEventResult.ignored; + } + } + return KeyEventResult.ignored; +}; + +final CommandShortcutEvent jumpRightCommand = CommandShortcutEvent( + key: 'move the cursor downward', + command: 'l', + handler: _jumpRightCommandHandler, +); + +CommandShortcutEventHandler _jumpRightCommandHandler = (editorState) { + final afKeyboard = editorState.service.keyboardServiceKey; + if (afKeyboard.currentState != null && + afKeyboard.currentState is AppFlowyKeyboardService) { + if (editorState.selection != null && + editorState.mode == VimModes.normalMode) { + final selection = editorState.selection; + final downPosition = + selection?.end.moveHorizontal(editorState, forward: false); + editorState.updateSelectionWithReason( + downPosition == null ? null : Selection.collapsed(downPosition), + reason: SelectionUpdateReason.uiEvent, + ); return KeyEventResult.handled; } else { return KeyEventResult.ignored; diff --git a/lib/src/render/selection/selectable.dart b/lib/src/render/selection/selectable.dart index f9e2cf55c..8cdb00033 100644 --- a/lib/src/render/selection/selectable.dart +++ b/lib/src/render/selection/selectable.dart @@ -2,12 +2,7 @@ import 'package:appflowy_editor/src/core/location/position.dart'; import 'package:appflowy_editor/src/core/location/selection.dart'; import 'package:flutter/material.dart'; -enum CursorStyle { - verticalLine, - borderLine, - cover, - block -} +enum CursorStyle { verticalLine, borderLine, cover, block } /// [SelectableMixin] is used for the editor to calculate the position /// and size of the selection. @@ -84,6 +79,7 @@ mixin SelectableMixin on State { bool get shouldCursorBlink => true; CursorStyle get cursorStyle => CursorStyle.verticalLine; + CursorStyle get blockCursorStyle => CursorStyle.block; Rect transformRectToGlobal( Rect r, { From daf4946a1685ef21b9e8e79d0206ce5006f14887 Mon Sep 17 00:00:00 2001 From: Sean Riley Hawkins Date: Tue, 24 Oct 2023 18:44:39 +0200 Subject: [PATCH 08/49] fix: change vim mode to be insert by default --- lib/src/editor_state.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/src/editor_state.dart b/lib/src/editor_state.dart index 74d892263..ff035f1c5 100644 --- a/lib/src/editor_state.dart +++ b/lib/src/editor_state.dart @@ -111,7 +111,7 @@ class EditorState { /// The previous selection of the editor. Selection? get prevSelection => prevSelectionNotifier.value; - var mode = VimModes.normalMode; + var mode = VimModes.insertMode; /// Sets the selection of the editor. set selection(Selection? value) { From df8c8943c39b8653d451ed9ee70120c7d5898d23 Mon Sep 17 00:00:00 2001 From: Sean Riley Hawkins Date: Sun, 29 Oct 2023 16:53:14 +0200 Subject: [PATCH 09/49] feat: add in extra vim movements - Add half page down command. - Add page down command. - Add move to start of the line command. - Add move to end of the line command. **Other features are there but still have bugs so they are commented out** --- .../command_shortcut_events/vim.dart | 489 +++++++++++++++++- 1 file changed, 481 insertions(+), 8 deletions(-) diff --git a/lib/src/editor/editor_component/service/shortcuts/command_shortcut_events/vim.dart b/lib/src/editor/editor_component/service/shortcuts/command_shortcut_events/vim.dart index 4c2ed6a5c..6e139e8da 100644 --- a/lib/src/editor/editor_component/service/shortcuts/command_shortcut_events/vim.dart +++ b/lib/src/editor/editor_component/service/shortcuts/command_shortcut_events/vim.dart @@ -1,14 +1,46 @@ import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter/material.dart'; +import 'dart:math'; final List vimKeyModes = [ + ///Insert Methods insertOnNewLineCommand, insertInlineCommand, insertNextInlineCommand, + + ///Vim Movements jumpUpCommand, jumpDownCommand, jumpLeftCommand, jumpRightCommand, + + ///Vim Jump to line + //BUG: Won't work properly keyboard shortcut fails + // vimJumpToLineCommand, + + ///Page Movements + //BUG: Conflicts with ctrl+b key + // vimPageUpCommand, + vimHalfPageDownCommand, + vimPageDownCommand, + //BUG: Conflicts with ctrl+u key + // vimHalfPageUpCommand, + + ///Undo Commands + //BUG: These commands won't work not sure why but + //The undoManager doesnt work in normal mode + // vimUndoCommand, + // vimRedoCommand, + + ///Navigate line Commands + vimMoveCursorToStartCommand, + vimMoveCursorToEndCommand, + //BUG: Selection doesnt show up to user + // vimSelectLineCommand, + + ///Text operations + //BUG: Transaction doesn't apply until delete keyword is pressed + // vimDeleteUnderCursorCommand, ]; /// Insert trigger keys @@ -131,10 +163,10 @@ CommandShortcutEventHandler _jumpUpCommandHandler = (editorState) { if (editorState.selection != null && editorState.mode == VimModes.normalMode) { final selection = editorState.selection; - final downPosition = + final upPosition = selection?.end.moveVertical(editorState, upwards: true); editorState.updateSelectionWithReason( - downPosition == null ? null : Selection.collapsed(downPosition), + upPosition == null ? null : Selection.collapsed(upPosition), reason: SelectionUpdateReason.uiEvent, ); return KeyEventResult.handled; @@ -146,7 +178,7 @@ CommandShortcutEventHandler _jumpUpCommandHandler = (editorState) { }; final CommandShortcutEvent jumpLeftCommand = CommandShortcutEvent( - key: 'move the cursor to the left', + key: 'move the cursor to the left in normal mode', command: 'h', handler: _jumpLeftCommandHandler, ); @@ -158,10 +190,10 @@ CommandShortcutEventHandler _jumpLeftCommandHandler = (editorState) { if (editorState.selection != null && editorState.mode == VimModes.normalMode) { final selection = editorState.selection; - final downPosition = + final leftPosition = selection?.end.moveHorizontal(editorState, forward: true); editorState.updateSelectionWithReason( - downPosition == null ? null : Selection.collapsed(downPosition), + leftPosition == null ? null : Selection.collapsed(leftPosition), reason: SelectionUpdateReason.uiEvent, ); return KeyEventResult.handled; @@ -173,7 +205,7 @@ CommandShortcutEventHandler _jumpLeftCommandHandler = (editorState) { }; final CommandShortcutEvent jumpRightCommand = CommandShortcutEvent( - key: 'move the cursor downward', + key: 'move the cursor to the right in normal mode', command: 'l', handler: _jumpRightCommandHandler, ); @@ -185,10 +217,51 @@ CommandShortcutEventHandler _jumpRightCommandHandler = (editorState) { if (editorState.selection != null && editorState.mode == VimModes.normalMode) { final selection = editorState.selection; - final downPosition = + final rightPosition = selection?.end.moveHorizontal(editorState, forward: false); editorState.updateSelectionWithReason( - downPosition == null ? null : Selection.collapsed(downPosition), + rightPosition == null ? null : Selection.collapsed(rightPosition), + reason: SelectionUpdateReason.uiEvent, + ); + return KeyEventResult.handled; + } else { + return KeyEventResult.ignored; + } + } + return KeyEventResult.ignored; +}; + +//BUG: Selection does not show up in normal mode +final CommandShortcutEvent vimSelectLineCommand = CommandShortcutEvent( + key: 'enter insert mode from previous selection', + command: 'shift+v', + handler: _vimSelectLineCommandHandler, +); + +CommandShortcutEventHandler _vimSelectLineCommandHandler = (editorState) { + final afKeyboard = editorState.service.keyboardServiceKey; + if (afKeyboard.currentState != null && + afKeyboard.currentState is AppFlowyKeyboardService) { + if (editorState.selection == null || editorState.prevSelection != null) { + //NOTE: Call editable first before changing mode + editorState.selection = editorState.selection; + final selection = editorState.selection; + editorState.selectionService.updateSelection(selection); + editorState.prevSelection = null; + + final nodes = editorState.getNodesInSelection(selection!); + if (nodes.isEmpty) { + return KeyEventResult.ignored; + } + var end = selection.end; + final position = isRTL(editorState) + ? nodes.last.selectable?.end() + : nodes.last.selectable?.start(); + if (position != null) { + end = position; + } + editorState.updateSelectionWithReason( + selection.copyWith(end: end), reason: SelectionUpdateReason.uiEvent, ); return KeyEventResult.handled; @@ -198,3 +271,403 @@ CommandShortcutEventHandler _jumpRightCommandHandler = (editorState) { } return KeyEventResult.ignored; }; +final CommandShortcutEvent vimUndoCommand = CommandShortcutEvent( + key: 'vim undo in normal mode', + command: 'u', + handler: _vimUndoCommandHandler, +); + +CommandShortcutEventHandler _vimUndoCommandHandler = (editorState) { + final afKeyboard = editorState.service.keyboardServiceKey; + if (afKeyboard.currentState != null && + afKeyboard.currentState is AppFlowyKeyboardService) { + if (editorState.selection != null && + editorState.mode == VimModes.normalMode) { + //BUG: undo doesnt work in Normal mode + //NOTE: Could be something to do with selection + editorState.undoManager.undo(); + return KeyEventResult.handled; + } else { + return KeyEventResult.ignored; + } + } + return KeyEventResult.ignored; +}; + +final CommandShortcutEvent vimRedoCommand = CommandShortcutEvent( + key: 'vim redo in normal mode', + command: 'ctrl+r', + handler: _vimRedoCommandHandler, +); + +CommandShortcutEventHandler _vimRedoCommandHandler = (editorState) { + final afKeyboard = editorState.service.keyboardServiceKey; + if (afKeyboard.currentState != null && + afKeyboard.currentState is AppFlowyKeyboardService) { + if (editorState.selection != null && + editorState.mode == VimModes.normalMode) { + //BUG: This also doesnt work in Normal mode + editorState.undoManager.redo(); + return KeyEventResult.handled; + } else { + return KeyEventResult.ignored; + } + } + return KeyEventResult.ignored; +}; + +final CommandShortcutEvent vimPageDownCommand = CommandShortcutEvent( + key: 'scroll one page down in normal mode', + command: 'ctrl+f', + handler: _vimPageDownCommandHandler, +); + +CommandShortcutEventHandler _vimPageDownCommandHandler = (editorState) { + final afKeyboard = editorState.service.keyboardServiceKey; + if (afKeyboard.currentState != null && + afKeyboard.currentState is AppFlowyKeyboardService) { + if (editorState.selection != null && + editorState.mode == VimModes.normalMode) { + final scrollService = editorState.service.scrollService; + if (scrollService == null) { + return KeyEventResult.ignored; + } + + final scrollHeight = scrollService.onePageHeight; + final dy = max(0, scrollService.dy); + if (scrollHeight == null) { + return KeyEventResult.ignored; + } + scrollService.scrollTo( + dy + scrollHeight, + duration: const Duration(milliseconds: 150), + ); + return KeyEventResult.handled; + } else { + return KeyEventResult.ignored; + } + } + return KeyEventResult.ignored; +}; +//NOTE: Move the cursor as well when moving the page +final CommandShortcutEvent vimHalfPageDownCommand = CommandShortcutEvent( + key: 'scroll half page down in normal mode', + command: 'ctrl+d', + handler: _vimHalfPageDownCommandHandler, +); + +CommandShortcutEventHandler _vimHalfPageDownCommandHandler = (editorState) { + final afKeyboard = editorState.service.keyboardServiceKey; + if (afKeyboard.currentState != null && + afKeyboard.currentState is AppFlowyKeyboardService) { + if (editorState.selection != null && + editorState.mode == VimModes.normalMode) { + final scrollService = editorState.service.scrollService; + if (scrollService == null) { + return KeyEventResult.ignored; + } + + final scrollHeight = scrollService.onePageHeight; + final dy = max(0, scrollService.dy); + if (scrollHeight == null) { + return KeyEventResult.ignored; + } + scrollService.scrollTo( + (dy + scrollHeight) / 2, + duration: const Duration(milliseconds: 150), + ); + return KeyEventResult.handled; + } else { + return KeyEventResult.ignored; + } + } + return KeyEventResult.ignored; +}; +//NOTE: Bug page up event not triggered could be conflicting with other keys +final CommandShortcutEvent vimPageUpCommand = CommandShortcutEvent( + key: 'scroll one page up in normal mode', + command: 'ctrl+b', + handler: _vimPageUpCommandHandler, +); + +CommandShortcutEventHandler _vimPageUpCommandHandler = (editorState) { + if (PlatformExtension.isMobile) { + assert(false, 'pageUpCommand is not supported on mobile platform.'); + return KeyEventResult.ignored; + } + final afKeyboard = editorState.service.keyboardServiceKey; + if (afKeyboard.currentState != null && + afKeyboard.currentState is AppFlowyKeyboardService) { + if (editorState.selection != null && + editorState.mode == VimModes.normalMode) { + final scrollService = editorState.service.scrollService; + if (scrollService == null) { + return KeyEventResult.ignored; + } + + final scrollHeight = scrollService.onePageHeight; + final dy = scrollService.dy; + if (dy <= 0 || scrollHeight == null) { + return KeyEventResult.ignored; + } + scrollService.scrollTo( + dy - scrollHeight, + duration: const Duration(milliseconds: 150), + ); + return KeyEventResult.handled; + } else { + return KeyEventResult.ignored; + } + } + return KeyEventResult.ignored; +}; + +final CommandShortcutEvent vimHalfPageUpCommand = CommandShortcutEvent( + key: 'scroll one page up in normal mode', + command: 'ctrl+u', + handler: _vimHalfPageUpCommandHandler, +); + +CommandShortcutEventHandler _vimHalfPageUpCommandHandler = (editorState) { + if (PlatformExtension.isMobile) { + assert(false, 'pageUpCommand is not supported on mobile platform.'); + return KeyEventResult.ignored; + } + final afKeyboard = editorState.service.keyboardServiceKey; + if (afKeyboard.currentState != null && + afKeyboard.currentState is AppFlowyKeyboardService) { + if (editorState.mode == VimModes.normalMode) { + final scrollService = editorState.service.scrollService; + if (scrollService == null) { + return KeyEventResult.ignored; + } + + final scrollHeight = scrollService.onePageHeight; + final dy = scrollService.dy; + if (dy <= 0 || scrollHeight == null) { + return KeyEventResult.ignored; + } + scrollService.scrollTo( + dy - scrollHeight, + duration: const Duration(milliseconds: 150), + ); + return KeyEventResult.handled; + } else { + return KeyEventResult.ignored; + } + } + return KeyEventResult.ignored; +}; + +final CommandShortcutEvent vimMoveCursorToStartCommand = CommandShortcutEvent( + key: 'vim move cursor to start of line in normal mode', + command: 'Digit 0', + handler: _vimMoveCursorToStartHandler, +); + +CommandShortcutEventHandler _vimMoveCursorToStartHandler = (editorState) { + final afKeyboard = editorState.service.keyboardServiceKey; + if (afKeyboard.currentState != null && + afKeyboard.currentState is AppFlowyKeyboardService) { + if (editorState.selection != null && + editorState.mode == VimModes.normalMode) { + if (isRTL(editorState)) { + editorState.moveCursorBackward(SelectionMoveRange.line); + } else { + editorState.moveCursorForward(SelectionMoveRange.line); + } + return KeyEventResult.handled; + } else { + return KeyEventResult.ignored; + } + } + return KeyEventResult.ignored; +}; + +final CommandShortcutEvent vimMoveCursorToEndCommand = CommandShortcutEvent( + key: 'vim move cursor to end of line in normal mode', + //NOTE: Used Digit 4, dollar sign would throw error + command: 'shift+Digit 4', + handler: _vimMoveCursorToEndHandler, +); + +CommandShortcutEventHandler _vimMoveCursorToEndHandler = (editorState) { + final afKeyboard = editorState.service.keyboardServiceKey; + if (afKeyboard.currentState != null && + afKeyboard.currentState is AppFlowyKeyboardService) { + if (editorState.selection != null && + editorState.mode == VimModes.normalMode) { + if (isRTL(editorState)) { + editorState.moveCursorForward(SelectionMoveRange.line); + } else { + editorState.moveCursorBackward(SelectionMoveRange.line); + } + return KeyEventResult.handled; + } else { + return KeyEventResult.ignored; + } + } + return KeyEventResult.ignored; +}; +final numList = List.generate(10, (i) => i); +final movements = [ + jumpDownCommand, + jumpUpCommand, +]; +/* + * The idea for this is to at least use + * a list for the numbers & key movements. + * Then from there jump accordingly, although + * might need to intercept that raw key event + * Manually of course + Basically -> 5j means jump five lines down + */ +final CommandShortcutEvent vimJumpToLineCommand = CommandShortcutEvent( + key: 'vim move cursor to start of line in normal mode', + //TODO: Find a way to await & chain shortcuts or key presses + // command: 'Digit 5', + command: 'Digit 5j', + handler: _vimJumpToLineHandler, +); + +CommandShortcutEventHandler _vimJumpToLineHandler = (editorState) { + final afKeyboard = editorState.service.keyboardServiceKey; + if (afKeyboard.currentState != null && + afKeyboard.currentState is AppFlowyKeyboardService) { + if (editorState.selection != null && + editorState.mode == VimModes.normalMode) { + final selection = editorState.selection; + //NOTE: Hard wired for now + //Besides this is just for scrolling doesnt move the cursor + editorState.scrollService?.jumpTo(5); + return KeyEventResult.handled; + } else { + return KeyEventResult.ignored; + } + } + return KeyEventResult.ignored; +}; + +//BUG: Transaction to delete word won't apply +final CommandShortcutEvent vimDeleteUnderCursorCommand = CommandShortcutEvent( + key: 'vim delete character under cursor in normal mode', + command: 'd', + handler: _vimDeleteUnderCursorHandler, +); + +CommandShortcutEventHandler _vimDeleteUnderCursorHandler = (editorState) { + final afKeyboard = editorState.service.keyboardServiceKey; + if (afKeyboard.currentState != null && + afKeyboard.currentState is AppFlowyKeyboardService) { + if (editorState.selection != null && + editorState.mode == VimModes.normalMode) { + final selection = editorState.selection; + final selectionType = editorState.selectionType; + print(selectionType); + if (selectionType == SelectionType.block) { + print('block section!'); + return _deleteInBlockSelection(editorState); + } else if (selection!.isCollapsed) { + print('collapsed section!'); + return _deleteInCollapsedSelection(editorState); + } else { + print('not in collapsed section!'); + return _deleteInNotCollapsedSelection(editorState); + } + } else { + return KeyEventResult.ignored; + } + } + return KeyEventResult.ignored; +}; + +///Delete Handlers + +/// Handle delete key event when selection is collapsed. +CommandShortcutEventHandler _deleteInCollapsedSelection = (editorState) { + final selection = editorState.selection; + if (selection == null || !selection.isCollapsed) { + return KeyEventResult.ignored; + } + + final position = selection.start; + final node = editorState.getNodeAtPath(position.path); + final delta = node?.delta; + if (node == null || delta == null) { + return KeyEventResult.ignored; + } + + final transaction = editorState.transaction; + + if (position.offset == delta.length) { + Node? tableParent = + node.findParent((element) => element.type == TableBlockKeys.type); + Node? nextTableParent; + final next = node.findDownward((element) { + nextTableParent = + element.findParent((element) => element.type == TableBlockKeys.type); + // break if only one is in a table or they're in different tables + return tableParent != nextTableParent || + // merge the next node with delta + element.delta != null; + }); + // table nodes should be deleted using the table menu + // in-table paragraphs should only be deleted inside the table + if (next != null && tableParent == nextTableParent) { + if (next.children.isNotEmpty) { + final path = node.path + [node.children.length]; + transaction.insertNodes(path, next.children); + } + /*NOTE: So transaction doesnt get applied + unless its in insert mode so need to work around it + */ + transaction + ..deleteNode(next) + ..mergeText( + node, + next, + ); + editorState.apply(transaction); + return KeyEventResult.handled; + } + } else { + //NOTE: This is for normal text blocks but not being triggered + final nextIndex = delta.nextRunePosition(position.offset); + if (nextIndex <= delta.length) { + transaction.deleteText( + node, + position.offset, + nextIndex - position.offset, + ); + //BUG: The transaction is not being applied + editorState.apply(transaction); + return KeyEventResult.handled; + } + } + + return KeyEventResult.ignored; +}; + +/// Handle delete key event when selection is not collapsed. +CommandShortcutEventHandler _deleteInNotCollapsedSelection = (editorState) { + final selection = editorState.selection; + if (selection == null || selection.isCollapsed) { + return KeyEventResult.ignored; + } + editorState.deleteSelection(selection); + return KeyEventResult.handled; +}; + +CommandShortcutEventHandler _deleteInBlockSelection = (editorState) { + final selection = editorState.selection; + if (selection == null || editorState.selectionType != SelectionType.block) { + return KeyEventResult.ignored; + } + final transaction = editorState.transaction; + transaction.deleteNodesAtPath(selection.start.path); + editorState + .apply(transaction) + .then((value) => editorState.selectionType = null); + + return KeyEventResult.handled; +}; From 1d0ecc01e88f36d9488a1fca0e173ba62b953202 Mon Sep 17 00:00:00 2001 From: Sean Riley Hawkins Date: Sun, 29 Oct 2023 16:53:41 +0200 Subject: [PATCH 10/49] docs: add some comments for future work --- .../selection/block_selection_area.dart | 14 ++++++++++++++ .../paragraph_block_component.dart | 7 +++++++ .../command_shortcut_events/escape_command.dart | 1 + 3 files changed, 22 insertions(+) diff --git a/lib/src/editor/block_component/base_component/selection/block_selection_area.dart b/lib/src/editor/block_component/base_component/selection/block_selection_area.dart index 6e2f650e6..aa0af52bd 100644 --- a/lib/src/editor/block_component/base_component/selection/block_selection_area.dart +++ b/lib/src/editor/block_component/base_component/selection/block_selection_area.dart @@ -97,6 +97,8 @@ class _BlockSelectionAreaState extends State { } final path = widget.node.path; + + ///Force cursor on in normal mode if (editorState.mode == VimModes.normalMode && editorState.selection != null) { if (!widget.supportTypes.contains(BlockSelectionType.selection) || @@ -138,6 +140,18 @@ class _BlockSelectionAreaState extends State { ), ); } + //BUG: This does not show selection in normal mode + if (editorState.mode == VimModes.normalMode) { + if (!widget.supportTypes.contains(BlockSelectionType.selection) || + prevSelectionRects == null || + prevSelectionRects!.isEmpty) { + return sizedBox; + } + return SelectionAreaPaint( + rects: prevSelectionRects!, + selectionColor: widget.selectionColor, + ); + } // show the cursor when the selection is collapsed else if (selection.isCollapsed && editorState.mode == VimModes.insertMode) { diff --git a/lib/src/editor/block_component/paragraph_block_component/paragraph_block_component.dart b/lib/src/editor/block_component/paragraph_block_component/paragraph_block_component.dart index 860028ff1..1df75001f 100644 --- a/lib/src/editor/block_component/paragraph_block_component/paragraph_block_component.dart +++ b/lib/src/editor/block_component/paragraph_block_component/paragraph_block_component.dart @@ -142,6 +142,13 @@ class _ParagraphBlockComponentWidgetState mainAxisSize: MainAxisSize.min, textDirection: textDirection, children: [ + /* + NOTE: + One can combine AppFlowyRichText + With a row then stick in the line number. + Only problem is the container doesnt maintain + its size + */ AppFlowyRichText( key: forwardKey, delegate: this, diff --git a/lib/src/editor/editor_component/service/shortcuts/command_shortcut_events/escape_command.dart b/lib/src/editor/editor_component/service/shortcuts/command_shortcut_events/escape_command.dart index bf0771552..dd1ec3800 100644 --- a/lib/src/editor/editor_component/service/shortcuts/command_shortcut_events/escape_command.dart +++ b/lib/src/editor/editor_component/service/shortcuts/command_shortcut_events/escape_command.dart @@ -7,6 +7,7 @@ import 'package:flutter/material.dart'; /// - desktop /// - web /// +//TODO: Allow custom escape key final CommandShortcutEvent exitEditingCommand = CommandShortcutEvent( key: 'exit the editing mode', command: 'escape', From f597249e4d000c4b8737e4c4fbc7f3575cf07541 Mon Sep 17 00:00:00 2001 From: Sean Riley Hawkins Date: Tue, 31 Oct 2023 09:16:53 +0200 Subject: [PATCH 11/49] feat: add jump word forward & backward --- .../command_shortcut_events/vim.dart | 138 +++++++++++++++++- 1 file changed, 137 insertions(+), 1 deletion(-) diff --git a/lib/src/editor/editor_component/service/shortcuts/command_shortcut_events/vim.dart b/lib/src/editor/editor_component/service/shortcuts/command_shortcut_events/vim.dart index 6e139e8da..3f08f46f8 100644 --- a/lib/src/editor/editor_component/service/shortcuts/command_shortcut_events/vim.dart +++ b/lib/src/editor/editor_component/service/shortcuts/command_shortcut_events/vim.dart @@ -35,6 +35,8 @@ final List vimKeyModes = [ ///Navigate line Commands vimMoveCursorToStartCommand, vimMoveCursorToEndCommand, + jumpWordBackwardCommand, + jumpWordForwardCommand, //BUG: Selection doesnt show up to user // vimSelectLineCommand, @@ -459,6 +461,7 @@ CommandShortcutEventHandler _vimHalfPageUpCommandHandler = (editorState) { return KeyEventResult.ignored; }; +///Navigate on the current line final CommandShortcutEvent vimMoveCursorToStartCommand = CommandShortcutEvent( key: 'vim move cursor to start of line in normal mode', command: 'Digit 0', @@ -509,6 +512,135 @@ CommandShortcutEventHandler _vimMoveCursorToEndHandler = (editorState) { } return KeyEventResult.ignored; }; + +final CommandShortcutEvent jumpWordBackwardCommand = CommandShortcutEvent( + key: 'move the cursor backward to the next wordl in normal mode', + command: 'b', + handler: _jumpWordBackwardCommandHandler, +); + +CommandShortcutEventHandler _jumpWordBackwardCommandHandler = (editorState) { + final afKeyboard = editorState.service.keyboardServiceKey; + if (afKeyboard.currentState != null && + afKeyboard.currentState is AppFlowyKeyboardService) { + if (editorState.selection != null && + editorState.mode == VimModes.normalMode) { + final selection = editorState.selection; + + final node = editorState.getNodeAtPath(selection!.end.path); + final delta = node?.delta; + + if (node == null || delta == null) { + return KeyEventResult.ignored; + } + + if (isRTL(editorState)) { + final endOfWord = selection.end.moveHorizontal( + editorState, + forward: false, + selectionRange: SelectionRange.word, + ); + final selectedWord = delta.toPlainText().substring( + selection.end.offset, + endOfWord?.offset, + ); + // check if the selected word is whitespace + if (selectedWord.trim().isEmpty) { + editorState.moveCursorBackward(SelectionMoveRange.word); + } + editorState.moveCursorBackward(SelectionMoveRange.word); + } else { + final startOfWord = selection.end.moveHorizontal( + editorState, + selectionRange: SelectionRange.word, + ); + if (startOfWord == null) { + return KeyEventResult.handled; + } + final selectedWord = delta.toPlainText().substring( + startOfWord.offset, + selection.end.offset, + ); + // check if the selected word is whitespace + if (selectedWord.trim().isEmpty) { + editorState.moveCursorForward(SelectionMoveRange.word); + } + editorState.moveCursorForward(SelectionMoveRange.word); + } + + return KeyEventResult.handled; + } else { + return KeyEventResult.ignored; + } + } + return KeyEventResult.ignored; +}; + +final CommandShortcutEvent jumpWordForwardCommand = CommandShortcutEvent( + key: 'move the cursor backward to the next wordl in normal mode', + command: 'w', + handler: _jumpWordForwardCommandHandler, +); + +CommandShortcutEventHandler _jumpWordForwardCommandHandler = (editorState) { + final afKeyboard = editorState.service.keyboardServiceKey; + if (afKeyboard.currentState != null && + afKeyboard.currentState is AppFlowyKeyboardService) { + if (editorState.selection != null && + editorState.mode == VimModes.normalMode) { + final selection = editorState.selection; + final node = editorState.getNodeAtPath(selection!.end.path); + final delta = node?.delta; + + if (node == null || delta == null) { + return KeyEventResult.ignored; + } + + if (isRTL(editorState)) { + final startOfWord = selection.end.moveHorizontal( + editorState, + selectionRange: SelectionRange.word, + ); + if (startOfWord == null) { + return KeyEventResult.ignored; + } + final selectedWord = delta.toPlainText().substring( + startOfWord.offset, + selection.end.offset, + ); + // check if the selected word is whitespace + if (selectedWord.trim().isEmpty) { + editorState.moveCursorForward(SelectionMoveRange.word); + } + editorState.moveCursorForward(SelectionMoveRange.word); + } else { + final endOfWord = selection.end.moveHorizontal( + editorState, + forward: false, + selectionRange: SelectionRange.word, + ); + if (endOfWord == null) { + return KeyEventResult.handled; + } + final selectedLine = delta.toPlainText(); + final selectedWord = selectedLine.substring( + selection.end.offset, + endOfWord.offset, + ); + // check if the selected word is whitespace + if (selectedWord.trim().isEmpty) { + editorState.moveCursorBackward(SelectionMoveRange.word); + } + editorState.moveCursorBackward(SelectionMoveRange.word); + } + return KeyEventResult.handled; + } else { + return KeyEventResult.ignored; + } + } + return KeyEventResult.ignored; +}; + final numList = List.generate(10, (i) => i); final movements = [ jumpDownCommand, @@ -526,7 +658,7 @@ final CommandShortcutEvent vimJumpToLineCommand = CommandShortcutEvent( key: 'vim move cursor to start of line in normal mode', //TODO: Find a way to await & chain shortcuts or key presses // command: 'Digit 5', - command: 'Digit 5j', + command: 'Digit 5', handler: _vimJumpToLineHandler, ); @@ -540,6 +672,10 @@ CommandShortcutEventHandler _vimJumpToLineHandler = (editorState) { //NOTE: Hard wired for now //Besides this is just for scrolling doesnt move the cursor editorState.scrollService?.jumpTo(5); + SelectionMoveRange downRange = SelectionMoveRange.line; + //This will be tricky need to modify 'select_commands.dart' + //BUG: It throws an error on a blank line + editorState.moveCursor(SelectionMoveDirection.backward, downRange); return KeyEventResult.handled; } else { return KeyEventResult.ignored; From 433d986e29770a05e4b92c2a44ab737ed0dd0223 Mon Sep 17 00:00:00 2001 From: Sean Riley Hawkins Date: Thu, 2 Nov 2023 09:53:43 +0200 Subject: [PATCH 12/49] doc: add some comments from keyboard shortcut --- .../service/shortcuts/command_shortcut_events/vim.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/src/editor/editor_component/service/shortcuts/command_shortcut_events/vim.dart b/lib/src/editor/editor_component/service/shortcuts/command_shortcut_events/vim.dart index 3f08f46f8..d2efb8ee2 100644 --- a/lib/src/editor/editor_component/service/shortcuts/command_shortcut_events/vim.dart +++ b/lib/src/editor/editor_component/service/shortcuts/command_shortcut_events/vim.dart @@ -657,8 +657,9 @@ final movements = [ final CommandShortcutEvent vimJumpToLineCommand = CommandShortcutEvent( key: 'vim move cursor to start of line in normal mode', //TODO: Find a way to await & chain shortcuts or key presses + //Well modifying the keyevent directly won't end well. Since the focus node only reads one keyevent at a time // command: 'Digit 5', - command: 'Digit 5', + command: 'Digit 5' 'j', handler: _vimJumpToLineHandler, ); From c8becbc9b55775bc5d3db5f3534efa11b7cf45db Mon Sep 17 00:00:00 2001 From: Sean Riley Hawkins Date: Thu, 2 Nov 2023 16:58:30 +0200 Subject: [PATCH 13/49] fix: reduce cursor size to increase visibility --- lib/src/render/selection/cursor.dart | 2 +- lib/src/render/selection/cursor_widget.dart | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/src/render/selection/cursor.dart b/lib/src/render/selection/cursor.dart index 3b8fed57e..d25efe924 100644 --- a/lib/src/render/selection/cursor.dart +++ b/lib/src/render/selection/cursor.dart @@ -85,7 +85,7 @@ class CursorState extends State { case CursorStyle.block: return Container( decoration: BoxDecoration( - border: Border.all(color: color, width: 6), + border: Border.all(color: color, width: 4), ), ); case CursorStyle.cover: diff --git a/lib/src/render/selection/cursor_widget.dart b/lib/src/render/selection/cursor_widget.dart index e5ee5ab88..dd76199d6 100644 --- a/lib/src/render/selection/cursor_widget.dart +++ b/lib/src/render/selection/cursor_widget.dart @@ -94,7 +94,7 @@ class CursorWidgetState extends State { case CursorStyle.block: return Container( decoration: BoxDecoration( - border: Border.all(color: color, width: 8), + border: Border.all(color: color, width: 4), ), ); case CursorStyle.cover: From 03f893c2c7d0aaa25d30aa9e28ebe5920329a82c Mon Sep 17 00:00:00 2001 From: Sean Riley Hawkins Date: Thu, 2 Nov 2023 17:08:56 +0200 Subject: [PATCH 14/49] refactor: display selection in vim mode - Add extra checks to determine if vim mode is on or is in insertMode --- .../selection/block_selection_area.dart | 58 +++++++------------ 1 file changed, 22 insertions(+), 36 deletions(-) diff --git a/lib/src/editor/block_component/base_component/selection/block_selection_area.dart b/lib/src/editor/block_component/base_component/selection/block_selection_area.dart index 25947eb96..3192c4bca 100644 --- a/lib/src/editor/block_component/base_component/selection/block_selection_area.dart +++ b/lib/src/editor/block_component/base_component/selection/block_selection_area.dart @@ -98,32 +98,10 @@ class _BlockSelectionAreaState extends State { final path = widget.node.path; - ///Force cursor on in normal mode - if (editorState.mode == VimModes.normalMode && - editorState.selection != null) { - if (!widget.supportTypes.contains(BlockSelectionType.selection) || - prevSelectionRects == null || - prevSelectionRects!.isEmpty) { - return sizedBox; - } - - final rect = widget.delegate.getCursorRectInPosition(selection.start); - final cursor = Cursor( - key: cursorKey, - rect: rect!, - shouldBlink: false, - cursorStyle: CursorStyle.block, - color: Colors.blue, - ); - cursorKey.currentState?.unwrapOrNull()?.show(); - return cursor; - } - if (!path.inSelection(selection)) { return sizedBox; } - //NOTE: Include this in Insert Mode? - //NOTE: Box decoration for selection + if (context.read().selectionType == SelectionType.block) { if (!widget.supportTypes.contains(BlockSelectionType.block) || !path.equals(selection.start.path) || @@ -140,21 +118,10 @@ class _BlockSelectionAreaState extends State { ), ); } - //BUG: This does not show selection in normal mode - if (editorState.mode == VimModes.normalMode) { - if (!widget.supportTypes.contains(BlockSelectionType.selection) || - prevSelectionRects == null || - prevSelectionRects!.isEmpty) { - return sizedBox; - } - return SelectionAreaPaint( - rects: prevSelectionRects!, - selectionColor: widget.selectionColor, - ); - } + // show the cursor when the selection is collapsed else if (selection.isCollapsed && - editorState.mode == VimModes.insertMode) { + (editorState.mode == VimModes.insertMode || !editorState.vimMode)) { if (!widget.supportTypes.contains(BlockSelectionType.cursor) || prevCursorRect == null) { return sizedBox; @@ -169,6 +136,25 @@ class _BlockSelectionAreaState extends State { // force to show the cursor cursorKey.currentState?.unwrapOrNull()?.show(); return cursor; + } else if (selection.isCollapsed && + editorState.mode == VimModes.normalMode && + editorState.vimMode) { + if (!widget.supportTypes.contains(BlockSelectionType.selection) || + prevSelectionRects == null) { + return sizedBox; + } + + final rect = widget.delegate.getCursorRectInPosition(selection.start); + //NOTE: Hard coded cursor style + final cursor = Cursor( + key: cursorKey, + rect: rect ?? prevCursorRect!, + shouldBlink: false, + cursorStyle: CursorStyle.block, + color: Colors.blue, + ); + cursorKey.currentState?.unwrapOrNull()?.show(); + return cursor; } else { // show the selection area when the selection is not collapsed if (!widget.supportTypes.contains(BlockSelectionType.selection) || From edccb1102f49d936bd8479d48a34b8c8e127f6cc Mon Sep 17 00:00:00 2001 From: Sean Riley Hawkins Date: Thu, 2 Nov 2023 17:17:53 +0200 Subject: [PATCH 15/49] feat: add global mode for vim keybindings - If vim mode is on all the other keybindings will listen in. - If not they will be ignored. - There are some bugs in it like deleting on an empty space will make the widget tree panic. - The checks for vimMode all over files ensure that if the vim option is off. - The user won't be affected with normal text. --- .../editor_component/service/editor.dart | 9 +++ .../selection/desktop_selection_service.dart | 2 +- .../escape_command.dart | 11 +++- .../command_shortcut_events/vim.dart | 59 ++++++++++++++++--- lib/src/editor_state.dart | 5 +- 5 files changed, 74 insertions(+), 12 deletions(-) diff --git a/lib/src/editor/editor_component/service/editor.dart b/lib/src/editor/editor_component/service/editor.dart index e5a37c1af..e98cb42dc 100644 --- a/lib/src/editor/editor_component/service/editor.dart +++ b/lib/src/editor/editor_component/service/editor.dart @@ -24,6 +24,7 @@ class AppFlowyEditor extends StatefulWidget { List? commandShortcutEvents, List>? contextMenuItems, this.editable = true, + this.vimMode = false, this.autoFocus = false, this.focusedSelection, this.shrinkWrap = false, @@ -128,6 +129,12 @@ class AppFlowyEditor extends StatefulWidget { /// without the editing, selecting, scrolling features. final bool editable; + /// Set the value to false to disable Vim Mode. + /// + /// if false, the editor will work normally like any other + /// without the vim motion features and keybindings. + final bool vimMode; + /// Set the value to true to focus the editor on the start of the document. final bool autoFocus; @@ -175,6 +182,7 @@ class _AppFlowyEditorState extends State { editorState.editorStyle = widget.editorStyle; editorState.renderer = _renderer; editorState.editable = widget.editable; + editorState.vimMode = widget.vimMode; // auto focus WidgetsBinding.instance.addPostFrameCallback((timeStamp) { @@ -198,6 +206,7 @@ class _AppFlowyEditorState extends State { editorState.editorStyle = widget.editorStyle; editorState.editable = widget.editable; + editorState.vimMode = widget.vimMode; if (editorState.service != oldWidget.editorState.service) { editorState.renderer = _renderer; diff --git a/lib/src/editor/editor_component/service/selection/desktop_selection_service.dart b/lib/src/editor/editor_component/service/selection/desktop_selection_service.dart index 2f9885b23..eaa721329 100644 --- a/lib/src/editor/editor_component/service/selection/desktop_selection_service.dart +++ b/lib/src/editor/editor_component/service/selection/desktop_selection_service.dart @@ -221,7 +221,7 @@ class _DesktopSelectionServiceWidgetState selection = Selection(start: start, end: end); } } else { - if (editorState.mode == VimModes.normalMode) { + if (editorState.mode == VimModes.normalMode && editorState.vimMode) { //NOTE: It throws a transaction error when I mimic the else statement for selection //NOTE: So settled for a single selection selection = Selection.collapsed(selectable.getPositionInOffset(offset)); diff --git a/lib/src/editor/editor_component/service/shortcuts/command_shortcut_events/escape_command.dart b/lib/src/editor/editor_component/service/shortcuts/command_shortcut_events/escape_command.dart index dd1ec3800..72d432d8b 100644 --- a/lib/src/editor/editor_component/service/shortcuts/command_shortcut_events/escape_command.dart +++ b/lib/src/editor/editor_component/service/shortcuts/command_shortcut_events/escape_command.dart @@ -15,14 +15,19 @@ final CommandShortcutEvent exitEditingCommand = CommandShortcutEvent( ); CommandShortcutEventHandler _exitEditingCommandHandler = (editorState) { - if (editorState.mode == VimModes.insertMode && editorState.editable == true) { + if (editorState.mode == VimModes.insertMode && + editorState.editable == true && + editorState.vimMode == true) { editorState.prevSelection = editorState.selection; editorState.selection = null; editorState.mode = VimModes.normalMode; - editorState.service.keyboardService?.closeKeyboard(); editorState.editable = false; editorState.selection = editorState.prevSelection; + editorState.service.keyboardService?.closeKeyboard(); return KeyEventResult.handled; } - return KeyEventResult.ignored; + editorState.selection = null; + editorState.mode = VimModes.normalMode; + editorState.service.keyboardService?.closeKeyboard(); + return KeyEventResult.handled; }; diff --git a/lib/src/editor/editor_component/service/shortcuts/command_shortcut_events/vim.dart b/lib/src/editor/editor_component/service/shortcuts/command_shortcut_events/vim.dart index d2efb8ee2..1518f816a 100644 --- a/lib/src/editor/editor_component/service/shortcuts/command_shortcut_events/vim.dart +++ b/lib/src/editor/editor_component/service/shortcuts/command_shortcut_events/vim.dart @@ -41,7 +41,8 @@ final List vimKeyModes = [ // vimSelectLineCommand, ///Text operations - //BUG: Transaction doesn't apply until delete keyword is pressed + //BUG: Deleting at the end of text will cause the widget tree to panic + //NOTE: Probably try using the 'delete' button instead // vimDeleteUnderCursorCommand, ]; @@ -132,6 +133,9 @@ final CommandShortcutEvent jumpDownCommand = CommandShortcutEvent( ); CommandShortcutEventHandler _jumpDownCommandHandler = (editorState) { + if (!editorState.vimMode) { + return KeyEventResult.ignored; + } final afKeyboard = editorState.service.keyboardServiceKey; if (afKeyboard.currentState != null && afKeyboard.currentState is AppFlowyKeyboardService) { @@ -158,6 +162,9 @@ final CommandShortcutEvent jumpUpCommand = CommandShortcutEvent( ); CommandShortcutEventHandler _jumpUpCommandHandler = (editorState) { + if (!editorState.vimMode) { + return KeyEventResult.ignored; + } final afKeyboard = editorState.service.keyboardServiceKey; if (afKeyboard.currentState != null && afKeyboard.currentState is AppFlowyKeyboardService) { @@ -186,6 +193,9 @@ final CommandShortcutEvent jumpLeftCommand = CommandShortcutEvent( ); CommandShortcutEventHandler _jumpLeftCommandHandler = (editorState) { + if (!editorState.vimMode) { + return KeyEventResult.ignored; + } final afKeyboard = editorState.service.keyboardServiceKey; if (afKeyboard.currentState != null && afKeyboard.currentState is AppFlowyKeyboardService) { @@ -213,6 +223,9 @@ final CommandShortcutEvent jumpRightCommand = CommandShortcutEvent( ); CommandShortcutEventHandler _jumpRightCommandHandler = (editorState) { + if (!editorState.vimMode) { + return KeyEventResult.ignored; + } final afKeyboard = editorState.service.keyboardServiceKey; if (afKeyboard.currentState != null && afKeyboard.currentState is AppFlowyKeyboardService) { @@ -280,6 +293,9 @@ final CommandShortcutEvent vimUndoCommand = CommandShortcutEvent( ); CommandShortcutEventHandler _vimUndoCommandHandler = (editorState) { + if (!editorState.vimMode) { + return KeyEventResult.ignored; + } final afKeyboard = editorState.service.keyboardServiceKey; if (afKeyboard.currentState != null && afKeyboard.currentState is AppFlowyKeyboardService) { @@ -303,6 +319,9 @@ final CommandShortcutEvent vimRedoCommand = CommandShortcutEvent( ); CommandShortcutEventHandler _vimRedoCommandHandler = (editorState) { + if (!editorState.vimMode) { + return KeyEventResult.ignored; + } final afKeyboard = editorState.service.keyboardServiceKey; if (afKeyboard.currentState != null && afKeyboard.currentState is AppFlowyKeyboardService) { @@ -325,6 +344,9 @@ final CommandShortcutEvent vimPageDownCommand = CommandShortcutEvent( ); CommandShortcutEventHandler _vimPageDownCommandHandler = (editorState) { + if (!editorState.vimMode) { + return KeyEventResult.ignored; + } final afKeyboard = editorState.service.keyboardServiceKey; if (afKeyboard.currentState != null && afKeyboard.currentState is AppFlowyKeyboardService) { @@ -359,6 +381,9 @@ final CommandShortcutEvent vimHalfPageDownCommand = CommandShortcutEvent( ); CommandShortcutEventHandler _vimHalfPageDownCommandHandler = (editorState) { + if (!editorState.vimMode) { + return KeyEventResult.ignored; + } final afKeyboard = editorState.service.keyboardServiceKey; if (afKeyboard.currentState != null && afKeyboard.currentState is AppFlowyKeyboardService) { @@ -397,6 +422,10 @@ CommandShortcutEventHandler _vimPageUpCommandHandler = (editorState) { assert(false, 'pageUpCommand is not supported on mobile platform.'); return KeyEventResult.ignored; } + + if (!editorState.vimMode) { + return KeyEventResult.ignored; + } final afKeyboard = editorState.service.keyboardServiceKey; if (afKeyboard.currentState != null && afKeyboard.currentState is AppFlowyKeyboardService) { @@ -435,6 +464,10 @@ CommandShortcutEventHandler _vimHalfPageUpCommandHandler = (editorState) { assert(false, 'pageUpCommand is not supported on mobile platform.'); return KeyEventResult.ignored; } + + if (!editorState.vimMode) { + return KeyEventResult.ignored; + } final afKeyboard = editorState.service.keyboardServiceKey; if (afKeyboard.currentState != null && afKeyboard.currentState is AppFlowyKeyboardService) { @@ -469,6 +502,9 @@ final CommandShortcutEvent vimMoveCursorToStartCommand = CommandShortcutEvent( ); CommandShortcutEventHandler _vimMoveCursorToStartHandler = (editorState) { + if (!editorState.vimMode) { + return KeyEventResult.ignored; + } final afKeyboard = editorState.service.keyboardServiceKey; if (afKeyboard.currentState != null && afKeyboard.currentState is AppFlowyKeyboardService) { @@ -495,6 +531,9 @@ final CommandShortcutEvent vimMoveCursorToEndCommand = CommandShortcutEvent( ); CommandShortcutEventHandler _vimMoveCursorToEndHandler = (editorState) { + if (!editorState.vimMode) { + return KeyEventResult.ignored; + } final afKeyboard = editorState.service.keyboardServiceKey; if (afKeyboard.currentState != null && afKeyboard.currentState is AppFlowyKeyboardService) { @@ -520,6 +559,9 @@ final CommandShortcutEvent jumpWordBackwardCommand = CommandShortcutEvent( ); CommandShortcutEventHandler _jumpWordBackwardCommandHandler = (editorState) { + if (!editorState.vimMode) { + return KeyEventResult.ignored; + } final afKeyboard = editorState.service.keyboardServiceKey; if (afKeyboard.currentState != null && afKeyboard.currentState is AppFlowyKeyboardService) { @@ -583,6 +625,9 @@ final CommandShortcutEvent jumpWordForwardCommand = CommandShortcutEvent( ); CommandShortcutEventHandler _jumpWordForwardCommandHandler = (editorState) { + if (!editorState.vimMode) { + return KeyEventResult.ignored; + } final afKeyboard = editorState.service.keyboardServiceKey; if (afKeyboard.currentState != null && afKeyboard.currentState is AppFlowyKeyboardService) { @@ -664,6 +709,9 @@ final CommandShortcutEvent vimJumpToLineCommand = CommandShortcutEvent( ); CommandShortcutEventHandler _vimJumpToLineHandler = (editorState) { + if (!editorState.vimMode) { + return KeyEventResult.ignored; + } final afKeyboard = editorState.service.keyboardServiceKey; if (afKeyboard.currentState != null && afKeyboard.currentState is AppFlowyKeyboardService) { @@ -693,6 +741,9 @@ final CommandShortcutEvent vimDeleteUnderCursorCommand = CommandShortcutEvent( ); CommandShortcutEventHandler _vimDeleteUnderCursorHandler = (editorState) { + if (!editorState.vimMode) { + return KeyEventResult.ignored; + } final afKeyboard = editorState.service.keyboardServiceKey; if (afKeyboard.currentState != null && afKeyboard.currentState is AppFlowyKeyboardService) { @@ -711,8 +762,6 @@ CommandShortcutEventHandler _vimDeleteUnderCursorHandler = (editorState) { print('not in collapsed section!'); return _deleteInNotCollapsedSelection(editorState); } - } else { - return KeyEventResult.ignored; } } return KeyEventResult.ignored; @@ -755,9 +804,6 @@ CommandShortcutEventHandler _deleteInCollapsedSelection = (editorState) { final path = node.path + [node.children.length]; transaction.insertNodes(path, next.children); } - /*NOTE: So transaction doesnt get applied - unless its in insert mode so need to work around it - */ transaction ..deleteNode(next) ..mergeText( @@ -776,7 +822,6 @@ CommandShortcutEventHandler _deleteInCollapsedSelection = (editorState) { position.offset, nextIndex - position.offset, ); - //BUG: The transaction is not being applied editorState.apply(transaction); return KeyEventResult.handled; } diff --git a/lib/src/editor_state.dart b/lib/src/editor_state.dart index ff035f1c5..61bd92b92 100644 --- a/lib/src/editor_state.dart +++ b/lib/src/editor_state.dart @@ -113,6 +113,9 @@ class EditorState { var mode = VimModes.insertMode; + /// Whether Vim mode is enabled or not + bool vimMode = false; + /// Sets the selection of the editor. set selection(Selection? value) { // clear the toggled style when the selection is changed. @@ -271,7 +274,7 @@ class EditorState { ApplyOptions options = const ApplyOptions(recordUndo: true), bool withUpdateSelection = true, }) async { - if (!editable) { + if (!editable && (!vimMode && mode == VimModes.normalMode)) { return; } From 993740cf822fd2016722e851605005c528ebea29 Mon Sep 17 00:00:00 2001 From: Sean Riley Hawkins Date: Fri, 3 Nov 2023 15:25:59 +0200 Subject: [PATCH 16/49] fix: add extra conditions to ensure modes don't conclict with each other --- lib/src/editor_state.dart | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/src/editor_state.dart b/lib/src/editor_state.dart index 61bd92b92..4c1ea8692 100644 --- a/lib/src/editor_state.dart +++ b/lib/src/editor_state.dart @@ -274,7 +274,9 @@ class EditorState { ApplyOptions options = const ApplyOptions(recordUndo: true), bool withUpdateSelection = true, }) async { - if (!editable && (!vimMode && mode == VimModes.normalMode)) { + if (!editable && !vimMode && mode == VimModes.normalMode) { + return; + } else if (!editable && vimMode) { return; } From c6ae21aee64d64fcb5718c43d4e2a24c657d7106 Mon Sep 17 00:00:00 2001 From: Sean Riley Hawkins Date: Fri, 10 Nov 2023 14:54:52 +0200 Subject: [PATCH 17/49] chore: add more checks for vim mode, enable vim mode by default --- example/lib/pages/desktop_editor.dart | 1 + .../shortcuts/command_shortcut_events/vim.dart | 16 ++++++++++++++-- lib/src/editor_state.dart | 2 ++ 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/example/lib/pages/desktop_editor.dart b/example/lib/pages/desktop_editor.dart index 359b8d98b..a00011d2c 100644 --- a/example/lib/pages/desktop_editor.dart +++ b/example/lib/pages/desktop_editor.dart @@ -79,6 +79,7 @@ class _DesktopEditorState extends State { child: Directionality( textDirection: widget.textDirection, child: AppFlowyEditor( + vimMode: true, editorState: editorState, editorScrollController: editorScrollController, blockComponentBuilders: blockComponentBuilders, diff --git a/lib/src/editor/editor_component/service/shortcuts/command_shortcut_events/vim.dart b/lib/src/editor/editor_component/service/shortcuts/command_shortcut_events/vim.dart index 1518f816a..2adf8908f 100644 --- a/lib/src/editor/editor_component/service/shortcuts/command_shortcut_events/vim.dart +++ b/lib/src/editor/editor_component/service/shortcuts/command_shortcut_events/vim.dart @@ -54,6 +54,9 @@ final CommandShortcutEvent insertOnNewLineCommand = CommandShortcutEvent( ); CommandShortcutEventHandler _insertOnNewLineCommandHandler = (editorState) { + if (!editorState.vimMode) { + return KeyEventResult.ignored; + } final afKeyboard = editorState.service.keyboardServiceKey; if (afKeyboard.currentState != null && @@ -81,6 +84,9 @@ final CommandShortcutEvent insertInlineCommand = CommandShortcutEvent( ); CommandShortcutEventHandler _insertInlineCommandHandler = (editorState) { + if (!editorState.vimMode) { + return KeyEventResult.ignored; + } final afKeyboard = editorState.service.keyboardServiceKey; if (afKeyboard.currentState != null && afKeyboard.currentState is AppFlowyKeyboardService) { @@ -106,6 +112,9 @@ final CommandShortcutEvent insertNextInlineCommand = CommandShortcutEvent( ); CommandShortcutEventHandler _insertNextInlineCommandHandler = (editorState) { + if (!editorState.vimMode) { + return KeyEventResult.ignored; + } final afKeyboard = editorState.service.keyboardServiceKey; if (afKeyboard.currentState != null && afKeyboard.currentState is AppFlowyKeyboardService) { @@ -299,10 +308,13 @@ CommandShortcutEventHandler _vimUndoCommandHandler = (editorState) { final afKeyboard = editorState.service.keyboardServiceKey; if (afKeyboard.currentState != null && afKeyboard.currentState is AppFlowyKeyboardService) { - if (editorState.selection != null && - editorState.mode == VimModes.normalMode) { + if (editorState.mode == VimModes.normalMode) { //BUG: undo doesnt work in Normal mode //NOTE: Could be something to do with selection + /* + The cursor does update but also disappears so makes it hard + for the block cursor to follow & track the current position + */ editorState.undoManager.undo(); return KeyEventResult.handled; } else { diff --git a/lib/src/editor_state.dart b/lib/src/editor_state.dart index 4c1ea8692..b00613448 100644 --- a/lib/src/editor_state.dart +++ b/lib/src/editor_state.dart @@ -277,6 +277,8 @@ class EditorState { if (!editable && !vimMode && mode == VimModes.normalMode) { return; } else if (!editable && vimMode) { + //NOTE: This statement blocks editor transactions + //So to apply transactions just remove it return; } From 881d6f0b5923a2001859fa28d9971ddd4f3371eb Mon Sep 17 00:00:00 2001 From: Sean Riley Hawkins Date: Wed, 29 Nov 2023 14:55:27 +0200 Subject: [PATCH 18/49] doc: add some comments for reference --- lib/src/editor_state.dart | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/lib/src/editor_state.dart b/lib/src/editor_state.dart index b00613448..843ad6977 100644 --- a/lib/src/editor_state.dart +++ b/lib/src/editor_state.dart @@ -277,9 +277,10 @@ class EditorState { if (!editable && !vimMode && mode == VimModes.normalMode) { return; } else if (!editable && vimMode) { + print(transaction.operations.toList()); //NOTE: This statement blocks editor transactions //So to apply transactions just remove it - return; + //return; } // it's a time consuming task, only enable it if necessary. @@ -294,7 +295,7 @@ class EditorState { for (final operation in transaction.operations) { Log.editor.debug('apply op: ${operation.toJson()}'); - _applyOperation(operation); + _applyOperation(operation, editable, vimMode, mode); } // broadcast to other users here, after applying the transaction @@ -532,13 +533,18 @@ class EditorState { }); } - void _applyOperation(Operation op) { + void _applyOperation(Operation op, var edit, var mode, var vimMode) { if (op is InsertOperation) { document.insert(op.path, op.nodes); } else if (op is UpdateOperation) { - // ignore the update operation if the attributes are the same. - if (!mapEquals(op.attributes, op.oldAttributes)) { - document.update(op.path, op.attributes); + //NOTE: This does help block letter but blocks other operations like delete + if (!edit && mode && vimMode == VimModes.normalMode) { + return; + } // ignore the update operation if the attributes are the same. + if (edit) { + if (!mapEquals(op.attributes, op.oldAttributes)) { + document.update(op.path, op.attributes); + } } } else if (op is DeleteOperation) { document.delete(op.path, op.nodes.length); From e117aa92ad48c8a09f1abb519cdaaade3d4302f1 Mon Sep 17 00:00:00 2001 From: Sean Riley Hawkins Date: Fri, 1 Dec 2023 12:21:22 +0200 Subject: [PATCH 19/49] refactor: update file import path --- lib/src/editor/block_component/standard_block_components.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/src/editor/block_component/standard_block_components.dart b/lib/src/editor/block_component/standard_block_components.dart index a01ffde48..85ef2ae27 100644 --- a/lib/src/editor/block_component/standard_block_components.dart +++ b/lib/src/editor/block_component/standard_block_components.dart @@ -1,5 +1,5 @@ import 'package:appflowy_editor/appflowy_editor.dart'; -import 'package:appflowy_editor/src/editor/editor_component/service/shortcuts/command_shortcut_events/vim.dart'; +import 'package:appflowy_editor/src/editor/editor_component/service/shortcuts/command/vim.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; From e623fda55ee47a8aa2b091a800f3967b412250a9 Mon Sep 17 00:00:00 2001 From: Sean Riley Hawkins Date: Sun, 14 Jan 2024 12:04:56 +0200 Subject: [PATCH 20/49] fix: disable other keys affecting Vim Keys. - Ctrl+b for bold is a shortcut in vim to scroll a page for example --- .../shortcuts/command/markdown_commands.dart | 16 ++++++---- .../service/shortcuts/command/vim.dart | 31 +++++++++++++------ .../shortcuts/command_shortcut_event.dart | 4 +++ 3 files changed, 35 insertions(+), 16 deletions(-) diff --git a/lib/src/editor/editor_component/service/shortcuts/command/markdown_commands.dart b/lib/src/editor/editor_component/service/shortcuts/command/markdown_commands.dart index 336a023a8..e5f4b7b5a 100644 --- a/lib/src/editor/editor_component/service/shortcuts/command/markdown_commands.dart +++ b/lib/src/editor/editor_component/service/shortcuts/command/markdown_commands.dart @@ -74,12 +74,16 @@ KeyEventResult _toggleAttribute( EditorState editorState, String key, ) { - final selection = editorState.selection; - if (selection == null) { - return KeyEventResult.ignored; - } + //NOTE: This is a fix for vim mode. Some keys are conflicting + if (editorState.mode == VimModes.insertMode || editorState.vimMode == false) { + final selection = editorState.selection; + if (selection == null) { + return KeyEventResult.ignored; + } - editorState.toggleAttribute(key); + editorState.toggleAttribute(key); - return KeyEventResult.handled; + return KeyEventResult.handled; + } + return KeyEventResult.ignored; } diff --git a/lib/src/editor/editor_component/service/shortcuts/command/vim.dart b/lib/src/editor/editor_component/service/shortcuts/command/vim.dart index 2adf8908f..2f9d36837 100644 --- a/lib/src/editor/editor_component/service/shortcuts/command/vim.dart +++ b/lib/src/editor/editor_component/service/shortcuts/command/vim.dart @@ -19,16 +19,24 @@ final List vimKeyModes = [ // vimJumpToLineCommand, ///Page Movements - //BUG: Conflicts with ctrl+b key - // vimPageUpCommand, + //NOTE: Conflicts with ctrl+b key + vimPageUpCommand, vimHalfPageDownCommand, vimPageDownCommand, - //BUG: Conflicts with ctrl+u key - // vimHalfPageUpCommand, + //BUG: Not working for some reason + //vimHalfPageUpCommand, ///Undo Commands //BUG: These commands won't work not sure why but //The undoManager doesnt work in normal mode + /* + There is an issue with transactions. + When the editor is in Normal mode, transactions are closed. What ever happens + then + well wont work. Problem is undo & redo needs that transaction window open. + Unless something is implemented to ignore all other keys unless they match + a VIM key? + */ // vimUndoCommand, // vimRedoCommand, @@ -38,7 +46,7 @@ final List vimKeyModes = [ jumpWordBackwardCommand, jumpWordForwardCommand, //BUG: Selection doesnt show up to user - // vimSelectLineCommand, + vimSelectLineCommand, ///Text operations //BUG: Deleting at the end of text will cause the widget tree to panic @@ -261,14 +269,17 @@ final CommandShortcutEvent vimSelectLineCommand = CommandShortcutEvent( command: 'shift+v', handler: _vimSelectLineCommandHandler, ); - +/* +What we probably need is to follow the `select all command` +Except that we want to get the cursor position or line. +When we get line/position then select the whole line. +*/ CommandShortcutEventHandler _vimSelectLineCommandHandler = (editorState) { final afKeyboard = editorState.service.keyboardServiceKey; if (afKeyboard.currentState != null && afKeyboard.currentState is AppFlowyKeyboardService) { if (editorState.selection == null || editorState.prevSelection != null) { - //NOTE: Call editable first before changing mode - editorState.selection = editorState.selection; + //BUG: Throwing issue on PropertyValueNotifier final selection = editorState.selection; editorState.selectionService.updateSelection(selection); editorState.prevSelection = null; @@ -422,7 +433,7 @@ CommandShortcutEventHandler _vimHalfPageDownCommandHandler = (editorState) { } return KeyEventResult.ignored; }; -//NOTE: Bug page up event not triggered could be conflicting with other keys + final CommandShortcutEvent vimPageUpCommand = CommandShortcutEvent( key: 'scroll one page up in normal mode', command: 'ctrl+b', @@ -565,7 +576,7 @@ CommandShortcutEventHandler _vimMoveCursorToEndHandler = (editorState) { }; final CommandShortcutEvent jumpWordBackwardCommand = CommandShortcutEvent( - key: 'move the cursor backward to the next wordl in normal mode', + key: 'move the cursor backward to the next word in normal mode', command: 'b', handler: _jumpWordBackwardCommandHandler, ); diff --git a/lib/src/editor/editor_component/service/shortcuts/command_shortcut_event.dart b/lib/src/editor/editor_component/service/shortcuts/command_shortcut_event.dart index 0e378a967..af4a4f6fd 100644 --- a/lib/src/editor/editor_component/service/shortcuts/command_shortcut_event.dart +++ b/lib/src/editor/editor_component/service/shortcuts/command_shortcut_event.dart @@ -83,6 +83,10 @@ class CommandShortcutEvent { this.command = command; matched = true; } + /*NOTE: This is the main thing to look at for multiple key binds + Though the problem is it has to listen out for anything. + So defining the keys before hand well might just not work + */ if (matched) { _keybindings = this From 8c71f99e5a020cd14dbeb072d88bafbe0a63d45d Mon Sep 17 00:00:00 2001 From: Sean Riley Hawkins Date: Sat, 16 Nov 2024 17:40:38 +0200 Subject: [PATCH 21/49] chore: resolve conflicts with remote. - Update keyboard shortcuts to work with current changes. --- .../selection/block_selection_area.dart | 2 +- .../service/shortcuts/command/vim.dart | 21 ++++ lib/src/editor_state.dart | 5 +- lib/src/l10n/l10n.dart | 111 ++++++++++++++++++ 4 files changed, 136 insertions(+), 3 deletions(-) diff --git a/lib/src/editor/block_component/base_component/selection/block_selection_area.dart b/lib/src/editor/block_component/base_component/selection/block_selection_area.dart index e590826ad..114db53d6 100644 --- a/lib/src/editor/block_component/base_component/selection/block_selection_area.dart +++ b/lib/src/editor/block_component/base_component/selection/block_selection_area.dart @@ -103,7 +103,7 @@ class _BlockSelectionAreaState extends State { return sizedBox; } - final editorState = context.read(); + //final editorState = context.read(); if (editorState.selectionType == SelectionType.block) { if (!widget.supportTypes.contains(BlockSelectionType.block) || !path.inSelection(selection, isSameDepth: true) || diff --git a/lib/src/editor/editor_component/service/shortcuts/command/vim.dart b/lib/src/editor/editor_component/service/shortcuts/command/vim.dart index 2f9d36837..1cdb69ae5 100644 --- a/lib/src/editor/editor_component/service/shortcuts/command/vim.dart +++ b/lib/src/editor/editor_component/service/shortcuts/command/vim.dart @@ -1,5 +1,6 @@ import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter/material.dart'; +import 'package:appflowy_editor/src/editor/util/platform_extension.dart'; import 'dart:math'; final List vimKeyModes = [ @@ -59,6 +60,7 @@ final CommandShortcutEvent insertOnNewLineCommand = CommandShortcutEvent( key: 'insert new line below previous selection', command: 'o', handler: _insertOnNewLineCommandHandler, + getDescription: () => AppFlowyEditorL10n.current.cmdInsertBelow, ); CommandShortcutEventHandler _insertOnNewLineCommandHandler = (editorState) { @@ -89,6 +91,7 @@ final CommandShortcutEvent insertInlineCommand = CommandShortcutEvent( key: 'enter insert mode from previous selection', command: 'i', handler: _insertInlineCommandHandler, + getDescription: () => AppFlowyEditorL10n.current.cmdInsertCurrentPos, ); CommandShortcutEventHandler _insertInlineCommandHandler = (editorState) { @@ -117,6 +120,7 @@ final CommandShortcutEvent insertNextInlineCommand = CommandShortcutEvent( key: 'enter insert mode on next character', command: 'a', handler: _insertNextInlineCommandHandler, + getDescription: () => AppFlowyEditorL10n.current.cmdInsertNextPos, ); CommandShortcutEventHandler _insertNextInlineCommandHandler = (editorState) { @@ -147,6 +151,7 @@ final CommandShortcutEvent jumpDownCommand = CommandShortcutEvent( key: 'move the cursor downward in normal mode', command: 'j', handler: _jumpDownCommandHandler, + getDescription: () => AppFlowyEditorL10n.current.cmdJumpDown, ); CommandShortcutEventHandler _jumpDownCommandHandler = (editorState) { @@ -176,6 +181,7 @@ final CommandShortcutEvent jumpUpCommand = CommandShortcutEvent( key: 'move the cursor upward in normal mode', command: 'k', handler: _jumpUpCommandHandler, + getDescription: () => AppFlowyEditorL10n.current.cmdJumpUp, ); CommandShortcutEventHandler _jumpUpCommandHandler = (editorState) { @@ -207,6 +213,7 @@ final CommandShortcutEvent jumpLeftCommand = CommandShortcutEvent( key: 'move the cursor to the left in normal mode', command: 'h', handler: _jumpLeftCommandHandler, + getDescription: () => AppFlowyEditorL10n.current.cmdJumpLeft, ); CommandShortcutEventHandler _jumpLeftCommandHandler = (editorState) { @@ -237,6 +244,7 @@ final CommandShortcutEvent jumpRightCommand = CommandShortcutEvent( key: 'move the cursor to the right in normal mode', command: 'l', handler: _jumpRightCommandHandler, + getDescription: () => AppFlowyEditorL10n.current.cmdJumpRight, ); CommandShortcutEventHandler _jumpRightCommandHandler = (editorState) { @@ -268,6 +276,7 @@ final CommandShortcutEvent vimSelectLineCommand = CommandShortcutEvent( key: 'enter insert mode from previous selection', command: 'shift+v', handler: _vimSelectLineCommandHandler, + getDescription: () => AppFlowyEditorL10n.current.cmdLineSelect, ); /* What we probably need is to follow the `select all command` @@ -310,6 +319,7 @@ final CommandShortcutEvent vimUndoCommand = CommandShortcutEvent( key: 'vim undo in normal mode', command: 'u', handler: _vimUndoCommandHandler, + getDescription: () => AppFlowyEditorL10n.current.cmdVimUndo, ); CommandShortcutEventHandler _vimUndoCommandHandler = (editorState) { @@ -339,6 +349,7 @@ final CommandShortcutEvent vimRedoCommand = CommandShortcutEvent( key: 'vim redo in normal mode', command: 'ctrl+r', handler: _vimRedoCommandHandler, + getDescription: () => AppFlowyEditorL10n.current.cmdVimRedo, ); CommandShortcutEventHandler _vimRedoCommandHandler = (editorState) { @@ -364,6 +375,7 @@ final CommandShortcutEvent vimPageDownCommand = CommandShortcutEvent( key: 'scroll one page down in normal mode', command: 'ctrl+f', handler: _vimPageDownCommandHandler, + getDescription: () => AppFlowyEditorL10n.current.cmdVimJumpPageDown, ); CommandShortcutEventHandler _vimPageDownCommandHandler = (editorState) { @@ -401,6 +413,7 @@ final CommandShortcutEvent vimHalfPageDownCommand = CommandShortcutEvent( key: 'scroll half page down in normal mode', command: 'ctrl+d', handler: _vimHalfPageDownCommandHandler, + getDescription: () => AppFlowyEditorL10n.current.cmdVimJumpHalfPageDown, ); CommandShortcutEventHandler _vimHalfPageDownCommandHandler = (editorState) { @@ -438,6 +451,7 @@ final CommandShortcutEvent vimPageUpCommand = CommandShortcutEvent( key: 'scroll one page up in normal mode', command: 'ctrl+b', handler: _vimPageUpCommandHandler, + getDescription: () => AppFlowyEditorL10n.current.cmdVimJumpPageUp, ); CommandShortcutEventHandler _vimPageUpCommandHandler = (editorState) { @@ -480,6 +494,7 @@ final CommandShortcutEvent vimHalfPageUpCommand = CommandShortcutEvent( key: 'scroll one page up in normal mode', command: 'ctrl+u', handler: _vimHalfPageUpCommandHandler, + getDescription: () => AppFlowyEditorL10n.current.cmdVimJumpPageUp, ); CommandShortcutEventHandler _vimHalfPageUpCommandHandler = (editorState) { @@ -522,6 +537,7 @@ final CommandShortcutEvent vimMoveCursorToStartCommand = CommandShortcutEvent( key: 'vim move cursor to start of line in normal mode', command: 'Digit 0', handler: _vimMoveCursorToStartHandler, + getDescription: () => AppFlowyEditorL10n.current.cmdVimJumpFirstChar, ); CommandShortcutEventHandler _vimMoveCursorToStartHandler = (editorState) { @@ -551,6 +567,7 @@ final CommandShortcutEvent vimMoveCursorToEndCommand = CommandShortcutEvent( //NOTE: Used Digit 4, dollar sign would throw error command: 'shift+Digit 4', handler: _vimMoveCursorToEndHandler, + getDescription: () => AppFlowyEditorL10n.current.cmdVimJumpEndChar, ); CommandShortcutEventHandler _vimMoveCursorToEndHandler = (editorState) { @@ -579,6 +596,7 @@ final CommandShortcutEvent jumpWordBackwardCommand = CommandShortcutEvent( key: 'move the cursor backward to the next word in normal mode', command: 'b', handler: _jumpWordBackwardCommandHandler, + getDescription: () => AppFlowyEditorL10n.current.cmdVimBackWordJump, ); CommandShortcutEventHandler _jumpWordBackwardCommandHandler = (editorState) { @@ -645,6 +663,7 @@ final CommandShortcutEvent jumpWordForwardCommand = CommandShortcutEvent( key: 'move the cursor backward to the next wordl in normal mode', command: 'w', handler: _jumpWordForwardCommandHandler, + getDescription: () => AppFlowyEditorL10n.current.cmdVimForwardWordJump, ); CommandShortcutEventHandler _jumpWordForwardCommandHandler = (editorState) { @@ -729,6 +748,7 @@ final CommandShortcutEvent vimJumpToLineCommand = CommandShortcutEvent( // command: 'Digit 5', command: 'Digit 5' 'j', handler: _vimJumpToLineHandler, + getDescription: () => AppFlowyEditorL10n.current.cmdVimNumJumper, ); CommandShortcutEventHandler _vimJumpToLineHandler = (editorState) { @@ -761,6 +781,7 @@ final CommandShortcutEvent vimDeleteUnderCursorCommand = CommandShortcutEvent( key: 'vim delete character under cursor in normal mode', command: 'd', handler: _vimDeleteUnderCursorHandler, + getDescription: () => AppFlowyEditorL10n.current.cmdVimDeleteCharCursor, ); CommandShortcutEventHandler _vimDeleteUnderCursorHandler = (editorState) { diff --git a/lib/src/editor_state.dart b/lib/src/editor_state.dart index 719f4a2c1..6e51b54f2 100644 --- a/lib/src/editor_state.dart +++ b/lib/src/editor_state.dart @@ -129,6 +129,7 @@ class EditorState { /// Whether Vim mode is enabled or not bool vimMode = false; + /// Remote selection is the selection from other users. final PropertyValueNotifier> remoteSelections = PropertyValueNotifier>([]); @@ -151,7 +152,7 @@ class EditorState { prevSelectionNotifier.value = value; } - SelectionType? selectionType; + //SelectionType? selectionType; SelectionType? _selectionType; set selectionType(SelectionType? value) { if (value == _selectionType) { @@ -622,7 +623,7 @@ class EditorState { void _applyTransactionInLocal(Transaction transaction) { for (final op in transaction.operations) { AppFlowyEditorLog.editor.debug('apply op (local): ${op.toJson()}'); - if(!edit && mode && vimMode == VimModes.normalMode){ + if (!editable && vimMode && mode == VimModes.normalMode) { return; } diff --git a/lib/src/l10n/l10n.dart b/lib/src/l10n/l10n.dart index 637f957ba..3e84bc978 100644 --- a/lib/src/l10n/l10n.dart +++ b/lib/src/l10n/l10n.dart @@ -1840,6 +1840,117 @@ class AppFlowyEditorLocalizations { args: [], ); } + + /// `vim insert below` + String get cmdInsertBelow { + return Intl.message('insert below', + name: 'cmdInsertBelow', desc: '', args: []); + } + + /// `vim insert at current position` + String get cmdInsertCurrentPos { + return Intl.message('insert at current position', + name: 'cmdInsertCurrentPos', desc: '', args: []); + } + + /// `vim insert after current position` + String get cmdInsertNextPos { + return Intl.message('insert at next position', + name: 'cmdInsertNextPos', desc: '', args: []); + } + + /// `vim jump down` + String get cmdJumpDown { + return Intl.message('navigate down', + name: 'cmdJumpDown', desc: '', args: []); + } + + /// `vim jump up` + String get cmdJumpUp { + return Intl.message('navigate up', name: 'cmdJumpUp', desc: '', args: []); + } + + /// `vim jump left` + String get cmdJumpLeft { + return Intl.message('navigate left', + name: 'cmdJumpLeft', desc: '', args: []); + } + + /// `vim jump right` + String get cmdJumpRight { + return Intl.message('navigate right', + name: 'cmdJumpRight', desc: '', args: []); + } + + /// `vim line select` + String get cmdLineSelect { + return Intl.message('line select', + name: 'cmdLineSelect', desc: '', args: []); + } + + /// `vim undo command` + String get cmdVimUndo { + return Intl.message('undo', name: 'cmdVimUndo', desc: '', args: []); + } + + /// `vim redo command` + String get cmdVimRedo { + return Intl.message('redo', name: 'cmdVimRedo', desc: '', args: []); + } + + /// `vim jump down one page command` + String get cmdVimJumpPageDown { + return Intl.message('jump one page down', + name: 'cmdVimJumpPageDown', desc: '', args: []); + } + + /// `vim jump down half page command` + String get cmdVimJumpHalfPageDown { + return Intl.message('jump half a page down', + name: 'cmdVimJumpHalfPageDown', desc: '', args: []); + } + + /// `vim jump up page command` + String get cmdVimJumpPageUp { + return Intl.message('jump a page up', + name: 'cmdVimJumpPageUp', desc: '', args: []); + } + + /// `vim jump to start of line command` + String get cmdVimJumpFirstChar { + return Intl.message('jump to beginning of the line', + name: 'cmdVimJumpFirstChar', desc: '', args: []); + } + + /// `vim jump to end of line command` + String get cmdVimJumpEndChar { + return Intl.message('jump to end of the line', + name: 'cmdVimJumpEndChar', desc: '', args: []); + } + + /// `vim move left by word command` + String get cmdVimBackWordJump { + return Intl.message('jump to start of each word but backwards', + name: 'cmdVimBackWordJump', desc: '', args: []); + } + + /// `vim move right by word command` + String get cmdVimForwardWordJump { + return Intl.message('jump to start of each word but forwards', + name: 'cmdVimForwardWordJump', desc: '', args: []); + } + + /// `vim delete character under cursor command` + String get cmdVimDeleteCharCursor { + return Intl.message('delete character under cursor', + name: 'cmdVimDeleteCharCursor', desc: '', args: []); + } + + /// `vim jump around number command` + String get cmdVimNumJumper { + return Intl.message('jump around with numbers', + name: 'cmdVimNumJumper', desc: '', args: []); + } } class AppLocalizationDelegate From 199600f6556d48890d8b6acb4fa8c21e8f8d6a39 Mon Sep 17 00:00:00 2001 From: Sean Riley Hawkins Date: Wed, 20 Nov 2024 22:56:23 +0200 Subject: [PATCH 22/49] chore: update `h`, `l` keys to follow arrow keys --- .../service/shortcuts/command/vim.dart | 25 ++++++++----------- 1 file changed, 10 insertions(+), 15 deletions(-) diff --git a/lib/src/editor/editor_component/service/shortcuts/command/vim.dart b/lib/src/editor/editor_component/service/shortcuts/command/vim.dart index 1cdb69ae5..5f12725b0 100644 --- a/lib/src/editor/editor_component/service/shortcuts/command/vim.dart +++ b/lib/src/editor/editor_component/service/shortcuts/command/vim.dart @@ -225,13 +225,11 @@ CommandShortcutEventHandler _jumpLeftCommandHandler = (editorState) { afKeyboard.currentState is AppFlowyKeyboardService) { if (editorState.selection != null && editorState.mode == VimModes.normalMode) { - final selection = editorState.selection; - final leftPosition = - selection?.end.moveHorizontal(editorState, forward: true); - editorState.updateSelectionWithReason( - leftPosition == null ? null : Selection.collapsed(leftPosition), - reason: SelectionUpdateReason.uiEvent, - ); + if (isRTL(editorState)) { + editorState.moveCursorBackward(SelectionMoveRange.character); + } else { + editorState.moveCursorForward(SelectionMoveRange.character); + } return KeyEventResult.handled; } else { return KeyEventResult.ignored; @@ -256,13 +254,11 @@ CommandShortcutEventHandler _jumpRightCommandHandler = (editorState) { afKeyboard.currentState is AppFlowyKeyboardService) { if (editorState.selection != null && editorState.mode == VimModes.normalMode) { - final selection = editorState.selection; - final rightPosition = - selection?.end.moveHorizontal(editorState, forward: false); - editorState.updateSelectionWithReason( - rightPosition == null ? null : Selection.collapsed(rightPosition), - reason: SelectionUpdateReason.uiEvent, - ); + if (isRTL(editorState)) { + editorState.moveCursorBackward(SelectionMoveRange.character); + } else { + editorState.moveCursorForward(SelectionMoveRange.character); + } return KeyEventResult.handled; } else { return KeyEventResult.ignored; @@ -292,7 +288,6 @@ CommandShortcutEventHandler _vimSelectLineCommandHandler = (editorState) { final selection = editorState.selection; editorState.selectionService.updateSelection(selection); editorState.prevSelection = null; - final nodes = editorState.getNodesInSelection(selection!); if (nodes.isEmpty) { return KeyEventResult.ignored; From 130e5fb387d2ffbef87e711145f0346888d14bba Mon Sep 17 00:00:00 2001 From: Sean Riley Hawkins Date: Tue, 11 Mar 2025 09:44:49 +0200 Subject: [PATCH 23/49] feat: use vimFSM to catch key shortcuts - The idea is to bind catch key events within the shortcuts layer --- .../service/keyboard_service_widget.dart | 11 +++ .../service/shortcut_events.dart | 1 + .../service/shortcuts/command/vim.dart | 8 +- .../service/shortcuts/vim/vim_fsm.dart | 80 +++++++++++++++++++ .../service/shortcuts/vim_shortcut_event.dart | 25 ++++++ .../extensions/vim_shortcut_extensions.dart | 29 +++++++ 6 files changed, 150 insertions(+), 4 deletions(-) create mode 100644 lib/src/editor/editor_component/service/shortcuts/vim/vim_fsm.dart create mode 100644 lib/src/editor/editor_component/service/shortcuts/vim_shortcut_event.dart create mode 100644 lib/src/extensions/vim_shortcut_extensions.dart diff --git a/lib/src/editor/editor_component/service/keyboard_service_widget.dart b/lib/src/editor/editor_component/service/keyboard_service_widget.dart index c36c34330..a93c5562b 100644 --- a/lib/src/editor/editor_component/service/keyboard_service_widget.dart +++ b/lib/src/editor/editor_component/service/keyboard_service_widget.dart @@ -1,5 +1,6 @@ import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor/src/editor/editor_component/service/ime/delta_input_on_floating_cursor_update.dart'; +import 'package:appflowy_editor/src/editor/editor_component/service/shortcuts/vim_shortcut_event.dart'; import 'package:appflowy_editor/src/editor/util/platform_extension.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -168,6 +169,16 @@ class KeyboardServiceWidgetState extends State return KeyEventResult.ignored; } + if (editorState.vimMode) { + print('vim mode enabled!'); + final VimCommandShortcutEvent vimCommandShortcutEvent = + VimCommandShortcutEvent(); + final vimResult = vimCommandShortcutEvent.handleKey(event, editorState); + if (vimResult == KeyEventResult.handled) { + return KeyEventResult.handled; + } + } + if ((event is! KeyDownEvent && event is! KeyRepeatEvent) || !enableIMEShortcuts) { if (textInputService.composingTextRange != TextRange.empty) { diff --git a/lib/src/editor/editor_component/service/shortcut_events.dart b/lib/src/editor/editor_component/service/shortcut_events.dart index c4d125a39..dde3ccfdc 100644 --- a/lib/src/editor/editor_component/service/shortcut_events.dart +++ b/lib/src/editor/editor_component/service/shortcut_events.dart @@ -2,3 +2,4 @@ export 'shortcuts/character/character_shortcut_events.dart'; export 'shortcuts/character_shortcut_event.dart'; export 'shortcuts/command/command_shortcut_events.dart'; export 'shortcuts/command_shortcut_event.dart'; +export 'shortcuts/vim_shortcut_event.dart'; diff --git a/lib/src/editor/editor_component/service/shortcuts/command/vim.dart b/lib/src/editor/editor_component/service/shortcuts/command/vim.dart index 5f12725b0..3d4a92335 100644 --- a/lib/src/editor/editor_component/service/shortcuts/command/vim.dart +++ b/lib/src/editor/editor_component/service/shortcuts/command/vim.dart @@ -10,10 +10,12 @@ final List vimKeyModes = [ insertNextInlineCommand, ///Vim Movements + /* jumpUpCommand, jumpDownCommand, jumpLeftCommand, jumpRightCommand, +*/ ///Vim Jump to line //BUG: Won't work properly keyboard shortcut fails @@ -146,6 +148,7 @@ CommandShortcutEventHandler _insertNextInlineCommandHandler = (editorState) { return KeyEventResult.ignored; }; +/* /// Motion Keys final CommandShortcutEvent jumpDownCommand = CommandShortcutEvent( key: 'move the cursor downward in normal mode', @@ -266,6 +269,7 @@ CommandShortcutEventHandler _jumpRightCommandHandler = (editorState) { } return KeyEventResult.ignored; }; +*/ //BUG: Selection does not show up in normal mode final CommandShortcutEvent vimSelectLineCommand = CommandShortcutEvent( @@ -724,10 +728,6 @@ CommandShortcutEventHandler _jumpWordForwardCommandHandler = (editorState) { }; final numList = List.generate(10, (i) => i); -final movements = [ - jumpDownCommand, - jumpUpCommand, -]; /* * The idea for this is to at least use * a list for the numbers & key movements. diff --git a/lib/src/editor/editor_component/service/shortcuts/vim/vim_fsm.dart b/lib/src/editor/editor_component/service/shortcuts/vim/vim_fsm.dart new file mode 100644 index 000000000..49bb96570 --- /dev/null +++ b/lib/src/editor/editor_component/service/shortcuts/vim/vim_fsm.dart @@ -0,0 +1,80 @@ +import 'package:appflowy_editor/src/extensions/vim_shortcut_extensions.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; + +const baseKeys = ['h', 'j', 'k', 'l']; + +class VimFSM { + String _buffer = ''; + + KeyEventResult processKey(KeyEvent event, EditorState editorState) { + if (event is! KeyDownEvent) { + return KeyEventResult.ignored; + } + + final key = event.logicalKey.keyLabel.toLowerCase(); + + if (RegExp(r'^\d$').hasMatch(key)) { + _buffer += key; + return KeyEventResult.handled; + } + + Position? newPosition; + if (baseKeys.contains(key)) { + final count = _buffer.isNotEmpty ? int.parse(_buffer) : 1; + _buffer = ''; + final selection = editorState.selection; + if (selection == null) return KeyEventResult.ignored; + + switch (key) { + case 'j': + newPosition = moveVerticalMultiple( + editorState, + selection.end, + upwards: false, + count: count, + ); + break; + + case 'k': + newPosition = moveVerticalMultiple( + editorState, + selection.end, + upwards: true, + count: count, + ); + break; + case 'h': + newPosition = moveHorizontalMultiple( + editorState, + selection.end, + forward: false, + count: count, + ); + break; + case 'l': + newPosition = moveHorizontalMultiple( + editorState, + selection.end, + forward: true, + count: count, + ); + break; + } + if (newPosition != null) { + editorState.updateSelectionWithReason( + Selection.collapsed(newPosition), + reason: SelectionUpdateReason.uiEvent, + ); + return KeyEventResult.handled; + } + } + _buffer = ''; + return KeyEventResult.ignored; + } + + void reset() { + _buffer = ''; + } +} diff --git a/lib/src/editor/editor_component/service/shortcuts/vim_shortcut_event.dart b/lib/src/editor/editor_component/service/shortcuts/vim_shortcut_event.dart new file mode 100644 index 000000000..7901061c5 --- /dev/null +++ b/lib/src/editor/editor_component/service/shortcuts/vim_shortcut_event.dart @@ -0,0 +1,25 @@ +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_editor/src/editor/editor_component/service/shortcuts/command_shortcut_event.dart'; +import 'package:appflowy_editor/src/editor/editor_component/service/shortcuts/vim/vim_fsm.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +class VimCommandShortcutEvent extends CommandShortcutEvent { + final VimFSM vimFSM = VimFSM(); + VimCommandShortcutEvent() + : super( + key: 'Vim FSM Handler', + command: '', + handler: _dummyHandler, + getDescription: () => 'Handles multi-key vim commands using an FSM', + ); + KeyEventResult handleKey(KeyEvent event, EditorState editorState) { + print(event); + return vimFSM.processKey(event, editorState); + } + + static KeyEventResult _dummyHandler(EditorState editorState) { + return KeyEventResult.ignored; + } +} + diff --git a/lib/src/extensions/vim_shortcut_extensions.dart b/lib/src/extensions/vim_shortcut_extensions.dart new file mode 100644 index 000000000..ed3fa2635 --- /dev/null +++ b/lib/src/extensions/vim_shortcut_extensions.dart @@ -0,0 +1,29 @@ +import 'package:appflowy_editor/appflowy_editor.dart'; + +Position? moveVerticalMultiple( + EditorState editorState, + Position startPosition, { + required bool upwards, + required int count, +}) { + Position? current = startPosition; + for (int i = 0; i < count; i++) { + current = current?.moveVertical(editorState, upwards: upwards); + if (current == null) break; + } + return current; +} + +Position? moveHorizontalMultiple( + EditorState editorState, + Position startPosition, { + required bool forward, + required int count, +}) { + Position? current = startPosition; + for (int i = 0; i < count; i++) { + current = current?.moveHorizontal(editorState, forward: forward); + if (current == null) break; + } + return current; +} From 5e3d42585d354693bf4daabe29e44b898a428ff1 Mon Sep 17 00:00:00 2001 From: Sean Riley Hawkins Date: Tue, 11 Mar 2025 11:13:54 +0200 Subject: [PATCH 24/49] feat: enable line jumping up & down the document --- .../service/keyboard_service_widget.dart | 13 +-- .../service/shortcuts/vim/vim_fsm.dart | 92 +++++++++++++------ .../service/shortcuts/vim_shortcut_event.dart | 1 - 3 files changed, 69 insertions(+), 37 deletions(-) diff --git a/lib/src/editor/editor_component/service/keyboard_service_widget.dart b/lib/src/editor/editor_component/service/keyboard_service_widget.dart index a93c5562b..98f6da0f1 100644 --- a/lib/src/editor/editor_component/service/keyboard_service_widget.dart +++ b/lib/src/editor/editor_component/service/keyboard_service_widget.dart @@ -170,12 +170,13 @@ class KeyboardServiceWidgetState extends State } if (editorState.vimMode) { - print('vim mode enabled!'); - final VimCommandShortcutEvent vimCommandShortcutEvent = - VimCommandShortcutEvent(); - final vimResult = vimCommandShortcutEvent.handleKey(event, editorState); - if (vimResult == KeyEventResult.handled) { - return KeyEventResult.handled; + if (editorState.mode == VimModes.normalMode) { + final VimCommandShortcutEvent vimCommandShortcutEvent = + VimCommandShortcutEvent(); + final vimResult = vimCommandShortcutEvent.handleKey(event, editorState); + if (vimResult == KeyEventResult.handled) { + return KeyEventResult.handled; + } } } diff --git a/lib/src/editor/editor_component/service/shortcuts/vim/vim_fsm.dart b/lib/src/editor/editor_component/service/shortcuts/vim/vim_fsm.dart index 49bb96570..883a0d21a 100644 --- a/lib/src/editor/editor_component/service/shortcuts/vim/vim_fsm.dart +++ b/lib/src/editor/editor_component/service/shortcuts/vim/vim_fsm.dart @@ -5,9 +5,9 @@ import 'package:appflowy_editor/appflowy_editor.dart'; const baseKeys = ['h', 'j', 'k', 'l']; -class VimFSM { - String _buffer = ''; +String _buffer = ''; +class VimFSM { KeyEventResult processKey(KeyEvent event, EditorState editorState) { if (event is! KeyDownEvent) { return KeyEventResult.ignored; @@ -26,43 +26,74 @@ class VimFSM { _buffer = ''; final selection = editorState.selection; if (selection == null) return KeyEventResult.ignored; + print('doc children'); + print(editorState.document.root); switch (key) { case 'j': - newPosition = moveVerticalMultiple( - editorState, - selection.end, - upwards: false, - count: count, - ); - break; + { + newPosition = moveVerticalMultiple( + editorState, + selection.end, + upwards: false, + count: count, + ); + int tmpPos = count + selection.end.path.first; + if (tmpPos < editorState.document.root.children.length) { + newPosition = Position(path: [tmpPos], offset: 0); + } + //newPosition = Position(path: [count+selection.end.path.first]); + break; + } case 'k': - newPosition = moveVerticalMultiple( - editorState, - selection.end, - upwards: true, - count: count, - ); - break; + { + newPosition = moveVerticalMultiple( + editorState, + selection.end, + upwards: true, + count: count, + ); + + int tmpPos = selection.end.path.first - count; + if (tmpPos < editorState.document.root.children.length) { + newPosition = Position(path: [tmpPos], offset: 0); + } + break; + } case 'h': - newPosition = moveHorizontalMultiple( - editorState, - selection.end, - forward: false, - count: count, - ); - break; + { + newPosition = moveHorizontalMultiple( + editorState, + selection.end, + forward: true, + count: count, + ); + + break; + } case 'l': - newPosition = moveHorizontalMultiple( - editorState, - selection.end, - forward: true, - count: count, - ); - break; + { + newPosition = moveHorizontalMultiple( + editorState, + selection.end, + forward: false, + count: count, + ); + break; + } } if (newPosition != null) { + print(selection); + print(newPosition); + print(count); + + //NOTE: This works + //Position tmp = Position(offset: 0, path: [3]); + //BUG:This does not work + print(newPosition.path.first); + Position tmp = + Position(offset: 0, path: [selection.end.path.first + count]); editorState.updateSelectionWithReason( Selection.collapsed(newPosition), reason: SelectionUpdateReason.uiEvent, @@ -71,6 +102,7 @@ class VimFSM { } } _buffer = ''; + return KeyEventResult.ignored; } diff --git a/lib/src/editor/editor_component/service/shortcuts/vim_shortcut_event.dart b/lib/src/editor/editor_component/service/shortcuts/vim_shortcut_event.dart index 7901061c5..0e9867143 100644 --- a/lib/src/editor/editor_component/service/shortcuts/vim_shortcut_event.dart +++ b/lib/src/editor/editor_component/service/shortcuts/vim_shortcut_event.dart @@ -14,7 +14,6 @@ class VimCommandShortcutEvent extends CommandShortcutEvent { getDescription: () => 'Handles multi-key vim commands using an FSM', ); KeyEventResult handleKey(KeyEvent event, EditorState editorState) { - print(event); return vimFSM.processKey(event, editorState); } From 3eab25d0e261a30216bd71ffa5ae5caf8527ea09 Mon Sep 17 00:00:00 2001 From: Sean Riley Hawkins Date: Tue, 11 Mar 2025 11:37:39 +0200 Subject: [PATCH 25/49] chore: add note comment to handle transactions --- .../service/shortcuts/vim/vim_fsm.dart | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/lib/src/editor/editor_component/service/shortcuts/vim/vim_fsm.dart b/lib/src/editor/editor_component/service/shortcuts/vim/vim_fsm.dart index 883a0d21a..7af7671f0 100644 --- a/lib/src/editor/editor_component/service/shortcuts/vim/vim_fsm.dart +++ b/lib/src/editor/editor_component/service/shortcuts/vim/vim_fsm.dart @@ -26,9 +26,16 @@ class VimFSM { _buffer = ''; final selection = editorState.selection; if (selection == null) return KeyEventResult.ignored; - print('doc children'); - print(editorState.document.root); + //NOTE: Figure a way out to perform transactions + //final transaction = editorState.transaction; + /* + transaction + .deleteNodesAtPath(editorState.prevSelection!.start.path); + editorState + .apply(transaction) + .then((value) => editorState.selectionType = null); + */ switch (key) { case 'j': { From 995e8f7e4c4023c5d6449e39bcf326f6c5f72cc8 Mon Sep 17 00:00:00 2001 From: Sean Riley Hawkins Date: Tue, 11 Mar 2025 21:54:34 +0200 Subject: [PATCH 26/49] feat: add 'dd' regex to delete whole line --- .../service/shortcuts/command/vim.dart | 2 ++ .../service/shortcuts/vim/vim_fsm.dart | 34 +++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/lib/src/editor/editor_component/service/shortcuts/command/vim.dart b/lib/src/editor/editor_component/service/shortcuts/command/vim.dart index 3d4a92335..3e83418cb 100644 --- a/lib/src/editor/editor_component/service/shortcuts/command/vim.dart +++ b/lib/src/editor/editor_component/service/shortcuts/command/vim.dart @@ -772,6 +772,7 @@ CommandShortcutEventHandler _vimJumpToLineHandler = (editorState) { }; //BUG: Transaction to delete word won't apply +/* final CommandShortcutEvent vimDeleteUnderCursorCommand = CommandShortcutEvent( key: 'vim delete character under cursor in normal mode', command: 'd', @@ -892,3 +893,4 @@ CommandShortcutEventHandler _deleteInBlockSelection = (editorState) { return KeyEventResult.handled; }; +*/ diff --git a/lib/src/editor/editor_component/service/shortcuts/vim/vim_fsm.dart b/lib/src/editor/editor_component/service/shortcuts/vim/vim_fsm.dart index 7af7671f0..370f78069 100644 --- a/lib/src/editor/editor_component/service/shortcuts/vim/vim_fsm.dart +++ b/lib/src/editor/editor_component/service/shortcuts/vim/vim_fsm.dart @@ -6,6 +6,22 @@ import 'package:appflowy_editor/appflowy_editor.dart'; const baseKeys = ['h', 'j', 'k', 'l']; String _buffer = ''; +String _deleteBuffer = ''; + +Position deleteCurrentLine(EditorState editorState, int count) { + final selection = editorState.selection; + if (selection == null) return Position(path: [0], offset: 0); + final node = editorState.getNodeAtPath(selection.end.path); + if (node == null || node.delta == null) return Position(path: [0], offset: 0); + final transaction = editorState.transaction; + final tmpPosition = Position(path: selection.start.path, offset: 0); + //transaction.deleteText(node, 0, node.delta!.length); + transaction.deleteNodesAtPath(selection.start.path); + editorState.apply(transaction).then( + (value) => {editorState.selectionType = null}, + ); + return tmpPosition; +} class VimFSM { KeyEventResult processKey(KeyEvent event, EditorState editorState) { @@ -20,6 +36,24 @@ class VimFSM { return KeyEventResult.handled; } + if (key == 'd') { + _deleteBuffer += key; + final RegExp ddRegExp = RegExp(r'^(\d*)dd$'); + final match = ddRegExp.firstMatch(_deleteBuffer); + if (match != null) { + final String? countStr = match.group(1); + final int count = + (countStr != null && countStr.isNotEmpty) ? int.parse(countStr) : 1; + final tmpPosition = deleteCurrentLine(editorState, count); + _deleteBuffer = ''; + editorState.selection = Selection( + end: tmpPosition, + start: tmpPosition, + ); + return KeyEventResult.handled; + } + } + Position? newPosition; if (baseKeys.contains(key)) { final count = _buffer.isNotEmpty ? int.parse(_buffer) : 1; From cbbf0a5f094afea0b63347d6af11221ce109f991 Mon Sep 17 00:00:00 2001 From: Sean Riley Hawkins Date: Sat, 15 Mar 2025 15:45:32 +0200 Subject: [PATCH 27/49] fix: remove conflicting keys in vim shortcuts - The '0' key shortcut was conflicting with vim_fsm logic. Moved it from vim.dart into vim_fsm to handle key behavior directly. - In escape_command only set vim.NormalMode if Vim mode is enabled. --- .../shortcuts/command/escape_command.dart | 5 +-- .../service/shortcuts/command/vim.dart | 41 +++++++++++++++-- .../service/shortcuts/vim/vim_fsm.dart | 45 ++++++++++++++----- 3 files changed, 73 insertions(+), 18 deletions(-) diff --git a/lib/src/editor/editor_component/service/shortcuts/command/escape_command.dart b/lib/src/editor/editor_component/service/shortcuts/command/escape_command.dart index a0862e372..aa01c9a30 100644 --- a/lib/src/editor/editor_component/service/shortcuts/command/escape_command.dart +++ b/lib/src/editor/editor_component/service/shortcuts/command/escape_command.dart @@ -16,9 +16,9 @@ final CommandShortcutEvent exitEditingCommand = CommandShortcutEvent( ); CommandShortcutEventHandler _exitEditingCommandHandler = (editorState) { - if (editorState.mode == VimModes.insertMode && + if (editorState.vimMode == true && editorState.editable == true && - editorState.vimMode == true) { + editorState.mode == VimModes.insertMode) { editorState.prevSelection = editorState.selection; editorState.selection = null; editorState.mode = VimModes.normalMode; @@ -28,7 +28,6 @@ CommandShortcutEventHandler _exitEditingCommandHandler = (editorState) { return KeyEventResult.handled; } editorState.selection = null; - editorState.mode = VimModes.normalMode; editorState.service.keyboardService?.closeKeyboard(); return KeyEventResult.handled; }; diff --git a/lib/src/editor/editor_component/service/shortcuts/command/vim.dart b/lib/src/editor/editor_component/service/shortcuts/command/vim.dart index 3e83418cb..71b41178d 100644 --- a/lib/src/editor/editor_component/service/shortcuts/command/vim.dart +++ b/lib/src/editor/editor_component/service/shortcuts/command/vim.dart @@ -34,17 +34,17 @@ final List vimKeyModes = [ //The undoManager doesnt work in normal mode /* There is an issue with transactions. - When the editor is in Normal mode, transactions are closed. What ever happens + When the editor is in Normal mode, transactions are closed. What ever happens then well wont work. Problem is undo & redo needs that transaction window open. - Unless something is implemented to ignore all other keys unless they match + Unless something is implemented to ignore all other keys unless they match a VIM key? */ // vimUndoCommand, // vimRedoCommand, ///Navigate line Commands - vimMoveCursorToStartCommand, + // vimMoveCursorToStartCommand, vimMoveCursorToEndCommand, jumpWordBackwardCommand, jumpWordForwardCommand, @@ -539,6 +539,37 @@ final CommandShortcutEvent vimMoveCursorToStartCommand = CommandShortcutEvent( getDescription: () => AppFlowyEditorL10n.current.cmdVimJumpFirstChar, ); +CommandShortcutEventHandler _vimMoveCursorToStartHandler = (editorState) { + if (!editorState.vimMode) { + return KeyEventResult.ignored; + } + // final afKeyboard = editorState.service.keyboardServiceKey; + if (editorState.mode == VimModes.normalMode) { + final selection = editorState.selection; + if (selection == null) { + return KeyEventResult.ignored; + } + final nodes = editorState.getNodesInSelection(selection); + if (nodes.isEmpty) { + return KeyEventResult.ignored; + } + var end = selection.end; + final position = isRTL(editorState) + ? nodes.last.selectable?.end() + : nodes.last.selectable?.start(); + if (position != null) { + end = position; + } + + editorState.selection = + Selection.collapsed(Position(path: end.path, offset: 0)); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; +}; + +/* + CommandShortcutEventHandler _vimMoveCursorToStartHandler = (editorState) { if (!editorState.vimMode) { return KeyEventResult.ignored; @@ -561,6 +592,8 @@ CommandShortcutEventHandler _vimMoveCursorToStartHandler = (editorState) { return KeyEventResult.ignored; }; +*/ + final CommandShortcutEvent vimMoveCursorToEndCommand = CommandShortcutEvent( key: 'vim move cursor to end of line in normal mode', //NOTE: Used Digit 4, dollar sign would throw error @@ -731,7 +764,7 @@ final numList = List.generate(10, (i) => i); /* * The idea for this is to at least use * a list for the numbers & key movements. - * Then from there jump accordingly, although + * Then from there jump accordingly, although * might need to intercept that raw key event * Manually of course Basically -> 5j means jump five lines down diff --git a/lib/src/editor/editor_component/service/shortcuts/vim/vim_fsm.dart b/lib/src/editor/editor_component/service/shortcuts/vim/vim_fsm.dart index 370f78069..ea6aa6c9e 100644 --- a/lib/src/editor/editor_component/service/shortcuts/vim/vim_fsm.dart +++ b/lib/src/editor/editor_component/service/shortcuts/vim/vim_fsm.dart @@ -8,6 +8,35 @@ const baseKeys = ['h', 'j', 'k', 'l']; String _buffer = ''; String _deleteBuffer = ''; +CommandShortcutEventHandler vimMoveCursorToStartHandler = (editorState) { + if (!editorState.vimMode) { + return KeyEventResult.ignored; + } + // final afKeyboard = editorState.service.keyboardServiceKey; + if (editorState.mode == VimModes.normalMode) { + final selection = editorState.selection; + if (selection == null) { + return KeyEventResult.ignored; + } + final nodes = editorState.getNodesInSelection(selection); + if (nodes.isEmpty) { + return KeyEventResult.ignored; + } + var end = selection.end; + final position = isRTL(editorState) + ? nodes.last.selectable?.end() + : nodes.last.selectable?.start(); + if (position != null) { + end = position; + } + + editorState.selection = + Selection.collapsed(Position(path: end.path, offset: 0)); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; +}; + Position deleteCurrentLine(EditorState editorState, int count) { final selection = editorState.selection; if (selection == null) return Position(path: [0], offset: 0); @@ -32,7 +61,11 @@ class VimFSM { final key = event.logicalKey.keyLabel.toLowerCase(); if (RegExp(r'^\d$').hasMatch(key)) { - _buffer += key; + if (_buffer.isEmpty && key == '0') { + vimMoveCursorToStartHandler(editorState); + } else { + _buffer += key; + } return KeyEventResult.handled; } @@ -125,16 +158,6 @@ class VimFSM { } } if (newPosition != null) { - print(selection); - print(newPosition); - print(count); - - //NOTE: This works - //Position tmp = Position(offset: 0, path: [3]); - //BUG:This does not work - print(newPosition.path.first); - Position tmp = - Position(offset: 0, path: [selection.end.path.first + count]); editorState.updateSelectionWithReason( Selection.collapsed(newPosition), reason: SelectionUpdateReason.uiEvent, From 669bfbb5eaee44483b7acdc226b011dcd9b6e997 Mon Sep 17 00:00:00 2001 From: Sean Riley Hawkins Date: Sat, 15 Mar 2025 15:45:50 +0200 Subject: [PATCH 28/49] chore: prune comments & unused packages --- .../editor/editor_component/service/editor_service.dart | 9 --------- .../service/shortcuts/vim_shortcut_event.dart | 3 --- 2 files changed, 12 deletions(-) diff --git a/lib/src/editor/editor_component/service/editor_service.dart b/lib/src/editor/editor_component/service/editor_service.dart index f141c82e3..79ff8ba61 100644 --- a/lib/src/editor/editor_component/service/editor_service.dart +++ b/lib/src/editor/editor_component/service/editor_service.dart @@ -21,15 +21,6 @@ class EditorService { AppFlowyKeyboardService? get keyboardService { if (keyboardServiceKey.currentState != null && keyboardServiceKey.currentState is AppFlowyKeyboardService) { - print('Enter normal mode'); - // print(selectionService.currentSelection.value); - // Selection? select = selectionService.currentSelection.value; - //NOTE: This causes the editor to freeze up and eats memory alot... - // keyboardService?.enableKeyBoard(select!); - //NOTE: Would need to display cursor after closing keyboard - // print(selectionService.currentSelection); - //NOTE: Causes and infinite loop & hangs - // keyboardService?.enable(); return keyboardServiceKey.currentState! as AppFlowyKeyboardService; } return null; diff --git a/lib/src/editor/editor_component/service/shortcuts/vim_shortcut_event.dart b/lib/src/editor/editor_component/service/shortcuts/vim_shortcut_event.dart index 0e9867143..13595c51f 100644 --- a/lib/src/editor/editor_component/service/shortcuts/vim_shortcut_event.dart +++ b/lib/src/editor/editor_component/service/shortcuts/vim_shortcut_event.dart @@ -1,7 +1,5 @@ import 'package:appflowy_editor/appflowy_editor.dart'; -import 'package:appflowy_editor/src/editor/editor_component/service/shortcuts/command_shortcut_event.dart'; import 'package:appflowy_editor/src/editor/editor_component/service/shortcuts/vim/vim_fsm.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; class VimCommandShortcutEvent extends CommandShortcutEvent { @@ -21,4 +19,3 @@ class VimCommandShortcutEvent extends CommandShortcutEvent { return KeyEventResult.ignored; } } - From 94723fcda25f4c6b01a7a744046682b99c4f40a6 Mon Sep 17 00:00:00 2001 From: Sean Riley Hawkins Date: Sat, 15 Mar 2025 16:21:48 +0200 Subject: [PATCH 29/49] test: commit test for (j, k) keys and one shortcut --- .../service/shortcuts/vim/vim_fsm.dart | 11 +- .../vim/movement_keys_test.dart | 116 ++++++++++++++++++ 2 files changed, 125 insertions(+), 2 deletions(-) create mode 100644 test/service/shortcut_event/vim/movement_keys_test.dart diff --git a/lib/src/editor/editor_component/service/shortcuts/vim/vim_fsm.dart b/lib/src/editor/editor_component/service/shortcuts/vim/vim_fsm.dart index ea6aa6c9e..4aac72f71 100644 --- a/lib/src/editor/editor_component/service/shortcuts/vim/vim_fsm.dart +++ b/lib/src/editor/editor_component/service/shortcuts/vim/vim_fsm.dart @@ -114,7 +114,10 @@ class VimFSM { ); int tmpPos = count + selection.end.path.first; if (tmpPos < editorState.document.root.children.length) { - newPosition = Position(path: [tmpPos], offset: 0); + newPosition = + //BUG: This causes editor to say null value on places where offset is empty + // Position(path: [tmpPos], offset: selection.end.offset ?? 0); + Position(path: [tmpPos], offset: 0); } //newPosition = Position(path: [count+selection.end.path.first]); break; @@ -131,7 +134,11 @@ class VimFSM { int tmpPos = selection.end.path.first - count; if (tmpPos < editorState.document.root.children.length) { - newPosition = Position(path: [tmpPos], offset: 0); + newPosition = + + //BUG: This causes editor to say null value on places where offset is empty + // Position(path: [tmpPos], offset: selection.end.offset ?? 0); + Position(path: [tmpPos], offset: 0); } break; } diff --git a/test/service/shortcut_event/vim/movement_keys_test.dart b/test/service/shortcut_event/vim/movement_keys_test.dart new file mode 100644 index 000000000..368280edd --- /dev/null +++ b/test/service/shortcut_event/vim/movement_keys_test.dart @@ -0,0 +1,116 @@ +import 'dart:io'; + +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../../new/infra/testable_editor.dart'; + +void main() async { + setUpAll(() { + TestWidgetsFlutterBinding.ensureInitialized(); + }); + + group('vim_fsm.dart', () { + testWidgets('vim normal mode [j, k] horizontal keys (up/down)', + (tester) async { + const text1 = 'Welcome to Appflowy 😁'; + const text2 = 'Welcome'; + final editor = tester.editor + ..addParagraph(initialText: text1) + ..addParagraph(initialText: text2); + + await editor.startTesting(); + editor.editorState.vimMode = true; + + final selection = Selection.single(path: [1], startOffset: text2.length); + await editor.updateSelection(selection); + + await editor.pressKey(key: LogicalKeyboardKey.escape); + expect(editor.editorState.mode, VimModes.normalMode); + + await editor.pressKey(key: LogicalKeyboardKey.keyK); + expect( + editor.selection, + Selection.single(path: [0], startOffset: 0), + ); + + await editor.pressKey(key: LogicalKeyboardKey.keyJ); + expect( + editor.selection, + Selection.single(path: [1], startOffset: 0), + ); + + await editor.dispose(); + }); + + testWidgets('vim normal mode move cursor to start', (tester) async { + const text = 'Welcome to Appflowy 😁'; + final editor = tester.editor..addParagraphs(2, initialText: text); + + await editor.startTesting(); + editor.editorState.vimMode = true; + + final selection = Selection.single(path: [1], startOffset: text.length); + await editor.updateSelection(selection); + + await editor.pressKey(key: LogicalKeyboardKey.escape); + + expect(editor.editorState.mode, VimModes.normalMode); + + await editor.pressKey(key: LogicalKeyboardKey.digit0); + + expect( + editor.selection, + Selection.single(path: [1], startOffset: 0), + ); + + await editor.dispose(); + }); +/* + testWidgets('redefine move cursor end command', (tester) async { + const text = 'Welcome to Appflowy 😁'; + final editor = tester.editor..addParagraphs(2, initialText: text); + + await editor.startTesting(); + + final selection = Selection.single(path: [1], startOffset: 0); + await editor.updateSelection(selection); + + await editor.pressKey( + key: Platform.isMacOS + ? LogicalKeyboardKey.arrowRight + : LogicalKeyboardKey.end, + isMetaPressed: Platform.isMacOS, + ); + + expect( + editor.selection, + Selection.single(path: [1], startOffset: text.length), + ); + + await editor.updateSelection(selection); + + const newCommand = 'alt+arrow right'; + moveCursorToEndCommand.updateCommand( + windowsCommand: newCommand, + linuxCommand: newCommand, + macOSCommand: newCommand, + ); + + await editor.pressKey( + key: LogicalKeyboardKey.arrowRight, + isAltPressed: true, + ); + + expect( + editor.selection, + Selection.single(path: [1], startOffset: text.length), + ); + + await editor.dispose(); + }); + */ + }); +} From 5f902ac0504a61834c8790becba9d6d0d893d980 Mon Sep 17 00:00:00 2001 From: Sean Riley Hawkins Date: Mon, 17 Mar 2025 07:21:20 +0200 Subject: [PATCH 30/49] fix: handle '0' key behavior and move it to vim_fsm.dart - State was becoming difficult to track in vim.dart --- .../service/shortcuts/command/vim.dart | 131 ++---------------- .../service/shortcuts/vim/vim_fsm.dart | 32 +++-- 2 files changed, 32 insertions(+), 131 deletions(-) diff --git a/lib/src/editor/editor_component/service/shortcuts/command/vim.dart b/lib/src/editor/editor_component/service/shortcuts/command/vim.dart index 71b41178d..92b9a6de4 100644 --- a/lib/src/editor/editor_component/service/shortcuts/command/vim.dart +++ b/lib/src/editor/editor_component/service/shortcuts/command/vim.dart @@ -531,69 +531,6 @@ CommandShortcutEventHandler _vimHalfPageUpCommandHandler = (editorState) { return KeyEventResult.ignored; }; -///Navigate on the current line -final CommandShortcutEvent vimMoveCursorToStartCommand = CommandShortcutEvent( - key: 'vim move cursor to start of line in normal mode', - command: 'Digit 0', - handler: _vimMoveCursorToStartHandler, - getDescription: () => AppFlowyEditorL10n.current.cmdVimJumpFirstChar, -); - -CommandShortcutEventHandler _vimMoveCursorToStartHandler = (editorState) { - if (!editorState.vimMode) { - return KeyEventResult.ignored; - } - // final afKeyboard = editorState.service.keyboardServiceKey; - if (editorState.mode == VimModes.normalMode) { - final selection = editorState.selection; - if (selection == null) { - return KeyEventResult.ignored; - } - final nodes = editorState.getNodesInSelection(selection); - if (nodes.isEmpty) { - return KeyEventResult.ignored; - } - var end = selection.end; - final position = isRTL(editorState) - ? nodes.last.selectable?.end() - : nodes.last.selectable?.start(); - if (position != null) { - end = position; - } - - editorState.selection = - Selection.collapsed(Position(path: end.path, offset: 0)); - return KeyEventResult.handled; - } - return KeyEventResult.ignored; -}; - -/* - -CommandShortcutEventHandler _vimMoveCursorToStartHandler = (editorState) { - if (!editorState.vimMode) { - return KeyEventResult.ignored; - } - final afKeyboard = editorState.service.keyboardServiceKey; - if (afKeyboard.currentState != null && - afKeyboard.currentState is AppFlowyKeyboardService) { - if (editorState.selection != null && - editorState.mode == VimModes.normalMode) { - if (isRTL(editorState)) { - editorState.moveCursorBackward(SelectionMoveRange.line); - } else { - editorState.moveCursorForward(SelectionMoveRange.line); - } - return KeyEventResult.handled; - } else { - return KeyEventResult.ignored; - } - } - return KeyEventResult.ignored; -}; - -*/ - final CommandShortcutEvent vimMoveCursorToEndCommand = CommandShortcutEvent( key: 'vim move cursor to end of line in normal mode', //NOTE: Used Digit 4, dollar sign would throw error @@ -606,20 +543,18 @@ CommandShortcutEventHandler _vimMoveCursorToEndHandler = (editorState) { if (!editorState.vimMode) { return KeyEventResult.ignored; } - final afKeyboard = editorState.service.keyboardServiceKey; - if (afKeyboard.currentState != null && - afKeyboard.currentState is AppFlowyKeyboardService) { - if (editorState.selection != null && - editorState.mode == VimModes.normalMode) { - if (isRTL(editorState)) { - editorState.moveCursorForward(SelectionMoveRange.line); - } else { - editorState.moveCursorBackward(SelectionMoveRange.line); - } - return KeyEventResult.handled; - } else { + // final afKeyboard = editorState.service.keyboardServiceKey; + if (editorState.mode == VimModes.normalMode) { + final selection = editorState.selection; + if (selection == null) { return KeyEventResult.ignored; } + if (isRTL(editorState)) { + editorState.moveCursorForward(SelectionMoveRange.line); + } else { + editorState.moveCursorBackward(SelectionMoveRange.line); + } + return KeyEventResult.handled; } return KeyEventResult.ignored; }; @@ -692,7 +627,7 @@ CommandShortcutEventHandler _jumpWordBackwardCommandHandler = (editorState) { }; final CommandShortcutEvent jumpWordForwardCommand = CommandShortcutEvent( - key: 'move the cursor backward to the next wordl in normal mode', + key: 'move the cursor backward to the next word in normal mode', command: 'w', handler: _jumpWordForwardCommandHandler, getDescription: () => AppFlowyEditorL10n.current.cmdVimForwardWordJump, @@ -760,50 +695,6 @@ CommandShortcutEventHandler _jumpWordForwardCommandHandler = (editorState) { return KeyEventResult.ignored; }; -final numList = List.generate(10, (i) => i); -/* - * The idea for this is to at least use - * a list for the numbers & key movements. - * Then from there jump accordingly, although - * might need to intercept that raw key event - * Manually of course - Basically -> 5j means jump five lines down - */ -final CommandShortcutEvent vimJumpToLineCommand = CommandShortcutEvent( - key: 'vim move cursor to start of line in normal mode', - //TODO: Find a way to await & chain shortcuts or key presses - //Well modifying the keyevent directly won't end well. Since the focus node only reads one keyevent at a time - // command: 'Digit 5', - command: 'Digit 5' 'j', - handler: _vimJumpToLineHandler, - getDescription: () => AppFlowyEditorL10n.current.cmdVimNumJumper, -); - -CommandShortcutEventHandler _vimJumpToLineHandler = (editorState) { - if (!editorState.vimMode) { - return KeyEventResult.ignored; - } - final afKeyboard = editorState.service.keyboardServiceKey; - if (afKeyboard.currentState != null && - afKeyboard.currentState is AppFlowyKeyboardService) { - if (editorState.selection != null && - editorState.mode == VimModes.normalMode) { - final selection = editorState.selection; - //NOTE: Hard wired for now - //Besides this is just for scrolling doesnt move the cursor - editorState.scrollService?.jumpTo(5); - SelectionMoveRange downRange = SelectionMoveRange.line; - //This will be tricky need to modify 'select_commands.dart' - //BUG: It throws an error on a blank line - editorState.moveCursor(SelectionMoveDirection.backward, downRange); - return KeyEventResult.handled; - } else { - return KeyEventResult.ignored; - } - } - return KeyEventResult.ignored; -}; - //BUG: Transaction to delete word won't apply /* final CommandShortcutEvent vimDeleteUnderCursorCommand = CommandShortcutEvent( diff --git a/lib/src/editor/editor_component/service/shortcuts/vim/vim_fsm.dart b/lib/src/editor/editor_component/service/shortcuts/vim/vim_fsm.dart index 4aac72f71..98e7aaa21 100644 --- a/lib/src/editor/editor_component/service/shortcuts/vim/vim_fsm.dart +++ b/lib/src/editor/editor_component/service/shortcuts/vim/vim_fsm.dart @@ -18,20 +18,30 @@ CommandShortcutEventHandler vimMoveCursorToStartHandler = (editorState) { if (selection == null) { return KeyEventResult.ignored; } - final nodes = editorState.getNodesInSelection(selection); - if (nodes.isEmpty) { + if (isRTL(editorState)) { + editorState.moveCursorBackward(SelectionMoveRange.line); + } else { + editorState.moveCursorForward(SelectionMoveRange.line); + } + return KeyEventResult.handled; + } + return KeyEventResult.ignored; +}; + +CommandShortcutEventHandler vimMoveCursorToEndHandler = (editorState) { + if (!editorState.vimMode) { + return KeyEventResult.ignored; + } + if (editorState.mode == VimModes.normalMode) { + final selection = editorState.selection; + if (selection == null) { return KeyEventResult.ignored; } - var end = selection.end; - final position = isRTL(editorState) - ? nodes.last.selectable?.end() - : nodes.last.selectable?.start(); - if (position != null) { - end = position; + if (isRTL(editorState)) { + editorState.moveCursorForward(SelectionMoveRange.line); + } else { + editorState.moveCursorBackward(SelectionMoveRange.line); } - - editorState.selection = - Selection.collapsed(Position(path: end.path, offset: 0)); return KeyEventResult.handled; } return KeyEventResult.ignored; From d66fae0a6e1d986bccdde5903e7eea15871bbace Mon Sep 17 00:00:00 2001 From: Sean Riley Hawkins Date: Mon, 17 Mar 2025 07:21:56 +0200 Subject: [PATCH 31/49] test: commit test for '0' key - This test is for 'move cursor to start' in vim mode --- .../vim/movement_keys_test.dart | 115 ++++++++++++------ 1 file changed, 75 insertions(+), 40 deletions(-) diff --git a/test/service/shortcut_event/vim/movement_keys_test.dart b/test/service/shortcut_event/vim/movement_keys_test.dart index 368280edd..894229b4a 100644 --- a/test/service/shortcut_event/vim/movement_keys_test.dart +++ b/test/service/shortcut_event/vim/movement_keys_test.dart @@ -1,7 +1,4 @@ -import 'dart:io'; - import 'package:appflowy_editor/appflowy_editor.dart'; -import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -45,72 +42,110 @@ void main() async { await editor.dispose(); }); - testWidgets('vim normal mode move cursor to start', (tester) async { - const text = 'Welcome to Appflowy 😁'; - final editor = tester.editor..addParagraphs(2, initialText: text); + testWidgets('vim normal mode horizontal keys left to right [h -> l]', + (tester) async { + const text1 = 'Welcome to Appflowy 😁'; + final editor = tester.editor..addParagraphs(2, initialText: text1); await editor.startTesting(); editor.editorState.vimMode = true; - final selection = Selection.single(path: [1], startOffset: text.length); + final selection = Selection.single(path: [0], startOffset: 0); await editor.updateSelection(selection); await editor.pressKey(key: LogicalKeyboardKey.escape); expect(editor.editorState.mode, VimModes.normalMode); - await editor.pressKey(key: LogicalKeyboardKey.digit0); + for (var i = 0; i < text1.length; i++) { + await editor.pressKey(key: LogicalKeyboardKey.keyL); + + if (i == text1.length - 1) { + // Wrap to next node if the cursor is at the end of the current node. + expect( + editor.selection, + Selection.single( + path: [1], + startOffset: 0, + ), + ); + } else { + final delta = editor.nodeAtPath([0])!.delta!; + expect( + editor.selection, + Selection.single( + path: [0], + startOffset: delta.nextRunePosition(i), + ), + ); + } + } + await editor.dispose(); + }); - expect( - editor.selection, - Selection.single(path: [1], startOffset: 0), - ); + testWidgets('vim normal mode horizontal keys right to left [l -> h]', + (tester) async { + const text1 = 'Welcome to Appflowy 😁'; + final editor = tester.editor..addParagraphs(2, initialText: text1); + + await editor.startTesting(); + editor.editorState.vimMode = true; + + final selection = Selection.single(path: [0], startOffset: 0); + await editor.updateSelection(selection); + await editor.pressKey(key: LogicalKeyboardKey.escape); + + expect(editor.editorState.mode, VimModes.normalMode); + + for (var i = 0; i < text1.length; i++) { + await editor.pressKey(key: LogicalKeyboardKey.keyL); + + if (i == text1.length - 1) { + // Wrap to next node if the cursor is at the end of the current node. + expect( + editor.selection, + Selection.single( + path: [1], + startOffset: 0, + ), + ); + } else { + final delta = editor.nodeAtPath([0])!.delta!; + expect( + editor.selection, + Selection.single( + path: [0], + startOffset: delta.nextRunePosition(i), + ), + ); + } + } await editor.dispose(); }); -/* - testWidgets('redefine move cursor end command', (tester) async { + + testWidgets('vim normal mode move cursor to start', (tester) async { const text = 'Welcome to Appflowy 😁'; final editor = tester.editor..addParagraphs(2, initialText: text); await editor.startTesting(); + editor.editorState.vimMode = true; - final selection = Selection.single(path: [1], startOffset: 0); + final selection = Selection.single(path: [1], startOffset: text.length); await editor.updateSelection(selection); - await editor.pressKey( - key: Platform.isMacOS - ? LogicalKeyboardKey.arrowRight - : LogicalKeyboardKey.end, - isMetaPressed: Platform.isMacOS, - ); - - expect( - editor.selection, - Selection.single(path: [1], startOffset: text.length), - ); - - await editor.updateSelection(selection); + await editor.pressKey(key: LogicalKeyboardKey.escape); - const newCommand = 'alt+arrow right'; - moveCursorToEndCommand.updateCommand( - windowsCommand: newCommand, - linuxCommand: newCommand, - macOSCommand: newCommand, - ); + expect(editor.editorState.mode, VimModes.normalMode); - await editor.pressKey( - key: LogicalKeyboardKey.arrowRight, - isAltPressed: true, - ); + await editor.pressKey(key: LogicalKeyboardKey.digit0); expect( editor.selection, - Selection.single(path: [1], startOffset: text.length), + Selection.single(path: [1], startOffset: 0), ); await editor.dispose(); }); - */ }); } From a9f66c52effab9f490c512af89e2f354ce7f10e1 Mon Sep 17 00:00:00 2001 From: Sean Riley Hawkins Date: Tue, 18 Mar 2025 19:43:46 +0200 Subject: [PATCH 32/49] refactor: remove guardrails blocking accidental edits - Most of the keys in vim.dart were relying on the ShortcutEvents main loop. Which resulted in accidental text edits. - Removing the vim.dart from the shortcuts file, allowed vim_fsm.dart to work properly. - Any other key that is not listed or captured in vim_fsm.dart is ignored. --- .../standard_block_components.dart | 4 +- .../service/shortcuts/command/vim.dart | 2 +- .../service/shortcuts/vim/vim_fsm.dart | 204 +++++++++++++++--- lib/src/editor_state.dart | 8 +- 4 files changed, 179 insertions(+), 39 deletions(-) diff --git a/lib/src/editor/block_component/standard_block_components.dart b/lib/src/editor/block_component/standard_block_components.dart index eeb3480bc..b516c11c0 100644 --- a/lib/src/editor/block_component/standard_block_components.dart +++ b/lib/src/editor/block_component/standard_block_components.dart @@ -3,7 +3,7 @@ import 'package:appflowy_editor/src/editor/block_component/heading_block_compone import 'package:appflowy_editor/src/editor/util/platform_extension.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:appflowy_editor/src/editor/editor_component/service/shortcuts/command/vim.dart'; +// import 'package:appflowy_editor/src/editor/editor_component/service/shortcuts/command/vim.dart'; const standardBlockComponentConfiguration = BlockComponentConfiguration(); @@ -155,5 +155,5 @@ final List standardCommandShortcutEvents = [ copyCommand, ...pasteCommands, cutCommand, - ...vimKeyModes + // ...vimKeyModes ]; diff --git a/lib/src/editor/editor_component/service/shortcuts/command/vim.dart b/lib/src/editor/editor_component/service/shortcuts/command/vim.dart index 92b9a6de4..10781aadb 100644 --- a/lib/src/editor/editor_component/service/shortcuts/command/vim.dart +++ b/lib/src/editor/editor_component/service/shortcuts/command/vim.dart @@ -45,7 +45,7 @@ final List vimKeyModes = [ ///Navigate line Commands // vimMoveCursorToStartCommand, - vimMoveCursorToEndCommand, + //vimMoveCursorToEndCommand, jumpWordBackwardCommand, jumpWordForwardCommand, //BUG: Selection doesnt show up to user diff --git a/lib/src/editor/editor_component/service/shortcuts/vim/vim_fsm.dart b/lib/src/editor/editor_component/service/shortcuts/vim/vim_fsm.dart index 98e7aaa21..57fe54fa4 100644 --- a/lib/src/editor/editor_component/service/shortcuts/vim/vim_fsm.dart +++ b/lib/src/editor/editor_component/service/shortcuts/vim/vim_fsm.dart @@ -1,13 +1,99 @@ +import 'dart:async'; + +import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor/src/extensions/vim_shortcut_extensions.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_editor/src/editor/command/selection_commands.dart'; const baseKeys = ['h', 'j', 'k', 'l']; String _buffer = ''; String _deleteBuffer = ''; +// void moveCursorHorizontal( +// EditorState editorState, SelectionMoveDirection direction, +// [SelectionMoveRange range = SelectionMoveRange.character]) { +// final selection = editorState.selection?.normalized; +// if (selection == null) { +// return; +// } +// if (!selection.isCollapsed && range != SelectionMoveRange.line) { +// editorState.selection = selection.collapse( +// atStart: direction == SelectionMoveDirection.forward); +// return; +// } + +// final node = getNodeAtPath(selection.start.path); +// if (node == null) { +// return; +// } + +// final start = node.selectable?.start(); +// final end = node.selectable?.end(); +// final offset = direction == SelectionMoveDirection.forward +// ? selection.startIndex +// : selection.endIndex; + +// { +// // the cursor is at the start of the node +// // move the cursor to the end of the previous node +// if (direction == SelectionMoveDirection.forward && +// start != null && +// start.offset >= offset) { +// final previousEnd = node +// .previousNodeWhere((element) => element.selectable != null) +// ?.selectable +// ?.end(); +// if (previousEnd != null) { +// updateSelectionWithReason( +// Selection.collapsed(previousEnd), +// reason: SelectionUpdateReason.uiEvent, +// ); +// } +// return; +// } +// // the cursor is at the end of the node +// // move the cursor to the start of the next node +// else if (direction == SelectionMoveDirection.backward && +// end != null && +// end.offset <= offset) { +// final nextStart = node.next?.selectable?.start(); +// if (nextStart != null) { +// updateSelectionWithReason( +// Selection.collapsed(nextStart), +// reason: SelectionUpdateReason.uiEvent, +// ); +// } +// return; +// } +// } +// final delta = node.delta; +// switch(range){ +// case SelectionMoveRange.line: +// if (delta != null){ +// updateSelectionWithReason( +// Selection.collapsed(selection.start.copyWith(offset: direction == SelectionMoveDirection.forward ? 0 : delta.length)) +// reason: SelectionUpdateReason.uiEvent +// ) +// } +// else { +// throw UnimplementedError(); +// } +// break; +// default: +// throw UnimplementedError(); +// } +// } + +/* +BUG: State gets sticky when at the end of the line & yet +and want to move to the start +Will have to build a custom one from moveCursorBackward +BUG: After selecting end then moving to start it freezes +then jumps backward a few characters after pressing another key +NOTE: Refer to the 'selection_commands.dart' +*/ CommandShortcutEventHandler vimMoveCursorToStartHandler = (editorState) { if (!editorState.vimMode) { return KeyEventResult.ignored; @@ -28,6 +114,9 @@ CommandShortcutEventHandler vimMoveCursorToStartHandler = (editorState) { return KeyEventResult.ignored; }; +/* +BUG: Has buggy behavior after selecting it +*/ CommandShortcutEventHandler vimMoveCursorToEndHandler = (editorState) { if (!editorState.vimMode) { return KeyEventResult.ignored; @@ -42,6 +131,7 @@ CommandShortcutEventHandler vimMoveCursorToEndHandler = (editorState) { } else { editorState.moveCursorBackward(SelectionMoveRange.line); } + return KeyEventResult.handled; } return KeyEventResult.ignored; @@ -50,52 +140,56 @@ CommandShortcutEventHandler vimMoveCursorToEndHandler = (editorState) { Position deleteCurrentLine(EditorState editorState, int count) { final selection = editorState.selection; if (selection == null) return Position(path: [0], offset: 0); - final node = editorState.getNodeAtPath(selection.end.path); + final node = editorState.getNodeAtPath(selection.start.path); if (node == null || node.delta == null) return Position(path: [0], offset: 0); - final transaction = editorState.transaction; + final tmpPosition = Position(path: selection.start.path, offset: 0); - //transaction.deleteText(node, 0, node.delta!.length); - transaction.deleteNodesAtPath(selection.start.path); - editorState.apply(transaction).then( - (value) => {editorState.selectionType = null}, - ); + final delta = node.delta; + if (delta != null) { + final deletionSelection = Selection( + start: Position(path: selection.start.path, offset: 0), + end: Position(path: selection.end.path, offset: delta.length)); + print('after modifying selection'); + print(deletionSelection); + editorState.selection = deletionSelection; + final transaction = editorState.transaction; + //transaction.deleteText(node, 0, node.delta!.length); + print('new editorSTate selection'); + print(editorState.selection); + transaction.deleteNodesAtPath(editorState.selection!.start.path); + // transaction.deleteNode(node); + + editorState + .apply(transaction) + .then((value) => {editorState.selectionType = null}); + editorState.updateSelectionWithReason( + Selection(start: tmpPosition, end: tmpPosition), + reason: SelectionUpdateReason.uiEvent); + print('editor transactions!'); + print(transaction.operations); + } return tmpPosition; } +String keyBuffer = ''; +Timer? resetTimer; + class VimFSM { KeyEventResult processKey(KeyEvent event, EditorState editorState) { if (event is! KeyDownEvent) { return KeyEventResult.ignored; } - final key = event.logicalKey.keyLabel.toLowerCase(); + resetTimer?.cancel(); + resetTimer = Timer(Duration(milliseconds: 500), () { + keyBuffer = ""; + }); - if (RegExp(r'^\d$').hasMatch(key)) { - if (_buffer.isEmpty && key == '0') { - vimMoveCursorToStartHandler(editorState); - } else { - _buffer += key; - } - return KeyEventResult.handled; + if (event.character == '\$') { + vimMoveCursorToEndHandler(editorState); } - if (key == 'd') { - _deleteBuffer += key; - final RegExp ddRegExp = RegExp(r'^(\d*)dd$'); - final match = ddRegExp.firstMatch(_deleteBuffer); - if (match != null) { - final String? countStr = match.group(1); - final int count = - (countStr != null && countStr.isNotEmpty) ? int.parse(countStr) : 1; - final tmpPosition = deleteCurrentLine(editorState, count); - _deleteBuffer = ''; - editorState.selection = Selection( - end: tmpPosition, - start: tmpPosition, - ); - return KeyEventResult.handled; - } - } + final key = event.logicalKey.keyLabel.toLowerCase(); Position? newPosition; if (baseKeys.contains(key)) { @@ -181,7 +275,46 @@ class VimFSM { ); return KeyEventResult.handled; } + } else { + _buffer = ''; + + keyBuffer += key; + if (RegExp(r'^\d$').hasMatch(key)) { + if (_buffer.isEmpty && key == '0') { + vimMoveCursorToStartHandler(editorState); + } else { + _buffer += key; + } + return KeyEventResult.handled; + } + print('key buffer: $keyBuffer'); + + if (keyBuffer == 'dd') { + // _deleteBuffer += key; + final RegExp ddRegExp = RegExp(r'^(\d*)dd$'); + final match = ddRegExp.firstMatch(_deleteBuffer); + // if (match != null) { + // final String? countStr = match.group(1); + // final int count = + // (countStr != null && countStr.isNotEmpty) ? int.parse(countStr) : 1; + final tmpPosition = deleteCurrentLine(editorState, 1); + _deleteBuffer = ''; + editorState.selection = Selection( + end: tmpPosition, + start: tmpPosition, + ); + resetKeyBuffer(); + return KeyEventResult.handled; + // } + } + return KeyEventResult.handled; } + + if (keyBuffer != 'dd') { + resetKeyBuffer(); + return KeyEventResult.ignored; + } + _buffer = ''; return KeyEventResult.ignored; @@ -190,4 +323,9 @@ class VimFSM { void reset() { _buffer = ''; } + + void resetKeyBuffer() { + keyBuffer = ""; + resetTimer?.cancel(); + } } diff --git a/lib/src/editor_state.dart b/lib/src/editor_state.dart index 0a9b77590..dbac91088 100644 --- a/lib/src/editor_state.dart +++ b/lib/src/editor_state.dart @@ -402,6 +402,7 @@ class EditorState { bool withUpdateSelection = true, bool skipHistoryDebounce = false, }) async { + /*/ if ((!editable && !vimMode && mode == VimModes.normalMode) || isDisposed) { return; } else if (!editable && vimMode) { @@ -410,6 +411,7 @@ class EditorState { //So to apply transactions just remove it //return; } + */ // it's a time consuming task, only enable it if necessary. if (_enableCheckIntegrity) { @@ -683,9 +685,9 @@ class EditorState { void _applyTransactionInLocal(Transaction transaction) { for (final op in transaction.operations) { AppFlowyEditorLog.editor.debug('apply op (local): ${op.toJson()}'); - if (!editable && vimMode && mode == VimModes.normalMode) { - return; - } + // if (!editable && vimMode && mode == VimModes.normalMode) { + // return; + // } if (op is InsertOperation) { document.insert(op.path, op.nodes); From 7f26dd6aa640a11e2245bd5e598746d83b0cf1d3 Mon Sep 17 00:00:00 2001 From: Sean Riley Hawkins Date: Wed, 19 Mar 2025 15:08:44 +0200 Subject: [PATCH 33/49] chore: remove vim keys from standard_block_components --- lib/src/editor/block_component/standard_block_components.dart | 2 +- .../editor/editor_component/service/shortcuts/command/vim.dart | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/src/editor/block_component/standard_block_components.dart b/lib/src/editor/block_component/standard_block_components.dart index b516c11c0..7d5a046ab 100644 --- a/lib/src/editor/block_component/standard_block_components.dart +++ b/lib/src/editor/block_component/standard_block_components.dart @@ -154,6 +154,6 @@ final List standardCommandShortcutEvents = [ // copy paste and cut copyCommand, ...pasteCommands, + // ...vimKeyModes, cutCommand, - // ...vimKeyModes ]; diff --git a/lib/src/editor/editor_component/service/shortcuts/command/vim.dart b/lib/src/editor/editor_component/service/shortcuts/command/vim.dart index 10781aadb..8adedecc2 100644 --- a/lib/src/editor/editor_component/service/shortcuts/command/vim.dart +++ b/lib/src/editor/editor_component/service/shortcuts/command/vim.dart @@ -49,7 +49,7 @@ final List vimKeyModes = [ jumpWordBackwardCommand, jumpWordForwardCommand, //BUG: Selection doesnt show up to user - vimSelectLineCommand, + // vimSelectLineCommand, ///Text operations //BUG: Deleting at the end of text will cause the widget tree to panic From 51295e2cabccf01c8e39d291f2aa454ad2339a90 Mon Sep 17 00:00:00 2001 From: Sean Riley Hawkins Date: Wed, 19 Mar 2025 15:09:14 +0200 Subject: [PATCH 34/49] fix: ensure pressing escape doesnt make the cursor disappear --- .../service/shortcuts/command/escape_command.dart | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/src/editor/editor_component/service/shortcuts/command/escape_command.dart b/lib/src/editor/editor_component/service/shortcuts/command/escape_command.dart index aa01c9a30..201356fe4 100644 --- a/lib/src/editor/editor_component/service/shortcuts/command/escape_command.dart +++ b/lib/src/editor/editor_component/service/shortcuts/command/escape_command.dart @@ -19,6 +19,9 @@ CommandShortcutEventHandler _exitEditingCommandHandler = (editorState) { if (editorState.vimMode == true && editorState.editable == true && editorState.mode == VimModes.insertMode) { + if (editorState.mode == VimModes.normalMode) { + return KeyEventResult.ignored; + } editorState.prevSelection = editorState.selection; editorState.selection = null; editorState.mode = VimModes.normalMode; From 0632d85b115775895bf9bf34edeaf9c68f014de3 Mon Sep 17 00:00:00 2001 From: Sean Riley Hawkins Date: Wed, 19 Mar 2025 15:10:17 +0200 Subject: [PATCH 35/49] chore: move some keys from vim.dart to vim_cursor.dart - Since keys are being intercepted directly best to move them in one location. --- .../service/shortcuts/vim/vim_cursor.dart | 69 +++++ .../service/shortcuts/vim/vim_fsm.dart | 269 ++++-------------- 2 files changed, 124 insertions(+), 214 deletions(-) create mode 100644 lib/src/editor/editor_component/service/shortcuts/vim/vim_cursor.dart diff --git a/lib/src/editor/editor_component/service/shortcuts/vim/vim_cursor.dart b/lib/src/editor/editor_component/service/shortcuts/vim/vim_cursor.dart new file mode 100644 index 000000000..de8ad766f --- /dev/null +++ b/lib/src/editor/editor_component/service/shortcuts/vim/vim_cursor.dart @@ -0,0 +1,69 @@ +import 'dart:async'; + +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_editor/src/editor/command/selection_commands.dart'; +import 'package:appflowy_editor/src/extensions/vim_shortcut_extensions.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +// import 'package:appflowy_editor/src/editor_state.dart'; + +const baseKeys = ['h', 'j', 'k', 'l', 'i', 'a', 'o']; +// String buffer = ''; + +class VimCursor { + static Position? processMotionKeys( + String key, EditorState editorState, Selection selection, int count) { + switch (key) { + case 'j': + { + int tmpPos = count + selection.end.path.first; + if (tmpPos < editorState.document.root.children.length) { + //BUG: This causes editor to say null value on places where offset is empty + // Position(path: [tmpPos], offset: selection.end.offset ?? 0); + return Position(path: [tmpPos], offset: 0); + } + //newPosition = Position(path: [count+selection.end.path.first]); + } + + case 'k': + { + int tmpPos = selection.end.path.first - count; + if (tmpPos < editorState.document.root.children.length) { + //BUG: This causes editor to say null value on places where offset is empty + // Position(path: [tmpPos], offset: selection.end.offset ?? 0); + return Position(path: [tmpPos], offset: 0); + } + } + case 'h': + return moveHorizontalMultiple( + editorState, + selection.end, + forward: true, + count: count, + ); + + case 'l': + return moveHorizontalMultiple( + editorState, + selection.end, + forward: false, + count: count, + ); + case 'i': + { + editorState.editable = true; + editorState.mode = VimModes.insertMode; + editorState.selection = editorState.selection; + editorState.selectionService.updateSelection(editorState.selection); + editorState.prevSelection = null; + return Position( + path: editorState.selection!.end.path, + offset: editorState.selection!.end.offset, + ); + } + default: + return null; + } + return null; + } +} diff --git a/lib/src/editor/editor_component/service/shortcuts/vim/vim_fsm.dart b/lib/src/editor/editor_component/service/shortcuts/vim/vim_fsm.dart index 57fe54fa4..76403b8e7 100644 --- a/lib/src/editor/editor_component/service/shortcuts/vim/vim_fsm.dart +++ b/lib/src/editor/editor_component/service/shortcuts/vim/vim_fsm.dart @@ -5,100 +5,17 @@ import 'package:appflowy_editor/src/extensions/vim_shortcut_extensions.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:appflowy_editor/src/editor/command/selection_commands.dart'; +import './vim_cursor.dart'; -const baseKeys = ['h', 'j', 'k', 'l']; +const baseKeys = ['h', 'j', 'k', 'l', 'i', 'a', 'o']; String _buffer = ''; String _deleteBuffer = ''; -// void moveCursorHorizontal( -// EditorState editorState, SelectionMoveDirection direction, -// [SelectionMoveRange range = SelectionMoveRange.character]) { -// final selection = editorState.selection?.normalized; -// if (selection == null) { -// return; -// } -// if (!selection.isCollapsed && range != SelectionMoveRange.line) { -// editorState.selection = selection.collapse( -// atStart: direction == SelectionMoveDirection.forward); -// return; -// } - -// final node = getNodeAtPath(selection.start.path); -// if (node == null) { -// return; -// } - -// final start = node.selectable?.start(); -// final end = node.selectable?.end(); -// final offset = direction == SelectionMoveDirection.forward -// ? selection.startIndex -// : selection.endIndex; - -// { -// // the cursor is at the start of the node -// // move the cursor to the end of the previous node -// if (direction == SelectionMoveDirection.forward && -// start != null && -// start.offset >= offset) { -// final previousEnd = node -// .previousNodeWhere((element) => element.selectable != null) -// ?.selectable -// ?.end(); -// if (previousEnd != null) { -// updateSelectionWithReason( -// Selection.collapsed(previousEnd), -// reason: SelectionUpdateReason.uiEvent, -// ); -// } -// return; -// } -// // the cursor is at the end of the node -// // move the cursor to the start of the next node -// else if (direction == SelectionMoveDirection.backward && -// end != null && -// end.offset <= offset) { -// final nextStart = node.next?.selectable?.start(); -// if (nextStart != null) { -// updateSelectionWithReason( -// Selection.collapsed(nextStart), -// reason: SelectionUpdateReason.uiEvent, -// ); -// } -// return; -// } -// } -// final delta = node.delta; -// switch(range){ -// case SelectionMoveRange.line: -// if (delta != null){ -// updateSelectionWithReason( -// Selection.collapsed(selection.start.copyWith(offset: direction == SelectionMoveDirection.forward ? 0 : delta.length)) -// reason: SelectionUpdateReason.uiEvent -// ) -// } -// else { -// throw UnimplementedError(); -// } -// break; -// default: -// throw UnimplementedError(); -// } -// } - -/* -BUG: State gets sticky when at the end of the line & yet -and want to move to the start -Will have to build a custom one from moveCursorBackward -BUG: After selecting end then moving to start it freezes -then jumps backward a few characters after pressing another key -NOTE: Refer to the 'selection_commands.dart' -*/ CommandShortcutEventHandler vimMoveCursorToStartHandler = (editorState) { if (!editorState.vimMode) { return KeyEventResult.ignored; } - // final afKeyboard = editorState.service.keyboardServiceKey; if (editorState.mode == VimModes.normalMode) { final selection = editorState.selection; if (selection == null) { @@ -114,9 +31,6 @@ CommandShortcutEventHandler vimMoveCursorToStartHandler = (editorState) { return KeyEventResult.ignored; }; -/* -BUG: Has buggy behavior after selecting it -*/ CommandShortcutEventHandler vimMoveCursorToEndHandler = (editorState) { if (!editorState.vimMode) { return KeyEventResult.ignored; @@ -142,31 +56,29 @@ Position deleteCurrentLine(EditorState editorState, int count) { if (selection == null) return Position(path: [0], offset: 0); final node = editorState.getNodeAtPath(selection.start.path); if (node == null || node.delta == null) return Position(path: [0], offset: 0); - - final tmpPosition = Position(path: selection.start.path, offset: 0); + final startPath = selection.start.path.first; + final endPath = startPath + count - 1; + final tmpPosition = Position(path: [startPath], offset: 0); final delta = node.delta; - if (delta != null) { - final deletionSelection = Selection( - start: Position(path: selection.start.path, offset: 0), - end: Position(path: selection.end.path, offset: delta.length)); - print('after modifying selection'); - print(deletionSelection); - editorState.selection = deletionSelection; - final transaction = editorState.transaction; - //transaction.deleteText(node, 0, node.delta!.length); - print('new editorSTate selection'); - print(editorState.selection); - transaction.deleteNodesAtPath(editorState.selection!.start.path); - // transaction.deleteNode(node); - - editorState - .apply(transaction) - .then((value) => {editorState.selectionType = null}); - editorState.updateSelectionWithReason( + for (int i = startPath; i <= endPath; i++) { + if (delta != null) { + final deletionSelection = Selection( + start: Position(path: selection.start.path, offset: 0), + end: Position(path: selection.end.path, offset: delta.length)); + editorState.selection = deletionSelection; + final transaction = editorState.transaction; + //transaction.deleteText(node, 0, node.delta!.length); + transaction.deleteNodesAtPath(editorState.selection!.start.path); + // transaction.deleteNode(node); + + editorState + .apply(transaction) + .then((value) => {editorState.selectionType = null}); + editorState.updateSelectionWithReason( Selection(start: tmpPosition, end: tmpPosition), - reason: SelectionUpdateReason.uiEvent); - print('editor transactions!'); - print(transaction.operations); + reason: SelectionUpdateReason.uiEvent, + ); + } } return tmpPosition; } @@ -185,89 +97,16 @@ class VimFSM { keyBuffer = ""; }); - if (event.character == '\$') { - vimMoveCursorToEndHandler(editorState); - } - final key = event.logicalKey.keyLabel.toLowerCase(); - - Position? newPosition; if (baseKeys.contains(key)) { final count = _buffer.isNotEmpty ? int.parse(_buffer) : 1; - _buffer = ''; + resetBuffer(); final selection = editorState.selection; if (selection == null) return KeyEventResult.ignored; - //NOTE: Figure a way out to perform transactions - //final transaction = editorState.transaction; - - /* - transaction - .deleteNodesAtPath(editorState.prevSelection!.start.path); - editorState - .apply(transaction) - .then((value) => editorState.selectionType = null); - */ - switch (key) { - case 'j': - { - newPosition = moveVerticalMultiple( - editorState, - selection.end, - upwards: false, - count: count, - ); - int tmpPos = count + selection.end.path.first; - if (tmpPos < editorState.document.root.children.length) { - newPosition = - //BUG: This causes editor to say null value on places where offset is empty - // Position(path: [tmpPos], offset: selection.end.offset ?? 0); - Position(path: [tmpPos], offset: 0); - } - //newPosition = Position(path: [count+selection.end.path.first]); - break; - } - - case 'k': - { - newPosition = moveVerticalMultiple( - editorState, - selection.end, - upwards: true, - count: count, - ); - int tmpPos = selection.end.path.first - count; - if (tmpPos < editorState.document.root.children.length) { - newPosition = + Position? newPosition = + VimCursor.processMotionKeys(key, editorState, selection, count); - //BUG: This causes editor to say null value on places where offset is empty - // Position(path: [tmpPos], offset: selection.end.offset ?? 0); - Position(path: [tmpPos], offset: 0); - } - break; - } - case 'h': - { - newPosition = moveHorizontalMultiple( - editorState, - selection.end, - forward: true, - count: count, - ); - - break; - } - case 'l': - { - newPosition = moveHorizontalMultiple( - editorState, - selection.end, - forward: false, - count: count, - ); - break; - } - } if (newPosition != null) { editorState.updateSelectionWithReason( Selection.collapsed(newPosition), @@ -276,55 +115,57 @@ class VimFSM { return KeyEventResult.handled; } } else { - _buffer = ''; + resetBuffer(); keyBuffer += key; if (RegExp(r'^\d$').hasMatch(key)) { + if (keyBuffer == 'shift left4' || + keyBuffer == 'shift right4' || + event.character == '\$') { + vimMoveCursorToEndHandler(editorState); + resetSequenceBuffer(); + } if (_buffer.isEmpty && key == '0') { vimMoveCursorToStartHandler(editorState); + resetBuffer(); } else { _buffer += key; + keyBuffer += key; } return KeyEventResult.handled; } - print('key buffer: $keyBuffer'); - if (keyBuffer == 'dd') { - // _deleteBuffer += key; - final RegExp ddRegExp = RegExp(r'^(\d*)dd$'); - final match = ddRegExp.firstMatch(_deleteBuffer); - // if (match != null) { - // final String? countStr = match.group(1); - // final int count = - // (countStr != null && countStr.isNotEmpty) ? int.parse(countStr) : 1; - final tmpPosition = deleteCurrentLine(editorState, 1); - _deleteBuffer = ''; - editorState.selection = Selection( - end: tmpPosition, - start: tmpPosition, - ); - resetKeyBuffer(); - return KeyEventResult.handled; - // } + if (keyBuffer.endsWith('dd')) { + final RegExp ddRegExp = RegExp(r'^(\d+)?dd$'); + final match = ddRegExp.firstMatch(keyBuffer); + if (match != null) { + final String? countStr = match.group(1); + final int count = (countStr != null && countStr.isNotEmpty) + ? int.parse(countStr) + : 1; + final tmpPosition = deleteCurrentLine(editorState, count); + editorState.selection = Selection( + end: tmpPosition, + start: tmpPosition, + ); + resetSequenceBuffer(); + return KeyEventResult.handled; + } } - return KeyEventResult.handled; - } - if (keyBuffer != 'dd') { - resetKeyBuffer(); - return KeyEventResult.ignored; + return KeyEventResult.handled; } - _buffer = ''; + resetBuffer(); return KeyEventResult.ignored; } - void reset() { + void resetBuffer() { _buffer = ''; } - void resetKeyBuffer() { + void resetSequenceBuffer() { keyBuffer = ""; resetTimer?.cancel(); } From 8aea59709d91ee084e6b160ddefb9b93f09c449d Mon Sep 17 00:00:00 2001 From: Sean Riley Hawkins Date: Wed, 19 Mar 2025 15:10:37 +0200 Subject: [PATCH 36/49] chore: remove guard rails for editor transactions --- lib/src/editor_state.dart | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/lib/src/editor_state.dart b/lib/src/editor_state.dart index dbac91088..a254d8bd4 100644 --- a/lib/src/editor_state.dart +++ b/lib/src/editor_state.dart @@ -402,17 +402,6 @@ class EditorState { bool withUpdateSelection = true, bool skipHistoryDebounce = false, }) async { - /*/ - if ((!editable && !vimMode && mode == VimModes.normalMode) || isDisposed) { - return; - } else if (!editable && vimMode) { - print(transaction.operations.toList()); - //NOTE: This statement blocks editor transactions - //So to apply transactions just remove it - //return; - } - */ - // it's a time consuming task, only enable it if necessary. if (_enableCheckIntegrity) { document.root.checkDocumentIntegrity(); @@ -685,9 +674,6 @@ class EditorState { void _applyTransactionInLocal(Transaction transaction) { for (final op in transaction.operations) { AppFlowyEditorLog.editor.debug('apply op (local): ${op.toJson()}'); - // if (!editable && vimMode && mode == VimModes.normalMode) { - // return; - // } if (op is InsertOperation) { document.insert(op.path, op.nodes); From 057b49ba97a2e096519438604fbdbc3a615c2c47 Mon Sep 17 00:00:00 2001 From: Sean Riley Hawkins Date: Wed, 19 Mar 2025 15:12:32 +0200 Subject: [PATCH 37/49] test: write test for '0' & '$' keys - These keys '0' is similar to 'Home' & '$' is similar to 'End' --- .../vim/shortcut_keys_test.dart | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 test/service/shortcut_event/vim/shortcut_keys_test.dart diff --git a/test/service/shortcut_event/vim/shortcut_keys_test.dart b/test/service/shortcut_event/vim/shortcut_keys_test.dart new file mode 100644 index 000000000..f14fe40e3 --- /dev/null +++ b/test/service/shortcut_event/vim/shortcut_keys_test.dart @@ -0,0 +1,76 @@ +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'dart:io' show Platform; + +import '../../../new/infra/testable_editor.dart'; + +void main() async { + setUpAll(() { + TestWidgetsFlutterBinding.ensureInitialized(); + }); + + group('vim.dart', () { + testWidgets('vim normal mode move cursor to end', (tester) async { + const text1 = 'Welcome to Appflowy 😁'; + const text2 = 'Welcome'; + final editor = tester.editor + ..addParagraph(initialText: text1) + ..addParagraph(initialText: text2); + + await editor.startTesting(); + editor.editorState.vimMode = true; + + final selection = Selection.collapsed(Position(path: [1])); + await editor.updateSelection(selection); + + await editor.pressKey(key: LogicalKeyboardKey.escape); + expect(editor.editorState.mode, VimModes.normalMode); + + await editor.pressKey( + key: LogicalKeyboardKey.digit4, + isShiftPressed: true, + ); + + if (Platform.isWindows || Platform.isLinux) { + expect( + editor.selection, + Selection.single(path: [1], startOffset: text2.length), + ); + } + + await editor.dispose(); + }); + + testWidgets('vim normal mode move cursor to start', (tester) async { + const text1 = 'Welcome to Appflowy 😁'; + const text2 = 'Welcome'; + final editor = tester.editor + ..addParagraph(initialText: text1) + ..addParagraph(initialText: text2); + + await editor.startTesting(); + editor.editorState.vimMode = true; + + final selection = + Selection.collapsed(Position(path: [1], offset: text2.length)); + await editor.updateSelection(selection); + + await editor.pressKey(key: LogicalKeyboardKey.escape); + expect(editor.editorState.mode, VimModes.normalMode); + + await editor.pressKey( + key: LogicalKeyboardKey.digit0, + ); + + if (Platform.isWindows || Platform.isLinux) { + expect( + editor.selection, + Selection.single(path: [1], startOffset: 0), + ); + } + + await editor.dispose(); + }); + }); +} From 57333139bedd98c4a10d977c0a39f8524ad48e8e Mon Sep 17 00:00:00 2001 From: Sean Riley Hawkins Date: Thu, 27 Mar 2025 05:55:58 +0200 Subject: [PATCH 38/49] fix: ensure if vim mode is NormalMode dont attach input service --- .../editor_component/service/keyboard_service_widget.dart | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/lib/src/editor/editor_component/service/keyboard_service_widget.dart b/lib/src/editor/editor_component/service/keyboard_service_widget.dart index 88ca6f6da..645218f35 100644 --- a/lib/src/editor/editor_component/service/keyboard_service_widget.dart +++ b/lib/src/editor/editor_component/service/keyboard_service_widget.dart @@ -247,6 +247,7 @@ class KeyboardServiceWidgetState extends State AppFlowyEditorLog.editor.debug( 'keyboard service - attach text input service: $textEditingValue', ); + if (textEditingValue != null) { textInputService.attach( textEditingValue, @@ -270,6 +271,10 @@ class KeyboardServiceWidgetState extends State // This function is used to get the current text editing value of the editor // based on the given selection. TextEditingValue? _getCurrentTextEditingValue(Selection selection) { + // This is to avoid the attachment service from capturing some input + if (editorState.vimMode && editorState.mode != VimModes.insertMode) { + return null; + } // Get all the editable nodes in the selection. final editableNodes = editorState .getNodesInSelection(selection) From e15af8931a0491df833ffb9548f039550778c1d6 Mon Sep 17 00:00:00 2001 From: Sean Riley Hawkins Date: Thu, 27 Mar 2025 05:56:39 +0200 Subject: [PATCH 39/49] fix: ensure escape key does nothing if in normal mode - In previous commits spamming the 'escape' key resulted in the cursor disappearing from the screen. --- .../service/shortcuts/command/escape_command.dart | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/src/editor/editor_component/service/shortcuts/command/escape_command.dart b/lib/src/editor/editor_component/service/shortcuts/command/escape_command.dart index 201356fe4..fceec7fa5 100644 --- a/lib/src/editor/editor_component/service/shortcuts/command/escape_command.dart +++ b/lib/src/editor/editor_component/service/shortcuts/command/escape_command.dart @@ -16,12 +16,12 @@ final CommandShortcutEvent exitEditingCommand = CommandShortcutEvent( ); CommandShortcutEventHandler _exitEditingCommandHandler = (editorState) { + if (editorState.mode == VimModes.normalMode) { + return KeyEventResult.handled; + } if (editorState.vimMode == true && editorState.editable == true && editorState.mode == VimModes.insertMode) { - if (editorState.mode == VimModes.normalMode) { - return KeyEventResult.ignored; - } editorState.prevSelection = editorState.selection; editorState.selection = null; editorState.mode = VimModes.normalMode; From 0540036aabd36148acc06ae54061de3ae502c83d Mon Sep 17 00:00:00 2001 From: Sean Riley Hawkins Date: Thu, 27 Mar 2025 05:58:51 +0200 Subject: [PATCH 40/49] fix: handle cases where user wants to go beyond doc length --- .../service/shortcuts/vim/vim_cursor.dart | 42 ++++++++++++++++--- 1 file changed, 37 insertions(+), 5 deletions(-) diff --git a/lib/src/editor/editor_component/service/shortcuts/vim/vim_cursor.dart b/lib/src/editor/editor_component/service/shortcuts/vim/vim_cursor.dart index de8ad766f..6654db21a 100644 --- a/lib/src/editor/editor_component/service/shortcuts/vim/vim_cursor.dart +++ b/lib/src/editor/editor_component/service/shortcuts/vim/vim_cursor.dart @@ -1,10 +1,5 @@ -import 'dart:async'; - import 'package:appflowy_editor/appflowy_editor.dart'; -import 'package:appflowy_editor/src/editor/command/selection_commands.dart'; import 'package:appflowy_editor/src/extensions/vim_shortcut_extensions.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; // import 'package:appflowy_editor/src/editor_state.dart'; const baseKeys = ['h', 'j', 'k', 'l', 'i', 'a', 'o']; @@ -17,6 +12,28 @@ class VimCursor { case 'j': { int tmpPos = count + selection.end.path.first; + Selection bottomLevel = Selection( + start: Position( + path: editorState.document.root.children.last.path, + offset: 0, + ), + end: Position( + path: editorState.document.root.children.last.path, + offset: 0, + ), + ); + if (editorState.selection == bottomLevel) { + return Position( + path: editorState.document.root.children.last.path, + offset: 0, + ); + } + if (count > editorState.document.root.children.length) { + return Position( + path: editorState.document.root.children.last.path, + offset: 0, + ); + } if (tmpPos < editorState.document.root.children.length) { //BUG: This causes editor to say null value on places where offset is empty // Position(path: [tmpPos], offset: selection.end.offset ?? 0); @@ -28,6 +45,21 @@ class VimCursor { case 'k': { int tmpPos = selection.end.path.first - count; + Selection topLevel = Selection( + start: Position(path: [0], offset: 0), + end: Position( + path: [0], + offset: 0, + ), + ); + + if (editorState.selection == topLevel) { + return Position(path: [0], offset: 0); + } + + if (count > editorState.document.root.children.length) { + return Position(path: [0], offset: 0); + } if (tmpPos < editorState.document.root.children.length) { //BUG: This causes editor to say null value on places where offset is empty // Position(path: [tmpPos], offset: selection.end.offset ?? 0); From 9bea1287087bc5852f2a58b613d6d41dbb3dc891 Mon Sep 17 00:00:00 2001 From: Sean Riley Hawkins Date: Thu, 27 Mar 2025 05:59:18 +0200 Subject: [PATCH 41/49] chore: add print statements before testing with AppFlowy --- .../service/shortcuts/vim/vim_cursor.dart | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/lib/src/editor/editor_component/service/shortcuts/vim/vim_cursor.dart b/lib/src/editor/editor_component/service/shortcuts/vim/vim_cursor.dart index 6654db21a..bbf8f5319 100644 --- a/lib/src/editor/editor_component/service/shortcuts/vim/vim_cursor.dart +++ b/lib/src/editor/editor_component/service/shortcuts/vim/vim_cursor.dart @@ -8,10 +8,14 @@ const baseKeys = ['h', 'j', 'k', 'l', 'i', 'a', 'o']; class VimCursor { static Position? processMotionKeys( String key, EditorState editorState, Selection selection, int count) { + //final int docLength = editorState.document.root.children.length; switch (key) { case 'j': { + //print('Here is the doc length!, $docLength'); int tmpPos = count + selection.end.path.first; + //print('counter: $count'); + Selection bottomLevel = Selection( start: Position( path: editorState.document.root.children.last.path, @@ -22,6 +26,7 @@ class VimCursor { offset: 0, ), ); + if (editorState.selection == bottomLevel) { return Position( path: editorState.document.root.children.last.path, @@ -29,6 +34,10 @@ class VimCursor { ); } if (count > editorState.document.root.children.length) { + //print('Found a value out of range!, $count'); + /*print( + 'Here is the doc length!, ${editorState.document.root.children.length}'); + */ return Position( path: editorState.document.root.children.last.path, offset: 0, From 5bbba402f1ff73d63a6f1e99df47d2ee5d892a17 Mon Sep 17 00:00:00 2001 From: Sean Riley Hawkins Date: Thu, 27 Mar 2025 06:00:29 +0200 Subject: [PATCH 42/49] chore: add notes & comments about buffer reset logic --- .../service/shortcuts/vim/vim_fsm.dart | 48 ++++++++++++++----- 1 file changed, 35 insertions(+), 13 deletions(-) diff --git a/lib/src/editor/editor_component/service/shortcuts/vim/vim_fsm.dart b/lib/src/editor/editor_component/service/shortcuts/vim/vim_fsm.dart index 76403b8e7..560507dc9 100644 --- a/lib/src/editor/editor_component/service/shortcuts/vim/vim_fsm.dart +++ b/lib/src/editor/editor_component/service/shortcuts/vim/vim_fsm.dart @@ -7,7 +7,7 @@ import 'package:flutter/services.dart'; import 'package:appflowy_editor/src/editor/command/selection_commands.dart'; import './vim_cursor.dart'; -const baseKeys = ['h', 'j', 'k', 'l', 'i', 'a', 'o']; +const baseKeys = ['h', 'j', 'k', 'l', 'i']; String _buffer = ''; String _deleteBuffer = ''; @@ -57,7 +57,9 @@ Position deleteCurrentLine(EditorState editorState, int count) { final node = editorState.getNodeAtPath(selection.start.path); if (node == null || node.delta == null) return Position(path: [0], offset: 0); final startPath = selection.start.path.first; + //print('start delete point: $startPath'); final endPath = startPath + count - 1; + //print('final end point: $endPath'); final tmpPosition = Position(path: [startPath], offset: 0); final delta = node.delta; for (int i = startPath; i <= endPath; i++) { @@ -86,6 +88,8 @@ Position deleteCurrentLine(EditorState editorState, int count) { String keyBuffer = ''; Timer? resetTimer; +String deleteBuffer = ''; + class VimFSM { KeyEventResult processKey(KeyEvent event, EditorState editorState) { if (event is! KeyDownEvent) { @@ -95,11 +99,13 @@ class VimFSM { resetTimer?.cancel(); resetTimer = Timer(Duration(milliseconds: 500), () { keyBuffer = ""; + _buffer = ""; }); - final key = event.logicalKey.keyLabel.toLowerCase(); if (baseKeys.contains(key)) { + //print('buffer key readout: $_buffer'); final count = _buffer.isNotEmpty ? int.parse(_buffer) : 1; + //print('counter for debugger: $count'); resetBuffer(); final selection = editorState.selection; if (selection == null) return KeyEventResult.ignored; @@ -112,32 +118,42 @@ class VimFSM { Selection.collapsed(newPosition), reason: SelectionUpdateReason.uiEvent, ); + resetBuffer(); return KeyEventResult.handled; } } else { - resetBuffer(); + //BUG: _buffer is causing double keys like 440 instead of 40 + _buffer += key; + deleteBuffer += key; + //NOTE: Resetting the _buffer here will break line jumping + // resetBuffer(); + resetSequenceBuffer(); keyBuffer += key; + /*print( + 'After appending, keyBuffer="$keyBuffer", _buffer="$_buffer", deleteBuffer="$deleteBuffer"'); + */ if (RegExp(r'^\d$').hasMatch(key)) { - if (keyBuffer == 'shift left4' || - keyBuffer == 'shift right4' || + if (_buffer == 'shift left4' || + _buffer == 'shift right4' || event.character == '\$') { vimMoveCursorToEndHandler(editorState); resetSequenceBuffer(); + resetBuffer(); } - if (_buffer.isEmpty && key == '0') { + + if (keyBuffer == '0' && _buffer == '0') { vimMoveCursorToStartHandler(editorState); resetBuffer(); - } else { - _buffer += key; - keyBuffer += key; + resetSequenceBuffer(); } return KeyEventResult.handled; } - if (keyBuffer.endsWith('dd')) { + if (deleteBuffer.endsWith('dd')) { + // final tmpString = deleteBuffer + keyBuffer; final RegExp ddRegExp = RegExp(r'^(\d+)?dd$'); - final match = ddRegExp.firstMatch(keyBuffer); + final match = ddRegExp.firstMatch(deleteBuffer); if (match != null) { final String? countStr = match.group(1); final int count = (countStr != null && countStr.isNotEmpty) @@ -148,16 +164,22 @@ class VimFSM { end: tmpPosition, start: tmpPosition, ); - resetSequenceBuffer(); + deleteBuffer = ''; + keyBuffer = ''; + // _buffer = ''; return KeyEventResult.handled; } } + deleteBuffer = ''; + resetBuffer(); + return KeyEventResult.handled; } + resetSequenceBuffer(); resetBuffer(); - + deleteBuffer = ''; return KeyEventResult.ignored; } From f6b9865e781df8818b62947f5220089ac7fcc35e Mon Sep 17 00:00:00 2001 From: Sean Riley Hawkins Date: Thu, 27 Mar 2025 06:02:35 +0200 Subject: [PATCH 43/49] test: commit working tests for basic movements in vim --- .../vim/movement_keys_test.dart | 170 ++++++++++++++++++ 1 file changed, 170 insertions(+) diff --git a/test/service/shortcut_event/vim/movement_keys_test.dart b/test/service/shortcut_event/vim/movement_keys_test.dart index 894229b4a..d5ae058e4 100644 --- a/test/service/shortcut_event/vim/movement_keys_test.dart +++ b/test/service/shortcut_event/vim/movement_keys_test.dart @@ -147,5 +147,175 @@ void main() async { await editor.dispose(); }); + + testWidgets('vim normal mode move cursor to end', (tester) async { + const text = 'Welcome to Appflowy 😁'; + final editor = tester.editor..addParagraphs(2, initialText: text); + + await editor.startTesting(); + editor.editorState.vimMode = true; + + final selection = Selection.single(path: [1], startOffset: text.length); + await editor.updateSelection(selection); + + await editor.pressKey(key: LogicalKeyboardKey.escape); + + expect(editor.editorState.mode, VimModes.normalMode); + + await editor.pressKey( + key: LogicalKeyboardKey.digit4, isShiftPressed: true); + + expect( + editor.selection, + Selection.single(path: [1], startOffset: text.length), + ); + + await editor.dispose(); + }); + + testWidgets('vim normal mode jump 400 lines down out of bounds', + (tester) async { + const text = 'Welcome to Appflowy 😁'; + final editor = tester.editor..addParagraphs(60, initialText: text); + + await editor.startTesting(); + editor.editorState.vimMode = true; + + final selection = Selection.single(path: [0], startOffset: 0); + await editor.updateSelection(selection); + + print(editor.selection); + await editor.pressKey(key: LogicalKeyboardKey.escape); + + expect(editor.editorState.mode, VimModes.normalMode); + + await editor.pressKey(key: LogicalKeyboardKey.digit4); + + await editor.pressKey(key: LogicalKeyboardKey.digit0); + await editor.pressKey(key: LogicalKeyboardKey.digit0); + + await editor.pressKey(key: LogicalKeyboardKey.keyJ); + print(editor.selection); + expect( + editor.selection, + Selection.single(path: [59], startOffset: 0), + ); + + await editor.dispose(); + }); + + testWidgets('vim normal mode jump 40 lines down', (tester) async { + const text = 'Welcome to Appflowy 😁'; + final editor = tester.editor..addParagraphs(100, initialText: text); + + await editor.startTesting(); + editor.editorState.vimMode = true; + + final selection = Selection.single(path: [0], startOffset: 0); + await editor.updateSelection(selection); + + print(editor.selection); + await editor.pressKey(key: LogicalKeyboardKey.escape); + + expect(editor.editorState.mode, VimModes.normalMode); + + await editor.pressKey(key: LogicalKeyboardKey.digit4); + + await editor.pressKey(key: LogicalKeyboardKey.digit0); + + await editor.pressKey(key: LogicalKeyboardKey.keyJ); + print(editor.selection); + expect( + editor.selection, + Selection.single(path: [40], startOffset: 0), + ); + + await editor.dispose(); + }); + + testWidgets('vim normal mode jump 40 lines up', (tester) async { + const text = 'Welcome to Appflowy 😁'; + final editor = tester.editor..addParagraphs(60, initialText: text); + + await editor.startTesting(); + editor.editorState.vimMode = true; + + final selection = Selection.single(path: [60], startOffset: 0); + await editor.updateSelection(selection); + + await editor.pressKey(key: LogicalKeyboardKey.escape); + + expect(editor.editorState.mode, VimModes.normalMode); + + await editor.pressKey(key: LogicalKeyboardKey.digit4); + + await editor.pressKey(key: LogicalKeyboardKey.digit0); + + await editor.pressKey(key: LogicalKeyboardKey.keyK); + expect( + editor.selection, + Selection.single(path: [20], startOffset: 0), + ); + + await editor.dispose(); + }); + + testWidgets( + 'vim normal mode jump 10 characters to the right on current line', + (tester) async { + const text = 'Welcome to Appflowy 😁'; + final editor = tester.editor..addParagraphs(2, initialText: text); + + await editor.startTesting(); + editor.editorState.vimMode = true; + + final selection = Selection.single(path: [0], startOffset: 0); + await editor.updateSelection(selection); + + await editor.pressKey(key: LogicalKeyboardKey.escape); + + expect(editor.editorState.mode, VimModes.normalMode); + + await editor.pressKey(key: LogicalKeyboardKey.digit1); + + await editor.pressKey(key: LogicalKeyboardKey.digit0); + + await editor.pressKey(key: LogicalKeyboardKey.keyL); + expect( + editor.selection, + Selection.single(path: [0], startOffset: 10), + ); + + await editor.dispose(); + }); + + testWidgets( + 'vim normal mode jump 10 characters to the left on current line', + (tester) async { + const text = 'Welcome to Appflowy 😁'; + final editor = tester.editor..addParagraphs(2, initialText: text); + + await editor.startTesting(); + editor.editorState.vimMode = true; + + final selection = Selection.single(path: [0], startOffset: text.length); + await editor.updateSelection(selection); + + await editor.pressKey(key: LogicalKeyboardKey.escape); + + expect(editor.editorState.mode, VimModes.normalMode); + + await editor.pressKey(key: LogicalKeyboardKey.digit1); + + await editor.pressKey(key: LogicalKeyboardKey.digit0); + + await editor.pressKey(key: LogicalKeyboardKey.keyH); + expect( + editor.selection, + Selection.single(path: [0], startOffset: 11), + ); + + await editor.dispose(); + }); }); } From 5eb397c01b5203bc0cd768ccac40d3b12e50a604 Mon Sep 17 00:00:00 2001 From: Sean Riley Hawkins Date: Mon, 31 Mar 2025 21:28:02 +0200 Subject: [PATCH 44/49] feat: handle extra vim keys - Handle 'o' in Normal Mode which creates a new line and allows input. - Handle 'a' in Normal Mode which allows insert on next character in the same line. - Handle 'w' which navigates the cursor forward by a 'word'. --- .../service/shortcuts/vim/vim_cursor.dart | 158 +++++++++++++++++- 1 file changed, 157 insertions(+), 1 deletion(-) diff --git a/lib/src/editor/editor_component/service/shortcuts/vim/vim_cursor.dart b/lib/src/editor/editor_component/service/shortcuts/vim/vim_cursor.dart index bbf8f5319..de0cc8d7a 100644 --- a/lib/src/editor/editor_component/service/shortcuts/vim/vim_cursor.dart +++ b/lib/src/editor/editor_component/service/shortcuts/vim/vim_cursor.dart @@ -2,7 +2,101 @@ import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor/src/extensions/vim_shortcut_extensions.dart'; // import 'package:appflowy_editor/src/editor_state.dart'; -const baseKeys = ['h', 'j', 'k', 'l', 'i', 'a', 'o']; +void moveCursor( + EditorState editorState, + SelectionMoveDirection direction, [ + SelectionMoveRange range = SelectionMoveRange.character, +]) { + final selection = editorState.selection?.normalized; + if (selection == null) { + return; + } + + // If the selection is not collapsed, then we want to collapse the selection + if (!selection.isCollapsed && range != SelectionMoveRange.line) { + // move the cursor to the start or end of the selection + editorState.selection = selection.collapse( + atStart: direction == SelectionMoveDirection.forward, + ); + return; + } + + final node = editorState.getNodeAtPath(selection.start.path); + if (node == null) { + return; + } + + // Originally, I want to make this function as pure as possible, + // but I have to import the selectable here to compute the selection. + final start = node.selectable?.start(); + final end = node.selectable?.end(); + final offset = direction == SelectionMoveDirection.forward + ? selection.startIndex + : selection.endIndex; + { + // the cursor is at the start of the node + // move the cursor to the end of the previous node + if (direction == SelectionMoveDirection.forward && + start != null && + start.offset >= offset) { + final previousEnd = node + .previousNodeWhere((element) => element.selectable != null) + ?.selectable + ?.end(); + if (previousEnd != null) { + editorState.updateSelectionWithReason( + Selection.collapsed(previousEnd), + reason: SelectionUpdateReason.uiEvent, + ); + } + return; + } + // the cursor is at the end of the node + // move the cursor to the start of the next node + else if (direction == SelectionMoveDirection.backward && + end != null && + end.offset <= offset) { + //Would have to handle if text has reached document end + if (end.offset == offset) { + editorState.insertText(end.offset, ' ', node: node); + final pos = Position(path: node.path, offset: end.offset + 1); + if (node.selectable?.end() != null) { + editorState.updateSelectionWithReason( + Selection.collapsed(pos), + reason: SelectionUpdateReason.uiEvent, + ); + } + } + + return; + } + } + + final delta = node.delta; + switch (range) { + case SelectionMoveRange.character: + if (delta != null) { + // move the cursor to the left or right by one character + editorState.updateSelectionWithReason( + Selection.collapsed( + selection.start.copyWith( + offset: direction == SelectionMoveDirection.forward + ? delta.prevRunePosition(offset) + : delta.nextRunePosition(offset), + ), + ), + reason: SelectionUpdateReason.uiEvent, + ); + } else { + throw UnimplementedError(); + } + break; + default: + throw UnimplementedError(); + } +} + +const baseKeys = ['h', 'j', 'k', 'l', 'i', 'a', 'o', 'w']; // String buffer = ''; class VimCursor { @@ -102,6 +196,68 @@ class VimCursor { offset: editorState.selection!.end.offset, ); } + + case 'a': + { + editorState.editable = true; + editorState.mode = VimModes.insertMode; + editorState.selection = editorState.selection; + + moveCursor(editorState, SelectionMoveDirection.backward); + + editorState.selectionService.updateSelection(editorState.selection); + editorState.prevSelection = null; + return Position( + path: editorState.selection!.end.path, + offset: editorState.selection!.end.offset, + ); + } + + case 'o': + { + editorState.editable = true; + editorState.mode = VimModes.insertMode; + editorState.selection = editorState.selection; + +//insertNewLine selects the old text and carries it over to the new line? +//NOTE: Manually build node and perform transaction + final transaction = editorState.transaction; + final nextPosition = Position( + path: [editorState.selection!.end.path.first + 1], + offset: 0, + ); + final Node blankLine = paragraphNode(text: ''); + transaction.insertNode(nextPosition.path, blankLine); + transaction.afterSelection = Selection.collapsed(nextPosition); + transaction.customSelectionType = SelectionType.inline; + editorState.apply(transaction).then((value) => {}); + + editorState.selection = editorState.selection; + editorState.selectionService.updateSelection(editorState.selection); + editorState.prevSelection = null; + //manually insert whitespace if next node is not present? + + return Position( + path: editorState.selection!.end.path, + offset: editorState.selection!.end.offset, + ); + } + case 'w': + { + editorState.selection = editorState.selection; + + editorState.moveCursor( + SelectionMoveDirection.backward, SelectionMoveRange.word); + + editorState.selection = editorState.selection; + editorState.selectionService.updateSelection(editorState.selection); + //manually insert whitespace if next node is not present? + + return Position( + path: editorState.selection!.end.path, + offset: editorState.selection!.end.offset, + ); + } default: return null; } From 9e413d3dde40059b31902eb92f1c8f3e952784ee Mon Sep 17 00:00:00 2001 From: Sean Riley Hawkins Date: Mon, 31 Mar 2025 21:29:03 +0200 Subject: [PATCH 45/49] refactor: use a vim state class to manage the buffer - By using the vim state class we can ensure that no key sequence is missed by both the user and flutter tests. - Also makes it easier to add/remove key handlers. --- .../service/shortcuts/vim/vim_fsm.dart | 173 +++++++----------- 1 file changed, 70 insertions(+), 103 deletions(-) diff --git a/lib/src/editor/editor_component/service/shortcuts/vim/vim_fsm.dart b/lib/src/editor/editor_component/service/shortcuts/vim/vim_fsm.dart index 560507dc9..9a3a0b24b 100644 --- a/lib/src/editor/editor_component/service/shortcuts/vim/vim_fsm.dart +++ b/lib/src/editor/editor_component/service/shortcuts/vim/vim_fsm.dart @@ -7,49 +7,33 @@ import 'package:flutter/services.dart'; import 'package:appflowy_editor/src/editor/command/selection_commands.dart'; import './vim_cursor.dart'; -const baseKeys = ['h', 'j', 'k', 'l', 'i']; +const baseKeys = ['h', 'j', 'k', 'l', 'i', 'a', 'o', 'w']; -String _buffer = ''; -String _deleteBuffer = ''; - -CommandShortcutEventHandler vimMoveCursorToStartHandler = (editorState) { - if (!editorState.vimMode) { - return KeyEventResult.ignored; - } +void vimMoveCursorToStartHandler(EditorState editorState) { + if (!editorState.vimMode) {} if (editorState.mode == VimModes.normalMode) { final selection = editorState.selection; - if (selection == null) { - return KeyEventResult.ignored; - } + if (selection == null) {} if (isRTL(editorState)) { editorState.moveCursorBackward(SelectionMoveRange.line); } else { editorState.moveCursorForward(SelectionMoveRange.line); } - return KeyEventResult.handled; } - return KeyEventResult.ignored; -}; +} -CommandShortcutEventHandler vimMoveCursorToEndHandler = (editorState) { - if (!editorState.vimMode) { - return KeyEventResult.ignored; - } +void vimMoveCursorToEndHandler(EditorState editorState) { + if (!editorState.vimMode) {} if (editorState.mode == VimModes.normalMode) { final selection = editorState.selection; - if (selection == null) { - return KeyEventResult.ignored; - } + if (selection == null) {} if (isRTL(editorState)) { editorState.moveCursorForward(SelectionMoveRange.line); } else { editorState.moveCursorBackward(SelectionMoveRange.line); } - - return KeyEventResult.handled; } - return KeyEventResult.ignored; -}; +} Position deleteCurrentLine(EditorState editorState, int count) { final selection = editorState.selection; @@ -57,9 +41,7 @@ Position deleteCurrentLine(EditorState editorState, int count) { final node = editorState.getNodeAtPath(selection.start.path); if (node == null || node.delta == null) return Position(path: [0], offset: 0); final startPath = selection.start.path.first; - //print('start delete point: $startPath'); final endPath = startPath + count - 1; - //print('final end point: $endPath'); final tmpPosition = Position(path: [startPath], offset: 0); final delta = node.delta; for (int i = startPath; i <= endPath; i++) { @@ -69,9 +51,7 @@ Position deleteCurrentLine(EditorState editorState, int count) { end: Position(path: selection.end.path, offset: delta.length)); editorState.selection = deletionSelection; final transaction = editorState.transaction; - //transaction.deleteText(node, 0, node.delta!.length); transaction.deleteNodesAtPath(editorState.selection!.start.path); - // transaction.deleteNode(node); editorState .apply(transaction) @@ -85,28 +65,37 @@ Position deleteCurrentLine(EditorState editorState, int count) { return tmpPosition; } -String keyBuffer = ''; -Timer? resetTimer; +class VimState { + String commandBuffer = ''; + String deleteBuffer = ''; + Timer? resetTimer; + void reset() { + commandBuffer = ''; + deleteBuffer = ''; + resetTimer?.cancel(); + } -String deleteBuffer = ''; + void scheduleReset() { + resetTimer?.cancel(); + resetTimer = Timer(const Duration(milliseconds: 500), reset); + } +} class VimFSM { + static final VimFSM _instance = VimFSM._internal(); + factory VimFSM() => _instance; + VimFSM._internal(); + final _state = VimState(); + KeyEventResult processKey(KeyEvent event, EditorState editorState) { if (event is! KeyDownEvent) { return KeyEventResult.ignored; } + _state.scheduleReset(); - resetTimer?.cancel(); - resetTimer = Timer(Duration(milliseconds: 500), () { - keyBuffer = ""; - _buffer = ""; - }); final key = event.logicalKey.keyLabel.toLowerCase(); if (baseKeys.contains(key)) { - //print('buffer key readout: $_buffer'); - final count = _buffer.isNotEmpty ? int.parse(_buffer) : 1; - //print('counter for debugger: $count'); - resetBuffer(); + final count = _parseCount(_state.commandBuffer); final selection = editorState.selection; if (selection == null) return KeyEventResult.ignored; @@ -118,77 +107,55 @@ class VimFSM { Selection.collapsed(newPosition), reason: SelectionUpdateReason.uiEvent, ); - resetBuffer(); return KeyEventResult.handled; } - } else { - //BUG: _buffer is causing double keys like 440 instead of 40 - _buffer += key; - deleteBuffer += key; - //NOTE: Resetting the _buffer here will break line jumping - // resetBuffer(); - resetSequenceBuffer(); - - keyBuffer += key; - /*print( - 'After appending, keyBuffer="$keyBuffer", _buffer="$_buffer", deleteBuffer="$deleteBuffer"'); - */ - if (RegExp(r'^\d$').hasMatch(key)) { - if (_buffer == 'shift left4' || - _buffer == 'shift right4' || - event.character == '\$') { - vimMoveCursorToEndHandler(editorState); - resetSequenceBuffer(); - resetBuffer(); - } - - if (keyBuffer == '0' && _buffer == '0') { - vimMoveCursorToStartHandler(editorState); - resetBuffer(); - resetSequenceBuffer(); - } - return KeyEventResult.handled; - } - - if (deleteBuffer.endsWith('dd')) { - // final tmpString = deleteBuffer + keyBuffer; - final RegExp ddRegExp = RegExp(r'^(\d+)?dd$'); - final match = ddRegExp.firstMatch(deleteBuffer); - if (match != null) { - final String? countStr = match.group(1); - final int count = (countStr != null && countStr.isNotEmpty) - ? int.parse(countStr) - : 1; - final tmpPosition = deleteCurrentLine(editorState, count); - editorState.selection = Selection( - end: tmpPosition, - start: tmpPosition, - ); - deleteBuffer = ''; - keyBuffer = ''; - // _buffer = ''; - return KeyEventResult.handled; - } - } - - deleteBuffer = ''; - resetBuffer(); - + } + String keySequence = _buildKeySequence(event); + _state.commandBuffer += keySequence; + if (_handleSpecialCommands(editorState)) { return KeyEventResult.handled; } - resetSequenceBuffer(); - resetBuffer(); - deleteBuffer = ''; return KeyEventResult.ignored; } - void resetBuffer() { - _buffer = ''; + int _parseCount(String buffer) { + final match = RegExp(r'^(\d+)').firstMatch(buffer); + if (match != null) { + return int.tryParse(match.group(1) ?? '') ?? 1; + } + return 1; } - void resetSequenceBuffer() { - keyBuffer = ""; - resetTimer?.cancel(); + String _buildKeySequence(KeyEvent event) { + final key = event.logicalKey.keyLabel.toLowerCase(); + if (key.startsWith('shift')) { + return key; + } + return key; + } + +//TODO: Handle other keys from vim.dart + bool _handleSpecialCommands(EditorState editorState) { + final buffer = _state.commandBuffer; + if (buffer == 'shift left4' || buffer == 'shift right4') { + vimMoveCursorToEndHandler(editorState); + _state.reset(); + return true; + } + + if (buffer == '0') { + vimMoveCursorToStartHandler(editorState); + _state.reset(); + return true; + } + if (buffer.endsWith('dd')) { + final count = _parseCount(buffer.substring(0, buffer.length - 2)); + final position = deleteCurrentLine(editorState, count); + editorState.selection = Selection(start: position, end: position); + _state.reset(); + return true; + } + return false; } } From 64512c7ed0ce8176ae2eda63104fc7a423b560f7 Mon Sep 17 00:00:00 2001 From: Sean Riley Hawkins Date: Mon, 31 Mar 2025 21:35:36 +0200 Subject: [PATCH 46/49] test: add test for extra vim keys --- .../vim/movement_keys_test.dart | 27 +++++- .../vim/shortcut_keys_test.dart | 95 ++++++++++++++++--- 2 files changed, 103 insertions(+), 19 deletions(-) diff --git a/test/service/shortcut_event/vim/movement_keys_test.dart b/test/service/shortcut_event/vim/movement_keys_test.dart index d5ae058e4..66d24b64b 100644 --- a/test/service/shortcut_event/vim/movement_keys_test.dart +++ b/test/service/shortcut_event/vim/movement_keys_test.dart @@ -184,7 +184,6 @@ void main() async { final selection = Selection.single(path: [0], startOffset: 0); await editor.updateSelection(selection); - print(editor.selection); await editor.pressKey(key: LogicalKeyboardKey.escape); expect(editor.editorState.mode, VimModes.normalMode); @@ -195,7 +194,6 @@ void main() async { await editor.pressKey(key: LogicalKeyboardKey.digit0); await editor.pressKey(key: LogicalKeyboardKey.keyJ); - print(editor.selection); expect( editor.selection, Selection.single(path: [59], startOffset: 0), @@ -214,7 +212,6 @@ void main() async { final selection = Selection.single(path: [0], startOffset: 0); await editor.updateSelection(selection); - print(editor.selection); await editor.pressKey(key: LogicalKeyboardKey.escape); expect(editor.editorState.mode, VimModes.normalMode); @@ -224,7 +221,6 @@ void main() async { await editor.pressKey(key: LogicalKeyboardKey.digit0); await editor.pressKey(key: LogicalKeyboardKey.keyJ); - print(editor.selection); expect( editor.selection, Selection.single(path: [40], startOffset: 0), @@ -288,6 +284,29 @@ void main() async { await editor.dispose(); }); + testWidgets('vim normal mode navigate by word', (tester) async { + const text = 'Welcome to Appflowy 😁'; + final editor = tester.editor..addParagraphs(2, initialText: text); + + await editor.startTesting(); + editor.editorState.vimMode = true; + + final selection = Selection.single(path: [0], startOffset: 0); + await editor.updateSelection(selection); + + await editor.pressKey(key: LogicalKeyboardKey.escape); + + expect(editor.editorState.mode, VimModes.normalMode); + + await editor.pressKey(key: LogicalKeyboardKey.keyW); + + expect( + editor.selection, + Selection.single(path: [0], startOffset: 7), + ); + + await editor.dispose(); + }); testWidgets( 'vim normal mode jump 10 characters to the left on current line', diff --git a/test/service/shortcut_event/vim/shortcut_keys_test.dart b/test/service/shortcut_event/vim/shortcut_keys_test.dart index f14fe40e3..176943bf7 100644 --- a/test/service/shortcut_event/vim/shortcut_keys_test.dart +++ b/test/service/shortcut_event/vim/shortcut_keys_test.dart @@ -9,9 +9,8 @@ void main() async { setUpAll(() { TestWidgetsFlutterBinding.ensureInitialized(); }); - group('vim.dart', () { - testWidgets('vim normal mode move cursor to end', (tester) async { + testWidgets('vim normal mode move cursor to start', (tester) async { const text1 = 'Welcome to Appflowy 😁'; const text2 = 'Welcome'; final editor = tester.editor @@ -21,7 +20,36 @@ void main() async { await editor.startTesting(); editor.editorState.vimMode = true; - final selection = Selection.collapsed(Position(path: [1])); + final selection = + Selection.collapsed(Position(path: [1], offset: text2.length)); + await editor.updateSelection(selection); + + await editor.pressKey(key: LogicalKeyboardKey.escape); + expect(editor.editorState.mode, VimModes.normalMode); + + await editor.pressKey( + key: LogicalKeyboardKey.digit0, + ); + + expect( + editor.selection, + Selection.single(path: [1], startOffset: 0), + ); + + await editor.dispose(); + }); + testWidgets('vim normal mode move cursor to end from start', + (tester) async { + const text1 = 'Welcome to Appflowy 😁'; + const text2 = 'Welcome'; + final editor = tester.editor + ..addParagraph(initialText: text1) + ..addParagraph(initialText: text2); + + await editor.startTesting(); + editor.editorState.vimMode = true; + + final selection = Selection.collapsed(Position(path: [1], offset: 0)); await editor.updateSelection(selection); await editor.pressKey(key: LogicalKeyboardKey.escape); @@ -31,7 +59,6 @@ void main() async { key: LogicalKeyboardKey.digit4, isShiftPressed: true, ); - if (Platform.isWindows || Platform.isLinux) { expect( editor.selection, @@ -42,12 +69,10 @@ void main() async { await editor.dispose(); }); - testWidgets('vim normal mode move cursor to start', (tester) async { + testWidgets('vim normal mode delete 2 lines (2dd)', (tester) async { const text1 = 'Welcome to Appflowy 😁'; const text2 = 'Welcome'; - final editor = tester.editor - ..addParagraph(initialText: text1) - ..addParagraph(initialText: text2); + final editor = tester.editor..addParagraphs(6, initialText: text1); await editor.startTesting(); editor.editorState.vimMode = true; @@ -60,15 +85,55 @@ void main() async { expect(editor.editorState.mode, VimModes.normalMode); await editor.pressKey( - key: LogicalKeyboardKey.digit0, + key: LogicalKeyboardKey.digit2, ); - if (Platform.isWindows || Platform.isLinux) { - expect( - editor.selection, - Selection.single(path: [1], startOffset: 0), - ); - } + await editor.pressKey( + key: LogicalKeyboardKey.keyD, + ); + + await editor.pressKey( + key: LogicalKeyboardKey.keyD, + ); + + expect(editor.document.root.children.length, equals(4)); + + expect( + editor.selection, + Selection.single(path: [1], startOffset: 0), + ); + + await editor.dispose(); + }); + testWidgets('vim normal mode delete 1 line (dd)', (tester) async { + const text1 = 'Welcome to Appflowy 😁'; + const text2 = 'Welcome'; + final editor = tester.editor..addParagraphs(6, initialText: text1); + + await editor.startTesting(); + editor.editorState.vimMode = true; + + final selection = + Selection.collapsed(Position(path: [1], offset: text2.length)); + await editor.updateSelection(selection); + + await editor.pressKey(key: LogicalKeyboardKey.escape); + expect(editor.editorState.mode, VimModes.normalMode); + + await editor.pressKey( + key: LogicalKeyboardKey.keyD, + ); + + await editor.pressKey( + key: LogicalKeyboardKey.keyD, + ); + + expect(editor.document.root.children.length, equals(5)); + + expect( + editor.selection, + Selection.single(path: [1], startOffset: 0), + ); await editor.dispose(); }); From 1f5cc3154470963318d7f57808215f398a39192c Mon Sep 17 00:00:00 2001 From: Sean Riley Hawkins Date: Sat, 12 Apr 2025 06:25:02 +0200 Subject: [PATCH 47/49] feat: implement undo & redo in vim - The redo only happens for 1 step. It would require looking into enhancing it. - Removed some keys from vim.dart and put them in vim_fsm.dart --- .../service/shortcuts/command/vim.dart | 562 ------------------ .../service/shortcuts/vim/vim_cursor.dart | 53 +- .../service/shortcuts/vim/vim_fsm.dart | 40 +- 3 files changed, 80 insertions(+), 575 deletions(-) diff --git a/lib/src/editor/editor_component/service/shortcuts/command/vim.dart b/lib/src/editor/editor_component/service/shortcuts/command/vim.dart index 8adedecc2..291e5d966 100644 --- a/lib/src/editor/editor_component/service/shortcuts/command/vim.dart +++ b/lib/src/editor/editor_component/service/shortcuts/command/vim.dart @@ -5,9 +5,6 @@ import 'dart:math'; final List vimKeyModes = [ ///Insert Methods - insertOnNewLineCommand, - insertInlineCommand, - insertNextInlineCommand, ///Vim Movements /* @@ -46,8 +43,6 @@ final List vimKeyModes = [ ///Navigate line Commands // vimMoveCursorToStartCommand, //vimMoveCursorToEndCommand, - jumpWordBackwardCommand, - jumpWordForwardCommand, //BUG: Selection doesnt show up to user // vimSelectLineCommand, @@ -57,220 +52,6 @@ final List vimKeyModes = [ // vimDeleteUnderCursorCommand, ]; -/// Insert trigger keys -final CommandShortcutEvent insertOnNewLineCommand = CommandShortcutEvent( - key: 'insert new line below previous selection', - command: 'o', - handler: _insertOnNewLineCommandHandler, - getDescription: () => AppFlowyEditorL10n.current.cmdInsertBelow, -); - -CommandShortcutEventHandler _insertOnNewLineCommandHandler = (editorState) { - if (!editorState.vimMode) { - return KeyEventResult.ignored; - } - final afKeyboard = editorState.service.keyboardServiceKey; - - if (afKeyboard.currentState != null && - afKeyboard.currentState is AppFlowyKeyboardService) { - if (editorState.selection == null || editorState.prevSelection != null) { - //NOTE: Call editable first before changing mode - editorState.editable = true; - editorState.mode = VimModes.insertMode; - editorState.selection = editorState.selection; - editorState.insertNewLine(); - editorState.selectionService.updateSelection(editorState.selection); - editorState.prevSelection = null; - return KeyEventResult.handled; - } else { - return KeyEventResult.ignored; - } - } - return KeyEventResult.ignored; -}; - -final CommandShortcutEvent insertInlineCommand = CommandShortcutEvent( - key: 'enter insert mode from previous selection', - command: 'i', - handler: _insertInlineCommandHandler, - getDescription: () => AppFlowyEditorL10n.current.cmdInsertCurrentPos, -); - -CommandShortcutEventHandler _insertInlineCommandHandler = (editorState) { - if (!editorState.vimMode) { - return KeyEventResult.ignored; - } - final afKeyboard = editorState.service.keyboardServiceKey; - if (afKeyboard.currentState != null && - afKeyboard.currentState is AppFlowyKeyboardService) { - if (editorState.selection == null || editorState.prevSelection != null) { - //NOTE: Call editable first before changing mode - editorState.editable = true; - editorState.mode = VimModes.insertMode; - editorState.selection = editorState.selection; - editorState.selectionService.updateSelection(editorState.selection); - editorState.prevSelection = null; - return KeyEventResult.handled; - } else { - return KeyEventResult.ignored; - } - } - return KeyEventResult.ignored; -}; - -final CommandShortcutEvent insertNextInlineCommand = CommandShortcutEvent( - key: 'enter insert mode on next character', - command: 'a', - handler: _insertNextInlineCommandHandler, - getDescription: () => AppFlowyEditorL10n.current.cmdInsertNextPos, -); - -CommandShortcutEventHandler _insertNextInlineCommandHandler = (editorState) { - if (!editorState.vimMode) { - return KeyEventResult.ignored; - } - final afKeyboard = editorState.service.keyboardServiceKey; - if (afKeyboard.currentState != null && - afKeyboard.currentState is AppFlowyKeyboardService) { - if (editorState.selection == null || editorState.prevSelection != null) { - //NOTE: Call editable first before changing mode - editorState.editable = true; - editorState.mode = VimModes.insertMode; - editorState.selection = editorState.selection; - editorState.moveCursor(SelectionMoveDirection.backward); - editorState.selectionService.updateSelection(editorState.selection); - editorState.prevSelection = null; - return KeyEventResult.handled; - } else { - return KeyEventResult.ignored; - } - } - return KeyEventResult.ignored; -}; - -/* -/// Motion Keys -final CommandShortcutEvent jumpDownCommand = CommandShortcutEvent( - key: 'move the cursor downward in normal mode', - command: 'j', - handler: _jumpDownCommandHandler, - getDescription: () => AppFlowyEditorL10n.current.cmdJumpDown, -); - -CommandShortcutEventHandler _jumpDownCommandHandler = (editorState) { - if (!editorState.vimMode) { - return KeyEventResult.ignored; - } - final afKeyboard = editorState.service.keyboardServiceKey; - if (afKeyboard.currentState != null && - afKeyboard.currentState is AppFlowyKeyboardService) { - if (editorState.selection != null && - editorState.mode == VimModes.normalMode) { - final selection = editorState.selection; - final downPosition = - selection?.end.moveVertical(editorState, upwards: false); - editorState.updateSelectionWithReason( - downPosition == null ? null : Selection.collapsed(downPosition), - reason: SelectionUpdateReason.uiEvent); - return KeyEventResult.handled; - } else { - return KeyEventResult.ignored; - } - } - return KeyEventResult.ignored; -}; - -final CommandShortcutEvent jumpUpCommand = CommandShortcutEvent( - key: 'move the cursor upward in normal mode', - command: 'k', - handler: _jumpUpCommandHandler, - getDescription: () => AppFlowyEditorL10n.current.cmdJumpUp, -); - -CommandShortcutEventHandler _jumpUpCommandHandler = (editorState) { - if (!editorState.vimMode) { - return KeyEventResult.ignored; - } - final afKeyboard = editorState.service.keyboardServiceKey; - if (afKeyboard.currentState != null && - afKeyboard.currentState is AppFlowyKeyboardService) { - // editorState.scrollService!.goBallistic(4); - if (editorState.selection != null && - editorState.mode == VimModes.normalMode) { - final selection = editorState.selection; - final upPosition = - selection?.end.moveVertical(editorState, upwards: true); - editorState.updateSelectionWithReason( - upPosition == null ? null : Selection.collapsed(upPosition), - reason: SelectionUpdateReason.uiEvent, - ); - return KeyEventResult.handled; - } else { - return KeyEventResult.ignored; - } - } - return KeyEventResult.ignored; -}; - -final CommandShortcutEvent jumpLeftCommand = CommandShortcutEvent( - key: 'move the cursor to the left in normal mode', - command: 'h', - handler: _jumpLeftCommandHandler, - getDescription: () => AppFlowyEditorL10n.current.cmdJumpLeft, -); - -CommandShortcutEventHandler _jumpLeftCommandHandler = (editorState) { - if (!editorState.vimMode) { - return KeyEventResult.ignored; - } - final afKeyboard = editorState.service.keyboardServiceKey; - if (afKeyboard.currentState != null && - afKeyboard.currentState is AppFlowyKeyboardService) { - if (editorState.selection != null && - editorState.mode == VimModes.normalMode) { - if (isRTL(editorState)) { - editorState.moveCursorBackward(SelectionMoveRange.character); - } else { - editorState.moveCursorForward(SelectionMoveRange.character); - } - return KeyEventResult.handled; - } else { - return KeyEventResult.ignored; - } - } - return KeyEventResult.ignored; -}; - -final CommandShortcutEvent jumpRightCommand = CommandShortcutEvent( - key: 'move the cursor to the right in normal mode', - command: 'l', - handler: _jumpRightCommandHandler, - getDescription: () => AppFlowyEditorL10n.current.cmdJumpRight, -); - -CommandShortcutEventHandler _jumpRightCommandHandler = (editorState) { - if (!editorState.vimMode) { - return KeyEventResult.ignored; - } - final afKeyboard = editorState.service.keyboardServiceKey; - if (afKeyboard.currentState != null && - afKeyboard.currentState is AppFlowyKeyboardService) { - if (editorState.selection != null && - editorState.mode == VimModes.normalMode) { - if (isRTL(editorState)) { - editorState.moveCursorBackward(SelectionMoveRange.character); - } else { - editorState.moveCursorForward(SelectionMoveRange.character); - } - return KeyEventResult.handled; - } else { - return KeyEventResult.ignored; - } - } - return KeyEventResult.ignored; -}; -*/ - //BUG: Selection does not show up in normal mode final CommandShortcutEvent vimSelectLineCommand = CommandShortcutEvent( key: 'enter insert mode from previous selection', @@ -314,61 +95,6 @@ CommandShortcutEventHandler _vimSelectLineCommandHandler = (editorState) { } return KeyEventResult.ignored; }; -final CommandShortcutEvent vimUndoCommand = CommandShortcutEvent( - key: 'vim undo in normal mode', - command: 'u', - handler: _vimUndoCommandHandler, - getDescription: () => AppFlowyEditorL10n.current.cmdVimUndo, -); - -CommandShortcutEventHandler _vimUndoCommandHandler = (editorState) { - if (!editorState.vimMode) { - return KeyEventResult.ignored; - } - final afKeyboard = editorState.service.keyboardServiceKey; - if (afKeyboard.currentState != null && - afKeyboard.currentState is AppFlowyKeyboardService) { - if (editorState.mode == VimModes.normalMode) { - //BUG: undo doesnt work in Normal mode - //NOTE: Could be something to do with selection - /* - The cursor does update but also disappears so makes it hard - for the block cursor to follow & track the current position - */ - editorState.undoManager.undo(); - return KeyEventResult.handled; - } else { - return KeyEventResult.ignored; - } - } - return KeyEventResult.ignored; -}; - -final CommandShortcutEvent vimRedoCommand = CommandShortcutEvent( - key: 'vim redo in normal mode', - command: 'ctrl+r', - handler: _vimRedoCommandHandler, - getDescription: () => AppFlowyEditorL10n.current.cmdVimRedo, -); - -CommandShortcutEventHandler _vimRedoCommandHandler = (editorState) { - if (!editorState.vimMode) { - return KeyEventResult.ignored; - } - final afKeyboard = editorState.service.keyboardServiceKey; - if (afKeyboard.currentState != null && - afKeyboard.currentState is AppFlowyKeyboardService) { - if (editorState.selection != null && - editorState.mode == VimModes.normalMode) { - //BUG: This also doesnt work in Normal mode - editorState.undoManager.redo(); - return KeyEventResult.handled; - } else { - return KeyEventResult.ignored; - } - } - return KeyEventResult.ignored; -}; final CommandShortcutEvent vimPageDownCommand = CommandShortcutEvent( key: 'scroll one page down in normal mode', @@ -530,291 +256,3 @@ CommandShortcutEventHandler _vimHalfPageUpCommandHandler = (editorState) { } return KeyEventResult.ignored; }; - -final CommandShortcutEvent vimMoveCursorToEndCommand = CommandShortcutEvent( - key: 'vim move cursor to end of line in normal mode', - //NOTE: Used Digit 4, dollar sign would throw error - command: 'shift+Digit 4', - handler: _vimMoveCursorToEndHandler, - getDescription: () => AppFlowyEditorL10n.current.cmdVimJumpEndChar, -); - -CommandShortcutEventHandler _vimMoveCursorToEndHandler = (editorState) { - if (!editorState.vimMode) { - return KeyEventResult.ignored; - } - // final afKeyboard = editorState.service.keyboardServiceKey; - if (editorState.mode == VimModes.normalMode) { - final selection = editorState.selection; - if (selection == null) { - return KeyEventResult.ignored; - } - if (isRTL(editorState)) { - editorState.moveCursorForward(SelectionMoveRange.line); - } else { - editorState.moveCursorBackward(SelectionMoveRange.line); - } - return KeyEventResult.handled; - } - return KeyEventResult.ignored; -}; - -final CommandShortcutEvent jumpWordBackwardCommand = CommandShortcutEvent( - key: 'move the cursor backward to the next word in normal mode', - command: 'b', - handler: _jumpWordBackwardCommandHandler, - getDescription: () => AppFlowyEditorL10n.current.cmdVimBackWordJump, -); - -CommandShortcutEventHandler _jumpWordBackwardCommandHandler = (editorState) { - if (!editorState.vimMode) { - return KeyEventResult.ignored; - } - final afKeyboard = editorState.service.keyboardServiceKey; - if (afKeyboard.currentState != null && - afKeyboard.currentState is AppFlowyKeyboardService) { - if (editorState.selection != null && - editorState.mode == VimModes.normalMode) { - final selection = editorState.selection; - - final node = editorState.getNodeAtPath(selection!.end.path); - final delta = node?.delta; - - if (node == null || delta == null) { - return KeyEventResult.ignored; - } - - if (isRTL(editorState)) { - final endOfWord = selection.end.moveHorizontal( - editorState, - forward: false, - selectionRange: SelectionRange.word, - ); - final selectedWord = delta.toPlainText().substring( - selection.end.offset, - endOfWord?.offset, - ); - // check if the selected word is whitespace - if (selectedWord.trim().isEmpty) { - editorState.moveCursorBackward(SelectionMoveRange.word); - } - editorState.moveCursorBackward(SelectionMoveRange.word); - } else { - final startOfWord = selection.end.moveHorizontal( - editorState, - selectionRange: SelectionRange.word, - ); - if (startOfWord == null) { - return KeyEventResult.handled; - } - final selectedWord = delta.toPlainText().substring( - startOfWord.offset, - selection.end.offset, - ); - // check if the selected word is whitespace - if (selectedWord.trim().isEmpty) { - editorState.moveCursorForward(SelectionMoveRange.word); - } - editorState.moveCursorForward(SelectionMoveRange.word); - } - - return KeyEventResult.handled; - } else { - return KeyEventResult.ignored; - } - } - return KeyEventResult.ignored; -}; - -final CommandShortcutEvent jumpWordForwardCommand = CommandShortcutEvent( - key: 'move the cursor backward to the next word in normal mode', - command: 'w', - handler: _jumpWordForwardCommandHandler, - getDescription: () => AppFlowyEditorL10n.current.cmdVimForwardWordJump, -); - -CommandShortcutEventHandler _jumpWordForwardCommandHandler = (editorState) { - if (!editorState.vimMode) { - return KeyEventResult.ignored; - } - final afKeyboard = editorState.service.keyboardServiceKey; - if (afKeyboard.currentState != null && - afKeyboard.currentState is AppFlowyKeyboardService) { - if (editorState.selection != null && - editorState.mode == VimModes.normalMode) { - final selection = editorState.selection; - final node = editorState.getNodeAtPath(selection!.end.path); - final delta = node?.delta; - - if (node == null || delta == null) { - return KeyEventResult.ignored; - } - - if (isRTL(editorState)) { - final startOfWord = selection.end.moveHorizontal( - editorState, - selectionRange: SelectionRange.word, - ); - if (startOfWord == null) { - return KeyEventResult.ignored; - } - final selectedWord = delta.toPlainText().substring( - startOfWord.offset, - selection.end.offset, - ); - // check if the selected word is whitespace - if (selectedWord.trim().isEmpty) { - editorState.moveCursorForward(SelectionMoveRange.word); - } - editorState.moveCursorForward(SelectionMoveRange.word); - } else { - final endOfWord = selection.end.moveHorizontal( - editorState, - forward: false, - selectionRange: SelectionRange.word, - ); - if (endOfWord == null) { - return KeyEventResult.handled; - } - final selectedLine = delta.toPlainText(); - final selectedWord = selectedLine.substring( - selection.end.offset, - endOfWord.offset, - ); - // check if the selected word is whitespace - if (selectedWord.trim().isEmpty) { - editorState.moveCursorBackward(SelectionMoveRange.word); - } - editorState.moveCursorBackward(SelectionMoveRange.word); - } - return KeyEventResult.handled; - } else { - return KeyEventResult.ignored; - } - } - return KeyEventResult.ignored; -}; - -//BUG: Transaction to delete word won't apply -/* -final CommandShortcutEvent vimDeleteUnderCursorCommand = CommandShortcutEvent( - key: 'vim delete character under cursor in normal mode', - command: 'd', - handler: _vimDeleteUnderCursorHandler, - getDescription: () => AppFlowyEditorL10n.current.cmdVimDeleteCharCursor, -); - -CommandShortcutEventHandler _vimDeleteUnderCursorHandler = (editorState) { - if (!editorState.vimMode) { - return KeyEventResult.ignored; - } - final afKeyboard = editorState.service.keyboardServiceKey; - if (afKeyboard.currentState != null && - afKeyboard.currentState is AppFlowyKeyboardService) { - if (editorState.selection != null && - editorState.mode == VimModes.normalMode) { - final selection = editorState.selection; - final selectionType = editorState.selectionType; - print(selectionType); - if (selectionType == SelectionType.block) { - print('block section!'); - return _deleteInBlockSelection(editorState); - } else if (selection!.isCollapsed) { - print('collapsed section!'); - return _deleteInCollapsedSelection(editorState); - } else { - print('not in collapsed section!'); - return _deleteInNotCollapsedSelection(editorState); - } - } - } - return KeyEventResult.ignored; -}; - -///Delete Handlers - -/// Handle delete key event when selection is collapsed. -CommandShortcutEventHandler _deleteInCollapsedSelection = (editorState) { - final selection = editorState.selection; - if (selection == null || !selection.isCollapsed) { - return KeyEventResult.ignored; - } - - final position = selection.start; - final node = editorState.getNodeAtPath(position.path); - final delta = node?.delta; - if (node == null || delta == null) { - return KeyEventResult.ignored; - } - - final transaction = editorState.transaction; - - if (position.offset == delta.length) { - Node? tableParent = - node.findParent((element) => element.type == TableBlockKeys.type); - Node? nextTableParent; - final next = node.findDownward((element) { - nextTableParent = - element.findParent((element) => element.type == TableBlockKeys.type); - // break if only one is in a table or they're in different tables - return tableParent != nextTableParent || - // merge the next node with delta - element.delta != null; - }); - // table nodes should be deleted using the table menu - // in-table paragraphs should only be deleted inside the table - if (next != null && tableParent == nextTableParent) { - if (next.children.isNotEmpty) { - final path = node.path + [node.children.length]; - transaction.insertNodes(path, next.children); - } - transaction - ..deleteNode(next) - ..mergeText( - node, - next, - ); - editorState.apply(transaction); - return KeyEventResult.handled; - } - } else { - //NOTE: This is for normal text blocks but not being triggered - final nextIndex = delta.nextRunePosition(position.offset); - if (nextIndex <= delta.length) { - transaction.deleteText( - node, - position.offset, - nextIndex - position.offset, - ); - editorState.apply(transaction); - return KeyEventResult.handled; - } - } - - return KeyEventResult.ignored; -}; - -/// Handle delete key event when selection is not collapsed. -CommandShortcutEventHandler _deleteInNotCollapsedSelection = (editorState) { - final selection = editorState.selection; - if (selection == null || selection.isCollapsed) { - return KeyEventResult.ignored; - } - editorState.deleteSelection(selection); - return KeyEventResult.handled; -}; - -CommandShortcutEventHandler _deleteInBlockSelection = (editorState) { - final selection = editorState.selection; - if (selection == null || editorState.selectionType != SelectionType.block) { - return KeyEventResult.ignored; - } - final transaction = editorState.transaction; - transaction.deleteNodesAtPath(selection.start.path); - editorState - .apply(transaction) - .then((value) => editorState.selectionType = null); - - return KeyEventResult.handled; -}; -*/ diff --git a/lib/src/editor/editor_component/service/shortcuts/vim/vim_cursor.dart b/lib/src/editor/editor_component/service/shortcuts/vim/vim_cursor.dart index de0cc8d7a..24ef36100 100644 --- a/lib/src/editor/editor_component/service/shortcuts/vim/vim_cursor.dart +++ b/lib/src/editor/editor_component/service/shortcuts/vim/vim_cursor.dart @@ -2,7 +2,7 @@ import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor/src/extensions/vim_shortcut_extensions.dart'; // import 'package:appflowy_editor/src/editor_state.dart'; -void moveCursor( +void customMoveCursor( EditorState editorState, SelectionMoveDirection direction, [ SelectionMoveRange range = SelectionMoveRange.character, @@ -72,6 +72,7 @@ void moveCursor( } } + //NOTE: Possibly support jumping to the next whitespace with "Shift+W" final delta = node.delta; switch (range) { case SelectionMoveRange.character: @@ -96,12 +97,13 @@ void moveCursor( } } -const baseKeys = ['h', 'j', 'k', 'l', 'i', 'a', 'o', 'w']; -// String buffer = ''; - class VimCursor { static Position? processMotionKeys( - String key, EditorState editorState, Selection selection, int count) { + String key, + EditorState editorState, + Selection selection, + int count, + ) { //final int docLength = editorState.document.root.children.length; switch (key) { case 'j': @@ -203,7 +205,7 @@ class VimCursor { editorState.mode = VimModes.insertMode; editorState.selection = editorState.selection; - moveCursor(editorState, SelectionMoveDirection.backward); + customMoveCursor(editorState, SelectionMoveDirection.backward); editorState.selectionService.updateSelection(editorState.selection); editorState.prevSelection = null; @@ -218,9 +220,6 @@ class VimCursor { editorState.editable = true; editorState.mode = VimModes.insertMode; editorState.selection = editorState.selection; - -//insertNewLine selects the old text and carries it over to the new line? -//NOTE: Manually build node and perform transaction final transaction = editorState.transaction; final nextPosition = Position( path: [editorState.selection!.end.path.first + 1], @@ -235,7 +234,6 @@ class VimCursor { editorState.selection = editorState.selection; editorState.selectionService.updateSelection(editorState.selection); editorState.prevSelection = null; - //manually insert whitespace if next node is not present? return Position( path: editorState.selection!.end.path, @@ -247,11 +245,42 @@ class VimCursor { editorState.selection = editorState.selection; editorState.moveCursor( - SelectionMoveDirection.backward, SelectionMoveRange.word); + SelectionMoveDirection.backward, + SelectionMoveRange.word, + ); + + editorState.selection = editorState.selection; + editorState.selectionService.updateSelection(editorState.selection); + + return Position( + path: editorState.selection!.end.path, + offset: editorState.selection!.end.offset, + ); + } + + case 'b': + { + editorState.selection = editorState.selection; + + editorState.moveCursor( + SelectionMoveDirection.forward, + SelectionMoveRange.word, + ); + + editorState.selection = editorState.selection; + editorState.selectionService.updateSelection(editorState.selection); + return Position( + path: editorState.selection!.end.path, + offset: editorState.selection!.end.offset, + ); + } + + case 'u': + { + editorState.undoManager.undo(); editorState.selection = editorState.selection; editorState.selectionService.updateSelection(editorState.selection); - //manually insert whitespace if next node is not present? return Position( path: editorState.selection!.end.path, diff --git a/lib/src/editor/editor_component/service/shortcuts/vim/vim_fsm.dart b/lib/src/editor/editor_component/service/shortcuts/vim/vim_fsm.dart index 9a3a0b24b..9033f4c0a 100644 --- a/lib/src/editor/editor_component/service/shortcuts/vim/vim_fsm.dart +++ b/lib/src/editor/editor_component/service/shortcuts/vim/vim_fsm.dart @@ -7,7 +7,13 @@ import 'package:flutter/services.dart'; import 'package:appflowy_editor/src/editor/command/selection_commands.dart'; import './vim_cursor.dart'; -const baseKeys = ['h', 'j', 'k', 'l', 'i', 'a', 'o', 'w']; +/* List of keys to implement +- 'ctrl+f' scroll one page down +- 'ctrl+d' scroll half page down +- 'ctrl+u' scroll one page up +*/ + +const baseKeys = ['h', 'j', 'k', 'l', 'i', 'a', 'o', 'w', 'b', 'u']; void vimMoveCursorToStartHandler(EditorState editorState) { if (!editorState.vimMode) {} @@ -65,6 +71,7 @@ Position deleteCurrentLine(EditorState editorState, int count) { return tmpPosition; } +/// Vim state to manage the buffers in between key events class VimState { String commandBuffer = ''; String deleteBuffer = ''; @@ -81,6 +88,7 @@ class VimState { } } +/// Vim State Machine, this class holds all the vim logic class VimFSM { static final VimFSM _instance = VimFSM._internal(); factory VimFSM() => _instance; @@ -107,6 +115,10 @@ class VimFSM { Selection.collapsed(newPosition), reason: SelectionUpdateReason.uiEvent, ); + + AppFlowyEditorLog.keyboard.debug( + 'keyboard service - handled by vim command: $key', + ); return KeyEventResult.handled; } } @@ -140,19 +152,45 @@ class VimFSM { final buffer = _state.commandBuffer; if (buffer == 'shift left4' || buffer == 'shift right4') { vimMoveCursorToEndHandler(editorState); + AppFlowyEditorLog.keyboard.debug( + 'keyboard service - handled by vim command shortcut: ${_state.commandBuffer}', + ); _state.reset(); return true; } if (buffer == '0') { vimMoveCursorToStartHandler(editorState); + + AppFlowyEditorLog.keyboard.debug( + 'keyboard service - handled by vim command shortcut: ${_state.commandBuffer}', + ); _state.reset(); return true; } + //NOTE: Does not handle blocks other than text yet. + //BUG: Breaks tables if (buffer.endsWith('dd')) { final count = _parseCount(buffer.substring(0, buffer.length - 2)); final position = deleteCurrentLine(editorState, count); editorState.selection = Selection(start: position, end: position); + + AppFlowyEditorLog.keyboard.debug( + 'keyboard service - handled by vim command shortcut: ${_state.commandBuffer}', + ); + _state.reset(); + return true; + } + if (buffer == 'control leftr' || buffer == 'control rightr') { + final prevSelection = editorState.selection; + //NOTE: Currently the redo stack can only go one level. undo_manager.dart + editorState.undoManager.redo(); + editorState.selection = prevSelection; + editorState.selectionService.updateSelection(prevSelection); + + AppFlowyEditorLog.keyboard.debug( + 'keyboard service - handled by vim command shortcut: ${_state.commandBuffer}', + ); _state.reset(); return true; } From ba4187058b25cfdcf2192f7283a64729d5b40b64 Mon Sep 17 00:00:00 2001 From: Sean Riley Hawkins Date: Sat, 12 Apr 2025 06:25:16 +0200 Subject: [PATCH 48/49] test: add test for undo & redo vim command --- .../vim/shortcut_keys_test.dart | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/test/service/shortcut_event/vim/shortcut_keys_test.dart b/test/service/shortcut_event/vim/shortcut_keys_test.dart index 176943bf7..3128728da 100644 --- a/test/service/shortcut_event/vim/shortcut_keys_test.dart +++ b/test/service/shortcut_event/vim/shortcut_keys_test.dart @@ -4,6 +4,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'dart:io' show Platform; import '../../../new/infra/testable_editor.dart'; +import '../../../new/util/util.dart'; void main() async { setUpAll(() { @@ -137,5 +138,60 @@ void main() async { await editor.dispose(); }); + + testWidgets('vim normal mode delete then perform undo and redo', + (tester) async { + const text1 = 'Welcome to Appflowy 😁'; + const text2 = 'Welcome'; + final editor = tester.editor..addParagraphs(6, initialText: text1); + + await editor.startTesting(); + editor.editorState.vimMode = true; + + final selection = + Selection.collapsed(Position(path: [0], offset: text1.length)); + await editor.updateSelection(selection); + + await editor.pressKey(key: LogicalKeyboardKey.escape); + expect(editor.editorState.mode, VimModes.normalMode); + + await editor.pressKey( + key: LogicalKeyboardKey.keyD, + ); + + await editor.pressKey( + key: LogicalKeyboardKey.keyD, + ); + await tester.pumpAndSettle(); + + expect(editor.document.root.children.length, equals(5)); + + expect( + editor.selection, + Selection.single(path: [0], startOffset: 0), + ); + + await editor.pressKey(key: LogicalKeyboardKey.keyU); + await tester.pumpAndSettle(); + expect(editor.document.root.children.length, equals(6)); + + expect( + editor.selection, + Selection.single(path: [0], startOffset: text1.length), + ); + + await editor.pressKey( + key: LogicalKeyboardKey.keyR, isControlPressed: true); + await tester.pumpAndSettle(); + + expect(editor.document.root.children.length, equals(5)); + + expect( + editor.selection, + Selection.single(path: [0], startOffset: text1.length), + ); + + await editor.dispose(); + }); }); } From 352ecce4a740a11f360506227bbb812fd9dfa981 Mon Sep 17 00:00:00 2001 From: Sean Riley Hawkins Date: Sat, 12 Apr 2025 06:49:00 +0200 Subject: [PATCH 49/49] doc: add technical doc for vim mode --- documentation/vim-mode.md | 77 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 documentation/vim-mode.md diff --git a/documentation/vim-mode.md b/documentation/vim-mode.md new file mode 100644 index 000000000..82bcd2544 --- /dev/null +++ b/documentation/vim-mode.md @@ -0,0 +1,77 @@ +# Vim Mode + +Vim mode is not meant to emulate all the vim functionality but instead provide partial emulation. +The editor was not built to have vim mode from the beginning. However this mode allows some functionality to be used. + +## Why not leverage on the existing shortcuts system? + +Leveraging on the shortcuts system was the inital solution however that showed its limitations. +In some cases if the vim shortcut did not recognize the key input as a vim command, it will treat it as regular input this would result in printing letters onto the editor. +Which is not meant to be the case with Vim. 'Normal Mode' does not allow any text input besides the known shortcuts/commands. +The proposed idea was to instead capture keyboard events directly without modifying the core editor itself. + + +### So how does it work? + +Inside the `keyboard_service_widget.dart` is where the keyboard capture is happening. Below is the snippet that is used specifically for vim mode. + +```dart + if (editorState.vimMode) { + if (editorState.mode == VimModes.normalMode) { + final VimCommandShortcutEvent vimCommandShortcutEvent = + VimCommandShortcutEvent(); + final vimResult = vimCommandShortcutEvent.handleKey(event, editorState); + if (vimResult == KeyEventResult.handled) { + return KeyEventResult.handled; + } + } + } +``` + +We only execute the block inside the if statement if the Vim Mode was enabled from the beginning. Vim Mode can be activated from the main AppFlowy constructor. + +```dart +AppFlowyEditor( + vimMode: true, + editorState: editorState, + // Other AppFlowy Commands + ... + ) +``` + +## Technical Details + +The `VimCommandShortcutEvent` value being used in `keyboard_service_widget.dart` does extend the CommandShortcut class but its not entirely used. +Instead its more of a wrapper for the actual vim handler. This was done so the vim keys can be registered in AppFlowy and not break anything else in the process. + + +```dart +class VimCommandShortcutEvent extends CommandShortcutEvent { + final VimFSM vimFSM = VimFSM(); + VimCommandShortcutEvent() + : super( + key: 'Vim FSM Handler', + command: '', + handler: _dummyHandler, + getDescription: () => 'Handles multi-key vim commands using an FSM', + ); + KeyEventResult handleKey(KeyEvent event, EditorState editorState) { + return vimFSM.processKey(event, editorState); + } + + static KeyEventResult _dummyHandler(EditorState editorState) { + return KeyEventResult.ignored; + } +} +``` + +### Vim Shortcuts handling + +There are known keys for Vim to at least get around the document, such as `[h, j, k, l]` any other keys would be either extensions of the mentioned keys or new ones entirely. +Currently there are hard-coded keys which the Vim keyboard handler will check with for every key press. If the pressed keys are within the list of known keys they will trigger an action. +Any other key that is not known will just be ignored. + + +In Vim there is the concept of doing key combinations or awaiting key presses. For example `4j` means jump 4 lines down from the current position. +Normally `4` and `j` are registered as separate keys, however with VimMode we hold the keys in a buffer before we release them. +So if `4j` matches then an action is triggered however if we try `10m` that will not trigger anything because `m` is not a known key, so the buffer will be cleared.