Consolidated embed builders

pull/933/head
Jonathan Salmon 3 years ago
parent 616fae851b
commit 606806ff6f
  1. 72
      example/lib/pages/home_page.dart
  2. 3
      example/lib/pages/read_only_page.dart
  3. 120
      example/lib/universal_ui/universal_ui.dart
  4. 351
      lib/src/embeds/default_embed_builder.dart
  5. 48
      lib/src/widgets/editor.dart
  6. 2
      lib/src/widgets/raw_editor.dart

@ -119,7 +119,10 @@ class _HomePageState extends State<HomePage> {
null), null),
sizeSmall: const TextStyle(fontSize: 9), sizeSmall: const TextStyle(fontSize: 9),
), ),
customElementsEmbedBuilder: customElementsEmbedBuilder, embedBuilders: [
...defaultEmbedBuilders,
NotesEmbedBuilder(addEditNote: _addEditNote)
],
); );
if (kIsWeb) { if (kIsWeb) {
quillEditor = QuillEditor( quillEditor = QuillEditor(
@ -145,7 +148,7 @@ class _HomePageState extends State<HomePage> {
null), null),
sizeSmall: const TextStyle(fontSize: 9), sizeSmall: const TextStyle(fontSize: 9),
), ),
embedBuilder: defaultEmbedBuilderWeb); embedBuilders: defaultEmbedBuildersWeb);
} }
var toolbar = QuillToolbar.basic( var toolbar = QuillToolbar.basic(
controller: _controller!, controller: _controller!,
@ -386,37 +389,42 @@ class _HomePageState extends State<HomePage> {
controller.replaceText(index, length, block, null); controller.replaceText(index, length, block, null);
} }
} }
}
Widget customElementsEmbedBuilder( class NotesEmbedBuilder implements IEmbedBuilder {
BuildContext context, NotesEmbedBuilder({required this.addEditNote});
QuillController controller,
CustomBlockEmbed block, Future<void> Function(BuildContext context, {Document? document}) addEditNote;
bool readOnly,
void Function(GlobalKey videoContainerKey)? onVideoInit, @override
) { String get key => 'notes';
switch (block.type) {
case 'notes': @override
final notes = NotesBlockEmbed(block.data).document; Widget build(
BuildContext context,
return Material( QuillController controller,
color: Colors.transparent, Embed node,
child: ListTile( bool readOnly,
title: Text( void Function(GlobalKey<State<StatefulWidget>> videoContainerKey)?
notes.toPlainText().replaceAll('\n', ' '), onVideoInit) {
maxLines: 3, final notes = NotesBlockEmbed(node.value.data).document;
overflow: TextOverflow.ellipsis,
), return Material(
leading: const Icon(Icons.notes), color: Colors.transparent,
onTap: () => _addEditNote(context, document: notes), child: ListTile(
shape: RoundedRectangleBorder( title: Text(
borderRadius: BorderRadius.circular(10), notes.toPlainText().replaceAll('\n', ' '),
side: const BorderSide(color: Colors.grey), maxLines: 3,
), overflow: TextOverflow.ellipsis,
), ),
); leading: const Icon(Icons.notes),
default: onTap: () => addEditNote(context, document: notes),
return const SizedBox(); shape: RoundedRectangleBorder(
} borderRadius: BorderRadius.circular(10),
side: const BorderSide(color: Colors.grey),
),
),
);
} }
} }

