feat: add copy link to link menu

This commit is contained in:
Lucas.Xu 2022-08-29 10:25:56 +08:00
parent 3686351592
commit cde48926e2
8 changed files with 95 additions and 61 deletions

View File

@ -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>

After

Width:  |  Height:  |  Size: 781 B

View File

@ -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);
}

View File

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

View File

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

View File

@ -1,6 +1,8 @@
import 'dart:async'; import 'dart:async';
import 'dart:ui'; 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/gestures.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/rendering.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/editor_state.dart';
import 'package:appflowy_editor/src/render/rich_text/rich_text_style.dart'; import 'package:appflowy_editor/src/render/rich_text/rich_text_style.dart';
import 'package:appflowy_editor/src/render/selection/selectable.dart'; import 'package:appflowy_editor/src/render/selection/selectable.dart';
import 'package:url_launcher/url_launcher_string.dart';
typedef FlowyTextSpanDecorator = TextSpan Function(TextSpan textSpan); typedef FlowyTextSpanDecorator = TextSpan Function(TextSpan textSpan);
@ -204,53 +205,23 @@ class _FlowyRichTextState extends State<FlowyRichText> with Selectable {
var offset = 0; var offset = 0;
return TextSpan( return TextSpan(
children: widget.textNode.delta.whereType<TextInsert>().map((insert) { children: widget.textNode.delta.whereType<TextInsert>().map((insert) {
GestureRecognizer? gestureDetector; GestureRecognizer? gestureRecognizer;
if (insert.attributes?[StyleKey.href] != null) { if (insert.attributes?[StyleKey.href] != null) {
final startOffset = offset; gestureRecognizer = _buildTapHrefGestureRecognizer(
Timer? timer; insert.attributes![StyleKey.href],
var tapCount = 0; Selection.single(
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, path: widget.textNode.path,
startOffset: startOffset, startOffset: offset,
endOffset: startOffset + insert.length, endOffset: offset + insert.length,
),
); );
widget.editorState.service.selectionService
.updateSelection(selection);
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
widget.editorState.service.toolbarService
?.triggerHandler('appflowy.toolbar.link');
});
});
};
} }
offset += insert.length; offset += insert.length;
final textSpan = RichTextStyle( final textSpan = RichTextStyle(
attributes: insert.attributes ?? {}, attributes: insert.attributes ?? {},
text: insert.content, text: insert.content,
height: _lineHeight, height: _lineHeight,
gestureRecognizer: gestureDetector, gestureRecognizer: gestureRecognizer,
).toTextSpan(); ).toTextSpan();
return textSpan; return textSpan;
}).toList(growable: false), }).toList(growable: false),
@ -266,4 +237,31 @@ class _FlowyRichTextState extends State<FlowyRichText> with Selectable {
height: _lineHeight, height: _lineHeight,
).toTextSpan() ).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;
}
} }

View File

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

View File

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

View File

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