Merge remote-tracking branch 'upstream/master'

pull/1272/head
Pramod 2 years ago
commit dc01bc6ab7
  1. 23
      .github/workflows/main.yml
  2. 1
      .gitignore
  3. 1024
      CHANGELOG.md
  4. 119
      README.md
  5. 237
      doc_cn.md
  6. 2
      example/ios/Flutter/AppFrameworkInfo.plist
  7. 2
      example/ios/Podfile
  8. 10
      example/ios/Runner.xcodeproj/project.pbxproj
  9. 2
      example/ios/Runner/Info.plist
  10. 122
      example/lib/pages/home_page.dart
  11. 26
      example/lib/universal_ui/universal_ui.dart
  12. 2
      example/linux/my_application.cc
  13. 4
      example/macos/Flutter/GeneratedPluginRegistrant.swift
  14. 4
      example/pubspec.yaml
  15. 10
      example/windows/runner/Runner.rc
  16. 23
      flutter_quill_extensions/CHANGELOG.md
  17. 72
      flutter_quill_extensions/lib/embeds/builders.dart
  18. 26
      flutter_quill_extensions/lib/embeds/embed_types.dart
  19. 3
      flutter_quill_extensions/lib/embeds/toolbar/camera_button.dart
  20. 3
      flutter_quill_extensions/lib/embeds/toolbar/formula_button.dart
  21. 3
      flutter_quill_extensions/lib/embeds/toolbar/image_button.dart
  22. 1
      flutter_quill_extensions/lib/embeds/toolbar/image_video_utils.dart
  23. 452
      flutter_quill_extensions/lib/embeds/toolbar/media_button.dart
  24. 3
      flutter_quill_extensions/lib/embeds/toolbar/video_button.dart
  25. 2
      flutter_quill_extensions/lib/embeds/widgets/image.dart
  26. 2
      flutter_quill_extensions/lib/embeds/widgets/youtube_video_app.dart
  27. 26
      flutter_quill_extensions/lib/flutter_quill_extensions.dart
  28. 23
      flutter_quill_extensions/lib/shims/dart_ui_fake.dart
  29. 1
      flutter_quill_extensions/lib/shims/dart_ui_real.dart
  30. 18
      flutter_quill_extensions/pubspec.yaml
  31. 1
      lib/extensions.dart
  32. 8
      lib/flutter_quill.dart
  33. 3
      lib/flutter_quill_test.dart
  34. 49
      lib/src/models/documents/attribute.dart
  35. 37
      lib/src/models/documents/document.dart
  36. 22
      lib/src/models/documents/history.dart
  37. 56
      lib/src/models/documents/nodes/line.dart
  38. 48
      lib/src/models/rules/insert.dart
  39. 19
      lib/src/models/structs/doc_change.dart
  40. 9
      lib/src/models/structs/history_changed.dart
  41. 9
      lib/src/models/structs/image_url.dart
  42. 6
      lib/src/models/structs/offset_value.dart
  43. 14
      lib/src/models/structs/optional_size.dart
  44. 9
      lib/src/models/structs/segment_leaf_node.dart
  45. 9
      lib/src/models/structs/vertical_spacing.dart
  46. 14
      lib/src/models/themes/quill_custom_button.dart
  47. 120
      lib/src/models/themes/quill_dialog_theme.dart
  48. 60
      lib/src/test/widget_tester_extension.dart
  49. 1370
      lib/src/translations/toolbar.i18n.dart
  50. 1
      lib/src/utils/cast.dart
  51. 7
      lib/src/utils/embeds.dart
  52. 3
      lib/src/utils/font.dart
  53. 4
      lib/src/utils/platform.dart
  54. 21
      lib/src/utils/widgets.dart
  55. 124
      lib/src/widgets/controller.dart
  56. 59
      lib/src/widgets/default_styles.dart
  57. 35
      lib/src/widgets/delegate.dart
  58. 144
      lib/src/widgets/editor.dart
  59. 9
      lib/src/widgets/embeds.dart
  60. 2
      lib/src/widgets/proxy.dart
  61. 837
      lib/src/widgets/raw_editor.dart
  62. 15
      lib/src/widgets/raw_editor/raw_editor_state_selection_delegate_mixin.dart
  63. 48
      lib/src/widgets/raw_editor/raw_editor_state_text_input_client_mixin.dart
  64. 4
      lib/src/widgets/style_widgets/bullet_point.dart
  65. 6
      lib/src/widgets/style_widgets/number_point.dart
  66. 99
      lib/src/widgets/text_block.dart
  67. 113
      lib/src/widgets/text_line.dart
  68. 103
      lib/src/widgets/text_selection.dart
  69. 314
      lib/src/widgets/toolbar.dart
  70. 71
      lib/src/widgets/toolbar/arrow_indicated_button_list.dart
  71. 8
      lib/src/widgets/toolbar/clear_format_button.dart
  72. 142
      lib/src/widgets/toolbar/color_button.dart
  73. 43
      lib/src/widgets/toolbar/custom_button.dart
  74. 32
      lib/src/widgets/toolbar/enum.dart
  75. 9
      lib/src/widgets/toolbar/history_button.dart
  76. 31
      lib/src/widgets/toolbar/indent_button.dart
  77. 35
      lib/src/widgets/toolbar/link_style_button.dart
  78. 446
      lib/src/widgets/toolbar/link_style_button2.dart
  79. 103
      lib/src/widgets/toolbar/quill_font_family_button.dart
  80. 81
      lib/src/widgets/toolbar/quill_font_size_button.dart
  81. 7
      lib/src/widgets/toolbar/quill_icon_button.dart
  82. 140
      lib/src/widgets/toolbar/search_button.dart
  83. 136
      lib/src/widgets/toolbar/search_dialog.dart
  84. 35
      lib/src/widgets/toolbar/select_alignment_button.dart
  85. 25
      lib/src/widgets/toolbar/select_header_style_button.dart
  86. 8
      lib/src/widgets/toolbar/toggle_check_list_button.dart
  87. 13
      lib/src/widgets/toolbar/toggle_style_button.dart
  88. 23
      pubspec.yaml
  89. 95
      test/bug_fix_test.dart
  90. 290
      test/widgets/controller_test.dart
  91. 82
      test/widgets/editor_test.dart

@ -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

1
.gitignore vendored

@ -29,6 +29,7 @@
.pub-cache/ .pub-cache/
.pub/ .pub/
build/ build/
coverage/
# Android related # Android related
**/android/**/gradle-wrapper.jar **/android/**/gradle-wrapper.jar

File diff suppressed because it is too large Load Diff

