feat: support resize, copy, align, delete in image ndoe widget
@ -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 |
@ -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"
|
||||
}
|
||||
},
|
||||
{
|
||||
@ -58,6 +59,13 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "image",
|
||||
"attributes": {
|
||||
"image_src": "https://images.unsplash.com/photo-1616530940355-351fabd9524b?ixlib=rb-1.2.1&q=80&cs=tinysrgb&fm=jpg",
|
||||
"align": "center"
|
||||
}
|
||||
},
|
||||
{
|
||||
"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()
|
||||
},
|
||||
),
|
||||
|
@ -0,0 +1,64 @@
|
||||
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'];
|
||||
return ImageNodeWidget(
|
||||
key: context.node.key,
|
||||
src: src,
|
||||
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();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@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 == 'center') {
|
||||
return Alignment.center;
|
||||
} else if (text == 'left') {
|
||||
return Alignment.centerLeft;
|
||||
} else if (text == 'right') {
|
||||
return Alignment.centerRight;
|
||||
}
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
String _alignmentToText(Alignment alignment) {
|
||||
if (alignment == Alignment.center) {
|
||||
return 'center';
|
||||
} else if (alignment == Alignment.centerLeft) {
|
||||
return 'left';
|
||||
} else if (alignment == Alignment.centerRight) {
|
||||
return 'right';
|
||||
}
|
||||
throw UnimplementedError();
|
||||
}
|
||||
}
|
@ -0,0 +1,276 @@
|
||||
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,
|
||||
required this.alignment,
|
||||
required this.onCopy,
|
||||
required this.onDelete,
|
||||
required this.onAlign,
|
||||
}) : super(key: key);
|
||||
|
||||
final String src;
|
||||
final Alignment alignment;
|
||||
final VoidCallback onCopy;
|
||||
final VoidCallback onDelete;
|
||||
final void Function(Alignment alignment) onAlign;
|
||||
|
||||
@override
|
||||
State<ImageNodeWidget> createState() => _ImageNodeWidgetState();
|
||||
}
|
||||
|
||||
class _ImageNodeWidgetState extends State<ImageNodeWidget> {
|
||||
double? imageWidth = defaultMaxTextNodeWidth;
|
||||
double _initial = 0;
|
||||
double _distance = 0;
|
||||
bool _onFocus = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// only support network image.
|
||||
return _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,
|
||||
loadingBuilder: (context, child, loadingProgress) =>
|
||||
loadingProgress == null
|
||||
? child
|
||||
: SizedBox(
|
||||
width: imageWidth,
|
||||
height: 300,
|
||||
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'),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
if (imageWidth == null) {
|
||||
networkImage.image.resolve(const ImageConfiguration()).addListener(
|
||||
ImageStreamListener(
|
||||
(image, _) {
|
||||
imageWidth = image.image.width.toDouble();
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
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)
|
||||
_buildImageToolbar(
|
||||
context,
|
||||
top: 8,
|
||||
right: 8,
|
||||
height: 30,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
},
|
||||
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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildImageToolbar(
|
||||
BuildContext context, {
|
||||
double? top,
|
||||
double? left,
|
||||
double? right,
|
||||
double? width,
|
||||
double? height,
|
||||
}) {
|
||||
return Positioned(
|
||||
top: top,
|
||||
left: left,
|
||||
right: right,
|
||||
width: width,
|
||||
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: widget.alignment == Alignment.centerLeft
|
||||
? const Color(0xFF00BCF0)
|
||||
: null,
|
||||
),
|
||||
onPressed: () {
|
||||
widget.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: widget.alignment == Alignment.center
|
||||
? const Color(0xFF00BCF0)
|
||||
: null,
|
||||
),
|
||||
onPressed: () {
|
||||
widget.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: widget.alignment == Alignment.centerRight
|
||||
? const Color(0xFF00BCF0)
|
||||
: null,
|
||||
),
|
||||
onPressed: () {
|
||||
widget.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: () {
|
||||
widget.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: () {
|
||||
widget.onDelete();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -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 {
|
||||
|
@ -32,6 +32,7 @@ flutter:
|
||||
assets:
|
||||
- assets/images/toolbar/
|
||||
- assets/images/selection_menu/
|
||||
- assets/images/image_toolbar/
|
||||
- assets/images/
|
||||
#
|
||||
# For details regarding assets in packages, see
|
||||
|