feat: support mobile drag selection (#209)

* feat: add handler for mobile selection widget

* feat: support dragging handler to update selection

* chore: format code

* feat: support dragging the cursor on mobile platform

* feat: optimize the scroll service
This commit is contained in:
Lucas.Xu 2023-06-20 10:26:22 +08:00 • committed by GitHub
commit 6e79060260
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 501 additions and 283 deletions

View file

@ -70,13 +70,17 @@ class _HomePageState extends State<HomePage> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
key: _scaffoldKey, key: _scaffoldKey,
extendBodyBehindAppBar: true, extendBodyBehindAppBar: PlatformExtension.isDesktopOrWeb,
drawer: _buildDrawer(context), drawer: _buildDrawer(context),
appBar: AppBar(
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
title: Text('AppFlowy Editor'),
),
body: SafeArea(child: _buildBody(context)), body: SafeArea(child: _buildBody(context)),
floatingActionButton: _buildFloatingActionButton(context), floatingActionButton: PlatformExtension.isDesktopOrWeb
floatingActionButtonLocation: PlatformExtension.isMobile ? _buildFloatingActionButton(context)
? FloatingActionButtonLocation.startTop : null,
: FloatingActionButtonLocation.endFloat, floatingActionButtonLocation: FloatingActionButtonLocation.endFloat,
); );
} }

View file

@ -14,7 +14,7 @@ class MyApp extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return const MaterialApp( return MaterialApp(
localizationsDelegates: [ localizationsDelegates: [
GlobalMaterialLocalizations.delegate, GlobalMaterialLocalizations.delegate,
GlobalCupertinoLocalizations.delegate, GlobalCupertinoLocalizations.delegate,
@ -24,6 +24,10 @@ class MyApp extends StatelessWidget {
supportedLocales: [Locale('en', 'US')], supportedLocales: [Locale('en', 'US')],
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
home: MyHomePage(title: 'AppFlowyEditor Example'), home: MyHomePage(title: 'AppFlowyEditor Example'),
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
useMaterial3: true,
),
); );
} }
} }

View file