@ -1,4 +1,4 @@
<p align="center"> <p align="center" style="background-color:#282C34">
<img src="https://user-images.githubusercontent.com/10923085/119221946-2de89000-baf2-11eb-8285-68168a78c658.png" width="600px"> <img src="https://user-images.githubusercontent.com/10923085/119221946-2de89000-baf2-11eb-8285-68168a78c658.png" width="600px">
</p> </p>
<h1 align="center">A rich text editor for Flutter</h1> <h1 align="center">A rich text editor for Flutter</h1>
@ -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-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 [github-forks-link]: https://github.com/singerdmx/flutter-quill/network/members
---
FlutterQuill is a rich text editor and a [Quill] component for [Flutter]. 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. 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
<p float="left">
<img width="400" alt="1" src="https://user-images.githubusercontent.com/122956/103142422-9bb19c80-46b7-11eb-83e4-dd0538a9236e.png">
<img width="400" alt="1" src="https://user-images.githubusercontent.com/122956/103142455-0531ab00-46b8-11eb-89f8-26a77de9227f.png">
</p>
<p float="left">
<img width="400" alt="1" src="https://user-images.githubusercontent.com/122956/102963021-f28f5a00-449c-11eb-8f5f-6e9dd60844c4.png">
<img width="400" alt="1" src="https://user-images.githubusercontent.com/122956/102977404-c9c88e00-44b7-11eb-9423-b68f3b30b0e0.png">
</p>
---
## Usage ## Usage
See the `example` directory for a minimal example of how to use FlutterQuill. You typically just need to instantiate a controller: 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(); QuillController _controller = QuillController.basic();
``` ```
@ -54,6 +69,7 @@ Column(
], ],
) )
``` ```
Check out [Sample Page] for advanced usage. Check out [Sample Page] for advanced usage.
## Input / Output ## 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.toDelta()` to extract the deltas.
* Use `_controller.document.toPlainText()` to extract plain text. * 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()); 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: To open a FlutterQuill editor with an existing JSON representation that you've previously stored, you can do something like this:
``` ```dart
var myJSON = jsonDecode(incomingJSONText); var myJSON = jsonDecode(r'{"insert":"hello\n"}');
_controller = QuillController( _controller = QuillController(
document: Document.fromJson(myJSON), document: Document.fromJson(myJSON),
selection: TextSelection.collapsed(offset: 0)); selection: TextSelection.collapsed(offset: 0),
);
``` ```
## Web ## Web
@ -85,38 +102,44 @@ _controller = QuillController(
For web development, use `flutter config --enable-web` for flutter or use [ReactQuill] for React. 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). 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 ## 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 ## 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. [Sample Page] provides sample code for advanced usage and configuration.
### Font Size ### 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<String, String>` consisting of a `String` title for the font size and a `String` value for the font size. Example: When enabled, the default font-size values can be modified via _optional_ `fontSizeValues`. `fontSizeValues` accepts a `Map<String, String>` 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'} 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'} fontSizeValues: const {'Small': '8', 'Medium': '24.5', 'Large': '46', 'Clear': '0'}
``` ```
### Font Family ### 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/). 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 ### Custom Buttons
You may add custom buttons to the _end_ of the toolbar, via the `customButtons` option, which is a `List` of `QuillCustomButton`. 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 To add an Icon, we should use a new QuillCustomButton class
```
```dart
QuillCustomButton( QuillCustomButton(
icon:Icons.ac_unit, icon:Icons.ac_unit,
onTap: () { 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: Each `QuillCustomButton` is used as part of the `customButtons` option as follows:
```
```dart
QuillToolbar.basic( QuillToolbar.basic(
(...), (...),
customButtons: [ customButtons: [
@ -153,7 +177,6 @@ QuillToolbar.basic(
] ]
``` ```
## Embed Blocks ## 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). 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).
@ -162,7 +185,7 @@ Provide a list of embed
### Using the embed blocks from `flutter_quill_extensions` ### Using the embed blocks from `flutter_quill_extensions`
``` ```dart
import 'package:flutter_quill_extensions/flutter_quill_extensions.dart'; import 'package:flutter_quill_extensions/flutter_quill_extensions.dart';
QuillEditor.basic( QuillEditor.basic(
@ -176,12 +199,11 @@ QuillToolbar.basic(
); );
``` ```
### Custom Size Image for Mobile ### Custom Size Image for Mobile
Define `mobileWidth`, `mobileHeight`, `mobileMargin`, `mobileAlignment` as follows: Define `mobileWidth`, `mobileHeight`, `mobileMargin`, `mobileAlignment` as follows:
```
```dart
{ {
"insert": { "insert": {
"image": "https://user-images.githubusercontent.com/122956/72955931-ccc07900-3d52-11ea-89b1-d468a6e2aa2b.png" "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! Don't forget to add this method to the `QuillEditor` after that!
```dart ```dart
class NotesEmbedBuilder implements EmbedBuilder { class NotesEmbedBuilder extends EmbedBuilder {
NotesEmbedBuilder({required this.addEditNote}); NotesEmbedBuilder({required this.addEditNote});
Future<void> Function(BuildContext context, {Document? document}) addEditNote; Future<void> Function(BuildContext context, {Document? document}) addEditNote;
@ -233,6 +255,7 @@ class NotesEmbedBuilder implements EmbedBuilder {
QuillController controller, QuillController controller,
Embed node, Embed node,
bool readOnly, bool readOnly,
bool inline,
) { ) {
final notes = NotesBlockEmbed(node.value.data).document; final notes = NotesBlockEmbed(node.value.data).document;
@ -297,7 +320,7 @@ Future<void> _addEditNote(BuildContext context, {Document? document}) async {
final length = controller.selection.extentOffset - index; final length = controller.selection.extentOffset - index;
if (isEditing) { if (isEditing) {
final offset = getEmbedNode(controller, controller.selection.start).item1; final offset = getEmbedNode(controller, controller.selection.start).offset;
controller.replaceText( controller.replaceText(
offset, 1, block, TextSelection.collapsed(offset: offset)); offset, 1, block, TextSelection.collapsed(offset: offset));
} else { } else {
@ -312,9 +335,8 @@ And voila, we have a custom widget inside of the rich text editor!
<img width="400" alt="1" src="https://i.imgur.com/yBTPYeS.png"> <img width="400" alt="1" src="https://i.imgur.com/yBTPYeS.png">
</p> </p>
> For more info and a video example, see the [PR of this feature](https://github.com/singerdmx/flutter-quill/pull/877) > 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)
> For more details, check out [this YouTube video](https://youtu.be/pI5p5j7cfHc)
### Translation ### Translation
@ -325,13 +347,15 @@ QuillToolbar(locale: Locale('fr'), ...)
QuillEditor(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('en')`
* `Locale('ar')` * `Locale('ar')`
* `Locale('cs')`
* `Locale('de')` * `Locale('de')`
* `Locale('da')` * `Locale('da')`
* `Locale('fr')` * `Locale('fr')`
* `Locale('he')`
* `Locale('zh', 'cn')` * `Locale('zh', 'cn')`
* `Locale('zh', 'hk')` * `Locale('zh', 'hk')`
* `Locale('ko')` * `Locale('ko')`
@ -344,29 +368,44 @@ Currently, translations are available for these 22 locales:
* `Locale('pl')` * `Locale('pl')`
* `Locale('vi')` * `Locale('vi')`
* `Locale('id')` * `Locale('id')`
* `Locale('it')`
* `Locale('ms')`
* `Locale('nl')` * `Locale('nl')`
* `Locale('no')` * `Locale('no')`
* `Locale('fa')` * `Locale('fa')`
* `Locale('hi')` * `Locale('hi')`
* `Locale('sr')` * `Locale('sr')`
* `Locale('ja')`
#### Contributing to translations #### 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! 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
<p float="left"> Having your document stored in Quill Delta format is sometimes not enough. Often you'll need to convert
<img width="400" alt="1" src="https://user-images.githubusercontent.com/122956/103142422-9bb19c80-46b7-11eb-83e4-dd0538a9236e.png"> it to other formats such as HTML in order to publish it, or send an email. One option is to use
<img width="400" alt="1" src="https://user-images.githubusercontent.com/122956/103142455-0531ab00-46b8-11eb-89f8-26a77de9227f.png"> [vsc_quill_delta_to_html](https://pub.dev/packages/vsc_quill_delta_to_html) to convert your document
</p> 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
<p float="left"> 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.
<img width="400" alt="1" src="https://user-images.githubusercontent.com/122956/102963021-f28f5a00-449c-11eb-8f5f-6e9dd60844c4.png">
<img width="400" alt="1" src="https://user-images.githubusercontent.com/122956/102977404-c9c88e00-44b7-11eb-9423-b68f3b30b0e0.png"> Import the test utilities in your test file:
</p>
```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 ## 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" "https://user-images.githubusercontent.com/122956/72955931-ccc07900-3d52-11ea-89b1-d468a6e2aa2b.png"
width="150px" height="150px"></a> width="150px" height="150px"></a>
---
[Chinese Documentation](./doc_cn.md)
[Quill]: https://quilljs.com/docs/formats [Quill]: https://quilljs.com/docs/formats
[Flutter]: https://github.com/flutter/flutter [Flutter]: https://github.com/flutter/flutter
[FlutterQuill]: https://pub.dev/packages/flutter_quill [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 [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 [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 [Code Introduction]: https://github.com/singerdmx/flutter-quill/blob/master/CodeIntroduction.md
<hr/>
[中文文档](./doc_cn.md)

@ -1,4 +1,4 @@
<p align="center"> <p align="center" style="background-color:#282C34">
<img src="https://user-images.githubusercontent.com/10923085/119221946-2de89000-baf2-11eb-8285-68168a78c658.png" width="600px"> <img src="https://user-images.githubusercontent.com/10923085/119221946-2de89000-baf2-11eb-8285-68168a78c658.png" width="600px">
</p> </p>
<h1 align="center">支持 Flutter 平台的富文本编辑器</h1> <h1 align="center">支持 Flutter 平台的富文本编辑器</h1>
@ -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-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 [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) 来进行讨论
该库是为移动平台构建的 “ 所见即所得 ” 的富文本编辑器,同时我们还正在对 Web 平台进行兼容。查看我们的 [Youtube 播放列表] 或 [代码介绍] 以了解代码的详细内容。你可以加入我们的 [Slack Group] 来进行讨论。 示例 `App` : [BULLET JOURNAL](https://bulletjournal.us/home/index.html)
Demo App: https://bulletjournal.us/home/index.html `Pub` : [FlutterQuill](https://pub.dev/packages/flutter_quill)
Pub: https://pub.dev/packages/flutter_quill ## 效果展示
<p float="left">
<img width="400" alt="1" src="https://user-images.githubusercontent.com/122956/103142422-9bb19c80-46b7-11eb-83e4-dd0538a9236e.png">
<img width="400" alt="1" src="https://user-images.githubusercontent.com/122956/103142455-0531ab00-46b8-11eb-89f8-26a77de9227f.png">
</p>
<p float="left">
<img width="400" alt="1" src="https://user-images.githubusercontent.com/122956/102963021-f28f5a00-449c-11eb-8f5f-6e9dd60844c4.png">
<img width="400" alt="1" src="https://user-images.githubusercontent.com/122956/102977404-c9c88e00-44b7-11eb-9423-b68f3b30b0e0.png">
</p>
---
## 用法 ## 用法
查看 `示例` 目录来学习 FlutterQuill 最简单的使用方法,你通常只需要实例化一个控制器: 查看 `示例` 目录来学习 `FlutterQuill` 最简单的使用方法,你通常只需要一个控制器实例
``` ```dart
QuillController _controller = QuillController.basic(); QuillController _controller = QuillController.basic();
``` ```
然后在你的 App 中嵌入工具栏和编辑器,例如: 然后在你的 `App` 中嵌入工具栏 `QuillToolbar` 和编辑器 `QuillEditor` ,如:
```dart ```dart
Column( Column(
@ -47,75 +64,88 @@ Column(
child: Container( child: Container(
child: QuillEditor.basic( child: QuillEditor.basic(
controller: _controller, 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()` 获取增量。 该库使用 [Quill 格式](https://quilljs.com/docs/formats) 作为内部数据格式
* 使用 `_controller.document.toPlainText()` 获取纯文本。
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()); var json = jsonEncode(_controller.document.toDelta().toJson());
``` ```
然后你就可以将其存储。 要将 `FlutterQuill` 使用之前存储的 `JSON` 数据,请执行以下操作:
想要 FlutterQuill 编辑器使用你之前存储的 JSON 数据,请执行以下操作: ```dart
var myJSON = jsonDecode(r'{"insert":"hello\n"}');
```
var myJSON = jsonDecode(incomingJSONText);
_controller = QuillController( _controller = QuillController(
document: Document.fromJson(myJSON), document: Document.fromJson(myJSON),
selection: TextSelection.collapsed(offset: 0)); selection: TextSelection.collapsed(offset: 0),
);
``` ```
## Web ## Web 端
对于 web 开发,请执行 `flutter config --enable-web` 来获取对 flutter 的支持或使用 [ReactQuill] 获取对 React 的支持。
对于 `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` 开发需要提供 `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` 开发还需要提供 `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>`,其中包含一个 `String` 类型的标题和一个 `String` 类型的字号。 例子: 在工具栏中提供了选择字号的下拉菜单,可通过 `showFontSize` 来启用或禁用
```
fontSizeValues: const {'Small': '8', 'Medium': '24.5', 'Large': '46'} 启用后,可以通过 *可选的* `fontSizeValues` 属性修改默认字号
`fontSizeValues` 接收一个 `Map<String, String>`,其中包含一个 `String` 类型的标题和一个 `String` 类型的字号,如:
```dart
fontSizeValues: const {'小字号': '8', '中字号': '24.5', '大字号': '46'}
``` ```
字体大小可以使用 `0` 值清除,例如: 字体大小可以使用 `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( QuillCustomButton(
icon:Icons.ac_unit, icon:Icons.ac_unit,
onTap: () { onTap: () {
@ -124,8 +154,9 @@ fontSizeValues: const {'Small': '8', 'Medium': '24.5', 'Large': '46', 'Clear': '
), ),
``` ```
每个 `QuillCustomButton` 都是 `customButtons` 可选参数的一部分,如下所示: 每个 `QuillCustomButton` 都是 `customButtons` 可选参数的一部分,如:
```
```dart
QuillToolbar.basic( QuillToolbar.basic(
(...), (...),
customButtons: [ customButtons: [
@ -135,14 +166,12 @@ QuillToolbar.basic(
debugPrint('snowflake1'); debugPrint('snowflake1');
} }
), ),
QuillCustomButton( QuillCustomButton(
icon:Icons.ac_unit, icon:Icons.ac_unit,
onTap: () { onTap: () {
debugPrint('snowflake2'); debugPrint('snowflake2');
} }
), ),
QuillCustomButton( QuillCustomButton(
icon:Icons.ac_unit, icon:Icons.ac_unit,
onTap: () { 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`如下: 定义`mobileWidth`、`mobileHeight`、`mobileMargin`、`mobileAlignment`如下:
```
```dart
{ {
"insert": { "insert": {
"image": "https://user-images.githubusercontent.com/122956/72955931-ccc07900-3d52-11ea-89b1-d468a6e2aa2b.png" "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 ```dart
class NotesBlockEmbed extends CustomBlockEmbed { class NotesBlockEmbed extends CustomBlockEmbed {
@ -188,19 +241,28 @@ class NotesBlockEmbed extends CustomBlockEmbed {
} }
``` ```
然后,我们需要将这个 “notes” 类型映射到 widget 中。在例子中,我使用 `ListTile` 来显示它,使用 `onTap` 方法俩编辑内容,另外不要忘记将此方法添加到 `QuillEditor` 中。 然后,我们需要将这个 `notes` 类型映射到其想渲染出的 `Widget`
在这里我们使用 `ListTile` 来渲染它,并使用 `onTap` 方法来编辑内容,最后不要忘记将此方法添加到 `QuillEditor`
```dart ```dart
Widget customElementsEmbedBuilder( class NotesEmbedBuilder extends EmbedBuilder {
NotesEmbedBuilder({required this.addEditNote});
Future<void> Function(BuildContext context, {Document? document}) addEditNote;
@override
String get key => 'notes';
@override
Widget build(
BuildContext context, BuildContext context,
QuillController controller, QuillController controller,
CustomBlockEmbed block, Embed node,
bool readOnly, bool readOnly,
void Function(GlobalKey videoContainerKey)? onVideoInit, bool inline,
) { ) {
switch (block.type) { final notes = NotesBlockEmbed(node.value.data).document;
case 'notes':
final notes = NotesBlockEmbed(block.data).document;
return Material( return Material(
color: Colors.transparent, color: Colors.transparent,
@ -211,20 +273,22 @@ Widget customElementsEmbedBuilder(
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
leading: const Icon(Icons.notes), leading: const Icon(Icons.notes),
onTap: () => _addEditNote(context, document: notes), onTap: () => addEditNote(context, document: notes),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(10),
side: const BorderSide(color: Colors.grey), side: const BorderSide(color: Colors.grey),
), ),
), ),
); );
default:
return const SizedBox();
} }
} }
``` ```
然后,编写一个方法来添加/编辑内容,`showDialog` 方法显示 Quill 编辑器以编辑内容,用户编辑完成后,需要检查文档是否有内容,如果有内容,在 `CustomBlockEmbed` 中添加/编辑 `NotesBlockEmbed`(注意,如果没有在 `NotesBlockEmbed` 中传递 `CustomBlockEmbed` ,编辑将不会生效)。 最后我们编写一个方法来添加或编辑内容
`showDialog` 方法先显示 `Quill` 编辑器以让用户编辑内容,编辑完成后,我们需要检查文档是否有内容,若有则在 `BlockEmbed.custom` 传入添加或编辑了的 `NotesBlockEmbed`
注意,如果我们没有在 `BlockEmbed.custom` 传如我们所自定义的 `CustomBlockEmbed` ,那么编辑将不会生效
```dart ```dart
Future<void> _addEditNote(BuildContext context, {Document? document}) async { Future<void> _addEditNote(BuildContext context, {Document? document}) async {
@ -265,7 +329,7 @@ Future<void> _addEditNote(BuildContext context, {Document? document}) async {
final length = controller.selection.extentOffset - index; final length = controller.selection.extentOffset - index;
if (isEditing) { if (isEditing) {
final offset = getEmbedNode(controller, controller.selection.start).item1; final offset = getEmbedNode(controller, controller.selection.start).offset;
controller.replaceText( controller.replaceText(
offset, 1, block, TextSelection.collapsed(offset: offset)); offset, 1, block, TextSelection.collapsed(offset: offset));
} else { } else {
@ -274,34 +338,35 @@ Future<void> _addEditNote(BuildContext context, {Document? document}) async {
} }
``` ```
这样我们就成功的在富文本编辑器中添加了一个自定义小组件 这样我们就成功的在富文本编辑器中添加了一个自定义小组件
<p float="left"> <p float="left">
<img width="400" alt="1" src="https://i.imgur.com/yBTPYeS.png"> <img width="400" alt="1" src="https://i.imgur.com/yBTPYeS.png">
</p> </p>
> 更多信息和视频示例,请参阅 [PR of this feature](https://github.com/singerdmx/flutter-quill/pull/877) > 1. 更多信息和视频示例,请参阅 [这个特性的 PR](https://github.com/singerdmx/flutter-quill/pull/877)
> 2. 有关更多详细信息,请查看 [这个 Youtube 视频](https://youtu.be/pI5p5j7cfHc)
> 有关更多详细信息,请查看 [this YouTube video](https://youtu.be/pI5p5j7cfHc)
### 翻译 ### 翻译
该库为 quill 工具栏和编辑器提供翻译,除非您设置自己的语言环境,否则它将遵循系统语言环境: 该库为 `QuillToolbar``QuillEditor` 提供了部分翻译,且若你未设置自己的语言环境,则它将使用系统语言环境:
```dart ```dart
QuillToolbar(locale: Locale('fr'), ...) QuillToolbar(locale: Locale('fr'), ...)
QuillEditor(locale: Locale('fr'), ...) QuillEditor(locale: Locale('fr'), ...)
``` ```
目前,可提供以下 22 种语言环境的翻译: 目前,可提供以下 27 种语言环境的翻译:
* `Locale('en')` * `Locale('en')`
* `Locale('ar')` * `Locale('ar')`
* `Locale('cs')`
* `Locale('de')` * `Locale('de')`
* `Locale('da')` * `Locale('da')`
* `Locale('fr')` * `Locale('fr')`
* `Locale('zh', 'CN')` * `Locale('he')`
* `Locale('zh', 'HK')` * `Locale('zh', 'cn')`
* `Locale('zh', 'hk')`
* `Locale('ko')` * `Locale('ko')`
* `Locale('ru')` * `Locale('ru')`
* `Locale('es')` * `Locale('es')`
@ -312,42 +377,38 @@ QuillEditor(locale: Locale('fr'), ...)
* `Locale('pl')` * `Locale('pl')`
* `Locale('vi')` * `Locale('vi')`
* `Locale('id')` * `Locale('id')`
* `Locale('it')`
* `Locale('ms')`
* `Locale('nl')` * `Locale('nl')`
* `Locale('no')` * `Locale('no')`
* `Locale('fa')` * `Locale('fa')`
* `Locale('hi')` * `Locale('hi')`
* `Locale('sr')` * `Locale('sr')`
* `Locale('jp')`
#### 贡献翻译 #### 贡献翻译
翻译文件位于 [toolbar.i18n.dart](lib/src/translations/toolbar.i18n.dart)。 随意贡献您自己的翻译,只需复制英文翻译映射并将值替换为您的翻译。 然后打开一个拉取请求,这样每个人都可以从您的翻译中受益!
--- 翻译文件位于 [toolbar.i18n.dart](lib/src/translations/toolbar.i18n.dart)
<p float="left"> 随意贡献你自己的翻译,只需复制英文翻译映射并将值替换为你的翻译即可
<img width="400" alt="1" src="https://user-images.githubusercontent.com/122956/103142422-9bb19c80-46b7-11eb-83e4-dd0538a9236e.png">
<img width="400" alt="1" src="https://user-images.githubusercontent.com/122956/103142455-0531ab00-46b8-11eb-89f8-26a77de9227f.png">
</p>
然后打开一个拉取请求,这样每个人都可以从你的翻译中受益!
<p float="left"> ### 转化至 HTML
<img width="400" alt="1" src="https://user-images.githubusercontent.com/122956/102963021-f28f5a00-449c-11eb-8f5f-6e9dd60844c4.png">
<img width="400" alt="1" src="https://user-images.githubusercontent.com/122956/102977404-c9c88e00-44b7-11eb-9423-b68f3b30b0e0.png">
</p>
将你的文档转为 `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` 部分
---
## 赞助
<a href="https://bulletjournal.us/home/index.html"> <a href="https://bulletjournal.us/home/index.html">
<img src= <img src=
"https://user-images.githubusercontent.com/122956/72955931-ccc07900-3d52-11ea-89b1-d468a6e2aa2b.png" "https://user-images.githubusercontent.com/122956/72955931-ccc07900-3d52-11ea-89b1-d468a6e2aa2b.png"
width="150px" height="150px"></a> width="150px" height="150px"></a>
[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

@ -21,6 +21,6 @@
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>1.0</string> <string>1.0</string>
<key>MinimumOSVersion</key> <key>MinimumOSVersion</key>
<string>9.0</string> <string>11.0</string>
</dict> </dict>
</plist> </plist>

@ -1,5 +1,5 @@
# Uncomment this line to define a global platform for your project # 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. # CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true' ENV['COCOAPODS_DISABLE_STATS'] = 'true'

@ -3,7 +3,7 @@
archiveVersion = 1; archiveVersion = 1;
classes = { classes = {
}; };
objectVersion = 50; objectVersion = 54;
objects = { objects = {
/* Begin PBXBuildFile section */ /* Begin PBXBuildFile section */
@ -210,6 +210,7 @@
/* Begin PBXShellScriptBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */
3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
isa = PBXShellScriptBuildPhase; isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
); );
@ -241,6 +242,7 @@
}; };
9740EEB61CF901F6004384FC /* Run Script */ = { 9740EEB61CF901F6004384FC /* Run Script */ = {
isa = PBXShellScriptBuildPhase; isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
); );
@ -351,7 +353,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES; GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 9.0; IPHONEOS_DEPLOYMENT_TARGET = 11.0;
MTL_ENABLE_DEBUG_INFO = NO; MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos; SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos; SUPPORTED_PLATFORMS = iphoneos;
@ -433,7 +435,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES; GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 9.0; IPHONEOS_DEPLOYMENT_TARGET = 11.0;
MTL_ENABLE_DEBUG_INFO = YES; MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES; ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos; SDKROOT = iphoneos;
@ -482,7 +484,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES; GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 9.0; IPHONEOS_DEPLOYMENT_TARGET = 11.0;
MTL_ENABLE_DEBUG_INFO = NO; MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos; SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos; SUPPORTED_PLATFORMS = iphoneos;

@ -45,5 +45,7 @@
<false/> <false/>
<key>CADisableMinimumFrameDurationOnPhone</key> <key>CADisableMinimumFrameDurationOnPhone</key>
<true/> <true/>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
</dict> </dict>
</plist> </plist>

@ -12,11 +12,16 @@ import 'package:flutter_quill/flutter_quill.dart' hide Text;
import 'package:flutter_quill_extensions/flutter_quill_extensions.dart'; import 'package:flutter_quill_extensions/flutter_quill_extensions.dart';
import 'package:path/path.dart'; import 'package:path/path.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:tuple/tuple.dart';
import '../universal_ui/universal_ui.dart'; import '../universal_ui/universal_ui.dart';
import 'read_only_page.dart'; import 'read_only_page.dart';
enum _SelectionType {
none,
word,
// line,
}
class HomePage extends StatefulWidget { class HomePage extends StatefulWidget {
@override @override
_HomePageState createState() => _HomePageState(); _HomePageState createState() => _HomePageState();
@ -25,6 +30,14 @@ class HomePage extends StatefulWidget {
class _HomePageState extends State<HomePage> { class _HomePageState extends State<HomePage> {
QuillController? _controller; QuillController? _controller;
final FocusNode _focusNode = FocusNode(); final FocusNode _focusNode = FocusNode();
Timer? _selectAllTimer;
_SelectionType _selectionType = _SelectionType.none;
@override
void dispose() {
_selectAllTimer?.cancel();
super.dispose();
}
@override @override
void initState() { void initState() {
@ -78,29 +91,74 @@ class _HomePageState extends State<HomePage> {
color: Colors.grey.shade800, color: Colors.grey.shade800,
child: _buildMenuBar(context), child: _buildMenuBar(context),
), ),
body: RawKeyboardListener( body: _buildWelcomeEditor(context),
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);
} }
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;
} }
},
child: _buildWelcomeEditor(context), 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) { Widget _buildWelcomeEditor(BuildContext context) {
var quillEditor = QuillEditor( Widget quillEditor = QuillEditor(
controller: _controller!, controller: _controller!,
scrollController: ScrollController(), scrollController: ScrollController(),
scrollable: true, scrollable: true,
@ -108,9 +166,13 @@ class _HomePageState extends State<HomePage> {
autoFocus: false, autoFocus: false,
readOnly: false, readOnly: false,
placeholder: 'Add content', placeholder: 'Add content',
enableSelectionToolbar: isMobile(),
expands: false, expands: false,
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
onImagePaste: _onImagePaste, onImagePaste: _onImagePaste,
onTapUp: (details, p1) {
return _onTripleClickSelection();
},
customStyles: DefaultStyles( customStyles: DefaultStyles(
h1: DefaultTextBlockStyle( h1: DefaultTextBlockStyle(
const TextStyle( const TextStyle(
@ -119,8 +181,8 @@ class _HomePageState extends State<HomePage> {
height: 1.15, height: 1.15,
fontWeight: FontWeight.w300, fontWeight: FontWeight.w300,
), ),
const Tuple2(16, 0), const VerticalSpacing(16, 0),
const Tuple2(0, 0), const VerticalSpacing(0, 0),
null), null),
sizeSmall: const TextStyle(fontSize: 9), sizeSmall: const TextStyle(fontSize: 9),
), ),
@ -140,6 +202,9 @@ class _HomePageState extends State<HomePage> {
placeholder: 'Add content', placeholder: 'Add content',
expands: false, expands: false,
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
onTapUp: (details, p1) {
return _onTripleClickSelection();
},
customStyles: DefaultStyles( customStyles: DefaultStyles(
h1: DefaultTextBlockStyle( h1: DefaultTextBlockStyle(
const TextStyle( const TextStyle(
@ -148,12 +213,15 @@ class _HomePageState extends State<HomePage> {
height: 1.15, height: 1.15,
fontWeight: FontWeight.w300, fontWeight: FontWeight.w300,
), ),
const Tuple2(16, 0), const VerticalSpacing(16, 0),
const Tuple2(0, 0), const VerticalSpacing(0, 0),
null), null),
sizeSmall: const TextStyle(fontSize: 9), sizeSmall: const TextStyle(fontSize: 9),
), ),
embedBuilders: defaultEmbedBuildersWeb); embedBuilders: [
...defaultEmbedBuildersWeb,
NotesEmbedBuilder(addEditNote: _addEditNote),
]);
} }
var toolbar = QuillToolbar.basic( var toolbar = QuillToolbar.basic(
controller: _controller!, controller: _controller!,
@ -347,6 +415,7 @@ class _HomePageState extends State<HomePage> {
} }
void _readOnly() { void _readOnly() {
Navigator.pop(super.context);
Navigator.push( Navigator.push(
super.context, super.context,
MaterialPageRoute( MaterialPageRoute(
@ -402,7 +471,8 @@ class _HomePageState extends State<HomePage> {
final length = controller.selection.extentOffset - index; final length = controller.selection.extentOffset - index;
if (isEditing) { if (isEditing) {
final offset = getEmbedNode(controller, controller.selection.start).item1; final offset =
getEmbedNode(controller, controller.selection.start).offset;
controller.replaceText( controller.replaceText(
offset, 1, block, TextSelection.collapsed(offset: offset)); offset, 1, block, TextSelection.collapsed(offset: offset));
} else { } else {
@ -411,7 +481,7 @@ class _HomePageState extends State<HomePage> {
} }
} }
class NotesEmbedBuilder implements EmbedBuilder { class NotesEmbedBuilder extends EmbedBuilder {
NotesEmbedBuilder({required this.addEditNote}); NotesEmbedBuilder({required this.addEditNote});
Future<void> Function(BuildContext context, {Document? document}) addEditNote; Future<void> Function(BuildContext context, {Document? document}) addEditNote;
@ -425,6 +495,8 @@ class NotesEmbedBuilder implements EmbedBuilder {
QuillController controller, QuillController controller,
Embed node, Embed node,
bool readOnly, bool readOnly,
bool inline,
TextStyle textStyle,
) { ) {
final notes = NotesBlockEmbed(node.value.data).document; final notes = NotesBlockEmbed(node.value.data).document;

@ -5,7 +5,7 @@ import 'package:flutter/foundation.dart';
import 'package:flutter_quill/flutter_quill.dart'; import 'package:flutter_quill/flutter_quill.dart';
import 'package:flutter_quill_extensions/flutter_quill_extensions.dart'; import 'package:flutter_quill_extensions/flutter_quill_extensions.dart';
import 'package:universal_html/html.dart' as html; 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 '../widgets/responsive_widget.dart';
import 'fake_ui.dart' if (dart.library.html) 'real_ui.dart' as ui_instance; import 'fake_ui.dart' if (dart.library.html) 'real_ui.dart' as ui_instance;
@ -27,7 +27,7 @@ class UniversalUI {
var ui = UniversalUI(); var ui = UniversalUI();
class ImageEmbedBuilderWeb implements EmbedBuilder { class ImageEmbedBuilderWeb extends EmbedBuilder {
@override @override
String get key => BlockEmbed.imageType; String get key => BlockEmbed.imageType;
@ -37,6 +37,8 @@ class ImageEmbedBuilderWeb implements EmbedBuilder {
QuillController controller, QuillController controller,
Embed node, Embed node,
bool readOnly, bool readOnly,
bool inline,
TextStyle textStyle,
) { ) {
final imageUrl = node.value.data; final imageUrl = node.value.data;
if (isImageBase64(imageUrl)) { if (isImageBase64(imageUrl)) {
@ -44,8 +46,12 @@ class ImageEmbedBuilderWeb implements EmbedBuilder {
return const SizedBox(); return const SizedBox();
} }
final size = MediaQuery.of(context).size; final size = MediaQuery.of(context).size;
UniversalUI().platformViewRegistry.registerViewFactory( UniversalUI().platformViewRegistry.registerViewFactory(imageUrl, (viewId) {
imageUrl, (viewId) => html.ImageElement()..src = imageUrl); return html.ImageElement()
..src = imageUrl
..style.height = 'auto'
..style.width = 'auto';
});
return Padding( return Padding(
padding: EdgeInsets.only( padding: EdgeInsets.only(
right: ResponsiveWidget.isMediumScreen(context) right: ResponsiveWidget.isMediumScreen(context)
@ -64,13 +70,19 @@ class ImageEmbedBuilderWeb implements EmbedBuilder {
} }
} }
class VideoEmbedBuilderWeb implements EmbedBuilder { class VideoEmbedBuilderWeb extends EmbedBuilder {
@override @override
String get key => BlockEmbed.videoType; String get key => BlockEmbed.videoType;
@override @override
Widget build(BuildContext context, QuillController controller, Embed node, Widget build(
bool readOnly) { BuildContext context,
QuillController controller,
Embed node,
bool readOnly,
bool inline,
TextStyle textStyle,
) {
var videoUrl = node.value.data; var videoUrl = node.value.data;
if (videoUrl.contains('youtube.com') || videoUrl.contains('youtu.be')) { if (videoUrl.contains('youtube.com') || videoUrl.contains('youtu.be')) {
final youtubeID = YoutubePlayer.convertUrlToId(videoUrl); final youtubeID = YoutubePlayer.convertUrlToId(videoUrl);

@ -27,7 +27,7 @@ static void my_application_activate(GApplication* application) {
// in case the window manager does more exotic layout, e.g. tiling. // 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 running on Wayland assume the header bar will work (may need changing
// if future cases occur). // if future cases occur).
gboolean use_header_bar = TRUE; gboolean use_header_bar = FALSE;
#ifdef GDK_WINDOWING_X11 #ifdef GDK_WINDOWING_X11
GdkScreen *screen = gtk_window_get_screen(window); GdkScreen *screen = gtk_window_get_screen(window);
if (GDK_IS_X11_SCREEN(screen)) { if (GDK_IS_X11_SCREEN(screen)) {

@ -5,9 +5,9 @@
import FlutterMacOS import FlutterMacOS
import Foundation import Foundation
import device_info_plus_macos import device_info_plus
import pasteboard import pasteboard
import path_provider_macos import path_provider_foundation
import url_launcher_macos import url_launcher_macos
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {

@ -29,8 +29,8 @@ dependencies:
# Use with the CupertinoIcons class for iOS style icons. # Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^1.0.4 cupertino_icons: ^1.0.4
path_provider: ^2.0.9 path_provider: ^2.0.9
filesystem_picker: ^2.0.0 filesystem_picker: ^3.1.0
file_picker: ^4.6.1 file_picker: ^5.2.2
flutter_quill: flutter_quill:
path: ../ path: ../
flutter_quill_extensions: flutter_quill_extensions:

@ -60,14 +60,14 @@ IDI_APP_ICON ICON "resources\\app_icon.ico"
// Version // Version
// //
#ifdef 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_BUILD_NUMBER #define VERSION_AS_NUMBER FLUTTER_VERSION_MAJOR,FLUTTER_VERSION_MINOR,FLUTTER_VERSION_PATCH,FLUTTER_VERSION_BUILD
#else #else
#define VERSION_AS_NUMBER 1,0,0 #define VERSION_AS_NUMBER 1,0,0,0
#endif #endif
#ifdef FLUTTER_BUILD_NAME #if defined(FLUTTER_VERSION)
#define VERSION_AS_STRING #FLUTTER_BUILD_NAME #define VERSION_AS_STRING FLUTTER_VERSION
#else #else
#define VERSION_AS_STRING "1.0.0" #define VERSION_AS_STRING "1.0.0"
#endif #endif

@ -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 ## 0.1.0
* Initial release * Initial release

@ -7,15 +7,17 @@ import 'package:flutter_quill/flutter_quill.dart' hide Text;
import 'package:flutter_quill/translations.dart'; import 'package:flutter_quill/translations.dart';
import 'package:gallery_saver/gallery_saver.dart'; import 'package:gallery_saver/gallery_saver.dart';
import 'package:math_keyboard/math_keyboard.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 'utils.dart';
import 'widgets/image.dart'; import 'widgets/image.dart';
import 'widgets/image_resizer.dart'; import 'widgets/image_resizer.dart';
import 'widgets/video_app.dart'; import 'widgets/video_app.dart';
import 'widgets/youtube_video_app.dart'; import 'widgets/youtube_video_app.dart';
class ImageEmbedBuilder implements EmbedBuilder { class ImageEmbedBuilder extends EmbedBuilder {
@override @override
String get key => BlockEmbed.imageType; String get key => BlockEmbed.imageType;
@ -25,12 +27,14 @@ class ImageEmbedBuilder implements EmbedBuilder {
QuillController controller, QuillController controller,
base.Embed node, base.Embed node,
bool readOnly, bool readOnly,
bool inline,
TextStyle textStyle,
) { ) {
assert(!kIsWeb, 'Please provide image EmbedBuilder for Web'); assert(!kIsWeb, 'Please provide image EmbedBuilder for Web');
var image; var image;
final imageUrl = standardizeImageUrl(node.value.data); final imageUrl = standardizeImageUrl(node.value.data);
Tuple2<double?, double?>? _widthHeight; OptionalSize? _imageSize;
final style = node.style.attributes['style']; final style = node.style.attributes['style'];
if (base.isMobile() && style != null) { if (base.isMobile() && style != null) {
final _attrs = base.parseKeyValuePairs(style.value.toString(), { final _attrs = base.parseKeyValuePairs(style.value.toString(), {
@ -46,7 +50,7 @@ class ImageEmbedBuilder implements EmbedBuilder {
'mobileWidth and mobileHeight must be specified'); 'mobileWidth and mobileHeight must be specified');
final w = double.parse(_attrs[Attribute.mobileWidth]!); final w = double.parse(_attrs[Attribute.mobileWidth]!);
final h = double.parse(_attrs[Attribute.mobileHeight]!); final h = double.parse(_attrs[Attribute.mobileHeight]!);
_widthHeight = Tuple2(w, h); _imageSize = OptionalSize(w, h);
final m = _attrs[Attribute.mobileMargin] == null final m = _attrs[Attribute.mobileMargin] == null
? 0.0 ? 0.0
: double.parse(_attrs[Attribute.mobileMargin]!); : double.parse(_attrs[Attribute.mobileMargin]!);
@ -57,9 +61,9 @@ class ImageEmbedBuilder implements EmbedBuilder {
} }
} }
if (_widthHeight == null) { if (_imageSize == null) {
image = imageByUrl(imageUrl); image = imageByUrl(imageUrl);
_widthHeight = Tuple2((image as Image).width, image.height); _imageSize = OptionalSize((image as Image).width, image.height);
} }
if (!readOnly && base.isMobile()) { if (!readOnly && base.isMobile()) {
@ -87,10 +91,10 @@ class ImageEmbedBuilder implements EmbedBuilder {
controller controller
..skipRequestKeyboard = true ..skipRequestKeyboard = true
..formatText( ..formatText(
res.item1, 1, StyleAttribute(attr)); res.offset, 1, StyleAttribute(attr));
}, },
imageWidth: _widthHeight?.item1, imageWidth: _imageSize?.width,
imageHeight: _widthHeight?.item2, imageHeight: _imageSize?.height,
maxWidth: _screenSize.width, maxWidth: _screenSize.width,
maxHeight: _screenSize.height); maxHeight: _screenSize.height);
}); });
@ -103,10 +107,10 @@ class ImageEmbedBuilder implements EmbedBuilder {
onPressed: () { onPressed: () {
final imageNode = final imageNode =
getEmbedNode(controller, controller.selection.start) getEmbedNode(controller, controller.selection.start)
.item2; .value;
final imageUrl = imageNode.value.data; final imageUrl = imageNode.value.data;
controller.copiedImageUrl = controller.copiedImageUrl =
Tuple2(imageUrl, getImageStyleString(controller)); ImageUrl(imageUrl, getImageStyleString(controller));
Navigator.pop(context); Navigator.pop(context);
}, },
); );
@ -117,7 +121,7 @@ class ImageEmbedBuilder implements EmbedBuilder {
onPressed: () { onPressed: () {
final offset = final offset =
getEmbedNode(controller, controller.selection.start) getEmbedNode(controller, controller.selection.start)
.item1; .offset;
controller.replaceText(offset, 1, '', controller.replaceText(offset, 1, '',
TextSelection.collapsed(offset: offset)); TextSelection.collapsed(offset: offset));
Navigator.pop(context); 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}); VideoEmbedBuilder({this.onVideoInit});
final void Function(GlobalKey videoContainerKey)? onVideoInit; final void Function(GlobalKey videoContainerKey)? onVideoInit;
@ -159,6 +199,8 @@ class VideoEmbedBuilder implements EmbedBuilder {
QuillController controller, QuillController controller,
base.Embed node, base.Embed node,
bool readOnly, bool readOnly,
bool inline,
TextStyle textStyle,
) { ) {
assert(!kIsWeb, 'Please provide video EmbedBuilder for Web'); 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 @override
String get key => BlockEmbed.formulaType; String get key => BlockEmbed.formulaType;
@ -186,6 +228,8 @@ class FormulaEmbedBuilder implements EmbedBuilder {
QuillController controller, QuillController controller,
base.Embed node, base.Embed node,
bool readOnly, bool readOnly,
bool inline,
TextStyle textStyle,
) { ) {
assert(!kIsWeb, 'Please provide formula EmbedBuilder for Web'); assert(!kIsWeb, 'Please provide formula EmbedBuilder for Web');

@ -1,4 +1,5 @@
import 'dart:io'; import 'dart:io';
import 'dart:typed_data';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -18,3 +19,28 @@ enum MediaPickSetting {
Camera, Camera,
Video, Video,
} }
typedef MediaFileUrl = String;
typedef MediaFilePicker = Future<QuillFile?> Function(QuillMediaType mediaType);
typedef MediaPickedCallback = Future<MediaFileUrl> 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;
}

@ -19,6 +19,7 @@ class CameraButton extends StatelessWidget {
this.webVideoPickImpl, this.webVideoPickImpl,
this.cameraPickSettingSelector, this.cameraPickSettingSelector,
this.iconTheme, this.iconTheme,
this.tooltip,
Key? key, Key? key,
}) : super(key: key); }) : super(key: key);
@ -42,6 +43,7 @@ class CameraButton extends StatelessWidget {
final MediaPickSettingSelector? cameraPickSettingSelector; final MediaPickSettingSelector? cameraPickSettingSelector;
final QuillIconTheme? iconTheme; final QuillIconTheme? iconTheme;
final String? tooltip;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -53,6 +55,7 @@ class CameraButton extends StatelessWidget {
return QuillIconButton( return QuillIconButton(
icon: Icon(icon, size: iconSize, color: iconColor), icon: Icon(icon, size: iconSize, color: iconColor),
tooltip: tooltip,
highlightElevation: 0, highlightElevation: 0,
hoverElevation: 0, hoverElevation: 0,
size: iconSize * 1.77, size: iconSize * 1.77,

@ -9,6 +9,7 @@ class FormulaButton extends StatelessWidget {
this.fillColor, this.fillColor,
this.iconTheme, this.iconTheme,
this.dialogTheme, this.dialogTheme,
this.tooltip,
Key? key, Key? key,
}) : super(key: key); }) : super(key: key);
@ -23,6 +24,7 @@ class FormulaButton extends StatelessWidget {
final QuillIconTheme? iconTheme; final QuillIconTheme? iconTheme;
final QuillDialogTheme? dialogTheme; final QuillDialogTheme? dialogTheme;
final String? tooltip;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -34,6 +36,7 @@ class FormulaButton extends StatelessWidget {
return QuillIconButton( return QuillIconButton(
icon: Icon(icon, size: iconSize, color: iconColor), icon: Icon(icon, size: iconSize, color: iconColor),
tooltip: tooltip,
highlightElevation: 0, highlightElevation: 0,
hoverElevation: 0, hoverElevation: 0,
size: iconSize * 1.77, size: iconSize * 1.77,

@ -17,6 +17,7 @@ class ImageButton extends StatelessWidget {
this.mediaPickSettingSelector, this.mediaPickSettingSelector,
this.iconTheme, this.iconTheme,
this.dialogTheme, this.dialogTheme,
this.tooltip,
Key? key, Key? key,
}) : super(key: key); }) : super(key: key);
@ -38,6 +39,7 @@ class ImageButton extends StatelessWidget {
final QuillIconTheme? iconTheme; final QuillIconTheme? iconTheme;
final QuillDialogTheme? dialogTheme; final QuillDialogTheme? dialogTheme;
final String? tooltip;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -49,6 +51,7 @@ class ImageButton extends StatelessWidget {
return QuillIconButton( return QuillIconButton(
icon: Icon(icon, size: iconSize, color: iconColor), icon: Icon(icon, size: iconSize, color: iconColor),
tooltip: tooltip,
highlightElevation: 0, highlightElevation: 0,
hoverElevation: 0, hoverElevation: 0,
size: iconSize * 1.77, size: iconSize * 1.77,

@ -80,7 +80,6 @@ class ImageVideoUtils {
context: context, context: context,
builder: (ctx) => AlertDialog( builder: (ctx) => AlertDialog(
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
backgroundColor: Colors.transparent,
content: Column( content: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [

@ -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<void> _onPressedHandler(BuildContext context) async {
if (onMediaPickedCallback != null) {
final mediaSource = await showDialog<MediaPickSetting>(
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<void> _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<MediaFileUrl?> _pickMediaFileUrl() async {
final mediaFile = await mediaFilePicker(type);
return mediaFile != null ? onMediaPickedCallback?.call(mediaFile) : null;
}
void _inputLink(BuildContext context) {
showDialog<String>(
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<MediaLinkDialog> createState() => _MediaLinkDialogState();
}
class _MediaLinkDialogState extends State<MediaLinkDialog> {
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: <Widget>[
Icon(icon),
SizedBox(height: gap),
Flexible(child: Text(label)),
],
),
),
),
);
}
}
/// Default file picker.
Future<QuillFile?> _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;
}

@ -17,6 +17,7 @@ class VideoButton extends StatelessWidget {
this.mediaPickSettingSelector, this.mediaPickSettingSelector,
this.iconTheme, this.iconTheme,
this.dialogTheme, this.dialogTheme,
this.tooltip,
Key? key, Key? key,
}) : super(key: key); }) : super(key: key);
@ -38,6 +39,7 @@ class VideoButton extends StatelessWidget {
final QuillIconTheme? iconTheme; final QuillIconTheme? iconTheme;
final QuillDialogTheme? dialogTheme; final QuillDialogTheme? dialogTheme;
final String? tooltip;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -49,6 +51,7 @@ class VideoButton extends StatelessWidget {
return QuillIconButton( return QuillIconButton(
icon: Icon(icon, size: iconSize, color: iconColor), icon: Icon(icon, size: iconSize, color: iconColor),
tooltip: tooltip,
highlightElevation: 0, highlightElevation: 0,
hoverElevation: 0, hoverElevation: 0,
size: iconSize * 1.77, size: iconSize * 1.77,

@ -21,7 +21,7 @@ String getImageStyleString(QuillController controller) {
final String? s = controller final String? s = controller
.getAllSelectionStyles() .getAllSelectionStyles()
.firstWhere((s) => s.attributes.containsKey(Attribute.style.key), .firstWhere((s) => s.attributes.containsKey(Attribute.style.key),
orElse: () => Style()) orElse: Style.new)
.attributes[Attribute.style.key] .attributes[Attribute.style.key]
?.value; ?.value;
return s ?? ''; return s ?? '';

@ -2,7 +2,7 @@ import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_quill/flutter_quill.dart'; import 'package:flutter_quill/flutter_quill.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
import 'package:youtube_player_flutter_quill/youtube_player_flutter_quill.dart'; import 'package:youtube_player_flutter/youtube_player_flutter.dart';
class YoutubeVideoApp extends StatefulWidget { class YoutubeVideoApp extends StatefulWidget {
const YoutubeVideoApp( const YoutubeVideoApp(

@ -15,23 +15,33 @@ export 'embeds/toolbar/camera_button.dart';
export 'embeds/toolbar/formula_button.dart'; export 'embeds/toolbar/formula_button.dart';
export 'embeds/toolbar/image_button.dart'; export 'embeds/toolbar/image_button.dart';
export 'embeds/toolbar/image_video_utils.dart'; export 'embeds/toolbar/image_video_utils.dart';
export 'embeds/toolbar/media_button.dart';
export 'embeds/toolbar/video_button.dart'; export 'embeds/toolbar/video_button.dart';
export 'embeds/utils.dart'; export 'embeds/utils.dart';
class FlutterQuillEmbeds { class FlutterQuillEmbeds {
static List<EmbedBuilder> builders( static List<EmbedBuilder> builders({
{void Function(GlobalKey videoContainerKey)? onVideoInit}) => void Function(GlobalKey videoContainerKey)? onVideoInit,
}) =>
[ [
ImageEmbedBuilder(), ImageEmbedBuilder(),
VideoEmbedBuilder(onVideoInit: onVideoInit), VideoEmbedBuilder(onVideoInit: onVideoInit),
FormulaEmbedBuilder(), FormulaEmbedBuilder(),
]; ];
static List<EmbedBuilder> webBuilders() => [
ImageEmbedBuilderWeb(),
];
static List<EmbedButtonBuilder> buttons({ static List<EmbedButtonBuilder> buttons({
bool showImageButton = true, bool showImageButton = true,
bool showVideoButton = true, bool showVideoButton = true,
bool showCameraButton = true, bool showCameraButton = true,
bool showFormulaButton = false, bool showFormulaButton = false,
String? imageButtonTooltip,
String? videoButtonTooltip,
String? cameraButtonTooltip,
String? formulaButtonTooltip,
OnImagePickCallback? onImagePickCallback, OnImagePickCallback? onImagePickCallback,
OnVideoPickCallback? onVideoPickCallback, OnVideoPickCallback? onVideoPickCallback,
MediaPickSettingSelector? mediaPickSettingSelector, MediaPickSettingSelector? mediaPickSettingSelector,
@ -39,12 +49,13 @@ class FlutterQuillEmbeds {
FilePickImpl? filePickImpl, FilePickImpl? filePickImpl,
WebImagePickImpl? webImagePickImpl, WebImagePickImpl? webImagePickImpl,
WebVideoPickImpl? webVideoPickImpl, WebVideoPickImpl? webVideoPickImpl,
}) { }) =>
return [ [
if (showImageButton) if (showImageButton)
(controller, toolbarIconSize, iconTheme, dialogTheme) => ImageButton( (controller, toolbarIconSize, iconTheme, dialogTheme) => ImageButton(
icon: Icons.image, icon: Icons.image,
iconSize: toolbarIconSize, iconSize: toolbarIconSize,
tooltip: imageButtonTooltip,
controller: controller, controller: controller,
onImagePickCallback: onImagePickCallback, onImagePickCallback: onImagePickCallback,
filePickImpl: filePickImpl, filePickImpl: filePickImpl,
@ -57,6 +68,7 @@ class FlutterQuillEmbeds {
(controller, toolbarIconSize, iconTheme, dialogTheme) => VideoButton( (controller, toolbarIconSize, iconTheme, dialogTheme) => VideoButton(
icon: Icons.movie_creation, icon: Icons.movie_creation,
iconSize: toolbarIconSize, iconSize: toolbarIconSize,
tooltip: videoButtonTooltip,
controller: controller, controller: controller,
onVideoPickCallback: onVideoPickCallback, onVideoPickCallback: onVideoPickCallback,
filePickImpl: filePickImpl, filePickImpl: filePickImpl,
@ -70,6 +82,7 @@ class FlutterQuillEmbeds {
(controller, toolbarIconSize, iconTheme, dialogTheme) => CameraButton( (controller, toolbarIconSize, iconTheme, dialogTheme) => CameraButton(
icon: Icons.photo_camera, icon: Icons.photo_camera,
iconSize: toolbarIconSize, iconSize: toolbarIconSize,
tooltip: cameraButtonTooltip,
controller: controller, controller: controller,
onImagePickCallback: onImagePickCallback, onImagePickCallback: onImagePickCallback,
onVideoPickCallback: onVideoPickCallback, onVideoPickCallback: onVideoPickCallback,
@ -80,13 +93,14 @@ class FlutterQuillEmbeds {
iconTheme: iconTheme, iconTheme: iconTheme,
), ),
if (showFormulaButton) if (showFormulaButton)
(controller, toolbarIconSize, iconTheme, dialogTheme) => FormulaButton( (controller, toolbarIconSize, iconTheme, dialogTheme) =>
FormulaButton(
icon: Icons.functions, icon: Icons.functions,
iconSize: toolbarIconSize, iconSize: toolbarIconSize,
tooltip: formulaButtonTooltip,
controller: controller, controller: controller,
iconTheme: iconTheme, iconTheme: iconTheme,
dialogTheme: dialogTheme, dialogTheme: dialogTheme,
) )
]; ];
} }
}

@ -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) {}
}

@ -1,30 +1,28 @@
name: flutter_quill_extensions name: flutter_quill_extensions
description: Embed extensions for flutter_quill including image, video, formula and etc. 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 homepage: https://bulletjournal.us/home/index.html
repository: https://github.com/singerdmx/flutter-quill/tree/master/flutter_quill_extensions repository: https://github.com/singerdmx/flutter-quill/tree/master/flutter_quill_extensions
environment: environment:
sdk: ">=2.12.0 <3.0.0" sdk: ">=2.17.0 <4.0.0"
flutter: ">=3.0.0" flutter: ">=3.0.0"
dependencies: dependencies:
flutter: flutter:
sdk: flutter sdk: flutter
flutter_quill: ^6.0.0 flutter_quill: ^7.2.1
image_picker: ^0.8.5+3 image_picker: ^0.8.5+3
photo_view: ^0.14.0 photo_view: ^0.14.0
video_player: ^2.4.2 video_player: ^2.4.2
youtube_player_flutter_quill: ^8.2.2 youtube_player_flutter: ^8.1.1
gallery_saver: ^2.3.2 gallery_saver: ^2.3.2
math_keyboard: ^0.1.6 math_keyboard: ^0.2.0
string_validator: ^0.3.0 string_validator: ^1.0.0
universal_html: ^2.2.1
#dependency_overrides: url_launcher: ^6.1.9
# flutter_quill:
# path: ../
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:

@ -4,3 +4,4 @@ export 'src/models/documents/nodes/leaf.dart' hide Text;
export 'src/models/rules/insert.dart'; export 'src/models/rules/insert.dart';
export 'src/utils/platform.dart'; export 'src/utils/platform.dart';
export 'src/utils/string.dart'; export 'src/utils/string.dart';
export 'src/utils/widgets.dart';

@ -2,11 +2,18 @@ library flutter_quill;
export 'src/models/documents/attribute.dart'; export 'src/models/documents/attribute.dart';
export 'src/models/documents/document.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/embeddable.dart';
export 'src/models/documents/nodes/leaf.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/nodes/node.dart';
export 'src/models/documents/style.dart'; export 'src/models/documents/style.dart';
export 'src/models/quill_delta.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_custom_button.dart';
export 'src/models/themes/quill_dialog_theme.dart'; export 'src/models/themes/quill_dialog_theme.dart';
export 'src/models/themes/quill_icon_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/link.dart' show LinkActionPickerDelegate, LinkMenuAction;
export 'src/widgets/style_widgets/style_widgets.dart'; export 'src/widgets/style_widgets/style_widgets.dart';
export 'src/widgets/toolbar.dart'; export 'src/widgets/toolbar.dart';
export 'src/widgets/toolbar/enum.dart';

@ -0,0 +1,3 @@
library flutter_quill_test;
export 'src/test/widget_tester_extension.dart';

@ -19,6 +19,8 @@ class Attribute<T> {
static final Map<String, Attribute> _registry = LinkedHashMap.of({ static final Map<String, Attribute> _registry = LinkedHashMap.of({
Attribute.bold.key: Attribute.bold, Attribute.bold.key: Attribute.bold,
Attribute.subscript.key: Attribute.subscript,
Attribute.superscript.key: Attribute.superscript,
Attribute.italic.key: Attribute.italic, Attribute.italic.key: Attribute.italic,
Attribute.small.key: Attribute.small, Attribute.small.key: Attribute.small,
Attribute.underline.key: Attribute.underline, Attribute.underline.key: Attribute.underline,
@ -42,10 +44,18 @@ class Attribute<T> {
Attribute.style.key: Attribute.style, Attribute.style.key: Attribute.style,
Attribute.token.key: Attribute.token, Attribute.token.key: Attribute.token,
Attribute.script.key: Attribute.script, Attribute.script.key: Attribute.script,
Attribute.image.key: Attribute.image,
Attribute.video.key: Attribute.video,
}); });
static const BoldAttribute bold = BoldAttribute(); 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 ItalicAttribute italic = ItalicAttribute();
static const SmallAttribute small = SmallAttribute(); static const SmallAttribute small = SmallAttribute();
@ -90,7 +100,7 @@ class Attribute<T> {
static const TokenAttribute token = TokenAttribute(''); static const TokenAttribute token = TokenAttribute('');
static const ScriptAttribute script = ScriptAttribute(''); static final ScriptAttribute script = ScriptAttribute(null);
static const String mobileWidth = 'mobileWidth'; static const String mobileWidth = 'mobileWidth';
@ -100,8 +110,14 @@ class Attribute<T> {
static const String mobileAlignment = 'mobileAlignment'; static const String mobileAlignment = 'mobileAlignment';
static const ImageAttribute image = ImageAttribute(null);
static const VideoAttribute video = VideoAttribute(null);
static final Set<String> inlineKeys = { static final Set<String> inlineKeys = {
Attribute.bold.key, Attribute.bold.key,
Attribute.subscript.key,
Attribute.superscript.key,
Attribute.italic.key, Attribute.italic.key,
Attribute.small.key, Attribute.small.key,
Attribute.underline.key, Attribute.underline.key,
@ -138,6 +154,11 @@ class Attribute<T> {
Attribute.blockQuote.key, Attribute.blockQuote.key,
}); });
static final Set<String> embedKeys = {
Attribute.image.key,
Attribute.video.key,
};
static const Attribute<int?> h1 = HeaderAttribute(level: 1); static const Attribute<int?> h1 = HeaderAttribute(level: 1);
static const Attribute<int?> h2 = HeaderAttribute(level: 2); static const Attribute<int?> h2 = HeaderAttribute(level: 2);
@ -345,8 +366,26 @@ class TokenAttribute extends Attribute<String> {
const TokenAttribute(String val) : super('token', AttributeScope.IGNORE, val); 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<String?> {
class ScriptAttribute extends Attribute<String> { ScriptAttribute(ScriptAttributes? val)
const ScriptAttribute(String val) : super('script', AttributeScope.INLINE, val?.value);
: super('script', AttributeScope.IGNORE, val); }
enum ScriptAttributes {
sup('super'),
sub('sub');
const ScriptAttributes(this.value);
final String value;
}
class ImageAttribute extends Attribute<String?> {
const ImageAttribute(String? url)
: super('image', AttributeScope.EMBEDS, url);
}
class VideoAttribute extends Attribute<String?> {
const VideoAttribute(String? url)
: super('video', AttributeScope.EMBEDS, url);
} }

@ -1,9 +1,11 @@
import 'dart:async'; import 'dart:async';
import 'package:tuple/tuple.dart';
import '../quill_delta.dart'; import '../quill_delta.dart';
import '../rules/rule.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 'attribute.dart';
import 'history.dart'; import 'history.dart';
import 'nodes/block.dart'; import 'nodes/block.dart';
@ -50,13 +52,12 @@ class Document {
_rules.setCustomRules(customRules); _rules.setCustomRules(customRules);
} }
final StreamController<Tuple3<Delta, Delta, ChangeSource>> _observer = final StreamController<DocChange> _observer = StreamController.broadcast();
StreamController.broadcast();
final History _history = History(); final History _history = History();
/// Stream of [Change]s applied to this document. /// Stream of [DocChange]s applied to this document.
Stream<Tuple3<Delta, Delta, ChangeSource>> get changes => _observer.stream; Stream<DocChange> get changes => _observer.stream;
/// Inserts [data] in this document at specified [index]. /// Inserts [data] in this document at specified [index].
/// ///
@ -158,7 +159,7 @@ class Document {
} }
/// Returns all styles for each node within selection /// Returns all styles for each node within selection
List<Tuple2<int, Style>> collectAllIndividualStyles(int index, int len) { List<OffsetValue<Style>> collectAllIndividualStyles(int index, int len) {
final res = queryChild(index); final res = queryChild(index);
return (res.node as Line).collectAllIndividualStyles(res.offset, len); return (res.node as Line).collectAllIndividualStyles(res.offset, len);
} }
@ -169,6 +170,12 @@ class Document {
return (res.node as Line).collectAllStyles(res.offset, len); return (res.node as Line).collectAllStyles(res.offset, len);
} }
/// Returns all styles for any character within the specified text range.
List<OffsetValue<Style>> 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. /// Returns plain text within the specified text range.
String getPlainText(int index, int len) { String getPlainText(int index, int len) {
final res = queryChild(index); final res = queryChild(index);
@ -216,19 +223,15 @@ class Document {
} }
/// Given offset, find its leaf node in document /// Given offset, find its leaf node in document
Tuple2<Line?, Leaf?> querySegmentLeafNode(int offset) { SegmentLeafNode querySegmentLeafNode(int offset) {
final result = queryChild(offset); final result = queryChild(offset);
if (result.node == null) { if (result.node == null) {
return const Tuple2(null, null); return const SegmentLeafNode(null, null);
} }
final line = result.node as Line; final line = result.node as Line;
final segmentResult = line.queryChild(result.offset, false); final segmentResult = line.queryChild(result.offset, false);
if (segmentResult.node == null) { return SegmentLeafNode(line, segmentResult.node as Leaf?);
return Tuple2(line, null);
}
final segment = segmentResult.node as Leaf;
return Tuple2(line, segment);
} }
/// Composes [change] Delta into this document. /// Composes [change] Delta into this document.
@ -276,16 +279,16 @@ class Document {
if (_delta != _root.toDelta()) { if (_delta != _root.toDelta()) {
throw 'Compose failed'; throw 'Compose failed';
} }
final change = Tuple3(originalDelta, delta, changeSource); final change = DocChange(originalDelta, delta, changeSource);
_observer.add(change); _observer.add(change);
_history.handleDocChange(change); _history.handleDocChange(change);
} }
Tuple2 undo() { HistoryChanged undo() {
return _history.undo(this); return _history.undo(this);
} }
Tuple2 redo() { HistoryChanged redo() {
return _history.redo(this); return _history.redo(this);
} }

@ -1,6 +1,6 @@
import 'package:tuple/tuple.dart';
import '../quill_delta.dart'; import '../quill_delta.dart';
import '../structs/doc_change.dart';
import '../structs/history_changed.dart';
import 'document.dart'; import 'document.dart';
class History { class History {
@ -32,12 +32,12 @@ class History {
///record delay ///record delay
final int interval; final int interval;
void handleDocChange(Tuple3<Delta, Delta, ChangeSource> change) { void handleDocChange(DocChange docChange) {
if (ignoreChange) return; if (ignoreChange) return;
if (!userOnly || change.item3 == ChangeSource.LOCAL) { if (!userOnly || docChange.source == ChangeSource.LOCAL) {
record(change.item2, change.item1); record(docChange.change, docChange.before);
} else { } else {
transform(change.item2); transform(docChange.change);
} }
} }
@ -85,9 +85,9 @@ class History {
} }
} }
Tuple2 _change(Document doc, List<Delta> source, List<Delta> dest) { HistoryChanged _change(Document doc, List<Delta> source, List<Delta> dest) {
if (source.isEmpty) { if (source.isEmpty) {
return const Tuple2(false, 0); return const HistoryChanged(false, 0);
} }
final delta = source.removeLast(); final delta = source.removeLast();
// look for insert or delete // look for insert or delete
@ -107,14 +107,14 @@ class History {
ignoreChange = true; ignoreChange = true;
doc.compose(delta, ChangeSource.LOCAL); doc.compose(delta, ChangeSource.LOCAL);
ignoreChange = false; 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); return _change(doc, stack.undo, stack.redo);
} }
Tuple2 redo(Document doc) { HistoryChanged redo(Document doc) {
return _change(doc, stack.redo, stack.undo); return _change(doc, stack.redo, stack.undo);
} }
} }

@ -1,9 +1,9 @@
import 'dart:math' as math; import 'dart:math' as math;
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:tuple/tuple.dart';
import '../../quill_delta.dart'; import '../../quill_delta.dart';
import '../../structs/offset_value.dart';
import '../attribute.dart'; import '../attribute.dart';
import '../style.dart'; import '../style.dart';
import 'block.dart'; import 'block.dart';
@ -381,7 +381,7 @@ class Line extends Container<Leaf?> {
} }
final remaining = len - local; final remaining = len - local;
if (remaining > 0) { if (remaining > 0 && nextLine != null) {
final rest = nextLine!.collectStyle(0, remaining); final rest = nextLine!.collectStyle(0, remaining);
_handle(rest); _handle(rest);
} }
@ -391,10 +391,10 @@ class Line extends Container<Leaf?> {
/// Returns each node segment's offset in selection /// Returns each node segment's offset in selection
/// with its corresponding style as a list /// with its corresponding style as a list
List<Tuple2<int, Style>> collectAllIndividualStyles(int offset, int len, List<OffsetValue<Style>> collectAllIndividualStyles(int offset, int len,
{int beg = 0}) { {int beg = 0}) {
final local = math.min(length - offset, len); final local = math.min(length - offset, len);
final result = <Tuple2<int, Style>>[]; final result = <OffsetValue<Style>>[];
final data = queryChild(offset, true); final data = queryChild(offset, true);
var node = data.node as Leaf?; var node = data.node as Leaf?;
@ -402,12 +402,12 @@ class Line extends Container<Leaf?> {
var pos = 0; var pos = 0;
if (node is Text) { if (node is Text) {
pos = node.length - data.offset; pos = node.length - data.offset;
result.add(Tuple2(beg, node.style)); result.add(OffsetValue(beg, node.style));
} }
while (!node!.isLast && pos < local) { while (!node!.isLast && pos < local) {
node = node.next as Leaf; node = node.next as Leaf;
if (node is Text) { if (node is Text) {
result.add(Tuple2(pos + beg, node.style)); result.add(OffsetValue(pos + beg, node.style));
pos += node.length; pos += node.length;
} }
} }
@ -416,7 +416,7 @@ class Line extends Container<Leaf?> {
// TODO: add line style and parent's block style // TODO: add line style and parent's block style
final remaining = len - local; final remaining = len - local;
if (remaining > 0) { if (remaining > 0 && nextLine != null) {
final rest = final rest =
nextLine!.collectAllIndividualStyles(0, remaining, beg: local); nextLine!.collectAllIndividualStyles(0, remaining, beg: local);
result.addAll(rest); result.addAll(rest);
@ -450,7 +450,7 @@ class Line extends Container<Leaf?> {
} }
final remaining = len - local; final remaining = len - local;
if (remaining > 0) { if (remaining > 0 && nextLine != null) {
final rest = nextLine!.collectAllStyles(0, remaining); final rest = nextLine!.collectAllStyles(0, remaining);
result.addAll(rest); result.addAll(rest);
} }
@ -458,6 +458,44 @@ class Line extends Container<Leaf?> {
return result; return result;
} }
/// Returns all styles for any character within the specified text range.
List<OffsetValue<Style>> collectAllStylesWithOffsets(
int offset,
int len, {
int beg = 0,
}) {
final local = math.min(length - offset, len);
final result = <OffsetValue<Style>>[];
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. /// Returns plain text within the specified text range.
String getPlainText(int offset, int len) { String getPlainText(int offset, int len) {
final plainText = StringBuffer(); final plainText = StringBuffer();
@ -501,7 +539,7 @@ class Line extends Container<Leaf?> {
} }
} }
if (_len > 0) { if (_len > 0 && nextLine != null) {
_len = nextLine!._getPlainText(0, _len, plainText); _len = nextLine!._getPlainText(0, _len, plainText);
} }
} }

@ -1,5 +1,3 @@
import 'package:tuple/tuple.dart';
import '../../models/documents/document.dart'; import '../../models/documents/document.dart';
import '../documents/attribute.dart'; import '../documents/attribute.dart';
import '../documents/nodes/embeddable.dart'; import '../documents/nodes/embeddable.dart';
@ -37,26 +35,26 @@ class PreserveLineStyleOnSplitRule extends InsertRule {
final itr = DeltaIterator(document); final itr = DeltaIterator(document);
final before = itr.skip(index); final before = itr.skip(index);
if (before == null || if (before == null) {
before.data is! String ||
(before.data as String).endsWith('\n')) {
return null; return null;
} }
final after = itr.next(); if (before.data is String && (before.data as String).endsWith('\n')) {
if (after.data is! String || (after.data as String).startsWith('\n')) {
return null; 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)); 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); assert(after.isPlain);
delta.insert('\n'); delta.insert('\n');
return delta; return delta;
} }
final nextNewLine = _getNextNewLine(itr); final nextNewLine = _getNextNewLine(itr);
final attributes = nextNewLine.item1?.attributes; final attributes = nextNewLine.operation?.attributes;
return delta..insert('\n', attributes); return delta..insert('\n', attributes);
} }
@ -85,8 +83,8 @@ class PreserveBlockStyleOnInsertRule extends InsertRule {
// Look for the next newline. // Look for the next newline.
final nextNewLine = _getNextNewLine(itr); final nextNewLine = _getNextNewLine(itr);
final lineStyle = final lineStyle = Style.fromJson(
Style.fromJson(nextNewLine.item1?.attributes ?? <String, dynamic>{}); nextNewLine.operation?.attributes ?? <String, dynamic>{});
final blockStyle = lineStyle.getBlocksExceptHeader(); final blockStyle = lineStyle.getBlocksExceptHeader();
// Are we currently in a block? If not then ignore. // 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. // Reset style of the original newline character if needed.
if (resetStyle.isNotEmpty) { if (resetStyle.isNotEmpty) {
delta delta
..retain(nextNewLine.item2!) ..retain(nextNewLine.skipped!)
..retain((nextNewLine.item1!.data as String).indexOf('\n')) ..retain((nextNewLine.operation!.data as String).indexOf('\n'))
..retain(1, resetStyle); ..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 // Keep looking for the next newline character to see if it shares the same
// block style as `cur`. // block style as `cur`.
final nextNewLine = _getNextNewLine(itr); final nextNewLine = _getNextNewLine(itr);
if (nextNewLine.item1 != null && if (nextNewLine.operation != null &&
nextNewLine.item1!.attributes != null && nextNewLine.operation!.attributes != null &&
Style.fromJson(nextNewLine.item1!.attributes).getBlockExceptHeader() == Style.fromJson(nextNewLine.operation!.attributes)
.getBlockExceptHeader() ==
blockStyle) { blockStyle) {
// We are not at the end of this block, ignore. // We are not at the end of this block, ignore.
return null; return null;
@ -477,7 +476,7 @@ class PreserveInlineStylesRule extends InsertRule {
} }
final itr = DeltaIterator(document); final itr = DeltaIterator(document);
final prev = itr.skip(index); final prev = itr.skip(len == 0 ? index : index + 1);
if (prev == null || if (prev == null ||
prev.data is! String || prev.data is! String ||
(prev.data as String).contains('\n')) { (prev.data as String).contains('\n')) {
@ -524,15 +523,22 @@ class CatchAllInsertRule extends InsertRule {
} }
} }
Tuple2<Operation?, int?> _getNextNewLine(DeltaIterator iterator) { _NextNewLine _getNextNewLine(DeltaIterator iterator) {
Operation op; Operation op;
for (var skipped = 0; iterator.hasNext; skipped += op.length!) { for (var skipped = 0; iterator.hasNext; skipped += op.length!) {
op = iterator.next(); op = iterator.next();
final lineBreak = final lineBreak =
(op.data is String ? op.data as String? : '')!.indexOf('\n'); (op.data is String ? op.data as String? : '')!.indexOf('\n');
if (lineBreak >= 0) { if (lineBreak >= 0) {
return Tuple2(op, skipped); return _NextNewLine(op, skipped);
}
} }
return const _NextNewLine(null, null);
} }
return const Tuple2(null, null);
class _NextNewLine {
const _NextNewLine(this.operation, this.skipped);
final Operation? operation;
final int? skipped;
} }

@ -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;
}

@ -0,0 +1,9 @@
class HistoryChanged {
const HistoryChanged(
this.changed,
this.len,
);
final bool changed;
final int? len;
}

@ -0,0 +1,9 @@
class ImageUrl {
const ImageUrl(
this.url,
this.styleString,
);
final String url;
final String styleString;
}

@ -0,0 +1,6 @@
class OffsetValue<T> {
OffsetValue(this.offset, this.value, [this.length]);
final int offset;
final int? length;
final T value;
}

@ -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;
}

@ -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;
}

@ -0,0 +1,9 @@
class VerticalSpacing {
const VerticalSpacing(
this.top,
this.bottom,
);
final double top;
final double bottom;
}

@ -1,14 +1,26 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
class QuillCustomButton { 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 ///The icon widget
final IconData? icon; final IconData? icon;
///The icon color;
final Color? iconColor;
///The function when the icon is tapped ///The function when the icon is tapped
final VoidCallback? onTap; final VoidCallback? onTap;
///The customButton placeholder ///The customButton placeholder
final Widget? child; final Widget? child;
/// The button tooltip.
final String? tooltip;
} }

@ -1,8 +1,21 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
class QuillDialogTheme { /// Used to configure the dialog's look and feel.
QuillDialogTheme( class QuillDialogTheme with Diagnosticable {
{this.labelTextStyle, this.inputTextStyle, this.dialogBackgroundColor}); 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 ///The text style to use for the label shown in the link-input dialog
final TextStyle? labelTextStyle; final TextStyle? labelTextStyle;
@ -10,6 +23,105 @@ class QuillDialogTheme {
///The text style to use for the input text shown in the link-input dialog ///The text style to use for the input text shown in the link-input dialog
final TextStyle? inputTextStyle; final TextStyle? inputTextStyle;
///The background color for the [LinkDialog()] ///The background color for the Quill dialog
final Color? dialogBackgroundColor; 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,
);
} }

@ -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<void> quillGiveFocus(Finder finder) {
return TestAsyncUtils.guard(() async {
final editor = state<QuillEditorState>(
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<void> 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<void> quillUpdateEditingValue(Finder finder, String text) async {
return TestAsyncUtils.guard(() async {
final editor = state<RawEditorState>(
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();
});
}
}

File diff suppressed because it is too large Load Diff

@ -0,0 +1 @@
T? castOrNull<T>(dynamic x) => x is T ? x : null;

@ -1,11 +1,10 @@
import 'dart:math'; import 'dart:math';
import 'package:tuple/tuple.dart';
import '../models/documents/nodes/leaf.dart'; import '../models/documents/nodes/leaf.dart';
import '../models/structs/offset_value.dart';
import '../widgets/controller.dart'; import '../widgets/controller.dart';
Tuple2<int, Embed> getEmbedNode(QuillController controller, int offset) { OffsetValue<Embed> getEmbedNode(QuillController controller, int offset) {
var offset = controller.selection.start; var offset = controller.selection.start;
var embedNode = controller.queryNode(offset); var embedNode = controller.queryNode(offset);
if (embedNode == null || !(embedNode is Embed)) { if (embedNode == null || !(embedNode is Embed)) {
@ -13,7 +12,7 @@ Tuple2<int, Embed> getEmbedNode(QuillController controller, int offset) {
embedNode = controller.queryNode(offset); embedNode = controller.queryNode(offset);
} }
if (embedNode != null && embedNode is Embed) { if (embedNode != null && embedNode is Embed) {
return Tuple2(offset, embedNode); return OffsetValue(offset, embedNode);
} }
return throw 'Embed node not found by offset $offset'; return throw 'Embed node not found by offset $offset';

@ -1,5 +1,6 @@
dynamic getFontSize(dynamic sizeValue) { 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; return sizeValue;
} }

@ -26,6 +26,10 @@ bool isAppleOS([TargetPlatform? targetPlatform]) {
} }
Future<bool> isIOSSimulator() async { Future<bool> isIOSSimulator() async {
if (!isAppleOS()) {
return false;
}
final deviceInfo = DeviceInfoPlugin(); final deviceInfo = DeviceInfoPlugin();
final osInfo = await deviceInfo.deviceInfo; final osInfo = await deviceInfo.deviceInfo;

@ -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;
}

@ -2,7 +2,6 @@ import 'dart:math' as math;
import 'package:flutter/cupertino.dart'; import 'package:flutter/cupertino.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:tuple/tuple.dart';
import '../models/documents/attribute.dart'; import '../models/documents/attribute.dart';
import '../models/documents/document.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/nodes/leaf.dart';
import '../models/documents/style.dart'; import '../models/documents/style.dart';
import '../models/quill_delta.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'; import '../utils/delta.dart';
typedef ReplaceTextCallback = bool Function(int index, int len, Object? data); 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 { class QuillController extends ChangeNotifier {
QuillController({ QuillController({
required this.document, required Document document,
required TextSelection selection, required TextSelection selection,
bool keepStyleOnNewLine = false, bool keepStyleOnNewLine = false,
this.onReplaceText, this.onReplaceText,
this.onDelete, this.onDelete,
this.onSelectionCompleted, this.onSelectionCompleted,
this.onSelectionChanged, this.onSelectionChanged,
}) : _selection = selection, }) : _document = document,
_selection = selection,
_keepStyleOnNewLine = keepStyleOnNewLine; _keepStyleOnNewLine = keepStyleOnNewLine;
factory QuillController.basic() { factory QuillController.basic() {
@ -35,7 +38,16 @@ class QuillController extends ChangeNotifier {
} }
/// Document managed by this controller. /// 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] /// Tells whether to keep or reset the [toggledStyle]
/// when user adds a new line. /// when user adds a new line.
@ -72,12 +84,7 @@ class QuillController extends ChangeNotifier {
/// removing or listeners to this instance. /// removing or listeners to this instance.
bool _isDisposed = false; bool _isDisposed = false;
// item1: Document state before [change]. Stream<DocChange> get changes => document.changes;
//
// item2: Change delta applied to the document.
//
// item3: The source of this change.
Stream<Tuple3<Delta, Delta, ChangeSource>> get changes => document.changes;
TextEditingValue get plainTextEditingValue => TextEditingValue( TextEditingValue get plainTextEditingValue => TextEditingValue(
text: document.toPlainText(), text: document.toPlainText(),
@ -92,8 +99,68 @@ class QuillController extends ChangeNotifier {
.mergeAll(toggledStyle); .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 /// Returns all styles for each node within selection
List<Tuple2<int, Style>> getAllIndividualSelectionStyles() { List<OffsetValue<Style>> getAllIndividualSelectionStyles() {
final styles = document.collectAllIndividualStyles( final styles = document.collectAllIndividualStyles(
selection.start, selection.end - selection.start); selection.start, selection.end - selection.start);
return styles; return styles;
@ -115,9 +182,9 @@ class QuillController extends ChangeNotifier {
} }
void undo() { void undo() {
final tup = document.undo(); final result = document.undo();
if (tup.item1) { if (result.changed) {
_handleHistoryChange(tup.item2); _handleHistoryChange(result.len);
} }
} }
@ -137,9 +204,9 @@ class QuillController extends ChangeNotifier {
} }
void redo() { void redo() {
final tup = document.redo(); final result = document.redo();
if (tup.item1) { if (result.changed) {
_handleHistoryChange(tup.item2); _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 (textSelection != null) {
if (delta == null || delta.isEmpty) { if (delta == null || delta.isEmpty) {
_updateSelection(textSelection, ChangeSource.LOCAL); _updateSelection(textSelection, ChangeSource.LOCAL);
@ -333,22 +392,27 @@ class QuillController extends ChangeNotifier {
_selection = selection.copyWith( _selection = selection.copyWith(
baseOffset: math.min(selection.baseOffset, end), baseOffset: math.min(selection.baseOffset, end),
extentOffset: math.min(selection.extentOffset, end)); extentOffset: math.min(selection.extentOffset, end));
if (_keepStyleOnNewLine) {
final style = getSelectionStyle();
final notInlineStyle = style.attributes.values.where((s) => !s.isInline);
toggledStyle = style.removeAll(notInlineStyle.toSet());
} else {
toggledStyle = Style(); toggledStyle = Style();
}
onSelectionChanged?.call(textSelection); onSelectionChanged?.call(textSelection);
} }
/// Given offset, find its leaf node in document /// Given offset, find its leaf node in document
Leaf? queryNode(int offset) { Leaf? queryNode(int offset) {
return document.querySegmentLeafNode(offset).item2; return document.querySegmentLeafNode(offset).leaf;
} }
/// Clipboard for image url and its corresponding style /// Clipboard for image url and its corresponding style
/// item1 is url and item2 is style string ImageUrl? _copiedImageUrl;
Tuple2<String, String>? _copiedImageUrl;
Tuple2<String, String>? get copiedImageUrl => _copiedImageUrl; ImageUrl? get copiedImageUrl => _copiedImageUrl;
set copiedImageUrl(Tuple2<String, String>? value) { set copiedImageUrl(ImageUrl? value) {
_copiedImageUrl = value; _copiedImageUrl = value;
Clipboard.setData(const ClipboardData(text: '')); Clipboard.setData(const ClipboardData(text: ''));
} }

@ -1,8 +1,10 @@
import 'dart:ui';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:tuple/tuple.dart';
import '../models/documents/attribute.dart'; import '../models/documents/attribute.dart';
import '../models/documents/style.dart'; import '../models/documents/style.dart';
import '../models/structs/vertical_spacing.dart';
import '../utils/platform.dart'; import '../utils/platform.dart';
import 'style_widgets/checkbox_point.dart'; import 'style_widgets/checkbox_point.dart';
@ -44,11 +46,11 @@ class DefaultTextBlockStyle {
final TextStyle style; final TextStyle style;
/// Vertical spacing around a text block. /// Vertical spacing around a text block.
final Tuple2<double, double> verticalSpacing; final VerticalSpacing verticalSpacing;
/// Vertical spacing for individual lines within a text block. /// Vertical spacing for individual lines within a text block.
/// ///
final Tuple2<double, double> lineSpacing; final VerticalSpacing lineSpacing;
/// Decoration of a text block. /// Decoration of a text block.
/// ///
@ -125,8 +127,8 @@ class InlineCodeStyle {
class DefaultListBlockStyle extends DefaultTextBlockStyle { class DefaultListBlockStyle extends DefaultTextBlockStyle {
DefaultListBlockStyle( DefaultListBlockStyle(
TextStyle style, TextStyle style,
Tuple2<double, double> verticalSpacing, VerticalSpacing verticalSpacing,
Tuple2<double, double> lineSpacing, VerticalSpacing lineSpacing,
BoxDecoration? decoration, BoxDecoration? decoration,
this.checkboxUIBuilder, this.checkboxUIBuilder,
) : super(style, verticalSpacing, lineSpacing, decoration); ) : super(style, verticalSpacing, lineSpacing, decoration);
@ -141,6 +143,8 @@ class DefaultStyles {
this.h3, this.h3,
this.paragraph, this.paragraph,
this.bold, this.bold,
this.subscript,
this.superscript,
this.italic, this.italic,
this.small, this.small,
this.underline, this.underline,
@ -165,6 +169,8 @@ class DefaultStyles {
final DefaultTextBlockStyle? h3; final DefaultTextBlockStyle? h3;
final DefaultTextBlockStyle? paragraph; final DefaultTextBlockStyle? paragraph;
final TextStyle? bold; final TextStyle? bold;
final TextStyle? subscript;
final TextStyle? superscript;
final TextStyle? italic; final TextStyle? italic;
final TextStyle? small; final TextStyle? small;
final TextStyle? underline; final TextStyle? underline;
@ -193,7 +199,7 @@ class DefaultStyles {
height: 1.3, height: 1.3,
decoration: TextDecoration.none, decoration: TextDecoration.none,
); );
const baseSpacing = Tuple2<double, double>(6, 0); const baseSpacing = VerticalSpacing(6, 0);
String fontFamily; String fontFamily;
if (isAppleOS(themeData.platform)) { if (isAppleOS(themeData.platform)) {
fontFamily = 'Menlo'; fontFamily = 'Menlo';
@ -216,8 +222,8 @@ class DefaultStyles {
fontWeight: FontWeight.w300, fontWeight: FontWeight.w300,
decoration: TextDecoration.none, decoration: TextDecoration.none,
), ),
const Tuple2(16, 0), const VerticalSpacing(16, 0),
const Tuple2(0, 0), const VerticalSpacing(0, 0),
null), null),
h2: DefaultTextBlockStyle( h2: DefaultTextBlockStyle(
defaultTextStyle.style.copyWith( defaultTextStyle.style.copyWith(
@ -227,8 +233,8 @@ class DefaultStyles {
fontWeight: FontWeight.normal, fontWeight: FontWeight.normal,
decoration: TextDecoration.none, decoration: TextDecoration.none,
), ),
const Tuple2(8, 0), const VerticalSpacing(8, 0),
const Tuple2(0, 0), const VerticalSpacing(0, 0),
null), null),
h3: DefaultTextBlockStyle( h3: DefaultTextBlockStyle(
defaultTextStyle.style.copyWith( defaultTextStyle.style.copyWith(
@ -238,12 +244,15 @@ class DefaultStyles {
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
decoration: TextDecoration.none, decoration: TextDecoration.none,
), ),
const Tuple2(8, 0), const VerticalSpacing(8, 0),
const Tuple2(0, 0), const VerticalSpacing(0, 0),
null), null),
paragraph: DefaultTextBlockStyle( paragraph: DefaultTextBlockStyle(baseStyle, const VerticalSpacing(0, 0),
baseStyle, const Tuple2(0, 0), const Tuple2(0, 0), null), const VerticalSpacing(0, 0), null),
bold: const TextStyle(fontWeight: FontWeight.bold), bold: const TextStyle(fontWeight: FontWeight.bold),
subscript: const TextStyle(fontFeatures: [FontFeature.subscripts()]),
superscript:
const TextStyle(fontFeatures: [FontFeature.superscripts()]),
italic: const TextStyle(fontStyle: FontStyle.italic), italic: const TextStyle(fontStyle: FontStyle.italic),
small: const TextStyle(fontSize: 12), small: const TextStyle(fontSize: 12),
underline: const TextStyle(decoration: TextDecoration.underline), underline: const TextStyle(decoration: TextDecoration.underline),
@ -272,15 +281,15 @@ class DefaultStyles {
height: 1.5, height: 1.5,
color: Colors.grey.withOpacity(0.6), color: Colors.grey.withOpacity(0.6),
), ),
const Tuple2(0, 0), const VerticalSpacing(0, 0),
const Tuple2(0, 0), const VerticalSpacing(0, 0),
null), null),
lists: DefaultListBlockStyle( lists: DefaultListBlockStyle(
baseStyle, baseSpacing, const Tuple2(0, 6), null, null), baseStyle, baseSpacing, const VerticalSpacing(0, 6), null, null),
quote: DefaultTextBlockStyle( quote: DefaultTextBlockStyle(
TextStyle(color: baseStyle.color!.withOpacity(0.6)), TextStyle(color: baseStyle.color!.withOpacity(0.6)),
baseSpacing, baseSpacing,
const Tuple2(6, 2), const VerticalSpacing(6, 2),
BoxDecoration( BoxDecoration(
border: Border( border: Border(
left: BorderSide(width: 4, color: Colors.grey.shade300), left: BorderSide(width: 4, color: Colors.grey.shade300),
@ -294,17 +303,17 @@ class DefaultStyles {
height: 1.15, height: 1.15,
), ),
baseSpacing, baseSpacing,
const Tuple2(0, 0), const VerticalSpacing(0, 0),
BoxDecoration( BoxDecoration(
color: Colors.grey.shade50, color: Colors.grey.shade50,
borderRadius: BorderRadius.circular(2), borderRadius: BorderRadius.circular(2),
)), )),
indent: DefaultTextBlockStyle( indent: DefaultTextBlockStyle(
baseStyle, baseSpacing, const Tuple2(0, 6), null), baseStyle, baseSpacing, const VerticalSpacing(0, 6), null),
align: DefaultTextBlockStyle( align: DefaultTextBlockStyle(baseStyle, const VerticalSpacing(0, 0),
baseStyle, const Tuple2(0, 0), const Tuple2(0, 0), null), const VerticalSpacing(0, 0), null),
leading: DefaultTextBlockStyle( leading: DefaultTextBlockStyle(baseStyle, const VerticalSpacing(0, 0),
baseStyle, const Tuple2(0, 0), const Tuple2(0, 0), null), const VerticalSpacing(0, 0), null),
sizeSmall: const TextStyle(fontSize: 10), sizeSmall: const TextStyle(fontSize: 10),
sizeLarge: const TextStyle(fontSize: 18), sizeLarge: const TextStyle(fontSize: 18),
sizeHuge: const TextStyle(fontSize: 22)); sizeHuge: const TextStyle(fontSize: 22));
@ -317,6 +326,8 @@ class DefaultStyles {
h3: other.h3 ?? h3, h3: other.h3 ?? h3,
paragraph: other.paragraph ?? paragraph, paragraph: other.paragraph ?? paragraph,
bold: other.bold ?? bold, bold: other.bold ?? bold,
subscript: other.subscript ?? subscript,
superscript: other.superscript ?? superscript,
italic: other.italic ?? italic, italic: other.italic ?? italic,
small: other.small ?? small, small: other.small ?? small,
underline: other.underline ?? underline, underline: other.underline ?? underline,

@ -3,19 +3,20 @@ import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/scheduler.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 '../utils/platform.dart';
import 'editor.dart';
import 'embeds.dart';
import 'text_selection.dart'; import 'text_selection.dart';
typedef EmbedsBuilder = Widget Function( typedef EmbedsBuilder = EmbedBuilder Function(Embed node);
BuildContext context,
QuillController controller,
Embed node,
bool readOnly,
);
typedef CustomStyleBuilder = TextStyle Function(Attribute attribute); typedef CustomStyleBuilder = TextStyle Function(Attribute attribute);
typedef CustomRecognizerBuilder = GestureRecognizer? Function(
Attribute attribute, Leaf leaf);
/// Delegate interface for the [EditorTextSelectionGestureDetectorBuilder]. /// Delegate interface for the [EditorTextSelectionGestureDetectorBuilder].
/// ///
/// The interface is usually implemented by textfield implementations wrapping /// The interface is usually implemented by textfield implementations wrapping
@ -66,7 +67,8 @@ class EditorTextSelectionGestureDetectorBuilder {
/// Creates a [EditorTextSelectionGestureDetectorBuilder]. /// Creates a [EditorTextSelectionGestureDetectorBuilder].
/// ///
/// The [delegate] must not be null. /// The [delegate] must not be null.
EditorTextSelectionGestureDetectorBuilder({required this.delegate}); EditorTextSelectionGestureDetectorBuilder(
{required this.delegate, this.detectWordBoundary = true});
/// The delegate for this [EditorTextSelectionGestureDetectorBuilder]. /// The delegate for this [EditorTextSelectionGestureDetectorBuilder].
/// ///
@ -83,6 +85,8 @@ class EditorTextSelectionGestureDetectorBuilder {
/// a stylus. /// a stylus.
bool shouldShowSelectionToolbar = true; bool shouldShowSelectionToolbar = true;
bool detectWordBoundary = true;
/// The [State] of the [EditableText] for which the builder will provide a /// The [State] of the [EditableText] for which the builder will provide a
/// [EditorTextSelectionGestureDetector]. /// [EditorTextSelectionGestureDetector].
@protected @protected
@ -308,7 +312,8 @@ class EditorTextSelectionGestureDetectorBuilder {
/// which triggers this callback./lib/src/material/text_field.dart /// which triggers this callback./lib/src/material/text_field.dart
@protected @protected
void onDragSelectionUpdate( void onDragSelectionUpdate(
DragStartDetails startDetails, DragUpdateDetails updateDetails) { //DragStartDetails startDetails,
DragUpdateDetails updateDetails) {
renderEditor!.extendSelection(updateDetails.globalPosition, renderEditor!.extendSelection(updateDetails.globalPosition,
cause: SelectionChangedCause.drag); cause: SelectionChangedCause.drag);
} }
@ -337,11 +342,15 @@ class EditorTextSelectionGestureDetectorBuilder {
/// ///
/// The [child] or its subtree should contain [EditableText]. /// The [child] or its subtree should contain [EditableText].
Widget build( Widget build(
{required HitTestBehavior behavior, required Widget child, Key? key}) { {required HitTestBehavior behavior,
required Widget child,
Key? key,
bool detectWordBoundary = true}) {
return EditorTextSelectionGestureDetector( return EditorTextSelectionGestureDetector(
key: key, key: key,
onTapDown: onTapDown, onTapDown: onTapDown,
onForcePressStart: delegate.forcePressEnabled ? onForcePressStart : null, onForcePressStart:
delegate.forcePressEnabled ? onForcePressStart : null,
onForcePressEnd: delegate.forcePressEnabled ? onForcePressEnd : null, onForcePressEnd: delegate.forcePressEnabled ? onForcePressEnd : null,
onSingleTapUp: onSingleTapUp, onSingleTapUp: onSingleTapUp,
onSingleTapCancel: onSingleTapCancel, onSingleTapCancel: onSingleTapCancel,
@ -354,7 +363,7 @@ class EditorTextSelectionGestureDetectorBuilder {
onDragSelectionUpdate: onDragSelectionUpdate, onDragSelectionUpdate: onDragSelectionUpdate,
onDragSelectionEnd: onDragSelectionEnd, onDragSelectionEnd: onDragSelectionEnd,
behavior: behavior, behavior: behavior,
child: child, detectWordBoundary: detectWordBoundary,
); child: child);
} }
} }

@ -9,13 +9,13 @@ import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart'; import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:i18n_extension/i18n_widget.dart'; import 'package:i18n_extension/i18n_widget.dart';
import 'package:tuple/tuple.dart';
import '../models/documents/document.dart'; import '../models/documents/document.dart';
import '../models/documents/nodes/container.dart' as container_node; 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';
import '../models/documents/style.dart'; import '../models/documents/style.dart';
import '../models/structs/offset_value.dart';
import '../models/themes/quill_dialog_theme.dart';
import '../utils/platform.dart'; import '../utils/platform.dart';
import 'box.dart'; import 'box.dart';
import 'controller.dart'; import 'controller.dart';
@ -38,7 +38,7 @@ abstract class EditorState extends State<RawEditor>
EditorTextSelectionOverlay? get selectionOverlay; EditorTextSelectionOverlay? get selectionOverlay;
List<Tuple2<int, Style>> get pasteStyle; List<OffsetValue<Style>> get pasteStyle;
String get pastePlainText; String get pastePlainText;
@ -46,6 +46,9 @@ abstract class EditorState extends State<RawEditor>
/// The floating cursor is animated to merge with the regular cursor. /// The floating cursor is animated to merge with the regular cursor.
AnimationController get floatingCursorResetController; AnimationController get floatingCursorResetController;
/// Returns true if the editor has been marked as needing to be rebuilt.
bool get dirty;
bool showToolbar(); bool showToolbar();
void requestKeyboard(); void requestKeyboard();
@ -144,8 +147,8 @@ abstract class RenderAbstractEditor implements TextLayoutMetrics {
} }
class QuillEditor extends StatefulWidget { class QuillEditor extends StatefulWidget {
const QuillEditor( const QuillEditor({
{required this.controller, required this.controller,
required this.focusNode, required this.focusNode,
required this.scrollController, required this.scrollController,
required this.scrollable, required this.scrollable,
@ -157,6 +160,7 @@ class QuillEditor extends StatefulWidget {
this.paintCursorAboveText, this.paintCursorAboveText,
this.placeholder, this.placeholder,
this.enableInteractiveSelection = true, this.enableInteractiveSelection = true,
this.enableSelectionToolbar = true,
this.scrollBottomInset = 0, this.scrollBottomInset = 0,
this.minHeight, this.minHeight,
this.maxHeight, this.maxHeight,
@ -172,14 +176,23 @@ class QuillEditor extends StatefulWidget {
this.onSingleLongTapMoveUpdate, this.onSingleLongTapMoveUpdate,
this.onSingleLongTapEnd, this.onSingleLongTapEnd,
this.embedBuilders, this.embedBuilders,
this.unknownEmbedBuilder,
this.linkActionPickerDelegate = defaultLinkActionPickerDelegate, this.linkActionPickerDelegate = defaultLinkActionPickerDelegate,
this.customStyleBuilder, this.customStyleBuilder,
this.customRecognizerBuilder,
this.locale, this.locale,
this.floatingCursorDisabled = false, this.floatingCursorDisabled = false,
this.textSelectionControls, this.textSelectionControls,
this.onImagePaste, this.onImagePaste,
Key? key}) this.customShortcuts,
: super(key: key); this.customActions,
this.detectWordBoundary = true,
this.enableUnfocusOnTapOutside = true,
this.customLinkPrefixes = const <String>[],
this.dialogTheme,
this.contentInsertionConfiguration,
Key? key,
}) : super(key: key);
factory QuillEditor.basic({ factory QuillEditor.basic({
required QuillController controller, required QuillController controller,
@ -241,6 +254,9 @@ class QuillEditor extends StatefulWidget {
/// Defaults to `false`. Cannot be `null`. /// Defaults to `false`. Cannot be `null`.
final bool autoFocus; final bool autoFocus;
/// Whether focus should be revoked on tap outside the editor.
final bool enableUnfocusOnTapOutside;
/// Whether to show cursor. /// Whether to show cursor.
/// ///
/// The cursor refers to the blinking caret when the editor is focused. /// 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 /// When this is false, the text selection cannot be adjusted by
/// the user, text cannot be copied, and the user cannot paste into /// the user, text cannot be copied, and the user cannot paste into
/// the text field from the clipboard. /// the text field from the clipboard.
///
/// To disable just the selection toolbar, set enableSelectionToolbar
/// to false.
final bool enableInteractiveSelection; 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. /// The minimum height to be occupied by this editor.
/// ///
/// This only has effect if [scrollable] is set to `true` and [expands] is /// 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. /// horizontally centered. This is mostly useful on devices with wide screens.
final double? maxContentWidth; final double? maxContentWidth;
/// Allows to override [DefaultStyles].
final DefaultStyles? customStyles; final DefaultStyles? customStyles;
/// Whether this editor's height will be sized to fill its parent. /// Whether this editor's height will be sized to fill its parent.
@ -352,7 +375,9 @@ class QuillEditor extends StatefulWidget {
onSingleLongTapEnd; onSingleLongTapEnd;
final Iterable<EmbedBuilder>? embedBuilders; final Iterable<EmbedBuilder>? embedBuilders;
final EmbedBuilder? unknownEmbedBuilder;
final CustomStyleBuilder? customStyleBuilder; final CustomStyleBuilder? customStyleBuilder;
final CustomRecognizerBuilder? customRecognizerBuilder;
/// The locale to use for the editor toolbar, defaults to system locale /// The locale to use for the editor toolbar, defaults to system locale
/// More https://github.com/singerdmx/flutter-quill#translation /// 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. /// Returns the url of the image if the image should be inserted.
final Future<String?> Function(Uint8List imageBytes)? onImagePaste; final Future<String?> Function(Uint8List imageBytes)? onImagePaste;
/// Contains user-defined shortcuts map.
///
/// [https://docs.flutter.dev/development/ui/advanced/actions-and-shortcuts#shortcuts]
final Map<ShortcutActivator, Intent>? customShortcuts;
/// Contains user-defined actions.
///
/// [https://docs.flutter.dev/development/ui/advanced/actions-and-shortcuts#actions]
final Map<Type, Action<Intent>>? 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<String> 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 @override
QuillEditorState createState() => QuillEditorState(); QuillEditorState createState() => QuillEditorState();
} }
@ -399,7 +451,8 @@ class QuillEditorState extends State<QuillEditor>
void initState() { void initState() {
super.initState(); super.initState();
_selectionGestureDetectorBuilder = _selectionGestureDetectorBuilder =
_QuillEditorSelectionGestureDetectorBuilder(this); _QuillEditorSelectionGestureDetectorBuilder(
this, widget.detectWordBoundary);
} }
@override @override
@ -424,8 +477,8 @@ class QuillEditorState extends State<QuillEditor>
selectionColor = selectionTheme.selectionColor ?? selectionColor = selectionTheme.selectionColor ??
cupertinoTheme.primaryColor.withOpacity(0.40); cupertinoTheme.primaryColor.withOpacity(0.40);
cursorRadius ??= const Radius.circular(2); cursorRadius ??= const Radius.circular(2);
cursorOffset = Offset( cursorOffset =
iOSHorizontalOffset / MediaQuery.of(context).devicePixelRatio, 0); Offset(iOSHorizontalOffset / View.of(context).devicePixelRatio, 0);
} else { } else {
textSelectionControls = materialTextSelectionControls; textSelectionControls = materialTextSelectionControls;
paintCursorAboveText = false; paintCursorAboveText = false;
@ -435,6 +488,9 @@ class QuillEditorState extends State<QuillEditor>
theme.colorScheme.primary.withOpacity(0.40); theme.colorScheme.primary.withOpacity(0.40);
} }
final showSelectionToolbar =
widget.enableInteractiveSelection && widget.enableSelectionToolbar;
final child = RawEditor( final child = RawEditor(
key: _editorKey, key: _editorKey,
controller: widget.controller, controller: widget.controller,
@ -446,12 +502,8 @@ class QuillEditorState extends State<QuillEditor>
readOnly: widget.readOnly, readOnly: widget.readOnly,
placeholder: widget.placeholder, placeholder: widget.placeholder,
onLaunchUrl: widget.onLaunchUrl, onLaunchUrl: widget.onLaunchUrl,
toolbarOptions: ToolbarOptions( contextMenuBuilder:
copy: widget.enableInteractiveSelection, showSelectionToolbar ? RawEditor.defaultContextMenuBuilder : null,
cut: widget.enableInteractiveSelection,
paste: widget.enableInteractiveSelection,
selectAll: widget.enableInteractiveSelection,
),
showSelectionHandles: isMobile(theme.platform), showSelectionHandles: isMobile(theme.platform),
showCursor: widget.showCursor, showCursor: widget.showCursor,
cursorStyle: CursorStyle( cursorStyle: CursorStyle(
@ -475,17 +527,18 @@ class QuillEditorState extends State<QuillEditor>
keyboardAppearance: widget.keyboardAppearance, keyboardAppearance: widget.keyboardAppearance,
enableInteractiveSelection: widget.enableInteractiveSelection, enableInteractiveSelection: widget.enableInteractiveSelection,
scrollPhysics: widget.scrollPhysics, scrollPhysics: widget.scrollPhysics,
embedBuilder: ( embedBuilder: _getEmbedBuilder,
context,
controller,
node,
readOnly,
) =>
_buildCustomBlockEmbed(node, context, controller, readOnly),
linkActionPickerDelegate: widget.linkActionPickerDelegate, linkActionPickerDelegate: widget.linkActionPickerDelegate,
customStyleBuilder: widget.customStyleBuilder, customStyleBuilder: widget.customStyleBuilder,
customRecognizerBuilder: widget.customRecognizerBuilder,
floatingCursorDisabled: widget.floatingCursorDisabled, floatingCursorDisabled: widget.floatingCursorDisabled,
onImagePaste: widget.onImagePaste, onImagePaste: widget.onImagePaste,
customShortcuts: widget.customShortcuts,
customActions: widget.customActions,
customLinkPrefixes: widget.customLinkPrefixes,
enableUnfocusOnTapOutside: widget.enableUnfocusOnTapOutside,
dialogTheme: widget.dialogTheme,
contentInsertionConfiguration: widget.contentInsertionConfiguration,
); );
final editor = I18n( final editor = I18n(
@ -493,6 +546,7 @@ class QuillEditorState extends State<QuillEditor>
child: selectionEnabled child: selectionEnabled
? _selectionGestureDetectorBuilder.build( ? _selectionGestureDetectorBuilder.build(
behavior: HitTestBehavior.translucent, behavior: HitTestBehavior.translucent,
detectWordBoundary: widget.detectWordBoundary,
child: child, child: child,
) )
: child, : child,
@ -515,29 +569,26 @@ class QuillEditorState extends State<QuillEditor>
return editor; return editor;
} }
Widget _buildCustomBlockEmbed(Embed node, BuildContext context, EmbedBuilder _getEmbedBuilder(Embed node) {
QuillController controller, bool readOnly) {
final builders = widget.embedBuilders; final builders = widget.embedBuilders;
if (builders != null) { 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) { for (final builder in builders) {
if (builder.key == _node.value.type) { if (builder.key == node.value.type) {
return builder.build(context, controller, _node, readOnly); return builder;
}
} }
} }
if (widget.unknownEmbedBuilder != null) {
return widget.unknownEmbedBuilder!;
} }
throw UnimplementedError( throw UnimplementedError(
'Embeddable type "${node.value.type}" is not supported by supplied ' 'Embeddable type "${node.value.type}" is not supported by supplied '
'embed builders. You must pass your own builder function to ' '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<QuillEditor>
class _QuillEditorSelectionGestureDetectorBuilder class _QuillEditorSelectionGestureDetectorBuilder
extends EditorTextSelectionGestureDetectorBuilder { extends EditorTextSelectionGestureDetectorBuilder {
_QuillEditorSelectionGestureDetectorBuilder(this._state) _QuillEditorSelectionGestureDetectorBuilder(
: super(delegate: _state); this._state, this._detectWordBoundary)
: super(delegate: _state, detectWordBoundary: _detectWordBoundary);
final QuillEditorState _state; final QuillEditorState _state;
final bool _detectWordBoundary;
@override @override
void onForcePressStart(ForcePressDetails details) { void onForcePressStart(ForcePressDetails details) {
@ -608,11 +661,11 @@ class _QuillEditorSelectionGestureDetectorBuilder
final pos = renderEditor!.getPositionForOffset(details.globalPosition); final pos = renderEditor!.getPositionForOffset(details.globalPosition);
final result = final result =
editor!.widget.controller.document.querySegmentLeafNode(pos.offset); editor!.widget.controller.document.querySegmentLeafNode(pos.offset);
final line = result.item1; final line = result.line;
if (line == null) { if (line == null) {
return false; return false;
} }
final segmentLeaf = result.item2; final segmentLeaf = result.leaf;
if (segmentLeaf == null && line.length == 1) { if (segmentLeaf == null && line.length == 1) {
editor!.widget.controller.updateSelection( editor!.widget.controller.updateSelection(
TextSelection.collapsed(offset: pos.offset), ChangeSource.LOCAL); TextSelection.collapsed(offset: pos.offset), ChangeSource.LOCAL);
@ -678,9 +731,15 @@ class _QuillEditorSelectionGestureDetectorBuilder
case PointerDeviceKind.unknown: case PointerDeviceKind.unknown:
// On macOS/iOS/iPadOS a touch tap places the cursor at the edge // On macOS/iOS/iPadOS a touch tap places the cursor at the edge
// of the word. // of the word.
if (_detectWordBoundary) {
renderEditor! renderEditor!
..selectWordEdge(SelectionChangedCause.tap) ..selectWordEdge(SelectionChangedCause.tap)
..onSelectionCompleted(); ..onSelectionCompleted();
} else {
renderEditor!
..selectPosition(cause: SelectionChangedCause.tap)
..onSelectionCompleted();
}
break; break;
case PointerDeviceKind.trackpad: case PointerDeviceKind.trackpad:
// TODO: Handle this case. // TODO: Handle this case.
@ -1109,7 +1168,7 @@ class RenderEditor extends RenderEditableContainerBox
start: localWord.start + nodeOffset, start: localWord.start + nodeOffset,
end: localWord.end + nodeOffset, end: localWord.end + nodeOffset,
); );
if (position.offset - word.start <= 1) { if (position.offset - word.start <= 1 && word.end != position.offset) {
_handleSelectionChange( _handleSelectionChange(
TextSelection.collapsed(offset: word.start), TextSelection.collapsed(offset: word.start),
cause, cause,
@ -1768,7 +1827,10 @@ class RenderEditableContainerBox extends RenderBox
dy += child.size.height; dy += child.size.height;
child = childAfter(child); 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 @override

@ -6,13 +6,22 @@ import '../models/themes/quill_icon_theme.dart';
import 'controller.dart'; import 'controller.dart';
abstract class EmbedBuilder { abstract class EmbedBuilder {
const EmbedBuilder();
String get key; String get key;
bool get expanded => true;
WidgetSpan buildWidgetSpan(Widget widget) {
return WidgetSpan(child: widget);
}
Widget build( Widget build(
BuildContext context, BuildContext context,
QuillController controller, QuillController controller,
leaf.Embed node, leaf.Embed node,
bool readOnly, bool readOnly,
bool inline,
TextStyle textStyle,
); );
} }

@ -290,7 +290,7 @@ class RenderParagraphProxy extends RenderProxyBox
@override @override
List<TextBox> getBoxesForSelection(TextSelection selection) => child! List<TextBox> getBoxesForSelection(TextSelection selection) => child!
.getBoxesForSelection(selection, boxHeightStyle: BoxHeightStyle.strut); .getBoxesForSelection(selection, boxHeightStyle: BoxHeightStyle.max);
@override @override
void performLayout() { void performLayout() {

File diff suppressed because it is too large Load Diff

@ -38,13 +38,13 @@ mixin RawEditorStateSelectionDelegateMixin on EditorState
if (insertedText == pastePlainText && pastePlainText != '') { if (insertedText == pastePlainText && pastePlainText != '') {
final pos = start; final pos = start;
for (var i = 0; i < pasteStyle.length; i++) { for (var i = 0; i < pasteStyle.length; i++) {
final offset = pasteStyle[i].item1; final offset = pasteStyle[i].offset;
final style = pasteStyle[i].item2; final style = pasteStyle[i].value;
widget.controller.formatTextStyle( widget.controller.formatTextStyle(
pos + offset, pos + offset,
i == pasteStyle.length - 1 i == pasteStyle.length - 1
? pastePlainText.length - offset ? pastePlainText.length - offset
: pasteStyle[i + 1].item1, : pasteStyle[i + 1].offset,
style); style);
} }
} }
@ -150,14 +150,15 @@ mixin RawEditorStateSelectionDelegateMixin on EditorState
} }
@override @override
bool get cutEnabled => widget.toolbarOptions.cut && !widget.readOnly; bool get cutEnabled => widget.contextMenuBuilder != null && !widget.readOnly;
@override @override
bool get copyEnabled => widget.toolbarOptions.copy; bool get copyEnabled => widget.contextMenuBuilder != null;
@override @override
bool get pasteEnabled => widget.toolbarOptions.paste && !widget.readOnly; bool get pasteEnabled =>
widget.contextMenuBuilder != null && !widget.readOnly;
@override @override
bool get selectAllEnabled => widget.toolbarOptions.selectAll; bool get selectAllEnabled => widget.contextMenuBuilder != null;
} }

@ -59,16 +59,52 @@ mixin RawEditorStateTextInputClientMixin on EditorState
enableSuggestions: !widget.readOnly, enableSuggestions: !widget.readOnly,
keyboardAppearance: widget.keyboardAppearance, keyboardAppearance: widget.keyboardAppearance,
textCapitalization: widget.textCapitalization, textCapitalization: widget.textCapitalization,
allowedMimeTypes: widget.contentInsertionConfiguration == null
? const <String>[]
: widget.contentInsertionConfiguration!.allowedMimeTypes,
), ),
); );
_updateSizeAndTransform(); _updateSizeAndTransform();
//update IME position for Windows
_updateComposingRectIfNeeded();
//update IME position for Macos
_updateCaretRectIfNeeded();
_textInputConnection!.setEditingState(_lastKnownRemoteTextEditingValue!); _textInputConnection!.setEditingState(_lastKnownRemoteTextEditingValue!);
} }
_textInputConnection!.show(); _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. /// Closes input connection if it's currently open. Otherwise does nothing.
void closeConnectionIfNeeded() { void closeConnectionIfNeeded() {
if (!hasConnection) { if (!hasConnection) {
@ -283,7 +319,8 @@ mixin RawEditorStateTextInputClientMixin on EditorState
@override @override
void showAutocorrectionPromptRect(int start, int end) { void showAutocorrectionPromptRect(int start, int end) {
throw UnimplementedError(); // this is called VERY OFTEN when editing a document, no longer throw
// an exception
} }
@override @override
@ -300,14 +337,11 @@ mixin RawEditorStateTextInputClientMixin on EditorState
if (hasConnection) { if (hasConnection) {
// Asking for renderEditor.size here can cause errors if layout hasn't // Asking for renderEditor.size here can cause errors if layout hasn't
// occurred yet. So we schedule a post frame callback instead. // occurred yet. So we schedule a post frame callback instead.
SchedulerBinding.instance.addPostFrameCallback((_) {
if (!mounted) {
return;
}
final size = renderEditor.size; final size = renderEditor.size;
final transform = renderEditor.getTransformTo(null); final transform = renderEditor.getTransformTo(null);
_textInputConnection?.setEditableSizeAndTransform(size, transform); _textInputConnection?.setEditableSizeAndTransform(size, transform);
}); SchedulerBinding.instance
.addPostFrameCallback((_) => _updateSizeAndTransform());
} }
} }
} }

@ -4,18 +4,20 @@ class QuillBulletPoint extends StatelessWidget {
const QuillBulletPoint({ const QuillBulletPoint({
required this.style, required this.style,
required this.width, required this.width,
this.padding = 0,
Key? key, Key? key,
}) : super(key: key); }) : super(key: key);
final TextStyle style; final TextStyle style;
final double width; final double width;
final double padding;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Container( return Container(
alignment: AlignmentDirectional.topEnd, alignment: AlignmentDirectional.topEnd,
width: width, width: width,
padding: const EdgeInsetsDirectional.only(end: 13), padding: EdgeInsetsDirectional.only(end: padding),
child: Text('', style: style), child: Text('', style: style),
); );
} }

@ -29,9 +29,9 @@ class QuillNumberPoint extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
var s = index.toString(); var s = index.toString();
int? level = 0; int? level = 0;
if (!attrs.containsKey(Attribute.indent.key) && if (!attrs.containsKey(Attribute.indent.key) && indentLevelCounts.isEmpty) {
!indentLevelCounts.containsKey(1)) {
indentLevelCounts.clear(); indentLevelCounts.clear();
indentLevelCounts[0] = 1;
return Container( return Container(
alignment: AlignmentDirectional.topEnd, alignment: AlignmentDirectional.topEnd,
width: width, width: width,
@ -41,7 +41,7 @@ class QuillNumberPoint extends StatelessWidget {
} }
if (attrs.containsKey(Attribute.indent.key)) { if (attrs.containsKey(Attribute.indent.key)) {
level = attrs[Attribute.indent.key]!.value; level = attrs[Attribute.indent.key]!.value;
} else { } else if (!indentLevelCounts.containsKey(0)) {
// first level but is back from previous indent level // first level but is back from previous indent level
// supposed to be "2." // supposed to be "2."
indentLevelCounts[0] = 1; indentLevelCounts[0] = 1;

@ -1,15 +1,21 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/rendering.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/block.dart';
import '../models/documents/nodes/line.dart'; import '../models/documents/nodes/line.dart';
import '../models/structs/vertical_spacing.dart';
import '../utils/delta.dart'; import '../utils/delta.dart';
import 'box.dart'; import 'box.dart';
import 'controller.dart';
import 'cursor.dart'; import 'cursor.dart';
import 'default_styles.dart';
import 'delegate.dart'; import 'delegate.dart';
import 'editor.dart';
import 'link.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_line.dart';
import 'text_selection.dart'; import 'text_selection.dart';
@ -62,17 +68,19 @@ class EditableTextBlock extends StatelessWidget {
required this.linkActionPicker, required this.linkActionPicker,
required this.cursorCont, required this.cursorCont,
required this.indentLevelCounts, required this.indentLevelCounts,
required this.clearIndents,
required this.onCheckboxTap, required this.onCheckboxTap,
required this.readOnly, required this.readOnly,
this.onLaunchUrl, this.onLaunchUrl,
this.customStyleBuilder, this.customStyleBuilder,
this.customLinkPrefixes = const <String>[],
Key? key}); Key? key});
final Block block; final Block block;
final QuillController controller; final QuillController controller;
final TextDirection textDirection; final TextDirection textDirection;
final double scrollBottomInset; final double scrollBottomInset;
final Tuple2 verticalSpacing; final VerticalSpacing verticalSpacing;
final TextSelection textSelection; final TextSelection textSelection;
final Color color; final Color color;
final DefaultStyles? styles; final DefaultStyles? styles;
@ -85,8 +93,10 @@ class EditableTextBlock extends StatelessWidget {
final CustomStyleBuilder? customStyleBuilder; final CustomStyleBuilder? customStyleBuilder;
final CursorCont cursorCont; final CursorCont cursorCont;
final Map<int, int> indentLevelCounts; final Map<int, int> indentLevelCounts;
final bool clearIndents;
final Function(int, bool) onCheckboxTap; final Function(int, bool) onCheckboxTap;
final bool readOnly; final bool readOnly;
final List<String> customLinkPrefixes;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -96,12 +106,12 @@ class EditableTextBlock extends StatelessWidget {
return _EditableBlock( return _EditableBlock(
block: block, block: block,
textDirection: textDirection, textDirection: textDirection,
padding: verticalSpacing as Tuple2<double, double>, padding: verticalSpacing,
scrollBottomInset: scrollBottomInset, scrollBottomInset: scrollBottomInset,
decoration: _getDecorationForBlock(block, defaultStyles) ?? decoration: _getDecorationForBlock(block, defaultStyles) ??
const BoxDecoration(), const BoxDecoration(),
contentPadding: contentPadding, contentPadding: contentPadding,
children: _buildChildren(context, indentLevelCounts)); children: _buildChildren(context, indentLevelCounts, clearIndents));
} }
BoxDecoration? _getDecorationForBlock( BoxDecoration? _getDecorationForBlock(
@ -116,11 +126,14 @@ class EditableTextBlock extends StatelessWidget {
return null; return null;
} }
List<Widget> _buildChildren( List<Widget> _buildChildren(BuildContext context,
BuildContext context, Map<int, int> indentLevelCounts) { Map<int, int> indentLevelCounts, bool clearIndents) {
final defaultStyles = QuillStyles.getStyles(context, false); final defaultStyles = QuillStyles.getStyles(context, false);
final count = block.children.length; final count = block.children.length;
final children = <Widget>[]; final children = <Widget>[];
if (clearIndents) {
indentLevelCounts.clear();
}
var index = 0; var index = 0;
for (final line in Iterable.castFrom<dynamic, Line>(block.children)) { for (final line in Iterable.castFrom<dynamic, Line>(block.children)) {
index++; index++;
@ -137,15 +150,16 @@ class EditableTextBlock extends StatelessWidget {
controller: controller, controller: controller,
linkActionPicker: linkActionPicker, linkActionPicker: linkActionPicker,
onLaunchUrl: onLaunchUrl, onLaunchUrl: onLaunchUrl,
customLinkPrefixes: customLinkPrefixes,
), ),
_getIndentWidth(), _getIndentWidth(context),
_getSpacingForLine(line, index, count, defaultStyles), _getSpacingForLine(line, index, count, defaultStyles),
textDirection, textDirection,
textSelection, textSelection,
color, color,
enableInteractiveSelection, enableInteractiveSelection,
hasFocus, hasFocus,
MediaQuery.of(context).devicePixelRatio, View.of(context).devicePixelRatio,
cursorCont); cursorCont);
final nodeTextDirection = getDirectionOfNode(line); final nodeTextDirection = getDirectionOfNode(line);
children.add(Directionality( children.add(Directionality(
@ -156,45 +170,48 @@ class EditableTextBlock extends StatelessWidget {
Widget? _buildLeading(BuildContext context, Line line, int index, Widget? _buildLeading(BuildContext context, Line line, int index,
Map<int, int> indentLevelCounts, int count) { Map<int, int> 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; final attrs = line.style.attributes;
if (attrs[Attribute.list.key] == Attribute.ol) { if (attrs[Attribute.list.key] == Attribute.ol) {
return QuillNumberPoint( return QuillNumberPoint(
index: index, index: index,
indentLevelCounts: indentLevelCounts, indentLevelCounts: indentLevelCounts,
count: count, count: count,
style: defaultStyles!.leading!.style, style: defaultStyles.leading!.style,
attrs: attrs, attrs: attrs,
width: 32, width: fontSize * 2,
padding: 8, padding: fontSize / 2,
); );
} }
if (attrs[Attribute.list.key] == Attribute.ul) { if (attrs[Attribute.list.key] == Attribute.ul) {
return QuillBulletPoint( return QuillBulletPoint(
style: style:
defaultStyles!.leading!.style.copyWith(fontWeight: FontWeight.bold), defaultStyles.leading!.style.copyWith(fontWeight: FontWeight.bold),
width: 32, width: fontSize * 2,
padding: fontSize / 2,
); );
} }
if (attrs[Attribute.list.key] == Attribute.checked) { if (attrs[Attribute.list.key] == Attribute.checked) {
return CheckboxPoint( return CheckboxPoint(
size: 14, size: fontSize,
value: true, value: true,
enabled: !readOnly, enabled: !readOnly,
onChanged: (checked) => onCheckboxTap(line.documentOffset, checked), onChanged: (checked) => onCheckboxTap(line.documentOffset, checked),
uiBuilder: defaultStyles?.lists?.checkboxUIBuilder, uiBuilder: defaultStyles.lists?.checkboxUIBuilder,
); );
} }
if (attrs[Attribute.list.key] == Attribute.unchecked) { if (attrs[Attribute.list.key] == Attribute.unchecked) {
return CheckboxPoint( return CheckboxPoint(
size: 14, size: fontSize,
value: false, value: false,
enabled: !readOnly, enabled: !readOnly,
onChanged: (checked) => onCheckboxTap(line.documentOffset, checked), onChanged: (checked) => onCheckboxTap(line.documentOffset, checked),
uiBuilder: defaultStyles?.lists?.checkboxUIBuilder, uiBuilder: defaultStyles.lists?.checkboxUIBuilder,
); );
} }
@ -203,41 +220,43 @@ class EditableTextBlock extends StatelessWidget {
index: index, index: index,
indentLevelCounts: indentLevelCounts, indentLevelCounts: indentLevelCounts,
count: count, count: count,
style: defaultStyles!.code!.style style: defaultStyles.code!.style
.copyWith(color: defaultStyles.code!.style.color!.withOpacity(0.4)), .copyWith(color: defaultStyles.code!.style.color!.withOpacity(0.4)),
width: 32, width: fontSize * 2,
attrs: attrs, attrs: attrs,
padding: 16, padding: fontSize,
withDot: false, withDot: false,
); );
} }
return null; 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 attrs = block.style.attributes;
final indent = attrs[Attribute.indent.key]; final indent = attrs[Attribute.indent.key];
var extraIndent = 0.0; var extraIndent = 0.0;
if (indent != null && indent.value != null) { if (indent != null && indent.value != null) {
extraIndent = 16.0 * indent.value; extraIndent = fontSize * indent.value;
} }
if (attrs.containsKey(Attribute.blockQuote.key)) { if (attrs.containsKey(Attribute.blockQuote.key)) {
return 16.0 + extraIndent; return fontSize + extraIndent;
} }
var baseIndent = 0.0; var baseIndent = 0.0;
if (attrs.containsKey(Attribute.list.key) || if (attrs.containsKey(Attribute.list.key) ||
attrs.containsKey(Attribute.codeBlock.key)) { attrs.containsKey(Attribute.codeBlock.key)) {
baseIndent = 32.0; baseIndent = fontSize * 2;
} }
return baseIndent + extraIndent; return baseIndent + extraIndent;
} }
Tuple2 _getSpacingForLine( VerticalSpacing _getSpacingForLine(
Line node, int index, int count, DefaultStyles? defaultStyles) { Line node, int index, int count, DefaultStyles? defaultStyles) {
var top = 0.0, bottom = 0.0; var top = 0.0, bottom = 0.0;
@ -246,22 +265,22 @@ class EditableTextBlock extends StatelessWidget {
final level = attrs[Attribute.header.key]!.value; final level = attrs[Attribute.header.key]!.value;
switch (level) { switch (level) {
case 1: case 1:
top = defaultStyles!.h1!.verticalSpacing.item1; top = defaultStyles!.h1!.verticalSpacing.top;
bottom = defaultStyles.h1!.verticalSpacing.item2; bottom = defaultStyles.h1!.verticalSpacing.bottom;
break; break;
case 2: case 2:
top = defaultStyles!.h2!.verticalSpacing.item1; top = defaultStyles!.h2!.verticalSpacing.top;
bottom = defaultStyles.h2!.verticalSpacing.item2; bottom = defaultStyles.h2!.verticalSpacing.bottom;
break; break;
case 3: case 3:
top = defaultStyles!.h3!.verticalSpacing.item1; top = defaultStyles!.h3!.verticalSpacing.top;
bottom = defaultStyles.h3!.verticalSpacing.item2; bottom = defaultStyles.h3!.verticalSpacing.bottom;
break; break;
default: default:
throw 'Invalid level $level'; throw 'Invalid level $level';
} }
} else { } else {
late Tuple2 lineSpacing; late VerticalSpacing lineSpacing;
if (attrs.containsKey(Attribute.blockQuote.key)) { if (attrs.containsKey(Attribute.blockQuote.key)) {
lineSpacing = defaultStyles!.quote!.lineSpacing; lineSpacing = defaultStyles!.quote!.lineSpacing;
} else if (attrs.containsKey(Attribute.indent.key)) { } else if (attrs.containsKey(Attribute.indent.key)) {
@ -276,8 +295,8 @@ class EditableTextBlock extends StatelessWidget {
// use paragraph linespacing as a default // use paragraph linespacing as a default
lineSpacing = defaultStyles!.paragraph!.lineSpacing; lineSpacing = defaultStyles!.paragraph!.lineSpacing;
} }
top = lineSpacing.item1; top = lineSpacing.top;
bottom = lineSpacing.item2; bottom = lineSpacing.bottom;
} }
if (index == 1) { if (index == 1) {
@ -288,7 +307,7 @@ class EditableTextBlock extends StatelessWidget {
bottom = 0.0; bottom = 0.0;
} }
return Tuple2(top, bottom); return VerticalSpacing(top, bottom);
} }
} }
@ -582,7 +601,7 @@ class RenderEditableTextBlock extends RenderEditableContainerBox
} }
class _EditableBlock extends MultiChildRenderObjectWidget { class _EditableBlock extends MultiChildRenderObjectWidget {
_EditableBlock( const _EditableBlock(
{required this.block, {required this.block,
required this.textDirection, required this.textDirection,
required this.padding, required this.padding,
@ -595,13 +614,13 @@ class _EditableBlock extends MultiChildRenderObjectWidget {
final Block block; final Block block;
final TextDirection textDirection; final TextDirection textDirection;
final Tuple2<double, double> padding; final VerticalSpacing padding;
final double scrollBottomInset; final double scrollBottomInset;
final Decoration decoration; final Decoration decoration;
final EdgeInsets? contentPadding; final EdgeInsets? contentPadding;
EdgeInsets get _padding => 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; EdgeInsets get _contentPadding => contentPadding ?? EdgeInsets.zero;

@ -6,16 +6,17 @@ import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart'; import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:tuple/tuple.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
import '../models/documents/attribute.dart'; import '../models/documents/attribute.dart';
import '../models/documents/nodes/container.dart' as container_node; 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';
import '../models/documents/nodes/leaf.dart' as leaf; import '../models/documents/nodes/leaf.dart' as leaf;
import '../models/documents/nodes/line.dart'; import '../models/documents/nodes/line.dart';
import '../models/documents/nodes/node.dart'; import '../models/documents/nodes/node.dart';
import '../models/documents/style.dart'; import '../models/documents/style.dart';
import '../models/structs/vertical_spacing.dart';
import '../utils/color.dart'; import '../utils/color.dart';
import '../utils/font.dart'; import '../utils/font.dart';
import '../utils/platform.dart'; import '../utils/platform.dart';
@ -40,6 +41,8 @@ class TextLine extends StatefulWidget {
required this.linkActionPicker, required this.linkActionPicker,
this.textDirection, this.textDirection,
this.customStyleBuilder, this.customStyleBuilder,
this.customRecognizerBuilder,
this.customLinkPrefixes = const <String>[],
Key? key, Key? key,
}) : super(key: key); }) : super(key: key);
@ -50,8 +53,10 @@ class TextLine extends StatefulWidget {
final bool readOnly; final bool readOnly;
final QuillController controller; final QuillController controller;
final CustomStyleBuilder? customStyleBuilder; final CustomStyleBuilder? customStyleBuilder;
final CustomRecognizerBuilder? customRecognizerBuilder;
final ValueChanged<String>? onLaunchUrl; final ValueChanged<String>? onLaunchUrl;
final LinkActionPicker linkActionPicker; final LinkActionPicker linkActionPicker;
final List<String> customLinkPrefixes;
@override @override
State<TextLine> createState() => _TextLineState(); State<TextLine> createState() => _TextLineState();
@ -132,18 +137,24 @@ class _TextLineState extends State<TextLine> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
assert(debugCheckHasMediaQuery(context)); assert(debugCheckHasMediaQuery(context));
if (widget.line.hasEmbed && widget.line.childCount == 1) { if (widget.line.hasEmbed && widget.line.childCount == 1) {
// For video, it is always single child // Single child embeds can be expanded
final embed = widget.line.children.single as Embed; 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( return EmbedProxy(
widget.embedBuilder( embedBuilder.build(context, widget.controller, embed, widget.readOnly,
context, false, lineStyle),
widget.controller,
embed,
widget.readOnly,
),
); );
} }
}
final textSpan = _getTextSpanForWholeLine(context); final textSpan = _getTextSpanForWholeLine(context);
final strutStyle = StrutStyle.fromTextStyle(textSpan.style!); final strutStyle = StrutStyle.fromTextStyle(textSpan.style!);
final textAlign = _getTextAlign(); final textAlign = _getTextAlign();
@ -173,24 +184,30 @@ class _TextLineState extends State<TextLine> {
// The line could contain more than one Embed & more than one Text // The line could contain more than one Embed & more than one Text
final textSpanChildren = <InlineSpan>[]; final textSpanChildren = <InlineSpan>[];
var textNodes = LinkedList<Node>(); var textNodes = LinkedList<Node>();
for (final child in widget.line.children) { for (var child in widget.line.children) {
if (child is Embed) { if (child is Embed) {
if (textNodes.isNotEmpty) { if (textNodes.isNotEmpty) {
textSpanChildren textSpanChildren
.add(_buildTextSpan(widget.styles, textNodes, lineStyle)); .add(_buildTextSpan(widget.styles, textNodes, lineStyle));
textNodes = LinkedList<Node>(); textNodes = LinkedList<Node>();
} }
// Here it should be image // Creates correct node for custom embed
final embed = WidgetSpan( if (child.value.type == BlockEmbed.customType) {
child: EmbedProxy( child = Embed(CustomBlockEmbed.fromJsonString(child.value.data))
widget.embedBuilder( ..applyStyle(child.style);
}
final embedBuilder = widget.embedBuilder(child);
final embedWidget = EmbedProxy(
embedBuilder.build(
context, context,
widget.controller, widget.controller,
child, child,
widget.readOnly, widget.readOnly,
), true,
lineStyle,
), ),
); );
final embed = embedBuilder.buildWidgetSpan(embedWidget);
textSpanChildren.add(embed); textSpanChildren.add(embed);
continue; continue;
} }
@ -262,7 +279,7 @@ class _TextLineState extends State<TextLine> {
toMerge = defaultStyles.quote!.style; toMerge = defaultStyles.quote!.style;
} else if (block == Attribute.codeBlock) { } else if (block == Attribute.codeBlock) {
toMerge = defaultStyles.code!.style; toMerge = defaultStyles.code!.style;
} else if (block == Attribute.list) { } else if (block?.key == Attribute.list.key) {
toMerge = defaultStyles.lists!.style; toMerge = defaultStyles.lists!.style;
} }
@ -295,12 +312,14 @@ class _TextLineState extends State<TextLine> {
final isLink = nodeStyle.containsKey(Attribute.link.key) && final isLink = nodeStyle.containsKey(Attribute.link.key) &&
nodeStyle.attributes[Attribute.link.key]!.value != null; nodeStyle.attributes[Attribute.link.key]!.value != null;
final recognizer = _getRecognizer(node, isLink);
return TextSpan( return TextSpan(
text: textNode.value, text: textNode.value,
style: _getInlineTextStyle( style: _getInlineTextStyle(
textNode, defaultStyles, nodeStyle, lineStyle, isLink), textNode, defaultStyles, nodeStyle, lineStyle, isLink),
recognizer: isLink && canLaunchLinks ? _getRecognizer(node) : null, recognizer: recognizer,
mouseCursor: isLink && canLaunchLinks ? SystemMouseCursors.click : null, mouseCursor: (recognizer != null) ? SystemMouseCursors.click : null,
); );
} }
@ -334,6 +353,14 @@ class _TextLineState extends State<TextLine> {
} }
}); });
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)) { if (nodeStyle.containsKey(Attribute.inlineCode.key)) {
res = _merge(res, defaultStyles.inlineCode!.styleFor(lineStyle)); res = _merge(res, defaultStyles.inlineCode!.styleFor(lineStyle));
} }
@ -380,11 +407,29 @@ class _TextLineState extends State<TextLine> {
return res; return res;
} }
GestureRecognizer _getRecognizer(Node segment) { GestureRecognizer? _getRecognizer(Node segment, bool isLink) {
if (_linkRecognizers.containsKey(segment)) { if (_linkRecognizers.containsKey(segment)) {
return _linkRecognizers[segment]!; return _linkRecognizers[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;
}
});
}
if (_linkRecognizers.containsKey(segment)) {
return _linkRecognizers[segment]!;
}
if (isLink && canLaunchLinks) {
if (isDesktop() || widget.readOnly) { if (isDesktop() || widget.readOnly) {
_linkRecognizers[segment] = TapGestureRecognizer() _linkRecognizers[segment] = TapGestureRecognizer()
..onTap = () => _tapNodeLink(segment); ..onTap = () => _tapNodeLink(segment);
@ -392,7 +437,8 @@ class _TextLineState extends State<TextLine> {
_linkRecognizers[segment] = LongPressGestureRecognizer() _linkRecognizers[segment] = LongPressGestureRecognizer()
..onLongPress = () => _longPressLink(segment); ..onLongPress = () => _longPressLink(segment);
} }
return _linkRecognizers[segment]!; }
return _linkRecognizers[segment];
} }
Future<void> _launchUrl(String url) async { Future<void> _launchUrl(String url) async {
@ -414,7 +460,7 @@ class _TextLineState extends State<TextLine> {
launchUrl ??= _launchUrl; launchUrl ??= _launchUrl;
link = link.trim(); link = link.trim();
if (!linkPrefixes if (!(widget.customLinkPrefixes + linkPrefixes)
.any((linkPrefix) => link!.toLowerCase().startsWith(linkPrefix))) { .any((linkPrefix) => link!.toLowerCase().startsWith(linkPrefix))) {
link = 'https://$link'; link = 'https://$link';
} }
@ -476,7 +522,7 @@ class EditableTextLine extends RenderObjectWidget {
final Widget? leading; final Widget? leading;
final Widget body; final Widget body;
final double indentWidth; final double indentWidth;
final Tuple2 verticalSpacing; final VerticalSpacing verticalSpacing;
final TextDirection textDirection; final TextDirection textDirection;
final TextSelection textSelection; final TextSelection textSelection;
final Color color; final Color color;
@ -526,8 +572,8 @@ class EditableTextLine extends RenderObjectWidget {
EdgeInsetsGeometry _getPadding() { EdgeInsetsGeometry _getPadding() {
return EdgeInsetsDirectional.only( return EdgeInsetsDirectional.only(
start: indentWidth, start: indentWidth,
top: verticalSpacing.item1, top: verticalSpacing.top,
bottom: verticalSpacing.item2); bottom: verticalSpacing.bottom);
} }
} }
@ -1033,9 +1079,16 @@ class RenderEditableTextLine extends RenderEditableBox {
@override @override
void paint(PaintingContext context, Offset offset) { void paint(PaintingContext context, Offset offset) {
if (_leading != null) { if (_leading != null) {
if (textDirection == TextDirection.ltr) {
final parentData = _leading!.parentData as BoxParentData; final parentData = _leading!.parentData as BoxParentData;
final effectiveOffset = offset + parentData.offset; final effectiveOffset = offset + parentData.offset;
context.paintChild(_leading!, effectiveOffset); 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) { if (_body != null) {
@ -1091,6 +1144,18 @@ class RenderEditableTextLine extends RenderEditableBox {
_selectedRects ??= _body!.getBoxesForSelection( _selectedRects ??= _body!.getBoxesForSelection(
local, 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); _paintSelection(context, effectiveOffset);
} }
} }

@ -4,7 +4,6 @@ import 'dart:math' as math;
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart'; import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/scheduler.dart'; import 'package:flutter/scheduler.dart';
import '../models/documents/nodes/node.dart'; import '../models/documents/nodes/node.dart';
@ -70,7 +69,6 @@ class EditorTextSelectionOverlay {
EditorTextSelectionOverlay({ EditorTextSelectionOverlay({
required this.value, required this.value,
required this.context, required this.context,
required this.toolbarLayerLink,
required this.startHandleLayerLink, required this.startHandleLayerLink,
required this.endHandleLayerLink, required this.endHandleLayerLink,
required this.renderObject, required this.renderObject,
@ -78,14 +76,18 @@ class EditorTextSelectionOverlay {
required this.selectionCtrls, required this.selectionCtrls,
required this.selectionDelegate, required this.selectionDelegate,
required this.clipboardStatus, required this.clipboardStatus,
required this.contextMenuBuilder,
this.onSelectionHandleTapped, this.onSelectionHandleTapped,
this.dragStartBehavior = DragStartBehavior.start, this.dragStartBehavior = DragStartBehavior.start,
this.handlesVisible = false, this.handlesVisible = false,
}) { }) {
final overlay = Overlay.of(context, rootOverlay: true)!; // Clipboard status is only checked on first instance of
// ClipboardStatusNotifier
_toolbarController = AnimationController( // if state has changed after creation, but prior to
duration: const Duration(milliseconds: 150), vsync: overlay); // our listener being created
// we won't know the status unless there is forced update
// i.e. occasionally no paste
clipboardStatus.update();
} }
TextEditingValue value; TextEditingValue value;
@ -115,10 +117,6 @@ class EditorTextSelectionOverlay {
/// Debugging information for explaining why the [Overlay] is required. /// Debugging information for explaining why the [Overlay] is required.
final Widget debugRequiredFor; 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 /// The objects supplied to the [CompositedTransformTarget] that wraps the
/// location of start selection handle. /// location of start selection handle.
final LayerLink startHandleLayerLink; final LayerLink startHandleLayerLink;
@ -137,6 +135,11 @@ class EditorTextSelectionOverlay {
/// text field. /// text field.
final TextSelectionDelegate selectionDelegate; 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. /// Determines the way that drag start behavior is handled.
/// ///
/// If set to [DragStartBehavior.start], handle drag behavior will /// 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 /// Useful because the actual value of the clipboard can only be checked
/// asynchronously (see [Clipboard.getData]). /// asynchronously (see [Clipboard.getData]).
final ClipboardStatusNotifier clipboardStatus; final ClipboardStatusNotifier clipboardStatus;
late AnimationController _toolbarController;
/// A pair of handles. If this is non-null, there are always 2, though the /// A pair of handles. If this is non-null, there are always 2, though the
/// second is hidden when the selection is collapsed. /// second is hidden when the selection is collapsed.
@ -181,8 +183,6 @@ class EditorTextSelectionOverlay {
TextSelection get _selection => value.selection; TextSelection get _selection => value.selection;
Animation<double> get _toolbarOpacity => _toolbarController.view;
void setHandlesVisible(bool visible) { void setHandlesVisible(bool visible) {
if (handlesVisible == visible) { if (handlesVisible == visible) {
return; return;
@ -213,7 +213,6 @@ class EditorTextSelectionOverlay {
/// To hide the whole overlay, see [hide]. /// To hide the whole overlay, see [hide].
void hideToolbar() { void hideToolbar() {
assert(toolbar != null); assert(toolbar != null);
_toolbarController.stop();
toolbar!.remove(); toolbar!.remove();
toolbar = null; toolbar = null;
} }
@ -221,10 +220,12 @@ class EditorTextSelectionOverlay {
/// Shows the toolbar by inserting it into the [context]'s overlay. /// Shows the toolbar by inserting it into the [context]'s overlay.
void showToolbar() { void showToolbar() {
assert(toolbar == null); assert(toolbar == null);
toolbar = OverlayEntry(builder: _buildToolbar); if (contextMenuBuilder == null) return;
Overlay.of(context, rootOverlay: true, debugRequiredFor: debugRequiredFor)! toolbar = OverlayEntry(builder: (context) {
return contextMenuBuilder!(context);
});
Overlay.of(context, rootOverlay: true, debugRequiredFor: debugRequiredFor)
.insert(toolbar!); .insert(toolbar!);
_toolbarController.forward(from: 0);
// make sure handles are visible as well // make sure handles are visible as well
if (_handles == null) { if (_handles == null) {
@ -312,63 +313,6 @@ class EditorTextSelectionOverlay {
..bringIntoView(textPosition); ..bringIntoView(textPosition);
} }
Widget _buildToolbar(BuildContext context) {
// Find the horizontal midpoint, just above the selected text.
List<TextSelectionPoint> 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]) { void markNeedsBuild([Duration? duration]) {
if (_handles != null) { if (_handles != null) {
_handles![0].markNeedsBuild(); _handles![0].markNeedsBuild();
@ -392,7 +336,6 @@ class EditorTextSelectionOverlay {
/// Final cleanup. /// Final cleanup.
void dispose() { void dispose() {
hide(); hide();
_toolbarController.dispose();
} }
/// Builds the handles by inserting them into the [context]'s overlay. /// Builds the handles by inserting them into the [context]'s overlay.
@ -407,7 +350,7 @@ class EditorTextSelectionOverlay {
_buildHandle(context, _TextSelectionHandlePosition.END)), _buildHandle(context, _TextSelectionHandlePosition.END)),
]; ];
Overlay.of(context, rootOverlay: true, debugRequiredFor: debugRequiredFor)! Overlay.of(context, rootOverlay: true, debugRequiredFor: debugRequiredFor)
.insertAll(_handles!); .insertAll(_handles!);
} }
@ -707,6 +650,7 @@ class EditorTextSelectionGestureDetector extends StatefulWidget {
this.onDragSelectionUpdate, this.onDragSelectionUpdate,
this.onDragSelectionEnd, this.onDragSelectionEnd,
this.behavior, this.behavior,
this.detectWordBoundary = true,
Key? key, Key? key,
}) : super(key: key); }) : super(key: key);
@ -769,7 +713,7 @@ class EditorTextSelectionGestureDetector extends StatefulWidget {
/// The frequency of calls is throttled to avoid excessive text layout /// The frequency of calls is throttled to avoid excessive text layout
/// operations in text fields. The throttling is controlled by the constant /// operations in text fields. The throttling is controlled by the constant
/// [_kDragSelectionUpdateThrottle]. /// [_kDragSelectionUpdateThrottle].
final DragSelectionUpdateCallback? onDragSelectionUpdate; final GestureDragUpdateCallback? onDragSelectionUpdate;
/// Called when a mouse that was previously dragging is released. /// Called when a mouse that was previously dragging is released.
final GestureDragEndCallback? onDragSelectionEnd; final GestureDragEndCallback? onDragSelectionEnd;
@ -782,6 +726,8 @@ class EditorTextSelectionGestureDetector extends StatefulWidget {
/// Child below this widget. /// Child below this widget.
final Widget child; final Widget child;
final bool detectWordBoundary;
@override @override
State<StatefulWidget> createState() => State<StatefulWidget> createState() =>
_EditorTextSelectionGestureDetectorState(); _EditorTextSelectionGestureDetectorState();
@ -911,7 +857,8 @@ class _EditorTextSelectionGestureDetectorState
assert(_lastDragUpdateDetails != null); assert(_lastDragUpdateDetails != null);
if (widget.onDragSelectionUpdate != null) { if (widget.onDragSelectionUpdate != null) {
widget.onDragSelectionUpdate!( widget.onDragSelectionUpdate!(
_lastDragStartDetails!, _lastDragUpdateDetails!); //_lastDragStartDetails!,
_lastDragUpdateDetails!);
} }
_dragUpdateThrottleTimer = null; _dragUpdateThrottleTimer = null;
_lastDragUpdateDetails = null; _lastDragUpdateDetails = null;

@ -6,18 +6,18 @@ import '../models/themes/quill_custom_button.dart';
import '../models/themes/quill_dialog_theme.dart'; import '../models/themes/quill_dialog_theme.dart';
import '../models/themes/quill_icon_theme.dart'; import '../models/themes/quill_icon_theme.dart';
import '../translations/toolbar.i18n.dart'; import '../translations/toolbar.i18n.dart';
import '../utils/font.dart';
import 'controller.dart'; import 'controller.dart';
import 'embeds.dart'; import 'embeds.dart';
import 'toolbar/arrow_indicated_button_list.dart'; import 'toolbar/arrow_indicated_button_list.dart';
import 'toolbar/clear_format_button.dart'; import 'toolbar/clear_format_button.dart';
import 'toolbar/color_button.dart'; import 'toolbar/color_button.dart';
import 'toolbar/custom_button.dart';
import 'toolbar/enum.dart';
import 'toolbar/history_button.dart'; import 'toolbar/history_button.dart';
import 'toolbar/indent_button.dart'; import 'toolbar/indent_button.dart';
import 'toolbar/link_style_button.dart'; import 'toolbar/link_style_button.dart';
import 'toolbar/quill_font_family_button.dart'; import 'toolbar/quill_font_family_button.dart';
import 'toolbar/quill_font_size_button.dart'; import 'toolbar/quill_font_size_button.dart';
import 'toolbar/quill_icon_button.dart';
import 'toolbar/search_button.dart'; import 'toolbar/search_button.dart';
import 'toolbar/select_alignment_button.dart'; import 'toolbar/select_alignment_button.dart';
import 'toolbar/select_header_style_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/clear_format_button.dart';
export 'toolbar/color_button.dart'; export 'toolbar/color_button.dart';
export 'toolbar/custom_button.dart';
export 'toolbar/history_button.dart'; export 'toolbar/history_button.dart';
export 'toolbar/indent_button.dart'; export 'toolbar/indent_button.dart';
export 'toolbar/link_style_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_font_size_button.dart';
export 'toolbar/quill_icon_button.dart'; export 'toolbar/quill_icon_button.dart';
export 'toolbar/search_button.dart';
export 'toolbar/select_alignment_button.dart'; export 'toolbar/select_alignment_button.dart';
export 'toolbar/select_header_style_button.dart'; export 'toolbar/select_header_style_button.dart';
export 'toolbar/toggle_check_list_button.dart'; export 'toolbar/toggle_check_list_button.dart';
export 'toolbar/toggle_style_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; 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; 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 { class QuillToolbar extends StatelessWidget implements PreferredSizeWidget {
const QuillToolbar({ const QuillToolbar({
required this.children, required this.children,
this.toolbarHeight = 36, this.axis = Axis.horizontal,
this.toolbarSize = kDefaultIconSize * 2,
this.toolbarSectionSpacing = kToolbarSectionSpacing,
this.toolbarIconAlignment = WrapAlignment.center, this.toolbarIconAlignment = WrapAlignment.center,
this.toolbarSectionSpacing = 4, this.toolbarIconCrossAlignment = WrapCrossAlignment.center,
this.multiRowsDisplay = true, this.multiRowsDisplay = true,
this.color, this.color,
this.customButtons = const [], this.customButtons = const [],
this.locale, this.locale,
VoidCallback? afterButtonPressed, VoidCallback? afterButtonPressed,
this.sectionDividerColor,
this.sectionDividerSpace,
Key? key, Key? key,
}) : super(key: key); }) : super(key: key);
factory QuillToolbar.basic({ factory QuillToolbar.basic({
required QuillController controller, required QuillController controller,
Axis axis = Axis.horizontal,
double toolbarIconSize = kDefaultIconSize, double toolbarIconSize = kDefaultIconSize,
double toolbarSectionSpacing = 4, double toolbarSectionSpacing = kToolbarSectionSpacing,
WrapAlignment toolbarIconAlignment = WrapAlignment.center, WrapAlignment toolbarIconAlignment = WrapAlignment.center,
WrapCrossAlignment toolbarIconCrossAlignment = WrapCrossAlignment.center,
bool multiRowsDisplay = true,
bool showDividers = true, bool showDividers = true,
bool showFontFamily = true, bool showFontFamily = true,
bool showFontSize = true, bool showFontSize = true,
@ -88,9 +102,10 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget {
bool showLink = true, bool showLink = true,
bool showUndo = true, bool showUndo = true,
bool showRedo = true, bool showRedo = true,
bool multiRowsDisplay = true,
bool showDirection = false, bool showDirection = false,
bool showSearchButton = true, bool showSearchButton = true,
bool showSubscript = true,
bool showSuperscript = true,
List<QuillCustomButton> customButtons = const [], List<QuillCustomButton> customButtons = const [],
///Map of font sizes in string ///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. /// Is called after whatever logic the button performs has run.
VoidCallback? afterButtonPressed, VoidCallback? afterButtonPressed,
///Map of tooltips for toolbar buttons
///
///The example is:
///```dart
/// tooltips = <ToolbarButtons, String>{
/// ToolbarButtons.undo: 'Undo',
/// ToolbarButtons.redo: 'Redo',
/// }
///
///```
///
/// To disable tooltips just pass empty map as well.
Map<ToolbarButtons, String>? tooltips,
/// The locale to use for the editor toolbar, defaults to system locale /// The locale to use for the editor toolbar, defaults to system locale
/// More at https://github.com/singerdmx/flutter-quill#translation /// More at https://github.com/singerdmx/flutter-quill#translation
Locale? locale, 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, Key? key,
}) { }) {
final isButtonGroupShown = [ final isButtonGroupShown = [
@ -162,14 +200,52 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget {
'Nunito': 'nunito', 'Nunito': 'nunito',
'Pacifico': 'pacifico', 'Pacifico': 'pacifico',
'Roboto Mono': 'roboto-mono', 'Roboto Mono': 'roboto-mono',
'Clear': 'Clear' 'Clear'.i18n: 'Clear'
};
//default button tooltips
final buttonTooltips = tooltips ??
<ToolbarButtons, String>{
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( return QuillToolbar(
key: key, key: key,
toolbarHeight: toolbarIconSize * 2, axis: axis,
color: color,
toolbarSize: toolbarIconSize * 2,
toolbarSectionSpacing: toolbarSectionSpacing, toolbarSectionSpacing: toolbarSectionSpacing,
toolbarIconAlignment: toolbarIconAlignment, toolbarIconAlignment: toolbarIconAlignment,
toolbarIconCrossAlignment: toolbarIconCrossAlignment,
multiRowsDisplay: multiRowsDisplay, multiRowsDisplay: multiRowsDisplay,
customButtons: customButtons, customButtons: customButtons,
locale: locale, locale: locale,
@ -179,6 +255,7 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget {
HistoryButton( HistoryButton(
icon: Icons.undo_outlined, icon: Icons.undo_outlined,
iconSize: toolbarIconSize, iconSize: toolbarIconSize,
tooltip: buttonTooltips[ToolbarButtons.undo],
controller: controller, controller: controller,
undo: true, undo: true,
iconTheme: iconTheme, iconTheme: iconTheme,
@ -188,6 +265,7 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget {
HistoryButton( HistoryButton(
icon: Icons.redo_outlined, icon: Icons.redo_outlined,
iconSize: toolbarIconSize, iconSize: toolbarIconSize,
tooltip: buttonTooltips[ToolbarButtons.redo],
controller: controller, controller: controller,
undo: false, undo: false,
iconTheme: iconTheme, iconTheme: iconTheme,
@ -197,23 +275,9 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget {
QuillFontFamilyButton( QuillFontFamilyButton(
iconTheme: iconTheme, iconTheme: iconTheme,
iconSize: toolbarIconSize, iconSize: toolbarIconSize,
tooltip: buttonTooltips[ToolbarButtons.fontFamily],
attribute: Attribute.font, attribute: Attribute.font,
controller: controller, controller: controller,
items: [
for (MapEntry<String, String> fontFamily in fontFamilies.entries)
PopupMenuItem<String>(
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, rawItemsMap: fontFamilies,
afterButtonPressed: afterButtonPressed, afterButtonPressed: afterButtonPressed,
), ),
@ -221,22 +285,9 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget {
QuillFontSizeButton( QuillFontSizeButton(
iconTheme: iconTheme, iconTheme: iconTheme,
iconSize: toolbarIconSize, iconSize: toolbarIconSize,
tooltip: buttonTooltips[ToolbarButtons.fontSize],
attribute: Attribute.size, attribute: Attribute.size,
controller: controller, controller: controller,
items: [
for (MapEntry<String, String> fontSize in fontSizes.entries)
PopupMenuItem<String>(
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, rawItemsMap: fontSizes,
afterButtonPressed: afterButtonPressed, afterButtonPressed: afterButtonPressed,
), ),
@ -245,6 +296,27 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget {
attribute: Attribute.bold, attribute: Attribute.bold,
icon: Icons.format_bold, icon: Icons.format_bold,
iconSize: toolbarIconSize, 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, controller: controller,
iconTheme: iconTheme, iconTheme: iconTheme,
afterButtonPressed: afterButtonPressed, afterButtonPressed: afterButtonPressed,
@ -254,6 +326,7 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget {
attribute: Attribute.italic, attribute: Attribute.italic,
icon: Icons.format_italic, icon: Icons.format_italic,
iconSize: toolbarIconSize, iconSize: toolbarIconSize,
tooltip: buttonTooltips[ToolbarButtons.italic],
controller: controller, controller: controller,
iconTheme: iconTheme, iconTheme: iconTheme,
afterButtonPressed: afterButtonPressed, afterButtonPressed: afterButtonPressed,
@ -263,6 +336,7 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget {
attribute: Attribute.small, attribute: Attribute.small,
icon: Icons.format_size, icon: Icons.format_size,
iconSize: toolbarIconSize, iconSize: toolbarIconSize,
tooltip: buttonTooltips[ToolbarButtons.small],
controller: controller, controller: controller,
iconTheme: iconTheme, iconTheme: iconTheme,
afterButtonPressed: afterButtonPressed, afterButtonPressed: afterButtonPressed,
@ -272,6 +346,7 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget {
attribute: Attribute.underline, attribute: Attribute.underline,
icon: Icons.format_underline, icon: Icons.format_underline,
iconSize: toolbarIconSize, iconSize: toolbarIconSize,
tooltip: buttonTooltips[ToolbarButtons.underline],
controller: controller, controller: controller,
iconTheme: iconTheme, iconTheme: iconTheme,
afterButtonPressed: afterButtonPressed, afterButtonPressed: afterButtonPressed,
@ -281,6 +356,7 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget {
attribute: Attribute.strikeThrough, attribute: Attribute.strikeThrough,
icon: Icons.format_strikethrough, icon: Icons.format_strikethrough,
iconSize: toolbarIconSize, iconSize: toolbarIconSize,
tooltip: buttonTooltips[ToolbarButtons.strikeThrough],
controller: controller, controller: controller,
iconTheme: iconTheme, iconTheme: iconTheme,
afterButtonPressed: afterButtonPressed, afterButtonPressed: afterButtonPressed,
@ -290,6 +366,7 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget {
attribute: Attribute.inlineCode, attribute: Attribute.inlineCode,
icon: Icons.code, icon: Icons.code,
iconSize: toolbarIconSize, iconSize: toolbarIconSize,
tooltip: buttonTooltips[ToolbarButtons.inlineCode],
controller: controller, controller: controller,
iconTheme: iconTheme, iconTheme: iconTheme,
afterButtonPressed: afterButtonPressed, afterButtonPressed: afterButtonPressed,
@ -298,6 +375,7 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget {
ColorButton( ColorButton(
icon: Icons.color_lens, icon: Icons.color_lens,
iconSize: toolbarIconSize, iconSize: toolbarIconSize,
tooltip: buttonTooltips[ToolbarButtons.color],
controller: controller, controller: controller,
background: false, background: false,
iconTheme: iconTheme, iconTheme: iconTheme,
@ -307,6 +385,7 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget {
ColorButton( ColorButton(
icon: Icons.format_color_fill, icon: Icons.format_color_fill,
iconSize: toolbarIconSize, iconSize: toolbarIconSize,
tooltip: buttonTooltips[ToolbarButtons.backgroundColor],
controller: controller, controller: controller,
background: true, background: true,
iconTheme: iconTheme, iconTheme: iconTheme,
@ -316,6 +395,7 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget {
ClearFormatButton( ClearFormatButton(
icon: Icons.format_clear, icon: Icons.format_clear,
iconSize: toolbarIconSize, iconSize: toolbarIconSize,
tooltip: buttonTooltips[ToolbarButtons.clearFormat],
controller: controller, controller: controller,
iconTheme: iconTheme, iconTheme: iconTheme,
afterButtonPressed: afterButtonPressed, afterButtonPressed: afterButtonPressed,
@ -330,14 +410,18 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget {
isButtonGroupShown[3] || isButtonGroupShown[3] ||
isButtonGroupShown[4] || isButtonGroupShown[4] ||
isButtonGroupShown[5])) isButtonGroupShown[5]))
VerticalDivider( QuillDivider(axis,
indent: 12, color: sectionDividerColor, space: sectionDividerSpace),
endIndent: 12,
color: Colors.grey.shade400,
),
if (showAlignmentButtons) if (showAlignmentButtons)
SelectAlignmentButton( SelectAlignmentButton(
controller: controller, controller: controller,
tooltips: Map.of(buttonTooltips)
..removeWhere((key, value) => ![
ToolbarButtons.leftAlignment,
ToolbarButtons.centerAlignment,
ToolbarButtons.rightAlignment,
ToolbarButtons.justifyAlignment,
].contains(key)),
iconSize: toolbarIconSize, iconSize: toolbarIconSize,
iconTheme: iconTheme, iconTheme: iconTheme,
showLeftAlignment: showLeftAlignment, showLeftAlignment: showLeftAlignment,
@ -349,6 +433,7 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget {
if (showDirection) if (showDirection)
ToggleStyleButton( ToggleStyleButton(
attribute: Attribute.rtl, attribute: Attribute.rtl,
tooltip: buttonTooltips[ToolbarButtons.direction],
controller: controller, controller: controller,
icon: Icons.format_textdirection_r_to_l, icon: Icons.format_textdirection_r_to_l,
iconSize: toolbarIconSize, iconSize: toolbarIconSize,
@ -361,14 +446,13 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget {
isButtonGroupShown[3] || isButtonGroupShown[3] ||
isButtonGroupShown[4] || isButtonGroupShown[4] ||
isButtonGroupShown[5])) isButtonGroupShown[5]))
VerticalDivider( QuillDivider(axis,
indent: 12, color: sectionDividerColor, space: sectionDividerSpace),
endIndent: 12,
color: Colors.grey.shade400,
),
if (showHeaderStyle) if (showHeaderStyle)
SelectHeaderStyleButton( SelectHeaderStyleButton(
tooltip: buttonTooltips[ToolbarButtons.headerStyle],
controller: controller, controller: controller,
axis: axis,
iconSize: toolbarIconSize, iconSize: toolbarIconSize,
iconTheme: iconTheme, iconTheme: iconTheme,
afterButtonPressed: afterButtonPressed, afterButtonPressed: afterButtonPressed,
@ -379,14 +463,12 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget {
(isButtonGroupShown[3] || (isButtonGroupShown[3] ||
isButtonGroupShown[4] || isButtonGroupShown[4] ||
isButtonGroupShown[5])) isButtonGroupShown[5]))
VerticalDivider( QuillDivider(axis,
indent: 12, color: sectionDividerColor, space: sectionDividerSpace),
endIndent: 12,
color: Colors.grey.shade400,
),
if (showListNumbers) if (showListNumbers)
ToggleStyleButton( ToggleStyleButton(
attribute: Attribute.ol, attribute: Attribute.ol,
tooltip: buttonTooltips[ToolbarButtons.listNumbers],
controller: controller, controller: controller,
icon: Icons.format_list_numbered, icon: Icons.format_list_numbered,
iconSize: toolbarIconSize, iconSize: toolbarIconSize,
@ -396,6 +478,7 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget {
if (showListBullets) if (showListBullets)
ToggleStyleButton( ToggleStyleButton(
attribute: Attribute.ul, attribute: Attribute.ul,
tooltip: buttonTooltips[ToolbarButtons.listBullets],
controller: controller, controller: controller,
icon: Icons.format_list_bulleted, icon: Icons.format_list_bulleted,
iconSize: toolbarIconSize, iconSize: toolbarIconSize,
@ -405,6 +488,7 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget {
if (showListCheck) if (showListCheck)
ToggleCheckListButton( ToggleCheckListButton(
attribute: Attribute.unchecked, attribute: Attribute.unchecked,
tooltip: buttonTooltips[ToolbarButtons.listChecks],
controller: controller, controller: controller,
icon: Icons.check_box, icon: Icons.check_box,
iconSize: toolbarIconSize, iconSize: toolbarIconSize,
@ -414,6 +498,7 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget {
if (showCodeBlock) if (showCodeBlock)
ToggleStyleButton( ToggleStyleButton(
attribute: Attribute.codeBlock, attribute: Attribute.codeBlock,
tooltip: buttonTooltips[ToolbarButtons.codeBlock],
controller: controller, controller: controller,
icon: Icons.code, icon: Icons.code,
iconSize: toolbarIconSize, iconSize: toolbarIconSize,
@ -423,14 +508,12 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget {
if (showDividers && if (showDividers &&
isButtonGroupShown[3] && isButtonGroupShown[3] &&
(isButtonGroupShown[4] || isButtonGroupShown[5])) (isButtonGroupShown[4] || isButtonGroupShown[5]))
VerticalDivider( QuillDivider(axis,
indent: 12, color: sectionDividerColor, space: sectionDividerSpace),
endIndent: 12,
color: Colors.grey.shade400,
),
if (showQuote) if (showQuote)
ToggleStyleButton( ToggleStyleButton(
attribute: Attribute.blockQuote, attribute: Attribute.blockQuote,
tooltip: buttonTooltips[ToolbarButtons.quote],
controller: controller, controller: controller,
icon: Icons.format_quote, icon: Icons.format_quote,
iconSize: toolbarIconSize, iconSize: toolbarIconSize,
@ -441,6 +524,7 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget {
IndentButton( IndentButton(
icon: Icons.format_indent_increase, icon: Icons.format_indent_increase,
iconSize: toolbarIconSize, iconSize: toolbarIconSize,
tooltip: buttonTooltips[ToolbarButtons.indentIncrease],
controller: controller, controller: controller,
isIncrease: true, isIncrease: true,
iconTheme: iconTheme, iconTheme: iconTheme,
@ -450,19 +534,18 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget {
IndentButton( IndentButton(
icon: Icons.format_indent_decrease, icon: Icons.format_indent_decrease,
iconSize: toolbarIconSize, iconSize: toolbarIconSize,
tooltip: buttonTooltips[ToolbarButtons.indentDecrease],
controller: controller, controller: controller,
isIncrease: false, isIncrease: false,
iconTheme: iconTheme, iconTheme: iconTheme,
afterButtonPressed: afterButtonPressed, afterButtonPressed: afterButtonPressed,
), ),
if (showDividers && isButtonGroupShown[4] && isButtonGroupShown[5]) if (showDividers && isButtonGroupShown[4] && isButtonGroupShown[5])
VerticalDivider( QuillDivider(axis,
indent: 12, color: sectionDividerColor, space: sectionDividerSpace),
endIndent: 12,
color: Colors.grey.shade400,
),
if (showLink) if (showLink)
LinkStyleButton( LinkStyleButton(
tooltip: buttonTooltips[ToolbarButtons.link],
controller: controller, controller: controller,
iconSize: toolbarIconSize, iconSize: toolbarIconSize,
iconTheme: iconTheme, iconTheme: iconTheme,
@ -473,6 +556,7 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget {
SearchButton( SearchButton(
icon: Icons.search, icon: Icons.search,
iconSize: toolbarIconSize, iconSize: toolbarIconSize,
tooltip: buttonTooltips[ToolbarButtons.search],
controller: controller, controller: controller,
iconTheme: iconTheme, iconTheme: iconTheme,
dialogTheme: dialogTheme, dialogTheme: dialogTheme,
@ -480,37 +564,35 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget {
), ),
if (customButtons.isNotEmpty) if (customButtons.isNotEmpty)
if (showDividers) if (showDividers)
VerticalDivider( QuillDivider(axis,
indent: 12, color: sectionDividerColor, space: sectionDividerSpace),
endIndent: 12, for (var customButton in customButtons)
color: Colors.grey.shade400,
),
for (var customButton in customButtons) ...[
if (customButton.child != null) ...[ if (customButton.child != null) ...[
InkWell( InkWell(
onTap: customButton.onTap, onTap: customButton.onTap,
child: customButton.child, child: customButton.child,
), ),
] else ...[ ] else ...[
QuillIconButton( CustomButton(
highlightElevation: 0,
hoverElevation: 0,
size: toolbarIconSize * kIconButtonFactor,
icon: Icon(customButton.icon, size: toolbarIconSize),
borderRadius: iconTheme?.borderRadius ?? 2,
onPressed: customButton.onTap, onPressed: customButton.onTap,
afterPressed: afterButtonPressed, icon: customButton.icon,
iconColor: customButton.iconColor,
iconSize: toolbarIconSize,
iconTheme: iconTheme,
afterButtonPressed: afterButtonPressed,
tooltip: customButton.tooltip,
), ),
], ],
], ],
],
); );
} }
final List<Widget> children; final List<Widget> children;
final double toolbarHeight; final Axis axis;
final double toolbarSize;
final double toolbarSectionSpacing; final double toolbarSectionSpacing;
final WrapAlignment toolbarIconAlignment; final WrapAlignment toolbarIconAlignment;
final WrapCrossAlignment toolbarIconCrossAlignment;
final bool multiRowsDisplay; final bool multiRowsDisplay;
/// The color of the toolbar. /// The color of the toolbar.
@ -526,8 +608,19 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget {
/// List of custom buttons /// List of custom buttons
final List<QuillCustomButton> customButtons; final List<QuillCustomButton> 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 @override
Size get preferredSize => Size.fromHeight(toolbarHeight); Size get preferredSize => axis == Axis.horizontal
? Size.fromHeight(toolbarSize)
: Size.fromWidth(toolbarSize);
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -535,17 +628,72 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget {
initialLocale: locale, initialLocale: locale,
child: multiRowsDisplay child: multiRowsDisplay
? Wrap( ? Wrap(
direction: axis,
alignment: toolbarIconAlignment, alignment: toolbarIconAlignment,
crossAxisAlignment: toolbarIconCrossAlignment,
runSpacing: 4, runSpacing: 4,
spacing: toolbarSectionSpacing, spacing: toolbarSectionSpacing,
children: children, children: children,
) )
: Container( : Container(
constraints: constraints: BoxConstraints.tightFor(
BoxConstraints.tightFor(height: preferredSize.height), height: axis == Axis.horizontal ? toolbarSize : null,
width: axis == Axis.vertical ? toolbarSize : null,
),
color: color ?? Theme.of(context).canvasColor, 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,
); );
} }
} }

@ -7,9 +7,13 @@ import 'package:flutter/material.dart';
/// The arrow indicators are automatically hidden if the list is not /// The arrow indicators are automatically hidden if the list is not
/// scrollable in the direction of the respective arrow. /// scrollable in the direction of the respective arrow.
class ArrowIndicatedButtonList extends StatefulWidget { class ArrowIndicatedButtonList extends StatefulWidget {
const ArrowIndicatedButtonList({required this.buttons, Key? key}) const ArrowIndicatedButtonList({
: super(key: key); required this.axis,
required this.buttons,
Key? key,
}) : super(key: key);
final Axis axis;
final List<Widget> buttons; final List<Widget> buttons;
@override @override
@ -20,8 +24,8 @@ class ArrowIndicatedButtonList extends StatefulWidget {
class _ArrowIndicatedButtonListState extends State<ArrowIndicatedButtonList> class _ArrowIndicatedButtonListState extends State<ArrowIndicatedButtonList>
with WidgetsBindingObserver { with WidgetsBindingObserver {
final ScrollController _controller = ScrollController(); final ScrollController _controller = ScrollController();
bool _showLeftArrow = false; bool _showBackwardArrow = false;
bool _showRightArrow = false; bool _showForwardArrow = false;
@override @override
void initState() { void initState() {
@ -40,12 +44,18 @@ class _ArrowIndicatedButtonListState extends State<ArrowIndicatedButtonList>
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Row( final children = <Widget>[
children: <Widget>[ _buildBackwardArrow(),
_buildLeftArrow(),
_buildScrollableList(), _buildScrollableList(),
_buildRightColor(), _buildForwardArrow(),
], ];
return widget.axis == Axis.horizontal
? Row(
children: children,
)
: Column(
children: children,
); );
} }
@ -63,20 +73,29 @@ class _ArrowIndicatedButtonListState extends State<ArrowIndicatedButtonList>
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
_showLeftArrow = _showBackwardArrow =
_controller.position.minScrollExtent != _controller.position.pixels; _controller.position.minScrollExtent != _controller.position.pixels;
_showRightArrow = _showForwardArrow =
_controller.position.maxScrollExtent != _controller.position.pixels; _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( return SizedBox(
width: 8, width: 8,
child: Transform.translate( child: Transform.translate(
// Move the icon a few pixels to center it // Move the icon a few pixels to center it
offset: const Offset(-5, 0), offset: const Offset(-5, 0),
child: _showLeftArrow ? const Icon(Icons.arrow_left, size: 18) : null, child: icon != null ? Icon(icon, size: 18) : null,
), ),
); );
} }
@ -87,15 +106,21 @@ class _ArrowIndicatedButtonListState extends State<ArrowIndicatedButtonList>
// Remove the glowing effect, as we already have the arrow indicators // Remove the glowing effect, as we already have the arrow indicators
behavior: _NoGlowBehavior(), behavior: _NoGlowBehavior(),
// The CustomScrollView is necessary so that the children are not // 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( child: CustomScrollView(
scrollDirection: Axis.horizontal, scrollDirection: widget.axis,
controller: _controller, controller: _controller,
physics: const ClampingScrollPhysics(), physics: const ClampingScrollPhysics(),
slivers: [ slivers: [
SliverFillRemaining( SliverFillRemaining(
hasScrollBody: false, hasScrollBody: false,
child: Row( child: widget.axis == Axis.horizontal
? Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: widget.buttons,
)
: Column(
mainAxisAlignment: MainAxisAlignment.spaceEvenly, mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: widget.buttons, children: widget.buttons,
), ),
@ -106,13 +131,22 @@ class _ArrowIndicatedButtonListState extends State<ArrowIndicatedButtonList>
); );
} }
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( return SizedBox(
width: 8, width: 8,
child: Transform.translate( child: Transform.translate(
// Move the icon a few pixels to center it // Move the icon a few pixels to center it
offset: const Offset(-5, 0), 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<ArrowIndicatedButtonList>
/// ScrollBehavior without the Material glow effect. /// ScrollBehavior without the Material glow effect.
class _NoGlowBehavior extends ScrollBehavior { class _NoGlowBehavior extends ScrollBehavior {
@override
Widget buildViewportChrome(BuildContext _, Widget child, AxisDirection __) { Widget buildViewportChrome(BuildContext _, Widget child, AxisDirection __) {
return child; return child;
} }

@ -1,6 +1,9 @@
import 'package:flutter/material.dart'; 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 { class ClearFormatButton extends StatefulWidget {
const ClearFormatButton({ const ClearFormatButton({
@ -9,6 +12,7 @@ class ClearFormatButton extends StatefulWidget {
this.iconSize = kDefaultIconSize, this.iconSize = kDefaultIconSize,
this.iconTheme, this.iconTheme,
this.afterButtonPressed, this.afterButtonPressed,
this.tooltip,
Key? key, Key? key,
}) : super(key: key); }) : super(key: key);
@ -19,6 +23,7 @@ class ClearFormatButton extends StatefulWidget {
final QuillIconTheme? iconTheme; final QuillIconTheme? iconTheme;
final VoidCallback? afterButtonPressed; final VoidCallback? afterButtonPressed;
final String? tooltip;
@override @override
_ClearFormatButtonState createState() => _ClearFormatButtonState(); _ClearFormatButtonState createState() => _ClearFormatButtonState();
@ -33,6 +38,7 @@ class _ClearFormatButtonState extends State<ClearFormatButton> {
final fillColor = final fillColor =
widget.iconTheme?.iconUnselectedFillColor ?? theme.canvasColor; widget.iconTheme?.iconUnselectedFillColor ?? theme.canvasColor;
return QuillIconButton( return QuillIconButton(
tooltip: widget.tooltip,
highlightElevation: 0, highlightElevation: 0,
hoverElevation: 0, hoverElevation: 0,
size: widget.iconSize * kIconButtonFactor, size: widget.iconSize * kIconButtonFactor,

@ -21,6 +21,7 @@ class ColorButton extends StatefulWidget {
this.iconSize = kDefaultIconSize, this.iconSize = kDefaultIconSize,
this.iconTheme, this.iconTheme,
this.afterButtonPressed, this.afterButtonPressed,
this.tooltip,
Key? key, Key? key,
}) : super(key: key); }) : super(key: key);
@ -30,6 +31,7 @@ class ColorButton extends StatefulWidget {
final QuillController controller; final QuillController controller;
final QuillIconTheme? iconTheme; final QuillIconTheme? iconTheme;
final VoidCallback? afterButtonPressed; final VoidCallback? afterButtonPressed;
final String? tooltip;
@override @override
_ColorButtonState createState() => _ColorButtonState(); _ColorButtonState createState() => _ColorButtonState();
@ -119,6 +121,7 @@ class _ColorButtonState extends State<ColorButton> {
: (widget.iconTheme?.iconUnselectedFillColor ?? theme.canvasColor); : (widget.iconTheme?.iconUnselectedFillColor ?? theme.canvasColor);
return QuillIconButton( return QuillIconButton(
tooltip: widget.tooltip,
highlightElevation: 0, highlightElevation: 0,
hoverElevation: 0, hoverElevation: 0,
size: widget.iconSize * kIconButtonFactor, size: widget.iconSize * kIconButtonFactor,
@ -133,29 +136,148 @@ class _ColorButtonState extends State<ColorButton> {
} }
void _changeColor(BuildContext context, Color color) { void _changeColor(BuildContext context, Color color) {
var hex = color.value.toRadixString(16); var hex = colorToHex(color);
if (hex.startsWith('ff')) {
hex = hex.substring(2);
}
hex = '#$hex'; hex = '#$hex';
widget.controller.formatSelection( widget.controller.formatSelection(
widget.background ? BackgroundAttribute(hex) : ColorAttribute(hex)); widget.background ? BackgroundAttribute(hex) : ColorAttribute(hex));
Navigator.of(context).pop();
} }
void _showColorPicker() { 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<String>(
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => StatefulBuilder(builder: (context, dlgSetState) {
return AlertDialog(
title: Text('Select Color'.i18n), title: Text('Select Color'.i18n),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop();
},
child: Text('OK'.i18n)),
],
backgroundColor: Theme.of(context).canvasColor, backgroundColor: Theme.of(context).canvasColor,
content: SingleChildScrollView( content: SingleChildScrollView(
child: MaterialPicker( child: Column(
pickerColor: const Color(0x00000000), mainAxisSize: MainAxisSize.min,
onColorChanged: (color) => _changeColor(context, color), 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();
} }
} }

@ -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,
);
}
}

@ -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,
}

@ -1,6 +1,8 @@
import 'package:flutter/material.dart'; 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 { class HistoryButton extends StatefulWidget {
const HistoryButton({ const HistoryButton({
@ -10,6 +12,7 @@ class HistoryButton extends StatefulWidget {
this.iconSize = kDefaultIconSize, this.iconSize = kDefaultIconSize,
this.iconTheme, this.iconTheme,
this.afterButtonPressed, this.afterButtonPressed,
this.tooltip,
Key? key, Key? key,
}) : super(key: key); }) : super(key: key);
@ -19,6 +22,7 @@ class HistoryButton extends StatefulWidget {
final QuillController controller; final QuillController controller;
final QuillIconTheme? iconTheme; final QuillIconTheme? iconTheme;
final VoidCallback? afterButtonPressed; final VoidCallback? afterButtonPressed;
final String? tooltip;
@override @override
_HistoryButtonState createState() => _HistoryButtonState(); _HistoryButtonState createState() => _HistoryButtonState();
@ -39,9 +43,10 @@ class _HistoryButtonState extends State<HistoryButton> {
_setIconColor(); _setIconColor();
}); });
return QuillIconButton( return QuillIconButton(
tooltip: widget.tooltip,
highlightElevation: 0, highlightElevation: 0,
hoverElevation: 0, hoverElevation: 0,
size: widget.iconSize * 1.77, size: widget.iconSize * kIconButtonFactor,
icon: Icon(widget.icon, size: widget.iconSize, color: _iconColor), icon: Icon(widget.icon, size: widget.iconSize, color: _iconColor),
fillColor: fillColor, fillColor: fillColor,
borderRadius: widget.iconTheme?.borderRadius ?? 2, borderRadius: widget.iconTheme?.borderRadius ?? 2,

@ -1,6 +1,8 @@
import 'package:flutter/material.dart'; 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 { class IndentButton extends StatefulWidget {
const IndentButton({ const IndentButton({
@ -10,6 +12,7 @@ class IndentButton extends StatefulWidget {
this.iconSize = kDefaultIconSize, this.iconSize = kDefaultIconSize,
this.iconTheme, this.iconTheme,
this.afterButtonPressed, this.afterButtonPressed,
this.tooltip,
Key? key, Key? key,
}) : super(key: key); }) : super(key: key);
@ -20,6 +23,7 @@ class IndentButton extends StatefulWidget {
final VoidCallback? afterButtonPressed; final VoidCallback? afterButtonPressed;
final QuillIconTheme? iconTheme; final QuillIconTheme? iconTheme;
final String? tooltip;
@override @override
_IndentButtonState createState() => _IndentButtonState(); _IndentButtonState createState() => _IndentButtonState();
@ -35,34 +39,15 @@ class _IndentButtonState extends State<IndentButton> {
final iconFillColor = final iconFillColor =
widget.iconTheme?.iconUnselectedFillColor ?? theme.canvasColor; widget.iconTheme?.iconUnselectedFillColor ?? theme.canvasColor;
return QuillIconButton( return QuillIconButton(
tooltip: widget.tooltip,
highlightElevation: 0, highlightElevation: 0,
hoverElevation: 0, hoverElevation: 0,
size: widget.iconSize * 1.77, size: widget.iconSize * kIconButtonFactor,
icon: Icon(widget.icon, size: widget.iconSize, color: iconColor), icon: Icon(widget.icon, size: widget.iconSize, color: iconColor),
fillColor: iconFillColor, fillColor: iconFillColor,
borderRadius: widget.iconTheme?.borderRadius ?? 2, borderRadius: widget.iconTheme?.borderRadius ?? 2,
onPressed: () { onPressed: () {
final indent = widget.controller widget.controller.indentSelection(widget.isIncrease);
.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));
}, },
afterPressed: widget.afterButtonPressed, afterPressed: widget.afterButtonPressed,
); );

@ -1,5 +1,4 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:tuple/tuple.dart';
import '../../models/documents/attribute.dart'; import '../../models/documents/attribute.dart';
import '../../models/rules/insert.dart'; import '../../models/rules/insert.dart';
@ -18,6 +17,7 @@ class LinkStyleButton extends StatefulWidget {
this.iconTheme, this.iconTheme,
this.dialogTheme, this.dialogTheme,
this.afterButtonPressed, this.afterButtonPressed,
this.tooltip,
Key? key, Key? key,
}) : super(key: key); }) : super(key: key);
@ -27,6 +27,7 @@ class LinkStyleButton extends StatefulWidget {
final QuillIconTheme? iconTheme; final QuillIconTheme? iconTheme;
final QuillDialogTheme? dialogTheme; final QuillDialogTheme? dialogTheme;
final VoidCallback? afterButtonPressed; final VoidCallback? afterButtonPressed;
final String? tooltip;
@override @override
_LinkStyleButtonState createState() => _LinkStyleButtonState(); _LinkStyleButtonState createState() => _LinkStyleButtonState();
@ -64,6 +65,7 @@ class _LinkStyleButtonState extends State<LinkStyleButton> {
final isToggled = _getLinkAttributeValue() != null; final isToggled = _getLinkAttributeValue() != null;
final pressedHandler = () => _openLinkDialog(context); final pressedHandler = () => _openLinkDialog(context);
return QuillIconButton( return QuillIconButton(
tooltip: widget.tooltip,
highlightElevation: 0, highlightElevation: 0,
hoverElevation: 0, hoverElevation: 0,
size: widget.iconSize * kIconButtonFactor, size: widget.iconSize * kIconButtonFactor,
@ -77,7 +79,7 @@ class _LinkStyleButtonState extends State<LinkStyleButton> {
), ),
fillColor: isToggled fillColor: isToggled
? (widget.iconTheme?.iconSelectedFillColor ?? ? (widget.iconTheme?.iconSelectedFillColor ??
theme.toggleableActiveColor) Theme.of(context).primaryColor)
: (widget.iconTheme?.iconUnselectedFillColor ?? theme.canvasColor), : (widget.iconTheme?.iconUnselectedFillColor ?? theme.canvasColor),
borderRadius: widget.iconTheme?.borderRadius ?? 2, borderRadius: widget.iconTheme?.borderRadius ?? 2,
onPressed: pressedHandler, onPressed: pressedHandler,
@ -86,7 +88,7 @@ class _LinkStyleButtonState extends State<LinkStyleButton> {
} }
void _openLinkDialog(BuildContext context) { void _openLinkDialog(BuildContext context) {
showDialog<dynamic>( showDialog<_TextLink>(
context: context, context: context,
builder: (ctx) { builder: (ctx) {
final link = _getLinkAttributeValue(); final link = _getLinkAttributeValue();
@ -96,7 +98,7 @@ class _LinkStyleButtonState extends State<LinkStyleButton> {
if (link != null) { if (link != null) {
// text should be the link's corresponding text, not selection // text should be the link's corresponding text, not selection
final leaf = final leaf =
widget.controller.document.querySegmentLeafNode(index).item2; widget.controller.document.querySegmentLeafNode(index).leaf;
if (leaf != null) { if (leaf != null) {
text = leaf.toPlainText(); text = leaf.toPlainText();
} }
@ -122,24 +124,21 @@ class _LinkStyleButtonState extends State<LinkStyleButton> {
?.value; ?.value;
} }
void _linkSubmitted(dynamic value) { void _linkSubmitted(_TextLink value) {
// text.isNotEmpty && link.isNotEmpty
final String text = (value as Tuple2).item1;
final String link = value.item2.trim();
var index = widget.controller.selection.start; var index = widget.controller.selection.start;
var length = widget.controller.selection.end - index; var length = widget.controller.selection.end - index;
if (_getLinkAttributeValue() != null) { if (_getLinkAttributeValue() != null) {
// text should be the link's corresponding text, not selection // 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) { if (leaf != null) {
final range = getLinkRange(leaf); final range = getLinkRange(leaf);
index = range.start; index = range.start;
length = range.end - range.start; length = range.end - range.start;
} }
} }
widget.controller.replaceText(index, length, text, null); widget.controller.replaceText(index, length, value.text, null);
widget.controller.formatText(index, text.length, LinkAttribute(link)); widget.controller
.formatText(index, value.text.length, LinkAttribute(value.link));
} }
} }
@ -240,6 +239,16 @@ class _LinkDialogState extends State<_LinkDialog> {
} }
void _applyLink() { 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;
} }

@ -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<LinkStyleButton2> createState() => _LinkStyleButton2State();
}
class _LinkStyleButton2State extends State<LinkStyleButton2> {
@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<void> _openLinkDialog() async {
final initialTextLink = QuillTextLink.prepare(widget.controller);
final textLink = await showDialog<QuillTextLink>(
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<LinkStyleDialog> createState() => _LinkStyleDialogState();
}
class _LinkStyleDialogState extends State<LinkStyleDialog> {
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));
}
}

@ -4,35 +4,61 @@ import '../../models/documents/attribute.dart';
import '../../models/documents/style.dart'; import '../../models/documents/style.dart';
import '../../models/themes/quill_icon_theme.dart'; import '../../models/themes/quill_icon_theme.dart';
import '../../translations/toolbar.i18n.dart'; import '../../translations/toolbar.i18n.dart';
import '../../utils/widgets.dart';
import '../controller.dart'; import '../controller.dart';
class QuillFontFamilyButton extends StatefulWidget { class QuillFontFamilyButton extends StatefulWidget {
const QuillFontFamilyButton({ const QuillFontFamilyButton({
required this.items,
required this.rawItemsMap, required this.rawItemsMap,
required this.attribute, required this.attribute,
required this.controller, required this.controller,
required this.onSelected, @Deprecated('It is not required because of `rawItemsMap`') this.items,
this.onSelected,
this.iconSize = 40, this.iconSize = 40,
this.fillColor, this.fillColor,
this.hoverElevation = 1, this.hoverElevation = 1,
this.highlightElevation = 1, this.highlightElevation = 1,
this.iconTheme, this.iconTheme,
this.afterButtonPressed, 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, Key? key,
}) : super(key: key); }) : assert(rawItemsMap.length > 0),
assert(initialValue == null || initialValue.length > 0),
super(key: key);
final double iconSize; final double iconSize;
final Color? fillColor; final Color? fillColor;
final double hoverElevation; final double hoverElevation;
final double highlightElevation; final double highlightElevation;
final List<PopupMenuEntry<String>> items; @Deprecated('It is not required because of `rawItemsMap`')
final List<PopupMenuEntry<String>>? items;
final Map<String, String> rawItemsMap; final Map<String, String> rawItemsMap;
final ValueChanged<String> onSelected; final ValueChanged<String>? onSelected;
final QuillIconTheme? iconTheme; final QuillIconTheme? iconTheme;
final Attribute attribute; final Attribute attribute;
final QuillController controller; final QuillController controller;
final VoidCallback? afterButtonPressed; 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 @override
_QuillFontFamilyButtonState createState() => _QuillFontFamilyButtonState(); _QuillFontFamilyButtonState createState() => _QuillFontFamilyButtonState();
@ -46,7 +72,7 @@ class _QuillFontFamilyButtonState extends State<QuillFontFamilyButton> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_currentValue = _defaultDisplayText = 'Font'.i18n; _currentValue = _defaultDisplayText = widget.initialValue ?? 'Font'.i18n;
widget.controller.addListener(_didChangeEditingValue); widget.controller.addListener(_didChangeEditingValue);
} }
@ -87,7 +113,22 @@ class _QuillFontFamilyButtonState extends State<QuillFontFamilyButton> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ConstrainedBox( return ConstrainedBox(
constraints: BoxConstraints.tightFor(height: widget.iconSize * 1.81), 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: RawMaterialButton( child: RawMaterialButton(
visualDensity: VisualDensity.compact, visualDensity: VisualDensity.compact,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
@ -103,14 +144,14 @@ class _QuillFontFamilyButtonState extends State<QuillFontFamilyButton> {
}, },
child: _buildContent(context), child: _buildContent(context),
), ),
),
); );
} }
void _showMenu() { void _showMenu() {
final popupMenuTheme = PopupMenuTheme.of(context); final popupMenuTheme = PopupMenuTheme.of(context);
final button = context.findRenderObject() as RenderBox; final button = context.findRenderObject() as RenderBox;
final overlay = final overlay = Overlay.of(context).context.findRenderObject() as RenderBox;
Overlay.of(context)!.context.findRenderObject() as RenderBox;
final position = RelativeRect.fromRect( final position = RelativeRect.fromRect(
Rect.fromPoints( Rect.fromPoints(
button.localToGlobal(Offset.zero, ancestor: overlay), button.localToGlobal(Offset.zero, ancestor: overlay),
@ -122,7 +163,24 @@ class _QuillFontFamilyButtonState extends State<QuillFontFamilyButton> {
showMenu<String>( showMenu<String>(
context: context, context: context,
elevation: 4, elevation: 4,
items: widget.items, items: [
for (MapEntry<String, String> fontFamily in widget.rawItemsMap.entries)
PopupMenuItem<String>(
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, position: position,
shape: popupMenuTheme.shape, shape: popupMenuTheme.shape,
color: popupMenuTheme.color, color: popupMenuTheme.color,
@ -135,7 +193,9 @@ class _QuillFontFamilyButtonState extends State<QuillFontFamilyButton> {
setState(() { setState(() {
_currentValue = keyName ?? _defaultDisplayText; _currentValue = keyName ?? _defaultDisplayText;
if (keyName != null) { 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<QuillFontFamilyButton> {
Widget _buildContent(BuildContext context) { Widget _buildContent(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final hasFinalWidth = widget.width != null;
return Padding( return Padding(
padding: const EdgeInsets.fromLTRB(10, 0, 0, 0), padding: widget.padding ?? const EdgeInsets.fromLTRB(10, 0, 0, 0),
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: !hasFinalWidth ? MainAxisSize.min : MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
Text(_currentValue, UtilityWidgets.maybeWidget(
style: TextStyle( enabled: hasFinalWidth,
wrapper: (child) => Expanded(child: child),
child: Text(
_currentValue,
maxLines: 1,
overflow: widget.labelOverflow,
style: widget.style ??
TextStyle(
fontSize: widget.iconSize / 1.15, fontSize: widget.iconSize / 1.15,
color: widget.iconTheme?.iconUnselectedColor ?? color: widget.iconTheme?.iconUnselectedColor ??
theme.iconTheme.color)), theme.iconTheme.color),
),
),
const SizedBox(width: 3), const SizedBox(width: 3),
Icon(Icons.arrow_drop_down, Icon(Icons.arrow_drop_down,
size: widget.iconSize / 1.15, size: widget.iconSize / 1.15,

@ -5,35 +5,57 @@ import '../../models/documents/style.dart';
import '../../models/themes/quill_icon_theme.dart'; import '../../models/themes/quill_icon_theme.dart';
import '../../translations/toolbar.i18n.dart'; import '../../translations/toolbar.i18n.dart';
import '../../utils/font.dart'; import '../../utils/font.dart';
import '../../utils/widgets.dart';
import '../controller.dart'; import '../controller.dart';
class QuillFontSizeButton extends StatefulWidget { class QuillFontSizeButton extends StatefulWidget {
const QuillFontSizeButton({ const QuillFontSizeButton({
required this.items,
required this.rawItemsMap, required this.rawItemsMap,
required this.attribute, required this.attribute,
required this.controller, required this.controller,
required this.onSelected, this.onSelected,
@Deprecated('It is not required because of `rawItemsMap`') this.items,
this.iconSize = 40, this.iconSize = 40,
this.fillColor, this.fillColor,
this.hoverElevation = 1, this.hoverElevation = 1,
this.highlightElevation = 1, this.highlightElevation = 1,
this.iconTheme, this.iconTheme,
this.afterButtonPressed, 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, Key? key,
}) : super(key: key); }) : assert(rawItemsMap.length > 0),
assert(initialValue == null || initialValue.length > 0),
super(key: key);
final double iconSize; final double iconSize;
final Color? fillColor; final Color? fillColor;
final double hoverElevation; final double hoverElevation;
final double highlightElevation; final double highlightElevation;
final List<PopupMenuEntry<String>> items; @Deprecated('It is not required because of `rawItemsMap`')
final List<PopupMenuEntry<String>>? items;
final Map<String, String> rawItemsMap; final Map<String, String> rawItemsMap;
final ValueChanged<String> onSelected; final ValueChanged<String>? onSelected;
final QuillIconTheme? iconTheme; final QuillIconTheme? iconTheme;
final Attribute attribute; final Attribute attribute;
final QuillController controller; final QuillController controller;
final VoidCallback? afterButtonPressed; 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 @override
_QuillFontSizeButtonState createState() => _QuillFontSizeButtonState(); _QuillFontSizeButtonState createState() => _QuillFontSizeButtonState();
@ -47,7 +69,7 @@ class _QuillFontSizeButtonState extends State<QuillFontSizeButton> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_currentValue = _defaultDisplayText = 'Size'.i18n; _currentValue = _defaultDisplayText = widget.initialValue ?? 'Size'.i18n;
widget.controller.addListener(_didChangeEditingValue); widget.controller.addListener(_didChangeEditingValue);
} }
@ -88,7 +110,12 @@ class _QuillFontSizeButtonState extends State<QuillFontSizeButton> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ConstrainedBox( return ConstrainedBox(
constraints: BoxConstraints.tightFor(height: widget.iconSize * 1.81), constraints: BoxConstraints.tightFor(
height: widget.iconSize * 1.81,
width: widget.width,
),
child: UtilityWidgets.maybeTooltip(
message: widget.tooltip,
child: RawMaterialButton( child: RawMaterialButton(
visualDensity: VisualDensity.compact, visualDensity: VisualDensity.compact,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
@ -104,14 +131,14 @@ class _QuillFontSizeButtonState extends State<QuillFontSizeButton> {
}, },
child: _buildContent(context), child: _buildContent(context),
), ),
),
); );
} }
void _showMenu() { void _showMenu() {
final popupMenuTheme = PopupMenuTheme.of(context); final popupMenuTheme = PopupMenuTheme.of(context);
final button = context.findRenderObject() as RenderBox; final button = context.findRenderObject() as RenderBox;
final overlay = final overlay = Overlay.of(context).context.findRenderObject() as RenderBox;
Overlay.of(context)!.context.findRenderObject() as RenderBox;
final position = RelativeRect.fromRect( final position = RelativeRect.fromRect(
Rect.fromPoints( Rect.fromPoints(
button.localToGlobal(Offset.zero, ancestor: overlay), button.localToGlobal(Offset.zero, ancestor: overlay),
@ -123,7 +150,21 @@ class _QuillFontSizeButtonState extends State<QuillFontSizeButton> {
showMenu<String>( showMenu<String>(
context: context, context: context,
elevation: 4, elevation: 4,
items: widget.items, items: [
for (MapEntry<String, String> fontSize in widget.rawItemsMap.entries)
PopupMenuItem<String>(
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, position: position,
shape: popupMenuTheme.shape, shape: popupMenuTheme.shape,
color: popupMenuTheme.color, color: popupMenuTheme.color,
@ -136,7 +177,9 @@ class _QuillFontSizeButtonState extends State<QuillFontSizeButton> {
setState(() { setState(() {
_currentValue = keyName ?? _defaultDisplayText; _currentValue = keyName ?? _defaultDisplayText;
if (keyName != null) { 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<QuillFontSizeButton> {
Widget _buildContent(BuildContext context) { Widget _buildContent(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final hasFinalWidth = widget.width != null;
return Padding( return Padding(
padding: const EdgeInsets.fromLTRB(10, 0, 0, 0), padding: widget.padding ?? const EdgeInsets.fromLTRB(10, 0, 0, 0),
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: !hasFinalWidth ? MainAxisSize.min : MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
Text(_currentValue, UtilityWidgets.maybeWidget(
style: TextStyle( enabled: hasFinalWidth,
wrapper: (child) => Expanded(child: child),
child: Text(_currentValue,
overflow: widget.labelOverflow,
style: widget.style ??
TextStyle(
fontSize: widget.iconSize / 1.15, fontSize: widget.iconSize / 1.15,
color: widget.iconTheme?.iconUnselectedColor ?? color: widget.iconTheme?.iconUnselectedColor ??
theme.iconTheme.color)), theme.iconTheme.color)),
),
const SizedBox(width: 3), const SizedBox(width: 3),
Icon(Icons.arrow_drop_down, Icon(Icons.arrow_drop_down,
size: widget.iconSize / 1.15, size: widget.iconSize / 1.15,

@ -1,5 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../utils/widgets.dart';
class QuillIconButton extends StatelessWidget { class QuillIconButton extends StatelessWidget {
const QuillIconButton({ const QuillIconButton({
required this.onPressed, required this.onPressed,
@ -10,6 +12,7 @@ class QuillIconButton extends StatelessWidget {
this.hoverElevation = 1, this.hoverElevation = 1,
this.highlightElevation = 1, this.highlightElevation = 1,
this.borderRadius = 2, this.borderRadius = 2,
this.tooltip,
Key? key, Key? key,
}) : super(key: key); }) : super(key: key);
@ -21,11 +24,14 @@ class QuillIconButton extends StatelessWidget {
final double hoverElevation; final double hoverElevation;
final double highlightElevation; final double highlightElevation;
final double borderRadius; final double borderRadius;
final String? tooltip;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ConstrainedBox( return ConstrainedBox(
constraints: BoxConstraints.tightFor(width: size, height: size), constraints: BoxConstraints.tightFor(width: size, height: size),
child: UtilityWidgets.maybeTooltip(
message: tooltip,
child: RawMaterialButton( child: RawMaterialButton(
visualDensity: VisualDensity.compact, visualDensity: VisualDensity.compact,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
@ -40,6 +46,7 @@ class QuillIconButton extends StatelessWidget {
}, },
child: icon, child: icon,
), ),
),
); );
} }
} }

@ -1,11 +1,10 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../models/documents/document.dart';
import '../../models/themes/quill_dialog_theme.dart'; import '../../models/themes/quill_dialog_theme.dart';
import '../../models/themes/quill_icon_theme.dart'; import '../../models/themes/quill_icon_theme.dart';
import '../../translations/toolbar.i18n.dart';
import '../controller.dart'; import '../controller.dart';
import '../toolbar.dart'; import '../toolbar.dart';
import 'search_dialog.dart';
class SearchButton extends StatelessWidget { class SearchButton extends StatelessWidget {
const SearchButton({ const SearchButton({
@ -16,6 +15,7 @@ class SearchButton extends StatelessWidget {
this.iconTheme, this.iconTheme,
this.dialogTheme, this.dialogTheme,
this.afterButtonPressed, this.afterButtonPressed,
this.tooltip,
Key? key, Key? key,
}) : super(key: key); }) : super(key: key);
@ -28,6 +28,7 @@ class SearchButton extends StatelessWidget {
final QuillDialogTheme? dialogTheme; final QuillDialogTheme? dialogTheme;
final VoidCallback? afterButtonPressed; final VoidCallback? afterButtonPressed;
final String? tooltip;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -38,10 +39,11 @@ class SearchButton extends StatelessWidget {
iconTheme?.iconUnselectedFillColor ?? (fillColor ?? theme.canvasColor); iconTheme?.iconUnselectedFillColor ?? (fillColor ?? theme.canvasColor);
return QuillIconButton( return QuillIconButton(
tooltip: tooltip,
icon: Icon(icon, size: iconSize, color: iconColor), icon: Icon(icon, size: iconSize, color: iconColor),
highlightElevation: 0, highlightElevation: 0,
hoverElevation: 0, hoverElevation: 0,
size: iconSize * 1.77, size: iconSize * kIconButtonFactor,
fillColor: iconFillColor, fillColor: iconFillColor,
borderRadius: iconTheme?.borderRadius ?? 2, borderRadius: iconTheme?.borderRadius ?? 2,
onPressed: () => _onPressedHandler(context), onPressed: () => _onPressedHandler(context),
@ -52,140 +54,10 @@ class SearchButton extends StatelessWidget {
Future<void> _onPressedHandler(BuildContext context) async { Future<void> _onPressedHandler(BuildContext context) async {
await showDialog<String>( await showDialog<String>(
context: context, context: context,
builder: (_) => _SearchDialog( builder: (_) => SearchDialog(
controller: controller, dialogTheme: dialogTheme, text: ''), controller: controller, dialogTheme: dialogTheme, text: ''),
).then(_searchSubmitted); ).then(_searchSubmitted);
} }
void _searchSubmitted(String? value) {} 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<int>? _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;
});
}
}

@ -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<SearchDialog> {
late String _text;
late TextEditingController _controller;
late List<int>? _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;
});
}
}

@ -4,8 +4,10 @@ import 'package:flutter/material.dart';
import '../../models/documents/attribute.dart'; import '../../models/documents/attribute.dart';
import '../../models/documents/style.dart'; import '../../models/documents/style.dart';
import '../../models/themes/quill_icon_theme.dart'; import '../../models/themes/quill_icon_theme.dart';
import '../../utils/widgets.dart';
import '../controller.dart'; import '../controller.dart';
import '../toolbar.dart'; import '../toolbar.dart';
import 'enum.dart';
class SelectAlignmentButton extends StatefulWidget { class SelectAlignmentButton extends StatefulWidget {
const SelectAlignmentButton({ const SelectAlignmentButton({
@ -17,6 +19,8 @@ class SelectAlignmentButton extends StatefulWidget {
this.showRightAlignment, this.showRightAlignment,
this.showJustifyAlignment, this.showJustifyAlignment,
this.afterButtonPressed, this.afterButtonPressed,
this.tooltips = const <ToolbarButtons, String>{},
this.padding,
Key? key, Key? key,
}) : super(key: key); }) : super(key: key);
@ -29,6 +33,8 @@ class SelectAlignmentButton extends StatefulWidget {
final bool? showRightAlignment; final bool? showRightAlignment;
final bool? showJustifyAlignment; final bool? showJustifyAlignment;
final VoidCallback? afterButtonPressed; final VoidCallback? afterButtonPressed;
final Map<ToolbarButtons, String> tooltips;
final EdgeInsetsGeometry? padding;
@override @override
_SelectAlignmentButtonState createState() => _SelectAlignmentButtonState(); _SelectAlignmentButtonState createState() => _SelectAlignmentButtonState();
@ -74,6 +80,16 @@ class _SelectAlignmentButtonState extends State<SelectAlignmentButton> {
if (widget.showRightAlignment!) Attribute.rightAlignment.value!, if (widget.showRightAlignment!) Attribute.rightAlignment.value!,
if (widget.showJustifyAlignment!) Attribute.justifyAlignment.value!, if (widget.showJustifyAlignment!) Attribute.justifyAlignment.value!,
]; ];
final _valueToButtons = <Attribute, ToolbarButtons>{
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); final theme = Theme.of(context);
@ -86,13 +102,15 @@ class _SelectAlignmentButtonState extends State<SelectAlignmentButton> {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: List.generate(buttonCount, (index) { children: List.generate(buttonCount, (index) {
return Padding( return Padding(
// ignore: prefer_const_constructors padding: widget.padding ??
padding: EdgeInsets.symmetric(horizontal: !kIsWeb ? 1.0 : 5.0), const EdgeInsets.symmetric(horizontal: !kIsWeb ? 1.0 : 5.0),
child: ConstrainedBox( child: ConstrainedBox(
constraints: BoxConstraints.tightFor( constraints: BoxConstraints.tightFor(
width: widget.iconSize * kIconButtonFactor, width: widget.iconSize * kIconButtonFactor,
height: widget.iconSize * kIconButtonFactor, height: widget.iconSize * kIconButtonFactor,
), ),
child: UtilityWidgets.maybeTooltip(
message: widget.tooltips[_valueToButtons[_valueAttribute[index]]],
child: RawMaterialButton( child: RawMaterialButton(
hoverElevation: 0, hoverElevation: 0,
highlightElevation: 0, highlightElevation: 0,
@ -103,14 +121,15 @@ class _SelectAlignmentButtonState extends State<SelectAlignmentButton> {
widget.iconTheme?.borderRadius ?? 2)), widget.iconTheme?.borderRadius ?? 2)),
fillColor: _valueToText[_value] == _valueString[index] fillColor: _valueToText[_value] == _valueString[index]
? (widget.iconTheme?.iconSelectedFillColor ?? ? (widget.iconTheme?.iconSelectedFillColor ??
theme.toggleableActiveColor) Theme.of(context).primaryColor)
: (widget.iconTheme?.iconUnselectedFillColor ?? : (widget.iconTheme?.iconUnselectedFillColor ??
theme.canvasColor), theme.canvasColor),
onPressed: () { onPressed: () {
_valueAttribute[index] == Attribute.leftAlignment _valueAttribute[index] == Attribute.leftAlignment
? widget.controller ? widget.controller.formatSelection(
.formatSelection(Attribute.clone(Attribute.align, null)) Attribute.clone(Attribute.align, null))
: widget.controller.formatSelection(_valueAttribute[index]); : widget.controller
.formatSelection(_valueAttribute[index]);
widget.afterButtonPressed?.call(); widget.afterButtonPressed?.call();
}, },
child: Icon( child: Icon(
@ -118,7 +137,8 @@ class _SelectAlignmentButtonState extends State<SelectAlignmentButton> {
? Icons.format_align_left ? Icons.format_align_left
: _valueString[index] == Attribute.centerAlignment.value : _valueString[index] == Attribute.centerAlignment.value
? Icons.format_align_center ? Icons.format_align_center
: _valueString[index] == Attribute.rightAlignment.value : _valueString[index] ==
Attribute.rightAlignment.value
? Icons.format_align_right ? Icons.format_align_right
: Icons.format_align_justify, : Icons.format_align_justify,
size: widget.iconSize, size: widget.iconSize,
@ -130,6 +150,7 @@ class _SelectAlignmentButtonState extends State<SelectAlignmentButton> {
), ),
), ),
), ),
),
); );
}), }),
); );

@ -4,12 +4,14 @@ import 'package:flutter/material.dart';
import '../../models/documents/attribute.dart'; import '../../models/documents/attribute.dart';
import '../../models/documents/style.dart'; import '../../models/documents/style.dart';
import '../../models/themes/quill_icon_theme.dart'; import '../../models/themes/quill_icon_theme.dart';
import '../../utils/widgets.dart';
import '../controller.dart'; import '../controller.dart';
import '../toolbar.dart'; import '../toolbar.dart';
class SelectHeaderStyleButton extends StatefulWidget { class SelectHeaderStyleButton extends StatefulWidget {
const SelectHeaderStyleButton({ const SelectHeaderStyleButton({
required this.controller, required this.controller,
this.axis = Axis.horizontal,
this.iconSize = kDefaultIconSize, this.iconSize = kDefaultIconSize,
this.iconTheme, this.iconTheme,
this.attributes = const [ this.attributes = const [
@ -19,14 +21,17 @@ class SelectHeaderStyleButton extends StatefulWidget {
Attribute.h3, Attribute.h3,
], ],
this.afterButtonPressed, this.afterButtonPressed,
this.tooltip,
Key? key, Key? key,
}) : super(key: key); }) : super(key: key);
final QuillController controller; final QuillController controller;
final Axis axis;
final double iconSize; final double iconSize;
final QuillIconTheme? iconTheme; final QuillIconTheme? iconTheme;
final List<Attribute> attributes; final List<Attribute> attributes;
final VoidCallback? afterButtonPressed; final VoidCallback? afterButtonPressed;
final String? tooltip;
@override @override
_SelectHeaderStyleButtonState createState() => _SelectHeaderStyleButtonState createState() =>
@ -67,9 +72,7 @@ class _SelectHeaderStyleButtonState extends State<SelectHeaderStyleButton> {
fontSize: widget.iconSize * 0.7, fontSize: widget.iconSize * 0.7,
); );
return Row( final children = widget.attributes.map((attribute) {
mainAxisSize: MainAxisSize.min,
children: widget.attributes.map((attribute) {
final isSelected = _selectedAttribute == attribute; final isSelected = _selectedAttribute == attribute;
return Padding( return Padding(
// ignore: prefer_const_constructors // ignore: prefer_const_constructors
@ -79,6 +82,8 @@ class _SelectHeaderStyleButtonState extends State<SelectHeaderStyleButton> {
width: widget.iconSize * kIconButtonFactor, width: widget.iconSize * kIconButtonFactor,
height: widget.iconSize * kIconButtonFactor, height: widget.iconSize * kIconButtonFactor,
), ),
child: UtilityWidgets.maybeTooltip(
message: widget.tooltip,
child: RawMaterialButton( child: RawMaterialButton(
hoverElevation: 0, hoverElevation: 0,
highlightElevation: 0, highlightElevation: 0,
@ -89,7 +94,7 @@ class _SelectHeaderStyleButtonState extends State<SelectHeaderStyleButton> {
widget.iconTheme?.borderRadius ?? 2)), widget.iconTheme?.borderRadius ?? 2)),
fillColor: isSelected fillColor: isSelected
? (widget.iconTheme?.iconSelectedFillColor ?? ? (widget.iconTheme?.iconSelectedFillColor ??
theme.toggleableActiveColor) Theme.of(context).primaryColor)
: (widget.iconTheme?.iconUnselectedFillColor ?? : (widget.iconTheme?.iconUnselectedFillColor ??
theme.canvasColor), theme.canvasColor),
onPressed: () { onPressed: () {
@ -111,8 +116,18 @@ class _SelectHeaderStyleButtonState extends State<SelectHeaderStyleButton> {
), ),
), ),
), ),
),
); );
}).toList(), }).toList();
return widget.axis == Axis.horizontal
? Row(
mainAxisSize: MainAxisSize.min,
children: children,
)
: Column(
mainAxisSize: MainAxisSize.min,
children: children,
); );
} }

@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import '../../models/documents/attribute.dart'; import '../../models/documents/attribute.dart';
import '../../models/documents/style.dart'; import '../../models/documents/style.dart';
import '../../models/themes/quill_icon_theme.dart'; import '../../models/themes/quill_icon_theme.dart';
import '../../utils/widgets.dart';
import '../controller.dart'; import '../controller.dart';
import '../toolbar.dart'; import '../toolbar.dart';
@ -16,6 +17,7 @@ class ToggleCheckListButton extends StatefulWidget {
this.childBuilder = defaultToggleStyleButtonBuilder, this.childBuilder = defaultToggleStyleButtonBuilder,
this.iconTheme, this.iconTheme,
this.afterButtonPressed, this.afterButtonPressed,
this.tooltip,
Key? key, Key? key,
}) : super(key: key); }) : super(key: key);
@ -32,6 +34,7 @@ class ToggleCheckListButton extends StatefulWidget {
final QuillIconTheme? iconTheme; final QuillIconTheme? iconTheme;
final VoidCallback? afterButtonPressed; final VoidCallback? afterButtonPressed;
final String? tooltip;
@override @override
_ToggleCheckListButtonState createState() => _ToggleCheckListButtonState(); _ToggleCheckListButtonState createState() => _ToggleCheckListButtonState();
@ -91,7 +94,9 @@ class _ToggleCheckListButtonState extends State<ToggleCheckListButton> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return widget.childBuilder( return UtilityWidgets.maybeTooltip(
message: widget.tooltip,
child: widget.childBuilder(
context, context,
Attribute.unchecked, Attribute.unchecked,
widget.icon, widget.icon,
@ -101,6 +106,7 @@ class _ToggleCheckListButtonState extends State<ToggleCheckListButton> {
widget.afterButtonPressed, widget.afterButtonPressed,
widget.iconSize, widget.iconSize,
widget.iconTheme, widget.iconTheme,
),
); );
} }

@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import '../../models/documents/attribute.dart'; import '../../models/documents/attribute.dart';
import '../../models/documents/style.dart'; import '../../models/documents/style.dart';
import '../../models/themes/quill_icon_theme.dart'; import '../../models/themes/quill_icon_theme.dart';
import '../../utils/widgets.dart';
import '../controller.dart'; import '../controller.dart';
import '../toolbar.dart'; import '../toolbar.dart';
@ -28,6 +29,7 @@ class ToggleStyleButton extends StatefulWidget {
this.childBuilder = defaultToggleStyleButtonBuilder, this.childBuilder = defaultToggleStyleButtonBuilder,
this.iconTheme, this.iconTheme,
this.afterButtonPressed, this.afterButtonPressed,
this.tooltip,
Key? key, Key? key,
}) : super(key: key); }) : super(key: key);
@ -46,6 +48,7 @@ class ToggleStyleButton extends StatefulWidget {
final QuillIconTheme? iconTheme; final QuillIconTheme? iconTheme;
final VoidCallback? afterButtonPressed; final VoidCallback? afterButtonPressed;
final String? tooltip;
@override @override
_ToggleStyleButtonState createState() => _ToggleStyleButtonState(); _ToggleStyleButtonState createState() => _ToggleStyleButtonState();
@ -65,7 +68,9 @@ class _ToggleStyleButtonState extends State<ToggleStyleButton> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return widget.childBuilder( return UtilityWidgets.maybeTooltip(
message: widget.tooltip,
child: widget.childBuilder(
context, context,
widget.attribute, widget.attribute,
widget.icon, widget.icon,
@ -75,6 +80,7 @@ class _ToggleStyleButtonState extends State<ToggleStyleButton> {
widget.afterButtonPressed, widget.afterButtonPressed,
widget.iconSize, widget.iconSize,
widget.iconTheme, widget.iconTheme,
),
); );
} }
@ -99,7 +105,8 @@ class _ToggleStyleButtonState extends State<ToggleStyleButton> {
} }
bool _getIsToggled(Map<String, Attribute> attrs) { bool _getIsToggled(Map<String, Attribute> 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]; final attribute = attrs[widget.attribute.key];
if (attribute == null) { if (attribute == null) {
return false; return false;
@ -139,7 +146,7 @@ Widget defaultToggleStyleButtonBuilder(
final fill = isEnabled final fill = isEnabled
? isToggled == true ? isToggled == true
? (iconTheme?.iconSelectedFillColor ?? ? (iconTheme?.iconSelectedFillColor ??
theme.toggleableActiveColor) //Selected icon fill color Theme.of(context).primaryColor) //Selected icon fill color
: (iconTheme?.iconUnselectedFillColor ?? : (iconTheme?.iconUnselectedFillColor ??
theme.canvasColor) //Unselected icon fill color : theme.canvasColor) //Unselected icon fill color :
: (iconTheme?.disabledIconFillColor ?? : (iconTheme?.disabledIconFillColor ??

@ -1,32 +1,31 @@
name: flutter_quill name: flutter_quill
description: A rich text editor supporting mobile and web (Demo App @ bulletjournal.us) description: A rich text editor supporting mobile and web (Demo App @ bulletjournal.us)
version: 6.0.6+1 version: 7.2.7
#author: bulletjournal #author: bulletjournal
homepage: https://bulletjournal.us/home/index.html homepage: https://bulletjournal.us/home/index.html
repository: https://github.com/singerdmx/flutter-quill repository: https://github.com/singerdmx/flutter-quill
environment: environment:
sdk: ">=2.12.0 <3.0.0" sdk: ">=2.17.0 <4.0.0"
flutter: ">=3.0.0" flutter: ">=3.10.0"
dependencies: dependencies:
flutter: flutter:
sdk: flutter sdk: flutter
collection: ^1.16.0 collection: ^1.17.0
flutter_colorpicker: ^1.0.3 flutter_colorpicker: ^1.0.3
flutter_keyboard_visibility: ^5.2.0 flutter_keyboard_visibility: ^5.4.0
quiver: ^3.1.0 quiver: ^3.2.1
tuple: ^2.0.0 url_launcher: ^6.1.9
url_launcher: ^6.1.2
pedantic: ^1.11.1 pedantic: ^1.11.1
characters: ^1.2.0 characters: ^1.2.1
diff_match_patch: ^0.4.1 diff_match_patch: ^0.4.1
i18n_extension: ^5.0.1 i18n_extension: ">=8.0.0 <10.0.0"
device_info_plus: ^4.0.0 device_info_plus: ^9.0.0
platform: ^3.1.0 platform: ^3.1.0
pasteboard: ^0.2.0 pasteboard: ^0.2.0
dev_dependencies: # Dependencies for testing utilities
flutter_test: flutter_test:
sdk: flutter sdk: flutter

@ -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);
});
});
});
}

@ -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'));
});
});
}

@ -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 <String>['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(<String, dynamic>{
'args': <dynamic>[
-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));
});
});
}
Loading…
Cancel
Save