Merge branch 'singerdmx:master' into master

pull/1270/head
Cierra_Runis 2 years ago committed by GitHub
commit e60c961668
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 23
      .github/workflows/main.yml
  2. 1
      .gitignore
  3. 33
      CHANGELOG.md
  4. 16
      README.md
  5. 129
      example/lib/pages/home_page.dart
  6. 2
      example/lib/universal_ui/universal_ui.dart
  7. 10
      example/windows/runner/Runner.rc
  8. 6
      flutter_quill_extensions/CHANGELOG.md
  9. 4
      flutter_quill_extensions/lib/embeds/builders.dart
  10. 8
      flutter_quill_extensions/pubspec.yaml
  11. 3
      lib/flutter_quill_test.dart
  12. 4
      lib/src/models/themes/quill_custom_button.dart
  13. 60
      lib/src/test/widget_tester_extension.dart
  14. 187
      lib/src/translations/toolbar.i18n.dart
  15. 16
      lib/src/widgets/controller.dart
  16. 3
      lib/src/widgets/delegate.dart
  17. 15
      lib/src/widgets/editor.dart
  18. 1
      lib/src/widgets/embeds.dart
  19. 141
      lib/src/widgets/raw_editor.dart
  20. 6
      lib/src/widgets/raw_editor/raw_editor_state_text_input_client_mixin.dart
  21. 4
      lib/src/widgets/style_widgets/bullet_point.dart
  22. 45
      lib/src/widgets/text_block.dart
  23. 28
      lib/src/widgets/text_line.dart
  24. 5
      lib/src/widgets/text_selection.dart
  25. 24
      lib/src/widgets/toolbar.dart
  26. 151
      lib/src/widgets/toolbar/color_button.dart
  27. 43
      lib/src/widgets/toolbar/custom_button.dart
  28. 2
      lib/src/widgets/toolbar/history_button.dart
  29. 2
      lib/src/widgets/toolbar/indent_button.dart
  30. 2
      lib/src/widgets/toolbar/search_button.dart
  31. 10
      pubspec.yaml
  32. 95
      test/bug_fix_test.dart
  33. 290
      test/widgets/controller_test.dart
  34. 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

