mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2024-08-30 18:12:39 +00:00
feat: add image entry into selection menu
This commit is contained in:
parent
3832af5fa8
commit
6af85fbe56
@ -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 |
@ -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"
|
||||
}
|
||||
},
|
||||
|
@ -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();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -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,
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
@ -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 {
|
||||
@ -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) {
|
||||
|
@ -30,6 +30,7 @@ void main() async {
|
||||
onAlign: (alignment) {
|
||||
onAlignHit = true;
|
||||
},
|
||||
onResize: (width) {},
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
|
@ -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;
|
||||
},
|
||||
);
|
||||
|
Loading…
x
Reference in New Issue
Block a user