diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e32fcc8..be1006b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,23 @@ +# [6.0.0] BREAKING CHANGE +* Removed embed (image, video & forumla) blocks from the package to reduce app size. + +These blocks have been moved to the package `flutter_quill_extensions`, migrate by filling the `embedBuilders` and `embedButtons` parameters as follows: + +``` +import 'package:flutter_quill_extensions/flutter_quill_extensions.dart'; + +QuillEditor.basic( + controller: controller, + embedBuilders: FlutterQuillEmbeds.builders, +); + +QuillToolbar.basic( + controller: controller, + embedButtons: FlutterQuillEmbeds.buttons(), +); +``` + + # [5.4.2] * Upgrade i18n_extension. diff --git a/README.md b/README.md index c412cda2..e1d2d7f7 100644 --- a/README.md +++ b/README.md @@ -153,6 +153,31 @@ QuillToolbar.basic( ] ``` + +## Embed Blocks + +As of version 6.0, embed blocks are not provided by default as part of this package. Instead, this packet provides an interface to all the user to provide there own implementations for embed blocks. Implementations for image, video and forumal embed blocks is proved in a separate package `flutter_quill_extensions`. + +Provide a list of embed + +### Using the embed blocks from `flutter_quill_extensions` + +``` +import 'package:flutter_quill_extensions/flutter_quill_extensions.dart'; + +QuillEditor.basic( + controller: controller, + embedBuilders: FlutterQuillEmbeds.builders, +); + +QuillToolbar.basic( + controller: controller, + embedButtons: FlutterQuillEmbeds.buttons(), +); +``` + + + ### Custom Size Image for Mobile Define `mobileWidth`, `mobileHeight`, `mobileMargin`, `mobileAlignment` as follows: @@ -171,7 +196,7 @@ Define `mobileWidth`, `mobileHeight`, `mobileMargin`, `mobileAlignment` as follo Sometimes you want to add some custom content inside your text, custom widgets inside of them. An example is adding notes to the text, or anything custom that you want to add in your text editor. -The only thing that you need is to add a `CustomBlockEmbed` and map it into the `customElementsEmbedBuilder`, to transform the data inside of the Custom Block into a widget! +The only thing that you need is to add a `CustomBlockEmbed` and provider a builder for it to the `embedBuilders` parameter, to transform the data inside of the Custom Block into a widget! Here is an example: @@ -194,35 +219,40 @@ After that, we need to map this "notes" type into a widget. In that case, I used Don't forget to add this method to the `QuillEditor` after that! ```dart -Widget customElementsEmbedBuilder( - BuildContext context, - QuillController controller, - CustomBlockEmbed block, - bool readOnly, - void Function(GlobalKey videoContainerKey)? onVideoInit, -) { - switch (block.type) { - case 'notes': - final notes = NotesBlockEmbed(block.data).document; - - return Material( - color: Colors.transparent, - child: ListTile( - title: Text( - notes.toPlainText().replaceAll('\n', ' '), - maxLines: 3, - overflow: TextOverflow.ellipsis, - ), - leading: const Icon(Icons.notes), - onTap: () => _addEditNote(context, document: notes), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), - side: const BorderSide(color: Colors.grey), - ), +class NotesEmbedBuilder implements EmbedBuilder { + NotesEmbedBuilder({required this.addEditNote}); + + Future Function(BuildContext context, {Document? document}) addEditNote; + + @override + String get key => 'notes'; + + @override + Widget build( + BuildContext context, + QuillController controller, + Embed node, + bool readOnly, + void Function(GlobalKey> videoContainerKey)? + onVideoInit) { + final notes = NotesBlockEmbed(node.value.data).document; + + return Material( + color: Colors.transparent, + child: ListTile( + title: Text( + notes.toPlainText().replaceAll('\n', ' '), + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + leading: const Icon(Icons.notes), + onTap: () => addEditNote(context, document: notes), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + side: const BorderSide(color: Colors.grey), ), - ); - default: - return const SizedBox(); + ), + ); } } ``` diff --git a/example/lib/pages/home_page.dart b/example/lib/pages/home_page.dart index 6c1aa87d..2d9b09b8 100644 --- a/example/lib/pages/home_page.dart +++ b/example/lib/pages/home_page.dart @@ -8,6 +8,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_quill/flutter_quill.dart' hide Text; +import 'package:flutter_quill_extensions/flutter_quill_extensions.dart'; import 'package:path/path.dart'; import 'package:path_provider/path_provider.dart'; import 'package:tuple/tuple.dart'; @@ -119,7 +120,10 @@ class _HomePageState extends State { null), sizeSmall: const TextStyle(fontSize: 9), ), - customElementsEmbedBuilder: customElementsEmbedBuilder, + embedBuilders: [ + ...FlutterQuillEmbeds.builders, + NotesEmbedBuilder(addEditNote: _addEditNote) + ], ); if (kIsWeb) { quillEditor = QuillEditor( @@ -145,34 +149,40 @@ class _HomePageState extends State { null), sizeSmall: const TextStyle(fontSize: 9), ), - embedBuilder: defaultEmbedBuilderWeb); + embedBuilders: defaultEmbedBuildersWeb); } var toolbar = QuillToolbar.basic( controller: _controller!, - // provide a callback to enable picking images from device. - // if omit, "image" button only allows adding images from url. - // same goes for videos. - onImagePickCallback: _onImagePickCallback, - onVideoPickCallback: _onVideoPickCallback, - // uncomment to provide a custom "pick from" dialog. - // mediaPickSettingSelector: _selectMediaPickSetting, - // uncomment to provide a custom "pick from" dialog. - // cameraPickSettingSelector: _selectCameraPickSetting, + embedButtons: FlutterQuillEmbeds.buttons( + // provide a callback to enable picking images from device. + // if omit, "image" button only allows adding images from url. + // same goes for videos. + onImagePickCallback: _onImagePickCallback, + onVideoPickCallback: _onVideoPickCallback, + // uncomment to provide a custom "pick from" dialog. + // mediaPickSettingSelector: _selectMediaPickSetting, + // uncomment to provide a custom "pick from" dialog. + // cameraPickSettingSelector: _selectCameraPickSetting, + ), showAlignmentButtons: true, ); if (kIsWeb) { toolbar = QuillToolbar.basic( controller: _controller!, - onImagePickCallback: _onImagePickCallback, - webImagePickImpl: _webImagePickImpl, + embedButtons: FlutterQuillEmbeds.buttons( + onImagePickCallback: _onImagePickCallback, + webImagePickImpl: _webImagePickImpl, + ), showAlignmentButtons: true, ); } if (_isDesktop()) { toolbar = QuillToolbar.basic( controller: _controller!, - onImagePickCallback: _onImagePickCallback, - filePickImpl: openFileSystemPickerForDesktop, + embedButtons: FlutterQuillEmbeds.buttons( + onImagePickCallback: _onImagePickCallback, + filePickImpl: openFileSystemPickerForDesktop, + ), showAlignmentButtons: true, ); } @@ -386,37 +396,42 @@ class _HomePageState extends State { controller.replaceText(index, length, block, null); } } +} - Widget customElementsEmbedBuilder( - BuildContext context, - QuillController controller, - CustomBlockEmbed block, - bool readOnly, - void Function(GlobalKey videoContainerKey)? onVideoInit, - ) { - switch (block.type) { - case 'notes': - final notes = NotesBlockEmbed(block.data).document; - - return Material( - color: Colors.transparent, - child: ListTile( - title: Text( - notes.toPlainText().replaceAll('\n', ' '), - maxLines: 3, - overflow: TextOverflow.ellipsis, - ), - leading: const Icon(Icons.notes), - onTap: () => _addEditNote(context, document: notes), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), - side: const BorderSide(color: Colors.grey), - ), - ), - ); - default: - return const SizedBox(); - } +class NotesEmbedBuilder implements EmbedBuilder { + NotesEmbedBuilder({required this.addEditNote}); + + Future Function(BuildContext context, {Document? document}) addEditNote; + + @override + String get key => 'notes'; + + @override + Widget build( + BuildContext context, + QuillController controller, + Embed node, + bool readOnly, + void Function(GlobalKey> videoContainerKey)? + onVideoInit) { + final notes = NotesBlockEmbed(node.value.data).document; + + return Material( + color: Colors.transparent, + child: ListTile( + title: Text( + notes.toPlainText().replaceAll('\n', ' '), + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + leading: const Icon(Icons.notes), + onTap: () => addEditNote(context, document: notes), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + side: const BorderSide(color: Colors.grey), + ), + ), + ); } } diff --git a/example/lib/pages/read_only_page.dart b/example/lib/pages/read_only_page.dart index 594d6123..478d7924 100644 --- a/example/lib/pages/read_only_page.dart +++ b/example/lib/pages/read_only_page.dart @@ -1,6 +1,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_quill/flutter_quill.dart' hide Text; +import 'package:flutter_quill_extensions/flutter_quill_extensions.dart'; import '../universal_ui/universal_ui.dart'; import '../widgets/demo_scaffold.dart'; @@ -38,6 +39,7 @@ class _ReadOnlyPageState extends State { readOnly: !_edit, expands: false, padding: EdgeInsets.zero, + embedBuilders: FlutterQuillEmbeds.builders, ); if (kIsWeb) { quillEditor = QuillEditor( @@ -49,7 +51,7 @@ class _ReadOnlyPageState extends State { readOnly: !_edit, expands: false, padding: EdgeInsets.zero, - embedBuilder: defaultEmbedBuilderWeb); + embedBuilders: defaultEmbedBuildersWeb); } return Padding( padding: const EdgeInsets.all(8), diff --git a/example/lib/universal_ui/universal_ui.dart b/example/lib/universal_ui/universal_ui.dart index 3a7224be..54441820 100644 --- a/example/lib/universal_ui/universal_ui.dart +++ b/example/lib/universal_ui/universal_ui.dart @@ -3,6 +3,7 @@ library universal_ui; import 'package:flutter/cupertino.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_quill/flutter_quill.dart'; +import 'package:flutter_quill_extensions/flutter_quill_extensions.dart'; import 'package:universal_html/html.dart' as html; import 'package:youtube_player_flutter_quill/youtube_player_flutter_quill.dart'; @@ -26,66 +27,72 @@ class UniversalUI { var ui = UniversalUI(); -Widget defaultEmbedBuilderWeb( - BuildContext context, - QuillController controller, - Embed node, - bool readOnly, - void Function(GlobalKey videoContainerKey)? onVideoInit, -) { - switch (node.value.type) { - case BlockEmbed.imageType: - final imageUrl = node.value.data; - if (isImageBase64(imageUrl)) { - // TODO: handle imageUrl of base64 - return const SizedBox(); - } - final size = MediaQuery.of(context).size; - UniversalUI().platformViewRegistry.registerViewFactory( - imageUrl, (viewId) => html.ImageElement()..src = imageUrl); - return Padding( - padding: EdgeInsets.only( - right: ResponsiveWidget.isMediumScreen(context) - ? size.width * 0.5 - : (ResponsiveWidget.isLargeScreen(context)) - ? size.width * 0.75 - : size.width * 0.2, - ), - child: SizedBox( - height: MediaQuery.of(context).size.height * 0.45, - child: HtmlElementView( - viewType: imageUrl, - ), +class ImageEmbedBuilderWeb implements EmbedBuilder { + @override + String get key => BlockEmbed.imageType; + + @override + Widget build(BuildContext context, QuillController controller, Embed node, + bool readOnly, void Function(GlobalKey videoContainerKey)? onVideoInit) { + final imageUrl = node.value.data; + if (isImageBase64(imageUrl)) { + // TODO: handle imageUrl of base64 + return const SizedBox(); + } + final size = MediaQuery.of(context).size; + UniversalUI().platformViewRegistry.registerViewFactory( + imageUrl, (viewId) => html.ImageElement()..src = imageUrl); + return Padding( + padding: EdgeInsets.only( + right: ResponsiveWidget.isMediumScreen(context) + ? size.width * 0.5 + : (ResponsiveWidget.isLargeScreen(context)) + ? size.width * 0.75 + : size.width * 0.2, + ), + child: SizedBox( + height: MediaQuery.of(context).size.height * 0.45, + child: HtmlElementView( + viewType: imageUrl, ), - ); - case BlockEmbed.videoType: - var videoUrl = node.value.data; - if (videoUrl.contains('youtube.com') || videoUrl.contains('youtu.be')) { - final youtubeID = YoutubePlayer.convertUrlToId(videoUrl); - if (youtubeID != null) { - videoUrl = 'https://www.youtube.com/embed/$youtubeID'; - } + ), + ); + } +} + +class VideoEmbedBuilderWeb implements EmbedBuilder { + @override + String get key => BlockEmbed.videoType; + + @override + Widget build(BuildContext context, QuillController controller, Embed node, + bool readOnly, void Function(GlobalKey videoContainerKey)? onVideoInit) { + var videoUrl = node.value.data; + if (videoUrl.contains('youtube.com') || videoUrl.contains('youtu.be')) { + final youtubeID = YoutubePlayer.convertUrlToId(videoUrl); + if (youtubeID != null) { + videoUrl = 'https://www.youtube.com/embed/$youtubeID'; } + } - UniversalUI().platformViewRegistry.registerViewFactory( - videoUrl, - (id) => html.IFrameElement() - ..width = MediaQuery.of(context).size.width.toString() - ..height = MediaQuery.of(context).size.height.toString() - ..src = videoUrl - ..style.border = 'none'); + UniversalUI().platformViewRegistry.registerViewFactory( + videoUrl, + (id) => html.IFrameElement() + ..width = MediaQuery.of(context).size.width.toString() + ..height = MediaQuery.of(context).size.height.toString() + ..src = videoUrl + ..style.border = 'none'); - return SizedBox( - height: 500, - child: HtmlElementView( - viewType: videoUrl, - ), - ); - default: - throw UnimplementedError( - 'Embeddable type "${node.value.type}" is not supported by default ' - 'embed builder of QuillEditor. You must pass your own builder function ' - 'to embedBuilder property of QuillEditor or QuillField widgets.', - ); + return SizedBox( + height: 500, + child: HtmlElementView( + viewType: videoUrl, + ), + ); } } + +List get defaultEmbedBuildersWeb => [ + ImageEmbedBuilderWeb(), + VideoEmbedBuilderWeb(), + ]; diff --git a/example/lib/widgets/demo_scaffold.dart b/example/lib/widgets/demo_scaffold.dart index 50b9ddc6..0b14663c 100644 --- a/example/lib/widgets/demo_scaffold.dart +++ b/example/lib/widgets/demo_scaffold.dart @@ -6,6 +6,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_quill/flutter_quill.dart' hide Text; +import 'package:flutter_quill_extensions/flutter_quill_extensions.dart'; import 'package:path_provider/path_provider.dart'; typedef DemoContentBuilder = Widget Function( @@ -89,11 +90,16 @@ class _DemoScaffoldState extends State { return const Scaffold(body: Center(child: Text('Loading...'))); } final actions = widget.actions ?? []; - var toolbar = QuillToolbar.basic(controller: _controller!); + var toolbar = QuillToolbar.basic( + controller: _controller!, + embedButtons: FlutterQuillEmbeds.buttons(), + ); if (_isDesktop()) { toolbar = QuillToolbar.basic( - controller: _controller!, - filePickImpl: openFileSystemPickerForDesktop); + controller: _controller!, + embedButtons: FlutterQuillEmbeds.buttons( + filePickImpl: openFileSystemPickerForDesktop), + ); } return Scaffold( key: _scaffoldKey, diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 5b5a8360..e79b0c7e 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -34,6 +34,12 @@ dependencies: file_picker: ^4.6.1 flutter_quill: path: ../ + flutter_quill_extensions: + path: ../flutter_quill_extensions + +dependency_overrides: + flutter_quill: + path: ../ dev_dependencies: flutter_test: diff --git a/flutter_quill_extensions/.metadata b/flutter_quill_extensions/.metadata new file mode 100644 index 00000000..e7011f64 --- /dev/null +++ b/flutter_quill_extensions/.metadata @@ -0,0 +1,10 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: f1875d570e39de09040c8f79aa13cc56baab8db1 + channel: stable + +project_type: package diff --git a/flutter_quill_extensions/CHANGELOG.md b/flutter_quill_extensions/CHANGELOG.md new file mode 100644 index 00000000..41cc7d81 --- /dev/null +++ b/flutter_quill_extensions/CHANGELOG.md @@ -0,0 +1,3 @@ +## 0.0.1 + +* TODO: Describe initial release. diff --git a/flutter_quill_extensions/LICENSE b/flutter_quill_extensions/LICENSE new file mode 100644 index 00000000..ba75c69f --- /dev/null +++ b/flutter_quill_extensions/LICENSE @@ -0,0 +1 @@ +TODO: Add your license here. diff --git a/flutter_quill_extensions/README.md b/flutter_quill_extensions/README.md new file mode 100644 index 00000000..431f5eb4 --- /dev/null +++ b/flutter_quill_extensions/README.md @@ -0,0 +1,22 @@ +# Flutter Quill Extensions + +Helpers to support embed widgets in flutter_quill. + +## Usage + +Set the `embedBuilders` and `embedToolbar` params in `QuillEditor` and `QuillToolbar` with the +values provided by this repository. + +``` +QuillEditor.basic( + controller: controller, + embedBuilders: FlutterQuillEmbeds.builders, +); +``` + +``` +QuillToolbar.basic( + controller: controller, + embedButtons: FlutterQuillEmbeds.buttons(), +); +``` diff --git a/flutter_quill_extensions/analysis_options.yaml b/flutter_quill_extensions/analysis_options.yaml new file mode 100644 index 00000000..7749c861 --- /dev/null +++ b/flutter_quill_extensions/analysis_options.yaml @@ -0,0 +1,37 @@ +include: package:pedantic/analysis_options.yaml + +analyzer: + errors: + undefined_prefixed_name: ignore + unsafe_html: ignore +linter: + rules: + - always_declare_return_types + - always_put_required_named_parameters_first + - annotate_overrides + - avoid_empty_else + - avoid_escaping_inner_quotes + - avoid_print + - avoid_redundant_argument_values + - avoid_types_on_closure_parameters + - avoid_void_async + - cascade_invocations + - directives_ordering + - lines_longer_than_80_chars + - omit_local_variable_types + - prefer_const_constructors + - prefer_const_constructors_in_immutables + - prefer_const_declarations + - prefer_final_fields + - prefer_final_in_for_each + - prefer_final_locals + - prefer_initializing_formals + - prefer_int_literals + - prefer_interpolation_to_compose_strings + - prefer_relative_imports + - prefer_single_quotes + - sort_constructors_first + - sort_unnamed_constructors_first + - unnecessary_lambdas + - unnecessary_parenthesis + - unnecessary_string_interpolations diff --git a/flutter_quill_extensions/lib/embeds/builders.dart b/flutter_quill_extensions/lib/embeds/builders.dart new file mode 100644 index 00000000..8e3540c7 --- /dev/null +++ b/flutter_quill_extensions/lib/embeds/builders.dart @@ -0,0 +1,282 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_quill/extensions.dart' as base; +import 'package:flutter_quill/flutter_quill.dart' hide Text; +import 'package:flutter_quill/translations.dart'; +import 'package:gallery_saver/gallery_saver.dart'; +import 'package:math_keyboard/math_keyboard.dart'; +import 'package:tuple/tuple.dart'; + +import 'utils.dart'; +import 'widgets/image.dart'; +import 'widgets/image_resizer.dart'; +import 'widgets/video_app.dart'; +import 'widgets/youtube_video_app.dart'; + +class ImageEmbedBuilder implements EmbedBuilder { + @override + String get key => BlockEmbed.imageType; + + @override + Widget build( + BuildContext context, + QuillController controller, + base.Embed node, + bool readOnly, + void Function(GlobalKey videoContainerKey)? onVideoInit, + ) { + assert(!kIsWeb, 'Please provide image EmbedBuilder for Web'); + + var image; + final imageUrl = standardizeImageUrl(node.value.data); + Tuple2? _widthHeight; + final style = node.style.attributes['style']; + if (base.isMobile() && style != null) { + final _attrs = base.parseKeyValuePairs(style.value.toString(), { + Attribute.mobileWidth, + Attribute.mobileHeight, + Attribute.mobileMargin, + Attribute.mobileAlignment + }); + if (_attrs.isNotEmpty) { + assert( + _attrs[Attribute.mobileWidth] != null && + _attrs[Attribute.mobileHeight] != null, + 'mobileWidth and mobileHeight must be specified'); + final w = double.parse(_attrs[Attribute.mobileWidth]!); + final h = double.parse(_attrs[Attribute.mobileHeight]!); + _widthHeight = Tuple2(w, h); + final m = _attrs[Attribute.mobileMargin] == null + ? 0.0 + : double.parse(_attrs[Attribute.mobileMargin]!); + final a = base.getAlignment(_attrs[Attribute.mobileAlignment]); + image = Padding( + padding: EdgeInsets.all(m), + child: imageByUrl(imageUrl, width: w, height: h, alignment: a)); + } + } + + if (_widthHeight == null) { + image = imageByUrl(imageUrl); + _widthHeight = Tuple2((image as Image).width, image.height); + } + + if (!readOnly && base.isMobile()) { + 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) { + final _screenSize = MediaQuery.of(context).size; + return ImageResizer( + onImageResize: (w, h) { + final res = getEmbedNode( + controller, controller.selection.start); + final attr = base.replaceStyleString( + getImageStyleString(controller), w, h); + controller + ..skipRequestKeyboard = true + ..formatText( + res.item1, 1, StyleAttribute(attr)); + }, + imageWidth: _widthHeight?.item1, + imageHeight: _widthHeight?.item2, + maxWidth: _screenSize.width, + maxHeight: _screenSize.height); + }); + }, + ); + final copyOption = _SimpleDialogItem( + icon: Icons.copy_all_outlined, + color: Colors.cyanAccent, + text: 'Copy'.i18n, + onPressed: () { + final imageNode = + getEmbedNode(controller, controller.selection.start) + .item2; + final imageUrl = imageNode.value.data; + controller.copiedImageUrl = + Tuple2(imageUrl, getImageStyleString(controller)); + Navigator.pop(context); + }, + ); + final removeOption = _SimpleDialogItem( + icon: Icons.delete_forever_outlined, + color: Colors.red.shade200, + text: 'Remove'.i18n, + onPressed: () { + final offset = + getEmbedNode(controller, controller.selection.start) + .item1; + 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: [resizeOption, copyOption, removeOption]), + ); + }); + }, + child: image); + } + + if (!readOnly || !base.isMobile() || isImageBase64(imageUrl)) { + return image; + } + + // We provide option menu for mobile platform excluding base64 image + return _menuOptionsForReadonlyImage(context, imageUrl, image); + } +} + +class VideoEmbedBuilder implements EmbedBuilder { + @override + String get key => BlockEmbed.videoType; + + @override + Widget build( + BuildContext context, + QuillController controller, + base.Embed node, + bool readOnly, + void Function(GlobalKey videoContainerKey)? onVideoInit) { + assert(!kIsWeb, 'Please provide video EmbedBuilder for Web'); + + final videoUrl = node.value.data; + if (videoUrl.contains('youtube.com') || videoUrl.contains('youtu.be')) { + return YoutubeVideoApp( + videoUrl: videoUrl, context: context, readOnly: readOnly); + } + return VideoApp( + videoUrl: videoUrl, + context: context, + readOnly: readOnly, + onVideoInit: onVideoInit, + ); + } +} + +class FormulaEmbedBuilder implements EmbedBuilder { + @override + String get key => BlockEmbed.formulaType; + + @override + Widget build( + BuildContext context, + QuillController controller, + base.Embed node, + bool readOnly, + void Function(GlobalKey videoContainerKey)? onVideoInit) { + assert(!kIsWeb, 'Please provide formula EmbedBuilder for Web'); + + final mathController = MathFieldEditingController(); + return Focus( + onFocusChange: (hasFocus) { + if (hasFocus) { + // If the MathField is tapped, hides the built in keyboard + SystemChannels.textInput.invokeMethod('TextInput.hide'); + debugPrint(mathController.currentEditingValue()); + } + }, + child: MathField( + controller: mathController, + variables: const ['x', 'y', 'z'], + onChanged: (value) {}, + onSubmitted: (value) {}, + ), + ); + } +} + +Widget _menuOptionsForReadonlyImage( + BuildContext context, String imageUrl, Widget image) { + return GestureDetector( + onTap: () { + showDialog( + context: context, + builder: (context) { + final saveOption = _SimpleDialogItem( + icon: Icons.save, + color: Colors.greenAccent, + text: 'Save'.i18n, + onPressed: () { + imageUrl = appendFileExtensionToImageUrl(imageUrl); + GallerySaver.saveImage(imageUrl).then((_) { + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text('Saved'.i18n))); + Navigator.pop(context); + }); + }, + ); + final zoomOption = _SimpleDialogItem( + icon: Icons.zoom_in, + color: Colors.cyanAccent, + text: 'Zoom'.i18n, + onPressed: () { + Navigator.pushReplacement( + context, + MaterialPageRoute( + builder: (context) => + ImageTapWrapper(imageUrl: imageUrl))); + }, + ); + 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); +} + +class _SimpleDialogItem extends StatelessWidget { + const _SimpleDialogItem( + {required this.icon, + required this.color, + required this.text, + required this.onPressed, + Key? key}) + : super(key: key); + + final IconData icon; + final Color color; + final String text; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + return SimpleDialogOption( + onPressed: onPressed, + child: Row( + children: [ + Icon(icon, size: 36, color: color), + Padding( + padding: const EdgeInsetsDirectional.only(start: 16), + child: + Text(text, style: const TextStyle(fontWeight: FontWeight.bold)), + ), + ], + ), + ); + } +} diff --git a/flutter_quill_extensions/lib/embeds/embed_types.dart b/flutter_quill_extensions/lib/embeds/embed_types.dart new file mode 100644 index 00000000..814b77b6 --- /dev/null +++ b/flutter_quill_extensions/lib/embeds/embed_types.dart @@ -0,0 +1,20 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; + +typedef OnImagePickCallback = Future Function(File file); +typedef OnVideoPickCallback = Future Function(File file); +typedef FilePickImpl = Future Function(BuildContext context); +typedef WebImagePickImpl = Future Function( + OnImagePickCallback onImagePickCallback); +typedef WebVideoPickImpl = Future Function( + OnVideoPickCallback onImagePickCallback); +typedef MediaPickSettingSelector = Future Function( + BuildContext context); + +enum MediaPickSetting { + Gallery, + Link, + Camera, + Video, +} diff --git a/lib/src/widgets/toolbar/camera_button.dart b/flutter_quill_extensions/lib/embeds/toolbar/camera_button.dart similarity index 95% rename from lib/src/widgets/toolbar/camera_button.dart rename to flutter_quill_extensions/lib/embeds/toolbar/camera_button.dart index d367ae6c..e1d544b8 100644 --- a/lib/src/widgets/toolbar/camera_button.dart +++ b/flutter_quill_extensions/lib/embeds/toolbar/camera_button.dart @@ -1,10 +1,11 @@ import 'package:flutter/material.dart'; +import 'package:flutter_quill/flutter_quill.dart' hide Text; import 'package:image_picker/image_picker.dart'; -import '../../models/themes/quill_icon_theme.dart'; -import '../../translations/toolbar.i18n.dart'; -import '../controller.dart'; -import '../toolbar.dart'; +import 'package:flutter_quill/translations.dart'; + +import '../embed_types.dart'; +import 'image_video_utils.dart'; class CameraButton extends StatelessWidget { const CameraButton({ diff --git a/lib/src/widgets/toolbar/formula_button.dart b/flutter_quill_extensions/lib/embeds/toolbar/formula_button.dart similarity index 88% rename from lib/src/widgets/toolbar/formula_button.dart rename to flutter_quill_extensions/lib/embeds/toolbar/formula_button.dart index 13cc036b..bf777b67 100644 --- a/lib/src/widgets/toolbar/formula_button.dart +++ b/flutter_quill_extensions/lib/embeds/toolbar/formula_button.dart @@ -1,10 +1,7 @@ import 'package:flutter/material.dart'; +import 'package:flutter_quill/flutter_quill.dart'; -import '../../models/documents/nodes/embeddable.dart'; -import '../../models/themes/quill_dialog_theme.dart'; -import '../../models/themes/quill_icon_theme.dart'; -import '../controller.dart'; -import '../toolbar.dart'; +import '../embed_types.dart'; class FormulaButton extends StatelessWidget { const FormulaButton({ diff --git a/lib/src/widgets/toolbar/image_button.dart b/flutter_quill_extensions/lib/embeds/toolbar/image_button.dart similarity index 92% rename from lib/src/widgets/toolbar/image_button.dart rename to flutter_quill_extensions/lib/embeds/toolbar/image_button.dart index 2a2fb827..5cc51aff 100644 --- a/lib/src/widgets/toolbar/image_button.dart +++ b/flutter_quill_extensions/lib/embeds/toolbar/image_button.dart @@ -1,11 +1,9 @@ import 'package:flutter/material.dart'; +import 'package:flutter_quill/flutter_quill.dart'; import 'package:image_picker/image_picker.dart'; -import '../../models/documents/nodes/embeddable.dart'; -import '../../models/themes/quill_dialog_theme.dart'; -import '../../models/themes/quill_icon_theme.dart'; -import '../controller.dart'; -import '../toolbar.dart'; +import '../embed_types.dart'; +import 'image_video_utils.dart'; class ImageButton extends StatelessWidget { const ImageButton({ diff --git a/lib/src/widgets/toolbar/image_video_utils.dart b/flutter_quill_extensions/lib/embeds/toolbar/image_video_utils.dart similarity index 94% rename from lib/src/widgets/toolbar/image_video_utils.dart rename to flutter_quill_extensions/lib/embeds/toolbar/image_video_utils.dart index eca9dc9c..a40c3c2b 100644 --- a/lib/src/widgets/toolbar/image_video_utils.dart +++ b/flutter_quill_extensions/lib/embeds/toolbar/image_video_utils.dart @@ -2,15 +2,13 @@ import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_quill/flutter_quill.dart' hide Text; import 'package:image_picker/image_picker.dart'; -import '../../models/documents/nodes/embeddable.dart'; -import '../../models/rules/insert.dart'; -import '../../models/themes/quill_dialog_theme.dart'; -import '../../translations/toolbar.i18n.dart'; -import '../../utils/platform.dart'; -import '../controller.dart'; -import '../toolbar.dart'; +import 'package:flutter_quill/translations.dart'; +import 'package:flutter_quill/extensions.dart'; + +import '../embed_types.dart'; class LinkDialog extends StatefulWidget { const LinkDialog({this.dialogTheme, this.link, Key? key}) : super(key: key); @@ -75,13 +73,6 @@ class LinkDialogState extends State { } } -enum MediaPickSetting { - Gallery, - Link, - Camera, - Video, -} - class ImageVideoUtils { static Future selectMediaPickSetting( BuildContext context, diff --git a/lib/src/widgets/toolbar/video_button.dart b/flutter_quill_extensions/lib/embeds/toolbar/video_button.dart similarity index 92% rename from lib/src/widgets/toolbar/video_button.dart rename to flutter_quill_extensions/lib/embeds/toolbar/video_button.dart index 17e7f1a6..e6193622 100644 --- a/lib/src/widgets/toolbar/video_button.dart +++ b/flutter_quill_extensions/lib/embeds/toolbar/video_button.dart @@ -1,11 +1,9 @@ import 'package:flutter/material.dart'; +import 'package:flutter_quill/flutter_quill.dart'; import 'package:image_picker/image_picker.dart'; -import '../../models/documents/nodes/embeddable.dart'; -import '../../models/themes/quill_dialog_theme.dart'; -import '../../models/themes/quill_icon_theme.dart'; -import '../controller.dart'; -import '../toolbar.dart'; +import '../embed_types.dart'; +import 'image_video_utils.dart'; class VideoButton extends StatelessWidget { const VideoButton({ diff --git a/flutter_quill_extensions/lib/embeds/utils.dart b/flutter_quill_extensions/lib/embeds/utils.dart new file mode 100644 index 00000000..360fb5d5 --- /dev/null +++ b/flutter_quill_extensions/lib/embeds/utils.dart @@ -0,0 +1,5 @@ +import 'package:string_validator/string_validator.dart'; + +bool isImageBase64(String imageUrl) { + return !imageUrl.startsWith('http') && isBase64(imageUrl); +} diff --git a/lib/src/widgets/embeds/image.dart b/flutter_quill_extensions/lib/embeds/widgets/image.dart similarity index 93% rename from lib/src/widgets/embeds/image.dart rename to flutter_quill_extensions/lib/embeds/widgets/image.dart index 8f2d70af..d4df2a4c 100644 --- a/lib/src/widgets/embeds/image.dart +++ b/flutter_quill_extensions/lib/embeds/widgets/image.dart @@ -2,12 +2,10 @@ import 'dart:convert'; import 'dart:io' as io; import 'package:flutter/material.dart'; +import 'package:flutter_quill/flutter_quill.dart'; import 'package:photo_view/photo_view.dart'; -import 'package:string_validator/string_validator.dart'; -import '../../models/documents/attribute.dart'; -import '../../models/documents/style.dart'; -import '../controller.dart'; +import '../utils.dart'; const List imageFileExtensions = [ '.jpeg', @@ -19,10 +17,6 @@ const List imageFileExtensions = [ '.heic' ]; -bool isImageBase64(String imageUrl) { - return !imageUrl.startsWith('http') && isBase64(imageUrl); -} - String getImageStyleString(QuillController controller) { final String? s = controller .getAllSelectionStyles() diff --git a/lib/src/widgets/embeds/image_resizer.dart b/flutter_quill_extensions/lib/embeds/widgets/image_resizer.dart similarity index 98% rename from lib/src/widgets/embeds/image_resizer.dart rename to flutter_quill_extensions/lib/embeds/widgets/image_resizer.dart index 03e3f623..e520d0c9 100644 --- a/lib/src/widgets/embeds/image_resizer.dart +++ b/flutter_quill_extensions/lib/embeds/widgets/image_resizer.dart @@ -3,7 +3,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; -import '../../translations/toolbar.i18n.dart'; +import 'package:flutter_quill/translations.dart'; class ImageResizer extends StatefulWidget { const ImageResizer( diff --git a/lib/src/widgets/embeds/video_app.dart b/flutter_quill_extensions/lib/embeds/widgets/video_app.dart similarity index 98% rename from lib/src/widgets/embeds/video_app.dart rename to flutter_quill_extensions/lib/embeds/widgets/video_app.dart index ce1ce881..c05fe600 100644 --- a/lib/src/widgets/embeds/video_app.dart +++ b/flutter_quill_extensions/lib/embeds/widgets/video_app.dart @@ -2,11 +2,10 @@ import 'dart:io'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_quill/flutter_quill.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:video_player/video_player.dart'; -import '../../../flutter_quill.dart'; - /// Widget for playing back video /// Refer to https://github.com/flutter/plugins/tree/master/packages/video_player/video_player class VideoApp extends StatefulWidget { diff --git a/lib/src/widgets/embeds/youtube_video_app.dart b/flutter_quill_extensions/lib/embeds/widgets/youtube_video_app.dart similarity index 97% rename from lib/src/widgets/embeds/youtube_video_app.dart rename to flutter_quill_extensions/lib/embeds/widgets/youtube_video_app.dart index 79b64b97..8032b30f 100644 --- a/lib/src/widgets/embeds/youtube_video_app.dart +++ b/flutter_quill_extensions/lib/embeds/widgets/youtube_video_app.dart @@ -1,10 +1,9 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_quill/flutter_quill.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:youtube_player_flutter_quill/youtube_player_flutter_quill.dart'; -import '../default_styles.dart'; - class YoutubeVideoApp extends StatefulWidget { const YoutubeVideoApp( {required this.videoUrl, required this.context, required this.readOnly}); diff --git a/flutter_quill_extensions/lib/flutter_quill_extensions.dart b/flutter_quill_extensions/lib/flutter_quill_extensions.dart new file mode 100644 index 00000000..e23b9d66 --- /dev/null +++ b/flutter_quill_extensions/lib/flutter_quill_extensions.dart @@ -0,0 +1,94 @@ +library flutter_quill_extensions; + +import 'package:flutter/material.dart'; +import 'package:flutter_quill/flutter_quill.dart'; + +import 'embeds/builders.dart'; +import 'embeds/embed_types.dart'; +import 'embeds/toolbar/camera_button.dart'; +import 'embeds/toolbar/formula_button.dart'; +import 'embeds/toolbar/image_button.dart'; +import 'embeds/toolbar/video_button.dart'; + +export 'embeds/embed_types.dart'; +export 'embeds/toolbar/camera_button.dart'; +export 'embeds/toolbar/formula_button.dart'; +export 'embeds/toolbar/image_button.dart'; +export 'embeds/toolbar/image_video_utils.dart'; +export 'embeds/toolbar/video_button.dart'; +export 'embeds/utils.dart'; + +class FlutterQuillEmbeds { + static List get builders => [ + ImageEmbedBuilder(), + VideoEmbedBuilder(), + FormulaEmbedBuilder(), + ]; + + static List buttons({ + bool showImageButton = true, + bool showVideoButton = true, + bool showCameraButton = true, + bool showFormulaButton = false, + OnImagePickCallback? onImagePickCallback, + OnVideoPickCallback? onVideoPickCallback, + MediaPickSettingSelector? mediaPickSettingSelector, + MediaPickSettingSelector? cameraPickSettingSelector, + FilePickImpl? filePickImpl, + WebImagePickImpl? webImagePickImpl, + WebVideoPickImpl? webVideoPickImpl, + }) { + return [ + if (showImageButton) + (controller, toolbarIconSize, iconTheme, dialogTheme) => ImageButton( + icon: Icons.image, + iconSize: toolbarIconSize, + controller: controller, + onImagePickCallback: onImagePickCallback, + filePickImpl: filePickImpl, + webImagePickImpl: webImagePickImpl, + mediaPickSettingSelector: mediaPickSettingSelector, + iconTheme: iconTheme, + dialogTheme: dialogTheme, + ), + if (showVideoButton) + (controller, toolbarIconSize, iconTheme, dialogTheme) => VideoButton( + icon: Icons.movie_creation, + iconSize: toolbarIconSize, + controller: controller, + onVideoPickCallback: onVideoPickCallback, + filePickImpl: filePickImpl, + webVideoPickImpl: webImagePickImpl, + mediaPickSettingSelector: mediaPickSettingSelector, + iconTheme: iconTheme, + dialogTheme: dialogTheme, + ), + if ((onImagePickCallback != null || onVideoPickCallback != null) && + showCameraButton) + (controller, toolbarIconSize, iconTheme, dialogTheme) => CameraButton( + icon: Icons.photo_camera, + iconSize: toolbarIconSize, + controller: controller, + onImagePickCallback: onImagePickCallback, + onVideoPickCallback: onVideoPickCallback, + filePickImpl: filePickImpl, + webImagePickImpl: webImagePickImpl, + webVideoPickImpl: webVideoPickImpl, + cameraPickSettingSelector: cameraPickSettingSelector, + iconTheme: iconTheme, + ), + if (showFormulaButton) + (controller, toolbarIconSize, iconTheme, dialogTheme) => FormulaButton( + icon: Icons.functions, + iconSize: toolbarIconSize, + controller: controller, + onImagePickCallback: onImagePickCallback, + filePickImpl: filePickImpl, + webImagePickImpl: webImagePickImpl, + mediaPickSettingSelector: mediaPickSettingSelector, + iconTheme: iconTheme, + dialogTheme: dialogTheme, + ) + ]; + } +} diff --git a/flutter_quill_extensions/pubspec.yaml b/flutter_quill_extensions/pubspec.yaml new file mode 100644 index 00000000..fe77b5aa --- /dev/null +++ b/flutter_quill_extensions/pubspec.yaml @@ -0,0 +1,36 @@ +name: flutter_quill_extensions +description: Embed extensions for flutter_quill +version: 0.0.1 +homepage: https://bulletjournal.us/home/index.html +#author: bulletjournal +repository: https://github.com/singerdmx/flutter-quill/flutter_quill_extensions + +environment: + sdk: ">=2.12.0 <3.0.0" + flutter: ">=3.0.0" + +dependencies: + flutter: + sdk: flutter + + flutter_quill: ^6.0.0 + + image_picker: ^0.8.5+3 + photo_view: ^0.14.0 + video_player: ^2.4.2 + youtube_player_flutter_quill: ^8.2.2 + gallery_saver: ^2.3.2 + math_keyboard: ^0.1.6 + string_validator: ^0.3.0 + +dependency_overrides: + flutter_quill: + path: ../ + +dev_dependencies: + flutter_test: + sdk: flutter + pedantic: ^1.11.1 + +# The following section is specific to Flutter packages. +flutter: diff --git a/lib/extensions.dart b/lib/extensions.dart new file mode 100644 index 00000000..12e961c2 --- /dev/null +++ b/lib/extensions.dart @@ -0,0 +1,6 @@ +library flutter_quill.extensions; + +export 'src/models/documents/nodes/leaf.dart' hide Text; +export 'src/models/rules/insert.dart'; +export 'src/utils/platform.dart'; +export 'src/utils/string.dart'; diff --git a/lib/flutter_quill.dart b/lib/flutter_quill.dart index f69945d7..eae6c5de 100644 --- a/lib/flutter_quill.dart +++ b/lib/flutter_quill.dart @@ -14,7 +14,7 @@ export 'src/utils/embeds.dart'; export 'src/widgets/controller.dart'; export 'src/widgets/default_styles.dart'; export 'src/widgets/editor.dart'; -export 'src/widgets/embeds/image.dart'; +export 'src/widgets/embeds.dart'; export 'src/widgets/link.dart' show LinkActionPickerDelegate, LinkMenuAction; export 'src/widgets/style_widgets/style_widgets.dart'; export 'src/widgets/toolbar.dart'; diff --git a/lib/src/widgets/delegate.dart b/lib/src/widgets/delegate.dart index 6fdd81af..812277a8 100644 --- a/lib/src/widgets/delegate.dart +++ b/lib/src/widgets/delegate.dart @@ -6,7 +6,7 @@ import 'package:flutter/scheduler.dart'; import '../../flutter_quill.dart'; import 'text_selection.dart'; -typedef EmbedBuilder = Widget Function( +typedef EmbedsBuilder = Widget Function( BuildContext context, QuillController controller, Embed node, @@ -14,14 +14,6 @@ typedef EmbedBuilder = Widget Function( void Function(GlobalKey videoContainerKey)? onVideoInit, ); -typedef CustomEmbedBuilder = Widget Function( - BuildContext context, - QuillController controller, - CustomBlockEmbed block, - bool readOnly, - void Function(GlobalKey videoContainerKey)? onVideoInit, -); - typedef CustomStyleBuilder = TextStyle Function(Attribute attribute); /// Delegate interface for the [EditorTextSelectionGestureDetectorBuilder]. diff --git a/lib/src/widgets/editor.dart b/lib/src/widgets/editor.dart index 6dd54cd1..1bb63641 100644 --- a/lib/src/widgets/editor.dart +++ b/lib/src/widgets/editor.dart @@ -12,6 +12,7 @@ import 'package:tuple/tuple.dart'; import '../models/documents/document.dart'; import '../models/documents/nodes/container.dart' as container_node; import '../models/documents/nodes/embeddable.dart'; +import '../models/documents/nodes/leaf.dart'; import '../models/documents/style.dart'; import '../utils/platform.dart'; import 'box.dart'; @@ -19,7 +20,7 @@ import 'controller.dart'; import 'cursor.dart'; import 'default_styles.dart'; import 'delegate.dart'; -import 'embeds/default_embed_builder.dart'; +import 'embeds.dart'; import 'float_cursor.dart'; import 'link.dart'; import 'raw_editor.dart'; @@ -168,8 +169,7 @@ class QuillEditor extends StatefulWidget { this.onSingleLongTapStart, this.onSingleLongTapMoveUpdate, this.onSingleLongTapEnd, - this.embedBuilder = defaultEmbedBuilder, - this.customElementsEmbedBuilder, + this.embedBuilders, this.linkActionPickerDelegate = defaultLinkActionPickerDelegate, this.customStyleBuilder, this.locale, @@ -182,6 +182,7 @@ class QuillEditor extends StatefulWidget { required QuillController controller, required bool readOnly, Brightness? keyboardAppearance, + Iterable? embedBuilders, /// The locale to use for the editor toolbar, defaults to system locale /// More at https://github.com/singerdmx/flutter-quill#translation @@ -198,6 +199,7 @@ class QuillEditor extends StatefulWidget { padding: EdgeInsets.zero, keyboardAppearance: keyboardAppearance ?? Brightness.light, locale: locale, + embedBuilders: embedBuilders, ); } @@ -346,8 +348,7 @@ class QuillEditor extends StatefulWidget { LongPressEndDetails details, TextPosition Function(Offset offset))? onSingleLongTapEnd; - final EmbedBuilder embedBuilder; - final CustomEmbedBuilder? customElementsEmbedBuilder; + final Iterable? embedBuilders; final CustomStyleBuilder? customStyleBuilder; /// The locale to use for the editor toolbar, defaults to system locale @@ -473,23 +474,28 @@ class QuillEditorState extends State readOnly, onVideoInit, ) { - final customElementsEmbedBuilder = widget.customElementsEmbedBuilder; - final isCustomType = node.value.type == BlockEmbed.customType; - if (customElementsEmbedBuilder != null && isCustomType) { - return customElementsEmbedBuilder( - context, - controller, - CustomBlockEmbed.fromJsonString(node.value.data), - readOnly, - onVideoInit, - ); + final builders = widget.embedBuilders; + + if (builders != null) { + var _node = node; + + // Creates correct node for custom embed + if (node.value.type == BlockEmbed.customType) { + _node = Embed(CustomBlockEmbed.fromJsonString(node.value.data)); + } + + for (final builder in builders) { + if (builder.key == _node.value.type) { + return builder.build( + context, controller, _node, readOnly, onVideoInit); + } + } } - return widget.embedBuilder( - context, - controller, - node, - readOnly, - onVideoInit, + + throw UnimplementedError( + 'Embeddable type "${node.value.type}" is not supported by supplied ' + 'embed builders. You must pass your own builder function to ' + 'embedBuilders property of QuillEditor or QuillField widgets.', ); }, linkActionPickerDelegate: widget.linkActionPickerDelegate, diff --git a/lib/src/widgets/embeds.dart b/lib/src/widgets/embeds.dart new file mode 100644 index 00000000..ffbcd14a --- /dev/null +++ b/lib/src/widgets/embeds.dart @@ -0,0 +1,24 @@ +import 'package:flutter/material.dart'; + +import '../models/documents/nodes/leaf.dart' as leaf; +import '../models/themes/quill_dialog_theme.dart'; +import '../models/themes/quill_icon_theme.dart'; +import 'controller.dart'; + +abstract class EmbedBuilder { + String get key; + + Widget build( + BuildContext context, + QuillController controller, + leaf.Embed node, + bool readOnly, + void Function(GlobalKey videoContainerKey)? onVideoInit, + ); +} + +typedef EmbedButtonBuilder = Widget Function( + QuillController controller, + double toolbarIconSize, + QuillIconTheme? iconTheme, + QuillDialogTheme? dialogTheme); diff --git a/lib/src/widgets/embeds/default_embed_builder.dart b/lib/src/widgets/embeds/default_embed_builder.dart deleted file mode 100644 index bc3b9a81..00000000 --- a/lib/src/widgets/embeds/default_embed_builder.dart +++ /dev/null @@ -1,260 +0,0 @@ -import 'package:flutter/cupertino.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:gallery_saver/gallery_saver.dart'; -import 'package:math_keyboard/math_keyboard.dart'; -import 'package:tuple/tuple.dart'; - -import '../../models/documents/attribute.dart'; -import '../../models/documents/nodes/embeddable.dart'; -import '../../models/documents/nodes/leaf.dart' as leaf; -import '../../translations/toolbar.i18n.dart'; -import '../../utils/embeds.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, - QuillController controller, - leaf.Embed node, - bool readOnly, - void Function(GlobalKey videoContainerKey)? onVideoInit, -) { - assert(!kIsWeb, 'Please provide EmbedBuilder for Web'); - - Tuple2? _widthHeight; - switch (node.value.type) { - case BlockEmbed.imageType: - final imageUrl = standardizeImageUrl(node.value.data); - var image; - final style = node.style.attributes['style']; - if (isMobile() && style != null) { - final _attrs = parseKeyValuePairs(style.value.toString(), { - Attribute.mobileWidth, - Attribute.mobileHeight, - Attribute.mobileMargin, - Attribute.mobileAlignment - }); - if (_attrs.isNotEmpty) { - assert( - _attrs[Attribute.mobileWidth] != null && - _attrs[Attribute.mobileHeight] != null, - 'mobileWidth and mobileHeight must be specified'); - final w = double.parse(_attrs[Attribute.mobileWidth]!); - final h = double.parse(_attrs[Attribute.mobileHeight]!); - _widthHeight = Tuple2(w, h); - final m = _attrs[Attribute.mobileMargin] == null - ? 0.0 - : double.parse(_attrs[Attribute.mobileMargin]!); - final a = getAlignment(_attrs[Attribute.mobileAlignment]); - image = Padding( - padding: EdgeInsets.all(m), - child: imageByUrl(imageUrl, width: w, height: h, alignment: a)); - } - } - - if (_widthHeight == null) { - image = imageByUrl(imageUrl); - _widthHeight = Tuple2((image as Image).width, image.height); - } - - if (!readOnly && isMobile()) { - 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) { - final _screenSize = MediaQuery.of(context).size; - return ImageResizer( - onImageResize: (w, h) { - final res = getEmbedNode( - controller, controller.selection.start); - final attr = replaceStyleString( - getImageStyleString(controller), w, h); - controller - ..skipRequestKeyboard = true - ..formatText( - res.item1, 1, StyleAttribute(attr)); - }, - imageWidth: _widthHeight?.item1, - imageHeight: _widthHeight?.item2, - maxWidth: _screenSize.width, - maxHeight: _screenSize.height); - }); - }, - ); - final copyOption = _SimpleDialogItem( - icon: Icons.copy_all_outlined, - color: Colors.cyanAccent, - text: 'Copy'.i18n, - onPressed: () { - final imageNode = - getEmbedNode(controller, controller.selection.start) - .item2; - final imageUrl = imageNode.value.data; - controller.copiedImageUrl = - Tuple2(imageUrl, getImageStyleString(controller)); - Navigator.pop(context); - }, - ); - final removeOption = _SimpleDialogItem( - icon: Icons.delete_forever_outlined, - color: Colors.red.shade200, - text: 'Remove'.i18n, - onPressed: () { - final offset = - getEmbedNode(controller, controller.selection.start) - .item1; - 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: [resizeOption, copyOption, removeOption]), - ); - }); - }, - child: image); - } - - if (!readOnly || !isMobile() || isImageBase64(imageUrl)) { - return image; - } - - // We provide option menu for mobile platform excluding base64 image - return _menuOptionsForReadonlyImage(context, imageUrl, image); - case BlockEmbed.videoType: - final videoUrl = node.value.data; - if (videoUrl.contains('youtube.com') || videoUrl.contains('youtu.be')) { - return YoutubeVideoApp( - videoUrl: videoUrl, context: context, readOnly: readOnly); - } - return VideoApp( - videoUrl: videoUrl, - context: context, - readOnly: readOnly, - onVideoInit: onVideoInit, - ); - case BlockEmbed.formulaType: - final mathController = MathFieldEditingController(); - - return Focus( - onFocusChange: (hasFocus) { - if (hasFocus) { - // If the MathField is tapped, hides the built in keyboard - SystemChannels.textInput.invokeMethod('TextInput.hide'); - debugPrint(mathController.currentEditingValue()); - } - }, - child: MathField( - controller: mathController, - variables: const ['x', 'y', 'z'], - onChanged: (value) {}, - onSubmitted: (value) {}, - ), - ); - default: - throw UnimplementedError( - 'Embeddable type "${node.value.type}" is not supported by default ' - 'embed builder of QuillEditor. You must pass your own builder function ' - 'to embedBuilder property of QuillEditor or QuillField widgets.', - ); - } -} - -Widget _menuOptionsForReadonlyImage( - BuildContext context, String imageUrl, Widget image) { - return GestureDetector( - onTap: () { - showDialog( - context: context, - builder: (context) { - final saveOption = _SimpleDialogItem( - icon: Icons.save, - color: Colors.greenAccent, - text: 'Save'.i18n, - onPressed: () { - imageUrl = appendFileExtensionToImageUrl(imageUrl); - GallerySaver.saveImage(imageUrl).then((_) { - ScaffoldMessenger.of(context) - .showSnackBar(SnackBar(content: Text('Saved'.i18n))); - Navigator.pop(context); - }); - }, - ); - final zoomOption = _SimpleDialogItem( - icon: Icons.zoom_in, - color: Colors.cyanAccent, - text: 'Zoom'.i18n, - onPressed: () { - Navigator.pushReplacement( - context, - MaterialPageRoute( - builder: (context) => - ImageTapWrapper(imageUrl: imageUrl))); - }, - ); - 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); -} - -class _SimpleDialogItem extends StatelessWidget { - const _SimpleDialogItem( - {required this.icon, - required this.color, - required this.text, - required this.onPressed, - Key? key}) - : super(key: key); - - final IconData icon; - final Color color; - final String text; - final VoidCallback onPressed; - - @override - Widget build(BuildContext context) { - return SimpleDialogOption( - onPressed: onPressed, - child: Row( - children: [ - Icon(icon, size: 36, color: color), - Padding( - padding: const EdgeInsetsDirectional.only(start: 16), - child: - Text(text, style: const TextStyle(fontWeight: FontWeight.bold)), - ), - ], - ), - ); - } -} diff --git a/lib/src/widgets/raw_editor.dart b/lib/src/widgets/raw_editor.dart index b661e512..9412b042 100644 --- a/lib/src/widgets/raw_editor.dart +++ b/lib/src/widgets/raw_editor.dart @@ -26,7 +26,6 @@ import 'cursor.dart'; import 'default_styles.dart'; import 'delegate.dart'; import 'editor.dart'; -import 'embeds/default_embed_builder.dart'; import 'keyboard_listener.dart'; import 'link.dart'; import 'proxy.dart'; @@ -46,6 +45,7 @@ class RawEditor extends StatefulWidget { required this.cursorStyle, required this.selectionColor, required this.selectionCtrls, + required this.embedBuilder, Key? key, this.scrollable = true, this.padding = EdgeInsets.zero, @@ -70,7 +70,6 @@ class RawEditor extends StatefulWidget { this.keyboardAppearance = Brightness.light, this.enableInteractiveSelection = true, this.scrollPhysics, - this.embedBuilder = defaultEmbedBuilder, this.linkActionPickerDelegate = defaultLinkActionPickerDelegate, this.customStyleBuilder, this.floatingCursorDisabled = false}) @@ -221,7 +220,7 @@ class RawEditor extends StatefulWidget { final ScrollPhysics? scrollPhysics; /// Builder function for embeddable objects. - final EmbedBuilder embedBuilder; + final EmbedsBuilder embedBuilder; final LinkActionPickerDelegate linkActionPickerDelegate; final CustomStyleBuilder? customStyleBuilder; final bool floatingCursorDisabled; diff --git a/lib/src/widgets/text_block.dart b/lib/src/widgets/text_block.dart index cd0d2c36..e4b2990d 100644 --- a/lib/src/widgets/text_block.dart +++ b/lib/src/widgets/text_block.dart @@ -79,7 +79,7 @@ class EditableTextBlock extends StatelessWidget { final bool enableInteractiveSelection; final bool hasFocus; final EdgeInsets? contentPadding; - final EmbedBuilder embedBuilder; + final EmbedsBuilder embedBuilder; final LinkActionPicker linkActionPicker; final ValueChanged? onLaunchUrl; final CustomStyleBuilder? customStyleBuilder; diff --git a/lib/src/widgets/text_line.dart b/lib/src/widgets/text_line.dart index d629485b..8c7d1be2 100644 --- a/lib/src/widgets/text_line.dart +++ b/lib/src/widgets/text_line.dart @@ -45,7 +45,7 @@ class TextLine extends StatefulWidget { final Line line; final TextDirection? textDirection; - final EmbedBuilder embedBuilder; + final EmbedsBuilder embedBuilder; final DefaultStyles styles; final bool readOnly; final QuillController controller; diff --git a/lib/src/widgets/toolbar.dart b/lib/src/widgets/toolbar.dart index 1819e9d9..715be887 100644 --- a/lib/src/widgets/toolbar.dart +++ b/lib/src/widgets/toolbar.dart @@ -1,5 +1,3 @@ -import 'dart:io'; - import 'package:flutter/material.dart'; import 'package:i18n_extension/i18n_widget.dart'; @@ -10,14 +8,11 @@ import '../models/themes/quill_icon_theme.dart'; import '../translations/toolbar.i18n.dart'; import '../utils/font.dart'; import 'controller.dart'; +import 'embeds.dart'; import 'toolbar/arrow_indicated_button_list.dart'; -import 'toolbar/camera_button.dart'; import 'toolbar/clear_format_button.dart'; import 'toolbar/color_button.dart'; -import 'toolbar/formula_button.dart'; import 'toolbar/history_button.dart'; -import 'toolbar/image_button.dart'; -import 'toolbar/image_video_utils.dart'; import 'toolbar/indent_button.dart'; import 'toolbar/link_style_button.dart'; import 'toolbar/quill_font_family_button.dart'; @@ -28,13 +23,10 @@ import 'toolbar/select_alignment_button.dart'; import 'toolbar/select_header_style_button.dart'; import 'toolbar/toggle_check_list_button.dart'; import 'toolbar/toggle_style_button.dart'; -import 'toolbar/video_button.dart'; export 'toolbar/clear_format_button.dart'; export 'toolbar/color_button.dart'; export 'toolbar/history_button.dart'; -export 'toolbar/image_button.dart'; -export 'toolbar/image_video_utils.dart'; export 'toolbar/indent_button.dart'; export 'toolbar/link_style_button.dart'; export 'toolbar/quill_font_size_button.dart'; @@ -43,17 +35,6 @@ export 'toolbar/select_alignment_button.dart'; export 'toolbar/select_header_style_button.dart'; export 'toolbar/toggle_check_list_button.dart'; export 'toolbar/toggle_style_button.dart'; -export 'toolbar/video_button.dart'; - -typedef OnImagePickCallback = Future Function(File file); -typedef OnVideoPickCallback = Future Function(File file); -typedef FilePickImpl = Future Function(BuildContext context); -typedef WebImagePickImpl = Future Function( - OnImagePickCallback onImagePickCallback); -typedef WebVideoPickImpl = Future Function( - OnVideoPickCallback onImagePickCallback); -typedef MediaPickSettingSelector = Future Function( - BuildContext context); // The default size of the icon of a button. const double kDefaultIconSize = 18; @@ -69,7 +50,6 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget { this.toolbarSectionSpacing = 4, this.multiRowsDisplay = true, this.color, - this.filePickImpl, this.customButtons = const [], this.locale, Key? key, @@ -108,19 +88,8 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget { bool showUndo = true, bool showRedo = true, bool multiRowsDisplay = true, - bool showImageButton = true, - bool showVideoButton = true, - bool showFormulaButton = false, - bool showCameraButton = true, bool showDirection = false, bool showSearchButton = true, - OnImagePickCallback? onImagePickCallback, - OnVideoPickCallback? onVideoPickCallback, - MediaPickSettingSelector? mediaPickSettingSelector, - MediaPickSettingSelector? cameraPickSettingSelector, - FilePickImpl? filePickImpl, - WebImagePickImpl? webImagePickImpl, - WebVideoPickImpl? webVideoPickImpl, List customButtons = const [], ///Map of font sizes in string @@ -129,6 +98,9 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget { ///Map of font families in string Map? fontFamilyValues, + /// Toolbar items to display for controls of embed blocks + List? embedButtons, + ///The theme to use for the icons in the toolbar, uses type [QuillIconTheme] QuillIconTheme? iconTheme, @@ -153,8 +125,7 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget { showColorButton || showBackgroundColorButton || showClearFormat || - onImagePickCallback != null || - onVideoPickCallback != null, + embedButtons?.isNotEmpty == true, showAlignmentButtons || showDirection, showLeftAlignment, showCenterAlignment, @@ -330,56 +301,9 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget { controller: controller, iconTheme: iconTheme, ), - if (showImageButton) - ImageButton( - icon: Icons.image, - iconSize: toolbarIconSize, - controller: controller, - onImagePickCallback: onImagePickCallback, - filePickImpl: filePickImpl, - webImagePickImpl: webImagePickImpl, - mediaPickSettingSelector: mediaPickSettingSelector, - iconTheme: iconTheme, - dialogTheme: dialogTheme, - ), - if (showVideoButton) - VideoButton( - icon: Icons.movie_creation, - iconSize: toolbarIconSize, - controller: controller, - onVideoPickCallback: onVideoPickCallback, - filePickImpl: filePickImpl, - webVideoPickImpl: webImagePickImpl, - mediaPickSettingSelector: mediaPickSettingSelector, - iconTheme: iconTheme, - dialogTheme: dialogTheme, - ), - if ((onImagePickCallback != null || onVideoPickCallback != null) && - showCameraButton) - CameraButton( - icon: Icons.photo_camera, - iconSize: toolbarIconSize, - controller: controller, - onImagePickCallback: onImagePickCallback, - onVideoPickCallback: onVideoPickCallback, - filePickImpl: filePickImpl, - webImagePickImpl: webImagePickImpl, - webVideoPickImpl: webVideoPickImpl, - cameraPickSettingSelector: cameraPickSettingSelector, - iconTheme: iconTheme, - ), - if (showFormulaButton) - FormulaButton( - icon: Icons.functions, - iconSize: toolbarIconSize, - controller: controller, - onImagePickCallback: onImagePickCallback, - filePickImpl: filePickImpl, - webImagePickImpl: webImagePickImpl, - mediaPickSettingSelector: mediaPickSettingSelector, - iconTheme: iconTheme, - dialogTheme: dialogTheme, - ), + if (embedButtons != null) + for (final builder in embedButtons) + builder(controller, toolbarIconSize, iconTheme, dialogTheme), if (showDividers && isButtonGroupShown[0] && (isButtonGroupShown[1] || @@ -554,8 +478,6 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget { /// is given. final Color? color; - final FilePickImpl? filePickImpl; - /// The locale to use for the editor toolbar, defaults to system locale /// More https://github.com/singerdmx/flutter-quill#translation final Locale? locale; diff --git a/lib/translations.dart b/lib/translations.dart new file mode 100644 index 00000000..d9a743c5 --- /dev/null +++ b/lib/translations.dart @@ -0,0 +1,3 @@ +library flutter_quill.translations; + +export 'src/translations/toolbar.i18n.dart'; diff --git a/pubspec.yaml b/pubspec.yaml index fc05a8af..6be94685 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -15,22 +15,15 @@ dependencies: collection: ^1.16.0 flutter_colorpicker: ^1.0.3 flutter_keyboard_visibility: ^5.2.0 - image_picker: ^0.8.5+3 - photo_view: ^0.14.0 quiver: ^3.1.0 - string_validator: ^0.3.0 tuple: ^2.0.0 url_launcher: ^6.1.2 pedantic: ^1.11.1 - video_player: ^2.4.2 characters: ^1.2.0 - youtube_player_flutter_quill: ^8.2.2 diff_match_patch: ^0.4.1 i18n_extension: ^5.0.1 - gallery_saver: ^2.3.2 device_info_plus: ^4.0.0 platform: ^3.1.0 - math_keyboard: ^0.1.6 dev_dependencies: flutter_test: