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) {
|
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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
final position = details.globalPosition;
|
||||||
if (selection == null ||
|
final selection = editorState.selection;
|
||||||
(selection.isCollapsed == true &&
|
if (selection == null) {
|
||||||
currentSelectedNodes.first is TextNode)) {
|
dragMode = MobileSelectionDragMode.none;
|
||||||
_onDoubleTapDown(details);
|
} 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) {
|
// only support selection mode now.
|
||||||
final nodes = getNodesInSelection(selection);
|
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,8 +384,21 @@ class _MobileSelectionServiceWidgetState
|
||||||
|
|
||||||
Log.selection.debug('update selection areas, $normalizedSelection');
|
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++) {
|
for (var i = 0; i < backwardNodes.length; i++) {
|
||||||
final node = backwardNodes[i];
|
final node = backwardNodes[i];
|
||||||
|
|
||||||
final selectable = node.selectable;
|
final selectable = node.selectable;
|
||||||
if (selectable == null) {
|
if (selectable == null) {
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -326,54 +427,24 @@ class _MobileSelectionServiceWidgetState
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const baseToolbarOffset = Offset(0, 35.0);
|
|
||||||
final rects = selectable.getRectsInSelection(newSelection);
|
final rects = selectable.getRectsInSelection(newSelection);
|
||||||
for (final rect in rects) {
|
for (final (j, rect) in rects.indexed) {
|
||||||
final selectionRect = selectable.transformRectToGlobal(rect);
|
final selectionRect = selectable.transformRectToGlobal(rect);
|
||||||
selectionRects.add(selectionRect);
|
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(
|
final overlay = OverlayEntry(
|
||||||
builder: (context) => SelectionWidget(
|
builder: (context) => MobileSelectionWidget(
|
||||||
color: widget.selectionColor,
|
color: widget.selectionColor,
|
||||||
layerLink: node.layerLink,
|
layerLink: node.layerLink,
|
||||||
rect: rect,
|
rect: rect,
|
||||||
|
showLeftHandler: i == 0 && j == 0,
|
||||||
|
showRightHandler:
|
||||||
|
i == backwardNodes.length - 1 && j == rects.length - 1,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
_selectionAreas.add(overlay);
|
_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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
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.
|
/// 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.
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue