Merge branch 'singerdmx:master' into master

pull/1355/head
Dilanka Yapa 2 years ago committed by GitHub
commit cfdae3b569
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 23
      .github/workflows/main.yml
  2. 1
      .gitignore
  3. 67
      CHANGELOG.md
  4. 25
      README.md
  5. 18
      doc_cn.md
  6. 6
      example/android/build.gradle
  7. 2
      example/android/gradle/wrapper/gradle-wrapper.properties
  8. BIN
      example/assets/fonts/SF-Pro-Display-Regular.otf
  9. 157
      example/lib/pages/home_page.dart
  10. 44
      example/lib/widgets/time_stamp_embed_widget.dart
  11. 4
      example/linux/flutter/generated_plugin_registrant.cc
  12. 1
      example/linux/flutter/generated_plugins.cmake
  13. 2
      example/macos/Flutter/GeneratedPluginRegistrant.swift
  14. 3
      example/pubspec.yaml
  15. 3
      example/windows/flutter/generated_plugin_registrant.cc
  16. 1
      example/windows/flutter/generated_plugins.cmake
  17. 7
      flutter_quill_extensions/CHANGELOG.md
  18. 3
      flutter_quill_extensions/lib/embeds/builders.dart
  19. 7
      flutter_quill_extensions/lib/embeds/toolbar/image_button.dart
  20. 13
      flutter_quill_extensions/lib/embeds/toolbar/image_video_utils.dart
  21. 4
      flutter_quill_extensions/lib/embeds/toolbar/video_button.dart
  22. 2
      flutter_quill_extensions/lib/embeds/widgets/video_app.dart
  23. 4
      flutter_quill_extensions/lib/flutter_quill_extensions.dart
  24. 8
      flutter_quill_extensions/pubspec.yaml
  25. 1
      lib/flutter_quill.dart
  26. 3
      lib/flutter_quill_test.dart
  27. 3
      lib/quill_delta.dart
  28. 47
      lib/src/models/documents/document.dart
  29. 9
      lib/src/models/documents/nodes/container.dart
  30. 28
      lib/src/models/documents/nodes/leaf.dart
  31. 38
      lib/src/models/documents/nodes/line.dart
  32. 6
      lib/src/models/documents/nodes/node.dart
  33. 3
      lib/src/models/documents/style.dart
  34. 16
      lib/src/models/rules/delete.dart
  35. 3
      lib/src/models/rules/insert.dart
  36. 7
      lib/src/models/structs/link_dialog_action.dart
  37. 4
      lib/src/models/themes/quill_custom_button.dart
  38. 8
      lib/src/models/themes/quill_dialog_theme.dart
  39. 60
      lib/src/test/widget_tester_extension.dart
  40. 320
      lib/src/translations/toolbar.i18n.dart
  41. 24
      lib/src/widgets/controller.dart
  42. 36
      lib/src/widgets/editor.dart
  43. 3
      lib/src/widgets/embeds.dart
  44. 51
      lib/src/widgets/raw_editor.dart
  45. 54
      lib/src/widgets/raw_editor/raw_editor_state_selection_delegate_mixin.dart
  46. 3
      lib/src/widgets/raw_editor/raw_editor_state_text_input_client_mixin.dart
  47. 44
      lib/src/widgets/toolbar.dart
  48. 43
      lib/src/widgets/toolbar/custom_button.dart
  49. 2
      lib/src/widgets/toolbar/history_button.dart
  50. 2
      lib/src/widgets/toolbar/indent_button.dart
  51. 45
      lib/src/widgets/toolbar/link_style_button.dart
  52. 2
      lib/src/widgets/toolbar/search_button.dart
  53. 186
      lib/src/widgets/toolbar/search_dialog.dart
  54. 9
      pubspec.yaml
  55. 95
      test/bug_fix_test.dart
  56. 295
      test/widgets/controller_test.dart
  57. 133
      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/
build/
coverage/
# Android related
**/android/**/gradle-wrapper.jar

@ -1,3 +1,70 @@
# [7.3.2]
- Added builder for custom button in _LinkDialog.
# [7.3.1]
- Added case sensitive and whole word search parameters.
- Added wrap around.
- Moved search dialog to the bottom in order not to override the editor and the text found.
- Other minor search dialog enhancements.
# [7.3.0]
- Add default attributes to basic factory.
# [7.2.19]
- Feat/link regexp.
# [7.2.18]
- Fix paste block text in words apply same style.
# [7.2.17]
- Fix paste text mess up style.
- Add support copy/cut block text.
# [7.2.16]
- Allow for custom context menu.
# [7.2.15]
- Add flutter_quill.delta library which only exposes Delta datatype.
# [7.2.14]
- Fix errors when the editor is used in the `screenshot` package.
# [7.2.13]
- Fix around image can't delete line break.
# [7.2.12]
- Add support for copy/cut select image and text together.
# [7.2.11]
- Add affinity for localPosition.
# [7.2.10]
- LINE._getPlainText queryChild inclusive=false.
# [7.2.9]
- Add toPlainText method to `EmbedBuilder`.
# [7.2.8]
- Add custom button widget in toolbar.
# [7.2.7]
- Fix language code of Japan.
# [7.2.6]
- Style custom toolbar buttons like builtins.
# [7.2.5]
- Always use text cursor for editor on desktop.
# [7.2.4]
- Fixed keepStyleOnNewLine.
# [7.2.3]
- Get pixel ratio from view.
# [7.2.2]
- Prevent operations on stale editor state.
# [7.2.1]
- Add support for android keyboard content insertion.
- Enhance color picker, enter hex color and color palette option.

