diff --git a/CHANGELOG.md b/CHANGELOG.md index 7dbb538f..0a9b7268 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,6 @@ +## [8.4.1] +- Add `copyWith` in `OptionalSize` class + ## [8.4.0] - **Breaking change**: Update the `QuillCustomButton` to have `QuillCustomButtonOptions`. We moved everything that is in the `QuillCustomButton` to `QuillCustomButtonOptions` but replaced the `iconData` with `icon` widget for more customizations - **Breaking change**: the `customButtons` in the `QuillToolbarConfigurations` is now of type `List` diff --git a/README.md b/README.md index 9bc98324..ae001b81 100644 --- a/README.md +++ b/README.md @@ -66,10 +66,13 @@ dependencies: git: https://github.com/singerdmx/flutter-quill.git ``` + +> +> Note: At this time, we are making too many changes to the library and you might see new version almost every day > > Using the latest version and reporting any issues you encounter on GitHub will greatly contribute to the improvement of the library. Your input and insights are valuable in shaping a stable and reliable version for all our users. Thank you for being part of the open-source community! > -> Please use the latest pre-release of [FlutterQuill Extensions] in order to work with the latest stable version of [FlutterQuill] +> If the latest version of [FlutterQuill Extensions] is pre-release, then please use it in order to work with the latest stable version of [FlutterQuill] > ## Usage @@ -297,7 +300,9 @@ Made with [contrib.rocks](https://contrib.rocks). We welcome contributions! -Please follow these guidelines when contributing to our project. See [CONTRIBUTING.md](./doc/CONTRIBUTING.md) for more details. +Please follow these guidelines when contributing to the project. See [CONTRIBUTING.md](./doc/CONTRIBUTING.md) for more details.
+ +You can check the [Todo](./doc/todo.md) list if you want to [Quill]: https://quilljs.com/docs/formats [Flutter]: https://github.com/flutter/flutter diff --git a/doc/CONTRIBUTING.md b/doc/CONTRIBUTING.md index fbbbf649..b3a51e4b 100644 --- a/doc/CONTRIBUTING.md +++ b/doc/CONTRIBUTING.md @@ -3,6 +3,8 @@ The contributions are more than welcome!
This project will be better with the open-source community help +You can check the [Todo](./todo.md) list if you want to + There are no guidelines for now. This page will be updated in the future. diff --git a/doc/todo.md b/doc/todo.md new file mode 100644 index 00000000..ab746b7e --- /dev/null +++ b/doc/todo.md @@ -0,0 +1,48 @@ +# Todo + +This is a todo list page that added recently and will be updated soon. + +## Table of contents +- [Todo](#todo) + - [Table of contents](#table-of-contents) + - [Flutter Quill](#flutter-quill) + - [Features](#features) + - [Improvemenets](#improvemenets) + - [Bugs](#bugs) + - [Flutter Quill Extensions](#flutter-quill-extensions) + - [Features](#features-1) + - [Improvemenets](#improvemenets-1) + - [Bugs](#bugs-1) + +## Flutter Quill + +### Features + + 1. Add a method to set TextInputAction, fore more [info](https://github.com/singerdmx/flutter-quill/issues/1328) + 2. Add support for Text magnification feature, for more [info](https://github.com/singerdmx/flutter-quill/issues/1504) + 3. Provide a way to expose quills undo redo stacks, for more [info](https://github.com/singerdmx/flutter-quill/issues/1381) + +### Improvemenets + + 1. Improve the Raw Quill Editor, for more [info](https://github.com/singerdmx/flutter-quill/issues/1509) + 2. Provide more support to all the platforms + +### Bugs + +Empty for now. +Please go to the [issues](https://github.com/singerdmx/flutter-quill/issues) + + +## Flutter Quill Extensions + +### Features +1. Add support for cropping an image, fore more [info](https://github.com/singerdmx/flutter-quill/issues/1494) +2. Add support for copying images to the Clipboard + +### Improvemenets + +Please check the todos, this list will be updated soon. + +### Bugs + +Please check the todos, this list will be updated soon. \ No newline at end of file diff --git a/example/assets/sample_data_testing.json b/example/assets/sample_data_testing.json index bd725222..45cb311f 100644 --- a/example/assets/sample_data_testing.json +++ b/example/assets/sample_data_testing.json @@ -1,6 +1,25 @@ [ { - "insert": "Here is an image: \n" + "insert": "This is an asset image: \n" + }, + { + "insert": "\n" + }, + { + "insert": { + "image": "assets/images/1.png" + }, + "attributes": { + "width": "100", + "height": "100", + "style": "width:500px; height:350px;" + } + }, + { + "insert": "\n" + }, + { + "insert": "Here is a network image: \n" }, { "insert": "\n" diff --git a/example/lib/pages/home_page.dart b/example/lib/pages/home_page.dart index 379b4f2a..c36e834c 100644 --- a/example/lib/pages/home_page.dart +++ b/example/lib/pages/home_page.dart @@ -397,7 +397,7 @@ class _HomePageState extends State { sizeSmall: TextStyle(fontSize: 9), ), embedBuilders: [ - ...FlutterQuillEmbeds.editorsWebBuilders(), + ...FlutterQuillEmbeds.editorWebBuilders(), TimeStampEmbedBuilderWidget() ], ), @@ -444,9 +444,8 @@ class _HomePageState extends State { ), embedBuilders: [ ...FlutterQuillEmbeds.editorBuilders( - imageEmbedConfigurations: const QuillEditorImageEmbedConfigurations( - forceUseMobileOptionMenuForImageClick: true, - ), + imageEmbedConfigurations: + const QuillEditorImageEmbedConfigurations(), ), TimeStampEmbedBuilderWidget() ], diff --git a/example/lib/pages/read_only_page.dart b/example/lib/pages/read_only_page.dart index 9670c152..5d4ff051 100644 --- a/example/lib/pages/read_only_page.dart +++ b/example/lib/pages/read_only_page.dart @@ -42,7 +42,7 @@ class _ReadOnlyPageState extends State { expands: false, padding: EdgeInsets.zero, embedBuilders: kIsWeb - ? FlutterQuillEmbeds.editorsWebBuilders() + ? FlutterQuillEmbeds.editorWebBuilders() : FlutterQuillEmbeds.editorBuilders(), scrollable: true, autoFocus: true, @@ -57,7 +57,7 @@ class _ReadOnlyPageState extends State { autoFocus: true, expands: false, padding: EdgeInsets.zero, - embedBuilders: FlutterQuillEmbeds.editorsWebBuilders(), + embedBuilders: FlutterQuillEmbeds.editorWebBuilders(), scrollable: true, ), scrollController: ScrollController(), diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 69411983..5ff55f23 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -41,6 +41,7 @@ flutter: uses-material-design: true assets: - assets/ + - assets/images/ fonts: - family: monospace diff --git a/flutter_quill_extensions/CHANGELOG.md b/flutter_quill_extensions/CHANGELOG.md index 2cb20ab3..ffb6cd2c 100644 --- a/flutter_quill_extensions/CHANGELOG.md +++ b/flutter_quill_extensions/CHANGELOG.md @@ -1,5 +1,15 @@ ## 0.6.5 - Support the new improved platform checking of `flutter_quill` +- Update the Image embed builder logic +- Fix Save image button exception +- Feature: Image cropping for the image embed builder +- Add support for copying the image to the cliboard +- Add new static method in `FlutterQuillEmbeds` which is `defaultEditorBuilders` for minimal configurations +- Fix the image size logic (it's still missing a lot of things but we will work on that soon) +- Fix the zoom image functionality to support different image providers +- Fix typo in the function name `editorsWebBuilders`, now it's called `editorWebBuilders` +- Deprecated: The boolean property `forceUseMobileOptionMenuForImageClick` is now deprecated as we will not using it anymore and it will be removed in the next major release +- Update `README.md` ## 0.6.4 - Update `QuillImageUtilities` diff --git a/flutter_quill_extensions/README.md b/flutter_quill_extensions/README.md index d85e0f32..58c52729 100644 --- a/flutter_quill_extensions/README.md +++ b/flutter_quill_extensions/README.md @@ -17,7 +17,7 @@ Currently the support for **Web** is limitied. - [Usage](#usage) - [Embed Blocks](#embed-blocks) - [Custom Size Image for Mobile](#custom-size-image-for-mobile) - - [Custom Size Image for other platforms (excluding web)](#custom-size-image-for-other-platforms-excluding-web) + - [Custom Size Image for other platforms](#custom-size-image-for-other-platforms) - [Drag and drop feature](#drag-and-drop-feature) - [Features](#features) - [Contributing](#contributing) @@ -135,7 +135,7 @@ Define `mobileWidth`, `mobileHeight`, `mobileMargin`, `mobileAlignment` as follo } ``` -### Custom Size Image for other platforms (excluding web) +### Custom Size Image for other platforms Define `width`, `height`, `margin`, `alignment` as follows: @@ -150,9 +150,7 @@ Define `width`, `height`, `margin`, `alignment` as follows: } ``` -On mobile we will use `mobileWidth`, `mobileHeight`, on desktop will use `width`, `heigth` -on Web we will use the `width` and the `height` but the ones in the `attributes` -This may not clear but don't worry we will update it soon. + ### Drag and drop feature Currently the drag and drop feature is not offically supported but you can achieve this very easily in the following steps: diff --git a/flutter_quill_extensions/lib/flutter_quill_extensions.dart b/flutter_quill_extensions/lib/flutter_quill_extensions.dart index 666f9177..6d66c427 100644 --- a/flutter_quill_extensions/lib/flutter_quill_extensions.dart +++ b/flutter_quill_extensions/lib/flutter_quill_extensions.dart @@ -12,9 +12,7 @@ import 'presentation/embeds/editor/webview.dart'; import 'presentation/embeds/toolbar/camera_button/camera_button.dart'; import 'presentation/embeds/toolbar/formula_button.dart'; import 'presentation/embeds/toolbar/image_button/image_button.dart'; -// TODO: Temporary -// ignore: unused_import -import 'presentation/embeds/toolbar/media_button/media_button.dart'; + import 'presentation/embeds/toolbar/video_button/video_button.dart'; import 'presentation/models/config/editor/image/image.dart'; import 'presentation/models/config/editor/image/image_web.dart'; @@ -47,8 +45,7 @@ export 'presentation/embeds/toolbar/utils/image_video_utils.dart'; export 'presentation/embeds/toolbar/video_button/video_button.dart'; export 'presentation/embeds/utils.dart'; export 'presentation/models/config/editor/image/image.dart'; -// TODO: Temporary -// ignore: unused_import + export 'presentation/models/config/editor/image/image_web.dart'; export 'presentation/models/config/editor/video/video.dart'; export 'presentation/models/config/editor/video/video_web.dart'; @@ -72,7 +69,7 @@ class FlutterQuillEmbeds { /// /// **Note:** This method is not intended for web usage. /// For web-specific embeds, - /// use [editorsWebBuilders]. + /// use [editorWebBuilders]. /// /// /// The method returns a list of [EmbedBuilder] objects that can be used with @@ -127,7 +124,7 @@ class FlutterQuillEmbeds { /// [QuillEditorWebVideoEmbedBuilder] is the embed builder for handling /// videos iframe on the web. /// - static List editorsWebBuilders( + static List editorWebBuilders( {QuillEditorWebImageEmbedConfigurations? imageEmbedConfigurations = const QuillEditorWebImageEmbedConfigurations(), QuillEditorWebVideoEmbedConfigurations? videoEmbedConfigurations = @@ -150,6 +147,15 @@ class FlutterQuillEmbeds { ]; } + /// Returns a list of default embed builders for QuillEditor. + /// + /// It will use [editorWebBuilders] for web and [editorBuilders] for others + /// + /// It's not customizable with minimal configurations + static List defaultEditorBuilders() { + return kIsWeb ? editorWebBuilders() : editorBuilders(); + } + /// Returns a list of embed button builders to customize the toolbar buttons. /// /// If you don't want to show one of the buttons for soem reason, diff --git a/flutter_quill_extensions/lib/presentation/embeds/editor/image/image.dart b/flutter_quill_extensions/lib/presentation/embeds/editor/image/image.dart index 7f3d6e94..3e6cac2c 100644 --- a/flutter_quill_extensions/lib/presentation/embeds/editor/image/image.dart +++ b/flutter_quill_extensions/lib/presentation/embeds/editor/image/image.dart @@ -1,16 +1,12 @@ -import 'package:flutter/cupertino.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_quill/extensions.dart' as base; -import 'package:flutter_quill/flutter_quill.dart'; -import 'package:flutter_quill/translations.dart'; +import 'package:flutter_quill/flutter_quill.dart' hide OptionalSize; +import '../../../../logic/models/config/shared_configurations.dart'; import '../../../models/config/editor/image/image.dart'; -import '../../embed_types/image.dart'; -import '../../utils.dart'; import '../../widgets/image.dart'; -import '../../widgets/image_resizer.dart'; -import '../../widgets/simple_dialog_item.dart'; +import 'image_menu.dart'; class QuillEditorImageEmbedBuilder extends EmbedBuilder { QuillEditorImageEmbedBuilder({ @@ -35,301 +31,214 @@ class QuillEditorImageEmbedBuilder extends EmbedBuilder { ) { assert(!kIsWeb, 'Please provide image EmbedBuilder for Web'); - Widget image = const SizedBox.shrink(); - final imageUrl = standardizeImageUrl(node.value.data); - OptionalSize? imageSize; - final style = node.style.attributes['style']; + final imageSource = standardizeImageUrl(node.value.data); + final ((imageSize), margin, alignment) = _getImageAttributes(node); - if (style != null) { - final attrs = base.isMobile(supportWeb: false) - ? base.parseKeyValuePairs(style.value.toString(), { - Attribute.mobileWidth, - Attribute.mobileHeight, - Attribute.mobileMargin, - Attribute.mobileAlignment, - }) - : base.parseKeyValuePairs(style.value.toString(), { - Attribute.width.key, - Attribute.height.key, - Attribute.margin, - Attribute.alignment, - }); - if (attrs.isNotEmpty) { - final width = double.tryParse( - (base.isMobile(supportWeb: false) - ? attrs[Attribute.mobileWidth] - : attrs[Attribute.width.key]) ?? - '', - ); - final height = double.tryParse( - (base.isMobile(supportWeb: false) - ? attrs[Attribute.mobileHeight] - : attrs[Attribute.height.key]) ?? - '', - ); - final alignment = base.getAlignment(base.isMobile(supportWeb: false) - ? attrs[Attribute.mobileAlignment] - : attrs[Attribute.alignment]); - final margin = (base.isMobile(supportWeb: false) - ? double.tryParse(Attribute.mobileMargin) - : double.tryParse(Attribute.margin)) ?? - 0.0; + final width = imageSize.width; + final height = imageSize.height; - // assert( - // width != null && height != null, - // base.isMobile() - // ? 'mobileWidth and mobileHeight must be specified' - // : 'width and height must be specified', - // ); - imageSize = OptionalSize(width, height); - image = Padding( - padding: EdgeInsets.all(margin), - child: getImageWidgetByImageSource( - imageUrl, - width: width, - height: height, - alignment: alignment, - imageProviderBuilder: configurations.imageProviderBuilder, - imageErrorWidgetBuilder: configurations.imageErrorWidgetBuilder, - ), - ); - } - } - - if (imageSize == null) { - image = getImageWidgetByImageSource( - imageUrl, - imageProviderBuilder: configurations.imageProviderBuilder, - imageErrorWidgetBuilder: configurations.imageErrorWidgetBuilder, - ); - imageSize = OptionalSize((image as Image).width, image.height); - } - - if (!readOnly && - (base.isMobile(supportWeb: false) || - configurations.forceUseMobileOptionMenuForImageClick)) { - return GestureDetector( - onTap: () { - showDialog( - context: context, - builder: (context) { - final copyOption = SimpleDialogItem( - icon: Icons.copy_all_outlined, - color: Colors.cyanAccent, - text: 'Copy'.i18n, - onPressed: () { - final imageNode = - getEmbedNode(controller, controller.selection.start) - .value; - final imageUrl = imageNode.value.data; - controller.copiedImageUrl = ImageUrl( - imageUrl, - getImageStyleString(controller), - ); - Navigator.pop(context); - }, - ); - final removeOption = SimpleDialogItem( - icon: Icons.delete_forever_outlined, - color: Colors.red.shade200, - text: 'Remove'.i18n, - onPressed: () async { - Navigator.of(context).pop(); - - // Call the remove check callback if set - if (await configurations.shouldRemoveImageCallback - ?.call(imageUrl) == - false) { - return; - } - - final offset = getEmbedNode( - controller, - controller.selection.start, - ).offset; - controller.replaceText( - offset, - 1, - '', - TextSelection.collapsed(offset: offset), - ); - // Call the post remove callback if set - await configurations.onImageRemovedCallback.call(imageUrl); - }, - ); - return 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: () { - Navigator.pop(context); - showCupertinoModalPopup( - context: context, - builder: (context) { - final screenSize = MediaQuery.sizeOf(context); - return ImageResizer( - onImageResize: (w, h) { - final res = getEmbedNode( - controller, - controller.selection.start, - ); - - final attr = - base.replaceStyleStringWithSize( - getImageStyleString(controller), - width: w, - height: h, - isMobile: - base.isMobile(supportWeb: false), - ); - controller - ..skipRequestKeyboard = true - ..formatText( - res.offset, - 1, - StyleAttribute(attr), - ); - }, - imageWidth: imageSize?.width, - imageHeight: imageSize?.height, - maxWidth: screenSize.width, - maxHeight: screenSize.height, - ); - }, - ); - }, - ), - copyOption, - removeOption, - ]), - ); - }); - }, - child: image, - ); - } - - if (!readOnly || isImageBase64(imageUrl)) { - // To enforce using it on the desktop and other platforms - // and that is up to the developer - if (!base.isMobile(supportWeb: false) && - configurations.forceUseMobileOptionMenuForImageClick) { - return _menuOptionsForReadonlyImage( - context: context, - imageUrl: imageUrl, - image: image, - imageProviderBuilder: configurations.imageProviderBuilder, - imageErrorWidgetBuilder: configurations.imageErrorWidgetBuilder, - ); - } - return image; - } - - // We provide option menu for mobile platform excluding base64 image - return _menuOptionsForReadonlyImage( - context: context, - imageUrl: imageUrl, - image: image, + final image = getImageWidgetByImageSource( + imageSource, imageProviderBuilder: configurations.imageProviderBuilder, imageErrorWidgetBuilder: configurations.imageErrorWidgetBuilder, + alignment: alignment, + height: height, + width: width, + ); + + // OptionalSize? imageSize; + // final style = node.style.attributes['style']; + + // if (style != null) { + // final attrs = base.isMobile(supportWeb: false) + // ? base.parseKeyValuePairs(style.value.toString(), { + // Attribute.mobileWidth, + // Attribute.mobileHeight, + // Attribute.mobileMargin, + // Attribute.mobileAlignment, + // }) + // : base.parseKeyValuePairs(style.value.toString(), { + // Attribute.width.key, + // Attribute.height.key, + // Attribute.margin, + // Attribute.alignment, + // }); + // if (attrs.isNotEmpty) { + // final width = double.tryParse( + // (base.isMobile(supportWeb: false) + // ? attrs[Attribute.mobileWidth] + // : attrs[Attribute.width.key]) ?? + // '', + // ); + // final height = double.tryParse( + // (base.isMobile(supportWeb: false) + // ? attrs[Attribute.mobileHeight] + // : attrs[Attribute.height.key]) ?? + // '', + // ); + // final alignment = base.getAlignment(base.isMobile(supportWeb: false) + // ? attrs[Attribute.mobileAlignment] + // : attrs[Attribute.alignment]); + // final margin = (base.isMobile(supportWeb: false) + // ? double.tryParse(Attribute.mobileMargin) + // : double.tryParse(Attribute.margin)) ?? + // 0.0; + + // imageSize = OptionalSize(width, height); + // image = Padding( + // padding: EdgeInsets.all(margin), + // child: getImageWidgetByImageSource( + // imageSource, + // width: width, + // height: height, + // alignment: alignment, + // imageProviderBuilder: configurations.imageProviderBuilder, + // imageErrorWidgetBuilder: configurations.imageErrorWidgetBuilder, + // ), + // ); + // } + // } + + // if (imageSize == null) { + // image = getImageWidgetByImageSource( + // imageSource, + // imageProviderBuilder: configurations.imageProviderBuilder, + // imageErrorWidgetBuilder: configurations.imageErrorWidgetBuilder, + // ); + // imageSize = OptionalSize((image as Image).width, image.height); + // } + + final imageSaverService = + QuillSharedExtensionsConfigurations.get(context: context) + .imageSaverService; + return GestureDetector( + child: Builder( + builder: (context) { + if (margin != null) { + return Padding( + padding: EdgeInsets.all(margin), + child: image, + ); + } + return image; + }, + ), + onTap: () => showDialog( + context: context, + builder: (context) { + return ImageOptionsMenu( + controller: controller, + configurations: configurations, + imageSource: imageSource, + imageSize: imageSize, + isReadOnly: readOnly, + imageSaverService: imageSaverService, + ); + }, + ), ); } } -Widget _menuOptionsForReadonlyImage({ - required BuildContext context, - required String imageUrl, - required Widget image, - required ImageEmbedBuilderProviderBuilder? imageProviderBuilder, - required ImageEmbedBuilderErrorWidgetBuilder? imageErrorWidgetBuilder, -}) { - return GestureDetector( - onTap: () { - showDialog( - context: context, - builder: (_) { - final saveOption = SimpleDialogItem( - icon: Icons.save, - color: Colors.greenAccent, - text: 'Save'.i18n, - onPressed: () async { - imageUrl = appendFileExtensionToImageUrl(imageUrl); - final messenger = ScaffoldMessenger.of(context); - Navigator.of(context).pop(); +( + OptionalSize imageSize, + double? margin, + Alignment alignment, +) _getImageAttributes( + Node node, +) { + var imageSize = const OptionalSize(null, null); + var imageAlignment = Alignment.center; + double? imageMargin; + + // Usually double value + final heightValue = double.tryParse( + node.style.attributes[Attribute.height.key]?.value.toString() ?? ''); + final widthValue = double.tryParse( + node.style.attributes[Attribute.width.key]?.value.toString() ?? ''); + + if (heightValue != null) { + imageSize = imageSize.copyWith( + height: heightValue, + ); + } + if (widthValue != null) { + imageSize = imageSize.copyWith( + width: widthValue, + ); + } - final saveImageResult = await saveImage( - imageUrl: imageUrl, - context: context, - ); - final imageSavedSuccessfully = saveImageResult.isSuccess; + final cssStyle = node.style.attributes['style']; + + if (cssStyle != null) { + final attrs = base.isMobile(supportWeb: false) + ? base.parseKeyValuePairs(cssStyle.value.toString(), { + Attribute.mobileWidth, + Attribute.mobileHeight, + Attribute.mobileMargin, + Attribute.mobileAlignment, + }) + : base.parseKeyValuePairs(cssStyle.value.toString(), { + Attribute.width.key, + Attribute.height.key, + Attribute.margin, + Attribute.alignment, + }); + if (attrs.isEmpty) { + return (imageSize, imageMargin, imageAlignment); + } - messenger.clearSnackBars(); + // It css value as string but we will try to support it anyway - if (!imageSavedSuccessfully) { - messenger.showSnackBar(SnackBar( - content: Text( - 'Error while saving image'.i18n, - ))); - return; - } + // TODO: This could be improved much better + final cssHeightValue = double.tryParse( + (attrs[Attribute.height.key] ?? '').replaceFirst('px', '')); + final cssWidthValue = double.tryParse( + (attrs[Attribute.width.key] ?? '').replaceFirst('px', '')); - String message; - switch (saveImageResult.method) { - case SaveImageResultMethod.network: - message = 'Saved using the network'.i18n; - break; - case SaveImageResultMethod.localStorage: - message = 'Saved using the local storage'.i18n; - break; - } + if (cssHeightValue != null) { + imageSize = imageSize.copyWith(height: cssHeightValue); + } + if (cssWidthValue != null) { + imageSize = imageSize.copyWith(width: cssWidthValue); + } - messenger.showSnackBar( - SnackBar( - content: Text(message), - ), - ); - }, - ); - final zoomOption = SimpleDialogItem( - icon: Icons.zoom_in, - color: Colors.cyanAccent, - text: 'Zoom'.i18n, - onPressed: () { - Navigator.pushReplacement( - context, - MaterialPageRoute( - builder: (context) => ImageTapWrapper( - imageUrl: imageUrl, - imageProviderBuilder: imageProviderBuilder, - imageErrorWidgetBuilder: imageErrorWidgetBuilder, - ), - ), - ); - }, - ); - return Padding( - padding: const EdgeInsets.fromLTRB(50, 0, 50, 0), - child: SimpleDialog( - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.all( - Radius.circular(10), - ), - ), - children: [saveOption, zoomOption], - ), - ); - }, - ); - }, - child: image, + imageAlignment = base.getAlignment(base.isMobile(supportWeb: false) + ? attrs[Attribute.mobileAlignment] + : attrs[Attribute.alignment]); + final margin = (base.isMobile(supportWeb: false) + ? double.tryParse(Attribute.mobileMargin) + : double.tryParse(Attribute.margin)); + if (margin != null) { + imageMargin = margin; + } + } + + return (imageSize, imageMargin, imageAlignment); +} + +@immutable +class OptionalSize { + const OptionalSize( + this.width, + this.height, ); + + /// If non-null, requires the child to have exactly this width. + /// If null, the child is free to choose its own width. + final double? width; + + /// If non-null, requires the child to have exactly this height. + /// If null, the child is free to choose its own height. + final double? height; + + OptionalSize copyWith({ + double? width, + double? height, + }) { + return OptionalSize( + width ?? this.width, + height ?? this.height, + ); + } } diff --git a/flutter_quill_extensions/lib/presentation/embeds/editor/image/image_menu.dart b/flutter_quill_extensions/lib/presentation/embeds/editor/image/image_menu.dart new file mode 100644 index 00000000..6fc71864 --- /dev/null +++ b/flutter_quill_extensions/lib/presentation/embeds/editor/image/image_menu.dart @@ -0,0 +1,195 @@ +import 'package:flutter/cupertino.dart' show showCupertinoModalPopup; +import 'package:flutter/material.dart'; +// import 'package:flutter/services.dart' show Clipboard, ClipboardData; +import 'package:flutter_quill/extensions.dart' + show isDesktop, isMobile, replaceStyleStringWithSize; +import 'package:flutter_quill/flutter_quill.dart' + show ImageUrl, QuillController, StyleAttribute, getEmbedNode; +import 'package:flutter_quill/translations.dart'; + +import '../../../../logic/services/image_saver/s_image_saver.dart'; +import '../../../models/config/editor/image/image.dart'; +import '../../utils.dart'; +import '../../widgets/image.dart' show ImageTapWrapper, getImageStyleString; +import '../../widgets/image_resizer.dart' show ImageResizer; +import 'image.dart' show OptionalSize; + +class ImageOptionsMenu extends StatelessWidget { + const ImageOptionsMenu({ + required this.controller, + required this.configurations, + required this.imageSource, + required this.imageSize, + required this.isReadOnly, + required this.imageSaverService, + super.key, + }); + + final QuillController controller; + final QuillEditorImageEmbedConfigurations configurations; + final String imageSource; + final OptionalSize imageSize; + final bool isReadOnly; + final ImageSaverService imageSaverService; + + @override + Widget build(BuildContext context) { + final materialTheme = Theme.of(context); + return Padding( + padding: const EdgeInsets.fromLTRB(50, 0, 50, 0), + child: SimpleDialog( + title: Text('Image'.i18n), + children: [ + if (!isReadOnly) + ListTile( + title: Text('Resize'.i18n), + leading: const Icon(Icons.settings_outlined), + onTap: () { + Navigator.pop(context); + showCupertinoModalPopup( + context: context, + builder: (context) { + final screenSize = MediaQuery.sizeOf(context); + return ImageResizer( + onImageResize: (w, h) { + final res = getEmbedNode( + controller, + controller.selection.start, + ); + + final attr = replaceStyleStringWithSize( + getImageStyleString(controller), + width: w, + height: h, + isMobile: isMobile(supportWeb: false), + ); + controller + ..skipRequestKeyboard = true + ..formatText( + res.offset, + 1, + StyleAttribute(attr), + ); + }, + imageWidth: imageSize.width, + imageHeight: imageSize.height, + maxWidth: screenSize.width, + maxHeight: screenSize.height, + ); + }, + ); + }, + ), + ListTile( + leading: const Icon(Icons.copy_all_outlined), + title: Text('Copy'.i18n), + onTap: () async { + final navigator = Navigator.of(context); + final imageNode = + getEmbedNode(controller, controller.selection.start).value; + final imageUrl = imageNode.value.data; + controller.copiedImageUrl = ImageUrl( + imageUrl, + getImageStyleString(controller), + ); + // TODO: Implement the copy image + // await Clipboard.setData( + // ClipboardData(text: '$imageUrl'), + // ); + navigator.pop(); + }, + ), + if (!isReadOnly) + ListTile( + leading: Icon( + Icons.delete_forever_outlined, + color: materialTheme.colorScheme.error, + ), + title: Text('Remove'.i18n), + onTap: () async { + Navigator.of(context).pop(); + + // Call the remove check callback if set + if (await configurations.shouldRemoveImageCallback + ?.call(imageSource) == + false) { + return; + } + + final offset = getEmbedNode( + controller, + controller.selection.start, + ).offset; + controller.replaceText( + offset, + 1, + '', + TextSelection.collapsed(offset: offset), + ); + // Call the post remove callback if set + await configurations.onImageRemovedCallback.call(imageSource); + }, + ), + ...[ + ListTile( + leading: const Icon(Icons.save), + title: Text('Save'.i18n), + enabled: !isDesktop(supportWeb: false), + onTap: () async { + final messenger = ScaffoldMessenger.of(context); + Navigator.of(context).pop(); + + final saveImageResult = await saveImage( + imageUrl: imageSource, + imageSaverService: imageSaverService, + ); + final imageSavedSuccessfully = saveImageResult.isSuccess; + + messenger.clearSnackBars(); + + if (!imageSavedSuccessfully) { + messenger.showSnackBar(SnackBar( + content: Text( + 'Error while saving image'.i18n, + ))); + return; + } + + String message; + switch (saveImageResult.method) { + case SaveImageResultMethod.network: + message = 'Saved using the network'.i18n; + break; + case SaveImageResultMethod.localStorage: + message = 'Saved using the local storage'.i18n; + break; + } + + messenger.showSnackBar( + SnackBar( + content: Text(message), + ), + ); + }, + ), + ListTile( + leading: const Icon(Icons.zoom_in), + title: Text('Zoom'.i18n), + onTap: () => Navigator.pushReplacement( + context, + MaterialPageRoute( + builder: (context) => ImageTapWrapper( + imageUrl: imageSource, + imageProviderBuilder: configurations.imageProviderBuilder, + imageErrorWidgetBuilder: + configurations.imageErrorWidgetBuilder, + ), + ), + ), + ), + ], + ], + ), + ); + } +} diff --git a/flutter_quill_extensions/lib/presentation/embeds/editor/image/image_web.dart b/flutter_quill_extensions/lib/presentation/embeds/editor/image/image_web.dart index 84d3cd84..b509eaaa 100644 --- a/flutter_quill_extensions/lib/presentation/embeds/editor/image/image_web.dart +++ b/flutter_quill_extensions/lib/presentation/embeds/editor/image/image_web.dart @@ -33,7 +33,7 @@ class QuillEditorWebImageEmbedBuilder extends EmbedBuilder { ) { assert(kIsWeb, 'ImageEmbedBuilderWeb is only for web platform'); - final (height, width, margin, alignment) = _getImageSizeForWeb(node); + final (height, width, margin, alignment) = _getImageWebAttributes(node); var imageSource = node.value.data.toString(); @@ -77,11 +77,12 @@ class QuillEditorWebImageEmbedBuilder extends EmbedBuilder { String width, String margin, String alignment, -) _getImageSizeForWeb( +) _getImageWebAttributes( Node node, ) { var height = 'auto'; var width = 'auto'; + // TODO: Add support for margin and alignment const margin = 'auto'; const alignment = 'center'; diff --git a/flutter_quill_extensions/lib/presentation/embeds/utils.dart b/flutter_quill_extensions/lib/presentation/embeds/utils.dart index f1331f84..d9ea1fea 100644 --- a/flutter_quill_extensions/lib/presentation/embeds/utils.dart +++ b/flutter_quill_extensions/lib/presentation/embeds/utils.dart @@ -1,8 +1,8 @@ import 'dart:io' show File; import 'package:flutter/foundation.dart' show immutable; -import 'package:flutter/widgets.dart' show BuildContext; -import '../../logic/models/config/shared_configurations.dart'; +import '../../logic/services/image_saver/s_image_saver.dart'; +import 'widgets/image.dart'; RegExp _base64 = RegExp( r'^(?:[A-Za-z0-9+\/]{4})*(?:[A-Za-z0-9+\/]{2}==|[A-Za-z0-9+\/]{3}=|[A-Za-z0-9+\/]{4})$', @@ -49,11 +49,8 @@ class SaveImageResult { Future saveImage({ required String imageUrl, - required BuildContext context, + required ImageSaverService imageSaverService, }) async { - final imageSaverService = - QuillSharedExtensionsConfigurations.get(context: context) - .imageSaverService; final imageFile = File(imageUrl); final hasPermission = await imageSaverService.hasAccess(); if (!hasPermission) { @@ -63,7 +60,7 @@ Future saveImage({ if (!imageExistsLocally) { try { await imageSaverService.saveImageFromNetwork( - Uri.parse(imageUrl), + Uri.parse(appendFileExtensionToImageUrl(imageUrl)), ); return const SaveImageResult( isSuccess: true, diff --git a/flutter_quill_extensions/lib/presentation/embeds/widgets/image.dart b/flutter_quill_extensions/lib/presentation/embeds/widgets/image.dart index a4f8f717..4dc4241c 100644 --- a/flutter_quill_extensions/lib/presentation/embeds/widgets/image.dart +++ b/flutter_quill_extensions/lib/presentation/embeds/widgets/image.dart @@ -28,6 +28,31 @@ String getImageStyleString(QuillController controller) { return s ?? ''; } +/// [imageProviderBuilder] To override the return value pass value to it +/// [imageSource] The source of the image in the quill delta json document +/// It could be http, file, network, asset, or base 64 image +ImageProvider getImageProviderByImageSource( + String imageSource, { + required ImageEmbedBuilderProviderBuilder? imageProviderBuilder, +}) { + if (imageProviderBuilder != null) { + return imageProviderBuilder(imageSource); + } + + if (isImageBase64(imageSource)) { + return MemoryImage(base64.decode(imageSource)); + } + + if (isHttpBasedUrl(imageSource)) { + return NetworkImage(imageSource); + } + if (imageSource.startsWith('assets')) { + // TODO: This impl needs to be improved + return AssetImage(imageSource); + } + return FileImage(File(imageSource)); +} + Image getImageWidgetByImageSource( String imageSource, { required ImageEmbedBuilderProviderBuilder? imageProviderBuilder, @@ -36,35 +61,11 @@ Image getImageWidgetByImageSource( double? height, AlignmentGeometry alignment = Alignment.center, }) { - if (isImageBase64(imageSource)) { - return Image.memory( - base64.decode(imageSource), - width: width, - height: height, - alignment: alignment, - ); - } - - if (imageProviderBuilder != null) { - return Image( - image: imageProviderBuilder(imageSource), - width: width, - height: height, - alignment: alignment, - errorBuilder: imageErrorWidgetBuilder, - ); - } - if (isHttpBasedUrl(imageSource)) { - return Image.network( + return Image( + image: getImageProviderByImageSource( imageSource, - width: width, - height: height, - alignment: alignment, - errorBuilder: imageErrorWidgetBuilder, - ); - } - return Image.file( - File(imageSource), + imageProviderBuilder: imageProviderBuilder, + ), width: width, height: height, alignment: alignment, @@ -110,20 +111,6 @@ class ImageTapWrapper extends StatelessWidget { final ImageEmbedBuilderProviderBuilder? imageProviderBuilder; final ImageEmbedBuilderErrorWidgetBuilder? imageErrorWidgetBuilder; - ImageProvider _imageProviderByUrl( - String imageUrl, { - required ImageEmbedBuilderProviderBuilder? customImageProviderBuilder, - }) { - if (customImageProviderBuilder != null) { - return customImageProviderBuilder(imageUrl); - } - if (isHttpBasedUrl(imageUrl)) { - return NetworkImage(imageUrl); - } - - return FileImage(File(imageUrl)); - } - @override Widget build(BuildContext context) { return Scaffold( @@ -134,9 +121,9 @@ class ImageTapWrapper extends StatelessWidget { child: Stack( children: [ PhotoView( - imageProvider: _imageProviderByUrl( + imageProvider: getImageProviderByImageSource( imageUrl, - customImageProviderBuilder: imageProviderBuilder, + imageProviderBuilder: imageProviderBuilder, ), errorBuilder: imageErrorWidgetBuilder, loadingBuilder: (context, event) { @@ -173,8 +160,11 @@ class ImageTapWrapper extends StatelessWidget { bottom: 0, left: 0, right: 0, - child: - Icon(Icons.close, color: Colors.grey[400], size: 28), + child: Icon( + Icons.close, + color: Colors.grey[400], + size: 28, + ), ) ], ), diff --git a/flutter_quill_extensions/lib/presentation/models/config/editor/image/image.dart b/flutter_quill_extensions/lib/presentation/models/config/editor/image/image.dart index 3e159d0e..518b61ed 100644 --- a/flutter_quill_extensions/lib/presentation/models/config/editor/image/image.dart +++ b/flutter_quill_extensions/lib/presentation/models/config/editor/image/image.dart @@ -12,6 +12,7 @@ import '../../../../embeds/embed_types/image.dart'; @immutable class QuillEditorImageEmbedConfigurations { const QuillEditorImageEmbedConfigurations({ + @Deprecated('This will be deleted in 0.7.0 as we will have one menu') this.forceUseMobileOptionMenuForImageClick = false, ImageEmbedBuilderOnRemovedCallback? onImageRemovedCallback, this.shouldRemoveImageCallback, @@ -161,6 +162,7 @@ class QuillEditorImageEmbedConfigurations { imageProviderBuilder: imageProviderBuilder ?? this.imageProviderBuilder, imageErrorWidgetBuilder: imageErrorWidgetBuilder ?? this.imageErrorWidgetBuilder, + // ignore: deprecated_member_use_from_same_package forceUseMobileOptionMenuForImageClick: forceUseMobileOptionMenuForImageClick ?? this.forceUseMobileOptionMenuForImageClick, diff --git a/flutter_quill_extensions/pubspec.yaml b/flutter_quill_extensions/pubspec.yaml index 03a3d162..3df390cd 100644 --- a/flutter_quill_extensions/pubspec.yaml +++ b/flutter_quill_extensions/pubspec.yaml @@ -1,6 +1,6 @@ name: flutter_quill_extensions description: Embed extensions for flutter_quill including image, video, formula and etc. -version: 0.6.4 +version: 0.6.5 homepage: https://github.com/singerdmx/flutter-quill/tree/master/flutter_quill_extensions repository: https://github.com/singerdmx/flutter-quill/tree/master/flutter_quill_extensions diff --git a/lib/src/models/structs/optional_size.dart b/lib/src/models/structs/optional_size.dart index 49d274c8..8362dee3 100644 --- a/lib/src/models/structs/optional_size.dart +++ b/lib/src/models/structs/optional_size.dart @@ -1,3 +1,4 @@ +// ignore_for_file: public_member_api_docs, sort_constructors_first import 'package:meta/meta.dart' show immutable; @immutable @@ -14,4 +15,14 @@ class OptionalSize { /// If non-null, requires the child to have exactly this height. /// If null, the child is free to choose its own height. final double? height; + + OptionalSize copyWith({ + double? width, + double? height, + }) { + return OptionalSize( + width ?? this.width, + height ?? this.height, + ); + } } diff --git a/lib/src/utils/string.dart b/lib/src/utils/string.dart index fe0207de..2975cf60 100644 --- a/lib/src/utils/string.dart +++ b/lib/src/utils/string.dart @@ -54,9 +54,10 @@ String replaceStyleStringWithSize( return sb.toString(); } -Alignment getAlignment(String? s) { +/// Get flutter [Alignment] value by [cssAlignment] +Alignment getAlignment(String? cssAlignment) { const defaultAlignment = Alignment.center; - if (s == null) { + if (cssAlignment == null) { return defaultAlignment; } @@ -70,7 +71,7 @@ Alignment getAlignment(String? s) { 'bottomLeft', 'bottomCenter', 'bottomRight' - ].indexOf(s); + ].indexOf(cssAlignment); if (index < 0) { return defaultAlignment; } diff --git a/lib/src/widgets/text_line.dart b/lib/src/widgets/text_line.dart index 0d1243d3..5f095dc7 100644 --- a/lib/src/widgets/text_line.dart +++ b/lib/src/widgets/text_line.dart @@ -143,15 +143,23 @@ class _TextLineState extends State { var embed = widget.line.children.single as Embed; // Creates correct node for custom embed if (embed.value.type == BlockEmbed.customType) { - embed = Embed(CustomBlockEmbed.fromJsonString(embed.value.data)); + embed = Embed( + CustomBlockEmbed.fromJsonString(embed.value.data), + ); } final embedBuilder = widget.embedBuilder(embed); if (embedBuilder.expanded) { // Creates correct node for custom embed final lineStyle = _getLineStyle(widget.styles); return EmbedProxy( - embedBuilder.build(context, widget.controller, embed, widget.readOnly, - false, lineStyle), + embedBuilder.build( + context, + widget.controller, + embed, + widget.readOnly, + false, + lineStyle, + ), ); } } @@ -167,12 +175,13 @@ class _TextLineState extends State { textScaleFactor: MediaQuery.textScaleFactorOf(context), ); return RichTextProxy( - textStyle: textSpan.style!, - textAlign: textAlign, - textDirection: widget.textDirection!, - strutStyle: strutStyle, - locale: Localizations.localeOf(context), - child: child); + textStyle: textSpan.style!, + textAlign: textAlign, + textDirection: widget.textDirection!, + strutStyle: strutStyle, + locale: Localizations.localeOf(context), + child: child, + ); } InlineSpan _getTextSpanForWholeLine(BuildContext context) { diff --git a/pubspec.yaml b/pubspec.yaml index 18f2f9ce..437590ff 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: flutter_quill description: A rich text editor built for the modern Android, iOS, web and desktop platforms. It is the WYSIWYG editor and a Quill component for Flutter. -version: 8.4.0 +version: 8.4.1 homepage: https://1o24bbs.com/c/bulletjournal/108 repository: https://github.com/singerdmx/flutter-quill