mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2024-08-30 18:12:39 +00:00
feat: add copy link to link menu
This commit is contained in:
parent
3686351592
commit
cde48926e2
@ -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 |
@ -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);
|
||||||
|
}
|
@ -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.
|
||||||
|
@ -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,
|
||||||
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
|
@ -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 {
|
||||||
|
@ -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) {
|
||||||
|
Loading…
Reference in New Issue
Block a user