mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2024-08-30 18:12:39 +00:00
feat: single tap to edit link and double tap to open the link
This commit is contained in:
parent
6517adece7
commit
70ea80878a
@ -56,8 +56,6 @@ class _BulletedListTextNodeWidgetState extends State<BulletedListTextNodeWidget>
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final topPadding = RichTextStyle.fromTextNode(widget.textNode).topPadding;
|
||||
|
||||
return SizedBox(
|
||||
width: defaultMaxTextNodeWidth,
|
||||
child: Padding(
|
||||
@ -69,8 +67,7 @@ class _BulletedListTextNodeWidgetState extends State<BulletedListTextNodeWidget>
|
||||
key: iconKey,
|
||||
width: _iconWidth,
|
||||
height: _iconWidth,
|
||||
padding:
|
||||
EdgeInsets.only(top: topPadding, right: _iconRightPadding),
|
||||
padding: EdgeInsets.only(right: _iconRightPadding),
|
||||
name: 'point',
|
||||
),
|
||||
Expanded(
|
||||
|
@ -63,7 +63,6 @@ class _CheckboxNodeWidgetState extends State<CheckboxNodeWidget>
|
||||
|
||||
Widget _buildWithSingle(BuildContext context) {
|
||||
final check = widget.textNode.attributes.check;
|
||||
final topPadding = RichTextStyle.fromTextNode(widget.textNode).topPadding;
|
||||
return SizedBox(
|
||||
width: defaultMaxTextNodeWidth,
|
||||
child: Padding(
|
||||
@ -76,10 +75,7 @@ class _CheckboxNodeWidgetState extends State<CheckboxNodeWidget>
|
||||
child: FlowySvg(
|
||||
width: _iconWidth,
|
||||
height: _iconWidth,
|
||||
padding: EdgeInsets.only(
|
||||
top: topPadding,
|
||||
right: _iconRightPadding,
|
||||
),
|
||||
padding: EdgeInsets.only(right: _iconRightPadding),
|
||||
name: check ? 'check' : 'uncheck',
|
||||
),
|
||||
onTap: () {
|
||||
|
@ -1,5 +1,7 @@
|
||||
import 'dart:async';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
|
||||
@ -11,6 +13,7 @@ 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);
|
||||
|
||||
@ -143,6 +146,11 @@ class _FlowyRichTextState extends State<FlowyRichText> with Selectable {
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Offset localToGlobal(Offset offset) {
|
||||
return _renderParagraph.localToGlobal(offset);
|
||||
}
|
||||
|
||||
Widget _buildRichText(BuildContext context) {
|
||||
return MouseRegion(
|
||||
cursor: SystemMouseCursors.text,
|
||||
@ -181,44 +189,63 @@ class _FlowyRichTextState extends State<FlowyRichText> with Selectable {
|
||||
);
|
||||
}
|
||||
|
||||
// unused now.
|
||||
// Widget _buildRichTextWithChildren(BuildContext context) {
|
||||
// return Column(
|
||||
// crossAxisAlignment: CrossAxisAlignment.start,
|
||||
// children: [
|
||||
// _buildSingleRichText(context),
|
||||
// ...widget.textNode.children
|
||||
// .map(
|
||||
// (child) => widget.editorState.service.renderPluginService
|
||||
// .buildPluginWidget(
|
||||
// NodeWidgetContext(
|
||||
// context: context,
|
||||
// node: child,
|
||||
// editorState: widget.editorState,
|
||||
// ),
|
||||
// ),
|
||||
// )
|
||||
// .toList()
|
||||
// ],
|
||||
// );
|
||||
// }
|
||||
TextSpan get _textSpan {
|
||||
var offset = 0;
|
||||
return TextSpan(
|
||||
children: widget.textNode.delta.whereType<TextInsert>().map((insert) {
|
||||
GestureRecognizer? gestureDetector;
|
||||
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();
|
||||
|
||||
@override
|
||||
Offset localToGlobal(Offset offset) {
|
||||
return _renderParagraph.localToGlobal(offset);
|
||||
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');
|
||||
});
|
||||
});
|
||||
};
|
||||
}
|
||||
offset += insert.length;
|
||||
final textSpan = RichTextStyle(
|
||||
attributes: insert.attributes ?? {},
|
||||
text: insert.content,
|
||||
height: _lineHeight,
|
||||
gestureRecognizer: gestureDetector,
|
||||
).toTextSpan();
|
||||
return textSpan;
|
||||
}).toList(growable: false),
|
||||
);
|
||||
}
|
||||
|
||||
TextSpan get _textSpan => TextSpan(
|
||||
children: widget.textNode.delta
|
||||
.whereType<TextInsert>()
|
||||
.map((insert) => RichTextStyle(
|
||||
attributes: insert.attributes ?? {},
|
||||
text: insert.content,
|
||||
height: _lineHeight,
|
||||
).toTextSpan())
|
||||
.toList(growable: false),
|
||||
);
|
||||
|
||||
TextSpan get _placeholderTextSpan => TextSpan(children: [
|
||||
RichTextStyle(
|
||||
text: widget.placeholderText,
|
||||
|
@ -56,7 +56,6 @@ class _NumberListTextNodeWidgetState extends State<NumberListTextNodeWidget>
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final topPadding = RichTextStyle.fromTextNode(widget.textNode).topPadding;
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(bottom: defaultLinePadding),
|
||||
child: SizedBox(
|
||||
@ -68,8 +67,7 @@ class _NumberListTextNodeWidgetState extends State<NumberListTextNodeWidget>
|
||||
key: iconKey,
|
||||
width: _iconWidth,
|
||||
height: _iconWidth,
|
||||
padding:
|
||||
EdgeInsets.only(top: topPadding, right: _iconRightPadding),
|
||||
padding: EdgeInsets.only(right: _iconRightPadding),
|
||||
number: widget.textNode.attributes.number,
|
||||
),
|
||||
Expanded(
|
||||
|
@ -55,7 +55,6 @@ class _QuotedTextNodeWidgetState extends State<QuotedTextNodeWidget>
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final topPadding = RichTextStyle.fromTextNode(widget.textNode).topPadding;
|
||||
return SizedBox(
|
||||
width: defaultMaxTextNodeWidth,
|
||||
child: Padding(
|
||||
@ -67,8 +66,7 @@ class _QuotedTextNodeWidgetState extends State<QuotedTextNodeWidget>
|
||||
FlowySvg(
|
||||
key: iconKey,
|
||||
width: _iconWidth,
|
||||
padding: EdgeInsets.only(
|
||||
top: topPadding, right: _iconRightPadding),
|
||||
padding: EdgeInsets.only(right: _iconRightPadding),
|
||||
name: 'quote',
|
||||
),
|
||||
Expanded(
|
||||
|
@ -1,8 +1,6 @@
|
||||
import 'package:appflowy_editor/src/document/attributes.dart';
|
||||
import 'package:appflowy_editor/src/document/node.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
|
||||
///
|
||||
/// Supported partial rendering types:
|
||||
@ -182,14 +180,13 @@ class RichTextStyle {
|
||||
RichTextStyle({
|
||||
required this.attributes,
|
||||
required this.text,
|
||||
this.gestureRecognizer,
|
||||
this.height = 1.5,
|
||||
});
|
||||
|
||||
RichTextStyle.fromTextNode(TextNode textNode)
|
||||
: this(attributes: textNode.attributes, text: textNode.toRawString());
|
||||
|
||||
final Attributes attributes;
|
||||
final String text;
|
||||
final GestureRecognizer? gestureRecognizer;
|
||||
final double height;
|
||||
|
||||
TextSpan toTextSpan() => _toTextSpan(height);
|
||||
@ -201,6 +198,7 @@ class RichTextStyle {
|
||||
TextSpan _toTextSpan(double? height) {
|
||||
return TextSpan(
|
||||
text: text,
|
||||
recognizer: _recognizer,
|
||||
style: TextStyle(
|
||||
fontWeight: _fontWeight,
|
||||
fontStyle: _fontStyle,
|
||||
@ -210,7 +208,6 @@ class RichTextStyle {
|
||||
background: _background,
|
||||
height: height,
|
||||
),
|
||||
recognizer: _recognizer,
|
||||
);
|
||||
}
|
||||
|
||||
@ -273,19 +270,6 @@ class RichTextStyle {
|
||||
|
||||
// recognizer
|
||||
GestureRecognizer? get _recognizer {
|
||||
final href = attributes.href;
|
||||
if (href != null) {
|
||||
return TapGestureRecognizer()
|
||||
..onTap = () async {
|
||||
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 null;
|
||||
return gestureRecognizer;
|
||||
}
|
||||
}
|
||||
|
@ -14,6 +14,7 @@ typedef ToolbarShowValidator = bool Function(EditorState editorState);
|
||||
class ToolbarItem {
|
||||
ToolbarItem({
|
||||
required this.id,
|
||||
required this.type,
|
||||
required this.icon,
|
||||
this.tooltipsMessage = '',
|
||||
required this.validator,
|
||||
@ -21,6 +22,7 @@ class ToolbarItem {
|
||||
});
|
||||
|
||||
final String id;
|
||||
final int type;
|
||||
final Widget icon;
|
||||
final String tooltipsMessage;
|
||||
final ToolbarShowValidator validator;
|
||||
@ -29,16 +31,32 @@ class ToolbarItem {
|
||||
factory ToolbarItem.divider() {
|
||||
return ToolbarItem(
|
||||
id: 'divider',
|
||||
type: -1,
|
||||
icon: const FlowySvg(name: 'toolbar/divider'),
|
||||
validator: (editorState) => true,
|
||||
handler: (editorState, context) {},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (other is! ToolbarItem) {
|
||||
return false;
|
||||
}
|
||||
if (identical(this, other)) {
|
||||
return true;
|
||||
}
|
||||
return id == other.id;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => id.hashCode;
|
||||
}
|
||||
|
||||
List<ToolbarItem> defaultToolbarItems = [
|
||||
ToolbarItem(
|
||||
id: 'appflowy.toolbar.h1',
|
||||
type: 1,
|
||||
tooltipsMessage: 'Heading 1',
|
||||
icon: const FlowySvg(name: 'toolbar/h1'),
|
||||
validator: _onlyShowInSingleTextSelection,
|
||||
@ -46,6 +64,7 @@ List<ToolbarItem> defaultToolbarItems = [
|
||||
),
|
||||
ToolbarItem(
|
||||
id: 'appflowy.toolbar.h2',
|
||||
type: 1,
|
||||
tooltipsMessage: 'Heading 2',
|
||||
icon: const FlowySvg(name: 'toolbar/h2'),
|
||||
validator: _onlyShowInSingleTextSelection,
|
||||
@ -53,14 +72,15 @@ List<ToolbarItem> defaultToolbarItems = [
|
||||
),
|
||||
ToolbarItem(
|
||||
id: 'appflowy.toolbar.h3',
|
||||
type: 1,
|
||||
tooltipsMessage: 'Heading 3',
|
||||
icon: const FlowySvg(name: 'toolbar/h3'),
|
||||
validator: _onlyShowInSingleTextSelection,
|
||||
handler: (editorState, context) => formatHeading(editorState, StyleKey.h3),
|
||||
),
|
||||
ToolbarItem.divider(),
|
||||
ToolbarItem(
|
||||
id: 'appflowy.toolbar.bold',
|
||||
type: 2,
|
||||
tooltipsMessage: 'Bold',
|
||||
icon: const FlowySvg(name: 'toolbar/bold'),
|
||||
validator: _showInTextSelection,
|
||||
@ -68,6 +88,7 @@ List<ToolbarItem> defaultToolbarItems = [
|
||||
),
|
||||
ToolbarItem(
|
||||
id: 'appflowy.toolbar.italic',
|
||||
type: 2,
|
||||
tooltipsMessage: 'Italic',
|
||||
icon: const FlowySvg(name: 'toolbar/italic'),
|
||||
validator: _showInTextSelection,
|
||||
@ -75,6 +96,7 @@ List<ToolbarItem> defaultToolbarItems = [
|
||||
),
|
||||
ToolbarItem(
|
||||
id: 'appflowy.toolbar.underline',
|
||||
type: 2,
|
||||
tooltipsMessage: 'Underline',
|
||||
icon: const FlowySvg(name: 'toolbar/underline'),
|
||||
validator: _showInTextSelection,
|
||||
@ -82,14 +104,15 @@ List<ToolbarItem> defaultToolbarItems = [
|
||||
),
|
||||
ToolbarItem(
|
||||
id: 'appflowy.toolbar.strikethrough',
|
||||
type: 2,
|
||||
tooltipsMessage: 'Strikethrough',
|
||||
icon: const FlowySvg(name: 'toolbar/strikethrough'),
|
||||
validator: _showInTextSelection,
|
||||
handler: (editorState, context) => formatStrikethrough(editorState),
|
||||
),
|
||||
ToolbarItem.divider(),
|
||||
ToolbarItem(
|
||||
id: 'appflowy.toolbar.quote',
|
||||
type: 3,
|
||||
tooltipsMessage: 'Quote',
|
||||
icon: const FlowySvg(name: 'toolbar/quote'),
|
||||
validator: _onlyShowInSingleTextSelection,
|
||||
@ -97,14 +120,15 @@ List<ToolbarItem> defaultToolbarItems = [
|
||||
),
|
||||
ToolbarItem(
|
||||
id: 'appflowy.toolbar.bulleted_list',
|
||||
type: 3,
|
||||
tooltipsMessage: 'Bulleted list',
|
||||
icon: const FlowySvg(name: 'toolbar/bulleted_list'),
|
||||
validator: _onlyShowInSingleTextSelection,
|
||||
handler: (editorState, context) => formatBulletedList(editorState),
|
||||
),
|
||||
ToolbarItem.divider(),
|
||||
ToolbarItem(
|
||||
id: 'appflowy.toolbar.link',
|
||||
type: 4,
|
||||
tooltipsMessage: 'Link',
|
||||
icon: const FlowySvg(name: 'toolbar/link'),
|
||||
validator: _onlyShowInSingleTextSelection,
|
||||
@ -112,6 +136,7 @@ List<ToolbarItem> defaultToolbarItems = [
|
||||
),
|
||||
ToolbarItem(
|
||||
id: 'appflowy.toolbar.highlight',
|
||||
type: 4,
|
||||
tooltipsMessage: 'Highlight',
|
||||
icon: const FlowySvg(name: 'toolbar/highlight'),
|
||||
validator: _showInTextSelection,
|
||||
@ -159,7 +184,7 @@ void _showLinkMenu(EditorState editorState, BuildContext context) {
|
||||
final linkText = node.getAttributeInSelection(selection, StyleKey.href);
|
||||
_linkMenuOverlay = OverlayEntry(builder: (context) {
|
||||
return Positioned(
|
||||
top: matchRect.bottom,
|
||||
top: matchRect.bottom + 5.0,
|
||||
left: matchRect.left,
|
||||
child: Material(
|
||||
child: LinkMenu(
|
||||
|
@ -50,9 +50,6 @@ class _ToolbarWidgetState extends State<ToolbarWidget> with ToolbarMixin {
|
||||
}
|
||||
|
||||
Widget _buildToolbar(BuildContext context) {
|
||||
final items = widget.items.where(
|
||||
(item) => item.validator(widget.editorState),
|
||||
);
|
||||
return Material(
|
||||
borderRadius: BorderRadius.circular(8.0),
|
||||
color: const Color(0xFF333333),
|
||||
@ -62,7 +59,7 @@ class _ToolbarWidgetState extends State<ToolbarWidget> with ToolbarMixin {
|
||||
height: 32.0,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: items
|
||||
children: widget.items
|
||||
.map(
|
||||
(item) => Center(
|
||||
child: ToolbarItemWidget(
|
||||
|
@ -39,13 +39,27 @@ class _FlowyToolbarState extends State<FlowyToolbar>
|
||||
void showInOffset(Offset offset, LayerLink layerLink) {
|
||||
hide();
|
||||
|
||||
final items = defaultToolbarItems
|
||||
.where((item) => item.validator(widget.editorState))
|
||||
.toList(growable: false)
|
||||
..sort((a, b) => a.type.compareTo(b.type));
|
||||
if (items.isEmpty) {
|
||||
return;
|
||||
}
|
||||
final List<ToolbarItem> dividedItems = [items.first];
|
||||
for (var i = 1; i < items.length; i++) {
|
||||
if (items[i].type != items[i - 1].type) {
|
||||
dividedItems.add(ToolbarItem.divider());
|
||||
}
|
||||
dividedItems.add(items[i]);
|
||||
}
|
||||
_toolbarOverlay = OverlayEntry(
|
||||
builder: (context) => ToolbarWidget(
|
||||
key: _toolbarWidgetKey,
|
||||
editorState: widget.editorState,
|
||||
layerLink: layerLink,
|
||||
offset: offset.translate(0, -37.0),
|
||||
items: defaultToolbarItems,
|
||||
items: dividedItems,
|
||||
),
|
||||
);
|
||||
Overlay.of(context)?.insert(_toolbarOverlay!);
|
||||
|
Loading…
x
Reference in New Issue
Block a user