diff --git a/example/lib/universal_ui/universal_ui.dart b/example/lib/universal_ui/universal_ui.dart index 55f54a7e..d214fc54 100644 --- a/example/lib/universal_ui/universal_ui.dart +++ b/example/lib/universal_ui/universal_ui.dart @@ -25,7 +25,8 @@ class UniversalUI { var ui = UniversalUI(); -Widget defaultEmbedBuilderWeb(BuildContext context, Embed node, bool readOnly) { +Widget defaultEmbedBuilderWeb(BuildContext context, QuillController controller, + Embed node, bool readOnly) { switch (node.value.type) { case 'image': final imageUrl = node.value.data; diff --git a/lib/src/widgets/controller.dart b/lib/src/widgets/controller.dart index a6a57f86..e03d8978 100644 --- a/lib/src/widgets/controller.dart +++ b/lib/src/widgets/controller.dart @@ -1,11 +1,13 @@ import 'dart:math' as math; import 'package:flutter/cupertino.dart'; +import 'package:flutter/services.dart'; import 'package:tuple/tuple.dart'; import '../models/documents/attribute.dart'; import '../models/documents/document.dart'; import '../models/documents/nodes/embeddable.dart'; +import '../models/documents/nodes/leaf.dart'; import '../models/documents/style.dart'; import '../models/quill_delta.dart'; import '../utils/delta.dart'; @@ -327,6 +329,21 @@ class QuillController extends ChangeNotifier { extentOffset: math.min(selection.extentOffset, end)); } + /// Given offset, find its leaf node in document + Leaf? queryNode(int offset) { + return document.querySegmentLeafNode(offset).item2; + } + + /// Clipboard for image url + String? _copiedImageUrl; + + String? getCopiedImageUrl() => _copiedImageUrl; + + set copiedImageUrl(String? value) { + _copiedImageUrl = value; + Clipboard.setData(const ClipboardData(text: '')); + } + // Notify toolbar buttons directly with attributes Map toolbarButtonToggler = {}; } diff --git a/lib/src/widgets/delegate.dart b/lib/src/widgets/delegate.dart index 17a5c723..21961871 100644 --- a/lib/src/widgets/delegate.dart +++ b/lib/src/widgets/delegate.dart @@ -5,8 +5,8 @@ import 'package:flutter/material.dart'; import '../../flutter_quill.dart'; import 'text_selection.dart'; -typedef EmbedBuilder = Widget Function( - BuildContext context, Embed node, bool readOnly); +typedef EmbedBuilder = Widget Function(BuildContext context, + QuillController controller, Embed node, bool readOnly); typedef CustomStyleBuilder = TextStyle Function(Attribute attribute); diff --git a/lib/src/widgets/embeds/default_embed_builder.dart b/lib/src/widgets/embeds/default_embed_builder.dart index 00b62d07..c34ee2e4 100644 --- a/lib/src/widgets/embeds/default_embed_builder.dart +++ b/lib/src/widgets/embeds/default_embed_builder.dart @@ -1,3 +1,6 @@ +import 'dart:math'; + +import 'package:flutter/cupertino.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:gallery_saver/gallery_saver.dart'; @@ -6,12 +9,14 @@ import '../../models/documents/nodes/leaf.dart' as leaf; import '../../translations/toolbar.i18n.dart'; import '../../utils/platform.dart'; import '../../utils/string.dart'; +import '../controller.dart'; import 'image.dart'; +import 'image_resizer.dart'; import 'video_app.dart'; import 'youtube_video_app.dart'; -Widget defaultEmbedBuilder( - BuildContext context, leaf.Embed node, bool readOnly) { +Widget defaultEmbedBuilder(BuildContext context, QuillController controller, + leaf.Embed node, bool readOnly) { assert(!kIsWeb, 'Please provide EmbedBuilder for Web'); switch (node.value.type) { @@ -40,34 +45,68 @@ Widget defaultEmbedBuilder( image ??= imageByUrl(imageUrl); if (!readOnly && isMobile()) { - // TODO: slider for width and height - // return GestureDetector( - // onTap: () { - // showDialog( - // context: context, - // builder: (context) => Padding( - // padding: const EdgeInsets.fromLTRB(50, 0, 50, 0), - // child: SimpleDialog( - // shape: const RoundedRectangleBorder( - // borderRadius: - // BorderRadius.all(Radius.circular(10))), - // children: [ - // _SimpleDialogItem( - // icon: Icons.settings_outlined, - // color: Colors.lightBlueAccent, - // text: 'Resize'.i18n, - // onPressed: () {}, - // ), - // _SimpleDialogItem( - // icon: Icons.delete_forever_outlined, - // color: Colors.red.shade200, - // text: 'Remove'.i18n, - // onPressed: () {}, - // ) - // ]), - // )); - // }, - // child: image); + return GestureDetector( + onTap: () { + showDialog( + context: context, + builder: (context) { + final resizeOption = _SimpleDialogItem( + icon: Icons.settings_outlined, + color: Colors.lightBlueAccent, + text: 'Resize'.i18n, + onPressed: () { + Navigator.pop(context); + showCupertinoModalPopup( + context: context, + builder: (context) { + return const ImageResizer(); + }); + }, + ); + final copyOption = _SimpleDialogItem( + icon: Icons.copy_all_outlined, + color: Colors.cyanAccent, + text: 'Copy'.i18n, + onPressed: () { + var offset = controller.selection.start; + var imageNode = controller.queryNode(offset); + if (imageNode == null || !(imageNode is leaf.Embed)) { + offset = max(0, offset - 1); + imageNode = controller.queryNode(offset); + } + if (imageNode != null && imageNode is leaf.Embed) { + final imageUrl = imageNode.value.data; + controller.copiedImageUrl = imageUrl; + } + Navigator.pop(context); + }, + ); + final removeOption = _SimpleDialogItem( + icon: Icons.delete_forever_outlined, + color: Colors.red.shade200, + text: 'Remove'.i18n, + onPressed: () { + var offset = controller.selection.start; + final imageNode = controller.queryNode(offset); + if (imageNode == null || !(imageNode is leaf.Embed)) { + offset = max(0, offset - 1); + } + controller.replaceText(offset, 1, '', + TextSelection.collapsed(offset: offset)); + Navigator.pop(context); + }, + ); + return Padding( + padding: const EdgeInsets.fromLTRB(50, 0, 50, 0), + child: SimpleDialog( + shape: const RoundedRectangleBorder( + borderRadius: + BorderRadius.all(Radius.circular(10))), + children: [copyOption, removeOption]), + ); + }); + }, + child: image); } if (!readOnly || !isMobile() || isImageBase64(imageUrl)) { diff --git a/lib/src/widgets/embeds/image_resizer.dart b/lib/src/widgets/embeds/image_resizer.dart new file mode 100644 index 00000000..9f6cd236 --- /dev/null +++ b/lib/src/widgets/embeds/image_resizer.dart @@ -0,0 +1,43 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; + +class ImageResizer extends StatefulWidget { + const ImageResizer({Key? key}) : super(key: key); + + @override + _ImageResizerState createState() => _ImageResizerState(); +} + +class _ImageResizerState extends State { + @override + Widget build(BuildContext context) { + return CupertinoActionSheet(actions: [ + CupertinoActionSheetAction( + onPressed: () {}, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Card( + child: Slider( + value: 50, + max: 100, + divisions: 5, + onChanged: (val) {}, + ), + )), + ), + CupertinoActionSheetAction( + onPressed: () {}, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Card( + child: Slider( + value: 10, + max: 100, + divisions: 5, + onChanged: (val) {}, + ), + )), + ) + ]); + } +} diff --git a/lib/src/widgets/raw_editor.dart b/lib/src/widgets/raw_editor.dart index d1d2a6d7..f77bda64 100644 --- a/lib/src/widgets/raw_editor.dart +++ b/lib/src/widgets/raw_editor.dart @@ -15,6 +15,7 @@ import 'package:tuple/tuple.dart'; import '../models/documents/attribute.dart'; import '../models/documents/document.dart'; import '../models/documents/nodes/block.dart'; +import '../models/documents/nodes/embeddable.dart'; import '../models/documents/nodes/line.dart'; import '../models/documents/nodes/node.dart'; import '../models/documents/style.dart'; @@ -880,6 +881,7 @@ class RawEditorState extends EditorState @override void copySelection(SelectionChangedCause cause) { + widget.controller.copiedImageUrl = null; _pastePlainText = widget.controller.getPlainText(); _pasteStyle = widget.controller.getAllIndividualSelectionStyles(); // Copied straight from EditableTextState @@ -904,8 +906,10 @@ class RawEditorState extends EditorState @override void cutSelection(SelectionChangedCause cause) { + widget.controller.copiedImageUrl = null; _pastePlainText = widget.controller.getPlainText(); _pasteStyle = widget.controller.getAllIndividualSelectionStyles(); + // Copied straight from EditableTextState super.cutSelection(cause); if (cause == SelectionChangedCause.toolbar) { @@ -916,8 +920,19 @@ class RawEditorState extends EditorState @override Future pasteText(SelectionChangedCause cause) async { + if (widget.controller.getCopiedImageUrl() != null) { + final index = textEditingValue.selection.baseOffset; + final length = textEditingValue.selection.extentOffset - index; + widget.controller.replaceText(index, length, + BlockEmbed.image(widget.controller.getCopiedImageUrl()!), null); + widget.controller.copiedImageUrl = null; + await Clipboard.setData(const ClipboardData(text: '')); + return; + } + // Copied straight from EditableTextState super.pasteText(cause); // ignore: unawaited_futures + if (cause == SelectionChangedCause.toolbar) { bringIntoView(textEditingValue.selection.extent); hideToolbar(); diff --git a/lib/src/widgets/text_line.dart b/lib/src/widgets/text_line.dart index 9d98254e..ef761237 100644 --- a/lib/src/widgets/text_line.dart +++ b/lib/src/widgets/text_line.dart @@ -133,7 +133,8 @@ class _TextLineState extends State { if (widget.line.hasEmbed && widget.line.childCount == 1) { // For video, it is always single child final embed = widget.line.children.single as Embed; - return EmbedProxy(widget.embedBuilder(context, embed, widget.readOnly)); + return EmbedProxy(widget.embedBuilder( + context, widget.controller, embed, widget.readOnly)); } final textSpan = _getTextSpanForWholeLine(context); final strutStyle = StrutStyle.fromTextStyle(textSpan.style!); @@ -173,8 +174,8 @@ class _TextLineState extends State { } // Here it should be image final embed = WidgetSpan( - child: EmbedProxy( - widget.embedBuilder(context, child, widget.readOnly))); + child: EmbedProxy(widget.embedBuilder( + context, widget.controller, child, widget.readOnly))); textSpanChildren.add(embed); continue; }