Merge pull request #1314 from LucasXu0/feature/context_menu

feat: implement context menu
This commit is contained in:
Lucas.Xu 2022-10-19 08:14:00 +07:00 committed by GitHub
commit 833a6cd95f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 269 additions and 203 deletions

View File

@ -65,6 +65,20 @@ class _MyHomePageState extends State<MyHomePage> {
return Scaffold(
extendBodyBehindAppBar: true,
body: _buildEditor(context),
// body: Center(
// child: ContextMenu(editorState: EditorState.empty(), items: [
// [
// ContextMenuItem(name: 'ABCDEFGHIJKLM', onPressed: (editorState) {}),
// ContextMenuItem(name: 'A', onPressed: (editorState) {}),
// ContextMenuItem(name: 'A', onPressed: (editorState) {})
// ],
// [
// ContextMenuItem(name: 'B', onPressed: (editorState) {}),
// ContextMenuItem(name: 'B', onPressed: (editorState) {}),
// ContextMenuItem(name: 'B', onPressed: (editorState) {})
// ]
// ]),
// ),
floatingActionButton: _buildExpandableFab(),
);
}

View File

@ -0,0 +1,28 @@
import 'package:appflowy_editor/src/service/context_menu/context_menu.dart';
import 'package:appflowy_editor/src/service/internal_key_event_handlers/copy_paste_handler.dart';
final builtInContextMenuItems = [
[
// cut
ContextMenuItem(
name: 'Cut',
onPressed: (editorState) {
cutEventHandler(editorState, null);
},
),
// copy
ContextMenuItem(
name: 'Copy',
onPressed: (editorState) {
copyEventHandler(editorState, null);
},
),
// Paste
ContextMenuItem(
name: 'Paste',
onPressed: (editorState) {
pasteEventHandler(editorState, null);
},
),
],
];

View File

@ -0,0 +1,90 @@
import 'package:appflowy_editor/src/editor_state.dart';
import 'package:flutter/material.dart';
class ContextMenuItem {
ContextMenuItem({
required this.name,
required this.onPressed,
});
final String name;
final void Function(EditorState editorState) onPressed;
}
class ContextMenu extends StatelessWidget {
const ContextMenu({
Key? key,
required this.position,
required this.editorState,
required this.items,
required this.onPressed,
}) : super(key: key);
final Offset position;
final EditorState editorState;
final List<List<ContextMenuItem>> items;
final VoidCallback onPressed;
@override
Widget build(BuildContext context) {
final children = <Widget>[];
for (var i = 0; i < items.length; i++) {
for (var j = 0; j < items[i].length; j++) {
children.add(
Material(
child: InkWell(
hoverColor: const Color(0xFFE0F8FF),
customBorder: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(6),
),
onTap: () {
items[i][j].onPressed(editorState);
onPressed();
},
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
items[i][j].name,
textAlign: TextAlign.start,
style: const TextStyle(fontSize: 14),
),
),
),
),
);
}
if (i != items.length - 1) {
children.add(const Divider());
}
}
return Positioned(
top: position.dy,
left: position.dx,
child: Container(
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 4),
constraints: const BoxConstraints(
minWidth: 140,
),
decoration: BoxDecoration(
color: Colors.white,
boxShadow: [
BoxShadow(
blurRadius: 5,
spreadRadius: 1,
color: Colors.black.withOpacity(0.1),
),
],
borderRadius: BorderRadius.circular(6.0),
),
child: IntrinsicWidth(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: children,
),
),
),
);
}
}

View File

