Merge pull request #908 from LucasXu0/feat/image

Integrate image plugin into appflowy_editor
This commit is contained in:
Nathan.fooo 2022-08-25 17:04:53 +08:00 committed by GitHub
commit 7b551277b2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
52 changed files with 1898 additions and 365 deletions

View File

@ -0,0 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="8" cy="8" r="7" fill="#F2F2F2"/>
<path d="M6 6L10 10" stroke="#BDBDBD" stroke-linecap="round"/>
<path d="M10 6L6 10" stroke="#BDBDBD" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 274 B

View File

@ -0,0 +1,6 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3 4.3999H4.11111H13" stroke="#333333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M5.77799 4.4V3.2C5.77799 2.88174 5.89506 2.57652 6.10343 2.35147C6.31181 2.12643 6.59442 2 6.88911 2H9.11133C9.40601 2 9.68863 2.12643 9.897 2.35147C10.1054 2.57652 10.2224 2.88174 10.2224 3.2V4.4M11.8891 4.4V12.8C11.8891 13.1183 11.772 13.4235 11.5637 13.6485C11.3553 13.8736 11.0727 14 10.778 14H5.22244C4.92775 14 4.64514 13.8736 4.43676 13.6485C4.22839 13.4235 4.11133 13.1183 4.11133 12.8V4.4H11.8891Z" stroke="#333333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M6.88867 7.3999V10.9999" stroke="#333333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9.11133 7.3999V10.9999" stroke="#333333" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 883 B

View File

@ -0,0 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4 4L12 4" stroke="white" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M5 8H11" stroke="white" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M4 12L12 12" stroke="white" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 355 B

View File

@ -0,0 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4 4L12 4" stroke="white" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M4 8H10" stroke="white" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M4 12L12 12" stroke="white" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 355 B

View File

@ -0,0 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4 4L12 4" stroke="white" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M6 8H12" stroke="white" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M4 12L12 12" stroke="white" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 355 B

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

@ -0,0 +1,6 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3 4.3999H4.11111H13" stroke="white" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M5.77799 4.4V3.2C5.77799 2.88174 5.89506 2.57652 6.10343 2.35147C6.31181 2.12643 6.59442 2 6.88911 2H9.11133C9.40601 2 9.68863 2.12643 9.897 2.35147C10.1054 2.57652 10.2224 2.88174 10.2224 3.2V4.4M11.8891 4.4V12.8C11.8891 13.1183 11.772 13.4235 11.5637 13.6485C11.3553 13.8736 11.0727 14 10.778 14H5.22244C4.92775 14 4.64514 13.8736 4.43676 13.6485C4.22839 13.4235 4.11133 13.1183 4.11133 12.8V4.4H11.8891Z" stroke="white" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M6.88867 7.3999V10.9999" stroke="white" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9.11133 7.3999V10.9999" stroke="white" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 875 B

View File

@ -0,0 +1,3 @@
<svg width="1" height="16" viewBox="0 0 1 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="1" height="16" rx="0.5" fill="#4F4F4F"/>
</svg>

After

Width:  |  Height:  |  Size: 155 B

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="M7 8.91521C7.21574 9.22582 7.49099 9.48283 7.80707 9.66881C8.12315 9.85479 8.47268 9.96538 8.83194 9.99309C9.1912 10.0208 9.5518 9.96497 9.88926 9.8294C10.2267 9.69383 10.5332 9.48169 10.7878 9.20736L12.2949 7.58431C12.7525 7.07413 13.0056 6.39083 12.9999 5.68156C12.9942 4.9723 12.73 4.29384 12.2643 3.7923C11.7986 3.29075 11.1686 3.00627 10.51 3.0001C9.85142 2.99394 9.21693 3.26659 8.7432 3.75935L7.87913 4.68448" stroke="white" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9 7.08479C8.78426 6.77418 8.50901 6.51717 8.19293 6.33119C7.87685 6.14521 7.52732 6.03462 7.16806 6.00691C6.8088 5.9792 6.4482 6.03503 6.11074 6.1706C5.77327 6.30617 5.46683 6.51831 5.21218 6.79264L3.7051 8.41569C3.24755 8.92587 2.99437 9.60918 3.00009 10.3184C3.00582 11.0277 3.26998 11.7062 3.73569 12.2077C4.2014 12.7092 4.8314 12.9937 5.48999 12.9999C6.14858 13.0061 6.78307 12.7334 7.2568 12.2407L8.11584 11.3155" stroke="white" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

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="M7 8.91521C7.21574 9.22582 7.49099 9.48283 7.80707 9.66881C8.12315 9.85479 8.47268 9.96538 8.83194 9.99309C9.1912 10.0208 9.5518 9.96497 9.88926 9.8294C10.2267 9.69383 10.5332 9.48169 10.7878 9.20736L12.2949 7.58431C12.7525 7.07413 13.0056 6.39083 12.9999 5.68156C12.9942 4.9723 12.73 4.29384 12.2643 3.7923C11.7986 3.29075 11.1686 3.00627 10.51 3.0001C9.85142 2.99394 9.21693 3.26659 8.7432 3.75935L7.87913 4.68448" stroke="#333333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9 7.08479C8.78426 6.77418 8.50901 6.51717 8.19293 6.33119C7.87685 6.14521 7.52732 6.03462 7.16806 6.00691C6.8088 5.9792 6.4482 6.03503 6.11074 6.1706C5.77327 6.30617 5.46683 6.51831 5.21218 6.79264L3.7051 8.41569C3.24755 8.92587 2.99437 9.60918 3.00009 10.3184C3.00582 11.0277 3.26998 11.7062 3.73569 12.2077C4.2014 12.7092 4.8314 12.9937 5.48999 12.9999C6.14858 13.0061 6.78307 12.7334 7.2568 12.2407L8.11584 11.3155" stroke="#333333" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -0,0 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="1.5" y="3" width="13" height="10" rx="1.5" stroke="#333333"/>
<circle cx="5.5" cy="6.5" r="1" stroke="#333333"/>
<path d="M5 13L10.112 8.45603C10.4211 8.18126 10.8674 8.12513 11.235 8.31482L14.5 10" stroke="#333333"/>
</svg>

After

Width:  |  Height:  |  Size: 330 B

View File

