feat: #1624 add shortcut for Shift + Option + Left/Right Arrow

This commit is contained in:
Lucas.Xu 2023-01-04 16:44:51 +08:00
parent 2006d35a50
commit 35a72f701b
8 changed files with 238 additions and 19 deletions

View File

@ -23,8 +23,8 @@ class SettingsLocation {
String? get path {
if (Platform.isMacOS) {
// remove the prefix `/Volumes/Macintosh HD/Users/`
return _path?.replaceFirst('/Volumes/Macintosh HD/Users', '');
// remove the prefix `/Volumes/*`
return _path?.replaceFirst(RegExp(r'^/Volumes/[^/]+'), '');
}
return _path;
}

View File

@ -35,8 +35,11 @@ mixin DefaultSelectable {
Offset localToGlobal(Offset offset) =>
forward.localToGlobal(offset) - baseOffset;
Selection? getWorldBoundaryInOffset(Offset offset) =>
forward.getWorldBoundaryInOffset(offset);
Selection? getWordBoundaryInOffset(Offset offset) =>
forward.getWordBoundaryInOffset(offset);
Selection? getWordBoundaryInPosition(Position position) =>
forward.getWordBoundaryInPosition(position);
Position start() => forward.start();

View File

@ -112,7 +112,7 @@ class _FlowyRichTextState extends State<FlowyRichText> with SelectableMixin {
}
@override
Selection? getWorldBoundaryInOffset(Offset offset) {
Selection? getWordBoundaryInOffset(Offset offset) {
final localOffset = _renderParagraph.globalToLocal(offset);
final textPosition = _renderParagraph.getPositionForOffset(localOffset);
final textRange = _renderParagraph.getWordBoundary(textPosition);
@ -121,6 +121,15 @@ class _FlowyRichTextState extends State<FlowyRichText> with SelectableMixin {
return Selection(start: start, end: end);
}
@override
Selection? getWordBoundaryInPosition(Position position) {
final textPosition = TextPosition(offset: position.offset);
final textRange = _renderParagraph.getWordBoundary(textPosition);
final start = Position(path: widget.textNode.path, offset: textRange.start);
final end = Position(path: widget.textNode.path, offset: textRange.end);
return Selection(start: start, end: end);
}
@override
List<Rect> getRectsInSelection(Selection selection) {
assert(selection.isSingle &&

View File

@ -55,9 +55,13 @@ mixin SelectableMixin<T extends StatefulWidget> on State<T> {
///
/// Only the widget rendered by [TextNode] need to implement the detail,
/// and the rest can return null.
Selection? getWorldBoundaryInOffset(Offset start) {
return null;
}
Selection? getWordBoundaryInOffset(Offset start) => null;
/// For [TextNode] only.
///
/// Only the widget rendered by [TextNode] need to implement the detail,
/// and the rest can return null.
Selection? getWordBoundaryInPosition(Position position) => null;
bool get shouldCursorBlink => true;

View File

@ -289,8 +289,50 @@ ShortcutEventHandler cursorRight = (editorState, event) {
return KeyEventResult.handled;
};
ShortcutEventHandler cursorLeftWordSelect = (editorState, event) {
final nodes = editorState.service.selectionService.currentSelectedNodes;
final selection = editorState.service.selectionService.currentSelection.value;
if (nodes.isEmpty || selection == null) {
return KeyEventResult.ignored;
}
final end =
selection.end.goLeft(editorState, selectionRange: _SelectionRange.word);
if (end == null) {
return KeyEventResult.ignored;
}
editorState.service.selectionService.updateSelection(
selection.copyWith(end: end),
);
return KeyEventResult.handled;
};
ShortcutEventHandler cursorRightWordSelect = (editorState, event) {
final nodes = editorState.service.selectionService.currentSelectedNodes;
final selection = editorState.service.selectionService.currentSelection.value;
if (nodes.isEmpty || selection == null) {
return KeyEventResult.ignored;
}
final end =
selection.end.goRight(editorState, selectionRange: _SelectionRange.word);
if (end == null) {
return KeyEventResult.ignored;
}
editorState.service.selectionService.updateSelection(
selection.copyWith(end: end),
);
return KeyEventResult.handled;
};
enum _SelectionRange {
character,
word,
}
extension on Position {
Position? goLeft(EditorState editorState) {
Position? goLeft(
EditorState editorState, {
_SelectionRange selectionRange = _SelectionRange.character,
}) {
final node = editorState.document.nodeAtPath(path);
if (node == null) {
return null;
@ -302,14 +344,38 @@ extension on Position {
}
return null;
}
if (node is TextNode) {
return Position(path: path, offset: node.delta.prevRunePosition(offset));
} else {
return Position(path: path, offset: offset);
switch (selectionRange) {
case _SelectionRange.character:
if (node is TextNode) {
return Position(
path: path,
offset: node.delta.prevRunePosition(offset),
);
} else {
return Position(path: path, offset: offset);
}
case _SelectionRange.word:
if (node is TextNode) {
final result = node.selectable?.getWordBoundaryInPosition(
Position(
path: path,
offset: node.delta.prevRunePosition(offset),
),
);
if (result != null) {
return result.start;
}
} else {
return Position(path: path, offset: offset);
}
}
return null;
}
Position? goRight(EditorState editorState) {
Position? goRight(
EditorState editorState, {
_SelectionRange selectionRange = _SelectionRange.character,
}) {
final node = editorState.document.nodeAtPath(path);
if (node == null) {
return null;
@ -322,11 +388,30 @@ extension on Position {
}
return null;
}
if (node is TextNode) {
return Position(path: path, offset: node.delta.nextRunePosition(offset));
} else {
return Position(path: path, offset: offset);
switch (selectionRange) {
case _SelectionRange.character:
if (node is TextNode) {
return Position(
path: path, offset: node.delta.nextRunePosition(offset));
} else {
return Position(path: path, offset: offset);
}
case _SelectionRange.word:
if (node is TextNode) {
final result = node.selectable?.getWordBoundaryInPosition(
Position(
path: path,
offset: node.delta.nextRunePosition(offset),
),
);
if (result != null) {
return result.end;
}
} else {
return Position(path: path, offset: offset);
}
}
return null;
}
}

View File

@ -298,7 +298,7 @@ class _AppFlowySelectionState extends State<AppFlowySelection>
void _onDoubleTapDown(TapDownDetails details) {
final offset = details.globalPosition;
final node = getNodeInOffset(offset);
final selection = node?.selectable?.getWorldBoundaryInOffset(offset);
final selection = node?.selectable?.getWordBoundaryInOffset(offset);
if (selection == null) {
clearSelection();
return;

View File

@ -48,6 +48,16 @@ List<ShortcutEvent> builtInShortcutEvents = [
command: 'shift+arrow down',
handler: cursorDownSelect,
),
ShortcutEvent(
key: 'Cursor down select',
command: 'shift+alt+arrow left',
handler: cursorLeftWordSelect,
),
ShortcutEvent(
key: 'Cursor down select',
command: 'shift+alt+arrow right',
handler: cursorRightWordSelect,
),
ShortcutEvent(
key: 'Cursor left select',
command: 'shift+arrow left',

View File

@ -341,6 +341,114 @@ void main() async {
),
);
});
testWidgets('Presses shift + alt + arrow left to select a word',
(tester) async {
const text = 'Welcome to Appflowy 😁';
final editor = tester.editor
..insertTextNode(text)
..insertTextNode(text);
await editor.startTesting();
final selection = Selection.single(path: [1], startOffset: 10);
await editor.updateSelection(selection);
await editor.pressLogicKey(
LogicalKeyboardKey.arrowLeft,
isShiftPressed: true,
isAltPressed: true,
);
// <to>
expect(
editor.documentSelection,
selection.copyWith(
end: Position(path: [1], offset: 8),
),
);
await editor.pressLogicKey(
LogicalKeyboardKey.arrowLeft,
isShiftPressed: true,
isAltPressed: true,
);
// < to>
expect(
editor.documentSelection,
selection.copyWith(
end: Position(path: [1], offset: 7),
),
);
await editor.pressLogicKey(
LogicalKeyboardKey.arrowLeft,
isShiftPressed: true,
isAltPressed: true,
);
// <Welcome to>
expect(
editor.documentSelection,
selection.copyWith(
end: Position(path: [1], offset: 0),
),
);
await editor.pressLogicKey(
LogicalKeyboardKey.arrowLeft,
isShiftPressed: true,
isAltPressed: true,
);
// <😁>
// <Welcome to>
expect(
editor.documentSelection,
selection.copyWith(
end: Position(path: [0], offset: 22),
),
);
});
testWidgets('Presses shift + alt + arrow left to select a word',
(tester) async {
const text = 'Welcome to Appflowy 😁';
final editor = tester.editor
..insertTextNode(text)
..insertTextNode(text);
await editor.startTesting();
final selection = Selection.single(path: [0], startOffset: 10);
await editor.updateSelection(selection);
await editor.pressLogicKey(
LogicalKeyboardKey.arrowRight,
isShiftPressed: true,
isAltPressed: true,
);
// < Appflowy>
expect(
editor.documentSelection,
selection.copyWith(
end: Position(path: [0], offset: 19),
),
);
await editor.pressLogicKey(
LogicalKeyboardKey.arrowRight,
isShiftPressed: true,
isAltPressed: true,
);
// < Appflowy 😁>
expect(
editor.documentSelection,
selection.copyWith(
end: Position(path: [0], offset: 22),
),
);
await editor.pressLogicKey(
LogicalKeyboardKey.arrowRight,
isShiftPressed: true,
isAltPressed: true,
);
// < Appflowy 😁>
// <>
expect(
editor.documentSelection,
selection.copyWith(
end: Position(path: [1], offset: 0),
),
);
});
}
Future<void> _testPressArrowKeyInNotCollapsedSelection(