mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2024-08-30 18:12:39 +00:00
Merge pull request #1314 from LucasXu0/feature/context_menu
feat: implement context menu
This commit is contained in:
commit
833a6cd95f
@ -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(),
|
||||
);
|
||||
}
|
||||
|
@ -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);
|
||||
},
|
||||
),
|
||||
],
|
||||
];
|
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -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) {
|
||||
|
@ -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) {
|
||||
|
@ -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) {
|
||||
|
@ -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;
|
||||
};
|
@ -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((_) {
|
||||
|
@ -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., -[],
|
||||
|
@ -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;
|
||||
},
|
||||
),
|
||||
},
|
||||
|
@ -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;
|
||||
|
@ -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',
|
||||
|
@ -3,5 +3,5 @@ import 'package:flutter/material.dart';
|
||||
|
||||
typedef ShortcutEventHandler = KeyEventResult Function(
|
||||
EditorState editorState,
|
||||
RawKeyEvent event,
|
||||
RawKeyEvent? event,
|
||||
);
|
||||
|
@ -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.
|
||||
});
|
||||
});
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user