feat: single tap to edit link and double tap to open the link

This commit is contained in:
Lucas.Xu 2022-08-22 20:38:24 +08:00
parent 6517adece7
commit 70ea80878a
9 changed files with 115 additions and 79 deletions

View File

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

View File

@ -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: () {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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