@ -217,7 +217,7 @@ ShortcutEventHandler cursorEndSelect = (editorState, event) {
return KeyEventResult.handled;
};
KeyEventResult cursorUp(EditorState editorState, RawKeyEvent event) {
ShortcutEventHandler cursorUp = (editorState, event) {
final nodes = editorState.service.selectionService.currentSelectedNodes;
final selection =
editorState.service.selectionService.currentSelection.value?.normalized;
@ -229,9 +229,9 @@ KeyEventResult cursorUp(EditorState editorState, RawKeyEvent event) {
upPosition == null ? null : Selection.collapsed(upPosition),
);
return KeyEventResult.handled;
}
};
KeyEventResult cursorDown(EditorState editorState, RawKeyEvent event) {
ShortcutEventHandler cursorDown = (editorState, event) {
final nodes = editorState.service.selectionService.currentSelectedNodes;
final selection =
editorState.service.selectionService.currentSelection.value?.normalized;
@ -243,9 +243,9 @@ KeyEventResult cursorDown(EditorState editorState, RawKeyEvent event) {
downPosition == null ? null : Selection.collapsed(downPosition),
);
return KeyEventResult.handled;
}
};
KeyEventResult cursorLeft(EditorState editorState, RawKeyEvent event) {
ShortcutEventHandler cursorLeft = (editorState, event) {
final nodes = editorState.service.selectionService.currentSelectedNodes;
final selection =
editorState.service.selectionService.currentSelection.value?.normalized;
@ -265,9 +265,9 @@ KeyEventResult cursorLeft(EditorState editorState, RawKeyEvent event) {
);
}
return KeyEventResult.handled;
}
};
KeyEventResult cursorRight(EditorState editorState, RawKeyEvent event) {
ShortcutEventHandler cursorRight = (editorState, event) {
final nodes = editorState.service.selectionService.currentSelectedNodes;
final selection =
editorState.service.selectionService.currentSelection.value?.normalized;
@ -287,7 +287,7 @@ KeyEventResult cursorRight(EditorState editorState, RawKeyEvent event) {
);
}
return KeyEventResult.handled;
}
};
extension on Position {
Position? goLeft(EditorState editorState) {

View File

@ -1,22 +1,9 @@
import 'package:appflowy_editor/src/infra/infra.dart';
import 'package:appflowy_editor/src/service/internal_key_event_handlers/number_list_helper.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
// Handle delete text.
ShortcutEventHandler deleteTextHandler = (editorState, event) {
if (event.logicalKey == LogicalKeyboardKey.backspace) {
return _handleBackspace(editorState, event);
}
if (event.logicalKey == LogicalKeyboardKey.delete) {
return _handleDelete(editorState, event);
}
return KeyEventResult.ignored;
};
KeyEventResult _handleBackspace(EditorState editorState, RawKeyEvent event) {
ShortcutEventHandler backspaceEventHandler = (editorState, event) {
var selection = editorState.service.selectionService.currentSelection.value;
if (selection == null) {
return KeyEventResult.ignored;
@ -122,7 +109,7 @@ KeyEventResult _handleBackspace(EditorState editorState, RawKeyEvent event) {
}
return KeyEventResult.handled;
}
};
KeyEventResult _backDeleteToPreviousTextNode(
EditorState editorState,
@ -182,7 +169,7 @@ KeyEventResult _backDeleteToPreviousTextNode(
return KeyEventResult.handled;
}
KeyEventResult _handleDelete(EditorState editorState, RawKeyEvent event) {
ShortcutEventHandler deleteEventHandler = (editorState, event) {
var selection = editorState.service.selectionService.currentSelection.value;
if (selection == null) {
return KeyEventResult.ignored;
@ -238,7 +225,7 @@ KeyEventResult _handleDelete(EditorState editorState, RawKeyEvent event) {
}
return KeyEventResult.handled;
}
};
KeyEventResult _mergeNextLineIntoThisLine(EditorState editorState,
TextNode textNode, Transaction transaction, Selection selection) {

View File

@ -39,7 +39,10 @@ void _handleCopy(EditorState editorState) async {
endOffset: selection.end.offset)
.toHTMLString();
Log.keyboard.debug('copy html: $htmlString');
RichClipboard.setData(RichClipboardData(html: htmlString));
RichClipboard.setData(RichClipboardData(
html: htmlString,
text: textNode.toPlainText(),
));
} else {
Log.keyboard.debug('unimplemented: copy non-text');
}
@ -55,13 +58,15 @@ void _handleCopy(EditorState editorState) async {
endNode: endNode,
).toList();
final copyString = NodesToHTMLConverter(
nodes: nodes,
startOffset: selection.start.offset,
endOffset: selection.end.offset)
.toHTMLString();
Log.keyboard.debug('copy html: $copyString');
RichClipboard.setData(RichClipboardData(html: copyString));
final html = NodesToHTMLConverter(
nodes: nodes,
startOffset: selection.start.offset,
endOffset: selection.end.offset,
).toHTMLString();
final text = nodes
.map((node) => node is TextNode ? node.toPlainText() : '\n')
.join('\n');
RichClipboard.setData(RichClipboardData(html: html, text: text));
}
void _pasteHTML(EditorState editorState, String html) {

View File

@ -1,153 +0,0 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
int _endOffsetOfNode(Node node) {
if (node is TextNode) {
return node.delta.length;
}
return 0;
}
extension on Position {
Position? goLeft(EditorState editorState) {
final node = editorState.document.nodeAtPath(path)!;
if (offset == 0) {
final prevNode = node.previous;
if (prevNode != null) {
return Position(
path: prevNode.path, offset: _endOffsetOfNode(prevNode));
}
return null;
}
if (node is TextNode) {
return Position(path: path, offset: node.delta.prevRunePosition(offset));
} else {
return Position(path: path, offset: offset);
}
}
Position? goRight(EditorState editorState) {
final node = editorState.document.nodeAtPath(path)!;
final lengthOfNode = _endOffsetOfNode(node);
if (offset >= lengthOfNode) {
final nextNode = node.next;
if (nextNode != null) {
return Position(path: nextNode.path, offset: 0);
}
return null;
}
if (node is TextNode) {
return Position(path: path, offset: node.delta.nextRunePosition(offset));
} else {
return Position(path: path, offset: offset);
}
}
}
Position? _goUp(EditorState editorState) {
final rects = editorState.service.selectionService.selectionRects;
if (rects.isEmpty) {
return null;
}
final first = rects.first;
final firstOffset = Offset(first.left, first.top);
final hitOffset = firstOffset - Offset(0, first.height * 0.5);
return editorState.service.selectionService.getPositionInOffset(hitOffset);
}
Position? _goDown(EditorState editorState) {
final rects = editorState.service.selectionService.selectionRects;
if (rects.isEmpty) {
return null;
}
final first = rects.last;
final firstOffset = Offset(first.right, first.bottom);
final hitOffset = firstOffset + Offset(0, first.height * 0.5);
return editorState.service.selectionService.getPositionInOffset(hitOffset);
}
KeyEventResult _handleShiftKey(EditorState editorState, RawKeyEvent event) {
final currentSelection = editorState.cursorSelection;
if (currentSelection == null) {
return KeyEventResult.ignored;
}
if (event.logicalKey == LogicalKeyboardKey.arrowLeft) {
final leftPosition = currentSelection.end.goLeft(editorState);
editorState.updateCursorSelection(leftPosition == null
? null
: Selection(start: currentSelection.start, end: leftPosition));
return KeyEventResult.handled;
} else if (event.logicalKey == LogicalKeyboardKey.arrowRight) {
final rightPosition = currentSelection.start.goRight(editorState);
editorState.updateCursorSelection(rightPosition == null
? null
: Selection(start: rightPosition, end: currentSelection.end));
return KeyEventResult.handled;
} else if (event.logicalKey == LogicalKeyboardKey.arrowUp) {
final position = _goUp(editorState);
editorState.updateCursorSelection(position == null
? null
: Selection(start: position, end: currentSelection.end));
return KeyEventResult.handled;
} else if (event.logicalKey == LogicalKeyboardKey.arrowDown) {
final position = _goDown(editorState);
editorState.updateCursorSelection(position == null
? null
: Selection(start: currentSelection.start, end: position));
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
ShortcutEventHandler arrowKeysHandler = (editorState, event) {
if (event.isShiftPressed) {
return _handleShiftKey(editorState, event);
}
final currentSelection = editorState.cursorSelection;
if (currentSelection == null) {
return KeyEventResult.ignored;
}
if (event.logicalKey == LogicalKeyboardKey.arrowLeft) {
if (currentSelection.isCollapsed) {
final leftPosition = currentSelection.start.goLeft(editorState);
if (leftPosition != null) {
editorState.updateCursorSelection(Selection.collapsed(leftPosition));
}
} else {
editorState.updateCursorSelection(
currentSelection.collapse(atStart: currentSelection.isBackward),
);
}
return KeyEventResult.handled;
} else if (event.logicalKey == LogicalKeyboardKey.arrowRight) {
if (currentSelection.isCollapsed) {
final rightPosition = currentSelection.end.goRight(editorState);
if (rightPosition != null) {
editorState.updateCursorSelection(Selection.collapsed(rightPosition));
}
} else {
editorState.updateCursorSelection(
currentSelection.collapse(atStart: !currentSelection.isBackward),
);
}
return KeyEventResult.handled;
} else if (event.logicalKey == LogicalKeyboardKey.arrowUp) {
final position = _goUp(editorState);
editorState.updateCursorSelection(
position == null ? null : Selection.collapsed(position));
return KeyEventResult.handled;
} else if (event.logicalKey == LogicalKeyboardKey.arrowDown) {
final position = _goDown(editorState);
editorState.updateCursorSelection(
position == null ? null : Selection.collapsed(position));
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
};

View File

@ -4,14 +4,9 @@ import 'package:appflowy_editor/src/render/selection_menu/selection_menu_service
import 'package:appflowy_editor/src/extensions/node_extensions.dart';
import 'package:appflowy_editor/src/service/shortcut_event/shortcut_event_handler.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
SelectionMenuService? _selectionMenuService;
ShortcutEventHandler slashShortcutHandler = (editorState, event) {
if (event.logicalKey != LogicalKeyboardKey.slash) {
return KeyEventResult.ignored;
}
final textNodes = editorState.service.selectionService.currentSelectedNodes
.whereType<TextNode>();
if (textNodes.length != 1) {
@ -26,8 +21,12 @@ ShortcutEventHandler slashShortcutHandler = (editorState, event) {
return KeyEventResult.ignored;
}
final transaction = editorState.transaction
..replaceText(textNode, selection.start.offset,
selection.end.offset - selection.start.offset, event.character ?? '');
..replaceText(
textNode,
selection.start.offset,
selection.end.offset - selection.start.offset,
'/',
);
editorState.apply(transaction);
WidgetsBinding.instance.addPostFrameCallback((_) {

View File

@ -1,7 +1,6 @@
import 'package:appflowy_editor/src/core/transform/transaction.dart';
import 'package:appflowy_editor/src/service/shortcut_event/shortcut_event_handler.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:appflowy_editor/src/core/legacy/built_in_attribute_keys.dart';
import 'package:appflowy_editor/src/core/document/node.dart';
import 'package:appflowy_editor/src/core/location/position.dart';
@ -24,10 +23,6 @@ const _unCheckboxListSymbols = ['[]', '-[]'];
final _numberRegex = RegExp(r'^(\d+)\.');
ShortcutEventHandler whiteSpaceHandler = (editorState, event) {
if (event.logicalKey != LogicalKeyboardKey.space) {
return KeyEventResult.ignored;
}
/// Process markdown input style.
///
/// like, #, *, -, 1., -[],

View File

@ -13,6 +13,7 @@ class SelectionGestureDetector extends StatefulWidget {
this.onTapDown,
this.onDoubleTapDown,
this.onTripleTapDown,
this.onSecondaryTapDown,
this.onPanStart,
this.onPanUpdate,
this.onPanEnd,
@ -27,6 +28,7 @@ class SelectionGestureDetector extends StatefulWidget {
final GestureTapDownCallback? onTapDown;
final GestureTapDownCallback? onDoubleTapDown;
final GestureTapDownCallback? onTripleTapDown;
final GestureTapDownCallback? onSecondaryTapDown;
final GestureDragStartCallback? onPanStart;
final GestureDragUpdateCallback? onPanUpdate;
final GestureDragEndCallback? onPanEnd;
@ -60,6 +62,7 @@ class SelectionGestureDetectorState extends State<SelectionGestureDetector> {
() => TapGestureRecognizer(),
(recognizer) {
recognizer.onTapDown = _tapDownDelegate;
recognizer.onSecondaryTapDown = widget.onSecondaryTapDown;
},
),
},

View File

@ -1,4 +1,6 @@
import 'package:appflowy_editor/src/infra/log.dart';
import 'package:appflowy_editor/src/service/context_menu/built_in_context_menu_item.dart';
import 'package:appflowy_editor/src/service/context_menu/context_menu.dart';
import 'package:flutter/material.dart';
import 'package:appflowy_editor/src/core/document/node.dart';
@ -108,6 +110,7 @@ class _AppFlowySelectionState extends State<AppFlowySelection>
final List<Rect> selectionRects = [];
final List<OverlayEntry> _selectionAreas = [];
final List<OverlayEntry> _cursorAreas = [];
final List<OverlayEntry> _contextMenuAreas = [];
// OverlayEntry? _debugOverlay;
@ -156,6 +159,7 @@ class _AppFlowySelectionState extends State<AppFlowySelection>
onPanUpdate: _onPanUpdate,
onPanEnd: _onPanEnd,
onTapDown: _onTapDown,
onSecondaryTapDown: _onSecondaryTapDown,
onDoubleTapDown: _onDoubleTapDown,
onTripleTapDown: _onTripleTapDown,
child: widget.child,
@ -232,6 +236,9 @@ class _AppFlowySelectionState extends State<AppFlowySelection>
// hide toolbar
editorState.service.toolbarService?.hide();
// clear context menu
_clearContextMenu();
}
@override
@ -242,6 +249,12 @@ class _AppFlowySelectionState extends State<AppFlowySelection>
..clear();
}
void _clearContextMenu() {
_contextMenuAreas
..forEach((overlay) => overlay.remove())
..clear();
}
@override
Node? getNodeInOffset(Offset offset) {
final sortedNodes =
@ -311,6 +324,20 @@ class _AppFlowySelectionState extends State<AppFlowySelection>
_enableInteraction();
}
void _onSecondaryTapDown(TapDownDetails details) {
// if selection is null, or
// selection.isCollapsedand and the selected node is TextNode.
// try to select the word.
final selection = currentSelection.value;
if (selection == null ||
(selection.isCollapsed == true &&
currentSelectedNodes.first is TextNode)) {
_onDoubleTapDown(details);
}
_showContextMenu(details);
}
void _onPanStart(DragStartDetails details) {
clearSelection();
@ -477,6 +504,20 @@ class _AppFlowySelectionState extends State<AppFlowySelection>
_cursorKey.currentState?.unwrapOrNull<CursorWidgetState>()?.show();
}
void _showContextMenu(TapDownDetails details) {
final contextMenu = OverlayEntry(
builder: (context) => ContextMenu(
position: details.globalPosition,
editorState: editorState,
items: builtInContextMenuItems,
onPressed: () => _clearContextMenu(),
),
);
_contextMenuAreas.add(contextMenu);
Overlay.of(context)?.insert(contextMenu);
}
void _scrollUpOrDownIfNeeded() {
final dy = editorState.service.scrollService?.dy;
final selectNodes = currentSelectedNodes;

View File

@ -208,12 +208,15 @@ List<ShortcutEvent> builtInShortcutEvents = [
command: 'end',
handler: cursorEnd,
),
// TODO: split the keys.
ShortcutEvent(
key: 'Delete Text by backspace',
command: 'backspace',
handler: backspaceEventHandler,
),
ShortcutEvent(
key: 'Delete Text',
command: 'delete,backspace',
handler: deleteTextHandler,
command: 'delete',
handler: deleteEventHandler,
),
ShortcutEvent(
key: 'selection menu',

View File

@ -3,5 +3,5 @@ import 'package:flutter/material.dart';
typedef ShortcutEventHandler = KeyEventResult Function(
EditorState editorState,
RawKeyEvent event,
RawKeyEvent? event,
);

View File

@ -1,4 +1,8 @@
import 'dart:io';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor/src/service/context_menu/context_menu.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter_test/flutter_test.dart';
import '../infra/test_editor.dart';
@ -79,5 +83,55 @@ void main() async {
Selection.single(path: [1], startOffset: 0, endOffset: text.length),
);
});
testWidgets('Test secondary tap', (tester) async {
const text = 'Welcome to Appflowy 😁';
final editor = tester.editor
..insertTextNode(text)
..insertTextNode(text)
..insertTextNode(text);
await editor.startTesting();
final secondTextNode = editor.nodeAtPath([1]) as TextNode;
final finder = find.byKey(secondTextNode.key!);
final rect = tester.getRect(finder);
// secondary tap
await tester.tapAt(
rect.centerLeft + const Offset(10.0, 0.0),
buttons: kSecondaryButton,
);
await tester.pump();
const welcome = 'Welcome';
expect(
editor.documentSelection,
Selection.single(
path: [1],
startOffset: 0,
endOffset: welcome.length,
), // Welcome
);
final contextMenu = find.byType(ContextMenu);
expect(contextMenu, findsOneWidget);
// test built in context menu items
// Skip the Windows platform because the rich_clipboard package doesn't support it perfectly.
if (Platform.isWindows) {
return;
}
// cut
await tester.tap(find.text('Cut'));
await tester.pump();
expect(
secondTextNode.toPlainText(),
text.replaceAll(welcome, ''),
);
// TODO: the copy and paste test is not working during test env.
});
});
}