Jelajahi Sumber

feat: implement autoscrolling on edge touch

Lucas.Xu 2 tahun lalu
induk
melakukan
e5787090d2

+ 56 - 0
frontend/app_flowy/packages/flowy_editor/example/assets/example.json

@@ -241,6 +241,62 @@
           "subtype": "number-list",
           "number": 3
         }
+      },
+      {
+        "type": "text",
+        "delta": [
+          {
+            "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow."
+          }
+        ]
+      },
+      {
+        "type": "text",
+        "delta": [
+          {
+            "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow."
+          }
+        ]
+      },
+      {
+        "type": "text",
+        "delta": [
+          {
+            "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow."
+          }
+        ]
+      },
+      {
+        "type": "text",
+        "delta": [
+          {
+            "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow."
+          }
+        ]
+      },
+      {
+        "type": "text",
+        "delta": [
+          {
+            "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow."
+          }
+        ]
+      },
+      {
+        "type": "text",
+        "delta": [
+          {
+            "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow."
+          }
+        ]
+      },
+      {
+        "type": "text",
+        "delta": [
+          {
+            "insert": "At AppFlowy, we embody what we value deep in our hearts, taking inspiration from other great companies while forging our own path. AppFlowy’s five core values are Mission Driven, Aim High & Iterate, Transparency, Collaboration, and Honesty. Together, they spell MATCH. We will continue to iterate and refine these values as we grow."
+          }
+        ]
       }
     ]
   }

+ 22 - 23
frontend/app_flowy/packages/flowy_editor/lib/render/editor/editor_entry.dart

@@ -1,7 +1,8 @@
+import 'package:flutter/material.dart';
+
 import 'package:flowy_editor/document/node.dart';
 import 'package:flowy_editor/editor_state.dart';
 import 'package:flowy_editor/service/render_plugin_service.dart';