@ -38,6 +38,7 @@ class _ReadOnlyPageState extends State<ReadOnlyPage> {
readOnly: !_edit, readOnly: !_edit,
expands: false, expands: false,
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
embedBuilders: defaultEmbedBuilders,
); );
if (kIsWeb) { if (kIsWeb) {
quillEditor = QuillEditor( quillEditor = QuillEditor(
@ -49,7 +50,7 @@ class _ReadOnlyPageState extends State<ReadOnlyPage> {
readOnly: !_edit, readOnly: !_edit,
expands: false, expands: false,
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
embedBuilder: defaultEmbedBuilderWeb); embedBuilders: defaultEmbedBuildersWeb);
} }
return Padding( return Padding(
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),

@ -26,66 +26,72 @@ class UniversalUI {
var ui = UniversalUI(); var ui = UniversalUI();
Widget defaultEmbedBuilderWeb( class ImageEmbedBuilderWeb implements IEmbedBuilder {
BuildContext context, @override
QuillController controller, String get key => BlockEmbed.imageType;
Embed node,
bool readOnly, @override
void Function(GlobalKey videoContainerKey)? onVideoInit, Widget build(BuildContext context, QuillController controller, Embed node,
) { bool readOnly, void Function(GlobalKey videoContainerKey)? onVideoInit) {
switch (node.value.type) { final imageUrl = node.value.data;
case BlockEmbed.imageType: if (isImageBase64(imageUrl)) {
final imageUrl = node.value.data; // TODO: handle imageUrl of base64
if (isImageBase64(imageUrl)) { return const SizedBox();
// TODO: handle imageUrl of base64 }
return const SizedBox(); final size = MediaQuery.of(context).size;
} UniversalUI().platformViewRegistry.registerViewFactory(
final size = MediaQuery.of(context).size; imageUrl, (viewId) => html.ImageElement()..src = imageUrl);
UniversalUI().platformViewRegistry.registerViewFactory( return Padding(
imageUrl, (viewId) => html.ImageElement()..src = imageUrl); padding: EdgeInsets.only(
return Padding( right: ResponsiveWidget.isMediumScreen(context)
padding: EdgeInsets.only( ? size.width * 0.5
right: ResponsiveWidget.isMediumScreen(context) : (ResponsiveWidget.isLargeScreen(context))
? size.width * 0.5 ? size.width * 0.75
: (ResponsiveWidget.isLargeScreen(context)) : size.width * 0.2,
? size.width * 0.75 ),
: size.width * 0.2, child: SizedBox(
), height: MediaQuery.of(context).size.height * 0.45,
child: SizedBox( child: HtmlElementView(
height: MediaQuery.of(context).size.height * 0.45, viewType: imageUrl,
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) { class VideoEmbedBuilderWeb implements IEmbedBuilder {
videoUrl = 'https://www.youtube.com/embed/$youtubeID'; @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( UniversalUI().platformViewRegistry.registerViewFactory(
videoUrl, videoUrl,
(id) => html.IFrameElement() (id) => html.IFrameElement()
..width = MediaQuery.of(context).size.width.toString() ..width = MediaQuery.of(context).size.width.toString()
..height = MediaQuery.of(context).size.height.toString() ..height = MediaQuery.of(context).size.height.toString()
..src = videoUrl ..src = videoUrl
..style.border = 'none'); ..style.border = 'none');
return SizedBox( return SizedBox(
height: 500, height: 500,
child: HtmlElementView( child: HtmlElementView(
viewType: videoUrl, 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.',
);
} }
} }
List<IEmbedBuilder> get defaultEmbedBuildersWeb => [
ImageEmbedBuilderWeb(),
VideoEmbedBuilderWeb(),
];

@ -36,171 +36,214 @@ typedef WebVideoPickImpl = Future<String?> Function(
typedef MediaPickSettingSelector = Future<MediaPickSetting?> Function( typedef MediaPickSettingSelector = Future<MediaPickSetting?> Function(
BuildContext context); BuildContext context);
abstract class IEmbedBuilder {
String get key;
Widget defaultEmbedBuilder( Widget build(
BuildContext context, BuildContext context,
QuillController controller, QuillController controller,
leaf.Embed node, leaf.Embed node,
bool readOnly, bool readOnly,
void Function(GlobalKey videoContainerKey)? onVideoInit, void Function(GlobalKey videoContainerKey)? onVideoInit,
) { );
assert(!kIsWeb, 'Please provide EmbedBuilder for Web'); }
Tuple2<double?, double?>? _widthHeight; class ImageEmbedBuilder implements IEmbedBuilder {
switch (node.value.type) { @override
case BlockEmbed.imageType: String get key => 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) { @override
image = imageByUrl(imageUrl); Widget build(
_widthHeight = Tuple2((image as Image).width, image.height); 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()) { var image;
return GestureDetector( final imageUrl = standardizeImageUrl(node.value.data);
onTap: () { Tuple2<double?, double?>? _widthHeight;
showDialog( final style = node.style.attributes['style'];
context: context, if (isMobile() && style != null) {
builder: (context) { final _attrs = parseKeyValuePairs(style.value.toString(), {
final resizeOption = _SimpleDialogItem( Attribute.mobileWidth,
icon: Icons.settings_outlined, Attribute.mobileHeight,
color: Colors.lightBlueAccent, Attribute.mobileMargin,
text: 'Resize'.i18n, Attribute.mobileAlignment
onPressed: () { });
Navigator.pop(context); if (_attrs.isNotEmpty) {
showCupertinoModalPopup<void>( assert(
context: context, _attrs[Attribute.mobileWidth] != null &&
builder: (context) { _attrs[Attribute.mobileHeight] != null,
final _screenSize = MediaQuery.of(context).size; 'mobileWidth and mobileHeight must be specified');
return ImageResizer( final w = double.parse(_attrs[Attribute.mobileWidth]!);
onImageResize: (w, h) { final h = double.parse(_attrs[Attribute.mobileHeight]!);
final res = getEmbedNode( _widthHeight = Tuple2(w, h);
controller, controller.selection.start); final m = _attrs[Attribute.mobileMargin] == null
final attr = replaceStyleString( ? 0.0
getImageStyleString(controller), w, h); : double.parse(_attrs[Attribute.mobileMargin]!);
controller final a = getAlignment(_attrs[Attribute.mobileAlignment]);
..skipRequestKeyboard = true image = Padding(
..formatText( padding: EdgeInsets.all(m),
res.item1, 1, StyleAttribute(attr)); child: imageByUrl(imageUrl, width: w, height: h, alignment: a));
},
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)) { if (_widthHeight == null) {
return image; image = imageByUrl(imageUrl);
} _widthHeight = Tuple2((image as Image).width, image.height);
}
// We provide option menu for mobile platform excluding base64 image if (!readOnly && isMobile()) {
return _menuOptionsForReadonlyImage(context, imageUrl, image); return GestureDetector(
case BlockEmbed.videoType: onTap: () {
final videoUrl = node.value.data; showDialog(
if (videoUrl.contains('youtube.com') || videoUrl.contains('youtu.be')) { context: context,
return YoutubeVideoApp( builder: (context) {
videoUrl: videoUrl, context: context, readOnly: readOnly); final resizeOption = _SimpleDialogItem(
} icon: Icons.settings_outlined,
return VideoApp( color: Colors.lightBlueAccent,
videoUrl: videoUrl, text: 'Resize'.i18n,
context: context, onPressed: () {
readOnly: readOnly, Navigator.pop(context);
onVideoInit: onVideoInit, showCupertinoModalPopup<void>(
); context: context,
case BlockEmbed.formulaType: builder: (context) {
final mathController = MathFieldEditingController(); 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( @override
onFocusChange: (hasFocus) { Widget build(
if (hasFocus) { BuildContext context,
// If the MathField is tapped, hides the built in keyboard QuillController controller,
SystemChannels.textInput.invokeMethod('TextInput.hide'); leaf.Embed node,
debugPrint(mathController.currentEditingValue()); bool readOnly,
} void Function(GlobalKey videoContainerKey)? onVideoInit) {
}, assert(!kIsWeb, 'Please provide video EmbedBuilder for Web');
child: MathField(
controller: mathController, final videoUrl = node.value.data;
variables: const ['x', 'y', 'z'], if (videoUrl.contains('youtube.com') || videoUrl.contains('youtu.be')) {
onChanged: (value) {}, return YoutubeVideoApp(
onSubmitted: (value) {}, videoUrl: videoUrl, context: context, readOnly: readOnly);
), }
); return VideoApp(
default: videoUrl: videoUrl,
throw UnimplementedError( context: context,
'Embeddable type "${node.value.type}" is not supported by default ' readOnly: readOnly,
'embed builder of QuillEditor. You must pass your own builder function ' onVideoInit: onVideoInit,
'to embedBuilder property of QuillEditor or QuillField widgets.', );
);
} }
} }
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<IEmbedBuilder> get defaultEmbedBuilders => [
ImageEmbedBuilder(),
VideoEmbedBuilder(),
FormulaEmbedBuilder(),
];
Widget _menuOptionsForReadonlyImage( Widget _menuOptionsForReadonlyImage(
BuildContext context, String imageUrl, Widget image) { BuildContext context, String imageUrl, Widget image) {
return GestureDetector( return GestureDetector(

@ -9,6 +9,8 @@ import 'package:flutter/services.dart';
import 'package:i18n_extension/i18n_widget.dart'; import 'package:i18n_extension/i18n_widget.dart';
import 'package:tuple/tuple.dart'; import 'package:tuple/tuple.dart';
import '../../flutter_quill.dart';
import '../embeds/default_embed_builder.dart';
import '../models/documents/document.dart'; import '../models/documents/document.dart';
import '../models/documents/nodes/container.dart' as container_node; import '../models/documents/nodes/container.dart' as container_node;
import '../models/documents/nodes/embeddable.dart'; import '../models/documents/nodes/embeddable.dart';
@ -19,7 +21,6 @@ import 'controller.dart';
import 'cursor.dart'; import 'cursor.dart';
import 'default_styles.dart'; import 'default_styles.dart';
import 'delegate.dart'; import 'delegate.dart';
import '../embeds/default_embed_builder.dart';
import 'float_cursor.dart'; import 'float_cursor.dart';
import 'link.dart'; import 'link.dart';
import 'raw_editor.dart'; import 'raw_editor.dart';
@ -168,8 +169,7 @@ class QuillEditor extends StatefulWidget {
this.onSingleLongTapStart, this.onSingleLongTapStart,
this.onSingleLongTapMoveUpdate, this.onSingleLongTapMoveUpdate,
this.onSingleLongTapEnd, this.onSingleLongTapEnd,
this.embedBuilder = defaultEmbedBuilder, this.embedBuilders,
this.customElementsEmbedBuilder,
this.linkActionPickerDelegate = defaultLinkActionPickerDelegate, this.linkActionPickerDelegate = defaultLinkActionPickerDelegate,
this.customStyleBuilder, this.customStyleBuilder,
this.locale, this.locale,
@ -182,6 +182,7 @@ class QuillEditor extends StatefulWidget {
required QuillController controller, required QuillController controller,
required bool readOnly, required bool readOnly,
Brightness? keyboardAppearance, Brightness? keyboardAppearance,
Iterable<IEmbedBuilder>? embedBuilders,
/// The locale to use for the editor toolbar, defaults to system locale /// The locale to use for the editor toolbar, defaults to system locale
/// More at https://github.com/singerdmx/flutter-quill#translation /// More at https://github.com/singerdmx/flutter-quill#translation
@ -198,6 +199,7 @@ class QuillEditor extends StatefulWidget {
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
keyboardAppearance: keyboardAppearance ?? Brightness.light, keyboardAppearance: keyboardAppearance ?? Brightness.light,
locale: locale, locale: locale,
embedBuilders: embedBuilders,
); );
} }
@ -346,8 +348,7 @@ class QuillEditor extends StatefulWidget {
LongPressEndDetails details, TextPosition Function(Offset offset))? LongPressEndDetails details, TextPosition Function(Offset offset))?
onSingleLongTapEnd; onSingleLongTapEnd;
final EmbedBuilder embedBuilder; final Iterable<IEmbedBuilder>? embedBuilders;
final CustomEmbedBuilder? customElementsEmbedBuilder;
final CustomStyleBuilder? customStyleBuilder; final CustomStyleBuilder? customStyleBuilder;
/// The locale to use for the editor toolbar, defaults to system locale /// The locale to use for the editor toolbar, defaults to system locale
@ -473,23 +474,28 @@ class QuillEditorState extends State<QuillEditor>
readOnly, readOnly,
onVideoInit, onVideoInit,
) { ) {
final customElementsEmbedBuilder = widget.customElementsEmbedBuilder; final builders = widget.embedBuilders;
final isCustomType = node.value.type == BlockEmbed.customType;
if (customElementsEmbedBuilder != null && isCustomType) { if (builders != null) {
return customElementsEmbedBuilder( var _node = node;
context,
controller, // Creates correct node for custom embed
CustomBlockEmbed.fromJsonString(node.value.data), if (node.value.type == BlockEmbed.customType) {
readOnly, _node = Embed(CustomBlockEmbed.fromJsonString(node.value.data));
onVideoInit, }
);
for (final builder in builders) {
if (builder.key == _node.value.type) {
return builder.build(
context, controller, _node, readOnly, onVideoInit);
}
}
} }
return widget.embedBuilder(
context, throw UnimplementedError(
controller, 'Embeddable type "${node.value.type}" is not supported by supplied '
node, 'embed builders. You must pass your own builder function to '
readOnly, 'embedBuilders property of QuillEditor or QuillField widgets.',
onVideoInit,
); );
}, },
linkActionPickerDelegate: widget.linkActionPickerDelegate, linkActionPickerDelegate: widget.linkActionPickerDelegate,

@ -46,6 +46,7 @@ class RawEditor extends StatefulWidget {
required this.cursorStyle, required this.cursorStyle,
required this.selectionColor, required this.selectionColor,
required this.selectionCtrls, required this.selectionCtrls,
required this.embedBuilder,
Key? key, Key? key,
this.scrollable = true, this.scrollable = true,
this.padding = EdgeInsets.zero, this.padding = EdgeInsets.zero,
@ -70,7 +71,6 @@ class RawEditor extends StatefulWidget {
this.keyboardAppearance = Brightness.light, this.keyboardAppearance = Brightness.light,
this.enableInteractiveSelection = true, this.enableInteractiveSelection = true,
this.scrollPhysics, this.scrollPhysics,
this.embedBuilder = defaultEmbedBuilder,
this.linkActionPickerDelegate = defaultLinkActionPickerDelegate, this.linkActionPickerDelegate = defaultLinkActionPickerDelegate,
this.customStyleBuilder, this.customStyleBuilder,
this.floatingCursorDisabled = false}) this.floatingCursorDisabled = false})

Loading…
Cancel
Save