@ -24,9 +24,7 @@
FlutterQuill is a rich text editor and a [Quill] component for [Flutter].
This library is a WYSIWYG editor built for the modern mobile platform, with web compatibility under development. Check out our [Youtube Playlist] or [Code Introduction] to take a detailed walkthrough of the code base. You can join our [Slack Group] for discussion.
Demo App: [BULLET JOURNAL](https://bulletjournal.us/home/index.html)
This library is a WYSIWYG editor built for the modern Android, iOS, web and desktop platforms. Check out our [Youtube Playlist] or [Code Introduction] to take a detailed walkthrough of the code base. You can join our [Slack Group] for discussion.
Pub: [FlutterQuill]
@ -347,10 +345,11 @@ QuillToolbar(locale: Locale('fr'), ...)
QuillEditor(locale: Locale('fr'), ...)
```
Currently, translations are available for these 27 locales:
Currently, translations are available for these 28 locales:
* `Locale('en')`
* `Locale('ar')`
* `Locale('bn')`
* `Locale('cs')`
* `Locale('de')`
* `Locale('da')`
@ -375,7 +374,7 @@ Currently, translations are available for these 27 locales:
* `Locale('fa')`
* `Locale('hi')`
* `Locale('sr')`
* `Locale('jp')`
* `Locale('ja')`
#### Contributing to translations
@ -391,6 +390,22 @@ tables, and mentions. Conversion can be performed in vanilla Dart (i.e., server-
It is a complete Dart part of the popular and mature [quill-delta-to-html](https://www.npmjs.com/package/quill-delta-to-html)
Typescript/Javascript package.
## Testing
To aid in testing applications using the editor an extension to the flutter `WidgetTester` is provided which includes methods to simplify interacting with the editor in test cases.
Import the test utilities in your test file:
```dart
import 'package:flutter_quill/flutter_quill_test.dart';
```
and then enter text using `quillEnterText`:
```dart
await tester.quillEnterText(find.byType(QuillEditor), 'test\n');
```
## Sponsors
<a href="https://bulletjournal.us/home/index.html">

@ -26,7 +26,7 @@
`FlutterQuill` 是一个富文本编辑器,也是 [Quill](https://quilljs.com/docs/formats) 在 [Flutter](https://github.com/flutter/flutter) 的版本
该库是为移动平台构建的『所见即所得』的富文本编辑器,同时我们还正在对 `Web` 平台进行兼容。查看我们的 [Youtube 播放列表](https://youtube.com/playlist?list=PLbhaS_83B97vONkOAWGJrSXWX58et9zZ2) 或 [代码介绍](https://github.com/singerdmx/flutter-quill/blob/master/CodeIntroduction.md) 以了解代码的详细内容。你可以加入我们的 [Slack Group](https://join.slack.com/t/bulletjournal1024/shared_invite/zt-fys7t9hi-ITVU5PGDen1rNRyCjdcQ2g) 来进行讨论
该库是为 Android、iOS、Web、Desktop 多平台构建的『所见即所得』的富文本编辑器。查看我们的 [Youtube 播放列表](https://youtube.com/playlist?list=PLbhaS_83B97vONkOAWGJrSXWX58et9zZ2) 或 [代码介绍](https://github.com/singerdmx/flutter-quill/blob/master/CodeIntroduction.md) 以了解代码的详细内容。你可以加入我们的 [Slack Group](https://join.slack.com/t/bulletjournal1024/shared_invite/zt-fys7t9hi-ITVU5PGDen1rNRyCjdcQ2g) 来进行讨论
示例 `App` : [BULLET JOURNAL](https://bulletjournal.us/home/index.html)
@ -404,6 +404,22 @@ QuillEditor(locale: Locale('fr'), ...)
其是流行且成熟的 [quill-delta-to-html](https://www.npmjs.com/package/quill-delta-to-html) `Typescript/Javascript` 包的 `Dart` 部分
## 测试
为了能在测试文件里测试编辑器,我们给 flutter `WidgetTester` 提供了一个扩展,其中包括在测试文件中简化与编辑器交互的方法。
在测试文件内导入测试工具:
```dart
import 'package:flutter_quill/flutter_quill_test.dart';
```
然后使用 `quillEnterText` 输入文字:
```dart
await tester.quillEnterText(find.byType(QuillEditor), 'test\n');
```
---
## 赞助

@ -1,12 +1,12 @@
buildscript {
ext.kotlin_version = '1.6.10'
ext.kotlin_version = '1.9.0'
repositories {
google()
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:4.1.0'
classpath 'com.android.tools.build:gradle:7.4.0'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
}
}
@ -26,6 +26,6 @@ subprojects {
project.evaluationDependsOn(':app')
}
task clean(type: Delete) {
tasks.register("clean", Delete) {
delete rootProject.buildDir
}

@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-6.7-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-all.zip

@ -1,6 +1,7 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:ui';
import 'package:file_picker/file_picker.dart';
import 'package:filesystem_picker/filesystem_picker.dart';
@ -14,6 +15,7 @@ import 'package:path/path.dart';
import 'package:path_provider/path_provider.dart';
import '../universal_ui/universal_ui.dart';
import '../widgets/time_stamp_embed_widget.dart';
import 'read_only_page.dart';
enum _SelectionType {
@ -80,9 +82,24 @@ class _HomePageState extends State<HomePage> {
),
actions: [
IconButton(
onPressed: () => _addEditNote(context),
icon: const Icon(Icons.note_add),
onPressed: () => _insertTimeStamp(
_controller!,
DateTime.now().toString(),
),
icon: const Icon(Icons.add_alarm_rounded),
),
IconButton(
onPressed: () => showDialog(
context: context,
builder: (context) => AlertDialog(
content: Text(_controller!.document.toPlainText([
...FlutterQuillEmbeds.builders(),
TimeStampEmbedBuilderWidget()
])),
),
),
icon: const Icon(Icons.text_fields_rounded),
)
],
),
drawer: Container(
@ -158,9 +175,7 @@ class _HomePageState extends State<HomePage> {
}
Widget _buildWelcomeEditor(BuildContext context) {
Widget quillEditor = MouseRegion(
cursor: SystemMouseCursors.text,
child: QuillEditor(
Widget quillEditor = QuillEditor(
controller: _controller!,
scrollController: ScrollController(),
scrollable: true,
@ -187,17 +202,22 @@ class _HomePageState extends State<HomePage> {
const VerticalSpacing(0, 0),
null),
sizeSmall: const TextStyle(fontSize: 9),
subscript: const TextStyle(
fontFamily: 'SF-UI-Display',
fontFeatures: [FontFeature.subscripts()],
),
superscript: const TextStyle(
fontFamily: 'SF-UI-Display',
fontFeatures: [FontFeature.superscripts()],
),
),
embedBuilders: [
...FlutterQuillEmbeds.builders(),
NotesEmbedBuilder(addEditNote: _addEditNote)
TimeStampEmbedBuilderWidget()
],
),
);
if (kIsWeb) {
quillEditor = MouseRegion(
cursor: SystemMouseCursors.text,
child: QuillEditor(
quillEditor = QuillEditor(
controller: _controller!,
scrollController: ScrollController(),
scrollable: true,
@ -225,9 +245,8 @@ class _HomePageState extends State<HomePage> {
),
embedBuilders: [
...defaultEmbedBuildersWeb,
NotesEmbedBuilder(addEditNote: _addEditNote),
]),
);
TimeStampEmbedBuilderWidget()
]);
}
var toolbar = QuillToolbar.basic(
controller: _controller!,
@ -439,99 +458,41 @@ class _HomePageState extends State<HomePage> {
return file.path.toString();
}
Future<void> _addEditNote(BuildContext context, {Document? document}) async {
final isEditing = document != null;
final quillEditorController = QuillController(
document: document ?? Document(),
selection: const TextSelection.collapsed(offset: 0),
);
await showDialog(
context: context,
builder: (context) => AlertDialog(
titlePadding: const EdgeInsets.only(left: 16, top: 8),
title: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('${isEditing ? 'Edit' : 'Add'} note'),
IconButton(
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Icons.close),
)
],
),
content: QuillEditor.basic(
controller: quillEditorController,
readOnly: false,
),
static void _insertTimeStamp(QuillController controller, String string) {
controller.document.insert(controller.selection.extentOffset, '\n');
controller.updateSelection(
TextSelection.collapsed(
offset: controller.selection.extentOffset + 1,
),
ChangeSource.LOCAL,
);
if (quillEditorController.document.isEmpty()) return;
final block = BlockEmbed.custom(
NotesBlockEmbed.fromDocument(quillEditorController.document),
controller.document.insert(
controller.selection.extentOffset,
TimeStampEmbed(string),
);
final controller = _controller!;
final index = controller.selection.baseOffset;
final length = controller.selection.extentOffset - index;
if (isEditing) {
final offset =
getEmbedNode(controller, controller.selection.start).offset;
controller.replaceText(
offset, 1, block, TextSelection.collapsed(offset: offset));
} else {
controller.replaceText(index, length, block, null);
}
}
}
class NotesEmbedBuilder extends EmbedBuilder {
NotesEmbedBuilder({required this.addEditNote});
Future<void> Function(BuildContext context, {Document? document}) addEditNote;
@override
String get key => 'notes';
controller.updateSelection(
TextSelection.collapsed(
offset: controller.selection.extentOffset + 1,
),
ChangeSource.LOCAL,
);
@override
Widget build(
BuildContext context,
QuillController controller,
Embed node,
bool readOnly,
bool inline,
TextStyle textStyle,
) {
final notes = NotesBlockEmbed(node.value.data).document;
return Material(
color: Colors.transparent,
child: ListTile(
title: Text(
notes.toPlainText().replaceAll('\n', ' '),
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
leading: const Icon(Icons.notes),
onTap: () => addEditNote(context, document: notes),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
side: const BorderSide(color: Colors.grey),
controller.document.insert(controller.selection.extentOffset, ' ');
controller.updateSelection(
TextSelection.collapsed(
offset: controller.selection.extentOffset + 1,
),
ChangeSource.LOCAL,
);
controller.document.insert(controller.selection.extentOffset, '\n');
controller.updateSelection(
TextSelection.collapsed(
offset: controller.selection.extentOffset + 1,
),
ChangeSource.LOCAL,
);
}
}
class NotesBlockEmbed extends CustomBlockEmbed {
const NotesBlockEmbed(String value) : super(noteType, value);
static const String noteType = 'notes';
static NotesBlockEmbed fromDocument(Document document) =>
NotesBlockEmbed(jsonEncode(document.toDelta().toJson()));
Document get document => Document.fromJson(jsonDecode(data));
}

@ -0,0 +1,44 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_quill/flutter_quill.dart' hide Text;
class TimeStampEmbed extends Embeddable {
const TimeStampEmbed(
String value,
) : super(timeStampType, value);
static const String timeStampType = 'timeStamp';
static TimeStampEmbed fromDocument(Document document) =>
TimeStampEmbed(jsonEncode(document.toDelta().toJson()));
Document get document => Document.fromJson(jsonDecode(data));
}
class TimeStampEmbedBuilderWidget extends EmbedBuilder {
@override
String get key => 'timeStamp';
@override
String toPlainText(Embed embed) {
return embed.value.data;
}
@override
Widget build(
BuildContext context,
QuillController controller,
Embed node,
bool readOnly,
bool inline,
TextStyle textStyle,
) {
return Row(
children: [
const Icon(Icons.access_time_rounded),
Text(node.value.data as String),
],
);
}
}

@ -6,10 +6,14 @@
#include "generated_plugin_registrant.h"
#include <file_selector_linux/file_selector_plugin.h>
#include <pasteboard/pasteboard_plugin.h>
#include <url_launcher_linux/url_launcher_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) file_selector_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin");
file_selector_plugin_register_with_registrar(file_selector_linux_registrar);
g_autoptr(FlPluginRegistrar) pasteboard_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "PasteboardPlugin");
pasteboard_plugin_register_with_registrar(pasteboard_registrar);

@ -3,6 +3,7 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
file_selector_linux
pasteboard
url_launcher_linux
)

@ -6,12 +6,14 @@ import FlutterMacOS
import Foundation
import device_info_plus
import file_selector_macos
import pasteboard
import path_provider_foundation
import url_launcher_macos
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin"))
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
PasteboardPlugin.register(with: registry.registrar(forPlugin: "PasteboardPlugin"))
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))

@ -87,6 +87,9 @@ flutter:
- family: roboto-mono
fonts:
- asset: assets/fonts/RobotoMono-Regular.ttf
- family: SF-UI-Display
fonts:
- asset: assets/fonts/SF-Pro-Display-Regular.otf
# An image asset can refer to one or more resolution-specific "variants", see
# https://flutter.dev/assets-and-images/#resolution-aware.

@ -6,10 +6,13 @@
#include "generated_plugin_registrant.h"
#include <file_selector_windows/file_selector_windows.h>
#include <pasteboard/pasteboard_plugin.h>
#include <url_launcher_windows/url_launcher_windows.h>
void RegisterPlugins(flutter::PluginRegistry* registry) {
FileSelectorWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FileSelectorWindows"));
PasteboardPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("PasteboardPlugin"));
UrlLauncherWindowsRegisterWithRegistrar(

@ -3,6 +3,7 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
file_selector_windows
pasteboard
url_launcher_windows
)

@ -1,3 +1,10 @@
## 0.4.0
- Fix backspace around images [PR #1309](https://github.com/singerdmx/flutter-quill/pull/1309)
- Feat/link regexp [PR #1329](https://github.com/singerdmx/flutter-quill/pull/1329)
## 0.3.4
* Resolve deprecated method use in the `video_player` package
## 0.3.3
* Fix a prototype bug which was bring by [PR #1230](https://github.com/singerdmx/flutter-quill/pull/1230#issuecomment-1560597099)

@ -21,6 +21,9 @@ class ImageEmbedBuilder extends EmbedBuilder {
@override
String get key => BlockEmbed.imageType;
@override
bool get expanded => false;
@override
Widget build(
BuildContext context,

@ -18,6 +18,7 @@ class ImageButton extends StatelessWidget {
this.iconTheme,
this.dialogTheme,
this.tooltip,
this.linkRegExp,
Key? key,
}) : super(key: key);
@ -40,6 +41,7 @@ class ImageButton extends StatelessWidget {
final QuillDialogTheme? dialogTheme;
final String? tooltip;
final RegExp? linkRegExp;
@override
Widget build(BuildContext context) {
@ -90,7 +92,10 @@ class ImageButton extends StatelessWidget {
void _typeLink(BuildContext context) {
showDialog<String>(
context: context,
builder: (_) => LinkDialog(dialogTheme: dialogTheme),
builder: (_) => LinkDialog(
dialogTheme: dialogTheme,
linkRegExp: linkRegExp,
),
).then(_linkSubmitted);
}

@ -10,10 +10,16 @@ import 'package:image_picker/image_picker.dart';
import '../embed_types.dart';
class LinkDialog extends StatefulWidget {
const LinkDialog({this.dialogTheme, this.link, Key? key}) : super(key: key);
const LinkDialog({
this.dialogTheme,
this.link,
this.linkRegExp,
Key? key,
}) : super(key: key);
final QuillDialogTheme? dialogTheme;
final String? link;
final RegExp? linkRegExp;
@override
LinkDialogState createState() => LinkDialogState();
@ -22,12 +28,14 @@ class LinkDialog extends StatefulWidget {
class LinkDialogState extends State<LinkDialog> {
late String _link;
late TextEditingController _controller;
late RegExp _linkRegExp;
@override
void initState() {
super.initState();
_link = widget.link ?? '';
_controller = TextEditingController(text: _link);
_linkRegExp = widget.linkRegExp ?? AutoFormatMultipleLinksRule.linkRegExp;
}
@override
@ -48,8 +56,7 @@ class LinkDialogState extends State<LinkDialog> {
),
actions: [
TextButton(
onPressed: _link.isNotEmpty &&
AutoFormatMultipleLinksRule.linkRegExp.hasMatch(_link)
onPressed: _link.isNotEmpty && _linkRegExp.hasMatch(_link)
? _applyLink
: null,
child: Text(

@ -18,6 +18,7 @@ class VideoButton extends StatelessWidget {
this.iconTheme,
this.dialogTheme,
this.tooltip,
this.linkRegExp,
Key? key,
}) : super(key: key);
@ -39,8 +40,11 @@ class VideoButton extends StatelessWidget {
final QuillIconTheme? iconTheme;
final QuillDialogTheme? dialogTheme;
final String? tooltip;
final RegExp? linkRegExp;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);

@ -34,7 +34,7 @@ class _VideoAppState extends State<VideoApp> {
super.initState();
_controller = widget.videoUrl.startsWith('http')
? VideoPlayerController.network(widget.videoUrl)
? VideoPlayerController.networkUrl(Uri.parse(widget.videoUrl))
: VideoPlayerController.file(File(widget.videoUrl))
..initialize().then((_) {
// Ensure the first frame is shown after the video is initialized,

@ -49,6 +49,8 @@ class FlutterQuillEmbeds {
FilePickImpl? filePickImpl,
WebImagePickImpl? webImagePickImpl,
WebVideoPickImpl? webVideoPickImpl,
RegExp? imageLinkRegExp,
RegExp? videoLinkRegExp,
}) =>
[
if (showImageButton)
@ -63,6 +65,7 @@ class FlutterQuillEmbeds {
mediaPickSettingSelector: mediaPickSettingSelector,
iconTheme: iconTheme,
dialogTheme: dialogTheme,
linkRegExp: imageLinkRegExp,
),
if (showVideoButton)
(controller, toolbarIconSize, iconTheme, dialogTheme) => VideoButton(
@ -76,6 +79,7 @@ class FlutterQuillEmbeds {
mediaPickSettingSelector: mediaPickSettingSelector,
iconTheme: iconTheme,
dialogTheme: dialogTheme,
linkRegExp: videoLinkRegExp,
),
if ((onImagePickCallback != null || onVideoPickCallback != null) &&
showCameraButton)

@ -1,6 +1,6 @@
name: flutter_quill_extensions
description: Embed extensions for flutter_quill including image, video, formula and etc.
version: 0.3.3
version: 0.4.0
homepage: https://bulletjournal.us/home/index.html
repository: https://github.com/singerdmx/flutter-quill/tree/master/flutter_quill_extensions
@ -12,14 +12,14 @@ dependencies:
flutter:
sdk: flutter
flutter_quill: ^7.2.1
flutter_quill: ^7.2.19
image_picker: ^0.8.5+3
photo_view: ^0.14.0
video_player: ^2.4.2
video_player: ^2.7.0
youtube_player_flutter: ^8.1.1
gallery_saver: ^2.3.2
math_keyboard: ^0.2.0
math_keyboard: ">=0.1.8 <0.3.0"
string_validator: ^1.0.0
universal_html: ^2.2.1
url_launcher: ^6.1.9

@ -11,6 +11,7 @@ export 'src/models/documents/style.dart';
export 'src/models/quill_delta.dart';
export 'src/models/structs/doc_change.dart';
export 'src/models/structs/image_url.dart';
export 'src/models/structs/link_dialog_action.dart';
export 'src/models/structs/offset_value.dart';
export 'src/models/structs/optional_size.dart';
export 'src/models/structs/vertical_spacing.dart';

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

@ -0,0 +1,3 @@
library flutter_quill.delta;
export 'src/models/quill_delta.dart';

@ -1,5 +1,6 @@
import 'dart:async';
import '../../widgets/embeds.dart';
import '../quill_delta.dart';
import '../rules/rule.dart';
import '../structs/doc_change.dart';
@ -158,10 +159,11 @@ class Document {
return (res.node as Line).collectStyle(res.offset, len);
}
/// Returns all styles for each node within selection
List<OffsetValue<Style>> collectAllIndividualStyles(int index, int len) {
/// Returns all styles and Embed for each node within selection
List<OffsetValue> collectAllIndividualStyleAndEmbed(int index, int len) {
final res = queryChild(index);
return (res.node as Line).collectAllIndividualStyles(res.offset, len);
return (res.node as Line)
.collectAllIndividualStylesAndEmbed(res.offset, len);
}
/// Returns all styles for any character within the specified text range.
@ -193,16 +195,21 @@ class Document {
return block.queryChild(res.offset, true);
}
/// Search the whole document for any substring matching the pattern
/// Returns the offsets that matches the pattern
List<int> search(Pattern other) {
/// Search given [substring] in the whole document
/// Supports [caseSensitive] and [wholeWord] options
/// Returns correspondent offsets
List<int> search(
String substring, {
bool caseSensitive = false,
bool wholeWord = false,
}) {
final matches = <int>[];
for (final node in _root.children) {
if (node is Line) {
_searchLine(other, node, matches);
_searchLine(substring, caseSensitive, wholeWord, node, matches);
} else if (node is Block) {
for (final line in Iterable.castFrom<dynamic, Line>(node.children)) {
_searchLine(other, line, matches);
_searchLine(substring, caseSensitive, wholeWord, line, matches);
}
} else {
throw StateError('Unreachable.');
@ -211,10 +218,22 @@ class Document {
return matches;
}
void _searchLine(Pattern other, Line line, List<int> matches) {
void _searchLine(
String substring,
bool caseSensitive,
bool wholeWord,
Line line,
List<int> matches,
) {
var index = -1;
final lineText = line.toPlainText();
var pattern = RegExp.escape(substring);
if (wholeWord) {
pattern = r'\b' + pattern + r'\b';
}
final searchExpression = RegExp(pattern, caseSensitive: caseSensitive);
while (true) {
index = line.toPlainText().indexOf(other, index + 1);
index = lineText.indexOf(searchExpression, index + 1);
if (index < 0) {
break;
}
@ -349,7 +368,13 @@ class Document {
}
/// Returns plain text representation of this document.
String toPlainText() => _root.children.map((e) => e.toPlainText()).join();
String toPlainText([
Iterable<EmbedBuilder>? embedBuilders,
EmbedBuilder? unknownEmbedBuilder,
]) =>
_root.children
.map((e) => e.toPlainText(embedBuilders, unknownEmbedBuilder))
.join();
void _loadDocument(Delta doc) {
if (doc.isEmpty) {

@ -1,5 +1,6 @@
import 'dart:collection';
import '../../../widgets/embeds.dart';
import '../style.dart';
import 'leaf.dart';
import 'line.dart';
@ -103,7 +104,13 @@ abstract class Container<T extends Node?> extends Node {
}
@override
String toPlainText() => children.map((child) => child.toPlainText()).join();
String toPlainText([
Iterable<EmbedBuilder>? embedBuilders,
EmbedBuilder? unknownEmbedBuilder,
]) =>
children
.map((e) => e.toPlainText(embedBuilders, unknownEmbedBuilder))
.join();
/// Content length of this node's children.
///

@ -1,5 +1,6 @@
import 'dart:math' as math;
import '../../../widgets/embeds.dart';
import '../../quill_delta.dart';
import '../style.dart';
import 'embeddable.dart';
@ -224,7 +225,11 @@ class Text extends Leaf {
String get value => _value as String;
@override
String toPlainText() => value;
String toPlainText([
Iterable<EmbedBuilder>? embedBuilders,
EmbedBuilder? unknownEmbedBuilder,
]) =>
value;
}
/// An embed node inside of a line in a Quill document.
@ -257,7 +262,26 @@ class Embed extends Leaf {
// Embed nodes are represented as unicode object replacement character in
// plain text.
@override
String toPlainText() => kObjectReplacementCharacter;
String toPlainText([
Iterable<EmbedBuilder>? embedBuilders,
EmbedBuilder? unknownEmbedBuilder,
]) {
final builders = embedBuilders;
if (builders != null) {
for (final builder in builders) {
if (builder.key == value.type) {
return builder.toPlainText(this);
}
}
}
if (unknownEmbedBuilder != null) {
return unknownEmbedBuilder.toPlainText(this);
}
return Embed.kObjectReplacementCharacter;
}
@override
String toString() => '${super.toString()} ${value.type}';

@ -2,6 +2,7 @@ import 'dart:math' as math;
import 'package:collection/collection.dart';
import '../../../widgets/embeds.dart';
import '../../quill_delta.dart';
import '../../structs/offset_value.dart';
import '../attribute.dart';
@ -65,7 +66,11 @@ class Line extends Container<Leaf?> {
}
@override
String toPlainText() => '${super.toPlainText()}\n';
String toPlainText([
Iterable<EmbedBuilder>? embedBuilders,
EmbedBuilder? unknownEmbedBuilder,
]) =>
'${super.toPlainText(embedBuilders, unknownEmbedBuilder)}\n';
@override
String toString() {
@ -390,35 +395,42 @@ class Line extends Container<Leaf?> {
}
/// Returns each node segment's offset in selection
/// with its corresponding style as a list
List<OffsetValue<Style>> collectAllIndividualStyles(int offset, int len,
/// with its corresponding style or embed as a list
List<OffsetValue> collectAllIndividualStylesAndEmbed(int offset, int len,
{int beg = 0}) {
final local = math.min(length - offset, len);
final result = <OffsetValue<Style>>[];
final result = <OffsetValue>[];
final data = queryChild(offset, true);
var node = data.node as Leaf?;
if (node != null) {
var pos = 0;
if (node is Text) {
pos = node.length - data.offset;
result.add(OffsetValue(beg, node.style));
if (node is Text && node.style.isNotEmpty) {
result.add(OffsetValue(beg, node.style, node.length));
} else if (node.value is Embeddable) {
result.add(OffsetValue(beg, node.value as Embeddable, node.length));
}
while (!node!.isLast && pos < local) {
node = node.next as Leaf;
if (node is Text) {
result.add(OffsetValue(pos + beg, node.style));
if (node is Text && node.style.isNotEmpty) {
result.add(OffsetValue(pos + beg, node.style, node.length));
} else if (node.value is Embeddable) {
result.add(
OffsetValue(pos + beg, node.value as Embeddable, node.length));
}
pos += node.length;
}
if (style.isNotEmpty) {
result.add(OffsetValue(beg, style, pos));
}
}
// TODO: add line style and parent's block style
final remaining = len - local;
if (remaining > 0 && nextLine != null) {
final rest =
nextLine!.collectAllIndividualStyles(0, remaining, beg: local);
final rest = nextLine!
.collectAllIndividualStylesAndEmbed(0, remaining, beg: local + beg);
result.addAll(rest);
}
@ -516,7 +528,7 @@ class Line extends Container<Leaf?> {
int _getPlainText(int offset, int len, StringBuffer plainText) {
var _len = len;
final data = queryChild(offset, true);
final data = queryChild(offset, false);
var node = data.node as Leaf?;
while (_len > 0) {

@ -1,5 +1,6 @@
import 'dart:collection';
import '../../../widgets/embeds.dart';
import '../../quill_delta.dart';
import '../attribute.dart';
import '../style.dart';
@ -109,7 +110,10 @@ abstract class Node extends LinkedListEntry<Node> {
Node newInstance();
String toPlainText();
String toPlainText([
Iterable<EmbedBuilder>? embedBuilders,
EmbedBuilder? unknownEmbedBuilder,
]);
Delta toDelta();

@ -42,6 +42,9 @@ class Style {
bool get isInline => isNotEmpty && values.every((item) => item.isInline);
bool get isBlock =>
isNotEmpty && values.every((item) => item.scope == AttributeScope.BLOCK);
bool get isIgnored =>
isNotEmpty && values.every((item) => item.scope == AttributeScope.IGNORE);

@ -1,4 +1,5 @@
import '../documents/attribute.dart';
import '../documents/nodes/embeddable.dart';
import '../quill_delta.dart';
import 'rule.dart';
@ -109,6 +110,8 @@ class PreserveLineStyleOnMergeRule extends DeleteRule {
}
/// Prevents user from merging a line containing an embed with other lines.
/// This rule applies to video, not image.
/// The rule relates to [InsertEmbedsRule].
class EnsureEmbedLineRule extends DeleteRule {
const EnsureEmbedLineRule();
@ -118,6 +121,13 @@ class EnsureEmbedLineRule extends DeleteRule {
final itr = DeltaIterator(document);
var op = itr.skip(index);
final opAfter = itr.skip(index + 1);
// Only video embed occupies a whole line.
if (!_isVideo(op) || !_isVideo(opAfter)) {
return null;
}
int? indexDelta = 0, lengthDelta = 0, remain = len;
var embedFound = op != null && op.data is! String;
final hasLineBreakBefore =
@ -157,4 +167,10 @@ class EnsureEmbedLineRule extends DeleteRule {
..retain(index + indexDelta)
..delete(len! + lengthDelta);
}
bool _isVideo(op) {
return op != null &&
op.data is! String &&
!(op.data as Map).containsKey(BlockEmbed.videoType);
}
}

@ -329,8 +329,7 @@ class AutoFormatMultipleLinksRule extends InsertRule {
// http://www.example.com/?action=birds&brass=apparatus
// https://example.net/
// URL generator tool (https://www.randomlists.com/urls) is used.
static const _linkPattern =
r'(https?:\/\/|www\.)[\w-\.]+\.[\w-\.]+(\/([\S]+)?)?';
static const _linkPattern = r'^https?:\/\/[\w\-]+(\.[\w\-]+)*(:\d+)?(\/.*)?$';
static final linkRegExp = RegExp(_linkPattern, caseSensitive: false);
@override

@ -0,0 +1,7 @@
import 'package:flutter/material.dart';
class LinkDialogAction {
LinkDialogAction({required this.builder});
Widget Function(bool canPress, void Function() applyLink) builder;
}

@ -6,6 +6,7 @@ class QuillCustomButton {
this.iconColor,
this.onTap,
this.tooltip,
this.child,
});
///The icon widget
@ -17,6 +18,9 @@ class QuillCustomButton {
///The function when the icon is tapped
final VoidCallback? onTap;
///The customButton placeholder
final Widget? child;
/// The button tooltip.
final String? tooltip;
}

@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
/// Used to configure the dialog's look and feel.
class QuillDialogTheme with Diagnosticable {
const QuillDialogTheme({
this.buttonTextStyle,
this.labelTextStyle,
this.inputTextStyle,
this.dialogBackgroundColor,
@ -17,6 +18,9 @@ class QuillDialogTheme with Diagnosticable {
this.runSpacing = 8.0,
}) : assert(runSpacing >= 0);
///The text style to use for the button shown in the dialog
final TextStyle? buttonTextStyle;
///The text style to use for the label shown in the link-input dialog
final TextStyle? labelTextStyle;
@ -59,6 +63,7 @@ class QuillDialogTheme with Diagnosticable {
final double runSpacing;
QuillDialogTheme copyWith({
TextStyle? buttonTextStyle,
TextStyle? labelTextStyle,
TextStyle? inputTextStyle,
Color? dialogBackgroundColor,
@ -72,6 +77,7 @@ class QuillDialogTheme with Diagnosticable {
double? runSpacing,
}) {
return QuillDialogTheme(
buttonTextStyle: buttonTextStyle ?? this.buttonTextStyle,
labelTextStyle: labelTextStyle ?? this.labelTextStyle,
inputTextStyle: inputTextStyle ?? this.inputTextStyle,
dialogBackgroundColor:
@ -96,6 +102,7 @@ class QuillDialogTheme with Diagnosticable {
return false;
}
return other is QuillDialogTheme &&
other.buttonTextStyle == buttonTextStyle &&
other.labelTextStyle == labelTextStyle &&
other.inputTextStyle == inputTextStyle &&
other.dialogBackgroundColor == dialogBackgroundColor &&
@ -112,6 +119,7 @@ class QuillDialogTheme with Diagnosticable {
@override
int get hashCode => Object.hash(
buttonTextStyle,
labelTextStyle,
inputTextStyle,
dialogBackgroundColor,

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

@ -71,6 +71,9 @@ extension Localization on String {
'Hex': 'Hex',
'Material': 'Material',
'Color': 'Color',
'Find text': 'Find text',
'Move to previous occurrence': 'Move to previous occurrence',
'Move to next occurrence': 'Move to next occurrence',
},
'en_us': {
'Paste a link': 'Paste a link',
@ -140,6 +143,9 @@ extension Localization on String {
'Hex': 'Hex',
'Material': 'Material',
'Color': 'Color',
'Find text': 'Find text',
'Move to previous occurrence': 'Move to previous occurrence',
'Move to next occurrence': 'Move to next occurrence',
},
'ar': {
'Paste a link': 'نسخ الرابط',
@ -211,6 +217,9 @@ extension Localization on String {
'Hex': 'Hex',
'Material': 'Material',
'Color': 'اللون',
'Find text': 'Find text',
'Move to previous occurrence': 'Move to previous occurrence',
'Move to next occurrence': 'Move to next occurrence',
},
'da': {
'Paste a link': 'Indsæt link',
@ -277,6 +286,9 @@ extension Localization on String {
'Enter media': 'Enter media',
'Edit': 'Edit',
'Apply': 'Apply',
'Find text': 'Find text',
'Move to previous occurrence': 'Move to previous occurrence',
'Move to next occurrence': 'Move to next occurrence',
},
'de': {
'Paste a link': 'Link hinzufügen',
@ -344,6 +356,9 @@ extension Localization on String {
'Enter media': 'Enter media',
'Edit': 'Edit',
'Apply': 'Apply',
'Find text': 'Find text',
'Move to previous occurrence': 'Move to previous occurrence',
'Move to next occurrence': 'Move to next occurrence',
},
'fr': {
'Paste a link': 'Coller un lien',
@ -353,13 +368,13 @@ extension Localization on String {
'Link': 'Lien',
'Please first select some text to transform into a link.':
"Veuillez d'abord sélectionner un texte à transformer en lien.",
'Open': 'Ouverte',
'Open': 'Ouvrir',
'Copy': 'Copier',
'Remove': 'Supprimer',
'Save': 'Sauvegarder',
'Zoom': 'Zoom',
'Zoom': 'Zoomer',
'Saved': 'Enregistrée',
'Text': 'Text',
'Text': 'Texte',
'What is entered is not a link': "Ce qui est saisi n'est pas un lien",
'Resize': 'Redimensionner',
'Width': 'Largeur',
@ -377,39 +392,42 @@ extension Localization on String {
'Next': 'Suivant',
'Camera': 'Caméra',
'Video': 'Vidéo',
'Undo': 'Undo',
'Redo': 'Redo',
'Font family': 'Font family',
'Font size': 'Font size',
'Bold': 'Bold',
'Subscript': 'Subscript',
'Superscript': 'Superscript',
'Italic': 'Italic',
'Underline': 'Underline',
'Strike through': 'Strike through',
'Inline code': 'Inline code',
'Font color': 'Font color',
'Background color': 'Background color',
'Clear format': 'Clear format',
'Align left': 'Align left',
'Align center': 'Align center',
'Align right': 'Align right',
'Justify win width': 'Justify win width',
'Text direction': 'Text direction',
'Header style': 'Header style',
'Numbered list': 'Numbered list',
'Bullet list': 'Bullet list',
'Checked list': 'Checked list',
'Code block': 'Code block',
'Quote': 'Quote',
'Increase indent': 'Increase indent',
'Decrease indent': 'Decrease indent',
'Insert URL': 'Insert URL',
'Visit link': 'Visit link',
'Enter link': 'Enter link',
'Enter media': 'Enter media',
'Edit': 'Edit',
'Apply': 'Apply',
'Undo': 'Annuler',
'Redo': 'Refaire',
'Font family': 'Famille de police',
'Font size': 'Taille de police',
'Bold': 'Gras',
'Subscript': 'Indice',
'Superscript': 'Exposant',
'Italic': 'Italique',
'Underline': 'Souligné',
'Strike through': 'Barré',
'Inline code': 'Code en ligne',
'Font color': 'Couleur de police',
'Background color': 'Couleur de fond',
'Clear format': 'Effacer la mise en forme',
'Align left': 'Aligner à gauche',
'Align center': 'Aligner au centre',
'Align right': 'Aligner à droite',
'Justify win width': 'Justifier',
'Text direction': 'Direction du texte',
'Header style': "Style d'en-tête",
'Numbered list': 'Liste numérotée',
'Bullet list': 'Liste à puces',
'Checked list': 'Check-list',
'Code block': 'Bloc de code',
'Quote': 'Citation',
'Increase indent': 'Augmenter le retrait',
'Decrease indent': 'Diminuer le retrait',
'Insert URL': 'Insérer une URL',
'Visit link': 'Visiter',
'Enter link': 'Entrer un lien',
'Enter media': 'Entrer un média',
'Edit': 'Modifier',
'Apply': 'Appliquer',
'Find text': 'Find text',
'Move to previous occurrence': 'Move to previous occurrence',
'Move to next occurrence': 'Move to next occurrence',
},
'zh_cn': {
'Paste a link': '粘贴链接',
@ -476,6 +494,9 @@ extension Localization on String {
'Enter media': '输入媒体',
'Edit': '编辑',
'Apply': '应用',
'Find text': 'Find text',
'Move to previous occurrence': 'Move to previous occurrence',
'Move to next occurrence': 'Move to next occurrence',
},
'zh_hk': {
'Paste a link': '貼上連結',
@ -542,8 +563,11 @@ extension Localization on String {
'Enter media': '輸入媒體',
'Edit': '編輯',
'Apply': '應用',
'Find text': 'Find text',
'Move to previous occurrence': 'Move to previous occurrence',
'Move to next occurrence': 'Move to next occurrence',
},
'jp': {
'ja': {
'Paste a link': 'リンクをペースト',
'Ok': '完了',
'Select Color': '色を選択',
@ -608,6 +632,9 @@ extension Localization on String {
'Enter media': 'ミディアムを輸入',
'Edit': '編集',
'Apply': '応用',
'Find text': 'Find text',
'Move to previous occurrence': 'Move to previous occurrence',
'Move to next occurrence': 'Move to next occurrence',
},
'ko': {
'Paste a link': '링크를 붙여넣어 주세요.',
@ -674,6 +701,9 @@ extension Localization on String {
'Enter media': 'Enter media',
'Edit': 'Edit',
'Apply': 'Apply',
'Find text': 'Find text',
'Move to previous occurrence': 'Move to previous occurrence',
'Move to next occurrence': 'Move to next occurrence',
},
'ru': {
'Paste a link': 'Вставить ссылку',
@ -740,6 +770,9 @@ extension Localization on String {
'Enter media': 'Enter media',
'Edit': 'Edit',
'Apply': 'Apply',
'Find text': 'Find text',
'Move to previous occurrence': 'Move to previous occurrence',
'Move to next occurrence': 'Move to next occurrence',
},
'es': {
'Paste a link': 'Pega un enlace',
@ -807,6 +840,9 @@ extension Localization on String {
'Enter media': 'Enter media',
'Edit': 'Edit',
'Apply': 'Apply',
'Find text': 'Find text',
'Move to previous occurrence': 'Move to previous occurrence',
'Move to next occurrence': 'Move to next occurrence',
},
'tr': {
'Paste a link': 'Bağlantıyı Yapıştır',
@ -817,14 +853,14 @@ extension Localization on String {
'Please first select some text to transform into a link.':
'Lütfen bağlantıya dönüştürmek için bir metin seçin.',
'Open': 'Açık',
'Copy': 'kopyala',
'Remove': 'Kaldırmak',
'Save': 'Kayıt etmek',
'Zoom': 'yakınlaştır',
'Saved': 'kaydedildi',
'Copy': 'Kopyala',
'Remove': 'Kaldır',
'Save': 'Kayıt Et',
'Zoom': 'Yakınlaştır',
'Saved': 'Kaydedildi',
'Text': 'Text',
'What is entered is not a link': 'What is entered is not a link',
'Resize': 'Resize',
'What is entered is not a link': 'Girilen bir bağlantı değil.',
'Resize': 'Yeniden Boyutlandır',
'Width': 'Genişlik',
'Height': 'Yükseklik',
'Size': 'Boyut',
@ -834,45 +870,48 @@ extension Localization on String {
'Clear': 'Temizle',
'Font': 'Yazı tipi',
'Search': 'Ara',
'matches': 'matches',
'showing match': 'showing match',
'Prev': 'Prev',
'Next': 'Devam',
'matches': 'Eşleşmeler',
'showing match': 'Eşleşmeyi Göster',
'Prev': 'Önceki',
'Next': 'Sonraki',
'Camera': 'Kamera',
'Video': 'Video',
'Undo': 'Undo',
'Redo': 'Redo',
'Font family': 'Font family',
'Font size': 'Font size',
'Bold': 'Bold',
'Subscript': 'Subscript',
'Superscript': 'Superscript',
'Italic': 'Italic',
'Underline': 'Underline',
'Strike through': 'Strike through',
'Undo': 'Geri',
'Redo': 'İleri',
'Font family': 'Yazı Türü',
'Font size': 'Yazı Boyutu',
'Bold': 'Kalın',
'Subscript': 'Alt Simge',
'Superscript': 'Üst Simge',
'Italic': 'İtalik',
'Underline': 'Altı Çizili',
'Strike through': 'Üsti Çizili',
'Inline code': 'Inline code',
'Font color': 'Font color',
'Background color': 'Background color',
'Clear format': 'Clear format',
'Align left': 'Align left',
'Align center': 'Align center',
'Align right': 'Align right',
'Justify win width': 'Justify win width',
'Text direction': 'Text direction',
'Header style': 'Header style',
'Numbered list': 'Numbered list',
'Bullet list': 'Bullet list',
'Checked list': 'Checked list',
'Code block': 'Code block',
'Quote': 'Quote',
'Increase indent': 'Increase indent',
'Decrease indent': 'Decrease indent',
'Insert URL': 'Insert URL',
'Visit link': 'Visit link',
'Enter link': 'Enter link',
'Enter media': 'Enter media',
'Edit': 'Edit',
'Apply': 'Apply',
'Font color': 'Yazı Rengi',
'Background color': 'Vurgu Rengi',
'Clear format': 'Formatı Temizle',
'Align left': 'Sola Hizala',
'Align center': 'Ortaya Hizala',
'Align right': 'Sağa Hizala',
'Justify win width': 'Kenarlara Hizala',
'Text direction': 'Metin Yönü',
'Header style': 'Başlık Stili',
'Numbered list': 'Numaralı Liste',
'Bullet list': 'Madde Listesi',
'Checked list': 'Kontrol Listesi',
'Code block': 'Kod Blogu',
'Quote': 'Alıntı',
'Increase indent': 'Girintiyi Artır',
'Decrease indent': 'Girintiyi Azalt ',
'Insert URL': 'URL Giriniz',
'Visit link': 'Bağlantıyı Ziyaret Et',
'Enter link': 'Bağlantı Giriniz',
'Enter media': 'Medya Giriniz',
'Edit': 'Düzenle',
'Apply': 'Uygula',
'Find text': 'Find text',
'Move to previous occurrence': 'Move to previous occurrence',
'Move to next occurrence': 'Move to next occurrence',
},
'uk': {
'Paste a link': 'Вставити посилання',
@ -939,6 +978,9 @@ extension Localization on String {
'Enter media': 'Enter media',
'Edit': 'Edit',
'Apply': 'Apply',
'Find text': 'Find text',
'Move to previous occurrence': 'Move to previous occurrence',
'Move to next occurrence': 'Move to next occurrence',
},
'pt': {
'Paste a link': 'Colar um link',
@ -1006,6 +1048,9 @@ extension Localization on String {
'Enter media': 'Enter media',
'Edit': 'Edit',
'Apply': 'Apply',
'Find text': 'Find text',
'Move to previous occurrence': 'Move to previous occurrence',
'Move to next occurrence': 'Move to next occurrence',
},
'pt_br': {
'Paste a link': 'Colar um link',
@ -1073,6 +1118,9 @@ extension Localization on String {
'Enter media': 'Inserir mídia',
'Edit': 'Editar',
'Apply': 'Aplicar',
'Find text': 'Find text',
'Move to previous occurrence': 'Move to previous occurrence',
'Move to next occurrence': 'Move to next occurrence',
},
'pl': {
'Paste a link': 'Wklej link',
@ -1140,6 +1188,9 @@ extension Localization on String {
'Enter media': 'Enter media',
'Edit': 'Edit',
'Apply': 'Apply',
'Find text': 'Find text',
'Move to previous occurrence': 'Move to previous occurrence',
'Move to next occurrence': 'Move to next occurrence',
},
'vi': {
'Paste a link': 'Chèn liên kết',
@ -1207,6 +1258,9 @@ extension Localization on String {
'Enter media': 'Enter media',
'Edit': 'Edit',
'Apply': 'Apply',
'Find text': 'Find text',
'Move to previous occurrence': 'Move to previous occurrence',
'Move to next occurrence': 'Move to next occurrence',
},
'ur': {
'Paste a link': 'لنک پیسٹ کریں',
@ -1273,6 +1327,9 @@ extension Localization on String {
'Enter media': 'Enter media',
'Edit': 'Edit',
'Apply': 'Apply',
'Find text': 'Find text',
'Move to previous occurrence': 'Move to previous occurrence',
'Move to next occurrence': 'Move to next occurrence',
},
'id': {
'Paste a link': 'Tempel tautan',
@ -1339,6 +1396,9 @@ extension Localization on String {
'Enter media': 'Enter media',
'Edit': 'Edit',
'Apply': 'Apply',
'Find text': 'Find text',
'Move to previous occurrence': 'Move to previous occurrence',
'Move to next occurrence': 'Move to next occurrence',
},
'no': {
'Paste a link': 'Lim inn lenke',
@ -1405,6 +1465,9 @@ extension Localization on String {
'Enter media': 'Enter media',
'Edit': 'Edit',
'Apply': 'Apply',
'Find text': 'Find text',
'Move to previous occurrence': 'Move to previous occurrence',
'Move to next occurrence': 'Move to next occurrence',
},
'fa': {
'Paste a link': 'جایگذاری لینک',
@ -1471,6 +1534,9 @@ extension Localization on String {
'Enter media': 'Enter media',
'Edit': 'Edit',
'Apply': 'Apply',
'Find text': 'Find text',
'Move to previous occurrence': 'Move to previous occurrence',
'Move to next occurrence': 'Move to next occurrence',
},
'hi': {
'Paste a link': 'िक पट कर',
@ -1537,6 +1603,9 @@ extension Localization on String {
'Enter media': 'Enter media',
'Edit': 'Edit',
'Apply': 'Apply',
'Find text': 'Find text',
'Move to previous occurrence': 'Move to previous occurrence',
'Move to next occurrence': 'Move to next occurrence',
},
'nl': {
'Paste a link': 'Plak een link',
@ -1603,6 +1672,9 @@ extension Localization on String {
'Enter media': 'Enter media',
'Edit': 'Edit',
'Apply': 'Apply',
'Find text': 'Find text',
'Move to previous occurrence': 'Move to previous occurrence',
'Move to next occurrence': 'Move to next occurrence',
},
'sr': {
'Paste a link': 'Nalepi vezu',
@ -1669,6 +1741,9 @@ extension Localization on String {
'Enter media': 'Enter media',
'Edit': 'Edit',
'Apply': 'Apply',
'Find text': 'Find text',
'Move to previous occurrence': 'Move to previous occurrence',
'Move to next occurrence': 'Move to next occurrence',
},
'cs': {
'Paste a link': 'Vložte odkaz',
@ -1735,6 +1810,9 @@ extension Localization on String {
'Enter media': 'Enter media',
'Edit': 'Edit',
'Apply': 'Apply',
'Find text': 'Find text',
'Move to previous occurrence': 'Move to previous occurrence',
'Move to next occurrence': 'Move to next occurrence',
},
'he': {
'Paste a link': 'הדבק את הלינק',
@ -1801,6 +1879,9 @@ extension Localization on String {
'Enter media': 'Enter media',
'Edit': 'Edit',
'Apply': 'Apply',
'Find text': 'Find text',
'Move to previous occurrence': 'Move to previous occurrence',
'Move to next occurrence': 'Move to next occurrence',
},
'ms': {
'Paste a link': 'Tampal Pautan',
@ -1868,6 +1949,9 @@ extension Localization on String {
'Enter media': 'Enter media',
'Edit': 'Edit',
'Apply': 'Apply',
'Find text': 'Find text',
'Move to previous occurrence': 'Move to previous occurrence',
'Move to next occurrence': 'Move to next occurrence',
},
'it': {
'Paste a link': 'Incolla un collegamento',
@ -1935,6 +2019,82 @@ extension Localization on String {
'Enter media': 'Enter media',
'Edit': 'Edit',
'Apply': 'Apply',
'Find text': 'Find text',
'Move to previous occurrence': 'Move to previous occurrence',
'Move to next occurrence': 'Move to next occurrence',
},
'bn': {
'Paste a link': 'িক পট কর',
'Ok': 'ওক',
'Select Color': 'র সিট কর',
'Gallery': 'ি',
'Link': 'ি',
'Please first select some text to transform into a link.':
'অনরহ কররথম একটিিতরিত কর'
'জনয কিয নিচন করন।',
'Open': 'ওপ',
'Copy': 'কপি',
'Remove': 'ি',
'Save': '',
'Zoom': '',
'Saved': 'ভড',
'Text': 'সট',
'What is entered is not a link': 'ওয় হয একটিিক নয',
'Resize': 'িইজ',
'Width': 'রস',
'Height': '',
'Size': 'ইজ',
'Small': '',
'Large': 'বড়',
'Huge': 'ি',
'Clear': 'ি',
'Font': 'ফন',
'Search': '',
'matches': 'ি',
'showing match': 'িল দ হচ',
'Prev': 'ববর',
'Next': 'পরবর',
'Camera': '',
'Video': 'িি',
'Undo': 'আন',
'Redo': 'ি',
'Font family': 'ফনট ফিি',
'Font size': 'ফনট সইজ',
'Bold': '',
'Subscript': 'বসি',
'Superscript': 'রসি',
'Italic': 'ইটি',
'Underline': 'আনরলইন',
'Strike through': 'ইক থ',
'Inline code': 'ইনলইন ক',
'Font color': 'ফনট ক',
'Background color': 'কগউনড ক',
'Clear format': 'ির ফরম',
'Align left': 'ম সিবদ',
'Align center': 'র সিবদ',
'Align right': 'ন সিবদ',
'Justify win width': 'রসর সযত',
'Text direction': 'সট ডিকশন',
'Header style': 'র সইল',
'Numbered list': 'ত তি',
'Bullet list': 'ট তি',
'Checked list': 'ক করি',
'Code block': 'ড বলক',
'Quote': 'উকি',
'Increase indent': 'ইনট ব',
'Decrease indent': 'ইনট কম',
'Insert URL': 'UR দি',
'Visit link': 'িিট লি',
'Enter link': 'িক দি',
'Enter media': 'িিি',
'Edit': 'ইডি',
'Apply': 'এপ',
'Hex': '',
'Material': 'ি',
'Color': '',
'Find text': 'Find text',
'Move to previous occurrence': 'Move to previous occurrence',
'Move to next occurrence': 'Move to next occurrence',
},
};

@ -39,7 +39,9 @@ class QuillController extends ChangeNotifier {
/// Document managed by this controller.
Document _document;
Document get document => _document;
set document(doc) {
_document = doc;
@ -159,11 +161,11 @@ class QuillController extends ChangeNotifier {
notifyListeners();
}
/// Returns all styles for each node within selection
List<OffsetValue<Style>> getAllIndividualSelectionStyles() {
final styles = document.collectAllIndividualStyles(
/// Returns all styles and Embed for each node within selection
List<OffsetValue> getAllIndividualSelectionStylesAndEmbed() {
final stylesAndEmbed = document.collectAllIndividualStyleAndEmbed(
selection.start, selection.end - selection.start);
return styles;
return stylesAndEmbed;
}
/// Returns plain text for each node within selection
@ -255,14 +257,6 @@ class QuillController extends ChangeNotifier {
}
}
if (_keepStyleOnNewLine) {
final style = getSelectionStyle();
final notInlineStyle = style.attributes.values.where((s) => !s.isInline);
toggledStyle = style.removeAll(notInlineStyle.toSet());
} else {
toggledStyle = Style();
}
if (textSelection != null) {
if (delta == null || delta.isEmpty) {
_updateSelection(textSelection, ChangeSource.LOCAL);
@ -400,7 +394,13 @@ class QuillController extends ChangeNotifier {
_selection = selection.copyWith(
baseOffset: math.min(selection.baseOffset, end),
extentOffset: math.min(selection.extentOffset, end));
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);
}

@ -1,4 +1,5 @@
import 'dart:math' as math;
// ignore: unnecessary_import
import 'dart:typed_data';
@ -13,7 +14,6 @@ import 'package:i18n_extension/i18n_widget.dart';
import '../models/documents/document.dart';
import '../models/documents/nodes/container.dart' as container_node;
import '../models/documents/nodes/leaf.dart';
import '../models/documents/style.dart';
import '../models/structs/offset_value.dart';
import '../models/themes/quill_dialog_theme.dart';
import '../utils/platform.dart';
@ -38,7 +38,7 @@ abstract class EditorState extends State<RawEditor>
EditorTextSelectionOverlay? get selectionOverlay;
List<OffsetValue<Style>> get pasteStyle;
List<OffsetValue> get pasteStyleAndEmbed;
String get pastePlainText;
@ -46,6 +46,9 @@ abstract class EditorState extends State<RawEditor>
/// The floating cursor is animated to merge with the regular cursor.
AnimationController get floatingCursorResetController;
/// Returns true if the editor has been marked as needing to be rebuilt.
bool get dirty;
bool showToolbar();
void requestKeyboard();
@ -188,6 +191,7 @@ class QuillEditor extends StatefulWidget {
this.customLinkPrefixes = const <String>[],
this.dialogTheme,
this.contentInsertionConfiguration,
this.contextMenuBuilder,
Key? key,
}) : super(key: key);
@ -196,6 +200,11 @@ class QuillEditor extends StatefulWidget {
required bool readOnly,
Brightness? keyboardAppearance,
Iterable<EmbedBuilder>? embedBuilders,
EdgeInsetsGeometry padding = EdgeInsets.zero,
bool autoFocus = true,
bool expands = false,
FocusNode? focusNode,
String? placeholder,
/// The locale to use for the editor toolbar, defaults to system locale
/// More at https://github.com/singerdmx/flutter-quill#translation
@ -205,14 +214,15 @@ class QuillEditor extends StatefulWidget {
controller: controller,
scrollController: ScrollController(),
scrollable: true,
focusNode: FocusNode(),
autoFocus: true,
focusNode: focusNode ?? FocusNode(),
autoFocus: autoFocus,
readOnly: readOnly,
expands: false,
padding: EdgeInsets.zero,
expands: expands,
padding: padding,
keyboardAppearance: keyboardAppearance ?? Brightness.light,
locale: locale,
embedBuilders: embedBuilders,
placeholder: placeholder,
);
}
@ -366,6 +376,7 @@ class QuillEditor extends StatefulWidget {
// Returns whether gesture is handled
final bool Function(LongPressMoveUpdateDetails details,
TextPosition Function(Offset offset))? onSingleLongTapMoveUpdate;
// Returns whether gesture is handled
final bool Function(
LongPressEndDetails details, TextPosition Function(Offset offset))?
@ -428,6 +439,9 @@ class QuillEditor extends StatefulWidget {
/// Configures the dialog theme.
final QuillDialogTheme? dialogTheme;
// Allows for creating a custom context menu
final QuillEditorContextMenuBuilder? contextMenuBuilder;
/// Configuration of handler for media content inserted via the system input
/// method.
///
@ -499,8 +513,9 @@ class QuillEditorState extends State<QuillEditor>
readOnly: widget.readOnly,
placeholder: widget.placeholder,
onLaunchUrl: widget.onLaunchUrl,
contextMenuBuilder:
showSelectionToolbar ? RawEditor.defaultContextMenuBuilder : null,
contextMenuBuilder: showSelectionToolbar
? (widget.contextMenuBuilder ?? RawEditor.defaultContextMenuBuilder)
: null,
showSelectionHandles: isMobile(theme.platform),
showCursor: widget.showCursor,
cursorStyle: CursorStyle(
@ -991,6 +1006,7 @@ class RenderEditor extends RenderEditableContainerBox
}
double? _maxContentWidth;
set maxContentWidth(double? value) {
if (_maxContentWidth == value) return;
_maxContentWidth = value;
@ -1003,7 +1019,9 @@ class RenderEditor extends RenderEditableContainerBox
if (textSelection.isCollapsed) {
final child = childAtPosition(textSelection.extent);
final localPosition = TextPosition(
offset: textSelection.extentOffset - child.container.offset);
offset: textSelection.extentOffset - child.container.offset,
affinity: textSelection.affinity,
);
final localOffset = child.getOffsetForCaret(localPosition);
final parentData = child.parentData as BoxParentData;
return <TextSelectionPoint>[

@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import '../../extensions.dart';
import '../models/documents/nodes/leaf.dart' as leaf;
import '../models/themes/quill_dialog_theme.dart';
import '../models/themes/quill_icon_theme.dart';
@ -15,6 +16,8 @@ abstract class EmbedBuilder {
return WidgetSpan(child: widget);
}
String toPlainText(Embed node) => Embed.kObjectReplacementCharacter;
Widget build(
BuildContext context,
QuillController controller,

@ -20,7 +20,6 @@ import '../models/documents/nodes/embeddable.dart';
import '../models/documents/nodes/leaf.dart' as leaf;
import '../models/documents/nodes/line.dart';
import '../models/documents/nodes/node.dart';
import '../models/documents/style.dart';
import '../models/structs/offset_value.dart';
import '../models/structs/vertical_spacing.dart';
import '../models/themes/quill_dialog_theme.dart';
@ -318,8 +317,8 @@ class RawEditorState extends EditorState
// for pasting style
@override
List<OffsetValue<Style>> get pasteStyle => _pasteStyle;
List<OffsetValue<Style>> _pasteStyle = <OffsetValue<Style>>[];
List<OffsetValue> get pasteStyleAndEmbed => _pasteStyleAndEmbed;
List<OffsetValue> _pasteStyleAndEmbed = <OffsetValue>[];
@override
String get pastePlainText => _pastePlainText;
@ -332,6 +331,10 @@ class RawEditorState extends EditorState
TextDirection get _textDirection => Directionality.of(context);
@override
bool get dirty => _dirty;
bool _dirty = false;
@override
void insertContent(KeyboardInsertedContent content) {
assert(widget.contentInsertionConfiguration?.allowedMimeTypes
@ -457,6 +460,8 @@ class RawEditorState extends EditorState
Widget child = CompositedTransformTarget(
link: _toolbarLayerLink,
child: Semantics(
child: MouseRegion(
cursor: SystemMouseCursors.text,
child: _Editor(
key: _editorKey,
document: _doc,
@ -476,6 +481,7 @@ class RawEditorState extends EditorState
children: _buildChildren(_doc, context),
),
),
),
);
if (widget.scrollable) {
@ -495,6 +501,8 @@ class RawEditorState extends EditorState
physics: widget.scrollPhysics,
viewportBuilder: (_, offset) => CompositedTransformTarget(
link: _toolbarLayerLink,
child: MouseRegion(
cursor: SystemMouseCursors.text,
child: _Editor(
key: _editorKey,
offset: offset,
@ -516,6 +524,7 @@ class RawEditorState extends EditorState
),
),
),
),
);
}
@ -855,6 +864,7 @@ class RawEditorState extends EditorState
final currentSelection = controller.selection.copyWith();
final attribute = value ? Attribute.checked : Attribute.unchecked;
_markNeedsBuild();
controller
..ignoreFocusOnTextChange = true
..formatText(offset, 0, attribute)
@ -929,9 +939,11 @@ class RawEditorState extends EditorState
clearIndents = false;
} else {
_dirty = false;
throw StateError('Unreachable.');
}
}
_dirty = false;
return result;
}
@ -1170,6 +1182,17 @@ class RawEditorState extends EditorState
_selectionOverlay?.updateForScroll();
}
/// Marks the editor as dirty and trigger a rebuild.
///
/// When the editor is dirty methods that depend on the editor
/// state being in sync with the controller know they may be
/// operating on stale data.
void _markNeedsBuild() {
setState(() {
_dirty = true;
});
}
void _didChangeTextEditingValue([bool ignoreFocus = false]) {
if (kIsWeb) {
_onChangeTextEditingValue(ignoreFocus);
@ -1184,10 +1207,9 @@ class RawEditorState extends EditorState
} else {
requestKeyboard();
if (mounted) {
setState(() {
// Use controller.value in build()
// Trigger build and updateChildren
});
// Mark widget as dirty and trigger build and updateChildren
_markNeedsBuild();
}
}
@ -1222,10 +1244,9 @@ class RawEditorState extends EditorState
_updateOrDisposeSelectionOverlayIfNeeded();
});
if (mounted) {
setState(() {
// Use controller.value in build()
// Trigger build and updateChildren
});
// Mark widget as dirty and trigger build and updateChildren
_markNeedsBuild();
}
}
@ -1258,6 +1279,11 @@ class RawEditorState extends EditorState
}
void _handleFocusChanged() {
if (dirty) {
SchedulerBinding.instance
.addPostFrameCallback((_) => _handleFocusChanged());
return;
}
openOrCloseConnection();
_cursorCont.startOrStopCursorTimerIfNeeded(_hasFocus, controller.selection);
_updateOrDisposeSelectionOverlayIfNeeded();
@ -1272,10 +1298,9 @@ class RawEditorState extends EditorState
void _onChangedClipboardStatus() {
if (!mounted) return;
setState(() {
// Inform the widget that the value of clipboardStatus has changed.
// Trigger build and updateChildren
});
_markNeedsBuild();
}
Future<LinkMenuAction> _linkActionPicker(Node linkNode) async {
@ -1409,7 +1434,7 @@ class RawEditorState extends EditorState
void copySelection(SelectionChangedCause cause) {
controller.copiedImageUrl = null;
_pastePlainText = controller.getPlainText();
_pasteStyle = controller.getAllIndividualSelectionStyles();
_pasteStyleAndEmbed = controller.getAllIndividualSelectionStylesAndEmbed();
final selection = textEditingValue.selection;
final text = textEditingValue.text;
@ -1438,7 +1463,7 @@ class RawEditorState extends EditorState
void cutSelection(SelectionChangedCause cause) {
controller.copiedImageUrl = null;
_pastePlainText = controller.getPlainText();
_pasteStyle = controller.getAllIndividualSelectionStyles();
_pasteStyleAndEmbed = controller.getAllIndividualSelectionStylesAndEmbed();
if (widget.readOnly) {
return;

@ -4,7 +4,9 @@ import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart';
import '../../models/documents/document.dart';
import '../../models/documents/nodes/embeddable.dart';
import '../../models/documents/nodes/leaf.dart';
import '../../models/documents/style.dart';
import '../../utils/delta.dart';
import '../editor.dart';
@ -26,38 +28,50 @@ mixin RawEditorStateSelectionDelegateMixin on EditorState
return;
}
final insertedText = _adjustInsertedText(diff.inserted);
var insertedText = diff.inserted;
final containsEmbed =
insertedText.codeUnits.contains(Embed.kObjectReplacementInt);
insertedText =
containsEmbed ? _adjustInsertedText(diff.inserted) : diff.inserted;
widget.controller.replaceText(
diff.start, diff.deleted.length, insertedText, value.selection);
_applyPasteStyle(insertedText, diff.start);
_applyPasteStyleAndEmbed(insertedText, diff.start, containsEmbed);
}
void _applyPasteStyle(String insertedText, int start) {
if (insertedText == pastePlainText && pastePlainText != '') {
void _applyPasteStyleAndEmbed(
String insertedText, int start, bool containsEmbed) {
if (insertedText == pastePlainText && pastePlainText != '' ||
containsEmbed) {
final pos = start;
for (var i = 0; i < pasteStyle.length; i++) {
final offset = pasteStyle[i].offset;
final style = pasteStyle[i].value;
widget.controller.formatTextStyle(
pos + offset,
i == pasteStyle.length - 1
? pastePlainText.length - offset
: pasteStyle[i + 1].offset,
style);
for (var i = 0; i < pasteStyleAndEmbed.length; i++) {
final offset = pasteStyleAndEmbed[i].offset;
final styleAndEmbed = pasteStyleAndEmbed[i].value;
final local = pos + offset;
if (styleAndEmbed is Embeddable) {
widget.controller.replaceText(local, 0, styleAndEmbed, null);
} else {
final style = styleAndEmbed as Style;
if (style.isInline) {
widget.controller
.formatTextStyle(local, pasteStyleAndEmbed[i].length!, style);
} else if (style.isBlock) {
final node = widget.controller.document.queryChild(local).node;
if (node != null &&
pasteStyleAndEmbed[i].length == node.length - 1) {
style.values.forEach((attribute) {
widget.controller.document.format(local, 0, attribute);
});
}
}
}
}
}
String _adjustInsertedText(String text) {
// For clip from editor, it may contain image, a.k.a 65532 or '\uFFFC'.
// For clip from browser, image is directly ignore.
// Here we skip image when pasting.
if (!text.codeUnits.contains(Embed.kObjectReplacementInt)) {
return text;
}
String _adjustInsertedText(String text) {
final sb = StringBuffer();
for (var i = 0; i < text.length; i++) {
if (text.codeUnitAt(i) == Embed.kObjectReplacementInt) {

@ -91,7 +91,8 @@ mixin RawEditorStateTextInputClientMixin on EditorState
void _updateCaretRectIfNeeded() {
if (hasConnection) {
if (renderEditor.selection.isValid &&
if (!dirty &&
renderEditor.selection.isValid &&
renderEditor.selection.isCollapsed) {
final currentTextPosition =
TextPosition(offset: renderEditor.selection.baseOffset);

@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:i18n_extension/i18n_widget.dart';
import '../models/documents/attribute.dart';
import '../models/structs/link_dialog_action.dart';
import '../models/themes/quill_custom_button.dart';
import '../models/themes/quill_dialog_theme.dart';
import '../models/themes/quill_icon_theme.dart';
@ -11,13 +12,13 @@ import 'embeds.dart';
import 'toolbar/arrow_indicated_button_list.dart';
import 'toolbar/clear_format_button.dart';
import 'toolbar/color_button.dart';
import 'toolbar/custom_button.dart';
import 'toolbar/enum.dart';
import 'toolbar/history_button.dart';
import 'toolbar/indent_button.dart';
import 'toolbar/link_style_button.dart';
import 'toolbar/quill_font_family_button.dart';
import 'toolbar/quill_font_size_button.dart';
import 'toolbar/quill_icon_button.dart';
import 'toolbar/search_button.dart';
import 'toolbar/select_alignment_button.dart';
import 'toolbar/select_header_style_button.dart';
@ -26,6 +27,7 @@ import 'toolbar/toggle_style_button.dart';
export 'toolbar/clear_format_button.dart';
export 'toolbar/color_button.dart';
export 'toolbar/custom_button.dart';
export 'toolbar/history_button.dart';
export 'toolbar/indent_button.dart';
export 'toolbar/link_style_button.dart';
@ -63,6 +65,7 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget {
VoidCallback? afterButtonPressed,
this.sectionDividerColor,
this.sectionDividerSpace,
this.linkDialogAction,
Key? key,
}) : super(key: key);
@ -153,6 +156,10 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget {
/// The space occupied by toolbar divider
double? sectionDividerSpace,
/// Validate the legitimacy of hyperlinks
RegExp? linkRegExp,
LinkDialogAction? linkDialogAction,
Key? key,
}) {
final isButtonGroupShown = [
@ -550,6 +557,8 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget {
iconTheme: iconTheme,
dialogTheme: dialogTheme,
afterButtonPressed: afterButtonPressed,
linkRegExp: linkRegExp,
linkDialogAction: linkDialogAction,
),
if (showSearchButton)
SearchButton(
@ -566,21 +575,23 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget {
QuillDivider(axis,
color: sectionDividerColor, space: sectionDividerSpace),
for (var customButton in customButtons)
QuillIconButton(
highlightElevation: 0,
hoverElevation: 0,
size: toolbarIconSize * kIconButtonFactor,
icon: Icon(
customButton.icon,
size: toolbarIconSize,
color: customButton.iconColor,
if (customButton.child != null) ...[
InkWell(
onTap: customButton.onTap,
child: customButton.child,
),
tooltip: customButton.tooltip,
borderRadius: iconTheme?.borderRadius ?? 2,
] else ...[
CustomButton(
onPressed: customButton.onTap,
afterPressed: afterButtonPressed,
icon: customButton.icon,
iconColor: customButton.iconColor,
iconSize: toolbarIconSize,
iconTheme: iconTheme,
afterButtonPressed: afterButtonPressed,
tooltip: customButton.tooltip,
),
],
],
);
}
@ -592,6 +603,9 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget {
final WrapCrossAlignment toolbarIconCrossAlignment;
final bool multiRowsDisplay;
// Overrides the action in the _LinkDialog widget
final LinkDialogAction? linkDialogAction;
/// The color of the toolbar.
///
/// Defaults to [ThemeData.canvasColor] of the current [Theme] if no color
@ -650,7 +664,7 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget {
/// The divider which is used for separation of buttons in the toolbar.
///
/// It can be used outside of this package, for example when user does not use
/// [QuillToolbar.basic] and compose toolbat's children on its own.
/// [QuillToolbar.basic] and compose toolbar's children on its own.
class QuillDivider extends StatelessWidget {
const QuillDivider(
this.axis, {
@ -659,11 +673,11 @@ class QuillDivider extends StatelessWidget {
this.space,
}) : super(key: key);
/// Provides a horizonal divider for vertical toolbar.
/// Provides a horizontal divider for vertical toolbar.
const QuillDivider.horizontal({Color? color, double? space})
: this(Axis.horizontal, color: color, space: space);
/// Provides a horizonal divider for horizontal toolbar.
/// Provides a horizontal divider for horizontal toolbar.
const QuillDivider.vertical({Color? color, double? space})
: this(Axis.vertical, color: color, space: space);

@ -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,
highlightElevation: 0,
hoverElevation: 0,
size: widget.iconSize * 1.77,
size: widget.iconSize * kIconButtonFactor,
icon: Icon(widget.icon, size: widget.iconSize, color: _iconColor),
fillColor: fillColor,
borderRadius: widget.iconTheme?.borderRadius ?? 2,

@ -42,7 +42,7 @@ class _IndentButtonState extends State<IndentButton> {
tooltip: widget.tooltip,
highlightElevation: 0,
hoverElevation: 0,
size: widget.iconSize * 1.77,
size: widget.iconSize * kIconButtonFactor,
icon: Icon(widget.icon, size: widget.iconSize, color: iconColor),
fillColor: iconFillColor,
borderRadius: widget.iconTheme?.borderRadius ?? 2,

@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import '../../models/documents/attribute.dart';
import '../../models/rules/insert.dart';
import '../../models/structs/link_dialog_action.dart';
import '../../models/themes/quill_dialog_theme.dart';
import '../../models/themes/quill_icon_theme.dart';
import '../../translations/toolbar.i18n.dart';
@ -18,6 +19,8 @@ class LinkStyleButton extends StatefulWidget {
this.dialogTheme,
this.afterButtonPressed,
this.tooltip,
this.linkRegExp,
this.linkDialogAction,
Key? key,
}) : super(key: key);
@ -28,6 +31,8 @@ class LinkStyleButton extends StatefulWidget {
final QuillDialogTheme? dialogTheme;
final VoidCallback? afterButtonPressed;
final String? tooltip;
final RegExp? linkRegExp;
final LinkDialogAction? linkDialogAction;
@override
_LinkStyleButtonState createState() => _LinkStyleButtonState();
@ -108,7 +113,12 @@ class _LinkStyleButtonState extends State<LinkStyleButton> {
text ??=
len == 0 ? '' : widget.controller.document.getPlainText(index, len);
return _LinkDialog(
dialogTheme: widget.dialogTheme, link: link, text: text);
dialogTheme: widget.dialogTheme,
link: link,
text: text,
linkRegExp: widget.linkRegExp,
action: widget.linkDialogAction,
);
},
).then(
(value) {
@ -143,12 +153,20 @@ class _LinkStyleButtonState extends State<LinkStyleButton> {
}
class _LinkDialog extends StatefulWidget {
const _LinkDialog({this.dialogTheme, this.link, this.text, Key? key})
: super(key: key);
const _LinkDialog({
this.dialogTheme,
this.link,
this.text,
this.linkRegExp,
this.action,
Key? key,
}) : super(key: key);
final QuillDialogTheme? dialogTheme;
final String? link;
final String? text;
final RegExp? linkRegExp;
final LinkDialogAction? action;
@override
_LinkDialogState createState() => _LinkDialogState();
@ -157,6 +175,7 @@ class _LinkDialog extends StatefulWidget {
class _LinkDialogState extends State<_LinkDialog> {
late String _link;
late String _text;
late RegExp linkRegExp;
late TextEditingController _linkController;
late TextEditingController _textController;
@ -165,6 +184,7 @@ class _LinkDialogState extends State<_LinkDialog> {
super.initState();
_link = widget.link ?? '';
_text = widget.text ?? '';
linkRegExp = widget.linkRegExp ?? AutoFormatMultipleLinksRule.linkRegExp;
_linkController = TextEditingController(text: _link);
_textController = TextEditingController(text: _text);
}
@ -202,15 +222,21 @@ class _LinkDialogState extends State<_LinkDialog> {
),
],
),
actions: [
TextButton(
actions: [_okButton()],
);
}
Widget _okButton() {
if (widget.action != null) {
return widget.action!.builder(_canPress(), _applyLink);
}
return TextButton(
onPressed: _canPress() ? _applyLink : null,
child: Text(
'Ok'.i18n,
style: widget.dialogTheme?.labelTextStyle,
style: widget.dialogTheme?.buttonTextStyle,
),
),
],
);
}
@ -218,8 +244,7 @@ class _LinkDialogState extends State<_LinkDialog> {
if (_text.isEmpty || _link.isEmpty) {
return false;
}
if (!AutoFormatMultipleLinksRule.linkRegExp.hasMatch(_link)) {
if (!linkRegExp.hasMatch(_link)) {
return false;
}

@ -43,7 +43,7 @@ class SearchButton extends StatelessWidget {
icon: Icon(icon, size: iconSize, color: iconColor),
highlightElevation: 0,
hoverElevation: 0,
size: iconSize * 1.77,
size: iconSize * kIconButtonFactor,
fillColor: iconFillColor,
borderRadius: iconTheme?.borderRadius ?? 2,
onPressed: () => _onPressedHandler(context),

@ -23,6 +23,8 @@ class _SearchDialogState extends State<SearchDialog> {
late TextEditingController _controller;
late List<int>? _offsets;
late int _index;
bool _caseSensitive = false;
bool _wholeWord = false;
@override
void initState() {
@ -35,87 +37,113 @@ class _SearchDialogState extends State<SearchDialog> {
@override
Widget build(BuildContext context) {
return StatefulBuilder(builder: (context, setState) {
var label = '';
var matchShown = '';
if (_offsets != null) {
label = '${_offsets!.length} ${'matches'.i18n}';
if (_offsets!.isNotEmpty) {
label += ', ${'showing match'.i18n} ${_index + 1}';
if (_offsets!.isEmpty) {
matchShown = '0/0';
} else {
matchShown = '${_index + 1}/${_offsets!.length}';
}
}
return AlertDialog(
return Dialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(5),
),
backgroundColor: widget.dialogTheme?.dialogBackgroundColor,
content: Container(
height: 100,
child: Column(
alignment: Alignment.bottomCenter,
insetPadding: EdgeInsets.zero,
child: SizedBox(
height: 45,
child: Row(
children: [
TextField(
keyboardType: TextInputType.multiline,
Tooltip(
message: 'Case sensitivity and whole word search'.i18n,
child: ToggleButtons(
onPressed: (index) {
if (index == 0) {
_changeCaseSensitivity();
} else if (index == 1) {
_changeWholeWord();
}
},
borderRadius: const BorderRadius.all(Radius.circular(2)),
isSelected: [_caseSensitive, _wholeWord],
children: const [
Text(
'\u0391\u03b1',
style: TextStyle(
fontFamily: 'MaterialIcons',
fontSize: 24,
),
),
Text(
'\u201c\u2026\u201d',
style: TextStyle(
fontFamily: 'MaterialIcons',
fontSize: 24,
),
),
],
),
),
Expanded(
child: Padding(
padding: const EdgeInsets.only(bottom: 12, left: 5),
child: TextField(
style: widget.dialogTheme?.inputTextStyle,
decoration: InputDecoration(
labelText: 'Search'.i18n,
labelStyle: widget.dialogTheme?.labelTextStyle,
floatingLabelStyle: widget.dialogTheme?.labelTextStyle),
isDense: true,
suffixText: (_offsets != null) ? matchShown : '',
suffixStyle: widget.dialogTheme?.labelTextStyle,
),
autofocus: true,
onChanged: _textChanged,
textInputAction: TextInputAction.done,
onEditingComplete: _findText,
controller: _controller,
),
if (_offsets != null)
Padding(
padding: const EdgeInsets.all(8),
child: Text(label, textAlign: TextAlign.left),
),
],
),
if (_offsets == null)
IconButton(
icon: const Icon(Icons.search),
tooltip: 'Find text'.i18n,
onPressed: _findText,
),
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)
IconButton(
icon: const Icon(Icons.keyboard_arrow_up),
tooltip: 'Move to previous occurrence'.i18n,
onPressed: (_offsets!.isNotEmpty) ? _moveToPrevious : null,
),
if (_offsets != null)
IconButton(
icon: const Icon(Icons.keyboard_arrow_down),
tooltip: 'Move to next occurrence'.i18n,
onPressed: (_offsets!.isNotEmpty) ? _moveToNext : null,
),
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: () {
);
}
void _findText() {
if (_text.isEmpty) {
return;
}
setState(() {
_offsets = widget.controller.document.search(_text);
_offsets = widget.controller.document.search(
_text,
caseSensitive: _caseSensitive,
wholeWord: _wholeWord,
);
_index = 0;
});
if (_offsets!.isNotEmpty) {
_moveToPosition();
}
},
child: Text(
'Ok'.i18n,
style: widget.dialogTheme?.labelTextStyle,
),
),
],
);
});
}
void _moveToPosition() {
@ -126,6 +154,34 @@ class _SearchDialogState extends State<SearchDialog> {
ChangeSource.LOCAL);
}
void _moveToPrevious() {
if (_offsets!.isEmpty) {
return;
}
setState(() {
if (_index > 0) {
_index -= 1;
} else {
_index = _offsets!.length - 1;
}
});
_moveToPosition();
}
void _moveToNext() {
if (_offsets!.isEmpty) {
return;
}
setState(() {
if (_index < _offsets!.length - 1) {
_index += 1;
} else {
_index = 0;
}
});
_moveToPosition();
}
void _textChanged(String value) {
setState(() {
_text = value;
@ -133,4 +189,20 @@ class _SearchDialogState extends State<SearchDialog> {
_index = 0;
});
}
void _changeCaseSensitivity() {
setState(() {
_caseSensitive = !_caseSensitive;
_offsets = null;
_index = 0;
});
}
void _changeWholeWord() {
setState(() {
_wholeWord = !_wholeWord;
_offsets = null;
_index = 0;
});
}
}

@ -1,8 +1,7 @@
name: flutter_quill
description: A rich text editor supporting mobile and web (Demo App @ bulletjournal.us)
version: 7.2.1
#author: bulletjournal
homepage: https://bulletjournal.us/home/index.html
description: A rich text editor built for the modern Android, iOS, web and desktop platforms. It is the WYSIWYG editor and a Quill component for Flutter.
version: 7.3.2
homepage: https://1o24bbs.com/c/bulletjournal/108
repository: https://github.com/singerdmx/flutter-quill
environment:
@ -25,7 +24,7 @@ dependencies:
platform: ^3.1.0
pasteboard: ^0.2.0
dev_dependencies:
# Dependencies for testing utilities
flutter_test:
sdk: flutter

@ -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,295 @@
import 'package:flutter/material.dart';
import 'package:flutter_quill/flutter_quill.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
const testDocumentContents = 'data';
late QuillController controller;
setUp(() {
controller = QuillController.basic()
..compose(Delta()..insert(testDocumentContents),
const TextSelection.collapsed(offset: 0), ChangeSource.LOCAL);
});
group('controller', () {
test('set document', () {
const replacementContents = 'replacement\n';
final newDocument =
Document.fromDelta(Delta()..insert(replacementContents));
var listenerCalled = false;
controller
..addListener(() {
listenerCalled = true;
})
..document = newDocument;
expect(listenerCalled, isTrue);
expect(controller.document.toPlainText(), replacementContents);
});
test('getSelectionStyle', () {
controller
..formatText(0, 5, Attribute.h1)
..updateSelection(const TextSelection(baseOffset: 0, extentOffset: 4),
ChangeSource.LOCAL);
expect(controller.getSelectionStyle().values, [Attribute.h1]);
});
test('indentSelection with single line document', () {
var listenerCalled = false;
// With selection range
controller
..updateSelection(const TextSelection(baseOffset: 0, extentOffset: 4),
ChangeSource.LOCAL)
..addListener(() {
listenerCalled = true;
})
..indentSelection(true);
expect(listenerCalled, isTrue);
expect(controller.getSelectionStyle().values, [Attribute.indentL1]);
controller.indentSelection(true);
expect(controller.getSelectionStyle().values, [Attribute.indentL2]);
controller.indentSelection(false);
expect(controller.getSelectionStyle().values, [Attribute.indentL1]);
controller.indentSelection(false);
expect(controller.getSelectionStyle().values, []);
// With collapsed selection
controller
..updateSelection(
const TextSelection.collapsed(offset: 0), ChangeSource.LOCAL)
..indentSelection(true);
expect(controller.getSelectionStyle().values, [Attribute.indentL1]);
controller
..updateSelection(
const TextSelection.collapsed(offset: 0), ChangeSource.LOCAL)
..indentSelection(true);
expect(controller.getSelectionStyle().values, [Attribute.indentL2]);
controller.indentSelection(false);
expect(controller.getSelectionStyle().values, [Attribute.indentL1]);
controller.indentSelection(false);
expect(controller.getSelectionStyle().values, []);
});
test('indentSelection with multiline document', () {
controller
..compose(Delta()..insert('line1\nline2\nline3\n'),
const TextSelection.collapsed(offset: 0), ChangeSource.LOCAL)
// Indent first line
..updateSelection(
const TextSelection.collapsed(offset: 0), ChangeSource.LOCAL)
..indentSelection(true);
expect(controller.getSelectionStyle().values, [Attribute.indentL1]);
// Indent first two lines
controller
..updateSelection(const TextSelection(baseOffset: 0, extentOffset: 11),
ChangeSource.LOCAL)
..indentSelection(true);
// Should have both L1 and L2 indent attributes in selection.
expect(controller.getAllSelectionStyles(),
contains(Style().put(Attribute.indentL1).put(Attribute.indentL2)));
// Remaining lines should have no attributes.
controller.updateSelection(
TextSelection(
baseOffset: 12,
extentOffset: controller.document.toPlainText().length - 1),
ChangeSource.LOCAL);
expect(controller.getAllSelectionStyles(), everyElement(Style()));
});
test('getAllIndividualSelectionStylesAndEmbed', () {
controller
..formatText(0, 2, Attribute.bold)
..replaceText(2, 2, BlockEmbed.image('/test'), null)
..updateSelection(const TextSelection(baseOffset: 0, extentOffset: 4),
ChangeSource.REMOTE);
final result = controller.getAllIndividualSelectionStylesAndEmbed();
expect(result.length, 2);
expect(result[0].offset, 0);
expect(result[0].value, Style().put(Attribute.bold));
expect((result[1].value as Embeddable).type, BlockEmbed.imageType);
});
test('getPlainText', () {
controller.updateSelection(
const TextSelection(baseOffset: 0, extentOffset: 4),
ChangeSource.LOCAL);
expect(controller.getPlainText(), testDocumentContents);
});
test('getAllSelectionStyles', () {
controller.formatText(0, 2, Attribute.bold);
expect(controller.getAllSelectionStyles(),
contains(Style().put(Attribute.bold)));
});
test('undo', () {
var listenerCalled = false;
controller.updateSelection(
const TextSelection.collapsed(offset: 4), ChangeSource.LOCAL);
expect(controller.document.toDelta(), Delta()..insert('data\n'));
controller
..addListener(() {
listenerCalled = true;
})
..undo();
expect(listenerCalled, isTrue);
expect(controller.document.toDelta(), Delta()..insert('\n'));
});
test('redo', () {
var listenerCalled = false;
controller.updateSelection(
const TextSelection.collapsed(offset: 4), ChangeSource.LOCAL);
expect(controller.document.toDelta(), Delta()..insert('data\n'));
controller.undo();
expect(controller.document.toDelta(), Delta()..insert('\n'));
controller
..addListener(() {
listenerCalled = true;
})
..redo();
expect(listenerCalled, isTrue);
expect(controller.document.toDelta(), Delta()..insert('data\n'));
});
test('clear', () {
var listenerCalled = false;
controller
..addListener(() {
listenerCalled = true;
})
..clear();
expect(listenerCalled, isTrue);
expect(controller.document.toDelta(), Delta()..insert('\n'));
});
test('replaceText', () {
var listenerCalled = false;
controller
..addListener(() {
listenerCalled = true;
})
..replaceText(1, 2, '11', const TextSelection.collapsed(offset: 0));
expect(listenerCalled, isTrue);
expect(controller.document.toDelta(), Delta()..insert('d11a\n'));
});
test('formatTextStyle', () {
var listenerCalled = false;
final style = Style().put(Attribute.bold).put(Attribute.italic);
controller
..addListener(() {
listenerCalled = true;
})
..formatTextStyle(0, 2, style);
expect(listenerCalled, isTrue);
expect(controller.document.collectAllStyles(0, 2), contains(style));
expect(controller.document.collectAllStyles(2, 4), everyElement(Style()));
});
test('formatText', () {
var listenerCalled = false;
controller
..addListener(() {
listenerCalled = true;
})
..formatText(0, 2, Attribute.bold);
expect(listenerCalled, isTrue);
expect(controller.document.collectAllStyles(0, 2),
contains(Style().put(Attribute.bold)));
expect(controller.document.collectAllStyles(2, 4), everyElement(Style()));
});
test('formatSelection', () {
var listenerCalled = false;
controller
..updateSelection(const TextSelection(baseOffset: 0, extentOffset: 2),
ChangeSource.LOCAL)
..addListener(() {
listenerCalled = true;
})
..formatSelection(Attribute.bold);
expect(listenerCalled, isTrue);
expect(controller.document.collectAllStyles(0, 2),
contains(Style().put(Attribute.bold)));
expect(controller.document.collectAllStyles(2, 4), everyElement(Style()));
});
test('moveCursorToStart', () {
var listenerCalled = false;
controller
..updateSelection(
const TextSelection.collapsed(offset: 4), ChangeSource.LOCAL)
..addListener(() {
listenerCalled = true;
});
expect(controller.selection, const TextSelection.collapsed(offset: 4));
controller.moveCursorToStart();
expect(listenerCalled, isTrue);
expect(controller.selection, const TextSelection.collapsed(offset: 0));
});
test('moveCursorToPosition', () {
var listenerCalled = false;
controller.addListener(() {
listenerCalled = true;
});
expect(controller.selection, const TextSelection.collapsed(offset: 0));
controller.moveCursorToPosition(2);
expect(listenerCalled, isTrue);
expect(controller.selection, const TextSelection.collapsed(offset: 2));
});
test('moveCursorToEnd', () {
var listenerCalled = false;
controller.addListener(() {
listenerCalled = true;
});
expect(controller.selection, const TextSelection.collapsed(offset: 0));
controller.moveCursorToEnd();
expect(listenerCalled, isTrue);
expect(controller.selection,
TextSelection.collapsed(offset: controller.document.length - 1));
});
test('updateSelection', () {
var listenerCalled = false;
const selection = TextSelection.collapsed(offset: 0);
controller
..addListener(() {
listenerCalled = true;
})
..updateSelection(selection, ChangeSource.LOCAL);
expect(listenerCalled, isTrue);
expect(controller.selection, selection);
});
test('compose', () {
var listenerCalled = false;
final originalContents = controller.document.toPlainText();
controller
..addListener(() {
listenerCalled = true;
})
..compose(Delta()..insert('test '),
const TextSelection.collapsed(offset: 0), ChangeSource.LOCAL);
expect(listenerCalled, isTrue);
expect(controller.document.toDelta(),
Delta()..insert('test $originalContents'));
});
});
}

@ -0,0 +1,133 @@
import 'dart:convert' show jsonDecode;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_quill/flutter_quill.dart';
import 'package:flutter_quill/flutter_quill_test.dart';
import 'package:flutter_quill/src/widgets/raw_editor.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
late QuillController controller;
var didCopy = false;
setUp(() {
controller = QuillController.basic();
});
tearDown(() {
controller.dispose();
});
group('QuillEditor', () {
testWidgets('Keyboard entered text is stored in document', (tester) async {
await tester.pumpWidget(
MaterialApp(
home: QuillEditor.basic(controller: controller, readOnly: false),
),
);
await tester.quillEnterText(find.byType(QuillEditor), 'test\n');
expect(controller.document.toPlainText(), 'test\n');
});
testWidgets('insertContent is handled correctly', (tester) async {
String? latestUri;
await tester.pumpWidget(
MaterialApp(
home: QuillEditor(
controller: controller,
focusNode: FocusNode(),
scrollController: ScrollController(),
scrollable: true,
padding: const EdgeInsets.all(0),
autoFocus: true,
readOnly: false,
expands: true,
contentInsertionConfiguration: ContentInsertionConfiguration(
onContentInserted: (content) {
latestUri = content.uri;
},
allowedMimeTypes: const <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));
});
Widget customBuilder(BuildContext context, RawEditorState state) {
return AdaptiveTextSelectionToolbar(
anchors: state.contextMenuAnchors,
children: [
Container(
height: 50,
color: Colors.white,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
IconButton(
onPressed: () {
didCopy = true;
},
icon: const Icon(Icons.copy),
),
],
),
),
],
);
}
testWidgets('custom context menu builder', (tester) async {
await tester.pumpWidget(MaterialApp(
home: QuillEditor(
controller: controller,
focusNode: FocusNode(),
scrollController: ScrollController(),
scrollable: true,
padding: EdgeInsets.zero,
autoFocus: true,
readOnly: false,
expands: true,
contextMenuBuilder: customBuilder,
),
));
// Long press to show menu
await tester.longPress(find.byType(QuillEditor));
await tester.pumpAndSettle();
// Verify custom widget shows
expect(find.byIcon(Icons.copy), findsOneWidget);
await tester.tap(find.byIcon(Icons.copy));
expect(didCopy, isTrue);
});
});
}
Loading…
Cancel
Save