-import 'package:flutter/material.dart';
 
 class EditorEntryWidgetBuilder extends NodeWidgetBuilder<Node> {
   @override
@@ -31,28 +32,26 @@ class EditorNodeWidget extends StatelessWidget {
 
   @override
   Widget build(BuildContext context) {
-    return SingleChildScrollView(
-      child: Column(
-        crossAxisAlignment: CrossAxisAlignment.start,
-        children: node.children
-            .map(
-              (child) =>
-                  editorState.service.renderPluginService.buildPluginWidget(
-                child is TextNode
-                    ? NodeWidgetContext<TextNode>(
-                        context: context,
-                        node: child,
-                        editorState: editorState,
-                      )
-                    : NodeWidgetContext<Node>(
-                        context: context,
-                        node: child,
-                        editorState: editorState,
-                      ),
-              ),
-            )
-            .toList(),
-      ),
+    return Column(
+      crossAxisAlignment: CrossAxisAlignment.start,
+      children: node.children
+          .map(
+            (child) =>
+                editorState.service.renderPluginService.buildPluginWidget(
+              child is TextNode
+                  ? NodeWidgetContext<TextNode>(
+                      context: context,
+                      node: child,
+                      editorState: editorState,
+                    )
+                  : NodeWidgetContext<Node>(
+                      context: context,
+                      node: child,
+                      editorState: editorState,
+                    ),
+            ),
+          )
+          .toList(),
     );
   }
 }

+ 12 - 11
frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/flowy_rich_text.dart

@@ -1,16 +1,16 @@
+import 'package:flutter/material.dart';
+import 'package:flutter/rendering.dart';
+
 import 'package:flowy_editor/document/node.dart';
+import 'package:flowy_editor/document/path.dart';
 import 'package:flowy_editor/document/position.dart';
 import 'package:flowy_editor/document/selection.dart';
 import 'package:flowy_editor/document/text_delta.dart';
 import 'package:flowy_editor/editor_state.dart';
-import 'package:flowy_editor/document/path.dart';
 import 'package:flowy_editor/render/rich_text/rich_text_style.dart';
 import 'package:flowy_editor/render/selection/selectable.dart';
 import 'package:flowy_editor/service/render_plugin_service.dart';
 
-import 'package:flutter/material.dart';
-import 'package:flutter/rendering.dart';
-
 class RichTextNodeWidgetBuilder extends NodeWidgetBuilder<TextNode> {
   @override
   Widget build(NodeWidgetContext<TextNode> context) {
@@ -173,11 +173,12 @@ class _FlowyRichTextState extends State<FlowyRichText> with Selectable {
   }
 
   TextSpan get _textSpan => TextSpan(
-      children: widget.textNode.delta.operations
-          .whereType<TextInsert>()
-          .map((insert) => RichTextStyle(
-                attributes: insert.attributes ?? {},
-                text: insert.content,
-              ).toTextSpan())
-          .toList(growable: false));
+        children: widget.textNode.delta.operations
+            .whereType<TextInsert>()
+            .map((insert) => RichTextStyle(
+                  attributes: insert.attributes ?? {},
+                  text: insert.content,
+                ).toTextSpan())
+            .toList(growable: false),
+      );
 }

+ 42 - 23
frontend/app_flowy/packages/flowy_editor/lib/service/editor_service.dart

@@ -1,5 +1,4 @@
-import 'package:flowy_editor/service/internal_key_event_handlers/delete_text_handler.dart';
-import 'package:flowy_editor/service/internal_key_event_handlers/update_text_style_by_command_x_handler.dart';
+import 'package:flutter/gestures.dart';
 import 'package:flutter/material.dart';
 
 import 'package:flowy_editor/editor_state.dart';
@@ -13,10 +12,13 @@ import 'package:flowy_editor/render/rich_text/quoted_text.dart';
 import 'package:flowy_editor/service/input_service.dart';
 import 'package:flowy_editor/service/internal_key_event_handlers/arrow_keys_handler.dart';
 import 'package:flowy_editor/service/internal_key_event_handlers/delete_nodes_handler.dart';
+import 'package:flowy_editor/service/internal_key_event_handlers/delete_text_handler.dart';
 import 'package:flowy_editor/service/internal_key_event_handlers/enter_in_edge_of_text_node_handler.dart';
 import 'package:flowy_editor/service/internal_key_event_handlers/slash_handler.dart';
+import 'package:flowy_editor/service/internal_key_event_handlers/update_text_style_by_command_x_handler.dart';
 import 'package:flowy_editor/service/keyboard_service.dart';
 import 'package:flowy_editor/service/render_plugin_service.dart';
+import 'package:flowy_editor/service/scroll_service.dart';
 import 'package:flowy_editor/service/selection_service.dart';
 import 'package:flowy_editor/service/toolbar_service.dart';
 
@@ -60,15 +62,25 @@ class FlowyEditor extends StatefulWidget {
 }
 
 class _FlowyEditorState extends State<FlowyEditor> {
+  late ScrollController _scrollController;
+
   EditorState get editorState => widget.editorState;
 
   @override
   void initState() {
     super.initState();
 
+    _scrollController = ScrollController()..addListener(_scrollCallback);
     editorState.service.renderPluginService = _createRenderPlugin();
   }
 
+  @override
+  void dispose() {
+    _scrollController.dispose();
+
+    super.dispose();
+  }
+
   @override
   void didUpdateWidget(covariant FlowyEditor oldWidget) {
     super.didUpdateWidget(oldWidget);
@@ -80,33 +92,36 @@ class _FlowyEditorState extends State<FlowyEditor> {
 
   @override
   Widget build(BuildContext context) {
-    return FlowySelection(
-      key: editorState.service.selectionServiceKey,
-      editorState: editorState,
-      child: FlowyInput(
-        key: editorState.service.inputServiceKey,
-        editorState: editorState,
-        child: FlowyKeyboard(
-          key: editorState.service.keyboardServiceKey,
-          handlers: [
-            ...defaultKeyEventHandler,
-            ...widget.keyEventHandlers,
-          ],
+    return FlowyScroll(
+        key: editorState.service.scrollServiceKey,
+        child: FlowySelection(
+          key: editorState.service.selectionServiceKey,
           editorState: editorState,
-          child: FlowyToolbar(
-            key: editorState.service.toolbarServiceKey,
+          child: FlowyInput(
+            key: editorState.service.inputServiceKey,
             editorState: editorState,
-            child: editorState.service.renderPluginService.buildPluginWidget(
-              NodeWidgetContext(
-                context: context,
-                node: editorState.document.root,
+            child: FlowyKeyboard(
+              key: editorState.service.keyboardServiceKey,
+              handlers: [
+                ...defaultKeyEventHandler,
+                ...widget.keyEventHandlers,
+              ],
+              editorState: editorState,
+              child: FlowyToolbar(
+                key: editorState.service.toolbarServiceKey,
                 editorState: editorState,
+                child:
+                    editorState.service.renderPluginService.buildPluginWidget(
+                  NodeWidgetContext(
+                    context: context,
+                    node: editorState.document.root,
+                    editorState: editorState,
+                  ),
+                ),
               ),
             ),
           ),
-        ),
-      ),
-    );
+        ));
   }
 
   FlowyRenderPlugin _createRenderPlugin() => FlowyRenderPlugin(
@@ -116,4 +131,8 @@ class _FlowyEditorState extends State<FlowyEditor> {
           ...widget.customBuilders,
         },
       );
+
+  void _scrollCallback() {
+    debugPrint('scrolling');
+  }
 }

+ 65 - 0
frontend/app_flowy/packages/flowy_editor/lib/service/scroll_service.dart

@@ -0,0 +1,65 @@
+import 'package:flutter/gestures.dart';
+import 'package:flutter/material.dart';
+
+mixin FlowyScrollService<T extends StatefulWidget> on State<T> {
+  double get dy;
+
+  void scrollTo(double dy);
+
+  RenderObject? scrollRenderObject();
+}
+
+class FlowyScroll extends StatefulWidget {
+  const FlowyScroll({
+    Key? key,
+    required this.child,
+  }) : super(key: key);
+
+  final Widget child;
+
+  @override
+  State<FlowyScroll> createState() => _FlowyScrollState();
+}
+
+class _FlowyScrollState extends State<FlowyScroll> with FlowyScrollService {
+  final _scrollController = ScrollController();
+  final _scrollViewKey = GlobalKey();
+
+  @override
+  double get dy => _scrollController.position.pixels;
+
+  @override
+  Widget build(BuildContext context) {
+    return Listener(
+      onPointerSignal: _onPointerSignal,
+      child: SingleChildScrollView(
+        key: _scrollViewKey,
+        physics: const NeverScrollableScrollPhysics(),
+        controller: _scrollController,
+        child: widget.child,
+      ),
+    );
+  }
+
+  @override
+  void scrollTo(double dy) {
+    _scrollController.position.jumpTo(
+      dy.clamp(
+        _scrollController.position.minScrollExtent,
+        _scrollController.position.maxScrollExtent,
+      ),
+    );
+  }
+
+  void _onPointerSignal(PointerSignalEvent event) {
+    if (event is PointerScrollEvent) {
+      final dy = (_scrollController.position.pixels + event.scrollDelta.dy);
+      scrollTo(dy);
+    }
+  }
+
+  @override
+  RenderObject? scrollRenderObject() {
+    return _scrollViewKey.currentContext?.findRenderObject();
+  }
+}

+ 184 - 118
frontend/app_flowy/packages/flowy_editor/lib/service/selection_service.dart

@@ -1,5 +1,6 @@
 import 'dart:async';
 
+import 'package:flutter/foundation.dart';
 import 'package:flutter/gestures.dart';
 import 'package:flutter/material.dart';
 
@@ -99,102 +100,18 @@ class FlowySelection extends StatefulWidget {
   State<FlowySelection> createState() => _FlowySelectionState();
 }
 
-/// Because the flutter's [DoubleTapGestureRecognizer] will block the [TapGestureRecognizer]
-/// for a while. So we need to implement our own GestureDetector.
-@immutable
-class _SelectionGestureDetector extends StatefulWidget {
-  const _SelectionGestureDetector(
-      {Key? key,
-      this.child,
-      this.onTapDown,
-      this.onDoubleTapDown,
-      this.onPanStart,
-      this.onPanUpdate,
-      this.onPanEnd})
-      : super(key: key);
-
-  @override
-  State<_SelectionGestureDetector> createState() =>
-      _SelectionGestureDetectorState();
-
-  final Widget? child;
-
-  final GestureTapDownCallback? onTapDown;
-  final GestureTapDownCallback? onDoubleTapDown;
-  final GestureDragStartCallback? onPanStart;
-  final GestureDragUpdateCallback? onPanUpdate;
-  final GestureDragEndCallback? onPanEnd;
-}
-
-class _SelectionGestureDetectorState extends State<_SelectionGestureDetector> {
-  bool _isDoubleTap = false;
-  Timer? _doubleTapTimer;
-  @override
-  Widget build(BuildContext context) {
-    return RawGestureDetector(
-      behavior: HitTestBehavior.translucent,
-      gestures: {
-        PanGestureRecognizer:
-            GestureRecognizerFactoryWithHandlers<PanGestureRecognizer>(
-          () => PanGestureRecognizer(),
-          (recognizer) {
-            recognizer
-              ..onStart = widget.onPanStart
-              ..onUpdate = widget.onPanUpdate
-              ..onEnd = widget.onPanEnd;
-          },
-        ),
-        TapGestureRecognizer:
-            GestureRecognizerFactoryWithHandlers<TapGestureRecognizer>(
-          () => TapGestureRecognizer(),
-          (recognizer) {
-            recognizer.onTapDown = _tapDownDelegate;
-          },
-        ),
-      },
-      child: widget.child,
-    );
-  }
-
-  _tapDownDelegate(TapDownDetails tapDownDetails) {
-    if (_isDoubleTap) {
-      _isDoubleTap = false;
-      _doubleTapTimer?.cancel();
-      _doubleTapTimer = null;
-      if (widget.onDoubleTapDown != null) {
-        widget.onDoubleTapDown!(tapDownDetails);
-      }
-    } else {
-      if (widget.onTapDown != null) {
-        widget.onTapDown!(tapDownDetails);
-      }
-
-      _isDoubleTap = true;
-      _doubleTapTimer?.cancel();
-      _doubleTapTimer = Timer(kDoubleTapTimeout, () {
-        _isDoubleTap = false;
-        _doubleTapTimer = null;
-      });
-    }
-  }
-
-  @override
-  void dispose() {
-    _doubleTapTimer?.cancel();
-    super.dispose();
-  }
-}
-
 class _FlowySelectionState extends State<FlowySelection>
     with FlowySelectionService, WidgetsBindingObserver {
   final _cursorKey = GlobalKey(debugLabel: 'cursor');
 
   final List<OverlayEntry> _selectionOverlays = [];
   final List<OverlayEntry> _cursorOverlays = [];
+  OverlayEntry? _debugOverlay;
 
   /// [Pan] and [Tap] must be mutually exclusive.
   /// Pan
   Offset? panStartOffset;
+  double? panStartScrollDy;
   Offset? panEndOffset;
 
   /// Tap
@@ -259,7 +176,7 @@ class _FlowySelectionState extends State<FlowySelection>
   @override
   void updateSelection(Selection selection) {
     _rects.clear();
-    _clearSelection();
+    clearSelection();
 
     // cursor
     if (selection.isCollapsed) {
@@ -273,7 +190,19 @@ class _FlowySelectionState extends State<FlowySelection>
 
   @override
   void clearSelection() {
-    _clearSelection();
+    currentSelection = null;
+    currentSelectedNodes.value = [];
+
+    // clear selection
+    _selectionOverlays
+      ..forEach((overlay) => overlay.remove())
+      ..clear();
+    // clear cursors
+    _cursorOverlays
+      ..forEach((overlay) => overlay.remove())
+      ..clear();
+    // clear toolbar
+    editorState.service.toolbarService?.hide();
   }
 
   @override
@@ -325,7 +254,7 @@ class _FlowySelectionState extends State<FlowySelection>
       }
     }
     for (final child in node.children) {
-      result.addAll(computeNodesInRange(child, start, end));
+      result.addAll(_computeNodesInRange(child, start, end));
     }
     return result;
   }
@@ -411,12 +340,24 @@ class _FlowySelectionState extends State<FlowySelection>
     clearSelection();
 
     panStartOffset = details.globalPosition;
+    panStartScrollDy = editorState.service.scrollService?.dy;
+
+    debugPrint('[_onPanStart] panStartOffset = $panStartOffset');
   }
 
   void _onPanUpdate(DragUpdateDetails details) {
-    panEndOffset = details.globalPosition;
+    if (panStartOffset == null || panStartScrollDy == null) {
+      return;
+    }
 
-    final nodes = getNodesInRange(panStartOffset!, panEndOffset!);
+    panEndOffset = details.globalPosition;
+    final dy = editorState.service.scrollService?.dy;
+    var panStartOffsetWithScrollDyGap = panStartOffset!;
+    if (dy != null) {
+      panStartOffsetWithScrollDyGap =
+          panStartOffsetWithScrollDyGap.translate(0, panStartScrollDy! - dy);
+    }
+    final nodes = getNodesInRange(panStartOffsetWithScrollDyGap, panEndOffset!);
     if (nodes.isEmpty) {
       return;
     }
@@ -427,40 +368,30 @@ class _FlowySelectionState extends State<FlowySelection>
     if (first != null && last != null) {
       bool isDownward;
       if (first == last) {
-        isDownward = panStartOffset!.dx < panEndOffset!.dx;
+        isDownward = panStartOffsetWithScrollDyGap.dx < panEndOffset!.dx;
       } else {
-        isDownward = panStartOffset!.dy < panEndOffset!.dy;
+        isDownward = panStartOffsetWithScrollDyGap.dy < panEndOffset!.dy;
       }
-      final start =
-          first.getSelectionInRange(panStartOffset!, panEndOffset!).start;
-      final end = last.getSelectionInRange(panStartOffset!, panEndOffset!).end;
+      final start = first
+          .getSelectionInRange(panStartOffsetWithScrollDyGap, panEndOffset!)
+          .start;
+      final end = last
+          .getSelectionInRange(panStartOffsetWithScrollDyGap, panEndOffset!)
+          .end;
       final selection = Selection(
           start: isDownward ? start : end, end: isDownward ? end : start);
       debugPrint('[_onPanUpdate] isDownward = $isDownward, $selection');
       editorState.service.selectionService.updateSelection(selection);
     }
+
+    _scrollUpOrDownIfNeeded(panEndOffset!);
+    _showDebugLayerIfNeeded();
   }
 
   void _onPanEnd(DragEndDetails details) {
     // do nothing
   }
 
-  void _clearSelection() {
-    currentSelection = null;
-    currentSelectedNodes.value = [];
-
-    // clear selection
-    _selectionOverlays
-      ..forEach((overlay) => overlay.remove())
-      ..clear();
-    // clear cursors
-    _cursorOverlays
-      ..forEach((overlay) => overlay.remove())
-      ..clear();
-    // clear toolbar
-    editorState.service.toolbarService?.hide();
-  }
-
   void _updateSelection(Selection selection) {
     final nodes =
         _selectedNodesInSelection(editorState.document.root, selection);
@@ -554,12 +485,12 @@ class _FlowySelectionState extends State<FlowySelection>
     if (rect != null) {
       _rects.add(_transformRectToGlobal(selectable!, rect));
       final cursor = OverlayEntry(
-        builder: ((context) => CursorWidget(
-              key: _cursorKey,
-              rect: rect,
-              color: widget.cursorColor,
-              layerLink: node.layerLink,
-            )),
+        builder: (context) => CursorWidget(
+          key: _cursorKey,
+          rect: rect,
+          color: widget.cursorColor,
+          layerLink: node.layerLink,
+        ),
       );
       _cursorOverlays.add(cursor);
       Overlay.of(context)?.insertAll(_cursorOverlays);
@@ -584,4 +515,139 @@ class _FlowySelectionState extends State<FlowySelection>
     }
     return result;
   }
+
+  void _scrollUpOrDownIfNeeded(Offset offset) {
+    final dy = editorState.service.scrollService?.dy;
+    if (dy == null) {
+      assert(false, 'Dy could not be null');
+      return;
+    }
+    final topLimit = MediaQuery.of(context).size.height * 0.2;
+    final bottomLimit = MediaQuery.of(context).size.height * 0.8;
+
+    /// TODO: It is necessary to calculate the relative speed
+    ///   according to the gap and move forward more gently.
+    final distance = 10.0;
+    if (offset.dy <= topLimit) {
+      // up
+      editorState.service.scrollService?.scrollTo(dy - distance);
+    } else if (offset.dy >= bottomLimit) {
+      //down
+      editorState.service.scrollService?.scrollTo(dy + distance);
+    }
+  }
+
+  void _showDebugLayerIfNeeded() {
+    // remove false to show debug overlay.
+    if (kDebugMode && false) {
+      _debugOverlay?.remove();
+      if (panStartOffset != null) {
+        _debugOverlay = OverlayEntry(
+          builder: (context) => Positioned.fromRect(
+            rect: Rect.fromPoints(
+                    panStartOffset?.translate(
+                          0,
+                          -(editorState.service.scrollService!.dy -
+                              panStartScrollDy!),
+                        ) ??
+                        Offset.zero,
+                    panEndOffset ?? Offset.zero)
+                .translate(0, 0),
+            child: Container(
+              color: Colors.red.withOpacity(0.2),
+            ),
+          ),
+        );
+        Overlay.of(context)?.insert(_debugOverlay!);
+      } else {
+        _debugOverlay = null;
+      }
+    }
+  }
+}
+
+/// Because the flutter's [DoubleTapGestureRecognizer] will block the [TapGestureRecognizer]
+/// for a while. So we need to implement our own GestureDetector.
+@immutable
+class _SelectionGestureDetector extends StatefulWidget {
+  const _SelectionGestureDetector(
+      {Key? key,
+      this.child,
+      this.onTapDown,
+      this.onDoubleTapDown,
+      this.onPanStart,
+      this.onPanUpdate,
+      this.onPanEnd})
+      : super(key: key);
+
+  @override
+  State<_SelectionGestureDetector> createState() =>
+      _SelectionGestureDetectorState();
+
+  final Widget? child;
+
+  final GestureTapDownCallback? onTapDown;
+  final GestureTapDownCallback? onDoubleTapDown;
+  final GestureDragStartCallback? onPanStart;
+  final GestureDragUpdateCallback? onPanUpdate;
+  final GestureDragEndCallback? onPanEnd;
+}
+
+class _SelectionGestureDetectorState extends State<_SelectionGestureDetector> {
+  bool _isDoubleTap = false;
+  Timer? _doubleTapTimer;
+  @override
+  Widget build(BuildContext context) {
+    return RawGestureDetector(
+      behavior: HitTestBehavior.translucent,
+      gestures: {
+        PanGestureRecognizer:
+            GestureRecognizerFactoryWithHandlers<PanGestureRecognizer>(
+          () => PanGestureRecognizer(),
+          (recognizer) {
+            recognizer
+              ..onStart = widget.onPanStart
+              ..onUpdate = widget.onPanUpdate
+              ..onEnd = widget.onPanEnd;
+          },
+        ),
+        TapGestureRecognizer:
+            GestureRecognizerFactoryWithHandlers<TapGestureRecognizer>(
+          () => TapGestureRecognizer(),
+          (recognizer) {
+            recognizer.onTapDown = _tapDownDelegate;
+          },
+        ),
+      },
+      child: widget.child,
+    );
+  }
+
+  _tapDownDelegate(TapDownDetails tapDownDetails) {
+    if (_isDoubleTap) {
+      _isDoubleTap = false;
+      _doubleTapTimer?.cancel();
+      _doubleTapTimer = null;
+      if (widget.onDoubleTapDown != null) {
+        widget.onDoubleTapDown!(tapDownDetails);
+      }
+    } else {
+      if (widget.onTapDown != null) {
+        widget.onTapDown!(tapDownDetails);
+      }
+
+      _isDoubleTap = true;
+      _doubleTapTimer?.cancel();
+      _doubleTapTimer = Timer(kDoubleTapTimeout, () {
+        _isDoubleTap = false;
+        _doubleTapTimer = null;
+      });
+    }
+  }
+
+  @override
+  void dispose() {
+    _doubleTapTimer?.cancel();
+    super.dispose();
+  }
 }

+ 17 - 5
frontend/app_flowy/packages/flowy_editor/lib/service/service.dart

@@ -1,8 +1,10 @@
+import 'package:flutter/material.dart';
+
 import 'package:flowy_editor/service/keyboard_service.dart';
 import 'package:flowy_editor/service/render_plugin_service.dart';
-import 'package:flowy_editor/service/toolbar_service.dart';
+import 'package:flowy_editor/service/scroll_service.dart';
 import 'package:flowy_editor/service/selection_service.dart';
-import 'package:flutter/material.dart';
+import 'package:flowy_editor/service/toolbar_service.dart';
 
 class FlowyService {
   // selection service
@@ -31,10 +33,20 @@ class FlowyService {
 
   // toolbar service
   final toolbarServiceKey = GlobalKey(debugLabel: 'flowy_toolbar_service');
-  ToolbarService? get toolbarService {
+  FlowyToolbarService? get toolbarService {
     if (toolbarServiceKey.currentState != null &&
-        toolbarServiceKey.currentState is ToolbarService) {
-      return toolbarServiceKey.currentState! as ToolbarService;
+        toolbarServiceKey.currentState is FlowyToolbarService) {
+      return toolbarServiceKey.currentState! as FlowyToolbarService;
+    }
+    return null;
+  }
+
+  // scroll service
+  final scrollServiceKey = GlobalKey(debugLabel: 'flowy_scroll_service');
+  FlowyScrollService? get scrollService {
+    if (scrollServiceKey.currentState != null &&
+        scrollServiceKey.currentState is FlowyScrollService) {
+      return scrollServiceKey.currentState! as FlowyScrollService;
     }
     return null;
   }

+ 4 - 3
frontend/app_flowy/packages/flowy_editor/lib/service/toolbar_service.dart

@@ -1,8 +1,9 @@
+import 'package:flutter/material.dart';
+
 import 'package:flowy_editor/flowy_editor.dart';
 import 'package:flowy_editor/render/selection/toolbar_widget.dart';
-import 'package:flutter/material.dart';
 
-mixin ToolbarService {
+mixin FlowyToolbarService {
   /// Show the toolbar widget beside the offset.
   void showInOffset(Offset offset, LayerLink layerLink);
 
@@ -24,7 +25,7 @@ class FlowyToolbar extends StatefulWidget {
   State<FlowyToolbar> createState() => _FlowyToolbarState();
 }
 
-class _FlowyToolbarState extends State<FlowyToolbar> with ToolbarService {
+class _FlowyToolbarState extends State<FlowyToolbar> with FlowyToolbarService {
   OverlayEntry? _toolbarOverlay;
 
   @override