diff --git a/frontend/app_flowy/packages/appflowy_editor/assets/images/copy.svg b/frontend/app_flowy/packages/appflowy_editor/assets/images/copy.svg new file mode 100644 index 0000000000..101cf34205 --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor/assets/images/copy.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/extensions/url_launcher_extension.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/extensions/url_launcher_extension.dart new file mode 100644 index 0000000000..1c0ea30c82 --- /dev/null +++ b/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 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); +} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/operation/transaction_builder.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/operation/transaction_builder.dart index 12c13bf2e5..1390b23918 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/operation/transaction_builder.dart +++ b/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. diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/link_menu/link_menu.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/link_menu/link_menu.dart index a33adf3b8c..07e1b947eb 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/link_menu/link_menu.dart +++ b/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 { @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 { 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 { 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, diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/flowy_rich_text.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/flowy_rich_text.dart index 884a8bbe12..517f7dd4b8 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/flowy_rich_text.dart +++ b/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 with Selectable { var offset = 0; return TextSpan( children: widget.textNode.delta.whereType().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 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; + } } diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/toolbar/toolbar_item.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/toolbar/toolbar_item.dart index 107ae23b6f..979f86cdd1 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/toolbar/toolbar_item.dart +++ b/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 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); } diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/input_service.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/input_service.dart index 96f0777544..a92fae1b95 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/input_service.dart +++ b/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 textNode, delta.insertionOffset, delta.textInserted, - removedAttributes: { - StyleKey.href: null, - }, ) ..commit(); } else { diff --git a/frontend/app_flowy/packages/appflowy_editor/test/render/link_menu/link_menu_test.dart b/frontend/app_flowy/packages/appflowy_editor/test/render/link_menu/link_menu_test.dart index 7b4541033b..5b102b9ec1 100644 --- a/frontend/app_flowy/packages/appflowy_editor/test/render/link_menu/link_menu_test.dart +++ b/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) {