From 8fc533354339e0b62eaa1c8f9e62515e2d730e46 Mon Sep 17 00:00:00 2001 From: Ahmed Hnewa <73608287+ahmedhnewa@users.noreply.github.com> Date: Thu, 5 Oct 2023 11:16:54 +0300 Subject: [PATCH] Add new features --- .../lib/embeds/builders.dart | 23 +- .../lib/embeds/utils.dart | 22 +- .../lib/flutter_quill_extensions.dart | 98 +++++-- .../lib/utils/quill_utils.dart | 251 ++++++++++++++++++ 4 files changed, 373 insertions(+), 21 deletions(-) create mode 100644 flutter_quill_extensions/lib/utils/quill_utils.dart diff --git a/flutter_quill_extensions/lib/embeds/builders.dart b/flutter_quill_extensions/lib/embeds/builders.dart index 58ad4ff8..89636b4f 100644 --- a/flutter_quill_extensions/lib/embeds/builders.dart +++ b/flutter_quill_extensions/lib/embeds/builders.dart @@ -23,10 +23,12 @@ class ImageEmbedBuilder extends EmbedBuilder { ImageEmbedBuilder({ this.afterRemoveImageFromEditor, this.shouldRemoveImageFromEditor, + this.forceUseMobileOptionMenu = false, }); final ImageEmbedBuilderAfterRemoveImageFromEditor? afterRemoveImageFromEditor; final ImageEmbedBuilderShouldRemoveImageFromEditor? shouldRemoveImageFromEditor; + final bool forceUseMobileOptionMenu; @override String get key => BlockEmbed.imageType; @@ -79,7 +81,7 @@ class ImageEmbedBuilder extends EmbedBuilder { imageSize = OptionalSize((image as Image).width, image.height); } - if (!readOnly && base.isMobile()) { + if (!readOnly && (base.isMobile() || forceUseMobileOptionMenu)) { return GestureDetector( onTap: () { showDialog( @@ -169,7 +171,11 @@ class ImageEmbedBuilder extends EmbedBuilder { child: SimpleDialog( shape: const RoundedRectangleBorder( borderRadius: BorderRadius.all(Radius.circular(10))), - children: [resizeOption, copyOption, removeOption]), + children: [ + if (base.isMobile()) resizeOption, + copyOption, + removeOption, + ]), ); }); }, @@ -177,7 +183,16 @@ class ImageEmbedBuilder extends EmbedBuilder { ); } - if (!readOnly || !base.isMobile() || isImageBase64(imageUrl)) { + 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; } @@ -246,7 +261,7 @@ class VideoEmbedBuilder extends EmbedBuilder { assert(!kIsWeb, 'Please provide video EmbedBuilder for Web'); final videoUrl = node.value.data; - if (videoUrl.contains('youtube.com') || videoUrl.contains('youtu.be')) { + if (isYouTubeUrl(videoUrl)) { return YoutubeVideoApp( videoUrl: videoUrl, context: context, readOnly: readOnly); } diff --git a/flutter_quill_extensions/lib/embeds/utils.dart b/flutter_quill_extensions/lib/embeds/utils.dart index a2be5b98..b82525f0 100644 --- a/flutter_quill_extensions/lib/embeds/utils.dart +++ b/flutter_quill_extensions/lib/embeds/utils.dart @@ -15,8 +15,28 @@ bool isBase64(String str) { return _base64.hasMatch(str); } +bool isHttpBasedUrl(String url) { + try { + final uri = Uri.parse(url.trim()); + return uri.isScheme('HTTP') || uri.isScheme('HTTPS'); + } catch (_) { + return false; + } +} + +bool isYouTubeUrl(String videoUrl) { + try { + final uri = Uri.parse(videoUrl); + return uri.host == 'www.youtube.com' || + uri.host == 'youtube.com' || + uri.host == 'youtu.be'; + } catch (_) { + return false; + } +} + bool isImageBase64(String imageUrl) { - return !imageUrl.startsWith('http') && isBase64(imageUrl); + return !isHttpBasedUrl(imageUrl) && isBase64(imageUrl); } enum SaveImageResultMethod { network, localStorage } diff --git a/flutter_quill_extensions/lib/flutter_quill_extensions.dart b/flutter_quill_extensions/lib/flutter_quill_extensions.dart index 2686581e..8545d66a 100644 --- a/flutter_quill_extensions/lib/flutter_quill_extensions.dart +++ b/flutter_quill_extensions/lib/flutter_quill_extensions.dart @@ -1,6 +1,7 @@ library flutter_quill_extensions; import 'package:flutter/material.dart'; +import 'package:flutter_quill/extensions.dart'; import 'package:flutter_quill/flutter_quill.dart'; import 'embeds/builders.dart'; @@ -22,17 +23,28 @@ export 'embeds/utils.dart'; class FlutterQuillEmbeds { /// Returns a list of embed builders for QuillEditor. /// + /// This method provides a collection of embed builders to enhance the + /// functionality + /// of a QuillEditor. It offers customization options for + /// handling various types of + /// embedded content, such as images, videos, and formulas. + /// /// **Note:** This method is not intended for web usage. - /// For web-specific embeds, use [webBuilders]. + /// For web-specific embeds, + /// use [webBuilders]. /// - /// [onVideoInit] is called when a video is initialized. + /// [onVideoInit] is a callback function that gets triggered when + /// a video is initialized. + /// You can use this to perform actions or setup configurations related + /// to video embedding. /// - /// [afterRemoveImageFromEditor] is called when an image - /// is removed from the editor. - /// By default, [afterRemoveImageFromEditor] deletes the cached - /// image if it still exists. - /// If you want to customize the behavior, pass your own function - /// that handles the removal. + /// [afterRemoveImageFromEditor] is called when an image is + /// removed from the editor. + /// By default, [afterRemoveImageFromEditor] deletes the + /// temporary image file if + /// the platform is mobile and if it still exists. You + /// can customize this behavior + /// by passing your own function that handles the removal process. /// /// Example of [afterRemoveImageFromEditor] customization: /// ```dart @@ -42,11 +54,10 @@ class FlutterQuillEmbeds { /// } /// ``` /// - /// [shouldRemoveImageFromEditor] is called when the user - /// attempts to remove an image - /// from the editor. It allows you to control whether the image - /// should be removed - /// based on your custom logic. + /// [shouldRemoveImageFromEditor] is a callback + /// function that is invoked when the + /// user attempts to remove an image from the editor. It allows you to control + /// whether the image should be removed based on your custom logic. /// /// Example of [shouldRemoveImageFromEditor] customization: /// ```dart @@ -56,8 +67,8 @@ class FlutterQuillEmbeds { /// context: context, /// options: const YesOrCancelDialogOptions( /// title: 'Deleting an image', - /// message: 'Are you sure you want to delete this image - /// from the editor?', + /// message: 'Are you sure you want' ' to delete this + /// image from the editor?', /// ), /// ); /// @@ -65,14 +76,69 @@ class FlutterQuillEmbeds { /// return isShouldRemove; /// } /// ``` + /// + /// [forceUseMobileOptionMenuForImageClick] is a boolean + /// flag that, when set to `true`, + /// enforces the use of the mobile-specific option menu for image clicks in + /// other platforms like web and desktop, this option doesn't affect mobile. + /// This option + /// can be used to override the default behavior based on the platform. + /// + /// The method returns a list of [EmbedBuilder] objects that can be used with + /// QuillEditor + /// to enable embedded content features like images, videos, and formulas. + /// + /// Example usage: + /// ```dart + /// final embedBuilders = QuillEmbedBuilders.builders( + /// onVideoInit: (videoContainerKey) { + /// // Custom video initialization logic + /// }, + /// // Customize other callback functions as needed + /// ); + /// + /// final quillEditor = QuillEditor( + /// // Other editor configurations + /// embedBuilders: embedBuilders, + /// ); + /// ``` static List builders({ void Function(GlobalKey videoContainerKey)? onVideoInit, ImageEmbedBuilderAfterRemoveImageFromEditor? afterRemoveImageFromEditor, ImageEmbedBuilderShouldRemoveImageFromEditor? shouldRemoveImageFromEditor, + bool forceUseMobileOptionMenuForImageClick = false, }) => [ ImageEmbedBuilder( - afterRemoveImageFromEditor: afterRemoveImageFromEditor, + forceUseMobileOptionMenu: forceUseMobileOptionMenuForImageClick, + afterRemoveImageFromEditor: afterRemoveImageFromEditor ?? + (imageFile) async { + final mobile = isMobile(); + // If the platform is not mobile, return void; + // Since the mobile OS gives us a copy of the image + + // Note: We should remove the image on Flutter web + // since the behavior is similar to how it is on mobile, + // but since this builder is not for web, we will ignore it + if (!mobile) { + return; + } + + // On mobile OS (Android, iOS), the system will not give us + // direct access to the image; instead, + // it will give us the image + // in the temp directory of the application. So, we want to + // remove it when we no longer need it. + + // but on desktop we don't want to touch user files + // especially on macOS, where we can't even delete it without + // permission + + final isFileExists = await imageFile.exists(); + if (isFileExists) { + await imageFile.delete(); + } + }, shouldRemoveImageFromEditor: shouldRemoveImageFromEditor, ), VideoEmbedBuilder(onVideoInit: onVideoInit), diff --git a/flutter_quill_extensions/lib/utils/quill_utils.dart b/flutter_quill_extensions/lib/utils/quill_utils.dart new file mode 100644 index 00000000..c5381584 --- /dev/null +++ b/flutter_quill_extensions/lib/utils/quill_utils.dart @@ -0,0 +1,251 @@ +import 'dart:io' show Directory, File, Platform; + +import 'package:flutter/foundation.dart'; +import 'package:flutter_quill/flutter_quill.dart' as quill; +import 'package:path/path.dart' as path; + +import '../flutter_quill_extensions.dart'; + +class QuillUtilities { + const QuillUtilities._(); + + /// Saves a list of images to a specified directory. + /// + /// This function is designed to work efficiently on + /// mobile platforms, but it can also be used on other platforms. + /// + /// When you have a list of cached image paths + /// from a Quill document and you want to save them, + /// you can use this function. + /// It takes a list of image paths and copies each image to the specified + /// directory. If the image + /// path does not exist, it returns an + /// empty string for that item. + /// + /// Make sure that the image paths provided in the [images] + /// list exist, and handle the cases where images are not found accordingly. + /// + /// [images]: List of image paths to be saved. + /// [deleteThePreviousImages]: Indicates whether to delete the + /// original cached images after copying. + /// [saveDirectory]: The directory where the images will be saved. + /// + /// Returns a list of paths to the newly saved images. + /// For images that do not exist, their paths are returned as empty strings. + /// + /// Example usage: + /// ```dart + /// final documentsDir = await getApplicationDocumentsDirectory(); + /// final savedImagePaths = await saveImagesToDirectory( + /// images: cachedImagePaths, + /// deleteThePreviousImages: true, + /// saveDirectory: documentsDir, + /// ); + /// ``` + static Future> saveImagesToDirectory({ + required Iterable images, + required deleteThePreviousImages, + required Directory saveDirectory, + }) async { + final newImagesFutures = images.map((cachedImagePath) async { + final previousImageFile = File(cachedImagePath); + final isPreviousImageFileExists = await previousImageFile.exists(); + + if (!isPreviousImageFileExists) { + return ''; + } + + final newImageFileExtensionWithDot = path.extension(cachedImagePath); + + final dateTimeAsString = DateTime.now().toIso8601String(); + // TODO: You might want to make it easier for the developer to change + // the newImageFileName, but he can rename it anyway + final newImageFileName = + 'quill-image-$dateTimeAsString$newImageFileExtensionWithDot'; + final newImagePath = path.join(saveDirectory.path, newImageFileName); + final newImageFile = await previousImageFile.copy(newImagePath); + if (deleteThePreviousImages) { + await previousImageFile.delete(); + } + return newImageFile.path; + }); + // Await for the saving process for each image + final newImages = await Future.wait(newImagesFutures); + return newImages; + } + + /// Deletes all local images referenced in a Quill document. + /// + /// This function removes local images from the + /// file system that are referenced in the provided [document]. + /// + /// [document]: The Quill document from which images will be deleted. + /// + /// Throws an [Exception] if any errors occur during the deletion process. + /// + /// Example usage: + /// ```dart + /// try { + /// await deleteAllLocalImagesOfDocument(myQuillDocument); + /// } catch (e) { + /// print('Error deleting local images: $e'); + /// } + /// ``` + static Future deleteAllLocalImagesOfDocument( + quill.Document document, + ) async { + final imagesPaths = getImagesPathsFromDocument( + document, + onlyLocalImages: true, + ); + for (final image in imagesPaths) { + final imageFile = File(image); + final fileExists = await imageFile.exists(); + if (!fileExists) { + return; + } + final deletedFile = await imageFile.delete(); + final deletedFileStillExists = await deletedFile.exists(); + if (deletedFileStillExists) { + throw Exception( + 'We have successfully deleted the file and it is still exists!!', + ); + } + } + } + + /// Retrieves paths to images embedded in a Quill document. + /// + /// This function parses the [document] and returns a list of image paths. + /// + /// [document]: The Quill document from which image paths will be retrieved. + /// [onlyLocalImages]: If `true`, + /// only local (non-web) image paths will be included. + /// + /// Returns an iterable of image paths. + /// + /// Example usage: + /// ```dart + /// final quillDocument = _controller.document; + /// final imagePaths + /// = getImagesPathsFromDocument(quillDocument, onlyLocalImages: true); + /// print('Image paths: $imagePaths'); + /// ``` + /// + /// Note: This function assumes that images are + /// embedded as block embeds in the Quill document. + static Iterable getImagesPathsFromDocument( + quill.Document document, { + required bool onlyLocalImages, + }) { + final images = document.root.children + .whereType() + .where((node) { + if (node.isEmpty) { + return false; + } + final firstNode = node.children.first; + if (firstNode is! quill.Embed) { + return false; + } + + if (firstNode.value.type != quill.BlockEmbed.imageType) { + return false; + } + final imageSource = firstNode.value.data; + if (imageSource is! String) { + return false; + } + if (onlyLocalImages && isHttpBasedUrl(imageSource)) { + return false; + } + return imageSource.trim().isNotEmpty; + }) + .toList() + .map((e) => (e.children.first as quill.Embed).value.data as String); + return images; + } + + /// Determines if an image file is cached based on the platform. + /// + /// On mobile platforms (Android and iOS), images are typically + /// cached in temporary directories. + /// This function helps identify whether the given image file path + /// is a cached path on supported platforms. + /// + /// [imagePath] is the path of the image file to check for caching. + /// + /// Returns `true` if the image is cached, `false` otherwise. + /// On other platforms it will always return false + static bool isImageCached(String imagePath) { + // Determine if the image path is a cached path based on platform + if (kIsWeb) { + // For now this will not work for web + return false; + } + if (Platform.isAndroid) { + return imagePath.contains('cache'); + } + if (Platform.isIOS) { + // Don't use isAppleOS() since macOS has different behavior + return imagePath.contains('tmp'); + } + // On other platforms like desktop + // The image is not cached and we will get a direct + // access to the image + return false; + } + + /// Retrieves cached image paths from a Quill document, + /// primarily for mobile platforms. + /// + /// This function scans a Quill document to identify + /// and return paths to locally cached images. + /// It is specifically designed for mobile + /// operating systems (Android and iOS). + /// + /// [document] is the Quill document from which to extract image paths. + /// + /// [replaceUnexistentImagesWith] is an optional parameter. + /// If provided, it replaces non-existent image paths + /// with the specified value. If not provided, non-existent + /// image paths are removed from the result. + /// + /// Returns a list of cached image paths found in the document. + /// On non-mobile platforms, this function returns an empty list. + static Future> getCachedImagePathsFromDocument( + quill.Document document, { + String? replaceUnexistentImagesWith, + }) async { + final imagePaths = getImagesPathsFromDocument( + document, + onlyLocalImages: true, + ); + + // We don't want the not cached images to be saved again for example. + final cachesImagePaths = imagePaths.where((imagePath) { + final isCurrentImageCached = isImageCached(imagePath); + return isCurrentImageCached; + }).toList(); + + // Remove all the images that doesn't exists + for (final imagePath in cachesImagePaths) { + final file = File(imagePath); + final exists = await file.exists(); + if (!exists) { + final index = cachesImagePaths.indexOf(imagePath); + if (index == -1) { + continue; + } + cachesImagePaths.removeAt(index); + if (replaceUnexistentImagesWith != null) { + cachesImagePaths.insert( + index, + replaceUnexistentImagesWith, + ); + } + } + } + return cachesImagePaths; + } +}