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:
parent
b298ceb643
commit
6e79060260
9 changed files with 501 additions and 283 deletions
|
|
@ -70,13 +70,17 @@ class _HomePageState extends State<HomePage> {
|
|||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
key: _scaffoldKey,
|
||||
extendBodyBehindAppBar: true,
|
||||
extendBodyBehindAppBar: PlatformExtension.isDesktopOrWeb,
|
||||
drawer: _buildDrawer(context),
|
||||
appBar: AppBar(
|
||||
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
|
||||
title: Text('AppFlowy Editor'),
|
||||
),
|
||||
body: SafeArea(child: _buildBody(context)),
|
||||
floatingActionButton: _buildFloatingActionButton(context),
|
||||
floatingActionButtonLocation: PlatformExtension.isMobile
|
||||
? FloatingActionButtonLocation.startTop
|
||||
: FloatingActionButtonLocation.endFloat,
|
||||
floatingActionButton: PlatformExtension.isDesktopOrWeb
|
||||
? _buildFloatingActionButton(context)
|
||||
: null,
|
||||
floatingActionButtonLocation: FloatingActionButtonLocation.endFloat,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ class MyApp extends StatelessWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const MaterialApp(
|
||||
return MaterialApp(
|
||||
localizationsDelegates: [
|
||||
GlobalMaterialLocalizations.delegate,
|
||||
GlobalCupertinoLocalizations.delegate,
|
||||
|
|
@ -24,6 +24,10 @@ class MyApp extends StatelessWidget {
|
|||
supportedLocales: [Locale('en', 'US')],
|
||||
debugShowCheckedModeBanner: false,
|
||||
home: MyHomePage(title: 'AppFlowyEditor Example'),
|
||||
theme: ThemeData(
|
||||
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
|
||||
useMaterial3: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
void updateSelection(Selection? selection) {
|
||||
if (currentSelection.value == selection) {
|
||||
|
|
@ -352,7 +328,7 @@ class _DesktopSelectionServiceWidgetState
|
|||
|
||||
void _updateBlockSelectionAreas(Selection selection) {
|
||||
assert(editorState.selectionType == SelectionType.block);
|
||||
final nodes = getNodesInSelection(selection).normalized;
|
||||
final nodes = editorState.getNodesInSelection(selection).normalized;
|
||||
|
||||
currentSelectedNodes = nodes;
|
||||
|
||||
|
|
@ -383,7 +359,7 @@ class _DesktopSelectionServiceWidgetState
|
|||
}
|
||||
|
||||
void _updateSelectionAreas(Selection selection) {
|
||||
final nodes = getNodesInSelection(selection);
|
||||
final nodes = editorState.getNodesInSelection(selection);
|
||||
|
||||
currentSelectedNodes = nodes;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,14 +1,27 @@
|
|||
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/service/context_menu/built_in_context_menu_item.dart';
|
||||
import 'package:appflowy_editor/src/service/context_menu/context_menu.dart';
|
||||
import 'package:appflowy_editor/src/render/selection/mobile_selection_widget.dart';
|
||||
import 'package:appflowy_editor/src/service/selection/mobile_selection_gesture.dart';
|
||||
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/selection_widget.dart';
|
||||
import 'package:appflowy_editor/src/service/selection/selection_gesture.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
enum MobileSelectionDragMode {
|
||||
none,
|
||||
leftSelectionHandler,
|
||||
rightSelectionHandler,
|
||||
cursor;
|
||||
}
|
||||
|
||||
enum MobileSelectionHandlerType {
|
||||
leftHandler,
|
||||
rightHandler,
|
||||
cursorHandler,
|
||||
}
|
||||
|
||||
class MobileSelectionServiceWidget extends StatefulWidget {
|
||||
const MobileSelectionServiceWidget({
|
||||
Key? key,
|
||||
|
|
@ -36,7 +49,6 @@ class _MobileSelectionServiceWidgetState
|
|||
final List<Rect> selectionRects = [];
|
||||
final List<OverlayEntry> _selectionAreas = [];
|
||||
final List<OverlayEntry> _cursorAreas = [];
|
||||
final List<OverlayEntry> _contextMenuAreas = [];
|
||||
|
||||
@override
|
||||
ValueNotifier<Selection?> currentSelection = ValueNotifier(null);
|
||||
|
|
@ -44,6 +56,14 @@ class _MobileSelectionServiceWidgetState
|
|||
@override
|
||||
List<Node> currentSelectedNodes = [];
|
||||
|
||||
final List<SelectionGestureInterceptor> _interceptors = [];
|
||||
|
||||
/// Pan
|
||||
Offset? _panStartOffset;
|
||||
double? _panStartScrollDy;
|
||||
|
||||
MobileSelectionDragMode dragMode = MobileSelectionDragMode.none;
|
||||
|
||||
late EditorState editorState = Provider.of<EditorState>(
|
||||
context,
|
||||
listen: false,
|
||||
|
|
@ -57,20 +77,6 @@ class _MobileSelectionServiceWidgetState
|
|||
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
|
||||
void dispose() {
|
||||
clearSelection();
|
||||
|
|
@ -82,41 +88,23 @@ class _MobileSelectionServiceWidgetState
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SelectionGestureDetector(
|
||||
return MobileSelectionGestureDetector(
|
||||
onPanStart: _onPanStart,
|
||||
onPanUpdate: _onPanUpdate,
|
||||
onPanEnd: _onPanEnd,
|
||||
onTapDown: _onTapDown,
|
||||
onSecondaryTapDown: _onSecondaryTapDown,
|
||||
onDoubleTapDown: _onDoubleTapDown,
|
||||
onTripleTapDown: _onTripleTapDown,
|
||||
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
|
||||
void updateSelection(Selection? selection) {
|
||||
if (currentSelection.value == selection) {
|
||||
return;
|
||||
}
|
||||
|
||||
selectionRects.clear();
|
||||
_clearSelection();
|
||||
|
||||
|
|
@ -124,6 +112,7 @@ class _MobileSelectionServiceWidgetState
|
|||
if (selection.isCollapsed) {
|
||||
// updates cursor area.
|
||||
Log.selection.debug('update cursor area, $selection');
|
||||
_forceShowCursor();
|
||||
_updateCursorAreas(selection.start);
|
||||
} else {
|
||||
// updates selection area.
|
||||
|
|
@ -133,30 +122,7 @@ class _MobileSelectionServiceWidgetState
|
|||
}
|
||||
|
||||
currentSelection.value = selection;
|
||||
editorState.updateCursorSelection(selection, CursorUpdateReason.uiEvent);
|
||||
}
|
||||
|
||||
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;
|
||||
});
|
||||
editorState.selection = selection;
|
||||
}
|
||||
|
||||
@override
|
||||
|
|
@ -174,12 +140,6 @@ class _MobileSelectionServiceWidgetState
|
|||
..forEach((overlay) => overlay.remove())
|
||||
..clear();
|
||||
// clear cursor areas
|
||||
|
||||
// hide toolbar
|
||||
// editorState.service.toolbarService?.hide();
|
||||
|
||||
// clear context menu
|
||||
_clearContextMenu();
|
||||
}
|
||||
|
||||
@override
|
||||
|
|
@ -190,12 +150,6 @@ class _MobileSelectionServiceWidgetState
|
|||
..clear();
|
||||
}
|
||||
|
||||
void _clearContextMenu() {
|
||||
_contextMenuAreas
|
||||
..forEach((overlay) => overlay.remove())
|
||||
..clear();
|
||||
}
|
||||
|
||||
@override
|
||||
Node? getNodeInOffset(Offset offset) {
|
||||
final sortedNodes =
|
||||
|
|
@ -219,21 +173,73 @@ class _MobileSelectionServiceWidgetState
|
|||
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) {
|
||||
final canTap =
|
||||
_interceptors.every((element) => element.canTap?.call(details) ?? true);
|
||||
final canTap = _interceptors.every(
|
||||
(element) => element.canTap?.call(details) ?? true,
|
||||
);
|
||||
if (!canTap) return;
|
||||
|
||||
editorState.service.scrollService?.stopAutoScroll();
|
||||
// clear old state.
|
||||
_panStartOffset = null;
|
||||
|
||||
final position = getPositionInOffset(details.globalPosition);
|
||||
if (position == null) {
|
||||
return;
|
||||
}
|
||||
final selection = Selection.collapsed(position);
|
||||
updateSelection(selection);
|
||||
|
||||
_showDebugLayerIfNeeded(offset: details.globalPosition);
|
||||
// updateSelection(selection);
|
||||
editorState.selection = Selection.collapsed(position);
|
||||
}
|
||||
|
||||
void _onDoubleTapDown(TapDownDetails details) {
|
||||
|
|
@ -262,32 +268,114 @@ class _MobileSelectionServiceWidgetState
|
|||
updateSelection(selection);
|
||||
}
|
||||
|
||||
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);
|
||||
void _onPanStart(DragStartDetails details) {
|
||||
_panStartOffset = details.globalPosition.translate(-3.0, 0);
|
||||
_panStartScrollDy = editorState.service.scrollService?.dy;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
_showContextMenu(details);
|
||||
void _onPanUpdate(DragUpdateDetails details) {
|
||||
if (_panStartOffset == null || _panStartScrollDy == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
void _updateSelectionAreas(Selection selection) {
|
||||
final nodes = getNodesInSelection(selection);
|
||||
// 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;
|
||||
|
||||
// TODO: need to be refactored.
|
||||
Offset? toolbarOffset;
|
||||
Alignment? alignment;
|
||||
LayerLink? layerLink;
|
||||
final editorOffset =
|
||||
editorState.renderBox?.localToGlobal(Offset.zero) ?? Offset.zero;
|
||||
final editorSize = editorState.renderBox?.size ?? Size.zero;
|
||||
final node = nodes.first;
|
||||
var offset = Offset.zero;
|
||||
var size = node.rect.size;
|
||||
final builder = editorState.renderer.blockComponentBuilder(node.type);
|
||||
if (builder != null && builder.showActions(node)) {
|
||||
offset = offset.translate(blockComponentActionContainerWidth, 0);
|
||||
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 =
|
||||
selection.isBackward ? nodes : nodes.reversed.toList(growable: false);
|
||||
|
|
@ -296,8 +384,21 @@ class _MobileSelectionServiceWidgetState
|
|||
|
||||
Log.selection.debug('update selection areas, $normalizedSelection');
|
||||
|
||||
if (editorState.selectionType == SelectionType.block) {
|
||||
final node = backwardNodes.first;
|
||||
final rect = Offset.zero & node.rect.size;
|
||||
final overlay = OverlayEntry(
|
||||
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];
|
||||
|
||||
final selectable = node.selectable;
|
||||
if (selectable == null) {
|
||||
continue;
|
||||
|
|
@ -326,54 +427,24 @@ class _MobileSelectionServiceWidgetState
|
|||
}
|
||||
}
|
||||
|
||||
const baseToolbarOffset = Offset(0, 35.0);
|
||||
final rects = selectable.getRectsInSelection(newSelection);
|
||||
for (final rect in rects) {
|
||||
for (final (j, rect) in rects.indexed) {
|
||||
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(
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Overlay.of(context)?.insertAll(_selectionAreas);
|
||||
}
|
||||
|
|
@ -418,30 +489,6 @@ class _MobileSelectionServiceWidgetState
|
|||
_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(
|
||||
List<Node> sortedNodes,
|
||||
Offset offset,
|
||||
|
|
@ -476,51 +523,33 @@ class _MobileSelectionServiceWidgetState
|
|||
return node;
|
||||
}
|
||||
|
||||
void _showDebugLayerIfNeeded({Offset? offset}) {
|
||||
// remove false to show debug overlay.
|
||||
// if (kDebugMode && 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;
|
||||
// }
|
||||
// }
|
||||
bool _isOverlayOnHandler(Offset point, MobileSelectionHandlerType type) {
|
||||
if (selectionRects.isEmpty) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final List<SelectionGestureInterceptor> _interceptors = [];
|
||||
@override
|
||||
void registerGestureInterceptor(SelectionGestureInterceptor interceptor) {
|
||||
_interceptors.add(interceptor);
|
||||
}
|
||||
const extend = 40.0;
|
||||
switch (type) {
|
||||
case MobileSelectionHandlerType.leftHandler:
|
||||
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
|
||||
void unregisterGestureInterceptor(String key) {
|
||||
_interceptors.removeWhere((element) => element.key == key);
|
||||
case MobileSelectionHandlerType.rightHandler:
|
||||
final last = selectionRects.last;
|
||||
final rightHandlerRect = Rect.fromLTWH(
|
||||
last.right - extend,
|
||||
last.top - extend,
|
||||
extend * 2,
|
||||
last.height + 2 * extend,
|
||||
);
|
||||
return rightHandlerRect.contains(point);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -63,10 +63,6 @@ class _SelectionServiceWidgetState extends State<SelectionServiceWidget>
|
|||
@override
|
||||
Node? getNodeInOffset(Offset offset) => forward.getNodeInOffset(offset);
|
||||
|
||||
@override
|
||||
List<Node> getNodesInSelection(Selection selection) =>
|
||||
forward.getNodesInSelection(selection);
|
||||
|
||||
@override
|
||||
Position? getPositionInOffset(Offset offset) =>
|
||||
forward.getPositionInOffset(offset);
|
||||
|
|
|
|||
88
lib/src/render/selection/mobile_selection_widget.dart
Normal file
88
lib/src/render/selection/mobile_selection_widget.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
125
lib/src/service/selection/mobile_selection_gesture.dart
Normal file
125
lib/src/service/selection/mobile_selection_gesture.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -65,9 +65,6 @@ abstract class AppFlowySelectionService {
|
|||
/// Clears the cursor area.
|
||||
void clearCursor();
|
||||
|
||||
/// Returns the [Node]s in [Selection].
|
||||
List<Node> getNodesInSelection(Selection selection);
|
||||
|
||||
/// Returns the [Node] containing to the [offset].
|
||||
///
|
||||
/// [offset] must be under the global coordinate system.
|
||||
|
|
|
|||
|
|
@ -29,7 +29,6 @@ void main() {
|
|||
);
|
||||
await editor.startTesting(shrinkWrap: false);
|
||||
final size = tester.getSize(find.byType(AppFlowyEditor));
|
||||
print(size);
|
||||
await editor.dispose();
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue