Merge pull request #926 from LucasXu0/test/image

#918
This commit is contained in:
Lucas.Xu 2022-08-29 19:29:55 +08:00 committed by GitHub
commit a7f8c99710
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
35 changed files with 579 additions and 240 deletions

View File

@ -0,0 +1,4 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M11.974 6.33301H7.35865C6.7922 6.33301 6.33301 6.7922 6.33301 7.35865V11.974C6.33301 12.5405 6.7922 12.9997 7.35865 12.9997H11.974C12.5405 12.9997 12.9997 12.5405 12.9997 11.974V7.35865C12.9997 6.7922 12.5405 6.33301 11.974 6.33301Z" stroke="white" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M4.53846 9.66667H4.02564C3.75362 9.66667 3.49275 9.55861 3.3004 9.36626C3.10806 9.17392 3 8.91304 3 8.64103V4.02564C3 3.75362 3.10806 3.49275 3.3004 3.3004C3.49275 3.10806 3.75362 3 4.02564 3H8.64103C8.91304 3 9.17392 3.10806 9.36626 3.3004C9.55861 3.49275 9.66667 3.75362 9.66667 4.02564V4.53846" stroke="white" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 781 B

View File

@ -45,10 +45,8 @@ class _MyHomePageState extends State<MyHomePage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
body: Container( extendBodyBehindAppBar: true,
alignment: Alignment.topCenter, body: _buildEditor(context),
child: _buildEditor(context),
),
floatingActionButton: _buildExpandableFab(), floatingActionButton: _buildExpandableFab(),
); );
} }
@ -92,10 +90,11 @@ class _MyHomePageState extends State<MyHomePage> {
..handler = (message) { ..handler = (message) {
debugPrint(message); debugPrint(message);
}; };
return Container( return SizedBox(
padding: const EdgeInsets.all(20), width: MediaQuery.of(context).size.width,
child: AppFlowyEditor( child: AppFlowyEditor(
editorState: _editorState, editorState: _editorState,
editorStyle: const EditorStyle.defaultStyle(),
), ),
); );
} else { } else {

View File

@ -2,6 +2,7 @@
library appflowy_editor; library appflowy_editor;
export 'src/infra/log.dart'; export 'src/infra/log.dart';
export 'src/render/style/editor_style.dart';
export 'src/document/node.dart'; export 'src/document/node.dart';
export 'src/document/path.dart'; export 'src/document/path.dart';
export 'src/document/position.dart'; export 'src/document/position.dart';

View File

@ -62,10 +62,17 @@ class StateTree {
} }
return false; return false;
} }
for (var i = 0; i < nodes.length; i++) { if (path.last <= 0) {
final node = nodes[i]; for (var i = 0; i < nodes.length; i++) {
insertedNode!.insertAfter(node); final node = nodes[i];
insertedNode = node; insertedNode.insertBefore(node);
}
} else {
for (var i = 0; i < nodes.length; i++) {
final node = nodes[i];
insertedNode!.insertAfter(node);
insertedNode = node;
}
} }
return true; return true;
} }

View File

