diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/bulleted_list_text.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/bulleted_list_text.dart index 267a5acc66..7d69ff459f 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/bulleted_list_text.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/bulleted_list_text.dart @@ -56,8 +56,6 @@ class _BulletedListTextNodeWidgetState extends State @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 key: iconKey, width: _iconWidth, height: _iconWidth, - padding: - EdgeInsets.only(top: topPadding, right: _iconRightPadding), + padding: EdgeInsets.only(right: _iconRightPadding), name: 'point', ), Expanded( diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/checkbox_text.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/checkbox_text.dart index 9b7d3a730f..0255a84049 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/checkbox_text.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/checkbox_text.dart @@ -63,7 +63,6 @@ class _CheckboxNodeWidgetState extends State 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 child: FlowySvg( width: _iconWidth, height: _iconWidth, - padding: EdgeInsets.only( - top: topPadding, - right: _iconRightPadding, - ), + padding: EdgeInsets.only(right: _iconRightPadding), name: check ? 'check' : 'uncheck', ), onTap: () { 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 89df8a54b1..39f484c23f 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,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 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 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().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() - .map((insert) => RichTextStyle( - attributes: insert.attributes ?? {}, - text: insert.content, - height: _lineHeight, - ).toTextSpan()) - .toList(growable: false), - ); - TextSpan get _placeholderTextSpan => TextSpan(children: [ RichTextStyle( text: widget.placeholderText, diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/number_list_text.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/number_list_text.dart index de3b0b55b6..c1062e1c3c 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/number_list_text.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/number_list_text.dart @@ -56,7 +56,6 @@ class _NumberListTextNodeWidgetState extends State @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 key: iconKey, width: _iconWidth, height: _iconWidth, - padding: - EdgeInsets.only(top: topPadding, right: _iconRightPadding), + padding: EdgeInsets.only(right: _iconRightPadding), number: widget.textNode.attributes.number, ), Expanded( diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/quoted_text.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/quoted_text.dart index 0389dfa50f..3391729779 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/quoted_text.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/quoted_text.dart @@ -55,7 +55,6 @@ class _QuotedTextNodeWidgetState extends State @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 FlowySvg( key: iconKey, width: _iconWidth, - padding: EdgeInsets.only( - top: topPadding, right: _iconRightPadding), + padding: EdgeInsets.only(right: _iconRightPadding), name: 'quote', ), Expanded( diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/rich_text_style.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/rich_text_style.dart index 6bc50c5115..efcdd3790f 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/rich_text_style.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/rich_text_style.dart @@ -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; } } 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 a068722cb2..b2f6a9b2f7 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 @@ -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 defaultToolbarItems = [ ToolbarItem( id: 'appflowy.toolbar.h1', + type: 1, tooltipsMessage: 'Heading 1', icon: const FlowySvg(name: 'toolbar/h1'), validator: _onlyShowInSingleTextSelection, @@ -46,6 +64,7 @@ List defaultToolbarItems = [ ), ToolbarItem( id: 'appflowy.toolbar.h2', + type: 1, tooltipsMessage: 'Heading 2', icon: const FlowySvg(name: 'toolbar/h2'), validator: _onlyShowInSingleTextSelection, @@ -53,14 +72,15 @@ List 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 defaultToolbarItems = [ ), ToolbarItem( id: 'appflowy.toolbar.italic', + type: 2, tooltipsMessage: 'Italic', icon: const FlowySvg(name: 'toolbar/italic'), validator: _showInTextSelection, @@ -75,6 +96,7 @@ List defaultToolbarItems = [ ), ToolbarItem( id: 'appflowy.toolbar.underline', + type: 2, tooltipsMessage: 'Underline', icon: const FlowySvg(name: 'toolbar/underline'), validator: _showInTextSelection, @@ -82,14 +104,15 @@ List 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 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 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( diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/toolbar/toolbar_widget.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/toolbar/toolbar_widget.dart index 167f8e79ba..395c6818bb 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/toolbar/toolbar_widget.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/toolbar/toolbar_widget.dart @@ -50,9 +50,6 @@ class _ToolbarWidgetState extends State 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 with ToolbarMixin { height: 32.0, child: Row( crossAxisAlignment: CrossAxisAlignment.start, - children: items + children: widget.items .map( (item) => Center( child: ToolbarItemWidget( diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/toolbar_service.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/toolbar_service.dart index 290fe4b4bb..143f899926 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/toolbar_service.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/toolbar_service.dart @@ -39,13 +39,27 @@ class _FlowyToolbarState extends State 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 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!);