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 46b9b977..6d511902 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,35 @@ -# [7.1.17] +# [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. + +# [7.2.0] +- Checkboxes, bullet points, and number points are now scaled based on the default paragraph font size. + +# [7.1.20] +- Pass linestyle to embedded block. + +# [7.1.19] +- Fix Rtl leading alignment problem. + +# [7.1.18] +- Support flutter latest version. + +# [7.1.17+1] - Updates `device_info_plus` to version 9.0.0 to benefit from AGP 8 (see [changelog#900](https://pub.dev/packages/device_info_plus/changelog#900)). # [7.1.16] diff --git a/README.md b/README.md index d31e9a58..9fe48b01 100644 --- a/README.md +++ b/README.md @@ -391,6 +391,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/example/lib/pages/home_page.dart b/example/lib/pages/home_page.dart index 40449aeb..9e3bcb02 100644 --- a/example/lib/pages/home_page.dart +++ b/example/lib/pages/home_page.dart @@ -158,76 +158,70 @@ 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), - ), - embedBuilders: [ - ...FlutterQuillEmbeds.builders(), - NotesEmbedBuilder(addEditNote: _addEditNote) - ], + 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), ), + embedBuilders: [ + ...FlutterQuillEmbeds.builders(), + NotesEmbedBuilder(addEditNote: _addEditNote) + ], ); 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, + NotesEmbedBuilder(addEditNote: _addEditNote), + ]); } var toolbar = QuillToolbar.basic( controller: _controller!, @@ -502,6 +496,7 @@ class NotesEmbedBuilder extends EmbedBuilder { Embed node, bool readOnly, bool inline, + TextStyle textStyle, ) { final notes = NotesBlockEmbed(node.value.data).document; diff --git a/example/lib/universal_ui/universal_ui.dart b/example/lib/universal_ui/universal_ui.dart index 7b35f1f1..91344afb 100644 --- a/example/lib/universal_ui/universal_ui.dart +++ b/example/lib/universal_ui/universal_ui.dart @@ -38,6 +38,7 @@ class ImageEmbedBuilderWeb extends EmbedBuilder { Embed node, bool readOnly, bool inline, + TextStyle textStyle, ) { final imageUrl = node.value.data; if (isImageBase64(imageUrl)) { @@ -80,6 +81,7 @@ class VideoEmbedBuilderWeb extends EmbedBuilder { Embed node, bool readOnly, bool inline, + TextStyle textStyle, ) { var videoUrl = node.value.data; if (videoUrl.contains('youtube.com') || videoUrl.contains('youtu.be')) { diff --git a/example/windows/runner/Runner.rc b/example/windows/runner/Runner.rc index 3ac0062f..a922e84e 100644 --- a/example/windows/runner/Runner.rc +++ b/example/windows/runner/Runner.rc @@ -60,14 +60,14 @@ IDI_APP_ICON ICON "resources\\app_icon.ico" // Version // -#ifdef FLUTTER_BUILD_NUMBER -#define VERSION_AS_NUMBER FLUTTER_BUILD_NUMBER +#if defined(FLUTTER_VERSION_MAJOR) && defined(FLUTTER_VERSION_MINOR) && defined(FLUTTER_VERSION_PATCH) && defined(FLUTTER_VERSION_BUILD) +#define VERSION_AS_NUMBER FLUTTER_VERSION_MAJOR,FLUTTER_VERSION_MINOR,FLUTTER_VERSION_PATCH,FLUTTER_VERSION_BUILD #else -#define VERSION_AS_NUMBER 1,0,0 +#define VERSION_AS_NUMBER 1,0,0,0 #endif -#ifdef FLUTTER_BUILD_NAME -#define VERSION_AS_STRING #FLUTTER_BUILD_NAME +#if defined(FLUTTER_VERSION) +#define VERSION_AS_STRING FLUTTER_VERSION #else #define VERSION_AS_STRING "1.0.0" #endif diff --git a/flutter_quill_extensions/CHANGELOG.md b/flutter_quill_extensions/CHANGELOG.md index b741b21a..176876c7 100644 --- a/flutter_quill_extensions/CHANGELOG.md +++ b/flutter_quill_extensions/CHANGELOG.md @@ -1,3 +1,9 @@ +## 0.3.3 +* Fix a prototype bug which was bring by [PR #1230](https://github.com/singerdmx/flutter-quill/pull/1230#issuecomment-1560597099) + +## 0.3.2 +* Updated dependencies to support intl 0.18 + ## 0.3.1 * Image embedding tweaks * Add MediaButton which is intened to superseed the ImageButton and VideoButton. Only image selection is working. diff --git a/flutter_quill_extensions/lib/embeds/builders.dart b/flutter_quill_extensions/lib/embeds/builders.dart index cf04a462..b16b5845 100644 --- a/flutter_quill_extensions/lib/embeds/builders.dart +++ b/flutter_quill_extensions/lib/embeds/builders.dart @@ -28,6 +28,7 @@ class ImageEmbedBuilder extends EmbedBuilder { base.Embed node, bool readOnly, bool inline, + TextStyle textStyle, ) { assert(!kIsWeb, 'Please provide image EmbedBuilder for Web'); @@ -164,6 +165,7 @@ class ImageEmbedBuilderWeb extends EmbedBuilder { Embed node, bool readOnly, bool inline, + TextStyle textStyle, ) { final imageUrl = node.value.data; @@ -198,6 +200,7 @@ class VideoEmbedBuilder extends EmbedBuilder { base.Embed node, bool readOnly, bool inline, + TextStyle textStyle, ) { assert(!kIsWeb, 'Please provide video EmbedBuilder for Web'); @@ -226,6 +229,7 @@ class FormulaEmbedBuilder extends EmbedBuilder { base.Embed node, bool readOnly, bool inline, + TextStyle textStyle, ) { assert(!kIsWeb, 'Please provide formula EmbedBuilder for Web'); diff --git a/flutter_quill_extensions/pubspec.yaml b/flutter_quill_extensions/pubspec.yaml index f03c97c7..f1b77e8c 100644 --- a/flutter_quill_extensions/pubspec.yaml +++ b/flutter_quill_extensions/pubspec.yaml @@ -1,25 +1,25 @@ name: flutter_quill_extensions description: Embed extensions for flutter_quill including image, video, formula and etc. -version: 0.3.1 +version: 0.3.3 homepage: https://bulletjournal.us/home/index.html repository: https://github.com/singerdmx/flutter-quill/tree/master/flutter_quill_extensions environment: - sdk: ">=2.17.0 <3.0.0" + sdk: ">=2.17.0 <4.0.0" flutter: ">=3.0.0" dependencies: flutter: sdk: flutter - flutter_quill: ^7.1.17 + flutter_quill: ^7.2.1 image_picker: ^0.8.5+3 photo_view: ^0.14.0 video_player: ^2.4.2 youtube_player_flutter: ^8.1.1 gallery_saver: ^2.3.2 - math_keyboard: ^0.1.8 + math_keyboard: ^0.2.0 string_validator: ^1.0.0 universal_html: ^2.2.1 url_launcher: ^6.1.9 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/src/models/themes/quill_custom_button.dart b/lib/src/models/themes/quill_custom_button.dart index 4ea4e4f5..bbb472f9 100644 --- a/lib/src/models/themes/quill_custom_button.dart +++ b/lib/src/models/themes/quill_custom_button.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; class QuillCustomButton { const QuillCustomButton({ this.icon, + this.iconColor, this.onTap, this.tooltip, }); @@ -10,6 +11,9 @@ class QuillCustomButton { ///The icon widget final IconData? icon; + ///The icon color; + final Color? iconColor; + ///The function when the icon is tapped final VoidCallback? onTap; 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 5eb2b344..7fc356e9 100644 --- a/lib/src/translations/toolbar.i18n.dart +++ b/lib/src/translations/toolbar.i18n.dart @@ -68,6 +68,9 @@ extension Localization on String { 'Enter media': 'Enter media', 'Edit': 'Edit', 'Apply': 'Apply', + 'Hex': 'Hex', + 'Material': 'Material', + 'Color': 'Color', }, 'en_us': { 'Paste a link': 'Paste a link', @@ -134,72 +137,80 @@ extension Localization on String { 'Enter media': 'Enter media', 'Edit': 'Edit', 'Apply': 'Apply', + 'Hex': 'Hex', + 'Material': 'Material', + 'Color': 'Color', }, 'ar': { 'Paste a link': 'نسخ الرابط', 'Ok': 'نعم', 'Select Color': 'اختار اللون', - 'Gallery': 'الصور', + 'Gallery': 'المعرض', 'Link': 'الرابط', 'Please first select some text to transform into a link.': 'يرجى اختيار نص للتحويل إلى رابط', 'Open': 'فتح', - 'Copy': 'ينسخ', + 'Copy': 'نسخ', 'Remove': 'إزالة', - 'Save': 'يحفظ', + 'Save': 'حفظ', 'Zoom': 'تكبير', - 'Saved': 'أنقذ', - 'Text': 'Text', - 'What is entered is not a link': 'What is entered is not a link', - 'Resize': 'Resize', - 'Width': 'Width', - 'Height': 'Height', - 'Size': 'Size', - 'Small': 'Small', - 'Large': 'Large', - 'Huge': 'Huge', - 'Clear': 'Clear', - 'Font': 'Font', - 'Search': 'Search', - 'matches': 'matches', - 'showing match': 'showing match', - 'Prev': 'Prev', - 'Next': 'Next', - 'Camera': 'Camera', - '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', - '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', + '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': 'محاذاة اليمين', + // i think it should be 'Justify with width' + // it is wrong in all properties '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', + 'Text direction': 'اتجاه النص', + 'Header style': 'ستايل العنوان', + 'Numbered list': 'قائمة مرقمة', + 'Bullet list': 'قائمة منقطة', + 'Checked list': 'قائمة للمهام', + 'Code block': 'كود كامل', + 'Quote': 'اقتباس', + 'Increase indent': 'زيادة الهامش', + 'Decrease indent': 'تنقيص الهامش', + 'Insert URL': 'ادخل عنوان رابط', + 'Visit link': 'زيارة الرابط', + 'Enter link': 'ادخل رابط', + 'Enter media': 'ادخل وسائط', + 'Edit': 'تعديل', + 'Apply': 'تطبيق', + 'Hex': 'Hex', + 'Material': 'Material', + 'Color': 'اللون', }, 'da': { 'Paste a link': 'Indsæt link', @@ -1023,45 +1034,45 @@ extension Localization on String { 'Clear': 'Limpar', 'Font': 'Fonte', 'Search': 'Buscar', - 'matches': 'matches', - 'showing match': 'showing match', + 'matches': 'resultado(s)', + 'showing match': 'mostrando resultado', 'Prev': 'Anterior', 'Next': 'Próximo', - 'Camera': 'Camera', + 'Camera': 'Câmera', 'Video': 'Vídeo', - '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': 'Desfazer', + 'Redo': 'Refazer', + 'Font family': 'Fonte', + 'Font size': 'Tamanho da fonte', + 'Bold': 'Negrito', + 'Subscript': 'Subscrito', + 'Superscript': 'Sobrescrito', + 'Italic': 'Itálico', + 'Underline': 'Sublinhado', + 'Strike through': 'Tachado', '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', + 'Font color': 'Cor da fonte', + 'Background color': 'Cor do fundo', + 'Clear format': 'Limpar formatação', + 'Align left': 'Texto à esquerda', + 'Align center': 'Centralizar', + 'Align right': 'Texto à direita', + 'Justify win width': 'Justificado', + 'Text direction': 'Direção do texto', + 'Header style': 'Estilo de cabeçalho', + 'Numbered list': 'Numeração', + 'Bullet list': 'Marcadores', + 'Checked list': 'Lista de verificação', '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', + 'Quote': 'Citação', + 'Increase indent': 'Aumentar recuo', + 'Decrease indent': 'Diminuir recuo', + 'Insert URL': 'Inserir URL', + 'Visit link': 'Visitar link', + 'Enter link': 'Inserir link', + 'Enter media': 'Inserir mídia', + 'Edit': 'Editar', + 'Apply': 'Aplicar', }, 'pl': { 'Paste a link': 'Wklej link', diff --git a/lib/src/widgets/controller.dart b/lib/src/widgets/controller.dart index 48bf136d..aedd7015 100644 --- a/lib/src/widgets/controller.dart +++ b/lib/src/widgets/controller.dart @@ -255,14 +255,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 +392,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/delegate.dart b/lib/src/widgets/delegate.dart index de92d8eb..988473bd 100644 --- a/lib/src/widgets/delegate.dart +++ b/lib/src/widgets/delegate.dart @@ -312,7 +312,8 @@ class EditorTextSelectionGestureDetectorBuilder { /// which triggers this callback./lib/src/material/text_field.dart @protected void onDragSelectionUpdate( - DragStartDetails startDetails, DragUpdateDetails updateDetails) { + //DragStartDetails startDetails, + DragUpdateDetails updateDetails) { renderEditor!.extendSelection(updateDetails.globalPosition, cause: SelectionChangedCause.drag); } diff --git a/lib/src/widgets/editor.dart b/lib/src/widgets/editor.dart index 906b67fb..52cf9a67 100644 --- a/lib/src/widgets/editor.dart +++ b/lib/src/widgets/editor.dart @@ -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(); @@ -187,6 +190,7 @@ class QuillEditor extends StatefulWidget { this.enableUnfocusOnTapOutside = true, this.customLinkPrefixes = const [], this.dialogTheme, + this.contentInsertionConfiguration, Key? key, }) : super(key: key); @@ -427,6 +431,12 @@ class QuillEditor extends StatefulWidget { /// Configures the dialog theme. final QuillDialogTheme? dialogTheme; + /// Configuration of handler for media content inserted via the system input + /// method. + /// + /// See [https://api.flutter.dev/flutter/widgets/EditableText/contentInsertionConfiguration.html] + final ContentInsertionConfiguration? contentInsertionConfiguration; + @override QuillEditorState createState() => QuillEditorState(); } @@ -467,8 +477,8 @@ class QuillEditorState extends State selectionColor = selectionTheme.selectionColor ?? cupertinoTheme.primaryColor.withOpacity(0.40); cursorRadius ??= const Radius.circular(2); - cursorOffset = Offset( - iOSHorizontalOffset / MediaQuery.of(context).devicePixelRatio, 0); + cursorOffset = + Offset(iOSHorizontalOffset / View.of(context).devicePixelRatio, 0); } else { textSelectionControls = materialTextSelectionControls; paintCursorAboveText = false; @@ -528,6 +538,7 @@ class QuillEditorState extends State customLinkPrefixes: widget.customLinkPrefixes, enableUnfocusOnTapOutside: widget.enableUnfocusOnTapOutside, dialogTheme: widget.dialogTheme, + contentInsertionConfiguration: widget.contentInsertionConfiguration, ); final editor = I18n( diff --git a/lib/src/widgets/embeds.dart b/lib/src/widgets/embeds.dart index 8d72cd5b..565ee778 100644 --- a/lib/src/widgets/embeds.dart +++ b/lib/src/widgets/embeds.dart @@ -21,6 +21,7 @@ abstract class EmbedBuilder { leaf.Embed node, bool readOnly, bool inline, + TextStyle textStyle, ); } diff --git a/lib/src/widgets/raw_editor.dart b/lib/src/widgets/raw_editor.dart index 89831d96..48b65df0 100644 --- a/lib/src/widgets/raw_editor.dart +++ b/lib/src/widgets/raw_editor.dart @@ -84,6 +84,7 @@ class RawEditor extends StatefulWidget { this.onImagePaste, this.customLinkPrefixes = const [], this.dialogTheme, + this.contentInsertionConfiguration, }) : assert(maxHeight == null || maxHeight > 0, 'maxHeight cannot be null'), assert(minHeight == null || minHeight >= 0, 'minHeight cannot be null'), assert(maxHeight == null || minHeight == null || maxHeight >= minHeight, @@ -270,6 +271,12 @@ class RawEditor extends StatefulWidget { /// Configures the dialog theme. final QuillDialogTheme? dialogTheme; + /// Configuration of handler for media content inserted via the system input + /// method. + /// + /// See [https://api.flutter.dev/flutter/widgets/EditableText/contentInsertionConfiguration.html] + final ContentInsertionConfiguration? contentInsertionConfiguration; + @override State createState() => RawEditorState(); } @@ -325,6 +332,18 @@ 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 + .contains(content.mimeType) ?? + false); + widget.contentInsertionConfiguration?.onContentInserted.call(content); + } + /// Returns the [ContextMenuButtonItem]s representing the buttons in this /// platform's default selection menu for [RawEditor]. /// @@ -442,23 +461,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), + ), ), ), ); @@ -480,24 +502,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), + ), ), ), ), @@ -840,6 +865,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) @@ -888,7 +914,7 @@ class RawEditorState extends EditorState final editableTextBlock = EditableTextBlock( block: node, controller: controller, - textDirection: _textDirection, + textDirection: getDirectionOfNode(node), scrollBottomInset: widget.scrollBottomInset, verticalSpacing: _getVerticalSpacingForBlock(node, _styles), textSelection: controller.selection, @@ -914,9 +940,11 @@ class RawEditorState extends EditorState clearIndents = false; } else { + _dirty = false; throw StateError('Unreachable.'); } } + _dirty = false; return result; } @@ -946,7 +974,7 @@ class RawEditorState extends EditorState widget.selectionColor, widget.enableInteractiveSelection, _hasFocus, - MediaQuery.of(context).devicePixelRatio, + View.of(context).devicePixelRatio, _cursorCont); return editableTextLine; } @@ -1155,6 +1183,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); @@ -1169,10 +1208,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(); } } @@ -1207,10 +1245,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(); } } @@ -1243,6 +1280,11 @@ class RawEditorState extends EditorState } void _handleFocusChanged() { + if (dirty) { + SchedulerBinding.instance + .addPostFrameCallback((_) => _handleFocusChanged()); + return; + } openOrCloseConnection(); _cursorCont.startOrStopCursorTimerIfNeeded(_hasFocus, controller.selection); _updateOrDisposeSelectionOverlayIfNeeded(); @@ -1257,10 +1299,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 { @@ -1709,7 +1750,7 @@ class RawEditorState extends EditorState } class _Editor extends MultiChildRenderObjectWidget { - _Editor({ + const _Editor({ required Key key, required List children, required this.document, 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 e3b9ff5f..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 @@ -59,6 +59,9 @@ mixin RawEditorStateTextInputClientMixin on EditorState enableSuggestions: !widget.readOnly, keyboardAppearance: widget.keyboardAppearance, textCapitalization: widget.textCapitalization, + allowedMimeTypes: widget.contentInsertionConfiguration == null + ? const [] + : widget.contentInsertionConfiguration!.allowedMimeTypes, ), ); @@ -88,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/style_widgets/bullet_point.dart b/lib/src/widgets/style_widgets/bullet_point.dart index ee33c93a..8b5fce70 100644 --- a/lib/src/widgets/style_widgets/bullet_point.dart +++ b/lib/src/widgets/style_widgets/bullet_point.dart @@ -4,18 +4,20 @@ class QuillBulletPoint extends StatelessWidget { const QuillBulletPoint({ required this.style, required this.width, + this.padding = 0, Key? key, }) : super(key: key); final TextStyle style; final double width; + final double padding; @override Widget build(BuildContext context) { return Container( alignment: AlignmentDirectional.topEnd, width: width, - padding: const EdgeInsetsDirectional.only(end: 13), + padding: EdgeInsetsDirectional.only(end: padding), child: Text('•', style: style), ); } diff --git a/lib/src/widgets/text_block.dart b/lib/src/widgets/text_block.dart index b61ad9cf..3906fb88 100644 --- a/lib/src/widgets/text_block.dart +++ b/lib/src/widgets/text_block.dart @@ -152,14 +152,14 @@ class EditableTextBlock extends StatelessWidget { onLaunchUrl: onLaunchUrl, customLinkPrefixes: customLinkPrefixes, ), - _getIndentWidth(), + _getIndentWidth(context), _getSpacingForLine(line, index, count, defaultStyles), textDirection, textSelection, color, enableInteractiveSelection, hasFocus, - MediaQuery.of(context).devicePixelRatio, + View.of(context).devicePixelRatio, cursorCont); final nodeTextDirection = getDirectionOfNode(line); children.add(Directionality( @@ -170,45 +170,48 @@ class EditableTextBlock extends StatelessWidget { Widget? _buildLeading(BuildContext context, Line line, int index, Map indentLevelCounts, int count) { - final defaultStyles = QuillStyles.getStyles(context, false); + final defaultStyles = QuillStyles.getStyles(context, false)!; + final fontSize = defaultStyles.paragraph?.style.fontSize ?? 16; final attrs = line.style.attributes; + if (attrs[Attribute.list.key] == Attribute.ol) { return QuillNumberPoint( index: index, indentLevelCounts: indentLevelCounts, count: count, - style: defaultStyles!.leading!.style, + style: defaultStyles.leading!.style, attrs: attrs, - width: 32, - padding: 8, + width: fontSize * 2, + padding: fontSize / 2, ); } if (attrs[Attribute.list.key] == Attribute.ul) { return QuillBulletPoint( style: - defaultStyles!.leading!.style.copyWith(fontWeight: FontWeight.bold), - width: 32, + defaultStyles.leading!.style.copyWith(fontWeight: FontWeight.bold), + width: fontSize * 2, + padding: fontSize / 2, ); } if (attrs[Attribute.list.key] == Attribute.checked) { return CheckboxPoint( - size: 14, + size: fontSize, value: true, enabled: !readOnly, onChanged: (checked) => onCheckboxTap(line.documentOffset, checked), - uiBuilder: defaultStyles?.lists?.checkboxUIBuilder, + uiBuilder: defaultStyles.lists?.checkboxUIBuilder, ); } if (attrs[Attribute.list.key] == Attribute.unchecked) { return CheckboxPoint( - size: 14, + size: fontSize, value: false, enabled: !readOnly, onChanged: (checked) => onCheckboxTap(line.documentOffset, checked), - uiBuilder: defaultStyles?.lists?.checkboxUIBuilder, + uiBuilder: defaultStyles.lists?.checkboxUIBuilder, ); } @@ -217,35 +220,37 @@ class EditableTextBlock extends StatelessWidget { index: index, indentLevelCounts: indentLevelCounts, count: count, - style: defaultStyles!.code!.style + style: defaultStyles.code!.style .copyWith(color: defaultStyles.code!.style.color!.withOpacity(0.4)), - width: 32, + width: fontSize * 2, attrs: attrs, - padding: 16, + padding: fontSize, withDot: false, ); } return null; } - double _getIndentWidth() { + double _getIndentWidth(BuildContext context) { + final defaultStyles = QuillStyles.getStyles(context, false)!; + final fontSize = defaultStyles.paragraph?.style.fontSize ?? 16; final attrs = block.style.attributes; final indent = attrs[Attribute.indent.key]; var extraIndent = 0.0; if (indent != null && indent.value != null) { - extraIndent = 16.0 * indent.value; + extraIndent = fontSize * indent.value; } if (attrs.containsKey(Attribute.blockQuote.key)) { - return 16.0 + extraIndent; + return fontSize + extraIndent; } var baseIndent = 0.0; if (attrs.containsKey(Attribute.list.key) || attrs.containsKey(Attribute.codeBlock.key)) { - baseIndent = 32.0; + baseIndent = fontSize * 2; } return baseIndent + extraIndent; @@ -596,7 +601,7 @@ class RenderEditableTextBlock extends RenderEditableContainerBox } class _EditableBlock extends MultiChildRenderObjectWidget { - _EditableBlock( + const _EditableBlock( {required this.block, required this.textDirection, required this.padding, diff --git a/lib/src/widgets/text_line.dart b/lib/src/widgets/text_line.dart index f38cbebf..9358777a 100644 --- a/lib/src/widgets/text_line.dart +++ b/lib/src/widgets/text_line.dart @@ -148,15 +148,10 @@ class _TextLineState extends State { final embedBuilder = widget.embedBuilder(embed); if (embedBuilder.expanded) { // Creates correct node for custom embed - + final lineStyle = _getLineStyle(widget.styles); return EmbedProxy( - embedBuilder.build( - context, - widget.controller, - embed, - widget.readOnly, - false, - ), + embedBuilder.build(context, widget.controller, embed, widget.readOnly, + false, lineStyle), ); } } @@ -198,7 +193,8 @@ class _TextLineState extends State { } // Creates correct node for custom embed if (child.value.type == BlockEmbed.customType) { - child = Embed(CustomBlockEmbed.fromJsonString(child.value.data)); + child = Embed(CustomBlockEmbed.fromJsonString(child.value.data)) + ..applyStyle(child.style); } final embedBuilder = widget.embedBuilder(child); final embedWidget = EmbedProxy( @@ -208,6 +204,7 @@ class _TextLineState extends State { child, widget.readOnly, true, + lineStyle, ), ); final embed = embedBuilder.buildWidgetSpan(embedWidget); @@ -1082,9 +1079,16 @@ class RenderEditableTextLine extends RenderEditableBox { @override void paint(PaintingContext context, Offset offset) { if (_leading != null) { - final parentData = _leading!.parentData as BoxParentData; - final effectiveOffset = offset + parentData.offset; - context.paintChild(_leading!, effectiveOffset); + if (textDirection == TextDirection.ltr) { + final parentData = _leading!.parentData as BoxParentData; + final effectiveOffset = offset + parentData.offset; + context.paintChild(_leading!, effectiveOffset); + } else { + final parentData = _leading!.parentData as BoxParentData; + final effectiveOffset = offset + parentData.offset; + context.paintChild(_leading!, + Offset(size.width - _leading!.size.width, effectiveOffset.dy)); + } } if (_body != null) { diff --git a/lib/src/widgets/text_selection.dart b/lib/src/widgets/text_selection.dart index ad1398e0..688505e2 100644 --- a/lib/src/widgets/text_selection.dart +++ b/lib/src/widgets/text_selection.dart @@ -713,7 +713,7 @@ class EditorTextSelectionGestureDetector extends StatefulWidget { /// The frequency of calls is throttled to avoid excessive text layout /// operations in text fields. The throttling is controlled by the constant /// [_kDragSelectionUpdateThrottle]. - final DragSelectionUpdateCallback? onDragSelectionUpdate; + final GestureDragUpdateCallback? onDragSelectionUpdate; /// Called when a mouse that was previously dragging is released. final GestureDragEndCallback? onDragSelectionEnd; @@ -857,7 +857,8 @@ class _EditorTextSelectionGestureDetectorState assert(_lastDragUpdateDetails != null); if (widget.onDragSelectionUpdate != null) { widget.onDragSelectionUpdate!( - _lastDragStartDetails!, _lastDragUpdateDetails!); + //_lastDragStartDetails!, + _lastDragUpdateDetails!); } _dragUpdateThrottleTimer = null; _lastDragUpdateDetails = null; diff --git a/lib/src/widgets/toolbar.dart b/lib/src/widgets/toolbar.dart index f1cb3d96..91521fb0 100644 --- a/lib/src/widgets/toolbar.dart +++ b/lib/src/widgets/toolbar.dart @@ -11,13 +11,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 +26,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'; @@ -566,15 +567,14 @@ 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), - tooltip: customButton.tooltip, - borderRadius: iconTheme?.borderRadius ?? 2, + CustomButton( onPressed: customButton.onTap, - afterPressed: afterButtonPressed, + icon: customButton.icon, + iconColor: customButton.iconColor, + iconSize: toolbarIconSize, + iconTheme: iconTheme, + afterButtonPressed: afterButtonPressed, + tooltip: customButton.tooltip, ), ], ); @@ -646,7 +646,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, { @@ -655,11 +655,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/color_button.dart b/lib/src/widgets/toolbar/color_button.dart index 60f0a591..41a57734 100644 --- a/lib/src/widgets/toolbar/color_button.dart +++ b/lib/src/widgets/toolbar/color_button.dart @@ -136,29 +136,148 @@ class _ColorButtonState extends State { } void _changeColor(BuildContext context, Color color) { - var hex = color.value.toRadixString(16); - if (hex.startsWith('ff')) { - hex = hex.substring(2); - } + var hex = colorToHex(color); hex = '#$hex'; widget.controller.formatSelection( widget.background ? BackgroundAttribute(hex) : ColorAttribute(hex)); - Navigator.of(context).pop(); } void _showColorPicker() { - showDialog( + var pickerType = 'material'; + + var selectedColor = Colors.black; + + if (_isToggledColor) { + selectedColor = widget.background + ? hexToColor(_selectionStyle.attributes['background']?.value) + : hexToColor(_selectionStyle.attributes['color']?.value); + } + + final hexController = + TextEditingController(text: colorToHex(selectedColor)); + late void Function(void Function()) colorBoxSetState; + + showDialog( context: context, - builder: (context) => AlertDialog( - title: Text('Select Color'.i18n), - backgroundColor: Theme.of(context).canvasColor, - content: SingleChildScrollView( - child: MaterialPicker( - pickerColor: const Color(0x00000000), - onColorChanged: (color) => _changeColor(context, color), - ), - ), - ), + builder: (context) => StatefulBuilder(builder: (context, dlgSetState) { + return AlertDialog( + title: Text('Select Color'.i18n), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(); + }, + child: Text('OK'.i18n)), + ], + backgroundColor: Theme.of(context).canvasColor, + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + TextButton( + onPressed: () { + dlgSetState(() { + pickerType = 'material'; + }); + }, + child: Text('Material'.i18n)), + TextButton( + onPressed: () { + dlgSetState(() { + pickerType = 'color'; + }); + }, + child: Text('Color'.i18n)), + ], + ), + Column(children: [ + if (pickerType == 'material') + MaterialPicker( + pickerColor: selectedColor, + onColorChanged: (color) { + _changeColor(context, color); + Navigator.of(context).pop(); + }, + ), + if (pickerType == 'color') + ColorPicker( + pickerColor: selectedColor, + onColorChanged: (color) { + _changeColor(context, color); + hexController.text = colorToHex(color); + selectedColor = color; + colorBoxSetState(() {}); + }, + ), + const SizedBox( + height: 10, + ), + Row( + children: [ + SizedBox( + width: 100, + height: 60, + child: TextFormField( + controller: hexController, + onChanged: (value) { + selectedColor = hexToColor(value); + _changeColor(context, selectedColor); + + colorBoxSetState(() {}); + }, + decoration: InputDecoration( + labelText: 'Hex'.i18n, + border: const OutlineInputBorder(), + ), + ), + ), + const SizedBox( + width: 10, + ), + StatefulBuilder(builder: (context, mcolorBoxSetState) { + colorBoxSetState = mcolorBoxSetState; + return Container( + width: 25, + height: 25, + decoration: BoxDecoration( + border: Border.all( + color: Colors.black45, + ), + color: selectedColor, + borderRadius: BorderRadius.circular(5), + ), + ); + }), + ], + ), + ]) + ], + ), + )); + }), ); } + + Color hexToColor(String? hexString) { + if (hexString == null) { + return Colors.black; + } + final hexRegex = RegExp(r'([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})$'); + + hexString = hexString.replaceAll('#', ''); + if (!hexRegex.hasMatch(hexString)) { + return Colors.black; + } + + final buffer = StringBuffer(); + if (hexString.length == 6 || hexString.length == 7) buffer.write('ff'); + buffer.write(hexString); + return Color(int.tryParse(buffer.toString(), radix: 16) ?? 0xFF000000); + } + + String colorToHex(Color color) { + return color.value.toRadixString(16).padLeft(8, '0').toUpperCase(); + } } 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/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/pubspec.yaml b/pubspec.yaml index 0a369e47..f9b266dc 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,13 +1,13 @@ name: flutter_quill description: A rich text editor supporting mobile and web (Demo App @ bulletjournal.us) -version: 7.1.17+1 +version: 7.2.6 #author: bulletjournal homepage: https://bulletjournal.us/home/index.html repository: https://github.com/singerdmx/flutter-quill environment: - sdk: ">=2.17.0 <3.0.0" - flutter: ">=3.0.0" + sdk: ">=2.17.0 <4.0.0" + flutter: ">=3.10.0" dependencies: flutter: @@ -20,12 +20,12 @@ dependencies: pedantic: ^1.11.1 characters: ^1.2.1 diff_match_patch: ^0.4.1 - i18n_extension: ^8.0.0 + i18n_extension: ">=8.0.0 <10.0.0" device_info_plus: ^9.0.0 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..047dfcae --- /dev/null +++ b/test/widgets/controller_test.dart @@ -0,0 +1,290 @@ +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('getAllIndividualSelectionStyles', () { + controller.formatText(0, 2, Attribute.bold); + final result = controller.getAllIndividualSelectionStyles(); + expect(result.length, 1); + expect(result[0].offset, 0); + expect(result[0].value, Style().put(Attribute.bold)); + }); + + 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..3fd425fc --- /dev/null +++ b/test/widgets/editor_test.dart @@ -0,0 +1,82 @@ +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_test/flutter_test.dart'; + +void main() { + late QuillController controller; + + 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)); + }); + }); +}