feat: implement context menu

This commit is contained in:
Lucas.Xu 2022-10-18 15:44:32 +08:00
parent 78728bb6ff
commit 30700cd513
12 changed files with 202 additions and 195 deletions

View File

@ -65,6 +65,20 @@ class _MyHomePageState extends State<MyHomePage> {
return Scaffold( return Scaffold(
extendBodyBehindAppBar: true, extendBodyBehindAppBar: true,
body: _buildEditor(context), 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(), 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; return KeyEventResult.handled;
}; };
KeyEventResult cursorUp(EditorState editorState, RawKeyEvent event) { ShortcutEventHandler cursorUp = (editorState, event) {
final nodes = editorState.service.selectionService.currentSelectedNodes; final nodes = editorState.service.selectionService.currentSelectedNodes;
final selection = final selection =
editorState.service.selectionService.currentSelection.value?.normalized; editorState.service.selectionService.currentSelection.value?.normalized;
@ -229,9 +229,9 @@ KeyEventResult cursorUp(EditorState editorState, RawKeyEvent event) {
upPosition == null ? null : Selection.collapsed(upPosition), upPosition == null ? null : Selection.collapsed(upPosition),
); );
return KeyEventResult.handled; return KeyEventResult.handled;
} };
KeyEventResult cursorDown(EditorState editorState, RawKeyEvent event) { ShortcutEventHandler cursorDown = (editorState, event) {
final nodes = editorState.service.selectionService.currentSelectedNodes; final nodes = editorState.service.selectionService.currentSelectedNodes;
final selection = final selection =
editorState.service.selectionService.currentSelection.value?.normalized; editorState.service.selectionService.currentSelection.value?.normalized;
@ -243,9 +243,9 @@ KeyEventResult cursorDown(EditorState editorState, RawKeyEvent event) {
downPosition == null ? null : Selection.collapsed(downPosition), downPosition == null ? null : Selection.collapsed(downPosition),
); );
return KeyEventResult.handled; return KeyEventResult.handled;
} };
KeyEventResult cursorLeft(EditorState editorState, RawKeyEvent event) { ShortcutEventHandler cursorLeft = (editorState, event) {
final nodes = editorState.service.selectionService.currentSelectedNodes; final nodes = editorState.service.selectionService.currentSelectedNodes;
final selection = final selection =
editorState.service.selectionService.currentSelection.value?.normalized; editorState.service.selectionService.currentSelection.value?.normalized;
@ -265,9 +265,9 @@ KeyEventResult cursorLeft(EditorState editorState, RawKeyEvent event) {
); );
} }
return KeyEventResult.handled; return KeyEventResult.handled;
} };
KeyEventResult cursorRight(EditorState editorState, RawKeyEvent event) { ShortcutEventHandler cursorRight = (editorState, event) {
final nodes = editorState.service.selectionService.currentSelectedNodes; final nodes = editorState.service.selectionService.currentSelectedNodes;
final selection = final selection =
editorState.service.selectionService.currentSelection.value?.normalized; editorState.service.selectionService.currentSelection.value?.normalized;
@ -287,7 +287,7 @@ KeyEventResult cursorRight(EditorState editorState, RawKeyEvent event) {
); );
} }
return KeyEventResult.handled; return KeyEventResult.handled;
} };
extension on Position { extension on Position {
Position? goLeft(EditorState editorState) { Position? goLeft(EditorState editorState) {

View File

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

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

View File

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

View File

@ -13,6 +13,7 @@ class SelectionGestureDetector extends StatefulWidget {
this.onTapDown, this.onTapDown,
this.onDoubleTapDown, this.onDoubleTapDown,
this.onTripleTapDown, this.onTripleTapDown,
this.onSecondaryTapDown,
this.onPanStart, this.onPanStart,
this.onPanUpdate, this.onPanUpdate,
this.onPanEnd, this.onPanEnd,
@ -27,6 +28,7 @@ class SelectionGestureDetector extends StatefulWidget {
final GestureTapDownCallback? onTapDown; final GestureTapDownCallback? onTapDown;
final GestureTapDownCallback? onDoubleTapDown; final GestureTapDownCallback? onDoubleTapDown;
final GestureTapDownCallback? onTripleTapDown; final GestureTapDownCallback? onTripleTapDown;
final GestureTapDownCallback? onSecondaryTapDown;
final GestureDragStartCallback? onPanStart; final GestureDragStartCallback? onPanStart;
final GestureDragUpdateCallback? onPanUpdate; final GestureDragUpdateCallback? onPanUpdate;
final GestureDragEndCallback? onPanEnd; final GestureDragEndCallback? onPanEnd;
@ -60,6 +62,7 @@ class SelectionGestureDetectorState extends State<SelectionGestureDetector> {
() => TapGestureRecognizer(), () => TapGestureRecognizer(),
(recognizer) { (recognizer) {
recognizer.onTapDown = _tapDownDelegate; 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/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:flutter/material.dart';
import 'package:appflowy_editor/src/core/document/node.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<Rect> selectionRects = [];
final List<OverlayEntry> _selectionAreas = []; final List<OverlayEntry> _selectionAreas = [];
final List<OverlayEntry> _cursorAreas = []; final List<OverlayEntry> _cursorAreas = [];
final List<OverlayEntry> _contextMenuAreas = [];
// OverlayEntry? _debugOverlay; // OverlayEntry? _debugOverlay;
@ -156,6 +159,7 @@ class _AppFlowySelectionState extends State<AppFlowySelection>
onPanUpdate: _onPanUpdate, onPanUpdate: _onPanUpdate,
onPanEnd: _onPanEnd, onPanEnd: _onPanEnd,
onTapDown: _onTapDown, onTapDown: _onTapDown,
onSecondaryTapDown: _onSecondaryTapDown,
onDoubleTapDown: _onDoubleTapDown, onDoubleTapDown: _onDoubleTapDown,
onTripleTapDown: _onTripleTapDown, onTripleTapDown: _onTripleTapDown,
child: widget.child, child: widget.child,
@ -232,6 +236,9 @@ class _AppFlowySelectionState extends State<AppFlowySelection>
// hide toolbar // hide toolbar
editorState.service.toolbarService?.hide(); editorState.service.toolbarService?.hide();
// clear context menu
_clearContextMenu();
} }
@override @override
@ -242,6 +249,12 @@ class _AppFlowySelectionState extends State<AppFlowySelection>
..clear(); ..clear();
} }
void _clearContextMenu() {
_contextMenuAreas
..forEach((overlay) => overlay.remove())
..clear();
}
@override @override
Node? getNodeInOffset(Offset offset) { Node? getNodeInOffset(Offset offset) {
final sortedNodes = final sortedNodes =
@ -311,6 +324,20 @@ class _AppFlowySelectionState extends State<AppFlowySelection>
_enableInteraction(); _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) { void _onPanStart(DragStartDetails details) {
clearSelection(); clearSelection();
@ -477,6 +504,20 @@ class _AppFlowySelectionState extends State<AppFlowySelection>
_cursorKey.currentState?.unwrapOrNull<CursorWidgetState>()?.show(); _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() { void _scrollUpOrDownIfNeeded() {
final dy = editorState.service.scrollService?.dy; final dy = editorState.service.scrollService?.dy;
final selectNodes = currentSelectedNodes; final selectNodes = currentSelectedNodes;

View File

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

View File

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