Merge pull request #1648 from LucasXu0/feat_1624

#1624 Support Shift + Option + Left/Right Arrow
This commit is contained in:
Lucas.Xu 2023-01-05 10:19:06 +08:00 committed by GitHub
commit e52f59b8c1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 252 additions and 19 deletions

View File

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

View File

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

View File

@ -112,7 +112,7 @@ class _FlowyRichTextState extends State<FlowyRichText> with SelectableMixin {
} }
@override @override
Selection? getWorldBoundaryInOffset(Offset offset) { Selection? getWordBoundaryInOffset(Offset offset) {
final localOffset = _renderParagraph.globalToLocal(offset); final localOffset = _renderParagraph.globalToLocal(offset);
final textPosition = _renderParagraph.getPositionForOffset(localOffset); final textPosition = _renderParagraph.getPositionForOffset(localOffset);
final textRange = _renderParagraph.getWordBoundary(textPosition); final textRange = _renderParagraph.getWordBoundary(textPosition);
@ -121,6 +121,15 @@ class _FlowyRichTextState extends State<FlowyRichText> with SelectableMixin {
return Selection(start: start, end: end); 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 @override
List<Rect> getRectsInSelection(Selection selection) { List<Rect> getRectsInSelection(Selection selection) {
assert(selection.isSingle && 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, /// Only the widget rendered by [TextNode] need to implement the detail,
/// and the rest can return null. /// and the rest can return null.
Selection? getWorldBoundaryInOffset(Offset start) { Selection? getWordBoundaryInOffset(Offset start) => null;
return 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; bool get shouldCursorBlink => true;

View File

@ -289,8 +289,50 @@ ShortcutEventHandler cursorRight = (editorState, event) {
return KeyEventResult.handled; 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 { extension on Position {
Position? goLeft(EditorState editorState) { Position? goLeft(
EditorState editorState, {
_SelectionRange selectionRange = _SelectionRange.character,
}) {
final node = editorState.document.nodeAtPath(path); final node = editorState.document.nodeAtPath(path);
if (node == null) { if (node == null) {
return null; return null;
@ -302,14 +344,38 @@ extension on Position {
} }
return null; return null;
} }
if (node is TextNode) { switch (selectionRange) {
return Position(path: path, offset: node.delta.prevRunePosition(offset)); case _SelectionRange.character:
} else { if (node is TextNode) {
return Position(path: path, offset: offset); 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); final node = editorState.document.nodeAtPath(path);
if (node == null) { if (node == null) {
return null; return null;
@ -322,11 +388,27 @@ extension on Position {
} }
return null; return null;
} }
if (node is TextNode) { switch (selectionRange) {
return Position(path: path, offset: node.delta.nextRunePosition(offset)); case _SelectionRange.character:
} else { if (node is TextNode) {
return Position(path: path, offset: offset); 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(this);
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) { void _onDoubleTapDown(TapDownDetails details) {
final offset = details.globalPosition; final offset = details.globalPosition;
final node = getNodeInOffset(offset); final node = getNodeInOffset(offset);
final selection = node?.selectable?.getWorldBoundaryInOffset(offset); final selection = node?.selectable?.getWordBoundaryInOffset(offset);
if (selection == null) { if (selection == null) {
clearSelection(); clearSelection();
return; return;

View File

@ -48,6 +48,16 @@ List<ShortcutEvent> builtInShortcutEvents = [
command: 'shift+arrow down', command: 'shift+arrow down',
handler: cursorDownSelect, 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( ShortcutEvent(
key: 'Cursor left select', key: 'Cursor left select',
command: 'shift+arrow left', command: 'shift+arrow left',

View File

@ -341,6 +341,131 @@ 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 right 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,
);
// < >
expect(
editor.documentSelection,
selection.copyWith(
end: Position(path: [0], offset: 11),
),
);
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,
);
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( Future<void> _testPressArrowKeyInNotCollapsedSelection(