diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 00000000..c9262307 --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,23 @@ +name: flutter-quill CI + +on: + push: + branches: [master] + pull_request: + branches: [master] + +jobs: + tests: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + - uses: subosito/flutter-action@v2 + with: + channel: 'stable' + - run: flutter --version + - run: flutter pub get + - run: flutter pub get -C flutter_quill_extensions + - run: flutter analyze + - run: flutter test + - run: flutter pub publish --dry-run diff --git a/.gitignore b/.gitignore index 08c3c879..6b87759f 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,7 @@ .pub-cache/ .pub/ build/ +coverage/ # Android related **/android/**/gradle-wrapper.jar diff --git a/CHANGELOG.md b/CHANGELOG.md index 639f46e8..4742cf80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,70 @@ +# [7.3.2] +- Added builder for custom button in _LinkDialog. + +# [7.3.1] +- Added case sensitive and whole word search parameters. +- Added wrap around. +- Moved search dialog to the bottom in order not to override the editor and the text found. +- Other minor search dialog enhancements. + +# [7.3.0] +- Add default attributes to basic factory. + +# [7.2.19] +- Feat/link regexp. + +# [7.2.18] +- Fix paste block text in words apply same style. + +# [7.2.17] +- Fix paste text mess up style. +- Add support copy/cut block text. + +# [7.2.16] +- Allow for custom context menu. + +# [7.2.15] +- Add flutter_quill.delta library which only exposes Delta datatype. + +# [7.2.14] +- Fix errors when the editor is used in the `screenshot` package. + +# [7.2.13] +- Fix around image can't delete line break. + +# [7.2.12] +- Add support for copy/cut select image and text together. + +# [7.2.11] +- Add affinity for localPosition. + +# [7.2.10] +- LINE._getPlainText queryChild inclusive=false. + +# [7.2.9] +- Add toPlainText method to `EmbedBuilder`. + +# [7.2.8] +- Add custom button widget in toolbar. + +# [7.2.7] +- Fix language code of Japan. + +# [7.2.6] +- Style custom toolbar buttons like builtins. + +# [7.2.5] +- Always use text cursor for editor on desktop. + +# [7.2.4] +- Fixed keepStyleOnNewLine. + +# [7.2.3] +- Get pixel ratio from view. + +# [7.2.2] +- Prevent operations on stale editor state. + # [7.2.1] - Add support for android keyboard content insertion. - Enhance color picker, enter hex color and color palette option. diff --git a/README.md b/README.md index d31e9a58..59779c6d 100644 --- a/README.md +++ b/README.md @@ -24,9 +24,7 @@ FlutterQuill is a rich text editor and a [Quill] component for [Flutter]. -This library is a WYSIWYG editor built for the modern mobile platform, with web compatibility under development. Check out our [Youtube Playlist] or [Code Introduction] to take a detailed walkthrough of the code base. You can join our [Slack Group] for discussion. - -Demo App: [BULLET JOURNAL](https://bulletjournal.us/home/index.html) +This library is a WYSIWYG editor built for the modern Android, iOS, web and desktop platforms. Check out our [Youtube Playlist] or [Code Introduction] to take a detailed walkthrough of the code base. You can join our [Slack Group] for discussion. Pub: [FlutterQuill] @@ -347,10 +345,11 @@ QuillToolbar(locale: Locale('fr'), ...) QuillEditor(locale: Locale('fr'), ...) ``` -Currently, translations are available for these 27 locales: +Currently, translations are available for these 28 locales: * `Locale('en')` * `Locale('ar')` +* `Locale('bn')` * `Locale('cs')` * `Locale('de')` * `Locale('da')` @@ -375,7 +374,7 @@ Currently, translations are available for these 27 locales: * `Locale('fa')` * `Locale('hi')` * `Locale('sr')` -* `Locale('jp')` +* `Locale('ja')` #### Contributing to translations @@ -391,6 +390,22 @@ tables, and mentions. Conversion can be performed in vanilla Dart (i.e., server- It is a complete Dart part of the popular and mature [quill-delta-to-html](https://www.npmjs.com/package/quill-delta-to-html) Typescript/Javascript package. +## Testing + +To aid in testing applications using the editor an extension to the flutter `WidgetTester` is provided which includes methods to simplify interacting with the editor in test cases. + +Import the test utilities in your test file: + +```dart +import 'package:flutter_quill/flutter_quill_test.dart'; +``` + +and then enter text using `quillEnterText`: + +```dart +await tester.quillEnterText(find.byType(QuillEditor), 'test\n'); +``` + ## Sponsors diff --git a/doc_cn.md b/doc_cn.md index 0286abf3..d5389d61 100644 --- a/doc_cn.md +++ b/doc_cn.md @@ -26,7 +26,7 @@ `FlutterQuill` 是一个富文本编辑器,也是 [Quill](https://quilljs.com/docs/formats) 在 [Flutter](https://github.com/flutter/flutter) 的版本 -该库是为移动平台构建的『所见即所得』的富文本编辑器,同时我们还正在对 `Web` 平台进行兼容。查看我们的 [Youtube 播放列表](https://youtube.com/playlist?list=PLbhaS_83B97vONkOAWGJrSXWX58et9zZ2) 或 [代码介绍](https://github.com/singerdmx/flutter-quill/blob/master/CodeIntroduction.md) 以了解代码的详细内容。你可以加入我们的 [Slack Group](https://join.slack.com/t/bulletjournal1024/shared_invite/zt-fys7t9hi-ITVU5PGDen1rNRyCjdcQ2g) 来进行讨论 +该库是为 Android、iOS、Web、Desktop 多平台构建的『所见即所得』的富文本编辑器。查看我们的 [Youtube 播放列表](https://youtube.com/playlist?list=PLbhaS_83B97vONkOAWGJrSXWX58et9zZ2) 或 [代码介绍](https://github.com/singerdmx/flutter-quill/blob/master/CodeIntroduction.md) 以了解代码的详细内容。你可以加入我们的 [Slack Group](https://join.slack.com/t/bulletjournal1024/shared_invite/zt-fys7t9hi-ITVU5PGDen1rNRyCjdcQ2g) 来进行讨论 示例 `App` : [BULLET JOURNAL](https://bulletjournal.us/home/index.html) @@ -404,6 +404,22 @@ QuillEditor(locale: Locale('fr'), ...) 其是流行且成熟的 [quill-delta-to-html](https://www.npmjs.com/package/quill-delta-to-html) `Typescript/Javascript` 包的 `Dart` 部分 +## 测试 + +为了能在测试文件里测试编辑器,我们给 flutter `WidgetTester` 提供了一个扩展,其中包括在测试文件中简化与编辑器交互的方法。 + +在测试文件内导入测试工具: + +```dart +import 'package:flutter_quill/flutter_quill_test.dart'; +``` + +然后使用 `quillEnterText` 输入文字: + +```dart +await tester.quillEnterText(find.byType(QuillEditor), 'test\n'); +``` + --- ## 赞助 diff --git a/example/android/build.gradle b/example/android/build.gradle index 4256f917..85ed7b51 100644 --- a/example/android/build.gradle +++ b/example/android/build.gradle @@ -1,12 +1,12 @@ buildscript { - ext.kotlin_version = '1.6.10' + ext.kotlin_version = '1.9.0' repositories { google() mavenCentral() } dependencies { - classpath 'com.android.tools.build:gradle:4.1.0' + classpath 'com.android.tools.build:gradle:7.4.0' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" } } @@ -26,6 +26,6 @@ subprojects { project.evaluationDependsOn(':app') } -task clean(type: Delete) { +tasks.register("clean", Delete) { delete rootProject.buildDir } diff --git a/example/android/gradle/wrapper/gradle-wrapper.properties b/example/android/gradle/wrapper/gradle-wrapper.properties index bc6a58af..6b665338 100644 --- a/example/android/gradle/wrapper/gradle-wrapper.properties +++ b/example/android/gradle/wrapper/gradle-wrapper.properties @@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-6.7-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-all.zip diff --git a/example/assets/fonts/SF-Pro-Display-Regular.otf b/example/assets/fonts/SF-Pro-Display-Regular.otf new file mode 100755 index 00000000..1279121f Binary files /dev/null and b/example/assets/fonts/SF-Pro-Display-Regular.otf differ diff --git a/example/lib/pages/home_page.dart b/example/lib/pages/home_page.dart index 11baf428..4e08e5be 100644 --- a/example/lib/pages/home_page.dart +++ b/example/lib/pages/home_page.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; +import 'dart:ui'; import 'package:file_picker/file_picker.dart'; import 'package:filesystem_picker/filesystem_picker.dart'; @@ -14,6 +15,7 @@ import 'package:path/path.dart'; import 'package:path_provider/path_provider.dart'; import '../universal_ui/universal_ui.dart'; +import '../widgets/time_stamp_embed_widget.dart'; import 'read_only_page.dart'; enum _SelectionType { @@ -80,9 +82,24 @@ class _HomePageState extends State { ), actions: [ IconButton( - onPressed: () => _addEditNote(context), - icon: const Icon(Icons.note_add), + onPressed: () => _insertTimeStamp( + _controller!, + DateTime.now().toString(), + ), + icon: const Icon(Icons.add_alarm_rounded), ), + IconButton( + onPressed: () => showDialog( + context: context, + builder: (context) => AlertDialog( + content: Text(_controller!.document.toPlainText([ + ...FlutterQuillEmbeds.builders(), + TimeStampEmbedBuilderWidget() + ])), + ), + ), + icon: const Icon(Icons.text_fields_rounded), + ) ], ), drawer: Container( @@ -158,76 +175,78 @@ class _HomePageState extends State { } Widget _buildWelcomeEditor(BuildContext context) { - Widget quillEditor = MouseRegion( - cursor: SystemMouseCursors.text, - child: QuillEditor( - controller: _controller!, - scrollController: ScrollController(), - scrollable: true, - focusNode: _focusNode, - autoFocus: false, - readOnly: false, - placeholder: 'Add content', - enableSelectionToolbar: isMobile(), - expands: false, - padding: EdgeInsets.zero, - onImagePaste: _onImagePaste, - onTapUp: (details, p1) { - return _onTripleClickSelection(); - }, - customStyles: DefaultStyles( - h1: DefaultTextBlockStyle( - const TextStyle( - fontSize: 32, - color: Colors.black, - height: 1.15, - fontWeight: FontWeight.w300, - ), - const VerticalSpacing(16, 0), - const VerticalSpacing(0, 0), - null), - sizeSmall: const TextStyle(fontSize: 9), + Widget quillEditor = QuillEditor( + controller: _controller!, + scrollController: ScrollController(), + scrollable: true, + focusNode: _focusNode, + autoFocus: false, + readOnly: false, + placeholder: 'Add content', + enableSelectionToolbar: isMobile(), + expands: false, + padding: EdgeInsets.zero, + onImagePaste: _onImagePaste, + onTapUp: (details, p1) { + return _onTripleClickSelection(); + }, + customStyles: DefaultStyles( + h1: DefaultTextBlockStyle( + const TextStyle( + fontSize: 32, + color: Colors.black, + height: 1.15, + fontWeight: FontWeight.w300, + ), + const VerticalSpacing(16, 0), + const VerticalSpacing(0, 0), + null), + sizeSmall: const TextStyle(fontSize: 9), + subscript: const TextStyle( + fontFamily: 'SF-UI-Display', + fontFeatures: [FontFeature.subscripts()], + ), + superscript: const TextStyle( + fontFamily: 'SF-UI-Display', + fontFeatures: [FontFeature.superscripts()], ), - embedBuilders: [ - ...FlutterQuillEmbeds.builders(), - NotesEmbedBuilder(addEditNote: _addEditNote) - ], ), + embedBuilders: [ + ...FlutterQuillEmbeds.builders(), + TimeStampEmbedBuilderWidget() + ], ); if (kIsWeb) { - quillEditor = MouseRegion( - cursor: SystemMouseCursors.text, - child: QuillEditor( - controller: _controller!, - scrollController: ScrollController(), - scrollable: true, - focusNode: _focusNode, - autoFocus: false, - readOnly: false, - placeholder: 'Add content', - expands: false, - padding: EdgeInsets.zero, - onTapUp: (details, p1) { - return _onTripleClickSelection(); - }, - customStyles: DefaultStyles( - h1: DefaultTextBlockStyle( - const TextStyle( - fontSize: 32, - color: Colors.black, - height: 1.15, - fontWeight: FontWeight.w300, - ), - const VerticalSpacing(16, 0), - const VerticalSpacing(0, 0), - null), - sizeSmall: const TextStyle(fontSize: 9), - ), - embedBuilders: [ - ...defaultEmbedBuildersWeb, - NotesEmbedBuilder(addEditNote: _addEditNote), - ]), - ); + quillEditor = QuillEditor( + controller: _controller!, + scrollController: ScrollController(), + scrollable: true, + focusNode: _focusNode, + autoFocus: false, + readOnly: false, + placeholder: 'Add content', + expands: false, + padding: EdgeInsets.zero, + onTapUp: (details, p1) { + return _onTripleClickSelection(); + }, + customStyles: DefaultStyles( + h1: DefaultTextBlockStyle( + const TextStyle( + fontSize: 32, + color: Colors.black, + height: 1.15, + fontWeight: FontWeight.w300, + ), + const VerticalSpacing(16, 0), + const VerticalSpacing(0, 0), + null), + sizeSmall: const TextStyle(fontSize: 9), + ), + embedBuilders: [ + ...defaultEmbedBuildersWeb, + TimeStampEmbedBuilderWidget() + ]); } var toolbar = QuillToolbar.basic( controller: _controller!, @@ -439,99 +458,41 @@ class _HomePageState extends State { return file.path.toString(); } - Future _addEditNote(BuildContext context, {Document? document}) async { - final isEditing = document != null; - final quillEditorController = QuillController( - document: document ?? Document(), - selection: const TextSelection.collapsed(offset: 0), - ); - - await showDialog( - context: context, - builder: (context) => AlertDialog( - titlePadding: const EdgeInsets.only(left: 16, top: 8), - title: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text('${isEditing ? 'Edit' : 'Add'} note'), - IconButton( - onPressed: () => Navigator.of(context).pop(), - icon: const Icon(Icons.close), - ) - ], - ), - content: QuillEditor.basic( - controller: quillEditorController, - readOnly: false, - ), + static void _insertTimeStamp(QuillController controller, String string) { + controller.document.insert(controller.selection.extentOffset, '\n'); + controller.updateSelection( + TextSelection.collapsed( + offset: controller.selection.extentOffset + 1, ), + ChangeSource.LOCAL, ); - if (quillEditorController.document.isEmpty()) return; - - final block = BlockEmbed.custom( - NotesBlockEmbed.fromDocument(quillEditorController.document), + controller.document.insert( + controller.selection.extentOffset, + TimeStampEmbed(string), ); - final controller = _controller!; - final index = controller.selection.baseOffset; - final length = controller.selection.extentOffset - index; - - if (isEditing) { - final offset = - getEmbedNode(controller, controller.selection.start).offset; - controller.replaceText( - offset, 1, block, TextSelection.collapsed(offset: offset)); - } else { - controller.replaceText(index, length, block, null); - } - } -} - -class NotesEmbedBuilder extends EmbedBuilder { - NotesEmbedBuilder({required this.addEditNote}); - Future Function(BuildContext context, {Document? document}) addEditNote; + controller.updateSelection( + TextSelection.collapsed( + offset: controller.selection.extentOffset + 1, + ), + ChangeSource.LOCAL, + ); - @override - String get key => 'notes'; + controller.document.insert(controller.selection.extentOffset, ' '); + controller.updateSelection( + TextSelection.collapsed( + offset: controller.selection.extentOffset + 1, + ), + ChangeSource.LOCAL, + ); - @override - Widget build( - BuildContext context, - QuillController controller, - Embed node, - bool readOnly, - bool inline, - TextStyle textStyle, - ) { - final notes = NotesBlockEmbed(node.value.data).document; - - return Material( - color: Colors.transparent, - child: ListTile( - title: Text( - notes.toPlainText().replaceAll('\n', ' '), - maxLines: 3, - overflow: TextOverflow.ellipsis, - ), - leading: const Icon(Icons.notes), - onTap: () => addEditNote(context, document: notes), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), - side: const BorderSide(color: Colors.grey), - ), + controller.document.insert(controller.selection.extentOffset, '\n'); + controller.updateSelection( + TextSelection.collapsed( + offset: controller.selection.extentOffset + 1, ), + ChangeSource.LOCAL, ); } } - -class NotesBlockEmbed extends CustomBlockEmbed { - const NotesBlockEmbed(String value) : super(noteType, value); - - static const String noteType = 'notes'; - - static NotesBlockEmbed fromDocument(Document document) => - NotesBlockEmbed(jsonEncode(document.toDelta().toJson())); - - Document get document => Document.fromJson(jsonDecode(data)); -} diff --git a/example/lib/widgets/time_stamp_embed_widget.dart b/example/lib/widgets/time_stamp_embed_widget.dart new file mode 100644 index 00000000..cf922952 --- /dev/null +++ b/example/lib/widgets/time_stamp_embed_widget.dart @@ -0,0 +1,44 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter_quill/flutter_quill.dart' hide Text; + +class TimeStampEmbed extends Embeddable { + const TimeStampEmbed( + String value, + ) : super(timeStampType, value); + + static const String timeStampType = 'timeStamp'; + + static TimeStampEmbed fromDocument(Document document) => + TimeStampEmbed(jsonEncode(document.toDelta().toJson())); + + Document get document => Document.fromJson(jsonDecode(data)); +} + +class TimeStampEmbedBuilderWidget extends EmbedBuilder { + @override + String get key => 'timeStamp'; + + @override + String toPlainText(Embed embed) { + return embed.value.data; + } + + @override + Widget build( + BuildContext context, + QuillController controller, + Embed node, + bool readOnly, + bool inline, + TextStyle textStyle, + ) { + return Row( + children: [ + const Icon(Icons.access_time_rounded), + Text(node.value.data as String), + ], + ); + } +} diff --git a/example/linux/flutter/generated_plugin_registrant.cc b/example/linux/flutter/generated_plugin_registrant.cc index 1560fc80..158f759a 100644 --- a/example/linux/flutter/generated_plugin_registrant.cc +++ b/example/linux/flutter/generated_plugin_registrant.cc @@ -6,10 +6,14 @@ #include "generated_plugin_registrant.h" +#include #include #include void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); + file_selector_plugin_register_with_registrar(file_selector_linux_registrar); g_autoptr(FlPluginRegistrar) pasteboard_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "PasteboardPlugin"); pasteboard_plugin_register_with_registrar(pasteboard_registrar); diff --git a/example/linux/flutter/generated_plugins.cmake b/example/linux/flutter/generated_plugins.cmake index 5a3c3882..93c755ee 100644 --- a/example/linux/flutter/generated_plugins.cmake +++ b/example/linux/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + file_selector_linux pasteboard url_launcher_linux ) diff --git a/example/macos/Flutter/GeneratedPluginRegistrant.swift b/example/macos/Flutter/GeneratedPluginRegistrant.swift index 85987338..a6432e6e 100644 --- a/example/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/example/macos/Flutter/GeneratedPluginRegistrant.swift @@ -6,12 +6,14 @@ import FlutterMacOS import Foundation import device_info_plus +import file_selector_macos import pasteboard import path_provider_foundation import url_launcher_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) + FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) PasteboardPlugin.register(with: registry.registrar(forPlugin: "PasteboardPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) diff --git a/example/pubspec.yaml b/example/pubspec.yaml index caaa4ab9..33b1e268 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -87,6 +87,9 @@ flutter: - family: roboto-mono fonts: - asset: assets/fonts/RobotoMono-Regular.ttf + - family: SF-UI-Display + fonts: + - asset: assets/fonts/SF-Pro-Display-Regular.otf # An image asset can refer to one or more resolution-specific "variants", see # https://flutter.dev/assets-and-images/#resolution-aware. diff --git a/example/windows/flutter/generated_plugin_registrant.cc b/example/windows/flutter/generated_plugin_registrant.cc index 02129f58..7d98a882 100644 --- a/example/windows/flutter/generated_plugin_registrant.cc +++ b/example/windows/flutter/generated_plugin_registrant.cc @@ -6,10 +6,13 @@ #include "generated_plugin_registrant.h" +#include #include #include void RegisterPlugins(flutter::PluginRegistry* registry) { + FileSelectorWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FileSelectorWindows")); PasteboardPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("PasteboardPlugin")); UrlLauncherWindowsRegisterWithRegistrar( diff --git a/example/windows/flutter/generated_plugins.cmake b/example/windows/flutter/generated_plugins.cmake index 82731c9d..722afbe7 100644 --- a/example/windows/flutter/generated_plugins.cmake +++ b/example/windows/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + file_selector_windows pasteboard url_launcher_windows ) diff --git a/flutter_quill_extensions/CHANGELOG.md b/flutter_quill_extensions/CHANGELOG.md index 176876c7..568cbe1d 100644 --- a/flutter_quill_extensions/CHANGELOG.md +++ b/flutter_quill_extensions/CHANGELOG.md @@ -1,3 +1,10 @@ +## 0.4.0 +- Fix backspace around images [PR #1309](https://github.com/singerdmx/flutter-quill/pull/1309) +- Feat/link regexp [PR #1329](https://github.com/singerdmx/flutter-quill/pull/1329) + +## 0.3.4 +* Resolve deprecated method use in the `video_player` package + ## 0.3.3 * Fix a prototype bug which was bring by [PR #1230](https://github.com/singerdmx/flutter-quill/pull/1230#issuecomment-1560597099) diff --git a/flutter_quill_extensions/lib/embeds/builders.dart b/flutter_quill_extensions/lib/embeds/builders.dart index b16b5845..6b392b8e 100644 --- a/flutter_quill_extensions/lib/embeds/builders.dart +++ b/flutter_quill_extensions/lib/embeds/builders.dart @@ -21,6 +21,9 @@ class ImageEmbedBuilder extends EmbedBuilder { @override String get key => BlockEmbed.imageType; + @override + bool get expanded => false; + @override Widget build( BuildContext context, diff --git a/flutter_quill_extensions/lib/embeds/toolbar/image_button.dart b/flutter_quill_extensions/lib/embeds/toolbar/image_button.dart index d05d851d..dffbb387 100644 --- a/flutter_quill_extensions/lib/embeds/toolbar/image_button.dart +++ b/flutter_quill_extensions/lib/embeds/toolbar/image_button.dart @@ -18,6 +18,7 @@ class ImageButton extends StatelessWidget { this.iconTheme, this.dialogTheme, this.tooltip, + this.linkRegExp, Key? key, }) : super(key: key); @@ -40,6 +41,7 @@ class ImageButton extends StatelessWidget { final QuillDialogTheme? dialogTheme; final String? tooltip; + final RegExp? linkRegExp; @override Widget build(BuildContext context) { @@ -90,7 +92,10 @@ class ImageButton extends StatelessWidget { void _typeLink(BuildContext context) { showDialog( context: context, - builder: (_) => LinkDialog(dialogTheme: dialogTheme), + builder: (_) => LinkDialog( + dialogTheme: dialogTheme, + linkRegExp: linkRegExp, + ), ).then(_linkSubmitted); } diff --git a/flutter_quill_extensions/lib/embeds/toolbar/image_video_utils.dart b/flutter_quill_extensions/lib/embeds/toolbar/image_video_utils.dart index aef7c9e7..77b1eb60 100644 --- a/flutter_quill_extensions/lib/embeds/toolbar/image_video_utils.dart +++ b/flutter_quill_extensions/lib/embeds/toolbar/image_video_utils.dart @@ -10,10 +10,16 @@ import 'package:image_picker/image_picker.dart'; import '../embed_types.dart'; class LinkDialog extends StatefulWidget { - const LinkDialog({this.dialogTheme, this.link, Key? key}) : super(key: key); + const LinkDialog({ + this.dialogTheme, + this.link, + this.linkRegExp, + Key? key, + }) : super(key: key); final QuillDialogTheme? dialogTheme; final String? link; + final RegExp? linkRegExp; @override LinkDialogState createState() => LinkDialogState(); @@ -22,12 +28,14 @@ class LinkDialog extends StatefulWidget { class LinkDialogState extends State { late String _link; late TextEditingController _controller; + late RegExp _linkRegExp; @override void initState() { super.initState(); _link = widget.link ?? ''; _controller = TextEditingController(text: _link); + _linkRegExp = widget.linkRegExp ?? AutoFormatMultipleLinksRule.linkRegExp; } @override @@ -48,8 +56,7 @@ class LinkDialogState extends State { ), actions: [ TextButton( - onPressed: _link.isNotEmpty && - AutoFormatMultipleLinksRule.linkRegExp.hasMatch(_link) + onPressed: _link.isNotEmpty && _linkRegExp.hasMatch(_link) ? _applyLink : null, child: Text( diff --git a/flutter_quill_extensions/lib/embeds/toolbar/video_button.dart b/flutter_quill_extensions/lib/embeds/toolbar/video_button.dart index e5c1ab73..eb31f28d 100644 --- a/flutter_quill_extensions/lib/embeds/toolbar/video_button.dart +++ b/flutter_quill_extensions/lib/embeds/toolbar/video_button.dart @@ -18,6 +18,7 @@ class VideoButton extends StatelessWidget { this.iconTheme, this.dialogTheme, this.tooltip, + this.linkRegExp, Key? key, }) : super(key: key); @@ -39,8 +40,11 @@ class VideoButton extends StatelessWidget { final QuillIconTheme? iconTheme; final QuillDialogTheme? dialogTheme; + final String? tooltip; + final RegExp? linkRegExp; + @override Widget build(BuildContext context) { final theme = Theme.of(context); diff --git a/flutter_quill_extensions/lib/embeds/widgets/video_app.dart b/flutter_quill_extensions/lib/embeds/widgets/video_app.dart index c05fe600..eb0ba0f7 100644 --- a/flutter_quill_extensions/lib/embeds/widgets/video_app.dart +++ b/flutter_quill_extensions/lib/embeds/widgets/video_app.dart @@ -34,7 +34,7 @@ class _VideoAppState extends State { super.initState(); _controller = widget.videoUrl.startsWith('http') - ? VideoPlayerController.network(widget.videoUrl) + ? VideoPlayerController.networkUrl(Uri.parse(widget.videoUrl)) : VideoPlayerController.file(File(widget.videoUrl)) ..initialize().then((_) { // Ensure the first frame is shown after the video is initialized, diff --git a/flutter_quill_extensions/lib/flutter_quill_extensions.dart b/flutter_quill_extensions/lib/flutter_quill_extensions.dart index fdbc54b2..a1945ab7 100644 --- a/flutter_quill_extensions/lib/flutter_quill_extensions.dart +++ b/flutter_quill_extensions/lib/flutter_quill_extensions.dart @@ -49,6 +49,8 @@ class FlutterQuillEmbeds { FilePickImpl? filePickImpl, WebImagePickImpl? webImagePickImpl, WebVideoPickImpl? webVideoPickImpl, + RegExp? imageLinkRegExp, + RegExp? videoLinkRegExp, }) => [ if (showImageButton) @@ -63,6 +65,7 @@ class FlutterQuillEmbeds { mediaPickSettingSelector: mediaPickSettingSelector, iconTheme: iconTheme, dialogTheme: dialogTheme, + linkRegExp: imageLinkRegExp, ), if (showVideoButton) (controller, toolbarIconSize, iconTheme, dialogTheme) => VideoButton( @@ -76,7 +79,8 @@ class FlutterQuillEmbeds { mediaPickSettingSelector: mediaPickSettingSelector, iconTheme: iconTheme, dialogTheme: dialogTheme, - ), + linkRegExp: videoLinkRegExp, + ), if ((onImagePickCallback != null || onVideoPickCallback != null) && showCameraButton) (controller, toolbarIconSize, iconTheme, dialogTheme) => CameraButton( diff --git a/flutter_quill_extensions/pubspec.yaml b/flutter_quill_extensions/pubspec.yaml index f1b77e8c..0e465c73 100644 --- a/flutter_quill_extensions/pubspec.yaml +++ b/flutter_quill_extensions/pubspec.yaml @@ -1,6 +1,6 @@ name: flutter_quill_extensions description: Embed extensions for flutter_quill including image, video, formula and etc. -version: 0.3.3 +version: 0.4.0 homepage: https://bulletjournal.us/home/index.html repository: https://github.com/singerdmx/flutter-quill/tree/master/flutter_quill_extensions @@ -12,14 +12,14 @@ dependencies: flutter: sdk: flutter - flutter_quill: ^7.2.1 + flutter_quill: ^7.2.19 image_picker: ^0.8.5+3 photo_view: ^0.14.0 - video_player: ^2.4.2 + video_player: ^2.7.0 youtube_player_flutter: ^8.1.1 gallery_saver: ^2.3.2 - math_keyboard: ^0.2.0 + math_keyboard: ">=0.1.8 <0.3.0" string_validator: ^1.0.0 universal_html: ^2.2.1 url_launcher: ^6.1.9 diff --git a/lib/flutter_quill.dart b/lib/flutter_quill.dart index bfc666f1..22a0a20f 100644 --- a/lib/flutter_quill.dart +++ b/lib/flutter_quill.dart @@ -11,6 +11,7 @@ export 'src/models/documents/style.dart'; export 'src/models/quill_delta.dart'; export 'src/models/structs/doc_change.dart'; export 'src/models/structs/image_url.dart'; +export 'src/models/structs/link_dialog_action.dart'; export 'src/models/structs/offset_value.dart'; export 'src/models/structs/optional_size.dart'; export 'src/models/structs/vertical_spacing.dart'; diff --git a/lib/flutter_quill_test.dart b/lib/flutter_quill_test.dart new file mode 100644 index 00000000..988e4e82 --- /dev/null +++ b/lib/flutter_quill_test.dart @@ -0,0 +1,3 @@ +library flutter_quill_test; + +export 'src/test/widget_tester_extension.dart'; diff --git a/lib/quill_delta.dart b/lib/quill_delta.dart new file mode 100644 index 00000000..8eb6b1a6 --- /dev/null +++ b/lib/quill_delta.dart @@ -0,0 +1,3 @@ +library flutter_quill.delta; + +export 'src/models/quill_delta.dart'; diff --git a/lib/src/models/documents/document.dart b/lib/src/models/documents/document.dart index 597d9025..b62299d1 100644 --- a/lib/src/models/documents/document.dart +++ b/lib/src/models/documents/document.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import '../../widgets/embeds.dart'; import '../quill_delta.dart'; import '../rules/rule.dart'; import '../structs/doc_change.dart'; @@ -158,10 +159,11 @@ class Document { return (res.node as Line).collectStyle(res.offset, len); } - /// Returns all styles for each node within selection - List> collectAllIndividualStyles(int index, int len) { + /// Returns all styles and Embed for each node within selection + List collectAllIndividualStyleAndEmbed(int index, int len) { final res = queryChild(index); - return (res.node as Line).collectAllIndividualStyles(res.offset, len); + return (res.node as Line) + .collectAllIndividualStylesAndEmbed(res.offset, len); } /// Returns all styles for any character within the specified text range. @@ -193,16 +195,21 @@ class Document { return block.queryChild(res.offset, true); } - /// Search the whole document for any substring matching the pattern - /// Returns the offsets that matches the pattern - List search(Pattern other) { + /// Search given [substring] in the whole document + /// Supports [caseSensitive] and [wholeWord] options + /// Returns correspondent offsets + List search( + String substring, { + bool caseSensitive = false, + bool wholeWord = false, + }) { final matches = []; for (final node in _root.children) { if (node is Line) { - _searchLine(other, node, matches); + _searchLine(substring, caseSensitive, wholeWord, node, matches); } else if (node is Block) { for (final line in Iterable.castFrom(node.children)) { - _searchLine(other, line, matches); + _searchLine(substring, caseSensitive, wholeWord, line, matches); } } else { throw StateError('Unreachable.'); @@ -211,10 +218,22 @@ class Document { return matches; } - void _searchLine(Pattern other, Line line, List matches) { + void _searchLine( + String substring, + bool caseSensitive, + bool wholeWord, + Line line, + List matches, + ) { var index = -1; + final lineText = line.toPlainText(); + var pattern = RegExp.escape(substring); + if (wholeWord) { + pattern = r'\b' + pattern + r'\b'; + } + final searchExpression = RegExp(pattern, caseSensitive: caseSensitive); while (true) { - index = line.toPlainText().indexOf(other, index + 1); + index = lineText.indexOf(searchExpression, index + 1); if (index < 0) { break; } @@ -349,7 +368,13 @@ class Document { } /// Returns plain text representation of this document. - String toPlainText() => _root.children.map((e) => e.toPlainText()).join(); + String toPlainText([ + Iterable? embedBuilders, + EmbedBuilder? unknownEmbedBuilder, + ]) => + _root.children + .map((e) => e.toPlainText(embedBuilders, unknownEmbedBuilder)) + .join(); void _loadDocument(Delta doc) { if (doc.isEmpty) { diff --git a/lib/src/models/documents/nodes/container.dart b/lib/src/models/documents/nodes/container.dart index 031e064f..f868f43d 100644 --- a/lib/src/models/documents/nodes/container.dart +++ b/lib/src/models/documents/nodes/container.dart @@ -1,5 +1,6 @@ import 'dart:collection'; +import '../../../widgets/embeds.dart'; import '../style.dart'; import 'leaf.dart'; import 'line.dart'; @@ -103,7 +104,13 @@ abstract class Container extends Node { } @override - String toPlainText() => children.map((child) => child.toPlainText()).join(); + String toPlainText([ + Iterable? embedBuilders, + EmbedBuilder? unknownEmbedBuilder, + ]) => + children + .map((e) => e.toPlainText(embedBuilders, unknownEmbedBuilder)) + .join(); /// Content length of this node's children. /// diff --git a/lib/src/models/documents/nodes/leaf.dart b/lib/src/models/documents/nodes/leaf.dart index 36f77152..eb9cae29 100644 --- a/lib/src/models/documents/nodes/leaf.dart +++ b/lib/src/models/documents/nodes/leaf.dart @@ -1,5 +1,6 @@ import 'dart:math' as math; +import '../../../widgets/embeds.dart'; import '../../quill_delta.dart'; import '../style.dart'; import 'embeddable.dart'; @@ -224,7 +225,11 @@ class Text extends Leaf { String get value => _value as String; @override - String toPlainText() => value; + String toPlainText([ + Iterable? embedBuilders, + EmbedBuilder? unknownEmbedBuilder, + ]) => + value; } /// An embed node inside of a line in a Quill document. @@ -257,7 +262,26 @@ class Embed extends Leaf { // Embed nodes are represented as unicode object replacement character in // plain text. @override - String toPlainText() => kObjectReplacementCharacter; + String toPlainText([ + Iterable? embedBuilders, + EmbedBuilder? unknownEmbedBuilder, + ]) { + final builders = embedBuilders; + + if (builders != null) { + for (final builder in builders) { + if (builder.key == value.type) { + return builder.toPlainText(this); + } + } + } + + if (unknownEmbedBuilder != null) { + return unknownEmbedBuilder.toPlainText(this); + } + + return Embed.kObjectReplacementCharacter; + } @override String toString() => '${super.toString()} ${value.type}'; diff --git a/lib/src/models/documents/nodes/line.dart b/lib/src/models/documents/nodes/line.dart index 42306edb..09380853 100644 --- a/lib/src/models/documents/nodes/line.dart +++ b/lib/src/models/documents/nodes/line.dart @@ -2,6 +2,7 @@ import 'dart:math' as math; import 'package:collection/collection.dart'; +import '../../../widgets/embeds.dart'; import '../../quill_delta.dart'; import '../../structs/offset_value.dart'; import '../attribute.dart'; @@ -65,7 +66,11 @@ class Line extends Container { } @override - String toPlainText() => '${super.toPlainText()}\n'; + String toPlainText([ + Iterable? embedBuilders, + EmbedBuilder? unknownEmbedBuilder, + ]) => + '${super.toPlainText(embedBuilders, unknownEmbedBuilder)}\n'; @override String toString() { @@ -390,35 +395,42 @@ class Line extends Container { } /// Returns each node segment's offset in selection - /// with its corresponding style as a list - List> collectAllIndividualStyles(int offset, int len, + /// with its corresponding style or embed as a list + List collectAllIndividualStylesAndEmbed(int offset, int len, {int beg = 0}) { final local = math.min(length - offset, len); - final result = >[]; + final result = []; final data = queryChild(offset, true); var node = data.node as Leaf?; if (node != null) { var pos = 0; - if (node is Text) { - pos = node.length - data.offset; - result.add(OffsetValue(beg, node.style)); + pos = node.length - data.offset; + if (node is Text && node.style.isNotEmpty) { + result.add(OffsetValue(beg, node.style, node.length)); + } else if (node.value is Embeddable) { + result.add(OffsetValue(beg, node.value as Embeddable, node.length)); } while (!node!.isLast && pos < local) { node = node.next as Leaf; - if (node is Text) { - result.add(OffsetValue(pos + beg, node.style)); - pos += node.length; + if (node is Text && node.style.isNotEmpty) { + result.add(OffsetValue(pos + beg, node.style, node.length)); + } else if (node.value is Embeddable) { + result.add( + OffsetValue(pos + beg, node.value as Embeddable, node.length)); } + pos += node.length; } - } - // TODO: add line style and parent's block style + if (style.isNotEmpty) { + result.add(OffsetValue(beg, style, pos)); + } + } final remaining = len - local; if (remaining > 0 && nextLine != null) { - final rest = - nextLine!.collectAllIndividualStyles(0, remaining, beg: local); + final rest = nextLine! + .collectAllIndividualStylesAndEmbed(0, remaining, beg: local + beg); result.addAll(rest); } @@ -516,7 +528,7 @@ class Line extends Container { int _getPlainText(int offset, int len, StringBuffer plainText) { var _len = len; - final data = queryChild(offset, true); + final data = queryChild(offset, false); var node = data.node as Leaf?; while (_len > 0) { diff --git a/lib/src/models/documents/nodes/node.dart b/lib/src/models/documents/nodes/node.dart index 9fbbbf15..6a057a90 100644 --- a/lib/src/models/documents/nodes/node.dart +++ b/lib/src/models/documents/nodes/node.dart @@ -1,5 +1,6 @@ import 'dart:collection'; +import '../../../widgets/embeds.dart'; import '../../quill_delta.dart'; import '../attribute.dart'; import '../style.dart'; @@ -109,7 +110,10 @@ abstract class Node extends LinkedListEntry { Node newInstance(); - String toPlainText(); + String toPlainText([ + Iterable? embedBuilders, + EmbedBuilder? unknownEmbedBuilder, + ]); Delta toDelta(); diff --git a/lib/src/models/documents/style.dart b/lib/src/models/documents/style.dart index 9ade3186..58469a2a 100644 --- a/lib/src/models/documents/style.dart +++ b/lib/src/models/documents/style.dart @@ -42,6 +42,9 @@ class Style { bool get isInline => isNotEmpty && values.every((item) => item.isInline); + bool get isBlock => + isNotEmpty && values.every((item) => item.scope == AttributeScope.BLOCK); + bool get isIgnored => isNotEmpty && values.every((item) => item.scope == AttributeScope.IGNORE); diff --git a/lib/src/models/rules/delete.dart b/lib/src/models/rules/delete.dart index caf8cf6a..fd547825 100644 --- a/lib/src/models/rules/delete.dart +++ b/lib/src/models/rules/delete.dart @@ -1,4 +1,5 @@ import '../documents/attribute.dart'; +import '../documents/nodes/embeddable.dart'; import '../quill_delta.dart'; import 'rule.dart'; @@ -109,6 +110,8 @@ class PreserveLineStyleOnMergeRule extends DeleteRule { } /// Prevents user from merging a line containing an embed with other lines. +/// This rule applies to video, not image. +/// The rule relates to [InsertEmbedsRule]. class EnsureEmbedLineRule extends DeleteRule { const EnsureEmbedLineRule(); @@ -118,6 +121,13 @@ class EnsureEmbedLineRule extends DeleteRule { final itr = DeltaIterator(document); var op = itr.skip(index); + final opAfter = itr.skip(index + 1); + + // Only video embed occupies a whole line. + if (!_isVideo(op) || !_isVideo(opAfter)) { + return null; + } + int? indexDelta = 0, lengthDelta = 0, remain = len; var embedFound = op != null && op.data is! String; final hasLineBreakBefore = @@ -157,4 +167,10 @@ class EnsureEmbedLineRule extends DeleteRule { ..retain(index + indexDelta) ..delete(len! + lengthDelta); } + + bool _isVideo(op) { + return op != null && + op.data is! String && + !(op.data as Map).containsKey(BlockEmbed.videoType); + } } diff --git a/lib/src/models/rules/insert.dart b/lib/src/models/rules/insert.dart index 44124fbc..b8297411 100644 --- a/lib/src/models/rules/insert.dart +++ b/lib/src/models/rules/insert.dart @@ -329,8 +329,7 @@ class AutoFormatMultipleLinksRule extends InsertRule { // http://www.example.com/?action=birds&brass=apparatus // https://example.net/ // URL generator tool (https://www.randomlists.com/urls) is used. - static const _linkPattern = - r'(https?:\/\/|www\.)[\w-\.]+\.[\w-\.]+(\/([\S]+)?)?'; + static const _linkPattern = r'^https?:\/\/[\w\-]+(\.[\w\-]+)*(:\d+)?(\/.*)?$'; static final linkRegExp = RegExp(_linkPattern, caseSensitive: false); @override diff --git a/lib/src/models/structs/link_dialog_action.dart b/lib/src/models/structs/link_dialog_action.dart new file mode 100644 index 00000000..06288c9f --- /dev/null +++ b/lib/src/models/structs/link_dialog_action.dart @@ -0,0 +1,7 @@ +import 'package:flutter/material.dart'; + +class LinkDialogAction { + LinkDialogAction({required this.builder}); + + Widget Function(bool canPress, void Function() applyLink) builder; +} diff --git a/lib/src/models/themes/quill_custom_button.dart b/lib/src/models/themes/quill_custom_button.dart index 791b7095..6f5b5365 100644 --- a/lib/src/models/themes/quill_custom_button.dart +++ b/lib/src/models/themes/quill_custom_button.dart @@ -6,6 +6,7 @@ class QuillCustomButton { this.iconColor, this.onTap, this.tooltip, + this.child, }); ///The icon widget @@ -13,10 +14,13 @@ class QuillCustomButton { ///The icon color; final Color? iconColor; - + ///The function when the icon is tapped final VoidCallback? onTap; + ///The customButton placeholder + final Widget? child; + /// The button tooltip. final String? tooltip; } diff --git a/lib/src/models/themes/quill_dialog_theme.dart b/lib/src/models/themes/quill_dialog_theme.dart index 1552c5f5..3664dc3e 100644 --- a/lib/src/models/themes/quill_dialog_theme.dart +++ b/lib/src/models/themes/quill_dialog_theme.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; /// Used to configure the dialog's look and feel. class QuillDialogTheme with Diagnosticable { const QuillDialogTheme({ + this.buttonTextStyle, this.labelTextStyle, this.inputTextStyle, this.dialogBackgroundColor, @@ -17,6 +18,9 @@ class QuillDialogTheme with Diagnosticable { this.runSpacing = 8.0, }) : assert(runSpacing >= 0); + ///The text style to use for the button shown in the dialog + final TextStyle? buttonTextStyle; + ///The text style to use for the label shown in the link-input dialog final TextStyle? labelTextStyle; @@ -59,6 +63,7 @@ class QuillDialogTheme with Diagnosticable { final double runSpacing; QuillDialogTheme copyWith({ + TextStyle? buttonTextStyle, TextStyle? labelTextStyle, TextStyle? inputTextStyle, Color? dialogBackgroundColor, @@ -72,6 +77,7 @@ class QuillDialogTheme with Diagnosticable { double? runSpacing, }) { return QuillDialogTheme( + buttonTextStyle: buttonTextStyle ?? this.buttonTextStyle, labelTextStyle: labelTextStyle ?? this.labelTextStyle, inputTextStyle: inputTextStyle ?? this.inputTextStyle, dialogBackgroundColor: @@ -96,6 +102,7 @@ class QuillDialogTheme with Diagnosticable { return false; } return other is QuillDialogTheme && + other.buttonTextStyle == buttonTextStyle && other.labelTextStyle == labelTextStyle && other.inputTextStyle == inputTextStyle && other.dialogBackgroundColor == dialogBackgroundColor && @@ -112,6 +119,7 @@ class QuillDialogTheme with Diagnosticable { @override int get hashCode => Object.hash( + buttonTextStyle, labelTextStyle, inputTextStyle, dialogBackgroundColor, diff --git a/lib/src/test/widget_tester_extension.dart b/lib/src/test/widget_tester_extension.dart new file mode 100644 index 00000000..21bb75ab --- /dev/null +++ b/lib/src/test/widget_tester_extension.dart @@ -0,0 +1,60 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../widgets/editor.dart'; +import '../widgets/raw_editor.dart'; + +/// Extends +extension QuillEnterText on WidgetTester { + /// Give the QuillEditor widget specified by [finder] the focus. + Future quillGiveFocus(Finder finder) { + return TestAsyncUtils.guard(() async { + final editor = state( + find.descendant( + of: finder, + matching: + find.byType(QuillEditor, skipOffstage: finder.skipOffstage), + matchRoot: true), + ); + editor.widget.focusNode.requestFocus(); + await pump(); + expect(editor.widget.focusNode.hasFocus, isTrue); + }); + } + + /// Give the QuillEditor widget specified by [finder] the focus and update its + /// editing value with [text], as if it had been provided by the onscreen + /// keyboard. + /// + /// The widget specified by [finder] must be a [QuillEditor] or have a + /// [QuillEditor] descendant. For example `find.byType(QuillEditor)`. + Future quillEnterText(Finder finder, String text) async { + return TestAsyncUtils.guard(() async { + await quillGiveFocus(finder); + await quillUpdateEditingValue(finder, text); + await idle(); + }); + } + + /// Update the text editing value of the QuillEditor widget specified by + /// [finder] with [text], as if it had been provided by the onscreen keyboard. + /// + /// The widget specified by [finder] must already have focus and be a + /// [QuillEditor] or have a [QuillEditor] descendant. For example + /// `find.byType(QuillEditor)`. + Future quillUpdateEditingValue(Finder finder, String text) async { + return TestAsyncUtils.guard(() async { + final editor = state( + find.descendant( + of: finder, + matching: find.byType(RawEditor, skipOffstage: finder.skipOffstage), + matchRoot: true), + ); + testTextInput.updateEditingValue(TextEditingValue( + text: text, + selection: TextSelection.collapsed( + offset: editor.textEditingValue.text.length))); + await idle(); + }); + } +} diff --git a/lib/src/translations/toolbar.i18n.dart b/lib/src/translations/toolbar.i18n.dart index 7fc356e9..21198a45 100644 --- a/lib/src/translations/toolbar.i18n.dart +++ b/lib/src/translations/toolbar.i18n.dart @@ -71,6 +71,9 @@ extension Localization on String { 'Hex': 'Hex', 'Material': 'Material', 'Color': 'Color', + 'Find text': 'Find text', + 'Move to previous occurrence': 'Move to previous occurrence', + 'Move to next occurrence': 'Move to next occurrence', }, 'en_us': { 'Paste a link': 'Paste a link', @@ -140,6 +143,9 @@ extension Localization on String { 'Hex': 'Hex', 'Material': 'Material', 'Color': 'Color', + 'Find text': 'Find text', + 'Move to previous occurrence': 'Move to previous occurrence', + 'Move to next occurrence': 'Move to next occurrence', }, 'ar': { 'Paste a link': 'نسخ الرابط', @@ -211,6 +217,9 @@ extension Localization on String { 'Hex': 'Hex', 'Material': 'Material', 'Color': 'اللون', + 'Find text': 'Find text', + 'Move to previous occurrence': 'Move to previous occurrence', + 'Move to next occurrence': 'Move to next occurrence', }, 'da': { 'Paste a link': 'Indsæt link', @@ -277,6 +286,9 @@ extension Localization on String { 'Enter media': 'Enter media', 'Edit': 'Edit', 'Apply': 'Apply', + 'Find text': 'Find text', + 'Move to previous occurrence': 'Move to previous occurrence', + 'Move to next occurrence': 'Move to next occurrence', }, 'de': { 'Paste a link': 'Link hinzufügen', @@ -344,6 +356,9 @@ extension Localization on String { 'Enter media': 'Enter media', 'Edit': 'Edit', 'Apply': 'Apply', + 'Find text': 'Find text', + 'Move to previous occurrence': 'Move to previous occurrence', + 'Move to next occurrence': 'Move to next occurrence', }, 'fr': { 'Paste a link': 'Coller un lien', @@ -353,13 +368,13 @@ extension Localization on String { 'Link': 'Lien', 'Please first select some text to transform into a link.': "Veuillez d'abord sélectionner un texte à transformer en lien.", - 'Open': 'Ouverte', + 'Open': 'Ouvrir', 'Copy': 'Copier', 'Remove': 'Supprimer', 'Save': 'Sauvegarder', - 'Zoom': 'Zoom', + 'Zoom': 'Zoomer', 'Saved': 'Enregistrée', - 'Text': 'Text', + 'Text': 'Texte', 'What is entered is not a link': "Ce qui est saisi n'est pas un lien", 'Resize': 'Redimensionner', 'Width': 'Largeur', @@ -377,39 +392,42 @@ extension Localization on String { 'Next': 'Suivant', 'Camera': 'Caméra', 'Video': 'Vidéo', - 'Undo': 'Undo', - 'Redo': 'Redo', - 'Font family': 'Font family', - 'Font size': 'Font size', - 'Bold': 'Bold', - 'Subscript': 'Subscript', - 'Superscript': 'Superscript', - 'Italic': 'Italic', - 'Underline': 'Underline', - 'Strike through': 'Strike through', - 'Inline code': 'Inline code', - 'Font color': 'Font color', - 'Background color': 'Background color', - 'Clear format': 'Clear format', - 'Align left': 'Align left', - 'Align center': 'Align center', - 'Align right': 'Align right', - 'Justify win width': 'Justify win width', - 'Text direction': 'Text direction', - 'Header style': 'Header style', - 'Numbered list': 'Numbered list', - 'Bullet list': 'Bullet list', - 'Checked list': 'Checked list', - 'Code block': 'Code block', - 'Quote': 'Quote', - 'Increase indent': 'Increase indent', - 'Decrease indent': 'Decrease indent', - 'Insert URL': 'Insert URL', - 'Visit link': 'Visit link', - 'Enter link': 'Enter link', - 'Enter media': 'Enter media', - 'Edit': 'Edit', - 'Apply': 'Apply', + 'Undo': 'Annuler', + 'Redo': 'Refaire', + 'Font family': 'Famille de police', + 'Font size': 'Taille de police', + 'Bold': 'Gras', + 'Subscript': 'Indice', + 'Superscript': 'Exposant', + 'Italic': 'Italique', + 'Underline': 'Souligné', + 'Strike through': 'Barré', + 'Inline code': 'Code en ligne', + 'Font color': 'Couleur de police', + 'Background color': 'Couleur de fond', + 'Clear format': 'Effacer la mise en forme', + 'Align left': 'Aligner à gauche', + 'Align center': 'Aligner au centre', + 'Align right': 'Aligner à droite', + 'Justify win width': 'Justifier', + 'Text direction': 'Direction du texte', + 'Header style': "Style d'en-tête", + 'Numbered list': 'Liste numérotée', + 'Bullet list': 'Liste à puces', + 'Checked list': 'Check-list', + 'Code block': 'Bloc de code', + 'Quote': 'Citation', + 'Increase indent': 'Augmenter le retrait', + 'Decrease indent': 'Diminuer le retrait', + 'Insert URL': 'Insérer une URL', + 'Visit link': 'Visiter', + 'Enter link': 'Entrer un lien', + 'Enter media': 'Entrer un média', + 'Edit': 'Modifier', + 'Apply': 'Appliquer', + 'Find text': 'Find text', + 'Move to previous occurrence': 'Move to previous occurrence', + 'Move to next occurrence': 'Move to next occurrence', }, 'zh_cn': { 'Paste a link': '粘贴链接', @@ -476,6 +494,9 @@ extension Localization on String { 'Enter media': '输入媒体', 'Edit': '编辑', 'Apply': '应用', + 'Find text': 'Find text', + 'Move to previous occurrence': 'Move to previous occurrence', + 'Move to next occurrence': 'Move to next occurrence', }, 'zh_hk': { 'Paste a link': '貼上連結', @@ -542,8 +563,11 @@ extension Localization on String { 'Enter media': '輸入媒體', 'Edit': '編輯', 'Apply': '應用', + 'Find text': 'Find text', + 'Move to previous occurrence': 'Move to previous occurrence', + 'Move to next occurrence': 'Move to next occurrence', }, - 'jp': { + 'ja': { 'Paste a link': 'リンクをペースト', 'Ok': '完了', 'Select Color': '色を選択', @@ -608,6 +632,9 @@ extension Localization on String { 'Enter media': 'ミディアムを輸入', 'Edit': '編集', 'Apply': '応用', + 'Find text': 'Find text', + 'Move to previous occurrence': 'Move to previous occurrence', + 'Move to next occurrence': 'Move to next occurrence', }, 'ko': { 'Paste a link': '링크를 붙여넣어 주세요.', @@ -674,6 +701,9 @@ extension Localization on String { 'Enter media': 'Enter media', 'Edit': 'Edit', 'Apply': 'Apply', + 'Find text': 'Find text', + 'Move to previous occurrence': 'Move to previous occurrence', + 'Move to next occurrence': 'Move to next occurrence', }, 'ru': { 'Paste a link': 'Вставить ссылку', @@ -740,6 +770,9 @@ extension Localization on String { 'Enter media': 'Enter media', 'Edit': 'Edit', 'Apply': 'Apply', + 'Find text': 'Find text', + 'Move to previous occurrence': 'Move to previous occurrence', + 'Move to next occurrence': 'Move to next occurrence', }, 'es': { 'Paste a link': 'Pega un enlace', @@ -807,6 +840,9 @@ extension Localization on String { 'Enter media': 'Enter media', 'Edit': 'Edit', 'Apply': 'Apply', + 'Find text': 'Find text', + 'Move to previous occurrence': 'Move to previous occurrence', + 'Move to next occurrence': 'Move to next occurrence', }, 'tr': { 'Paste a link': 'Bağlantıyı Yapıştır', @@ -817,14 +853,14 @@ extension Localization on String { 'Please first select some text to transform into a link.': 'Lütfen bağlantıya dönüştürmek için bir metin seçin.', 'Open': 'Açık', - 'Copy': 'kopyala', - 'Remove': 'Kaldırmak', - 'Save': 'Kayıt etmek', - 'Zoom': 'yakınlaştır', - 'Saved': 'kaydedildi', + 'Copy': 'Kopyala', + 'Remove': 'Kaldır', + 'Save': 'Kayıt Et', + 'Zoom': 'Yakınlaştır', + 'Saved': 'Kaydedildi', 'Text': 'Text', - 'What is entered is not a link': 'What is entered is not a link', - 'Resize': 'Resize', + 'What is entered is not a link': 'Girilen bir bağlantı değil.', + 'Resize': 'Yeniden Boyutlandır', 'Width': 'Genişlik', 'Height': 'Yükseklik', 'Size': 'Boyut', @@ -834,45 +870,48 @@ extension Localization on String { 'Clear': 'Temizle', 'Font': 'Yazı tipi', 'Search': 'Ara', - 'matches': 'matches', - 'showing match': 'showing match', - 'Prev': 'Prev', - 'Next': 'Devam', + 'matches': 'Eşleşmeler', + 'showing match': 'Eşleşmeyi Göster', + 'Prev': 'Önceki', + 'Next': 'Sonraki', 'Camera': 'Kamera', 'Video': 'Video', - 'Undo': 'Undo', - 'Redo': 'Redo', - 'Font family': 'Font family', - 'Font size': 'Font size', - 'Bold': 'Bold', - 'Subscript': 'Subscript', - 'Superscript': 'Superscript', - 'Italic': 'Italic', - 'Underline': 'Underline', - 'Strike through': 'Strike through', + 'Undo': 'Geri', + 'Redo': 'İleri', + 'Font family': 'Yazı Türü', + 'Font size': 'Yazı Boyutu', + 'Bold': 'Kalın', + 'Subscript': 'Alt Simge', + 'Superscript': 'Üst Simge', + 'Italic': 'İtalik', + 'Underline': 'Altı Çizili', + 'Strike through': 'Üsti Çizili', 'Inline code': 'Inline code', - 'Font color': 'Font color', - 'Background color': 'Background color', - 'Clear format': 'Clear format', - 'Align left': 'Align left', - 'Align center': 'Align center', - 'Align right': 'Align right', - 'Justify win width': 'Justify win width', - 'Text direction': 'Text direction', - 'Header style': 'Header style', - 'Numbered list': 'Numbered list', - 'Bullet list': 'Bullet list', - 'Checked list': 'Checked list', - 'Code block': 'Code block', - 'Quote': 'Quote', - 'Increase indent': 'Increase indent', - 'Decrease indent': 'Decrease indent', - 'Insert URL': 'Insert URL', - 'Visit link': 'Visit link', - 'Enter link': 'Enter link', - 'Enter media': 'Enter media', - 'Edit': 'Edit', - 'Apply': 'Apply', + 'Font color': 'Yazı Rengi', + 'Background color': 'Vurgu Rengi', + 'Clear format': 'Formatı Temizle', + 'Align left': 'Sola Hizala', + 'Align center': 'Ortaya Hizala', + 'Align right': 'Sağa Hizala', + 'Justify win width': 'Kenarlara Hizala', + 'Text direction': 'Metin Yönü', + 'Header style': 'Başlık Stili', + 'Numbered list': 'Numaralı Liste', + 'Bullet list': 'Madde Listesi', + 'Checked list': 'Kontrol Listesi', + 'Code block': 'Kod Blogu', + 'Quote': 'Alıntı', + 'Increase indent': 'Girintiyi Artır', + 'Decrease indent': 'Girintiyi Azalt ', + 'Insert URL': 'URL Giriniz', + 'Visit link': 'Bağlantıyı Ziyaret Et', + 'Enter link': 'Bağlantı Giriniz', + 'Enter media': 'Medya Giriniz', + 'Edit': 'Düzenle', + 'Apply': 'Uygula', + 'Find text': 'Find text', + 'Move to previous occurrence': 'Move to previous occurrence', + 'Move to next occurrence': 'Move to next occurrence', }, 'uk': { 'Paste a link': 'Вставити посилання', @@ -939,6 +978,9 @@ extension Localization on String { 'Enter media': 'Enter media', 'Edit': 'Edit', 'Apply': 'Apply', + 'Find text': 'Find text', + 'Move to previous occurrence': 'Move to previous occurrence', + 'Move to next occurrence': 'Move to next occurrence', }, 'pt': { 'Paste a link': 'Colar um link', @@ -1006,6 +1048,9 @@ extension Localization on String { 'Enter media': 'Enter media', 'Edit': 'Edit', 'Apply': 'Apply', + 'Find text': 'Find text', + 'Move to previous occurrence': 'Move to previous occurrence', + 'Move to next occurrence': 'Move to next occurrence', }, 'pt_br': { 'Paste a link': 'Colar um link', @@ -1073,6 +1118,9 @@ extension Localization on String { 'Enter media': 'Inserir mídia', 'Edit': 'Editar', 'Apply': 'Aplicar', + 'Find text': 'Find text', + 'Move to previous occurrence': 'Move to previous occurrence', + 'Move to next occurrence': 'Move to next occurrence', }, 'pl': { 'Paste a link': 'Wklej link', @@ -1140,6 +1188,9 @@ extension Localization on String { 'Enter media': 'Enter media', 'Edit': 'Edit', 'Apply': 'Apply', + 'Find text': 'Find text', + 'Move to previous occurrence': 'Move to previous occurrence', + 'Move to next occurrence': 'Move to next occurrence', }, 'vi': { 'Paste a link': 'Chèn liên kết', @@ -1207,6 +1258,9 @@ extension Localization on String { 'Enter media': 'Enter media', 'Edit': 'Edit', 'Apply': 'Apply', + 'Find text': 'Find text', + 'Move to previous occurrence': 'Move to previous occurrence', + 'Move to next occurrence': 'Move to next occurrence', }, 'ur': { 'Paste a link': 'لنک پیسٹ کریں', @@ -1273,6 +1327,9 @@ extension Localization on String { 'Enter media': 'Enter media', 'Edit': 'Edit', 'Apply': 'Apply', + 'Find text': 'Find text', + 'Move to previous occurrence': 'Move to previous occurrence', + 'Move to next occurrence': 'Move to next occurrence', }, 'id': { 'Paste a link': 'Tempel tautan', @@ -1339,6 +1396,9 @@ extension Localization on String { 'Enter media': 'Enter media', 'Edit': 'Edit', 'Apply': 'Apply', + 'Find text': 'Find text', + 'Move to previous occurrence': 'Move to previous occurrence', + 'Move to next occurrence': 'Move to next occurrence', }, 'no': { 'Paste a link': 'Lim inn lenke', @@ -1405,6 +1465,9 @@ extension Localization on String { 'Enter media': 'Enter media', 'Edit': 'Edit', 'Apply': 'Apply', + 'Find text': 'Find text', + 'Move to previous occurrence': 'Move to previous occurrence', + 'Move to next occurrence': 'Move to next occurrence', }, 'fa': { 'Paste a link': 'جایگذاری لینک', @@ -1471,6 +1534,9 @@ extension Localization on String { 'Enter media': 'Enter media', 'Edit': 'Edit', 'Apply': 'Apply', + 'Find text': 'Find text', + 'Move to previous occurrence': 'Move to previous occurrence', + 'Move to next occurrence': 'Move to next occurrence', }, 'hi': { 'Paste a link': 'लिंक पेस्ट करें', @@ -1537,6 +1603,9 @@ extension Localization on String { 'Enter media': 'Enter media', 'Edit': 'Edit', 'Apply': 'Apply', + 'Find text': 'Find text', + 'Move to previous occurrence': 'Move to previous occurrence', + 'Move to next occurrence': 'Move to next occurrence', }, 'nl': { 'Paste a link': 'Plak een link', @@ -1603,6 +1672,9 @@ extension Localization on String { 'Enter media': 'Enter media', 'Edit': 'Edit', 'Apply': 'Apply', + 'Find text': 'Find text', + 'Move to previous occurrence': 'Move to previous occurrence', + 'Move to next occurrence': 'Move to next occurrence', }, 'sr': { 'Paste a link': 'Nalepi vezu', @@ -1669,6 +1741,9 @@ extension Localization on String { 'Enter media': 'Enter media', 'Edit': 'Edit', 'Apply': 'Apply', + 'Find text': 'Find text', + 'Move to previous occurrence': 'Move to previous occurrence', + 'Move to next occurrence': 'Move to next occurrence', }, 'cs': { 'Paste a link': 'Vložte odkaz', @@ -1735,6 +1810,9 @@ extension Localization on String { 'Enter media': 'Enter media', 'Edit': 'Edit', 'Apply': 'Apply', + 'Find text': 'Find text', + 'Move to previous occurrence': 'Move to previous occurrence', + 'Move to next occurrence': 'Move to next occurrence', }, 'he': { 'Paste a link': 'הדבק את הלינק', @@ -1801,6 +1879,9 @@ extension Localization on String { 'Enter media': 'Enter media', 'Edit': 'Edit', 'Apply': 'Apply', + 'Find text': 'Find text', + 'Move to previous occurrence': 'Move to previous occurrence', + 'Move to next occurrence': 'Move to next occurrence', }, 'ms': { 'Paste a link': 'Tampal Pautan', @@ -1868,6 +1949,9 @@ extension Localization on String { 'Enter media': 'Enter media', 'Edit': 'Edit', 'Apply': 'Apply', + 'Find text': 'Find text', + 'Move to previous occurrence': 'Move to previous occurrence', + 'Move to next occurrence': 'Move to next occurrence', }, 'it': { 'Paste a link': 'Incolla un collegamento', @@ -1935,6 +2019,82 @@ extension Localization on String { 'Enter media': 'Enter media', 'Edit': 'Edit', 'Apply': 'Apply', + 'Find text': 'Find text', + 'Move to previous occurrence': 'Move to previous occurrence', + 'Move to next occurrence': 'Move to next occurrence', + }, + 'bn': { + 'Paste a link': 'লিঙ্ক পেস্ট করুন', + 'Ok': 'ওকে', + 'Select Color': 'কালার সিলেক্ট করুন', + 'Gallery': 'গ্যালারি', + 'Link': 'লিঙ্ক', + 'Please first select some text to transform into a link.': + 'অনুগ্রহ করে প্রথমে একটি লিঙ্কে রূপান্তরিত করার ' + 'জন্য কিছু পাঠ্য নির্বাচন করুন।', + 'Open': 'ওপেন', + 'Copy': 'কপি', + 'Remove': 'রিমুভ', + 'Save': 'সেভ', + 'Zoom': 'জুম', + 'Saved': 'সেভড', + 'Text': 'টেক্সট', + 'What is entered is not a link': 'যা দেওয়া হয়েছে তা একটি লিঙ্ক নয়', + 'Resize': 'রিসাইজ', + 'Width': 'প্রস্থ', + 'Height': 'দৈর্ঘ্য', + 'Size': 'সাইজ', + 'Small': 'ছোট', + 'Large': 'বড়', + 'Huge': 'বিশাল', + 'Clear': 'ক্লিয়ার', + 'Font': 'ফন্ট', + 'Search': 'সার্চ', + 'matches': 'মিলে', + 'showing match': 'মিল দেখানো হচ্ছে', + 'Prev': 'পূর্ববর্তী', + 'Next': 'পরবর্তী', + 'Camera': 'ক্যামেরা', + 'Video': 'ভিডিও', + 'Undo': 'আন্ডু', + 'Redo': 'রিডু', + 'Font family': 'ফন্ট ফ্যামিলি', + 'Font size': 'ফন্ট সাইজ', + 'Bold': 'বোল্ড', + 'Subscript': 'সাবস্ক্রিপ্ট', + 'Superscript': 'সুপারস্ক্রিপ্ট', + 'Italic': 'ইটালিক', + 'Underline': 'আন্ডারলাইন', + 'Strike through': 'স্ট্রাইক থ্রু', + 'Inline code': 'ইনলাইন কোড', + 'Font color': 'ফন্ট কালার', + 'Background color': 'ব্যাকগ্রাউন্ড কালার', + 'Clear format': 'ক্লিয়ার ফরম্যাট', + 'Align left': 'বাম সারিবদ্ধ', + 'Align center': 'কেন্দ্র সারিবদ্ধ', + 'Align right': 'ডান সারিবদ্ধ', + 'Justify win width': 'প্রস্থের সাথে সংযত', + 'Text direction': 'টেক্সট ডিরেকশন', + 'Header style': 'হেডার স্টাইল', + 'Numbered list': 'সংখ্যাযুক্ত তালিকা', + 'Bullet list': 'বুলেট তালিকা', + 'Checked list': 'চেক করা তালিকা', + 'Code block': 'কোড ব্লক', + 'Quote': 'উক্তি', + 'Increase indent': 'ইন্ডেন্ট বাড়ান', + 'Decrease indent': 'ইন্ডেন্ট কমান', + 'Insert URL': 'UR দিন', + 'Visit link': 'ভিজিট লিঙ্ক', + 'Enter link': 'লিঙ্ক দিন', + 'Enter media': 'মিডিয়া দিন', + 'Edit': 'ইডিট', + 'Apply': 'এপ্লাই', + 'Hex': 'হেক্স', + 'Material': 'ম্যাটারিয়াল', + 'Color': 'কালার', + 'Find text': 'Find text', + 'Move to previous occurrence': 'Move to previous occurrence', + 'Move to next occurrence': 'Move to next occurrence', }, }; diff --git a/lib/src/widgets/controller.dart b/lib/src/widgets/controller.dart index 48bf136d..a3732449 100644 --- a/lib/src/widgets/controller.dart +++ b/lib/src/widgets/controller.dart @@ -39,7 +39,9 @@ class QuillController extends ChangeNotifier { /// Document managed by this controller. Document _document; + Document get document => _document; + set document(doc) { _document = doc; @@ -159,11 +161,11 @@ class QuillController extends ChangeNotifier { notifyListeners(); } - /// Returns all styles for each node within selection - List> getAllIndividualSelectionStyles() { - final styles = document.collectAllIndividualStyles( + /// Returns all styles and Embed for each node within selection + List getAllIndividualSelectionStylesAndEmbed() { + final stylesAndEmbed = document.collectAllIndividualStyleAndEmbed( selection.start, selection.end - selection.start); - return styles; + return stylesAndEmbed; } /// Returns plain text for each node within selection @@ -255,14 +257,6 @@ class QuillController extends ChangeNotifier { } } - if (_keepStyleOnNewLine) { - final style = getSelectionStyle(); - final notInlineStyle = style.attributes.values.where((s) => !s.isInline); - toggledStyle = style.removeAll(notInlineStyle.toSet()); - } else { - toggledStyle = Style(); - } - if (textSelection != null) { if (delta == null || delta.isEmpty) { _updateSelection(textSelection, ChangeSource.LOCAL); @@ -400,7 +394,13 @@ class QuillController extends ChangeNotifier { _selection = selection.copyWith( baseOffset: math.min(selection.baseOffset, end), extentOffset: math.min(selection.extentOffset, end)); - toggledStyle = Style(); + if (_keepStyleOnNewLine) { + final style = getSelectionStyle(); + final notInlineStyle = style.attributes.values.where((s) => !s.isInline); + toggledStyle = style.removeAll(notInlineStyle.toSet()); + } else { + toggledStyle = Style(); + } onSelectionChanged?.call(textSelection); } diff --git a/lib/src/widgets/editor.dart b/lib/src/widgets/editor.dart index aefa21dc..fb1e495f 100644 --- a/lib/src/widgets/editor.dart +++ b/lib/src/widgets/editor.dart @@ -1,4 +1,5 @@ import 'dart:math' as math; + // ignore: unnecessary_import import 'dart:typed_data'; @@ -13,7 +14,6 @@ import 'package:i18n_extension/i18n_widget.dart'; import '../models/documents/document.dart'; import '../models/documents/nodes/container.dart' as container_node; import '../models/documents/nodes/leaf.dart'; -import '../models/documents/style.dart'; import '../models/structs/offset_value.dart'; import '../models/themes/quill_dialog_theme.dart'; import '../utils/platform.dart'; @@ -38,7 +38,7 @@ abstract class EditorState extends State EditorTextSelectionOverlay? get selectionOverlay; - List> get pasteStyle; + List get pasteStyleAndEmbed; String get pastePlainText; @@ -46,6 +46,9 @@ abstract class EditorState extends State /// The floating cursor is animated to merge with the regular cursor. AnimationController get floatingCursorResetController; + /// Returns true if the editor has been marked as needing to be rebuilt. + bool get dirty; + bool showToolbar(); void requestKeyboard(); @@ -188,6 +191,7 @@ class QuillEditor extends StatefulWidget { this.customLinkPrefixes = const [], this.dialogTheme, this.contentInsertionConfiguration, + this.contextMenuBuilder, Key? key, }) : super(key: key); @@ -196,6 +200,11 @@ class QuillEditor extends StatefulWidget { required bool readOnly, Brightness? keyboardAppearance, Iterable? embedBuilders, + EdgeInsetsGeometry padding = EdgeInsets.zero, + bool autoFocus = true, + bool expands = false, + FocusNode? focusNode, + String? placeholder, /// The locale to use for the editor toolbar, defaults to system locale /// More at https://github.com/singerdmx/flutter-quill#translation @@ -205,14 +214,15 @@ class QuillEditor extends StatefulWidget { controller: controller, scrollController: ScrollController(), scrollable: true, - focusNode: FocusNode(), - autoFocus: true, + focusNode: focusNode ?? FocusNode(), + autoFocus: autoFocus, readOnly: readOnly, - expands: false, - padding: EdgeInsets.zero, + expands: expands, + padding: padding, keyboardAppearance: keyboardAppearance ?? Brightness.light, locale: locale, embedBuilders: embedBuilders, + placeholder: placeholder, ); } @@ -366,6 +376,7 @@ class QuillEditor extends StatefulWidget { // Returns whether gesture is handled final bool Function(LongPressMoveUpdateDetails details, TextPosition Function(Offset offset))? onSingleLongTapMoveUpdate; + // Returns whether gesture is handled final bool Function( LongPressEndDetails details, TextPosition Function(Offset offset))? @@ -428,6 +439,9 @@ class QuillEditor extends StatefulWidget { /// Configures the dialog theme. final QuillDialogTheme? dialogTheme; + // Allows for creating a custom context menu + final QuillEditorContextMenuBuilder? contextMenuBuilder; + /// Configuration of handler for media content inserted via the system input /// method. /// @@ -499,8 +513,9 @@ class QuillEditorState extends State readOnly: widget.readOnly, placeholder: widget.placeholder, onLaunchUrl: widget.onLaunchUrl, - contextMenuBuilder: - showSelectionToolbar ? RawEditor.defaultContextMenuBuilder : null, + contextMenuBuilder: showSelectionToolbar + ? (widget.contextMenuBuilder ?? RawEditor.defaultContextMenuBuilder) + : null, showSelectionHandles: isMobile(theme.platform), showCursor: widget.showCursor, cursorStyle: CursorStyle( @@ -991,6 +1006,7 @@ class RenderEditor extends RenderEditableContainerBox } double? _maxContentWidth; + set maxContentWidth(double? value) { if (_maxContentWidth == value) return; _maxContentWidth = value; @@ -1003,7 +1019,9 @@ class RenderEditor extends RenderEditableContainerBox if (textSelection.isCollapsed) { final child = childAtPosition(textSelection.extent); final localPosition = TextPosition( - offset: textSelection.extentOffset - child.container.offset); + offset: textSelection.extentOffset - child.container.offset, + affinity: textSelection.affinity, + ); final localOffset = child.getOffsetForCaret(localPosition); final parentData = child.parentData as BoxParentData; return [ diff --git a/lib/src/widgets/embeds.dart b/lib/src/widgets/embeds.dart index 565ee778..11d34b87 100644 --- a/lib/src/widgets/embeds.dart +++ b/lib/src/widgets/embeds.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; +import '../../extensions.dart'; import '../models/documents/nodes/leaf.dart' as leaf; import '../models/themes/quill_dialog_theme.dart'; import '../models/themes/quill_icon_theme.dart'; @@ -15,6 +16,8 @@ abstract class EmbedBuilder { return WidgetSpan(child: widget); } + String toPlainText(Embed node) => Embed.kObjectReplacementCharacter; + Widget build( BuildContext context, QuillController controller, diff --git a/lib/src/widgets/raw_editor.dart b/lib/src/widgets/raw_editor.dart index 657c63f7..2b3ba326 100644 --- a/lib/src/widgets/raw_editor.dart +++ b/lib/src/widgets/raw_editor.dart @@ -20,7 +20,6 @@ import '../models/documents/nodes/embeddable.dart'; import '../models/documents/nodes/leaf.dart' as leaf; import '../models/documents/nodes/line.dart'; import '../models/documents/nodes/node.dart'; -import '../models/documents/style.dart'; import '../models/structs/offset_value.dart'; import '../models/structs/vertical_spacing.dart'; import '../models/themes/quill_dialog_theme.dart'; @@ -318,8 +317,8 @@ class RawEditorState extends EditorState // for pasting style @override - List> get pasteStyle => _pasteStyle; - List> _pasteStyle = >[]; + List get pasteStyleAndEmbed => _pasteStyleAndEmbed; + List _pasteStyleAndEmbed = []; @override String get pastePlainText => _pastePlainText; @@ -332,6 +331,10 @@ class RawEditorState extends EditorState TextDirection get _textDirection => Directionality.of(context); + @override + bool get dirty => _dirty; + bool _dirty = false; + @override void insertContent(KeyboardInsertedContent content) { assert(widget.contentInsertionConfiguration?.allowedMimeTypes @@ -457,23 +460,26 @@ class RawEditorState extends EditorState Widget child = CompositedTransformTarget( link: _toolbarLayerLink, child: Semantics( - child: _Editor( - key: _editorKey, - document: _doc, - selection: controller.selection, - hasFocus: _hasFocus, - scrollable: widget.scrollable, - cursorController: _cursorCont, - textDirection: _textDirection, - startHandleLayerLink: _startHandleLayerLink, - endHandleLayerLink: _endHandleLayerLink, - onSelectionChanged: _handleSelectionChanged, - onSelectionCompleted: _handleSelectionCompleted, - scrollBottomInset: widget.scrollBottomInset, - padding: widget.padding, - maxContentWidth: widget.maxContentWidth, - floatingCursorDisabled: widget.floatingCursorDisabled, - children: _buildChildren(_doc, context), + child: MouseRegion( + cursor: SystemMouseCursors.text, + child: _Editor( + key: _editorKey, + document: _doc, + selection: controller.selection, + hasFocus: _hasFocus, + scrollable: widget.scrollable, + cursorController: _cursorCont, + textDirection: _textDirection, + startHandleLayerLink: _startHandleLayerLink, + endHandleLayerLink: _endHandleLayerLink, + onSelectionChanged: _handleSelectionChanged, + onSelectionCompleted: _handleSelectionCompleted, + scrollBottomInset: widget.scrollBottomInset, + padding: widget.padding, + maxContentWidth: widget.maxContentWidth, + floatingCursorDisabled: widget.floatingCursorDisabled, + children: _buildChildren(_doc, context), + ), ), ), ); @@ -495,24 +501,27 @@ class RawEditorState extends EditorState physics: widget.scrollPhysics, viewportBuilder: (_, offset) => CompositedTransformTarget( link: _toolbarLayerLink, - child: _Editor( - key: _editorKey, - offset: offset, - document: _doc, - selection: controller.selection, - hasFocus: _hasFocus, - scrollable: widget.scrollable, - textDirection: _textDirection, - startHandleLayerLink: _startHandleLayerLink, - endHandleLayerLink: _endHandleLayerLink, - onSelectionChanged: _handleSelectionChanged, - onSelectionCompleted: _handleSelectionCompleted, - scrollBottomInset: widget.scrollBottomInset, - padding: widget.padding, - maxContentWidth: widget.maxContentWidth, - cursorController: _cursorCont, - floatingCursorDisabled: widget.floatingCursorDisabled, - children: _buildChildren(_doc, context), + child: MouseRegion( + cursor: SystemMouseCursors.text, + child: _Editor( + key: _editorKey, + offset: offset, + document: _doc, + selection: controller.selection, + hasFocus: _hasFocus, + scrollable: widget.scrollable, + textDirection: _textDirection, + startHandleLayerLink: _startHandleLayerLink, + endHandleLayerLink: _endHandleLayerLink, + onSelectionChanged: _handleSelectionChanged, + onSelectionCompleted: _handleSelectionCompleted, + scrollBottomInset: widget.scrollBottomInset, + padding: widget.padding, + maxContentWidth: widget.maxContentWidth, + cursorController: _cursorCont, + floatingCursorDisabled: widget.floatingCursorDisabled, + children: _buildChildren(_doc, context), + ), ), ), ), @@ -855,6 +864,7 @@ class RawEditorState extends EditorState final currentSelection = controller.selection.copyWith(); final attribute = value ? Attribute.checked : Attribute.unchecked; + _markNeedsBuild(); controller ..ignoreFocusOnTextChange = true ..formatText(offset, 0, attribute) @@ -929,9 +939,11 @@ class RawEditorState extends EditorState clearIndents = false; } else { + _dirty = false; throw StateError('Unreachable.'); } } + _dirty = false; return result; } @@ -1170,6 +1182,17 @@ class RawEditorState extends EditorState _selectionOverlay?.updateForScroll(); } + /// Marks the editor as dirty and trigger a rebuild. + /// + /// When the editor is dirty methods that depend on the editor + /// state being in sync with the controller know they may be + /// operating on stale data. + void _markNeedsBuild() { + setState(() { + _dirty = true; + }); + } + void _didChangeTextEditingValue([bool ignoreFocus = false]) { if (kIsWeb) { _onChangeTextEditingValue(ignoreFocus); @@ -1184,10 +1207,9 @@ class RawEditorState extends EditorState } else { requestKeyboard(); if (mounted) { - setState(() { - // Use controller.value in build() - // Trigger build and updateChildren - }); + // Use controller.value in build() + // Mark widget as dirty and trigger build and updateChildren + _markNeedsBuild(); } } @@ -1222,10 +1244,9 @@ class RawEditorState extends EditorState _updateOrDisposeSelectionOverlayIfNeeded(); }); if (mounted) { - setState(() { - // Use controller.value in build() - // Trigger build and updateChildren - }); + // Use controller.value in build() + // Mark widget as dirty and trigger build and updateChildren + _markNeedsBuild(); } } @@ -1258,6 +1279,11 @@ class RawEditorState extends EditorState } void _handleFocusChanged() { + if (dirty) { + SchedulerBinding.instance + .addPostFrameCallback((_) => _handleFocusChanged()); + return; + } openOrCloseConnection(); _cursorCont.startOrStopCursorTimerIfNeeded(_hasFocus, controller.selection); _updateOrDisposeSelectionOverlayIfNeeded(); @@ -1272,10 +1298,9 @@ class RawEditorState extends EditorState void _onChangedClipboardStatus() { if (!mounted) return; - setState(() { - // Inform the widget that the value of clipboardStatus has changed. - // Trigger build and updateChildren - }); + // Inform the widget that the value of clipboardStatus has changed. + // Trigger build and updateChildren + _markNeedsBuild(); } Future _linkActionPicker(Node linkNode) async { @@ -1409,7 +1434,7 @@ class RawEditorState extends EditorState void copySelection(SelectionChangedCause cause) { controller.copiedImageUrl = null; _pastePlainText = controller.getPlainText(); - _pasteStyle = controller.getAllIndividualSelectionStyles(); + _pasteStyleAndEmbed = controller.getAllIndividualSelectionStylesAndEmbed(); final selection = textEditingValue.selection; final text = textEditingValue.text; @@ -1438,7 +1463,7 @@ class RawEditorState extends EditorState void cutSelection(SelectionChangedCause cause) { controller.copiedImageUrl = null; _pastePlainText = controller.getPlainText(); - _pasteStyle = controller.getAllIndividualSelectionStyles(); + _pasteStyleAndEmbed = controller.getAllIndividualSelectionStylesAndEmbed(); if (widget.readOnly) { return; diff --git a/lib/src/widgets/raw_editor/raw_editor_state_selection_delegate_mixin.dart b/lib/src/widgets/raw_editor/raw_editor_state_selection_delegate_mixin.dart index fdd9244b..363b9d31 100644 --- a/lib/src/widgets/raw_editor/raw_editor_state_selection_delegate_mixin.dart +++ b/lib/src/widgets/raw_editor/raw_editor_state_selection_delegate_mixin.dart @@ -4,7 +4,9 @@ import 'package:flutter/rendering.dart'; import 'package:flutter/widgets.dart'; import '../../models/documents/document.dart'; +import '../../models/documents/nodes/embeddable.dart'; import '../../models/documents/nodes/leaf.dart'; +import '../../models/documents/style.dart'; import '../../utils/delta.dart'; import '../editor.dart'; @@ -26,38 +28,50 @@ mixin RawEditorStateSelectionDelegateMixin on EditorState return; } - final insertedText = _adjustInsertedText(diff.inserted); + var insertedText = diff.inserted; + final containsEmbed = + insertedText.codeUnits.contains(Embed.kObjectReplacementInt); + insertedText = + containsEmbed ? _adjustInsertedText(diff.inserted) : diff.inserted; widget.controller.replaceText( diff.start, diff.deleted.length, insertedText, value.selection); - _applyPasteStyle(insertedText, diff.start); + _applyPasteStyleAndEmbed(insertedText, diff.start, containsEmbed); } - void _applyPasteStyle(String insertedText, int start) { - if (insertedText == pastePlainText && pastePlainText != '') { + void _applyPasteStyleAndEmbed( + String insertedText, int start, bool containsEmbed) { + if (insertedText == pastePlainText && pastePlainText != '' || + containsEmbed) { final pos = start; - for (var i = 0; i < pasteStyle.length; i++) { - final offset = pasteStyle[i].offset; - final style = pasteStyle[i].value; - widget.controller.formatTextStyle( - pos + offset, - i == pasteStyle.length - 1 - ? pastePlainText.length - offset - : pasteStyle[i + 1].offset, - style); + for (var i = 0; i < pasteStyleAndEmbed.length; i++) { + final offset = pasteStyleAndEmbed[i].offset; + final styleAndEmbed = pasteStyleAndEmbed[i].value; + + final local = pos + offset; + if (styleAndEmbed is Embeddable) { + widget.controller.replaceText(local, 0, styleAndEmbed, null); + } else { + final style = styleAndEmbed as Style; + if (style.isInline) { + widget.controller + .formatTextStyle(local, pasteStyleAndEmbed[i].length!, style); + } else if (style.isBlock) { + final node = widget.controller.document.queryChild(local).node; + if (node != null && + pasteStyleAndEmbed[i].length == node.length - 1) { + style.values.forEach((attribute) { + widget.controller.document.format(local, 0, attribute); + }); + } + } + } } } } String _adjustInsertedText(String text) { - // For clip from editor, it may contain image, a.k.a 65532 or '\uFFFC'. - // For clip from browser, image is directly ignore. - // Here we skip image when pasting. - if (!text.codeUnits.contains(Embed.kObjectReplacementInt)) { - return text; - } - final sb = StringBuffer(); for (var i = 0; i < text.length; i++) { if (text.codeUnitAt(i) == Embed.kObjectReplacementInt) { diff --git a/lib/src/widgets/raw_editor/raw_editor_state_text_input_client_mixin.dart b/lib/src/widgets/raw_editor/raw_editor_state_text_input_client_mixin.dart index fff4c5ac..396a76e1 100644 --- a/lib/src/widgets/raw_editor/raw_editor_state_text_input_client_mixin.dart +++ b/lib/src/widgets/raw_editor/raw_editor_state_text_input_client_mixin.dart @@ -91,7 +91,8 @@ mixin RawEditorStateTextInputClientMixin on EditorState void _updateCaretRectIfNeeded() { if (hasConnection) { - if (renderEditor.selection.isValid && + if (!dirty && + renderEditor.selection.isValid && renderEditor.selection.isCollapsed) { final currentTextPosition = TextPosition(offset: renderEditor.selection.baseOffset); diff --git a/lib/src/widgets/toolbar.dart b/lib/src/widgets/toolbar.dart index ed5f6a07..17886fc8 100644 --- a/lib/src/widgets/toolbar.dart +++ b/lib/src/widgets/toolbar.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:i18n_extension/i18n_widget.dart'; import '../models/documents/attribute.dart'; +import '../models/structs/link_dialog_action.dart'; import '../models/themes/quill_custom_button.dart'; import '../models/themes/quill_dialog_theme.dart'; import '../models/themes/quill_icon_theme.dart'; @@ -11,13 +12,13 @@ import 'embeds.dart'; import 'toolbar/arrow_indicated_button_list.dart'; import 'toolbar/clear_format_button.dart'; import 'toolbar/color_button.dart'; +import 'toolbar/custom_button.dart'; import 'toolbar/enum.dart'; import 'toolbar/history_button.dart'; import 'toolbar/indent_button.dart'; import 'toolbar/link_style_button.dart'; import 'toolbar/quill_font_family_button.dart'; import 'toolbar/quill_font_size_button.dart'; -import 'toolbar/quill_icon_button.dart'; import 'toolbar/search_button.dart'; import 'toolbar/select_alignment_button.dart'; import 'toolbar/select_header_style_button.dart'; @@ -26,6 +27,7 @@ import 'toolbar/toggle_style_button.dart'; export 'toolbar/clear_format_button.dart'; export 'toolbar/color_button.dart'; +export 'toolbar/custom_button.dart'; export 'toolbar/history_button.dart'; export 'toolbar/indent_button.dart'; export 'toolbar/link_style_button.dart'; @@ -63,6 +65,7 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget { VoidCallback? afterButtonPressed, this.sectionDividerColor, this.sectionDividerSpace, + this.linkDialogAction, Key? key, }) : super(key: key); @@ -153,6 +156,10 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget { /// The space occupied by toolbar divider double? sectionDividerSpace, + + /// Validate the legitimacy of hyperlinks + RegExp? linkRegExp, + LinkDialogAction? linkDialogAction, Key? key, }) { final isButtonGroupShown = [ @@ -550,6 +557,8 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget { iconTheme: iconTheme, dialogTheme: dialogTheme, afterButtonPressed: afterButtonPressed, + linkRegExp: linkRegExp, + linkDialogAction: linkDialogAction, ), if (showSearchButton) SearchButton( @@ -566,20 +575,22 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget { QuillDivider(axis, color: sectionDividerColor, space: sectionDividerSpace), for (var customButton in customButtons) - QuillIconButton( - highlightElevation: 0, - hoverElevation: 0, - size: toolbarIconSize * kIconButtonFactor, - icon: Icon( - customButton.icon, - size: toolbarIconSize, - color: customButton.iconColor, + if (customButton.child != null) ...[ + InkWell( + onTap: customButton.onTap, + child: customButton.child, ), - tooltip: customButton.tooltip, - borderRadius: iconTheme?.borderRadius ?? 2, - onPressed: customButton.onTap, - afterPressed: afterButtonPressed, - ), + ] else ...[ + CustomButton( + onPressed: customButton.onTap, + icon: customButton.icon, + iconColor: customButton.iconColor, + iconSize: toolbarIconSize, + iconTheme: iconTheme, + afterButtonPressed: afterButtonPressed, + tooltip: customButton.tooltip, + ), + ], ], ); } @@ -592,6 +603,9 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget { final WrapCrossAlignment toolbarIconCrossAlignment; final bool multiRowsDisplay; + // Overrides the action in the _LinkDialog widget + final LinkDialogAction? linkDialogAction; + /// The color of the toolbar. /// /// Defaults to [ThemeData.canvasColor] of the current [Theme] if no color @@ -650,7 +664,7 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget { /// The divider which is used for separation of buttons in the toolbar. /// /// It can be used outside of this package, for example when user does not use -/// [QuillToolbar.basic] and compose toolbat's children on its own. +/// [QuillToolbar.basic] and compose toolbar's children on its own. class QuillDivider extends StatelessWidget { const QuillDivider( this.axis, { @@ -659,11 +673,11 @@ class QuillDivider extends StatelessWidget { this.space, }) : super(key: key); - /// Provides a horizonal divider for vertical toolbar. + /// Provides a horizontal divider for vertical toolbar. const QuillDivider.horizontal({Color? color, double? space}) : this(Axis.horizontal, color: color, space: space); - /// Provides a horizonal divider for horizontal toolbar. + /// Provides a horizontal divider for horizontal toolbar. const QuillDivider.vertical({Color? color, double? space}) : this(Axis.vertical, color: color, space: space); diff --git a/lib/src/widgets/toolbar/custom_button.dart b/lib/src/widgets/toolbar/custom_button.dart new file mode 100644 index 00000000..614c79bc --- /dev/null +++ b/lib/src/widgets/toolbar/custom_button.dart @@ -0,0 +1,43 @@ +import 'package:flutter/material.dart'; + +import '../../models/themes/quill_icon_theme.dart'; +import '../toolbar.dart'; + +class CustomButton extends StatelessWidget { + const CustomButton({ + required this.onPressed, + required this.icon, + this.iconColor, + this.iconSize = kDefaultIconSize, + this.iconTheme, + this.afterButtonPressed, + this.tooltip, + Key? key, + }) : super(key: key); + + final VoidCallback? onPressed; + final IconData? icon; + final Color? iconColor; + final double iconSize; + final QuillIconTheme? iconTheme; + final VoidCallback? afterButtonPressed; + final String? tooltip; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + final iconColor = iconTheme?.iconUnselectedColor ?? theme.iconTheme.color; + return QuillIconButton( + highlightElevation: 0, + hoverElevation: 0, + size: iconSize * kIconButtonFactor, + icon: Icon(icon, size: iconSize, color: iconColor), + tooltip: tooltip, + borderRadius: iconTheme?.borderRadius ?? 2, + onPressed: onPressed, + afterPressed: afterButtonPressed, + fillColor: iconTheme?.iconUnselectedFillColor ?? theme.canvasColor, + ); + } +} diff --git a/lib/src/widgets/toolbar/history_button.dart b/lib/src/widgets/toolbar/history_button.dart index 6d3c29ad..909842c9 100644 --- a/lib/src/widgets/toolbar/history_button.dart +++ b/lib/src/widgets/toolbar/history_button.dart @@ -46,7 +46,7 @@ class _HistoryButtonState extends State { tooltip: widget.tooltip, highlightElevation: 0, hoverElevation: 0, - size: widget.iconSize * 1.77, + size: widget.iconSize * kIconButtonFactor, icon: Icon(widget.icon, size: widget.iconSize, color: _iconColor), fillColor: fillColor, borderRadius: widget.iconTheme?.borderRadius ?? 2, diff --git a/lib/src/widgets/toolbar/indent_button.dart b/lib/src/widgets/toolbar/indent_button.dart index 1ce83e99..89b57d4a 100644 --- a/lib/src/widgets/toolbar/indent_button.dart +++ b/lib/src/widgets/toolbar/indent_button.dart @@ -42,7 +42,7 @@ class _IndentButtonState extends State { tooltip: widget.tooltip, highlightElevation: 0, hoverElevation: 0, - size: widget.iconSize * 1.77, + size: widget.iconSize * kIconButtonFactor, icon: Icon(widget.icon, size: widget.iconSize, color: iconColor), fillColor: iconFillColor, borderRadius: widget.iconTheme?.borderRadius ?? 2, diff --git a/lib/src/widgets/toolbar/link_style_button.dart b/lib/src/widgets/toolbar/link_style_button.dart index a0865941..b5346b42 100644 --- a/lib/src/widgets/toolbar/link_style_button.dart +++ b/lib/src/widgets/toolbar/link_style_button.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import '../../models/documents/attribute.dart'; import '../../models/rules/insert.dart'; +import '../../models/structs/link_dialog_action.dart'; import '../../models/themes/quill_dialog_theme.dart'; import '../../models/themes/quill_icon_theme.dart'; import '../../translations/toolbar.i18n.dart'; @@ -18,6 +19,8 @@ class LinkStyleButton extends StatefulWidget { this.dialogTheme, this.afterButtonPressed, this.tooltip, + this.linkRegExp, + this.linkDialogAction, Key? key, }) : super(key: key); @@ -28,6 +31,8 @@ class LinkStyleButton extends StatefulWidget { final QuillDialogTheme? dialogTheme; final VoidCallback? afterButtonPressed; final String? tooltip; + final RegExp? linkRegExp; + final LinkDialogAction? linkDialogAction; @override _LinkStyleButtonState createState() => _LinkStyleButtonState(); @@ -108,7 +113,12 @@ class _LinkStyleButtonState extends State { text ??= len == 0 ? '' : widget.controller.document.getPlainText(index, len); return _LinkDialog( - dialogTheme: widget.dialogTheme, link: link, text: text); + dialogTheme: widget.dialogTheme, + link: link, + text: text, + linkRegExp: widget.linkRegExp, + action: widget.linkDialogAction, + ); }, ).then( (value) { @@ -143,12 +153,20 @@ class _LinkStyleButtonState extends State { } class _LinkDialog extends StatefulWidget { - const _LinkDialog({this.dialogTheme, this.link, this.text, Key? key}) - : super(key: key); + const _LinkDialog({ + this.dialogTheme, + this.link, + this.text, + this.linkRegExp, + this.action, + Key? key, + }) : super(key: key); final QuillDialogTheme? dialogTheme; final String? link; final String? text; + final RegExp? linkRegExp; + final LinkDialogAction? action; @override _LinkDialogState createState() => _LinkDialogState(); @@ -157,6 +175,7 @@ class _LinkDialog extends StatefulWidget { class _LinkDialogState extends State<_LinkDialog> { late String _link; late String _text; + late RegExp linkRegExp; late TextEditingController _linkController; late TextEditingController _textController; @@ -165,6 +184,7 @@ class _LinkDialogState extends State<_LinkDialog> { super.initState(); _link = widget.link ?? ''; _text = widget.text ?? ''; + linkRegExp = widget.linkRegExp ?? AutoFormatMultipleLinksRule.linkRegExp; _linkController = TextEditingController(text: _link); _textController = TextEditingController(text: _text); } @@ -202,15 +222,21 @@ class _LinkDialogState extends State<_LinkDialog> { ), ], ), - actions: [ - TextButton( - onPressed: _canPress() ? _applyLink : null, - child: Text( - 'Ok'.i18n, - style: widget.dialogTheme?.labelTextStyle, - ), - ), - ], + actions: [_okButton()], + ); + } + + Widget _okButton() { + if (widget.action != null) { + return widget.action!.builder(_canPress(), _applyLink); + } + + return TextButton( + onPressed: _canPress() ? _applyLink : null, + child: Text( + 'Ok'.i18n, + style: widget.dialogTheme?.buttonTextStyle, + ), ); } @@ -218,8 +244,7 @@ class _LinkDialogState extends State<_LinkDialog> { if (_text.isEmpty || _link.isEmpty) { return false; } - - if (!AutoFormatMultipleLinksRule.linkRegExp.hasMatch(_link)) { + if (!linkRegExp.hasMatch(_link)) { return false; } diff --git a/lib/src/widgets/toolbar/search_button.dart b/lib/src/widgets/toolbar/search_button.dart index 9233cf45..fc0dc79f 100644 --- a/lib/src/widgets/toolbar/search_button.dart +++ b/lib/src/widgets/toolbar/search_button.dart @@ -43,7 +43,7 @@ class SearchButton extends StatelessWidget { icon: Icon(icon, size: iconSize, color: iconColor), highlightElevation: 0, hoverElevation: 0, - size: iconSize * 1.77, + size: iconSize * kIconButtonFactor, fillColor: iconFillColor, borderRadius: iconTheme?.borderRadius ?? 2, onPressed: () => _onPressedHandler(context), diff --git a/lib/src/widgets/toolbar/search_dialog.dart b/lib/src/widgets/toolbar/search_dialog.dart index b8ce67ac..d9792a5c 100644 --- a/lib/src/widgets/toolbar/search_dialog.dart +++ b/lib/src/widgets/toolbar/search_dialog.dart @@ -23,6 +23,8 @@ class _SearchDialogState extends State { late TextEditingController _controller; late List? _offsets; late int _index; + bool _caseSensitive = false; + bool _wholeWord = false; @override void initState() { @@ -35,87 +37,113 @@ class _SearchDialogState extends State { @override Widget build(BuildContext context) { - return StatefulBuilder(builder: (context, setState) { - var label = ''; - if (_offsets != null) { - label = '${_offsets!.length} ${'matches'.i18n}'; - if (_offsets!.isNotEmpty) { - label += ', ${'showing match'.i18n} ${_index + 1}'; - } + var matchShown = ''; + if (_offsets != null) { + if (_offsets!.isEmpty) { + matchShown = '0/0'; + } else { + matchShown = '${_index + 1}/${_offsets!.length}'; } - return AlertDialog( - backgroundColor: widget.dialogTheme?.dialogBackgroundColor, - content: Container( - height: 100, - child: Column( - children: [ - TextField( - keyboardType: TextInputType.multiline, - style: widget.dialogTheme?.inputTextStyle, - decoration: InputDecoration( - labelText: 'Search'.i18n, - labelStyle: widget.dialogTheme?.labelTextStyle, - floatingLabelStyle: widget.dialogTheme?.labelTextStyle), - autofocus: true, - onChanged: _textChanged, - controller: _controller, + } + + return Dialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(5), + ), + backgroundColor: widget.dialogTheme?.dialogBackgroundColor, + alignment: Alignment.bottomCenter, + insetPadding: EdgeInsets.zero, + child: SizedBox( + height: 45, + child: Row( + children: [ + Tooltip( + message: 'Case sensitivity and whole word search'.i18n, + child: ToggleButtons( + onPressed: (index) { + if (index == 0) { + _changeCaseSensitivity(); + } else if (index == 1) { + _changeWholeWord(); + } + }, + borderRadius: const BorderRadius.all(Radius.circular(2)), + isSelected: [_caseSensitive, _wholeWord], + children: const [ + Text( + '\u0391\u03b1', + style: TextStyle( + fontFamily: 'MaterialIcons', + fontSize: 24, + ), + ), + Text( + '\u201c\u2026\u201d', + style: TextStyle( + fontFamily: 'MaterialIcons', + fontSize: 24, + ), + ), + ], ), - if (_offsets != null) - Padding( - padding: const EdgeInsets.all(8), - child: Text(label, textAlign: TextAlign.left), + ), + Expanded( + child: Padding( + padding: const EdgeInsets.only(bottom: 12, left: 5), + child: TextField( + style: widget.dialogTheme?.inputTextStyle, + decoration: InputDecoration( + isDense: true, + suffixText: (_offsets != null) ? matchShown : '', + suffixStyle: widget.dialogTheme?.labelTextStyle, + ), + autofocus: true, + onChanged: _textChanged, + textInputAction: TextInputAction.done, + onEditingComplete: _findText, + controller: _controller, ), - ], - ), - ), - actions: [ - if (_offsets != null && _offsets!.isNotEmpty && _index > 0) - TextButton( - onPressed: () { - setState(() { - _index -= 1; - }); - _moveToPosition(); - }, - child: Text( - 'Prev'.i18n, - style: widget.dialogTheme?.labelTextStyle, ), ), - if (_offsets != null && - _offsets!.isNotEmpty && - _index < _offsets!.length - 1) - TextButton( - onPressed: () { - setState(() { - _index += 1; - }); - _moveToPosition(); - }, - child: Text( - 'Next'.i18n, - style: widget.dialogTheme?.labelTextStyle, + if (_offsets == null) + IconButton( + icon: const Icon(Icons.search), + tooltip: 'Find text'.i18n, + onPressed: _findText, ), - ), - if (_offsets == null && _text.isNotEmpty) - TextButton( - onPressed: () { - setState(() { - _offsets = widget.controller.document.search(_text); - _index = 0; - }); - if (_offsets!.isNotEmpty) { - _moveToPosition(); - } - }, - child: Text( - 'Ok'.i18n, - style: widget.dialogTheme?.labelTextStyle, + if (_offsets != null) + IconButton( + icon: const Icon(Icons.keyboard_arrow_up), + tooltip: 'Move to previous occurrence'.i18n, + onPressed: (_offsets!.isNotEmpty) ? _moveToPrevious : null, ), - ), - ], + if (_offsets != null) + IconButton( + icon: const Icon(Icons.keyboard_arrow_down), + tooltip: 'Move to next occurrence'.i18n, + onPressed: (_offsets!.isNotEmpty) ? _moveToNext : null, + ), + ], + ), + ), + ); + } + + void _findText() { + if (_text.isEmpty) { + return; + } + setState(() { + _offsets = widget.controller.document.search( + _text, + caseSensitive: _caseSensitive, + wholeWord: _wholeWord, ); + _index = 0; }); + if (_offsets!.isNotEmpty) { + _moveToPosition(); + } } void _moveToPosition() { @@ -126,6 +154,34 @@ class _SearchDialogState extends State { ChangeSource.LOCAL); } + void _moveToPrevious() { + if (_offsets!.isEmpty) { + return; + } + setState(() { + if (_index > 0) { + _index -= 1; + } else { + _index = _offsets!.length - 1; + } + }); + _moveToPosition(); + } + + void _moveToNext() { + if (_offsets!.isEmpty) { + return; + } + setState(() { + if (_index < _offsets!.length - 1) { + _index += 1; + } else { + _index = 0; + } + }); + _moveToPosition(); + } + void _textChanged(String value) { setState(() { _text = value; @@ -133,4 +189,20 @@ class _SearchDialogState extends State { _index = 0; }); } + + void _changeCaseSensitivity() { + setState(() { + _caseSensitive = !_caseSensitive; + _offsets = null; + _index = 0; + }); + } + + void _changeWholeWord() { + setState(() { + _wholeWord = !_wholeWord; + _offsets = null; + _index = 0; + }); + } } diff --git a/pubspec.yaml b/pubspec.yaml index f105b378..28fa15ae 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,8 +1,7 @@ name: flutter_quill -description: A rich text editor supporting mobile and web (Demo App @ bulletjournal.us) -version: 7.2.1 -#author: bulletjournal -homepage: https://bulletjournal.us/home/index.html +description: A rich text editor built for the modern Android, iOS, web and desktop platforms. It is the WYSIWYG editor and a Quill component for Flutter. +version: 7.3.2 +homepage: https://1o24bbs.com/c/bulletjournal/108 repository: https://github.com/singerdmx/flutter-quill environment: @@ -25,7 +24,7 @@ dependencies: platform: ^3.1.0 pasteboard: ^0.2.0 -dev_dependencies: + # Dependencies for testing utilities flutter_test: sdk: flutter diff --git a/test/bug_fix_test.dart b/test/bug_fix_test.dart new file mode 100644 index 00000000..0689476e --- /dev/null +++ b/test/bug_fix_test.dart @@ -0,0 +1,95 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_quill/flutter_quill.dart'; +import 'package:flutter_quill/flutter_quill_test.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('Bug fix', () { + group( + '1266 - QuillToolbar.basic() custom buttons do not have correct fill' + 'color set', () { + testWidgets('fillColor of custom buttons and builtin buttons match', + (tester) async { + const tooltip = 'custom button'; + + await tester.pumpWidget(MaterialApp( + home: QuillToolbar.basic( + showRedo: false, + controller: QuillController.basic(), + customButtons: [const QuillCustomButton(tooltip: tooltip)], + ))); + + final builtinFinder = find.descendant( + of: find.byType(HistoryButton), + matching: find.byType(QuillIconButton), + matchRoot: true); + expect(builtinFinder, findsOneWidget); + final builtinButton = + builtinFinder.evaluate().first.widget as QuillIconButton; + + final customFinder = find.descendant( + of: find.byType(QuillToolbar), + matching: find.byWidgetPredicate((widget) => + widget is QuillIconButton && widget.tooltip == tooltip), + matchRoot: true); + expect(customFinder, findsOneWidget); + final customButton = + customFinder.evaluate().first.widget as QuillIconButton; + + expect(customButton.fillColor, equals(builtinButton.fillColor)); + }); + }); + + group('1189 - The provided text position is not in the current node', () { + late QuillController controller; + late QuillEditor editor; + + setUp(() { + controller = QuillController.basic(); + editor = QuillEditor.basic(controller: controller, readOnly: false); + }); + + tearDown(() { + controller.dispose(); + }); + + testWidgets('Refocus editor after controller clears document', + (tester) async { + await tester.pumpWidget(MaterialApp(home: Column(children: [editor]))); + await tester.quillEnterText(find.byType(QuillEditor), 'test\n'); + + editor.focusNode.unfocus(); + await tester.pump(); + controller.clear(); + editor.focusNode.requestFocus(); + await tester.pump(); + expect(tester.takeException(), isNull); + }); + + testWidgets('Refocus editor after removing block attribute', + (tester) async { + await tester.pumpWidget(MaterialApp(home: Column(children: [editor]))); + await tester.quillEnterText(find.byType(QuillEditor), 'test\n'); + + controller.formatSelection(Attribute.ul); + editor.focusNode.unfocus(); + await tester.pump(); + controller.formatSelection(const ListAttribute(null)); + editor.focusNode.requestFocus(); + await tester.pump(); + expect(tester.takeException(), isNull); + }); + + testWidgets('Tap checkbox in unfocused editor', (tester) async { + await tester.pumpWidget(MaterialApp(home: Column(children: [editor]))); + await tester.quillEnterText(find.byType(QuillEditor), 'test\n'); + + controller.formatSelection(Attribute.unchecked); + editor.focusNode.unfocus(); + await tester.pump(); + await tester.tap(find.byType(CheckboxPoint)); + expect(tester.takeException(), isNull); + }); + }); + }); +} diff --git a/test/widgets/controller_test.dart b/test/widgets/controller_test.dart new file mode 100644 index 00000000..0221d32e --- /dev/null +++ b/test/widgets/controller_test.dart @@ -0,0 +1,295 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_quill/flutter_quill.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + const testDocumentContents = 'data'; + late QuillController controller; + + setUp(() { + controller = QuillController.basic() + ..compose(Delta()..insert(testDocumentContents), + const TextSelection.collapsed(offset: 0), ChangeSource.LOCAL); + }); + + group('controller', () { + test('set document', () { + const replacementContents = 'replacement\n'; + final newDocument = + Document.fromDelta(Delta()..insert(replacementContents)); + var listenerCalled = false; + controller + ..addListener(() { + listenerCalled = true; + }) + ..document = newDocument; + expect(listenerCalled, isTrue); + expect(controller.document.toPlainText(), replacementContents); + }); + + test('getSelectionStyle', () { + controller + ..formatText(0, 5, Attribute.h1) + ..updateSelection(const TextSelection(baseOffset: 0, extentOffset: 4), + ChangeSource.LOCAL); + + expect(controller.getSelectionStyle().values, [Attribute.h1]); + }); + + test('indentSelection with single line document', () { + var listenerCalled = false; + // With selection range + controller + ..updateSelection(const TextSelection(baseOffset: 0, extentOffset: 4), + ChangeSource.LOCAL) + ..addListener(() { + listenerCalled = true; + }) + ..indentSelection(true); + expect(listenerCalled, isTrue); + expect(controller.getSelectionStyle().values, [Attribute.indentL1]); + controller.indentSelection(true); + expect(controller.getSelectionStyle().values, [Attribute.indentL2]); + controller.indentSelection(false); + expect(controller.getSelectionStyle().values, [Attribute.indentL1]); + controller.indentSelection(false); + expect(controller.getSelectionStyle().values, []); + + // With collapsed selection + controller + ..updateSelection( + const TextSelection.collapsed(offset: 0), ChangeSource.LOCAL) + ..indentSelection(true); + expect(controller.getSelectionStyle().values, [Attribute.indentL1]); + controller + ..updateSelection( + const TextSelection.collapsed(offset: 0), ChangeSource.LOCAL) + ..indentSelection(true); + expect(controller.getSelectionStyle().values, [Attribute.indentL2]); + controller.indentSelection(false); + expect(controller.getSelectionStyle().values, [Attribute.indentL1]); + controller.indentSelection(false); + expect(controller.getSelectionStyle().values, []); + }); + + test('indentSelection with multiline document', () { + controller + ..compose(Delta()..insert('line1\nline2\nline3\n'), + const TextSelection.collapsed(offset: 0), ChangeSource.LOCAL) + // Indent first line + ..updateSelection( + const TextSelection.collapsed(offset: 0), ChangeSource.LOCAL) + ..indentSelection(true); + expect(controller.getSelectionStyle().values, [Attribute.indentL1]); + + // Indent first two lines + controller + ..updateSelection(const TextSelection(baseOffset: 0, extentOffset: 11), + ChangeSource.LOCAL) + ..indentSelection(true); + + // Should have both L1 and L2 indent attributes in selection. + expect(controller.getAllSelectionStyles(), + contains(Style().put(Attribute.indentL1).put(Attribute.indentL2))); + + // Remaining lines should have no attributes. + controller.updateSelection( + TextSelection( + baseOffset: 12, + extentOffset: controller.document.toPlainText().length - 1), + ChangeSource.LOCAL); + expect(controller.getAllSelectionStyles(), everyElement(Style())); + }); + + test('getAllIndividualSelectionStylesAndEmbed', () { + controller + ..formatText(0, 2, Attribute.bold) + ..replaceText(2, 2, BlockEmbed.image('/test'), null) + ..updateSelection(const TextSelection(baseOffset: 0, extentOffset: 4), + ChangeSource.REMOTE); + final result = controller.getAllIndividualSelectionStylesAndEmbed(); + expect(result.length, 2); + expect(result[0].offset, 0); + expect(result[0].value, Style().put(Attribute.bold)); + expect((result[1].value as Embeddable).type, BlockEmbed.imageType); + }); + + test('getPlainText', () { + controller.updateSelection( + const TextSelection(baseOffset: 0, extentOffset: 4), + ChangeSource.LOCAL); + + expect(controller.getPlainText(), testDocumentContents); + }); + + test('getAllSelectionStyles', () { + controller.formatText(0, 2, Attribute.bold); + expect(controller.getAllSelectionStyles(), + contains(Style().put(Attribute.bold))); + }); + + test('undo', () { + var listenerCalled = false; + controller.updateSelection( + const TextSelection.collapsed(offset: 4), ChangeSource.LOCAL); + + expect(controller.document.toDelta(), Delta()..insert('data\n')); + controller + ..addListener(() { + listenerCalled = true; + }) + ..undo(); + expect(listenerCalled, isTrue); + expect(controller.document.toDelta(), Delta()..insert('\n')); + }); + + test('redo', () { + var listenerCalled = false; + controller.updateSelection( + const TextSelection.collapsed(offset: 4), ChangeSource.LOCAL); + + expect(controller.document.toDelta(), Delta()..insert('data\n')); + controller.undo(); + expect(controller.document.toDelta(), Delta()..insert('\n')); + controller + ..addListener(() { + listenerCalled = true; + }) + ..redo(); + expect(listenerCalled, isTrue); + expect(controller.document.toDelta(), Delta()..insert('data\n')); + }); + test('clear', () { + var listenerCalled = false; + controller + ..addListener(() { + listenerCalled = true; + }) + ..clear(); + + expect(listenerCalled, isTrue); + expect(controller.document.toDelta(), Delta()..insert('\n')); + }); + + test('replaceText', () { + var listenerCalled = false; + controller + ..addListener(() { + listenerCalled = true; + }) + ..replaceText(1, 2, '11', const TextSelection.collapsed(offset: 0)); + + expect(listenerCalled, isTrue); + expect(controller.document.toDelta(), Delta()..insert('d11a\n')); + }); + + test('formatTextStyle', () { + var listenerCalled = false; + final style = Style().put(Attribute.bold).put(Attribute.italic); + controller + ..addListener(() { + listenerCalled = true; + }) + ..formatTextStyle(0, 2, style); + expect(listenerCalled, isTrue); + expect(controller.document.collectAllStyles(0, 2), contains(style)); + expect(controller.document.collectAllStyles(2, 4), everyElement(Style())); + }); + + test('formatText', () { + var listenerCalled = false; + controller + ..addListener(() { + listenerCalled = true; + }) + ..formatText(0, 2, Attribute.bold); + expect(listenerCalled, isTrue); + expect(controller.document.collectAllStyles(0, 2), + contains(Style().put(Attribute.bold))); + expect(controller.document.collectAllStyles(2, 4), everyElement(Style())); + }); + + test('formatSelection', () { + var listenerCalled = false; + controller + ..updateSelection(const TextSelection(baseOffset: 0, extentOffset: 2), + ChangeSource.LOCAL) + ..addListener(() { + listenerCalled = true; + }) + ..formatSelection(Attribute.bold); + expect(listenerCalled, isTrue); + expect(controller.document.collectAllStyles(0, 2), + contains(Style().put(Attribute.bold))); + expect(controller.document.collectAllStyles(2, 4), everyElement(Style())); + }); + + test('moveCursorToStart', () { + var listenerCalled = false; + controller + ..updateSelection( + const TextSelection.collapsed(offset: 4), ChangeSource.LOCAL) + ..addListener(() { + listenerCalled = true; + }); + expect(controller.selection, const TextSelection.collapsed(offset: 4)); + + controller.moveCursorToStart(); + expect(listenerCalled, isTrue); + expect(controller.selection, const TextSelection.collapsed(offset: 0)); + }); + + test('moveCursorToPosition', () { + var listenerCalled = false; + controller.addListener(() { + listenerCalled = true; + }); + expect(controller.selection, const TextSelection.collapsed(offset: 0)); + + controller.moveCursorToPosition(2); + expect(listenerCalled, isTrue); + expect(controller.selection, const TextSelection.collapsed(offset: 2)); + }); + + test('moveCursorToEnd', () { + var listenerCalled = false; + controller.addListener(() { + listenerCalled = true; + }); + expect(controller.selection, const TextSelection.collapsed(offset: 0)); + + controller.moveCursorToEnd(); + expect(listenerCalled, isTrue); + expect(controller.selection, + TextSelection.collapsed(offset: controller.document.length - 1)); + }); + + test('updateSelection', () { + var listenerCalled = false; + const selection = TextSelection.collapsed(offset: 0); + controller + ..addListener(() { + listenerCalled = true; + }) + ..updateSelection(selection, ChangeSource.LOCAL); + + expect(listenerCalled, isTrue); + expect(controller.selection, selection); + }); + + test('compose', () { + var listenerCalled = false; + final originalContents = controller.document.toPlainText(); + controller + ..addListener(() { + listenerCalled = true; + }) + ..compose(Delta()..insert('test '), + const TextSelection.collapsed(offset: 0), ChangeSource.LOCAL); + + expect(listenerCalled, isTrue); + expect(controller.document.toDelta(), + Delta()..insert('test $originalContents')); + }); + }); +} diff --git a/test/widgets/editor_test.dart b/test/widgets/editor_test.dart new file mode 100644 index 00000000..dccf85ea --- /dev/null +++ b/test/widgets/editor_test.dart @@ -0,0 +1,133 @@ +import 'dart:convert' show jsonDecode; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_quill/flutter_quill.dart'; +import 'package:flutter_quill/flutter_quill_test.dart'; +import 'package:flutter_quill/src/widgets/raw_editor.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + late QuillController controller; + var didCopy = false; + + setUp(() { + controller = QuillController.basic(); + }); + + tearDown(() { + controller.dispose(); + }); + + group('QuillEditor', () { + testWidgets('Keyboard entered text is stored in document', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: QuillEditor.basic(controller: controller, readOnly: false), + ), + ); + await tester.quillEnterText(find.byType(QuillEditor), 'test\n'); + + expect(controller.document.toPlainText(), 'test\n'); + }); + + testWidgets('insertContent is handled correctly', (tester) async { + String? latestUri; + await tester.pumpWidget( + MaterialApp( + home: QuillEditor( + controller: controller, + focusNode: FocusNode(), + scrollController: ScrollController(), + scrollable: true, + padding: const EdgeInsets.all(0), + autoFocus: true, + readOnly: false, + expands: true, + contentInsertionConfiguration: ContentInsertionConfiguration( + onContentInserted: (content) { + latestUri = content.uri; + }, + allowedMimeTypes: const ['image/gif'], + ), + ), + ), + ); + await tester.tap(find.byType(QuillEditor)); + await tester.quillEnterText(find.byType(QuillEditor), 'test\n'); + await tester.idle(); + + const uri = + 'content://com.google.android.inputmethod.latin.fileprovider/test.gif'; + final messageBytes = + const JSONMessageCodec().encodeMessage({ + 'args': [ + -1, + 'TextInputAction.commitContent', + jsonDecode( + '{"mimeType": "image/gif", "data": [0,1,0,1,0,1,0,0,0], "uri": "$uri"}'), + ], + 'method': 'TextInputClient.performAction', + }); + + Object? error; + try { + await tester.binding.defaultBinaryMessenger + .handlePlatformMessage('flutter/textinput', messageBytes, (_) {}); + } catch (e) { + error = e; + } + expect(error, isNull); + expect(latestUri, equals(uri)); + }); + + Widget customBuilder(BuildContext context, RawEditorState state) { + return AdaptiveTextSelectionToolbar( + anchors: state.contextMenuAnchors, + children: [ + Container( + height: 50, + color: Colors.white, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + IconButton( + onPressed: () { + didCopy = true; + }, + icon: const Icon(Icons.copy), + ), + ], + ), + ), + ], + ); + } + + testWidgets('custom context menu builder', (tester) async { + await tester.pumpWidget(MaterialApp( + home: QuillEditor( + controller: controller, + focusNode: FocusNode(), + scrollController: ScrollController(), + scrollable: true, + padding: EdgeInsets.zero, + autoFocus: true, + readOnly: false, + expands: true, + contextMenuBuilder: customBuilder, + ), + )); + + // Long press to show menu + await tester.longPress(find.byType(QuillEditor)); + await tester.pumpAndSettle(); + + // Verify custom widget shows + expect(find.byIcon(Icons.copy), findsOneWidget); + + await tester.tap(find.byIcon(Icons.copy)); + expect(didCopy, isTrue); + }); + }); +}