|
|
|
import 'dart:io' show File;
|
|
|
|
|
|
|
|
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:math_keyboard/math_keyboard.dart';
|
|
|
|
import 'package:universal_html/html.dart' as html;
|
|
|
|
|
|
|
|
import '../shims/dart_ui_fake.dart'
|
|
|
|
if (dart.library.html) '../shims/dart_ui_real.dart' as ui;
|
|
|
|
import 'embed_types.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 extends EmbedBuilder {
|
|
|
|
ImageEmbedBuilder({
|
|
|
|
required this.imageProviderBuilder,
|
|
|
|
required this.imageErrorWidgetBuilder,
|
|
|
|
required this.onImageRemovedCallback,
|
|
|
|
required this.shouldRemoveImageCallback,
|
|
|
|
this.forceUseMobileOptionMenu = false,
|
|
|
|
});
|
|
|
|
final ImageEmbedBuilderOnRemovedCallback? onImageRemovedCallback;
|
|
|
|
final ImageEmbedBuilderWillRemoveCallback? shouldRemoveImageCallback;
|
|
|
|
final bool forceUseMobileOptionMenu;
|
|
|
|
final ImageEmbedBuilderProviderBuilder? imageProviderBuilder;
|
|
|
|
final ImageEmbedBuilderErrorWidgetBuilder? imageErrorWidgetBuilder;
|
|
|
|
|
|
|
|
@override
|
|
|
|
String get key => BlockEmbed.imageType;
|
|
|
|
|
|
|
|
@override
|
|
|
|
bool get expanded => false;
|
|
|
|
|
|
|
|
@override
|
|
|
|
Widget build(
|
|
|
|
BuildContext context,
|
|
|
|
QuillController controller,
|
|
|
|
base.Embed node,
|
|
|
|
bool readOnly,
|
|
|
|
bool inline,
|
|
|
|
TextStyle textStyle,
|
|
|
|
) {
|
|
|
|
assert(!kIsWeb, 'Please provide image EmbedBuilder for Web');
|
|
|
|
|
|
|
|
Widget image = const SizedBox.shrink();
|
|
|
|
final imageUrl = standardizeImageUrl(node.value.data);
|
|
|
|
OptionalSize? imageSize;
|
|
|
|
final style = node.style.attributes['style'];
|
|
|
|
|
|
|
|
// TODO: Please use the one from [Attribute.margin]
|
|
|
|
const marginKey = 'margin';
|
|
|
|
// TODO: Please use the one from [Attribute.alignment]
|
|
|
|
const alignmentKey = 'alignment';
|
|
|
|
if (style != null) {
|
|
|
|
final attrs = base.isMobile()
|
|
|
|
? base.parseKeyValuePairs(style.value.toString(), {
|
|
|
|
Attribute.mobileWidth,
|
|
|
|
Attribute.mobileHeight,
|
|
|
|
Attribute.mobileMargin,
|
|
|
|
Attribute.mobileAlignment,
|
|
|
|
})
|
|
|
|
: base.parseKeyValuePairs(style.value.toString(), {
|
|
|
|
Attribute.width.key,
|
|
|
|
Attribute.height.key,
|
|
|
|
marginKey,
|
|
|
|
alignmentKey,
|
|
|
|
});
|
|
|
|
if (attrs.isNotEmpty) {
|
|
|
|
final width = double.tryParse(
|
|
|
|
(base.isMobile()
|
|
|
|
? attrs[Attribute.mobileWidth]
|
|
|
|
: attrs[Attribute.width.key]) ??
|
|
|
|
'',
|
|
|
|
);
|
|
|
|
final height = double.tryParse(
|
|
|
|
(base.isMobile()
|
|
|
|
? attrs[Attribute.mobileHeight]
|
|
|
|
: attrs[Attribute.height.key]) ??
|
|
|
|
'',
|
|
|
|
);
|
|
|
|
final alignment = base.getAlignment(base.isMobile()
|
|
|
|
? attrs[Attribute.mobileAlignment]
|
|
|
|
: attrs[alignmentKey]);
|
|
|
|
final margin = (base.isMobile()
|
|
|
|
? double.tryParse(Attribute.mobileMargin)
|
|
|
|
: double.tryParse(marginKey)) ??
|
|
|
|
0.0;
|
|
|
|
|
|
|
|
assert(
|
|
|
|
width != null && height != null,
|
|
|
|
base.isMobile()
|
|
|
|
? 'mobileWidth and mobileHeight must be specified'
|
|
|
|
: 'width and height must be specified',
|
|
|
|
);
|
|
|
|
imageSize = OptionalSize(width, height);
|
|
|
|
image = Padding(
|
|
|
|
padding: EdgeInsets.all(margin),
|
|
|
|
child: getQuillImageByUrl(
|
|
|
|
imageUrl,
|
|
|
|
width: width,
|
|
|
|
height: height,
|
|
|
|
alignment: alignment,
|
|
|
|
imageProviderBuilder: imageProviderBuilder,
|
|
|
|
imageErrorWidgetBuilder: imageErrorWidgetBuilder,
|
|
|
|
),
|
|
|
|
);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if (imageSize == null) {
|
|
|
|
image = getQuillImageByUrl(
|
|
|
|
imageUrl,
|
|
|
|
imageProviderBuilder: imageProviderBuilder,
|
|
|
|
imageErrorWidgetBuilder: imageErrorWidgetBuilder,
|
|
|
|
);
|
|
|
|
imageSize = OptionalSize((image as Image).width, image.height);
|
|
|
|
}
|
|
|
|
|
|
|
|
if (!readOnly && (base.isMobile() || forceUseMobileOptionMenu)) {
|
|
|
|
return GestureDetector(
|
|
|
|
onTap: () {
|
|
|
|
showDialog(
|
|
|
|
context: context,
|
|
|
|
builder: (context) {
|
|
|
|
final copyOption = _SimpleDialogItem(
|
|
|
|
icon: Icons.copy_all_outlined,
|
|
|
|
color: Colors.cyanAccent,
|
|
|
|
text: 'Copy'.i18n,
|
|
|
|
onPressed: () {
|
|
|
|
final imageNode =
|
|
|
|
getEmbedNode(controller, controller.selection.start)
|
|
|
|
.value;
|
|
|
|
final imageUrl = imageNode.value.data;
|
|
|
|
controller.copiedImageUrl =
|
|
|
|
ImageUrl(imageUrl, getImageStyleString(controller));
|
|
|
|
Navigator.pop(context);
|
|
|
|
},
|
|
|
|
);
|
|
|
|
final removeOption = _SimpleDialogItem(
|
|
|
|
icon: Icons.delete_forever_outlined,
|
|
|
|
color: Colors.red.shade200,
|
|
|
|
text: 'Remove'.i18n,
|
|
|
|
onPressed: () async {
|
|
|
|
Navigator.of(context).pop();
|
|
|
|
|
|
|
|
final imageFile = File(imageUrl);
|
|
|
|
|
|
|
|
// Call the remove check callback if set
|
|
|
|
if (await shouldRemoveImageCallback?.call(imageFile) ==
|
|
|
|
false) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
final offset = getEmbedNode(
|
|
|
|
controller,
|
|
|
|
controller.selection.start,
|
|
|
|
).offset;
|
|
|
|
controller.replaceText(
|
|
|
|
offset,
|
|
|
|
1,
|
|
|
|
'',
|
|
|
|
TextSelection.collapsed(offset: offset),
|
|
|
|
);
|
|
|
|
// Call the post remove callback if set
|
|
|
|
await onImageRemovedCallback?.call(imageFile);
|
|
|
|
},
|
|
|
|
);
|
|
|
|
return Padding(
|
|
|
|
padding: const EdgeInsets.fromLTRB(50, 0, 50, 0),
|
|
|
|
child: SimpleDialog(
|
|
|
|
shape: const RoundedRectangleBorder(
|
|
|
|
borderRadius: BorderRadius.all(
|
|
|
|
Radius.circular(10),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
children: [
|
|
|
|
_SimpleDialogItem(
|
|
|
|
icon: Icons.settings_outlined,
|
|
|
|
color: Colors.lightBlueAccent,
|
|
|
|
text: 'Resize'.i18n,
|
|
|
|
onPressed: () {
|
|
|
|
Navigator.pop(context);
|
|
|
|
showCupertinoModalPopup<void>(
|
|
|
|
context: context,
|
|
|
|
builder: (context) {
|
|
|
|
final screenSize = MediaQuery.sizeOf(context);
|
|
|
|
return ImageResizer(
|
|
|
|
onImageResize: (w, h) {
|
|
|
|
final res = getEmbedNode(
|
|
|
|
controller,
|
|
|
|
controller.selection.start,
|
|
|
|
);
|
|
|
|
// For desktop
|
|
|
|
String _replaceStyleStringWithSize(
|
|
|
|
String s,
|
|
|
|
double width,
|
|
|
|
double height,
|
|
|
|
) {
|
|
|
|
final result = <String, String>{};
|
|
|
|
final pairs = s.split(';');
|
|
|
|
for (final pair in pairs) {
|
|
|
|
final _index = pair.indexOf(':');
|
|
|
|
if (_index < 0) {
|
|
|
|
continue;
|
|
|
|
}
|
|
|
|
final _key =
|
|
|
|
pair.substring(0, _index).trim();
|
|
|
|
result[_key] =
|
|
|
|
pair.substring(_index + 1).trim();
|
|
|
|
}
|
|
|
|
|
|
|
|
result[Attribute.width.key] =
|
|
|
|
width.toString();
|
|
|
|
result[Attribute.height.key] =
|
|
|
|
height.toString();
|
|
|
|
final sb = StringBuffer();
|
|
|
|
for (final pair in result.entries) {
|
|
|
|
sb
|
|
|
|
..write(pair.key)
|
|
|
|
..write(': ')
|
|
|
|
..write(pair.value)
|
|
|
|
..write('; ');
|
|
|
|
}
|
|
|
|
return sb.toString();
|
|
|
|
}
|
|
|
|
|
|
|
|
// TODO: When update flutter_quill
|
|
|
|
// we should update flutter_quill_extensions
|
|
|
|
// to use the latest version and use
|
|
|
|
// base.replaceStyleStringWithSize()
|
|
|
|
// instead of replaceStyleString
|
|
|
|
|
|
|
|
final attr = base.isMobile()
|
|
|
|
? base.replaceStyleString(
|
|
|
|
getImageStyleString(controller),
|
|
|
|
w,
|
|
|
|
h,
|
|
|
|
)
|
|
|
|
: _replaceStyleStringWithSize(
|
|
|
|
getImageStyleString(controller),
|
|
|
|
w,
|
|
|
|
h,
|
|
|
|
);
|
|
|
|
controller
|
|
|
|
..skipRequestKeyboard = true
|
|
|
|
..formatText(
|
|
|
|
res.offset,
|
|
|
|
1,
|
|
|
|
StyleAttribute(attr),
|
|
|
|
);
|
|
|
|
},
|
|
|
|
imageWidth: imageSize?.width,
|
|
|
|
imageHeight: imageSize?.height,
|
|
|
|
maxWidth: screenSize.width,
|
|
|
|
maxHeight: screenSize.height,
|
|
|
|
);
|
|
|
|
},
|
|
|
|
);
|
|
|
|
},
|
|
|
|
),
|
|
|
|
copyOption,
|
|
|
|
removeOption,
|
|
|
|
]),
|
|
|
|
);
|
|
|
|
});
|
|
|
|
},
|
|
|
|
child: image,
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
if (!readOnly || isImageBase64(imageUrl)) {
|
|
|
|
// To enforce using it on the web, desktop and other platforms
|
|
|
|
// and that is up to the developer
|
|
|
|
if (!base.isMobile() && forceUseMobileOptionMenu) {
|
|
|
|
return _menuOptionsForReadonlyImage(
|
|
|
|
context: context,
|
|
|
|
imageUrl: imageUrl,
|
|
|
|
image: image,
|
|
|
|
imageProviderBuilder: imageProviderBuilder,
|
|
|
|
imageErrorWidgetBuilder: imageErrorWidgetBuilder,
|
|
|
|
);
|
|
|
|
}
|
|
|
|
return image;
|
|
|
|
}
|
|
|
|
|
|
|
|
// We provide option menu for mobile platform excluding base64 image
|
|
|
|
return _menuOptionsForReadonlyImage(
|
|
|
|
context: context,
|
|
|
|
imageUrl: imageUrl,
|
|
|
|
image: image,
|
|
|
|
imageProviderBuilder: imageProviderBuilder,
|
|
|
|
imageErrorWidgetBuilder: imageErrorWidgetBuilder,
|
|
|
|
);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
class ImageEmbedBuilderWeb extends EmbedBuilder {
|
|
|
|
ImageEmbedBuilderWeb({this.constraints})
|
|
|
|
: assert(kIsWeb, 'ImageEmbedBuilderWeb is only for web platform');
|
|
|
|
|
|
|
|
final BoxConstraints? constraints;
|
|
|
|
|
|
|
|
@override
|
|
|
|
String get key => BlockEmbed.imageType;
|
|
|
|
|
|
|
|
@override
|
|
|
|
Widget build(
|
|
|
|
BuildContext context,
|
|
|
|
QuillController controller,
|
|
|
|
Embed node,
|
|
|
|
bool readOnly,
|
|
|
|
bool inline,
|
|
|
|
TextStyle textStyle,
|
|
|
|
) {
|
|
|
|
final imageUrl = node.value.data;
|
|
|
|
|
|
|
|
ui.platformViewRegistry.registerViewFactory(imageUrl, (viewId) {
|
|
|
|
return html.ImageElement()
|
|
|
|
..src = imageUrl
|
|
|
|
..style.height = 'auto'
|
|
|
|
..style.width = 'auto';
|
|
|
|
});
|
|
|
|
|
|
|
|
return ConstrainedBox(
|
|
|
|
constraints: constraints ?? BoxConstraints.loose(const Size(200, 200)),
|
|
|
|
child: HtmlElementView(
|
|
|
|
viewType: imageUrl,
|
|
|
|
),
|
|
|
|
);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
class VideoEmbedBuilder extends EmbedBuilder {
|
|
|
|
VideoEmbedBuilder({this.onVideoInit});
|
|
|
|
|
|
|
|
final void Function(GlobalKey videoContainerKey)? onVideoInit;
|
|
|
|
|
|
|
|
@override
|
|
|
|
String get key => BlockEmbed.videoType;
|
|
|
|
|
|
|
|
@override
|
|
|
|
Widget build(
|
|
|
|
BuildContext context,
|
|
|
|
QuillController controller,
|
|
|
|
base.Embed node,
|
|
|
|
bool readOnly,
|
|
|
|
bool inline,
|
|
|
|
TextStyle textStyle,
|
|
|
|
) {
|
|
|
|
assert(!kIsWeb, 'Please provide video EmbedBuilder for Web');
|
|
|
|
|
|
|
|
final videoUrl = node.value.data;
|
|
|
|
if (isYouTubeUrl(videoUrl)) {
|
|
|
|
return YoutubeVideoApp(
|
|
|
|
videoUrl: videoUrl, context: context, readOnly: readOnly);
|
|
|
|
}
|
|
|
|
return VideoApp(
|
|
|
|
videoUrl: videoUrl,
|
|
|
|
context: context,
|
|
|
|
readOnly: readOnly,
|
|
|
|
onVideoInit: onVideoInit,
|
|
|
|
);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
class FormulaEmbedBuilder extends EmbedBuilder {
|
|
|
|
@override
|
|
|
|
String get key => BlockEmbed.formulaType;
|
|
|
|
|
|
|
|
@override
|
|
|
|
Widget build(
|
|
|
|
BuildContext context,
|
|
|
|
QuillController controller,
|
|
|
|
base.Embed node,
|
|
|
|
bool readOnly,
|
|
|
|
bool inline,
|
|
|
|
TextStyle textStyle,
|
|
|
|
) {
|
|
|
|
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({
|
|
|
|
required BuildContext context,
|
|
|
|
required String imageUrl,
|
|
|
|
required Widget image,
|
|
|
|
required ImageEmbedBuilderProviderBuilder? imageProviderBuilder,
|
|
|
|
required ImageEmbedBuilderErrorWidgetBuilder? imageErrorWidgetBuilder,
|
|
|
|
}) {
|
|
|
|
return GestureDetector(
|
|
|
|
onTap: () {
|
|
|
|
showDialog(
|
|
|
|
context: context,
|
|
|
|
builder: (context) {
|
|
|
|
final saveOption = _SimpleDialogItem(
|
|
|
|
icon: Icons.save,
|
|
|
|
color: Colors.greenAccent,
|
|
|
|
text: 'Save'.i18n,
|
|
|
|
onPressed: () async {
|
|
|
|
imageUrl = appendFileExtensionToImageUrl(imageUrl);
|
|
|
|
final messenger = ScaffoldMessenger.of(context);
|
|
|
|
Navigator.of(context).pop();
|
|
|
|
|
|
|
|
final saveImageResult = await saveImage(imageUrl);
|
|
|
|
final imageSavedSuccessfully = saveImageResult.isSuccess;
|
|
|
|
|
|
|
|
messenger.clearSnackBars();
|
|
|
|
|
|
|
|
if (!imageSavedSuccessfully) {
|
|
|
|
messenger.showSnackBar(SnackBar(
|
|
|
|
content: Text(
|
|
|
|
'Error while saving image'.i18n,
|
|
|
|
)));
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
var message;
|
|
|
|
switch (saveImageResult.method) {
|
|
|
|
case SaveImageResultMethod.network:
|
|
|
|
message = 'Saved using the network'.i18n;
|
|
|
|
break;
|
|
|
|
case SaveImageResultMethod.localStorage:
|
|
|
|
message = 'Saved using the local storage'.i18n;
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
|
|
|
|
messenger.showSnackBar(
|
|
|
|
SnackBar(
|
|
|
|
content: Text(message),
|
|
|
|
),
|
|
|
|
);
|
|
|
|
},
|
|
|
|
);
|
|
|
|
final zoomOption = _SimpleDialogItem(
|
|
|
|
icon: Icons.zoom_in,
|
|
|
|
color: Colors.cyanAccent,
|
|
|
|
text: 'Zoom'.i18n,
|
|
|
|
onPressed: () {
|
|
|
|
Navigator.pushReplacement(
|
|
|
|
context,
|
|
|
|
// TODO: Consider add support for other theme system
|
|
|
|
// like Cupertino or at least add the option to by
|
|
|
|
// by using PageRoute as option so dev can ovveride this
|
|
|
|
// this change should be done in all places if you want to
|
|
|
|
MaterialPageRoute(
|
|
|
|
builder: (context) => ImageTapWrapper(
|
|
|
|
imageUrl: imageUrl,
|
|
|
|
imageProviderBuilder: imageProviderBuilder,
|
|
|
|
imageErrorWidgetBuilder: imageErrorWidgetBuilder,
|
|
|
|
),
|
|
|
|
),
|
|
|
|
);
|
|
|
|
},
|
|
|
|
);
|
|
|
|
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)),
|
|
|
|
),
|
|
|
|
],
|
|
|
|
),
|
|
|
|
);
|
|
|
|
}
|
|
|
|
}
|