diff --git a/example/lib/pages/home_page.dart b/example/lib/pages/home_page.dart index 6c1aa87d..7f1d120c 100644 --- a/example/lib/pages/home_page.dart +++ b/example/lib/pages/home_page.dart @@ -119,7 +119,10 @@ class _HomePageState extends State { null), sizeSmall: const TextStyle(fontSize: 9), ), - customElementsEmbedBuilder: customElementsEmbedBuilder, + embedBuilders: [ + ...defaultEmbedBuilders, + NotesEmbedBuilder(addEditNote: _addEditNote) + ], ); if (kIsWeb) { quillEditor = QuillEditor( @@ -145,7 +148,7 @@ class _HomePageState extends State { null), sizeSmall: const TextStyle(fontSize: 9), ), - embedBuilder: defaultEmbedBuilderWeb); + embedBuilders: defaultEmbedBuildersWeb); } var toolbar = QuillToolbar.basic( controller: _controller!, @@ -386,37 +389,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 IEmbedBuilder { + 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..8eecf33a 100644 --- a/example/lib/pages/read_only_page.dart +++ b/example/lib/pages/read_only_page.dart @@ -38,6 +38,7 @@ class _ReadOnlyPageState extends State { readOnly: !_edit, expands: false, padding: EdgeInsets.zero, + embedBuilders: defaultEmbedBuilders, ); if (kIsWeb) { quillEditor = QuillEditor( @@ -49,7 +50,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..1a37f552 100644 --- a/example/lib/universal_ui/universal_ui.dart +++ b/example/lib/universal_ui/universal_ui.dart @@ -26,66 +26,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 IEmbedBuilder { + @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 IEmbedBuilder { + @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/lib/src/embeds/default_embed_builder.dart b/lib/src/embeds/default_embed_builder.dart index 404d2217..a813abfc 100644 --- a/lib/src/embeds/default_embed_builder.dart +++ b/lib/src/embeds/default_embed_builder.dart @@ -36,171 +36,214 @@ typedef WebVideoPickImpl = Future Function( typedef MediaPickSettingSelector = Future Function( BuildContext context); +abstract class IEmbedBuilder { + String get key; -Widget defaultEmbedBuilder( - BuildContext context, - QuillController controller, - leaf.Embed node, - bool readOnly, - void Function(GlobalKey videoContainerKey)? onVideoInit, -) { - assert(!kIsWeb, 'Please provide EmbedBuilder for Web'); + Widget build( + BuildContext context, + QuillController controller, + leaf.Embed node, + bool readOnly, + void Function(GlobalKey videoContainerKey)? onVideoInit, + ); +} - 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)); - } - } +class ImageEmbedBuilder implements IEmbedBuilder { + @override + String get key => BlockEmbed.imageType; - if (_widthHeight == null) { - image = imageByUrl(imageUrl); - _widthHeight = Tuple2((image as Image).width, image.height); - } + @override + Widget build( + BuildContext context, + QuillController controller, + leaf.Embed node, + bool readOnly, + void Function(GlobalKey videoContainerKey)? onVideoInit, + ) { + assert(!kIsWeb, 'Please provide image EmbedBuilder for Web'); - 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); + var image; + final imageUrl = standardizeImageUrl(node.value.data); + Tuple2? _widthHeight; + 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 (!readOnly || !isMobile() || isImageBase64(imageUrl)) { - return image; - } + if (_widthHeight == null) { + image = imageByUrl(imageUrl); + _widthHeight = Tuple2((image as Image).width, image.height); + } - // 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(); + 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); + } +} + +class VideoEmbedBuilder implements IEmbedBuilder { + @override + String get key => BlockEmbed.videoType; - 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.', - ); + @override + Widget build( + BuildContext context, + QuillController controller, + leaf.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 IEmbedBuilder { + @override + String get key => BlockEmbed.formulaType; + + @override + Widget build( + BuildContext context, + QuillController controller, + leaf.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) {}, + ), + ); + } +} + +List get defaultEmbedBuilders => [ + ImageEmbedBuilder(), + VideoEmbedBuilder(), + FormulaEmbedBuilder(), + ]; + Widget _menuOptionsForReadonlyImage( BuildContext context, String imageUrl, Widget image) { return GestureDetector( diff --git a/lib/src/widgets/editor.dart b/lib/src/widgets/editor.dart index 4d784fbd..6d8f5d96 100644 --- a/lib/src/widgets/editor.dart +++ b/lib/src/widgets/editor.dart @@ -9,6 +9,8 @@ import 'package:flutter/services.dart'; import 'package:i18n_extension/i18n_widget.dart'; import 'package:tuple/tuple.dart'; +import '../../flutter_quill.dart'; +import '../embeds/default_embed_builder.dart'; import '../models/documents/document.dart'; import '../models/documents/nodes/container.dart' as container_node; import '../models/documents/nodes/embeddable.dart'; @@ -19,7 +21,6 @@ import 'controller.dart'; import 'cursor.dart'; import 'default_styles.dart'; import 'delegate.dart'; -import '../embeds/default_embed_builder.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/raw_editor.dart b/lib/src/widgets/raw_editor.dart index d4c3a913..30a93948 100644 --- a/lib/src/widgets/raw_editor.dart +++ b/lib/src/widgets/raw_editor.dart @@ -46,6 +46,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 +71,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})