Bladeren bron

feat: add copy link to link menu

Lucas.Xu 2 jaren geleden
bovenliggende
commit
cde48926e2

+ 4 - 0
frontend/app_flowy/packages/appflowy_editor/assets/images/copy.svg

@@ -0,0 +1,4 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M11.974 6.33301H7.35865C6.7922 6.33301 6.33301 6.7922 6.33301 7.35865V11.974C6.33301 12.5405 6.7922 12.9997 7.35865 12.9997H11.974C12.5405 12.9997 12.9997 12.5405 12.9997 11.974V7.35865C12.9997 6.7922 12.5405 6.33301 11.974 6.33301Z" stroke="white" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M4.53846 9.66667H4.02564C3.75362 9.66667 3.49275 9.55861 3.3004 9.36626C3.10806 9.17392 3 8.91304 3 8.64103V4.02564C3 3.75362 3.10806 3.49275 3.3004 3.3004C3.49275 3.10806 3.75362 3 4.02564 3H8.64103C8.91304 3 9.17392 3.10806 9.36626 3.3004C9.55861 3.49275 9.66667 3.75362 9.66667 4.02564V4.53846" stroke="white" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>

+ 14 - 0
frontend/app_flowy/packages/appflowy_editor/lib/src/extensions/url_launcher_extension.dart

@@ -0,0 +1,14 @@
+import 'package:url_launcher/url_launcher_string.dart';
+
+Future<bool> safeLaunchUrl(String? href) async {
+  if (href == null) {
+    return Future.value(false);
+  }
+  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 Future.value(true);
+}

+ 8 - 6
frontend/app_flowy/packages/appflowy_editor/lib/src/operation/transaction_builder.dart

@@ -115,17 +115,18 @@ class TransactionBuilder {
   /// Inserts content at a specified index.
   /// Optionally, you may specify formatting attributes that are applied to the inserted string.
   /// By default, the formatting attributes before the insert position will be used.
-  insertText(TextNode node, int index, String content,
-      {Attributes? attributes, Attributes? removedAttributes}) {
+  insertText(
+    TextNode node,
+    int index,
+    String content, {
+    Attributes? attributes,
+  }) {
     var newAttributes = attributes;
     if (index != 0 && attributes == null) {
       newAttributes =
           node.delta.slice(max(index - 1, 0), index).first.attributes;
       if (newAttributes != null) {
         newAttributes = Attributes.from(newAttributes);
-        if (removedAttributes != null) {
-          newAttributes.addAll(removedAttributes);
-        }
       }
     }
     textEdit(
@@ -138,7 +139,8 @@ class TransactionBuilder {
         ),
     );
     afterSelection = Selection.collapsed(
-        Position(path: node.path, offset: index + content.length));
+      Position(path: node.path, offset: index + content.length),
+    );
   }
 
   /// Assigns formatting attributes to a range of text.

+ 14 - 5
frontend/app_flowy/packages/appflowy_editor/lib/src/render/link_menu/link_menu.dart

@@ -6,12 +6,14 @@ class LinkMenu extends StatefulWidget {
     Key? key,
     this.linkText,
     required this.onSubmitted,
+    required this.onOpenLink,
     required this.onCopyLink,
     required this.onRemoveLink,
   }) : super(key: key);
 
   final String? linkText;
   final void Function(String text) onSubmitted;
+  final VoidCallback onOpenLink;
   final VoidCallback onCopyLink;
   final VoidCallback onRemoveLink;
 
@@ -26,15 +28,12 @@ class _LinkMenuState extends State<LinkMenu> {
   @override
   void initState() {
     super.initState();
-
     _textEditingController.text = widget.linkText ?? '';
-    _focusNode.requestFocus();
   }
 
   @override
   void dispose() {
-    _focusNode.dispose();
-
+    _textEditingController.dispose();
     super.dispose();
   }
 
@@ -67,6 +66,12 @@ class _LinkMenuState extends State<LinkMenu> {
               if (widget.linkText != null) ...[
                 _buildIconButton(
                   iconName: 'link',
+                  text: 'Open link',
+                  onPressed: widget.onOpenLink,
+                ),
+                _buildIconButton(
+                  iconName: 'copy',
+                  color: Colors.black,
                   text: 'Copy link',
                   onPressed: widget.onCopyLink,
                 ),
@@ -126,11 +131,15 @@ class _LinkMenuState extends State<LinkMenu> {
 
   Widget _buildIconButton({
     required String iconName,
+    Color? color,
     required String text,
     required VoidCallback onPressed,
   }) {
     return TextButton.icon(
-      icon: FlowySvg(name: iconName),
+      icon: FlowySvg(
+        name: iconName,
+        color: color,
+      ),
       style: TextButton.styleFrom(
         minimumSize: const Size.fromHeight(40),
         padding: EdgeInsets.zero,

+ 39 - 41
frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/flowy_rich_text.dart

@@ -1,6 +1,8 @@
 import 'dart:async';
 import 'dart:ui';
 
+import 'package:appflowy_editor/src/extensions/url_launcher_extension.dart';
+import 'package:appflowy_editor/src/render/toolbar/toolbar_item.dart';
 import 'package:flutter/gestures.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter/rendering.dart';
@@ -13,7 +15,6 @@ 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);
 
@@ -204,53 +205,23 @@ class _FlowyRichTextState extends State<FlowyRichText> with Selectable {
     var offset = 0;
     return TextSpan(
       children: widget.textNode.delta.whereType<TextInsert>().map((insert) {
-        GestureRecognizer? gestureDetector;
+        GestureRecognizer? gestureRecognizer;
         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();
-
-              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;
-              }
-
-              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');
-                });
-              });
-            };
+          gestureRecognizer = _buildTapHrefGestureRecognizer(
+            insert.attributes![StyleKey.href],
+            Selection.single(
+              path: widget.textNode.path,
+              startOffset: offset,
+              endOffset: offset + insert.length,
+            ),
+          );
         }
         offset += insert.length;
         final textSpan = RichTextStyle(
           attributes: insert.attributes ?? {},
           text: insert.content,
           height: _lineHeight,
-          gestureRecognizer: gestureDetector,
+          gestureRecognizer: gestureRecognizer,
         ).toTextSpan();
         return textSpan;
       }).toList(growable: false),
@@ -266,4 +237,31 @@ class _FlowyRichTextState extends State<FlowyRichText> with Selectable {
           height: _lineHeight,
         ).toTextSpan()
       ]);
+
+  GestureRecognizer _buildTapHrefGestureRecognizer(
+      String href, Selection selection) {
+    Timer? timer;
+    var tapCount = 0;
+    final tapGestureRecognizer = TapGestureRecognizer()
+      ..onTap = () async {
+        // implement a simple double tap logic
+        tapCount += 1;
+        timer?.cancel();
+
+        if (tapCount == 2) {
+          tapCount = 0;
+          safeLaunchUrl(href);
+          return;
+        }
+
+        timer = Timer(const Duration(milliseconds: 200), () {
+          tapCount = 0;
+          WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
+            showLinkMenu(context, widget.editorState,
+                customSelection: selection);
+          });
+        });
+      };
+    return tapGestureRecognizer;
+  }
 }

+ 15 - 5
frontend/app_flowy/packages/appflowy_editor/lib/src/render/toolbar/toolbar_item.dart

@@ -1,4 +1,5 @@
 import 'package:appflowy_editor/appflowy_editor.dart';
+import 'package:appflowy_editor/src/extensions/url_launcher_extension.dart';
 import 'package:appflowy_editor/src/infra/flowy_svg.dart';
 import 'package:appflowy_editor/src/render/link_menu/link_menu.dart';
 import 'package:appflowy_editor/src/render/rich_text/rich_text_style.dart';
@@ -132,7 +133,7 @@ List<ToolbarItem> defaultToolbarItems = [
     tooltipsMessage: 'Link',
     icon: const FlowySvg(name: 'toolbar/link'),
     validator: _onlyShowInSingleTextSelection,
-    handler: (editorState, context) => _showLinkMenu(editorState, context),
+    handler: (editorState, context) => showLinkMenu(context, editorState),
   ),
   ToolbarItem(
     id: 'appflowy.toolbar.highlight',
@@ -157,7 +158,11 @@ ToolbarShowValidator _showInTextSelection = (editorState) {
 
 OverlayEntry? _linkMenuOverlay;
 EditorState? _editorState;
-void _showLinkMenu(EditorState editorState, BuildContext context) {
+void showLinkMenu(
+  BuildContext context,
+  EditorState editorState, {
+  Selection? customSelection,
+}) {
   final rects = editorState.service.selectionService.selectionRects;
   var maxBottom = 0.0;
   late Rect matchRect;
@@ -173,8 +178,11 @@ void _showLinkMenu(EditorState editorState, BuildContext context) {
 
   // Since the link menu will only show in single text selection,
   // We get the text node directly instead of judging details again.
-  final selection =
-      editorState.service.selectionService.currentSelection.value!;
+  final selection = customSelection ??
+      editorState.service.selectionService.currentSelection.value;
+  if (selection == null) {
+    return;
+  }
   final index =
       selection.isBackward ? selection.start.offset : selection.end.offset;
   final length = (selection.start.offset - selection.end.offset).abs();
@@ -191,6 +199,9 @@ void _showLinkMenu(EditorState editorState, BuildContext context) {
       child: Material(
         child: LinkMenu(
           linkText: linkText,
+          onOpenLink: () async {
+            await safeLaunchUrl(linkText);
+          },
           onSubmitted: (text) {
             TransactionBuilder(editorState)
               ..formatText(node, index, length, {StyleKey.href: text})
@@ -214,7 +225,6 @@ void _showLinkMenu(EditorState editorState, BuildContext context) {
   Overlay.of(context)?.insert(_linkMenuOverlay!);
 
   editorState.service.scrollService?.disable();
-  editorState.service.keyboardService?.disable();
   editorState.service.selectionService.currentSelection
       .addListener(_dismissLinkMenu);
 }

+ 0 - 4
frontend/app_flowy/packages/appflowy_editor/lib/src/service/input_service.dart

@@ -1,5 +1,4 @@
 import 'package:appflowy_editor/src/infra/log.dart';
-import 'package:appflowy_editor/src/render/rich_text/rich_text_style.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter/services.dart';
 
@@ -150,9 +149,6 @@ class _AppFlowyInputState extends State<AppFlowyInput>
           textNode,
           delta.insertionOffset,
           delta.textInserted,
-          removedAttributes: {
-            StyleKey.href: null,
-          },
         )
         ..commit();
     } else {

+ 1 - 0
frontend/app_flowy/packages/appflowy_editor/test/render/link_menu/link_menu_test.dart

@@ -12,6 +12,7 @@ void main() async {
       const link = 'appflowy.io';
       var submittedText = '';
       final linkMenu = LinkMenu(
+        onOpenLink: () {},
         onCopyLink: () {},
         onRemoveLink: () {},
         onSubmitted: (text) {