@ -1,4 +1,4 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7 8.91521C7.21574 9.22582 7.49099 9.48283 7.80707 9.66881C8.12315 9.85479 8.47268 9.96538 8.83194 9.99309C9.1912 10.0208 9.5518 9.96497 9.88926 9.8294C10.2267 9.69383 10.5332 9.48169 10.7878 9.20736L12.2949 7.58431C12.7525 7.07413 13.0056 6.39083 12.9999 5.68156C12.9942 4.9723 12.73 4.29384 12.2643 3.7923C11.7986 3.29075 11.1686 3.00627 10.51 3.0001C9.85142 2.99394 9.21693 3.26659 8.7432 3.75935L7.87913 4.68448" stroke="#00BCF0" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9 7.08479C8.78426 6.77418 8.50901 6.51717 8.19293 6.33119C7.87685 6.14521 7.52732 6.03462 7.16806 6.00691C6.8088 5.9792 6.4482 6.03503 6.11074 6.1706C5.77327 6.30617 5.46683 6.51831 5.21218 6.79264L3.7051 8.41569C3.24755 8.92587 2.99437 9.60918 3.00009 10.3184C3.00582 11.0277 3.26998 11.7062 3.73569 12.2077C4.2014 12.7092 4.8314 12.9937 5.48999 12.9999C6.14858 13.0061 6.78307 12.7334 7.2568 12.2407L8.11584 11.3155" stroke="#00BCF0" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M7 8.91521C7.21574 9.22582 7.49099 9.48283 7.80707 9.66881C8.12315 9.85479 8.47268 9.96538 8.83194 9.99309C9.1912 10.0208 9.5518 9.96497 9.88926 9.8294C10.2267 9.69383 10.5332 9.48169 10.7878 9.20736L12.2949 7.58431C12.7525 7.07413 13.0056 6.39083 12.9999 5.68156C12.9942 4.9723 12.73 4.29384 12.2643 3.7923C11.7986 3.29075 11.1686 3.00627 10.51 3.0001C9.85142 2.99394 9.21693 3.26659 8.7432 3.75935L7.87913 4.68448" stroke="white" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9 7.08479C8.78426 6.77418 8.50901 6.51717 8.19293 6.33119C7.87685 6.14521 7.52732 6.03462 7.16806 6.00691C6.8088 5.9792 6.4482 6.03503 6.11074 6.1706C5.77327 6.30617 5.46683 6.51831 5.21218 6.79264L3.7051 8.41569C3.24755 8.92587 2.99437 9.60918 3.00009 10.3184C3.00582 11.0277 3.26998 11.7062 3.73569 12.2077C4.2014 12.7092 4.8314 12.9937 5.48999 12.9999C6.14858 13.0061 6.78307 12.7334 7.2568 12.2407L8.11584 11.3155" stroke="white" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -6,7 +6,8 @@
{
"type": "image",
"attributes": {
"image_src": "https://images.squarespace-cdn.com/content/v1/617f6f16b877c06711e87373/c3f23723-37f4-44d7-9c5d-6e2a53064ae7/Asset+10.png"
"image_src": "https://images.squarespace-cdn.com/content/v1/617f6f16b877c06711e87373/c3f23723-37f4-44d7-9c5d-6e2a53064ae7/Asset+10.png",
"align": "center"
}
},
{
@ -121,6 +122,14 @@
"heading": "h3"
}
},
{
"type": "image",
"attributes": {
"image_src": "https://s1.ax1x.com/2022/08/24/vgAJED.png",
"align": "left",
"width": 300
}
},
{
"type": "text",
"delta": [

View File

@ -4,7 +4,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'expandable_floating_action_button.dart';
import 'plugin/image_node_widget.dart';
// import 'plugin/image_node_widget.dart';
import 'plugin/youtube_link_node_widget.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
@ -139,7 +139,7 @@ class _MyHomePageState extends State<MyHomePage> {
editorState: editorState,
keyEventHandlers: const [],
customBuilders: {
'image': ImageNodeBuilder(),
// 'image': ImageNodeBuilder(),
'youtube_link': YouTubeLinkNodeBuilder()
},
),

View File

@ -6,22 +6,57 @@ import 'package:appflowy_editor/src/document/text_delta.dart';
import 'package:appflowy_editor/src/render/rich_text/rich_text_style.dart';
extension TextNodeExtension on TextNode {
dynamic getAttributeInSelection(Selection selection, String styleKey) {
final ops = delta.whereType<TextInsert>();
final startOffset =
selection.isBackward ? selection.start.offset : selection.end.offset;
final endOffset =
selection.isBackward ? selection.end.offset : selection.start.offset;
var start = 0;
for (final op in ops) {
if (start >= endOffset) {
break;
}
final length = op.length;
if (start < endOffset && start + length > startOffset) {
if (op.attributes?.containsKey(styleKey) == true) {
return op.attributes![styleKey];
}
}
start += length;
}
return null;
}
bool allSatisfyLinkInSelection(Selection selection) =>
allSatisfyInSelection(StyleKey.href, selection, (value) {
return value != null;
});
bool allSatisfyBoldInSelection(Selection selection) =>
allSatisfyInSelection(StyleKey.bold, true, selection);
allSatisfyInSelection(StyleKey.bold, selection, (value) {
return value == true;
});
bool allSatisfyItalicInSelection(Selection selection) =>
allSatisfyInSelection(StyleKey.italic, true, selection);
allSatisfyInSelection(StyleKey.italic, selection, (value) {
return value == true;
});
bool allSatisfyUnderlineInSelection(Selection selection) =>
allSatisfyInSelection(StyleKey.underline, true, selection);
allSatisfyInSelection(StyleKey.underline, selection, (value) {
return value == true;
});
bool allSatisfyStrikethroughInSelection(Selection selection) =>
allSatisfyInSelection(StyleKey.strikethrough, true, selection);
allSatisfyInSelection(StyleKey.strikethrough, selection, (value) {
return value == true;
});
bool allSatisfyInSelection(
String styleKey,
dynamic value,
Selection selection,
bool Function(dynamic value) compare,
) {
final ops = delta.whereType<TextInsert>();
final startOffset =
@ -37,7 +72,7 @@ extension TextNodeExtension on TextNode {
if (start < endOffset && start + length > startOffset) {
if (op.attributes == null ||
!op.attributes!.containsKey(styleKey) ||
op.attributes![styleKey] != value) {
!compare(op.attributes![styleKey])) {
return false;
}
}
@ -91,13 +126,15 @@ extension TextNodesExtension on List<TextNode> {
bool allSatisfyInSelection(
String styleKey,
Selection selection,
dynamic value,
dynamic matchValue,
) {
if (isEmpty) {
return false;
}
if (length == 1) {
return first.allSatisfyInSelection(styleKey, value, selection);
return first.allSatisfyInSelection(styleKey, selection, (value) {
return value == matchValue;
});
} else {
for (var i = 0; i < length; i++) {
final node = this[i];
@ -117,7 +154,9 @@ extension TextNodesExtension on List<TextNode> {
end: Position(path: node.path, offset: node.toRawString().length),
);
}
if (!node.allSatisfyInSelection(styleKey, value, newSelection)) {
if (!node.allSatisfyInSelection(styleKey, newSelection, (value) {
return value == matchValue;
})) {
return false;
}
}

View File

@ -75,6 +75,11 @@ class Log {
/// For example, uses the logger when processing scroll events.
static Log scroll = Log._(name: 'scroll');
/// For logging message related to [AppFlowyToolbarService].
///
/// For example, uses the logger when processing toolbar events.
static Log toolbar = Log._(name: 'toolbar');
/// For logging message related to UI.
///
/// For example, uses the logger when building the widget.

View File

@ -116,11 +116,17 @@ class TransactionBuilder {
/// 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? attributes, Attributes? removedAttributes}) {
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(
node,

View File

@ -0,0 +1,72 @@
import 'package:appflowy_editor/src/document/node.dart';
import 'package:appflowy_editor/src/operation/transaction_builder.dart';
import 'package:appflowy_editor/src/service/render_plugin_service.dart';
import 'package:flutter/material.dart';
import 'package:rich_clipboard/rich_clipboard.dart';
import 'image_node_widget.dart';
class ImageNodeBuilder extends NodeWidgetBuilder<Node> {
@override
Widget build(NodeWidgetContext<Node> context) {
final src = context.node.attributes['image_src'];
final align = context.node.attributes['align'];
double? width;
if (context.node.attributes.containsKey('width')) {
width = context.node.attributes['width'].toDouble();
}
return ImageNodeWidget(
key: context.node.key,
src: src,
width: width,
alignment: _textToAlignment(align),
onCopy: () {
RichClipboard.setData(RichClipboardData(text: src));
},
onDelete: () {
TransactionBuilder(context.editorState)
..deleteNode(context.node)
..commit();
},
onAlign: (alignment) {
TransactionBuilder(context.editorState)
..updateNode(context.node, {
'align': _alignmentToText(alignment),
})
..commit();
},
onResize: (width) {
TransactionBuilder(context.editorState)
..updateNode(context.node, {
'width': width,
})
..commit();
},
);
}
@override
NodeValidator<Node> get nodeValidator => ((node) {
return node.type == 'image' &&
node.attributes.containsKey('image_src') &&
node.attributes.containsKey('align');
});
Alignment _textToAlignment(String text) {
if (text == 'left') {
return Alignment.centerLeft;
} else if (text == 'right') {
return Alignment.centerRight;
}
return Alignment.center;
}
String _alignmentToText(Alignment alignment) {
if (alignment == Alignment.centerLeft) {
return 'left';
} else if (alignment == Alignment.centerRight) {
return 'right';
}
return 'center';
}
}

View File

@ -0,0 +1,340 @@
import 'package:appflowy_editor/src/infra/flowy_svg.dart';
import 'package:appflowy_editor/src/render/rich_text/rich_text_style.dart';
import 'package:flutter/material.dart';
class ImageNodeWidget extends StatefulWidget {
const ImageNodeWidget({
Key? key,
required this.src,
this.width,
required this.alignment,
required this.onCopy,
required this.onDelete,
required this.onAlign,
required this.onResize,
}) : super(key: key);
final String src;
final double? width;
final Alignment alignment;
final VoidCallback onCopy;
final VoidCallback onDelete;
final void Function(Alignment alignment) onAlign;
final void Function(double width) onResize;
@override
State<ImageNodeWidget> createState() => _ImageNodeWidgetState();
}
class _ImageNodeWidgetState extends State<ImageNodeWidget> {
double? _imageWidth;
double _initial = 0;
double _distance = 0;
bool _onFocus = false;
ImageStream? _imageStream;
late ImageStreamListener _imageStreamListener;
@override
void initState() {
super.initState();
_imageWidth = widget.width;
_imageStreamListener = ImageStreamListener(
(image, _) {
_imageWidth = image.image.width.toDouble();
},
);
}
@override
void dispose() {
_imageStream?.removeListener(_imageStreamListener);
super.dispose();
}
@override
Widget build(BuildContext context) {
// only support network image.
return Container(
width: defaultMaxTextNodeWidth,
padding: const EdgeInsets.only(top: 8, bottom: 8),
child: _buildNetworkImage(context),
);
}
Widget _buildNetworkImage(BuildContext context) {
return Align(
alignment: widget.alignment,
child: MouseRegion(
onEnter: (event) => setState(() {
_onFocus = true;
}),
onExit: (event) => setState(() {
_onFocus = false;
}),
child: _buildResizableImage(context),
),
);
}
Widget _buildResizableImage(BuildContext context) {
final networkImage = Image.network(
widget.src,
width: _imageWidth == null ? null : _imageWidth! - _distance,
gaplessPlayback: true,
loadingBuilder: (context, child, loadingProgress) =>
loadingProgress == null ? child : _buildLoading(context),
errorBuilder: (context, error, stackTrace) {
_imageWidth ??= defaultMaxTextNodeWidth;
return _buildError(context);
},
);
if (_imageWidth == null) {
_imageStream = networkImage.image.resolve(const ImageConfiguration())
..addListener(_imageStreamListener);
}
return Stack(
children: [
networkImage,
_buildEdgeGesture(
context,
top: 0,
left: 0,
bottom: 0,
width: 5,
onUpdate: (distance) {
setState(() {
_distance = distance;
});
},
),
_buildEdgeGesture(
context,
top: 0,
right: 0,
bottom: 0,
width: 5,
onUpdate: (distance) {
setState(() {
_distance = -distance;
});
},
),
if (_onFocus)
ImageToolbar(
top: 8,
right: 8,
height: 30,
alignment: widget.alignment,
onAlign: widget.onAlign,
onCopy: widget.onCopy,
onDelete: widget.onDelete,
)
],
);
}
Widget _buildLoading(BuildContext context) {
return SizedBox(
height: 150,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox.fromSize(
size: const Size(18, 18),
child: const CircularProgressIndicator(),
),
SizedBox.fromSize(
size: const Size(10, 10),
),
const Text('Loading'),
],
),
);
}
Widget _buildError(BuildContext context) {
return Container(
height: 100,
width: _imageWidth,
alignment: Alignment.center,
padding: const EdgeInsets.only(top: 8.0, bottom: 8.0),
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(4.0)),
border: Border.all(width: 1, color: Colors.black),
),
child: const Text('Could not load the image'),
);
}
Widget _buildEdgeGesture(
BuildContext context, {
double? top,
double? left,
double? right,
double? bottom,
double? width,
void Function(double distance)? onUpdate,
}) {
return Positioned(
top: top,
left: left,
right: right,
bottom: bottom,
width: width,
child: GestureDetector(
onHorizontalDragStart: (details) {
_initial = details.globalPosition.dx;
},
onHorizontalDragUpdate: (details) {
if (onUpdate != null) {
onUpdate(details.globalPosition.dx - _initial);
}
},
onHorizontalDragEnd: (details) {
_imageWidth = _imageWidth! - _distance;
_initial = 0;
_distance = 0;
widget.onResize(_imageWidth!);
},
child: MouseRegion(
cursor: SystemMouseCursors.resizeLeftRight,
child: _onFocus
? Center(
child: Container(
height: 40,
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.2),
borderRadius: const BorderRadius.all(
Radius.circular(5.0),
),
),
),
)
: null,
),
),
);
}
}
@visibleForTesting
class ImageToolbar extends StatelessWidget {
const ImageToolbar({
Key? key,
required this.top,
required this.right,
required this.height,
required this.alignment,
required this.onCopy,
required this.onDelete,
required this.onAlign,
}) : super(key: key);
final double top;
final double right;
final double height;
final Alignment alignment;
final VoidCallback onCopy;
final VoidCallback onDelete;
final void Function(Alignment alignment) onAlign;
@override
Widget build(BuildContext context) {
return Positioned(
top: top,
right: right,
height: height,
child: Container(
decoration: BoxDecoration(
color: const Color(0xFF333333),
boxShadow: [
BoxShadow(
blurRadius: 5,
spreadRadius: 1,
color: Colors.black.withOpacity(0.1),
),
],
borderRadius: BorderRadius.circular(8.0),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
IconButton(
hoverColor: Colors.transparent,
constraints: const BoxConstraints(),
padding: const EdgeInsets.fromLTRB(6.0, 4.0, 0.0, 4.0),
icon: FlowySvg(
name: 'image_toolbar/align_left',
color: alignment == Alignment.centerLeft
? const Color(0xFF00BCF0)
: null,
),
onPressed: () {
onAlign(Alignment.centerLeft);
},
),
IconButton(
hoverColor: Colors.transparent,
constraints: const BoxConstraints(),
padding: const EdgeInsets.fromLTRB(0.0, 4.0, 0.0, 4.0),
icon: FlowySvg(
name: 'image_toolbar/align_center',
color: alignment == Alignment.center
? const Color(0xFF00BCF0)
: null,
),
onPressed: () {
onAlign(Alignment.center);
},
),
IconButton(
hoverColor: Colors.transparent,
constraints: const BoxConstraints(),
padding: const EdgeInsets.fromLTRB(0.0, 4.0, 4.0, 4.0),
icon: FlowySvg(
name: 'image_toolbar/align_right',
color: alignment == Alignment.centerRight
? const Color(0xFF00BCF0)
: null,
),
onPressed: () {
onAlign(Alignment.centerRight);
},
),
const Center(
child: FlowySvg(
name: 'image_toolbar/divider',
),
),
IconButton(
hoverColor: Colors.transparent,
constraints: const BoxConstraints(),
padding: const EdgeInsets.fromLTRB(4.0, 4.0, 0.0, 4.0),
icon: const FlowySvg(
name: 'image_toolbar/copy',
),
onPressed: () {
onCopy();
},
),
IconButton(
hoverColor: Colors.transparent,
constraints: const BoxConstraints(),
padding: const EdgeInsets.fromLTRB(0.0, 4.0, 6.0, 4.0),
icon: const FlowySvg(
name: 'image_toolbar/delete',
),
onPressed: () {
onDelete();
},
),
],
),
),
);
}
}

View File

@ -0,0 +1,202 @@
import 'dart:collection';
import 'package:appflowy_editor/src/document/node.dart';
import 'package:appflowy_editor/src/editor_state.dart';
import 'package:appflowy_editor/src/infra/flowy_svg.dart';
import 'package:appflowy_editor/src/operation/transaction_builder.dart';
import 'package:appflowy_editor/src/render/selection_menu/selection_menu_service.dart';
import 'package:flutter/material.dart';
OverlayEntry? _imageUploadMenu;
EditorState? _editorState;
void showImageUploadMenu(
EditorState editorState,
SelectionMenuService menuService,
BuildContext context,
) {
menuService.dismiss();
_imageUploadMenu?.remove();
_imageUploadMenu = OverlayEntry(builder: (context) {
return Positioned(
top: menuService.topLeft.dy,
left: menuService.topLeft.dx,
child: Material(
child: ImageUploadMenu(
onSubmitted: (text) {
// _dismissImageUploadMenu();
editorState.insertImageNode(text);
},
onUpload: (text) {
// _dismissImageUploadMenu();
editorState.insertImageNode(text);
},
),
),
);
});
Overlay.of(context)?.insert(_imageUploadMenu!);
editorState.service.selectionService.currentSelection
.addListener(_dismissImageUploadMenu);
}
void _dismissImageUploadMenu() {
_imageUploadMenu?.remove();
_imageUploadMenu = null;
_editorState?.service.selectionService.currentSelection
.removeListener(_dismissImageUploadMenu);
_editorState = null;
}
class ImageUploadMenu extends StatefulWidget {
const ImageUploadMenu({
Key? key,
required this.onSubmitted,
required this.onUpload,
}) : super(key: key);
final void Function(String text) onSubmitted;
final void Function(String text) onUpload;
@override
State<ImageUploadMenu> createState() => _ImageUploadMenuState();
}
class _ImageUploadMenuState extends State<ImageUploadMenu> {
final _textEditingController = TextEditingController();
final _focusNode = FocusNode();
@override
void initState() {
super.initState();
_focusNode.requestFocus();
}
@override
void dispose() {
_focusNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Container(
width: 300,
padding: const EdgeInsets.all(24.0),
decoration: BoxDecoration(
color: Colors.white,
boxShadow: [
BoxShadow(
blurRadius: 5,
spreadRadius: 1,
color: Colors.black.withOpacity(0.1),
),
],
borderRadius: BorderRadius.circular(6.0),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildHeader(context),
const SizedBox(height: 16.0),
_buildInput(),
const SizedBox(height: 18.0),
_buildUploadButton(context),
],
),
);
}
Widget _buildHeader(BuildContext context) {
return const Text(
'URL Image',
textAlign: TextAlign.left,
style: TextStyle(
fontSize: 14.0,
color: Colors.black,
fontWeight: FontWeight.w500,
),
);
}
Widget _buildInput() {
return TextField(
focusNode: _focusNode,
style: const TextStyle(fontSize: 14.0),
textAlign: TextAlign.left,
controller: _textEditingController,
onSubmitted: widget.onSubmitted,
decoration: InputDecoration(
hintText: 'URL',
hintStyle: const TextStyle(fontSize: 14.0),
contentPadding: const EdgeInsets.all(16.0),
isDense: true,
suffixIcon: IconButton(
padding: const EdgeInsets.all(4.0),
icon: const FlowySvg(
name: 'clear',
width: 24,
height: 24,
),
onPressed: () {
_textEditingController.clear();
},
),
border: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12.0)),
borderSide: BorderSide(color: Color(0xFFBDBDBD)),
),
),
);
}
Widget _buildUploadButton(BuildContext context) {
return SizedBox(
width: 170,
height: 48,
child: TextButton(
style: ButtonStyle(
backgroundColor: MaterialStateProperty.all(const Color(0xFF00BCF0)),
shape: MaterialStateProperty.all<RoundedRectangleBorder>(
RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12.0),
),
),
),
onPressed: () {
widget.onUpload(_textEditingController.text);
},
child: const Text(
'Upload',
style: TextStyle(color: Colors.white, fontSize: 14.0),
),
),
);
}
}
extension on EditorState {
void insertImageNode(String src) {
final selection = service.selectionService.currentSelection.value;
if (selection == null) {
return;
}
final imageNode = Node(
type: 'image',
children: LinkedList(),
attributes: {
'image_src': src,
'align': 'center',
},
);
TransactionBuilder(this)
..insertNode(
selection.start.path,
imageNode,
)
..commit();
}
}

