|
|
|
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) 'package:flutter_quill_extensions/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.afterRemoveImageFromEditor,
|
|
|
|
required this.shouldRemoveImageFromEditor,
|
|
|
|
});
|
|
|
|
final ImageEmbedBuilderAfterRemoveImageFromEditor afterRemoveImageFromEditor;
|
|
|
|
final ImageEmbedBuilderShouldRemoveImageFromEditor
|
|
|
|
shouldRemoveImageFromEditor;
|
|
|
|
|
|
|
|
@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'];
|
|
|
|
if (base.isMobile() && style != null) {
|
|
|
|
final attrs = base.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]!);
|
|
|
|
imageSize = OptionalSize(w, h);
|
|
|
|
final m = attrs[Attribute.mobileMargin] == null
|
|
|
|
? 0.0
|
|
|
|
: double.parse(attrs[Attribute.mobileMargin]!);
|
|
|
|
final a = base.getAlignment(attrs[Attribute.mobileAlignment]);
|
|
|
|
image = Padding(
|
|
|
|
padding: EdgeInsets.all(m),
|
|
|
|
child: imageByUrl(imageUrl, width: w, height: h, alignment: a));
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if (imageSize == null) {
|
|
|
|
image = imageByUrl(imageUrl);
|
|
|
|
imageSize = OptionalSize((image as Image).width, image.height);
|
|
|
|
}
|
|
|
|
|
|
|
|
if (!readOnly && base.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 = base.replaceStyleString(
|
|
|
|
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);
|
|
|
|
});
|
|
|
|
},
|
|
|
|
);
|
|
|
|
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);
|
|
|
|
final shouldRemoveImage =
|
|
|
|
await shouldRemoveImageFromEditor(imageFile);
|
|
|
|
|
|
|
|
if (!shouldRemoveImage) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
final offset = getEmbedNode(
|
|
|
|
controller,
|
|
|
|
controller.selection.start,
|
|
|
|
).offset;
|
|
|
|
controller.replaceText(
|
|
|
|
offset,
|
|
|
|
1,
|
|
|
|
'',
|
|
|
|
TextSelection.collapsed(offset: offset),
|
|
|
|
);
|
|
|
|
await afterRemoveImageFromEditor(imageFile);
|
|
|
|
},
|
|
|
|
);
|
|
|
|
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 || !base.isMobile() || isImageBase64(imageUrl)) {
|
|
|
|
return image;
|
|
|
|
}
|
|
|
|
|
|
|
|
// We provide option menu for mobile platform excluding base64 image
|
|
|
|
return _menuOptionsForReadonlyImage(
|
|
|
|
context,
|
|
|
|
imageUrl,
|
|
|
|
image,
|
|
|
|
);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
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 (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 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(
|
|
|
|
BuildContext context, String imageUrl, Widget image) {
|
|
|
|
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) {
|
|
|
|
// TODO: Please translate this
|
|
|
|
messenger.showSnackBar(const SnackBar(
|
|
|
|
content: Text(
|
|
|
|
'Error while saveing the image',
|
|
|
|
)));
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
var message = 'Saved'.i18n;
|
|
|
|
switch (saveImageResult.method) {
|
|
|
|
// TODO: Please translate this too
|
|
|
|
case SaveImageResultMethod.network:
|
|
|
|
message += ' using the network.';
|
|
|
|
break;
|
|
|
|
case SaveImageResultMethod.localStorage:
|
|
|
|
message += ' using the local storage.';
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
|
|
|
|
messenger.showSnackBar(
|
|
|
|
SnackBar(
|
|
|
|
content: Text(message),
|
|
|
|
),
|
|
|
|
);
|
|
|
|
},
|
|
|
|
);
|
|
|
|
final zoomOption = _SimpleDialogItem(
|
|
|
|
icon: Icons.zoom_in,
|
|
|
|
color: Colors.cyanAccent,
|
|
|
|
text: 'Zoom'.i18n,
|
|
|
|
onPressed: () {
|
|
|
|
Navigator.pushReplacement(
|
|
|
|
context,
|
|
|
|
MaterialPageRoute(
|
|
|
|
builder: (context) =>
|
|
|
|
ImageTapWrapper(imageUrl: imageUrl)));
|
|
|
|
},
|
|
|
|
);
|
|
|
|
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)),
|
|
|
|
),
|
|
|
|
],
|
|
|
|
),
|
|
|
|
);
|
|
|
|
}
|
|
|
|
}
|