Merge pull request #908 from LucasXu0/feat/image
Integrate image plugin into appflowy_editor
@ -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 |
@ -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 |
@ -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 |
@ -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 |
@ -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 |
@ -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 |
@ -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 |
@ -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 |
@ -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 |
@ -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 |
@ -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 |
@ -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 |
@ -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": [
|
||||
|
@ -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()
|
||||
},
|
||||
),
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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.
|
||||
|
@ -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,
|
||||
|
@ -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';
|
||||
}
|
||||
}
|
@ -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();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -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();
|
||||
}
|
||||
}
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
@ -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(
|
||||
|
@ -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: () {
|
||||
|
@ -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,
|
||||
|
@ -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(
|
||||
|
@ -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;
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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');
|
||||
}
|
||||
}
|
@ -45,7 +45,7 @@ class SelectionMenuItemWidget extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
onPressed: () {
|
||||
item.handler(editorState, menuService);
|
||||
item.handler(editorState, menuService, context);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
@ -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);
|
||||
},
|
||||
),
|
||||
|
@ -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) {
|
||||
|
@ -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;
|
||||
}
|
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -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 {
|
||||
|
@ -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 {
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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));
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
@ -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;
|
||||
},
|
||||
);
|
||||
|
@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
@ -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 {});
|
||||
});
|
||||
}
|
@ -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);
|
||||
}
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|