@ -1,4 +1,35 @@
# [7.1.17] # [7.2.6]
- Style custom toolbar buttons like builtins.
# [7.2.5]
- Always use text cursor for editor on desktop.
# [7.2.4]
- Fixed keepStyleOnNewLine.
# [7.2.3]
- Get pixel ratio from view.
# [7.2.2]
- Prevent operations on stale editor state.
# [7.2.1]
- Add support for android keyboard content insertion.
- Enhance color picker, enter hex color and color palette option.
# [7.2.0]
- Checkboxes, bullet points, and number points are now scaled based on the default paragraph font size.
# [7.1.20]
- Pass linestyle to embedded block.
# [7.1.19]
- Fix Rtl leading alignment problem.
# [7.1.18]
- Support flutter latest version.
# [7.1.17+1]
- Updates `device_info_plus` to version 9.0.0 to benefit from AGP 8 (see [changelog#900](https://pub.dev/packages/device_info_plus/changelog#900)). - Updates `device_info_plus` to version 9.0.0 to benefit from AGP 8 (see [changelog#900](https://pub.dev/packages/device_info_plus/changelog#900)).
# [7.1.16] # [7.1.16]

@ -391,6 +391,22 @@ tables, and mentions. Conversion can be performed in vanilla Dart (i.e., server-
It is a complete Dart part of the popular and mature [quill-delta-to-html](https://www.npmjs.com/package/quill-delta-to-html) 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. Typescript/Javascript package.
## Testing
To aid in testing applications using the editor an extension to the flutter `WidgetTester` is provided which includes methods to simplify interacting with the editor in test cases.
Import the test utilities in your test file:
```dart
import 'package:flutter_quill/flutter_quill_test.dart';
```
and then enter text using `quillEnterText`:
```dart
await tester.quillEnterText(find.byType(QuillEditor), 'test\n');
```
## Sponsors ## Sponsors
<a href="https://bulletjournal.us/home/index.html"> <a href="https://bulletjournal.us/home/index.html">

@ -158,76 +158,70 @@ class _HomePageState extends State<HomePage> {
} }
Widget _buildWelcomeEditor(BuildContext context) { Widget _buildWelcomeEditor(BuildContext context) {
Widget quillEditor = MouseRegion( Widget quillEditor = QuillEditor(
cursor: SystemMouseCursors.text, controller: _controller!,
child: QuillEditor( scrollController: ScrollController(),
controller: _controller!, scrollable: true,
scrollController: ScrollController(), focusNode: _focusNode,
scrollable: true, autoFocus: false,
focusNode: _focusNode, readOnly: false,
autoFocus: false, placeholder: 'Add content',
readOnly: false, enableSelectionToolbar: isMobile(),
placeholder: 'Add content', expands: false,
enableSelectionToolbar: isMobile(), padding: EdgeInsets.zero,
expands: false, onImagePaste: _onImagePaste,
padding: EdgeInsets.zero, onTapUp: (details, p1) {
onImagePaste: _onImagePaste, return _onTripleClickSelection();
onTapUp: (details, p1) { },
return _onTripleClickSelection(); customStyles: DefaultStyles(
}, h1: DefaultTextBlockStyle(
customStyles: DefaultStyles( const TextStyle(
h1: DefaultTextBlockStyle( fontSize: 32,
const TextStyle( color: Colors.black,
fontSize: 32, height: 1.15,
color: Colors.black, fontWeight: FontWeight.w300,
height: 1.15, ),
fontWeight: FontWeight.w300, const VerticalSpacing(16, 0),
), const VerticalSpacing(0, 0),
const VerticalSpacing(16, 0), null),
const VerticalSpacing(0, 0), sizeSmall: const TextStyle(fontSize: 9),
null),
sizeSmall: const TextStyle(fontSize: 9),
),
embedBuilders: [
...FlutterQuillEmbeds.builders(),
NotesEmbedBuilder(addEditNote: _addEditNote)
],
), ),
embedBuilders: [
...FlutterQuillEmbeds.builders(),
NotesEmbedBuilder(addEditNote: _addEditNote)
],
); );
if (kIsWeb) { if (kIsWeb) {
quillEditor = MouseRegion( quillEditor = QuillEditor(
cursor: SystemMouseCursors.text, controller: _controller!,
child: QuillEditor( scrollController: ScrollController(),
controller: _controller!, scrollable: true,
scrollController: ScrollController(), focusNode: _focusNode,
scrollable: true, autoFocus: false,
focusNode: _focusNode, readOnly: false,
autoFocus: false, placeholder: 'Add content',
readOnly: false, expands: false,
placeholder: 'Add content', padding: EdgeInsets.zero,
expands: false, onTapUp: (details, p1) {
padding: EdgeInsets.zero, return _onTripleClickSelection();
onTapUp: (details, p1) { },
return _onTripleClickSelection(); customStyles: DefaultStyles(
}, h1: DefaultTextBlockStyle(
customStyles: DefaultStyles( const TextStyle(
h1: DefaultTextBlockStyle( fontSize: 32,
const TextStyle( color: Colors.black,
fontSize: 32, height: 1.15,
color: Colors.black, fontWeight: FontWeight.w300,
height: 1.15, ),
fontWeight: FontWeight.w300, const VerticalSpacing(16, 0),
), const VerticalSpacing(0, 0),
const VerticalSpacing(16, 0), null),
const VerticalSpacing(0, 0), sizeSmall: const TextStyle(fontSize: 9),
null), ),
sizeSmall: const TextStyle(fontSize: 9), embedBuilders: [
), ...defaultEmbedBuildersWeb,
embedBuilders: [ NotesEmbedBuilder(addEditNote: _addEditNote),
...defaultEmbedBuildersWeb, ]);
NotesEmbedBuilder(addEditNote: _addEditNote),
]),
);
} }
var toolbar = QuillToolbar.basic( var toolbar = QuillToolbar.basic(
controller: _controller!, controller: _controller!,
@ -502,6 +496,7 @@ class NotesEmbedBuilder extends EmbedBuilder {
Embed node, Embed node,
bool readOnly, bool readOnly,
bool inline, bool inline,
TextStyle textStyle,
) { ) {
final notes = NotesBlockEmbed(node.value.data).document; final notes = NotesBlockEmbed(node.value.data).document;

@ -38,6 +38,7 @@ class ImageEmbedBuilderWeb extends EmbedBuilder {
Embed node, Embed node,
bool readOnly, bool readOnly,
bool inline, bool inline,
TextStyle textStyle,
) { ) {
final imageUrl = node.value.data; final imageUrl = node.value.data;
if (isImageBase64(imageUrl)) { if (isImageBase64(imageUrl)) {
@ -80,6 +81,7 @@ class VideoEmbedBuilderWeb extends EmbedBuilder {
Embed node, Embed node,
bool readOnly, bool readOnly,
bool inline, 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')) {

@ -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,9 @@
## 0.3.3
* Fix a prototype bug which was bring by [PR #1230](https://github.com/singerdmx/flutter-quill/pull/1230#issuecomment-1560597099)
## 0.3.2
* Updated dependencies to support intl 0.18
## 0.3.1 ## 0.3.1
* Image embedding tweaks * Image embedding tweaks
* Add MediaButton which is intened to superseed the ImageButton and VideoButton. Only image selection is working. * Add MediaButton which is intened to superseed the ImageButton and VideoButton. Only image selection is working.

@ -28,6 +28,7 @@ class ImageEmbedBuilder extends EmbedBuilder {
base.Embed node, base.Embed node,
bool readOnly, bool readOnly,
bool inline, bool inline,
TextStyle textStyle,
) { ) {
assert(!kIsWeb, 'Please provide image EmbedBuilder for Web'); assert(!kIsWeb, 'Please provide image EmbedBuilder for Web');
@ -164,6 +165,7 @@ class ImageEmbedBuilderWeb extends EmbedBuilder {
Embed node, Embed node,
bool readOnly, bool readOnly,
bool inline, bool inline,
TextStyle textStyle,
) { ) {
final imageUrl = node.value.data; final imageUrl = node.value.data;
@ -198,6 +200,7 @@ class VideoEmbedBuilder extends EmbedBuilder {
base.Embed node, base.Embed node,
bool readOnly, bool readOnly,
bool inline, bool inline,
TextStyle textStyle,
) { ) {
assert(!kIsWeb, 'Please provide video EmbedBuilder for Web'); assert(!kIsWeb, 'Please provide video EmbedBuilder for Web');
@ -226,6 +229,7 @@ class FormulaEmbedBuilder extends EmbedBuilder {
base.Embed node, base.Embed node,
bool readOnly, bool readOnly,
bool inline, bool inline,
TextStyle textStyle,
) { ) {
assert(!kIsWeb, 'Please provide formula EmbedBuilder for Web'); assert(!kIsWeb, 'Please provide formula EmbedBuilder for Web');

@ -1,25 +1,25 @@
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.3.1 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.17.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: ^7.1.17 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: ^8.1.1 youtube_player_flutter: ^8.1.1
gallery_saver: ^2.3.2 gallery_saver: ^2.3.2
math_keyboard: ^0.1.8 math_keyboard: ^0.2.0
string_validator: ^1.0.0 string_validator: ^1.0.0
universal_html: ^2.2.1 universal_html: ^2.2.1
url_launcher: ^6.1.9 url_launcher: ^6.1.9

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

@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
class QuillCustomButton { class QuillCustomButton {
const QuillCustomButton({ const QuillCustomButton({
this.icon, this.icon,
this.iconColor,
this.onTap, this.onTap,
this.tooltip, this.tooltip,
}); });
@ -10,6 +11,9 @@ class QuillCustomButton {
///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;

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

@ -68,6 +68,9 @@ extension Localization on String {
'Enter media': 'Enter media', 'Enter media': 'Enter media',
'Edit': 'Edit', 'Edit': 'Edit',
'Apply': 'Apply', 'Apply': 'Apply',
'Hex': 'Hex',
'Material': 'Material',
'Color': 'Color',
}, },
'en_us': { 'en_us': {
'Paste a link': 'Paste a link', 'Paste a link': 'Paste a link',
@ -134,72 +137,80 @@ extension Localization on String {
'Enter media': 'Enter media', 'Enter media': 'Enter media',
'Edit': 'Edit', 'Edit': 'Edit',
'Apply': 'Apply', 'Apply': 'Apply',
'Hex': 'Hex',
'Material': 'Material',
'Color': 'Color',
}, },
'ar': { 'ar': {
'Paste a link': 'نسخ الرابط', 'Paste a link': 'نسخ الرابط',
'Ok': 'نعم', 'Ok': 'نعم',
'Select Color': 'اختار اللون', 'Select Color': 'اختار اللون',
'Gallery': 'الصور', 'Gallery': 'المعرض',
'Link': 'الرابط', 'Link': 'الرابط',
'Please first select some text to transform into a link.': 'Please first select some text to transform into a link.':
'يرجى اختيار نص للتحويل إلى رابط', 'يرجى اختيار نص للتحويل إلى رابط',
'Open': 'فتح', 'Open': 'فتح',
'Copy': 'ينسخ', 'Copy': 'نسخ',
'Remove': 'إزالة', 'Remove': 'إزالة',
'Save': 'يحفظ', 'Save': 'حفظ',
'Zoom': 'تكبير', 'Zoom': 'تكبير',
'Saved': 'أنقذ', 'Saved': 'تم الحفظ',
'Text': 'Text', 'Text': 'نص',
'What is entered is not a link': 'What is entered is not a link', 'What is entered is not a link': 'ما تم ادخاله ليس رابط',
'Resize': 'Resize', 'Resize': 'تحجيم',
'Width': 'Width', 'Width': 'عرض',
'Height': 'Height', 'Height': 'ارتفاع',
'Size': 'Size', 'Size': 'حجم',
'Small': 'Small', 'Small': 'صغير',
'Large': 'Large', 'Large': 'كبير',
'Huge': 'Huge', 'Huge': 'ضخم',
'Clear': 'Clear', 'Clear': 'تنظيف',
'Font': 'Font', 'Font': 'خط',
'Search': 'Search', 'Search': 'بحث',
'matches': 'matches', 'matches': 'تطابق',
'showing match': 'showing match', 'showing match': 'عرض التطابق',
'Prev': 'Prev', 'Prev': 'سابق',
'Next': 'Next', 'Next': 'تالي',
'Camera': 'Camera', 'Camera': 'كاميرا',
'Video': 'Video', 'Video': 'فيديو',
'Undo': 'Undo', 'Undo': 'تراجع',
'Redo': 'Redo', 'Redo': 'تقدم',
'Font family': 'Font family', 'Font family': 'عائلة الخط',
'Font size': 'Font size', 'Font size': 'حجم الخط',
'Bold': 'Bold', 'Bold': 'عريض',
'Subscript': 'Subscript', 'Subscript': 'نص سفلي',
'Superscript': 'Superscript', 'Superscript': 'نص علوي',
'Italic': 'Italic', 'Italic': 'مائل',
'Underline': 'Underline', 'Underline': 'تحته خط',
'Strike through': 'Strike through', 'Strike through': 'داخله خط',
'Inline code': 'Inline code', 'Inline code': 'كود بوسط السطر',
'Font color': 'Font color', 'Font color': 'لون الخط',
'Background color': 'Background color', 'Background color': 'لون الخلفية',
'Clear format': 'Clear format', 'Clear format': 'تنظيف التنسيق',
'Align left': 'Align left', 'Align left': 'محاذاة اليسار',
'Align center': 'Align center', 'Align center': 'محاذاة الوسط',
'Align right': 'Align right', 'Align right': 'محاذاة اليمين',
// i think it should be 'Justify with width'
// it is wrong in all properties
'Justify win width': 'Justify win width', 'Justify win width': 'Justify win width',
'Text direction': 'Text direction', 'Text direction': 'اتجاه النص',
'Header style': 'Header style', 'Header style': 'ستايل العنوان',
'Numbered list': 'Numbered list', 'Numbered list': 'قائمة مرقمة',
'Bullet list': 'Bullet list', 'Bullet list': 'قائمة منقطة',
'Checked list': 'Checked list', 'Checked list': 'قائمة للمهام',
'Code block': 'Code block', 'Code block': 'كود كامل',
'Quote': 'Quote', 'Quote': 'اقتباس',
'Increase indent': 'Increase indent', 'Increase indent': 'زيادة الهامش',
'Decrease indent': 'Decrease indent', 'Decrease indent': 'تنقيص الهامش',
'Insert URL': 'Insert URL', 'Insert URL': 'ادخل عنوان رابط',
'Visit link': 'Visit link', 'Visit link': 'زيارة الرابط',
'Enter link': 'Enter link', 'Enter link': 'ادخل رابط',
'Enter media': 'Enter media', 'Enter media': 'ادخل وسائط',
'Edit': 'Edit', 'Edit': 'تعديل',
'Apply': 'Apply', 'Apply': 'تطبيق',
'Hex': 'Hex',
'Material': 'Material',
'Color': 'اللون',
}, },
'da': { 'da': {
'Paste a link': 'Indsæt link', 'Paste a link': 'Indsæt link',
@ -1023,45 +1034,45 @@ extension Localization on String {
'Clear': 'Limpar', 'Clear': 'Limpar',
'Font': 'Fonte', 'Font': 'Fonte',
'Search': 'Buscar', 'Search': 'Buscar',
'matches': 'matches', 'matches': 'resultado(s)',
'showing match': 'showing match', 'showing match': 'mostrando resultado',
'Prev': 'Anterior', 'Prev': 'Anterior',
'Next': 'Próximo', 'Next': 'Próximo',
'Camera': 'Camera', 'Camera': 'Câmera',
'Video': 'Vídeo', 'Video': 'Vídeo',
'Undo': 'Undo', 'Undo': 'Desfazer',
'Redo': 'Redo', 'Redo': 'Refazer',
'Font family': 'Font family', 'Font family': 'Fonte',
'Font size': 'Font size', 'Font size': 'Tamanho da fonte',
'Bold': 'Bold', 'Bold': 'Negrito',
'Subscript': 'Subscript', 'Subscript': 'Subscrito',
'Superscript': 'Superscript', 'Superscript': 'Sobrescrito',
'Italic': 'Italic', 'Italic': 'Itálico',
'Underline': 'Underline', 'Underline': 'Sublinhado',
'Strike through': 'Strike through', 'Strike through': 'Tachado',
'Inline code': 'Inline code', 'Inline code': 'Inline code',
'Font color': 'Font color', 'Font color': 'Cor da fonte',
'Background color': 'Background color', 'Background color': 'Cor do fundo',
'Clear format': 'Clear format', 'Clear format': 'Limpar formatação',
'Align left': 'Align left', 'Align left': 'Texto à esquerda',
'Align center': 'Align center', 'Align center': 'Centralizar',
'Align right': 'Align right', 'Align right': 'Texto à direita',
'Justify win width': 'Justify win width', 'Justify win width': 'Justificado',
'Text direction': 'Text direction', 'Text direction': 'Direção do texto',
'Header style': 'Header style', 'Header style': 'Estilo de cabeçalho',
'Numbered list': 'Numbered list', 'Numbered list': 'Numeração',
'Bullet list': 'Bullet list', 'Bullet list': 'Marcadores',
'Checked list': 'Checked list', 'Checked list': 'Lista de verificação',
'Code block': 'Code block', 'Code block': 'Code block',
'Quote': 'Quote', 'Quote': 'Citação',
'Increase indent': 'Increase indent', 'Increase indent': 'Aumentar recuo',
'Decrease indent': 'Decrease indent', 'Decrease indent': 'Diminuir recuo',
'Insert URL': 'Insert URL', 'Insert URL': 'Inserir URL',
'Visit link': 'Visit link', 'Visit link': 'Visitar link',
'Enter link': 'Enter link', 'Enter link': 'Inserir link',
'Enter media': 'Enter media', 'Enter media': 'Inserir mídia',
'Edit': 'Edit', 'Edit': 'Editar',
'Apply': 'Apply', 'Apply': 'Aplicar',
}, },
'pl': { 'pl': {
'Paste a link': 'Wklej link', 'Paste a link': 'Wklej link',

@ -255,14 +255,6 @@ class QuillController extends ChangeNotifier {
} }
} }
if (_keepStyleOnNewLine) {
final style = getSelectionStyle();
final notInlineStyle = style.attributes.values.where((s) => !s.isInline);
toggledStyle = style.removeAll(notInlineStyle.toSet());
} else {
toggledStyle = Style();
}
if (textSelection != null) { if (textSelection != null) {
if (delta == null || delta.isEmpty) { if (delta == null || delta.isEmpty) {
_updateSelection(textSelection, ChangeSource.LOCAL); _updateSelection(textSelection, ChangeSource.LOCAL);
@ -400,7 +392,13 @@ 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));
toggledStyle = Style(); if (_keepStyleOnNewLine) {
final style = getSelectionStyle();
final notInlineStyle = style.attributes.values.where((s) => !s.isInline);
toggledStyle = style.removeAll(notInlineStyle.toSet());
} else {
toggledStyle = Style();
}
onSelectionChanged?.call(textSelection); onSelectionChanged?.call(textSelection);
} }

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

@ -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();
@ -187,6 +190,7 @@ class QuillEditor extends StatefulWidget {
this.enableUnfocusOnTapOutside = true, this.enableUnfocusOnTapOutside = true,
this.customLinkPrefixes = const <String>[], this.customLinkPrefixes = const <String>[],
this.dialogTheme, this.dialogTheme,
this.contentInsertionConfiguration,
Key? key, Key? key,
}) : super(key: key); }) : super(key: key);
@ -427,6 +431,12 @@ class QuillEditor extends StatefulWidget {
/// Configures the dialog theme. /// Configures the dialog theme.
final QuillDialogTheme? dialogTheme; 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();
} }
@ -467,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;
@ -528,6 +538,7 @@ class QuillEditorState extends State<QuillEditor>
customLinkPrefixes: widget.customLinkPrefixes, customLinkPrefixes: widget.customLinkPrefixes,
enableUnfocusOnTapOutside: widget.enableUnfocusOnTapOutside, enableUnfocusOnTapOutside: widget.enableUnfocusOnTapOutside,
dialogTheme: widget.dialogTheme, dialogTheme: widget.dialogTheme,
contentInsertionConfiguration: widget.contentInsertionConfiguration,
); );
final editor = I18n( final editor = I18n(

@ -21,6 +21,7 @@ abstract class EmbedBuilder {
leaf.Embed node, leaf.Embed node,
bool readOnly, bool readOnly,
bool inline, bool inline,
TextStyle textStyle,
); );
} }

@ -84,6 +84,7 @@ class RawEditor extends StatefulWidget {
this.onImagePaste, this.onImagePaste,
this.customLinkPrefixes = const <String>[], this.customLinkPrefixes = const <String>[],
this.dialogTheme, this.dialogTheme,
this.contentInsertionConfiguration,
}) : assert(maxHeight == null || maxHeight > 0, 'maxHeight cannot be null'), }) : assert(maxHeight == null || maxHeight > 0, 'maxHeight cannot be null'),
assert(minHeight == null || minHeight >= 0, 'minHeight cannot be null'), assert(minHeight == null || minHeight >= 0, 'minHeight cannot be null'),
assert(maxHeight == null || minHeight == null || maxHeight >= minHeight, assert(maxHeight == null || minHeight == null || maxHeight >= minHeight,
@ -270,6 +271,12 @@ class RawEditor extends StatefulWidget {
/// Configures the dialog theme. /// Configures the dialog theme.
final QuillDialogTheme? dialogTheme; 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
State<StatefulWidget> createState() => RawEditorState(); State<StatefulWidget> createState() => RawEditorState();
} }
@ -325,6 +332,18 @@ class RawEditorState extends EditorState
TextDirection get _textDirection => Directionality.of(context); TextDirection get _textDirection => Directionality.of(context);
@override
bool get dirty => _dirty;
bool _dirty = false;
@override
void insertContent(KeyboardInsertedContent content) {
assert(widget.contentInsertionConfiguration?.allowedMimeTypes
.contains(content.mimeType) ??
false);
widget.contentInsertionConfiguration?.onContentInserted.call(content);
}
/// Returns the [ContextMenuButtonItem]s representing the buttons in this /// Returns the [ContextMenuButtonItem]s representing the buttons in this
/// platform's default selection menu for [RawEditor]. /// platform's default selection menu for [RawEditor].
/// ///
@ -442,23 +461,26 @@ class RawEditorState extends EditorState
Widget child = CompositedTransformTarget( Widget child = CompositedTransformTarget(
link: _toolbarLayerLink, link: _toolbarLayerLink,
child: Semantics( child: Semantics(
child: _Editor( child: MouseRegion(
key: _editorKey, cursor: SystemMouseCursors.text,
document: _doc, child: _Editor(
selection: controller.selection, key: _editorKey,
hasFocus: _hasFocus, document: _doc,
scrollable: widget.scrollable, selection: controller.selection,
cursorController: _cursorCont, hasFocus: _hasFocus,
textDirection: _textDirection, scrollable: widget.scrollable,
startHandleLayerLink: _startHandleLayerLink, cursorController: _cursorCont,
endHandleLayerLink: _endHandleLayerLink, textDirection: _textDirection,
onSelectionChanged: _handleSelectionChanged, startHandleLayerLink: _startHandleLayerLink,
onSelectionCompleted: _handleSelectionCompleted, endHandleLayerLink: _endHandleLayerLink,
scrollBottomInset: widget.scrollBottomInset, onSelectionChanged: _handleSelectionChanged,
padding: widget.padding, onSelectionCompleted: _handleSelectionCompleted,
maxContentWidth: widget.maxContentWidth, scrollBottomInset: widget.scrollBottomInset,
floatingCursorDisabled: widget.floatingCursorDisabled, padding: widget.padding,
children: _buildChildren(_doc, context), maxContentWidth: widget.maxContentWidth,
floatingCursorDisabled: widget.floatingCursorDisabled,
children: _buildChildren(_doc, context),
),
), ),
), ),
); );
@ -480,24 +502,27 @@ class RawEditorState extends EditorState
physics: widget.scrollPhysics, physics: widget.scrollPhysics,
viewportBuilder: (_, offset) => CompositedTransformTarget( viewportBuilder: (_, offset) => CompositedTransformTarget(
link: _toolbarLayerLink, link: _toolbarLayerLink,
child: _Editor( child: MouseRegion(
key: _editorKey, cursor: SystemMouseCursors.text,
offset: offset, child: _Editor(
document: _doc, key: _editorKey,
selection: controller.selection, offset: offset,
hasFocus: _hasFocus, document: _doc,
scrollable: widget.scrollable, selection: controller.selection,
textDirection: _textDirection, hasFocus: _hasFocus,
startHandleLayerLink: _startHandleLayerLink, scrollable: widget.scrollable,
endHandleLayerLink: _endHandleLayerLink, textDirection: _textDirection,
onSelectionChanged: _handleSelectionChanged, startHandleLayerLink: _startHandleLayerLink,
onSelectionCompleted: _handleSelectionCompleted, endHandleLayerLink: _endHandleLayerLink,
scrollBottomInset: widget.scrollBottomInset, onSelectionChanged: _handleSelectionChanged,
padding: widget.padding, onSelectionCompleted: _handleSelectionCompleted,
maxContentWidth: widget.maxContentWidth, scrollBottomInset: widget.scrollBottomInset,
cursorController: _cursorCont, padding: widget.padding,
floatingCursorDisabled: widget.floatingCursorDisabled, maxContentWidth: widget.maxContentWidth,
children: _buildChildren(_doc, context), cursorController: _cursorCont,
floatingCursorDisabled: widget.floatingCursorDisabled,
children: _buildChildren(_doc, context),
),
), ),
), ),
), ),
@ -840,6 +865,7 @@ class RawEditorState extends EditorState
final currentSelection = controller.selection.copyWith(); final currentSelection = controller.selection.copyWith();
final attribute = value ? Attribute.checked : Attribute.unchecked; final attribute = value ? Attribute.checked : Attribute.unchecked;
_markNeedsBuild();
controller controller
..ignoreFocusOnTextChange = true ..ignoreFocusOnTextChange = true
..formatText(offset, 0, attribute) ..formatText(offset, 0, attribute)
@ -888,7 +914,7 @@ class RawEditorState extends EditorState
final editableTextBlock = EditableTextBlock( final editableTextBlock = EditableTextBlock(
block: node, block: node,
controller: controller, controller: controller,
textDirection: _textDirection, textDirection: getDirectionOfNode(node),
scrollBottomInset: widget.scrollBottomInset, scrollBottomInset: widget.scrollBottomInset,
verticalSpacing: _getVerticalSpacingForBlock(node, _styles), verticalSpacing: _getVerticalSpacingForBlock(node, _styles),
textSelection: controller.selection, textSelection: controller.selection,
@ -914,9 +940,11 @@ class RawEditorState extends EditorState
clearIndents = false; clearIndents = false;
} else { } else {
_dirty = false;
throw StateError('Unreachable.'); throw StateError('Unreachable.');
} }
} }
_dirty = false;
return result; return result;
} }
@ -946,7 +974,7 @@ class RawEditorState extends EditorState
widget.selectionColor, widget.selectionColor,
widget.enableInteractiveSelection, widget.enableInteractiveSelection,
_hasFocus, _hasFocus,
MediaQuery.of(context).devicePixelRatio, View.of(context).devicePixelRatio,
_cursorCont); _cursorCont);
return editableTextLine; return editableTextLine;
} }
@ -1155,6 +1183,17 @@ class RawEditorState extends EditorState
_selectionOverlay?.updateForScroll(); _selectionOverlay?.updateForScroll();
} }
/// Marks the editor as dirty and trigger a rebuild.
///
/// When the editor is dirty methods that depend on the editor
/// state being in sync with the controller know they may be
/// operating on stale data.
void _markNeedsBuild() {
setState(() {
_dirty = true;
});
}
void _didChangeTextEditingValue([bool ignoreFocus = false]) { void _didChangeTextEditingValue([bool ignoreFocus = false]) {
if (kIsWeb) { if (kIsWeb) {
_onChangeTextEditingValue(ignoreFocus); _onChangeTextEditingValue(ignoreFocus);
@ -1169,10 +1208,9 @@ class RawEditorState extends EditorState
} else { } else {
requestKeyboard(); requestKeyboard();
if (mounted) { if (mounted) {
setState(() { // Use controller.value in build()
// Use controller.value in build() // Mark widget as dirty and trigger build and updateChildren
// Trigger build and updateChildren _markNeedsBuild();
});
} }
} }
@ -1207,10 +1245,9 @@ class RawEditorState extends EditorState
_updateOrDisposeSelectionOverlayIfNeeded(); _updateOrDisposeSelectionOverlayIfNeeded();
}); });
if (mounted) { if (mounted) {
setState(() { // Use controller.value in build()
// Use controller.value in build() // Mark widget as dirty and trigger build and updateChildren
// Trigger build and updateChildren _markNeedsBuild();
});
} }
} }
@ -1243,6 +1280,11 @@ class RawEditorState extends EditorState
} }
void _handleFocusChanged() { void _handleFocusChanged() {
if (dirty) {
SchedulerBinding.instance
.addPostFrameCallback((_) => _handleFocusChanged());
return;
}
openOrCloseConnection(); openOrCloseConnection();
_cursorCont.startOrStopCursorTimerIfNeeded(_hasFocus, controller.selection); _cursorCont.startOrStopCursorTimerIfNeeded(_hasFocus, controller.selection);
_updateOrDisposeSelectionOverlayIfNeeded(); _updateOrDisposeSelectionOverlayIfNeeded();
@ -1257,10 +1299,9 @@ class RawEditorState extends EditorState
void _onChangedClipboardStatus() { void _onChangedClipboardStatus() {
if (!mounted) return; if (!mounted) return;
setState(() { // Inform the widget that the value of clipboardStatus has changed.
// Inform the widget that the value of clipboardStatus has changed. // Trigger build and updateChildren
// Trigger build and updateChildren _markNeedsBuild();
});
} }
Future<LinkMenuAction> _linkActionPicker(Node linkNode) async { Future<LinkMenuAction> _linkActionPicker(Node linkNode) async {
@ -1709,7 +1750,7 @@ class RawEditorState extends EditorState
} }
class _Editor extends MultiChildRenderObjectWidget { class _Editor extends MultiChildRenderObjectWidget {
_Editor({ const _Editor({
required Key key, required Key key,
required List<Widget> children, required List<Widget> children,
required this.document, required this.document,

@ -59,6 +59,9 @@ 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,
), ),
); );
@ -88,7 +91,8 @@ mixin RawEditorStateTextInputClientMixin on EditorState
void _updateCaretRectIfNeeded() { void _updateCaretRectIfNeeded() {
if (hasConnection) { if (hasConnection) {
if (renderEditor.selection.isValid && if (!dirty &&
renderEditor.selection.isValid &&
renderEditor.selection.isCollapsed) { renderEditor.selection.isCollapsed) {
final currentTextPosition = final currentTextPosition =
TextPosition(offset: renderEditor.selection.baseOffset); TextPosition(offset: renderEditor.selection.baseOffset);

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

@ -152,14 +152,14 @@ class EditableTextBlock extends StatelessWidget {
onLaunchUrl: onLaunchUrl, onLaunchUrl: onLaunchUrl,
customLinkPrefixes: customLinkPrefixes, 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(
@ -170,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,
); );
} }
@ -217,35 +220,37 @@ 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;
@ -596,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,

@ -148,15 +148,10 @@ class _TextLineState extends State<TextLine> {
final embedBuilder = widget.embedBuilder(embed); final embedBuilder = widget.embedBuilder(embed);
if (embedBuilder.expanded) { if (embedBuilder.expanded) {
// Creates correct node for custom embed // Creates correct node for custom embed
final lineStyle = _getLineStyle(widget.styles);
return EmbedProxy( return EmbedProxy(
embedBuilder.build( embedBuilder.build(context, widget.controller, embed, widget.readOnly,
context, false, lineStyle),
widget.controller,
embed,
widget.readOnly,
false,
),
); );
} }
} }
@ -198,7 +193,8 @@ class _TextLineState extends State<TextLine> {
} }
// Creates correct node for custom embed // Creates correct node for custom embed
if (child.value.type == BlockEmbed.customType) { if (child.value.type == BlockEmbed.customType) {
child = Embed(CustomBlockEmbed.fromJsonString(child.value.data)); child = Embed(CustomBlockEmbed.fromJsonString(child.value.data))
..applyStyle(child.style);
} }
final embedBuilder = widget.embedBuilder(child); final embedBuilder = widget.embedBuilder(child);
final embedWidget = EmbedProxy( final embedWidget = EmbedProxy(
@ -208,6 +204,7 @@ class _TextLineState extends State<TextLine> {
child, child,
widget.readOnly, widget.readOnly,
true, true,
lineStyle,
), ),
); );
final embed = embedBuilder.buildWidgetSpan(embedWidget); final embed = embedBuilder.buildWidgetSpan(embedWidget);
@ -1082,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) {
final parentData = _leading!.parentData as BoxParentData; if (textDirection == TextDirection.ltr) {
final effectiveOffset = offset + parentData.offset; final parentData = _leading!.parentData as BoxParentData;
context.paintChild(_leading!, effectiveOffset); final effectiveOffset = offset + parentData.offset;
context.paintChild(_leading!, effectiveOffset);
} else {
final parentData = _leading!.parentData as BoxParentData;
final effectiveOffset = offset + parentData.offset;
context.paintChild(_leading!,
Offset(size.width - _leading!.size.width, effectiveOffset.dy));
}
} }
if (_body != null) { if (_body != null) {

@ -713,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;
@ -857,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;

@ -11,13 +11,13 @@ 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/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,6 +26,7 @@ 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';
@ -566,15 +567,14 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget {
QuillDivider(axis, QuillDivider(axis,
color: sectionDividerColor, space: sectionDividerSpace), color: sectionDividerColor, space: sectionDividerSpace),
for (var customButton in customButtons) for (var customButton in customButtons)
QuillIconButton( CustomButton(
highlightElevation: 0,
hoverElevation: 0,
size: toolbarIconSize * kIconButtonFactor,
icon: Icon(customButton.icon, size: toolbarIconSize),
tooltip: customButton.tooltip,
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,
), ),
], ],
); );
@ -646,7 +646,7 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget {
/// The divider which is used for separation of buttons in the toolbar. /// 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 /// It can be used outside of this package, for example when user does not use
/// [QuillToolbar.basic] and compose toolbat's children on its own. /// [QuillToolbar.basic] and compose toolbar's children on its own.
class QuillDivider extends StatelessWidget { class QuillDivider extends StatelessWidget {
const QuillDivider( const QuillDivider(
this.axis, { this.axis, {
@ -655,11 +655,11 @@ class QuillDivider extends StatelessWidget {
this.space, this.space,
}) : super(key: key); }) : super(key: key);
/// Provides a horizonal divider for vertical toolbar. /// Provides a horizontal divider for vertical toolbar.
const QuillDivider.horizontal({Color? color, double? space}) const QuillDivider.horizontal({Color? color, double? space})
: this(Axis.horizontal, color: color, space: space); : this(Axis.horizontal, color: color, space: space);
/// Provides a horizonal divider for horizontal toolbar. /// Provides a horizontal divider for horizontal toolbar.
const QuillDivider.vertical({Color? color, double? space}) const QuillDivider.vertical({Color? color, double? space})
: this(Axis.vertical, color: color, space: space); : this(Axis.vertical, color: color, space: space);

@ -136,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) {
title: Text('Select Color'.i18n), return AlertDialog(
backgroundColor: Theme.of(context).canvasColor, title: Text('Select Color'.i18n),
content: SingleChildScrollView( actions: [
child: MaterialPicker( TextButton(
pickerColor: const Color(0x00000000), onPressed: () {
onColorChanged: (color) => _changeColor(context, color), Navigator.of(context).pop();
), },
), child: Text('OK'.i18n)),
), ],
backgroundColor: Theme.of(context).canvasColor,
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
TextButton(
onPressed: () {
dlgSetState(() {
pickerType = 'material';
});
},
child: Text('Material'.i18n)),
TextButton(
onPressed: () {
dlgSetState(() {
pickerType = 'color';
});
},
child: Text('Color'.i18n)),
],
),
Column(children: [
if (pickerType == 'material')
MaterialPicker(
pickerColor: selectedColor,
onColorChanged: (color) {
_changeColor(context, color);
Navigator.of(context).pop();
},
),
if (pickerType == 'color')
ColorPicker(
pickerColor: selectedColor,
onColorChanged: (color) {
_changeColor(context, color);
hexController.text = colorToHex(color);
selectedColor = color;
colorBoxSetState(() {});
},
),
const SizedBox(
height: 10,
),
Row(
children: [
SizedBox(
width: 100,
height: 60,
child: TextFormField(
controller: hexController,
onChanged: (value) {
selectedColor = hexToColor(value);
_changeColor(context, selectedColor);
colorBoxSetState(() {});
},
decoration: InputDecoration(
labelText: 'Hex'.i18n,
border: const OutlineInputBorder(),
),
),
),
const SizedBox(
width: 10,
),
StatefulBuilder(builder: (context, mcolorBoxSetState) {
colorBoxSetState = mcolorBoxSetState;
return Container(
width: 25,
height: 25,
decoration: BoxDecoration(
border: Border.all(
color: Colors.black45,
),
color: selectedColor,
borderRadius: BorderRadius.circular(5),
),
);
}),
],
),
])
],
),
));
}),
); );
} }
Color hexToColor(String? hexString) {
if (hexString == null) {
return Colors.black;
}
final hexRegex = RegExp(r'([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})$');
hexString = hexString.replaceAll('#', '');
if (!hexRegex.hasMatch(hexString)) {
return Colors.black;
}
final buffer = StringBuffer();
if (hexString.length == 6 || hexString.length == 7) buffer.write('ff');
buffer.write(hexString);
return Color(int.tryParse(buffer.toString(), radix: 16) ?? 0xFF000000);
}
String colorToHex(Color color) {
return color.value.toRadixString(16).padLeft(8, '0').toUpperCase();
}
} }

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

@ -46,7 +46,7 @@ class _HistoryButtonState extends State<HistoryButton> {
tooltip: widget.tooltip, 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,

@ -42,7 +42,7 @@ class _IndentButtonState extends State<IndentButton> {
tooltip: widget.tooltip, 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,

@ -43,7 +43,7 @@ class SearchButton extends StatelessWidget {
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),

@ -1,13 +1,13 @@
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: 7.1.17+1 version: 7.2.6
#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.17.0 <3.0.0" sdk: ">=2.17.0 <4.0.0"
flutter: ">=3.0.0" flutter: ">=3.10.0"
dependencies: dependencies:
flutter: flutter:
@ -20,12 +20,12 @@ dependencies:
pedantic: ^1.11.1 pedantic: ^1.11.1
characters: ^1.2.1 characters: ^1.2.1
diff_match_patch: ^0.4.1 diff_match_patch: ^0.4.1
i18n_extension: ^8.0.0 i18n_extension: ">=8.0.0 <10.0.0"
device_info_plus: ^9.0.0 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