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 d4e8f538..f834734c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,31 +1,329 @@ +# [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. + +# [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] +- Fixed subscript key from 'sup' to 'sub'. + +# [7.1.15] +- Fixed a bug introduced in 7.1.7 where each section in `QuillToolbar` was displayed on its own line. + +# [7.1.14] +- Add indents change for multiline selection. + +# [7.1.13] +- Add custom recognizer. + +# [7.1.12] +- Add superscript and subscript styles. + +# [7.1.11] +- Add inserting indents for lines of list if text is selected. + +# [7.1.10] +- Image embedding tweaks + - Add MediaButton which is intened to superseed the ImageButton and VideoButton. Only image selection is working. + - Implement image insert for web (image as base64) + +# [7.1.9] + +- Editor tweaks PR from [bambinoua](https://github.com/bambinoua). + - Shortcuts now working in Mac OS + - QuillDialogTheme is extended with new properties buttonStyle, linkDialogConstraints, imageDialogConstraints, isWrappable, runSpacing, + - Added LinkStyleButton2 with new LinkStyleDialog (similar to Quill implementation + - Conditinally use Row or Wrap for dialog's children. + - Update minimum Dart SDK version to 2.17.0 to use enum extensions. + - Use merging shortcuts and actions correclty (if the key combination is the same) + +# [7.1.8] +- Dropdown tweaks + - Add itemHeight, itemPadding, defaultItemColor for customization of dropdown items. + - Remove alignment property as useless. + - Fix bugs with max width when width property is null. + +# [7.1.7] + +- Toolbar tweaks. + - Implement tooltips for embed CameraButton, VideoButton, FormulaButton, ImageButton. + - Extends customization for SelectAlignmentButton, QuillFontFamilyButton, QuillFontSizeButton adding padding, text style, alignment, width. + - Add renderFontFamilies to QuillFontFamilyButton to show font faces in dropdown. + - Add AxisDivider and its named constructors for for use in parent project. + - Export ToolbarButtons enum to allow specify tooltips for SelectAlignmentButton. + - Export QuillFontFamilyButton, SearchButton as they were not exported before. + - Deprecate items property in QuillFontFamilyButton, QuillFontSizeButton as the it can be built usinr rawItemsMap. + - Make onSelection QuillFontFamilyButton, QuillFontSizeButton omittable as no need to execute callback outside if controller is passed to widget. + +Now the package is more friendly for web projects. + +# [7.1.6] + +- Add enableUnfocusOnTapOutside field to RawEditor and Editor widgets. + +# [7.1.5] + +- Add tooltips for toolbar buttons. + +# [7.1.4] + +- Fix inserting tab character in lists. + +# [7.1.3] + +- Fix ios cursor bug when word.length==1. + +# [7.1.2] + +- Fix non scrollable editor exception, when tapped under content. + +# [7.1.1] + +- customLinkPrefixes parameter - makes possible to open links with custom protoco. + +# [7.1.0] + +- Fix ordered list numeration with several lists in document. + +# [7.0.9] + +- Use const constructor for EmbedBuilder. + +# [7.0.8] + +- Fix IME position bug with scroller. + +# [7.0.7] + +- Add TextFieldTapRegion for contextMenu. + +# [7.0.6] + +- Fix line style loss on new line from non string. + +# [7.0.5] + +- Fix IME position bug for Mac and Windows. +- Unfocus when tap outside editor. fix the bug that cant refocus in afterButtonPressed after click ToggleStyleButton on Mac. + +# [7.0.4] + +- Have text selection span full line height for uneven sized text. + +# [7.0.3] + +- Fix ordered list numeration for lists with more than one level of list. + +# [7.0.2] + +- Allow widgets to override widget span properties. + +# [7.0.1] + +- Update i18n_extension dependency to version 8.0.0. + +# [7.0.0] + +- Breaking change: Tuples are no longer used. They have been replaced with a number of data classes. + +# [6.4.4] + +- Increased compatibility with Flutter widget tests. + +# [6.4.3] + +- Update dependencies (collection: 1.17.0, flutter_keyboard_visibility: 5.4.0, quiver: 3.2.1, tuple: 2.0.1, url_launcher: 6.1.9, characters: 1.2.1, i18n_extension: 7.0.0, device_info_plus: 8.1.0) + +# [6.4.2] + +- Replace `buildToolbar` with `contextMenuBuilder`. + +# [6.4.1] + +- Control the detect word boundary behaviour. + +# [6.4.0] + +- Use `axis` to make the toolbar vertical. +- Use `toolbarIconCrossAlignment` to align the toolbar icons on the cross axis. +- Breaking change: `QuillToolbar`'s parameter `toolbarHeight` was renamed to `toolbarSize`. + +# [6.3.5] + +- Ability to add custom shortcuts. + +# [6.3.4] + +- Update clipboard status prior to showing selected text overlay. + +# [6.3.3] + +- Fixed handling of mac intents. + +# [6.3.2] + +- Added `unknownEmbedBuilder` to QuillEditor. +- Fix error style when input chinese japanese or korean. + +# [6.3.1] + +- Add color property to the basic factory function. + +# [6.3.0] + +- Support Flutter 3.7. + +# [6.2.2] + +- Fix: nextLine getter null where no assertion. + +# [6.2.1] + +- Revert "Align numerical and bullet lists along with text content". + +# [6.2.0] + +- Align numerical and bullet lists along with text content. + +# [6.1.12] + +- Apply i18n for default font dropdown option labels corresponding to 'Clear'. + +# [6.1.11] + +- Remove iOS hack for delaying focus calculation. + +# [6.1.10] + +- Delay focus calculation for iOS. + +# [6.1.9] + +- Bump keyboard show up wait to 1 sec. + +# [6.1.8] + +- Recalculate focus when showing keyboard. + +# [6.1.7] + +- Add czech localizations. + +# [6.1.6] + +- Upgrade i18n_extension to 6.0.0. + +# [6.1.5] + +- Fix formatting exception. + +# [6.1.4] + +- Add double quotes validation. + +# [6.1.3] + +- Revert "fix order list numbering (#988)". + +# [6.1.2] + +- Add typing shortcuts. + +# [6.1.1] + +- Fix order list numbering. + +# [6.1.0] + +- Add keyboard shortcuts for editor actions. + +# [6.0.10] + +- Upgrade device info plus to ^7.0.0. + +# [6.0.9] + +- Don't throw showAutocorrectionPromptRect not implemented. The function is called with every keystroke as a user is typing. + +# [6.0.8+1] + +- Fixes null pointer when setting documents. + +# [6.0.8] + +- Make QuillController.document mutable. + +# [6.0.7] + +- Allow disabling of selection toolbar. + # [6.0.6+1] -* Revert 6.0.6 + +- Revert 6.0.6. # [6.0.6] -* Fix wrong custom embed key. + +- Fix wrong custom embed key. # [6.0.5] -* Fixes toolbar buttons stealing focus from editor. + +- Fixes toolbar buttons stealing focus from editor. # [6.0.4] -* Bug fix for Type 'Uint8List' not found. + +- Bug fix for Type 'Uint8List' not found. # [6.0.3] -* Add ability to paste images. + +- Add ability to paste images. # [6.0.2] -* Address Dart Analysis issues. + +- Address Dart Analysis issues. # [6.0.1] -* Changed translation country code (zh_HK -> zh_hk) to lower case, which is required for i18n_extension used in flutter_quill. -* Add localization in example's main to demonstrate translation. -* Issue [Windows] selection's copy / paste tool bar not shown #861: add selection's copy / paste toolbar, escape to hide toolbar, mouse right click to show toolbar, ctrl-Y / ctrl-Z to undo / redo. -* Image and video displayed in Windows platform caused screen flickering while selecting text, a sample_data_nomedia.json asset is added for Desktop to demonstrate the added features. -* Known issue: keyboard action sometimes causes exception mentioned in Flutter's issue #106475 ([Windows] Keyboard shortcuts stop working after modifier key repeat flutter/flutter#106475). -* Know issue: user needs to click the editor to get focus before toolbar is able to display. + +- Changed translation country code (zh_HK -> zh_hk) to lower case, which is required for i18n_extension used in flutter_quill. +- Add localization in example's main to demonstrate translation. +- Issue [Windows] selection's copy / paste tool bar not shown #861: add selection's copy / paste toolbar, escape to hide toolbar, mouse right click to show toolbar, ctrl-Y / ctrl-Z to undo / redo. +- Image and video displayed in Windows platform caused screen flickering while selecting text, a sample_data_nomedia.json asset is added for Desktop to demonstrate the added features. +- Known issue: keyboard action sometimes causes exception mentioned in Flutter's issue #106475 ([Windows] Keyboard shortcuts stop working after modifier key repeat flutter/flutter#106475). +- Know issue: user needs to click the editor to get focus before toolbar is able to display. # [6.0.0] BREAKING CHANGE -* Removed embed (image, video & formula) blocks from the package to reduce app size. + +- Removed embed (image, video & formula) blocks from the package to reduce app size. These blocks have been moved to the package `flutter_quill_extensions`, migrate by filling the `embedBuilders` and `embedButtons` parameters as follows: @@ -43,699 +341,929 @@ QuillToolbar.basic( ); ``` - # [5.4.2] -* Upgrade i18n_extension. + +- Upgrade i18n_extension. # [5.4.1] -* Update German Translation. + +- Update German Translation. # [5.4.0] -* Added Formula Button (for maths support). + +- Added Formula Button (for maths support). # [5.3.2] -* Add more font family. + +- Add more font family. # [5.3.1] -* Enable search when text is not empty. + +- Enable search when text is not empty. # [5.3.0] -* Added search function. + +- Added search function. # [5.2.11] -* Remove default small color. + +- Remove default small color. # [5.2.10] -* Don't wrap the QuillEditor's child in the EditorTextSelectionGestureDetector if selection is disabled. + +- Don't wrap the QuillEditor's child in the EditorTextSelectionGestureDetector if selection is disabled. # [5.2.9] -* Added option to modify SelectHeaderStyleButton options. -* Added option to click again on h1, h2, h3 button to go back to normal. + +- Added option to modify SelectHeaderStyleButton options. +- Added option to click again on h1, h2, h3 button to go back to normal. # [5.2.8] -* Remove tooltip for LinkStyleButton. -* Make link match regex case insensitive. + +- Remove tooltip for LinkStyleButton. +- Make link match regex case insensitive. # [5.2.7] -* Add locale to QuillEditor.basic. + +- Add locale to QuillEditor.basic. # [5.2.6] -* Fix keyboard pops up when resizing the image. + +- Fix keyboard pops up when resizing the image. # [5.2.5] -* Upgrade youtube_player_flutter_quill to 8.2.2. + +- Upgrade youtube_player_flutter_quill to 8.2.2. # [5.2.4] -* Upgrade youtube_player_flutter_quill to 8.2.1. + +- Upgrade youtube_player_flutter_quill to 8.2.1. # [5.2.3] -* Flutter Quill Doesn't Work On iOS 16 or Xcode 14 Betas (Stored properties cannot be marked potentially unavailable with '@available'). + +- Flutter Quill Doesn't Work On iOS 16 or Xcode 14 Betas (Stored properties cannot be marked potentially unavailable with '@available'). # [5.2.2] -* Fix Web Unsupported operation: Platform._operatingSystem error. + +- Fix Web Unsupported operation: Platform.\_operatingSystem error. # [5.2.1] -* Rename QuillCustomIcon to QuillCustomButton. + +- Rename QuillCustomIcon to QuillCustomButton. # [5.2.0] -* Support font family selection. + +- Support font family selection. # [5.1.1] -* Update README. + +- Update README. # [5.1.0] -* Added CustomBlockEmbed and customElementsEmbedBuilder. + +- Added CustomBlockEmbed and customElementsEmbedBuilder. # [5.0.5] -* Upgrade device_info_plus to 4.0.0. + +- Upgrade device_info_plus to 4.0.0. # [5.0.4] -* Added onVideoInit callback for video documents. + +- Added onVideoInit callback for video documents. # [5.0.3] -* Update dependencies. + +- Update dependencies. # [5.0.2] -* Keep cursor position on checkbox tap. + +- Keep cursor position on checkbox tap. # [5.0.1] -* Fix static analysis errors. + +- Fix static analysis errors. # [5.0.0] -* Flutter 3.0.0 support. + +- Flutter 3.0.0 support. # [4.2.3] -* Ignore color:inherit and convert double to int for level. + +- Ignore color:inherit and convert double to int for level. # [4.2.2] -* Add clear option to font size dropdown. + +- Add clear option to font size dropdown. # [4.2.1] -* Refactor font size dropdown. + +- Refactor font size dropdown. # [4.2.0] -* Ensure selectionOverlay is available for showToolbar. + +- Ensure selectionOverlay is available for showToolbar. # [4.1.9] -* Using properly iconTheme colors. + +- Using properly iconTheme colors. # [4.1.8] -* Update font size dropdown. + +- Update font size dropdown. # [4.1.7] -* Convert FontSize to a Map to allow for named Font Size. + +- Convert FontSize to a Map to allow for named Font Size. # [4.1.6] -* Update quill_dropdown_button.dart. + +- Update quill_dropdown_button.dart. # [4.1.5] -* Add Font Size dropdown to the toolbar. + +- Add Font Size dropdown to the toolbar. # [4.1.4] -* New borderRadius for iconTheme. + +- New borderRadius for iconTheme. # [4.1.3] -* Fix selection handles show/hide after paste, backspace, copy. + +- Fix selection handles show/hide after paste, backspace, copy. # [4.1.2] -* Add full support for hardware keyboards (Chromebook, Android tablets, etc) that don't alter screen UI. + +- Add full support for hardware keyboards (Chromebook, Android tablets, etc) that don't alter screen UI. # [4.1.1] -* Added textSelectionControls field in QuillEditor. + +- Added textSelectionControls field in QuillEditor. # [4.1.0] -* Added Node to linkActionPickerDelegate. + +- Added Node to linkActionPickerDelegate. # [4.0.12] -* Add Persian(fa) language. + +- Add Persian(fa) language. # [4.0.11] -* Fix cut selection error in multi-node line. + +- Fix cut selection error in multi-node line. # [4.0.10] -* Fix vertical caret position bug. + +- Fix vertical caret position bug. # [4.0.9] -* Request keyboard focus when no child is found. + +- Request keyboard focus when no child is found. # [4.0.8] -* Fix blank lines do not display when --web-renderer=html. + +- Fix blank lines do not display when --web-renderer=html. # [4.0.7] -* Refactor getPlainText (better handling of blank lines and lines with multiple markups. + +- Refactor getPlainText (better handling of blank lines and lines with multiple markups. # [4.0.6] -* Bug fix for copying text with new lines. + +- Bug fix for copying text with new lines. # [4.0.5] -* Fixed casting null to Tuple2 when link dialog is dismissed without any input (e.g. barrier dismissed). + +- Fixed casting null to Tuple2 when link dialog is dismissed without any input (e.g. barrier dismissed). # [4.0.4] -* Bug fix for text direction rtl. + +- Bug fix for text direction rtl. # [4.0.3] -* Support text direction rtl. + +- Support text direction rtl. # [4.0.2] -* Clear toggled style on selection change. + +- Clear toggled style on selection change. # [4.0.1] -* Fix copy/cut/paste/selectAll not working. + +- Fix copy/cut/paste/selectAll not working. # [4.0.0] -* Upgrade for Flutter 2.10. + +- Upgrade for Flutter 2.10. # [3.9.11] -* Added Indonesian translation. + +- Added Indonesian translation. # [3.9.10] -* Fix for undoing a modification ending with an indented line. + +- Fix for undoing a modification ending with an indented line. # [3.9.9] -* iOS: Save image whose filename does not end with image file extension. + +- iOS: Save image whose filename does not end with image file extension. # [3.9.8] -* Added Urdu translation. + +- Added Urdu translation. # [3.9.7] -* Fix for clicking on the Link button without any text on a new line crashes. + +- Fix for clicking on the Link button without any text on a new line crashes. # [3.9.6] -* Apply locale to QuillEditor(contents). + +- Apply locale to QuillEditor(contents). # [3.9.5] -* Fix image pasting. + +- Fix image pasting. # [3.9.4] -* Hiding dialog after selecting action for image. + +- Hiding dialog after selecting action for image. # [3.9.3] -* Update ImageResizer for Android. + +- Update ImageResizer for Android. # [3.9.2] -* Copy image with its style. + +- Copy image with its style. # [3.9.1] -* Support resizing image. + +- Support resizing image. # [3.9.0] -* Image menu options for copy/remove. + +- Image menu options for copy/remove. # [3.8.8] -* Update set textEditingValue. + +- Update set textEditingValue. # [3.8.7] -* Fix checkbox not toggled correctly in toolbar button. + +- Fix checkbox not toggled correctly in toolbar button. # [3.8.6] -* Fix cursor position changes when checking/unchecking the checkbox. + +- Fix cursor position changes when checking/unchecking the checkbox. # [3.8.5] -* Fix _handleDragUpdate in _TextSelectionHandleOverlayState. + +- Fix \_handleDragUpdate in \_TextSelectionHandleOverlayState. # [3.8.4] -* Fix link dialog layout. + +- Fix link dialog layout. # [3.8.3] -* Fix for errors on a non scrollable editor. + +- Fix for errors on a non scrollable editor. # [3.8.2] -* Fix certain keys not working on web when editor is a child of a scroll view. + +- Fix certain keys not working on web when editor is a child of a scroll view. # [3.8.1] -* Refactor _QuillEditorState to QuillEditorState. + +- Refactor \_QuillEditorState to QuillEditorState. # [3.8.0] -* Support pasting with format. + +- Support pasting with format. # [3.7.3] -* Fix selection overlay for collapsed selection. + +- Fix selection overlay for collapsed selection. # [3.7.2] -* Reverted Embed toPlainText change. + +- Reverted Embed toPlainText change. # [3.7.1] -* Change Embed toPlainText to be empty string. + +- Change Embed toPlainText to be empty string. # [3.7.0] -* Replace Toolbar showHistory group with individual showRedo and showUndo. + +- Replace Toolbar showHistory group with individual showRedo and showUndo. # [3.6.5] -* Update Link dialogue for image/video. + +- Update Link dialogue for image/video. # [3.6.4] -* Link dialogue TextInputType.multiline. + +- Link dialogue TextInputType.multiline. # [3.6.3] -* Bug fix for link button text selection. + +- Bug fix for link button text selection. # [3.6.2] -* Improve link button. + +- Improve link button. # [3.6.1] -* Remove SnackBar 'What is entered is not a link'. + +- Remove SnackBar 'What is entered is not a link'. # [3.6.0] -* Allow link button to enter text. + +- Allow link button to enter text. # [3.5.3] -* Change link button behavior. + +- Change link button behavior. # [3.5.2] -* Bug fix for embed. + +- Bug fix for embed. # [3.5.1] -* Bug fix for platform util. + +- Bug fix for platform util. # [3.5.0] -* Removed redundant classes. + +- Removed redundant classes. # [3.4.4] -* Add more translations. + +- Add more translations. # [3.4.3] -* Preset link from attributes. + +- Preset link from attributes. # [3.4.2] -* Fix launch link edit mode. + +- Fix launch link edit mode. # [3.4.1] -* Placeholder effective in scrollable. + +- Placeholder effective in scrollable. # [3.4.0] -* Option to save image in read-only mode. + +- Option to save image in read-only mode. # [3.3.1] -* Pass any specified key in QuillEditor constructor to super. + +- Pass any specified key in QuillEditor constructor to super. # [3.3.0] -* Fixed Style toggle issue. + +- Fixed Style toggle issue. # [3.2.1] -* Added new translations. + +- Added new translations. # [3.2.0] -* Support multiple links insertion on the go. + +- Support multiple links insertion on the go. # [3.1.1] -* Add selection completed callback. + +- Add selection completed callback. # [3.1.0] -* Fixed image ontap functionality. + +- Fixed image ontap functionality. # [3.0.4] -* Add maxContentWidth constraint to editor. + +- Add maxContentWidth constraint to editor. # [3.0.3] -* Do not show caret on screen when the editor is not focused. + +- Do not show caret on screen when the editor is not focused. # [3.0.2] -* Fix launch link for read-only mode. + +- Fix launch link for read-only mode. # [3.0.1] -* Handle null value of Attribute.link. + +- Handle null value of Attribute.link. # [3.0.0] -* Launch link improvements. -* Removed QuillSimpleViewer. + +- Launch link improvements. +- Removed QuillSimpleViewer. # [2.5.2] -* Skip image when pasting. + +- Skip image when pasting. # [2.5.1] -* Bug fix for Desktop `Shift` + `Click` support. + +- Bug fix for Desktop `Shift` + `Click` support. # [2.5.0] -* Update checkbox list. + +- Update checkbox list. # [2.4.1] -* Desktop selection improvements. + +- Desktop selection improvements. # [2.4.0] -* Improve inline code style. + +- Improve inline code style. # [2.3.3] -* Improves selection rects to have consistent height regardless of individual segment text styles. + +- Improves selection rects to have consistent height regardless of individual segment text styles. # [2.3.2] -* Allow disabling floating cursor. + +- Allow disabling floating cursor. # [2.3.1] -* Preserve last newline character on delete. + +- Preserve last newline character on delete. # [2.3.0] -* Massive changes to support flutter 2.8. + +- Massive changes to support flutter 2.8. # [2.2.2] -* iOS - floating cursor. + +- iOS - floating cursor. # [2.2.1] -* Bug fix for imports supporting flutter 2.8. + +- Bug fix for imports supporting flutter 2.8. # [2.2.0] -* Support flutter 2.8. + +- Support flutter 2.8. # [2.1.1] -* Add methods of clearing editor and moving cursor. + +- Add methods of clearing editor and moving cursor. # [2.1.0] -* Add delete handler. + +- Add delete handler. # [2.0.23] -* Support custom replaceText handler. + +- Support custom replaceText handler. # [2.0.22] -* Fix attribute compare and fix font size parsing. + +- Fix attribute compare and fix font size parsing. # [2.0.21] -* Handle click on embed object. + +- Handle click on embed object. # [2.0.20] -* Improved UX/UI of Image widget. + +- Improved UX/UI of Image widget. # [2.0.19] -* When uploading a video, applying indicator. + +- When uploading a video, applying indicator. # [2.0.18] -* Make toolbar dividers optional. + +- Make toolbar dividers optional. # [2.0.17] -* Allow alignment of the toolbar icons to match WrapAlignment. + +- Allow alignment of the toolbar icons to match WrapAlignment. # [2.0.16] -* Add hide / show alignment buttons. + +- Add hide / show alignment buttons. # [2.0.15] -* Implement change cursor to SystemMouseCursors.click when hovering a link styled text. + +- Implement change cursor to SystemMouseCursors.click when hovering a link styled text. # [2.0.14] -* Enable customize the checkbox widget using DefaultListBlockStyle style. + +- Enable customize the checkbox widget using DefaultListBlockStyle style. # [2.0.13] -* Improve the scrolling performance by reducing the repaint areas. + +- Improve the scrolling performance by reducing the repaint areas. # [2.0.12] -* Fix the selection effect can't be seen as the textLine with background color. + +- Fix the selection effect can't be seen as the textLine with background color. # [2.0.11] -* Fix visibility of text selection handlers on scroll. + +- Fix visibility of text selection handlers on scroll. # [2.0.10] -* cursorConnt.color notify the text_line to repaint if it was disposed. + +- cursorConnt.color notify the text_line to repaint if it was disposed. # [2.0.9] -* Improve UX when trying to add a link. + +- Improve UX when trying to add a link. # [2.0.8] -* Adding translations to the toolbar. + +- Adding translations to the toolbar. # [2.0.7] -* Added theming options for toolbar icons and LinkDialog. + +- Added theming options for toolbar icons and LinkDialog. # [2.0.6] -* Avoid runtime error when placed inside TabBarView. + +- Avoid runtime error when placed inside TabBarView. # [2.0.5] -* Support inline code formatting. + +- Support inline code formatting. # [2.0.4] -* Enable history shortcuts for desktop. + +- Enable history shortcuts for desktop. # [2.0.3] -* Fix cursor when line contains image. + +- Fix cursor when line contains image. # [2.0.2] -* Address KeyboardListener class name conflict. + +- Address KeyboardListener class name conflict. # [2.0.1] -* Upgrade flutter_colorpicker to 0.5.0. + +- Upgrade flutter_colorpicker to 0.5.0. # [2.0.0] -* Text Alignment functions + Block Format standards. + +- Text Alignment functions + Block Format standards. # [1.9.6] -* Support putting QuillEditor inside a Scrollable view. + +- Support putting QuillEditor inside a Scrollable view. # [1.9.5] -* Skip image when pasting. + +- Skip image when pasting. # [1.9.4] -* Bug fix for cursor position when tapping at the end of line with image(s). + +- Bug fix for cursor position when tapping at the end of line with image(s). # [1.9.3] -* Bug fix when line only contains one image. + +- Bug fix when line only contains one image. # [1.9.2] -* Support for building custom inline styles. + +- Support for building custom inline styles. # [1.9.1] -* Cursor jumps to the most appropriate offset to display selection. + +- Cursor jumps to the most appropriate offset to display selection. # [1.9.0] -* Support inline image. + +- Support inline image. # [1.8.3] -* Updated quill_delta. + +- Updated quill_delta. # [1.8.2] -* Support mobile image alignment. + +- Support mobile image alignment. # [1.8.1] -* Support mobile custom size image. + +- Support mobile custom size image. # [1.8.0] -* Support entering link for image/video. + +- Support entering link for image/video. # [1.7.3] -* Bumps photo_view version. + +- Bumps photo_view version. # [1.7.2] -* Fix static analysis error. + +- Fix static analysis error. # [1.7.1] -* Support Youtube video. + +- Support Youtube video. # [1.7.0] -* Support video. + +- Support video. # [1.6.4] -* Bug fix for clear format button. + +- Bug fix for clear format button. # [1.6.3] -* Fixed dragging right handle scrolling issue. + +- Fixed dragging right handle scrolling issue. # [1.6.2] -* Fixed the position of the selection status drag handle. + +- Fixed the position of the selection status drag handle. # [1.6.1] -* Upgrade image_picker and flutter_colorpicker. + +- Upgrade image_picker and flutter_colorpicker. # [1.6.0] -* Support Multi Row Toolbar. + +- Support Multi Row Toolbar. # [1.5.0] -* Remove file_picker dependency. + +- Remove file_picker dependency. # [1.4.1] -* Remove filesystem_picker dependency. + +- Remove filesystem_picker dependency. # [1.4.0] -* Remove path_provider dependency. + +- Remove path_provider dependency. # [1.3.4] -* Add option to paintCursorAboveText. + +- Add option to paintCursorAboveText. # [1.3.3] -* Upgrade file_picker version. + +- Upgrade file_picker version. # [1.3.2] -* Fix copy/paste bug. + +- Fix copy/paste bug. # [1.3.1] -* New logo. + +- New logo. # [1.3.0] -* Support flutter 2.2.0. + +- Support flutter 2.2.0. # [1.2.2] -* Checkbox supports tapping. + +- Checkbox supports tapping. # [1.2.1] -* Indented position not holding while editing. + +- Indented position not holding while editing. # [1.2.0] -* Fix image button cancel causes crash. + +- Fix image button cancel causes crash. # [1.1.8] -* Fix height of empty line bug. + +- Fix height of empty line bug. # [1.1.7] -* Fix text selection in read-only mode. + +- Fix text selection in read-only mode. # [1.1.6] -* Remove universal_html dependency. + +- Remove universal_html dependency. # [1.1.5] -* Enable "Select", "Select All" and "Copy" in read-only mode. + +- Enable "Select", "Select All" and "Copy" in read-only mode. # [1.1.4] -* Fix text selection issue. + +- Fix text selection issue. # [1.1.3] -* Update example folder. + +- Update example folder. # [1.1.2] -* Add pedantic. + +- Add pedantic. # [1.1.1] -* Base64 image support. + +- Base64 image support. # [1.1.0] -* Support null safety. + +- Support null safety. # [1.0.9] -* Web support for raw editor and keyboard listener. + +- Web support for raw editor and keyboard listener. # [1.0.8] -* Support token attribute. + +- Support token attribute. # [1.0.7] -* Fix crash on web (dart:io). + +- Fix crash on web (dart:io). # [1.0.6] -* Add desktop support - WINDOWS, MACOS and LINUX. + +- Add desktop support - WINDOWS, MACOS and LINUX. # [1.0.5] -* Bug fix: Can not insert newline when Bold is toggled ON. + +- Bug fix: Can not insert newline when Bold is toggled ON. # [1.0.4] -* Upgrade photo_view to ^0.11.0. + +- Upgrade photo_view to ^0.11.0. # [1.0.3] -* Fix issue that text is not displayed while typing [WEB]. + +- Fix issue that text is not displayed while typing [WEB]. # [1.0.2] -* Update toolbar in sample home page. + +- Update toolbar in sample home page. # [1.0.1] -* Fix static analysis errors. + +- Fix static analysis errors. # [1.0.0] -* Support flutter 2.0. + +- Support flutter 2.0. # [1.0.0-dev.2] -* Improve link handling for tel, mailto and etc. + +- Improve link handling for tel, mailto and etc. # [1.0.0-dev.1] -* Upgrade prerelease SDK & Bump for master. + +- Upgrade prerelease SDK & Bump for master. # [0.3.5] -* Fix for cursor focus issues when keyboard is on. + +- Fix for cursor focus issues when keyboard is on. # [0.3.4] -* Improve link handling for tel, mailto and etc. + +- Improve link handling for tel, mailto and etc. # [0.3.3] -* More fix on cursor focus issue when keyboard is on. + +- More fix on cursor focus issue when keyboard is on. # [0.3.2] -* Fix cursor focus issue when keyboard is on. + +- Fix cursor focus issue when keyboard is on. # [0.3.1] -* cursor focus when keyboard is on. + +- cursor focus when keyboard is on. # [0.3.0] -* Line Height calculated based on font size. + +- Line Height calculated based on font size. # [0.2.12] -* Support placeholder. + +- Support placeholder. # [0.2.11] -* Fix static analysis error. + +- Fix static analysis error. # [0.2.10] -* Update TextInputConfiguration autocorrect to true in stable branch. + +- Update TextInputConfiguration autocorrect to true in stable branch. # [0.2.9] -* Update TextInputConfiguration autocorrect to true. + +- Update TextInputConfiguration autocorrect to true. # [0.2.8] -* Support display local image besides network image in stable branch. + +- Support display local image besides network image in stable branch. # [0.2.7] -* Support display local image besides network image. + +- Support display local image besides network image. # [0.2.6] -* Fix cursor after pasting. + +- Fix cursor after pasting. # [0.2.5] -* Toggle text/background color button in toolbar. + +- Toggle text/background color button in toolbar. # [0.2.4] -* Support the use of custom icon size in toolbar. + +- Support the use of custom icon size in toolbar. # [0.2.3] -* Support custom styles and image on local device storage without uploading. + +- Support custom styles and image on local device storage without uploading. # [0.2.2] -* Update git repo. + +- Update git repo. # [0.2.1] -* Fix static analysis error. + +- Fix static analysis error. # [0.2.0] -* Add checked/unchecked list button in toolbar. + +- Add checked/unchecked list button in toolbar. # [0.1.8] -* Support font and size attributes. + +- Support font and size attributes. # [0.1.7] -* Support checked/unchecked list. + +- Support checked/unchecked list. # [0.1.6] -* Fix getExtentEndpointForSelection. + +- Fix getExtentEndpointForSelection. # [0.1.5] -* Support text alignment. + +- Support text alignment. # [0.1.4] -* Handle url with trailing spaces. + +- Handle url with trailing spaces. # [0.1.3] -* Handle cursor position change when undo/redo. + +- Handle cursor position change when undo/redo. # [0.1.2] -* Handle more text colors. + +- Handle more text colors. # [0.1.1] -* Fix cursor issue when undo. + +- Fix cursor issue when undo. # [0.1.0] -* Fix insert image. + +- Fix insert image. # [0.0.9] -* Handle rgba color. + +- Handle rgba color. # [0.0.8] -* Fix launching url. + +- Fix launching url. # [0.0.7] -* Handle multiple image inserts. + +- Handle multiple image inserts. # [0.0.6] -* More toolbar functionality. + +- More toolbar functionality. # [0.0.5] -* Update example. + +- Update example. # [0.0.4] -* Update example. + +- Update example. # [0.0.3] -* Update home page meta data. + +- Update home page meta data. # [0.0.2] -* Support image upload and launch url in read-only mode. + +- Support image upload and launch url in read-only mode. # [0.0.1] -* Rich text editor based on Quill Delta. + +- Rich text editor based on Quill Delta. diff --git a/README.md b/README.md index 06fcd9e8..3c3ca406 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -

+

A rich text editor for Flutter

@@ -20,20 +20,35 @@ [github-forks-badge]: https://img.shields.io/github/forks/singerdmx/flutter-quill.svg?style=for-the-badge&logo=github&logoColor=ffffff [github-forks-link]: https://github.com/singerdmx/flutter-quill/network/members +--- 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: https://bulletjournal.us/home/index.html +Demo App: [BULLET JOURNAL](https://bulletjournal.us/home/index.html) + +Pub: [FlutterQuill] -Pub: https://pub.dev/packages/flutter_quill +## Demo + +

+ 1 + 1 +

+ +

+ 1 + 1 +

+ +--- ## Usage See the `example` directory for a minimal example of how to use FlutterQuill. You typically just need to instantiate a controller: -``` +```dart QuillController _controller = QuillController.basic(); ``` @@ -54,6 +69,7 @@ Column( ], ) ``` + Check out [Sample Page] for advanced usage. ## Input / Output @@ -63,9 +79,9 @@ This library uses [Quill] as an internal data format. * Use `_controller.document.toDelta()` to extract the deltas. * Use `_controller.document.toPlainText()` to extract plain text. -FlutterQuill provides some JSON serialisation support, so that you can save and open documents. To save a document as JSON, do something like the following: +FlutterQuill provides some JSON serialization support, so that you can save and open documents. To save a document as JSON, do something like the following: -``` +```dart var json = jsonEncode(_controller.document.toDelta().toJson()); ``` @@ -73,11 +89,12 @@ You can then write this to storage. To open a FlutterQuill editor with an existing JSON representation that you've previously stored, you can do something like this: -``` -var myJSON = jsonDecode(incomingJSONText); +```dart +var myJSON = jsonDecode(r'{"insert":"hello\n"}'); _controller = QuillController( document: Document.fromJson(myJSON), - selection: TextSelection.collapsed(offset: 0)); + selection: TextSelection.collapsed(offset: 0), + ); ``` ## Web @@ -85,38 +102,44 @@ _controller = QuillController( For web development, use `flutter config --enable-web` for flutter or use [ReactQuill] for React. It is required to provide `EmbedBuilder`, e.g. [defaultEmbedBuildersWeb](https://github.com/singerdmx/flutter-quill/blob/master/example/lib/universal_ui/universal_ui.dart#L99). -Also it is required to provide `webImagePickImpl`, e.g. [Sample Page](https://github.com/singerdmx/flutter-quill/blob/master/example/lib/pages/home_page.dart#L241). +Also it is required to provide `webImagePickImpl`, e.g. [Sample Page](https://github.com/singerdmx/flutter-quill/blob/master/example/lib/pages/home_page.dart#L317). ## Desktop -It is required to provide `filePickImpl` for toolbar image button, e.g. [Sample Page](https://github.com/singerdmx/flutter-quill/blob/master/example/lib/pages/home_page.dart#L221). +It is required to provide `filePickImpl` for toolbar image button, e.g. [Sample Page](https://github.com/singerdmx/flutter-quill/blob/master/example/lib/pages/home_page.dart#L297). ## Configuration -The `QuillToolbar` class lets you customise which formatting options are available. +The `QuillToolbar` class lets you customize which formatting options are available. [Sample Page] provides sample code for advanced usage and configuration. ### Font Size -Within the editor toolbar, a drop-down with font-sizing capabilities is available. This can be enabled or disabled with `showFontSize`. + +Within the editor toolbar, a drop-down with font-sizing capabilities is available. This can be enabled or disabled with `showFontSize`. When enabled, the default font-size values can be modified via _optional_ `fontSizeValues`. `fontSizeValues` accepts a `Map` consisting of a `String` title for the font size and a `String` value for the font size. Example: -``` + +```dart fontSizeValues: const {'Small': '8', 'Medium': '24.5', 'Large': '46'} ``` -Font size can be cleared with a value of `0`, for example: -``` +Font size can be cleared with a value of `0`, for example: + +```dart fontSizeValues: const {'Small': '8', 'Medium': '24.5', 'Large': '46', 'Clear': '0'} ``` ### Font Family + To use your own fonts, update your [assets folder](https://github.com/singerdmx/flutter-quill/tree/master/example/assets/fonts) and pass in `fontFamilyValues`. More details at [this change](https://github.com/singerdmx/flutter-quill/commit/71d06f6b7be1b7b6dba2ea48e09fed0d7ff8bbaa), [this article](https://stackoverflow.com/questions/55075834/fontfamily-property-not-working-properly-in-flutter) and [this](https://www.flutterbeads.com/change-font-family-flutter/). ### Custom Buttons + You may add custom buttons to the _end_ of the toolbar, via the `customButtons` option, which is a `List` of `QuillCustomButton`. To add an Icon, we should use a new QuillCustomButton class -``` + +```dart QuillCustomButton( icon:Icons.ac_unit, onTap: () { @@ -126,7 +149,8 @@ To add an Icon, we should use a new QuillCustomButton class ``` Each `QuillCustomButton` is used as part of the `customButtons` option as follows: -``` + +```dart QuillToolbar.basic( (...), customButtons: [ @@ -153,16 +177,15 @@ QuillToolbar.basic( ] ``` - ## Embed Blocks As of version 6.0, embed blocks are not provided by default as part of this package. Instead, this package provides an interface to all the user to provide there own implementations for embed blocks. Implementations for image, video and formula embed blocks is proved in a separate package [`flutter_quill_extensions`](https://pub.dev/packages/flutter_quill_extensions). -Provide a list of embed +Provide a list of embed ### Using the embed blocks from `flutter_quill_extensions` -``` +```dart import 'package:flutter_quill_extensions/flutter_quill_extensions.dart'; QuillEditor.basic( @@ -176,12 +199,11 @@ QuillToolbar.basic( ); ``` - - ### Custom Size Image for Mobile Define `mobileWidth`, `mobileHeight`, `mobileMargin`, `mobileAlignment` as follows: -``` + +```dart { "insert": { "image": "https://user-images.githubusercontent.com/122956/72955931-ccc07900-3d52-11ea-89b1-d468a6e2aa2b.png" @@ -219,7 +241,7 @@ After that, we need to map this "notes" type into a widget. In that case, I used Don't forget to add this method to the `QuillEditor` after that! ```dart -class NotesEmbedBuilder implements EmbedBuilder { +class NotesEmbedBuilder extends EmbedBuilder { NotesEmbedBuilder({required this.addEditNote}); Future Function(BuildContext context, {Document? document}) addEditNote; @@ -233,6 +255,7 @@ class NotesEmbedBuilder implements EmbedBuilder { QuillController controller, Embed node, bool readOnly, + bool inline, ) { final notes = NotesBlockEmbed(node.value.data).document; @@ -297,7 +320,7 @@ Future _addEditNote(BuildContext context, {Document? document}) async { final length = controller.selection.extentOffset - index; if (isEditing) { - final offset = getEmbedNode(controller, controller.selection.start).item1; + final offset = getEmbedNode(controller, controller.selection.start).offset; controller.replaceText( offset, 1, block, TextSelection.collapsed(offset: offset)); } else { @@ -312,9 +335,8 @@ And voila, we have a custom widget inside of the rich text editor! 1