@ -1,6 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'package:appflowy_editor/src/infra/log.dart'; 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/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:appflowy_editor/src/service/service.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -58,6 +59,9 @@ class EditorState {
/// Stores the selection menu items. /// Stores the selection menu items.
List<SelectionMenuItem> selectionMenuItems = []; List<SelectionMenuItem> selectionMenuItems = [];
/// Stores the editor style.
EditorStyle editorStyle = const EditorStyle.defaultStyle();
final UndoManager undoManager = UndoManager(); final UndoManager undoManager = UndoManager();
Selection? _cursorSelection; Selection? _cursorSelection;

View File

@ -0,0 +1,14 @@
import 'package:url_launcher/url_launcher_string.dart';
Future<bool> safeLaunchUrl(String? href) async {
if (href == null) {
return Future.value(false);
}
final uri = Uri.parse(href);
// url_launcher cannot open a link without scheme.
final newHref = (uri.scheme.isNotEmpty ? href : 'http://$href').trim();
if (await canLaunchUrlString(newHref)) {
await launchUrlString(newHref);
}
return Future.value(true);
}

View File

@ -115,17 +115,18 @@ class TransactionBuilder {
/// Inserts content at a specified index. /// Inserts content at a specified index.
/// Optionally, you may specify formatting attributes that are applied to the inserted string. /// Optionally, you may specify formatting attributes that are applied to the inserted string.
/// By default, the formatting attributes before the insert position will be used. /// By default, the formatting attributes before the insert position will be used.
insertText(TextNode node, int index, String content, insertText(
{Attributes? attributes, Attributes? removedAttributes}) { TextNode node,
int index,
String content, {
Attributes? attributes,
}) {
var newAttributes = attributes; var newAttributes = attributes;
if (index != 0 && attributes == null) { if (index != 0 && attributes == null) {
newAttributes = newAttributes =
node.delta.slice(max(index - 1, 0), index).first.attributes; node.delta.slice(max(index - 1, 0), index).first.attributes;
if (newAttributes != null) { if (newAttributes != null) {
newAttributes = Attributes.from(newAttributes); newAttributes = Attributes.from(newAttributes);
if (removedAttributes != null) {
newAttributes.addAll(removedAttributes);
}
} }
} }
textEdit( textEdit(
@ -138,7 +139,8 @@ class TransactionBuilder {
), ),
); );
afterSelection = Selection.collapsed( afterSelection = Selection.collapsed(
Position(path: node.path, offset: index + content.length)); Position(path: node.path, offset: index + content.length),
);
} }
/// Assigns formatting attributes to a range of text. /// Assigns formatting attributes to a range of text.

View File

@ -33,7 +33,7 @@ class EditorNodeWidget extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.start,
children: node.children children: node.children
.map( .map(
(child) => (child) =>

View File

@ -17,6 +17,7 @@ class ImageNodeBuilder extends NodeWidgetBuilder<Node> {
} }
return ImageNodeWidget( return ImageNodeWidget(
key: context.node.key, key: context.node.key,
node: context.node,
src: src, src: src,
width: width, width: width,
alignment: _textToAlignment(align), alignment: _textToAlignment(align),

View File

@ -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/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'; import 'package:flutter/material.dart';
class ImageNodeWidget extends StatefulWidget { class ImageNodeWidget extends StatefulWidget {
const ImageNodeWidget({ const ImageNodeWidget({
Key? key, Key? key,
required this.node,
required this.src, required this.src,
this.width, this.width,
required this.alignment, required this.alignment,
@ -14,6 +19,7 @@ class ImageNodeWidget extends StatefulWidget {
required this.onResize, required this.onResize,
}) : super(key: key); }) : super(key: key);
final Node node;
final String src; final String src;
final double? width; final double? width;
final Alignment alignment; final Alignment alignment;
@ -26,7 +32,9 @@ class ImageNodeWidget extends StatefulWidget {
State<ImageNodeWidget> createState() => _ImageNodeWidgetState(); State<ImageNodeWidget> createState() => _ImageNodeWidgetState();
} }
class _ImageNodeWidgetState extends State<ImageNodeWidget> { class _ImageNodeWidgetState extends State<ImageNodeWidget> with Selectable {
final _imageKey = GlobalKey();
double? _imageWidth; double? _imageWidth;
double _initial = 0; double _initial = 0;
double _distance = 0; double _distance = 0;
@ -42,7 +50,11 @@ class _ImageNodeWidgetState extends State<ImageNodeWidget> {
_imageWidth = widget.width; _imageWidth = widget.width;
_imageStreamListener = ImageStreamListener( _imageStreamListener = ImageStreamListener(
(image, _) { (image, _) {
_imageWidth = image.image.width.toDouble(); _imageWidth = _imageKey.currentContext
?.findRenderObject()
?.unwrapOrNull<RenderBox>()
?.size
.width;
}, },
); );
} }
@ -56,14 +68,54 @@ class _ImageNodeWidgetState extends State<ImageNodeWidget> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// only support network image. // only support network image.
return Container( return Container(
width: defaultMaxTextNodeWidth, key: _imageKey,
padding: const EdgeInsets.only(top: 8, bottom: 8), padding: const EdgeInsets.only(top: 8, bottom: 8),
child: _buildNetworkImage(context), 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<Rect> 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) { Widget _buildNetworkImage(BuildContext context) {
return Align( return Align(
alignment: widget.alignment, alignment: widget.alignment,
@ -87,7 +139,7 @@ class _ImageNodeWidgetState extends State<ImageNodeWidget> {
loadingBuilder: (context, child, loadingProgress) => loadingBuilder: (context, child, loadingProgress) =>
loadingProgress == null ? child : _buildLoading(context), loadingProgress == null ? child : _buildLoading(context),
errorBuilder: (context, error, stackTrace) { errorBuilder: (context, error, stackTrace) {
_imageWidth ??= defaultMaxTextNodeWidth; // _imageWidth ??= defaultMaxTextNodeWidth;
return _buildError(context); return _buildError(context);
}, },
); );

View File

@ -6,14 +6,18 @@ class LinkMenu extends StatefulWidget {
Key? key, Key? key,
this.linkText, this.linkText,
required this.onSubmitted, required this.onSubmitted,
required this.onOpenLink,
required this.onCopyLink, required this.onCopyLink,
required this.onRemoveLink, required this.onRemoveLink,
required this.onFocusChange,
}) : super(key: key); }) : super(key: key);
final String? linkText; final String? linkText;
final void Function(String text) onSubmitted; final void Function(String text) onSubmitted;
final VoidCallback onOpenLink;
final VoidCallback onCopyLink; final VoidCallback onCopyLink;
final VoidCallback onRemoveLink; final VoidCallback onRemoveLink;
final void Function(bool value) onFocusChange;
@override @override
State<LinkMenu> createState() => _LinkMenuState(); State<LinkMenu> createState() => _LinkMenuState();
@ -26,15 +30,14 @@ class _LinkMenuState extends State<LinkMenu> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_textEditingController.text = widget.linkText ?? ''; _textEditingController.text = widget.linkText ?? '';
_focusNode.requestFocus(); _focusNode.addListener(_onFocusChange);
} }
@override @override
void dispose() { void dispose() {
_focusNode.dispose(); _textEditingController.dispose();
_focusNode.removeListener(_onFocusChange);
super.dispose(); super.dispose();
} }
@ -67,6 +70,12 @@ class _LinkMenuState extends State<LinkMenu> {
if (widget.linkText != null) ...[ if (widget.linkText != null) ...[
_buildIconButton( _buildIconButton(
iconName: 'link', iconName: 'link',
text: 'Open link',
onPressed: widget.onOpenLink,
),
_buildIconButton(
iconName: 'copy',
color: Colors.black,
text: 'Copy link', text: 'Copy link',
onPressed: widget.onCopyLink, onPressed: widget.onCopyLink,
), ),
@ -126,11 +135,15 @@ class _LinkMenuState extends State<LinkMenu> {
Widget _buildIconButton({ Widget _buildIconButton({
required String iconName, required String iconName,
Color? color,
required String text, required String text,
required VoidCallback onPressed, required VoidCallback onPressed,
}) { }) {
return TextButton.icon( return TextButton.icon(
icon: FlowySvg(name: iconName), icon: FlowySvg(
name: iconName,
color: color,
),
style: TextButton.styleFrom( style: TextButton.styleFrom(
minimumSize: const Size.fromHeight(40), minimumSize: const Size.fromHeight(40),
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
@ -148,4 +161,8 @@ class _LinkMenuState extends State<LinkMenu> {
onPressed: onPressed, onPressed: onPressed,
); );
} }
void _onFocusChange() {
widget.onFocusChange(_focusNode.hasFocus);
}
} }

View File

@ -56,30 +56,27 @@ class _BulletedListTextNodeWidgetState extends State<BulletedListTextNodeWidget>
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return SizedBox( return Padding(
width: defaultMaxTextNodeWidth, padding: EdgeInsets.only(bottom: defaultLinePadding),
child: Padding( child: Row(
padding: EdgeInsets.only(bottom: defaultLinePadding), crossAxisAlignment: CrossAxisAlignment.start,
child: Row( children: [
crossAxisAlignment: CrossAxisAlignment.start, FlowySvg(
children: [ key: iconKey,
FlowySvg( width: _iconWidth,
key: iconKey, height: _iconWidth,
width: _iconWidth, padding: EdgeInsets.only(right: _iconRightPadding),
height: _iconWidth, name: 'point',
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,
),
),
],
),
), ),
); );
} }

View File

@ -63,41 +63,38 @@ class _CheckboxNodeWidgetState extends State<CheckboxNodeWidget>
Widget _buildWithSingle(BuildContext context) { Widget _buildWithSingle(BuildContext context) {
final check = widget.textNode.attributes.check; final check = widget.textNode.attributes.check;
return SizedBox( return Padding(
width: defaultMaxTextNodeWidth, padding: EdgeInsets.only(bottom: defaultLinePadding),
child: Padding( child: Row(
padding: EdgeInsets.only(bottom: defaultLinePadding), crossAxisAlignment: CrossAxisAlignment.start,
child: Row( children: [
crossAxisAlignment: CrossAxisAlignment.start, GestureDetector(
children: [ key: iconKey,
GestureDetector( child: FlowySvg(
key: iconKey, width: _iconWidth,
child: FlowySvg( height: _iconWidth,
width: _iconWidth, padding: EdgeInsets.only(right: _iconRightPadding),
height: _iconWidth, name: check ? 'check' : 'uncheck',
padding: EdgeInsets.only(right: _iconRightPadding),
name: check ? 'check' : 'uncheck',
),
onTap: () {
TransactionBuilder(widget.editorState)
..updateNode(widget.textNode, {
StyleKey.checkbox: !check,
})
..commit();
},
), ),
Expanded( onTap: () {
child: FlowyRichText( TransactionBuilder(widget.editorState)
key: _richTextKey, ..updateNode(widget.textNode, {
placeholderText: 'To-do', StyleKey.checkbox: !check,
textNode: widget.textNode, })
textSpanDecorator: _textSpanDecorator, ..commit();
placeholderTextSpanDecorator: _textSpanDecorator, },
editorState: widget.editorState, ),
), Flexible(
child: FlowyRichText(
key: _richTextKey,
placeholderText: 'To-do',
textNode: widget.textNode,
textSpanDecorator: _textSpanDecorator,
placeholderTextSpanDecorator: _textSpanDecorator,
editorState: widget.editorState,
), ),
], ),
), ],
), ),
); );
} }

View File

@ -1,6 +1,8 @@
import 'dart:async'; import 'dart:async';
import 'dart:ui'; import 'dart:ui';
import 'package:appflowy_editor/src/extensions/url_launcher_extension.dart';
import 'package:appflowy_editor/src/render/toolbar/toolbar_item.dart';
import 'package:flutter/gestures.dart'; import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart'; import 'package:flutter/rendering.dart';
@ -13,7 +15,6 @@ import 'package:appflowy_editor/src/document/text_delta.dart';
import 'package:appflowy_editor/src/editor_state.dart'; import 'package:appflowy_editor/src/editor_state.dart';
import 'package:appflowy_editor/src/render/rich_text/rich_text_style.dart'; import 'package:appflowy_editor/src/render/rich_text/rich_text_style.dart';
import 'package:appflowy_editor/src/render/selection/selectable.dart'; import 'package:appflowy_editor/src/render/selection/selectable.dart';
import 'package:url_launcher/url_launcher_string.dart';
typedef FlowyTextSpanDecorator = TextSpan Function(TextSpan textSpan); typedef FlowyTextSpanDecorator = TextSpan Function(TextSpan textSpan);
@ -42,7 +43,7 @@ class FlowyRichText extends StatefulWidget {
} }
class _FlowyRichTextState extends State<FlowyRichText> with Selectable { class _FlowyRichTextState extends State<FlowyRichText> with Selectable {
final _textKey = GlobalKey(); var _textKey = GlobalKey();
final _placeholderTextKey = GlobalKey(); final _placeholderTextKey = GlobalKey();
final _lineHeight = 1.5; final _lineHeight = 1.5;
@ -53,6 +54,17 @@ class _FlowyRichTextState extends State<FlowyRichText> with Selectable {
RenderParagraph get _placeholderRenderParagraph => RenderParagraph get _placeholderRenderParagraph =>
_placeholderTextKey.currentContext?.findRenderObject() as RenderParagraph; _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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return _buildRichText(context); return _buildRichText(context);
@ -182,7 +194,9 @@ class _FlowyRichTextState extends State<FlowyRichText> with Selectable {
return RichText( return RichText(
key: _textKey, key: _textKey,
textHeightBehavior: const TextHeightBehavior( textHeightBehavior: const TextHeightBehavior(
applyHeightToFirstAscent: false, applyHeightToLastDescent: false), applyHeightToFirstAscent: false,
applyHeightToLastDescent: false,
),
text: widget.textSpanDecorator != null text: widget.textSpanDecorator != null
? widget.textSpanDecorator!(textSpan) ? widget.textSpanDecorator!(textSpan)
: textSpan, : textSpan,
@ -193,53 +207,23 @@ class _FlowyRichTextState extends State<FlowyRichText> with Selectable {
var offset = 0; var offset = 0;
return TextSpan( return TextSpan(
children: widget.textNode.delta.whereType<TextInsert>().map((insert) { children: widget.textNode.delta.whereType<TextInsert>().map((insert) {
GestureRecognizer? gestureDetector; GestureRecognizer? gestureRecognizer;
if (insert.attributes?[StyleKey.href] != null) { if (insert.attributes?[StyleKey.href] != null) {
final startOffset = offset; gestureRecognizer = _buildTapHrefGestureRecognizer(
Timer? timer; insert.attributes![StyleKey.href],
var tapCount = 0; Selection.single(
gestureDetector = TapGestureRecognizer() path: widget.textNode.path,
..onTap = () async { startOffset: offset,
// implement a simple double tap logic endOffset: offset + insert.length,
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');
});
});
};
} }
offset += insert.length; offset += insert.length;
final textSpan = RichTextStyle( final textSpan = RichTextStyle(
attributes: insert.attributes ?? {}, attributes: insert.attributes ?? {},
text: insert.content, text: insert.content,
height: _lineHeight, height: _lineHeight,
gestureRecognizer: gestureDetector, gestureRecognizer: gestureRecognizer,
).toTextSpan(); ).toTextSpan();
return textSpan; return textSpan;
}).toList(growable: false), }).toList(growable: false),
@ -255,4 +239,34 @@ class _FlowyRichTextState extends State<FlowyRichText> with Selectable {
height: _lineHeight, height: _lineHeight,
).toTextSpan() ).toTextSpan()
]); ]);
GestureRecognizer _buildTapHrefGestureRecognizer(
String href, Selection selection) {
Timer? timer;
var tapCount = 0;
final tapGestureRecognizer = TapGestureRecognizer()
..onTap = () async {
// implement a simple double tap logic
tapCount += 1;
timer?.cancel();
if (tapCount == 2) {
tapCount = 0;
safeLaunchUrl(href);
return;
}
timer = Timer(const Duration(milliseconds: 200), () {
tapCount = 0;
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
showLinkMenu(
context,
widget.editorState,
customSelection: selection,
);
});
});
};
return tapGestureRecognizer;
}
} }

View File

@ -63,16 +63,13 @@ class _HeadingTextNodeWidgetState extends State<HeadingTextNodeWidget>
top: _topPadding, top: _topPadding,
bottom: defaultLinePadding, bottom: defaultLinePadding,
), ),
child: SizedBox( child: FlowyRichText(
width: defaultMaxTextNodeWidth, key: _richTextKey,
child: FlowyRichText( placeholderText: 'Heading',
key: _richTextKey, placeholderTextSpanDecorator: _placeholderTextSpanDecorator,
placeholderText: 'Heading', textSpanDecorator: _textSpanDecorator,
placeholderTextSpanDecorator: _placeholderTextSpanDecorator, textNode: widget.textNode,
textSpanDecorator: _textSpanDecorator, editorState: widget.editorState,
textNode: widget.textNode,
editorState: widget.editorState,
),
), ),
); );
} }

View File

@ -58,28 +58,25 @@ class _NumberListTextNodeWidgetState extends State<NumberListTextNodeWidget>
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Padding( return Padding(
padding: EdgeInsets.only(bottom: defaultLinePadding), padding: EdgeInsets.only(bottom: defaultLinePadding),
child: SizedBox( child: Row(
width: defaultMaxTextNodeWidth, crossAxisAlignment: CrossAxisAlignment.start,
child: Row( children: [
crossAxisAlignment: CrossAxisAlignment.start, FlowySvg(
children: [ key: iconKey,
FlowySvg( width: _iconWidth,
key: iconKey, height: _iconWidth,
width: _iconWidth, padding: EdgeInsets.only(right: _iconRightPadding),
height: _iconWidth, number: widget.textNode.attributes.number,
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,
),
),
],
),
)); ));
} }
} }

