diff --git a/example/lib/home_page.dart b/example/lib/home_page.dart index 73c506885..140df09ad 100644 --- a/example/lib/home_page.dart +++ b/example/lib/home_page.dart @@ -70,13 +70,17 @@ class _HomePageState extends State { 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, ); } diff --git a/example/lib/main.dart b/example/lib/main.dart index b98cec364..71eb95e4a 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -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, + ), ); } } diff --git a/lib/src/editor/editor_component/service/selection/desktop_selection_service.dart b/lib/src/editor/editor_component/service/selection/desktop_selection_service.dart index 2d301a86a..8d2a7ebf3 100644 --- a/lib/src/editor/editor_component/service/selection/desktop_selection_service.dart +++ b/lib/src/editor/editor_component/service/selection/desktop_selection_service.dart @@ -101,30 +101,6 @@ class _DesktopSelectionServiceWidgetState ); } - @override - List 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; diff --git a/lib/src/editor/editor_component/service/selection/mobile_selection_service.dart b/lib/src/editor/editor_component/service/selection/mobile_selection_service.dart index e99487625..46e2692a6 100644 --- a/lib/src/editor/editor_component/service/selection/mobile_selection_service.dart +++ b/lib/src/editor/editor_component/service/selection/mobile_selection_service.dart @@ -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 selectionRects = []; final List _selectionAreas = []; final List _cursorAreas = []; - final List _contextMenuAreas = []; @override ValueNotifier currentSelection = ValueNotifier(null); @@ -44,6 +56,14 @@ class _MobileSelectionServiceWidgetState @override List currentSelectedNodes = []; + final List _interceptors = []; + + /// Pan + Offset? _panStartOffset; + double? _panStartScrollDy; + + MobileSelectionDragMode dragMode = MobileSelectionDragMode.none; + late EditorState editorState = Provider.of( 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,9 +88,11 @@ 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, @@ -92,31 +100,11 @@ class _MobileSelectionServiceWidgetState } @override - List 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); - } + void updateSelection(Selection? selection) { + if (currentSelection.value == selection) { + return; } - return []; - } - @override - void updateSelection(Selection? selection) { 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,33 +268,115 @@ 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; + } + } + + void _onPanUpdate(DragUpdateDetails details) { + 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; + + 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); - _showContextMenu(details); + Overlay.of(context)?.insertAll(_selectionAreas); } void _updateSelectionAreas(Selection selection) { - final nodes = getNodesInSelection(selection); + final nodes = editorState.getNodesInSelection(selection); 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 backwardNodes = selection.isBackward ? nodes : nodes.reversed.toList(growable: false); final normalizedSelection = selection.normalized; @@ -296,82 +384,65 @@ class _MobileSelectionServiceWidgetState Log.selection.debug('update selection areas, $normalizedSelection'); - for (var i = 0; i < backwardNodes.length; i++) { - final node = backwardNodes[i]; - final selectable = node.selectable; - if (selectable == null) { - continue; - } - - var newSelection = normalizedSelection.copyWith(); - - /// In the case of multiple selections, - /// we need to return a new selection for each selected node individually. - /// - /// < > means selected. - /// text: abcdopqr - /// - if (!normalizedSelection.isSingle) { - if (i == 0) { - newSelection = newSelection.copyWith(end: selectable.end()); - } else if (i == nodes.length - 1) { - newSelection = newSelection.copyWith(start: selectable.start()); - } else { - newSelection = Selection( - start: selectable.start(), - end: selectable.end(), - ); + 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; } - } - 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; + var newSelection = normalizedSelection.copyWith(); + + /// In the case of multiple selections, + /// we need to return a new selection for each selected node individually. + /// + /// < > means selected. + /// text: abcdopqr + /// + if (!normalizedSelection.isSingle) { + if (i == 0) { + newSelection = newSelection.copyWith(end: selectable.end()); + } else if (i == nodes.length - 1) { + newSelection = newSelection.copyWith(start: selectable.start()); } else { - toolbarOffset ??= rect.topCenter - baseToolbarOffset; - alignment ??= Alignment.topCenter; + newSelection = Selection( + start: selectable.start(), + end: selectable.end(), + ); } } - layerLink ??= node.layerLink; - - final overlay = OverlayEntry( - builder: (context) => SelectionWidget( - color: widget.selectionColor, - layerLink: node.layerLink, - rect: rect, - ), - ); - _selectionAreas.add(overlay); + 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); + } } } @@ -418,30 +489,6 @@ class _MobileSelectionServiceWidgetState _cursorKey.currentState?.unwrapOrNull()?.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 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; - // } - // } - } - - final List _interceptors = []; - @override - void registerGestureInterceptor(SelectionGestureInterceptor interceptor) { - _interceptors.add(interceptor); - } + bool _isOverlayOnHandler(Offset point, MobileSelectionHandlerType type) { + if (selectionRects.isEmpty) { + return false; + } - @override - void unregisterGestureInterceptor(String key) { - _interceptors.removeWhere((element) => element.key == key); + 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); + + 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); + } } } diff --git a/lib/src/editor/editor_component/service/selection_service_widget.dart b/lib/src/editor/editor_component/service/selection_service_widget.dart index c11706a42..fdb0b5836 100644 --- a/lib/src/editor/editor_component/service/selection_service_widget.dart +++ b/lib/src/editor/editor_component/service/selection_service_widget.dart @@ -63,10 +63,6 @@ class _SelectionServiceWidgetState extends State @override Node? getNodeInOffset(Offset offset) => forward.getNodeInOffset(offset); - @override - List getNodesInSelection(Selection selection) => - forward.getNodesInSelection(selection); - @override Position? getPositionInOffset(Offset offset) => forward.getPositionInOffset(offset); diff --git a/lib/src/render/selection/mobile_selection_widget.dart b/lib/src/render/selection/mobile_selection_widget.dart new file mode 100644 index 000000000..766e86922 --- /dev/null +++ b/lib/src/render/selection/mobile_selection_widget.dart @@ -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; + } +} diff --git a/lib/src/service/selection/mobile_selection_gesture.dart b/lib/src/service/selection/mobile_selection_gesture.dart new file mode 100644 index 000000000..cd301f4d7 --- /dev/null +++ b/lib/src/service/selection/mobile_selection_gesture.dart @@ -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 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 { + 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( + 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(), + (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(); + } +} diff --git a/lib/src/service/selection_service.dart b/lib/src/service/selection_service.dart index 61c3a4b4d..1da25e412 100644 --- a/lib/src/service/selection_service.dart +++ b/lib/src/service/selection_service.dart @@ -65,9 +65,6 @@ abstract class AppFlowySelectionService { /// Clears the cursor area. void clearCursor(); - /// Returns the [Node]s in [Selection]. - List getNodesInSelection(Selection selection); - /// Returns the [Node] containing to the [offset]. /// /// [offset] must be under the global coordinate system. diff --git a/test/service/editor_service_test.dart b/test/service/editor_service_test.dart index 073b8e493..8d7ea0b65 100644 --- a/test/service/editor_service_test.dart +++ b/test/service/editor_service_test.dart @@ -29,7 +29,6 @@ void main() { ); await editor.startTesting(shrinkWrap: false); final size = tester.getSize(find.byType(AppFlowyEditor)); - print(size); await editor.dispose(); });