diff --git a/frontend/app_flowy/packages/appflowy_editor/assets/images/copy.svg b/frontend/app_flowy/packages/appflowy_editor/assets/images/copy.svg new file mode 100644 index 0000000000..101cf34205 --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor/assets/images/copy.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/app_flowy/packages/appflowy_editor/example/lib/main.dart b/frontend/app_flowy/packages/appflowy_editor/example/lib/main.dart index e72739e246..4bd1cb1972 100644 --- a/frontend/app_flowy/packages/appflowy_editor/example/lib/main.dart +++ b/frontend/app_flowy/packages/appflowy_editor/example/lib/main.dart @@ -45,10 +45,8 @@ class _MyHomePageState extends State { @override Widget build(BuildContext context) { return Scaffold( - body: Container( - alignment: Alignment.topCenter, - child: _buildEditor(context), - ), + extendBodyBehindAppBar: true, + body: _buildEditor(context), floatingActionButton: _buildExpandableFab(), ); } @@ -92,10 +90,11 @@ class _MyHomePageState extends State { ..handler = (message) { debugPrint(message); }; - return Container( - padding: const EdgeInsets.all(20), + return SizedBox( + width: MediaQuery.of(context).size.width, child: AppFlowyEditor( editorState: _editorState, + editorStyle: const EditorStyle.defaultStyle(), ), ); } else { diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/appflowy_editor.dart b/frontend/app_flowy/packages/appflowy_editor/lib/appflowy_editor.dart index 14826ff713..12b3a29252 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/appflowy_editor.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/appflowy_editor.dart @@ -2,6 +2,7 @@ library appflowy_editor; export 'src/infra/log.dart'; +export 'src/render/style/editor_style.dart'; export 'src/document/node.dart'; export 'src/document/path.dart'; export 'src/document/position.dart'; diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/document/state_tree.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/document/state_tree.dart index a17b2fbf98..a4a9869df5 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/document/state_tree.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/document/state_tree.dart @@ -62,10 +62,17 @@ class StateTree { } return false; } - for (var i = 0; i < nodes.length; i++) { - final node = nodes[i]; - insertedNode!.insertAfter(node); - insertedNode = node; + if (path.last <= 0) { + for (var i = 0; i < nodes.length; i++) { + final node = nodes[i]; + insertedNode.insertBefore(node); + } + } else { + for (var i = 0; i < nodes.length; i++) { + final node = nodes[i]; + insertedNode!.insertAfter(node); + insertedNode = node; + } } return true; } diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/editor_state.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/editor_state.dart index 396b428baf..2750af07a6 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/editor_state.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/editor_state.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:appflowy_editor/src/infra/log.dart'; import 'package:appflowy_editor/src/render/selection_menu/selection_menu_widget.dart'; +import 'package:appflowy_editor/src/render/style/editor_style.dart'; import 'package:appflowy_editor/src/service/service.dart'; import 'package:flutter/material.dart'; @@ -58,6 +59,9 @@ class EditorState { /// Stores the selection menu items. List selectionMenuItems = []; + /// Stores the editor style. + EditorStyle editorStyle = const EditorStyle.defaultStyle(); + final UndoManager undoManager = UndoManager(); Selection? _cursorSelection; diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/extensions/url_launcher_extension.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/extensions/url_launcher_extension.dart new file mode 100644 index 0000000000..1c0ea30c82 --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/extensions/url_launcher_extension.dart @@ -0,0 +1,14 @@ +import 'package:url_launcher/url_launcher_string.dart'; + +Future 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); +} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/operation/transaction_builder.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/operation/transaction_builder.dart index 12c13bf2e5..1390b23918 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/operation/transaction_builder.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/operation/transaction_builder.dart @@ -115,17 +115,18 @@ class TransactionBuilder { /// Inserts content at a specified index. /// 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. - insertText(TextNode node, int index, String content, - {Attributes? attributes, Attributes? removedAttributes}) { + insertText( + TextNode node, + int index, + String content, { + Attributes? attributes, + }) { var newAttributes = attributes; if (index != 0 && attributes == null) { newAttributes = node.delta.slice(max(index - 1, 0), index).first.attributes; if (newAttributes != null) { newAttributes = Attributes.from(newAttributes); - if (removedAttributes != null) { - newAttributes.addAll(removedAttributes); - } } } textEdit( @@ -138,7 +139,8 @@ class TransactionBuilder { ), ); 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. diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/editor/editor_entry.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/editor/editor_entry.dart index e71dc7c79b..4167ca1b38 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/editor/editor_entry.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/editor/editor_entry.dart @@ -33,7 +33,7 @@ class EditorNodeWidget extends StatelessWidget { @override Widget build(BuildContext context) { return Column( - crossAxisAlignment: CrossAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, children: node.children .map( (child) => diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/image/image_node_builder.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/image/image_node_builder.dart index 796e96c250..ad3cf19d53 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/image/image_node_builder.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/image/image_node_builder.dart @@ -17,6 +17,7 @@ class ImageNodeBuilder extends NodeWidgetBuilder { } return ImageNodeWidget( key: context.node.key, + node: context.node, src: src, width: width, alignment: _textToAlignment(align), diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/image/image_node_widget.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/image/image_node_widget.dart index 2cc0916b66..a65df11541 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/image/image_node_widget.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/image/image_node_widget.dart @@ -1,10 +1,15 @@ +import 'package:appflowy_editor/src/extensions/object_extensions.dart'; +import 'package:appflowy_editor/src/document/node.dart'; +import 'package:appflowy_editor/src/document/position.dart'; +import 'package:appflowy_editor/src/document/selection.dart'; import 'package:appflowy_editor/src/infra/flowy_svg.dart'; -import 'package:appflowy_editor/src/render/rich_text/rich_text_style.dart'; +import 'package:appflowy_editor/src/render/selection/selectable.dart'; import 'package:flutter/material.dart'; class ImageNodeWidget extends StatefulWidget { const ImageNodeWidget({ Key? key, + required this.node, required this.src, this.width, required this.alignment, @@ -14,6 +19,7 @@ class ImageNodeWidget extends StatefulWidget { required this.onResize, }) : super(key: key); + final Node node; final String src; final double? width; final Alignment alignment; @@ -26,7 +32,9 @@ class ImageNodeWidget extends StatefulWidget { State createState() => _ImageNodeWidgetState(); } -class _ImageNodeWidgetState extends State { +class _ImageNodeWidgetState extends State with Selectable { + final _imageKey = GlobalKey(); + double? _imageWidth; double _initial = 0; double _distance = 0; @@ -42,7 +50,11 @@ class _ImageNodeWidgetState extends State { _imageWidth = widget.width; _imageStreamListener = ImageStreamListener( (image, _) { - _imageWidth = image.image.width.toDouble(); + _imageWidth = _imageKey.currentContext + ?.findRenderObject() + ?.unwrapOrNull() + ?.size + .width; }, ); } @@ -56,14 +68,54 @@ class _ImageNodeWidgetState extends State { @override Widget build(BuildContext context) { // only support network image. - return Container( - width: defaultMaxTextNodeWidth, + key: _imageKey, padding: const EdgeInsets.only(top: 8, bottom: 8), child: _buildNetworkImage(context), ); } + @override + Position start() { + return Position(path: widget.node.path, offset: 0); + } + + @override + Position end() { + return Position(path: widget.node.path, offset: 1); + } + + @override + Position getPositionInOffset(Offset start) { + return end(); + } + + @override + Rect? getCursorRectInPosition(Position position) { + return null; + } + + @override + List getRectsInSelection(Selection selection) { + final renderBox = context.findRenderObject() as RenderBox; + return [Offset.zero & renderBox.size]; + } + + @override + Selection getSelectionInRange(Offset start, Offset end) { + if (start <= end) { + return Selection(start: this.start(), end: this.end()); + } else { + return Selection(start: this.end(), end: this.start()); + } + } + + @override + Offset localToGlobal(Offset offset) { + final renderBox = context.findRenderObject() as RenderBox; + return renderBox.localToGlobal(offset); + } + Widget _buildNetworkImage(BuildContext context) { return Align( alignment: widget.alignment, @@ -87,7 +139,7 @@ class _ImageNodeWidgetState extends State { loadingBuilder: (context, child, loadingProgress) => loadingProgress == null ? child : _buildLoading(context), errorBuilder: (context, error, stackTrace) { - _imageWidth ??= defaultMaxTextNodeWidth; + // _imageWidth ??= defaultMaxTextNodeWidth; return _buildError(context); }, ); diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/link_menu/link_menu.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/link_menu/link_menu.dart index a33adf3b8c..13396a33c4 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/link_menu/link_menu.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/link_menu/link_menu.dart @@ -6,14 +6,18 @@ class LinkMenu extends StatefulWidget { Key? key, this.linkText, required this.onSubmitted, + required this.onOpenLink, required this.onCopyLink, required this.onRemoveLink, + required this.onFocusChange, }) : super(key: key); final String? linkText; final void Function(String text) onSubmitted; + final VoidCallback onOpenLink; final VoidCallback onCopyLink; final VoidCallback onRemoveLink; + final void Function(bool value) onFocusChange; @override State createState() => _LinkMenuState(); @@ -26,15 +30,14 @@ class _LinkMenuState extends State { @override void initState() { super.initState(); - _textEditingController.text = widget.linkText ?? ''; - _focusNode.requestFocus(); + _focusNode.addListener(_onFocusChange); } @override void dispose() { - _focusNode.dispose(); - + _textEditingController.dispose(); + _focusNode.removeListener(_onFocusChange); super.dispose(); } @@ -67,6 +70,12 @@ class _LinkMenuState extends State { if (widget.linkText != null) ...[ _buildIconButton( iconName: 'link', + text: 'Open link', + onPressed: widget.onOpenLink, + ), + _buildIconButton( + iconName: 'copy', + color: Colors.black, text: 'Copy link', onPressed: widget.onCopyLink, ), @@ -126,11 +135,15 @@ class _LinkMenuState extends State { Widget _buildIconButton({ required String iconName, + Color? color, required String text, required VoidCallback onPressed, }) { return TextButton.icon( - icon: FlowySvg(name: iconName), + icon: FlowySvg( + name: iconName, + color: color, + ), style: TextButton.styleFrom( minimumSize: const Size.fromHeight(40), padding: EdgeInsets.zero, @@ -148,4 +161,8 @@ class _LinkMenuState extends State { onPressed: onPressed, ); } + + void _onFocusChange() { + widget.onFocusChange(_focusNode.hasFocus); + } } 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 7d69ff459f..7f0f0363f8 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,30 +56,27 @@ class _BulletedListTextNodeWidgetState extends State @override Widget build(BuildContext context) { - return SizedBox( - width: defaultMaxTextNodeWidth, - child: Padding( - padding: EdgeInsets.only(bottom: defaultLinePadding), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - FlowySvg( - key: iconKey, - width: _iconWidth, - height: _iconWidth, - padding: EdgeInsets.only(right: _iconRightPadding), - name: 'point', + return Padding( + padding: EdgeInsets.only(bottom: defaultLinePadding), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + FlowySvg( + key: iconKey, + width: _iconWidth, + height: _iconWidth, + padding: EdgeInsets.only(right: _iconRightPadding), + name: 'point', + ), + Flexible( + child: FlowyRichText( + key: _richTextKey, + placeholderText: 'List', + textNode: widget.textNode, + editorState: widget.editorState, ), - Expanded( - child: FlowyRichText( - key: _richTextKey, - placeholderText: 'List', - textNode: widget.textNode, - editorState: widget.editorState, - ), - ), - ], - ), + ) + ], ), ); } 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 0255a84049..ed6748a43e 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,41 +63,38 @@ class _CheckboxNodeWidgetState extends State Widget _buildWithSingle(BuildContext context) { final check = widget.textNode.attributes.check; - return SizedBox( - width: defaultMaxTextNodeWidth, - child: Padding( - padding: EdgeInsets.only(bottom: defaultLinePadding), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - GestureDetector( - key: iconKey, - child: FlowySvg( - width: _iconWidth, - height: _iconWidth, - padding: EdgeInsets.only(right: _iconRightPadding), - name: check ? 'check' : 'uncheck', - ), - onTap: () { - TransactionBuilder(widget.editorState) - ..updateNode(widget.textNode, { - StyleKey.checkbox: !check, - }) - ..commit(); - }, + return Padding( + padding: EdgeInsets.only(bottom: defaultLinePadding), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + GestureDetector( + key: iconKey, + child: FlowySvg( + width: _iconWidth, + height: _iconWidth, + padding: EdgeInsets.only(right: _iconRightPadding), + name: check ? 'check' : 'uncheck', ), - Expanded( - child: FlowyRichText( - key: _richTextKey, - placeholderText: 'To-do', - textNode: widget.textNode, - textSpanDecorator: _textSpanDecorator, - placeholderTextSpanDecorator: _textSpanDecorator, - editorState: widget.editorState, - ), + onTap: () { + TransactionBuilder(widget.editorState) + ..updateNode(widget.textNode, { + StyleKey.checkbox: !check, + }) + ..commit(); + }, + ), + Flexible( + child: FlowyRichText( + key: _richTextKey, + placeholderText: 'To-do', + textNode: widget.textNode, + textSpanDecorator: _textSpanDecorator, + placeholderTextSpanDecorator: _textSpanDecorator, + editorState: widget.editorState, ), - ], - ), + ), + ], ), ); } 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 39f484c23f..3489c2bb52 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,6 +1,8 @@ import 'dart:async'; 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/material.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/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); @@ -42,7 +43,7 @@ class FlowyRichText extends StatefulWidget { } class _FlowyRichTextState extends State with Selectable { - final _textKey = GlobalKey(); + var _textKey = GlobalKey(); final _placeholderTextKey = GlobalKey(); final _lineHeight = 1.5; @@ -53,6 +54,17 @@ class _FlowyRichTextState extends State with Selectable { RenderParagraph get _placeholderRenderParagraph => _placeholderTextKey.currentContext?.findRenderObject() as RenderParagraph; + @override + void didUpdateWidget(covariant FlowyRichText oldWidget) { + super.didUpdateWidget(oldWidget); + + // https://github.com/flutter/flutter/issues/110342 + if (_textKey.currentWidget is RichText) { + // Force refresh the RichText widget. + _textKey = GlobalKey(); + } + } + @override Widget build(BuildContext context) { return _buildRichText(context); @@ -182,7 +194,9 @@ class _FlowyRichTextState extends State with Selectable { return RichText( key: _textKey, textHeightBehavior: const TextHeightBehavior( - applyHeightToFirstAscent: false, applyHeightToLastDescent: false), + applyHeightToFirstAscent: false, + applyHeightToLastDescent: false, + ), text: widget.textSpanDecorator != null ? widget.textSpanDecorator!(textSpan) : textSpan, @@ -193,53 +207,23 @@ class _FlowyRichTextState extends State with Selectable { var offset = 0; return TextSpan( children: widget.textNode.delta.whereType().map((insert) { - GestureRecognizer? gestureDetector; + GestureRecognizer? gestureRecognizer; 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(); - - 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'); - }); - }); - }; + gestureRecognizer = _buildTapHrefGestureRecognizer( + insert.attributes![StyleKey.href], + Selection.single( + path: widget.textNode.path, + startOffset: offset, + endOffset: offset + insert.length, + ), + ); } offset += insert.length; final textSpan = RichTextStyle( attributes: insert.attributes ?? {}, text: insert.content, height: _lineHeight, - gestureRecognizer: gestureDetector, + gestureRecognizer: gestureRecognizer, ).toTextSpan(); return textSpan; }).toList(growable: false), @@ -255,4 +239,34 @@ class _FlowyRichTextState extends State with Selectable { height: _lineHeight, ).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; + } } diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/heading_text.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/heading_text.dart index 050b330f8b..fff25dd2d5 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/heading_text.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/heading_text.dart @@ -63,16 +63,13 @@ class _HeadingTextNodeWidgetState extends State top: _topPadding, bottom: defaultLinePadding, ), - child: SizedBox( - width: defaultMaxTextNodeWidth, - child: FlowyRichText( - key: _richTextKey, - placeholderText: 'Heading', - placeholderTextSpanDecorator: _placeholderTextSpanDecorator, - textSpanDecorator: _textSpanDecorator, - textNode: widget.textNode, - editorState: widget.editorState, - ), + child: FlowyRichText( + key: _richTextKey, + placeholderText: 'Heading', + placeholderTextSpanDecorator: _placeholderTextSpanDecorator, + textSpanDecorator: _textSpanDecorator, + textNode: widget.textNode, + editorState: widget.editorState, ), ); } 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 c1062e1c3c..36cf91bdce 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 @@ -58,28 +58,25 @@ class _NumberListTextNodeWidgetState extends State Widget build(BuildContext context) { return Padding( padding: EdgeInsets.only(bottom: defaultLinePadding), - child: SizedBox( - width: defaultMaxTextNodeWidth, - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - FlowySvg( - key: iconKey, - width: _iconWidth, - height: _iconWidth, - padding: EdgeInsets.only(right: _iconRightPadding), - number: widget.textNode.attributes.number, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + FlowySvg( + key: iconKey, + width: _iconWidth, + height: _iconWidth, + padding: EdgeInsets.only(right: _iconRightPadding), + number: widget.textNode.attributes.number, + ), + Flexible( + child: FlowyRichText( + key: _richTextKey, + placeholderText: 'List', + textNode: widget.textNode, + editorState: widget.editorState, ), - Expanded( - child: FlowyRichText( - key: _richTextKey, - placeholderText: 'List', - textNode: widget.textNode, - editorState: widget.editorState, - ), - ), - ], - ), + ), + ], )); } } 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 78c6653904..9c2366d1cb 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,30 +55,27 @@ class _QuotedTextNodeWidgetState extends State @override Widget build(BuildContext context) { - return SizedBox( - width: defaultMaxTextNodeWidth, - child: Padding( - padding: EdgeInsets.only(bottom: defaultLinePadding), - child: IntrinsicHeight( - child: Row( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - FlowySvg( - key: iconKey, - width: _iconWidth, - padding: EdgeInsets.only(right: _iconRightPadding), - name: 'quote', + return Padding( + padding: EdgeInsets.only(bottom: defaultLinePadding), + child: IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + FlowySvg( + key: iconKey, + width: _iconWidth, + padding: EdgeInsets.only(right: _iconRightPadding), + name: 'quote', + ), + Flexible( + child: FlowyRichText( + key: _richTextKey, + placeholderText: 'Quote', + textNode: widget.textNode, + editorState: widget.editorState, ), - Expanded( - child: FlowyRichText( - key: _richTextKey, - placeholderText: 'Quote', - textNode: widget.textNode, - editorState: widget.editorState, - ), - ), - ], - ), + ), + ], ), ), ); diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/rich_text.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/rich_text.dart index d8dcfb91f6..b9a3e2f314 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/rich_text.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/rich_text.dart @@ -52,15 +52,12 @@ class _RichTextNodeWidgetState extends State @override Widget build(BuildContext context) { - return SizedBox( - width: defaultMaxTextNodeWidth, - child: Padding( - padding: EdgeInsets.only(bottom: defaultLinePadding), - child: FlowyRichText( - key: _richTextKey, - textNode: widget.textNode, - editorState: widget.editorState, - ), + return Padding( + padding: EdgeInsets.only(bottom: defaultLinePadding), + child: FlowyRichText( + key: _richTextKey, + textNode: widget.textNode, + editorState: widget.editorState, ), ); } 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 efcdd3790f..6270127610 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 @@ -61,7 +61,6 @@ class StyleKey { } // TODO: customize -double defaultMaxTextNodeWidth = 780.0; double defaultLinePadding = 8.0; double baseFontSize = 16.0; String defaultHighlightColor = '0x6000BCF0'; diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/style/editor_style.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/style/editor_style.dart new file mode 100644 index 0000000000..e691ea689e --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/style/editor_style.dart @@ -0,0 +1,20 @@ +import 'package:flutter/material.dart'; + +/// Editor style configuration +class EditorStyle { + const EditorStyle({ + required this.padding, + }); + + const EditorStyle.defaultStyle() + : padding = const EdgeInsets.fromLTRB(200.0, 0.0, 200.0, 0.0); + + /// The margin of the document context from the editor. + final EdgeInsets padding; + + EditorStyle copyWith({EdgeInsets? padding}) { + return EditorStyle( + padding: padding ?? this.padding, + ); + } +} 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 107ae23b6f..9a1b2f1c02 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 @@ -1,4 +1,5 @@ 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/render/link_menu/link_menu.dart'; import 'package:appflowy_editor/src/render/rich_text/rich_text_style.dart'; @@ -132,7 +133,7 @@ List defaultToolbarItems = [ tooltipsMessage: 'Link', icon: const FlowySvg(name: 'toolbar/link'), validator: _onlyShowInSingleTextSelection, - handler: (editorState, context) => _showLinkMenu(editorState, context), + handler: (editorState, context) => showLinkMenu(context, editorState), ), ToolbarItem( id: 'appflowy.toolbar.highlight', @@ -157,7 +158,12 @@ ToolbarShowValidator _showInTextSelection = (editorState) { OverlayEntry? _linkMenuOverlay; EditorState? _editorState; -void _showLinkMenu(EditorState editorState, BuildContext context) { +bool _changeSelectionInner = false; +void showLinkMenu( + BuildContext context, + EditorState editorState, { + Selection? customSelection, +}) { final rects = editorState.service.selectionService.selectionRects; var maxBottom = 0.0; late Rect matchRect; @@ -173,16 +179,19 @@ void _showLinkMenu(EditorState editorState, BuildContext context) { // Since the link menu will only show in single text selection, // We get the text node directly instead of judging details again. - final selection = - editorState.service.selectionService.currentSelection.value!; + final selection = customSelection ?? + editorState.service.selectionService.currentSelection.value; + final node = editorState.service.selectionService.currentSelectedNodes; + if (selection == null || node.isEmpty || node.first is! TextNode) { + return; + } final index = selection.isBackward ? selection.start.offset : selection.end.offset; final length = (selection.start.offset - selection.end.offset).abs(); - final node = editorState.service.selectionService.currentSelectedNodes.first - as TextNode; + final textNode = node.first as TextNode; String? linkText; - if (node.allSatisfyLinkInSelection(selection)) { - linkText = node.getAttributeInSelection(selection, StyleKey.href); + if (textNode.allSatisfyLinkInSelection(selection)) { + linkText = textNode.getAttributeInSelection(selection, StyleKey.href); } _linkMenuOverlay = OverlayEntry(builder: (context) { return Positioned( @@ -191,9 +200,12 @@ void _showLinkMenu(EditorState editorState, BuildContext context) { child: Material( child: LinkMenu( linkText: linkText, + onOpenLink: () async { + await safeLaunchUrl(linkText); + }, onSubmitted: (text) { TransactionBuilder(editorState) - ..formatText(node, index, length, {StyleKey.href: text}) + ..formatText(textNode, index, length, {StyleKey.href: text}) ..commit(); _dismissLinkMenu(); }, @@ -203,10 +215,17 @@ void _showLinkMenu(EditorState editorState, BuildContext context) { }, onRemoveLink: () { TransactionBuilder(editorState) - ..formatText(node, index, length, {StyleKey.href: null}) + ..formatText(textNode, index, length, {StyleKey.href: null}) ..commit(); _dismissLinkMenu(); }, + onFocusChange: (value) { + if (value && customSelection != null) { + _changeSelectionInner = true; + editorState.service.selectionService + .updateSelection(customSelection); + } + }, ), ), ); @@ -214,12 +233,24 @@ void _showLinkMenu(EditorState editorState, BuildContext context) { Overlay.of(context)?.insert(_linkMenuOverlay!); editorState.service.scrollService?.disable(); - editorState.service.keyboardService?.disable(); editorState.service.selectionService.currentSelection .addListener(_dismissLinkMenu); } void _dismissLinkMenu() { + // workaround: SelectionService has been released after hot reload. + final isSelectionDisposed = + _editorState?.service.selectionServiceKey.currentState == null; + if (isSelectionDisposed) { + return; + } + if (_editorState?.service.selectionService.currentSelection.value == null) { + return; + } + if (_changeSelectionInner) { + _changeSelectionInner = false; + return; + } _linkMenuOverlay?.remove(); _linkMenuOverlay = null; diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/editor_service.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/editor_service.dart index 2781471b46..3a8d75560b 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/editor_service.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/editor_service.dart @@ -1,5 +1,6 @@ import 'package:appflowy_editor/src/render/image/image_node_builder.dart'; import 'package:appflowy_editor/src/render/selection_menu/selection_menu_widget.dart'; +import 'package:appflowy_editor/src/render/style/editor_style.dart'; import 'package:appflowy_editor/src/service/internal_key_event_handlers/default_key_event_handlers.dart'; import 'package:flutter/material.dart'; @@ -36,6 +37,7 @@ class AppFlowyEditor extends StatefulWidget { this.customBuilders = const {}, this.keyEventHandlers = const [], this.selectionMenuItems = const [], + this.editorStyle = const EditorStyle.defaultStyle(), }) : super(key: key); final EditorState editorState; @@ -48,6 +50,8 @@ class AppFlowyEditor extends StatefulWidget { final List selectionMenuItems; + final EditorStyle editorStyle; + @override State createState() => _AppFlowyEditorState(); } @@ -60,6 +64,7 @@ class _AppFlowyEditorState extends State { super.initState(); editorState.selectionMenuItems = widget.selectionMenuItems; + editorState.editorStyle = widget.editorStyle; editorState.service.renderPluginService = _createRenderPlugin(); } @@ -68,6 +73,8 @@ class _AppFlowyEditorState extends State { super.didUpdateWidget(oldWidget); if (editorState.service != oldWidget.editorState.service) { + editorState.selectionMenuItems = widget.selectionMenuItems; + editorState.editorStyle = widget.editorStyle; editorState.service.renderPluginService = _createRenderPlugin(); } } @@ -76,27 +83,31 @@ class _AppFlowyEditorState extends State { Widget build(BuildContext context) { return AppFlowyScroll( key: editorState.service.scrollServiceKey, - child: AppFlowySelection( - key: editorState.service.selectionServiceKey, - editorState: editorState, - child: AppFlowyInput( - key: editorState.service.inputServiceKey, + child: Padding( + padding: widget.editorStyle.padding, + child: AppFlowySelection( + key: editorState.service.selectionServiceKey, editorState: editorState, - child: AppFlowyKeyboard( - key: editorState.service.keyboardServiceKey, - handlers: [ - ...defaultKeyEventHandlers, - ...widget.keyEventHandlers, - ], + child: AppFlowyInput( + key: editorState.service.inputServiceKey, editorState: editorState, - child: FlowyToolbar( - key: editorState.service.toolbarServiceKey, + child: AppFlowyKeyboard( + key: editorState.service.keyboardServiceKey, + handlers: [ + ...defaultKeyEventHandlers, + ...widget.keyEventHandlers, + ], editorState: editorState, - child: editorState.service.renderPluginService.buildPluginWidget( - NodeWidgetContext( - context: context, - node: editorState.document.root, - editorState: editorState, + child: FlowyToolbar( + key: editorState.service.toolbarServiceKey, + editorState: editorState, + child: + editorState.service.renderPluginService.buildPluginWidget( + NodeWidgetContext( + context: context, + node: editorState.document.root, + editorState: editorState, + ), ), ), ), diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/input_service.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/input_service.dart index 96f0777544..a92fae1b95 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/input_service.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/input_service.dart @@ -1,5 +1,4 @@ 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/services.dart'; @@ -150,9 +149,6 @@ class _AppFlowyInputState extends State textNode, delta.insertionOffset, delta.textInserted, - removedAttributes: { - StyleKey.href: null, - }, ) ..commit(); } else { diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/delete_text_handler.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/backspace_handler.dart similarity index 90% rename from frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/delete_text_handler.dart rename to frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/backspace_handler.dart index d2a3d51e64..0eeaf654de 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/delete_text_handler.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/backspace_handler.dart @@ -11,10 +11,16 @@ KeyEventResult _handleBackspace(EditorState editorState, RawKeyEvent event) { var nodes = editorState.service.selectionService.currentSelectedNodes; nodes = selection.isBackward ? nodes : nodes.reversed.toList(growable: false); selection = selection.isBackward ? selection : selection.reversed; - // make sure all nodes is [TextNode]. final textNodes = nodes.whereType().toList(); + final nonTextNodes = + nodes.where((node) => node is! TextNode).toList(growable: false); final transactionBuilder = TransactionBuilder(editorState); + + if (nonTextNodes.isNotEmpty) { + transactionBuilder.deleteNodes(nonTextNodes); + } + if (textNodes.length == 1) { final textNode = textNodes.first; final index = textNode.delta.prevRunePosition(selection.start.offset); @@ -68,10 +74,15 @@ KeyEventResult _handleBackspace(EditorState editorState, RawKeyEvent event) { } } } else { - _deleteNodes(transactionBuilder, textNodes, selection); + if (textNodes.isNotEmpty) { + _deleteTextNodes(transactionBuilder, textNodes, selection); + } } if (transactionBuilder.operations.isNotEmpty) { + if (nonTextNodes.isNotEmpty) { + transactionBuilder.afterSelection = Selection.collapsed(selection.start); + } transactionBuilder.commit(); } @@ -121,7 +132,7 @@ KeyEventResult _handleDelete(EditorState editorState, RawKeyEvent event) { } } } else { - _deleteNodes(transactionBuilder, textNodes, selection); + _deleteTextNodes(transactionBuilder, textNodes, selection); } transactionBuilder.commit(); @@ -129,7 +140,7 @@ KeyEventResult _handleDelete(EditorState editorState, RawKeyEvent event) { return KeyEventResult.handled; } -void _deleteNodes(TransactionBuilder transactionBuilder, +void _deleteTextNodes(TransactionBuilder transactionBuilder, List textNodes, Selection selection) { final first = textNodes.first; final last = textNodes.last; diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/default_key_event_handlers.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/default_key_event_handlers.dart index 468eda4e98..a8cbdee3ab 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/default_key_event_handlers.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/default_key_event_handlers.dart @@ -1,6 +1,6 @@ import 'package:appflowy_editor/src/service/internal_key_event_handlers/arrow_keys_handler.dart'; import 'package:appflowy_editor/src/service/internal_key_event_handlers/copy_paste_handler.dart'; -import 'package:appflowy_editor/src/service/internal_key_event_handlers/delete_text_handler.dart'; +import 'package:appflowy_editor/src/service/internal_key_event_handlers/backspace_handler.dart'; import 'package:appflowy_editor/src/service/internal_key_event_handlers/enter_without_shift_in_text_node_handler.dart'; import 'package:appflowy_editor/src/service/internal_key_event_handlers/redo_undo_handler.dart'; import 'package:appflowy_editor/src/service/internal_key_event_handlers/slash_handler.dart'; diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/keyboard_service.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/keyboard_service.dart index 1867574993..dee3f42725 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/keyboard_service.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/keyboard_service.dart @@ -129,6 +129,10 @@ class _AppFlowyKeyboardState extends State void _onFocusChange(bool value) { Log.keyboard.debug('on keyboard event focus change $value'); + isFocus = value; + if (!value) { + widget.editorState.service.selectionService.clearCursor(); + } } KeyEventResult _onKey(FocusNode node, RawKeyEvent event) { diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/selection_service.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/selection_service.dart index c5e351059c..6f6897596f 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/selection_service.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/selection_service.dart @@ -57,6 +57,9 @@ abstract class AppFlowySelectionService { /// Clears the selection area, cursor area and the popup list area. void clearSelection(); + /// Clears the cursor area. + void clearCursor(); + /// Returns the [Node]s in [Selection]. List getNodesInSelection(Selection selection); @@ -205,16 +208,23 @@ class _AppFlowySelectionState extends State currentSelectedNodes = []; currentSelection.value = null; + clearCursor(); // clear selection areas _selectionAreas ..forEach((overlay) => overlay.remove()) ..clear(); // clear cursor areas + + // hide toolbar + editorState.service.toolbarService?.hide(); + } + + @override + void clearCursor() { + // clear cursor areas _cursorAreas ..forEach((overlay) => overlay.remove()) ..clear(); - // hide toolbar - editorState.service.toolbarService?.hide(); } @override 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 e26a186387..8dba7dcb8e 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 @@ -90,7 +90,7 @@ class _FlowyToolbarState extends State .where((item) => item.validator(widget.editorState)) .toList(growable: false) ..sort((a, b) => a.type.compareTo(b.type)); - if (items.isEmpty) { + if (filterItems.isEmpty) { return []; } final List dividedItems = [filterItems.first]; diff --git a/frontend/app_flowy/packages/appflowy_editor/test/infra/test_editor.dart b/frontend/app_flowy/packages/appflowy_editor/test/infra/test_editor.dart index a815d91875..e3a5a7d0c5 100644 --- a/frontend/app_flowy/packages/appflowy_editor/test/infra/test_editor.dart +++ b/frontend/app_flowy/packages/appflowy_editor/test/infra/test_editor.dart @@ -80,7 +80,7 @@ class EditorWidgetTester { } else { _editorState.service.selectionService.updateSelection(selection); } - await tester.pumpAndSettle(); + await tester.pump(const Duration(milliseconds: 200)); expect(_editorState.service.selectionService.currentSelection.value, selection); diff --git a/frontend/app_flowy/packages/appflowy_editor/test/render/image/image_node_builder_test.dart b/frontend/app_flowy/packages/appflowy_editor/test/render/image/image_node_builder_test.dart index 9121fa1868..a9732d8a20 100644 --- a/frontend/app_flowy/packages/appflowy_editor/test/render/image/image_node_builder_test.dart +++ b/frontend/app_flowy/packages/appflowy_editor/test/render/image/image_node_builder_test.dart @@ -49,9 +49,10 @@ void main() async { final editorRect = tester.getRect(editorFinder); final leftImageRect = tester.getRect(imageFinder.at(0)); - expect(leftImageRect.left, editorRect.left); + expect(leftImageRect.left, editor.editorState.editorStyle.padding.left); final rightImageRect = tester.getRect(imageFinder.at(2)); - expect(rightImageRect.right, editorRect.right); + expect(rightImageRect.right, + editorRect.right - editor.editorState.editorStyle.padding.right); final centerImageRect = tester.getRect(imageFinder.at(1)); expect(centerImageRect.left, (leftImageRect.left + rightImageRect.left) / 2.0); @@ -73,8 +74,8 @@ void main() async { leftImage.onAlign(Alignment.centerRight); await tester.pump(const Duration(milliseconds: 100)); expect( - tester.getRect(imageFinder.at(0)).left, - rightImageRect.left, + tester.getRect(imageFinder.at(0)).right, + rightImageRect.right, ); }); }); diff --git a/frontend/app_flowy/packages/appflowy_editor/test/render/image/image_node_widget_test.dart b/frontend/app_flowy/packages/appflowy_editor/test/render/image/image_node_widget_test.dart index d2f774d33f..a566b7ec07 100644 --- a/frontend/app_flowy/packages/appflowy_editor/test/render/image/image_node_widget_test.dart +++ b/frontend/app_flowy/packages/appflowy_editor/test/render/image/image_node_widget_test.dart @@ -1,3 +1,6 @@ +import 'dart:collection'; + +import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor/src/render/image/image_node_widget.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; @@ -20,6 +23,14 @@ void main() async { final widget = ImageNodeWidget( src: src, + node: Node( + type: 'image', + children: LinkedList(), + attributes: { + 'image_src': src, + 'align': 'center', + }, + ), alignment: Alignment.center, onCopy: () { onCopyHit = true; diff --git a/frontend/app_flowy/packages/appflowy_editor/test/render/link_menu/link_menu_test.dart b/frontend/app_flowy/packages/appflowy_editor/test/render/link_menu/link_menu_test.dart index 7b4541033b..cef16a1cec 100644 --- a/frontend/app_flowy/packages/appflowy_editor/test/render/link_menu/link_menu_test.dart +++ b/frontend/app_flowy/packages/appflowy_editor/test/render/link_menu/link_menu_test.dart @@ -12,8 +12,10 @@ void main() async { const link = 'appflowy.io'; var submittedText = ''; final linkMenu = LinkMenu( + onOpenLink: () {}, onCopyLink: () {}, onRemoveLink: () {}, + onFocusChange: (value) {}, onSubmitted: (text) { submittedText = text; }, diff --git a/frontend/app_flowy/packages/appflowy_editor/test/render/rich_text/checkbox_text_test.dart b/frontend/app_flowy/packages/appflowy_editor/test/render/rich_text/checkbox_text_test.dart index f039c227d9..afd89ddee9 100644 --- a/frontend/app_flowy/packages/appflowy_editor/test/render/rich_text/checkbox_text_test.dart +++ b/frontend/app_flowy/packages/appflowy_editor/test/render/rich_text/checkbox_text_test.dart @@ -10,8 +10,8 @@ void main() async { TestWidgetsFlutterBinding.ensureInitialized(); }); - group('delete_text_handler.dart', () { - testWidgets('Presses backspace key in empty document', (tester) async { + group('checkbox_text_handler.dart', () { + testWidgets('Click checkbox icon', (tester) async { // Before // // [BIUS]Welcome to Appflowy 😁[BIUS] diff --git a/frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/delete_text_handler_test.dart b/frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/backspace_handler_test.dart similarity index 73% rename from frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/delete_text_handler_test.dart rename to frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/backspace_handler_test.dart index 1e7bf4e842..1976ec3250 100644 --- a/frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/delete_text_handler_test.dart +++ b/frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/backspace_handler_test.dart @@ -1,7 +1,9 @@ import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_editor/src/render/image/image_node_widget.dart'; import 'package:appflowy_editor/src/render/rich_text/rich_text_style.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:network_image_mock/network_image_mock.dart'; import '../../infra/test_editor.dart'; void main() async { @@ -9,7 +11,7 @@ void main() async { TestWidgetsFlutterBinding.ensureInitialized(); }); - group('delete_text_handler.dart', () { + group('backspace_handler.dart', () { testWidgets('Presses backspace key in empty document', (tester) async { // Before // @@ -167,6 +169,129 @@ void main() async { testWidgets('Presses delete key in styled text (quote)', (tester) async { await _deleteStyledTextByDelete(tester, StyleKey.quote); }); + + // Before + // + // Welcome to Appflowy 😁 + // Welcome to Appflowy 😁 + // [Image] + // Welcome to Appflowy 😁 + // Welcome to Appflowy 😁 + // + // After + // + // Welcome to Appflowy 😁 + // Welcome to Appflowy 😁 + // + testWidgets('Deletes the image surrounded by text', (tester) async { + mockNetworkImagesFor(() async { + const text = 'Welcome to Appflowy 😁'; + const src = 'https://s1.ax1x.com/2022/08/26/v2sSbR.jpg'; + final editor = tester.editor + ..insertTextNode(text) + ..insertTextNode(text) + ..insertImageNode(src) + ..insertTextNode(text) + ..insertTextNode(text); + await editor.startTesting(); + + expect(editor.documentLength, 5); + expect(find.byType(ImageNodeWidget), findsOneWidget); + + await editor.updateSelection( + Selection( + start: Position(path: [1], offset: 0), + end: Position(path: [3], offset: text.length), + ), + ); + + await editor.pressLogicKey(LogicalKeyboardKey.backspace); + expect(editor.documentLength, 3); + expect(find.byType(ImageNodeWidget), findsNothing); + expect( + editor.documentSelection, + Selection.single(path: [1], startOffset: 0), + ); + }); + }); + + testWidgets('Deletes the first image, and selection is backward', + (tester) async { + await _deleteFirstImage(tester, true); + }); + + testWidgets('Deletes the first image, and selection is not backward', + (tester) async { + await _deleteFirstImage(tester, false); + }); + + testWidgets('Deletes the last image and selection is backward', + (tester) async { + await _deleteLastImage(tester, true); + }); + + testWidgets('Deletes the last image and selection is not backward', + (tester) async { + await _deleteLastImage(tester, false); + }); +} + +Future _deleteFirstImage(WidgetTester tester, bool isBackward) async { + mockNetworkImagesFor(() async { + const text = 'Welcome to Appflowy 😁'; + const src = 'https://s1.ax1x.com/2022/08/26/v2sSbR.jpg'; + final editor = tester.editor + ..insertImageNode(src) + ..insertTextNode(text) + ..insertTextNode(text); + await editor.startTesting(); + + expect(editor.documentLength, 3); + expect(find.byType(ImageNodeWidget), findsOneWidget); + + final start = Position(path: [0], offset: 0); + final end = Position(path: [1], offset: 1); + await editor.updateSelection( + Selection( + start: isBackward ? start : end, + end: isBackward ? end : start, + ), + ); + + await editor.pressLogicKey(LogicalKeyboardKey.backspace); + expect(editor.documentLength, 2); + expect(find.byType(ImageNodeWidget), findsNothing); + expect(editor.documentSelection, Selection.collapsed(start)); + }); +} + +Future _deleteLastImage(WidgetTester tester, bool isBackward) async { + mockNetworkImagesFor(() async { + const text = 'Welcome to Appflowy 😁'; + const src = 'https://s1.ax1x.com/2022/08/26/v2sSbR.jpg'; + final editor = tester.editor + ..insertTextNode(text) + ..insertTextNode(text) + ..insertImageNode(src); + await editor.startTesting(); + + expect(editor.documentLength, 3); + expect(find.byType(ImageNodeWidget), findsOneWidget); + + final start = Position(path: [1], offset: 0); + final end = Position(path: [2], offset: 1); + await editor.updateSelection( + Selection( + start: isBackward ? start : end, + end: isBackward ? end : start, + ), + ); + + await editor.pressLogicKey(LogicalKeyboardKey.backspace); + expect(editor.documentLength, 2); + expect(find.byType(ImageNodeWidget), findsNothing); + expect(editor.documentSelection, Selection.collapsed(start)); + }); } Future _deleteStyledTextByBackspace( diff --git a/frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/enter_without_shift_in_text_node_handler_test.dart b/frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/enter_without_shift_in_text_node_handler_test.dart index ee21dfa455..5bfe1ada67 100644 --- a/frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/enter_without_shift_in_text_node_handler_test.dart +++ b/frontend/app_flowy/packages/appflowy_editor/test/service/internal_key_event_handlers/enter_without_shift_in_text_node_handler_test.dart @@ -116,6 +116,27 @@ void main() async { (tester) async { _testMultipleSelection(tester, false); }); + + testWidgets('Presses enter key in the first line', (tester) async { + // Before + // + // Welcome to Appflowy 😁 + // + // After + // + // [Empty Line] + // Welcome to Appflowy 😁 + // + const text = 'Welcome to Appflowy 😁'; + final editor = tester.editor..insertTextNode(text); + await editor.startTesting(); + await editor.updateSelection( + Selection.single(path: [0], startOffset: 0), + ); + await editor.pressLogicKey(LogicalKeyboardKey.enter); + expect(editor.documentLength, 2); + expect((editor.nodeAtPath([1]) as TextNode).toRawString(), text); + }); }); }