@ -101,30 +101,6 @@ class _DesktopSelectionServiceWidgetState
); );
} }
@override
List<Node> getNodesInSelection(Selection selection) {
final start =
selection.isBackward ? selection.start.path : selection.end.path;
final end =
selection.isBackward ? selection.end.path : selection.start.path;
assert(start <= end);
final startNode = editorState.document.nodeAtPath(start);
final endNode = editorState.document.nodeAtPath(end);
if (startNode != null && endNode != null) {
final nodes = NodeIterator(
document: editorState.document,
startNode: startNode,
endNode: endNode,
).toList();
if (selection.isBackward) {
return nodes;
} else {
return nodes.reversed.toList(growable: false);
}
}
return [];
}
@override @override
void updateSelection(Selection? selection) { void updateSelection(Selection? selection) {
if (currentSelection.value == selection) { if (currentSelection.value == selection) {
@ -352,7 +328,7 @@ class _DesktopSelectionServiceWidgetState
void _updateBlockSelectionAreas(Selection selection) { void _updateBlockSelectionAreas(Selection selection) {
assert(editorState.selectionType == SelectionType.block); assert(editorState.selectionType == SelectionType.block);
final nodes = getNodesInSelection(selection).normalized; final nodes = editorState.getNodesInSelection(selection).normalized;
currentSelectedNodes = nodes; currentSelectedNodes = nodes;
@ -383,7 +359,7 @@ class _DesktopSelectionServiceWidgetState
} }
void _updateSelectionAreas(Selection selection) { void _updateSelectionAreas(Selection selection) {
final nodes = getNodesInSelection(selection); final nodes = editorState.getNodesInSelection(selection);
currentSelectedNodes = nodes; currentSelectedNodes = nodes;

View file

@ -1,14 +1,27 @@
import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor/src/editor/editor_component/service/renderer/block_component_action.dart';
import 'package:appflowy_editor/src/flutter/overlay.dart'; import 'package:appflowy_editor/src/flutter/overlay.dart';
import 'package:appflowy_editor/src/service/context_menu/built_in_context_menu_item.dart'; import 'package:appflowy_editor/src/render/selection/mobile_selection_widget.dart';
import 'package:appflowy_editor/src/service/context_menu/context_menu.dart'; import 'package:appflowy_editor/src/service/selection/mobile_selection_gesture.dart';
import 'package:flutter/material.dart' hide Overlay, OverlayEntry; import 'package:flutter/material.dart' hide Overlay, OverlayEntry;
import 'package:appflowy_editor/src/render/selection/cursor_widget.dart'; import 'package:appflowy_editor/src/render/selection/cursor_widget.dart';
import 'package:appflowy_editor/src/render/selection/selection_widget.dart'; import 'package:appflowy_editor/src/render/selection/selection_widget.dart';
import 'package:appflowy_editor/src/service/selection/selection_gesture.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
enum MobileSelectionDragMode {
none,
leftSelectionHandler,
rightSelectionHandler,
cursor;
}
enum MobileSelectionHandlerType {
leftHandler,
rightHandler,
cursorHandler,
}
class MobileSelectionServiceWidget extends StatefulWidget { class MobileSelectionServiceWidget extends StatefulWidget {
const MobileSelectionServiceWidget({ const MobileSelectionServiceWidget({
Key? key, Key? key,
@ -36,7 +49,6 @@ class _MobileSelectionServiceWidgetState
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 = [];
@override @override
ValueNotifier<Selection?> currentSelection = ValueNotifier(null); ValueNotifier<Selection?> currentSelection = ValueNotifier(null);
@ -44,6 +56,14 @@ class _MobileSelectionServiceWidgetState
@override @override
List<Node> currentSelectedNodes = []; List<Node> currentSelectedNodes = [];
final List<SelectionGestureInterceptor> _interceptors = [];
/// Pan
Offset? _panStartOffset;
double? _panStartScrollDy;
MobileSelectionDragMode dragMode = MobileSelectionDragMode.none;
late EditorState editorState = Provider.of<EditorState>( late EditorState editorState = Provider.of<EditorState>(
context, context,
listen: false, listen: false,
@ -57,20 +77,6 @@ class _MobileSelectionServiceWidgetState
editorState.selectionNotifier.addListener(_updateSelection); editorState.selectionNotifier.addListener(_updateSelection);
} }
@override
void didChangeMetrics() {
super.didChangeMetrics();
// Need to refresh the selection when the metrics changed.
if (currentSelection.value != null) {
// Debounce.debounce(
// 'didChangeMetrics - update selection ',
// const Duration(milliseconds: 100),
// () => updateSelection(currentSelection.value!),
// );
}
}
@override @override
void dispose() { void dispose() {
clearSelection(); clearSelection();
@ -82,41 +88,23 @@ class _MobileSelectionServiceWidgetState
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return SelectionGestureDetector( return MobileSelectionGestureDetector(
onPanStart: _onPanStart,
onPanUpdate: _onPanUpdate,
onPanEnd: _onPanEnd,
onTapDown: _onTapDown, onTapDown: _onTapDown,
onSecondaryTapDown: _onSecondaryTapDown,
onDoubleTapDown: _onDoubleTapDown, onDoubleTapDown: _onDoubleTapDown,
onTripleTapDown: _onTripleTapDown, onTripleTapDown: _onTripleTapDown,
child: widget.child, child: widget.child,
); );
} }
@override
List<Node> getNodesInSelection(Selection selection) {
final start =
selection.isBackward ? selection.start.path : selection.end.path;
final end =
selection.isBackward ? selection.end.path : selection.start.path;
assert(start <= end);
final startNode = editorState.document.nodeAtPath(start);
final endNode = editorState.document.nodeAtPath(end);
if (startNode != null && endNode != null) {
final nodes = NodeIterator(
document: editorState.document,
startNode: startNode,
endNode: endNode,
).toList();
if (selection.isBackward) {
return nodes;
} else {
return nodes.reversed.toList(growable: false);
}
}
return [];
}
@override @override
void updateSelection(Selection? selection) { void updateSelection(Selection? selection) {
if (currentSelection.value == selection) {
return;
}
selectionRects.clear(); selectionRects.clear();
_clearSelection(); _clearSelection();
@ -124,6 +112,7 @@ class _MobileSelectionServiceWidgetState
if (selection.isCollapsed) { if (selection.isCollapsed) {
// updates cursor area. // updates cursor area.
Log.selection.debug('update cursor area, $selection'); Log.selection.debug('update cursor area, $selection');
_forceShowCursor();
_updateCursorAreas(selection.start); _updateCursorAreas(selection.start);
} else { } else {
// updates selection area. // updates selection area.
@ -133,30 +122,7 @@ class _MobileSelectionServiceWidgetState
} }
currentSelection.value = selection; currentSelection.value = selection;
editorState.updateCursorSelection(selection, CursorUpdateReason.uiEvent); editorState.selection = selection;
}
void _updateSelection() {
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
selectionRects.clear();
_clearSelection();
final selection = editorState.selection;
if (selection != null) {
if (selection.isCollapsed) {
// updates cursor area.
Log.selection.debug('update cursor area, $selection');
_updateCursorAreas(selection.start);
} else {
// updates selection area.
Log.selection.debug('update cursor area, $selection');
_updateSelectionAreas(selection);
}
}
currentSelection.value = selection;
});
} }
@override @override
@ -174,12 +140,6 @@ class _MobileSelectionServiceWidgetState
..forEach((overlay) => overlay.remove()) ..forEach((overlay) => overlay.remove())
..clear(); ..clear();
// clear cursor areas // clear cursor areas
// hide toolbar
// editorState.service.toolbarService?.hide();
// clear context menu
_clearContextMenu();
} }
@override @override
@ -190,12 +150,6 @@ class _MobileSelectionServiceWidgetState
..clear(); ..clear();
} }
void _clearContextMenu() {
_contextMenuAreas
..forEach((overlay) => overlay.remove())
..clear();
}
@override @override
Node? getNodeInOffset(Offset offset) { Node? getNodeInOffset(Offset offset) {
final sortedNodes = final sortedNodes =
@ -219,21 +173,73 @@ class _MobileSelectionServiceWidgetState
return selectable.getPositionInOffset(offset); return selectable.getPositionInOffset(offset);
} }
@override
void registerGestureInterceptor(SelectionGestureInterceptor interceptor) {
_interceptors.add(interceptor);
}
@override
void unregisterGestureInterceptor(String key) {
_interceptors.removeWhere((element) => element.key == key);
}
void _updateSelection() {
final selection = editorState.selection;
// TODO: why do we need to check this?
if (currentSelection.value == selection &&
editorState.selectionUpdateReason == SelectionUpdateReason.uiEvent &&
editorState.selectionType != SelectionType.block) {
return;
}
currentSelection.value = selection;
void updateSelection() {
selectionRects.clear();
_clearSelection();
if (selection != null) {
if (editorState.selectionType == SelectionType.block) {
// updates selection area.
Log.selection.debug('update block selection area, $selection');
_updateBlockSelectionAreas(selection);
} else if (selection.isCollapsed) {
// updates cursor area.
Log.selection.debug('update cursor area, $selection');
_updateCursorAreas(selection.start);
} else {
// updates selection area.
Log.selection.debug('update selection area, $selection');
_updateSelectionAreas(selection);
}
}
}
if (editorState.selectionUpdateReason == SelectionUpdateReason.uiEvent) {
updateSelection();
} else {
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
updateSelection();
});
}
}
void _onTapDown(TapDownDetails details) { void _onTapDown(TapDownDetails details) {
final canTap = final canTap = _interceptors.every(
_interceptors.every((element) => element.canTap?.call(details) ?? true); (element) => element.canTap?.call(details) ?? true,
);
if (!canTap) return; if (!canTap) return;
editorState.service.scrollService?.stopAutoScroll(); // clear old state.
_panStartOffset = null;
final position = getPositionInOffset(details.globalPosition); final position = getPositionInOffset(details.globalPosition);
if (position == null) { if (position == null) {
return; return;
} }
final selection = Selection.collapsed(position);
updateSelection(selection);
_showDebugLayerIfNeeded(offset: details.globalPosition); // updateSelection(selection);
editorState.selection = Selection.collapsed(position);
} }
void _onDoubleTapDown(TapDownDetails details) { void _onDoubleTapDown(TapDownDetails details) {
@ -262,32 +268,114 @@ class _MobileSelectionServiceWidgetState
updateSelection(selection); updateSelection(selection);
} }
void _onSecondaryTapDown(TapDownDetails details) { void _onPanStart(DragStartDetails details) {
// if selection is null, or _panStartOffset = details.globalPosition.translate(-3.0, 0);
// selection.isCollapsedand and the selected node is TextNode. _panStartScrollDy = editorState.service.scrollService?.dy;
// try to select the word.
final selection = currentSelection.value;
if (selection == null ||
(selection.isCollapsed == true &&
currentSelectedNodes.first is TextNode)) {
_onDoubleTapDown(details);
}
_showContextMenu(details); final position = details.globalPosition;
final selection = editorState.selection;
if (selection == null) {
dragMode = MobileSelectionDragMode.none;
} else if (selection.isCollapsed &&
_isOverlayOnHandler(
position,
MobileSelectionHandlerType.cursorHandler,
)) {
dragMode = MobileSelectionDragMode.cursor;
} else if (_isOverlayOnHandler(
position,
MobileSelectionHandlerType.leftHandler,
)) {
dragMode = MobileSelectionDragMode.leftSelectionHandler;
} else if (_isOverlayOnHandler(
position,
MobileSelectionHandlerType.rightHandler,
)) {
dragMode = MobileSelectionDragMode.rightSelectionHandler;
} else {
dragMode = MobileSelectionDragMode.none;
}
} }
void _updateSelectionAreas(Selection selection) { void _onPanUpdate(DragUpdateDetails details) {
final nodes = getNodesInSelection(selection); if (_panStartOffset == null || _panStartScrollDy == null) {
return;
}
// only support selection mode now.
final selection = editorState.selection;
if (selection == null || dragMode == MobileSelectionDragMode.none) {
return;
}
final panEndOffset = details.globalPosition;
final dy = editorState.service.scrollService?.dy;
final panStartOffset = dy == null
? _panStartOffset!
: _panStartOffset!.translate(0, _panStartScrollDy! - dy);
final end = getNodeInOffset(panEndOffset)
?.selectable
?.getSelectionInRange(panStartOffset, panEndOffset)
.end;
if (end != null) {
if (dragMode == MobileSelectionDragMode.leftSelectionHandler) {
updateSelection(
selection.copyWith(start: end),
);
} else if (dragMode == MobileSelectionDragMode.rightSelectionHandler) {
updateSelection(
selection.copyWith(end: end),
);
} else if (dragMode == MobileSelectionDragMode.cursor) {
updateSelection(
Selection.collapsed(end),
);
}
}
}
void _onPanEnd(DragEndDetails details) {
// do nothing
}
void _updateBlockSelectionAreas(Selection selection) {
assert(editorState.selectionType == SelectionType.block);
final nodes = editorState.getNodesInSelection(selection).normalized;
currentSelectedNodes = nodes; currentSelectedNodes = nodes;
// TODO: need to be refactored. final node = nodes.first;
Offset? toolbarOffset; var offset = Offset.zero;
Alignment? alignment; var size = node.rect.size;
LayerLink? layerLink; final builder = editorState.renderer.blockComponentBuilder(node.type);
final editorOffset = if (builder != null && builder.showActions(node)) {
editorState.renderBox?.localToGlobal(Offset.zero) ?? Offset.zero; offset = offset.translate(blockComponentActionContainerWidth, 0);
final editorSize = editorState.renderBox?.size ?? Size.zero; size = Size(size.width - blockComponentActionContainerWidth, size.height);
}
final rect = offset & size;
final overlay = OverlayEntry(
builder: (context) => MobileSelectionWidget(
color: widget.selectionColor,
layerLink: node.layerLink,
rect: rect,
decoration: BoxDecoration(
color: widget.selectionColor,
borderRadius: BorderRadius.circular(4.0),
),
),
);
_selectionAreas.add(overlay);
Overlay.of(context)?.insertAll(_selectionAreas);
}
void _updateSelectionAreas(Selection selection) {
final nodes = editorState.getNodesInSelection(selection);
currentSelectedNodes = nodes;
final backwardNodes = final backwardNodes =
selection.isBackward ? nodes : nodes.reversed.toList(growable: false); selection.isBackward ? nodes : nodes.reversed.toList(growable: false);
@ -296,83 +384,66 @@ class _MobileSelectionServiceWidgetState
Log.selection.debug('update selection areas, $normalizedSelection'); Log.selection.debug('update selection areas, $normalizedSelection');
for (var i = 0; i < backwardNodes.length; i++) { if (editorState.selectionType == SelectionType.block) {
final node = backwardNodes[i]; final node = backwardNodes.first;
final selectable = node.selectable; final rect = Offset.zero & node.rect.size;
if (selectable == null) { final overlay = OverlayEntry(
continue; builder: (context) => SelectionWidget(
} color: widget.selectionColor,
layerLink: node.layerLink,
rect: rect,
),
);
_selectionAreas.add(overlay);
} else {
for (var i = 0; i < backwardNodes.length; i++) {
final node = backwardNodes[i];
var newSelection = normalizedSelection.copyWith(); final selectable = node.selectable;
if (selectable == null) {
continue;
}
/// In the case of multiple selections, var newSelection = normalizedSelection.copyWith();
/// we need to return a new selection for each selected node individually.
/// /// In the case of multiple selections,
/// < > means selected. /// we need to return a new selection for each selected node individually.
/// text: abcd<ef ///
/// text: ghijkl /// < > means selected.
/// text: mn>opqr /// text: abcd<ef
/// /// text: ghijkl
if (!normalizedSelection.isSingle) { /// text: mn>opqr
if (i == 0) { ///
newSelection = newSelection.copyWith(end: selectable.end()); if (!normalizedSelection.isSingle) {
} else if (i == nodes.length - 1) { if (i == 0) {
newSelection = newSelection.copyWith(start: selectable.start()); newSelection = newSelection.copyWith(end: selectable.end());
} else { } else if (i == nodes.length - 1) {
newSelection = Selection( newSelection = newSelection.copyWith(start: selectable.start());
start: selectable.start(), } else {
end: selectable.end(), newSelection = Selection(
start: selectable.start(),
end: selectable.end(),
);
}
}
final rects = selectable.getRectsInSelection(newSelection);
for (final (j, rect) in rects.indexed) {
final selectionRect = selectable.transformRectToGlobal(rect);
selectionRects.add(selectionRect);
final overlay = OverlayEntry(
builder: (context) => MobileSelectionWidget(
color: widget.selectionColor,
layerLink: node.layerLink,
rect: rect,
showLeftHandler: i == 0 && j == 0,
showRightHandler:
i == backwardNodes.length - 1 && j == rects.length - 1,
),
); );
_selectionAreas.add(overlay);
} }
} }
const baseToolbarOffset = Offset(0, 35.0);
final rects = selectable.getRectsInSelection(newSelection);
for (final rect in rects) {
final selectionRect = selectable.transformRectToGlobal(rect);
selectionRects.add(selectionRect);
// TODO: Need to compute more precise location.
if ((selectionRect.topLeft.dy - editorOffset.dy) <=
baseToolbarOffset.dy) {
if (selectionRect.topLeft.dx <=
editorSize.width / 3.0 + editorOffset.dx) {
toolbarOffset ??= rect.bottomLeft;
alignment ??= Alignment.topLeft;
} else if (selectionRect.topRight.dx >=
editorSize.width * 2.0 / 3.0 + editorOffset.dx) {
toolbarOffset ??= rect.bottomRight;
alignment ??= Alignment.topRight;
} else {
toolbarOffset ??= rect.bottomCenter;
alignment ??= Alignment.topCenter;
}
} else {
if (selectionRect.topLeft.dx <=
editorSize.width / 3.0 + editorOffset.dx) {
toolbarOffset ??= rect.topLeft - baseToolbarOffset;
alignment ??= Alignment.topLeft;
} else if (selectionRect.topRight.dx >=
editorSize.width * 2.0 / 3.0 + editorOffset.dx) {
toolbarOffset ??= rect.topRight - baseToolbarOffset;
alignment ??= Alignment.topRight;
} else {
toolbarOffset ??= rect.topCenter - baseToolbarOffset;
alignment ??= Alignment.topCenter;
}
}
layerLink ??= node.layerLink;
final overlay = OverlayEntry(
builder: (context) => SelectionWidget(
color: widget.selectionColor,
layerLink: node.layerLink,
rect: rect,
),
);
_selectionAreas.add(overlay);
}
} }
Overlay.of(context)?.insertAll(_selectionAreas); Overlay.of(context)?.insertAll(_selectionAreas);
@ -418,30 +489,6 @@ class _MobileSelectionServiceWidgetState
_cursorKey.currentState?.unwrapOrNull<CursorWidgetState>()?.show(); _cursorKey.currentState?.unwrapOrNull<CursorWidgetState>()?.show();
} }
void _showContextMenu(TapDownDetails details) {
_clearContextMenu();
// For now, only support the text node.
if (!currentSelectedNodes.every((element) => element is TextNode)) {
return;
}
final baseOffset =
editorState.renderBox?.localToGlobal(Offset.zero) ?? Offset.zero;
final offset = details.globalPosition + const Offset(10, 10) - baseOffset;
final contextMenu = OverlayEntry(
builder: (context) => ContextMenu(
position: offset,
editorState: editorState,
items: builtInContextMenuItems,
onPressed: () => _clearContextMenu(),
),
);
_contextMenuAreas.add(contextMenu);
Overlay.of(context)?.insert(contextMenu);
}
Node? _getNodeInOffset( Node? _getNodeInOffset(
List<Node> sortedNodes, List<Node> sortedNodes,
Offset offset, Offset offset,
@ -476,51 +523,33 @@ class _MobileSelectionServiceWidgetState
return node; return node;
} }
void _showDebugLayerIfNeeded({Offset? offset}) { bool _isOverlayOnHandler(Offset point, MobileSelectionHandlerType type) {
// remove false to show debug overlay. if (selectionRects.isEmpty) {
// if (kDebugMode && false) { return false;
// _debugOverlay?.remove(); }
// if (offset != null) {
// _debugOverlay = OverlayEntry(
// builder: (context) => Positioned.fromRect(
// rect: Rect.fromPoints(offset, offset.translate(20, 20)),
// child: Container(
// color: Colors.red.withOpacity(0.2),
// ),
// ),
// );
// Overlay.of(context)?.insert(_debugOverlay!);
// } else if (_panStartOffset != null) {
// _debugOverlay = OverlayEntry(
// builder: (context) => Positioned.fromRect(
// rect: Rect.fromPoints(
// _panStartOffset?.translate(
// 0,
// -(editorState.service.scrollService!.dy -
// _panStartScrollDy!),
// ) ??
// Offset.zero,
// offset ?? Offset.zero),
// child: Container(
// color: Colors.red.withOpacity(0.2),
// ),
// ),
// );
// Overlay.of(context)?.insert(_debugOverlay!);
// } else {
// _debugOverlay = null;
// }
// }
}
final List<SelectionGestureInterceptor> _interceptors = []; const extend = 40.0;
@override switch (type) {
void registerGestureInterceptor(SelectionGestureInterceptor interceptor) { case MobileSelectionHandlerType.leftHandler:
_interceptors.add(interceptor); case MobileSelectionHandlerType.cursorHandler:
} final first = selectionRects.first;
final handlerRect = Rect.fromLTWH(
first.left - extend,
first.top - extend,
extend * 2,
first.height + 2 * extend,
);
return handlerRect.contains(point);
@override case MobileSelectionHandlerType.rightHandler:
void unregisterGestureInterceptor(String key) { final last = selectionRects.last;
_interceptors.removeWhere((element) => element.key == key); final rightHandlerRect = Rect.fromLTWH(
last.right - extend,
last.top - extend,
extend * 2,
last.height + 2 * extend,
);
return rightHandlerRect.contains(point);
}
} }
} }

View file

@ -63,10 +63,6 @@ class _SelectionServiceWidgetState extends State<SelectionServiceWidget>
@override @override
Node? getNodeInOffset(Offset offset) => forward.getNodeInOffset(offset); Node? getNodeInOffset(Offset offset) => forward.getNodeInOffset(offset);
@override
List<Node> getNodesInSelection(Selection selection) =>
forward.getNodesInSelection(selection);
@override @override
Position? getPositionInOffset(Offset offset) => Position? getPositionInOffset(Offset offset) =>
forward.getPositionInOffset(offset); forward.getPositionInOffset(offset);

View file

@ -0,0 +1,88 @@
import 'package:flutter/material.dart';
class MobileSelectionWidget extends StatelessWidget {
const MobileSelectionWidget({
Key? key,
required this.layerLink,
required this.rect,
required this.color,
this.decoration,
this.showLeftHandler = false,
this.showRightHandler = false,
}) : super(key: key);
final Color color;
final Rect rect;
final LayerLink layerLink;
final BoxDecoration? decoration;
final bool showLeftHandler;
final bool showRightHandler;
@override
Widget build(BuildContext context) {
return Positioned.fromRect(
rect: rect,
child: CompositedTransformFollower(
link: layerLink,
offset: rect.topLeft,
showWhenUnlinked: true,
// Ignore the gestures in selection overlays
// to solve the problem that selection areas cannot overlap.
child: IgnorePointer(
child: MobileSelectionWithHandler(
color: color,
decoration: decoration,
showLeftHandler: showLeftHandler,
showRightHandler: showRightHandler,
),
),
),
);
}
}
class MobileSelectionWithHandler extends StatelessWidget {
const MobileSelectionWithHandler({
super.key,
required this.color,
this.showLeftHandler = false,
this.showRightHandler = false,
this.handlerColor = Colors.black,
this.decoration,
this.handlerWidth = 2.0,
});
final Color color;
final BoxDecoration? decoration;
final bool showLeftHandler;
final bool showRightHandler;
final Color handlerColor;
final double handlerWidth;
@override
Widget build(BuildContext context) {
Widget child = Container(
color: decoration == null ? color : null,
decoration: decoration,
);
if (showLeftHandler || showRightHandler) {
child = Row(
children: [
if (showLeftHandler)
Container(
width: handlerWidth,
color: handlerColor,
),
Expanded(child: child),
if (showRightHandler)
Container(
width: handlerWidth,
color: handlerColor,
),
],
);
}
return child;
}
}

View file

@ -0,0 +1,125 @@
import 'dart:async';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
/// Because the flutter's [DoubleTapGestureRecognizer] will block the [TapGestureRecognizer]
/// for a while. So we need to implement our own GestureDetector.
class MobileSelectionGestureDetector extends StatefulWidget {
const MobileSelectionGestureDetector({
Key? key,
this.child,
this.onTapDown,
this.onDoubleTapDown,
this.onTripleTapDown,
this.onSecondaryTapDown,
this.onPanStart,
this.onPanUpdate,
this.onPanEnd,
}) : super(key: key);
@override
State<MobileSelectionGestureDetector> createState() =>
MobileSelectionGestureDetectorState();
final Widget? child;
final GestureTapDownCallback? onTapDown;
final GestureTapDownCallback? onDoubleTapDown;
final GestureTapDownCallback? onTripleTapDown;
final GestureTapDownCallback? onSecondaryTapDown;
final GestureDragStartCallback? onPanStart;
final GestureDragUpdateCallback? onPanUpdate;
final GestureDragEndCallback? onPanEnd;
}
class MobileSelectionGestureDetectorState
extends State<MobileSelectionGestureDetector> {
bool _isDoubleTap = false;
Timer? _doubleTapTimer;
int _tripleTabCount = 0;
Timer? _tripleTabTimer;
final kTripleTapTimeout = const Duration(milliseconds: 500);
@override
Widget build(BuildContext context) {
return RawGestureDetector(
behavior: HitTestBehavior.opaque,
gestures: {
PanGestureRecognizer:
GestureRecognizerFactoryWithHandlers<PanGestureRecognizer>(
() => PanGestureRecognizer(
supportedDevices: {
// // https://docs.flutter.dev/release/breaking-changes/trackpad-gestures#for-gesture-interactions-not-suitable-for-trackpad-usage
// PointerDeviceKind.trackpad,
PointerDeviceKind.touch,
PointerDeviceKind.mouse,
PointerDeviceKind.stylus,
PointerDeviceKind.invertedStylus,
},
),
(recognizer) {
recognizer
..onStart = widget.onPanStart
..onUpdate = widget.onPanUpdate
..onEnd = widget.onPanEnd;
},
),
TapGestureRecognizer:
GestureRecognizerFactoryWithHandlers<TapGestureRecognizer>(
() => TapGestureRecognizer(),
(recognizer) {
recognizer.onTapDown = _tapDownDelegate;
recognizer.onSecondaryTapDown = widget.onSecondaryTapDown;
},
),
},
child: widget.child,
);
}
void _tapDownDelegate(TapDownDetails tapDownDetails) {
if (_tripleTabCount == 2) {
_tripleTabCount = 0;
_tripleTabTimer?.cancel();
_tripleTabTimer = null;
if (widget.onTripleTapDown != null) {
widget.onTripleTapDown!(tapDownDetails);
}
} else if (_isDoubleTap) {
_isDoubleTap = false;
_doubleTapTimer?.cancel();
_doubleTapTimer = null;
if (widget.onDoubleTapDown != null) {
widget.onDoubleTapDown!(tapDownDetails);
}
_tripleTabCount++;
} else {
if (widget.onTapDown != null) {
widget.onTapDown!(tapDownDetails);
}
_isDoubleTap = true;
_doubleTapTimer?.cancel();
_doubleTapTimer = Timer(kDoubleTapTimeout, () {
_isDoubleTap = false;
_doubleTapTimer = null;
});
_tripleTabCount = 1;
_tripleTabTimer?.cancel();
_tripleTabTimer = Timer(kTripleTapTimeout, () {
_tripleTabCount = 0;
_tripleTabTimer = null;
});
}
}
@override
void dispose() {
_doubleTapTimer?.cancel();
_tripleTabTimer?.cancel();
super.dispose();
}
}

View file

@ -65,9 +65,6 @@ abstract class AppFlowySelectionService {
/// Clears the cursor area. /// Clears the cursor area.
void clearCursor(); void clearCursor();
/// Returns the [Node]s in [Selection].
List<Node> getNodesInSelection(Selection selection);
/// Returns the [Node] containing to the [offset]. /// Returns the [Node] containing to the [offset].
/// ///
/// [offset] must be under the global coordinate system. /// [offset] must be under the global coordinate system.

View file

@ -29,7 +29,6 @@ void main() {
); );
await editor.startTesting(shrinkWrap: false); await editor.startTesting(shrinkWrap: false);
final size = tester.getSize(find.byType(AppFlowyEditor)); final size = tester.getSize(find.byType(AppFlowyEditor));
print(size);
await editor.dispose(); await editor.dispose();
}); });