dartlangeditorflutterflutter-appsflutter-examplesflutter-packageflutter-widgetquillquill-deltaquilljsreactquillrich-textrich-text-editorwysiwygwysiwyg-editor
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.
402 lines
13 KiB
402 lines
13 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({ |
|
this.onImageRemovedCallback, |
|
this.shouldRemoveImageCallback, |
|
this.forceUseMobileOptionMenu = false, |
|
}); |
|
final ImageEmbedBuilderOnRemovedCallback? onImageRemovedCallback; |
|
final ImageEmbedBuilderWillRemoveCallback? shouldRemoveImageCallback; |
|
final bool forceUseMobileOptionMenu; |
|
|
|
@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() || forceUseMobileOptionMenu)) { |
|
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); |
|
|
|
// 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: [ |
|
if (base.isMobile()) resizeOption, |
|
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, |
|
imageUrl, |
|
image, |
|
); |
|
} |
|
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 (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( |
|
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) { |
|
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, |
|
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)), |
|
), |
|
], |
|
), |
|
); |
|
} |
|
}
|
|
|