View File

@ -0,0 +1,151 @@
import 'package:appflowy_editor/src/infra/flowy_svg.dart';
import 'package:flutter/material.dart';
class LinkMenu extends StatefulWidget {
const LinkMenu({
Key? key,
this.linkText,
required this.onSubmitted,
required this.onCopyLink,
required this.onRemoveLink,
}) : super(key: key);
final String? linkText;
final void Function(String text) onSubmitted;
final VoidCallback onCopyLink;
final VoidCallback onRemoveLink;
@override
State<LinkMenu> createState() => _LinkMenuState();
}
class _LinkMenuState extends State<LinkMenu> {
final _textEditingController = TextEditingController();
final _focusNode = FocusNode();
@override
void initState() {
super.initState();
_textEditingController.text = widget.linkText ?? '';
_focusNode.requestFocus();
}
@override
void dispose() {
_focusNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return SizedBox(
width: 350,
child: Container(
decoration: BoxDecoration(
color: Colors.white,
boxShadow: [
BoxShadow(
blurRadius: 5,
spreadRadius: 1,
color: Colors.black.withOpacity(0.1),
),
],
borderRadius: BorderRadius.circular(6.0),
),
child: Padding(
padding: const EdgeInsets.all(20.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
_buildHeader(),
const SizedBox(height: 16.0),
_buildInput(),
const SizedBox(height: 16.0),
if (widget.linkText != null) ...[
_buildIconButton(
iconName: 'link',
text: 'Copy link',
onPressed: widget.onCopyLink,
),
_buildIconButton(
iconName: 'delete',
text: 'Remove link',
onPressed: widget.onRemoveLink,
),
]
],
),
),
),
);
}
Widget _buildHeader() {
return const Text(
'Add your link',
style: TextStyle(
color: Colors.grey,
fontWeight: FontWeight.bold,
),
);
}
Widget _buildInput() {
return TextField(
focusNode: _focusNode,
style: const TextStyle(fontSize: 14.0),
textAlign: TextAlign.left,
controller: _textEditingController,
onSubmitted: widget.onSubmitted,
decoration: InputDecoration(
hintText: 'URL',
hintStyle: const TextStyle(fontSize: 14.0),
contentPadding: const EdgeInsets.all(16.0),
isDense: true,
suffixIcon: IconButton(
padding: const EdgeInsets.all(4.0),
icon: const FlowySvg(
name: 'clear',
width: 24,
height: 24,
),
onPressed: () {
_textEditingController.clear();
},
),
border: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12.0)),
borderSide: BorderSide(color: Color(0xFFBDBDBD)),
),
),
);
}
Widget _buildIconButton({
required String iconName,
required String text,
required VoidCallback onPressed,
}) {
return TextButton.icon(
icon: FlowySvg(name: iconName),
style: TextButton.styleFrom(
minimumSize: const Size.fromHeight(40),
padding: EdgeInsets.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
alignment: Alignment.centerLeft,
),
label: Text(
text,
textAlign: TextAlign.left,
style: const TextStyle(
color: Colors.black,
fontSize: 14.0,
),
),
onPressed: onPressed,
);
}
}

