feat: add image entry into selection menu

This commit is contained in:
Lucas.Xu 2022-08-24 15:42:01 +08:00
parent 3832af5fa8
commit 6af85fbe56
10 changed files with 270 additions and 27 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">
<rect x="1.5" y="3" width="13" height="10" rx="1.5" stroke="#333333"/>
<circle cx="5.5" cy="6.5" r="1" stroke="#333333"/>
<path d="M5 13L10.112 8.45603C10.4211 8.18126 10.8674 8.12513 11.235 8.31482L14.5 10" stroke="#333333"/>
</svg>

After

Width:  |  Height:  |  Size: 330 B

View File

@ -62,7 +62,7 @@
{
"type": "image",
"attributes": {
"image_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",
"image_src": "https://s1.ax1x.com/2022/08/24/vgAJED.png",
"align": "center"
}
},

View File

@ -11,9 +11,11 @@ class ImageNodeBuilder extends NodeWidgetBuilder<Node> {
Widget build(NodeWidgetContext<Node> context) {
final src = context.node.attributes['image_src'];
final align = context.node.attributes['align'];
final width = context.node.attributes['width'];
return ImageNodeWidget(
key: context.node.key,
src: src,
width: width,
alignment: _textToAlignment(align),
onCopy: () {
RichClipboard.setData(RichClipboardData(text: src));
@ -30,6 +32,13 @@ class ImageNodeBuilder extends NodeWidgetBuilder<Node> {
})
..commit();
},
onResize: (width) {
TransactionBuilder(context.editorState)
..updateNode(context.node, {
'width': width,
})
..commit();
},
);
}

View File

@ -1,33 +1,57 @@
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 = defaultMaxTextNodeWidth;
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.
@ -52,18 +76,13 @@ class _ImageNodeWidgetState extends State<ImageNodeWidget> {
Widget _buildResizableImage(BuildContext context) {
final networkImage = Image.network(
widget.src,
width: imageWidth == null ? null : imageWidth! - _distance,
width: _imageWidth == null ? null : _imageWidth! - _distance,
loadingBuilder: (context, child, loadingProgress) =>
loadingProgress == null ? child : _buildLoading(context),
);
if (imageWidth == null) {
networkImage.image.resolve(const ImageConfiguration()).addListener(
ImageStreamListener(
(image, _) {
imageWidth = image.image.width.toDouble();
},
),
);
if (_imageWidth == null) {
_imageStream = networkImage.image.resolve(const ImageConfiguration())
..addListener(_imageStreamListener);
}
return Stack(
children: [
@ -108,7 +127,7 @@ class _ImageNodeWidgetState extends State<ImageNodeWidget> {
Widget _buildLoading(BuildContext context) {
return SizedBox(
width: imageWidth,
width: _imageWidth,
height: 300,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
@ -151,9 +170,11 @@ class _ImageNodeWidgetState extends State<ImageNodeWidget> {
}
},
onHorizontalDragEnd: (details) {
imageWidth = imageWidth! - _distance;
_imageWidth = _imageWidth! - _distance;
_initial = 0;
_distance = 0;
widget.onResize(_imageWidth!);
},
child: MouseRegion(
cursor: SystemMouseCursors.resizeLeftRight,

View File

@ -0,0 +1,194 @@
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;
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!);
}
void _dismissImageUploadMenu() {
_imageUploadMenu?.remove();
_imageUploadMenu = null;
}
class ImageUploadMenu extends StatefulWidget {
const ImageUploadMenu({
Key? key,
required this.onSubmitted,
required this.onUpload,
}) : super(key: key);
final void Function(String text) onSubmitted;
final void Function(String text) onUpload;
@override
State<ImageUploadMenu> createState() => _ImageUploadMenuState();
}
class _ImageUploadMenuState extends State<ImageUploadMenu> {
final _textEditingController = TextEditingController();
final _focusNode = FocusNode();
@override
void initState() {
super.initState();
_focusNode.requestFocus();
}
@override
void dispose() {
_focusNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Container(
width: 300,
padding: const EdgeInsets.all(24.0),
decoration: BoxDecoration(
color: Colors.white,
boxShadow: [
BoxShadow(
blurRadius: 5,
spreadRadius: 1,
color: Colors.black.withOpacity(0.1),
),
],
borderRadius: BorderRadius.circular(6.0),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildHeader(context),
const SizedBox(height: 16.0),
_buildInput(),
const SizedBox(height: 18.0),
_buildUploadButton(context),
],
),
);
}
Widget _buildHeader(BuildContext context) {
return const Text(
'URL Image',
textAlign: TextAlign.left,
style: TextStyle(
fontSize: 14.0,
color: Colors.black,
fontWeight: FontWeight.w500,
),
);
}
Widget _buildInput() {
return TextField(
focusNode: _focusNode,
style: const TextStyle(fontSize: 14.0),
textAlign: TextAlign.left,
controller: _textEditingController,
onSubmitted: widget.onSubmitted,
decoration: InputDecoration(
hintText: 'URL',
hintStyle: const TextStyle(fontSize: 14.0),
contentPadding: const EdgeInsets.all(16.0),
isDense: true,
suffixIcon: IconButton(
padding: const EdgeInsets.all(4.0),
icon: const FlowySvg(
name: 'clear',
width: 24,
height: 24,
),
onPressed: () {
_textEditingController.clear();
},
),
border: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12.0)),
borderSide: BorderSide(color: Color(0xFFBDBDBD)),
),
),
);
}
Widget _buildUploadButton(BuildContext context) {
return SizedBox(
width: 170,
height: 48,
child: TextButton(
style: ButtonStyle(
backgroundColor: MaterialStateProperty.all(const Color(0xFF00BCF0)),
shape: MaterialStateProperty.all<RoundedRectangleBorder>(
RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12.0),
),
),
),
onPressed: () {
widget.onUpload(_textEditingController.text);
},
child: const Text(
'Upload',
style: TextStyle(color: Colors.white, fontSize: 14.0),
),
),
);
}
}
extension on EditorState {
void insertImageNode(String src) {
final selection = service.selectionService.currentSelection.value;
if (selection == null) {
return;
}
final imageNode = Node(
type: 'image',
children: LinkedList(),
attributes: {
'image_src': src,
'align': 'center',
},
);
TransactionBuilder(this)
..insertNode(
selection.start.path,
imageNode,
)
..commit();
}
}

View File

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

View File

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

View File

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

View File

@ -30,6 +30,7 @@ void main() async {
onAlign: (alignment) {
onAlignHit = true;
},
onResize: (width) {},
);
await tester.pumpWidget(

View File

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