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) {