View File

@ -56,8 +56,6 @@ class _BulletedListTextNodeWidgetState extends State<BulletedListTextNodeWidget>
@override
Widget build(BuildContext context) {
final topPadding = RichTextStyle.fromTextNode(widget.textNode).topPadding;
return SizedBox(
width: defaultMaxTextNodeWidth,
child: Padding(
@ -69,8 +67,7 @@ class _BulletedListTextNodeWidgetState extends State<BulletedListTextNodeWidget>
key: iconKey,
width: _iconWidth,
height: _iconWidth,
padding:
EdgeInsets.only(top: topPadding, right: _iconRightPadding),
padding: EdgeInsets.only(right: _iconRightPadding),
name: 'point',
),
Expanded(

View File

@ -63,7 +63,6 @@ class _CheckboxNodeWidgetState extends State<CheckboxNodeWidget>
Widget _buildWithSingle(BuildContext context) {
final check = widget.textNode.attributes.check;
final topPadding = RichTextStyle.fromTextNode(widget.textNode).topPadding;
return SizedBox(
width: defaultMaxTextNodeWidth,
child: Padding(
@ -76,10 +75,7 @@ class _CheckboxNodeWidgetState extends State<CheckboxNodeWidget>
child: FlowySvg(
width: _iconWidth,
height: _iconWidth,
padding: EdgeInsets.only(
top: topPadding,
right: _iconRightPadding,
),
padding: EdgeInsets.only(right: _iconRightPadding),
name: check ? 'check' : 'uncheck',
),
onTap: () {

View File

@ -1,5 +1,7 @@
import 'dart:async';
import 'dart:ui';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
@ -11,6 +13,7 @@ import 'package:appflowy_editor/src/document/text_delta.dart';
import 'package:appflowy_editor/src/editor_state.dart';
import 'package:appflowy_editor/src/render/rich_text/rich_text_style.dart';
import 'package:appflowy_editor/src/render/selection/selectable.dart';
import 'package:url_launcher/url_launcher_string.dart';
typedef FlowyTextSpanDecorator = TextSpan Function(TextSpan textSpan);
@ -143,6 +146,11 @@ class _FlowyRichTextState extends State<FlowyRichText> with Selectable {
);
}
@override
Offset localToGlobal(Offset offset) {
return _renderParagraph.localToGlobal(offset);
}
Widget _buildRichText(BuildContext context) {
return MouseRegion(
cursor: SystemMouseCursors.text,
@ -181,44 +189,63 @@ class _FlowyRichTextState extends State<FlowyRichText> with Selectable {
);
}
// unused now.
// Widget _buildRichTextWithChildren(BuildContext context) {
// return Column(
// crossAxisAlignment: CrossAxisAlignment.start,
// children: [
// _buildSingleRichText(context),
// ...widget.textNode.children
// .map(
// (child) => widget.editorState.service.renderPluginService
// .buildPluginWidget(
// NodeWidgetContext(
// context: context,
// node: child,
// editorState: widget.editorState,
// ),
// ),
// )
// .toList()
// ],
// );
// }
TextSpan get _textSpan {
var offset = 0;
return TextSpan(
children: widget.textNode.delta.whereType<TextInsert>().map((insert) {
GestureRecognizer? gestureDetector;
if (insert.attributes?[StyleKey.href] != null) {
final startOffset = offset;
Timer? timer;
var tapCount = 0;
gestureDetector = TapGestureRecognizer()
..onTap = () async {
// implement a simple double tap logic
tapCount += 1;
timer?.cancel();
@override
Offset localToGlobal(Offset offset) {
return _renderParagraph.localToGlobal(offset);
if (tapCount == 2) {
tapCount = 0;
final href = insert.attributes![StyleKey.href];
final uri = Uri.parse(href);
// url_launcher cannot open a link without scheme.
final newHref =
(uri.scheme.isNotEmpty ? href : 'http://$href').trim();
if (await canLaunchUrlString(newHref)) {
await launchUrlString(newHref);
}
return;
}
timer = Timer(const Duration(milliseconds: 200), () {
tapCount = 0;
// update selection
final selection = Selection.single(
path: widget.textNode.path,
startOffset: startOffset,
endOffset: startOffset + insert.length,
);
widget.editorState.service.selectionService
.updateSelection(selection);
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
widget.editorState.service.toolbarService
?.triggerHandler('appflowy.toolbar.link');
});
});
};
}
offset += insert.length;
final textSpan = RichTextStyle(
attributes: insert.attributes ?? {},
text: insert.content,
height: _lineHeight,
gestureRecognizer: gestureDetector,
).toTextSpan();
return textSpan;
}).toList(growable: false),
);
}
TextSpan get _textSpan => TextSpan(
children: widget.textNode.delta
.whereType<TextInsert>()
.map((insert) => RichTextStyle(
attributes: insert.attributes ?? {},
text: insert.content,
height: _lineHeight,
).toTextSpan())
.toList(growable: false),
);
TextSpan get _placeholderTextSpan => TextSpan(children: [
RichTextStyle(
text: widget.placeholderText,

View File

@ -56,7 +56,6 @@ class _NumberListTextNodeWidgetState extends State<NumberListTextNodeWidget>
@override
Widget build(BuildContext context) {
final topPadding = RichTextStyle.fromTextNode(widget.textNode).topPadding;
return Padding(
padding: EdgeInsets.only(bottom: defaultLinePadding),
child: SizedBox(
@ -68,8 +67,7 @@ class _NumberListTextNodeWidgetState extends State<NumberListTextNodeWidget>
key: iconKey,
width: _iconWidth,
height: _iconWidth,
padding:
EdgeInsets.only(top: topPadding, right: _iconRightPadding),
padding: EdgeInsets.only(right: _iconRightPadding),
number: widget.textNode.attributes.number,
),
Expanded(

View File

@ -55,39 +55,32 @@ class _QuotedTextNodeWidgetState extends State<QuotedTextNodeWidget>
@override
Widget build(BuildContext context) {
final topPadding = RichTextStyle.fromTextNode(widget.textNode).topPadding;
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(
top: topPadding, right: _iconRightPadding),
name: 'quote',
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',
),
Expanded(
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,
),
),
],
),
),
],
),
));
}
double get _quoteHeight {
final lines =
widget.textNode.toRawString().characters.where((c) => c == '\n').length;
return (lines + 1) * _iconWidth;
),
),
);
}
}

View File

