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),
sizeSmall: const TextStyle(fontSize: 9),
),
customElementsEmbedBuilder: customElementsEmbedBuilder,
embedBuilders: [
...defaultEmbedBuilders,
NotesEmbedBuilder(addEditNote: _addEditNote)
],
);
if (kIsWeb) {
quillEditor = QuillEditor(
@ -145,7 +148,7 @@ class _HomePageState extends State<HomePage> {
null),
sizeSmall: const TextStyle(fontSize: 9),
),
embedBuilder: defaultEmbedBuilderWeb);
embedBuilders: defaultEmbedBuildersWeb);
}
var toolbar = QuillToolbar.basic(
controller: _controller!,
@ -386,37 +389,42 @@ class _HomePageState extends State<HomePage> {
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<void> 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<State<StatefulWidget>> 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),
),
),
);
}
}

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

@ -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<IEmbedBuilder> get defaultEmbedBuildersWeb => [
ImageEmbedBuilderWeb(),
VideoEmbedBuilderWeb(),
];

@ -36,171 +36,214 @@ typedef WebVideoPickImpl = Future<String?> Function(
typedef MediaPickSettingSelector = Future<MediaPickSetting?> 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<double?, double?>? _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<void>(
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<double?, double?>? _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<void>(
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<IEmbedBuilder> get defaultEmbedBuilders => [
ImageEmbedBuilder(),
VideoEmbedBuilder(),
FormulaEmbedBuilder(),
];
Widget _menuOptionsForReadonlyImage(
BuildContext context, String imageUrl, Widget image) {
return GestureDetector(

@ -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<IEmbedBuilder>? 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<IEmbedBuilder>? embedBuilders;
final CustomStyleBuilder? customStyleBuilder;
/// The locale to use for the editor toolbar, defaults to system locale
@ -473,23 +474,28 @@ class QuillEditorState extends State<QuillEditor>
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,

@ -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})

Loading…
Cancel
Save