-> For more info and a video example, see the [PR of this feature](https://github.com/singerdmx/flutter-quill/pull/877) - -> For more details, check out [this YouTube video](https://youtu.be/pI5p5j7cfHc) +> 1. For more info and a video example, see the [PR of this feature](https://github.com/singerdmx/flutter-quill/pull/877) +> 2. For more details, check out [this YouTube video](https://youtu.be/pI5p5j7cfHc) ### Translation @@ -325,13 +347,15 @@ QuillToolbar(locale: Locale('fr'), ...) QuillEditor(locale: Locale('fr'), ...) ``` -Currently, translations are available for these 22 locales: +Currently, translations are available for these 27 locales: * `Locale('en')` * `Locale('ar')` +* `Locale('cs')` * `Locale('de')` * `Locale('da')` * `Locale('fr')` +* `Locale('he')` * `Locale('zh', 'cn')` * `Locale('zh', 'hk')` * `Locale('ko')` @@ -344,29 +368,44 @@ Currently, translations are available for these 22 locales: * `Locale('pl')` * `Locale('vi')` * `Locale('id')` +* `Locale('it')` +* `Locale('ms')` * `Locale('nl')` * `Locale('no')` * `Locale('fa')` * `Locale('hi')` * `Locale('sr')` +* `Locale('ja')` #### Contributing to translations + The translation file is located at [toolbar.i18n.dart](lib/src/translations/toolbar.i18n.dart). Feel free to contribute your own translations, just copy the English translations map and replace the values with your translations. Then open a pull request so everyone can benefit from your translations! ---- +## Conversion to HTML -

- 1 - 1 -

+Having your document stored in Quill Delta format is sometimes not enough. Often you'll need to convert +it to other formats such as HTML in order to publish it, or send an email. One option is to use +[vsc_quill_delta_to_html](https://pub.dev/packages/vsc_quill_delta_to_html) to convert your document +to HTML. This package has full support for all Quill operations - including images, videos, formulas, +tables, and mentions. Conversion can be performed in vanilla Dart (i.e., server-side or CLI) or in Flutter. +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 -

- 1 - 1 -

+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 @@ -375,10 +414,6 @@ The translation file is located at [toolbar.i18n.dart](lib/src/translations/tool "https://user-images.githubusercontent.com/122956/72955931-ccc07900-3d52-11ea-89b1-d468a6e2aa2b.png" width="150px" height="150px"> ---- - -[Chinese Documentation](./doc_cn.md) - [Quill]: https://quilljs.com/docs/formats [Flutter]: https://github.com/flutter/flutter [FlutterQuill]: https://pub.dev/packages/flutter_quill @@ -387,3 +422,7 @@ The translation file is located at [toolbar.i18n.dart](lib/src/translations/tool [Slack Group]: https://join.slack.com/t/bulletjournal1024/shared_invite/zt-fys7t9hi-ITVU5PGDen1rNRyCjdcQ2g [Sample Page]: https://github.com/singerdmx/flutter-quill/blob/master/example/lib/pages/home_page.dart [Code Introduction]: https://github.com/singerdmx/flutter-quill/blob/master/CodeIntroduction.md + +
+ +[中文文档](./doc_cn.md) diff --git a/doc_cn.md b/doc_cn.md index 2bf9de93..0286abf3 100644 --- a/doc_cn.md +++ b/doc_cn.md @@ -1,4 +1,4 @@ -

+

支持 Flutter 平台的富文本编辑器

@@ -20,24 +20,41 @@ [github-forks-badge]: https://img.shields.io/github/forks/singerdmx/flutter-quill.svg?style=for-the-badge&logo=github&logoColor=ffffff [github-forks-link]: https://github.com/singerdmx/flutter-quill/network/members +[原文档](./README.md) -FlutterQuill 是一个富文本编辑器,同样也是 [Quill] 在 [Flutter] 的版本。 +--- + +`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) 来进行讨论 + +示例 `App` : [BULLET JOURNAL](https://bulletjournal.us/home/index.html) + +`Pub` : [FlutterQuill](https://pub.dev/packages/flutter_quill) -该库是为移动平台构建的 “ 所见即所得 ” 的富文本编辑器,同时我们还正在对 Web 平台进行兼容。查看我们的 [Youtube 播放列表] 或 [代码介绍] 以了解代码的详细内容。你可以加入我们的 [Slack Group] 来进行讨论。 +## 效果展示 + +

+ 1 + 1 +

-Demo App: https://bulletjournal.us/home/index.html +

+ 1 + 1 +

-Pub: https://pub.dev/packages/flutter_quill +--- ## 用法 -查看 `示例` 目录来学习 FlutterQuill 最简单的使用方法,你通常只需要实例化一个控制器: +查看 `示例` 目录来学习 `FlutterQuill` 最简单的使用方法,你通常只需要一个控制器实例: -``` +```dart QuillController _controller = QuillController.basic(); ``` -然后在你的 App 中嵌入工具栏和编辑器,例如: +然后在你的 `App` 中嵌入工具栏 `QuillToolbar` 和编辑器 `QuillEditor` ,如: ```dart Column( @@ -47,75 +64,88 @@ Column( child: Container( child: QuillEditor.basic( controller: _controller, - readOnly: false, // true for view only mode + readOnly: false, // 为 true 时只读 ), ), ) ], ) ``` -查看 [示例页面] 以了解高级用户。 -## 输入 / 输出 +查看 [示例页面](https://github.com/singerdmx/flutter-quill/blob/master/example/lib/pages/home_page.dart) 查看高级用法 -该库使用 [Quill] 作为内部数据格式。 +## 保存和读取 -* 使用 `_controller.document.toDelta()` 获取增量。 -* 使用 `_controller.document.toPlainText()` 获取纯文本。 +该库使用 [Quill 格式](https://quilljs.com/docs/formats) 作为内部数据格式 -FlutterQuill 提供了一些 JSON 序列化支持,以便您可以保存和打开文档。 要将文档保存为 JSON 类型,请执行以下操作: +* 使用 `_controller.document.toDelta()` 获取 [Delta 格式](https://quilljs.com/docs/delta/) +* 使用 `_controller.document.toPlainText()` 获取纯文本 -``` +`FlutterQuill` 提供了一些 `JSON` 序列化支持,以便你保存和打开文档 + +要将文档转化为 `JSON` 类型,请执行以下操作: + +```dart var json = jsonEncode(_controller.document.toDelta().toJson()); ``` -然后你就可以将其存储。 - -想要 FlutterQuill 编辑器使用你之前存储的 JSON 数据,请执行以下操作: +要将 `FlutterQuill` 使用之前存储的 `JSON` 数据,请执行以下操作: -``` -var myJSON = jsonDecode(incomingJSONText); +```dart +var myJSON = jsonDecode(r'{"insert":"hello\n"}'); _controller = QuillController( document: Document.fromJson(myJSON), - selection: TextSelection.collapsed(offset: 0)); + selection: TextSelection.collapsed(offset: 0), + ); ``` -## Web -对于 web 开发,请执行 `flutter config --enable-web` 来获取对 flutter 的支持或使用 [ReactQuill] 获取对 React 的支持。 +## Web 端 + +对于 `Web` 开发,请执行 `flutter config --enable-web` 来获取 `Flutter` 的支持,或使用 [ReactQuill](https://github.com/zenoamaro/react-quill) 获取对 `React` 的支持 -进行 Web 开发需要提供 `EmbedBuilder`, 参考:[defaultEmbedBuilderWeb](https://github.com/singerdmx/flutter-quill/blob/master/example/lib/universal_ui/universal_ui.dart#L29). -进行 Web 开发还需要提供 `webImagePickImpl`, 参考: [示例页面](https://github.com/singerdmx/flutter-quill/blob/master/example/lib/pages/home_page.dart#L225). +进行 `Web` 开发需要提供 `EmbedBuilder` ,参见 [defaultEmbedBuilderWeb](https://github.com/singerdmx/flutter-quill/blob/master/example/lib/universal_ui/universal_ui.dart#L29) +进行 `Web` 开发还需要提供 `webImagePickImpl` ,参见 [示例页面](https://github.com/singerdmx/flutter-quill/blob/master/example/lib/pages/home_page.dart#L225) -## Desktop +## 桌面端 -在桌面端进行工具栏按钮开发,需要提供 `filePickImpl`。参考: [示例页面](https://github.com/singerdmx/flutter-quill/blob/master/example/lib/pages/home_page.dart#L205). +进行桌面端工具栏按钮开发需要提供 `filePickImpl` ,参见 [示例页面](https://github.com/singerdmx/flutter-quill/blob/master/example/lib/pages/home_page.dart#L205) ## 配置 -`QuillToolbar` 类允许您自定义可用的格式选项。[示例页面] 提供了高级使用和配置的示例代码。 +`QuillToolbar` 类允许你自定义可用的格式选项,参见 [示例页面](https://github.com/singerdmx/flutter-quill/blob/master/example/lib/pages/home_page.dart) 提供了高级使用和配置的示例代码 ### 字号 -在编辑器工具栏中,提供了具有字号功能的下拉菜单。 这可以通过 `showFontSize` 启用或禁用。 -启用后,可以通过*可选的* `fontSizeValues` 属性修改默认字号。 `fontSizeValues` 接受一个 `Map`,其中包含一个 `String` 类型的标题和一个 `String` 类型的字号。 例子: -``` -fontSizeValues: const {'Small': '8', 'Medium': '24.5', 'Large': '46'} +在工具栏中提供了选择字号的下拉菜单,可通过 `showFontSize` 来启用或禁用 + +启用后,可以通过 *可选的* `fontSizeValues` 属性修改默认字号 + +`fontSizeValues` 接收一个 `Map`,其中包含一个 `String` 类型的标题和一个 `String` 类型的字号,如: + +```dart +fontSizeValues: const {'小字号': '8', '中字号': '24.5', '大字号': '46'} ``` 字体大小可以使用 `0` 值清除,例如: -``` -fontSizeValues: const {'Small': '8', 'Medium': '24.5', 'Large': '46', 'Clear': '0'} + +```dart +fontSizeValues: const {'小字号': '8', '中字号': '24.5', '大字号': '46', '清除': '0'} ``` ### 字体 -想要使用你自己的字体,请更新你的 [assets folder](https://github.com/singerdmx/flutter-quill/tree/master/example/assets/fonts) 并且传入 `fontFamilyValues`。详情内容请查看 [this change](https://github.com/singerdmx/flutter-quill/commit/71d06f6b7be1b7b6dba2ea48e09fed0d7ff8bbaa), [this article](https://stackoverflow.com/questions/55075834/fontfamily-property-not-working-properly-in-flutter) 和 [this](https://www.flutterbeads.com/change-font-family-flutter/)。 + +想要使用你自己的字体,请更新你的 [assets folder](https://github.com/singerdmx/flutter-quill/tree/master/example/assets/fonts) 并且传入 `fontFamilyValues` + +详见 [这个 Commit](https://github.com/singerdmx/flutter-quill/commit/71d06f6b7be1b7b6dba2ea48e09fed0d7ff8bbaa) 和 [这篇文章](https://stackoverflow.com/questions/55075834/fontfamily-property-not-working-properly-in-flutter) 以及 [这个教程](https://www.flutterbeads.com/change-font-family-flutter/) ### 自定义按钮 -您可以通过 `customButtons` 可选参数将自定义按钮添加到工具栏的*末尾*,该参数接收的了行是 `QuillCustomButton` 的 `List`。 -要添加一个 Icon,我们应该实例化一个新的新的 `QuillCustomButton` -``` +你可以通过 `customButtons` 可选参数将自定义按钮添加到工具栏的 *末尾* ,该参数接收 `QuillCustomButton` 的 `List` + +要添加一个 `Icon` ,我们应该实例化一个新的 `QuillCustomButton` + +```dart QuillCustomButton( icon:Icons.ac_unit, onTap: () { @@ -124,8 +154,9 @@ fontSizeValues: const {'Small': '8', 'Medium': '24.5', 'Large': '46', 'Clear': ' ), ``` -每个 `QuillCustomButton` 都是 `customButtons` 可选参数的一部分,如下所示: -``` +每个 `QuillCustomButton` 都是 `customButtons` 可选参数的一部分,如: + +```dart QuillToolbar.basic( (...), customButtons: [ @@ -135,14 +166,12 @@ QuillToolbar.basic( debugPrint('snowflake1'); } ), - QuillCustomButton( icon:Icons.ac_unit, onTap: () { debugPrint('snowflake2'); } ), - QuillCustomButton( icon:Icons.ac_unit, onTap: () { @@ -152,10 +181,33 @@ QuillToolbar.basic( ] ``` +## 嵌入块 + +自 `6.0` 版本,本库不默认支持嵌入块,反之本库提供接口给所有用户来创建所需的嵌入块。 + +若需要图片、视频、公式块的支持,请查看独立库 [`flutter_quill_extensions`](https://pub.dev/packages/flutter_quill_extensions) + +### 根据 `flutter_quill_extensions` 使用图片、视频、公式等自定义嵌入块 + +```dart +import 'package:flutter_quill_extensions/flutter_quill_extensions.dart'; + +QuillEditor.basic( + controller: controller, + embedBuilders: FlutterQuillEmbeds.builders(), +); + +QuillToolbar.basic( + controller: controller, + embedButtons: FlutterQuillEmbeds.buttons(), +); +``` + ### 移动端上自定义图片尺寸 定义`mobileWidth`、`mobileHeight`、`mobileMargin`、`mobileAlignment`如下: -``` + +```dart { "insert": { "image": "https://user-images.githubusercontent.com/122956/72955931-ccc07900-3d52-11ea-89b1-d468a6e2aa2b.png" @@ -167,13 +219,14 @@ QuillToolbar.basic( ``` ### 自定义嵌入块 -有时您想在文本中添加一些自定义内容或者是自定义小部件。 比如向文本添加注释,或者在文本编辑器中添加的任何自定义内容。 -您唯一需要做的就是添加一个 `CustomBlockEmbed` 并将其映射到 `customElementsEmbedBuilder` 中,以将自定义块内的数据转换为一个 widget! +有时你想在文本中添加一些自定义内容或者是自定义小部件 -例子: +比如向文本添加注释,或者在文本编辑器中添加的任何自定义内容 -从 `CustomBlockEmbed` 开始,我们在这里扩展它并添加对 'Note' widget 的方法,这就是 `Document`,`flutter_quill` 使用它来呈现富文本。 +你唯一需要做的就是添加一个 `CustomBlockEmbed` 并将其映射到 `customElementsEmbedBuilder` 中,以将自定义块内的数据转换为一个 `Widget` ,如: + +先从 `CustomBlockEmbed` `extent` 出一个 `NotesBlockEmbed` 类,并添加两个方法以返回 `Document` 用以 `flutter_quill` 渲染富文本 ```dart class NotesBlockEmbed extends CustomBlockEmbed { @@ -188,43 +241,54 @@ class NotesBlockEmbed extends CustomBlockEmbed { } ``` -然后,我们需要将这个 “notes” 类型映射到 widget 中。在例子中,我使用 `ListTile` 来显示它,使用 `onTap` 方法俩编辑内容,另外不要忘记将此方法添加到 `QuillEditor` 中。 +然后,我们需要将这个 `notes` 类型映射到其想渲染出的 `Widget` 中 + +在这里我们使用 `ListTile` 来渲染它,并使用 `onTap` 方法来编辑内容,最后不要忘记将此方法添加到 `QuillEditor` 中 ```dart -Widget customElementsEmbedBuilder( - BuildContext context, - QuillController controller, - CustomBlockEmbed block, - bool readOnly, - void Function(GlobalKey videoContainerKey)? onVideoInit, -) { - switch (block.type) { - case 'notes': - final notes = NotesBlockEmbed(block.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), - ), +class NotesEmbedBuilder extends EmbedBuilder { + NotesEmbedBuilder({required this.addEditNote}); + + Future Function(BuildContext context, {Document? document}) addEditNote; + + @override + String get key => 'notes'; + + @override + Widget build( + BuildContext context, + QuillController controller, + Embed node, + bool readOnly, + bool inline, + ) { + 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, ), - ); - default: - return const SizedBox(); + leading: const Icon(Icons.notes), + onTap: () => addEditNote(context, document: notes), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + side: const BorderSide(color: Colors.grey), + ), + ), + ); } } ``` -然后,编写一个方法来添加/编辑内容,`showDialog` 方法显示 Quill 编辑器以编辑内容,用户编辑完成后,需要检查文档是否有内容,如果有内容,在 `CustomBlockEmbed` 中添加/编辑 `NotesBlockEmbed`(注意,如果没有在 `NotesBlockEmbed` 中传递 `CustomBlockEmbed` ,编辑将不会生效)。 +最后我们编写一个方法来添加或编辑内容 + +`showDialog` 方法先显示 `Quill` 编辑器以让用户编辑内容,编辑完成后,我们需要检查文档是否有内容,若有则在 `BlockEmbed.custom` 传入添加或编辑了的 `NotesBlockEmbed` + +注意,如果我们没有在 `BlockEmbed.custom` 传如我们所自定义的 `CustomBlockEmbed` ,那么编辑将不会生效 ```dart Future _addEditNote(BuildContext context, {Document? document}) async { @@ -265,7 +329,7 @@ Future _addEditNote(BuildContext context, {Document? document}) async { final length = controller.selection.extentOffset - index; if (isEditing) { - final offset = getEmbedNode(controller, controller.selection.start).item1; + final offset = getEmbedNode(controller, controller.selection.start).offset; controller.replaceText( offset, 1, block, TextSelection.collapsed(offset: offset)); } else { @@ -274,34 +338,35 @@ Future _addEditNote(BuildContext context, {Document? document}) async { } ``` -这样我们就成功的在富文本编辑器中添加了一个自定义小组件。 +这样我们就成功的在富文本编辑器中添加了一个自定义小组件

1

-> 更多信息和视频示例,请参阅 [PR of this feature](https://github.com/singerdmx/flutter-quill/pull/877) - -> 有关更多详细信息,请查看 [this YouTube video](https://youtu.be/pI5p5j7cfHc) +> 1. 更多信息和视频示例,请参阅 [这个特性的 PR](https://github.com/singerdmx/flutter-quill/pull/877) +> 2. 有关更多详细信息,请查看 [这个 Youtube 视频](https://youtu.be/pI5p5j7cfHc) ### 翻译 -该库为 quill 工具栏和编辑器提供翻译,除非您设置自己的语言环境,否则它将遵循系统语言环境: +该库为 `QuillToolbar` 和 `QuillEditor` 提供了部分翻译,且若你未设置自己的语言环境,则它将使用系统语言环境: ```dart QuillToolbar(locale: Locale('fr'), ...) QuillEditor(locale: Locale('fr'), ...) ``` -目前,可提供以下 22 种语言环境的翻译: +目前,可提供以下 27 种语言环境的翻译: * `Locale('en')` * `Locale('ar')` +* `Locale('cs')` * `Locale('de')` * `Locale('da')` * `Locale('fr')` -* `Locale('zh', 'CN')` -* `Locale('zh', 'HK')` +* `Locale('he')` +* `Locale('zh', 'cn')` +* `Locale('zh', 'hk')` * `Locale('ko')` * `Locale('ru')` * `Locale('es')` @@ -312,42 +377,38 @@ QuillEditor(locale: Locale('fr'), ...) * `Locale('pl')` * `Locale('vi')` * `Locale('id')` +* `Locale('it')` +* `Locale('ms')` * `Locale('nl')` * `Locale('no')` * `Locale('fa')` * `Locale('hi')` * `Locale('sr')` +* `Locale('jp')` #### 贡献翻译 -翻译文件位于 [toolbar.i18n.dart](lib/src/translations/toolbar.i18n.dart)。 随意贡献您自己的翻译,只需复制英文翻译映射并将值替换为您的翻译。 然后打开一个拉取请求,这样每个人都可以从您的翻译中受益! ---- +翻译文件位于 [toolbar.i18n.dart](lib/src/translations/toolbar.i18n.dart) -

- 1 - 1 -

+随意贡献你自己的翻译,只需复制英文翻译映射并将值替换为你的翻译即可 +然后打开一个拉取请求,这样每个人都可以从你的翻译中受益! -

- 1 - 1 -

+### 转化至 HTML + +将你的文档转为 `Quill Delta` 格式有时还不够,通常你需要将其转化为其他如 `HTML` 格式来分发他,或作为邮件发出 +一个方案是使用 [vsc_quill_delta_to_html](https://pub.dev/packages/vsc_quill_delta_to_html) `Flutter` 包来转化至 `HTML` 格式。此包支持所以的 `Quill` 操作,包含图片、视频、公式、表格和注释 +转化过程可以在 `vanilla Dart` 如服务器端或 `CLI` 执行,也可在 `Flutter` 中执行 -## 帮助 +其是流行且成熟的 [quill-delta-to-html](https://www.npmjs.com/package/quill-delta-to-html) `Typescript/Javascript` 包的 `Dart` 部分 + +--- + +## 赞助 - -[Quill]: https://quilljs.com/docs/formats -[Flutter]: https://github.com/flutter/flutter -[FlutterQuill]: https://pub.dev/packages/flutter_quill -[ReactQuill]: https://github.com/zenoamaro/react-quill -[Youtube 播放列表]: https://youtube.com/playlist?list=PLbhaS_83B97vONkOAWGJrSXWX58et9zZ2 -[Slack Group]: https://join.slack.com/t/bulletjournal1024/shared_invite/zt-fys7t9hi-ITVU5PGDen1rNRyCjdcQ2g -[示例页面]: https://github.com/singerdmx/flutter-quill/blob/master/example/lib/pages/home_page.dart -[代码介绍]: https://github.com/singerdmx/flutter-quill/blob/master/CodeIntroduction.md diff --git a/example/ios/Flutter/AppFrameworkInfo.plist b/example/ios/Flutter/AppFrameworkInfo.plist index f2872cf4..4f8d4d24 100644 --- a/example/ios/Flutter/AppFrameworkInfo.plist +++ b/example/ios/Flutter/AppFrameworkInfo.plist @@ -21,6 +21,6 @@ CFBundleVersion 1.0 MinimumOSVersion - 9.0 + 11.0 diff --git a/example/ios/Podfile b/example/ios/Podfile index 1e8c3c90..88359b22 100644 --- a/example/ios/Podfile +++ b/example/ios/Podfile @@ -1,5 +1,5 @@ # Uncomment this line to define a global platform for your project -# platform :ios, '9.0' +# platform :ios, '11.0' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' diff --git a/example/ios/Runner.xcodeproj/project.pbxproj b/example/ios/Runner.xcodeproj/project.pbxproj index 1cce8762..3d83c0c2 100644 --- a/example/ios/Runner.xcodeproj/project.pbxproj +++ b/example/ios/Runner.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 50; + objectVersion = 54; objects = { /* Begin PBXBuildFile section */ @@ -210,6 +210,7 @@ /* Begin PBXShellScriptBuildPhase section */ 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; buildActionMask = 2147483647; files = ( ); @@ -241,6 +242,7 @@ }; 9740EEB61CF901F6004384FC /* Run Script */ = { isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; buildActionMask = 2147483647; files = ( ); @@ -351,7 +353,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 9.0; + IPHONEOS_DEPLOYMENT_TARGET = 11.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; @@ -433,7 +435,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 9.0; + IPHONEOS_DEPLOYMENT_TARGET = 11.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; @@ -482,7 +484,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 9.0; + IPHONEOS_DEPLOYMENT_TARGET = 11.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; diff --git a/example/ios/Runner/Info.plist b/example/ios/Runner/Info.plist index 55f5c308..b8fc1f1d 100644 --- a/example/ios/Runner/Info.plist +++ b/example/ios/Runner/Info.plist @@ -45,5 +45,7 @@ CADisableMinimumFrameDurationOnPhone + UIApplicationSupportsIndirectInputEvents + diff --git a/example/lib/pages/home_page.dart b/example/lib/pages/home_page.dart index fcff61f2..9e3bcb02 100644 --- a/example/lib/pages/home_page.dart +++ b/example/lib/pages/home_page.dart @@ -12,11 +12,16 @@ import 'package:flutter_quill/flutter_quill.dart' hide Text; import 'package:flutter_quill_extensions/flutter_quill_extensions.dart'; import 'package:path/path.dart'; import 'package:path_provider/path_provider.dart'; -import 'package:tuple/tuple.dart'; import '../universal_ui/universal_ui.dart'; import 'read_only_page.dart'; +enum _SelectionType { + none, + word, + // line, +} + class HomePage extends StatefulWidget { @override _HomePageState createState() => _HomePageState(); @@ -25,6 +30,14 @@ class HomePage extends StatefulWidget { class _HomePageState extends State { QuillController? _controller; final FocusNode _focusNode = FocusNode(); + Timer? _selectAllTimer; + _SelectionType _selectionType = _SelectionType.none; + + @override + void dispose() { + _selectAllTimer?.cancel(); + super.dispose(); + } @override void initState() { @@ -78,29 +91,74 @@ class _HomePageState extends State { color: Colors.grey.shade800, child: _buildMenuBar(context), ), - body: RawKeyboardListener( - focusNode: FocusNode(), - onKey: (event) { - if (event.data.isControlPressed && event.character == 'b') { - if (_controller! - .getSelectionStyle() - .attributes - .keys - .contains('bold')) { - _controller! - .formatSelection(Attribute.clone(Attribute.bold, null)); - } else { - _controller!.formatSelection(Attribute.bold); - } - } - }, - child: _buildWelcomeEditor(context), - ), + body: _buildWelcomeEditor(context), ); } + bool _onTripleClickSelection() { + final controller = _controller!; + + _selectAllTimer?.cancel(); + _selectAllTimer = null; + + // If you want to select all text after paragraph, uncomment this line + // if (_selectionType == _SelectionType.line) { + // final selection = TextSelection( + // baseOffset: 0, + // extentOffset: controller.document.length, + // ); + + // controller.updateSelection(selection, ChangeSource.REMOTE); + + // _selectionType = _SelectionType.none; + + // return true; + // } + + if (controller.selection.isCollapsed) { + _selectionType = _SelectionType.none; + } + + if (_selectionType == _SelectionType.none) { + _selectionType = _SelectionType.word; + _startTripleClickTimer(); + return false; + } + + if (_selectionType == _SelectionType.word) { + final child = controller.document.queryChild( + controller.selection.baseOffset, + ); + final offset = child.node?.documentOffset ?? 0; + final length = child.node?.length ?? 0; + + final selection = TextSelection( + baseOffset: offset, + extentOffset: offset + length, + ); + + controller.updateSelection(selection, ChangeSource.REMOTE); + + // _selectionType = _SelectionType.line; + + _selectionType = _SelectionType.none; + + _startTripleClickTimer(); + + return true; + } + + return false; + } + + void _startTripleClickTimer() { + _selectAllTimer = Timer(const Duration(milliseconds: 900), () { + _selectionType = _SelectionType.none; + }); + } + Widget _buildWelcomeEditor(BuildContext context) { - var quillEditor = QuillEditor( + Widget quillEditor = QuillEditor( controller: _controller!, scrollController: ScrollController(), scrollable: true, @@ -108,9 +166,13 @@ class _HomePageState extends State { 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( @@ -119,8 +181,8 @@ class _HomePageState extends State { height: 1.15, fontWeight: FontWeight.w300, ), - const Tuple2(16, 0), - const Tuple2(0, 0), + const VerticalSpacing(16, 0), + const VerticalSpacing(0, 0), null), sizeSmall: const TextStyle(fontSize: 9), ), @@ -140,6 +202,9 @@ class _HomePageState extends State { placeholder: 'Add content', expands: false, padding: EdgeInsets.zero, + onTapUp: (details, p1) { + return _onTripleClickSelection(); + }, customStyles: DefaultStyles( h1: DefaultTextBlockStyle( const TextStyle( @@ -148,12 +213,15 @@ class _HomePageState extends State { height: 1.15, fontWeight: FontWeight.w300, ), - const Tuple2(16, 0), - const Tuple2(0, 0), + const VerticalSpacing(16, 0), + const VerticalSpacing(0, 0), null), sizeSmall: const TextStyle(fontSize: 9), ), - embedBuilders: defaultEmbedBuildersWeb); + embedBuilders: [ + ...defaultEmbedBuildersWeb, + NotesEmbedBuilder(addEditNote: _addEditNote), + ]); } var toolbar = QuillToolbar.basic( controller: _controller!, @@ -347,6 +415,7 @@ class _HomePageState extends State { } void _readOnly() { + Navigator.pop(super.context); Navigator.push( super.context, MaterialPageRoute( @@ -402,7 +471,8 @@ class _HomePageState extends State { final length = controller.selection.extentOffset - index; if (isEditing) { - final offset = getEmbedNode(controller, controller.selection.start).item1; + final offset = + getEmbedNode(controller, controller.selection.start).offset; controller.replaceText( offset, 1, block, TextSelection.collapsed(offset: offset)); } else { @@ -411,7 +481,7 @@ class _HomePageState extends State { } } -class NotesEmbedBuilder implements EmbedBuilder { +class NotesEmbedBuilder extends EmbedBuilder { NotesEmbedBuilder({required this.addEditNote}); Future Function(BuildContext context, {Document? document}) addEditNote; @@ -425,6 +495,8 @@ class NotesEmbedBuilder implements EmbedBuilder { QuillController controller, 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 443255a4..91344afb 100644 --- a/example/lib/universal_ui/universal_ui.dart +++ b/example/lib/universal_ui/universal_ui.dart @@ -5,7 +5,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter_quill/flutter_quill.dart'; import 'package:flutter_quill_extensions/flutter_quill_extensions.dart'; import 'package:universal_html/html.dart' as html; -import 'package:youtube_player_flutter_quill/youtube_player_flutter_quill.dart'; +import 'package:youtube_player_flutter/youtube_player_flutter.dart'; import '../widgets/responsive_widget.dart'; import 'fake_ui.dart' if (dart.library.html) 'real_ui.dart' as ui_instance; @@ -27,7 +27,7 @@ class UniversalUI { var ui = UniversalUI(); -class ImageEmbedBuilderWeb implements EmbedBuilder { +class ImageEmbedBuilderWeb extends EmbedBuilder { @override String get key => BlockEmbed.imageType; @@ -37,6 +37,8 @@ class ImageEmbedBuilderWeb implements EmbedBuilder { QuillController controller, Embed node, bool readOnly, + bool inline, + TextStyle textStyle, ) { final imageUrl = node.value.data; if (isImageBase64(imageUrl)) { @@ -44,8 +46,12 @@ class ImageEmbedBuilderWeb implements EmbedBuilder { return const SizedBox(); } final size = MediaQuery.of(context).size; - UniversalUI().platformViewRegistry.registerViewFactory( - imageUrl, (viewId) => html.ImageElement()..src = imageUrl); + UniversalUI().platformViewRegistry.registerViewFactory(imageUrl, (viewId) { + return html.ImageElement() + ..src = imageUrl + ..style.height = 'auto' + ..style.width = 'auto'; + }); return Padding( padding: EdgeInsets.only( right: ResponsiveWidget.isMediumScreen(context) @@ -64,13 +70,19 @@ class ImageEmbedBuilderWeb implements EmbedBuilder { } } -class VideoEmbedBuilderWeb implements EmbedBuilder { +class VideoEmbedBuilderWeb extends EmbedBuilder { @override String get key => BlockEmbed.videoType; @override - Widget build(BuildContext context, QuillController controller, Embed node, - bool readOnly) { + Widget build( + BuildContext context, + QuillController controller, + Embed node, + bool readOnly, + bool inline, + TextStyle textStyle, + ) { var videoUrl = node.value.data; if (videoUrl.contains('youtube.com') || videoUrl.contains('youtu.be')) { final youtubeID = YoutubePlayer.convertUrlToId(videoUrl); diff --git a/example/linux/my_application.cc b/example/linux/my_application.cc index 8ef02f26..5f64f84a 100644 --- a/example/linux/my_application.cc +++ b/example/linux/my_application.cc @@ -27,7 +27,7 @@ static void my_application_activate(GApplication* application) { // in case the window manager does more exotic layout, e.g. tiling. // If running on Wayland assume the header bar will work (may need changing // if future cases occur). - gboolean use_header_bar = TRUE; + gboolean use_header_bar = FALSE; #ifdef GDK_WINDOWING_X11 GdkScreen *screen = gtk_window_get_screen(window); if (GDK_IS_X11_SCREEN(screen)) { diff --git a/example/macos/Flutter/GeneratedPluginRegistrant.swift b/example/macos/Flutter/GeneratedPluginRegistrant.swift index 886c66ec..85987338 100644 --- a/example/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/example/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,9 +5,9 @@ import FlutterMacOS import Foundation -import device_info_plus_macos +import device_info_plus import pasteboard -import path_provider_macos +import path_provider_foundation import url_launcher_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { diff --git a/example/pubspec.yaml b/example/pubspec.yaml index f271d0b7..caaa4ab9 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -29,8 +29,8 @@ dependencies: # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.4 path_provider: ^2.0.9 - filesystem_picker: ^2.0.0 - file_picker: ^4.6.1 + filesystem_picker: ^3.1.0 + file_picker: ^5.2.2 flutter_quill: path: ../ flutter_quill_extensions: 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 13187808..176876c7 100644 --- a/flutter_quill_extensions/CHANGELOG.md +++ b/flutter_quill_extensions/CHANGELOG.md @@ -1,3 +1,26 @@ +## 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. + * Implement image insert for web (image as base64) + +## 0.3.0 + +* Added support for adding custom tooltips to toolbar buttons + +## 0.2.0 + +* Allow widgets to override widget span properties [b7951b0](https://github.com/singerdmx/flutter-quill/commit/b7951b02c9086ea42e7aad6d78e6c9b0297562e5) +* Remove tuples [3e9452e](https://github.com/singerdmx/flutter-quill/commit/3e9452e675e8734ff50364c5f7b5d34088d5ff05) +* Remove transparent color of ImageVideoUtils dialog [74544bd](https://github.com/singerdmx/flutter-quill/commit/74544bd945a9d212ca1e8d6b3053dbecee22b720) +* Migrate to `youtube_player_flutter` from `youtube_player_flutter_quill` +* Updates to forumla button [5228f38](https://github.com/singerdmx/flutter-quill/commit/5228f389ba6f37d61d445cfe138c19fcf8766d71) + ## 0.1.0 * Initial release diff --git a/flutter_quill_extensions/lib/embeds/builders.dart b/flutter_quill_extensions/lib/embeds/builders.dart index c443cc59..b16b5845 100644 --- a/flutter_quill_extensions/lib/embeds/builders.dart +++ b/flutter_quill_extensions/lib/embeds/builders.dart @@ -7,15 +7,17 @@ import 'package:flutter_quill/flutter_quill.dart' hide Text; import 'package:flutter_quill/translations.dart'; import 'package:gallery_saver/gallery_saver.dart'; import 'package:math_keyboard/math_keyboard.dart'; -import 'package:tuple/tuple.dart'; +import 'package:universal_html/html.dart' as html; +import '../shims/dart_ui_fake.dart' + if (dart.library.html) '../shims/dart_ui_real.dart' as ui; import 'utils.dart'; import 'widgets/image.dart'; import 'widgets/image_resizer.dart'; import 'widgets/video_app.dart'; import 'widgets/youtube_video_app.dart'; -class ImageEmbedBuilder implements EmbedBuilder { +class ImageEmbedBuilder extends EmbedBuilder { @override String get key => BlockEmbed.imageType; @@ -25,12 +27,14 @@ class ImageEmbedBuilder implements EmbedBuilder { QuillController controller, base.Embed node, bool readOnly, + bool inline, + TextStyle textStyle, ) { assert(!kIsWeb, 'Please provide image EmbedBuilder for Web'); var image; final imageUrl = standardizeImageUrl(node.value.data); - Tuple2? _widthHeight; + OptionalSize? _imageSize; final style = node.style.attributes['style']; if (base.isMobile() && style != null) { final _attrs = base.parseKeyValuePairs(style.value.toString(), { @@ -46,7 +50,7 @@ class ImageEmbedBuilder implements EmbedBuilder { 'mobileWidth and mobileHeight must be specified'); final w = double.parse(_attrs[Attribute.mobileWidth]!); final h = double.parse(_attrs[Attribute.mobileHeight]!); - _widthHeight = Tuple2(w, h); + _imageSize = OptionalSize(w, h); final m = _attrs[Attribute.mobileMargin] == null ? 0.0 : double.parse(_attrs[Attribute.mobileMargin]!); @@ -57,9 +61,9 @@ class ImageEmbedBuilder implements EmbedBuilder { } } - if (_widthHeight == null) { + if (_imageSize == null) { image = imageByUrl(imageUrl); - _widthHeight = Tuple2((image as Image).width, image.height); + _imageSize = OptionalSize((image as Image).width, image.height); } if (!readOnly && base.isMobile()) { @@ -87,10 +91,10 @@ class ImageEmbedBuilder implements EmbedBuilder { controller ..skipRequestKeyboard = true ..formatText( - res.item1, 1, StyleAttribute(attr)); + res.offset, 1, StyleAttribute(attr)); }, - imageWidth: _widthHeight?.item1, - imageHeight: _widthHeight?.item2, + imageWidth: _imageSize?.width, + imageHeight: _imageSize?.height, maxWidth: _screenSize.width, maxHeight: _screenSize.height); }); @@ -103,10 +107,10 @@ class ImageEmbedBuilder implements EmbedBuilder { onPressed: () { final imageNode = getEmbedNode(controller, controller.selection.start) - .item2; + .value; final imageUrl = imageNode.value.data; controller.copiedImageUrl = - Tuple2(imageUrl, getImageStyleString(controller)); + ImageUrl(imageUrl, getImageStyleString(controller)); Navigator.pop(context); }, ); @@ -117,7 +121,7 @@ class ImageEmbedBuilder implements EmbedBuilder { onPressed: () { final offset = getEmbedNode(controller, controller.selection.start) - .item1; + .offset; controller.replaceText(offset, 1, '', TextSelection.collapsed(offset: offset)); Navigator.pop(context); @@ -145,7 +149,43 @@ class ImageEmbedBuilder implements EmbedBuilder { } } -class VideoEmbedBuilder implements EmbedBuilder { +class ImageEmbedBuilderWeb extends EmbedBuilder { + ImageEmbedBuilderWeb({this.constraints}) + : assert(kIsWeb, 'ImageEmbedBuilderWeb is only for web platform'); + + final BoxConstraints? constraints; + + @override + String get key => BlockEmbed.imageType; + + @override + Widget build( + BuildContext context, + QuillController controller, + Embed node, + bool readOnly, + bool inline, + TextStyle textStyle, + ) { + final imageUrl = node.value.data; + + ui.platformViewRegistry.registerViewFactory(imageUrl, (viewId) { + return html.ImageElement() + ..src = imageUrl + ..style.height = 'auto' + ..style.width = 'auto'; + }); + + return ConstrainedBox( + constraints: constraints ?? BoxConstraints.loose(const Size(200, 200)), + child: HtmlElementView( + viewType: imageUrl, + ), + ); + } +} + +class VideoEmbedBuilder extends EmbedBuilder { VideoEmbedBuilder({this.onVideoInit}); final void Function(GlobalKey videoContainerKey)? onVideoInit; @@ -159,6 +199,8 @@ class VideoEmbedBuilder implements EmbedBuilder { QuillController controller, base.Embed node, bool readOnly, + bool inline, + TextStyle textStyle, ) { assert(!kIsWeb, 'Please provide video EmbedBuilder for Web'); @@ -176,7 +218,7 @@ class VideoEmbedBuilder implements EmbedBuilder { } } -class FormulaEmbedBuilder implements EmbedBuilder { +class FormulaEmbedBuilder extends EmbedBuilder { @override String get key => BlockEmbed.formulaType; @@ -186,6 +228,8 @@ class FormulaEmbedBuilder implements EmbedBuilder { QuillController controller, base.Embed node, bool readOnly, + bool inline, + TextStyle textStyle, ) { assert(!kIsWeb, 'Please provide formula EmbedBuilder for Web'); diff --git a/flutter_quill_extensions/lib/embeds/embed_types.dart b/flutter_quill_extensions/lib/embeds/embed_types.dart index 814b77b6..6a48f066 100644 --- a/flutter_quill_extensions/lib/embeds/embed_types.dart +++ b/flutter_quill_extensions/lib/embeds/embed_types.dart @@ -1,4 +1,5 @@ import 'dart:io'; +import 'dart:typed_data'; import 'package:flutter/material.dart'; @@ -18,3 +19,28 @@ enum MediaPickSetting { Camera, Video, } + +typedef MediaFileUrl = String; +typedef MediaFilePicker = Future Function(QuillMediaType mediaType); +typedef MediaPickedCallback = Future Function(QuillFile file); + +enum QuillMediaType { image, video } + +extension QuillMediaTypeX on QuillMediaType { + bool get isImage => this == QuillMediaType.image; + bool get isVideo => this == QuillMediaType.video; +} + +/// Represents a file data which returned by file picker. +class QuillFile { + QuillFile({ + required this.name, + this.path = '', + Uint8List? bytes, + }) : assert(name.isNotEmpty), + bytes = bytes ?? Uint8List(0); + + final String name; + final String path; + final Uint8List bytes; +} diff --git a/flutter_quill_extensions/lib/embeds/toolbar/camera_button.dart b/flutter_quill_extensions/lib/embeds/toolbar/camera_button.dart index bb906f30..5ec8e28e 100644 --- a/flutter_quill_extensions/lib/embeds/toolbar/camera_button.dart +++ b/flutter_quill_extensions/lib/embeds/toolbar/camera_button.dart @@ -19,6 +19,7 @@ class CameraButton extends StatelessWidget { this.webVideoPickImpl, this.cameraPickSettingSelector, this.iconTheme, + this.tooltip, Key? key, }) : super(key: key); @@ -42,6 +43,7 @@ class CameraButton extends StatelessWidget { final MediaPickSettingSelector? cameraPickSettingSelector; final QuillIconTheme? iconTheme; + final String? tooltip; @override Widget build(BuildContext context) { @@ -53,6 +55,7 @@ class CameraButton extends StatelessWidget { return QuillIconButton( icon: Icon(icon, size: iconSize, color: iconColor), + tooltip: tooltip, highlightElevation: 0, hoverElevation: 0, size: iconSize * 1.77, diff --git a/flutter_quill_extensions/lib/embeds/toolbar/formula_button.dart b/flutter_quill_extensions/lib/embeds/toolbar/formula_button.dart index fb0ab679..5c7c5684 100644 --- a/flutter_quill_extensions/lib/embeds/toolbar/formula_button.dart +++ b/flutter_quill_extensions/lib/embeds/toolbar/formula_button.dart @@ -9,6 +9,7 @@ class FormulaButton extends StatelessWidget { this.fillColor, this.iconTheme, this.dialogTheme, + this.tooltip, Key? key, }) : super(key: key); @@ -23,6 +24,7 @@ class FormulaButton extends StatelessWidget { final QuillIconTheme? iconTheme; final QuillDialogTheme? dialogTheme; + final String? tooltip; @override Widget build(BuildContext context) { @@ -34,6 +36,7 @@ class FormulaButton extends StatelessWidget { return QuillIconButton( icon: Icon(icon, size: iconSize, color: iconColor), + tooltip: tooltip, highlightElevation: 0, hoverElevation: 0, size: iconSize * 1.77, diff --git a/flutter_quill_extensions/lib/embeds/toolbar/image_button.dart b/flutter_quill_extensions/lib/embeds/toolbar/image_button.dart index 5cc51aff..d05d851d 100644 --- a/flutter_quill_extensions/lib/embeds/toolbar/image_button.dart +++ b/flutter_quill_extensions/lib/embeds/toolbar/image_button.dart @@ -17,6 +17,7 @@ class ImageButton extends StatelessWidget { this.mediaPickSettingSelector, this.iconTheme, this.dialogTheme, + this.tooltip, Key? key, }) : super(key: key); @@ -38,6 +39,7 @@ class ImageButton extends StatelessWidget { final QuillIconTheme? iconTheme; final QuillDialogTheme? dialogTheme; + final String? tooltip; @override Widget build(BuildContext context) { @@ -49,6 +51,7 @@ class ImageButton extends StatelessWidget { return QuillIconButton( icon: Icon(icon, size: iconSize, color: iconColor), + tooltip: tooltip, highlightElevation: 0, hoverElevation: 0, size: iconSize * 1.77, 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 a72f1996..aef7c9e7 100644 --- a/flutter_quill_extensions/lib/embeds/toolbar/image_video_utils.dart +++ b/flutter_quill_extensions/lib/embeds/toolbar/image_video_utils.dart @@ -80,7 +80,6 @@ class ImageVideoUtils { context: context, builder: (ctx) => AlertDialog( contentPadding: EdgeInsets.zero, - backgroundColor: Colors.transparent, content: Column( mainAxisSize: MainAxisSize.min, children: [ diff --git a/flutter_quill_extensions/lib/embeds/toolbar/media_button.dart b/flutter_quill_extensions/lib/embeds/toolbar/media_button.dart new file mode 100644 index 00000000..837ca825 --- /dev/null +++ b/flutter_quill_extensions/lib/embeds/toolbar/media_button.dart @@ -0,0 +1,452 @@ +//import 'dart:io'; +import 'dart:math' as math; +import 'dart:ui'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_quill/extensions.dart'; +import 'package:flutter_quill/flutter_quill.dart' hide Text; +import 'package:flutter_quill/translations.dart'; +import 'package:image_picker/image_picker.dart'; + +import '../embed_types.dart'; + +/// Widget which combines [ImageButton] and [VideButton] widgets. This widget +/// has more customization and uses dialog similar to one which is used +/// on [http://quilljs.com]. +class MediaButton extends StatelessWidget { + const MediaButton({ + required this.controller, + required this.icon, + this.type = QuillMediaType.image, + this.iconSize = kDefaultIconSize, + this.fillColor, + this.mediaFilePicker = _defaultMediaPicker, + this.onMediaPickedCallback, + this.iconTheme, + this.dialogTheme, + this.tooltip, + this.childrenSpacing = 16.0, + this.labelText, + this.hintText, + this.submitButtonText, + this.submitButtonSize, + this.galleryButtonText, + this.linkButtonText, + this.autovalidateMode = AutovalidateMode.disabled, + Key? key, + this.validationMessage, + }) : assert(type == QuillMediaType.image, + 'Video selection is not supported yet'), + super(key: key); + + final QuillController controller; + final IconData icon; + final double iconSize; + final Color? fillColor; + final QuillMediaType type; + final QuillIconTheme? iconTheme; + final QuillDialogTheme? dialogTheme; + final String? tooltip; + final MediaFilePicker mediaFilePicker; + final MediaPickedCallback? onMediaPickedCallback; + + /// The margin between child widgets in the dialog. + final double childrenSpacing; + + /// The text of label in link add mode. + final String? labelText; + + /// The hint text for link [TextField]. + final String? hintText; + + /// The text of the submit button. + final String? submitButtonText; + + /// The size of dialog buttons. + final Size? submitButtonSize; + + /// The text of the gallery button [MediaSourceSelectorDialog]. + final String? galleryButtonText; + + /// The text of the link button [MediaSourceSelectorDialog]. + final String? linkButtonText; + + final AutovalidateMode autovalidateMode; + final String? validationMessage; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final iconColor = iconTheme?.iconUnselectedColor ?? theme.iconTheme.color; + final iconFillColor = + iconTheme?.iconUnselectedFillColor ?? fillColor ?? theme.canvasColor; + + return QuillIconButton( + icon: Icon(icon, size: iconSize, color: iconColor), + tooltip: tooltip, + highlightElevation: 0, + hoverElevation: 0, + size: iconSize * 1.77, + fillColor: iconFillColor, + borderRadius: iconTheme?.borderRadius ?? 2, + onPressed: () => _onPressedHandler(context), + ); + } + + Future _onPressedHandler(BuildContext context) async { + if (onMediaPickedCallback != null) { + final mediaSource = await showDialog( + context: context, + builder: (_) => MediaSourceSelectorDialog( + dialogTheme: dialogTheme, + galleryButtonText: galleryButtonText, + linkButtonText: linkButtonText, + ), + ); + if (mediaSource != null) { + if (mediaSource == MediaPickSetting.Gallery) { + await _pickImage(); + } else { + _inputLink(context); + } + } + } else { + _inputLink(context); + } + } + + Future _pickImage() async { + if (!(kIsWeb || isMobile() || isDesktop())) { + throw UnsupportedError( + 'Unsupported target platform: ${defaultTargetPlatform.name}'); + } + + final mediaFileUrl = await _pickMediaFileUrl(); + + if (mediaFileUrl != null) { + final index = controller.selection.baseOffset; + final length = controller.selection.extentOffset - index; + controller.replaceText( + index, length, BlockEmbed.image(mediaFileUrl), null); + } + } + + Future _pickMediaFileUrl() async { + final mediaFile = await mediaFilePicker(type); + return mediaFile != null ? onMediaPickedCallback?.call(mediaFile) : null; + } + + void _inputLink(BuildContext context) { + showDialog( + context: context, + builder: (_) => MediaLinkDialog( + dialogTheme: dialogTheme, + labelText: labelText, + hintText: hintText, + buttonText: submitButtonText, + buttonSize: submitButtonSize, + childrenSpacing: childrenSpacing, + autovalidateMode: autovalidateMode, + validationMessage: validationMessage, + ), + ).then(_linkSubmitted); + } + + void _linkSubmitted(String? value) { + if (value != null && value.isNotEmpty) { + final index = controller.selection.baseOffset; + final length = controller.selection.extentOffset - index; + final data = + type.isImage ? BlockEmbed.image(value) : BlockEmbed.video(value); + controller.replaceText(index, length, data, null); + } + } +} + +/// Provides a dialog for input link to media resource. +class MediaLinkDialog extends StatefulWidget { + const MediaLinkDialog({ + Key? key, + this.link, + this.dialogTheme, + this.childrenSpacing = 16.0, + this.labelText, + this.hintText, + this.buttonText, + this.buttonSize, + this.autovalidateMode = AutovalidateMode.disabled, + this.validationMessage, + }) : assert(childrenSpacing > 0), + super(key: key); + + final String? link; + final QuillDialogTheme? dialogTheme; + + /// The margin between child widgets in the dialog. + final double childrenSpacing; + + /// The text of label in link add mode. + final String? labelText; + + /// The hint text for link [TextField]. + final String? hintText; + + /// The text of the submit button. + final String? buttonText; + + /// The size of dialog buttons. + final Size? buttonSize; + + final AutovalidateMode autovalidateMode; + final String? validationMessage; + + @override + State createState() => _MediaLinkDialogState(); +} + +class _MediaLinkDialogState extends State { + final _linkFocus = FocusNode(); + final _linkController = TextEditingController(); + + @override + void dispose() { + _linkFocus.dispose(); + _linkController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final constraints = widget.dialogTheme?.linkDialogConstraints ?? + () { + final mediaQuery = MediaQuery.of(context); + final maxWidth = + kIsWeb ? mediaQuery.size.width / 4 : mediaQuery.size.width - 80; + return BoxConstraints(maxWidth: maxWidth, maxHeight: 80); + }(); + + final buttonStyle = widget.buttonSize != null + ? Theme.of(context) + .elevatedButtonTheme + .style + ?.copyWith(fixedSize: MaterialStatePropertyAll(widget.buttonSize)) + : widget.dialogTheme?.buttonStyle; + + final isWrappable = widget.dialogTheme?.isWrappable ?? false; + + final children = [ + Text(widget.labelText ?? 'Enter media'.i18n), + UtilityWidgets.maybeWidget( + enabled: !isWrappable, + wrapper: (child) => Expanded( + child: child, + ), + child: Padding( + padding: EdgeInsets.symmetric(horizontal: widget.childrenSpacing), + child: TextFormField( + controller: _linkController, + focusNode: _linkFocus, + style: widget.dialogTheme?.inputTextStyle, + keyboardType: TextInputType.url, + textInputAction: TextInputAction.done, + decoration: InputDecoration( + labelStyle: widget.dialogTheme?.labelTextStyle, + hintText: widget.hintText, + ), + autofocus: true, + autovalidateMode: widget.autovalidateMode, + validator: _validateLink, + onChanged: _linkChanged, + ), + ), + ), + ElevatedButton( + onPressed: _canPress() ? _submitLink : null, + style: buttonStyle, + child: Text(widget.buttonText ?? 'Ok'.i18n), + ), + ]; + + return Dialog( + backgroundColor: widget.dialogTheme?.dialogBackgroundColor, + shape: widget.dialogTheme?.shape ?? + DialogTheme.of(context).shape ?? + RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)), + child: ConstrainedBox( + constraints: constraints, + child: Padding( + padding: + widget.dialogTheme?.linkDialogPadding ?? const EdgeInsets.all(16), + child: isWrappable + ? Wrap( + alignment: WrapAlignment.center, + crossAxisAlignment: WrapCrossAlignment.center, + runSpacing: widget.dialogTheme?.runSpacing ?? 0.0, + children: children, + ) + : Row( + children: children, + ), + ), + ), + ); + } + + bool _canPress() => _validateLink(_linkController.text) == null; + + void _linkChanged(String value) { + setState(() { + _linkController.text = value; + }); + } + + void _submitLink() => Navigator.pop(context, _linkController.text); + + String? _validateLink(String? value) { + if ((value?.isEmpty ?? false) || + !AutoFormatMultipleLinksRule.linkRegExp.hasMatch(value!)) { + return widget.validationMessage ?? 'That is not a valid URL'; + } + + return null; + } +} + +/// Media souce selector. +class MediaSourceSelectorDialog extends StatelessWidget { + const MediaSourceSelectorDialog({ + Key? key, + this.dialogTheme, + this.galleryButtonText, + this.linkButtonText, + }) : super(key: key); + + final QuillDialogTheme? dialogTheme; + + /// The text of the gallery button [MediaSourceSelectorDialog]. + final String? galleryButtonText; + + /// The text of the link button [MediaSourceSelectorDialog]. + final String? linkButtonText; + + @override + Widget build(BuildContext context) { + final constraints = dialogTheme?.mediaSelectorDialogConstraints ?? + () { + final mediaQuery = MediaQuery.of(context); + double maxWidth, maxHeight; + if (kIsWeb) { + maxWidth = mediaQuery.size.width / 7; + maxHeight = mediaQuery.size.height / 7; + } else { + maxWidth = mediaQuery.size.width - 80; + maxHeight = maxWidth / 2; + } + return BoxConstraints(maxWidth: maxWidth, maxHeight: maxHeight); + }(); + + final shape = dialogTheme?.shape ?? + DialogTheme.of(context).shape ?? + RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)); + + return Dialog( + backgroundColor: dialogTheme?.dialogBackgroundColor, + shape: shape, + child: ConstrainedBox( + constraints: constraints, + child: Padding( + padding: dialogTheme?.mediaSelectorDialogPadding ?? + const EdgeInsets.all(16), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: TextButtonWithIcon( + icon: Icons.collections, + label: galleryButtonText ?? 'Gallery'.i18n, + onPressed: () => + Navigator.pop(context, MediaPickSetting.Gallery), + ), + ), + const SizedBox(width: 10), + Expanded( + child: TextButtonWithIcon( + icon: Icons.link, + label: linkButtonText ?? 'Link'.i18n, + onPressed: () => + Navigator.pop(context, MediaPickSetting.Link), + ), + ) + ], + ), + ), + ), + ); + } +} + +class TextButtonWithIcon extends StatelessWidget { + const TextButtonWithIcon({ + required this.label, + required this.icon, + required this.onPressed, + this.textStyle, + Key? key, + }) : super(key: key); + + final String label; + final IconData icon; + final VoidCallback onPressed; + final TextStyle? textStyle; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scale = MediaQuery.maybeOf(context)?.textScaleFactor ?? 1; + final gap = scale <= 1 ? 8.0 : lerpDouble(8, 4, math.min(scale - 1, 1))!; + final buttonStyle = TextButtonTheme.of(context).style; + final shape = buttonStyle?.shape?.resolve({}) ?? + const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(4))); + return Material( + shape: shape, + textStyle: textStyle ?? + theme.textButtonTheme.style?.textStyle?.resolve({}) ?? + theme.textTheme.labelLarge, + elevation: buttonStyle?.elevation?.resolve({}) ?? 0, + child: InkWell( + customBorder: shape, + onTap: onPressed, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon), + SizedBox(height: gap), + Flexible(child: Text(label)), + ], + ), + ), + ), + ); + } +} + +/// Default file picker. +Future _defaultMediaPicker(QuillMediaType mediaType) async { + final pickedFile = mediaType.isImage + ? await ImagePicker().pickImage(source: ImageSource.gallery) + : await ImagePicker().pickVideo(source: ImageSource.gallery); + + if (pickedFile != null) { + return QuillFile( + name: pickedFile.name, + path: pickedFile.path, + bytes: await pickedFile.readAsBytes(), + ); + } + + return null; +} diff --git a/flutter_quill_extensions/lib/embeds/toolbar/video_button.dart b/flutter_quill_extensions/lib/embeds/toolbar/video_button.dart index e6193622..e5c1ab73 100644 --- a/flutter_quill_extensions/lib/embeds/toolbar/video_button.dart +++ b/flutter_quill_extensions/lib/embeds/toolbar/video_button.dart @@ -17,6 +17,7 @@ class VideoButton extends StatelessWidget { this.mediaPickSettingSelector, this.iconTheme, this.dialogTheme, + this.tooltip, Key? key, }) : super(key: key); @@ -38,6 +39,7 @@ class VideoButton extends StatelessWidget { final QuillIconTheme? iconTheme; final QuillDialogTheme? dialogTheme; + final String? tooltip; @override Widget build(BuildContext context) { @@ -49,6 +51,7 @@ class VideoButton extends StatelessWidget { return QuillIconButton( icon: Icon(icon, size: iconSize, color: iconColor), + tooltip: tooltip, highlightElevation: 0, hoverElevation: 0, size: iconSize * 1.77, diff --git a/flutter_quill_extensions/lib/embeds/widgets/image.dart b/flutter_quill_extensions/lib/embeds/widgets/image.dart index d4df2a4c..658c5050 100644 --- a/flutter_quill_extensions/lib/embeds/widgets/image.dart +++ b/flutter_quill_extensions/lib/embeds/widgets/image.dart @@ -21,7 +21,7 @@ String getImageStyleString(QuillController controller) { final String? s = controller .getAllSelectionStyles() .firstWhere((s) => s.attributes.containsKey(Attribute.style.key), - orElse: () => Style()) + orElse: Style.new) .attributes[Attribute.style.key] ?.value; return s ?? ''; diff --git a/flutter_quill_extensions/lib/embeds/widgets/youtube_video_app.dart b/flutter_quill_extensions/lib/embeds/widgets/youtube_video_app.dart index 8032b30f..02e53fbe 100644 --- a/flutter_quill_extensions/lib/embeds/widgets/youtube_video_app.dart +++ b/flutter_quill_extensions/lib/embeds/widgets/youtube_video_app.dart @@ -2,7 +2,7 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter_quill/flutter_quill.dart'; import 'package:url_launcher/url_launcher.dart'; -import 'package:youtube_player_flutter_quill/youtube_player_flutter_quill.dart'; +import 'package:youtube_player_flutter/youtube_player_flutter.dart'; class YoutubeVideoApp extends StatefulWidget { const YoutubeVideoApp( diff --git a/flutter_quill_extensions/lib/flutter_quill_extensions.dart b/flutter_quill_extensions/lib/flutter_quill_extensions.dart index 7ef38328..fdbc54b2 100644 --- a/flutter_quill_extensions/lib/flutter_quill_extensions.dart +++ b/flutter_quill_extensions/lib/flutter_quill_extensions.dart @@ -15,23 +15,33 @@ export 'embeds/toolbar/camera_button.dart'; export 'embeds/toolbar/formula_button.dart'; export 'embeds/toolbar/image_button.dart'; export 'embeds/toolbar/image_video_utils.dart'; +export 'embeds/toolbar/media_button.dart'; export 'embeds/toolbar/video_button.dart'; export 'embeds/utils.dart'; class FlutterQuillEmbeds { - static List builders( - {void Function(GlobalKey videoContainerKey)? onVideoInit}) => + static List builders({ + void Function(GlobalKey videoContainerKey)? onVideoInit, + }) => [ ImageEmbedBuilder(), VideoEmbedBuilder(onVideoInit: onVideoInit), FormulaEmbedBuilder(), ]; + static List webBuilders() => [ + ImageEmbedBuilderWeb(), + ]; + static List buttons({ bool showImageButton = true, bool showVideoButton = true, bool showCameraButton = true, bool showFormulaButton = false, + String? imageButtonTooltip, + String? videoButtonTooltip, + String? cameraButtonTooltip, + String? formulaButtonTooltip, OnImagePickCallback? onImagePickCallback, OnVideoPickCallback? onVideoPickCallback, MediaPickSettingSelector? mediaPickSettingSelector, @@ -39,54 +49,58 @@ class FlutterQuillEmbeds { FilePickImpl? filePickImpl, WebImagePickImpl? webImagePickImpl, WebVideoPickImpl? webVideoPickImpl, - }) { - return [ - if (showImageButton) - (controller, toolbarIconSize, iconTheme, dialogTheme) => ImageButton( - icon: Icons.image, - iconSize: toolbarIconSize, - controller: controller, - onImagePickCallback: onImagePickCallback, - filePickImpl: filePickImpl, - webImagePickImpl: webImagePickImpl, - mediaPickSettingSelector: mediaPickSettingSelector, - iconTheme: iconTheme, - dialogTheme: dialogTheme, - ), - if (showVideoButton) - (controller, toolbarIconSize, iconTheme, dialogTheme) => VideoButton( - icon: Icons.movie_creation, - iconSize: toolbarIconSize, - controller: controller, - onVideoPickCallback: onVideoPickCallback, - filePickImpl: filePickImpl, - webVideoPickImpl: webImagePickImpl, - mediaPickSettingSelector: mediaPickSettingSelector, - iconTheme: iconTheme, - dialogTheme: dialogTheme, - ), - if ((onImagePickCallback != null || onVideoPickCallback != null) && - showCameraButton) - (controller, toolbarIconSize, iconTheme, dialogTheme) => CameraButton( - icon: Icons.photo_camera, - iconSize: toolbarIconSize, - controller: controller, - onImagePickCallback: onImagePickCallback, - onVideoPickCallback: onVideoPickCallback, - filePickImpl: filePickImpl, - webImagePickImpl: webImagePickImpl, - webVideoPickImpl: webVideoPickImpl, - cameraPickSettingSelector: cameraPickSettingSelector, - iconTheme: iconTheme, - ), - if (showFormulaButton) - (controller, toolbarIconSize, iconTheme, dialogTheme) => FormulaButton( - icon: Icons.functions, - iconSize: toolbarIconSize, - controller: controller, - iconTheme: iconTheme, - dialogTheme: dialogTheme, - ) - ]; - } + }) => + [ + if (showImageButton) + (controller, toolbarIconSize, iconTheme, dialogTheme) => ImageButton( + icon: Icons.image, + iconSize: toolbarIconSize, + tooltip: imageButtonTooltip, + controller: controller, + onImagePickCallback: onImagePickCallback, + filePickImpl: filePickImpl, + webImagePickImpl: webImagePickImpl, + mediaPickSettingSelector: mediaPickSettingSelector, + iconTheme: iconTheme, + dialogTheme: dialogTheme, + ), + if (showVideoButton) + (controller, toolbarIconSize, iconTheme, dialogTheme) => VideoButton( + icon: Icons.movie_creation, + iconSize: toolbarIconSize, + tooltip: videoButtonTooltip, + controller: controller, + onVideoPickCallback: onVideoPickCallback, + filePickImpl: filePickImpl, + webVideoPickImpl: webImagePickImpl, + mediaPickSettingSelector: mediaPickSettingSelector, + iconTheme: iconTheme, + dialogTheme: dialogTheme, + ), + if ((onImagePickCallback != null || onVideoPickCallback != null) && + showCameraButton) + (controller, toolbarIconSize, iconTheme, dialogTheme) => CameraButton( + icon: Icons.photo_camera, + iconSize: toolbarIconSize, + tooltip: cameraButtonTooltip, + controller: controller, + onImagePickCallback: onImagePickCallback, + onVideoPickCallback: onVideoPickCallback, + filePickImpl: filePickImpl, + webImagePickImpl: webImagePickImpl, + webVideoPickImpl: webVideoPickImpl, + cameraPickSettingSelector: cameraPickSettingSelector, + iconTheme: iconTheme, + ), + if (showFormulaButton) + (controller, toolbarIconSize, iconTheme, dialogTheme) => + FormulaButton( + icon: Icons.functions, + iconSize: toolbarIconSize, + tooltip: formulaButtonTooltip, + controller: controller, + iconTheme: iconTheme, + dialogTheme: dialogTheme, + ) + ]; } diff --git a/flutter_quill_extensions/lib/shims/dart_ui_fake.dart b/flutter_quill_extensions/lib/shims/dart_ui_fake.dart new file mode 100644 index 00000000..baaf9ebd --- /dev/null +++ b/flutter_quill_extensions/lib/shims/dart_ui_fake.dart @@ -0,0 +1,23 @@ +// ignore_for_file: avoid_classes_with_only_static_members, camel_case_types, lines_longer_than_80_chars + +import 'package:universal_html/html.dart' as html; + +// Fake interface for the logic that this package needs from (web-only) dart:ui. +// This is conditionally exported so the analyzer sees these methods as available. + +typedef PlatroformViewFactory = html.Element Function(int viewId); + +/// Shim for web_ui engine.PlatformViewRegistry +/// https://github.com/flutter/engine/blob/master/lib/web_ui/lib/ui.dart#L62 +class platformViewRegistry { + /// Shim for registerViewFactory + /// https://github.com/flutter/engine/blob/master/lib/web_ui/lib/ui.dart#L72 + static dynamic registerViewFactory( + String viewTypeId, PlatroformViewFactory viewFactory) {} +} + +/// Shim for web_ui engine.AssetManager +/// https://github.com/flutter/engine/blob/master/lib/web_ui/lib/src/engine/assets.dart#L12 +class webOnlyAssetManager { + static dynamic getAssetUrl(String asset) {} +} diff --git a/flutter_quill_extensions/lib/shims/dart_ui_real.dart b/flutter_quill_extensions/lib/shims/dart_ui_real.dart new file mode 100644 index 00000000..69c06ee2 --- /dev/null +++ b/flutter_quill_extensions/lib/shims/dart_ui_real.dart @@ -0,0 +1 @@ +export 'dart:ui'; diff --git a/flutter_quill_extensions/pubspec.yaml b/flutter_quill_extensions/pubspec.yaml index 01a85962..f1b77e8c 100644 --- a/flutter_quill_extensions/pubspec.yaml +++ b/flutter_quill_extensions/pubspec.yaml @@ -1,30 +1,28 @@ name: flutter_quill_extensions description: Embed extensions for flutter_quill including image, video, formula and etc. -version: 0.1.0 +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.12.0 <3.0.0" + sdk: ">=2.17.0 <4.0.0" flutter: ">=3.0.0" dependencies: flutter: sdk: flutter - flutter_quill: ^6.0.0 + flutter_quill: ^7.2.1 image_picker: ^0.8.5+3 photo_view: ^0.14.0 video_player: ^2.4.2 - youtube_player_flutter_quill: ^8.2.2 + youtube_player_flutter: ^8.1.1 gallery_saver: ^2.3.2 - math_keyboard: ^0.1.6 - string_validator: ^0.3.0 - -#dependency_overrides: -# flutter_quill: -# path: ../ + math_keyboard: ^0.2.0 + string_validator: ^1.0.0 + universal_html: ^2.2.1 + url_launcher: ^6.1.9 dev_dependencies: flutter_test: diff --git a/lib/extensions.dart b/lib/extensions.dart index 12e961c2..b7c2b0af 100644 --- a/lib/extensions.dart +++ b/lib/extensions.dart @@ -4,3 +4,4 @@ export 'src/models/documents/nodes/leaf.dart' hide Text; export 'src/models/rules/insert.dart'; export 'src/utils/platform.dart'; export 'src/utils/string.dart'; +export 'src/utils/widgets.dart'; diff --git a/lib/flutter_quill.dart b/lib/flutter_quill.dart index eae6c5de..bfc666f1 100644 --- a/lib/flutter_quill.dart +++ b/lib/flutter_quill.dart @@ -2,11 +2,18 @@ library flutter_quill; export 'src/models/documents/attribute.dart'; export 'src/models/documents/document.dart'; +export 'src/models/documents/nodes/block.dart'; export 'src/models/documents/nodes/embeddable.dart'; export 'src/models/documents/nodes/leaf.dart'; +export 'src/models/documents/nodes/line.dart'; export 'src/models/documents/nodes/node.dart'; 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/offset_value.dart'; +export 'src/models/structs/optional_size.dart'; +export 'src/models/structs/vertical_spacing.dart'; export 'src/models/themes/quill_custom_button.dart'; export 'src/models/themes/quill_dialog_theme.dart'; export 'src/models/themes/quill_icon_theme.dart'; @@ -18,3 +25,4 @@ export 'src/widgets/embeds.dart'; export 'src/widgets/link.dart' show LinkActionPickerDelegate, LinkMenuAction; export 'src/widgets/style_widgets/style_widgets.dart'; export 'src/widgets/toolbar.dart'; +export 'src/widgets/toolbar/enum.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/src/models/documents/attribute.dart b/lib/src/models/documents/attribute.dart index 2ec7d7f2..c8d40360 100644 --- a/lib/src/models/documents/attribute.dart +++ b/lib/src/models/documents/attribute.dart @@ -19,6 +19,8 @@ class Attribute { static final Map _registry = LinkedHashMap.of({ Attribute.bold.key: Attribute.bold, + Attribute.subscript.key: Attribute.subscript, + Attribute.superscript.key: Attribute.superscript, Attribute.italic.key: Attribute.italic, Attribute.small.key: Attribute.small, Attribute.underline.key: Attribute.underline, @@ -42,10 +44,18 @@ class Attribute { Attribute.style.key: Attribute.style, Attribute.token.key: Attribute.token, Attribute.script.key: Attribute.script, + Attribute.image.key: Attribute.image, + Attribute.video.key: Attribute.video, }); static const BoldAttribute bold = BoldAttribute(); + static final ScriptAttribute subscript = + ScriptAttribute(ScriptAttributes.sub); + + static final ScriptAttribute superscript = + ScriptAttribute(ScriptAttributes.sup); + static const ItalicAttribute italic = ItalicAttribute(); static const SmallAttribute small = SmallAttribute(); @@ -90,7 +100,7 @@ class Attribute { static const TokenAttribute token = TokenAttribute(''); - static const ScriptAttribute script = ScriptAttribute(''); + static final ScriptAttribute script = ScriptAttribute(null); static const String mobileWidth = 'mobileWidth'; @@ -100,8 +110,14 @@ class Attribute { static const String mobileAlignment = 'mobileAlignment'; + static const ImageAttribute image = ImageAttribute(null); + + static const VideoAttribute video = VideoAttribute(null); + static final Set inlineKeys = { Attribute.bold.key, + Attribute.subscript.key, + Attribute.superscript.key, Attribute.italic.key, Attribute.small.key, Attribute.underline.key, @@ -138,6 +154,11 @@ class Attribute { Attribute.blockQuote.key, }); + static final Set embedKeys = { + Attribute.image.key, + Attribute.video.key, + }; + static const Attribute h1 = HeaderAttribute(level: 1); static const Attribute h2 = HeaderAttribute(level: 2); @@ -345,8 +366,26 @@ class TokenAttribute extends Attribute { const TokenAttribute(String val) : super('token', AttributeScope.IGNORE, val); } -// `script` is supposed to be inline attribute but it is not supported yet -class ScriptAttribute extends Attribute { - const ScriptAttribute(String val) - : super('script', AttributeScope.IGNORE, val); +class ScriptAttribute extends Attribute { + ScriptAttribute(ScriptAttributes? val) + : super('script', AttributeScope.INLINE, val?.value); +} + +enum ScriptAttributes { + sup('super'), + sub('sub'); + + const ScriptAttributes(this.value); + + final String value; +} + +class ImageAttribute extends Attribute { + const ImageAttribute(String? url) + : super('image', AttributeScope.EMBEDS, url); +} + +class VideoAttribute extends Attribute { + const VideoAttribute(String? url) + : super('video', AttributeScope.EMBEDS, url); } diff --git a/lib/src/models/documents/document.dart b/lib/src/models/documents/document.dart index e3e2237f..597d9025 100644 --- a/lib/src/models/documents/document.dart +++ b/lib/src/models/documents/document.dart @@ -1,9 +1,11 @@ import 'dart:async'; -import 'package:tuple/tuple.dart'; - import '../quill_delta.dart'; import '../rules/rule.dart'; +import '../structs/doc_change.dart'; +import '../structs/history_changed.dart'; +import '../structs/offset_value.dart'; +import '../structs/segment_leaf_node.dart'; import 'attribute.dart'; import 'history.dart'; import 'nodes/block.dart'; @@ -50,13 +52,12 @@ class Document { _rules.setCustomRules(customRules); } - final StreamController> _observer = - StreamController.broadcast(); + final StreamController _observer = StreamController.broadcast(); final History _history = History(); - /// Stream of [Change]s applied to this document. - Stream> get changes => _observer.stream; + /// Stream of [DocChange]s applied to this document. + Stream get changes => _observer.stream; /// Inserts [data] in this document at specified [index]. /// @@ -158,7 +159,7 @@ class Document { } /// Returns all styles for each node within selection - List> collectAllIndividualStyles(int index, int len) { + List> collectAllIndividualStyles(int index, int len) { final res = queryChild(index); return (res.node as Line).collectAllIndividualStyles(res.offset, len); } @@ -169,6 +170,12 @@ class Document { return (res.node as Line).collectAllStyles(res.offset, len); } + /// Returns all styles for any character within the specified text range. + List> collectAllStylesWithOffset(int index, int len) { + final res = queryChild(index); + return (res.node as Line).collectAllStylesWithOffsets(res.offset, len); + } + /// Returns plain text within the specified text range. String getPlainText(int index, int len) { final res = queryChild(index); @@ -216,19 +223,15 @@ class Document { } /// Given offset, find its leaf node in document - Tuple2 querySegmentLeafNode(int offset) { + SegmentLeafNode querySegmentLeafNode(int offset) { final result = queryChild(offset); if (result.node == null) { - return const Tuple2(null, null); + return const SegmentLeafNode(null, null); } final line = result.node as Line; final segmentResult = line.queryChild(result.offset, false); - if (segmentResult.node == null) { - return Tuple2(line, null); - } - final segment = segmentResult.node as Leaf; - return Tuple2(line, segment); + return SegmentLeafNode(line, segmentResult.node as Leaf?); } /// Composes [change] Delta into this document. @@ -276,16 +279,16 @@ class Document { if (_delta != _root.toDelta()) { throw 'Compose failed'; } - final change = Tuple3(originalDelta, delta, changeSource); + final change = DocChange(originalDelta, delta, changeSource); _observer.add(change); _history.handleDocChange(change); } - Tuple2 undo() { + HistoryChanged undo() { return _history.undo(this); } - Tuple2 redo() { + HistoryChanged redo() { return _history.redo(this); } diff --git a/lib/src/models/documents/history.dart b/lib/src/models/documents/history.dart index d406505e..fa87700c 100644 --- a/lib/src/models/documents/history.dart +++ b/lib/src/models/documents/history.dart @@ -1,6 +1,6 @@ -import 'package:tuple/tuple.dart'; - import '../quill_delta.dart'; +import '../structs/doc_change.dart'; +import '../structs/history_changed.dart'; import 'document.dart'; class History { @@ -32,12 +32,12 @@ class History { ///record delay final int interval; - void handleDocChange(Tuple3 change) { + void handleDocChange(DocChange docChange) { if (ignoreChange) return; - if (!userOnly || change.item3 == ChangeSource.LOCAL) { - record(change.item2, change.item1); + if (!userOnly || docChange.source == ChangeSource.LOCAL) { + record(docChange.change, docChange.before); } else { - transform(change.item2); + transform(docChange.change); } } @@ -85,9 +85,9 @@ class History { } } - Tuple2 _change(Document doc, List source, List dest) { + HistoryChanged _change(Document doc, List source, List dest) { if (source.isEmpty) { - return const Tuple2(false, 0); + return const HistoryChanged(false, 0); } final delta = source.removeLast(); // look for insert or delete @@ -107,14 +107,14 @@ class History { ignoreChange = true; doc.compose(delta, ChangeSource.LOCAL); ignoreChange = false; - return Tuple2(true, len); + return HistoryChanged(true, len); } - Tuple2 undo(Document doc) { + HistoryChanged undo(Document doc) { return _change(doc, stack.undo, stack.redo); } - Tuple2 redo(Document doc) { + HistoryChanged redo(Document doc) { return _change(doc, stack.redo, stack.undo); } } diff --git a/lib/src/models/documents/nodes/line.dart b/lib/src/models/documents/nodes/line.dart index 3e3bbb04..42306edb 100644 --- a/lib/src/models/documents/nodes/line.dart +++ b/lib/src/models/documents/nodes/line.dart @@ -1,9 +1,9 @@ import 'dart:math' as math; import 'package:collection/collection.dart'; -import 'package:tuple/tuple.dart'; import '../../quill_delta.dart'; +import '../../structs/offset_value.dart'; import '../attribute.dart'; import '../style.dart'; import 'block.dart'; @@ -381,7 +381,7 @@ class Line extends Container { } final remaining = len - local; - if (remaining > 0) { + if (remaining > 0 && nextLine != null) { final rest = nextLine!.collectStyle(0, remaining); _handle(rest); } @@ -391,10 +391,10 @@ 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, + List> collectAllIndividualStyles(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?; @@ -402,12 +402,12 @@ class Line extends Container { var pos = 0; if (node is Text) { pos = node.length - data.offset; - result.add(Tuple2(beg, node.style)); + result.add(OffsetValue(beg, node.style)); } while (!node!.isLast && pos < local) { node = node.next as Leaf; if (node is Text) { - result.add(Tuple2(pos + beg, node.style)); + result.add(OffsetValue(pos + beg, node.style)); pos += node.length; } } @@ -416,7 +416,7 @@ class Line extends Container { // TODO: add line style and parent's block style final remaining = len - local; - if (remaining > 0) { + if (remaining > 0 && nextLine != null) { final rest = nextLine!.collectAllIndividualStyles(0, remaining, beg: local); result.addAll(rest); @@ -450,7 +450,7 @@ class Line extends Container { } final remaining = len - local; - if (remaining > 0) { + if (remaining > 0 && nextLine != null) { final rest = nextLine!.collectAllStyles(0, remaining); result.addAll(rest); } @@ -458,6 +458,44 @@ class Line extends Container { return result; } + /// Returns all styles for any character within the specified text range. + List> collectAllStylesWithOffsets( + int offset, + int len, { + int beg = 0, + }) { + final local = math.min(length - offset, len); + final result = >[]; + + final data = queryChild(offset, true); + var node = data.node as Leaf?; + if (node != null) { + var pos = 0; + pos = node.length - data.offset; + result.add(OffsetValue(node.documentOffset, node.style, node.length)); + while (!node!.isLast && pos < local) { + node = node.next as Leaf; + result.add(OffsetValue(node.documentOffset, node.style, node.length)); + pos += node.length; + } + } + + result.add(OffsetValue(documentOffset, style, length)); + if (parent is Block) { + final block = parent as Block; + result.add(OffsetValue(block.documentOffset, block.style, block.length)); + } + + final remaining = len - local; + if (remaining > 0 && nextLine != null) { + final rest = + nextLine!.collectAllStylesWithOffsets(0, remaining, beg: local); + result.addAll(rest); + } + + return result; + } + /// Returns plain text within the specified text range. String getPlainText(int offset, int len) { final plainText = StringBuffer(); @@ -501,7 +539,7 @@ class Line extends Container { } } - if (_len > 0) { + if (_len > 0 && nextLine != null) { _len = nextLine!._getPlainText(0, _len, plainText); } } diff --git a/lib/src/models/rules/insert.dart b/lib/src/models/rules/insert.dart index ef77e77a..44124fbc 100644 --- a/lib/src/models/rules/insert.dart +++ b/lib/src/models/rules/insert.dart @@ -1,5 +1,3 @@ -import 'package:tuple/tuple.dart'; - import '../../models/documents/document.dart'; import '../documents/attribute.dart'; import '../documents/nodes/embeddable.dart'; @@ -37,26 +35,26 @@ class PreserveLineStyleOnSplitRule extends InsertRule { final itr = DeltaIterator(document); final before = itr.skip(index); - if (before == null || - before.data is! String || - (before.data as String).endsWith('\n')) { + if (before == null) { return null; } - final after = itr.next(); - if (after.data is! String || (after.data as String).startsWith('\n')) { + if (before.data is String && (before.data as String).endsWith('\n')) { return null; } - final text = after.data as String; + final after = itr.next(); + if (after.data is String && (after.data as String).startsWith('\n')) { + return null; + } final delta = Delta()..retain(index + (len ?? 0)); - if (text.contains('\n')) { + if (after.data is String && (after.data as String).contains('\n')) { assert(after.isPlain); delta.insert('\n'); return delta; } final nextNewLine = _getNextNewLine(itr); - final attributes = nextNewLine.item1?.attributes; + final attributes = nextNewLine.operation?.attributes; return delta..insert('\n', attributes); } @@ -85,8 +83,8 @@ class PreserveBlockStyleOnInsertRule extends InsertRule { // Look for the next newline. final nextNewLine = _getNextNewLine(itr); - final lineStyle = - Style.fromJson(nextNewLine.item1?.attributes ?? {}); + final lineStyle = Style.fromJson( + nextNewLine.operation?.attributes ?? {}); final blockStyle = lineStyle.getBlocksExceptHeader(); // Are we currently in a block? If not then ignore. @@ -126,8 +124,8 @@ class PreserveBlockStyleOnInsertRule extends InsertRule { // Reset style of the original newline character if needed. if (resetStyle.isNotEmpty) { delta - ..retain(nextNewLine.item2!) - ..retain((nextNewLine.item1!.data as String).indexOf('\n')) + ..retain(nextNewLine.skipped!) + ..retain((nextNewLine.operation!.data as String).indexOf('\n')) ..retain(1, resetStyle); } @@ -188,9 +186,10 @@ class AutoExitBlockRule extends InsertRule { // Keep looking for the next newline character to see if it shares the same // block style as `cur`. final nextNewLine = _getNextNewLine(itr); - if (nextNewLine.item1 != null && - nextNewLine.item1!.attributes != null && - Style.fromJson(nextNewLine.item1!.attributes).getBlockExceptHeader() == + if (nextNewLine.operation != null && + nextNewLine.operation!.attributes != null && + Style.fromJson(nextNewLine.operation!.attributes) + .getBlockExceptHeader() == blockStyle) { // We are not at the end of this block, ignore. return null; @@ -477,7 +476,7 @@ class PreserveInlineStylesRule extends InsertRule { } final itr = DeltaIterator(document); - final prev = itr.skip(index); + final prev = itr.skip(len == 0 ? index : index + 1); if (prev == null || prev.data is! String || (prev.data as String).contains('\n')) { @@ -524,15 +523,22 @@ class CatchAllInsertRule extends InsertRule { } } -Tuple2 _getNextNewLine(DeltaIterator iterator) { +_NextNewLine _getNextNewLine(DeltaIterator iterator) { Operation op; for (var skipped = 0; iterator.hasNext; skipped += op.length!) { op = iterator.next(); final lineBreak = (op.data is String ? op.data as String? : '')!.indexOf('\n'); if (lineBreak >= 0) { - return Tuple2(op, skipped); + return _NextNewLine(op, skipped); } } - return const Tuple2(null, null); + return const _NextNewLine(null, null); +} + +class _NextNewLine { + const _NextNewLine(this.operation, this.skipped); + + final Operation? operation; + final int? skipped; } diff --git a/lib/src/models/structs/doc_change.dart b/lib/src/models/structs/doc_change.dart new file mode 100644 index 00000000..d2772a59 --- /dev/null +++ b/lib/src/models/structs/doc_change.dart @@ -0,0 +1,19 @@ +import '../documents/document.dart'; +import '../quill_delta.dart'; + +class DocChange { + DocChange( + this.before, + this.change, + this.source, + ); + + /// Document state before [change]. + final Delta before; + + /// Change delta applied to the document. + final Delta change; + + /// The source of this change. + final ChangeSource source; +} diff --git a/lib/src/models/structs/history_changed.dart b/lib/src/models/structs/history_changed.dart new file mode 100644 index 00000000..abb61567 --- /dev/null +++ b/lib/src/models/structs/history_changed.dart @@ -0,0 +1,9 @@ +class HistoryChanged { + const HistoryChanged( + this.changed, + this.len, + ); + + final bool changed; + final int? len; +} diff --git a/lib/src/models/structs/image_url.dart b/lib/src/models/structs/image_url.dart new file mode 100644 index 00000000..097e199b --- /dev/null +++ b/lib/src/models/structs/image_url.dart @@ -0,0 +1,9 @@ +class ImageUrl { + const ImageUrl( + this.url, + this.styleString, + ); + + final String url; + final String styleString; +} diff --git a/lib/src/models/structs/offset_value.dart b/lib/src/models/structs/offset_value.dart new file mode 100644 index 00000000..58275458 --- /dev/null +++ b/lib/src/models/structs/offset_value.dart @@ -0,0 +1,6 @@ +class OffsetValue { + OffsetValue(this.offset, this.value, [this.length]); + final int offset; + final int? length; + final T value; +} diff --git a/lib/src/models/structs/optional_size.dart b/lib/src/models/structs/optional_size.dart new file mode 100644 index 00000000..a887b468 --- /dev/null +++ b/lib/src/models/structs/optional_size.dart @@ -0,0 +1,14 @@ +class OptionalSize { + OptionalSize( + this.width, + this.height, + ); + + /// If non-null, requires the child to have exactly this width. + /// If null, the child is free to choose its own width. + final double? width; + + /// If non-null, requires the child to have exactly this height. + /// If null, the child is free to choose its own height. + final double? height; +} diff --git a/lib/src/models/structs/segment_leaf_node.dart b/lib/src/models/structs/segment_leaf_node.dart new file mode 100644 index 00000000..43921b93 --- /dev/null +++ b/lib/src/models/structs/segment_leaf_node.dart @@ -0,0 +1,9 @@ +import '../documents/nodes/leaf.dart'; +import '../documents/nodes/line.dart'; + +class SegmentLeafNode { + const SegmentLeafNode(this.line, this.leaf); + + final Line? line; + final Leaf? leaf; +} diff --git a/lib/src/models/structs/vertical_spacing.dart b/lib/src/models/structs/vertical_spacing.dart new file mode 100644 index 00000000..54f76f7c --- /dev/null +++ b/lib/src/models/structs/vertical_spacing.dart @@ -0,0 +1,9 @@ +class VerticalSpacing { + const VerticalSpacing( + this.top, + this.bottom, + ); + + final double top; + final double bottom; +} diff --git a/lib/src/models/themes/quill_custom_button.dart b/lib/src/models/themes/quill_custom_button.dart index aadd28e8..6f5b5365 100644 --- a/lib/src/models/themes/quill_custom_button.dart +++ b/lib/src/models/themes/quill_custom_button.dart @@ -1,14 +1,26 @@ import 'package:flutter/material.dart'; class QuillCustomButton { - const QuillCustomButton({this.icon, this.onTap, this.child}); + const QuillCustomButton({ + this.icon, + this.iconColor, + this.onTap, + this.tooltip, + this.child, + }); ///The icon widget final IconData? icon; + ///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 795d35d5..1552c5f5 100644 --- a/lib/src/models/themes/quill_dialog_theme.dart +++ b/lib/src/models/themes/quill_dialog_theme.dart @@ -1,8 +1,21 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -class QuillDialogTheme { - QuillDialogTheme( - {this.labelTextStyle, this.inputTextStyle, this.dialogBackgroundColor}); +/// Used to configure the dialog's look and feel. +class QuillDialogTheme with Diagnosticable { + const QuillDialogTheme({ + this.labelTextStyle, + this.inputTextStyle, + this.dialogBackgroundColor, + this.shape, + this.buttonStyle, + this.linkDialogConstraints, + this.linkDialogPadding = const EdgeInsets.all(16), + this.mediaSelectorDialogConstraints, + this.mediaSelectorDialogPadding = const EdgeInsets.all(16), + this.isWrappable = false, + this.runSpacing = 8.0, + }) : assert(runSpacing >= 0); ///The text style to use for the label shown in the link-input dialog final TextStyle? labelTextStyle; @@ -10,6 +23,105 @@ class QuillDialogTheme { ///The text style to use for the input text shown in the link-input dialog final TextStyle? inputTextStyle; - ///The background color for the [LinkDialog()] + ///The background color for the Quill dialog final Color? dialogBackgroundColor; + + /// The shape of this dialog's border. + /// + /// Defines the dialog's [Material.shape]. + /// + /// The default shape is a [RoundedRectangleBorder] with a radius of 4.0 + final ShapeBorder? shape; + + /// Constrains for [LinkStyleDialog]. + final BoxConstraints? linkDialogConstraints; + + /// The padding for content of [LinkStyleDialog]. + final EdgeInsetsGeometry linkDialogPadding; + + /// Constrains for [MediaSourceSelectorDialog]. + final BoxConstraints? mediaSelectorDialogConstraints; + + /// The padding for content of [MediaSourceSelectorDialog]. + final EdgeInsetsGeometry mediaSelectorDialogPadding; + + /// Customizes this button's appearance. + final ButtonStyle? buttonStyle; + + /// Whether dialog's children are wrappred with [Wrap] instead of [Row]. + final bool isWrappable; + + /// How much space to place between the runs themselves in the cross axis. + /// + /// Make sense if [isWrappable] is `true`. + /// + /// Defaults to 0.0. + final double runSpacing; + + QuillDialogTheme copyWith({ + TextStyle? labelTextStyle, + TextStyle? inputTextStyle, + Color? dialogBackgroundColor, + ShapeBorder? shape, + ButtonStyle? buttonStyle, + BoxConstraints? linkDialogConstraints, + EdgeInsetsGeometry? linkDialogPadding, + BoxConstraints? imageDialogConstraints, + EdgeInsetsGeometry? mediaDialogPadding, + bool? isWrappable, + double? runSpacing, + }) { + return QuillDialogTheme( + labelTextStyle: labelTextStyle ?? this.labelTextStyle, + inputTextStyle: inputTextStyle ?? this.inputTextStyle, + dialogBackgroundColor: + dialogBackgroundColor ?? this.dialogBackgroundColor, + shape: shape ?? this.shape, + buttonStyle: buttonStyle ?? this.buttonStyle, + linkDialogConstraints: + linkDialogConstraints ?? this.linkDialogConstraints, + linkDialogPadding: linkDialogPadding ?? this.linkDialogPadding, + mediaSelectorDialogConstraints: + imageDialogConstraints ?? mediaSelectorDialogConstraints, + mediaSelectorDialogPadding: + mediaDialogPadding ?? mediaSelectorDialogPadding, + isWrappable: isWrappable ?? this.isWrappable, + runSpacing: runSpacing ?? this.runSpacing, + ); + } + + @override + bool operator ==(Object other) { + if (other.runtimeType != runtimeType) { + return false; + } + return other is QuillDialogTheme && + other.labelTextStyle == labelTextStyle && + other.inputTextStyle == inputTextStyle && + other.dialogBackgroundColor == dialogBackgroundColor && + other.shape == shape && + other.buttonStyle == buttonStyle && + other.linkDialogConstraints == linkDialogConstraints && + other.linkDialogPadding == linkDialogPadding && + other.mediaSelectorDialogConstraints == + mediaSelectorDialogConstraints && + other.mediaSelectorDialogPadding == mediaSelectorDialogPadding && + other.isWrappable == isWrappable && + other.runSpacing == runSpacing; + } + + @override + int get hashCode => Object.hash( + labelTextStyle, + inputTextStyle, + dialogBackgroundColor, + shape, + buttonStyle, + linkDialogConstraints, + linkDialogPadding, + mediaSelectorDialogConstraints, + mediaSelectorDialogPadding, + isWrappable, + runSpacing, + ); } 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 d0af83f0..be92116c 100644 --- a/lib/src/translations/toolbar.i18n.dart +++ b/lib/src/translations/toolbar.i18n.dart @@ -35,6 +35,42 @@ extension Localization on String { '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', + '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', + 'Hex': 'Hex', + 'Material': 'Material', + 'Color': 'Color', }, 'en_us': { 'Paste a link': 'Paste a link', @@ -68,39 +104,113 @@ extension Localization on String { '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', + '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', + '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', + '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': 'اتجاه النص', + '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', @@ -134,6 +244,39 @@ extension Localization on String { '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', + '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', }, 'de': { 'Paste a link': 'Link hinzufügen', @@ -168,6 +311,39 @@ extension Localization on String { 'Next': 'Nächster', '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', + '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', }, 'fr': { 'Paste a link': 'Coller un lien', @@ -178,29 +354,62 @@ extension Localization on String { 'Please first select some text to transform into a link.': "Veuillez d'abord sélectionner un texte à transformer en lien.", 'Open': 'Ouverte', - 'Copy': 'Copie', + 'Copy': 'Copier', 'Remove': 'Supprimer', 'Save': 'Sauvegarder', 'Zoom': 'Zoom', 'Saved': 'Enregistrée', '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', + 'What is entered is not a link': "Ce qui est saisi n'est pas un lien", + 'Resize': 'Redimensionner', + 'Width': 'Largeur', + 'Height': 'Hauteur', + 'Size': 'Taille', + 'Small': 'Petit', + 'Large': 'Grand', + 'Huge': 'Énorme', + 'Clear': 'Supprimer la mise en forme', + 'Font': 'Police', + 'Search': 'Rechercher', + 'matches': 'correspondances', + 'showing match': 'voir la correspondance', + 'Prev': 'Précédent', + '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', }, 'zh_cn': { 'Paste a link': '粘贴链接', @@ -209,7 +418,7 @@ extension Localization on String { 'Gallery': '相簿', 'Link': '链接', 'Please first select some text to transform into a link.': - '请先选择一些要转化为链接的文本', + '请先选择需转化为链接的文本', 'Open': '打开', 'Copy': '复制', 'Remove': '移除', @@ -222,18 +431,183 @@ extension Localization on String { 'Width': '宽度', 'Height': '高度', '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', + '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': '插入链接', + 'Visit link': '访问链接', + 'Enter link': '输入链接', + 'Enter media': '输入媒体', + 'Edit': '编辑', + 'Apply': '应用', + }, + 'zh_hk': { + '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': '插入鏈接', + 'Visit link': '訪問鏈接', + 'Enter link': '輸入鏈接', + 'Enter media': '輸入媒體', + 'Edit': '編輯', + 'Apply': '應用', + }, + 'ja': { + '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': 'ハイパーリンクを挿入', + 'Visit link': 'ハイパーリンクを訪問', + 'Enter link': 'ハイパーリンクを輸入', + 'Enter media': 'ミディアムを輸入', + 'Edit': '編集', + 'Apply': '応用', }, 'ko': { 'Paste a link': '링크를 붙여넣어 주세요.', @@ -251,21 +625,55 @@ extension Localization on String { 'Saved': '저장되었습니다.', 'Text': '텍스트', 'What is entered is not a link': '입력한 내용은 링크가 아닙니다.', - 'Resize': 'Resize', - 'Width': 'Width', - 'Height': 'Height', - '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', + 'Resize': '크기조정', + 'Width': '넓이', + 'Height': '높이', + 'Size': '크기', + 'Small': '작게', + 'Large': '크게', + 'Huge': '매우크게', + 'Clear': '초기화', + 'Font': '글꼴', + 'Search': '검색', + 'matches': '결과', + 'showing match': '결과 보기', + 'Prev': '이전', + 'Next': '다음', + 'Camera': '카메라', + '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', + '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', }, 'ru': { 'Paste a link': 'Вставить ссылку', @@ -299,6 +707,39 @@ extension Localization on String { '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', + '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', }, 'es': { 'Paste a link': 'Pega un enlace', @@ -333,6 +774,39 @@ extension Localization on String { 'Next': 'Siguiente', 'Camera': 'Cámara', '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', + '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', }, 'tr': { 'Paste a link': 'Bağlantıyı Yapıştır', @@ -351,21 +825,54 @@ extension Localization on String { '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', + 'Width': 'Genişlik', + 'Height': 'Yükseklik', + 'Size': 'Boyut', + 'Small': 'Küçük', + 'Large': 'Büyük', + 'Huge': 'Daha Büyük', + 'Clear': 'Temizle', + 'Font': 'Yazı tipi', + 'Search': 'Ara', 'matches': 'matches', 'showing match': 'showing match', 'Prev': 'Prev', - 'Next': 'Next', - 'Camera': 'Camera', + 'Next': 'Devam', + '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', + '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', }, 'uk': { 'Paste a link': 'Вставити посилання', @@ -399,6 +906,39 @@ extension Localization on String { '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', + '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', }, 'pt': { 'Paste a link': 'Colar um link', @@ -433,6 +973,39 @@ extension Localization on String { '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', + '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', }, 'pt_br': { 'Paste a link': 'Colar um link', @@ -461,12 +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': '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': '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': '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', @@ -501,6 +1107,39 @@ extension Localization on String { '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', + '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', }, 'vi': { 'Paste a link': 'Chèn liên kết', @@ -535,6 +1174,39 @@ extension Localization on String { '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', + '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', }, 'ur': { 'Paste a link': 'لنک پیسٹ کریں', @@ -568,6 +1240,39 @@ extension Localization on String { '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', + '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', }, 'id': { 'Paste a link': 'Tempel tautan', @@ -601,6 +1306,39 @@ extension Localization on String { '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', + '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', }, 'no': { 'Paste a link': 'Lim inn lenke', @@ -634,10 +1372,43 @@ extension Localization on String { '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', + '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', }, 'fa': { 'Paste a link': 'جایگذاری لینک', - 'Ok': 'اوکی', + 'Ok': 'تایید', 'Select Color': 'انتخاب رنگ', 'Gallery': 'گالری', 'Link': 'لینک', @@ -650,7 +1421,7 @@ extension Localization on String { 'Zoom': 'بزرگنمایی', 'Saved': 'ذخیره شد', 'Text': 'متن', - 'What is entered is not a link': 'ورودی وارد شده لینک نمی باشد', + 'What is entered is not a link': 'لینک وارد شده معتبر نمی باشد', 'Resize': 'تغییر اندازه', 'Width': 'عرض', 'Height': 'طول', @@ -667,6 +1438,39 @@ extension Localization on String { 'Next': 'بعدی', 'Camera': 'دوربین', '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', + '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', }, 'hi': { 'Paste a link': 'लिंक पेस्ट करें', @@ -700,6 +1504,39 @@ extension Localization on String { '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', + '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', }, 'nl': { 'Paste a link': 'Plak een link', @@ -733,39 +1570,39 @@ extension Localization on String { 'Next': 'Next', 'Camera': 'Camera', 'Video': 'Video', - }, - 'zh_hk': { - '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': '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', }, 'sr': { 'Paste a link': 'Nalepi vezu', @@ -799,6 +1636,305 @@ extension Localization on String { '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', + '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', + }, + 'cs': { + 'Paste a link': 'Vložte odkaz', + 'Ok': 'Ok', + 'Select Color': 'Vyberte barvu', + 'Gallery': 'Galerie', + 'Link': 'Odkaz', + 'Please first select some text to transform into a link.': + 'Nejprve vyberte nějaký text, který chcete převést na odkaz.', + 'Open': 'Otevřít', + 'Copy': 'Kopírovat', + 'Remove': 'Odstranit', + 'Save': 'Uložit', + 'Zoom': 'Přiblížit', + 'Saved': 'Uloženo', + 'Text': 'Text', + 'What is entered is not a link': 'Zadaný vstup není odkaz', + 'Resize': 'Změnit velikost', + 'Width': 'Šířka', + 'Height': 'Výška', + 'Size': 'Velikost', + 'Small': 'Malý', + 'Large': 'Velký', + 'Huge': 'Obrovský', + 'Clear': 'Smazat', + 'Font': 'Písmo', + 'Search': 'Hledat', + 'matches': 'odpovídá', + 'showing match': 'zobrazuje odpovídající', + 'Prev': 'Předchozí', + 'Next': 'Další', + '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', + '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', + }, + 'he': { + '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': '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', + }, + 'ms': { + 'Paste a link': 'Tampal Pautan', + 'Ok': 'Ok', + 'Select Color': 'Pilih Warna', + 'Gallery': 'Galeri', + 'Link': 'Pautan', + 'Please first select some text to transform into a link.': + 'Sila pilih beberapa patah perkataan' + ' untuk diubah menjadi pautan.', + 'Open': 'Buka', + 'Copy': 'Salin', + 'Remove': 'Buang', + 'Save': 'Simpan', + 'Zoom': 'Zum', + 'Saved': 'Telah Disimpan', + 'Text': 'Perkataan', + 'What is entered is not a link': 'Apa yang diisi bukan pautan', + 'Resize': 'Ubah saiz', + 'Width': 'Lebar', + 'Height': 'Tinggi', + 'Size': 'Saiz', + 'Small': 'Kecil', + 'Large': 'Besar', + 'Huge': 'Amat Besar', + 'Clear': 'Padam', + 'Font': 'Fon', + 'Search': 'Carian', + 'matches': 'padanan', + 'showing match': 'menunjukkan padanan', + 'Prev': 'Sebelum', + 'Next': 'Seterusnya', + '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', + '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', + }, + 'it': { + 'Paste a link': 'Incolla un collegamento', + 'Ok': 'Ok', + 'Select Color': 'Seleziona Colore', + 'Gallery': 'Galleria', + 'Link': 'Collegamento', + 'Please first select some text to transform into a link.': + 'Per prima cosa seleziona del testo da trasformare in un link.', + 'Open': 'Apri', + 'Copy': 'Copia', + 'Remove': 'Rimuovi', + 'Save': 'Salva', + 'Zoom': 'Ingrandisci', + 'Saved': 'Salvato', + 'Text': 'Testo', + 'What is entered is not a link': + 'Ciò che viene inserito non è un collegamento', + 'Resize': 'Ridimensiona', + 'Width': 'Larghezza', + 'Height': 'Altezza', + 'Size': 'Dimensione', + 'Small': 'Piccolo', + 'Large': 'Largo', + 'Huge': 'Enorme', + 'Clear': 'Cancella', + 'Font': 'Font', + 'Search': 'Ricerca', + 'matches': 'corrispondenze', + 'showing match': 'visualizza corrispondenza', + 'Prev': 'Prec', + 'Next': 'Succ', + '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', + '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', }, }; diff --git a/lib/src/utils/cast.dart b/lib/src/utils/cast.dart new file mode 100644 index 00000000..9bc50fdb --- /dev/null +++ b/lib/src/utils/cast.dart @@ -0,0 +1 @@ +T? castOrNull(dynamic x) => x is T ? x : null; diff --git a/lib/src/utils/embeds.dart b/lib/src/utils/embeds.dart index 7ee2dd0d..db693ba8 100644 --- a/lib/src/utils/embeds.dart +++ b/lib/src/utils/embeds.dart @@ -1,11 +1,10 @@ import 'dart:math'; -import 'package:tuple/tuple.dart'; - import '../models/documents/nodes/leaf.dart'; +import '../models/structs/offset_value.dart'; import '../widgets/controller.dart'; -Tuple2 getEmbedNode(QuillController controller, int offset) { +OffsetValue getEmbedNode(QuillController controller, int offset) { var offset = controller.selection.start; var embedNode = controller.queryNode(offset); if (embedNode == null || !(embedNode is Embed)) { @@ -13,7 +12,7 @@ Tuple2 getEmbedNode(QuillController controller, int offset) { embedNode = controller.queryNode(offset); } if (embedNode != null && embedNode is Embed) { - return Tuple2(offset, embedNode); + return OffsetValue(offset, embedNode); } return throw 'Embed node not found by offset $offset'; diff --git a/lib/src/utils/font.dart b/lib/src/utils/font.dart index 4962a9a2..1e996e10 100644 --- a/lib/src/utils/font.dart +++ b/lib/src/utils/font.dart @@ -1,5 +1,6 @@ dynamic getFontSize(dynamic sizeValue) { - if (sizeValue is String && ['small', 'large', 'huge'].contains(sizeValue)) { + if (sizeValue is String && + ['small', 'normal', 'large', 'huge'].contains(sizeValue)) { return sizeValue; } diff --git a/lib/src/utils/platform.dart b/lib/src/utils/platform.dart index 27d183aa..96cb866b 100644 --- a/lib/src/utils/platform.dart +++ b/lib/src/utils/platform.dart @@ -26,6 +26,10 @@ bool isAppleOS([TargetPlatform? targetPlatform]) { } Future isIOSSimulator() async { + if (!isAppleOS()) { + return false; + } + final deviceInfo = DeviceInfoPlugin(); final osInfo = await deviceInfo.deviceInfo; diff --git a/lib/src/utils/widgets.dart b/lib/src/utils/widgets.dart new file mode 100644 index 00000000..c27777f0 --- /dev/null +++ b/lib/src/utils/widgets.dart @@ -0,0 +1,21 @@ +import 'package:flutter/material.dart'; + +typedef WidgetWrapper = Widget Function(Widget child); + +/// Provides utiulity widgets. +abstract class UtilityWidgets { + /// Conditionally wraps the [child] with [Tooltip] widget if [message] + /// is not null and not empty. + static Widget maybeTooltip({required Widget child, String? message}) => + (message ?? '').isNotEmpty + ? Tooltip(message: message!, child: child) + : child; + + /// Conditionally wraps the [child] with [wrapper] widget if [enabled] + /// is true. + static Widget maybeWidget( + {required WidgetWrapper wrapper, + required Widget child, + bool enabled = false}) => + enabled ? wrapper(child) : child; +} diff --git a/lib/src/widgets/controller.dart b/lib/src/widgets/controller.dart index db6637a5..aedd7015 100644 --- a/lib/src/widgets/controller.dart +++ b/lib/src/widgets/controller.dart @@ -2,7 +2,6 @@ import 'dart:math' as math; import 'package:flutter/cupertino.dart'; import 'package:flutter/services.dart'; -import 'package:tuple/tuple.dart'; import '../models/documents/attribute.dart'; import '../models/documents/document.dart'; @@ -10,6 +9,9 @@ import '../models/documents/nodes/embeddable.dart'; import '../models/documents/nodes/leaf.dart'; import '../models/documents/style.dart'; import '../models/quill_delta.dart'; +import '../models/structs/doc_change.dart'; +import '../models/structs/image_url.dart'; +import '../models/structs/offset_value.dart'; import '../utils/delta.dart'; typedef ReplaceTextCallback = bool Function(int index, int len, Object? data); @@ -17,14 +19,15 @@ typedef DeleteCallback = void Function(int cursorPosition, bool forward); class QuillController extends ChangeNotifier { QuillController({ - required this.document, + required Document document, required TextSelection selection, bool keepStyleOnNewLine = false, this.onReplaceText, this.onDelete, this.onSelectionCompleted, this.onSelectionChanged, - }) : _selection = selection, + }) : _document = document, + _selection = selection, _keepStyleOnNewLine = keepStyleOnNewLine; factory QuillController.basic() { @@ -35,7 +38,16 @@ class QuillController extends ChangeNotifier { } /// Document managed by this controller. - final Document document; + Document _document; + Document get document => _document; + set document(doc) { + _document = doc; + + // Prevent the selection from + _selection = const TextSelection(baseOffset: 0, extentOffset: 0); + + notifyListeners(); + } /// Tells whether to keep or reset the [toggledStyle] /// when user adds a new line. @@ -72,12 +84,7 @@ class QuillController extends ChangeNotifier { /// removing or listeners to this instance. bool _isDisposed = false; - // item1: Document state before [change]. - // - // item2: Change delta applied to the document. - // - // item3: The source of this change. - Stream> get changes => document.changes; + Stream get changes => document.changes; TextEditingValue get plainTextEditingValue => TextEditingValue( text: document.toPlainText(), @@ -92,8 +99,68 @@ class QuillController extends ChangeNotifier { .mergeAll(toggledStyle); } + // Increases or decreases the indent of the current selection by 1. + void indentSelection(bool isIncrease) { + if (selection.isCollapsed) { + _indentSelectionFormat(isIncrease); + } else { + _indentSelectionEachLine(isIncrease); + } + } + + void _indentSelectionFormat(bool isIncrease) { + final indent = getSelectionStyle().attributes[Attribute.indent.key]; + if (indent == null) { + if (isIncrease) { + formatSelection(Attribute.indentL1); + } + return; + } + if (indent.value == 1 && !isIncrease) { + formatSelection(Attribute.clone(Attribute.indentL1, null)); + return; + } + if (isIncrease) { + formatSelection(Attribute.getIndentLevel(indent.value + 1)); + return; + } + formatSelection(Attribute.getIndentLevel(indent.value - 1)); + } + + void _indentSelectionEachLine(bool isIncrease) { + final styles = document.collectAllStylesWithOffset( + selection.start, + selection.end - selection.start, + ); + for (final style in styles) { + final indent = style.value.attributes[Attribute.indent.key]; + final formatIndex = math.max(style.offset, selection.start); + final formatLength = math.min( + style.offset + (style.length ?? 0), + selection.end, + ) - + style.offset; + Attribute? formatAttribute; + if (indent == null) { + if (isIncrease) { + formatAttribute = Attribute.indentL1; + } + } else if (indent.value == 1 && !isIncrease) { + formatAttribute = Attribute.clone(Attribute.indentL1, null); + } else if (isIncrease) { + formatAttribute = Attribute.getIndentLevel(indent.value + 1); + } else { + formatAttribute = Attribute.getIndentLevel(indent.value - 1); + } + if (formatAttribute != null) { + document.format(formatIndex, formatLength, formatAttribute); + } + } + notifyListeners(); + } + /// Returns all styles for each node within selection - List> getAllIndividualSelectionStyles() { + List> getAllIndividualSelectionStyles() { final styles = document.collectAllIndividualStyles( selection.start, selection.end - selection.start); return styles; @@ -115,9 +182,9 @@ class QuillController extends ChangeNotifier { } void undo() { - final tup = document.undo(); - if (tup.item1) { - _handleHistoryChange(tup.item2); + final result = document.undo(); + if (result.changed) { + _handleHistoryChange(result.len); } } @@ -137,9 +204,9 @@ class QuillController extends ChangeNotifier { } void redo() { - final tup = document.redo(); - if (tup.item1) { - _handleHistoryChange(tup.item2); + final result = document.redo(); + if (result.changed) { + _handleHistoryChange(result.len); } } @@ -188,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); @@ -333,22 +392,27 @@ 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); } /// Given offset, find its leaf node in document Leaf? queryNode(int offset) { - return document.querySegmentLeafNode(offset).item2; + return document.querySegmentLeafNode(offset).leaf; } /// Clipboard for image url and its corresponding style - /// item1 is url and item2 is style string - Tuple2? _copiedImageUrl; + ImageUrl? _copiedImageUrl; - Tuple2? get copiedImageUrl => _copiedImageUrl; + ImageUrl? get copiedImageUrl => _copiedImageUrl; - set copiedImageUrl(Tuple2? value) { + set copiedImageUrl(ImageUrl? value) { _copiedImageUrl = value; Clipboard.setData(const ClipboardData(text: '')); } diff --git a/lib/src/widgets/default_styles.dart b/lib/src/widgets/default_styles.dart index 6c766e2b..c2d6e878 100644 --- a/lib/src/widgets/default_styles.dart +++ b/lib/src/widgets/default_styles.dart @@ -1,8 +1,10 @@ +import 'dart:ui'; + import 'package:flutter/material.dart'; -import 'package:tuple/tuple.dart'; import '../models/documents/attribute.dart'; import '../models/documents/style.dart'; +import '../models/structs/vertical_spacing.dart'; import '../utils/platform.dart'; import 'style_widgets/checkbox_point.dart'; @@ -44,11 +46,11 @@ class DefaultTextBlockStyle { final TextStyle style; /// Vertical spacing around a text block. - final Tuple2 verticalSpacing; + final VerticalSpacing verticalSpacing; /// Vertical spacing for individual lines within a text block. /// - final Tuple2 lineSpacing; + final VerticalSpacing lineSpacing; /// Decoration of a text block. /// @@ -125,8 +127,8 @@ class InlineCodeStyle { class DefaultListBlockStyle extends DefaultTextBlockStyle { DefaultListBlockStyle( TextStyle style, - Tuple2 verticalSpacing, - Tuple2 lineSpacing, + VerticalSpacing verticalSpacing, + VerticalSpacing lineSpacing, BoxDecoration? decoration, this.checkboxUIBuilder, ) : super(style, verticalSpacing, lineSpacing, decoration); @@ -141,6 +143,8 @@ class DefaultStyles { this.h3, this.paragraph, this.bold, + this.subscript, + this.superscript, this.italic, this.small, this.underline, @@ -165,6 +169,8 @@ class DefaultStyles { final DefaultTextBlockStyle? h3; final DefaultTextBlockStyle? paragraph; final TextStyle? bold; + final TextStyle? subscript; + final TextStyle? superscript; final TextStyle? italic; final TextStyle? small; final TextStyle? underline; @@ -193,7 +199,7 @@ class DefaultStyles { height: 1.3, decoration: TextDecoration.none, ); - const baseSpacing = Tuple2(6, 0); + const baseSpacing = VerticalSpacing(6, 0); String fontFamily; if (isAppleOS(themeData.platform)) { fontFamily = 'Menlo'; @@ -216,8 +222,8 @@ class DefaultStyles { fontWeight: FontWeight.w300, decoration: TextDecoration.none, ), - const Tuple2(16, 0), - const Tuple2(0, 0), + const VerticalSpacing(16, 0), + const VerticalSpacing(0, 0), null), h2: DefaultTextBlockStyle( defaultTextStyle.style.copyWith( @@ -227,8 +233,8 @@ class DefaultStyles { fontWeight: FontWeight.normal, decoration: TextDecoration.none, ), - const Tuple2(8, 0), - const Tuple2(0, 0), + const VerticalSpacing(8, 0), + const VerticalSpacing(0, 0), null), h3: DefaultTextBlockStyle( defaultTextStyle.style.copyWith( @@ -238,12 +244,15 @@ class DefaultStyles { fontWeight: FontWeight.w500, decoration: TextDecoration.none, ), - const Tuple2(8, 0), - const Tuple2(0, 0), + const VerticalSpacing(8, 0), + const VerticalSpacing(0, 0), null), - paragraph: DefaultTextBlockStyle( - baseStyle, const Tuple2(0, 0), const Tuple2(0, 0), null), + paragraph: DefaultTextBlockStyle(baseStyle, const VerticalSpacing(0, 0), + const VerticalSpacing(0, 0), null), bold: const TextStyle(fontWeight: FontWeight.bold), + subscript: const TextStyle(fontFeatures: [FontFeature.subscripts()]), + superscript: + const TextStyle(fontFeatures: [FontFeature.superscripts()]), italic: const TextStyle(fontStyle: FontStyle.italic), small: const TextStyle(fontSize: 12), underline: const TextStyle(decoration: TextDecoration.underline), @@ -272,15 +281,15 @@ class DefaultStyles { height: 1.5, color: Colors.grey.withOpacity(0.6), ), - const Tuple2(0, 0), - const Tuple2(0, 0), + const VerticalSpacing(0, 0), + const VerticalSpacing(0, 0), null), lists: DefaultListBlockStyle( - baseStyle, baseSpacing, const Tuple2(0, 6), null, null), + baseStyle, baseSpacing, const VerticalSpacing(0, 6), null, null), quote: DefaultTextBlockStyle( TextStyle(color: baseStyle.color!.withOpacity(0.6)), baseSpacing, - const Tuple2(6, 2), + const VerticalSpacing(6, 2), BoxDecoration( border: Border( left: BorderSide(width: 4, color: Colors.grey.shade300), @@ -294,17 +303,17 @@ class DefaultStyles { height: 1.15, ), baseSpacing, - const Tuple2(0, 0), + const VerticalSpacing(0, 0), BoxDecoration( color: Colors.grey.shade50, borderRadius: BorderRadius.circular(2), )), indent: DefaultTextBlockStyle( - baseStyle, baseSpacing, const Tuple2(0, 6), null), - align: DefaultTextBlockStyle( - baseStyle, const Tuple2(0, 0), const Tuple2(0, 0), null), - leading: DefaultTextBlockStyle( - baseStyle, const Tuple2(0, 0), const Tuple2(0, 0), null), + baseStyle, baseSpacing, const VerticalSpacing(0, 6), null), + align: DefaultTextBlockStyle(baseStyle, const VerticalSpacing(0, 0), + const VerticalSpacing(0, 0), null), + leading: DefaultTextBlockStyle(baseStyle, const VerticalSpacing(0, 0), + const VerticalSpacing(0, 0), null), sizeSmall: const TextStyle(fontSize: 10), sizeLarge: const TextStyle(fontSize: 18), sizeHuge: const TextStyle(fontSize: 22)); @@ -317,6 +326,8 @@ class DefaultStyles { h3: other.h3 ?? h3, paragraph: other.paragraph ?? paragraph, bold: other.bold ?? bold, + subscript: other.subscript ?? subscript, + superscript: other.superscript ?? superscript, italic: other.italic ?? italic, small: other.small ?? small, underline: other.underline ?? underline, diff --git a/lib/src/widgets/delegate.dart b/lib/src/widgets/delegate.dart index a75b64e7..988473bd 100644 --- a/lib/src/widgets/delegate.dart +++ b/lib/src/widgets/delegate.dart @@ -3,19 +3,20 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; -import '../../flutter_quill.dart'; +import '../models/documents/attribute.dart'; +import '../models/documents/nodes/leaf.dart'; import '../utils/platform.dart'; +import 'editor.dart'; +import 'embeds.dart'; import 'text_selection.dart'; -typedef EmbedsBuilder = Widget Function( - BuildContext context, - QuillController controller, - Embed node, - bool readOnly, -); +typedef EmbedsBuilder = EmbedBuilder Function(Embed node); typedef CustomStyleBuilder = TextStyle Function(Attribute attribute); +typedef CustomRecognizerBuilder = GestureRecognizer? Function( + Attribute attribute, Leaf leaf); + /// Delegate interface for the [EditorTextSelectionGestureDetectorBuilder]. /// /// The interface is usually implemented by textfield implementations wrapping @@ -66,7 +67,8 @@ class EditorTextSelectionGestureDetectorBuilder { /// Creates a [EditorTextSelectionGestureDetectorBuilder]. /// /// The [delegate] must not be null. - EditorTextSelectionGestureDetectorBuilder({required this.delegate}); + EditorTextSelectionGestureDetectorBuilder( + {required this.delegate, this.detectWordBoundary = true}); /// The delegate for this [EditorTextSelectionGestureDetectorBuilder]. /// @@ -83,6 +85,8 @@ class EditorTextSelectionGestureDetectorBuilder { /// a stylus. bool shouldShowSelectionToolbar = true; + bool detectWordBoundary = true; + /// The [State] of the [EditableText] for which the builder will provide a /// [EditorTextSelectionGestureDetector]. @protected @@ -308,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); } @@ -337,24 +342,28 @@ class EditorTextSelectionGestureDetectorBuilder { /// /// The [child] or its subtree should contain [EditableText]. Widget build( - {required HitTestBehavior behavior, required Widget child, Key? key}) { + {required HitTestBehavior behavior, + required Widget child, + Key? key, + bool detectWordBoundary = true}) { return EditorTextSelectionGestureDetector( - key: key, - onTapDown: onTapDown, - onForcePressStart: delegate.forcePressEnabled ? onForcePressStart : null, - onForcePressEnd: delegate.forcePressEnabled ? onForcePressEnd : null, - onSingleTapUp: onSingleTapUp, - onSingleTapCancel: onSingleTapCancel, - onSingleLongTapStart: onSingleLongTapStart, - onSingleLongTapMoveUpdate: onSingleLongTapMoveUpdate, - onSingleLongTapEnd: onSingleLongTapEnd, - onDoubleTapDown: onDoubleTapDown, - onSecondarySingleTapUp: onSecondarySingleTapUp, - onDragSelectionStart: onDragSelectionStart, - onDragSelectionUpdate: onDragSelectionUpdate, - onDragSelectionEnd: onDragSelectionEnd, - behavior: behavior, - child: child, - ); + key: key, + onTapDown: onTapDown, + onForcePressStart: + delegate.forcePressEnabled ? onForcePressStart : null, + onForcePressEnd: delegate.forcePressEnabled ? onForcePressEnd : null, + onSingleTapUp: onSingleTapUp, + onSingleTapCancel: onSingleTapCancel, + onSingleLongTapStart: onSingleLongTapStart, + onSingleLongTapMoveUpdate: onSingleLongTapMoveUpdate, + onSingleLongTapEnd: onSingleLongTapEnd, + onDoubleTapDown: onDoubleTapDown, + onSecondarySingleTapUp: onSecondarySingleTapUp, + onDragSelectionStart: onDragSelectionStart, + onDragSelectionUpdate: onDragSelectionUpdate, + onDragSelectionEnd: onDragSelectionEnd, + behavior: behavior, + detectWordBoundary: detectWordBoundary, + child: child); } } diff --git a/lib/src/widgets/editor.dart b/lib/src/widgets/editor.dart index 5568b8ba..52cf9a67 100644 --- a/lib/src/widgets/editor.dart +++ b/lib/src/widgets/editor.dart @@ -9,13 +9,13 @@ import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; import 'package:i18n_extension/i18n_widget.dart'; -import 'package:tuple/tuple.dart'; import '../models/documents/document.dart'; import '../models/documents/nodes/container.dart' as container_node; -import '../models/documents/nodes/embeddable.dart'; 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'; import 'box.dart'; import 'controller.dart'; @@ -38,7 +38,7 @@ abstract class EditorState extends State EditorTextSelectionOverlay? get selectionOverlay; - List> get pasteStyle; + List> get pasteStyle; 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(); @@ -144,42 +147,52 @@ abstract class RenderAbstractEditor implements TextLayoutMetrics { } class QuillEditor extends StatefulWidget { - const QuillEditor( - {required this.controller, - required this.focusNode, - required this.scrollController, - required this.scrollable, - required this.padding, - required this.autoFocus, - required this.readOnly, - required this.expands, - this.showCursor, - this.paintCursorAboveText, - this.placeholder, - this.enableInteractiveSelection = true, - this.scrollBottomInset = 0, - this.minHeight, - this.maxHeight, - this.maxContentWidth, - this.customStyles, - this.textCapitalization = TextCapitalization.sentences, - this.keyboardAppearance = Brightness.light, - this.scrollPhysics, - this.onLaunchUrl, - this.onTapDown, - this.onTapUp, - this.onSingleLongTapStart, - this.onSingleLongTapMoveUpdate, - this.onSingleLongTapEnd, - this.embedBuilders, - this.linkActionPickerDelegate = defaultLinkActionPickerDelegate, - this.customStyleBuilder, - this.locale, - this.floatingCursorDisabled = false, - this.textSelectionControls, - this.onImagePaste, - Key? key}) - : super(key: key); + const QuillEditor({ + required this.controller, + required this.focusNode, + required this.scrollController, + required this.scrollable, + required this.padding, + required this.autoFocus, + required this.readOnly, + required this.expands, + this.showCursor, + this.paintCursorAboveText, + this.placeholder, + this.enableInteractiveSelection = true, + this.enableSelectionToolbar = true, + this.scrollBottomInset = 0, + this.minHeight, + this.maxHeight, + this.maxContentWidth, + this.customStyles, + this.textCapitalization = TextCapitalization.sentences, + this.keyboardAppearance = Brightness.light, + this.scrollPhysics, + this.onLaunchUrl, + this.onTapDown, + this.onTapUp, + this.onSingleLongTapStart, + this.onSingleLongTapMoveUpdate, + this.onSingleLongTapEnd, + this.embedBuilders, + this.unknownEmbedBuilder, + this.linkActionPickerDelegate = defaultLinkActionPickerDelegate, + this.customStyleBuilder, + this.customRecognizerBuilder, + this.locale, + this.floatingCursorDisabled = false, + this.textSelectionControls, + this.onImagePaste, + this.customShortcuts, + this.customActions, + this.detectWordBoundary = true, + this.enableUnfocusOnTapOutside = true, + this.customLinkPrefixes = const [], + this.dialogTheme, + this.contentInsertionConfiguration, + Key? key, + }) : super(key: key); factory QuillEditor.basic({ required QuillController controller, @@ -241,6 +254,9 @@ class QuillEditor extends StatefulWidget { /// Defaults to `false`. Cannot be `null`. final bool autoFocus; + /// Whether focus should be revoked on tap outside the editor. + final bool enableUnfocusOnTapOutside; + /// Whether to show cursor. /// /// The cursor refers to the blinking caret when the editor is focused. @@ -266,8 +282,14 @@ class QuillEditor extends StatefulWidget { /// When this is false, the text selection cannot be adjusted by /// the user, text cannot be copied, and the user cannot paste into /// the text field from the clipboard. + /// + /// To disable just the selection toolbar, set enableSelectionToolbar + /// to false. final bool enableInteractiveSelection; + /// Whether to show the cut/copy/paste menu when selecting text. + final bool enableSelectionToolbar; + /// The minimum height to be occupied by this editor. /// /// This only has effect if [scrollable] is set to `true` and [expands] is @@ -287,6 +309,7 @@ class QuillEditor extends StatefulWidget { /// horizontally centered. This is mostly useful on devices with wide screens. final double? maxContentWidth; + /// Allows to override [DefaultStyles]. final DefaultStyles? customStyles; /// Whether this editor's height will be sized to fill its parent. @@ -352,7 +375,9 @@ class QuillEditor extends StatefulWidget { onSingleLongTapEnd; final Iterable? embedBuilders; + final EmbedBuilder? unknownEmbedBuilder; final CustomStyleBuilder? customStyleBuilder; + final CustomRecognizerBuilder? customRecognizerBuilder; /// The locale to use for the editor toolbar, defaults to system locale /// More https://github.com/singerdmx/flutter-quill#translation @@ -385,6 +410,33 @@ class QuillEditor extends StatefulWidget { /// Returns the url of the image if the image should be inserted. final Future Function(Uint8List imageBytes)? onImagePaste; + /// Contains user-defined shortcuts map. + /// + /// [https://docs.flutter.dev/development/ui/advanced/actions-and-shortcuts#shortcuts] + final Map? customShortcuts; + + /// Contains user-defined actions. + /// + /// [https://docs.flutter.dev/development/ui/advanced/actions-and-shortcuts#actions] + final Map>? customActions; + + final bool detectWordBoundary; + + /// Additional list if links prefixes, which must not be prepended + /// with "https://" when [LinkMenuAction.launch] happened + /// + /// Useful for deeplinks + final List customLinkPrefixes; + + /// 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(); } @@ -399,7 +451,8 @@ class QuillEditorState extends State void initState() { super.initState(); _selectionGestureDetectorBuilder = - _QuillEditorSelectionGestureDetectorBuilder(this); + _QuillEditorSelectionGestureDetectorBuilder( + this, widget.detectWordBoundary); } @override @@ -424,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; @@ -435,6 +488,9 @@ class QuillEditorState extends State theme.colorScheme.primary.withOpacity(0.40); } + final showSelectionToolbar = + widget.enableInteractiveSelection && widget.enableSelectionToolbar; + final child = RawEditor( key: _editorKey, controller: widget.controller, @@ -446,12 +502,8 @@ class QuillEditorState extends State readOnly: widget.readOnly, placeholder: widget.placeholder, onLaunchUrl: widget.onLaunchUrl, - toolbarOptions: ToolbarOptions( - copy: widget.enableInteractiveSelection, - cut: widget.enableInteractiveSelection, - paste: widget.enableInteractiveSelection, - selectAll: widget.enableInteractiveSelection, - ), + contextMenuBuilder: + showSelectionToolbar ? RawEditor.defaultContextMenuBuilder : null, showSelectionHandles: isMobile(theme.platform), showCursor: widget.showCursor, cursorStyle: CursorStyle( @@ -475,17 +527,18 @@ class QuillEditorState extends State keyboardAppearance: widget.keyboardAppearance, enableInteractiveSelection: widget.enableInteractiveSelection, scrollPhysics: widget.scrollPhysics, - embedBuilder: ( - context, - controller, - node, - readOnly, - ) => - _buildCustomBlockEmbed(node, context, controller, readOnly), + embedBuilder: _getEmbedBuilder, linkActionPickerDelegate: widget.linkActionPickerDelegate, customStyleBuilder: widget.customStyleBuilder, + customRecognizerBuilder: widget.customRecognizerBuilder, floatingCursorDisabled: widget.floatingCursorDisabled, onImagePaste: widget.onImagePaste, + customShortcuts: widget.customShortcuts, + customActions: widget.customActions, + customLinkPrefixes: widget.customLinkPrefixes, + enableUnfocusOnTapOutside: widget.enableUnfocusOnTapOutside, + dialogTheme: widget.dialogTheme, + contentInsertionConfiguration: widget.contentInsertionConfiguration, ); final editor = I18n( @@ -493,6 +546,7 @@ class QuillEditorState extends State child: selectionEnabled ? _selectionGestureDetectorBuilder.build( behavior: HitTestBehavior.translucent, + detectWordBoundary: widget.detectWordBoundary, child: child, ) : child, @@ -515,29 +569,26 @@ class QuillEditorState extends State return editor; } - Widget _buildCustomBlockEmbed(Embed node, BuildContext context, - QuillController controller, bool readOnly) { + EmbedBuilder _getEmbedBuilder(Embed node) { final builders = widget.embedBuilders; if (builders != null) { - var _node = node; - - // Creates correct node for custom embed - if (node.value.type == BlockEmbed.customType) { - _node = Embed(CustomBlockEmbed.fromJsonString(node.value.data)); - } - for (final builder in builders) { - if (builder.key == _node.value.type) { - return builder.build(context, controller, _node, readOnly); + if (builder.key == node.value.type) { + return builder; } } } + if (widget.unknownEmbedBuilder != null) { + return widget.unknownEmbedBuilder!; + } + throw UnimplementedError( 'Embeddable type "${node.value.type}" is not supported by supplied ' 'embed builders. You must pass your own builder function to ' - 'embedBuilders property of QuillEditor or QuillField widgets.', + 'embedBuilders property of QuillEditor or QuillField widgets or ' + 'specify an unknownEmbedBuilder.', ); } @@ -557,10 +608,12 @@ class QuillEditorState extends State class _QuillEditorSelectionGestureDetectorBuilder extends EditorTextSelectionGestureDetectorBuilder { - _QuillEditorSelectionGestureDetectorBuilder(this._state) - : super(delegate: _state); + _QuillEditorSelectionGestureDetectorBuilder( + this._state, this._detectWordBoundary) + : super(delegate: _state, detectWordBoundary: _detectWordBoundary); final QuillEditorState _state; + final bool _detectWordBoundary; @override void onForcePressStart(ForcePressDetails details) { @@ -608,11 +661,11 @@ class _QuillEditorSelectionGestureDetectorBuilder final pos = renderEditor!.getPositionForOffset(details.globalPosition); final result = editor!.widget.controller.document.querySegmentLeafNode(pos.offset); - final line = result.item1; + final line = result.line; if (line == null) { return false; } - final segmentLeaf = result.item2; + final segmentLeaf = result.leaf; if (segmentLeaf == null && line.length == 1) { editor!.widget.controller.updateSelection( TextSelection.collapsed(offset: pos.offset), ChangeSource.LOCAL); @@ -678,9 +731,15 @@ class _QuillEditorSelectionGestureDetectorBuilder case PointerDeviceKind.unknown: // On macOS/iOS/iPadOS a touch tap places the cursor at the edge // of the word. - renderEditor! - ..selectWordEdge(SelectionChangedCause.tap) - ..onSelectionCompleted(); + if (_detectWordBoundary) { + renderEditor! + ..selectWordEdge(SelectionChangedCause.tap) + ..onSelectionCompleted(); + } else { + renderEditor! + ..selectPosition(cause: SelectionChangedCause.tap) + ..onSelectionCompleted(); + } break; case PointerDeviceKind.trackpad: // TODO: Handle this case. @@ -1109,7 +1168,7 @@ class RenderEditor extends RenderEditableContainerBox start: localWord.start + nodeOffset, end: localWord.end + nodeOffset, ); - if (position.offset - word.start <= 1) { + if (position.offset - word.start <= 1 && word.end != position.offset) { _handleSelectionChange( TextSelection.collapsed(offset: word.start), cause, @@ -1768,7 +1827,10 @@ class RenderEditableContainerBox extends RenderBox dy += child.size.height; child = childAfter(child); } - throw StateError('No child at offset $offset.'); + + // this case possible, when editor not scrollable, + // but minHeight > content height and tap was under content + return lastChild!; } @override diff --git a/lib/src/widgets/embeds.dart b/lib/src/widgets/embeds.dart index c0262a08..565ee778 100644 --- a/lib/src/widgets/embeds.dart +++ b/lib/src/widgets/embeds.dart @@ -6,13 +6,22 @@ import '../models/themes/quill_icon_theme.dart'; import 'controller.dart'; abstract class EmbedBuilder { + const EmbedBuilder(); + String get key; + bool get expanded => true; + + WidgetSpan buildWidgetSpan(Widget widget) { + return WidgetSpan(child: widget); + } Widget build( BuildContext context, QuillController controller, leaf.Embed node, bool readOnly, + bool inline, + TextStyle textStyle, ); } diff --git a/lib/src/widgets/proxy.dart b/lib/src/widgets/proxy.dart index 8f4d231f..155e620a 100644 --- a/lib/src/widgets/proxy.dart +++ b/lib/src/widgets/proxy.dart @@ -290,7 +290,7 @@ class RenderParagraphProxy extends RenderProxyBox @override List getBoxesForSelection(TextSelection selection) => child! - .getBoxesForSelection(selection, boxHeightStyle: BoxHeightStyle.strut); + .getBoxesForSelection(selection, boxHeightStyle: BoxHeightStyle.max); @override void performLayout() { diff --git a/lib/src/widgets/raw_editor.dart b/lib/src/widgets/raw_editor.dart index 1b4cd939..48b65df0 100644 --- a/lib/src/widgets/raw_editor.dart +++ b/lib/src/widgets/raw_editor.dart @@ -1,10 +1,10 @@ import 'dart:async'; import 'dart:convert'; +import 'dart:io'; import 'dart:math' as math; -// ignore: unnecessary_import -import 'dart:typed_data'; +import 'dart:ui' as ui hide TextStyle; -import 'package:flutter/cupertino.dart'; +import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; @@ -12,15 +12,19 @@ import 'package:flutter/scheduler.dart'; import 'package:flutter/services.dart'; import 'package:flutter_keyboard_visibility/flutter_keyboard_visibility.dart'; import 'package:pasteboard/pasteboard.dart'; -import 'package:tuple/tuple.dart'; import '../models/documents/attribute.dart'; import '../models/documents/document.dart'; import '../models/documents/nodes/block.dart'; 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'; +import '../utils/cast.dart'; import '../utils/delta.dart'; import '../utils/embeds.dart'; import '../utils/platform.dart'; @@ -38,46 +42,50 @@ import 'raw_editor/raw_editor_state_text_input_client_mixin.dart'; import 'text_block.dart'; import 'text_line.dart'; import 'text_selection.dart'; +import 'toolbar/link_style_button2.dart'; +import 'toolbar/search_dialog.dart'; class RawEditor extends StatefulWidget { - const RawEditor( - {required this.controller, - required this.focusNode, - required this.scrollController, - required this.scrollBottomInset, - required this.cursorStyle, - required this.selectionColor, - required this.selectionCtrls, - required this.embedBuilder, - Key? key, - this.scrollable = true, - this.padding = EdgeInsets.zero, - this.readOnly = false, - this.placeholder, - this.onLaunchUrl, - this.toolbarOptions = const ToolbarOptions( - copy: true, - cut: true, - paste: true, - selectAll: true, - ), - this.showSelectionHandles = false, - bool? showCursor, - this.textCapitalization = TextCapitalization.none, - this.maxHeight, - this.minHeight, - this.maxContentWidth, - this.customStyles, - this.expands = false, - this.autoFocus = false, - this.keyboardAppearance = Brightness.light, - this.enableInteractiveSelection = true, - this.scrollPhysics, - this.linkActionPickerDelegate = defaultLinkActionPickerDelegate, - this.customStyleBuilder, - this.floatingCursorDisabled = false, - this.onImagePaste}) - : assert(maxHeight == null || maxHeight > 0, 'maxHeight cannot be null'), + const RawEditor({ + required this.controller, + required this.focusNode, + required this.scrollController, + required this.scrollBottomInset, + required this.cursorStyle, + required this.selectionColor, + required this.selectionCtrls, + required this.embedBuilder, + Key? key, + this.scrollable = true, + this.padding = EdgeInsets.zero, + this.readOnly = false, + this.placeholder, + this.onLaunchUrl, + this.contextMenuBuilder = defaultContextMenuBuilder, + this.showSelectionHandles = false, + bool? showCursor, + this.textCapitalization = TextCapitalization.none, + this.maxHeight, + this.minHeight, + this.maxContentWidth, + this.customStyles, + this.customShortcuts, + this.customActions, + this.expands = false, + this.autoFocus = false, + this.enableUnfocusOnTapOutside = true, + this.keyboardAppearance = Brightness.light, + this.enableInteractiveSelection = true, + this.scrollPhysics, + this.linkActionPickerDelegate = defaultLinkActionPickerDelegate, + this.customStyleBuilder, + this.customRecognizerBuilder, + this.floatingCursorDisabled = false, + 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, 'maxHeight cannot be null'), @@ -92,6 +100,7 @@ class RawEditor extends StatefulWidget { final ScrollController scrollController; final bool scrollable; final double scrollBottomInset; + final bool enableUnfocusOnTapOutside; /// Additional space around the editor contents. final EdgeInsetsGeometry padding; @@ -110,11 +119,26 @@ class RawEditor extends StatefulWidget { /// a link in the document. final ValueChanged? onLaunchUrl; - /// Configuration of toolbar options. + /// Builds the text selection toolbar when requested by the user. /// - /// By default, all options are enabled. If [readOnly] is true, - /// paste and cut will be disabled regardless. - final ToolbarOptions toolbarOptions; + /// See also: + /// * [EditableText.contextMenuBuilder], which builds the default + /// text selection toolbar for [EditableText]. + /// + /// If not provided, no context menu will be shown. + final QuillEditorContextMenuBuilder? contextMenuBuilder; + + static Widget defaultContextMenuBuilder( + BuildContext context, + RawEditorState state, + ) { + return TextFieldTapRegion( + child: AdaptiveTextSelectionToolbar.buttonItems( + buttonItems: state.contextMenuButtonItems, + anchors: state.contextMenuAnchors, + ), + ); + } /// Whether to show selection handles. /// @@ -170,6 +194,7 @@ class RawEditor extends StatefulWidget { /// horizontally centered. This is mostly useful on devices with wide screens. final double? maxContentWidth; + /// Allows to override [DefaultStyles]. final DefaultStyles? customStyles; /// Whether this widget's height will be sized to fill its parent. @@ -225,11 +250,32 @@ class RawEditor extends StatefulWidget { final Future Function(Uint8List imageBytes)? onImagePaste; + /// Contains user-defined shortcuts map. + /// + /// [https://docs.flutter.dev/development/ui/advanced/actions-and-shortcuts#shortcuts] + final Map? customShortcuts; + + /// Contains user-defined actions. + /// + /// [https://docs.flutter.dev/development/ui/advanced/actions-and-shortcuts#actions] + final Map>? customActions; + /// Builder function for embeddable objects. final EmbedsBuilder embedBuilder; final LinkActionPickerDelegate linkActionPickerDelegate; final CustomStyleBuilder? customStyleBuilder; + final CustomRecognizerBuilder? customRecognizerBuilder; final bool floatingCursorDisabled; + final List customLinkPrefixes; + + /// 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(); @@ -264,6 +310,7 @@ class RawEditorState extends EditorState // Focus bool _didAutoFocus = false; + bool get _hasFocus => widget.focusNode.hasFocus; // Theme @@ -271,8 +318,9 @@ class RawEditorState extends EditorState // for pasting style @override - List> get pasteStyle => _pasteStyle; - List> _pasteStyle = >[]; + List> get pasteStyle => _pasteStyle; + List> _pasteStyle = >[]; + @override String get pastePlainText => _pastePlainText; String _pastePlainText = ''; @@ -284,6 +332,120 @@ 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]. + /// + /// Copied from [EditableTextState]. + List get contextMenuButtonItems { + return EditableText.getEditableButtonItems( + clipboardStatus: _clipboardStatus.value, + onCopy: copyEnabled + ? () => copySelection(SelectionChangedCause.toolbar) + : null, + onCut: + cutEnabled ? () => cutSelection(SelectionChangedCause.toolbar) : null, + onPaste: + pasteEnabled ? () => pasteText(SelectionChangedCause.toolbar) : null, + onSelectAll: selectAllEnabled + ? () => selectAll(SelectionChangedCause.toolbar) + : null, + ); + } + + /// Returns the anchor points for the default context menu. + /// + /// Copied from [EditableTextState]. + TextSelectionToolbarAnchors get contextMenuAnchors { + final glyphHeights = _getGlyphHeights(); + final selection = textEditingValue.selection; + final points = renderEditor.getEndpointsForSelection(selection); + return TextSelectionToolbarAnchors.fromSelection( + renderBox: renderEditor, + startGlyphHeight: glyphHeights.startGlyphHeight, + endGlyphHeight: glyphHeights.endGlyphHeight, + selectionEndpoints: points, + ); + } + + /// Gets the line heights at the start and end of the selection for the given + /// [RawEditorState]. + /// + /// Copied from [EditableTextState]. + _GlyphHeights _getGlyphHeights() { + final selection = textEditingValue.selection; + + // Only calculate handle rects if the text in the previous frame + // is the same as the text in the current frame. This is done because + // widget.renderObject contains the renderEditable from the previous frame. + // If the text changed between the current and previous frames then + // widget.renderObject.getRectForComposingRange might fail. In cases where + // the current frame is different from the previous we fall back to + // renderObject.preferredLineHeight. + final prevText = renderEditor.document.toPlainText(); + final currText = textEditingValue.text; + if (prevText != currText || !selection.isValid || selection.isCollapsed) { + return _GlyphHeights( + renderEditor.preferredLineHeight(selection.base), + renderEditor.preferredLineHeight(selection.base), + ); + } + + final startCharacterRect = + renderEditor.getLocalRectForCaret(selection.base); + final endCharacterRect = + renderEditor.getLocalRectForCaret(selection.extent); + return _GlyphHeights( + startCharacterRect.height, + endCharacterRect.height, + ); + } + + void _defaultOnTapOutside(PointerDownEvent event) { + /// The focus dropping behavior is only present on desktop platforms + /// and mobile browsers. + switch (defaultTargetPlatform) { + case TargetPlatform.android: + case TargetPlatform.iOS: + case TargetPlatform.fuchsia: + // On mobile platforms, we don't unfocus on touch events unless they're + // in the web browser, but we do unfocus for all other kinds of events. + switch (event.kind) { + case ui.PointerDeviceKind.touch: + if (kIsWeb) { + widget.focusNode.unfocus(); + } + break; + case ui.PointerDeviceKind.mouse: + case ui.PointerDeviceKind.stylus: + case ui.PointerDeviceKind.invertedStylus: + case ui.PointerDeviceKind.unknown: + widget.focusNode.unfocus(); + break; + case ui.PointerDeviceKind.trackpad: + throw UnimplementedError( + 'Unexpected pointer down event for trackpad'); + } + break; + case TargetPlatform.linux: + case TargetPlatform.macOS: + case TargetPlatform.windows: + widget.focusNode.unfocus(); + break; + } + } + @override Widget build(BuildContext context) { assert(debugCheckHasMediaQuery(context)); @@ -291,30 +453,34 @@ class RawEditorState extends EditorState var _doc = controller.document; if (_doc.isEmpty() && widget.placeholder != null) { + final raw = widget.placeholder?.replaceAll(r'"', '\\"'); _doc = Document.fromJson(jsonDecode( - '[{"attributes":{"placeholder":true},"insert":"${widget.placeholder}\\n"}]')); + '[{"attributes":{"placeholder":true},"insert":"$raw\\n"}]')); } 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), + ), ), ), ); @@ -327,7 +493,7 @@ class RawEditorState extends EditorState /// baseline. // This implies that the first line has no styles applied to it. final baselinePadding = - EdgeInsets.only(top: _styles!.paragraph!.verticalSpacing.item1); + EdgeInsets.only(top: _styles!.paragraph!.verticalSpacing.top); child = BaselineProxy( textStyle: _styles!.paragraph!.style, padding: baselinePadding, @@ -336,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), + ), ), ), ), @@ -366,26 +535,156 @@ class RawEditorState extends EditorState minHeight: widget.minHeight ?? 0.0, maxHeight: widget.maxHeight ?? double.infinity); - return QuillStyles( - data: _styles!, - child: Shortcuts( - shortcuts: { - // shortcuts added for Windows platform - LogicalKeySet(LogicalKeyboardKey.escape): - const HideSelectionToolbarIntent(), - LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyZ): - const UndoTextIntent(SelectionChangedCause.keyboard), - LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyY): - const RedoTextIntent(SelectionChangedCause.keyboard), - }, - child: Actions( - actions: _actions, - child: Focus( - focusNode: widget.focusNode, - child: QuillKeyboardListener( - child: Container( - constraints: constraints, - child: child, + final isMacOS = Theme.of(context).platform == TargetPlatform.macOS; + + return TextFieldTapRegion( + enabled: widget.enableUnfocusOnTapOutside, + onTapOutside: _defaultOnTapOutside, + child: QuillStyles( + data: _styles!, + child: Shortcuts( + shortcuts: mergeMaps({ + // shortcuts added for Desktop platforms. + const SingleActivator( + LogicalKeyboardKey.escape, + ): const HideSelectionToolbarIntent(), + SingleActivator( + LogicalKeyboardKey.keyZ, + control: !isMacOS, + meta: isMacOS, + ): const UndoTextIntent(SelectionChangedCause.keyboard), + SingleActivator( + LogicalKeyboardKey.keyY, + control: !isMacOS, + meta: isMacOS, + ): const RedoTextIntent(SelectionChangedCause.keyboard), + + // Selection formatting. + SingleActivator( + LogicalKeyboardKey.keyB, + control: !isMacOS, + meta: isMacOS, + ): const ToggleTextStyleIntent(Attribute.bold), + SingleActivator( + LogicalKeyboardKey.keyU, + control: !isMacOS, + meta: isMacOS, + ): const ToggleTextStyleIntent(Attribute.underline), + SingleActivator( + LogicalKeyboardKey.keyI, + control: !isMacOS, + meta: isMacOS, + ): const ToggleTextStyleIntent(Attribute.italic), + SingleActivator( + LogicalKeyboardKey.keyS, + control: !isMacOS, + meta: isMacOS, + shift: true, + ): const ToggleTextStyleIntent(Attribute.strikeThrough), + SingleActivator( + LogicalKeyboardKey.backquote, + control: !isMacOS, + meta: isMacOS, + ): const ToggleTextStyleIntent(Attribute.inlineCode), + SingleActivator( + LogicalKeyboardKey.tilde, + control: !isMacOS, + meta: isMacOS, + shift: true, + ): const ToggleTextStyleIntent(Attribute.codeBlock), + SingleActivator( + LogicalKeyboardKey.keyB, + control: !isMacOS, + meta: isMacOS, + shift: true, + ): const ToggleTextStyleIntent(Attribute.blockQuote), + SingleActivator( + LogicalKeyboardKey.keyK, + control: !isMacOS, + meta: isMacOS, + ): const ApplyLinkIntent(), + + // Lists + SingleActivator( + LogicalKeyboardKey.keyL, + control: !isMacOS, + meta: isMacOS, + shift: true, + ): const ToggleTextStyleIntent(Attribute.ul), + SingleActivator( + LogicalKeyboardKey.keyO, + control: !isMacOS, + meta: isMacOS, + shift: true, + ): const ToggleTextStyleIntent(Attribute.ol), + SingleActivator( + LogicalKeyboardKey.keyC, + control: !isMacOS, + meta: isMacOS, + shift: true, + ): const ApplyCheckListIntent(), + + // Indents + SingleActivator( + LogicalKeyboardKey.keyM, + control: !isMacOS, + meta: isMacOS, + ): const IndentSelectionIntent(true), + SingleActivator( + LogicalKeyboardKey.keyM, + control: !isMacOS, + meta: isMacOS, + shift: true, + ): const IndentSelectionIntent(false), + + // Headers + SingleActivator( + LogicalKeyboardKey.digit1, + control: !isMacOS, + meta: isMacOS, + ): const ApplyHeaderIntent(Attribute.h1), + SingleActivator( + LogicalKeyboardKey.digit2, + control: !isMacOS, + meta: isMacOS, + ): const ApplyHeaderIntent(Attribute.h2), + SingleActivator( + LogicalKeyboardKey.digit3, + control: !isMacOS, + meta: isMacOS, + ): const ApplyHeaderIntent(Attribute.h3), + SingleActivator( + LogicalKeyboardKey.digit0, + control: !isMacOS, + meta: isMacOS, + ): const ApplyHeaderIntent(Attribute.header), + + SingleActivator( + LogicalKeyboardKey.keyG, + control: !isMacOS, + meta: isMacOS, + ): const InsertEmbedIntent(Attribute.image), + + SingleActivator( + LogicalKeyboardKey.keyF, + control: !isMacOS, + meta: isMacOS, + ): const OpenSearchIntent(), + }, { + ...?widget.customShortcuts + }), + child: Actions( + actions: mergeMaps>(_actions, { + ...?widget.customActions, + }), + child: Focus( + focusNode: widget.focusNode, + onKey: _onKey, + child: QuillKeyboardListener( + child: Container( + constraints: constraints, + child: child, + ), ), ), ), @@ -394,6 +693,142 @@ class RawEditorState extends EditorState ); } + KeyEventResult _onKey(node, RawKeyEvent event) { + // Don't handle key if there is a meta key pressed. + if (event.isAltPressed || event.isControlPressed || event.isMetaPressed) { + return KeyEventResult.ignored; + } + + if (event is! RawKeyDownEvent) { + return KeyEventResult.ignored; + } + // Handle indenting blocks when pressing the tab key. + if (event.logicalKey == LogicalKeyboardKey.tab) { + return _handleTabKey(event); + } + + // Don't handle key if there is an active selection. + if (controller.selection.baseOffset != controller.selection.extentOffset) { + return KeyEventResult.ignored; + } + + // Handle inserting lists when space is pressed following + // a list initiating phrase. + if (event.logicalKey == LogicalKeyboardKey.space) { + return _handleSpaceKey(event); + } + + return KeyEventResult.ignored; + } + + KeyEventResult _handleSpaceKey(RawKeyEvent event) { + final child = + controller.document.queryChild(controller.selection.baseOffset); + if (child.node == null) { + return KeyEventResult.ignored; + } + + final line = child.node as Line?; + if (line == null) { + return KeyEventResult.ignored; + } + + final text = castOrNull(line.first); + if (text == null) { + return KeyEventResult.ignored; + } + + const olKeyPhrase = '1.'; + const ulKeyPhrase = '-'; + + if (text.value == olKeyPhrase) { + _updateSelectionForKeyPhrase(olKeyPhrase, Attribute.ol); + } else if (text.value == ulKeyPhrase) { + _updateSelectionForKeyPhrase(ulKeyPhrase, Attribute.ul); + } else { + return KeyEventResult.ignored; + } + + return KeyEventResult.handled; + } + + KeyEventResult _handleTabKey(RawKeyEvent event) { + final child = + controller.document.queryChild(controller.selection.baseOffset); + + KeyEventResult insertTabCharacter() { + controller.replaceText(controller.selection.baseOffset, 0, '\t', null); + _moveCursor(1); + return KeyEventResult.handled; + } + + if (controller.selection.baseOffset != controller.selection.extentOffset) { + if (child.node == null || child.node!.parent == null) { + return KeyEventResult.handled; + } + final parentBlock = child.node!.parent!; + if (parentBlock.style.containsKey(Attribute.ol.key) || + parentBlock.style.containsKey(Attribute.ul.key) || + parentBlock.style.containsKey(Attribute.checked.key)) { + controller.indentSelection(!event.isShiftPressed); + } + return KeyEventResult.handled; + } + + if (child.node == null) { + return insertTabCharacter(); + } + + final node = child.node!; + + final parent = node.parent; + if (parent == null || parent is! Block) { + return insertTabCharacter(); + } + + if (node is! Line || (node.isNotEmpty && node.first is! leaf.Text)) { + return insertTabCharacter(); + } + + final parentBlock = parent; + if (parentBlock.style.containsKey(Attribute.ol.key) || + parentBlock.style.containsKey(Attribute.ul.key) || + parentBlock.style.containsKey(Attribute.checked.key)) { + if (node.isNotEmpty && + (node.first as leaf.Text).value.isNotEmpty && + controller.selection.base.offset > node.documentOffset) { + return insertTabCharacter(); + } + controller.indentSelection(!event.isShiftPressed); + return KeyEventResult.handled; + } + + if (node.isNotEmpty && (node.first as leaf.Text).value.isNotEmpty) { + return insertTabCharacter(); + } + + return insertTabCharacter(); + } + + void _moveCursor(int chars) { + final selection = controller.selection; + controller.updateSelection( + controller.selection.copyWith( + baseOffset: selection.baseOffset + chars, + extentOffset: selection.baseOffset + chars), + ChangeSource.LOCAL); + } + + void _updateSelectionForKeyPhrase(String phrase, Attribute attribute) { + controller.replaceText(controller.selection.baseOffset - phrase.length, + phrase.length, '\n', null); + _moveCursor(-phrase.length); + controller + ..formatSelection(attribute) + // Remove the added newline. + ..replaceText(controller.selection.baseOffset + 1, 1, '', null); + } + void _handleSelectionChanged( TextSelection selection, SelectionChangedCause cause) { final oldSelection = controller.selection; @@ -430,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) @@ -453,17 +889,32 @@ class RawEditorState extends EditorState List _buildChildren(Document doc, BuildContext context) { final result = []; final indentLevelCounts = {}; + // this need for several ordered list in document + // we need to reset indents Map, if list finished + // List finished when there is node without Attribute.ol in styles + // So in this case we set clearIndents=true and send it + // to the next EditableTextBlock + var prevNodeOl = false; + var clearIndents = false; + for (final node in doc.root.children) { + final attrs = node.style.attributes; + + if (prevNodeOl && attrs[Attribute.list.key] != Attribute.ol) { + clearIndents = true; + } + + prevNodeOl = attrs[Attribute.list.key] == Attribute.ol; + if (node is Line) { final editableTextLine = _getEditableTextLineFromNode(node, context); result.add(Directionality( textDirection: getDirectionOfNode(node), child: editableTextLine)); } else if (node is Block) { - final attrs = node.style.attributes; final editableTextBlock = EditableTextBlock( block: node, controller: controller, - textDirection: _textDirection, + textDirection: getDirectionOfNode(node), scrollBottomInset: widget.scrollBottomInset, verticalSpacing: _getVerticalSpacingForBlock(node, _styles), textSelection: controller.selection, @@ -479,15 +930,21 @@ class RawEditorState extends EditorState onLaunchUrl: widget.onLaunchUrl, cursorCont: _cursorCont, indentLevelCounts: indentLevelCounts, + clearIndents: clearIndents, onCheckboxTap: _handleCheckboxTap, readOnly: widget.readOnly, - customStyleBuilder: widget.customStyleBuilder); + customStyleBuilder: widget.customStyleBuilder, + customLinkPrefixes: widget.customLinkPrefixes); result.add(Directionality( textDirection: getDirectionOfNode(node), child: editableTextBlock)); + + clearIndents = false; } else { + _dirty = false; throw StateError('Unreachable.'); } } + _dirty = false; return result; } @@ -498,11 +955,13 @@ class RawEditorState extends EditorState textDirection: _textDirection, embedBuilder: widget.embedBuilder, customStyleBuilder: widget.customStyleBuilder, + customRecognizerBuilder: widget.customRecognizerBuilder, styles: _styles!, readOnly: widget.readOnly, controller: controller, linkActionPicker: _linkActionPicker, onLaunchUrl: widget.onLaunchUrl, + customLinkPrefixes: widget.customLinkPrefixes, ); final editableTextLine = EditableTextLine( node, @@ -515,12 +974,12 @@ class RawEditorState extends EditorState widget.selectionColor, widget.enableInteractiveSelection, _hasFocus, - MediaQuery.of(context).devicePixelRatio, + View.of(context).devicePixelRatio, _cursorCont); return editableTextLine; } - Tuple2 _getVerticalSpacingForLine( + VerticalSpacing _getVerticalSpacingForLine( Line line, DefaultStyles? defaultStyles) { final attrs = line.style.attributes; if (attrs.containsKey(Attribute.header.key)) { @@ -545,7 +1004,7 @@ class RawEditorState extends EditorState return defaultStyles!.paragraph!.verticalSpacing; } - Tuple2 _getVerticalSpacingForBlock( + VerticalSpacing _getVerticalSpacingForBlock( Block node, DefaultStyles? defaultStyles) { final attrs = node.style.attributes; if (attrs.containsKey(Attribute.blockQuote.key)) { @@ -559,7 +1018,7 @@ class RawEditorState extends EditorState } else if (attrs.containsKey(Attribute.align.key)) { return defaultStyles!.align!.verticalSpacing; } - return const Tuple2(0, 0); + return const VerticalSpacing(0, 0); } @override @@ -587,6 +1046,9 @@ class RawEditorState extends EditorState if (isKeyboardOS()) { _keyboardVisible = true; + } else if (!kIsWeb && Platform.environment.containsKey('FLUTTER_TEST')) { + // treat tests like a keyboard OS + _keyboardVisible = true; } else { // treat iOS Simulator like a keyboard OS isIOSSimulator().then((isIosSimulator) { @@ -721,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); @@ -735,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(); } } @@ -773,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(); } } @@ -793,13 +1264,15 @@ class RawEditorState extends EditorState value: textEditingValue, context: context, debugRequiredFor: widget, - toolbarLayerLink: _toolbarLayerLink, startHandleLayerLink: _startHandleLayerLink, endHandleLayerLink: _endHandleLayerLink, renderObject: renderEditor, selectionCtrls: widget.selectionCtrls, selectionDelegate: this, clipboardStatus: _clipboardStatus, + contextMenuBuilder: widget.contextMenuBuilder == null + ? null + : (context) => widget.contextMenuBuilder!(context, this), ); _selectionOverlay!.handlesVisible = _shouldShowSelectionHandles(); _selectionOverlay!.showHandles(); @@ -807,6 +1280,11 @@ class RawEditorState extends EditorState } void _handleFocusChanged() { + if (dirty) { + SchedulerBinding.instance + .addPostFrameCallback((_) => _handleFocusChanged()); + return; + } openOrCloseConnection(); _cursorCont.startOrStopCursorTimerIfNeeded(_hasFocus, controller.selection); _updateOrDisposeSelectionOverlayIfNeeded(); @@ -821,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 { @@ -902,8 +1379,14 @@ class RawEditorState extends EditorState return; } if (_hasFocus) { + final keyboardAlreadyShown = _keyboardVisible; openConnectionIfNeeded(); - _showCaretOnScreen(); + if (!keyboardAlreadyShown) { + /// delay 500 milliseconds for waiting keyboard show up + Future.delayed(const Duration(milliseconds: 500), _showCaretOnScreen); + } else { + _showCaretOnScreen(); + } } else { widget.focusNode.requestFocus(); } @@ -1011,11 +1494,10 @@ class RawEditorState extends EditorState final index = textEditingValue.selection.baseOffset; final length = textEditingValue.selection.extentOffset - index; final copied = controller.copiedImageUrl!; - controller.replaceText( - index, length, BlockEmbed.image(copied.item1), null); - if (copied.item2.isNotEmpty) { - controller.formatText(getEmbedNode(controller, index + 1).item1, 1, - StyleAttribute(copied.item2)); + controller.replaceText(index, length, BlockEmbed.image(copied.url), null); + if (copied.styleString.isNotEmpty) { + controller.formatText(getEmbedNode(controller, index + 1).offset, 1, + StyleAttribute(copied.styleString)); } controller.copiedImageUrl = null; await Clipboard.setData(const ClipboardData(text: '')); @@ -1168,6 +1650,17 @@ class RawEditorState extends EditorState _UpdateTextSelectionToAdjacentLineAction< ExtendSelectionVerticallyToAdjacentLineIntent>(this); + late final _ToggleTextStyleAction _formatSelectionAction = + _ToggleTextStyleAction(this); + + late final _IndentSelectionAction _indentSelectionAction = + _IndentSelectionAction(this); + + late final _OpenSearchAction _openSearchAction = _OpenSearchAction(this); + late final _ApplyHeaderAction _applyHeaderAction = _ApplyHeaderAction(this); + late final _ApplyCheckListAction _applyCheckListAction = + _ApplyCheckListAction(this); + late final Map> _actions = >{ DoNothingAndStopPropagationTextIntent: DoNothingAction(consumesKey: false), ReplaceTextIntent: _replaceTextAction, @@ -1214,6 +1707,15 @@ class RawEditorState extends EditorState _makeOverridable(_HideSelectionToolbarAction(this)), UndoTextIntent: _makeOverridable(_UndoKeyboardAction(this)), RedoTextIntent: _makeOverridable(_RedoKeyboardAction(this)), + + OpenSearchIntent: _openSearchAction, + + // Selection Formatting + ToggleTextStyleIntent: _formatSelectionAction, + IndentSelectionIntent: _indentSelectionAction, + ApplyHeaderIntent: _applyHeaderAction, + ApplyCheckListIntent: _applyCheckListAction, + ApplyLinkIntent: ApplyLinkAction(this) }; @override @@ -1227,10 +1729,28 @@ class RawEditorState extends EditorState // this is needed for Scribble (Stylus input) in Apple platforms // and this package does not implement this feature } + + @override + void didChangeInputControl( + TextInputControl? oldControl, TextInputControl? newControl) { + // TODO: implement didChangeInputControl + } + + @override + void performSelector(String selectorName) { + final intent = intentForMacOSSelector(selectorName); + + if (intent != null) { + final primaryContext = primaryFocus?.context; + if (primaryContext != null) { + Actions.invoke(primaryContext, intent); + } + } + } } class _Editor extends MultiChildRenderObjectWidget { - _Editor({ + const _Editor({ required Key key, required List children, required this.document, @@ -1491,6 +2011,7 @@ class _DocumentBoundary extends _TextBoundary { @override TextPosition getLeadingTextBoundaryAt(TextPosition position) => const TextPosition(offset: 0); + @override TextPosition getTrailingTextBoundaryAt(TextPosition position) { return TextPosition( @@ -1962,3 +2483,209 @@ class _RedoKeyboardAction extends ContextAction { @override bool get isActionEnabled => true; } + +class ToggleTextStyleIntent extends Intent { + const ToggleTextStyleIntent(this.attribute); + + final Attribute attribute; +} + +// Toggles a text style (underline, bold, italic, strikethrough) on, or off. +class _ToggleTextStyleAction extends Action { + _ToggleTextStyleAction(this.state); + + final RawEditorState state; + + bool _isStyleActive(Attribute styleAttr, Map attrs) { + if (styleAttr.key == Attribute.list.key) { + final attribute = attrs[styleAttr.key]; + if (attribute == null) { + return false; + } + return attribute.value == styleAttr.value; + } + return attrs.containsKey(styleAttr.key); + } + + @override + void invoke(ToggleTextStyleIntent intent, [BuildContext? context]) { + final isActive = _isStyleActive( + intent.attribute, state.controller.getSelectionStyle().attributes); + state.controller.formatSelection( + isActive ? Attribute.clone(intent.attribute, null) : intent.attribute); + } + + @override + bool get isActionEnabled => true; +} + +class IndentSelectionIntent extends Intent { + const IndentSelectionIntent(this.isIncrease); + + final bool isIncrease; +} + +// Toggles a text style (underline, bold, italic, strikethrough) on, or off. +class _IndentSelectionAction extends Action { + _IndentSelectionAction(this.state); + + final RawEditorState state; + + @override + void invoke(IndentSelectionIntent intent, [BuildContext? context]) { + state.controller.indentSelection(intent.isIncrease); + } + + @override + bool get isActionEnabled => true; +} + +class OpenSearchIntent extends Intent { + const OpenSearchIntent(); +} + +// Toggles a text style (underline, bold, italic, strikethrough) on, or off. +class _OpenSearchAction extends ContextAction { + _OpenSearchAction(this.state); + + final RawEditorState state; + + @override + Future invoke(OpenSearchIntent intent, [BuildContext? context]) async { + await showDialog( + context: context!, + builder: (_) => SearchDialog(controller: state.controller, text: ''), + ); + } + + @override + bool get isActionEnabled => true; +} + +class ApplyHeaderIntent extends Intent { + const ApplyHeaderIntent(this.header); + + final Attribute header; +} + +// Toggles a text style (underline, bold, italic, strikethrough) on, or off. +class _ApplyHeaderAction extends Action { + _ApplyHeaderAction(this.state); + + final RawEditorState state; + + Attribute _getHeaderValue() { + return state.controller + .getSelectionStyle() + .attributes[Attribute.header.key] ?? + Attribute.header; + } + + @override + void invoke(ApplyHeaderIntent intent, [BuildContext? context]) { + final _attribute = + _getHeaderValue() == intent.header ? Attribute.header : intent.header; + state.controller.formatSelection(_attribute); + } + + @override + bool get isActionEnabled => true; +} + +class ApplyCheckListIntent extends Intent { + const ApplyCheckListIntent(); +} + +// Toggles a text style (underline, bold, italic, strikethrough) on, or off. +class _ApplyCheckListAction extends Action { + _ApplyCheckListAction(this.state); + + final RawEditorState state; + + bool _getIsToggled() { + final attrs = state.controller.getSelectionStyle().attributes; + var attribute = state.controller.toolbarButtonToggler[Attribute.list.key]; + + if (attribute == null) { + attribute = attrs[Attribute.list.key]; + } else { + // checkbox tapping causes controller.selection to go to offset 0 + state.controller.toolbarButtonToggler.remove(Attribute.list.key); + } + + if (attribute == null) { + return false; + } + return attribute.value == Attribute.unchecked.value || + attribute.value == Attribute.checked.value; + } + + @override + void invoke(ApplyCheckListIntent intent, [BuildContext? context]) { + state.controller.formatSelection(_getIsToggled() + ? Attribute.clone(Attribute.unchecked, null) + : Attribute.unchecked); + } + + @override + bool get isActionEnabled => true; +} + +class ApplyLinkIntent extends Intent { + const ApplyLinkIntent(); +} + +class ApplyLinkAction extends Action { + ApplyLinkAction(this.state); + + final RawEditorState state; + + @override + Object? invoke(ApplyLinkIntent intent) async { + final initialTextLink = QuillTextLink.prepare(state.controller); + + final textLink = await showDialog( + context: state.context, + builder: (context) { + return LinkStyleDialog( + text: initialTextLink.text, + link: initialTextLink.link, + dialogTheme: state.widget.dialogTheme, + ); + }, + ); + + if (textLink != null) { + textLink.submit(state.controller); + } + return null; + } +} + +class InsertEmbedIntent extends Intent { + const InsertEmbedIntent(this.type); + + final Attribute type; +} + +/// Signature for a widget builder that builds a context menu for the given +/// [RawEditorState]. +/// +/// See also: +/// +/// * [EditableTextContextMenuBuilder], which performs the same role for +/// [EditableText] +typedef QuillEditorContextMenuBuilder = Widget Function( + BuildContext context, + RawEditorState rawEditorState, +); + +class _GlyphHeights { + _GlyphHeights( + this.startGlyphHeight, + this.endGlyphHeight, + ); + + final double startGlyphHeight; + final double endGlyphHeight; +} 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 8b494dd1..fdd9244b 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 @@ -38,13 +38,13 @@ mixin RawEditorStateSelectionDelegateMixin on EditorState if (insertedText == pastePlainText && pastePlainText != '') { final pos = start; for (var i = 0; i < pasteStyle.length; i++) { - final offset = pasteStyle[i].item1; - final style = pasteStyle[i].item2; + 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].item1, + : pasteStyle[i + 1].offset, style); } } @@ -150,14 +150,15 @@ mixin RawEditorStateSelectionDelegateMixin on EditorState } @override - bool get cutEnabled => widget.toolbarOptions.cut && !widget.readOnly; + bool get cutEnabled => widget.contextMenuBuilder != null && !widget.readOnly; @override - bool get copyEnabled => widget.toolbarOptions.copy; + bool get copyEnabled => widget.contextMenuBuilder != null; @override - bool get pasteEnabled => widget.toolbarOptions.paste && !widget.readOnly; + bool get pasteEnabled => + widget.contextMenuBuilder != null && !widget.readOnly; @override - bool get selectAllEnabled => widget.toolbarOptions.selectAll; + bool get selectAllEnabled => widget.contextMenuBuilder != null; } 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 24cdc955..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,16 +59,52 @@ mixin RawEditorStateTextInputClientMixin on EditorState enableSuggestions: !widget.readOnly, keyboardAppearance: widget.keyboardAppearance, textCapitalization: widget.textCapitalization, + allowedMimeTypes: widget.contentInsertionConfiguration == null + ? const [] + : widget.contentInsertionConfiguration!.allowedMimeTypes, ), ); _updateSizeAndTransform(); + //update IME position for Windows + _updateComposingRectIfNeeded(); + //update IME position for Macos + _updateCaretRectIfNeeded(); _textInputConnection!.setEditingState(_lastKnownRemoteTextEditingValue!); } - _textInputConnection!.show(); } + void _updateComposingRectIfNeeded() { + final composingRange = _lastKnownRemoteTextEditingValue?.composing ?? + textEditingValue.composing; + if (hasConnection) { + assert(mounted); + final offset = composingRange.isValid ? composingRange.start : 0; + final composingRect = + renderEditor.getLocalRectForCaret(TextPosition(offset: offset)); + _textInputConnection!.setComposingRect(composingRect); + SchedulerBinding.instance + .addPostFrameCallback((_) => _updateComposingRectIfNeeded()); + } + } + + void _updateCaretRectIfNeeded() { + if (hasConnection) { + if (!dirty && + renderEditor.selection.isValid && + renderEditor.selection.isCollapsed) { + final currentTextPosition = + TextPosition(offset: renderEditor.selection.baseOffset); + final caretRect = + renderEditor.getLocalRectForCaret(currentTextPosition); + _textInputConnection!.setCaretRect(caretRect); + } + SchedulerBinding.instance + .addPostFrameCallback((_) => _updateCaretRectIfNeeded()); + } + } + /// Closes input connection if it's currently open. Otherwise does nothing. void closeConnectionIfNeeded() { if (!hasConnection) { @@ -283,7 +319,8 @@ mixin RawEditorStateTextInputClientMixin on EditorState @override void showAutocorrectionPromptRect(int start, int end) { - throw UnimplementedError(); + // this is called VERY OFTEN when editing a document, no longer throw + // an exception } @override @@ -300,14 +337,11 @@ mixin RawEditorStateTextInputClientMixin on EditorState if (hasConnection) { // Asking for renderEditor.size here can cause errors if layout hasn't // occurred yet. So we schedule a post frame callback instead. - SchedulerBinding.instance.addPostFrameCallback((_) { - if (!mounted) { - return; - } - final size = renderEditor.size; - final transform = renderEditor.getTransformTo(null); - _textInputConnection?.setEditableSizeAndTransform(size, transform); - }); + final size = renderEditor.size; + final transform = renderEditor.getTransformTo(null); + _textInputConnection?.setEditableSizeAndTransform(size, transform); + SchedulerBinding.instance + .addPostFrameCallback((_) => _updateSizeAndTransform()); } } } 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/style_widgets/number_point.dart b/lib/src/widgets/style_widgets/number_point.dart index f1ffddf1..54d5ebc9 100644 --- a/lib/src/widgets/style_widgets/number_point.dart +++ b/lib/src/widgets/style_widgets/number_point.dart @@ -29,9 +29,9 @@ class QuillNumberPoint extends StatelessWidget { Widget build(BuildContext context) { var s = index.toString(); int? level = 0; - if (!attrs.containsKey(Attribute.indent.key) && - !indentLevelCounts.containsKey(1)) { + if (!attrs.containsKey(Attribute.indent.key) && indentLevelCounts.isEmpty) { indentLevelCounts.clear(); + indentLevelCounts[0] = 1; return Container( alignment: AlignmentDirectional.topEnd, width: width, @@ -41,7 +41,7 @@ class QuillNumberPoint extends StatelessWidget { } if (attrs.containsKey(Attribute.indent.key)) { level = attrs[Attribute.indent.key]!.value; - } else { + } else if (!indentLevelCounts.containsKey(0)) { // first level but is back from previous indent level // supposed to be "2." indentLevelCounts[0] = 1; diff --git a/lib/src/widgets/text_block.dart b/lib/src/widgets/text_block.dart index e4b2990d..3906fb88 100644 --- a/lib/src/widgets/text_block.dart +++ b/lib/src/widgets/text_block.dart @@ -1,15 +1,21 @@ import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; -import 'package:tuple/tuple.dart'; -import '../../flutter_quill.dart'; +import '../models/documents/attribute.dart'; import '../models/documents/nodes/block.dart'; import '../models/documents/nodes/line.dart'; +import '../models/structs/vertical_spacing.dart'; import '../utils/delta.dart'; import 'box.dart'; +import 'controller.dart'; import 'cursor.dart'; +import 'default_styles.dart'; import 'delegate.dart'; +import 'editor.dart'; import 'link.dart'; +import 'style_widgets/bullet_point.dart'; +import 'style_widgets/checkbox_point.dart'; +import 'style_widgets/number_point.dart'; import 'text_line.dart'; import 'text_selection.dart'; @@ -62,17 +68,19 @@ class EditableTextBlock extends StatelessWidget { required this.linkActionPicker, required this.cursorCont, required this.indentLevelCounts, + required this.clearIndents, required this.onCheckboxTap, required this.readOnly, this.onLaunchUrl, this.customStyleBuilder, + this.customLinkPrefixes = const [], Key? key}); final Block block; final QuillController controller; final TextDirection textDirection; final double scrollBottomInset; - final Tuple2 verticalSpacing; + final VerticalSpacing verticalSpacing; final TextSelection textSelection; final Color color; final DefaultStyles? styles; @@ -85,8 +93,10 @@ class EditableTextBlock extends StatelessWidget { final CustomStyleBuilder? customStyleBuilder; final CursorCont cursorCont; final Map indentLevelCounts; + final bool clearIndents; final Function(int, bool) onCheckboxTap; final bool readOnly; + final List customLinkPrefixes; @override Widget build(BuildContext context) { @@ -96,12 +106,12 @@ class EditableTextBlock extends StatelessWidget { return _EditableBlock( block: block, textDirection: textDirection, - padding: verticalSpacing as Tuple2, + padding: verticalSpacing, scrollBottomInset: scrollBottomInset, decoration: _getDecorationForBlock(block, defaultStyles) ?? const BoxDecoration(), contentPadding: contentPadding, - children: _buildChildren(context, indentLevelCounts)); + children: _buildChildren(context, indentLevelCounts, clearIndents)); } BoxDecoration? _getDecorationForBlock( @@ -116,11 +126,14 @@ class EditableTextBlock extends StatelessWidget { return null; } - List _buildChildren( - BuildContext context, Map indentLevelCounts) { + List _buildChildren(BuildContext context, + Map indentLevelCounts, bool clearIndents) { final defaultStyles = QuillStyles.getStyles(context, false); final count = block.children.length; final children = []; + if (clearIndents) { + indentLevelCounts.clear(); + } var index = 0; for (final line in Iterable.castFrom(block.children)) { index++; @@ -137,15 +150,16 @@ class EditableTextBlock extends StatelessWidget { controller: controller, linkActionPicker: linkActionPicker, 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( @@ -156,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, ); } @@ -203,41 +220,43 @@ 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; } - Tuple2 _getSpacingForLine( + VerticalSpacing _getSpacingForLine( Line node, int index, int count, DefaultStyles? defaultStyles) { var top = 0.0, bottom = 0.0; @@ -246,22 +265,22 @@ class EditableTextBlock extends StatelessWidget { final level = attrs[Attribute.header.key]!.value; switch (level) { case 1: - top = defaultStyles!.h1!.verticalSpacing.item1; - bottom = defaultStyles.h1!.verticalSpacing.item2; + top = defaultStyles!.h1!.verticalSpacing.top; + bottom = defaultStyles.h1!.verticalSpacing.bottom; break; case 2: - top = defaultStyles!.h2!.verticalSpacing.item1; - bottom = defaultStyles.h2!.verticalSpacing.item2; + top = defaultStyles!.h2!.verticalSpacing.top; + bottom = defaultStyles.h2!.verticalSpacing.bottom; break; case 3: - top = defaultStyles!.h3!.verticalSpacing.item1; - bottom = defaultStyles.h3!.verticalSpacing.item2; + top = defaultStyles!.h3!.verticalSpacing.top; + bottom = defaultStyles.h3!.verticalSpacing.bottom; break; default: throw 'Invalid level $level'; } } else { - late Tuple2 lineSpacing; + late VerticalSpacing lineSpacing; if (attrs.containsKey(Attribute.blockQuote.key)) { lineSpacing = defaultStyles!.quote!.lineSpacing; } else if (attrs.containsKey(Attribute.indent.key)) { @@ -276,8 +295,8 @@ class EditableTextBlock extends StatelessWidget { // use paragraph linespacing as a default lineSpacing = defaultStyles!.paragraph!.lineSpacing; } - top = lineSpacing.item1; - bottom = lineSpacing.item2; + top = lineSpacing.top; + bottom = lineSpacing.bottom; } if (index == 1) { @@ -288,7 +307,7 @@ class EditableTextBlock extends StatelessWidget { bottom = 0.0; } - return Tuple2(top, bottom); + return VerticalSpacing(top, bottom); } } @@ -582,7 +601,7 @@ class RenderEditableTextBlock extends RenderEditableContainerBox } class _EditableBlock extends MultiChildRenderObjectWidget { - _EditableBlock( + const _EditableBlock( {required this.block, required this.textDirection, required this.padding, @@ -595,13 +614,13 @@ class _EditableBlock extends MultiChildRenderObjectWidget { final Block block; final TextDirection textDirection; - final Tuple2 padding; + final VerticalSpacing padding; final double scrollBottomInset; final Decoration decoration; final EdgeInsets? contentPadding; EdgeInsets get _padding => - EdgeInsets.only(top: padding.item1, bottom: padding.item2); + EdgeInsets.only(top: padding.top, bottom: padding.bottom); EdgeInsets get _contentPadding => contentPadding ?? EdgeInsets.zero; diff --git a/lib/src/widgets/text_line.dart b/lib/src/widgets/text_line.dart index b20ea22f..9358777a 100644 --- a/lib/src/widgets/text_line.dart +++ b/lib/src/widgets/text_line.dart @@ -6,16 +6,17 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; -import 'package:tuple/tuple.dart'; import 'package:url_launcher/url_launcher.dart'; import '../models/documents/attribute.dart'; import '../models/documents/nodes/container.dart' as container_node; +import '../models/documents/nodes/embeddable.dart'; import '../models/documents/nodes/leaf.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/vertical_spacing.dart'; import '../utils/color.dart'; import '../utils/font.dart'; import '../utils/platform.dart'; @@ -40,6 +41,8 @@ class TextLine extends StatefulWidget { required this.linkActionPicker, this.textDirection, this.customStyleBuilder, + this.customRecognizerBuilder, + this.customLinkPrefixes = const [], Key? key, }) : super(key: key); @@ -50,8 +53,10 @@ class TextLine extends StatefulWidget { final bool readOnly; final QuillController controller; final CustomStyleBuilder? customStyleBuilder; + final CustomRecognizerBuilder? customRecognizerBuilder; final ValueChanged? onLaunchUrl; final LinkActionPicker linkActionPicker; + final List customLinkPrefixes; @override State createState() => _TextLineState(); @@ -132,17 +137,23 @@ class _TextLineState extends State { @override Widget build(BuildContext context) { assert(debugCheckHasMediaQuery(context)); + if (widget.line.hasEmbed && widget.line.childCount == 1) { - // For video, it is always single child - final embed = widget.line.children.single as Embed; - return EmbedProxy( - widget.embedBuilder( - context, - widget.controller, - embed, - widget.readOnly, - ), - ); + // Single child embeds can be expanded + var embed = widget.line.children.single as Embed; + // Creates correct node for custom embed + if (embed.value.type == BlockEmbed.customType) { + embed = Embed(CustomBlockEmbed.fromJsonString(embed.value.data)); + } + 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, lineStyle), + ); + } } final textSpan = _getTextSpanForWholeLine(context); final strutStyle = StrutStyle.fromTextStyle(textSpan.style!); @@ -173,24 +184,30 @@ class _TextLineState extends State { // The line could contain more than one Embed & more than one Text final textSpanChildren = []; var textNodes = LinkedList(); - for (final child in widget.line.children) { + for (var child in widget.line.children) { if (child is Embed) { if (textNodes.isNotEmpty) { textSpanChildren .add(_buildTextSpan(widget.styles, textNodes, lineStyle)); textNodes = LinkedList(); } - // Here it should be image - final embed = WidgetSpan( - child: EmbedProxy( - widget.embedBuilder( - context, - widget.controller, - child, - widget.readOnly, - ), + // Creates correct node for custom embed + if (child.value.type == BlockEmbed.customType) { + child = Embed(CustomBlockEmbed.fromJsonString(child.value.data)) + ..applyStyle(child.style); + } + final embedBuilder = widget.embedBuilder(child); + final embedWidget = EmbedProxy( + embedBuilder.build( + context, + widget.controller, + child, + widget.readOnly, + true, + lineStyle, ), ); + final embed = embedBuilder.buildWidgetSpan(embedWidget); textSpanChildren.add(embed); continue; } @@ -262,7 +279,7 @@ class _TextLineState extends State { toMerge = defaultStyles.quote!.style; } else if (block == Attribute.codeBlock) { toMerge = defaultStyles.code!.style; - } else if (block == Attribute.list) { + } else if (block?.key == Attribute.list.key) { toMerge = defaultStyles.lists!.style; } @@ -295,12 +312,14 @@ class _TextLineState extends State { final isLink = nodeStyle.containsKey(Attribute.link.key) && nodeStyle.attributes[Attribute.link.key]!.value != null; + final recognizer = _getRecognizer(node, isLink); + return TextSpan( text: textNode.value, style: _getInlineTextStyle( textNode, defaultStyles, nodeStyle, lineStyle, isLink), - recognizer: isLink && canLaunchLinks ? _getRecognizer(node) : null, - mouseCursor: isLink && canLaunchLinks ? SystemMouseCursors.click : null, + recognizer: recognizer, + mouseCursor: (recognizer != null) ? SystemMouseCursors.click : null, ); } @@ -334,6 +353,14 @@ class _TextLineState extends State { } }); + if (nodeStyle.containsKey(Attribute.script.key)) { + if (nodeStyle.attributes.values.contains(Attribute.subscript)) { + res = _merge(res, defaultStyles.subscript!); + } else if (nodeStyle.attributes.values.contains(Attribute.superscript)) { + res = _merge(res, defaultStyles.superscript!); + } + } + if (nodeStyle.containsKey(Attribute.inlineCode.key)) { res = _merge(res, defaultStyles.inlineCode!.styleFor(lineStyle)); } @@ -380,19 +407,38 @@ class _TextLineState extends State { return res; } - GestureRecognizer _getRecognizer(Node segment) { + GestureRecognizer? _getRecognizer(Node segment, bool isLink) { if (_linkRecognizers.containsKey(segment)) { return _linkRecognizers[segment]!; } - if (isDesktop() || widget.readOnly) { - _linkRecognizers[segment] = TapGestureRecognizer() - ..onTap = () => _tapNodeLink(segment); - } else { - _linkRecognizers[segment] = LongPressGestureRecognizer() - ..onLongPress = () => _longPressLink(segment); + if (widget.customRecognizerBuilder != null) { + final textNode = segment as leaf.Text; + final nodeStyle = textNode.style; + + nodeStyle.attributes.forEach((key, value) { + final recognizer = widget.customRecognizerBuilder!.call(value, segment); + if (recognizer != null) { + _linkRecognizers[segment] = recognizer; + return; + } + }); } - return _linkRecognizers[segment]!; + + if (_linkRecognizers.containsKey(segment)) { + return _linkRecognizers[segment]!; + } + + if (isLink && canLaunchLinks) { + if (isDesktop() || widget.readOnly) { + _linkRecognizers[segment] = TapGestureRecognizer() + ..onTap = () => _tapNodeLink(segment); + } else { + _linkRecognizers[segment] = LongPressGestureRecognizer() + ..onLongPress = () => _longPressLink(segment); + } + } + return _linkRecognizers[segment]; } Future _launchUrl(String url) async { @@ -414,7 +460,7 @@ class _TextLineState extends State { launchUrl ??= _launchUrl; link = link.trim(); - if (!linkPrefixes + if (!(widget.customLinkPrefixes + linkPrefixes) .any((linkPrefix) => link!.toLowerCase().startsWith(linkPrefix))) { link = 'https://$link'; } @@ -476,7 +522,7 @@ class EditableTextLine extends RenderObjectWidget { final Widget? leading; final Widget body; final double indentWidth; - final Tuple2 verticalSpacing; + final VerticalSpacing verticalSpacing; final TextDirection textDirection; final TextSelection textSelection; final Color color; @@ -526,8 +572,8 @@ class EditableTextLine extends RenderObjectWidget { EdgeInsetsGeometry _getPadding() { return EdgeInsetsDirectional.only( start: indentWidth, - top: verticalSpacing.item1, - bottom: verticalSpacing.item2); + top: verticalSpacing.top, + bottom: verticalSpacing.bottom); } } @@ -1033,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) { @@ -1091,6 +1144,18 @@ class RenderEditableTextLine extends RenderEditableBox { _selectedRects ??= _body!.getBoxesForSelection( local, ); + + // Paint a small rect at the start of empty lines that + // are contained by the selection. + if (line.isEmpty && + textSelection.baseOffset <= line.offset && + textSelection.extentOffset > line.offset) { + final lineHeight = + preferredLineHeight(TextPosition(offset: line.offset)); + _selectedRects + ?.add(TextBox.fromLTRBD(0, 0, 3, lineHeight, textDirection)); + } + _paintSelection(context, effectiveOffset); } } diff --git a/lib/src/widgets/text_selection.dart b/lib/src/widgets/text_selection.dart index 770c3b3d..688505e2 100644 --- a/lib/src/widgets/text_selection.dart +++ b/lib/src/widgets/text_selection.dart @@ -4,7 +4,6 @@ import 'dart:math' as math; import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/rendering.dart'; import 'package:flutter/scheduler.dart'; import '../models/documents/nodes/node.dart'; @@ -70,7 +69,6 @@ class EditorTextSelectionOverlay { EditorTextSelectionOverlay({ required this.value, required this.context, - required this.toolbarLayerLink, required this.startHandleLayerLink, required this.endHandleLayerLink, required this.renderObject, @@ -78,14 +76,18 @@ class EditorTextSelectionOverlay { required this.selectionCtrls, required this.selectionDelegate, required this.clipboardStatus, + required this.contextMenuBuilder, this.onSelectionHandleTapped, this.dragStartBehavior = DragStartBehavior.start, this.handlesVisible = false, }) { - final overlay = Overlay.of(context, rootOverlay: true)!; - - _toolbarController = AnimationController( - duration: const Duration(milliseconds: 150), vsync: overlay); + // Clipboard status is only checked on first instance of + // ClipboardStatusNotifier + // if state has changed after creation, but prior to + // our listener being created + // we won't know the status unless there is forced update + // i.e. occasionally no paste + clipboardStatus.update(); } TextEditingValue value; @@ -115,10 +117,6 @@ class EditorTextSelectionOverlay { /// Debugging information for explaining why the [Overlay] is required. final Widget debugRequiredFor; - /// The object supplied to the [CompositedTransformTarget] that wraps the text - /// field. - final LayerLink toolbarLayerLink; - /// The objects supplied to the [CompositedTransformTarget] that wraps the /// location of start selection handle. final LayerLink startHandleLayerLink; @@ -137,6 +135,11 @@ class EditorTextSelectionOverlay { /// text field. final TextSelectionDelegate selectionDelegate; + /// {@macro flutter.widgets.EditableText.contextMenuBuilder} + /// + /// If not provided, no context menu will be built. + final WidgetBuilder? contextMenuBuilder; + /// Determines the way that drag start behavior is handled. /// /// If set to [DragStartBehavior.start], handle drag behavior will @@ -170,7 +173,6 @@ class EditorTextSelectionOverlay { /// Useful because the actual value of the clipboard can only be checked /// asynchronously (see [Clipboard.getData]). final ClipboardStatusNotifier clipboardStatus; - late AnimationController _toolbarController; /// A pair of handles. If this is non-null, there are always 2, though the /// second is hidden when the selection is collapsed. @@ -181,8 +183,6 @@ class EditorTextSelectionOverlay { TextSelection get _selection => value.selection; - Animation get _toolbarOpacity => _toolbarController.view; - void setHandlesVisible(bool visible) { if (handlesVisible == visible) { return; @@ -213,7 +213,6 @@ class EditorTextSelectionOverlay { /// To hide the whole overlay, see [hide]. void hideToolbar() { assert(toolbar != null); - _toolbarController.stop(); toolbar!.remove(); toolbar = null; } @@ -221,10 +220,12 @@ class EditorTextSelectionOverlay { /// Shows the toolbar by inserting it into the [context]'s overlay. void showToolbar() { assert(toolbar == null); - toolbar = OverlayEntry(builder: _buildToolbar); - Overlay.of(context, rootOverlay: true, debugRequiredFor: debugRequiredFor)! + if (contextMenuBuilder == null) return; + toolbar = OverlayEntry(builder: (context) { + return contextMenuBuilder!(context); + }); + Overlay.of(context, rootOverlay: true, debugRequiredFor: debugRequiredFor) .insert(toolbar!); - _toolbarController.forward(from: 0); // make sure handles are visible as well if (_handles == null) { @@ -312,63 +313,6 @@ class EditorTextSelectionOverlay { ..bringIntoView(textPosition); } - Widget _buildToolbar(BuildContext context) { - // Find the horizontal midpoint, just above the selected text. - List endpoints; - - try { - // building with an invalid selection with throw an exception - // This happens where the selection has changed, but the toolbar - // hasn't been dismissed yet. - endpoints = renderObject.getEndpointsForSelection(_selection); - } catch (_) { - return Container(); - } - - final editingRegion = Rect.fromPoints( - renderObject.localToGlobal(Offset.zero), - renderObject.localToGlobal(renderObject.size.bottomRight(Offset.zero)), - ); - - final baseLineHeight = renderObject.preferredLineHeight(_selection.base); - final extentLineHeight = - renderObject.preferredLineHeight(_selection.extent); - final smallestLineHeight = math.min(baseLineHeight, extentLineHeight); - final isMultiline = endpoints.last.point.dy - endpoints.first.point.dy > - smallestLineHeight / 2; - - // If the selected text spans more than 1 line, - // horizontally center the toolbar. - // Derived from both iOS and Android. - final midX = isMultiline - ? editingRegion.width / 2 - : (endpoints.first.point.dx + endpoints.last.point.dx) / 2; - - final midpoint = Offset( - midX, - // The y-coordinate won't be made use of most likely. - endpoints[0].point.dy - baseLineHeight, - ); - - return FadeTransition( - opacity: _toolbarOpacity, - child: CompositedTransformFollower( - link: toolbarLayerLink, - showWhenUnlinked: false, - offset: -editingRegion.topLeft, - child: selectionCtrls.buildToolbar( - context, - editingRegion, - baseLineHeight, - midpoint, - endpoints, - selectionDelegate, - clipboardStatus, - null), - ), - ); - } - void markNeedsBuild([Duration? duration]) { if (_handles != null) { _handles![0].markNeedsBuild(); @@ -392,7 +336,6 @@ class EditorTextSelectionOverlay { /// Final cleanup. void dispose() { hide(); - _toolbarController.dispose(); } /// Builds the handles by inserting them into the [context]'s overlay. @@ -407,7 +350,7 @@ class EditorTextSelectionOverlay { _buildHandle(context, _TextSelectionHandlePosition.END)), ]; - Overlay.of(context, rootOverlay: true, debugRequiredFor: debugRequiredFor)! + Overlay.of(context, rootOverlay: true, debugRequiredFor: debugRequiredFor) .insertAll(_handles!); } @@ -707,6 +650,7 @@ class EditorTextSelectionGestureDetector extends StatefulWidget { this.onDragSelectionUpdate, this.onDragSelectionEnd, this.behavior, + this.detectWordBoundary = true, Key? key, }) : super(key: key); @@ -769,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; @@ -782,6 +726,8 @@ class EditorTextSelectionGestureDetector extends StatefulWidget { /// Child below this widget. final Widget child; + final bool detectWordBoundary; + @override State createState() => _EditorTextSelectionGestureDetectorState(); @@ -911,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 0889596d..fea5b420 100644 --- a/lib/src/widgets/toolbar.dart +++ b/lib/src/widgets/toolbar.dart @@ -6,18 +6,18 @@ import '../models/themes/quill_custom_button.dart'; import '../models/themes/quill_dialog_theme.dart'; import '../models/themes/quill_icon_theme.dart'; import '../translations/toolbar.i18n.dart'; -import '../utils/font.dart'; import 'controller.dart'; 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,41 +26,55 @@ 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'; +export 'toolbar/link_style_button2.dart'; +export 'toolbar/quill_font_family_button.dart'; export 'toolbar/quill_font_size_button.dart'; export 'toolbar/quill_icon_button.dart'; +export 'toolbar/search_button.dart'; export 'toolbar/select_alignment_button.dart'; export 'toolbar/select_header_style_button.dart'; export 'toolbar/toggle_check_list_button.dart'; export 'toolbar/toggle_style_button.dart'; -// The default size of the icon of a button. +/// The default size of the icon of a button. const double kDefaultIconSize = 18; -// The factor of how much larger the button is in relation to the icon. +/// The factor of how much larger the button is in relation to the icon. const double kIconButtonFactor = 1.77; +/// The horizontal margin between the contents of each toolbar section. +const double kToolbarSectionSpacing = 4; + class QuillToolbar extends StatelessWidget implements PreferredSizeWidget { const QuillToolbar({ required this.children, - this.toolbarHeight = 36, + this.axis = Axis.horizontal, + this.toolbarSize = kDefaultIconSize * 2, + this.toolbarSectionSpacing = kToolbarSectionSpacing, this.toolbarIconAlignment = WrapAlignment.center, - this.toolbarSectionSpacing = 4, + this.toolbarIconCrossAlignment = WrapCrossAlignment.center, this.multiRowsDisplay = true, this.color, this.customButtons = const [], this.locale, VoidCallback? afterButtonPressed, + this.sectionDividerColor, + this.sectionDividerSpace, Key? key, }) : super(key: key); factory QuillToolbar.basic({ required QuillController controller, + Axis axis = Axis.horizontal, double toolbarIconSize = kDefaultIconSize, - double toolbarSectionSpacing = 4, + double toolbarSectionSpacing = kToolbarSectionSpacing, WrapAlignment toolbarIconAlignment = WrapAlignment.center, + WrapCrossAlignment toolbarIconCrossAlignment = WrapCrossAlignment.center, + bool multiRowsDisplay = true, bool showDividers = true, bool showFontFamily = true, bool showFontSize = true, @@ -88,9 +102,10 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget { bool showLink = true, bool showUndo = true, bool showRedo = true, - bool multiRowsDisplay = true, bool showDirection = false, bool showSearchButton = true, + bool showSubscript = true, + bool showSuperscript = true, List customButtons = const [], ///Map of font sizes in string @@ -113,9 +128,32 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget { /// Is called after whatever logic the button performs has run. VoidCallback? afterButtonPressed, + ///Map of tooltips for toolbar buttons + /// + ///The example is: + ///```dart + /// tooltips = { + /// ToolbarButtons.undo: 'Undo', + /// ToolbarButtons.redo: 'Redo', + /// } + /// + ///``` + /// + /// To disable tooltips just pass empty map as well. + Map? tooltips, + /// The locale to use for the editor toolbar, defaults to system locale /// More at https://github.com/singerdmx/flutter-quill#translation Locale? locale, + + /// The color of the toolbar + Color? color, + + /// The color of the toolbar section divider + Color? sectionDividerColor, + + /// The space occupied by toolbar divider + double? sectionDividerSpace, Key? key, }) { final isButtonGroupShown = [ @@ -162,14 +200,52 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget { 'Nunito': 'nunito', 'Pacifico': 'pacifico', 'Roboto Mono': 'roboto-mono', - 'Clear': 'Clear' + 'Clear'.i18n: 'Clear' + }; + + //default button tooltips + final buttonTooltips = tooltips ?? + { + ToolbarButtons.undo: 'Undo'.i18n, + ToolbarButtons.redo: 'Redo'.i18n, + ToolbarButtons.fontFamily: 'Font family'.i18n, + ToolbarButtons.fontSize: 'Font size'.i18n, + ToolbarButtons.bold: 'Bold'.i18n, + ToolbarButtons.subscript: 'Subscript'.i18n, + ToolbarButtons.superscript: 'Superscript'.i18n, + ToolbarButtons.italic: 'Italic'.i18n, + ToolbarButtons.small: 'Small'.i18n, + ToolbarButtons.underline: 'Underline'.i18n, + ToolbarButtons.strikeThrough: 'Strike through'.i18n, + ToolbarButtons.inlineCode: 'Inline code'.i18n, + ToolbarButtons.color: 'Font color'.i18n, + ToolbarButtons.backgroundColor: 'Background color'.i18n, + ToolbarButtons.clearFormat: 'Clear format'.i18n, + ToolbarButtons.leftAlignment: 'Align left'.i18n, + ToolbarButtons.centerAlignment: 'Align center'.i18n, + ToolbarButtons.rightAlignment: 'Align right'.i18n, + ToolbarButtons.justifyAlignment: 'Justify win width'.i18n, + ToolbarButtons.direction: 'Text direction'.i18n, + ToolbarButtons.headerStyle: 'Header style'.i18n, + ToolbarButtons.listNumbers: 'Numbered list'.i18n, + ToolbarButtons.listBullets: 'Bullet list'.i18n, + ToolbarButtons.listChecks: 'Checked list'.i18n, + ToolbarButtons.codeBlock: 'Code block'.i18n, + ToolbarButtons.quote: 'Quote'.i18n, + ToolbarButtons.indentIncrease: 'Increase indent'.i18n, + ToolbarButtons.indentDecrease: 'Decrease indent'.i18n, + ToolbarButtons.link: 'Insert URL'.i18n, + ToolbarButtons.search: 'Search'.i18n, }; return QuillToolbar( key: key, - toolbarHeight: toolbarIconSize * 2, + axis: axis, + color: color, + toolbarSize: toolbarIconSize * 2, toolbarSectionSpacing: toolbarSectionSpacing, toolbarIconAlignment: toolbarIconAlignment, + toolbarIconCrossAlignment: toolbarIconCrossAlignment, multiRowsDisplay: multiRowsDisplay, customButtons: customButtons, locale: locale, @@ -179,6 +255,7 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget { HistoryButton( icon: Icons.undo_outlined, iconSize: toolbarIconSize, + tooltip: buttonTooltips[ToolbarButtons.undo], controller: controller, undo: true, iconTheme: iconTheme, @@ -188,6 +265,7 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget { HistoryButton( icon: Icons.redo_outlined, iconSize: toolbarIconSize, + tooltip: buttonTooltips[ToolbarButtons.redo], controller: controller, undo: false, iconTheme: iconTheme, @@ -197,23 +275,9 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget { QuillFontFamilyButton( iconTheme: iconTheme, iconSize: toolbarIconSize, + tooltip: buttonTooltips[ToolbarButtons.fontFamily], attribute: Attribute.font, controller: controller, - items: [ - for (MapEntry fontFamily in fontFamilies.entries) - PopupMenuItem( - key: ValueKey(fontFamily.key), - value: fontFamily.value, - child: Text(fontFamily.key.toString(), - style: TextStyle( - color: - fontFamily.value == 'Clear' ? Colors.red : null)), - ), - ], - onSelected: (newFont) { - controller.formatSelection(Attribute.fromKeyValue( - 'font', newFont == 'Clear' ? null : newFont)); - }, rawItemsMap: fontFamilies, afterButtonPressed: afterButtonPressed, ), @@ -221,22 +285,9 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget { QuillFontSizeButton( iconTheme: iconTheme, iconSize: toolbarIconSize, + tooltip: buttonTooltips[ToolbarButtons.fontSize], attribute: Attribute.size, controller: controller, - items: [ - for (MapEntry fontSize in fontSizes.entries) - PopupMenuItem( - key: ValueKey(fontSize.key), - value: fontSize.value, - child: Text(fontSize.key.toString(), - style: TextStyle( - color: fontSize.value == '0' ? Colors.red : null)), - ), - ], - onSelected: (newSize) { - controller.formatSelection(Attribute.fromKeyValue( - 'size', newSize == '0' ? null : getFontSize(newSize))); - }, rawItemsMap: fontSizes, afterButtonPressed: afterButtonPressed, ), @@ -245,6 +296,27 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget { attribute: Attribute.bold, icon: Icons.format_bold, iconSize: toolbarIconSize, + tooltip: buttonTooltips[ToolbarButtons.bold], + controller: controller, + iconTheme: iconTheme, + afterButtonPressed: afterButtonPressed, + ), + if (showSubscript) + ToggleStyleButton( + attribute: Attribute.subscript, + icon: Icons.subscript, + iconSize: toolbarIconSize, + tooltip: buttonTooltips[ToolbarButtons.subscript], + controller: controller, + iconTheme: iconTheme, + afterButtonPressed: afterButtonPressed, + ), + if (showSuperscript) + ToggleStyleButton( + attribute: Attribute.superscript, + icon: Icons.superscript, + iconSize: toolbarIconSize, + tooltip: buttonTooltips[ToolbarButtons.superscript], controller: controller, iconTheme: iconTheme, afterButtonPressed: afterButtonPressed, @@ -254,6 +326,7 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget { attribute: Attribute.italic, icon: Icons.format_italic, iconSize: toolbarIconSize, + tooltip: buttonTooltips[ToolbarButtons.italic], controller: controller, iconTheme: iconTheme, afterButtonPressed: afterButtonPressed, @@ -263,6 +336,7 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget { attribute: Attribute.small, icon: Icons.format_size, iconSize: toolbarIconSize, + tooltip: buttonTooltips[ToolbarButtons.small], controller: controller, iconTheme: iconTheme, afterButtonPressed: afterButtonPressed, @@ -272,6 +346,7 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget { attribute: Attribute.underline, icon: Icons.format_underline, iconSize: toolbarIconSize, + tooltip: buttonTooltips[ToolbarButtons.underline], controller: controller, iconTheme: iconTheme, afterButtonPressed: afterButtonPressed, @@ -281,6 +356,7 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget { attribute: Attribute.strikeThrough, icon: Icons.format_strikethrough, iconSize: toolbarIconSize, + tooltip: buttonTooltips[ToolbarButtons.strikeThrough], controller: controller, iconTheme: iconTheme, afterButtonPressed: afterButtonPressed, @@ -290,6 +366,7 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget { attribute: Attribute.inlineCode, icon: Icons.code, iconSize: toolbarIconSize, + tooltip: buttonTooltips[ToolbarButtons.inlineCode], controller: controller, iconTheme: iconTheme, afterButtonPressed: afterButtonPressed, @@ -298,6 +375,7 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget { ColorButton( icon: Icons.color_lens, iconSize: toolbarIconSize, + tooltip: buttonTooltips[ToolbarButtons.color], controller: controller, background: false, iconTheme: iconTheme, @@ -307,6 +385,7 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget { ColorButton( icon: Icons.format_color_fill, iconSize: toolbarIconSize, + tooltip: buttonTooltips[ToolbarButtons.backgroundColor], controller: controller, background: true, iconTheme: iconTheme, @@ -316,6 +395,7 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget { ClearFormatButton( icon: Icons.format_clear, iconSize: toolbarIconSize, + tooltip: buttonTooltips[ToolbarButtons.clearFormat], controller: controller, iconTheme: iconTheme, afterButtonPressed: afterButtonPressed, @@ -330,14 +410,18 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget { isButtonGroupShown[3] || isButtonGroupShown[4] || isButtonGroupShown[5])) - VerticalDivider( - indent: 12, - endIndent: 12, - color: Colors.grey.shade400, - ), + QuillDivider(axis, + color: sectionDividerColor, space: sectionDividerSpace), if (showAlignmentButtons) SelectAlignmentButton( controller: controller, + tooltips: Map.of(buttonTooltips) + ..removeWhere((key, value) => ![ + ToolbarButtons.leftAlignment, + ToolbarButtons.centerAlignment, + ToolbarButtons.rightAlignment, + ToolbarButtons.justifyAlignment, + ].contains(key)), iconSize: toolbarIconSize, iconTheme: iconTheme, showLeftAlignment: showLeftAlignment, @@ -349,6 +433,7 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget { if (showDirection) ToggleStyleButton( attribute: Attribute.rtl, + tooltip: buttonTooltips[ToolbarButtons.direction], controller: controller, icon: Icons.format_textdirection_r_to_l, iconSize: toolbarIconSize, @@ -361,14 +446,13 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget { isButtonGroupShown[3] || isButtonGroupShown[4] || isButtonGroupShown[5])) - VerticalDivider( - indent: 12, - endIndent: 12, - color: Colors.grey.shade400, - ), + QuillDivider(axis, + color: sectionDividerColor, space: sectionDividerSpace), if (showHeaderStyle) SelectHeaderStyleButton( + tooltip: buttonTooltips[ToolbarButtons.headerStyle], controller: controller, + axis: axis, iconSize: toolbarIconSize, iconTheme: iconTheme, afterButtonPressed: afterButtonPressed, @@ -379,14 +463,12 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget { (isButtonGroupShown[3] || isButtonGroupShown[4] || isButtonGroupShown[5])) - VerticalDivider( - indent: 12, - endIndent: 12, - color: Colors.grey.shade400, - ), + QuillDivider(axis, + color: sectionDividerColor, space: sectionDividerSpace), if (showListNumbers) ToggleStyleButton( attribute: Attribute.ol, + tooltip: buttonTooltips[ToolbarButtons.listNumbers], controller: controller, icon: Icons.format_list_numbered, iconSize: toolbarIconSize, @@ -396,6 +478,7 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget { if (showListBullets) ToggleStyleButton( attribute: Attribute.ul, + tooltip: buttonTooltips[ToolbarButtons.listBullets], controller: controller, icon: Icons.format_list_bulleted, iconSize: toolbarIconSize, @@ -405,6 +488,7 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget { if (showListCheck) ToggleCheckListButton( attribute: Attribute.unchecked, + tooltip: buttonTooltips[ToolbarButtons.listChecks], controller: controller, icon: Icons.check_box, iconSize: toolbarIconSize, @@ -414,6 +498,7 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget { if (showCodeBlock) ToggleStyleButton( attribute: Attribute.codeBlock, + tooltip: buttonTooltips[ToolbarButtons.codeBlock], controller: controller, icon: Icons.code, iconSize: toolbarIconSize, @@ -423,14 +508,12 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget { if (showDividers && isButtonGroupShown[3] && (isButtonGroupShown[4] || isButtonGroupShown[5])) - VerticalDivider( - indent: 12, - endIndent: 12, - color: Colors.grey.shade400, - ), + QuillDivider(axis, + color: sectionDividerColor, space: sectionDividerSpace), if (showQuote) ToggleStyleButton( attribute: Attribute.blockQuote, + tooltip: buttonTooltips[ToolbarButtons.quote], controller: controller, icon: Icons.format_quote, iconSize: toolbarIconSize, @@ -441,6 +524,7 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget { IndentButton( icon: Icons.format_indent_increase, iconSize: toolbarIconSize, + tooltip: buttonTooltips[ToolbarButtons.indentIncrease], controller: controller, isIncrease: true, iconTheme: iconTheme, @@ -450,19 +534,18 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget { IndentButton( icon: Icons.format_indent_decrease, iconSize: toolbarIconSize, + tooltip: buttonTooltips[ToolbarButtons.indentDecrease], controller: controller, isIncrease: false, iconTheme: iconTheme, afterButtonPressed: afterButtonPressed, ), if (showDividers && isButtonGroupShown[4] && isButtonGroupShown[5]) - VerticalDivider( - indent: 12, - endIndent: 12, - color: Colors.grey.shade400, - ), + QuillDivider(axis, + color: sectionDividerColor, space: sectionDividerSpace), if (showLink) LinkStyleButton( + tooltip: buttonTooltips[ToolbarButtons.link], controller: controller, iconSize: toolbarIconSize, iconTheme: iconTheme, @@ -473,6 +556,7 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget { SearchButton( icon: Icons.search, iconSize: toolbarIconSize, + tooltip: buttonTooltips[ToolbarButtons.search], controller: controller, iconTheme: iconTheme, dialogTheme: dialogTheme, @@ -480,37 +564,35 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget { ), if (customButtons.isNotEmpty) if (showDividers) - VerticalDivider( - indent: 12, - endIndent: 12, - color: Colors.grey.shade400, - ), - for (var customButton in customButtons) ...[ + QuillDivider(axis, + color: sectionDividerColor, space: sectionDividerSpace), + for (var customButton in customButtons) if (customButton.child != null) ...[ InkWell( onTap: customButton.onTap, child: customButton.child, ), ] else ...[ - QuillIconButton( - highlightElevation: 0, - hoverElevation: 0, - size: toolbarIconSize * kIconButtonFactor, - icon: Icon(customButton.icon, size: toolbarIconSize), - 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, ), ], - ], ], ); } final List children; - final double toolbarHeight; + final Axis axis; + final double toolbarSize; final double toolbarSectionSpacing; final WrapAlignment toolbarIconAlignment; + final WrapCrossAlignment toolbarIconCrossAlignment; final bool multiRowsDisplay; /// The color of the toolbar. @@ -526,8 +608,19 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget { /// List of custom buttons final List customButtons; + /// The color to use when painting the toolbar section divider. + /// + /// If this is null, then the [DividerThemeData.color] is used. If that is + /// also null, then [ThemeData.dividerColor] is used. + final Color? sectionDividerColor; + + /// The space occupied by toolbar section divider. + final double? sectionDividerSpace; + @override - Size get preferredSize => Size.fromHeight(toolbarHeight); + Size get preferredSize => axis == Axis.horizontal + ? Size.fromHeight(toolbarSize) + : Size.fromWidth(toolbarSize); @override Widget build(BuildContext context) { @@ -535,17 +628,72 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget { initialLocale: locale, child: multiRowsDisplay ? Wrap( + direction: axis, alignment: toolbarIconAlignment, + crossAxisAlignment: toolbarIconCrossAlignment, runSpacing: 4, spacing: toolbarSectionSpacing, children: children, ) : Container( - constraints: - BoxConstraints.tightFor(height: preferredSize.height), + constraints: BoxConstraints.tightFor( + height: axis == Axis.horizontal ? toolbarSize : null, + width: axis == Axis.vertical ? toolbarSize : null, + ), color: color ?? Theme.of(context).canvasColor, - child: ArrowIndicatedButtonList(buttons: children), + child: ArrowIndicatedButtonList( + axis: axis, + buttons: children, + ), ), ); } } + +/// 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 toolbar's children on its own. +class QuillDivider extends StatelessWidget { + const QuillDivider( + this.axis, { + Key? key, + this.color, + this.space, + }) : super(key: key); + + /// Provides a horizontal divider for vertical toolbar. + const QuillDivider.horizontal({Color? color, double? space}) + : this(Axis.horizontal, color: color, space: space); + + /// Provides a horizontal divider for horizontal toolbar. + const QuillDivider.vertical({Color? color, double? space}) + : this(Axis.vertical, color: color, space: space); + + /// The axis along which the toolbar is. + final Axis axis; + + /// The color to use when painting this divider's line. + final Color? color; + + /// The divider's space (width or height) depending of [axis]. + final double? space; + + @override + Widget build(BuildContext context) { + // Vertical toolbar requires horizontal divider, and vice versa + return axis == Axis.vertical + ? Divider( + height: space, + color: color, + indent: 12, + endIndent: 12, + ) + : VerticalDivider( + width: space, + color: color, + indent: 12, + endIndent: 12, + ); + } +} diff --git a/lib/src/widgets/toolbar/arrow_indicated_button_list.dart b/lib/src/widgets/toolbar/arrow_indicated_button_list.dart index 52098099..84f19854 100644 --- a/lib/src/widgets/toolbar/arrow_indicated_button_list.dart +++ b/lib/src/widgets/toolbar/arrow_indicated_button_list.dart @@ -7,9 +7,13 @@ import 'package:flutter/material.dart'; /// The arrow indicators are automatically hidden if the list is not /// scrollable in the direction of the respective arrow. class ArrowIndicatedButtonList extends StatefulWidget { - const ArrowIndicatedButtonList({required this.buttons, Key? key}) - : super(key: key); + const ArrowIndicatedButtonList({ + required this.axis, + required this.buttons, + Key? key, + }) : super(key: key); + final Axis axis; final List buttons; @override @@ -20,8 +24,8 @@ class ArrowIndicatedButtonList extends StatefulWidget { class _ArrowIndicatedButtonListState extends State with WidgetsBindingObserver { final ScrollController _controller = ScrollController(); - bool _showLeftArrow = false; - bool _showRightArrow = false; + bool _showBackwardArrow = false; + bool _showForwardArrow = false; @override void initState() { @@ -40,13 +44,19 @@ class _ArrowIndicatedButtonListState extends State @override Widget build(BuildContext context) { - return Row( - children: [ - _buildLeftArrow(), - _buildScrollableList(), - _buildRightColor(), - ], - ); + final children = [ + _buildBackwardArrow(), + _buildScrollableList(), + _buildForwardArrow(), + ]; + + return widget.axis == Axis.horizontal + ? Row( + children: children, + ) + : Column( + children: children, + ); } @override @@ -63,20 +73,29 @@ class _ArrowIndicatedButtonListState extends State if (!mounted) return; setState(() { - _showLeftArrow = + _showBackwardArrow = _controller.position.minScrollExtent != _controller.position.pixels; - _showRightArrow = + _showForwardArrow = _controller.position.maxScrollExtent != _controller.position.pixels; }); } - Widget _buildLeftArrow() { + Widget _buildBackwardArrow() { + IconData? icon; + if (_showBackwardArrow) { + if (widget.axis == Axis.horizontal) { + icon = Icons.arrow_left; + } else { + icon = Icons.arrow_drop_up; + } + } + return SizedBox( width: 8, child: Transform.translate( // Move the icon a few pixels to center it offset: const Offset(-5, 0), - child: _showLeftArrow ? const Icon(Icons.arrow_left, size: 18) : null, + child: icon != null ? Icon(icon, size: 18) : null, ), ); } @@ -87,18 +106,24 @@ class _ArrowIndicatedButtonListState extends State // Remove the glowing effect, as we already have the arrow indicators behavior: _NoGlowBehavior(), // The CustomScrollView is necessary so that the children are not - // stretched to the height of the toolbar, https://bit.ly/3uC3bjI + // stretched to the height of the toolbar: + // https://stackoverflow.com/a/65998731/7091839 child: CustomScrollView( - scrollDirection: Axis.horizontal, + scrollDirection: widget.axis, controller: _controller, physics: const ClampingScrollPhysics(), slivers: [ SliverFillRemaining( hasScrollBody: false, - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: widget.buttons, - ), + child: widget.axis == Axis.horizontal + ? Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: widget.buttons, + ) + : Column( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: widget.buttons, + ), ) ], ), @@ -106,13 +131,22 @@ class _ArrowIndicatedButtonListState extends State ); } - Widget _buildRightColor() { + Widget _buildForwardArrow() { + IconData? icon; + if (_showForwardArrow) { + if (widget.axis == Axis.horizontal) { + icon = Icons.arrow_right; + } else { + icon = Icons.arrow_drop_down; + } + } + return SizedBox( width: 8, child: Transform.translate( // Move the icon a few pixels to center it offset: const Offset(-5, 0), - child: _showRightArrow ? const Icon(Icons.arrow_right, size: 18) : null, + child: icon != null ? Icon(icon, size: 18) : null, ), ); } @@ -120,7 +154,6 @@ class _ArrowIndicatedButtonListState extends State /// ScrollBehavior without the Material glow effect. class _NoGlowBehavior extends ScrollBehavior { - @override Widget buildViewportChrome(BuildContext _, Widget child, AxisDirection __) { return child; } diff --git a/lib/src/widgets/toolbar/clear_format_button.dart b/lib/src/widgets/toolbar/clear_format_button.dart index 2e98a0f8..14610232 100644 --- a/lib/src/widgets/toolbar/clear_format_button.dart +++ b/lib/src/widgets/toolbar/clear_format_button.dart @@ -1,6 +1,9 @@ import 'package:flutter/material.dart'; -import '../../../flutter_quill.dart'; +import '../../models/documents/attribute.dart'; +import '../../models/themes/quill_icon_theme.dart'; +import '../controller.dart'; +import '../toolbar.dart'; class ClearFormatButton extends StatefulWidget { const ClearFormatButton({ @@ -9,6 +12,7 @@ class ClearFormatButton extends StatefulWidget { this.iconSize = kDefaultIconSize, this.iconTheme, this.afterButtonPressed, + this.tooltip, Key? key, }) : super(key: key); @@ -19,6 +23,7 @@ class ClearFormatButton extends StatefulWidget { final QuillIconTheme? iconTheme; final VoidCallback? afterButtonPressed; + final String? tooltip; @override _ClearFormatButtonState createState() => _ClearFormatButtonState(); @@ -33,6 +38,7 @@ class _ClearFormatButtonState extends State { final fillColor = widget.iconTheme?.iconUnselectedFillColor ?? theme.canvasColor; return QuillIconButton( + tooltip: widget.tooltip, highlightElevation: 0, hoverElevation: 0, size: widget.iconSize * kIconButtonFactor, diff --git a/lib/src/widgets/toolbar/color_button.dart b/lib/src/widgets/toolbar/color_button.dart index 087ae94f..41a57734 100644 --- a/lib/src/widgets/toolbar/color_button.dart +++ b/lib/src/widgets/toolbar/color_button.dart @@ -21,6 +21,7 @@ class ColorButton extends StatefulWidget { this.iconSize = kDefaultIconSize, this.iconTheme, this.afterButtonPressed, + this.tooltip, Key? key, }) : super(key: key); @@ -30,6 +31,7 @@ class ColorButton extends StatefulWidget { final QuillController controller; final QuillIconTheme? iconTheme; final VoidCallback? afterButtonPressed; + final String? tooltip; @override _ColorButtonState createState() => _ColorButtonState(); @@ -119,6 +121,7 @@ class _ColorButtonState extends State { : (widget.iconTheme?.iconUnselectedFillColor ?? theme.canvasColor); return QuillIconButton( + tooltip: widget.tooltip, highlightElevation: 0, hoverElevation: 0, size: widget.iconSize * kIconButtonFactor, @@ -133,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/enum.dart b/lib/src/widgets/toolbar/enum.dart new file mode 100644 index 00000000..e6719e50 --- /dev/null +++ b/lib/src/widgets/toolbar/enum.dart @@ -0,0 +1,32 @@ +enum ToolbarButtons { + undo, + redo, + fontFamily, + fontSize, + bold, + subscript, + superscript, + italic, + small, + underline, + strikeThrough, + inlineCode, + color, + backgroundColor, + clearFormat, + centerAlignment, + leftAlignment, + rightAlignment, + justifyAlignment, + direction, + headerStyle, + listNumbers, + listBullets, + listChecks, + codeBlock, + quote, + indentIncrease, + indentDecrease, + link, + search, +} diff --git a/lib/src/widgets/toolbar/history_button.dart b/lib/src/widgets/toolbar/history_button.dart index dafd4ca3..909842c9 100644 --- a/lib/src/widgets/toolbar/history_button.dart +++ b/lib/src/widgets/toolbar/history_button.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; -import '../../../flutter_quill.dart'; +import '../../models/themes/quill_icon_theme.dart'; +import '../controller.dart'; +import '../toolbar.dart'; class HistoryButton extends StatefulWidget { const HistoryButton({ @@ -10,6 +12,7 @@ class HistoryButton extends StatefulWidget { this.iconSize = kDefaultIconSize, this.iconTheme, this.afterButtonPressed, + this.tooltip, Key? key, }) : super(key: key); @@ -19,6 +22,7 @@ class HistoryButton extends StatefulWidget { final QuillController controller; final QuillIconTheme? iconTheme; final VoidCallback? afterButtonPressed; + final String? tooltip; @override _HistoryButtonState createState() => _HistoryButtonState(); @@ -39,9 +43,10 @@ class _HistoryButtonState extends State { _setIconColor(); }); return QuillIconButton( + 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 227c56e9..89b57d4a 100644 --- a/lib/src/widgets/toolbar/indent_button.dart +++ b/lib/src/widgets/toolbar/indent_button.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; -import '../../../flutter_quill.dart'; +import '../../models/themes/quill_icon_theme.dart'; +import '../controller.dart'; +import '../toolbar.dart'; class IndentButton extends StatefulWidget { const IndentButton({ @@ -10,6 +12,7 @@ class IndentButton extends StatefulWidget { this.iconSize = kDefaultIconSize, this.iconTheme, this.afterButtonPressed, + this.tooltip, Key? key, }) : super(key: key); @@ -20,6 +23,7 @@ class IndentButton extends StatefulWidget { final VoidCallback? afterButtonPressed; final QuillIconTheme? iconTheme; + final String? tooltip; @override _IndentButtonState createState() => _IndentButtonState(); @@ -35,34 +39,15 @@ class _IndentButtonState extends State { final iconFillColor = widget.iconTheme?.iconUnselectedFillColor ?? theme.canvasColor; return QuillIconButton( + 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, onPressed: () { - final indent = widget.controller - .getSelectionStyle() - .attributes[Attribute.indent.key]; - if (indent == null) { - if (widget.isIncrease) { - widget.controller.formatSelection(Attribute.indentL1); - } - return; - } - if (indent.value == 1 && !widget.isIncrease) { - widget.controller - .formatSelection(Attribute.clone(Attribute.indentL1, null)); - return; - } - if (widget.isIncrease) { - widget.controller - .formatSelection(Attribute.getIndentLevel(indent.value + 1)); - return; - } - widget.controller - .formatSelection(Attribute.getIndentLevel(indent.value - 1)); + widget.controller.indentSelection(widget.isIncrease); }, afterPressed: widget.afterButtonPressed, ); diff --git a/lib/src/widgets/toolbar/link_style_button.dart b/lib/src/widgets/toolbar/link_style_button.dart index cd97fa98..a0865941 100644 --- a/lib/src/widgets/toolbar/link_style_button.dart +++ b/lib/src/widgets/toolbar/link_style_button.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:tuple/tuple.dart'; import '../../models/documents/attribute.dart'; import '../../models/rules/insert.dart'; @@ -18,6 +17,7 @@ class LinkStyleButton extends StatefulWidget { this.iconTheme, this.dialogTheme, this.afterButtonPressed, + this.tooltip, Key? key, }) : super(key: key); @@ -27,6 +27,7 @@ class LinkStyleButton extends StatefulWidget { final QuillIconTheme? iconTheme; final QuillDialogTheme? dialogTheme; final VoidCallback? afterButtonPressed; + final String? tooltip; @override _LinkStyleButtonState createState() => _LinkStyleButtonState(); @@ -64,6 +65,7 @@ class _LinkStyleButtonState extends State { final isToggled = _getLinkAttributeValue() != null; final pressedHandler = () => _openLinkDialog(context); return QuillIconButton( + tooltip: widget.tooltip, highlightElevation: 0, hoverElevation: 0, size: widget.iconSize * kIconButtonFactor, @@ -77,7 +79,7 @@ class _LinkStyleButtonState extends State { ), fillColor: isToggled ? (widget.iconTheme?.iconSelectedFillColor ?? - theme.toggleableActiveColor) + Theme.of(context).primaryColor) : (widget.iconTheme?.iconUnselectedFillColor ?? theme.canvasColor), borderRadius: widget.iconTheme?.borderRadius ?? 2, onPressed: pressedHandler, @@ -86,7 +88,7 @@ class _LinkStyleButtonState extends State { } void _openLinkDialog(BuildContext context) { - showDialog( + showDialog<_TextLink>( context: context, builder: (ctx) { final link = _getLinkAttributeValue(); @@ -96,7 +98,7 @@ class _LinkStyleButtonState extends State { if (link != null) { // text should be the link's corresponding text, not selection final leaf = - widget.controller.document.querySegmentLeafNode(index).item2; + widget.controller.document.querySegmentLeafNode(index).leaf; if (leaf != null) { text = leaf.toPlainText(); } @@ -122,24 +124,21 @@ class _LinkStyleButtonState extends State { ?.value; } - void _linkSubmitted(dynamic value) { - // text.isNotEmpty && link.isNotEmpty - final String text = (value as Tuple2).item1; - final String link = value.item2.trim(); - + void _linkSubmitted(_TextLink value) { var index = widget.controller.selection.start; var length = widget.controller.selection.end - index; if (_getLinkAttributeValue() != null) { // text should be the link's corresponding text, not selection - final leaf = widget.controller.document.querySegmentLeafNode(index).item2; + final leaf = widget.controller.document.querySegmentLeafNode(index).leaf; if (leaf != null) { final range = getLinkRange(leaf); index = range.start; length = range.end - range.start; } } - widget.controller.replaceText(index, length, text, null); - widget.controller.formatText(index, text.length, LinkAttribute(link)); + widget.controller.replaceText(index, length, value.text, null); + widget.controller + .formatText(index, value.text.length, LinkAttribute(value.link)); } } @@ -240,6 +239,16 @@ class _LinkDialogState extends State<_LinkDialog> { } void _applyLink() { - Navigator.pop(context, Tuple2(_text.trim(), _link.trim())); + Navigator.pop(context, _TextLink(_text.trim(), _link.trim())); } } + +class _TextLink { + _TextLink( + this.text, + this.link, + ); + + final String text; + final String link; +} diff --git a/lib/src/widgets/toolbar/link_style_button2.dart b/lib/src/widgets/toolbar/link_style_button2.dart new file mode 100644 index 00000000..42aaff11 --- /dev/null +++ b/lib/src/widgets/toolbar/link_style_button2.dart @@ -0,0 +1,446 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:url_launcher/link.dart'; + +import '../../../extensions.dart'; +import '../../../translations.dart'; +import '../../models/documents/attribute.dart'; +import '../../models/themes/quill_dialog_theme.dart'; +import '../../models/themes/quill_icon_theme.dart'; +import '../controller.dart'; +import '../link.dart'; +import '../toolbar.dart'; + +/// Alternative version of [LinkStyleButton]. This widget has more customization +/// and uses dialog similar to one which is used on [http://quilljs.com]. +class LinkStyleButton2 extends StatefulWidget { + const LinkStyleButton2({ + required this.controller, + this.icon, + this.iconSize = kDefaultIconSize, + this.iconTheme, + this.dialogTheme, + this.afterButtonPressed, + this.tooltip, + this.constraints, + this.addLinkLabel, + this.editLinkLabel, + this.linkColor, + this.childrenSpacing = 16.0, + this.autovalidateMode = AutovalidateMode.disabled, + this.validationMessage, + this.buttonSize, + Key? key, + }) : assert(addLinkLabel == null || addLinkLabel.length > 0), + assert(editLinkLabel == null || editLinkLabel.length > 0), + assert(childrenSpacing > 0), + assert(validationMessage == null || validationMessage.length > 0), + super(key: key); + + final QuillController controller; + final IconData? icon; + final double iconSize; + final QuillIconTheme? iconTheme; + final QuillDialogTheme? dialogTheme; + final VoidCallback? afterButtonPressed; + final String? tooltip; + + /// The constrains for dialog. + final BoxConstraints? constraints; + + /// The text of label in link add mode. + final String? addLinkLabel; + + /// The text of label in link edit mode. + final String? editLinkLabel; + + /// The color of URL. + final Color? linkColor; + + /// The margin between child widgets in the dialog. + final double childrenSpacing; + + final AutovalidateMode autovalidateMode; + final String? validationMessage; + + /// The size of dialog buttons. + final Size? buttonSize; + + @override + State createState() => _LinkStyleButton2State(); +} + +class _LinkStyleButton2State extends State { + @override + void dispose() { + super.dispose(); + widget.controller.removeListener(_didChangeSelection); + } + + @override + void initState() { + super.initState(); + widget.controller.addListener(_didChangeSelection); + } + + @override + void didUpdateWidget(covariant LinkStyleButton2 oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + oldWidget.controller.removeListener(_didChangeSelection); + widget.controller.addListener(_didChangeSelection); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isToggled = _getLinkAttributeValue() != null; + return QuillIconButton( + tooltip: widget.tooltip, + highlightElevation: 0, + hoverElevation: 0, + size: widget.iconSize * kIconButtonFactor, + icon: Icon( + widget.icon ?? Icons.link, + size: widget.iconSize, + color: isToggled + ? (widget.iconTheme?.iconSelectedColor ?? + theme.primaryIconTheme.color) + : (widget.iconTheme?.iconUnselectedColor ?? theme.iconTheme.color), + ), + fillColor: isToggled + ? (widget.iconTheme?.iconSelectedFillColor ?? + Theme.of(context).primaryColor) + : (widget.iconTheme?.iconUnselectedFillColor ?? theme.canvasColor), + borderRadius: widget.iconTheme?.borderRadius ?? 2, + onPressed: _openLinkDialog, + afterPressed: widget.afterButtonPressed, + ); + } + + Future _openLinkDialog() async { + final initialTextLink = QuillTextLink.prepare(widget.controller); + + final textLink = await showDialog( + context: context, + builder: (_) => LinkStyleDialog( + dialogTheme: widget.dialogTheme, + text: initialTextLink.text, + link: initialTextLink.link, + constraints: widget.constraints, + addLinkLabel: widget.addLinkLabel, + editLinkLabel: widget.editLinkLabel, + linkColor: widget.linkColor, + childrenSpacing: widget.childrenSpacing, + autovalidateMode: widget.autovalidateMode, + validationMessage: widget.validationMessage, + buttonSize: widget.buttonSize, + ), + ); + + if (textLink != null) { + textLink.submit(widget.controller); + } + } + + String? _getLinkAttributeValue() { + return widget.controller + .getSelectionStyle() + .attributes[Attribute.link.key] + ?.value; + } + + void _didChangeSelection() { + setState(() {}); + } +} + +class LinkStyleDialog extends StatefulWidget { + const LinkStyleDialog({ + Key? key, + this.text, + this.link, + this.dialogTheme, + this.constraints, + this.contentPadding = + const EdgeInsets.symmetric(vertical: 16, horizontal: 16), + this.addLinkLabel, + this.editLinkLabel, + this.linkColor, + this.childrenSpacing = 16.0, + this.autovalidateMode = AutovalidateMode.disabled, + this.validationMessage, + this.buttonSize, + }) : assert(addLinkLabel == null || addLinkLabel.length > 0), + assert(editLinkLabel == null || editLinkLabel.length > 0), + assert(childrenSpacing > 0), + assert(validationMessage == null || validationMessage.length > 0), + super(key: key); + + final String? text; + final String? link; + final QuillDialogTheme? dialogTheme; + + /// The constrains for dialog. + final BoxConstraints? constraints; + + /// The padding for content of dialog. + final EdgeInsetsGeometry contentPadding; + + /// The text of label in link add mode. + final String? addLinkLabel; + + /// The text of label in link edit mode. + final String? editLinkLabel; + + /// The color of URL. + final Color? linkColor; + + /// The margin between child widgets in the dialog. + final double childrenSpacing; + + final AutovalidateMode autovalidateMode; + final String? validationMessage; + + /// The size of dialog buttons. + final Size? buttonSize; + + @override + State createState() => _LinkStyleDialogState(); +} + +class _LinkStyleDialogState extends State { + late final TextEditingController _linkController; + + late String _link; + late String _text; + + late bool _isEditMode; + + @override + void dispose() { + _linkController.dispose(); + super.dispose(); + } + + @override + void initState() { + super.initState(); + _link = widget.link ?? ''; + _text = widget.text ?? ''; + _isEditMode = _link.isNotEmpty; + _linkController = TextEditingController.fromValue( + TextEditingValue( + text: _isEditMode ? _link : '', + selection: _isEditMode + ? TextSelection(baseOffset: 0, extentOffset: _link.length) + : const TextSelection.collapsed(offset: 0), + ), + ); + } + + @override + Widget build(BuildContext context) { + final constraints = widget.constraints ?? + widget.dialogTheme?.linkDialogConstraints ?? + () { + final mediaQuery = MediaQuery.of(context); + final maxWidth = + kIsWeb ? mediaQuery.size.width / 4 : mediaQuery.size.width - 80; + return BoxConstraints(maxWidth: maxWidth, maxHeight: 80); + }(); + + final buttonStyle = widget.buttonSize != null + ? Theme.of(context) + .elevatedButtonTheme + .style + ?.copyWith(fixedSize: MaterialStatePropertyAll(widget.buttonSize)) + : widget.dialogTheme?.buttonStyle; + + final isWrappable = widget.dialogTheme?.isWrappable ?? false; + + final children = _isEditMode + ? [ + Text(widget.editLinkLabel ?? 'Visit link'.i18n), + UtilityWidgets.maybeWidget( + enabled: !isWrappable, + wrapper: (child) => Expanded( + child: Align( + alignment: Alignment.centerLeft, + child: child, + ), + ), + child: Padding( + padding: + EdgeInsets.symmetric(horizontal: widget.childrenSpacing), + child: Link( + uri: Uri.parse(_linkController.text), + builder: (context, followLink) { + return TextButton( + onPressed: followLink, + style: TextButton.styleFrom( + backgroundColor: Colors.transparent, + ), + child: Text( + widget.link!, + textAlign: TextAlign.left, + overflow: TextOverflow.ellipsis, + style: widget.dialogTheme?.inputTextStyle?.copyWith( + color: widget.linkColor ?? Colors.blue, + decoration: TextDecoration.underline, + ), + ), + ); + }, + ), + ), + ), + ElevatedButton( + onPressed: () { + setState(() { + _isEditMode = !_isEditMode; + }); + }, + style: buttonStyle, + child: Text('Edit'.i18n), + ), + Padding( + padding: EdgeInsets.only(left: widget.childrenSpacing), + child: ElevatedButton( + onPressed: _removeLink, + style: buttonStyle, + child: Text('Remove'.i18n), + ), + ), + ] + : [ + Text(widget.addLinkLabel ?? 'Enter link'.i18n), + UtilityWidgets.maybeWidget( + enabled: !isWrappable, + wrapper: (child) => Expanded( + child: child, + ), + child: Padding( + padding: + EdgeInsets.symmetric(horizontal: widget.childrenSpacing), + child: TextFormField( + controller: _linkController, + style: widget.dialogTheme?.inputTextStyle, + keyboardType: TextInputType.url, + textInputAction: TextInputAction.done, + decoration: InputDecoration( + labelStyle: widget.dialogTheme?.labelTextStyle, + ), + autofocus: true, + autovalidateMode: widget.autovalidateMode, + validator: _validateLink, + onChanged: _linkChanged, + ), + ), + ), + ElevatedButton( + onPressed: _canPress() ? _applyLink : null, + style: buttonStyle, + child: Text('Apply'.i18n), + ), + ]; + + return Dialog( + backgroundColor: widget.dialogTheme?.dialogBackgroundColor, + shape: widget.dialogTheme?.shape ?? + DialogTheme.of(context).shape ?? + RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)), + child: ConstrainedBox( + constraints: constraints, + child: Padding( + padding: widget.contentPadding, + child: isWrappable + ? Wrap( + alignment: WrapAlignment.center, + crossAxisAlignment: WrapCrossAlignment.center, + runSpacing: widget.dialogTheme?.runSpacing ?? 0.0, + children: children, + ) + : Row( + children: children, + ), + ), + ), + ); + } + + void _linkChanged(String value) { + setState(() { + _link = value; + }); + } + + bool _canPress() => _validateLink(_link) == null; + + String? _validateLink(String? value) { + if ((value?.isEmpty ?? false) || + !AutoFormatMultipleLinksRule.linkRegExp.hasMatch(value!)) { + return widget.validationMessage ?? 'That is not a valid URL'; + } + + return null; + } + + void _applyLink() => + Navigator.pop(context, QuillTextLink(_text.trim(), _link.trim())); + + void _removeLink() => + Navigator.pop(context, QuillTextLink(_text.trim(), null)); +} + +/// Contains information about text URL. +class QuillTextLink { + QuillTextLink( + this.text, + this.link, + ); + + final String text; + final String? link; + + static QuillTextLink prepare(QuillController controller) { + final link = + controller.getSelectionStyle().attributes[Attribute.link.key]?.value; + final index = controller.selection.start; + + var text; + if (link != null) { + // text should be the link's corresponding text, not selection + final leaf = controller.document.querySegmentLeafNode(index).leaf; + if (leaf != null) { + text = leaf.toPlainText(); + } + } + + final len = controller.selection.end - index; + text ??= len == 0 ? '' : controller.document.getPlainText(index, len); + + return QuillTextLink(text, link); + } + + void submit(QuillController controller) { + var index = controller.selection.start; + var length = controller.selection.end - index; + final linkValue = + controller.getSelectionStyle().attributes[Attribute.link.key]?.value; + + if (linkValue != null) { + // text should be the link's corresponding text, not selection + final leaf = controller.document.querySegmentLeafNode(index).leaf; + if (leaf != null) { + final range = getLinkRange(leaf); + index = range.start; + length = range.end - range.start; + } + } + controller + ..replaceText(index, length, text, null) + ..formatText(index, text.length, LinkAttribute(link)); + } +} diff --git a/lib/src/widgets/toolbar/quill_font_family_button.dart b/lib/src/widgets/toolbar/quill_font_family_button.dart index 065daf7d..8108c200 100644 --- a/lib/src/widgets/toolbar/quill_font_family_button.dart +++ b/lib/src/widgets/toolbar/quill_font_family_button.dart @@ -4,35 +4,61 @@ import '../../models/documents/attribute.dart'; import '../../models/documents/style.dart'; import '../../models/themes/quill_icon_theme.dart'; import '../../translations/toolbar.i18n.dart'; +import '../../utils/widgets.dart'; import '../controller.dart'; class QuillFontFamilyButton extends StatefulWidget { const QuillFontFamilyButton({ - required this.items, required this.rawItemsMap, required this.attribute, required this.controller, - required this.onSelected, + @Deprecated('It is not required because of `rawItemsMap`') this.items, + this.onSelected, this.iconSize = 40, this.fillColor, this.hoverElevation = 1, this.highlightElevation = 1, this.iconTheme, this.afterButtonPressed, + this.tooltip, + this.padding, + this.style, + this.width, + this.renderFontFamilies = true, + this.initialValue, + this.labelOverflow = TextOverflow.visible, + this.overrideTooltipByFontFamily = false, + this.itemHeight, + this.itemPadding, + this.defaultItemColor = Colors.red, Key? key, - }) : super(key: key); + }) : assert(rawItemsMap.length > 0), + assert(initialValue == null || initialValue.length > 0), + super(key: key); final double iconSize; final Color? fillColor; final double hoverElevation; final double highlightElevation; - final List> items; + @Deprecated('It is not required because of `rawItemsMap`') + final List>? items; final Map rawItemsMap; - final ValueChanged onSelected; + final ValueChanged? onSelected; final QuillIconTheme? iconTheme; final Attribute attribute; final QuillController controller; final VoidCallback? afterButtonPressed; + final String? tooltip; + final EdgeInsetsGeometry? padding; + final TextStyle? style; + final double? width; + final bool renderFontFamilies; + final String? initialValue; + final TextOverflow labelOverflow; + final bool overrideTooltipByFontFamily; + final double? itemHeight; + final EdgeInsets? itemPadding; + final Color? defaultItemColor; @override _QuillFontFamilyButtonState createState() => _QuillFontFamilyButtonState(); @@ -46,7 +72,7 @@ class _QuillFontFamilyButtonState extends State { @override void initState() { super.initState(); - _currentValue = _defaultDisplayText = 'Font'.i18n; + _currentValue = _defaultDisplayText = widget.initialValue ?? 'Font'.i18n; widget.controller.addListener(_didChangeEditingValue); } @@ -87,21 +113,37 @@ class _QuillFontFamilyButtonState extends State { @override Widget build(BuildContext context) { return ConstrainedBox( - constraints: BoxConstraints.tightFor(height: widget.iconSize * 1.81), - child: RawMaterialButton( - visualDensity: VisualDensity.compact, - shape: RoundedRectangleBorder( - borderRadius: - BorderRadius.circular(widget.iconTheme?.borderRadius ?? 2)), - fillColor: widget.fillColor, - elevation: 0, - hoverElevation: widget.hoverElevation, - highlightElevation: widget.hoverElevation, - onPressed: () { - _showMenu(); - widget.afterButtonPressed?.call(); + constraints: BoxConstraints.tightFor( + height: widget.iconSize * 1.81, + width: widget.width, + ), + child: UtilityWidgets.maybeWidget( + enabled: (widget.tooltip ?? '').isNotEmpty || + widget.overrideTooltipByFontFamily, + wrapper: (child) { + var effectiveTooltip = widget.tooltip ?? ''; + if (widget.overrideTooltipByFontFamily) { + effectiveTooltip = effectiveTooltip.isNotEmpty + ? '$effectiveTooltip: $_currentValue' + : '${'Font'.i18n}: $_currentValue'; + } + return Tooltip(message: effectiveTooltip, child: child); }, - child: _buildContent(context), + child: RawMaterialButton( + visualDensity: VisualDensity.compact, + shape: RoundedRectangleBorder( + borderRadius: + BorderRadius.circular(widget.iconTheme?.borderRadius ?? 2)), + fillColor: widget.fillColor, + elevation: 0, + hoverElevation: widget.hoverElevation, + highlightElevation: widget.hoverElevation, + onPressed: () { + _showMenu(); + widget.afterButtonPressed?.call(); + }, + child: _buildContent(context), + ), ), ); } @@ -109,8 +151,7 @@ class _QuillFontFamilyButtonState extends State { void _showMenu() { final popupMenuTheme = PopupMenuTheme.of(context); final button = context.findRenderObject() as RenderBox; - final overlay = - Overlay.of(context)!.context.findRenderObject() as RenderBox; + final overlay = Overlay.of(context).context.findRenderObject() as RenderBox; final position = RelativeRect.fromRect( Rect.fromPoints( button.localToGlobal(Offset.zero, ancestor: overlay), @@ -122,7 +163,24 @@ class _QuillFontFamilyButtonState extends State { showMenu( context: context, elevation: 4, - items: widget.items, + items: [ + for (MapEntry fontFamily in widget.rawItemsMap.entries) + PopupMenuItem( + key: ValueKey(fontFamily.key), + value: fontFamily.value, + height: widget.itemHeight ?? kMinInteractiveDimension, + padding: widget.itemPadding, + child: Text( + fontFamily.key.toString(), + style: TextStyle( + fontFamily: widget.renderFontFamilies ? fontFamily.value : null, + color: fontFamily.value == 'Clear' + ? widget.defaultItemColor + : null, + ), + ), + ), + ], position: position, shape: popupMenuTheme.shape, color: popupMenuTheme.color, @@ -135,7 +193,9 @@ class _QuillFontFamilyButtonState extends State { setState(() { _currentValue = keyName ?? _defaultDisplayText; if (keyName != null) { - widget.onSelected(newValue); + widget.controller.formatSelection(Attribute.fromKeyValue( + 'font', newValue == 'Clear' ? null : newValue)); + widget.onSelected?.call(newValue); } }); }); @@ -143,16 +203,27 @@ class _QuillFontFamilyButtonState extends State { Widget _buildContent(BuildContext context) { final theme = Theme.of(context); + final hasFinalWidth = widget.width != null; return Padding( - padding: const EdgeInsets.fromLTRB(10, 0, 0, 0), + padding: widget.padding ?? const EdgeInsets.fromLTRB(10, 0, 0, 0), child: Row( - mainAxisSize: MainAxisSize.min, + mainAxisSize: !hasFinalWidth ? MainAxisSize.min : MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text(_currentValue, - style: TextStyle( - fontSize: widget.iconSize / 1.15, - color: widget.iconTheme?.iconUnselectedColor ?? - theme.iconTheme.color)), + UtilityWidgets.maybeWidget( + enabled: hasFinalWidth, + wrapper: (child) => Expanded(child: child), + child: Text( + _currentValue, + maxLines: 1, + overflow: widget.labelOverflow, + style: widget.style ?? + TextStyle( + fontSize: widget.iconSize / 1.15, + color: widget.iconTheme?.iconUnselectedColor ?? + theme.iconTheme.color), + ), + ), const SizedBox(width: 3), Icon(Icons.arrow_drop_down, size: widget.iconSize / 1.15, diff --git a/lib/src/widgets/toolbar/quill_font_size_button.dart b/lib/src/widgets/toolbar/quill_font_size_button.dart index fc99618c..e445c0ae 100644 --- a/lib/src/widgets/toolbar/quill_font_size_button.dart +++ b/lib/src/widgets/toolbar/quill_font_size_button.dart @@ -5,35 +5,57 @@ import '../../models/documents/style.dart'; import '../../models/themes/quill_icon_theme.dart'; import '../../translations/toolbar.i18n.dart'; import '../../utils/font.dart'; +import '../../utils/widgets.dart'; import '../controller.dart'; class QuillFontSizeButton extends StatefulWidget { const QuillFontSizeButton({ - required this.items, required this.rawItemsMap, required this.attribute, required this.controller, - required this.onSelected, + this.onSelected, + @Deprecated('It is not required because of `rawItemsMap`') this.items, this.iconSize = 40, this.fillColor, this.hoverElevation = 1, this.highlightElevation = 1, this.iconTheme, this.afterButtonPressed, + this.tooltip, + this.padding, + this.style, + this.width, + this.initialValue, + this.labelOverflow = TextOverflow.visible, + this.itemHeight, + this.itemPadding, + this.defaultItemColor = Colors.red, Key? key, - }) : super(key: key); + }) : assert(rawItemsMap.length > 0), + assert(initialValue == null || initialValue.length > 0), + super(key: key); final double iconSize; final Color? fillColor; final double hoverElevation; final double highlightElevation; - final List> items; + @Deprecated('It is not required because of `rawItemsMap`') + final List>? items; final Map rawItemsMap; - final ValueChanged onSelected; + final ValueChanged? onSelected; final QuillIconTheme? iconTheme; final Attribute attribute; final QuillController controller; final VoidCallback? afterButtonPressed; + final String? tooltip; + final EdgeInsetsGeometry? padding; + final TextStyle? style; + final double? width; + final String? initialValue; + final TextOverflow labelOverflow; + final double? itemHeight; + final EdgeInsets? itemPadding; + final Color? defaultItemColor; @override _QuillFontSizeButtonState createState() => _QuillFontSizeButtonState(); @@ -47,7 +69,7 @@ class _QuillFontSizeButtonState extends State { @override void initState() { super.initState(); - _currentValue = _defaultDisplayText = 'Size'.i18n; + _currentValue = _defaultDisplayText = widget.initialValue ?? 'Size'.i18n; widget.controller.addListener(_didChangeEditingValue); } @@ -88,21 +110,27 @@ class _QuillFontSizeButtonState extends State { @override Widget build(BuildContext context) { return ConstrainedBox( - constraints: BoxConstraints.tightFor(height: widget.iconSize * 1.81), - child: RawMaterialButton( - visualDensity: VisualDensity.compact, - shape: RoundedRectangleBorder( - borderRadius: - BorderRadius.circular(widget.iconTheme?.borderRadius ?? 2)), - fillColor: widget.fillColor, - elevation: 0, - hoverElevation: widget.hoverElevation, - highlightElevation: widget.hoverElevation, - onPressed: () { - _showMenu(); - widget.afterButtonPressed?.call(); - }, - child: _buildContent(context), + constraints: BoxConstraints.tightFor( + height: widget.iconSize * 1.81, + width: widget.width, + ), + child: UtilityWidgets.maybeTooltip( + message: widget.tooltip, + child: RawMaterialButton( + visualDensity: VisualDensity.compact, + shape: RoundedRectangleBorder( + borderRadius: + BorderRadius.circular(widget.iconTheme?.borderRadius ?? 2)), + fillColor: widget.fillColor, + elevation: 0, + hoverElevation: widget.hoverElevation, + highlightElevation: widget.hoverElevation, + onPressed: () { + _showMenu(); + widget.afterButtonPressed?.call(); + }, + child: _buildContent(context), + ), ), ); } @@ -110,8 +138,7 @@ class _QuillFontSizeButtonState extends State { void _showMenu() { final popupMenuTheme = PopupMenuTheme.of(context); final button = context.findRenderObject() as RenderBox; - final overlay = - Overlay.of(context)!.context.findRenderObject() as RenderBox; + final overlay = Overlay.of(context).context.findRenderObject() as RenderBox; final position = RelativeRect.fromRect( Rect.fromPoints( button.localToGlobal(Offset.zero, ancestor: overlay), @@ -123,7 +150,21 @@ class _QuillFontSizeButtonState extends State { showMenu( context: context, elevation: 4, - items: widget.items, + items: [ + for (MapEntry fontSize in widget.rawItemsMap.entries) + PopupMenuItem( + key: ValueKey(fontSize.key), + value: fontSize.value, + height: widget.itemHeight ?? kMinInteractiveDimension, + padding: widget.itemPadding, + child: Text( + fontSize.key.toString(), + style: TextStyle( + color: fontSize.value == '0' ? widget.defaultItemColor : null, + ), + ), + ), + ], position: position, shape: popupMenuTheme.shape, color: popupMenuTheme.color, @@ -136,7 +177,9 @@ class _QuillFontSizeButtonState extends State { setState(() { _currentValue = keyName ?? _defaultDisplayText; if (keyName != null) { - widget.onSelected(newValue); + widget.controller.formatSelection(Attribute.fromKeyValue( + 'size', newValue == '0' ? null : getFontSize(newValue))); + widget.onSelected?.call(newValue); } }); }); @@ -144,16 +187,24 @@ class _QuillFontSizeButtonState extends State { Widget _buildContent(BuildContext context) { final theme = Theme.of(context); + final hasFinalWidth = widget.width != null; return Padding( - padding: const EdgeInsets.fromLTRB(10, 0, 0, 0), + padding: widget.padding ?? const EdgeInsets.fromLTRB(10, 0, 0, 0), child: Row( - mainAxisSize: MainAxisSize.min, + mainAxisSize: !hasFinalWidth ? MainAxisSize.min : MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text(_currentValue, - style: TextStyle( - fontSize: widget.iconSize / 1.15, - color: widget.iconTheme?.iconUnselectedColor ?? - theme.iconTheme.color)), + UtilityWidgets.maybeWidget( + enabled: hasFinalWidth, + wrapper: (child) => Expanded(child: child), + child: Text(_currentValue, + overflow: widget.labelOverflow, + style: widget.style ?? + TextStyle( + fontSize: widget.iconSize / 1.15, + color: widget.iconTheme?.iconUnselectedColor ?? + theme.iconTheme.color)), + ), const SizedBox(width: 3), Icon(Icons.arrow_drop_down, size: widget.iconSize / 1.15, diff --git a/lib/src/widgets/toolbar/quill_icon_button.dart b/lib/src/widgets/toolbar/quill_icon_button.dart index 7714187c..86c5b30b 100644 --- a/lib/src/widgets/toolbar/quill_icon_button.dart +++ b/lib/src/widgets/toolbar/quill_icon_button.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; +import '../../utils/widgets.dart'; + class QuillIconButton extends StatelessWidget { const QuillIconButton({ required this.onPressed, @@ -10,6 +12,7 @@ class QuillIconButton extends StatelessWidget { this.hoverElevation = 1, this.highlightElevation = 1, this.borderRadius = 2, + this.tooltip, Key? key, }) : super(key: key); @@ -21,24 +24,28 @@ class QuillIconButton extends StatelessWidget { final double hoverElevation; final double highlightElevation; final double borderRadius; + final String? tooltip; @override Widget build(BuildContext context) { return ConstrainedBox( constraints: BoxConstraints.tightFor(width: size, height: size), - child: RawMaterialButton( - visualDensity: VisualDensity.compact, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(borderRadius)), - fillColor: fillColor, - elevation: 0, - hoverElevation: hoverElevation, - highlightElevation: hoverElevation, - onPressed: () { - onPressed?.call(); - afterPressed?.call(); - }, - child: icon, + child: UtilityWidgets.maybeTooltip( + message: tooltip, + child: RawMaterialButton( + visualDensity: VisualDensity.compact, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(borderRadius)), + fillColor: fillColor, + elevation: 0, + hoverElevation: hoverElevation, + highlightElevation: hoverElevation, + onPressed: () { + onPressed?.call(); + afterPressed?.call(); + }, + child: icon, + ), ), ); } diff --git a/lib/src/widgets/toolbar/search_button.dart b/lib/src/widgets/toolbar/search_button.dart index 52bc2068..fc0dc79f 100644 --- a/lib/src/widgets/toolbar/search_button.dart +++ b/lib/src/widgets/toolbar/search_button.dart @@ -1,11 +1,10 @@ import 'package:flutter/material.dart'; -import '../../models/documents/document.dart'; import '../../models/themes/quill_dialog_theme.dart'; import '../../models/themes/quill_icon_theme.dart'; -import '../../translations/toolbar.i18n.dart'; import '../controller.dart'; import '../toolbar.dart'; +import 'search_dialog.dart'; class SearchButton extends StatelessWidget { const SearchButton({ @@ -16,6 +15,7 @@ class SearchButton extends StatelessWidget { this.iconTheme, this.dialogTheme, this.afterButtonPressed, + this.tooltip, Key? key, }) : super(key: key); @@ -28,6 +28,7 @@ class SearchButton extends StatelessWidget { final QuillDialogTheme? dialogTheme; final VoidCallback? afterButtonPressed; + final String? tooltip; @override Widget build(BuildContext context) { @@ -38,10 +39,11 @@ class SearchButton extends StatelessWidget { iconTheme?.iconUnselectedFillColor ?? (fillColor ?? theme.canvasColor); return QuillIconButton( + tooltip: tooltip, 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), @@ -52,140 +54,10 @@ class SearchButton extends StatelessWidget { Future _onPressedHandler(BuildContext context) async { await showDialog( context: context, - builder: (_) => _SearchDialog( + builder: (_) => SearchDialog( controller: controller, dialogTheme: dialogTheme, text: ''), ).then(_searchSubmitted); } void _searchSubmitted(String? value) {} } - -class _SearchDialog extends StatefulWidget { - const _SearchDialog( - {required this.controller, this.dialogTheme, this.text, Key? key}) - : super(key: key); - - final QuillController controller; - final QuillDialogTheme? dialogTheme; - final String? text; - - @override - _SearchDialogState createState() => _SearchDialogState(); -} - -class _SearchDialogState extends State<_SearchDialog> { - late String _text; - late TextEditingController _controller; - late List? _offsets; - late int _index; - - @override - void initState() { - super.initState(); - _text = widget.text ?? ''; - _offsets = null; - _index = 0; - _controller = TextEditingController(text: _text); - } - - @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}'; - } - } - 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, - ), - if (_offsets != null) - Padding( - padding: const EdgeInsets.all(8), - child: Text(label, textAlign: TextAlign.left), - ), - ], - ), - ), - 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 && _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, - ), - ), - ], - ); - }); - } - - void _moveToPosition() { - widget.controller.updateSelection( - TextSelection( - baseOffset: _offsets![_index], - extentOffset: _offsets![_index] + _text.length), - ChangeSource.LOCAL); - } - - void _textChanged(String value) { - setState(() { - _text = value; - _offsets = null; - _index = 0; - }); - } -} diff --git a/lib/src/widgets/toolbar/search_dialog.dart b/lib/src/widgets/toolbar/search_dialog.dart new file mode 100644 index 00000000..b8ce67ac --- /dev/null +++ b/lib/src/widgets/toolbar/search_dialog.dart @@ -0,0 +1,136 @@ +import 'package:flutter/material.dart'; + +import '../../../translations.dart'; +import '../../models/documents/document.dart'; +import '../../models/themes/quill_dialog_theme.dart'; +import '../controller.dart'; + +class SearchDialog extends StatefulWidget { + const SearchDialog( + {required this.controller, this.dialogTheme, this.text, Key? key}) + : super(key: key); + + final QuillController controller; + final QuillDialogTheme? dialogTheme; + final String? text; + + @override + _SearchDialogState createState() => _SearchDialogState(); +} + +class _SearchDialogState extends State { + late String _text; + late TextEditingController _controller; + late List? _offsets; + late int _index; + + @override + void initState() { + super.initState(); + _text = widget.text ?? ''; + _offsets = null; + _index = 0; + _controller = TextEditingController(text: _text); + } + + @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}'; + } + } + 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, + ), + if (_offsets != null) + Padding( + padding: const EdgeInsets.all(8), + child: Text(label, textAlign: TextAlign.left), + ), + ], + ), + ), + 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 && _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, + ), + ), + ], + ); + }); + } + + void _moveToPosition() { + widget.controller.updateSelection( + TextSelection( + baseOffset: _offsets![_index], + extentOffset: _offsets![_index] + _text.length), + ChangeSource.LOCAL); + } + + void _textChanged(String value) { + setState(() { + _text = value; + _offsets = null; + _index = 0; + }); + } +} diff --git a/lib/src/widgets/toolbar/select_alignment_button.dart b/lib/src/widgets/toolbar/select_alignment_button.dart index c6fb32b3..922b30fc 100644 --- a/lib/src/widgets/toolbar/select_alignment_button.dart +++ b/lib/src/widgets/toolbar/select_alignment_button.dart @@ -4,8 +4,10 @@ import 'package:flutter/material.dart'; import '../../models/documents/attribute.dart'; import '../../models/documents/style.dart'; import '../../models/themes/quill_icon_theme.dart'; +import '../../utils/widgets.dart'; import '../controller.dart'; import '../toolbar.dart'; +import 'enum.dart'; class SelectAlignmentButton extends StatefulWidget { const SelectAlignmentButton({ @@ -17,6 +19,8 @@ class SelectAlignmentButton extends StatefulWidget { this.showRightAlignment, this.showJustifyAlignment, this.afterButtonPressed, + this.tooltips = const {}, + this.padding, Key? key, }) : super(key: key); @@ -29,6 +33,8 @@ class SelectAlignmentButton extends StatefulWidget { final bool? showRightAlignment; final bool? showJustifyAlignment; final VoidCallback? afterButtonPressed; + final Map tooltips; + final EdgeInsetsGeometry? padding; @override _SelectAlignmentButtonState createState() => _SelectAlignmentButtonState(); @@ -74,6 +80,16 @@ class _SelectAlignmentButtonState extends State { if (widget.showRightAlignment!) Attribute.rightAlignment.value!, if (widget.showJustifyAlignment!) Attribute.justifyAlignment.value!, ]; + final _valueToButtons = { + if (widget.showLeftAlignment!) + Attribute.leftAlignment: ToolbarButtons.leftAlignment, + if (widget.showCenterAlignment!) + Attribute.centerAlignment: ToolbarButtons.centerAlignment, + if (widget.showRightAlignment!) + Attribute.rightAlignment: ToolbarButtons.rightAlignment, + if (widget.showJustifyAlignment!) + Attribute.justifyAlignment: ToolbarButtons.justifyAlignment, + }; final theme = Theme.of(context); @@ -86,47 +102,52 @@ class _SelectAlignmentButtonState extends State { mainAxisSize: MainAxisSize.min, children: List.generate(buttonCount, (index) { return Padding( - // ignore: prefer_const_constructors - padding: EdgeInsets.symmetric(horizontal: !kIsWeb ? 1.0 : 5.0), + padding: widget.padding ?? + const EdgeInsets.symmetric(horizontal: !kIsWeb ? 1.0 : 5.0), child: ConstrainedBox( constraints: BoxConstraints.tightFor( width: widget.iconSize * kIconButtonFactor, height: widget.iconSize * kIconButtonFactor, ), - child: RawMaterialButton( - hoverElevation: 0, - highlightElevation: 0, - elevation: 0, - visualDensity: VisualDensity.compact, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular( - widget.iconTheme?.borderRadius ?? 2)), - fillColor: _valueToText[_value] == _valueString[index] - ? (widget.iconTheme?.iconSelectedFillColor ?? - theme.toggleableActiveColor) - : (widget.iconTheme?.iconUnselectedFillColor ?? - theme.canvasColor), - onPressed: () { - _valueAttribute[index] == Attribute.leftAlignment - ? widget.controller - .formatSelection(Attribute.clone(Attribute.align, null)) - : widget.controller.formatSelection(_valueAttribute[index]); - widget.afterButtonPressed?.call(); - }, - child: Icon( - _valueString[index] == Attribute.leftAlignment.value - ? Icons.format_align_left - : _valueString[index] == Attribute.centerAlignment.value - ? Icons.format_align_center - : _valueString[index] == Attribute.rightAlignment.value - ? Icons.format_align_right - : Icons.format_align_justify, - size: widget.iconSize, - color: _valueToText[_value] == _valueString[index] - ? (widget.iconTheme?.iconSelectedColor ?? - theme.primaryIconTheme.color) - : (widget.iconTheme?.iconUnselectedColor ?? - theme.iconTheme.color), + child: UtilityWidgets.maybeTooltip( + message: widget.tooltips[_valueToButtons[_valueAttribute[index]]], + child: RawMaterialButton( + hoverElevation: 0, + highlightElevation: 0, + elevation: 0, + visualDensity: VisualDensity.compact, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular( + widget.iconTheme?.borderRadius ?? 2)), + fillColor: _valueToText[_value] == _valueString[index] + ? (widget.iconTheme?.iconSelectedFillColor ?? + Theme.of(context).primaryColor) + : (widget.iconTheme?.iconUnselectedFillColor ?? + theme.canvasColor), + onPressed: () { + _valueAttribute[index] == Attribute.leftAlignment + ? widget.controller.formatSelection( + Attribute.clone(Attribute.align, null)) + : widget.controller + .formatSelection(_valueAttribute[index]); + widget.afterButtonPressed?.call(); + }, + child: Icon( + _valueString[index] == Attribute.leftAlignment.value + ? Icons.format_align_left + : _valueString[index] == Attribute.centerAlignment.value + ? Icons.format_align_center + : _valueString[index] == + Attribute.rightAlignment.value + ? Icons.format_align_right + : Icons.format_align_justify, + size: widget.iconSize, + color: _valueToText[_value] == _valueString[index] + ? (widget.iconTheme?.iconSelectedColor ?? + theme.primaryIconTheme.color) + : (widget.iconTheme?.iconUnselectedColor ?? + theme.iconTheme.color), + ), ), ), ), diff --git a/lib/src/widgets/toolbar/select_header_style_button.dart b/lib/src/widgets/toolbar/select_header_style_button.dart index ee53b681..986abc22 100644 --- a/lib/src/widgets/toolbar/select_header_style_button.dart +++ b/lib/src/widgets/toolbar/select_header_style_button.dart @@ -4,12 +4,14 @@ import 'package:flutter/material.dart'; import '../../models/documents/attribute.dart'; import '../../models/documents/style.dart'; import '../../models/themes/quill_icon_theme.dart'; +import '../../utils/widgets.dart'; import '../controller.dart'; import '../toolbar.dart'; class SelectHeaderStyleButton extends StatefulWidget { const SelectHeaderStyleButton({ required this.controller, + this.axis = Axis.horizontal, this.iconSize = kDefaultIconSize, this.iconTheme, this.attributes = const [ @@ -19,14 +21,17 @@ class SelectHeaderStyleButton extends StatefulWidget { Attribute.h3, ], this.afterButtonPressed, + this.tooltip, Key? key, }) : super(key: key); final QuillController controller; + final Axis axis; final double iconSize; final QuillIconTheme? iconTheme; final List attributes; final VoidCallback? afterButtonPressed; + final String? tooltip; @override _SelectHeaderStyleButtonState createState() => @@ -67,18 +72,18 @@ class _SelectHeaderStyleButtonState extends State { fontSize: widget.iconSize * 0.7, ); - return Row( - mainAxisSize: MainAxisSize.min, - children: widget.attributes.map((attribute) { - final isSelected = _selectedAttribute == attribute; - return Padding( - // ignore: prefer_const_constructors - padding: EdgeInsets.symmetric(horizontal: !kIsWeb ? 1.0 : 5.0), - child: ConstrainedBox( - constraints: BoxConstraints.tightFor( - width: widget.iconSize * kIconButtonFactor, - height: widget.iconSize * kIconButtonFactor, - ), + final children = widget.attributes.map((attribute) { + final isSelected = _selectedAttribute == attribute; + return Padding( + // ignore: prefer_const_constructors + padding: EdgeInsets.symmetric(horizontal: !kIsWeb ? 1.0 : 5.0), + child: ConstrainedBox( + constraints: BoxConstraints.tightFor( + width: widget.iconSize * kIconButtonFactor, + height: widget.iconSize * kIconButtonFactor, + ), + child: UtilityWidgets.maybeTooltip( + message: widget.tooltip, child: RawMaterialButton( hoverElevation: 0, highlightElevation: 0, @@ -89,7 +94,7 @@ class _SelectHeaderStyleButtonState extends State { widget.iconTheme?.borderRadius ?? 2)), fillColor: isSelected ? (widget.iconTheme?.iconSelectedFillColor ?? - theme.toggleableActiveColor) + Theme.of(context).primaryColor) : (widget.iconTheme?.iconUnselectedFillColor ?? theme.canvasColor), onPressed: () { @@ -111,9 +116,19 @@ class _SelectHeaderStyleButtonState extends State { ), ), ), - ); - }).toList(), - ); + ), + ); + }).toList(); + + return widget.axis == Axis.horizontal + ? Row( + mainAxisSize: MainAxisSize.min, + children: children, + ) + : Column( + mainAxisSize: MainAxisSize.min, + children: children, + ); } void _didChangeEditingValue() { diff --git a/lib/src/widgets/toolbar/toggle_check_list_button.dart b/lib/src/widgets/toolbar/toggle_check_list_button.dart index 037c47cd..6912916b 100644 --- a/lib/src/widgets/toolbar/toggle_check_list_button.dart +++ b/lib/src/widgets/toolbar/toggle_check_list_button.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import '../../models/documents/attribute.dart'; import '../../models/documents/style.dart'; import '../../models/themes/quill_icon_theme.dart'; +import '../../utils/widgets.dart'; import '../controller.dart'; import '../toolbar.dart'; @@ -16,6 +17,7 @@ class ToggleCheckListButton extends StatefulWidget { this.childBuilder = defaultToggleStyleButtonBuilder, this.iconTheme, this.afterButtonPressed, + this.tooltip, Key? key, }) : super(key: key); @@ -32,6 +34,7 @@ class ToggleCheckListButton extends StatefulWidget { final QuillIconTheme? iconTheme; final VoidCallback? afterButtonPressed; + final String? tooltip; @override _ToggleCheckListButtonState createState() => _ToggleCheckListButtonState(); @@ -91,16 +94,19 @@ class _ToggleCheckListButtonState extends State { @override Widget build(BuildContext context) { - return widget.childBuilder( - context, - Attribute.unchecked, - widget.icon, - widget.fillColor, - _isToggled, - _toggleAttribute, - widget.afterButtonPressed, - widget.iconSize, - widget.iconTheme, + return UtilityWidgets.maybeTooltip( + message: widget.tooltip, + child: widget.childBuilder( + context, + Attribute.unchecked, + widget.icon, + widget.fillColor, + _isToggled, + _toggleAttribute, + widget.afterButtonPressed, + widget.iconSize, + widget.iconTheme, + ), ); } diff --git a/lib/src/widgets/toolbar/toggle_style_button.dart b/lib/src/widgets/toolbar/toggle_style_button.dart index b1080a66..53099743 100644 --- a/lib/src/widgets/toolbar/toggle_style_button.dart +++ b/lib/src/widgets/toolbar/toggle_style_button.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import '../../models/documents/attribute.dart'; import '../../models/documents/style.dart'; import '../../models/themes/quill_icon_theme.dart'; +import '../../utils/widgets.dart'; import '../controller.dart'; import '../toolbar.dart'; @@ -28,6 +29,7 @@ class ToggleStyleButton extends StatefulWidget { this.childBuilder = defaultToggleStyleButtonBuilder, this.iconTheme, this.afterButtonPressed, + this.tooltip, Key? key, }) : super(key: key); @@ -46,6 +48,7 @@ class ToggleStyleButton extends StatefulWidget { final QuillIconTheme? iconTheme; final VoidCallback? afterButtonPressed; + final String? tooltip; @override _ToggleStyleButtonState createState() => _ToggleStyleButtonState(); @@ -65,16 +68,19 @@ class _ToggleStyleButtonState extends State { @override Widget build(BuildContext context) { - return widget.childBuilder( - context, - widget.attribute, - widget.icon, - widget.fillColor, - _isToggled, - _toggleAttribute, - widget.afterButtonPressed, - widget.iconSize, - widget.iconTheme, + return UtilityWidgets.maybeTooltip( + message: widget.tooltip, + child: widget.childBuilder( + context, + widget.attribute, + widget.icon, + widget.fillColor, + _isToggled, + _toggleAttribute, + widget.afterButtonPressed, + widget.iconSize, + widget.iconTheme, + ), ); } @@ -99,7 +105,8 @@ class _ToggleStyleButtonState extends State { } bool _getIsToggled(Map attrs) { - if (widget.attribute.key == Attribute.list.key) { + if (widget.attribute.key == Attribute.list.key || + widget.attribute.key == Attribute.script.key) { final attribute = attrs[widget.attribute.key]; if (attribute == null) { return false; @@ -139,7 +146,7 @@ Widget defaultToggleStyleButtonBuilder( final fill = isEnabled ? isToggled == true ? (iconTheme?.iconSelectedFillColor ?? - theme.toggleableActiveColor) //Selected icon fill color + Theme.of(context).primaryColor) //Selected icon fill color : (iconTheme?.iconUnselectedFillColor ?? theme.canvasColor) //Unselected icon fill color : : (iconTheme?.disabledIconFillColor ?? diff --git a/pubspec.yaml b/pubspec.yaml index 5cfb1351..a603ba42 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,32 +1,31 @@ name: flutter_quill description: A rich text editor supporting mobile and web (Demo App @ bulletjournal.us) -version: 6.0.6+1 +version: 7.2.7 #author: bulletjournal homepage: https://bulletjournal.us/home/index.html repository: https://github.com/singerdmx/flutter-quill environment: - sdk: ">=2.12.0 <3.0.0" - flutter: ">=3.0.0" + sdk: ">=2.17.0 <4.0.0" + flutter: ">=3.10.0" dependencies: flutter: sdk: flutter - collection: ^1.16.0 + collection: ^1.17.0 flutter_colorpicker: ^1.0.3 - flutter_keyboard_visibility: ^5.2.0 - quiver: ^3.1.0 - tuple: ^2.0.0 - url_launcher: ^6.1.2 + flutter_keyboard_visibility: ^5.4.0 + quiver: ^3.2.1 + url_launcher: ^6.1.9 pedantic: ^1.11.1 - characters: ^1.2.0 + characters: ^1.2.1 diff_match_patch: ^0.4.1 - i18n_extension: ^5.0.1 - device_info_plus: ^4.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)); + }); + }); +}