@ -1,8 +1,6 @@
import 'package:appflowy_editor/src/document/attributes.dart';
import 'package:appflowy_editor/src/document/node.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher_string.dart';
///
/// Supported partial rendering types:
@ -182,14 +180,13 @@ class RichTextStyle {
RichTextStyle({
required this.attributes,
required this.text,
this.gestureRecognizer,
this.height = 1.5,
});
RichTextStyle.fromTextNode(TextNode textNode)
: this(attributes: textNode.attributes, text: textNode.toRawString());
final Attributes attributes;
final String text;
final GestureRecognizer? gestureRecognizer;
final double height;
TextSpan toTextSpan() => _toTextSpan(height);
@ -201,6 +198,7 @@ class RichTextStyle {
TextSpan _toTextSpan(double? height) {
return TextSpan(
text: text,
recognizer: _recognizer,
style: TextStyle(
fontWeight: _fontWeight,
fontStyle: _fontStyle,
@ -210,7 +208,6 @@ class RichTextStyle {
background: _background,
height: height,
),
recognizer: _recognizer,
);
}
@ -273,13 +270,6 @@ class RichTextStyle {
// recognizer
GestureRecognizer? get _recognizer {
final href = attributes.href;
if (href != null) {
return TapGestureRecognizer()
..onTap = () async {
await launchUrlString(href);
};
}
return null;
return gestureRecognizer;
}
}

View File

@ -1,217 +0,0 @@
import 'package:appflowy_editor/src/render/rich_text/rich_text_style.dart';
import 'package:flutter/material.dart';
import 'package:appflowy_editor/src/editor_state.dart';
import 'package:appflowy_editor/src/infra/flowy_svg.dart';
import 'package:appflowy_editor/src/service/default_text_operations/format_rich_text_style.dart';
typedef ToolbarEventHandler = void Function(EditorState editorState);
typedef ToolbarEventHandlers = Map<String, ToolbarEventHandler>;
ToolbarEventHandlers defaultToolbarEventHandlers = {
'bold': (editorState) => formatBold(editorState),
'italic': (editorState) => formatItalic(editorState),
'strikethrough': (editorState) => formatStrikethrough(editorState),
'underline': (editorState) => formatUnderline(editorState),
'quote': (editorState) => formatQuote(editorState),
'bulleted_list': (editorState) => formatBulletedList(editorState),
'highlight': (editorState) => formatHighlight(editorState),
'Text': (editorState) => formatText(editorState),
'h1': (editorState) => formatHeading(editorState, StyleKey.h1),
'h2': (editorState) => formatHeading(editorState, StyleKey.h2),
'h3': (editorState) => formatHeading(editorState, StyleKey.h3),
};
List<String> defaultListToolbarEventNames = [
'Text',
'H1',
'H2',
'H3',
];
mixin ToolbarMixin<T extends StatefulWidget> on State<T> {
void hide();
}
class ToolbarWidget extends StatefulWidget {
const ToolbarWidget({
Key? key,
required this.editorState,
required this.layerLink,
required this.offset,
required this.handlers,
}) : super(key: key);
final EditorState editorState;
final LayerLink layerLink;
final Offset offset;
final ToolbarEventHandlers handlers;
@override
State<ToolbarWidget> createState() => _ToolbarWidgetState();
}
class _ToolbarWidgetState extends State<ToolbarWidget> with ToolbarMixin {
// final GlobalKey _listToolbarKey = GlobalKey();
final toolbarHeight = 32.0;
final topPadding = 5.0;
final listToolbarWidth = 60.0;
final listToolbarHeight = 120.0;
final cornerRadius = 8.0;
OverlayEntry? _listToolbarOverlay;
@override
Widget build(BuildContext context) {
return Positioned(
top: widget.offset.dx,
left: widget.offset.dy,
child: CompositedTransformFollower(
link: widget.layerLink,
showWhenUnlinked: true,
offset: widget.offset,
child: _buildToolbar(context),
),
);
}
@override
void hide() {
_listToolbarOverlay?.remove();
_listToolbarOverlay = null;
}
Widget _buildToolbar(BuildContext context) {
return Material(
borderRadius: BorderRadius.circular(cornerRadius),
color: const Color(0xFF333333),
child: SizedBox(
height: toolbarHeight,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// _listToolbar(context),
_centerToolbarIcon('h1', tooltipMessage: 'Heading 1'),
_centerToolbarIcon('h2', tooltipMessage: 'Heading 2'),
_centerToolbarIcon('h3', tooltipMessage: 'Heading 3'),
_centerToolbarIcon('divider', width: 2),
_centerToolbarIcon('bold', tooltipMessage: 'Bold'),
_centerToolbarIcon('italic', tooltipMessage: 'Italic'),
_centerToolbarIcon('strikethrough',
tooltipMessage: 'Strikethrough'),
_centerToolbarIcon('underline', tooltipMessage: 'Underline'),
_centerToolbarIcon('divider', width: 2),
_centerToolbarIcon('quote', tooltipMessage: 'Quote'),
// _centerToolbarIcon('number_list'),
_centerToolbarIcon('bulleted_list',
tooltipMessage: 'Bulleted List'),
_centerToolbarIcon('divider', width: 2),
_centerToolbarIcon('highlight', tooltipMessage: 'Highlight'),
],
),
),
);
}
// Widget _listToolbar(BuildContext context) {
// return _centerToolbarIcon(
// 'quote',
// key: _listToolbarKey,
// width: listToolbarWidth,
// onTap: () => _onTapListToolbar(context),
// );
// }
Widget _centerToolbarIcon(String name,
{Key? key, String? tooltipMessage, double? width, VoidCallback? onTap}) {
return Tooltip(
key: key,
preferBelow: false,
message: tooltipMessage ?? '',
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: onTap ?? () => _onTap(name),
child: SizedBox.fromSize(
size:
Size(toolbarHeight - (width != null ? 20 : 0), toolbarHeight),
child: Center(
child: FlowySvg(
width: width ?? 20,
name: 'toolbar/$name',
),
),
),
),
));
}
// void _onTapListToolbar(BuildContext context) {
// // TODO: implement more detailed UI.
// final items = defaultListToolbarEventNames;
// final renderBox =
// _listToolbarKey.currentContext?.findRenderObject() as RenderBox;
// final offset = renderBox
// .localToGlobal(Offset.zero)
// .translate(0, toolbarHeight - cornerRadius);
// final rect = offset & Size(listToolbarWidth, listToolbarHeight);
// _listToolbarOverlay?.remove();
// _listToolbarOverlay = OverlayEntry(builder: (context) {
// return Positioned.fromRect(
// rect: rect,
// child: Material(
// borderRadius: BorderRadius.only(
// bottomLeft: Radius.circular(cornerRadius),
// bottomRight: Radius.circular(cornerRadius),
// ),
// color: const Color(0xFF333333),
// child: SingleChildScrollView(
// child: ListView.builder(
// itemExtent: toolbarHeight,
// padding: const EdgeInsets.only(bottom: 10.0),
// shrinkWrap: true,
// itemCount: items.length,
// itemBuilder: ((context, index) {
// return ListTile(
// contentPadding: const EdgeInsets.only(
// left: 3.0,
// right: 3.0,
// ),
// minVerticalPadding: 0.0,
// title: FittedBox(
// fit: BoxFit.scaleDown,
// child: Text(
// items[index],
// textAlign: TextAlign.center,
// style: const TextStyle(
// color: Colors.white,
// ),
// ),
// ),
// onTap: () {
// _onTap(items[index]);
// },
// );
// }),
// ),
// ),
// ),
// );
// });
// // TODO: disable scrolling.
// Overlay.of(context)?.insert(_listToolbarOverlay!);
// }
void _onTap(String eventName) {
if (defaultToolbarEventHandlers.containsKey(eventName)) {
defaultToolbarEventHandlers[eventName]!(widget.editorState);
return;
}
assert(false, 'Could not find the event handler for $eventName');
}
}

View File

@ -45,7 +45,7 @@ class SelectionMenuItemWidget extends StatelessWidget {
),
),
onPressed: () {
item.handler(editorState, menuService);
item.handler(editorState, menuService, context);
},
),
),

View File