View File

@ -55,30 +55,27 @@ class _QuotedTextNodeWidgetState extends State<QuotedTextNodeWidget>
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return SizedBox( return Padding(
width: defaultMaxTextNodeWidth, padding: EdgeInsets.only(bottom: defaultLinePadding),
child: Padding( child: IntrinsicHeight(
padding: EdgeInsets.only(bottom: defaultLinePadding), child: Row(
child: IntrinsicHeight( crossAxisAlignment: CrossAxisAlignment.stretch,
child: Row( children: [
crossAxisAlignment: CrossAxisAlignment.stretch, FlowySvg(
children: [ key: iconKey,
FlowySvg( width: _iconWidth,
key: iconKey, padding: EdgeInsets.only(right: _iconRightPadding),
width: _iconWidth, name: 'quote',
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,
),
),
],
),
), ),
), ),
); );

View File

@ -52,15 +52,12 @@ class _RichTextNodeWidgetState extends State<RichTextNodeWidget>
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return SizedBox( return Padding(
width: defaultMaxTextNodeWidth, padding: EdgeInsets.only(bottom: defaultLinePadding),
child: Padding( child: FlowyRichText(
padding: EdgeInsets.only(bottom: defaultLinePadding), key: _richTextKey,
child: FlowyRichText( textNode: widget.textNode,
key: _richTextKey, editorState: widget.editorState,
textNode: widget.textNode,
editorState: widget.editorState,
),
), ),
); );
} }

