Rich text editor for Flutter
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

527 lines
18 KiB

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)),
),
],
),
);
}
}