mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2024-08-30 18:12:39 +00:00
Merge pull request #1648 from LucasXu0/feat_1624
#1624 Support Shift + Option + Left/Right Arrow
This commit is contained in:
commit
e52f59b8c1
@ -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;
|
||||||
}
|
}
|
||||||
|
@ -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();
|
||||||
|
|
||||||
|
@ -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 &&
|
||||||
|
@ -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;
|
||||||
|
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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;
|
||||||
|
@ -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',
|
||||||
|
@ -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(
|
||||||
|
Loading…
Reference in New Issue
Block a user