|
|
|
@ -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( |
|
|
|
|