View File

@ -61,7 +61,6 @@ class StyleKey {
} }
// TODO: customize // TODO: customize
double defaultMaxTextNodeWidth = 780.0;
double defaultLinePadding = 8.0; double defaultLinePadding = 8.0;
double baseFontSize = 16.0; double baseFontSize = 16.0;
String defaultHighlightColor = '0x6000BCF0'; String defaultHighlightColor = '0x6000BCF0';

View File

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

View File

@ -1,4 +1,5 @@
import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor/src/extensions/url_launcher_extension.dart';
import 'package:appflowy_editor/src/infra/flowy_svg.dart'; import 'package:appflowy_editor/src/infra/flowy_svg.dart';
import 'package:appflowy_editor/src/render/link_menu/link_menu.dart'; import 'package:appflowy_editor/src/render/link_menu/link_menu.dart';
import 'package:appflowy_editor/src/render/rich_text/rich_text_style.dart'; import 'package:appflowy_editor/src/render/rich_text/rich_text_style.dart';
@ -132,7 +133,7 @@ List<ToolbarItem> defaultToolbarItems = [
tooltipsMessage: 'Link', tooltipsMessage: 'Link',
icon: const FlowySvg(name: 'toolbar/link'), icon: const FlowySvg(name: 'toolbar/link'),
validator: _onlyShowInSingleTextSelection, validator: _onlyShowInSingleTextSelection,
handler: (editorState, context) => _showLinkMenu(editorState, context), handler: (editorState, context) => showLinkMenu(context, editorState),
), ),
ToolbarItem( ToolbarItem(
id: 'appflowy.toolbar.highlight', id: 'appflowy.toolbar.highlight',
@ -157,7 +158,12 @@ ToolbarShowValidator _showInTextSelection = (editorState) {
OverlayEntry? _linkMenuOverlay; OverlayEntry? _linkMenuOverlay;
EditorState? _editorState; 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; final rects = editorState.service.selectionService.selectionRects;
var maxBottom = 0.0; var maxBottom = 0.0;
late Rect matchRect; late Rect matchRect;
@ -173,16 +179,19 @@ void _showLinkMenu(EditorState editorState, BuildContext context) {
// Since the link menu will only show in single text selection, // Since the link menu will only show in single text selection,
// We get the text node directly instead of judging details again. // We get the text node directly instead of judging details again.
final selection = final selection = customSelection ??
editorState.service.selectionService.currentSelection.value!; editorState.service.selectionService.currentSelection.value;
final node = editorState.service.selectionService.currentSelectedNodes;
if (selection == null || node.isEmpty || node.first is! TextNode) {
return;
}
final index = final index =
selection.isBackward ? selection.start.offset : selection.end.offset; selection.isBackward ? selection.start.offset : selection.end.offset;
final length = (selection.start.offset - selection.end.offset).abs(); final length = (selection.start.offset - selection.end.offset).abs();
final node = editorState.service.selectionService.currentSelectedNodes.first final textNode = node.first as TextNode;
as TextNode;
String? linkText; String? linkText;
if (node.allSatisfyLinkInSelection(selection)) { if (textNode.allSatisfyLinkInSelection(selection)) {
linkText = node.getAttributeInSelection(selection, StyleKey.href); linkText = textNode.getAttributeInSelection(selection, StyleKey.href);
} }
_linkMenuOverlay = OverlayEntry(builder: (context) { _linkMenuOverlay = OverlayEntry(builder: (context) {
return Positioned( return Positioned(
@ -191,9 +200,12 @@ void _showLinkMenu(EditorState editorState, BuildContext context) {
child: Material( child: Material(
child: LinkMenu( child: LinkMenu(
linkText: linkText, linkText: linkText,
onOpenLink: () async {
await safeLaunchUrl(linkText);
},
onSubmitted: (text) { onSubmitted: (text) {
TransactionBuilder(editorState) TransactionBuilder(editorState)
..formatText(node, index, length, {StyleKey.href: text}) ..formatText(textNode, index, length, {StyleKey.href: text})
..commit(); ..commit();
_dismissLinkMenu(); _dismissLinkMenu();
}, },
@ -203,10 +215,17 @@ void _showLinkMenu(EditorState editorState, BuildContext context) {
}, },
onRemoveLink: () { onRemoveLink: () {
TransactionBuilder(editorState) TransactionBuilder(editorState)
..formatText(node, index, length, {StyleKey.href: null}) ..formatText(textNode, index, length, {StyleKey.href: null})
..commit(); ..commit();
_dismissLinkMenu(); _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!); Overlay.of(context)?.insert(_linkMenuOverlay!);
editorState.service.scrollService?.disable(); editorState.service.scrollService?.disable();
editorState.service.keyboardService?.disable();
editorState.service.selectionService.currentSelection editorState.service.selectionService.currentSelection
.addListener(_dismissLinkMenu); .addListener(_dismissLinkMenu);
} }
void _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?.remove();
_linkMenuOverlay = null; _linkMenuOverlay = null;

View File

@ -1,5 +1,6 @@
import 'package:appflowy_editor/src/render/image/image_node_builder.dart'; 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/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:appflowy_editor/src/service/internal_key_event_handlers/default_key_event_handlers.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -36,6 +37,7 @@ class AppFlowyEditor extends StatefulWidget {
this.customBuilders = const {}, this.customBuilders = const {},
this.keyEventHandlers = const [], this.keyEventHandlers = const [],
this.selectionMenuItems = const [], this.selectionMenuItems = const [],
this.editorStyle = const EditorStyle.defaultStyle(),
}) : super(key: key); }) : super(key: key);
final EditorState editorState; final EditorState editorState;
@ -48,6 +50,8 @@ class AppFlowyEditor extends StatefulWidget {
final List<SelectionMenuItem> selectionMenuItems; final List<SelectionMenuItem> selectionMenuItems;
final EditorStyle editorStyle;
@override @override
State<AppFlowyEditor> createState() => _AppFlowyEditorState(); State<AppFlowyEditor> createState() => _AppFlowyEditorState();
} }
@ -60,6 +64,7 @@ class _AppFlowyEditorState extends State<AppFlowyEditor> {
super.initState(); super.initState();
editorState.selectionMenuItems = widget.selectionMenuItems; editorState.selectionMenuItems = widget.selectionMenuItems;
editorState.editorStyle = widget.editorStyle;
editorState.service.renderPluginService = _createRenderPlugin(); editorState.service.renderPluginService = _createRenderPlugin();
} }
@ -68,6 +73,8 @@ class _AppFlowyEditorState extends State<AppFlowyEditor> {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
if (editorState.service != oldWidget.editorState.service) { if (editorState.service != oldWidget.editorState.service) {
editorState.selectionMenuItems = widget.selectionMenuItems;
editorState.editorStyle = widget.editorStyle;
editorState.service.renderPluginService = _createRenderPlugin(); editorState.service.renderPluginService = _createRenderPlugin();
} }
} }
@ -76,27 +83,31 @@ class _AppFlowyEditorState extends State<AppFlowyEditor> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return AppFlowyScroll( return AppFlowyScroll(
key: editorState.service.scrollServiceKey, key: editorState.service.scrollServiceKey,
child: AppFlowySelection( child: Padding(
key: editorState.service.selectionServiceKey, padding: widget.editorStyle.padding,
editorState: editorState, child: AppFlowySelection(
child: AppFlowyInput( key: editorState.service.selectionServiceKey,
key: editorState.service.inputServiceKey,
editorState: editorState, editorState: editorState,
child: AppFlowyKeyboard( child: AppFlowyInput(
key: editorState.service.keyboardServiceKey, key: editorState.service.inputServiceKey,
handlers: [
...defaultKeyEventHandlers,
...widget.keyEventHandlers,
],
editorState: editorState, editorState: editorState,
child: FlowyToolbar( child: AppFlowyKeyboard(
key: editorState.service.toolbarServiceKey, key: editorState.service.keyboardServiceKey,
handlers: [
...defaultKeyEventHandlers,
...widget.keyEventHandlers,
],
editorState: editorState, editorState: editorState,
child: editorState.service.renderPluginService.buildPluginWidget( child: FlowyToolbar(
NodeWidgetContext( key: editorState.service.toolbarServiceKey,
context: context, editorState: editorState,
node: editorState.document.root, child:
editorState: editorState, editorState.service.renderPluginService.buildPluginWidget(
NodeWidgetContext(
context: context,
node: editorState.document.root,
editorState: editorState,
),
), ),
), ),
), ),

View File

@ -1,5 +1,4 @@
import 'package:appflowy_editor/src/infra/log.dart'; import 'package:appflowy_editor/src/infra/log.dart';
import 'package:appflowy_editor/src/render/rich_text/rich_text_style.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
@ -150,9 +149,6 @@ class _AppFlowyInputState extends State<AppFlowyInput>
textNode, textNode,
delta.insertionOffset, delta.insertionOffset,
delta.textInserted, delta.textInserted,
removedAttributes: {
StyleKey.href: null,
},
) )
..commit(); ..commit();
} else { } else {

View File

@ -11,10 +11,16 @@ KeyEventResult _handleBackspace(EditorState editorState, RawKeyEvent event) {
var nodes = editorState.service.selectionService.currentSelectedNodes; var nodes = editorState.service.selectionService.currentSelectedNodes;
nodes = selection.isBackward ? nodes : nodes.reversed.toList(growable: false); nodes = selection.isBackward ? nodes : nodes.reversed.toList(growable: false);
selection = selection.isBackward ? selection : selection.reversed; selection = selection.isBackward ? selection : selection.reversed;
// make sure all nodes is [TextNode].
final textNodes = nodes.whereType<TextNode>().toList(); final textNodes = nodes.whereType<TextNode>().toList();
final nonTextNodes =
nodes.where((node) => node is! TextNode).toList(growable: false);
final transactionBuilder = TransactionBuilder(editorState); final transactionBuilder = TransactionBuilder(editorState);
if (nonTextNodes.isNotEmpty) {
transactionBuilder.deleteNodes(nonTextNodes);
}
if (textNodes.length == 1) { if (textNodes.length == 1) {
final textNode = textNodes.first; final textNode = textNodes.first;
final index = textNode.delta.prevRunePosition(selection.start.offset); final index = textNode.delta.prevRunePosition(selection.start.offset);
@ -68,10 +74,15 @@ KeyEventResult _handleBackspace(EditorState editorState, RawKeyEvent event) {
} }
} }
} else { } else {
_deleteNodes(transactionBuilder, textNodes, selection); if (textNodes.isNotEmpty) {
_deleteTextNodes(transactionBuilder, textNodes, selection);
}
} }
if (transactionBuilder.operations.isNotEmpty) { if (transactionBuilder.operations.isNotEmpty) {
if (nonTextNodes.isNotEmpty) {
transactionBuilder.afterSelection = Selection.collapsed(selection.start);
}
transactionBuilder.commit(); transactionBuilder.commit();
} }
@ -121,7 +132,7 @@ KeyEventResult _handleDelete(EditorState editorState, RawKeyEvent event) {
} }
} }
} else { } else {
_deleteNodes(transactionBuilder, textNodes, selection); _deleteTextNodes(transactionBuilder, textNodes, selection);
} }
transactionBuilder.commit(); transactionBuilder.commit();
@ -129,7 +140,7 @@ KeyEventResult _handleDelete(EditorState editorState, RawKeyEvent event) {
return KeyEventResult.handled; return KeyEventResult.handled;
} }
void _deleteNodes(TransactionBuilder transactionBuilder, void _deleteTextNodes(TransactionBuilder transactionBuilder,
List<TextNode> textNodes, Selection selection) { List<TextNode> textNodes, Selection selection) {
final first = textNodes.first; final first = textNodes.first;
final last = textNodes.last; final last = textNodes.last;

View File

@ -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/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/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/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/redo_undo_handler.dart';
import 'package:appflowy_editor/src/service/internal_key_event_handlers/slash_handler.dart'; import 'package:appflowy_editor/src/service/internal_key_event_handlers/slash_handler.dart';

View File

@ -129,6 +129,10 @@ class _AppFlowyKeyboardState extends State<AppFlowyKeyboard>
void _onFocusChange(bool value) { void _onFocusChange(bool value) {
Log.keyboard.debug('on keyboard event focus change $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) { KeyEventResult _onKey(FocusNode node, RawKeyEvent event) {

View File

@ -57,6 +57,9 @@ abstract class AppFlowySelectionService {
/// Clears the selection area, cursor area and the popup list area. /// Clears the selection area, cursor area and the popup list area.
void clearSelection(); void clearSelection();
/// Clears the cursor area.
void clearCursor();
/// Returns the [Node]s in [Selection]. /// Returns the [Node]s in [Selection].
List<Node> getNodesInSelection(Selection selection); List<Node> getNodesInSelection(Selection selection);
@ -205,16 +208,23 @@ class _AppFlowySelectionState extends State<AppFlowySelection>
currentSelectedNodes = []; currentSelectedNodes = [];
currentSelection.value = null; currentSelection.value = null;
clearCursor();
// clear selection areas // clear selection areas
_selectionAreas _selectionAreas
..forEach((overlay) => overlay.remove()) ..forEach((overlay) => overlay.remove())
..clear(); ..clear();
// clear cursor areas // clear cursor areas
// hide toolbar
editorState.service.toolbarService?.hide();
}
@override
void clearCursor() {
// clear cursor areas
_cursorAreas _cursorAreas
..forEach((overlay) => overlay.remove()) ..forEach((overlay) => overlay.remove())
..clear(); ..clear();
// hide toolbar
editorState.service.toolbarService?.hide();
} }
@override @override

View File

@ -90,7 +90,7 @@ class _FlowyToolbarState extends State<FlowyToolbar>
.where((item) => item.validator(widget.editorState)) .where((item) => item.validator(widget.editorState))
.toList(growable: false) .toList(growable: false)
..sort((a, b) => a.type.compareTo(b.type)); ..sort((a, b) => a.type.compareTo(b.type));
if (items.isEmpty) { if (filterItems.isEmpty) {
return []; return [];
} }
final List<ToolbarItem> dividedItems = [filterItems.first]; final List<ToolbarItem> dividedItems = [filterItems.first];

View File

@ -80,7 +80,7 @@ class EditorWidgetTester {
} else { } else {
_editorState.service.selectionService.updateSelection(selection); _editorState.service.selectionService.updateSelection(selection);
} }
await tester.pumpAndSettle(); await tester.pump(const Duration(milliseconds: 200));
expect(_editorState.service.selectionService.currentSelection.value, expect(_editorState.service.selectionService.currentSelection.value,
selection); selection);

View File

@ -49,9 +49,10 @@ void main() async {
final editorRect = tester.getRect(editorFinder); final editorRect = tester.getRect(editorFinder);
final leftImageRect = tester.getRect(imageFinder.at(0)); 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)); 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)); final centerImageRect = tester.getRect(imageFinder.at(1));
expect(centerImageRect.left, expect(centerImageRect.left,
(leftImageRect.left + rightImageRect.left) / 2.0); (leftImageRect.left + rightImageRect.left) / 2.0);
@ -73,8 +74,8 @@ void main() async {
leftImage.onAlign(Alignment.centerRight); leftImage.onAlign(Alignment.centerRight);
await tester.pump(const Duration(milliseconds: 100)); await tester.pump(const Duration(milliseconds: 100));
expect( expect(
tester.getRect(imageFinder.at(0)).left, tester.getRect(imageFinder.at(0)).right,
rightImageRect.left, rightImageRect.right,
); );
}); });
}); });

