Embed refactor (#933)
* Moved all embed code to seperate folder * Consolidated embed builders * Moved embed toolbar items out * Moved embed code to separate package * Updated imports * Removed I from interface names * Refactored embed button implementation * Update readmepull/934/head
parent
4b46df25e7
commit
82d4bf76e3
38 changed files with 828 additions and 570 deletions
@ -0,0 +1,10 @@ |
|||||||
|
# This file tracks properties of this Flutter project. |
||||||
|
# Used by Flutter tool to assess capabilities and perform upgrades etc. |
||||||
|
# |
||||||
|
# This file should be version controlled and should not be manually edited. |
||||||
|
|
||||||
|
version: |
||||||
|
revision: f1875d570e39de09040c8f79aa13cc56baab8db1 |
||||||
|
channel: stable |
||||||
|
|
||||||
|
project_type: package |
@ -0,0 +1,3 @@ |
|||||||
|
## 0.0.1 |
||||||
|
|
||||||
|
* TODO: Describe initial release. |
@ -0,0 +1 @@ |
|||||||
|
TODO: Add your license here. |
@ -0,0 +1,22 @@ |
|||||||
|
# Flutter Quill Extensions |
||||||
|
|
||||||
|
Helpers to support embed widgets in flutter_quill. |
||||||
|
|
||||||
|
## Usage |
||||||
|
|
||||||
|
Set the `embedBuilders` and `embedToolbar` params in `QuillEditor` and `QuillToolbar` with the |
||||||
|
values provided by this repository. |
||||||
|
|
||||||
|
``` |
||||||
|
QuillEditor.basic( |
||||||
|
controller: controller, |
||||||
|
embedBuilders: FlutterQuillEmbeds.builders, |
||||||
|
); |
||||||
|
``` |
||||||
|
|
||||||
|
``` |
||||||
|
QuillToolbar.basic( |
||||||
|
controller: controller, |
||||||
|
embedButtons: FlutterQuillEmbeds.buttons(), |
||||||
|
); |
||||||
|
``` |
@ -0,0 +1,37 @@ |
|||||||
|
include: package:pedantic/analysis_options.yaml |
||||||
|
|
||||||
|
analyzer: |
||||||
|
errors: |
||||||
|
undefined_prefixed_name: ignore |
||||||
|
unsafe_html: ignore |
||||||
|
linter: |
||||||
|
rules: |
||||||
|
- always_declare_return_types |
||||||
|
- always_put_required_named_parameters_first |
||||||
|
- annotate_overrides |
||||||
|
- avoid_empty_else |
||||||
|
- avoid_escaping_inner_quotes |
||||||
|
- avoid_print |
||||||
|
- avoid_redundant_argument_values |
||||||
|
- avoid_types_on_closure_parameters |
||||||
|
- avoid_void_async |
||||||
|
- cascade_invocations |
||||||
|
- directives_ordering |
||||||
|
- lines_longer_than_80_chars |
||||||
|
- omit_local_variable_types |
||||||
|
- prefer_const_constructors |
||||||
|
- prefer_const_constructors_in_immutables |
||||||
|
- prefer_const_declarations |
||||||
|
- prefer_final_fields |
||||||
|
- prefer_final_in_for_each |
||||||
|
- prefer_final_locals |
||||||
|
- prefer_initializing_formals |
||||||
|
- prefer_int_literals |
||||||
|
- prefer_interpolation_to_compose_strings |
||||||
|
- prefer_relative_imports |
||||||
|
- prefer_single_quotes |
||||||
|
- sort_constructors_first |
||||||
|
- sort_unnamed_constructors_first |
||||||
|
- unnecessary_lambdas |
||||||
|
- unnecessary_parenthesis |
||||||
|
- unnecessary_string_interpolations |
@ -0,0 +1,282 @@ |
|||||||
|
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:gallery_saver/gallery_saver.dart'; |
||||||
|
import 'package:math_keyboard/math_keyboard.dart'; |
||||||
|
import 'package:tuple/tuple.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 implements EmbedBuilder { |
||||||
|
@override |
||||||
|
String get key => BlockEmbed.imageType; |
||||||
|
|
||||||
|
@override |
||||||
|
Widget build( |
||||||
|
BuildContext context, |
||||||
|
QuillController controller, |
||||||
|
base.Embed node, |
||||||
|
bool readOnly, |
||||||
|
void Function(GlobalKey videoContainerKey)? onVideoInit, |
||||||
|
) { |
||||||
|
assert(!kIsWeb, 'Please provide image EmbedBuilder for Web'); |
||||||
|
|
||||||
|
var image; |
||||||
|
final imageUrl = standardizeImageUrl(node.value.data); |
||||||
|
Tuple2<double?, double?>? _widthHeight; |
||||||
|
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]!); |
||||||
|
_widthHeight = Tuple2(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 (_widthHeight == null) { |
||||||
|
image = imageByUrl(imageUrl); |
||||||
|
_widthHeight = Tuple2((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.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 || !base.isMobile() || isImageBase64(imageUrl)) { |
||||||
|
return image; |
||||||
|
} |
||||||
|
|
||||||
|
// We provide option menu for mobile platform excluding base64 image |
||||||
|
return _menuOptionsForReadonlyImage(context, imageUrl, image); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
class VideoEmbedBuilder implements EmbedBuilder { |
||||||
|
@override |
||||||
|
String get key => BlockEmbed.videoType; |
||||||
|
|
||||||
|
@override |
||||||
|
Widget build( |
||||||
|
BuildContext context, |
||||||
|
QuillController controller, |
||||||
|
base.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 EmbedBuilder { |
||||||
|
@override |
||||||
|
String get key => BlockEmbed.formulaType; |
||||||
|
|
||||||
|
@override |
||||||
|
Widget build( |
||||||
|
BuildContext context, |
||||||
|
QuillController controller, |
||||||
|
base.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) {}, |
||||||
|
), |
||||||
|
); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
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: () { |
||||||
|
imageUrl = appendFileExtensionToImageUrl(imageUrl); |
||||||
|
GallerySaver.saveImage(imageUrl).then((_) { |
||||||
|
ScaffoldMessenger.of(context) |
||||||
|
.showSnackBar(SnackBar(content: Text('Saved'.i18n))); |
||||||
|
Navigator.pop(context); |
||||||
|
}); |
||||||
|
}, |
||||||
|
); |
||||||
|
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)), |
||||||
|
), |
||||||
|
], |
||||||
|
), |
||||||
|
); |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,20 @@ |
|||||||
|
import 'dart:io'; |
||||||
|
|
||||||
|
import 'package:flutter/material.dart'; |
||||||
|
|
||||||
|
typedef OnImagePickCallback = Future<String?> Function(File file); |
||||||
|
typedef OnVideoPickCallback = Future<String?> Function(File file); |
||||||
|
typedef FilePickImpl = Future<String?> Function(BuildContext context); |
||||||
|
typedef WebImagePickImpl = Future<String?> Function( |
||||||
|
OnImagePickCallback onImagePickCallback); |
||||||
|
typedef WebVideoPickImpl = Future<String?> Function( |
||||||
|
OnVideoPickCallback onImagePickCallback); |
||||||
|
typedef MediaPickSettingSelector = Future<MediaPickSetting?> Function( |
||||||
|
BuildContext context); |
||||||
|
|
||||||
|
enum MediaPickSetting { |
||||||
|
Gallery, |
||||||
|
Link, |
||||||
|
Camera, |
||||||
|
Video, |
||||||
|
} |
@ -1,10 +1,11 @@ |
|||||||
import 'package:flutter/material.dart'; |
import 'package:flutter/material.dart'; |
||||||
|
import 'package:flutter_quill/flutter_quill.dart' hide Text; |
||||||
import 'package:image_picker/image_picker.dart'; |
import 'package:image_picker/image_picker.dart'; |
||||||
|
|
||||||
import '../../models/themes/quill_icon_theme.dart'; |
import 'package:flutter_quill/translations.dart'; |
||||||
import '../../translations/toolbar.i18n.dart'; |
|
||||||
import '../controller.dart'; |
import '../embed_types.dart'; |
||||||
import '../toolbar.dart'; |
import 'image_video_utils.dart'; |
||||||
|
|
||||||
class CameraButton extends StatelessWidget { |
class CameraButton extends StatelessWidget { |
||||||
const CameraButton({ |
const CameraButton({ |
@ -1,10 +1,7 @@ |
|||||||
import 'package:flutter/material.dart'; |
import 'package:flutter/material.dart'; |
||||||
|
import 'package:flutter_quill/flutter_quill.dart'; |
||||||
|
|
||||||
import '../../models/documents/nodes/embeddable.dart'; |
import '../embed_types.dart'; |
||||||
import '../../models/themes/quill_dialog_theme.dart'; |
|
||||||
import '../../models/themes/quill_icon_theme.dart'; |
|
||||||
import '../controller.dart'; |
|
||||||
import '../toolbar.dart'; |
|
||||||
|
|
||||||
class FormulaButton extends StatelessWidget { |
class FormulaButton extends StatelessWidget { |
||||||
const FormulaButton({ |
const FormulaButton({ |
@ -1,11 +1,9 @@ |
|||||||
import 'package:flutter/material.dart'; |
import 'package:flutter/material.dart'; |
||||||
|
import 'package:flutter_quill/flutter_quill.dart'; |
||||||
import 'package:image_picker/image_picker.dart'; |
import 'package:image_picker/image_picker.dart'; |
||||||
|
|
||||||
import '../../models/documents/nodes/embeddable.dart'; |
import '../embed_types.dart'; |
||||||
import '../../models/themes/quill_dialog_theme.dart'; |
import 'image_video_utils.dart'; |
||||||
import '../../models/themes/quill_icon_theme.dart'; |
|
||||||
import '../controller.dart'; |
|
||||||
import '../toolbar.dart'; |
|
||||||
|
|
||||||
class ImageButton extends StatelessWidget { |
class ImageButton extends StatelessWidget { |
||||||
const ImageButton({ |
const ImageButton({ |
@ -1,11 +1,9 @@ |
|||||||
import 'package:flutter/material.dart'; |
import 'package:flutter/material.dart'; |
||||||
|
import 'package:flutter_quill/flutter_quill.dart'; |
||||||
import 'package:image_picker/image_picker.dart'; |
import 'package:image_picker/image_picker.dart'; |
||||||
|
|
||||||
import '../../models/documents/nodes/embeddable.dart'; |
import '../embed_types.dart'; |
||||||
import '../../models/themes/quill_dialog_theme.dart'; |
import 'image_video_utils.dart'; |
||||||
import '../../models/themes/quill_icon_theme.dart'; |
|
||||||
import '../controller.dart'; |
|
||||||
import '../toolbar.dart'; |
|
||||||
|
|
||||||
class VideoButton extends StatelessWidget { |
class VideoButton extends StatelessWidget { |
||||||
const VideoButton({ |
const VideoButton({ |
@ -0,0 +1,5 @@ |
|||||||
|
import 'package:string_validator/string_validator.dart'; |
||||||
|
|
||||||
|
bool isImageBase64(String imageUrl) { |
||||||
|
return !imageUrl.startsWith('http') && isBase64(imageUrl); |
||||||
|
} |
@ -1,10 +1,9 @@ |
|||||||
import 'package:flutter/gestures.dart'; |
import 'package:flutter/gestures.dart'; |
||||||
import 'package:flutter/material.dart'; |
import 'package:flutter/material.dart'; |
||||||
|
import 'package:flutter_quill/flutter_quill.dart'; |
||||||
import 'package:url_launcher/url_launcher.dart'; |
import 'package:url_launcher/url_launcher.dart'; |
||||||
import 'package:youtube_player_flutter_quill/youtube_player_flutter_quill.dart'; |
import 'package:youtube_player_flutter_quill/youtube_player_flutter_quill.dart'; |
||||||
|
|
||||||
import '../default_styles.dart'; |
|
||||||
|
|
||||||
class YoutubeVideoApp extends StatefulWidget { |
class YoutubeVideoApp extends StatefulWidget { |
||||||
const YoutubeVideoApp( |
const YoutubeVideoApp( |
||||||
{required this.videoUrl, required this.context, required this.readOnly}); |
{required this.videoUrl, required this.context, required this.readOnly}); |
@ -0,0 +1,94 @@ |
|||||||
|
library flutter_quill_extensions; |
||||||
|
|
||||||
|
import 'package:flutter/material.dart'; |
||||||
|
import 'package:flutter_quill/flutter_quill.dart'; |
||||||
|
|
||||||
|
import 'embeds/builders.dart'; |
||||||
|
import 'embeds/embed_types.dart'; |
||||||
|
import 'embeds/toolbar/camera_button.dart'; |
||||||
|
import 'embeds/toolbar/formula_button.dart'; |
||||||
|
import 'embeds/toolbar/image_button.dart'; |
||||||
|
import 'embeds/toolbar/video_button.dart'; |
||||||
|
|
||||||
|
export 'embeds/embed_types.dart'; |
||||||
|
export 'embeds/toolbar/camera_button.dart'; |
||||||
|
export 'embeds/toolbar/formula_button.dart'; |
||||||
|
export 'embeds/toolbar/image_button.dart'; |
||||||
|
export 'embeds/toolbar/image_video_utils.dart'; |
||||||
|
export 'embeds/toolbar/video_button.dart'; |
||||||
|
export 'embeds/utils.dart'; |
||||||
|
|
||||||
|
class FlutterQuillEmbeds { |
||||||
|
static List<EmbedBuilder> get builders => [ |
||||||
|
ImageEmbedBuilder(), |
||||||
|
VideoEmbedBuilder(), |
||||||
|
FormulaEmbedBuilder(), |
||||||
|
]; |
||||||
|
|
||||||
|
static List<EmbedButtonBuilder> buttons({ |
||||||
|
bool showImageButton = true, |
||||||
|
bool showVideoButton = true, |
||||||
|
bool showCameraButton = true, |
||||||
|
bool showFormulaButton = false, |
||||||
|
OnImagePickCallback? onImagePickCallback, |
||||||
|
OnVideoPickCallback? onVideoPickCallback, |
||||||
|
MediaPickSettingSelector? mediaPickSettingSelector, |
||||||
|
MediaPickSettingSelector? cameraPickSettingSelector, |
||||||
|
FilePickImpl? filePickImpl, |
||||||
|
WebImagePickImpl? webImagePickImpl, |
||||||
|
WebVideoPickImpl? webVideoPickImpl, |
||||||
|
}) { |
||||||
|
return [ |
||||||
|
if (showImageButton) |
||||||
|
(controller, toolbarIconSize, iconTheme, dialogTheme) => ImageButton( |
||||||
|
icon: Icons.image, |
||||||
|
iconSize: toolbarIconSize, |
||||||
|
controller: controller, |
||||||
|
onImagePickCallback: onImagePickCallback, |
||||||
|
filePickImpl: filePickImpl, |
||||||
|
webImagePickImpl: webImagePickImpl, |
||||||
|
mediaPickSettingSelector: mediaPickSettingSelector, |
||||||
|
iconTheme: iconTheme, |
||||||
|
dialogTheme: dialogTheme, |
||||||
|
), |
||||||
|
if (showVideoButton) |
||||||
|
(controller, toolbarIconSize, iconTheme, dialogTheme) => VideoButton( |
||||||
|
icon: Icons.movie_creation, |
||||||
|
iconSize: toolbarIconSize, |
||||||
|
controller: controller, |
||||||
|
onVideoPickCallback: onVideoPickCallback, |
||||||
|
filePickImpl: filePickImpl, |
||||||
|
webVideoPickImpl: webImagePickImpl, |
||||||
|
mediaPickSettingSelector: mediaPickSettingSelector, |
||||||
|
iconTheme: iconTheme, |
||||||
|
dialogTheme: dialogTheme, |
||||||
|
), |
||||||
|
if ((onImagePickCallback != null || onVideoPickCallback != null) && |
||||||
|
showCameraButton) |
||||||
|
(controller, toolbarIconSize, iconTheme, dialogTheme) => CameraButton( |
||||||
|
icon: Icons.photo_camera, |
||||||
|
iconSize: toolbarIconSize, |
||||||
|
controller: controller, |
||||||
|
onImagePickCallback: onImagePickCallback, |
||||||
|
onVideoPickCallback: onVideoPickCallback, |
||||||
|
filePickImpl: filePickImpl, |
||||||
|
webImagePickImpl: webImagePickImpl, |
||||||
|
webVideoPickImpl: webVideoPickImpl, |
||||||
|
cameraPickSettingSelector: cameraPickSettingSelector, |
||||||
|
iconTheme: iconTheme, |
||||||
|
), |
||||||
|
if (showFormulaButton) |
||||||
|
(controller, toolbarIconSize, iconTheme, dialogTheme) => FormulaButton( |
||||||
|
icon: Icons.functions, |
||||||
|
iconSize: toolbarIconSize, |
||||||
|
controller: controller, |
||||||
|
onImagePickCallback: onImagePickCallback, |
||||||
|
filePickImpl: filePickImpl, |
||||||
|
webImagePickImpl: webImagePickImpl, |
||||||
|
mediaPickSettingSelector: mediaPickSettingSelector, |
||||||
|
iconTheme: iconTheme, |
||||||
|
dialogTheme: dialogTheme, |
||||||
|
) |
||||||
|
]; |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,36 @@ |
|||||||
|
name: flutter_quill_extensions |
||||||
|
description: Embed extensions for flutter_quill |
||||||
|
version: 0.0.1 |
||||||
|
homepage: https://bulletjournal.us/home/index.html |
||||||
|
#author: bulletjournal |
||||||
|
repository: https://github.com/singerdmx/flutter-quill/flutter_quill_extensions |
||||||
|
|
||||||
|
environment: |
||||||
|
sdk: ">=2.12.0 <3.0.0" |
||||||
|
flutter: ">=3.0.0" |
||||||
|
|
||||||
|
dependencies: |
||||||
|
flutter: |
||||||
|
sdk: flutter |
||||||
|
|
||||||
|
flutter_quill: ^6.0.0 |
||||||
|
|
||||||
|
image_picker: ^0.8.5+3 |
||||||
|
photo_view: ^0.14.0 |
||||||
|
video_player: ^2.4.2 |
||||||
|
youtube_player_flutter_quill: ^8.2.2 |
||||||
|
gallery_saver: ^2.3.2 |
||||||
|
math_keyboard: ^0.1.6 |
||||||
|
string_validator: ^0.3.0 |
||||||
|
|
||||||
|
dependency_overrides: |
||||||
|
flutter_quill: |
||||||
|
path: ../ |
||||||
|
|
||||||
|
dev_dependencies: |
||||||
|
flutter_test: |
||||||
|
sdk: flutter |
||||||
|
pedantic: ^1.11.1 |
||||||
|
|
||||||
|
# The following section is specific to Flutter packages. |
||||||
|
flutter: |
@ -0,0 +1,6 @@ |
|||||||
|
library flutter_quill.extensions; |
||||||
|
|
||||||
|
export 'src/models/documents/nodes/leaf.dart' hide Text; |
||||||
|
export 'src/models/rules/insert.dart'; |
||||||
|
export 'src/utils/platform.dart'; |
||||||
|
export 'src/utils/string.dart'; |
@ -0,0 +1,24 @@ |
|||||||
|
import 'package:flutter/material.dart'; |
||||||
|
|
||||||
|
import '../models/documents/nodes/leaf.dart' as leaf; |
||||||
|
import '../models/themes/quill_dialog_theme.dart'; |
||||||
|
import '../models/themes/quill_icon_theme.dart'; |
||||||
|
import 'controller.dart'; |
||||||
|
|
||||||
|
abstract class EmbedBuilder { |
||||||
|
String get key; |
||||||
|
|
||||||
|
Widget build( |
||||||
|
BuildContext context, |
||||||
|
QuillController controller, |
||||||
|
leaf.Embed node, |
||||||
|
bool readOnly, |
||||||
|
void Function(GlobalKey videoContainerKey)? onVideoInit, |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
typedef EmbedButtonBuilder = Widget Function( |
||||||
|
QuillController controller, |
||||||
|
double toolbarIconSize, |
||||||
|
QuillIconTheme? iconTheme, |
||||||
|
QuillDialogTheme? dialogTheme); |
@ -1,260 +0,0 @@ |
|||||||
import 'package:flutter/cupertino.dart'; |
|
||||||
import 'package:flutter/foundation.dart'; |
|
||||||
import 'package:flutter/material.dart'; |
|
||||||
import 'package:flutter/services.dart'; |
|
||||||
import 'package:gallery_saver/gallery_saver.dart'; |
|
||||||
import 'package:math_keyboard/math_keyboard.dart'; |
|
||||||
import 'package:tuple/tuple.dart'; |
|
||||||
|
|
||||||
import '../../models/documents/attribute.dart'; |
|
||||||
import '../../models/documents/nodes/embeddable.dart'; |
|
||||||
import '../../models/documents/nodes/leaf.dart' as leaf; |
|
||||||
import '../../translations/toolbar.i18n.dart'; |
|
||||||
import '../../utils/embeds.dart'; |
|
||||||
import '../../utils/platform.dart'; |
|
||||||
import '../../utils/string.dart'; |
|
||||||
import '../controller.dart'; |
|
||||||
import 'image.dart'; |
|
||||||
import 'image_resizer.dart'; |
|
||||||
import 'video_app.dart'; |
|
||||||
import 'youtube_video_app.dart'; |
|
||||||
|
|
||||||
Widget defaultEmbedBuilder( |
|
||||||
BuildContext context, |
|
||||||
QuillController controller, |
|
||||||
leaf.Embed node, |
|
||||||
bool readOnly, |
|
||||||
void Function(GlobalKey videoContainerKey)? onVideoInit, |
|
||||||
) { |
|
||||||
assert(!kIsWeb, 'Please provide EmbedBuilder for Web'); |
|
||||||
|
|
||||||
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)); |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
if (_widthHeight == null) { |
|
||||||
image = imageByUrl(imageUrl); |
|
||||||
_widthHeight = Tuple2((image as Image).width, image.height); |
|
||||||
} |
|
||||||
|
|
||||||
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); |
|
||||||
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(); |
|
||||||
|
|
||||||
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.', |
|
||||||
); |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
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: () { |
|
||||||
imageUrl = appendFileExtensionToImageUrl(imageUrl); |
|
||||||
GallerySaver.saveImage(imageUrl).then((_) { |
|
||||||
ScaffoldMessenger.of(context) |
|
||||||
.showSnackBar(SnackBar(content: Text('Saved'.i18n))); |
|
||||||
Navigator.pop(context); |
|
||||||
}); |
|
||||||
}, |
|
||||||
); |
|
||||||
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)), |
|
||||||
), |
|
||||||
], |
|
||||||
), |
|
||||||
); |
|
||||||
} |
|
||||||
} |
|
@ -0,0 +1,3 @@ |
|||||||
|
library flutter_quill.translations; |
||||||
|
|
||||||
|
export 'src/translations/toolbar.i18n.dart'; |
Loading…
Reference in new issue