feat: support resize, copy, align, delete in image ndoe widget

This commit is contained in:
Lucas.Xu 2022-08-23 20:24:54 +08:00
parent a2c4f73e7d
commit 82f1f0e3e3
13 changed files with 386 additions and 3 deletions

View File

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

After

Width:  |  Height:  |  Size: 355 B

View File

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

After

Width:  |  Height:  |  Size: 355 B

View File

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

After

Width:  |  Height:  |  Size: 355 B

View File

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

After

Width:  |  Height:  |  Size: 781 B

View File

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

After

Width:  |  Height:  |  Size: 875 B

View File

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

After

Width:  |  Height:  |  Size: 155 B

View File

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

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -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": [

View File

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

View File

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

View File

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

View File

@ -1,3 +1,4 @@
import 'package:appflowy_editor/src/render/image/image_node_builder.dart';
import 'package:appflowy_editor/src/render/selection_menu/selection_menu_widget.dart';
import 'package:appflowy_editor/src/service/internal_key_event_handlers/default_key_event_handlers.dart';
import 'package:flutter/material.dart';
@ -25,6 +26,7 @@ NodeWidgetBuilders defaultBuilders = {
'text/bulleted-list': BulletedListTextNodeWidgetBuilder(),
'text/number-list': NumberListTextNodeWidgetBuilder(),
'text/quote': QuotedTextNodeWidgetBuilder(),
'image': ImageNodeBuilder(),
};
class AppFlowyEditor extends StatefulWidget {

View File

@ -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