mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2024-08-30 18:12:39 +00:00
commit
a7f8c99710
@ -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 |
@ -45,10 +45,8 @@ class _MyHomePageState extends State<MyHomePage> {
|
||||
@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<MyHomePage> {
|
||||
..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 {
|
||||
|
@ -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';
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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<SelectionMenuItem> selectionMenuItems = [];
|
||||
|
||||
/// Stores the editor style.
|
||||
EditorStyle editorStyle = const EditorStyle.defaultStyle();
|
||||
|
||||
final UndoManager undoManager = UndoManager();
|
||||
Selection? _cursorSelection;
|
||||
|
||||
|
@ -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);
|
||||
}
|
@ -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.
|
||||
|
@ -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) =>
|
||||
|
@ -17,6 +17,7 @@ class ImageNodeBuilder extends NodeWidgetBuilder<Node> {
|
||||
}
|
||||
return ImageNodeWidget(
|
||||
key: context.node.key,
|
||||
node: context.node,
|
||||
src: src,
|
||||
width: width,
|
||||
alignment: _textToAlignment(align),
|
||||
|
@ -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<ImageNodeWidget> createState() => _ImageNodeWidgetState();
|
||||
}
|
||||
|
||||
class _ImageNodeWidgetState extends State<ImageNodeWidget> {
|
||||
class _ImageNodeWidgetState extends State<ImageNodeWidget> with Selectable {
|
||||
final _imageKey = GlobalKey();
|
||||
|
||||
double? _imageWidth;
|
||||
double _initial = 0;
|
||||
double _distance = 0;
|
||||
@ -42,7 +50,11 @@ class _ImageNodeWidgetState extends State<ImageNodeWidget> {
|
||||
_imageWidth = widget.width;
|
||||
_imageStreamListener = ImageStreamListener(
|
||||
(image, _) {
|
||||
_imageWidth = image.image.width.toDouble();
|
||||
_imageWidth = _imageKey.currentContext
|
||||
?.findRenderObject()
|
||||
?.unwrapOrNull<RenderBox>()
|
||||
?.size
|
||||
.width;
|
||||
},
|
||||
);
|
||||
}
|
||||
@ -56,14 +68,54 @@ class _ImageNodeWidgetState extends State<ImageNodeWidget> {
|
||||
@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<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) {
|
||||
return Align(
|
||||
alignment: widget.alignment,
|
||||
@ -87,7 +139,7 @@ class _ImageNodeWidgetState extends State<ImageNodeWidget> {
|
||||
loadingBuilder: (context, child, loadingProgress) =>
|
||||
loadingProgress == null ? child : _buildLoading(context),
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
_imageWidth ??= defaultMaxTextNodeWidth;
|
||||
// _imageWidth ??= defaultMaxTextNodeWidth;
|
||||
return _buildError(context);
|
||||
},
|
||||
);
|
||||
|
@ -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<LinkMenu> createState() => _LinkMenuState();
|
||||
@ -26,15 +30,14 @@ class _LinkMenuState extends State<LinkMenu> {
|
||||
@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<LinkMenu> {
|
||||
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<LinkMenu> {
|
||||
|
||||
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<LinkMenu> {
|
||||
onPressed: onPressed,
|
||||
);
|
||||
}
|
||||
|
||||
void _onFocusChange() {
|
||||
widget.onFocusChange(_focusNode.hasFocus);
|
||||
}
|
||||
}
|
||||
|
@ -56,30 +56,27 @@ class _BulletedListTextNodeWidgetState extends State<BulletedListTextNodeWidget>
|
||||
|
||||
@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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
@ -63,41 +63,38 @@ class _CheckboxNodeWidgetState extends State<CheckboxNodeWidget>
|
||||
|
||||
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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
@ -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<FlowyRichText> with Selectable {
|
||||
final _textKey = GlobalKey();
|
||||
var _textKey = GlobalKey();
|
||||
final _placeholderTextKey = GlobalKey();
|
||||
|
||||
final _lineHeight = 1.5;
|
||||
@ -53,6 +54,17 @@ class _FlowyRichTextState extends State<FlowyRichText> 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<FlowyRichText> 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<FlowyRichText> with Selectable {
|
||||
var offset = 0;
|
||||
return TextSpan(
|
||||
children: widget.textNode.delta.whereType<TextInsert>().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<FlowyRichText> 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;
|
||||
}
|
||||
}
|
||||
|
@ -63,16 +63,13 @@ class _HeadingTextNodeWidgetState extends State<HeadingTextNodeWidget>
|
||||
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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
@ -58,28 +58,25 @@ class _NumberListTextNodeWidgetState extends State<NumberListTextNodeWidget>
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
));
|
||||
}
|
||||
}
|
||||
|
@ -55,30 +55,27 @@ class _QuotedTextNodeWidgetState extends State<QuotedTextNodeWidget>
|
||||
|
||||
@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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
@ -52,15 +52,12 @@ class _RichTextNodeWidgetState extends State<RichTextNodeWidget>
|
||||
|
||||
@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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
@ -61,7 +61,6 @@ class StyleKey {
|
||||
}
|
||||
|
||||
// TODO: customize
|
||||
double defaultMaxTextNodeWidth = 780.0;
|
||||
double defaultLinePadding = 8.0;
|
||||
double baseFontSize = 16.0;
|
||||
String defaultHighlightColor = '0x6000BCF0';
|
||||
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
@ -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<ToolbarItem> 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;
|
||||
|
||||
|
@ -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<SelectionMenuItem> selectionMenuItems;
|
||||
|
||||
final EditorStyle editorStyle;
|
||||
|
||||
@override
|
||||
State<AppFlowyEditor> createState() => _AppFlowyEditorState();
|
||||
}
|
||||
@ -60,6 +64,7 @@ class _AppFlowyEditorState extends State<AppFlowyEditor> {
|
||||
super.initState();
|
||||
|
||||
editorState.selectionMenuItems = widget.selectionMenuItems;
|
||||
editorState.editorStyle = widget.editorStyle;
|
||||
editorState.service.renderPluginService = _createRenderPlugin();
|
||||
}
|
||||
|
||||
@ -68,6 +73,8 @@ class _AppFlowyEditorState extends State<AppFlowyEditor> {
|
||||
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<AppFlowyEditor> {
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
@ -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<AppFlowyInput>
|
||||
textNode,
|
||||
delta.insertionOffset,
|
||||
delta.textInserted,
|
||||
removedAttributes: {
|
||||
StyleKey.href: null,
|
||||
},
|
||||
)
|
||||
..commit();
|
||||
} else {
|
||||
|
@ -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<TextNode>().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<TextNode> textNodes, Selection selection) {
|
||||
final first = textNodes.first;
|
||||
final last = textNodes.last;
|
@ -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';
|
||||
|
@ -129,6 +129,10 @@ class _AppFlowyKeyboardState extends State<AppFlowyKeyboard>
|
||||
|
||||
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) {
|
||||
|
@ -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<Node> getNodesInSelection(Selection selection);
|
||||
|
||||
@ -205,16 +208,23 @@ class _AppFlowySelectionState extends State<AppFlowySelection>
|
||||
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
|
||||
|
@ -90,7 +90,7 @@ class _FlowyToolbarState extends State<FlowyToolbar>
|
||||
.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<ToolbarItem> dividedItems = [filterItems.first];
|
||||
|
@ -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);
|
||||
|
@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
},
|
||||
|
@ -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]
|
||||
|
@ -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<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(
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user