@ -1,5 +1,6 @@
import 'package:appflowy_editor/src/editor_state.dart';
import 'package:appflowy_editor/src/infra/flowy_svg.dart';
import 'package:appflowy_editor/src/render/image/image_upload_widget.dart';
import 'package:appflowy_editor/src/render/rich_text/rich_text_style.dart';
import 'package:appflowy_editor/src/render/selection_menu/selection_menu_widget.dart';
import 'package:appflowy_editor/src/service/default_text_operations/format_rich_text_style.dart';
@ -23,6 +24,7 @@ class SelectionMenu implements SelectionMenuService {
OverlayEntry? _selectionMenuEntry;
bool _selectionUpdateByInner = false;
Offset? _topLeft;
@override
void dismiss() {
@ -53,6 +55,7 @@ class SelectionMenu implements SelectionMenuService {
return;
}
final offset = selectionRects.first.bottomRight + const Offset(10, 10);
_topLeft = offset;
_selectionMenuEntry = OverlayEntry(builder: (context) {
return Positioned(
@ -84,8 +87,9 @@ class SelectionMenu implements SelectionMenuService {
}
@override
// TODO: implement topLeft
Offset get topLeft => throw UnimplementedError();
Offset get topLeft {
return _topLeft ?? Offset.zero;
}
void _onSelectionChange() {
// workaround: SelectionService has been released after hot reload.
@ -115,7 +119,7 @@ final List<SelectionMenuItem> _defaultSelectionMenuItems = [
name: 'Text',
icon: _selectionMenuIcon('text'),
keywords: ['text'],
handler: (editorState, menuService) {
handler: (editorState, _, __) {
insertTextNodeAfterSelection(editorState, {});
},
),
@ -123,7 +127,7 @@ final List<SelectionMenuItem> _defaultSelectionMenuItems = [
name: 'Heading 1',
icon: _selectionMenuIcon('h1'),
keywords: ['heading 1, h1'],
handler: (editorState, menuService) {
handler: (editorState, _, __) {
insertHeadingAfterSelection(editorState, StyleKey.h1);
},
),
@ -131,7 +135,7 @@ final List<SelectionMenuItem> _defaultSelectionMenuItems = [
name: 'Heading 2',
icon: _selectionMenuIcon('h2'),
keywords: ['heading 2, h2'],
handler: (editorState, menuService) {
handler: (editorState, _, __) {
insertHeadingAfterSelection(editorState, StyleKey.h2);
},
),
@ -139,15 +143,21 @@ final List<SelectionMenuItem> _defaultSelectionMenuItems = [
name: 'Heading 3',
icon: _selectionMenuIcon('h3'),
keywords: ['heading 3, h3'],
handler: (editorState, menuService) {
handler: (editorState, _, __) {
insertHeadingAfterSelection(editorState, StyleKey.h3);
},
),
SelectionMenuItem(
name: 'Image',
icon: _selectionMenuIcon('image'),
keywords: ['image'],
handler: showImageUploadMenu,
),
SelectionMenuItem(
name: 'Bulleted list',
icon: _selectionMenuIcon('bulleted_list'),
keywords: ['bulleted list', 'list', 'unordered list'],
handler: (editorState, menuService) {
handler: (editorState, _, __) {
insertBulletedListAfterSelection(editorState);
},
),
@ -155,7 +165,7 @@ final List<SelectionMenuItem> _defaultSelectionMenuItems = [
name: 'Checkbox',
icon: _selectionMenuIcon('checkbox'),
keywords: ['todo list', 'list', 'checkbox list'],
handler: (editorState, menuService) {
handler: (editorState, _, __) {
insertCheckboxAfterSelection(editorState);
},
),

View File

@ -22,8 +22,11 @@ class SelectionMenuItem {
///
/// The keywords are used to quickly retrieve items.
final List<String> keywords;
final void Function(EditorState editorState, SelectionMenuService menuService)
handler;
final void Function(
EditorState editorState,
SelectionMenuService menuService,
BuildContext context,
) handler;
}
class SelectionMenuWidget extends StatefulWidget {
@ -202,8 +205,10 @@ class _SelectionMenuWidgetState extends State<SelectionMenuWidget> {
if (event.logicalKey == LogicalKeyboardKey.enter) {
if (0 <= _selectedIndex && _selectedIndex < _showingItems.length) {
_deleteLastCharacters(length: keyword.length + 1);
_showingItems[_selectedIndex]
.handler(widget.editorState, widget.menuService);
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
_showingItems[_selectedIndex]
.handler(widget.editorState, widget.menuService, context);
});
return KeyEventResult.handled;
}
} else if (event.logicalKey == LogicalKeyboardKey.escape) {

View File

@ -0,0 +1,231 @@
import 'package:appflowy_editor/appflowy_editor.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';
import 'package:appflowy_editor/src/extensions/text_node_extensions.dart';
import 'package:appflowy_editor/src/service/default_text_operations/format_rich_text_style.dart';
import 'package:flutter/material.dart';
import 'package:rich_clipboard/rich_clipboard.dart';
typedef ToolbarEventHandler = void Function(
EditorState editorState, BuildContext context);
typedef ToolbarShowValidator = bool Function(EditorState editorState);
class ToolbarItem {
ToolbarItem({
required this.id,
required this.type,
required this.icon,
this.tooltipsMessage = '',
required this.validator,
required this.handler,
});
final String id;
final int type;
final Widget icon;
final String tooltipsMessage;
final ToolbarShowValidator validator;
final ToolbarEventHandler handler;
factory ToolbarItem.divider() {
return ToolbarItem(
id: 'divider',
type: -1,
icon: const FlowySvg(name: 'toolbar/divider'),
validator: (editorState) => true,
handler: (editorState, context) {},
);
}
@override
bool operator ==(Object other) {
if (other is! ToolbarItem) {
return false;
}
if (identical(this, other)) {
return true;
}
return id == other.id;
}
@override
int get hashCode => id.hashCode;
}
List<ToolbarItem> defaultToolbarItems = [
ToolbarItem(
id: 'appflowy.toolbar.h1',
type: 1,
tooltipsMessage: 'Heading 1',
icon: const FlowySvg(name: 'toolbar/h1'),
validator: _onlyShowInSingleTextSelection,
handler: (editorState, context) => formatHeading(editorState, StyleKey.h1),
),
ToolbarItem(
id: 'appflowy.toolbar.h2',
type: 1,
tooltipsMessage: 'Heading 2',
icon: const FlowySvg(name: 'toolbar/h2'),
validator: _onlyShowInSingleTextSelection,
handler: (editorState, context) => formatHeading(editorState, StyleKey.h2),
),
ToolbarItem(
id: 'appflowy.toolbar.h3',
type: 1,
tooltipsMessage: 'Heading 3',
icon: const FlowySvg(name: 'toolbar/h3'),
validator: _onlyShowInSingleTextSelection,
handler: (editorState, context) => formatHeading(editorState, StyleKey.h3),
),
ToolbarItem(
id: 'appflowy.toolbar.bold',
type: 2,
tooltipsMessage: 'Bold',
icon: const FlowySvg(name: 'toolbar/bold'),
validator: _showInTextSelection,
handler: (editorState, context) => formatBold(editorState),
),
ToolbarItem(
id: 'appflowy.toolbar.italic',
type: 2,
tooltipsMessage: 'Italic',
icon: const FlowySvg(name: 'toolbar/italic'),
validator: _showInTextSelection,
handler: (editorState, context) => formatItalic(editorState),
),
ToolbarItem(
id: 'appflowy.toolbar.underline',
type: 2,
tooltipsMessage: 'Underline',
icon: const FlowySvg(name: 'toolbar/underline'),
validator: _showInTextSelection,
handler: (editorState, context) => formatUnderline(editorState),
),
ToolbarItem(
id: 'appflowy.toolbar.strikethrough',
type: 2,
tooltipsMessage: 'Strikethrough',
icon: const FlowySvg(name: 'toolbar/strikethrough'),
validator: _showInTextSelection,
handler: (editorState, context) => formatStrikethrough(editorState),
),
ToolbarItem(
id: 'appflowy.toolbar.quote',
type: 3,
tooltipsMessage: 'Quote',
icon: const FlowySvg(name: 'toolbar/quote'),
validator: _onlyShowInSingleTextSelection,
handler: (editorState, context) => formatQuote(editorState),
),
ToolbarItem(
id: 'appflowy.toolbar.bulleted_list',
type: 3,
tooltipsMessage: 'Bulleted list',
icon: const FlowySvg(name: 'toolbar/bulleted_list'),
validator: _onlyShowInSingleTextSelection,
handler: (editorState, context) => formatBulletedList(editorState),
),
ToolbarItem(
id: 'appflowy.toolbar.link',
type: 4,
tooltipsMessage: 'Link',
icon: const FlowySvg(name: 'toolbar/link'),
validator: _onlyShowInSingleTextSelection,
handler: (editorState, context) => _showLinkMenu(editorState, context),
),
ToolbarItem(
id: 'appflowy.toolbar.highlight',
type: 4,
tooltipsMessage: 'Highlight',
icon: const FlowySvg(name: 'toolbar/highlight'),
validator: _showInTextSelection,
handler: (editorState, context) => formatHighlight(editorState),
),
];
ToolbarShowValidator _onlyShowInSingleTextSelection = (editorState) {
final nodes = editorState.service.selectionService.currentSelectedNodes;
return (nodes.length == 1 && nodes.first is TextNode);
};
ToolbarShowValidator _showInTextSelection = (editorState) {
final nodes = editorState.service.selectionService.currentSelectedNodes
.whereType<TextNode>();
return nodes.isNotEmpty;
};
OverlayEntry? _linkMenuOverlay;
EditorState? _editorState;
void _showLinkMenu(EditorState editorState, BuildContext context) {
final rects = editorState.service.selectionService.selectionRects;
var maxBottom = 0.0;
late Rect matchRect;
for (final rect in rects) {
if (rect.bottom > maxBottom) {
maxBottom = rect.bottom;
matchRect = rect;
}
}
_dismissLinkMenu();
_editorState = editorState;
// 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 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;
String? linkText;
if (node.allSatisfyLinkInSelection(selection)) {
linkText = node.getAttributeInSelection(selection, StyleKey.href);
}
_linkMenuOverlay = OverlayEntry(builder: (context) {
return Positioned(
top: matchRect.bottom + 5.0,
left: matchRect.left,
child: Material(
child: LinkMenu(
linkText: linkText,
onSubmitted: (text) {
TransactionBuilder(editorState)
..formatText(node, index, length, {StyleKey.href: text})
..commit();
_dismissLinkMenu();
},
onCopyLink: () {
RichClipboard.setData(RichClipboardData(text: linkText));
_dismissLinkMenu();
},
onRemoveLink: () {
TransactionBuilder(editorState)
..formatText(node, index, length, {StyleKey.href: null})
..commit();
_dismissLinkMenu();
},
),
),
);
});
Overlay.of(context)?.insert(_linkMenuOverlay!);
editorState.service.scrollService?.disable();
editorState.service.keyboardService?.disable();
editorState.service.selectionService.currentSelection
.addListener(_dismissLinkMenu);
}
void _dismissLinkMenu() {
_linkMenuOverlay?.remove();
_linkMenuOverlay = null;
_editorState?.service.scrollService?.enable();
_editorState?.service.keyboardService?.enable();
_editorState?.service.selectionService.currentSelection
.removeListener(_dismissLinkMenu);
_editorState = null;
}

View File

@ -0,0 +1,35 @@
import 'package:flutter/material.dart';
import 'toolbar_item.dart';
class ToolbarItemWidget extends StatelessWidget {
const ToolbarItemWidget({
Key? key,
required this.item,
required this.onPressed,
}) : super(key: key);
final ToolbarItem item;
final VoidCallback onPressed;
@override
Widget build(BuildContext context) {
return SizedBox(
width: 28,
height: 28,
child: Tooltip(
preferBelow: false,
message: item.tooltipsMessage,
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: IconButton(
padding: EdgeInsets.zero,
icon: item.icon,
iconSize: 28,
onPressed: onPressed,
),
),
),
);
}
}

View File

@ -0,0 +1,79 @@
import 'package:appflowy_editor/src/render/toolbar/toolbar_item.dart';
import 'package:appflowy_editor/src/render/toolbar/toolbar_item_widget.dart';
import 'package:flutter/material.dart';
import 'package:appflowy_editor/src/editor_state.dart';
mixin ToolbarMixin<T extends StatefulWidget> on State<T> {
void hide();
}
class ToolbarWidget extends StatefulWidget {
const ToolbarWidget({
Key? key,
required this.editorState,
required this.layerLink,
required this.offset,
required this.items,
}) : super(key: key);
final EditorState editorState;
final LayerLink layerLink;
final Offset offset;
final List<ToolbarItem> items;
@override
State<ToolbarWidget> createState() => _ToolbarWidgetState();
}
class _ToolbarWidgetState extends State<ToolbarWidget> with ToolbarMixin {
OverlayEntry? _listToolbarOverlay;
@override
Widget build(BuildContext context) {
return Positioned(
top: widget.offset.dx,
left: widget.offset.dy,
child: CompositedTransformFollower(
link: widget.layerLink,
showWhenUnlinked: true,
offset: widget.offset,
child: _buildToolbar(context),
),
);
}
@override
void hide() {
_listToolbarOverlay?.remove();
_listToolbarOverlay = null;
}
Widget _buildToolbar(BuildContext context) {
return Material(
borderRadius: BorderRadius.circular(8.0),
color: const Color(0xFF333333),
child: Padding(
padding: const EdgeInsets.only(left: 8.0, right: 8.0),
child: SizedBox(
height: 32.0,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: widget.items
.map(
(item) => Center(
child: ToolbarItemWidget(
item: item,
onPressed: () {
item.handler(widget.editorState, context);
},
),
),
)
.toList(growable: false),
),
),
),
);
}
}

View File

@ -1,3 +1,4 @@
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/service/internal_key_event_handlers/default_key_event_handlers.dart';
import 'package:flutter/material.dart';
@ -25,6 +26,7 @@ NodeWidgetBuilders defaultBuilders = {
'text/bulleted-list': BulletedListTextNodeWidgetBuilder(),
'text/number-list': NumberListTextNodeWidgetBuilder(),
'text/quote': QuotedTextNodeWidgetBuilder(),
'image': ImageNodeBuilder(),
};
class AppFlowyEditor extends StatefulWidget {

View File

@ -1,4 +1,5 @@
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';
@ -87,15 +88,18 @@ class _AppFlowyInputState extends State<AppFlowyInput>
@override
void attach(TextEditingValue textEditingValue) {
_textInputConnection ??= TextInput.attach(
this,
const TextInputConfiguration(
// TODO: customize
enableDeltaModel: true,
inputType: TextInputType.multiline,
textCapitalization: TextCapitalization.sentences,
),
);
if (_textInputConnection == null ||
_textInputConnection!.attached == false) {
_textInputConnection = TextInput.attach(
this,
const TextInputConfiguration(
// TODO: customize
enableDeltaModel: true,
inputType: TextInputType.multiline,
textCapitalization: TextCapitalization.sentences,
),
);
}
_textInputConnection!
..setEditingState(textEditingValue)
@ -146,6 +150,9 @@ class _AppFlowyInputState extends State<AppFlowyInput>
textNode,
delta.insertionOffset,
delta.textInserted,
removedAttributes: {
StyleKey.href: null,
},
)
..commit();
} else {

View File

@ -13,9 +13,6 @@ KeyEventResult _handleBackspace(EditorState editorState, RawKeyEvent event) {
selection = selection.isBackward ? selection : selection.reversed;
// make sure all nodes is [TextNode].
final textNodes = nodes.whereType<TextNode>().toList();
if (textNodes.length != nodes.length) {
return KeyEventResult.ignored;
}
final transactionBuilder = TransactionBuilder(editorState);
if (textNodes.length == 1) {
@ -37,9 +34,9 @@ KeyEventResult _handleBackspace(EditorState editorState, RawKeyEvent event) {
} else {
// 2. non-style
// find previous text node.
while (textNode.previous != null) {
if (textNode.previous is TextNode) {
final previous = textNode.previous as TextNode;
var previous = textNode.previous;
while (previous != null) {
if (previous is TextNode) {
transactionBuilder
..mergeText(previous, textNode)
..deleteNode(textNode)
@ -50,6 +47,8 @@ KeyEventResult _handleBackspace(EditorState editorState, RawKeyEvent event) {
),
);
break;
} else {
previous = previous.previous;
}
}
}

View File

@ -36,6 +36,12 @@ AppFlowyKeyEventHandler updateTextStyleByCommandXHandler =
event.isShiftPressed) {
formatHighlight(editorState);
return KeyEventResult.handled;
} else if (event.logicalKey == LogicalKeyboardKey.keyK) {
if (editorState.service.toolbarService
?.triggerHandler('appflowy.toolbar.link') ==
true) {
return KeyEventResult.handled;
}
}
return KeyEventResult.ignored;

View File

@ -36,10 +36,10 @@ class FlowyService {
// toolbar service
final toolbarServiceKey = GlobalKey(debugLabel: 'flowy_toolbar_service');
FlowyToolbarService? get toolbarService {
AppFlowyToolbarService? get toolbarService {
if (toolbarServiceKey.currentState != null &&
toolbarServiceKey.currentState is FlowyToolbarService) {
return toolbarServiceKey.currentState! as FlowyToolbarService;
toolbarServiceKey.currentState is AppFlowyToolbarService) {
return toolbarServiceKey.currentState! as AppFlowyToolbarService;
}
return null;
}

View File

@ -1,15 +1,19 @@
import 'package:appflowy_editor/src/render/toolbar/toolbar_item.dart';
import 'package:flutter/material.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor/src/render/selection/toolbar_widget.dart';
import 'package:appflowy_editor/src/render/toolbar/toolbar_widget.dart';
import 'package:appflowy_editor/src/extensions/object_extensions.dart';
abstract class FlowyToolbarService {
abstract class AppFlowyToolbarService {
/// Show the toolbar widget beside the offset.
void showInOffset(Offset offset, LayerLink layerLink);
/// Hide the toolbar widget.
void hide();
/// Trigger the specified handler.
bool triggerHandler(String id);
}
class FlowyToolbar extends StatefulWidget {
@ -27,7 +31,7 @@ class FlowyToolbar extends StatefulWidget {
}
class _FlowyToolbarState extends State<FlowyToolbar>
implements FlowyToolbarService {
implements AppFlowyToolbarService {
OverlayEntry? _toolbarOverlay;
final _toolbarWidgetKey = GlobalKey(debugLabel: '_toolbar_widget');
@ -41,7 +45,7 @@ class _FlowyToolbarState extends State<FlowyToolbar>
editorState: widget.editorState,
layerLink: layerLink,
offset: offset.translate(0, -37.0),
handlers: const {},
items: _filterItems(defaultToolbarItems),
),
);
Overlay.of(context)?.insert(_toolbarOverlay!);
@ -54,6 +58,17 @@ class _FlowyToolbarState extends State<FlowyToolbar>
_toolbarOverlay = null;
}
@override
bool triggerHandler(String id) {
final items = defaultToolbarItems.where((item) => item.id == id);
if (items.length != 1) {
assert(items.length == 1, 'The toolbar item\'s id must be unique');
return false;
}
items.first.handler(widget.editorState, context);
return true;
}
@override
Widget build(BuildContext context) {
return Container(
@ -67,4 +82,24 @@ class _FlowyToolbarState extends State<FlowyToolbar>
super.dispose();
}
// Filter items that should not be displayed, sort according to type,
// and insert dividers between different types.
List<ToolbarItem> _filterItems(List<ToolbarItem> items) {
final filterItems = items
.where((item) => item.validator(widget.editorState))
.toList(growable: false)
..sort((a, b) => a.type.compareTo(b.type));
if (items.isEmpty) {
return [];
}
final List<ToolbarItem> dividedItems = [filterItems.first];
for (var i = 1; i < filterItems.length; i++) {
if (filterItems[i].type != filterItems[i - 1].type) {
dividedItems.add(ToolbarItem.divider());
}
dividedItems.add(filterItems[i]);
}
return dividedItems;
}
}

View File

@ -22,6 +22,7 @@ dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^2.0.0
network_image_mock: ^2.1.1
# For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec
@ -32,6 +33,7 @@ flutter:
assets:
- assets/images/toolbar/
- assets/images/selection_menu/
- assets/images/image_toolbar/
- assets/images/
#
# For details regarding assets in packages, see

View File

@ -57,6 +57,19 @@ class EditorWidgetTester {
);
}
void insertImageNode(String src, {String? align}) {
insert(
Node(
type: 'image',
children: LinkedList(),
attributes: {
'image_src': src,
'align': align ?? 'center',
},
),
);
}
Node? nodeAtPath(Path path) {
return root.childAtPath(path);
}

View File

@ -115,6 +115,9 @@ extension on LogicalKeyboardKey {
if (this == LogicalKeyboardKey.keyI) {
return PhysicalKeyboardKey.keyI;
}
if (this == LogicalKeyboardKey.keyK) {
return PhysicalKeyboardKey.keyK;
}
if (this == LogicalKeyboardKey.keyS) {
return PhysicalKeyboardKey.keyS;
}

View File

@ -0,0 +1,131 @@
import 'package:appflowy_editor/src/render/image/image_node_widget.dart';
import 'package:appflowy_editor/src/service/editor_service.dart';
import 'package:flutter/material.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 {
setUpAll(() {
TestWidgetsFlutterBinding.ensureInitialized();
});
group('image_node_builder.dart', () {
testWidgets('render image node', (tester) async {
mockNetworkImagesFor(() async {
const text = 'Welcome to Appflowy 😁';
const src =
'https://images.unsplash.com/photo-1471897488648-5eae4ac6686b?ixlib=rb-1.2.1&dl=sarah-dorweiler-QeVmJxZOv3k-unsplash.jpg&w=640&q=80&fm=jpg&crop=entropy&cs=tinysrgb';
final editor = tester.editor
..insertTextNode(text)
..insertImageNode(src)
..insertTextNode(text);
await editor.startTesting();
expect(editor.documentLength, 3);
expect(find.byType(Image), findsOneWidget);
});
});
testWidgets('render image align', (tester) async {
mockNetworkImagesFor(() async {
const text = 'Welcome to Appflowy 😁';
const src =
'https://images.unsplash.com/photo-1471897488648-5eae4ac6686b?ixlib=rb-1.2.1&dl=sarah-dorweiler-QeVmJxZOv3k-unsplash.jpg&w=640&q=80&fm=jpg&crop=entropy&cs=tinysrgb';
final editor = tester.editor
..insertTextNode(text)
..insertImageNode(src, align: 'left')
..insertImageNode(src, align: 'center')
..insertImageNode(src, align: 'right')
..insertTextNode(text);
await editor.startTesting();
expect(editor.documentLength, 5);
final imageFinder = find.byType(Image);
expect(imageFinder, findsNWidgets(3));
final editorFinder = find.byType(AppFlowyEditor);
final editorRect = tester.getRect(editorFinder);
final leftImageRect = tester.getRect(imageFinder.at(0));
expect(leftImageRect.left, editorRect.left);
final rightImageRect = tester.getRect(imageFinder.at(2));
expect(rightImageRect.right, editorRect.right);
final centerImageRect = tester.getRect(imageFinder.at(1));
expect(centerImageRect.left,
(leftImageRect.left + rightImageRect.left) / 2.0);
expect(leftImageRect.size, centerImageRect.size);
expect(rightImageRect.size, centerImageRect.size);
final imageNodeWidgetFinder = find.byType(ImageNodeWidget);
final leftImage =
tester.firstWidget(imageNodeWidgetFinder) as ImageNodeWidget;
leftImage.onAlign(Alignment.center);
await tester.pump(const Duration(milliseconds: 100));
expect(
tester.getRect(imageFinder.at(0)).left,
centerImageRect.left,
);
leftImage.onAlign(Alignment.centerRight);
await tester.pump(const Duration(milliseconds: 100));
expect(
tester.getRect(imageFinder.at(0)).left,
rightImageRect.left,
);
});
});
testWidgets('render image copy', (tester) async {
mockNetworkImagesFor(() async {
const text = 'Welcome to Appflowy 😁';
const src =
'https://images.unsplash.com/photo-1471897488648-5eae4ac6686b?ixlib=rb-1.2.1&dl=sarah-dorweiler-QeVmJxZOv3k-unsplash.jpg&w=640&q=80&fm=jpg&crop=entropy&cs=tinysrgb';
final editor = tester.editor
..insertTextNode(text)
..insertImageNode(src)
..insertTextNode(text);
await editor.startTesting();
expect(editor.documentLength, 3);
final imageFinder = find.byType(Image);
expect(imageFinder, findsOneWidget);
final imageNodeWidgetFinder = find.byType(ImageNodeWidget);
final image =
tester.firstWidget(imageNodeWidgetFinder) as ImageNodeWidget;
image.onCopy();
});
});
testWidgets('render image delete', (tester) async {
mockNetworkImagesFor(() async {
const text = 'Welcome to Appflowy 😁';
const src =
'https://images.unsplash.com/photo-1471897488648-5eae4ac6686b?ixlib=rb-1.2.1&dl=sarah-dorweiler-QeVmJxZOv3k-unsplash.jpg&w=640&q=80&fm=jpg&crop=entropy&cs=tinysrgb';
final editor = tester.editor
..insertTextNode(text)
..insertImageNode(src)
..insertImageNode(src)
..insertTextNode(text);
await editor.startTesting();
expect(editor.documentLength, 4);
final imageFinder = find.byType(Image);
expect(imageFinder, findsNWidgets(2));
final imageNodeWidgetFinder = find.byType(ImageNodeWidget);
final image =
tester.firstWidget(imageNodeWidgetFinder) as ImageNodeWidget;
image.onDelete();
await tester.pump(const Duration(milliseconds: 100));
expect(editor.documentLength, 3);
expect(find.byType(Image), findsNWidgets(1));
});
});
});
}

View File

@ -0,0 +1,81 @@
import 'package:appflowy_editor/src/render/image/image_node_widget.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:network_image_mock/network_image_mock.dart';
void main() async {
setUpAll(() {
TestWidgetsFlutterBinding.ensureInitialized();
});
group('image_node_widget.dart', () {
testWidgets('build the image node widget', (tester) async {
mockNetworkImagesFor(() async {
var onCopyHit = false;
var onDeleteHit = false;
var onAlignHit = false;
const src =
'https://images.unsplash.com/photo-1471897488648-5eae4ac6686b?ixlib=rb-1.2.1&dl=sarah-dorweiler-QeVmJxZOv3k-unsplash.jpg&w=640&q=80&fm=jpg&crop=entropy&cs=tinysrgb';
final widget = ImageNodeWidget(
src: src,
alignment: Alignment.center,
onCopy: () {
onCopyHit = true;
},
onDelete: () {
onDeleteHit = true;
},
onAlign: (alignment) {
onAlignHit = true;
},
onResize: (width) {},
);
await tester.pumpWidget(
MaterialApp(
home: Material(
child: widget,
),
),
);
expect(find.byType(ImageNodeWidget), findsOneWidget);
final gesture =
await tester.createGesture(kind: PointerDeviceKind.mouse);
await gesture.addPointer(location: Offset.zero);
expect(find.byType(ImageToolbar), findsNothing);
addTearDown(gesture.removePointer);
await tester.pump();
await gesture.moveTo(tester.getCenter(find.byType(ImageNodeWidget)));
await tester.pump();
expect(find.byType(ImageToolbar), findsOneWidget);
final iconFinder = find.byType(IconButton);
expect(iconFinder, findsNWidgets(5));
await tester.tap(iconFinder.at(0));
expect(onAlignHit, true);
onAlignHit = false;
await tester.tap(iconFinder.at(1));
expect(onAlignHit, true);
onAlignHit = false;
await tester.tap(iconFinder.at(2));
expect(onAlignHit, true);
onAlignHit = false;
await tester.tap(iconFinder.at(3));
expect(onCopyHit, true);
await tester.tap(iconFinder.at(4));
expect(onDeleteHit, true);
});
});
});
}

View File

@ -0,0 +1,41 @@
import 'package:appflowy_editor/src/render/link_menu/link_menu.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
void main() async {
setUpAll(() {
TestWidgetsFlutterBinding.ensureInitialized();
});
group('link_menu.dart', () {
testWidgets('test empty link menu actions', (tester) async {
const link = 'appflowy.io';
var submittedText = '';
final linkMenu = LinkMenu(
onCopyLink: () {},
onRemoveLink: () {},
onSubmitted: (text) {
submittedText = text;
},
);
await tester.pumpWidget(
MaterialApp(
home: Material(
child: linkMenu,
),
),
);
expect(find.byType(TextButton), findsNothing);
expect(find.byType(TextField), findsOneWidget);
await tester.tap(find.byType(TextField));
await tester.enterText(find.byType(TextField), link);
await tester.pumpAndSettle();
await tester.testTextInput.receiveAction(TextInputAction.done);
await tester.pumpAndSettle();
expect(submittedText, link);
});
});
}

View File

@ -20,7 +20,7 @@ void main() async {
name: 'example',
icon: icon,
keywords: ['example A', 'example B'],
handler: (editorState, menuService) {
handler: (editorState, menuService, context) {
flag = true;
},
);

View File

@ -25,7 +25,9 @@ void main() async {
find.byType(SelectionMenuWidget, skipOffstage: false),
findsNothing,
);
await _testDefaultSelectionMenuItems(i, editor);
if (defaultSelectionMenuItems[i].name != 'Image') {
await _testDefaultSelectionMenuItems(i, editor);
}
});
}
});

View File

@ -0,0 +1,46 @@
import 'package:appflowy_editor/src/render/toolbar/toolbar_item.dart';
import 'package:appflowy_editor/src/render/toolbar/toolbar_item_widget.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
void main() async {
setUpAll(() {
TestWidgetsFlutterBinding.ensureInitialized();
});
group('toolbar_item_widget.dart', () {
testWidgets('test single toolbar item widget', (tester) async {
final key = GlobalKey();
var hit = false;
final item = ToolbarItem(
id: 'appflowy.toolbar.test',
type: 1,
icon: const Icon(Icons.abc),
validator: (editorState) => true,
handler: (editorState, context) {},
);
final widget = ToolbarItemWidget(
key: key,
item: item,
onPressed: (() {
hit = true;
}),
);
await tester.pumpWidget(
MaterialApp(
home: Material(
child: widget,
),
),
);
expect(find.byKey(key), findsOneWidget);
await tester.tap(find.byKey(key));
await tester.pumpAndSettle();
expect(hit, true);
});
});
}

View File

@ -0,0 +1,11 @@
import 'package:flutter_test/flutter_test.dart';
void main() async {
setUpAll(() {
TestWidgetsFlutterBinding.ensureInitialized();
});
group('toolbar_widget.dart', () {
testWidgets('test toolbar widget', (tester) async {});
});
}

View File

@ -1,6 +1,10 @@
import 'package:appflowy_editor/appflowy_editor.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/extensions/text_node_extensions.dart';
import 'package:appflowy_editor/src/render/toolbar/toolbar_item.dart';
import 'package:appflowy_editor/src/render/toolbar/toolbar_widget.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../infra/test_editor.dart';
@ -54,6 +58,10 @@ void main() async {
LogicalKeyboardKey.keyH,
);
});
testWidgets('Presses Command + K to trigger link menu', (tester) async {
await _testLinkMenuInSingleTextSelection(tester);
});
});
}
@ -82,7 +90,14 @@ Future<void> _testUpdateTextStyleByCommandX(
);
var textNode = editor.nodeAtPath([1]) as TextNode;
expect(
textNode.allSatisfyInSelection(matchStyle, matchValue, selection), true);
textNode.allSatisfyInSelection(
matchStyle,
selection,
(value) {
return value == matchValue;
},
),
true);
selection =
Selection.single(path: [1], startOffset: 0, endOffset: text.length);
@ -94,7 +109,14 @@ Future<void> _testUpdateTextStyleByCommandX(
);
textNode = editor.nodeAtPath([1]) as TextNode;
expect(
textNode.allSatisfyInSelection(matchStyle, matchValue, selection), true);
textNode.allSatisfyInSelection(
matchStyle,
selection,
(value) {
return value == matchValue;
},
),
true);
await editor.updateSelection(selection);
await editor.pressLogicKey(
@ -123,9 +145,14 @@ Future<void> _testUpdateTextStyleByCommandX(
expect(
node.allSatisfyInSelection(
matchStyle,
matchValue,
Selection.single(
path: node.path, startOffset: 0, endOffset: text.length),
path: node.path,
startOffset: 0,
endOffset: text.length,
),
(value) {
return value == matchValue;
},
),
true,
);
@ -152,3 +179,74 @@ Future<void> _testUpdateTextStyleByCommandX(
);
}
}
Future<void> _testLinkMenuInSingleTextSelection(WidgetTester tester) async {
const link = 'appflowy.io';
const text = 'Welcome to Appflowy 😁';
final editor = tester.editor
..insertTextNode(text)
..insertTextNode(text)
..insertTextNode(text);
await editor.startTesting();
final selection =
Selection.single(path: [1], startOffset: 0, endOffset: text.length);
await editor.updateSelection(selection);
// show toolbar
expect(find.byType(ToolbarWidget), findsOneWidget);
final item = defaultToolbarItems
.where((item) => item.id == 'appflowy.toolbar.link')
.first;
expect(find.byWidget(item.icon), findsOneWidget);
// trigger the link menu
await editor.pressLogicKey(LogicalKeyboardKey.keyK, isMetaPressed: true);
expect(find.byType(LinkMenu), findsOneWidget);
await tester.enterText(find.byType(TextField), link);
await tester.testTextInput.receiveAction(TextInputAction.done);
await tester.pumpAndSettle();
expect(find.byType(LinkMenu), findsNothing);
final node = editor.nodeAtPath([1]) as TextNode;
expect(
node.allSatisfyInSelection(
StyleKey.href,
selection,
(value) => value == link,
),
true);
await editor.updateSelection(selection);
await editor.pressLogicKey(LogicalKeyboardKey.keyK, isMetaPressed: true);
expect(find.byType(LinkMenu), findsOneWidget);
expect(
find.text(link, findRichText: true, skipOffstage: false), findsOneWidget);
// Copy link
final copyLink = find.text('Copy link');
expect(copyLink, findsOneWidget);
await tester.tap(copyLink);
await tester.pumpAndSettle();
expect(find.byType(LinkMenu), findsNothing);
// Remove link
await editor.pressLogicKey(LogicalKeyboardKey.keyK, isMetaPressed: true);
final removeLink = find.text('Remove link');
expect(removeLink, findsOneWidget);
await tester.tap(removeLink);
await tester.pumpAndSettle();
expect(find.byType(LinkMenu), findsNothing);
expect(
node.allSatisfyInSelection(
StyleKey.href,
selection,
(value) => value == link,
),
false);
}

View File

@ -0,0 +1,36 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor/src/render/toolbar/toolbar_item.dart';
import 'package:appflowy_editor/src/render/toolbar/toolbar_widget.dart';
import 'package:flutter_test/flutter_test.dart';
import '../infra/test_editor.dart';
void main() async {
setUpAll(() {
TestWidgetsFlutterBinding.ensureInitialized();
});
group('toolbar_service.dart', () {
testWidgets('Test toolbar service in multi text selection', (tester) async {
const text = 'Welcome to Appflowy 😁';
final editor = tester.editor
..insertTextNode(text)
..insertTextNode(text)
..insertTextNode(text);
await editor.startTesting();
final selection = Selection(
start: Position(path: [0], offset: 0),
end: Position(path: [1], offset: text.length),
);
await editor.updateSelection(selection);
expect(find.byType(ToolbarWidget), findsOneWidget);
// no link item
final item = defaultToolbarItems
.where((item) => item.id == 'appflowy.toolbar.link')
.first;
expect(find.byWidget(item.icon), findsNothing);
});
});
}