View File

@ -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:appflowy_editor/src/render/image/image_node_widget.dart';
import 'package:flutter/gestures.dart'; import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -20,6 +23,14 @@ void main() async {
final widget = ImageNodeWidget( final widget = ImageNodeWidget(
src: src, src: src,
node: Node(
type: 'image',
children: LinkedList(),
attributes: {
'image_src': src,
'align': 'center',
},
),
alignment: Alignment.center, alignment: Alignment.center,
onCopy: () { onCopy: () {
onCopyHit = true; onCopyHit = true;

View File

@ -12,8 +12,10 @@ void main() async {
const link = 'appflowy.io'; const link = 'appflowy.io';
var submittedText = ''; var submittedText = '';
final linkMenu = LinkMenu( final linkMenu = LinkMenu(
onOpenLink: () {},
onCopyLink: () {}, onCopyLink: () {},
onRemoveLink: () {}, onRemoveLink: () {},
onFocusChange: (value) {},
onSubmitted: (text) { onSubmitted: (text) {
submittedText = text; submittedText = text;
}, },

View File

@ -10,8 +10,8 @@ void main() async {
TestWidgetsFlutterBinding.ensureInitialized(); TestWidgetsFlutterBinding.ensureInitialized();
}); });
group('delete_text_handler.dart', () { group('checkbox_text_handler.dart', () {
testWidgets('Presses backspace key in empty document', (tester) async { testWidgets('Click checkbox icon', (tester) async {
// Before // Before
// //
// [BIUS]Welcome to Appflowy 😁[BIUS] // [BIUS]Welcome to Appflowy 😁[BIUS]

View File

@ -1,7 +1,9 @@
import 'package:appflowy_editor/appflowy_editor.dart'; 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:appflowy_editor/src/render/rich_text/rich_text_style.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:network_image_mock/network_image_mock.dart';
import '../../infra/test_editor.dart'; import '../../infra/test_editor.dart';
void main() async { void main() async {
@ -9,7 +11,7 @@ void main() async {
TestWidgetsFlutterBinding.ensureInitialized(); TestWidgetsFlutterBinding.ensureInitialized();
}); });
group('delete_text_handler.dart', () { group('backspace_handler.dart', () {
testWidgets('Presses backspace key in empty document', (tester) async { testWidgets('Presses backspace key in empty document', (tester) async {
// Before // Before
// //
@ -167,6 +169,129 @@ void main() async {
testWidgets('Presses delete key in styled text (quote)', (tester) async { testWidgets('Presses delete key in styled text (quote)', (tester) async {
await _deleteStyledTextByDelete(tester, StyleKey.quote); 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<void> _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<void> _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<void> _deleteStyledTextByBackspace( Future<void> _deleteStyledTextByBackspace(

View File

@ -116,6 +116,27 @@ void main() async {
(tester) async { (tester) async {
_testMultipleSelection(tester, false); _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);
});
}); });
} }