Ver código fonte

feat: single tap to edit link and double tap to open the link

Lucas.Xu 2 anos atrás
pai
commit
70ea80878a

+ 1 - 4
frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/bulleted_list_text.dart

@@ -56,8 +56,6 @@ class _BulletedListTextNodeWidgetState extends State<BulletedListTextNodeWidget>
 
   @override
   Widget build(BuildContext context) {
-    final topPadding = RichTextStyle.fromTextNode(widget.textNode).topPadding;
-
     return SizedBox(
       width: defaultMaxTextNodeWidth,
       child: Padding(
@@ -69,8 +67,7 @@ class _BulletedListTextNodeWidgetState extends State<BulletedListTextNodeWidget>
               key: iconKey,
               width: _iconWidth,
               height: _iconWidth,
-              padding:
-                  EdgeInsets.only(top: topPadding, right: _iconRightPadding),
+              padding: EdgeInsets.only(right: _iconRightPadding),
               name: 'point',
             ),
             Expanded(

+ 1 - 5
frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/checkbox_text.dart

@@ -63,7 +63,6 @@ class _CheckboxNodeWidgetState extends State<CheckboxNodeWidget>
 
   Widget _buildWithSingle(BuildContext context) {
     final check = widget.textNode.attributes.check;
-    final topPadding = RichTextStyle.fromTextNode(widget.textNode).topPadding;
     return SizedBox(
       width: defaultMaxTextNodeWidth,
       child: Padding(
@@ -76,10 +75,7 @@ class _CheckboxNodeWidgetState extends State<CheckboxNodeWidget>
               child: FlowySvg(
                 width: _iconWidth,
                 height: _iconWidth,
-                padding: EdgeInsets.only(
-                  top: topPadding,
-                  right: _iconRightPadding,
-                ),
+                padding: EdgeInsets.only(right: _iconRightPadding),
                 name: check ? 'check' : 'uncheck',
               ),
               onTap: () {

+ 62 - 35
frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/flowy_rich_text.dart

@@ -1,5 +1,7 @@
+import 'dart:async';
 import 'dart:ui';
 
+import 'package:flutter/gestures.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter/rendering.dart';
 
@@ -11,6 +13,7 @@ import 'package:appflowy_editor/src/document/text_delta.dart';
 import 'package:appflowy_editor/src/editor_state.dart';
 import 'package:appflowy_editor/src/render/rich_text/rich_text_style.dart';
 import 'package:appflowy_editor/src/render/selection/selectable.dart';
+import 'package:url_launcher/url_launcher_string.dart';
 
 typedef FlowyTextSpanDecorator = TextSpan Function(TextSpan textSpan);
 
@@ -143,6 +146,11 @@ class _FlowyRichTextState extends State<FlowyRichText> with Selectable {
     );
   }
 
+  @override
+  Offset localToGlobal(Offset offset) {
+    return _renderParagraph.localToGlobal(offset);
+  }
+
   Widget _buildRichText(BuildContext context) {
     return MouseRegion(
       cursor: SystemMouseCursors.text,
@@ -181,43 +189,62 @@ class _FlowyRichTextState extends State<FlowyRichText> with Selectable {
     );
   }
 
-  // unused now.
-  // Widget _buildRichTextWithChildren(BuildContext context) {
-  //   return Column(
-  //     crossAxisAlignment: CrossAxisAlignment.start,
-  //     children: [
-  //       _buildSingleRichText(context),
-  //       ...widget.textNode.children
-  //           .map(
-  //             (child) => widget.editorState.service.renderPluginService
-  //                 .buildPluginWidget(
-  //               NodeWidgetContext(
-  //                 context: context,
-  //                 node: child,
-  //                 editorState: widget.editorState,
-  //               ),
-  //             ),
-  //           )
-  //           .toList()
-  //     ],
-  //   );
-  // }
+  TextSpan get _textSpan {
+    var offset = 0;
+    return TextSpan(
+      children: widget.textNode.delta.whereType<TextInsert>().map((insert) {
+        GestureRecognizer? gestureDetector;
+        if (insert.attributes?[StyleKey.href] != null) {
+          final startOffset = offset;
+          Timer? timer;
+          var tapCount = 0;
+          gestureDetector = TapGestureRecognizer()
+            ..onTap = () async {
+              // implement a simple double tap logic
+              tapCount += 1;
+              timer?.cancel();
 
-  @override
-  Offset localToGlobal(Offset offset) {
-    return _renderParagraph.localToGlobal(offset);
-  }
+              if (tapCount == 2) {
+                tapCount = 0;
+                final href = insert.attributes![StyleKey.href];
+                final uri = Uri.parse(href);
+                // url_launcher cannot open a link without scheme.
+                final newHref =
+                    (uri.scheme.isNotEmpty ? href : 'http://$href').trim();
+                if (await canLaunchUrlString(newHref)) {
+                  await launchUrlString(newHref);
+                }
+                return;
+              }
 
-  TextSpan get _textSpan => TextSpan(
-        children: widget.textNode.delta
-            .whereType<TextInsert>()
-            .map((insert) => RichTextStyle(
-                  attributes: insert.attributes ?? {},
-                  text: insert.content,
-                  height: _lineHeight,
-                ).toTextSpan())
-            .toList(growable: false),
-      );
+              timer = Timer(const Duration(milliseconds: 200), () {
+                tapCount = 0;
+                // update selection
+                final selection = Selection.single(
+                  path: widget.textNode.path,
+                  startOffset: startOffset,
+                  endOffset: startOffset + insert.length,
+                );
+                widget.editorState.service.selectionService
+                    .updateSelection(selection);
+                WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
+                  widget.editorState.service.toolbarService
+                      ?.triggerHandler('appflowy.toolbar.link');
+                });
+              });
+            };
+        }
+        offset += insert.length;
+        final textSpan = RichTextStyle(
+          attributes: insert.attributes ?? {},
+          text: insert.content,
+          height: _lineHeight,
+          gestureRecognizer: gestureDetector,
+        ).toTextSpan();
+        return textSpan;
+      }).toList(growable: false),
+    );
+  }
 
   TextSpan get _placeholderTextSpan => TextSpan(children: [
         RichTextStyle(

+ 1 - 3
frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/number_list_text.dart

@@ -56,7 +56,6 @@ class _NumberListTextNodeWidgetState extends State<NumberListTextNodeWidget>
 
   @override
   Widget build(BuildContext context) {
-    final topPadding = RichTextStyle.fromTextNode(widget.textNode).topPadding;
     return Padding(
         padding: EdgeInsets.only(bottom: defaultLinePadding),
         child: SizedBox(
@@ -68,8 +67,7 @@ class _NumberListTextNodeWidgetState extends State<NumberListTextNodeWidget>
                 key: iconKey,
                 width: _iconWidth,
                 height: _iconWidth,
-                padding:
-                    EdgeInsets.only(top: topPadding, right: _iconRightPadding),
+                padding: EdgeInsets.only(right: _iconRightPadding),
                 number: widget.textNode.attributes.number,
               ),
               Expanded(

+ 1 - 3
frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/quoted_text.dart

@@ -55,7 +55,6 @@ class _QuotedTextNodeWidgetState extends State<QuotedTextNodeWidget>
 
   @override
   Widget build(BuildContext context) {
-    final topPadding = RichTextStyle.fromTextNode(widget.textNode).topPadding;
     return SizedBox(
         width: defaultMaxTextNodeWidth,
         child: Padding(
@@ -67,8 +66,7 @@ class _QuotedTextNodeWidgetState extends State<QuotedTextNodeWidget>
                 FlowySvg(
                   key: iconKey,
                   width: _iconWidth,
-                  padding: EdgeInsets.only(
-                      top: topPadding, right: _iconRightPadding),
+                  padding: EdgeInsets.only(right: _iconRightPadding),
                   name: 'quote',
                 ),
                 Expanded(

+ 4 - 20
frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/rich_text_style.dart

@@ -1,8 +1,6 @@
 import 'package:appflowy_editor/src/document/attributes.dart';
-import 'package:appflowy_editor/src/document/node.dart';
 import 'package:flutter/gestures.dart';
 import 'package:flutter/material.dart';
-import 'package:url_launcher/url_launcher_string.dart';
 
 ///
 /// Supported partial rendering types:
@@ -182,14 +180,13 @@ class RichTextStyle {
   RichTextStyle({
     required this.attributes,
     required this.text,
+    this.gestureRecognizer,
     this.height = 1.5,
   });
 
-  RichTextStyle.fromTextNode(TextNode textNode)
-      : this(attributes: textNode.attributes, text: textNode.toRawString());
-
   final Attributes attributes;
   final String text;
+  final GestureRecognizer? gestureRecognizer;
   final double height;
 
   TextSpan toTextSpan() => _toTextSpan(height);
@@ -201,6 +198,7 @@ class RichTextStyle {
   TextSpan _toTextSpan(double? height) {
     return TextSpan(
       text: text,
+      recognizer: _recognizer,
       style: TextStyle(
         fontWeight: _fontWeight,
         fontStyle: _fontStyle,
@@ -210,7 +208,6 @@ class RichTextStyle {
         background: _background,
         height: height,
       ),
-      recognizer: _recognizer,
     );
   }
 
@@ -273,19 +270,6 @@ class RichTextStyle {
 
   // recognizer
   GestureRecognizer? get _recognizer {
-    final href = attributes.href;
-    if (href != null) {
-      return TapGestureRecognizer()
-        ..onTap = () async {
-          final uri = Uri.parse(href);
-          // url_launcher cannot open a link without scheme.
-          final newHref =
-              (uri.scheme.isNotEmpty ? href : 'http://$href').trim();
-          if (await canLaunchUrlString(newHref)) {
-            await launchUrlString(newHref);
-          }
-        };
-    }
-    return null;
+    return gestureRecognizer;
   }
 }

+ 29 - 4
frontend/app_flowy/packages/appflowy_editor/lib/src/render/toolbar/toolbar_item.dart

@@ -14,6 +14,7 @@ typedef ToolbarShowValidator = bool Function(EditorState editorState);
 class ToolbarItem {
   ToolbarItem({
     required this.id,
+    required this.type,
     required this.icon,
     this.tooltipsMessage = '',
     required this.validator,
@@ -21,6 +22,7 @@ class ToolbarItem {
   });
 
   final String id;
+  final int type;
   final Widget icon;
   final String tooltipsMessage;
   final ToolbarShowValidator validator;
@@ -29,16 +31,32 @@ class ToolbarItem {
   factory ToolbarItem.divider() {
     return ToolbarItem(
       id: 'divider',
+      type: -1,
       icon: const FlowySvg(name: 'toolbar/divider'),
       validator: (editorState) => true,
       handler: (editorState, context) {},
     );
   }
+
+  @override
+  bool operator ==(Object other) {
+    if (other is! ToolbarItem) {
+      return false;
+    }
+    if (identical(this, other)) {
+      return true;
+    }
+    return id == other.id;
+  }
+
+  @override
+  int get hashCode => id.hashCode;
 }
 
 List<ToolbarItem> defaultToolbarItems = [
   ToolbarItem(
     id: 'appflowy.toolbar.h1',
+    type: 1,
     tooltipsMessage: 'Heading 1',
     icon: const FlowySvg(name: 'toolbar/h1'),
     validator: _onlyShowInSingleTextSelection,
@@ -46,6 +64,7 @@ List<ToolbarItem> defaultToolbarItems = [
   ),
   ToolbarItem(
     id: 'appflowy.toolbar.h2',
+    type: 1,
     tooltipsMessage: 'Heading 2',
     icon: const FlowySvg(name: 'toolbar/h2'),
     validator: _onlyShowInSingleTextSelection,
@@ -53,14 +72,15 @@ List<ToolbarItem> defaultToolbarItems = [
   ),
   ToolbarItem(
     id: 'appflowy.toolbar.h3',
+    type: 1,
     tooltipsMessage: 'Heading 3',
     icon: const FlowySvg(name: 'toolbar/h3'),
     validator: _onlyShowInSingleTextSelection,
     handler: (editorState, context) => formatHeading(editorState, StyleKey.h3),
   ),
-  ToolbarItem.divider(),
   ToolbarItem(
     id: 'appflowy.toolbar.bold',
+    type: 2,
     tooltipsMessage: 'Bold',
     icon: const FlowySvg(name: 'toolbar/bold'),
     validator: _showInTextSelection,
@@ -68,6 +88,7 @@ List<ToolbarItem> defaultToolbarItems = [
   ),
   ToolbarItem(
     id: 'appflowy.toolbar.italic',
+    type: 2,
     tooltipsMessage: 'Italic',
     icon: const FlowySvg(name: 'toolbar/italic'),
     validator: _showInTextSelection,
@@ -75,6 +96,7 @@ List<ToolbarItem> defaultToolbarItems = [
   ),
   ToolbarItem(
     id: 'appflowy.toolbar.underline',
+    type: 2,
     tooltipsMessage: 'Underline',
     icon: const FlowySvg(name: 'toolbar/underline'),
     validator: _showInTextSelection,
@@ -82,14 +104,15 @@ List<ToolbarItem> defaultToolbarItems = [
   ),
   ToolbarItem(
     id: 'appflowy.toolbar.strikethrough',
+    type: 2,
     tooltipsMessage: 'Strikethrough',
     icon: const FlowySvg(name: 'toolbar/strikethrough'),
     validator: _showInTextSelection,
     handler: (editorState, context) => formatStrikethrough(editorState),
   ),
-  ToolbarItem.divider(),
   ToolbarItem(
     id: 'appflowy.toolbar.quote',
+    type: 3,
     tooltipsMessage: 'Quote',
     icon: const FlowySvg(name: 'toolbar/quote'),
     validator: _onlyShowInSingleTextSelection,
@@ -97,14 +120,15 @@ List<ToolbarItem> defaultToolbarItems = [
   ),
   ToolbarItem(
     id: 'appflowy.toolbar.bulleted_list',
+    type: 3,
     tooltipsMessage: 'Bulleted list',
     icon: const FlowySvg(name: 'toolbar/bulleted_list'),
     validator: _onlyShowInSingleTextSelection,
     handler: (editorState, context) => formatBulletedList(editorState),
   ),
-  ToolbarItem.divider(),
   ToolbarItem(
     id: 'appflowy.toolbar.link',
+    type: 4,
     tooltipsMessage: 'Link',
     icon: const FlowySvg(name: 'toolbar/link'),
     validator: _onlyShowInSingleTextSelection,
@@ -112,6 +136,7 @@ List<ToolbarItem> defaultToolbarItems = [
   ),
   ToolbarItem(
     id: 'appflowy.toolbar.highlight',
+    type: 4,
     tooltipsMessage: 'Highlight',
     icon: const FlowySvg(name: 'toolbar/highlight'),
     validator: _showInTextSelection,
@@ -159,7 +184,7 @@ void _showLinkMenu(EditorState editorState, BuildContext context) {
   final linkText = node.getAttributeInSelection(selection, StyleKey.href);
   _linkMenuOverlay = OverlayEntry(builder: (context) {
     return Positioned(
-      top: matchRect.bottom,
+      top: matchRect.bottom + 5.0,
       left: matchRect.left,
       child: Material(
         child: LinkMenu(

+ 1 - 4
frontend/app_flowy/packages/appflowy_editor/lib/src/render/toolbar/toolbar_widget.dart

@@ -50,9 +50,6 @@ class _ToolbarWidgetState extends State<ToolbarWidget> with ToolbarMixin {
   }
 
   Widget _buildToolbar(BuildContext context) {
-    final items = widget.items.where(
-      (item) => item.validator(widget.editorState),
-    );
     return Material(
       borderRadius: BorderRadius.circular(8.0),
       color: const Color(0xFF333333),
@@ -62,7 +59,7 @@ class _ToolbarWidgetState extends State<ToolbarWidget> with ToolbarMixin {
           height: 32.0,
           child: Row(
             crossAxisAlignment: CrossAxisAlignment.start,
-            children: items
+            children: widget.items
                 .map(
                   (item) => Center(
                     child: ToolbarItemWidget(

+ 15 - 1
frontend/app_flowy/packages/appflowy_editor/lib/src/service/toolbar_service.dart

@@ -39,13 +39,27 @@ class _FlowyToolbarState extends State<FlowyToolbar>
   void showInOffset(Offset offset, LayerLink layerLink) {
     hide();
 
+    final items = defaultToolbarItems
+        .where((item) => item.validator(widget.editorState))
+        .toList(growable: false)
+      ..sort((a, b) => a.type.compareTo(b.type));
+    if (items.isEmpty) {
+      return;
+    }
+    final List<ToolbarItem> dividedItems = [items.first];
+    for (var i = 1; i < items.length; i++) {
+      if (items[i].type != items[i - 1].type) {
+        dividedItems.add(ToolbarItem.divider());
+      }
+      dividedItems.add(items[i]);
+    }
     _toolbarOverlay = OverlayEntry(
       builder: (context) => ToolbarWidget(
         key: _toolbarWidgetKey,
         editorState: widget.editorState,
         layerLink: layerLink,
         offset: offset.translate(0, -37.0),
-        items: defaultToolbarItems,
+        items: dividedItems,
       ),
     );
     Overlay.of(context)?.insert(_toolbarOverlay!);