Merge branch 'singerdmx:master' into custom-video-player-changes

pull/756/head
JasmitSingh90 3 years ago committed by GitHub
commit 3ca7b3237d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 136
      CHANGELOG.md
  2. 29
      README.md
  3. 9
      example/lib/pages/home_page.dart
  4. 2
      example/macos/Runner/DebugProfile.entitlements
  5. 2
      example/test/widget_test.dart
  6. 6
      example/windows/flutter/generated_plugin_registrant.cc
  7. 4
      lib/flutter_quill.dart
  8. 30
      lib/src/models/documents/attribute.dart
  9. 2
      lib/src/models/documents/nodes/container.dart
  10. 2
      lib/src/models/documents/nodes/embed.dart
  11. 25
      lib/src/models/documents/nodes/line.dart
  12. 3
      lib/src/models/documents/nodes/node.dart
  13. 26
      lib/src/models/quill_delta.dart
  14. 40
      lib/src/models/rules/delete.dart
  15. 92
      lib/src/models/rules/format.dart
  16. 25
      lib/src/models/rules/insert.dart
  17. 3
      lib/src/models/rules/rule.dart
  18. 15
      lib/src/models/themes/quill_dialog_theme.dart
  19. 30
      lib/src/models/themes/quill_icon_theme.dart
  20. 110
      lib/src/translations/toolbar.i18n.dart
  21. 2
      lib/src/utils/color.dart
  22. 29
      lib/src/utils/diff_delta.dart
  23. 36
      lib/src/utils/string_helper.dart
  24. 4
      lib/src/widgets/box.dart
  25. 58
      lib/src/widgets/controller.dart
  26. 31
      lib/src/widgets/cursor.dart
  27. 128
      lib/src/widgets/default_styles.dart
  28. 210
      lib/src/widgets/delegate.dart
  29. 878
      lib/src/widgets/editor.dart
  30. 31
      lib/src/widgets/float_cursor.dart
  31. 49
      lib/src/widgets/image.dart
  32. 175
      lib/src/widgets/keyboard_listener.dart
  33. 170
      lib/src/widgets/link.dart
  34. 19
      lib/src/widgets/link_dialog.dart
  35. 26
      lib/src/widgets/proxy.dart
  36. 354
      lib/src/widgets/quill_single_child_scroll_view.dart
  37. 473
      lib/src/widgets/raw_editor.dart
  38. 355
      lib/src/widgets/raw_editor/raw_editor_state_keyboard_mixin.dart
  39. 42
      lib/src/widgets/raw_editor/raw_editor_state_selection_delegate_mixin.dart
  40. 152
      lib/src/widgets/raw_editor/raw_editor_state_text_input_client_mixin.dart
  41. 358
      lib/src/widgets/simple_viewer.dart
  42. 22
      lib/src/widgets/style_widgets/bullet_point.dart
  43. 78
      lib/src/widgets/style_widgets/checkbox_point.dart
  44. 108
      lib/src/widgets/style_widgets/number_point.dart
  45. 3
      lib/src/widgets/style_widgets/style_widgets.dart
  46. 226
      lib/src/widgets/text_block.dart
  47. 482
      lib/src/widgets/text_line.dart
  48. 190
      lib/src/widgets/text_selection.dart
  49. 174
      lib/src/widgets/toolbar.dart
  50. 2
      lib/src/widgets/toolbar/arrow_indicated_button_list.dart
  51. 14
      lib/src/widgets/toolbar/camera_button.dart
  52. 10
      lib/src/widgets/toolbar/clear_format_button.dart
  53. 15
      lib/src/widgets/toolbar/color_button.dart
  54. 14
      lib/src/widgets/toolbar/history_button.dart
  55. 20
      lib/src/widgets/toolbar/image_button.dart
  56. 6
      lib/src/widgets/toolbar/image_video_utils.dart
  57. 13
      lib/src/widgets/toolbar/indent_button.dart
  58. 14
      lib/src/widgets/toolbar/insert_embed_button.dart
  59. 38
      lib/src/widgets/toolbar/link_style_button.dart
  60. 154
      lib/src/widgets/toolbar/select_alignment_button.dart
  61. 18
      lib/src/widgets/toolbar/select_header_style_button.dart
  62. 12
      lib/src/widgets/toolbar/toggle_check_list_button.dart
  63. 34
      lib/src/widgets/toolbar/toggle_style_button.dart
  64. 20
      lib/src/widgets/toolbar/video_button.dart
  65. 11
      lib/src/widgets/video_app.dart
  66. 1
      lib/src/widgets/youtube_video_app.dart
  67. 3
      lib/widgets/keyboard_listener.dart
  68. 3
      lib/widgets/raw_editor/raw_editor_state_keyboard_mixin.dart
  69. 3
      lib/widgets/simple_viewer.dart
  70. 9
      pubspec.yaml

@ -1,3 +1,139 @@
# [3.0.4]
* Add maxContentWidth constraint to editor.
# [3.0.3]
* Do not show caret on screen when the editor is not focused.
# [3.0.2]
* Fix launch link for read-only mode.
## [3.0.1]
* Handle null value of Attribute.link.
## [3.0.0]
* Launch link improvements.
* Removed QuillSimpleViewer.
## [2.5.2]
* Skip image when pasting.
## [2.5.1]
* Bug fix for Desktop `Shift` + `Click` support.
## [2.5.0]
* Update checkbox list.
## [2.4.1]
* Desktop selection improvements.
## [2.4.0]
* Improve inline code style.
## [2.3.3]
* Improves selection rects to have consistent height regardless of individual segment text styles.
## [2.3.2]
* Allow disabling floating cursor.
## [2.3.1]
* Preserve last newline character on delete.
## [2.3.0]
* Massive changes to support flutter 2.8.
## [2.2.2]
* iOS - floating cursor.
## [2.2.1]
* Bug fix for imports supporting flutter 2.8.
## [2.2.0]
* Support flutter 2.8.
## [2.1.1]
* Add methods of clearing editor and moving cursor.
## [2.1.0]
* Add delete handler.
## [2.0.23]
* Support custom replaceText handler.
## [2.0.22]
* Fix attribute compare and fix font size parsing.
## [2.0.21]
* Handle click on embed object.
## [2.0.20]
* Improved UX/UI of Image widget.
## [2.0.19]
* When uploading a video, applying indicator.
## [2.0.18]
* Make toolbar dividers optional.
## [2.0.17]
* Allow alignment of the toolbar icons to match WrapAlignment.
## [2.0.16]
* Add hide / show alignment buttons.
## [2.0.15]
* Implement change cursor to SystemMouseCursors.click when hovering a link styled text.
## [2.0.14]
* Enable customize the checkbox widget using DefaultListBlockStyle style.
## [2.0.13]
* Improve the scrolling performance by reducing the repaint areas.
## [2.0.12]
* Fix the selection effect can't be seen as the textLine with background color.
## [2.0.11]
* Fix visibility of text selection handlers on scroll.
## [2.0.10]
* cursorConnt.color notify the text_line to repaint if it was disposed.
## [2.0.9]
* Improve UX when trying to add a link.
## [2.0.8]
* Adding translations to the toolbar.
## [2.0.7]
* Added theming options for toolbar icons and LinkDialog.
## [2.0.6]
* Avoid runtime error when placed inside TabBarView.
## [2.0.5]
* Support inline code formatting.
## [2.0.4]
* Enable history shortcuts for desktop.
## [2.0.3]
* Fix cursor when line contains image.
## [2.0.2]
* Address KeyboardListener class name conflict.
## [2.0.1]
* Upgrade flutter_colorpicker to 0.5.0.
## [2.0.0]
* Text Alignment functions + Block Format standards.
## [1.9.6]
* Support putting QuillEditor inside a Scrollable view.
## [1.9.5]
* Skip image when pasting.
## [1.9.4] ## [1.9.4]
* Bug fix for cursor position when tapping at the end of line with image(s). * Bug fix for cursor position when tapping at the end of line with image(s).

@ -87,14 +87,14 @@ The `QuillToolbar` class lets you customise which formatting options are availab
## Web ## Web
For web development, use `flutter config --enable-web` for flutter and use [ReactQuill] for React. For web development, use `flutter config --enable-web` for flutter or use [ReactQuill] for React.
It is required to provide `EmbedBuilder`, e.g. [defaultEmbedBuilderWeb](https://github.com/singerdmx/flutter-quill/blob/master/example/lib/universal_ui/universal_ui.dart#L28). It is required to provide `EmbedBuilder`, e.g. [defaultEmbedBuilderWeb](https://github.com/singerdmx/flutter-quill/blob/master/example/lib/universal_ui/universal_ui.dart#L28).
Also it is required to provide `webImagePickImpl`, e.g. [Sample Page](https://github.com/singerdmx/flutter-quill/blob/master/example/lib/pages/home_page.dart#L212). Also it is required to provide `webImagePickImpl`, e.g. [Sample Page](https://github.com/singerdmx/flutter-quill/blob/master/example/lib/pages/home_page.dart#L218).
## Desktop ## Desktop
It is required to provide `filePickImpl` for toolbar image button, e.g. [Sample Page](https://github.com/singerdmx/flutter-quill/blob/master/example/lib/pages/home_page.dart#L192). It is required to provide `filePickImpl` for toolbar image button, e.g. [Sample Page](https://github.com/singerdmx/flutter-quill/blob/master/example/lib/pages/home_page.dart#L198).
## Custom Size Image for Mobile ## Custom Size Image for Mobile
@ -110,9 +110,26 @@ Define `mobileWidth`, `mobileHeight`, `mobileMargin`, `mobileAlignment` as follo
} }
``` ```
## Migrate Zefyr Data ## Translation of toolbar
The package offers translations for the quill toolbar, it will follow the system locale unless you set your own locale with:
Check out [code](https://github.com/jwehrle/zefyr_quill_convert) and [doc](https://docs.google.com/document/d/1FUSrpbarHnilb7uDN5J5DDahaI0v1RMXBjj4fFSpSuY/edit?usp=sharing). ```
QuillToolbar(locale: Locale('fr'), ...)
```
Currently, translations are available for these locales:
* `Locale('en')`
* `Locale('ar')`
* `Locale('de')`
* `Locale('da')`
* `Locale('fr')`
* `Locale('zh', 'CN')`
* `Locale('ko')`
* `Locale('ru')`
* `Locale('es')`
* `Locale('tr')`
* `Locale('uk')`
### Contributing to translations
The translation file is located at [lib/src/translations/toolbar.i18n.dart](lib/src/translations/toolbar.i18n.dart). Feel free to contribute your own translations, just copy the English translations map and replace the values with your translations. Then open a pull request so everyone can benefit from your translations!
--- ---

@ -149,18 +149,23 @@ class _HomePageState extends State<HomePage> {
onVideoPickCallback: _onVideoPickCallback, onVideoPickCallback: _onVideoPickCallback,
// uncomment to provide a custom "pick from" dialog. // uncomment to provide a custom "pick from" dialog.
// mediaPickSettingSelector: _selectMediaPickSetting, // mediaPickSettingSelector: _selectMediaPickSetting,
showAlignmentButtons: true,
); );
if (kIsWeb) { if (kIsWeb) {
toolbar = QuillToolbar.basic( toolbar = QuillToolbar.basic(
controller: _controller!, controller: _controller!,
onImagePickCallback: _onImagePickCallback, onImagePickCallback: _onImagePickCallback,
webImagePickImpl: _webImagePickImpl); webImagePickImpl: _webImagePickImpl,
showAlignmentButtons: true,
);
} }
if (_isDesktop()) { if (_isDesktop()) {
toolbar = QuillToolbar.basic( toolbar = QuillToolbar.basic(
controller: _controller!, controller: _controller!,
onImagePickCallback: _onImagePickCallback, onImagePickCallback: _onImagePickCallback,
filePickImpl: openFileSystemPickerForDesktop); filePickImpl: openFileSystemPickerForDesktop,
showAlignmentButtons: true,
);
} }
return SafeArea( return SafeArea(

@ -8,5 +8,7 @@
<true/> <true/>
<key>com.apple.security.network.server</key> <key>com.apple.security.network.server</key>
<true/> <true/>
<key>com.apple.security.network.client</key>
<true/>
</dict> </dict>
</plist> </plist>

@ -5,9 +5,9 @@
// gestures. You can also use WidgetTester to find child widgets in the widget // gestures. You can also use WidgetTester to find child widgets in the widget
// tree, read text, and verify that the values of widget properties are correct. // tree, read text, and verify that the values of widget properties are correct.
import 'package:app/main.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:app/main.dart';
void main() { void main() {
testWidgets('Counter increments smoke test', (tester) async { testWidgets('Counter increments smoke test', (tester) async {

@ -4,9 +4,9 @@
#include "generated_plugin_registrant.h" #include "generated_plugin_registrant.h"
#include <url_launcher_windows/url_launcher_plugin.h> #include <url_launcher_windows/url_launcher_windows.h>
void RegisterPlugins(flutter::PluginRegistry* registry) { void RegisterPlugins(flutter::PluginRegistry* registry) {
UrlLauncherPluginRegisterWithRegistrar( UrlLauncherWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("UrlLauncherPlugin")); registry->GetRegistrarForPlugin("UrlLauncherWindows"));
} }

@ -5,7 +5,11 @@ export 'src/models/documents/document.dart';
export 'src/models/documents/nodes/embed.dart'; export 'src/models/documents/nodes/embed.dart';
export 'src/models/documents/nodes/leaf.dart'; export 'src/models/documents/nodes/leaf.dart';
export 'src/models/quill_delta.dart'; export 'src/models/quill_delta.dart';
export 'src/models/themes/quill_dialog_theme.dart';
export 'src/models/themes/quill_icon_theme.dart';
export 'src/widgets/controller.dart'; export 'src/widgets/controller.dart';
export 'src/widgets/default_styles.dart'; export 'src/widgets/default_styles.dart';
export 'src/widgets/editor.dart'; export 'src/widgets/editor.dart';
export 'src/widgets/link.dart' show LinkActionPickerDelegate, LinkMenuAction;
export 'src/widgets/style_widgets/style_widgets.dart';
export 'src/widgets/toolbar.dart'; export 'src/widgets/toolbar.dart';

@ -19,8 +19,10 @@ class Attribute<T> {
static final Map<String, Attribute> _registry = LinkedHashMap.of({ static final Map<String, Attribute> _registry = LinkedHashMap.of({
Attribute.bold.key: Attribute.bold, Attribute.bold.key: Attribute.bold,
Attribute.italic.key: Attribute.italic, Attribute.italic.key: Attribute.italic,
Attribute.small.key: Attribute.small,
Attribute.underline.key: Attribute.underline, Attribute.underline.key: Attribute.underline,
Attribute.strikeThrough.key: Attribute.strikeThrough, Attribute.strikeThrough.key: Attribute.strikeThrough,
Attribute.inlineCode.key: Attribute.inlineCode,
Attribute.font.key: Attribute.font, Attribute.font.key: Attribute.font,
Attribute.size.key: Attribute.size, Attribute.size.key: Attribute.size,
Attribute.link.key: Attribute.link, Attribute.link.key: Attribute.link,
@ -37,16 +39,21 @@ class Attribute<T> {
Attribute.height.key: Attribute.height, Attribute.height.key: Attribute.height,
Attribute.style.key: Attribute.style, Attribute.style.key: Attribute.style,
Attribute.token.key: Attribute.token, Attribute.token.key: Attribute.token,
Attribute.script.key: Attribute.script,
}); });
static final BoldAttribute bold = BoldAttribute(); static final BoldAttribute bold = BoldAttribute();
static final ItalicAttribute italic = ItalicAttribute(); static final ItalicAttribute italic = ItalicAttribute();
static final SmallAttribute small = SmallAttribute();
static final UnderlineAttribute underline = UnderlineAttribute(); static final UnderlineAttribute underline = UnderlineAttribute();
static final StrikeThroughAttribute strikeThrough = StrikeThroughAttribute(); static final StrikeThroughAttribute strikeThrough = StrikeThroughAttribute();
static final InlineCodeAttribute inlineCode = InlineCodeAttribute();
static final FontAttribute font = FontAttribute(null); static final FontAttribute font = FontAttribute(null);
static final SizeAttribute size = SizeAttribute(null); static final SizeAttribute size = SizeAttribute(null);
@ -79,9 +86,12 @@ class Attribute<T> {
static final TokenAttribute token = TokenAttribute(''); static final TokenAttribute token = TokenAttribute('');
static final ScriptAttribute script = ScriptAttribute('');
static final Set<String> inlineKeys = { static final Set<String> inlineKeys = {
Attribute.bold.key, Attribute.bold.key,
Attribute.italic.key, Attribute.italic.key,
Attribute.small.key,
Attribute.underline.key, Attribute.underline.key,
Attribute.strikeThrough.key, Attribute.strikeThrough.key,
Attribute.link.key, Attribute.link.key,
@ -107,6 +117,13 @@ class Attribute<T> {
Attribute.indent.key, Attribute.indent.key,
}); });
static final Set<String> exclusiveBlockKeys = LinkedHashSet.of({
Attribute.header.key,
Attribute.list.key,
Attribute.codeBlock.key,
Attribute.blockQuote.key,
});
static Attribute<int?> get h1 => HeaderAttribute(level: 1); static Attribute<int?> get h1 => HeaderAttribute(level: 1);
static Attribute<int?> get h2 => HeaderAttribute(level: 2); static Attribute<int?> get h2 => HeaderAttribute(level: 2);
@ -217,6 +234,10 @@ class ItalicAttribute extends Attribute<bool> {
ItalicAttribute() : super('italic', AttributeScope.INLINE, true); ItalicAttribute() : super('italic', AttributeScope.INLINE, true);
} }
class SmallAttribute extends Attribute<bool> {
SmallAttribute() : super('small', AttributeScope.INLINE, true);
}
class UnderlineAttribute extends Attribute<bool> { class UnderlineAttribute extends Attribute<bool> {
UnderlineAttribute() : super('underline', AttributeScope.INLINE, true); UnderlineAttribute() : super('underline', AttributeScope.INLINE, true);
} }
@ -225,6 +246,10 @@ class StrikeThroughAttribute extends Attribute<bool> {
StrikeThroughAttribute() : super('strike', AttributeScope.INLINE, true); StrikeThroughAttribute() : super('strike', AttributeScope.INLINE, true);
} }
class InlineCodeAttribute extends Attribute<bool> {
InlineCodeAttribute() : super('code', AttributeScope.INLINE, true);
}
class FontAttribute extends Attribute<String?> { class FontAttribute extends Attribute<String?> {
FontAttribute(String? val) : super('font', AttributeScope.INLINE, val); FontAttribute(String? val) : super('font', AttributeScope.INLINE, val);
} }
@ -290,3 +315,8 @@ class StyleAttribute extends Attribute<String?> {
class TokenAttribute extends Attribute<String> { class TokenAttribute extends Attribute<String> {
TokenAttribute(String val) : super('token', AttributeScope.IGNORE, val); TokenAttribute(String val) : super('token', AttributeScope.IGNORE, val);
} }
// `script` is supposed to be inline attribute but it is not supported yet
class ScriptAttribute extends Attribute<String> {
ScriptAttribute(String val) : super('script', AttributeScope.IGNORE, val);
}

@ -94,7 +94,7 @@ abstract class Container<T extends Node?> extends Node {
for (final node in children) { for (final node in children) {
final len = node.length; final len = node.length;
if (offset < len || (inclusive && offset == len && node.isLast)) { if (offset < len || (inclusive && offset == len)) {
return ChildQuery(node, offset); return ChildQuery(node, offset);
} }
offset -= len; offset -= len;

@ -19,7 +19,7 @@ class Embeddable {
static Embeddable fromJson(Map<String, dynamic> json) { static Embeddable fromJson(Map<String, dynamic> json) {
final m = Map<String, dynamic>.from(json); final m = Map<String, dynamic>.from(json);
assert(m.length == 1, 'Embeddable map has one key'); assert(m.length == 1, 'Embeddable map must only have one key');
return BlockEmbed(m.keys.first, m.values.first); return BlockEmbed(m.keys.first, m.values.first);
} }

@ -202,11 +202,26 @@ class Line extends Container<Leaf?> {
if (parent is Block) { if (parent is Block) {
final parentStyle = (parent as Block).style.getBlocksExceptHeader(); final parentStyle = (parent as Block).style.getBlocksExceptHeader();
if (blockStyle.value == null) { // Ensure that we're only unwrapping the block only if we unset a single
// block format in the `parentStyle` and there are no more block formats
// left to unset.
if (blockStyle.value == null &&
parentStyle.containsKey(blockStyle.key) &&
parentStyle.length == 1) {
_unwrap(); _unwrap();
} else if (!const MapEquality() } else if (!const MapEquality()
.equals(newStyle.getBlocksExceptHeader(), parentStyle)) { .equals(newStyle.getBlocksExceptHeader(), parentStyle)) {
_unwrap(); _unwrap();
// Block style now can contain multiple attributes
if (newStyle.attributes.keys
.any(Attribute.exclusiveBlockKeys.contains)) {
parentStyle.removeWhere(
(key, attr) => Attribute.exclusiveBlockKeys.contains(key));
}
parentStyle.removeWhere(
(key, attr) => newStyle?.attributes.keys.contains(key) ?? false);
final parentStyleToMerge = Style.attr(parentStyle);
newStyle = newStyle.mergeAll(parentStyleToMerge);
_applyBlockStyles(newStyle); _applyBlockStyles(newStyle);
} // else the same style, no-op. } // else the same style, no-op.
} else if (blockStyle.value != null) { } else if (blockStyle.value != null) {
@ -344,8 +359,8 @@ class Line extends Container<Leaf?> {
result = result.mergeAll(node.style); result = result.mergeAll(node.style);
var pos = node.length - data.offset; var pos = node.length - data.offset;
while (!node!.isLast && pos < local) { while (!node!.isLast && pos < local) {
node = node.next as Leaf?; node = node.next as Leaf;
_handle(node!.style); _handle(node.style);
pos += node.length; pos += node.length;
} }
} }
@ -376,8 +391,8 @@ class Line extends Container<Leaf?> {
result.add(node.style); result.add(node.style);
var pos = node.length - data.offset; var pos = node.length - data.offset;
while (!node!.isLast && pos < local) { while (!node!.isLast && pos < local) {
node = node.next as Leaf?; node = node.next as Leaf;
result.add(node!.style); result.add(node.style);
pos += node.length; pos += node.length;
} }
} }

@ -14,7 +14,8 @@ import 'line.dart';
/// The [offset] property is relative to [parent]. See also [documentOffset] /// The [offset] property is relative to [parent]. See also [documentOffset]
/// which provides absolute offset of this node within the document. /// which provides absolute offset of this node within the document.
/// ///
/// The current parent node is exposed by the [parent] property. /// The current parent node is exposed by the [parent] property. A node is
/// considered [mounted] when the [parent] property is not `null`.
abstract class Node extends LinkedListEntry<Node> { abstract class Node extends LinkedListEntry<Node> {
/// Current parent of this node. May be null if this node is not mounted. /// Current parent of this node. May be null if this node is not mounted.
Container? parent; Container? parent;

@ -150,6 +150,11 @@ class Operation {
/// Returns `true` if [other] operation has the same attributes as this one. /// Returns `true` if [other] operation has the same attributes as this one.
bool hasSameAttributes(Operation other) { bool hasSameAttributes(Operation other) {
// treat null and empty equal
if ((_attributes?.isEmpty ?? true) &&
(other._attributes?.isEmpty ?? true)) {
return true;
}
return _attributeEquality.equals(_attributes, other._attributes); return _attributeEquality.equals(_attributes, other._attributes);
} }
@ -603,9 +608,28 @@ class Delta {
} }
} }
/// Removes trailing '\n'
void _trimNewLine() {
if (isNotEmpty) {
final lastOp = _operations.last;
final lastOpData = lastOp.data;
if (lastOpData is String && lastOpData.endsWith('\n')) {
_operations.removeLast();
if (lastOpData.length > 1) {
insert(lastOpData.substring(0, lastOpData.length - 1),
lastOp.attributes);
}
}
}
}
/// Concatenates [other] with this delta and returns the result. /// Concatenates [other] with this delta and returns the result.
Delta concat(Delta other) { Delta concat(Delta other, {bool trimNewLine = false}) {
final result = Delta.from(this); final result = Delta.from(this);
if (trimNewLine) {
result._trimNewLine();
}
if (other.isNotEmpty) { if (other.isNotEmpty) {
// In case first operation of other can be merged with last operation in // In case first operation of other can be merged with last operation in
// our list. // our list.

@ -2,6 +2,7 @@ import '../documents/attribute.dart';
import '../quill_delta.dart'; import '../quill_delta.dart';
import 'rule.dart'; import 'rule.dart';
/// A heuristic rule for delete operations.
abstract class DeleteRule extends Rule { abstract class DeleteRule extends Rule {
const DeleteRule(); const DeleteRule();
@ -16,18 +17,42 @@ abstract class DeleteRule extends Rule {
} }
} }
class EnsureLastLineBreakDeleteRule extends DeleteRule {
const EnsureLastLineBreakDeleteRule();
@override
Delta? applyRule(Delta document, int index,
{int? len, Object? data, Attribute? attribute}) {
final itr = DeltaIterator(document)..skip(index + len!);
return Delta()
..retain(index)
..delete(itr.hasNext ? len : len - 1);
}
}
/// Fallback rule for delete operations which simply deletes specified text
/// range without any special handling.
class CatchAllDeleteRule extends DeleteRule { class CatchAllDeleteRule extends DeleteRule {
const CatchAllDeleteRule(); const CatchAllDeleteRule();
@override @override
Delta applyRule(Delta document, int index, Delta applyRule(Delta document, int index,
{int? len, Object? data, Attribute? attribute}) { {int? len, Object? data, Attribute? attribute}) {
final itr = DeltaIterator(document)..skip(index + len!);
return Delta() return Delta()
..retain(index) ..retain(index)
..delete(len!); ..delete(itr.hasNext ? len : len - 1);
} }
} }
/// Preserves line format when user deletes the line's newline character
/// effectively merging it with the next line.
///
/// This rule makes sure to apply all style attributes of deleted newline
/// to the next available newline, which may reset any style attributes
/// already present there.
class PreserveLineStyleOnMergeRule extends DeleteRule { class PreserveLineStyleOnMergeRule extends DeleteRule {
const PreserveLineStyleOnMergeRule(); const PreserveLineStyleOnMergeRule();
@ -44,6 +69,14 @@ class PreserveLineStyleOnMergeRule extends DeleteRule {
final attrs = op.attributes; final attrs = op.attributes;
itr.skip(len! - 1); itr.skip(len! - 1);
if (!itr.hasNext) {
// User attempts to delete the last newline character, prevent it.
return Delta()
..retain(index)
..delete(len - 1);
}
final delta = Delta() final delta = Delta()
..retain(index) ..retain(index)
..delete(len); ..delete(len);
@ -66,13 +99,16 @@ class PreserveLineStyleOnMergeRule extends DeleteRule {
attributes ??= <String, dynamic>{}; attributes ??= <String, dynamic>{};
attributes.addAll(attrs!); attributes.addAll(attrs!);
} }
delta..retain(lineBreak)..retain(1, attributes); delta
..retain(lineBreak)
..retain(1, attributes);
break; break;
} }
return delta; return delta;
} }
} }
/// Prevents user from merging a line containing an embed with other lines.
class EnsureEmbedLineRule extends DeleteRule { class EnsureEmbedLineRule extends DeleteRule {
const EnsureEmbedLineRule(); const EnsureEmbedLineRule();

@ -2,6 +2,7 @@ import '../documents/attribute.dart';
import '../quill_delta.dart'; import '../quill_delta.dart';
import 'rule.dart'; import 'rule.dart';
/// A heuristic rule for format (retain) operations.
abstract class FormatRule extends Rule { abstract class FormatRule extends Rule {
const FormatRule(); const FormatRule();
@ -16,6 +17,8 @@ abstract class FormatRule extends Rule {
} }
} }
/// Produces Delta with line-level attributes applied strictly to
/// newline characters.
class ResolveLineFormatRule extends FormatRule { class ResolveLineFormatRule extends FormatRule {
const ResolveLineFormatRule(); const ResolveLineFormatRule();
@ -26,44 +29,81 @@ class ResolveLineFormatRule extends FormatRule {
return null; return null;
} }
var delta = Delta()..retain(index); // Apply line styles to all newline characters within range of this
// retain operation.
var result = Delta()..retain(index);
final itr = DeltaIterator(document)..skip(index); final itr = DeltaIterator(document)..skip(index);
Operation op; Operation op;
for (var cur = 0; cur < len! && itr.hasNext; cur += op.length!) { for (var cur = 0; cur < len! && itr.hasNext; cur += op.length!) {
op = itr.next(len - cur); op = itr.next(len - cur);
if (op.data is! String || !(op.data as String).contains('\n')) { final opText = op.data is String ? op.data as String : '';
delta.retain(op.length!); if (!opText.contains('\n')) {
result.retain(op.length!);
continue; continue;
} }
final text = op.data as String;
final tmp = Delta();
var offset = 0;
for (var lineBreak = text.indexOf('\n'); final delta = _applyAttribute(opText, op, attribute);
lineBreak >= 0; result = result.concat(delta);
lineBreak = text.indexOf('\n', offset)) {
tmp..retain(lineBreak - offset)..retain(1, attribute.toJson());
offset = lineBreak + 1;
}
tmp.retain(text.length - offset);
delta = delta.concat(tmp);
} }
// And include extra newline after retain
while (itr.hasNext) { while (itr.hasNext) {
op = itr.next(); op = itr.next();
final text = op.data is String ? (op.data as String?)! : ''; final opText = op.data is String ? op.data as String : '';
final lineBreak = text.indexOf('\n'); final lf = opText.indexOf('\n');
if (lineBreak < 0) { if (lf < 0) {
delta.retain(op.length!); result.retain(op.length!);
continue; continue;
} }
delta..retain(lineBreak)..retain(1, attribute.toJson());
final delta = _applyAttribute(opText, op, attribute, firstOnly: true);
result = result.concat(delta);
break; break;
} }
return delta; return result;
}
Delta _applyAttribute(String text, Operation op, Attribute attribute,
{bool firstOnly = false}) {
final result = Delta();
var offset = 0;
var lf = text.indexOf('\n');
final removedBlocks = _getRemovedBlocks(attribute, op);
while (lf >= 0) {
final actualStyle = attribute.toJson()..addEntries(removedBlocks);
result
..retain(lf - offset)
..retain(1, actualStyle);
if (firstOnly) {
return result;
}
offset = lf + 1;
lf = text.indexOf('\n', offset);
}
// Retain any remaining characters in text
result.retain(text.length - offset);
return result;
}
Iterable<MapEntry<String, dynamic>> _getRemovedBlocks(
Attribute<dynamic> attribute, Operation op) {
// Enforce Block Format exclusivity by rule
if (!Attribute.exclusiveBlockKeys.contains(attribute.key)) {
return <MapEntry<String, dynamic>>[];
}
return op.attributes?.keys
.where((key) =>
Attribute.exclusiveBlockKeys.contains(key) &&
attribute.key != key &&
attribute.value != null)
.map((key) => MapEntry<String, dynamic>(key, null)) ??
[];
} }
} }
/// Allows updating link format with collapsed selection.
class FormatLinkAtCaretPositionRule extends FormatRule { class FormatLinkAtCaretPositionRule extends FormatRule {
const FormatLinkAtCaretPositionRule(); const FormatLinkAtCaretPositionRule();
@ -89,11 +129,15 @@ class FormatLinkAtCaretPositionRule extends FormatRule {
return null; return null;
} }
delta..retain(beg)..retain(retain!, attribute.toJson()); delta
..retain(beg)
..retain(retain!, attribute.toJson());
return delta; return delta;
} }
} }
/// Produces Delta with inline-level attributes applied too all characters
/// except newlines.
class ResolveInlineFormatRule extends FormatRule { class ResolveInlineFormatRule extends FormatRule {
const ResolveInlineFormatRule(); const ResolveInlineFormatRule();
@ -118,7 +162,9 @@ class ResolveInlineFormatRule extends FormatRule {
} }
var pos = 0; var pos = 0;
while (lineBreak >= 0) { while (lineBreak >= 0) {
delta..retain(lineBreak - pos, attribute.toJson())..retain(1); delta
..retain(lineBreak - pos, attribute.toJson())
..retain(1);
pos = lineBreak + 1; pos = lineBreak + 1;
lineBreak = text.indexOf('\n', pos); lineBreak = text.indexOf('\n', pos);
} }

@ -5,6 +5,7 @@ import '../documents/style.dart';
import '../quill_delta.dart'; import '../quill_delta.dart';
import 'rule.dart'; import 'rule.dart';
/// A heuristic rule for insert operations.
abstract class InsertRule extends Rule { abstract class InsertRule extends Rule {
const InsertRule(); const InsertRule();
@ -18,6 +19,10 @@ abstract class InsertRule extends Rule {
} }
} }
/// Preserves line format when user splits the line into two.
///
/// This rule ignores scenarios when the line is split on its edge, meaning
/// a newline is inserted at the beginning or the end of a line.
class PreserveLineStyleOnSplitRule extends InsertRule { class PreserveLineStyleOnSplitRule extends InsertRule {
const PreserveLineStyleOnSplitRule(); const PreserveLineStyleOnSplitRule();
@ -87,12 +92,12 @@ class PreserveBlockStyleOnInsertRule extends InsertRule {
return null; return null;
} }
Map<String, dynamic>? resetStyle; final resetStyle = <String, dynamic>{};
// If current line had heading style applied to it we'll need to move this // If current line had heading style applied to it we'll need to move this
// style to the newly inserted line before it and reset style of the // style to the newly inserted line before it and reset style of the
// original line. // original line.
if (lineStyle.containsKey(Attribute.header.key)) { if (lineStyle.containsKey(Attribute.header.key)) {
resetStyle = Attribute.header.toJson(); resetStyle.addAll(Attribute.header.toJson());
} }
// Go over each inserted line and ensure block style is applied. // Go over each inserted line and ensure block style is applied.
@ -113,7 +118,7 @@ class PreserveBlockStyleOnInsertRule extends InsertRule {
} }
// Reset style of the original newline character if needed. // Reset style of the original newline character if needed.
if (resetStyle != null) { if (resetStyle.isNotEmpty) {
delta delta
..retain(nextNewLine.item2!) ..retain(nextNewLine.item2!)
..retain((nextNewLine.item1!.data as String).indexOf('\n')) ..retain((nextNewLine.item1!.data as String).indexOf('\n'))
@ -192,10 +197,17 @@ class AutoExitBlockRule extends InsertRule {
attributes.keys.firstWhere(Attribute.blockKeysExceptHeader.contains); attributes.keys.firstWhere(Attribute.blockKeysExceptHeader.contains);
attributes[k] = null; attributes[k] = null;
// retain(1) should be '\n', set it with no attribute // retain(1) should be '\n', set it with no attribute
return Delta()..retain(index + (len ?? 0))..retain(1, attributes); return Delta()
..retain(index + (len ?? 0))
..retain(1, attributes);
} }
} }
/// Resets format for a newly inserted line when insert occurred at the end
/// of a line (right before a newline).
///
/// This handles scenarios when a new line is added when at the end of a
/// heading line. The newly added line should be a regular paragraph.
class ResetLineFormatOnNewLineRule extends InsertRule { class ResetLineFormatOnNewLineRule extends InsertRule {
const ResetLineFormatOnNewLineRule(); const ResetLineFormatOnNewLineRule();
@ -225,6 +237,7 @@ class ResetLineFormatOnNewLineRule extends InsertRule {
} }
} }
/// Handles all format operations which manipulate embeds.
class InsertEmbedsRule extends InsertRule { class InsertEmbedsRule extends InsertRule {
const InsertEmbedsRule(); const InsertEmbedsRule();
@ -273,6 +286,8 @@ class InsertEmbedsRule extends InsertRule {
} }
} }
/// Applies link format to text segment (which looks like a link) when user
/// inserts space character after it.
class AutoFormatLinksRule extends InsertRule { class AutoFormatLinksRule extends InsertRule {
const AutoFormatLinksRule(); const AutoFormatLinksRule();
@ -312,6 +327,7 @@ class AutoFormatLinksRule extends InsertRule {
} }
} }
/// Preserves inline styles when user inserts text inside formatted segment.
class PreserveInlineStylesRule extends InsertRule { class PreserveInlineStylesRule extends InsertRule {
const PreserveInlineStylesRule(); const PreserveInlineStylesRule();
@ -357,6 +373,7 @@ class PreserveInlineStylesRule extends InsertRule {
} }
} }
/// Fallback rule which simply inserts text as-is without any special handling.
class CatchAllInsertRule extends InsertRule { class CatchAllInsertRule extends InsertRule {
const CatchAllInsertRule(); const CatchAllInsertRule();

@ -19,6 +19,8 @@ abstract class Rule {
void validateArgs(int? len, Object? data, Attribute? attribute); void validateArgs(int? len, Object? data, Attribute? attribute);
/// Applies heuristic rule to an operation on a [document] and returns
/// resulting [Delta].
Delta? applyRule(Delta document, int index, Delta? applyRule(Delta document, int index,
{int? len, Object? data, Attribute? attribute}); {int? len, Object? data, Attribute? attribute});
@ -46,6 +48,7 @@ class Rules {
const EnsureEmbedLineRule(), const EnsureEmbedLineRule(),
const PreserveLineStyleOnMergeRule(), const PreserveLineStyleOnMergeRule(),
const CatchAllDeleteRule(), const CatchAllDeleteRule(),
const EnsureLastLineBreakDeleteRule()
]); ]);
static Rules getInstance() => _instance; static Rules getInstance() => _instance;

@ -0,0 +1,15 @@
import 'package:flutter/material.dart';
class QuillDialogTheme {
QuillDialogTheme(
{this.labelTextStyle, this.inputTextStyle, this.dialogBackgroundColor});
///The text style to use for the label shown in the link-input dialog
final TextStyle? labelTextStyle;
///The text style to use for the input text shown in the link-input dialog
final TextStyle? inputTextStyle;
///The background color for the [LinkDialog()]
final Color? dialogBackgroundColor;
}

@ -0,0 +1,30 @@
import 'package:flutter/material.dart';
class QuillIconTheme {
const QuillIconTheme({
this.iconSelectedColor,
this.iconUnselectedColor,
this.iconSelectedFillColor,
this.iconUnselectedFillColor,
this.disabledIconColor,
this.disabledIconFillColor,
});
///The color to use for selected icons in the toolbar
final Color? iconSelectedColor;
///The color to use for unselected icons in the toolbar
final Color? iconUnselectedColor;
///The fill color to use for the selected icons in the toolbar
final Color? iconSelectedFillColor;
///The fill color to use for the unselected icons in the toolbar
final Color? iconUnselectedFillColor;
///The color to use for disabled icons in the toolbar
final Color? disabledIconColor;
///The fill color to use for disabled icons in the toolbar
final Color? disabledIconFillColor;
}

@ -0,0 +1,110 @@
import 'package:i18n_extension/i18n_extension.dart';
extension Localization on String {
static final _t = Translations.byLocale('en') +
{
'en': {
'Paste a link': 'Paste a link',
'Ok': 'Ok',
'Select Color': 'Select Color',
'Gallery': 'Gallery',
'Link': 'Link',
'Please first select some text to transform into a link.':
'Please first select some text to transform into a link.',
},
'ar': {
'Paste a link': 'نسخ الرابط',
'Ok': 'نعم',
'Select Color': 'اختار اللون',
'Gallery': 'الصور',
'Link': 'الرابط',
'Please first select some text to transform into a link.':
'يرجى اختيار نص للتحويل إلى رابط',
},
'da': {
'Paste a link': 'Indsæt link',
'Ok': 'Ok',
'Select Color': 'Vælg farve',
'Gallery': 'Galleri',
'Link': 'Link',
'Please first select some text to transform into a link.':
'Vælg venligst først noget tekst for at lave det om til et link.',
},
'de': {
'Paste a link': 'Link hinzufügen',
'Ok': 'Ok',
'Select Color': 'Farbe auswählen',
'Gallery': 'Gallerie',
'Link': 'Link',
'Please first select some text to transform into a link.':
'Markiere bitte zuerst einen Text, um diesen in einen Link zu '
'verwandeln.',
},
'fr': {
'Paste a link': 'Coller un lien',
'Ok': 'Ok',
'Select Color': 'Choisir une couleur',
'Gallery': 'Galerie',
'Link': 'Lien',
'Please first select some text to transform into a link.':
"Veuillez d'abord sélectionner un texte à transformer en lien.",
},
'zh_CN': {
'Paste a link': '粘贴链接',
'Ok': '',
'Select Color': '选择颜色',
'Gallery': '相簿',
'Link': '链接',
'Please first select some text to transform into a link.':
'请先选择一些要转化为链接的文本',
},
'ko': {
'Paste a link': '링크를 붙여넣어 주세요.',
'Ok': '확인',
'Select Color': '색상 선택',
'Gallery': '갤러리',
'Link': '링크',
'Please first select some text to transform into a link.':
'링크로 전환할 글자를 먼저 선택해주세요.',
},
'ru': {
'Paste a link': 'Вставить ссылку',
'Ok': 'ОК',
'Select Color': 'Выбрать цвет',
'Gallery': 'Галерея',
'Link': 'Ссылка',
'Please first select some text to transform into a link.':
'Выделите часть текста для создания ссылки.',
},
'es': {
'Paste a link': 'Pega un enlace',
'Ok': 'Ok',
'Select Color': 'Selecciona un color',
'Gallery': 'Galeria',
'Link': 'Enlace',
'Please first select some text to transform into a link.':
'Por favor selecciona primero un texto para transformarlo '
'en un enlace',
},
'tr': {
'Paste a link': 'Bağlantıyı Yapıştır',
'Ok': 'Tamam',
'Select Color': 'Renk Seçin',
'Gallery': 'Galeri',
'Link': 'Bağlantı',
'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.',
},
'uk': {
'Paste a link': 'Вставити посилання',
'Ok': 'ОК',
'Select Color': 'Вибрати колір',
'Gallery': 'Галерея',
'Link': 'Посилання',
'Please first select some text to transform into a link.':
'Виділіть текст для створення посилання.',
},
};
String get i18n => localize(this, _t);
}

@ -1,5 +1,3 @@
import 'dart:ui';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
Color stringToColor(String? s) { Color stringToColor(String? s) {

@ -2,35 +2,6 @@ import 'dart:math' as math;
import '../models/quill_delta.dart'; import '../models/quill_delta.dart';
const Set<int> WHITE_SPACE = {
0x9,
0xA,
0xB,
0xC,
0xD,
0x1C,
0x1D,
0x1E,
0x1F,
0x20,
0xA0,
0x1680,
0x2000,
0x2001,
0x2002,
0x2003,
0x2004,
0x2005,
0x2006,
0x2007,
0x2008,
0x2009,
0x200A,
0x202F,
0x205F,
0x3000
};
// Diff between two texts - old text and new text // Diff between two texts - old text and new text
class Diff { class Diff {
Diff(this.start, this.deleted, this.inserted); Diff(this.start, this.deleted, this.inserted);

@ -1,3 +1,5 @@
import 'package:flutter/cupertino.dart';
Map<String, String> parseKeyValuePairs(String s, Set<String> targetKeys) { Map<String, String> parseKeyValuePairs(String s, Set<String> targetKeys) {
final result = <String, String>{}; final result = <String, String>{};
final pairs = s.split(';'); final pairs = s.split(';');
@ -14,3 +16,37 @@ Map<String, String> parseKeyValuePairs(String s, Set<String> targetKeys) {
return result; return result;
} }
Alignment getAlignment(String? s) {
const _defaultAlignment = Alignment.center;
if (s == null) {
return _defaultAlignment;
}
final _index = [
'topLeft',
'topCenter',
'topRight',
'centerLeft',
'center',
'centerRight',
'bottomLeft',
'bottomCenter',
'bottomRight'
].indexOf(s);
if (_index < 0) {
return _defaultAlignment;
}
return [
Alignment.topLeft,
Alignment.topCenter,
Alignment.topRight,
Alignment.centerLeft,
Alignment.center,
Alignment.centerRight,
Alignment.bottomLeft,
Alignment.bottomCenter,
Alignment.bottomRight
][_index];
}

@ -119,4 +119,8 @@ abstract class RenderEditableBox extends RenderBox {
/// Returns the [Rect] in local coordinates for the caret at the given text /// Returns the [Rect] in local coordinates for the caret at the given text
/// position. /// position.
Rect getLocalRectForCaret(TextPosition position); Rect getLocalRectForCaret(TextPosition position);
/// Returns the [Rect] of the caret prototype at the given text
/// position. [Rect] starts at origin.
Rect getCaretPrototype(TextPosition position);
} }

@ -10,11 +10,18 @@ import '../models/documents/style.dart';
import '../models/quill_delta.dart'; import '../models/quill_delta.dart';
import '../utils/diff_delta.dart'; import '../utils/diff_delta.dart';
typedef ReplaceTextCallback = bool Function(int index, int len, Object? data);
typedef DeleteCallback = void Function(int cursorPosition, bool forward);
class QuillController extends ChangeNotifier { class QuillController extends ChangeNotifier {
QuillController({ QuillController({
required this.document, required this.document,
required TextSelection selection, required TextSelection selection,
}) : _selection = selection; bool keepStyleOnNewLine = false,
this.onReplaceText,
this.onDelete,
}) : _selection = selection,
_keepStyleOnNewLine = keepStyleOnNewLine;
factory QuillController.basic() { factory QuillController.basic() {
return QuillController( return QuillController(
@ -26,10 +33,21 @@ class QuillController extends ChangeNotifier {
/// Document managed by this controller. /// Document managed by this controller.
final Document document; final Document document;
/// Tells whether to keep or reset the [toggledStyle]
/// when user adds a new line.
final bool _keepStyleOnNewLine;
/// Currently selected text within the [document]. /// Currently selected text within the [document].
TextSelection get selection => _selection; TextSelection get selection => _selection;
TextSelection _selection; TextSelection _selection;
/// Custom [replaceText] handler
/// Return false to ignore the event
ReplaceTextCallback? onReplaceText;
/// Custom delete handler
DeleteCallback? onDelete;
/// Store any styles attribute that got toggled by the tap of a button /// Store any styles attribute that got toggled by the tap of a button
/// and that has not been applied yet. /// and that has not been applied yet.
/// It gets reset after each format action within the [document]. /// It gets reset after each format action within the [document].
@ -79,7 +97,7 @@ class QuillController extends ChangeNotifier {
} }
void _handleHistoryChange(int? len) { void _handleHistoryChange(int? len) {
if (len! > 0) { if (len! != 0) {
// if (this.selection.extentOffset >= document.length) { // if (this.selection.extentOffset >= document.length) {
// // cursor exceeds the length of document, position it in the end // // cursor exceeds the length of document, position it in the end
// updateSelection( // updateSelection(
@ -104,11 +122,21 @@ class QuillController extends ChangeNotifier {
bool get hasRedo => document.hasRedo; bool get hasRedo => document.hasRedo;
/// clear editor
void clear() {
replaceText(0, plainTextEditingValue.text.length - 1, '',
const TextSelection.collapsed(offset: 0));
}
void replaceText( void replaceText(
int index, int len, Object? data, TextSelection? textSelection, int index, int len, Object? data, TextSelection? textSelection,
{bool ignoreFocus = false}) { {bool ignoreFocus = false}) {
assert(data is String || data is Embeddable); assert(data is String || data is Embeddable);
if (onReplaceText != null && !onReplaceText!(index, len, data)) {
return;
}
Delta? delta; Delta? delta;
if (len > 0 || data is! String || data.isNotEmpty) { if (len > 0 || data is! String || data.isNotEmpty) {
delta = document.replace(index, len, data); delta = document.replace(index, len, data);
@ -135,7 +163,14 @@ 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(); toggledStyle = Style();
}
if (textSelection != null) { if (textSelection != null) {
if (delta == null || delta.isEmpty) { if (delta == null || delta.isEmpty) {
_updateSelection(textSelection, ChangeSource.LOCAL); _updateSelection(textSelection, ChangeSource.LOCAL);
@ -162,6 +197,14 @@ class QuillController extends ChangeNotifier {
ignoreFocusOnTextChange = false; ignoreFocusOnTextChange = false;
} }
/// Called in two cases:
/// forward == false && textBefore.isEmpty
/// forward == true && textAfter.isEmpty
/// Android only
/// see https://github.com/singerdmx/flutter-quill/discussions/514
void handleDelete(int cursorPosition, bool forward) =>
onDelete?.call(cursorPosition, forward);
void formatText(int index, int len, Attribute? attribute) { void formatText(int index, int len, Attribute? attribute) {
if (len == 0 && if (len == 0 &&
attribute!.isInline && attribute!.isInline &&
@ -183,6 +226,17 @@ class QuillController extends ChangeNotifier {
formatText(selection.start, selection.end - selection.start, attribute); formatText(selection.start, selection.end - selection.start, attribute);
} }
void moveCursorToStart() {
updateSelection(
const TextSelection.collapsed(offset: 0), ChangeSource.LOCAL);
}
void moveCursorToEnd() {
updateSelection(
TextSelection.collapsed(offset: plainTextEditingValue.text.length),
ChangeSource.LOCAL);
}
void updateSelection(TextSelection textSelection, ChangeSource source) { void updateSelection(TextSelection textSelection, ChangeSource source) {
_updateSelection(textSelection, source); _updateSelection(textSelection, source);
notifyListeners(); notifyListeners();

@ -131,6 +131,17 @@ class CursorCont extends ChangeNotifier {
Timer? _cursorTimer; Timer? _cursorTimer;
bool _targetCursorVisibility = false; bool _targetCursorVisibility = false;
final ValueNotifier<TextPosition?> _floatingCursorTextPosition =
ValueNotifier(null);
ValueNotifier<TextPosition?> get floatingCursorTextPosition =>
_floatingCursorTextPosition;
void setFloatingCursorTextPosition(TextPosition? position) =>
_floatingCursorTextPosition.value = position;
bool get isFloatingCursorActive => floatingCursorTextPosition.value != null;
CursorStyle _style; CursorStyle _style;
CursorStyle get style => _style; CursorStyle get style => _style;
set style(CursorStyle value) { set style(CursorStyle value) {
@ -228,13 +239,13 @@ class CursorCont extends ChangeNotifier {
/// Paints the editing cursor. /// Paints the editing cursor.
class CursorPainter { class CursorPainter {
CursorPainter( CursorPainter({
this.editable, required this.editable,
this.style, required this.style,
this.prototype, required this.prototype,
this.color, required this.color,
this.devicePixelRatio, required this.devicePixelRatio,
); });
final RenderContentProxyBox? editable; final RenderContentProxyBox? editable;
final CursorStyle style; final CursorStyle style;
@ -245,10 +256,11 @@ class CursorPainter {
/// Paints cursor on [canvas] at specified [position]. /// Paints cursor on [canvas] at specified [position].
/// [offset] is global top left (x, y) of text line /// [offset] is global top left (x, y) of text line
/// [position] is relative (x) in text line /// [position] is relative (x) in text line
void paint(Canvas canvas, Offset offset, TextPosition position) { void paint(
Canvas canvas, Offset offset, TextPosition position, bool lineHasEmbed) {
// relative (x, y) to global offset // relative (x, y) to global offset
var relativeCaretOffset = editable!.getOffsetForCaret(position, prototype); var relativeCaretOffset = editable!.getOffsetForCaret(position, prototype);
if (relativeCaretOffset == Offset.zero) { if (lineHasEmbed && relativeCaretOffset == Offset.zero) {
relativeCaretOffset = editable!.getOffsetForCaret( relativeCaretOffset = editable!.getOffsetForCaret(
TextPosition( TextPosition(
offset: position.offset - 1, affinity: position.affinity), offset: position.offset - 1, affinity: position.affinity),
@ -257,6 +269,7 @@ class CursorPainter {
relativeCaretOffset = relativeCaretOffset =
Offset(relativeCaretOffset.dx + 6, relativeCaretOffset.dy); Offset(relativeCaretOffset.dx + 6, relativeCaretOffset.dy);
} }
final caretOffset = relativeCaretOffset + offset; final caretOffset = relativeCaretOffset + offset;
var caretRect = prototype.shift(caretOffset); var caretRect = prototype.shift(caretOffset);
if (style.offset != null) { if (style.offset != null) {

@ -1,7 +1,9 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'package:tuple/tuple.dart'; import 'package:tuple/tuple.dart';
import '../../flutter_quill.dart';
import '../../models/documents/style.dart';
class QuillStyles extends InheritedWidget { class QuillStyles extends InheritedWidget {
const QuillStyles({ const QuillStyles({
required this.data, required this.data,
@ -26,6 +28,8 @@ class QuillStyles extends InheritedWidget {
} }
} }
/// Style theme applied to a block of rich text, including single-line
/// paragraphs.
class DefaultTextBlockStyle { class DefaultTextBlockStyle {
DefaultTextBlockStyle( DefaultTextBlockStyle(
this.style, this.style,
@ -34,15 +38,100 @@ class DefaultTextBlockStyle {
this.decoration, this.decoration,
); );
/// Base text style for a text block.
final TextStyle style; final TextStyle style;
/// Vertical spacing around a text block.
final Tuple2<double, double> verticalSpacing; final Tuple2<double, double> verticalSpacing;
/// Vertical spacing for individual lines within a text block.
///
final Tuple2<double, double> lineSpacing; final Tuple2<double, double> lineSpacing;
/// Decoration of a text block.
///
/// Decoration, if present, is painted in the content area, excluding
/// any [spacing].
final BoxDecoration? decoration; final BoxDecoration? decoration;
} }
/// Theme data for inline code.
class InlineCodeStyle {
InlineCodeStyle({
required this.style,
this.header1,
this.header2,
this.header3,
this.backgroundColor,
this.radius,
});
/// Base text style for an inline code.
final TextStyle style;
/// Style override for inline code in header level 1.
final TextStyle? header1;
/// Style override for inline code in headings level 2.
final TextStyle? header2;
/// Style override for inline code in headings level 3.
final TextStyle? header3;
/// Background color for inline code.
final Color? backgroundColor;
/// Radius used when paining the background.
final Radius? radius;
/// Returns effective style to use for inline code for the specified
/// [lineStyle].
TextStyle styleFor(Style lineStyle) {
if (lineStyle.containsKey(Attribute.h1.key)) {
return header1 ?? style;
}
if (lineStyle.containsKey(Attribute.h2.key)) {
return header2 ?? style;
}
if (lineStyle.containsKey(Attribute.h3.key)) {
return header3 ?? style;
}
return style;
}
@override
bool operator ==(Object other) {
if (identical(this, other)) {
return true;
}
if (other is! InlineCodeStyle) {
return false;
}
return other.style == style &&
other.header1 == header1 &&
other.header2 == header2 &&
other.header3 == header3 &&
other.backgroundColor == backgroundColor &&
other.radius == radius;
}
@override
int get hashCode =>
Object.hash(style, header1, header2, header3, backgroundColor, radius);
}
class DefaultListBlockStyle extends DefaultTextBlockStyle {
DefaultListBlockStyle(
TextStyle style,
Tuple2<double, double> verticalSpacing,
Tuple2<double, double> lineSpacing,
BoxDecoration? decoration,
this.checkboxUIBuilder,
) : super(style, verticalSpacing, lineSpacing, decoration);
final QuillCheckboxBuilder? checkboxUIBuilder;
}
class DefaultStyles { class DefaultStyles {
DefaultStyles({ DefaultStyles({
this.h1, this.h1,
@ -51,8 +140,10 @@ class DefaultStyles {
this.paragraph, this.paragraph,
this.bold, this.bold,
this.italic, this.italic,
this.small,
this.underline, this.underline,
this.strikeThrough, this.strikeThrough,
this.inlineCode,
this.link, this.link,
this.color, this.color,
this.placeHolder, this.placeHolder,
@ -73,15 +164,19 @@ class DefaultStyles {
final DefaultTextBlockStyle? paragraph; final DefaultTextBlockStyle? paragraph;
final TextStyle? bold; final TextStyle? bold;
final TextStyle? italic; final TextStyle? italic;
final TextStyle? small;
final TextStyle? underline; final TextStyle? underline;
final TextStyle? strikeThrough; final TextStyle? strikeThrough;
/// Theme of inline code.
final InlineCodeStyle? inlineCode;
final TextStyle? sizeSmall; // 'small' final TextStyle? sizeSmall; // 'small'
final TextStyle? sizeLarge; // 'large' final TextStyle? sizeLarge; // 'large'
final TextStyle? sizeHuge; // 'huge' final TextStyle? sizeHuge; // 'huge'
final TextStyle? link; final TextStyle? link;
final Color? color; final Color? color;
final DefaultTextBlockStyle? placeHolder; final DefaultTextBlockStyle? placeHolder;
final DefaultTextBlockStyle? lists; final DefaultListBlockStyle? lists;
final DefaultTextBlockStyle? quote; final DefaultTextBlockStyle? quote;
final DefaultTextBlockStyle? code; final DefaultTextBlockStyle? code;
final DefaultTextBlockStyle? indent; final DefaultTextBlockStyle? indent;
@ -112,6 +207,12 @@ class DefaultStyles {
throw UnimplementedError(); throw UnimplementedError();
} }
final inlineCodeStyle = TextStyle(
fontSize: 14,
color: themeData.colorScheme.primaryVariant.withOpacity(0.8),
fontFamily: fontFamily,
);
return DefaultStyles( return DefaultStyles(
h1: DefaultTextBlockStyle( h1: DefaultTextBlockStyle(
defaultTextStyle.style.copyWith( defaultTextStyle.style.copyWith(
@ -147,10 +248,25 @@ class DefaultStyles {
baseStyle, const Tuple2(0, 0), const Tuple2(0, 0), null), baseStyle, const Tuple2(0, 0), const Tuple2(0, 0), null),
bold: const TextStyle(fontWeight: FontWeight.bold), bold: const TextStyle(fontWeight: FontWeight.bold),
italic: const TextStyle(fontStyle: FontStyle.italic), italic: const TextStyle(fontStyle: FontStyle.italic),
small: const TextStyle(fontSize: 12, color: Colors.black45),
underline: const TextStyle(decoration: TextDecoration.underline), underline: const TextStyle(decoration: TextDecoration.underline),
strikeThrough: const TextStyle(decoration: TextDecoration.lineThrough), strikeThrough: const TextStyle(decoration: TextDecoration.lineThrough),
inlineCode: InlineCodeStyle(
backgroundColor: Colors.grey.shade100,
radius: const Radius.circular(3),
style: inlineCodeStyle,
header1: inlineCodeStyle.copyWith(
fontSize: 32,
fontWeight: FontWeight.w300,
),
header2: inlineCodeStyle.copyWith(fontSize: 22),
header3: inlineCodeStyle.copyWith(
fontSize: 18,
fontWeight: FontWeight.w500,
),
),
link: TextStyle( link: TextStyle(
color: themeData.accentColor, color: themeData.colorScheme.secondary,
decoration: TextDecoration.underline, decoration: TextDecoration.underline,
), ),
placeHolder: DefaultTextBlockStyle( placeHolder: DefaultTextBlockStyle(
@ -162,8 +278,8 @@ class DefaultStyles {
const Tuple2(0, 0), const Tuple2(0, 0),
const Tuple2(0, 0), const Tuple2(0, 0),
null), null),
lists: DefaultTextBlockStyle( lists: DefaultListBlockStyle(
baseStyle, baseSpacing, const Tuple2(0, 6), null), baseStyle, baseSpacing, const Tuple2(0, 6), null, null),
quote: DefaultTextBlockStyle( quote: DefaultTextBlockStyle(
TextStyle(color: baseStyle.color!.withOpacity(0.6)), TextStyle(color: baseStyle.color!.withOpacity(0.6)),
baseSpacing, baseSpacing,
@ -205,8 +321,10 @@ class DefaultStyles {
paragraph: other.paragraph ?? paragraph, paragraph: other.paragraph ?? paragraph,
bold: other.bold ?? bold, bold: other.bold ?? bold,
italic: other.italic ?? italic, italic: other.italic ?? italic,
small: other.small ?? small,
underline: other.underline ?? underline, underline: other.underline ?? underline,
strikeThrough: other.strikeThrough ?? strikeThrough, strikeThrough: other.strikeThrough ?? strikeThrough,
inlineCode: other.inlineCode ?? inlineCode,
link: other.link ?? link, link: other.link ?? link,
color: other.color ?? color, color: other.color ?? color,
placeHolder: other.placeHolder ?? placeHolder, placeHolder: other.placeHolder ?? placeHolder,

@ -1,11 +1,8 @@
import 'package:flutter/cupertino.dart'; import 'package:flutter/cupertino.dart';
import 'package:flutter/gestures.dart'; import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import '../../flutter_quill.dart';
import '../models/documents/nodes/leaf.dart'; import '../../flutter_quill.dart';
import 'editor.dart';
import 'text_selection.dart'; import 'text_selection.dart';
typedef EmbedBuilder = Widget Function( typedef EmbedBuilder = Widget Function(
@ -21,29 +18,94 @@ abstract class EditorTextSelectionGestureDetectorBuilderDelegate {
bool getSelectionEnabled(); bool getSelectionEnabled();
} }
/// Builds a [EditorTextSelectionGestureDetector] to wrap an [EditableText].
///
/// The class implements sensible defaults for many user interactions
/// with an [EditableText] (see the documentation of the various gesture handler
/// methods, e.g. [onTapDown], [onForcePressStart], etc.). Subclasses of
/// [EditorTextSelectionGestureDetectorBuilder] can change the behavior
/// performed in responds to these gesture events by overriding
/// the corresponding handler methods of this class.
///
/// The resulting [EditorTextSelectionGestureDetector] to wrap an [EditableText]
/// is obtained by calling [buildGestureDetector].
///
/// See also:
///
/// * [TextField], which uses a subclass to implement the Material-specific
/// gesture logic of an [EditableText].
/// * [CupertinoTextField], which uses a subclass to implement the
/// Cupertino-specific gesture logic of an [EditableText].
class EditorTextSelectionGestureDetectorBuilder { class EditorTextSelectionGestureDetectorBuilder {
EditorTextSelectionGestureDetectorBuilder(this.delegate); /// Creates a [EditorTextSelectionGestureDetectorBuilder].
///
/// The [delegate] must not be null.
EditorTextSelectionGestureDetectorBuilder({required this.delegate});
/// The delegate for this [EditorTextSelectionGestureDetectorBuilder].
///
/// The delegate provides the builder with information about what actions can
/// currently be performed on the textfield. Based on this, the builder adds
/// the correct gesture handlers to the gesture detector.
@protected
final EditorTextSelectionGestureDetectorBuilderDelegate delegate; final EditorTextSelectionGestureDetectorBuilderDelegate delegate;
/// Whether to show the selection toolbar.
///
/// It is based on the signal source when a [onTapDown] is called. This getter
/// will return true if current [onTapDown] event is triggered by a touch or
/// a stylus.
bool shouldShowSelectionToolbar = true; bool shouldShowSelectionToolbar = true;
/// The [State] of the [EditableText] for which the builder will provide a
/// [EditorTextSelectionGestureDetector].
@protected
EditorState? getEditor() { EditorState? getEditor() {
return delegate.getEditableTextKey().currentState; return delegate.getEditableTextKey().currentState;
} }
/// The [RenderObject] of the [EditableText] for which the builder will
/// provide a [EditorTextSelectionGestureDetector].
@protected
RenderEditor? getRenderEditor() { RenderEditor? getRenderEditor() {
return getEditor()!.getRenderEditor(); return getEditor()!.getRenderEditor();
} }
/// Handler for [EditorTextSelectionGestureDetector.onTapDown].
///
/// By default, it forwards the tap to [RenderEditable.handleTapDown] and sets
/// [shouldShowSelectionToolbar] to true if the tap was initiated by a finger
/// or stylus.
///
/// See also:
///
/// * [EditorTextSelectionGestureDetector.onTapDown],
/// which triggers this callback.
@protected
void onTapDown(TapDownDetails details) { void onTapDown(TapDownDetails details) {
getRenderEditor()!.handleTapDown(details); getRenderEditor()!.handleTapDown(details);
// The selection overlay should only be shown when the user is interacting
// through a touch screen (via either a finger or a stylus).
// A mouse shouldn't trigger the selection overlay.
// For backwards-compatibility, we treat a null kind the same as touch.
final kind = details.kind; final kind = details.kind;
shouldShowSelectionToolbar = kind == null || shouldShowSelectionToolbar = kind == null ||
kind == PointerDeviceKind.touch || kind == PointerDeviceKind.touch ||
kind == PointerDeviceKind.stylus; kind == PointerDeviceKind.stylus;
} }
/// Handler for [EditorTextSelectionGestureDetector.onForcePressStart].
///
/// By default, it selects the word at the position of the force press,
/// if selection is enabled.
///
/// This callback is only applicable when force press is enabled.
///
/// See also:
///
/// * [EditorTextSelectionGestureDetector.onForcePressStart],
/// which triggers this callback.
@protected
void onForcePressStart(ForcePressDetails details) { void onForcePressStart(ForcePressDetails details) {
assert(delegate.getForcePressEnabled()); assert(delegate.getForcePressEnabled());
shouldShowSelectionToolbar = true; shouldShowSelectionToolbar = true;
@ -56,6 +118,18 @@ class EditorTextSelectionGestureDetectorBuilder {
} }
} }
/// Handler for [EditorTextSelectionGestureDetector.onForcePressEnd].
///
/// By default, it selects words in the range specified in [details] and shows
/// toolbar if it is necessary.
///
/// This callback is only applicable when force press is enabled.
///
/// See also:
///
/// * [EditorTextSelectionGestureDetector.onForcePressEnd],
/// which triggers this callback.
@protected
void onForcePressEnd(ForcePressDetails details) { void onForcePressEnd(ForcePressDetails details) {
assert(delegate.getForcePressEnabled()); assert(delegate.getForcePressEnabled());
getRenderEditor()!.selectWordsInRange( getRenderEditor()!.selectWordsInRange(
@ -68,40 +142,97 @@ class EditorTextSelectionGestureDetectorBuilder {
} }
} }
/// Handler for [EditorTextSelectionGestureDetector.onSingleTapUp].
///
/// By default, it selects word edge if selection is enabled.
///
/// See also:
///
/// * [EditorTextSelectionGestureDetector.onSingleTapUp], which triggers
/// this callback.
@protected
void onSingleTapUp(TapUpDetails details) { void onSingleTapUp(TapUpDetails details) {
if (delegate.getSelectionEnabled()) { if (delegate.getSelectionEnabled()) {
getRenderEditor()!.selectWordEdge(SelectionChangedCause.tap); getRenderEditor()!.selectWordEdge(SelectionChangedCause.tap);
} }
} }
void onSingleTapCancel() {} /// Handler for [EditorTextSelectionGestureDetector.onSingleTapCancel].
///
/// By default, it services as place holder to enable subclass override.
///
/// See also:
///
/// * [EditorTextSelectionGestureDetector.onSingleTapCancel], which triggers
/// this callback.
@protected
void onSingleTapCancel() {
/* Subclass should override this method if needed. */
}
/// Handler for [EditorTextSelectionGestureDetector.onSingleLongTapStart].
///
/// By default, it selects text position specified in [details] if selection
/// is enabled.
///
/// See also:
///
/// * [EditorTextSelectionGestureDetector.onSingleLongTapStart],
/// which triggers this callback.
@protected
void onSingleLongTapStart(LongPressStartDetails details) { void onSingleLongTapStart(LongPressStartDetails details) {
if (delegate.getSelectionEnabled()) { if (delegate.getSelectionEnabled()) {
getRenderEditor()!.selectPositionAt( getRenderEditor()!.selectPositionAt(
details.globalPosition, from: details.globalPosition,
null, cause: SelectionChangedCause.longPress,
SelectionChangedCause.longPress,
); );
} }
} }
/// Handler for [EditorTextSelectionGestureDetector.onSingleLongTapMoveUpdate]
///
/// By default, it updates the selection location specified in [details] if
/// selection is enabled.
///
/// See also:
///
/// * [EditorTextSelectionGestureDetector.onSingleLongTapMoveUpdate], which
/// triggers this callback.
@protected
void onSingleLongTapMoveUpdate(LongPressMoveUpdateDetails details) { void onSingleLongTapMoveUpdate(LongPressMoveUpdateDetails details) {
if (delegate.getSelectionEnabled()) { if (delegate.getSelectionEnabled()) {
getRenderEditor()!.selectPositionAt( getRenderEditor()!.selectPositionAt(
details.globalPosition, from: details.globalPosition,
null, cause: SelectionChangedCause.longPress,
SelectionChangedCause.longPress,
); );
} }
} }
/// Handler for [EditorTextSelectionGestureDetector.onSingleLongTapEnd].
///
/// By default, it shows toolbar if necessary.
///
/// See also:
///
/// * [EditorTextSelectionGestureDetector.onSingleLongTapEnd],
/// which triggers this callback.
@protected
void onSingleLongTapEnd(LongPressEndDetails details) { void onSingleLongTapEnd(LongPressEndDetails details) {
if (shouldShowSelectionToolbar) { if (shouldShowSelectionToolbar) {
getEditor()!.showToolbar(); getEditor()!.showToolbar();
} }
} }
/// Handler for [EditorTextSelectionGestureDetector.onDoubleTapDown].
///
/// By default, it selects a word through [RenderEditable.selectWord] if
/// selectionEnabled and shows toolbar if necessary.
///
/// See also:
///
/// * [EditorTextSelectionGestureDetector.onDoubleTapDown],
/// which triggers this callback.
@protected
void onDoubleTapDown(TapDownDetails details) { void onDoubleTapDown(TapDownDetails details) {
if (delegate.getSelectionEnabled()) { if (delegate.getSelectionEnabled()) {
getRenderEditor()!.selectWord(SelectionChangedCause.tap); getRenderEditor()!.selectWord(SelectionChangedCause.tap);
@ -111,27 +242,56 @@ class EditorTextSelectionGestureDetectorBuilder {
} }
} }
/// Handler for [EditorTextSelectionGestureDetector.onDragSelectionStart].
///
/// By default, it selects a text position specified in [details].
///
/// See also:
///
/// * [EditorTextSelectionGestureDetector.onDragSelectionStart],
/// which triggers this callback.
@protected
void onDragSelectionStart(DragStartDetails details) { void onDragSelectionStart(DragStartDetails details) {
getRenderEditor()!.selectPositionAt( getRenderEditor()!.handleDragStart(details);
details.globalPosition,
null,
SelectionChangedCause.drag,
);
} }
/// Handler for [EditorTextSelectionGestureDetector.onDragSelectionUpdate].
///
/// By default, it updates the selection location specified in the provided
/// details objects.
///
/// See also:
///
/// * [EditorTextSelectionGestureDetector.onDragSelectionUpdate],
/// which triggers this callback./lib/src/material/text_field.dart
@protected
void onDragSelectionUpdate( void onDragSelectionUpdate(
DragStartDetails startDetails, DragUpdateDetails updateDetails) { DragStartDetails startDetails, DragUpdateDetails updateDetails) {
getRenderEditor()!.selectPositionAt( getRenderEditor()!.extendSelection(updateDetails.globalPosition,
startDetails.globalPosition, cause: SelectionChangedCause.drag);
updateDetails.globalPosition,
SelectionChangedCause.drag,
);
} }
void onDragSelectionEnd(DragEndDetails details) {} /// Handler for [EditorTextSelectionGestureDetector.onDragSelectionEnd].
///
/// By default, it services as place holder to enable subclass override.
///
/// See also:
///
/// * [EditorTextSelectionGestureDetector.onDragSelectionEnd],
/// which triggers this callback.
@protected
void onDragSelectionEnd(DragEndDetails details) {
getRenderEditor()!.handleDragEnd(details);
}
Widget build(HitTestBehavior behavior, Widget child) { /// Returns a [EditorTextSelectionGestureDetector] configured with
/// the handlers provided by this builder.
///
/// The [child] or its subtree should contain [EditableText].
Widget build(
{required HitTestBehavior behavior, required Widget child, Key? key}) {
return EditorTextSelectionGestureDetector( return EditorTextSelectionGestureDetector(
key: key,
onTapDown: onTapDown, onTapDown: onTapDown,
onForcePressStart: onForcePressStart:
delegate.getForcePressEnabled() ? onForcePressStart : null, delegate.getForcePressEnabled() ? onForcePressStart : null,

File diff suppressed because it is too large Load Diff

@ -0,0 +1,31 @@
// The corner radius of the floating cursor in pixels.
import 'dart:ui';
import '../../widgets/cursor.dart';
const Radius _kFloatingCaretRadius = Radius.circular(1);
/// Floating painter responsible for painting the floating cursor when
/// floating mode is activated
class FloatingCursorPainter {
FloatingCursorPainter({
required this.floatingCursorRect,
required this.style,
});
CursorStyle style;
Rect? floatingCursorRect;
final Paint floatingCursorPaint = Paint();
void paint(Canvas canvas) {
final floatingCursorRect = this.floatingCursorRect;
final floatingCursorColor = style.color.withOpacity(0.75);
if (floatingCursorRect == null) return;
canvas.drawRRect(
RRect.fromRectAndRadius(floatingCursorRect, _kFloatingCaretRadius),
floatingCursorPaint..color = floatingCursorColor,
);
}
}

@ -1,6 +1,4 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:photo_view/photo_view.dart'; import 'package:photo_view/photo_view.dart';
class ImageTapWrapper extends StatelessWidget { class ImageTapWrapper extends StatelessWidget {
@ -17,13 +15,52 @@ class ImageTapWrapper extends StatelessWidget {
constraints: BoxConstraints.expand( constraints: BoxConstraints.expand(
height: MediaQuery.of(context).size.height, height: MediaQuery.of(context).size.height,
), ),
child: GestureDetector( child: Stack(
onTapDown: (_) { children: [
PhotoView(
imageProvider: imageProvider,
loadingBuilder: (context, event) {
return Container(
color: Colors.black,
child: const Center(
child: CircularProgressIndicator(),
),
);
},
),
Positioned(
right: 10,
top: MediaQuery.of(context).padding.top + 10.0,
child: InkWell(
onTap: () {
Navigator.pop(context); Navigator.pop(context);
}, },
child: PhotoView( child: Stack(
imageProvider: imageProvider, children: [
Opacity(
opacity: 0.2,
child: Container(
height: 30,
width: 30,
decoration: const BoxDecoration(
shape: BoxShape.circle,
color: Colors.black87,
),
),
),
Positioned(
top: 0,
bottom: 0,
left: 0,
right: 0,
child:
Icon(Icons.close, color: Colors.grey[400], size: 28),
)
],
),
),
), ),
],
), ),
), ),
); );

@ -1,106 +1,89 @@
import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
class QuillPressedKeys extends ChangeNotifier {
enum InputShortcut { CUT, COPY, PASTE, SELECT_ALL } static QuillPressedKeys of(BuildContext context) {
final widget =
typedef CursorMoveCallback = void Function( context.dependOnInheritedWidgetOfExactType<_QuillPressedKeysAccess>();
LogicalKeyboardKey key, bool wordModifier, bool lineModifier, bool shift); return widget!.pressedKeys;
typedef InputShortcutCallback = void Function(InputShortcut? shortcut);
typedef OnDeleteCallback = void Function(bool forward);
class KeyboardListener {
KeyboardListener(this.onCursorMove, this.onShortcut, this.onDelete);
final CursorMoveCallback onCursorMove;
final InputShortcutCallback onShortcut;
final OnDeleteCallback onDelete;
static final Set<LogicalKeyboardKey> _moveKeys = <LogicalKeyboardKey>{
LogicalKeyboardKey.arrowRight,
LogicalKeyboardKey.arrowLeft,
LogicalKeyboardKey.arrowUp,
LogicalKeyboardKey.arrowDown,
};
static final Set<LogicalKeyboardKey> _shortcutKeys = <LogicalKeyboardKey>{
LogicalKeyboardKey.keyA,
LogicalKeyboardKey.keyC,
LogicalKeyboardKey.keyV,
LogicalKeyboardKey.keyX,
LogicalKeyboardKey.delete,
LogicalKeyboardKey.backspace,
};
static final Set<LogicalKeyboardKey> _nonModifierKeys = <LogicalKeyboardKey>{
..._shortcutKeys,
..._moveKeys,
};
static final Set<LogicalKeyboardKey> _modifierKeys = <LogicalKeyboardKey>{
LogicalKeyboardKey.shift,
LogicalKeyboardKey.control,
LogicalKeyboardKey.alt,
};
static final Set<LogicalKeyboardKey> _macOsModifierKeys =
<LogicalKeyboardKey>{
LogicalKeyboardKey.shift,
LogicalKeyboardKey.meta,
LogicalKeyboardKey.alt,
};
static final Set<LogicalKeyboardKey> _interestingKeys = <LogicalKeyboardKey>{
..._modifierKeys,
..._macOsModifierKeys,
..._nonModifierKeys,
};
static final Map<LogicalKeyboardKey, InputShortcut> _keyToShortcut = {
LogicalKeyboardKey.keyX: InputShortcut.CUT,
LogicalKeyboardKey.keyC: InputShortcut.COPY,
LogicalKeyboardKey.keyV: InputShortcut.PASTE,
LogicalKeyboardKey.keyA: InputShortcut.SELECT_ALL,
};
KeyEventResult handleRawKeyEvent(RawKeyEvent event) {
if (kIsWeb) {
// On web platform, we ignore the key because it's already processed.
return KeyEventResult.ignored;
} }
if (event is! RawKeyDownEvent) { bool _metaPressed = false;
return KeyEventResult.ignored; bool _controlPressed = false;
/// Whether meta key is currently pressed.
bool get metaPressed => _metaPressed;
/// Whether control key is currently pressed.
bool get controlPressed => _controlPressed;
void _updatePressedKeys(Set<LogicalKeyboardKey> pressedKeys) {
final meta = pressedKeys.contains(LogicalKeyboardKey.metaLeft) ||
pressedKeys.contains(LogicalKeyboardKey.metaRight);
final control = pressedKeys.contains(LogicalKeyboardKey.controlLeft) ||
pressedKeys.contains(LogicalKeyboardKey.controlRight);
if (_metaPressed != meta || _controlPressed != control) {
_metaPressed = meta;
_controlPressed = control;
notifyListeners();
}
} }
}
class QuillKeyboardListener extends StatefulWidget {
const QuillKeyboardListener({required this.child, Key? key})
: super(key: key);
final Widget child;
final keysPressed = @override
LogicalKeyboardKey.collapseSynonyms(RawKeyboard.instance.keysPressed); QuillKeyboardListenerState createState() => QuillKeyboardListenerState();
final key = event.logicalKey;
final isMacOS = event.data is RawKeyEventDataMacOs;
if (!_nonModifierKeys.contains(key) ||
keysPressed
.difference(isMacOS ? _macOsModifierKeys : _modifierKeys)
.length >
1 ||
keysPressed.difference(_interestingKeys).isNotEmpty) {
return KeyEventResult.ignored;
} }
if (_moveKeys.contains(key)) { class QuillKeyboardListenerState extends State<QuillKeyboardListener> {
onCursorMove( final QuillPressedKeys _pressedKeys = QuillPressedKeys();
key,
isMacOS ? event.isAltPressed : event.isControlPressed, bool _keyEvent(KeyEvent event) {
isMacOS ? event.isMetaPressed : event.isAltPressed, _pressedKeys
event.isShiftPressed); ._updatePressedKeys(HardwareKeyboard.instance.logicalKeysPressed);
} else if (isMacOS return false;
? event.isMetaPressed
: event.isControlPressed && _shortcutKeys.contains(key)) {
onShortcut(_keyToShortcut[key]);
} else if (key == LogicalKeyboardKey.delete) {
onDelete(true);
} else if (key == LogicalKeyboardKey.backspace) {
onDelete(false);
} }
return KeyEventResult.ignored;
@override
void initState() {
super.initState();
HardwareKeyboard.instance.addHandler(_keyEvent);
_pressedKeys
._updatePressedKeys(HardwareKeyboard.instance.logicalKeysPressed);
}
@override
void dispose() {
HardwareKeyboard.instance.removeHandler(_keyEvent);
_pressedKeys.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return _QuillPressedKeysAccess(
pressedKeys: _pressedKeys,
child: widget.child,
);
}
}
class _QuillPressedKeysAccess extends InheritedWidget {
const _QuillPressedKeysAccess({
required this.pressedKeys,
required Widget child,
Key? key,
}) : super(key: key, child: child);
final QuillPressedKeys pressedKeys;
@override
bool updateShouldNotify(covariant _QuillPressedKeysAccess oldWidget) {
return oldWidget.pressedKeys != pressedKeys;
} }
} }

@ -0,0 +1,170 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import '../../models/documents/nodes/node.dart';
/// List of possible actions returned from [LinkActionPickerDelegate].
enum LinkMenuAction {
/// Launch the link
launch,
/// Copy to clipboard
copy,
/// Remove link style attribute
remove,
/// No-op
none,
}
/// Used internally by widget layer.
typedef LinkActionPicker = Future<LinkMenuAction> Function(Node linkNode);
typedef LinkActionPickerDelegate = Future<LinkMenuAction> Function(
BuildContext context, String link);
Future<LinkMenuAction> defaultLinkActionPickerDelegate(
BuildContext context, String link) async {
switch (defaultTargetPlatform) {
case TargetPlatform.iOS:
return _showCupertinoLinkMenu(context, link);
case TargetPlatform.android:
return _showMaterialMenu(context, link);
default:
assert(
false,
'defaultShowLinkActionsMenu not supposed to '
'be invoked for $defaultTargetPlatform');
return LinkMenuAction.none;
}
}
Future<LinkMenuAction> _showCupertinoLinkMenu(
BuildContext context, String link) async {
final result = await showCupertinoModalPopup<LinkMenuAction>(
context: context,
builder: (ctx) {
return CupertinoActionSheet(
title: Text(link),
actions: [
_CupertinoAction(
title: 'Open',
icon: Icons.language_sharp,
onPressed: () => Navigator.of(context).pop(LinkMenuAction.launch),
),
_CupertinoAction(
title: 'Copy',
icon: Icons.copy_sharp,
onPressed: () => Navigator.of(context).pop(LinkMenuAction.copy),
),
_CupertinoAction(
title: 'Remove',
icon: Icons.link_off_sharp,
onPressed: () => Navigator.of(context).pop(LinkMenuAction.remove),
),
],
);
},
);
return result ?? LinkMenuAction.none;
}
class _CupertinoAction extends StatelessWidget {
const _CupertinoAction({
required this.title,
required this.icon,
required this.onPressed,
Key? key,
}) : super(key: key);
final String title;
final IconData icon;
final VoidCallback onPressed;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return CupertinoActionSheetAction(
onPressed: onPressed,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Row(
children: [
Expanded(
child: Text(
title,
textAlign: TextAlign.start,
style: TextStyle(color: theme.colorScheme.onSurface),
),
),
Icon(
icon,
size: theme.iconTheme.size,
color: theme.colorScheme.onSurface.withOpacity(0.75),
)
],
),
),
);
}
}
Future<LinkMenuAction> _showMaterialMenu(
BuildContext context, String link) async {
final result = await showModalBottomSheet<LinkMenuAction>(
context: context,
builder: (ctx) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
_MaterialAction(
title: 'Open',
icon: Icons.language_sharp,
onPressed: () => Navigator.of(context).pop(LinkMenuAction.launch),
),
_MaterialAction(
title: 'Copy',
icon: Icons.copy_sharp,
onPressed: () => Navigator.of(context).pop(LinkMenuAction.copy),
),
_MaterialAction(
title: 'Remove',
icon: Icons.link_off_sharp,
onPressed: () => Navigator.of(context).pop(LinkMenuAction.remove),
),
],
);
},
);
return result ?? LinkMenuAction.none;
}
class _MaterialAction extends StatelessWidget {
const _MaterialAction({
required this.title,
required this.icon,
required this.onPressed,
Key? key,
}) : super(key: key);
final String title;
final IconData icon;
final VoidCallback onPressed;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return ListTile(
leading: Icon(
icon,
size: theme.iconTheme.size,
color: theme.colorScheme.onSurface.withOpacity(0.75),
),
title: Text(title),
onTap: onPressed,
);
}
}

@ -1,7 +1,12 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../models/themes/quill_dialog_theme.dart';
import '../translations/toolbar.i18n.dart';
class LinkDialog extends StatefulWidget { class LinkDialog extends StatefulWidget {
const LinkDialog({Key? key}) : super(key: key); const LinkDialog({this.dialogTheme, Key? key}) : super(key: key);
final QuillDialogTheme? dialogTheme;
@override @override
LinkDialogState createState() => LinkDialogState(); LinkDialogState createState() => LinkDialogState();
@ -13,15 +18,23 @@ class LinkDialogState extends State<LinkDialog> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return AlertDialog( return AlertDialog(
backgroundColor: widget.dialogTheme?.dialogBackgroundColor,
content: TextField( content: TextField(
decoration: const InputDecoration(labelText: 'Paste a link'), style: widget.dialogTheme?.inputTextStyle,
decoration: InputDecoration(
labelText: 'Paste a link'.i18n,
labelStyle: widget.dialogTheme?.labelTextStyle,
floatingLabelStyle: widget.dialogTheme?.labelTextStyle),
autofocus: true, autofocus: true,
onChanged: _linkChanged, onChanged: _linkChanged,
), ),
actions: [ actions: [
TextButton( TextButton(
onPressed: _link.isNotEmpty ? _applyLink : null, onPressed: _link.isNotEmpty ? _applyLink : null,
child: const Text('Ok'), child: Text(
'Ok'.i18n,
style: widget.dialogTheme?.labelTextStyle,
),
), ),
], ],
); );

@ -1,3 +1,5 @@
import 'dart:ui';
import 'package:flutter/rendering.dart'; import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
@ -127,17 +129,19 @@ class RenderEmbedProxy extends RenderProxyBox implements RenderContentProxyBox {
} }
class RichTextProxy extends SingleChildRenderObjectWidget { class RichTextProxy extends SingleChildRenderObjectWidget {
/// Child argument should be an instance of RichText widget.
const RichTextProxy( const RichTextProxy(
RichText child, {required RichText child,
this.textStyle, required this.textStyle,
this.textAlign, required this.textAlign,
this.textDirection, required this.textDirection,
this.textScaleFactor, required this.locale,
this.locale, required this.strutStyle,
this.strutStyle, this.textScaleFactor = 1.0,
this.textWidthBasis, this.textWidthBasis = TextWidthBasis.parent,
this.textHeightBehavior, this.textHeightBehavior,
) : super(child: child); Key? key})
: super(key: key, child: child);
final TextStyle textStyle; final TextStyle textStyle;
final TextAlign textAlign; final TextAlign textAlign;
@ -291,8 +295,8 @@ class RenderParagraphProxy extends RenderProxyBox
child!.getWordBoundary(position); child!.getWordBoundary(position);
@override @override
List<TextBox> getBoxesForSelection(TextSelection selection) => List<TextBox> getBoxesForSelection(TextSelection selection) => child!
child!.getBoxesForSelection(selection); .getBoxesForSelection(selection, boxHeightStyle: BoxHeightStyle.strut);
@override @override
void performLayout() { void performLayout() {

@ -0,0 +1,354 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart';
/// Very similar to [SingleChildView] but with a [ViewportBuilder] argument
/// instead of a [Widget]
///
/// Useful when child needs [ViewportOffset] (e.g. [RenderEditor])
/// see: [SingleChildScrollView]
class QuillSingleChildScrollView extends StatelessWidget {
/// Creates a box in which a single widget can be scrolled.
const QuillSingleChildScrollView({
required this.controller,
required this.viewportBuilder,
Key? key,
this.physics,
this.restorationId,
}) : super(key: key);
/// An object that can be used to control the position to which this scroll
/// view is scrolled.
///
/// Must be null if [primary] is true.
///
/// A [ScrollController] serves several purposes. It can be used to control
/// the initial scroll position (see [ScrollController.initialScrollOffset]).
/// It can be used to control whether the scroll view should automatically
/// save and restore its scroll position in the [PageStorage] (see
/// [ScrollController.keepScrollOffset]). It can be used to read the current
/// scroll position (see [ScrollController.offset]), or change it (see
/// [ScrollController.animateTo]).
final ScrollController controller;
/// How the scroll view should respond to user input.
///
/// For example, determines how the scroll view continues to animate after the
/// user stops dragging the scroll view.
///
/// Defaults to matching platform conventions.
final ScrollPhysics? physics;
/// {@macro flutter.widgets.scrollable.restorationId}
final String? restorationId;
final ViewportBuilder viewportBuilder;
AxisDirection _getDirection(BuildContext context) {
return getAxisDirectionFromAxisReverseAndDirectionality(
context, Axis.vertical, false);
}
@override
Widget build(BuildContext context) {
final axisDirection = _getDirection(context);
final scrollController = controller;
final scrollable = Scrollable(
axisDirection: axisDirection,
controller: scrollController,
physics: physics,
restorationId: restorationId,
viewportBuilder: (context, offset) {
return _SingleChildViewport(
offset: offset,
child: viewportBuilder(context, offset),
);
},
);
return scrollable;
}
}
class _SingleChildViewport extends SingleChildRenderObjectWidget {
const _SingleChildViewport({
required this.offset,
Key? key,
Widget? child,
}) : super(key: key, child: child);
final ViewportOffset offset;
@override
_RenderSingleChildViewport createRenderObject(BuildContext context) {
return _RenderSingleChildViewport(
offset: offset,
);
}
@override
void updateRenderObject(
BuildContext context, _RenderSingleChildViewport renderObject) {
// Order dependency: The offset setter reads the axis direction.
renderObject.offset = offset;
}
}
class _RenderSingleChildViewport extends RenderBox
with RenderObjectWithChildMixin<RenderBox>
implements RenderAbstractViewport {
_RenderSingleChildViewport({
required ViewportOffset offset,
double cacheExtent = RenderAbstractViewport.defaultCacheExtent,
RenderBox? child,
}) : _offset = offset,
_cacheExtent = cacheExtent {
this.child = child;
}
ViewportOffset get offset => _offset;
ViewportOffset _offset;
set offset(ViewportOffset value) {
if (value == _offset) return;
if (attached) _offset.removeListener(_hasScrolled);
_offset = value;
if (attached) _offset.addListener(_hasScrolled);
markNeedsLayout();
}
/// {@macro flutter.rendering.RenderViewportBase.cacheExtent}
double get cacheExtent => _cacheExtent;
double _cacheExtent;
set cacheExtent(double value) {
if (value == _cacheExtent) return;
_cacheExtent = value;
markNeedsLayout();
}
void _hasScrolled() {
markNeedsPaint();
markNeedsSemanticsUpdate();
}
@override
void setupParentData(RenderObject child) {
// We don't actually use the offset argument in BoxParentData, so let's
// avoid allocating it at all.
if (child.parentData is! ParentData) child.parentData = ParentData();
}
@override
bool get isRepaintBoundary => true;
double get _viewportExtent {
assert(hasSize);
return size.height;
}
double get _minScrollExtent {
assert(hasSize);
return 0;
}
double get _maxScrollExtent {
assert(hasSize);
if (child == null) return 0;
return math.max(0, child!.size.height - size.height);
}
BoxConstraints _getInnerConstraints(BoxConstraints constraints) {
return constraints.widthConstraints();
}
@override
double computeMinIntrinsicWidth(double height) {
if (child != null) return child!.getMinIntrinsicWidth(height);
return 0;
}
@override
double computeMaxIntrinsicWidth(double height) {
if (child != null) return child!.getMaxIntrinsicWidth(height);
return 0;
}
@override
double computeMinIntrinsicHeight(double width) {
if (child != null) return child!.getMinIntrinsicHeight(width);
return 0;
}
@override
double computeMaxIntrinsicHeight(double width) {
if (child != null) return child!.getMaxIntrinsicHeight(width);
return 0;
}
// We don't override computeDistanceToActualBaseline(), because we
// want the default behavior (returning null). Otherwise, as you
// scroll, it would shift in its parent if the parent was baseline-aligned,
// which makes no sense.
@override
Size computeDryLayout(BoxConstraints constraints) {
if (child == null) {
return constraints.smallest;
}
final childSize = child!.getDryLayout(_getInnerConstraints(constraints));
return constraints.constrain(childSize);
}
@override
void performLayout() {
final constraints = this.constraints;
if (child == null) {
size = constraints.smallest;
} else {
child!.layout(_getInnerConstraints(constraints), parentUsesSize: true);
size = constraints.constrain(child!.size);
}
offset
..applyViewportDimension(_viewportExtent)
..applyContentDimensions(_minScrollExtent, _maxScrollExtent);
}
Offset get _paintOffset => _paintOffsetForPosition(offset.pixels);
Offset _paintOffsetForPosition(double position) {
return Offset(0, -position);
}
bool _shouldClipAtPaintOffset(Offset paintOffset) {
assert(child != null);
return paintOffset.dx < 0 ||
paintOffset.dy < 0 ||
paintOffset.dx + child!.size.width > size.width ||
paintOffset.dy + child!.size.height > size.height;
}
@override
void paint(PaintingContext context, Offset offset) {
if (child != null) {
final paintOffset = _paintOffset;
void paintContents(PaintingContext context, Offset offset) {
context.paintChild(child!, offset + paintOffset);
}
if (_shouldClipAtPaintOffset(paintOffset)) {
_clipRectLayer.layer = context.pushClipRect(
needsCompositing,
offset,
Offset.zero & size,
paintContents,
oldLayer: _clipRectLayer.layer,
);
} else {
_clipRectLayer.layer = null;
paintContents(context, offset);
}
}
}
final _clipRectLayer = LayerHandle<ClipRectLayer>();
@override
void applyPaintTransform(RenderBox child, Matrix4 transform) {
final paintOffset = _paintOffset;
transform.translate(paintOffset.dx, paintOffset.dy);
}
@override
Rect? describeApproximatePaintClip(RenderObject? child) {
if (child != null && _shouldClipAtPaintOffset(_paintOffset)) {
return Offset.zero & size;
}
return null;
}
@override
bool hitTestChildren(BoxHitTestResult result, {required Offset position}) {
if (child != null) {
return result.addWithPaintOffset(
offset: _paintOffset,
position: position,
hitTest: (result, transformed) {
assert(transformed == position + -_paintOffset);
return child!.hitTest(result, position: transformed);
},
);
}
return false;
}
@override
RevealedOffset getOffsetToReveal(RenderObject target, double alignment,
{Rect? rect}) {
rect ??= target.paintBounds;
if (target is! RenderBox) {
return RevealedOffset(offset: offset.pixels, rect: rect);
}
final targetBox = target;
final transform = targetBox.getTransformTo(child);
final bounds = MatrixUtils.transformRect(transform, rect);
final double leadingScrollOffset;
final double targetMainAxisExtent;
final double mainAxisExtent;
mainAxisExtent = size.height;
leadingScrollOffset = bounds.top;
targetMainAxisExtent = bounds.height;
final targetOffset = leadingScrollOffset -
(mainAxisExtent - targetMainAxisExtent) * alignment;
final targetRect = bounds.shift(_paintOffsetForPosition(targetOffset));
return RevealedOffset(offset: targetOffset, rect: targetRect);
}
@override
void showOnScreen({
RenderObject? descendant,
Rect? rect,
Duration duration = Duration.zero,
Curve curve = Curves.ease,
}) {
if (!offset.allowImplicitScrolling) {
return super.showOnScreen(
descendant: descendant,
rect: rect,
duration: duration,
curve: curve,
);
}
final newRect = RenderViewportBase.showInViewport(
descendant: descendant,
viewport: this,
offset: offset,
rect: rect,
duration: duration,
curve: curve,
);
super.showOnScreen(
rect: newRect,
duration: duration,
curve: curve,
);
}
@override
Rect describeSemanticsClip(RenderObject child) {
return Rect.fromLTRB(
semanticBounds.left,
semanticBounds.top - cacheExtent,
semanticBounds.right,
semanticBounds.bottom + cacheExtent,
);
}
}

@ -1,9 +1,9 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:math' as math;
import 'package:flutter/cupertino.dart'; import 'package:flutter/cupertino.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart'; import 'package:flutter/rendering.dart';
import 'package:flutter/scheduler.dart'; import 'package:flutter/scheduler.dart';
@ -11,6 +11,7 @@ import 'package:flutter/services.dart';
import 'package:flutter_keyboard_visibility/flutter_keyboard_visibility.dart'; import 'package:flutter_keyboard_visibility/flutter_keyboard_visibility.dart';
import 'package:tuple/tuple.dart'; import 'package:tuple/tuple.dart';
import '../../models/documents/nodes/node.dart';
import '../models/documents/attribute.dart'; import '../models/documents/attribute.dart';
import '../models/documents/document.dart'; import '../models/documents/document.dart';
import '../models/documents/nodes/block.dart'; import '../models/documents/nodes/block.dart';
@ -21,8 +22,9 @@ import 'default_styles.dart';
import 'delegate.dart'; import 'delegate.dart';
import 'editor.dart'; import 'editor.dart';
import 'keyboard_listener.dart'; import 'keyboard_listener.dart';
import 'link.dart';
import 'proxy.dart'; import 'proxy.dart';
import 'raw_editor/raw_editor_state_keyboard_mixin.dart'; import 'quill_single_child_scroll_view.dart';
import 'raw_editor/raw_editor_state_selection_delegate_mixin.dart'; import 'raw_editor/raw_editor_state_selection_delegate_mixin.dart';
import 'raw_editor/raw_editor_state_text_input_client_mixin.dart'; import 'raw_editor/raw_editor_state_text_input_client_mixin.dart';
import 'text_block.dart'; import 'text_block.dart';
@ -31,66 +33,191 @@ import 'text_selection.dart';
class RawEditor extends StatefulWidget { class RawEditor extends StatefulWidget {
const RawEditor( const RawEditor(
Key key, {required this.controller,
this.controller, required this.focusNode,
this.focusNode, required this.scrollController,
this.scrollController, required this.scrollBottomInset,
this.scrollable, required this.cursorStyle,
this.scrollBottomInset, required this.selectionColor,
this.padding, required this.selectionCtrls,
this.readOnly, Key? key,
this.scrollable = true,
this.padding = EdgeInsets.zero,
this.readOnly = false,
this.placeholder, this.placeholder,
this.onLaunchUrl, this.onLaunchUrl,
this.toolbarOptions, this.toolbarOptions = const ToolbarOptions(
this.showSelectionHandles, copy: true,
cut: true,
paste: true,
selectAll: true,
),
this.showSelectionHandles = false,
bool? showCursor, bool? showCursor,
this.cursorStyle, this.textCapitalization = TextCapitalization.none,
this.textCapitalization,
this.maxHeight, this.maxHeight,
this.minHeight, this.minHeight,
this.maxContentWidth,
this.customStyles, this.customStyles,
this.expands, this.expands = false,
this.autoFocus, this.autoFocus = false,
this.selectionColor, this.keyboardAppearance = Brightness.light,
this.selectionCtrls, this.enableInteractiveSelection = true,
this.keyboardAppearance,
this.enableInteractiveSelection,
this.scrollPhysics, this.scrollPhysics,
this.embedBuilder, this.embedBuilder = defaultEmbedBuilder,
this.linkActionPickerDelegate = defaultLinkActionPickerDelegate,
this.customStyleBuilder, this.customStyleBuilder,
) : assert(maxHeight == null || maxHeight > 0, 'maxHeight cannot be null'), this.floatingCursorDisabled = false})
: assert(maxHeight == null || maxHeight > 0, 'maxHeight cannot be null'),
assert(minHeight == null || minHeight >= 0, 'minHeight cannot be null'), assert(minHeight == null || minHeight >= 0, 'minHeight cannot be null'),
assert(maxHeight == null || minHeight == null || maxHeight >= minHeight, assert(maxHeight == null || minHeight == null || maxHeight >= minHeight,
'maxHeight cannot be null'), 'maxHeight cannot be null'),
showCursor = showCursor ?? true, showCursor = showCursor ?? true,
super(key: key); super(key: key);
/// Controls the document being edited.
final QuillController controller; final QuillController controller;
/// Controls whether this editor has keyboard focus.
final FocusNode focusNode; final FocusNode focusNode;
final ScrollController scrollController; final ScrollController scrollController;
final bool scrollable; final bool scrollable;
final double scrollBottomInset; final double scrollBottomInset;
/// Additional space around the editor contents.
final EdgeInsetsGeometry padding; final EdgeInsetsGeometry padding;
/// Whether the text can be changed.
///
/// When this is set to true, the text cannot be modified
/// by any shortcut or keyboard operation. The text is still selectable.
///
/// Defaults to false. Must not be null.
final bool readOnly; final bool readOnly;
final String? placeholder; final String? placeholder;
/// Callback which is triggered when the user wants to open a URL from
/// a link in the document.
final ValueChanged<String>? onLaunchUrl; final ValueChanged<String>? onLaunchUrl;
/// Configuration of toolbar options.
///
/// By default, all options are enabled. If [readOnly] is true,
/// paste and cut will be disabled regardless.
final ToolbarOptions toolbarOptions; final ToolbarOptions toolbarOptions;
/// Whether to show selection handles.
///
/// When a selection is active, there will be two handles at each side of
/// boundary, or one handle if the selection is collapsed. The handles can be
/// dragged to adjust the selection.
///
/// See also:
///
/// * [showCursor], which controls the visibility of the cursor.
final bool showSelectionHandles; final bool showSelectionHandles;
/// Whether to show cursor.
///
/// The cursor refers to the blinking caret when the editor is focused.
///
/// See also:
///
/// * [cursorStyle], which controls the cursor visual representation.
/// * [showSelectionHandles], which controls the visibility of the selection
/// handles.
final bool showCursor; final bool showCursor;
/// The style to be used for the editing cursor.
final CursorStyle cursorStyle; final CursorStyle cursorStyle;
/// Configures how the platform keyboard will select an uppercase or
/// lowercase keyboard.
///
/// Only supports text keyboards, other keyboard types will ignore this
/// configuration. Capitalization is locale-aware.
///
/// Defaults to [TextCapitalization.none]. Must not be null.
///
/// See also:
///
/// * [TextCapitalization], for a description of each capitalization behavior
final TextCapitalization textCapitalization; final TextCapitalization textCapitalization;
/// The maximum height this editor can have.
///
/// If this is null then there is no limit to the editor's height and it will
/// expand to fill its parent.
final double? maxHeight; final double? maxHeight;
/// The minimum height this editor can have.
final double? minHeight; final double? minHeight;
/// The maximum width to be occupied by the content of this editor.
///
/// If this is not null and and this editor's width is larger than this value
/// then the contents will be constrained to the provided maximum width and
/// horizontally centered. This is mostly useful on devices with wide screens.
final double? maxContentWidth;
final DefaultStyles? customStyles; final DefaultStyles? customStyles;
/// Whether this widget's height will be sized to fill its parent.
///
/// If set to true and wrapped in a parent widget like [Expanded] or
///
/// Defaults to false.
final bool expands; final bool expands;
/// Whether this editor should focus itself if nothing else is already
/// focused.
///
/// If true, the keyboard will open as soon as this text field obtains focus.
/// Otherwise, the keyboard is only shown after the user taps the text field.
///
/// Defaults to false. Cannot be null.
final bool autoFocus; final bool autoFocus;
/// The color to use when painting the selection.
final Color selectionColor; final Color selectionColor;
/// Delegate for building the text selection handles and toolbar.
///
/// The [RawEditor] widget used on its own will not trigger the display
/// of the selection toolbar by itself. The toolbar is shown by calling
/// [RawEditorState.showToolbar] in response to an appropriate user event.
final TextSelectionControls selectionCtrls; final TextSelectionControls selectionCtrls;
/// The appearance of the keyboard.
///
/// This setting is only honored on iOS devices.
///
/// Defaults to [Brightness.light].
final Brightness keyboardAppearance; final Brightness keyboardAppearance;
/// If true, then long-pressing this TextField will select text and show the
/// cut/copy/paste menu, and tapping will move the text caret.
///
/// True by default.
///
/// If false, most of the accessibility support for selecting text, copy
/// and paste, and moving the caret will be disabled.
final bool enableInteractiveSelection; final bool enableInteractiveSelection;
/// The [ScrollPhysics] to use when vertically scrolling the input.
///
/// If not specified, it will behave according to the current platform.
///
/// See [Scrollable.physics].
final ScrollPhysics? scrollPhysics; final ScrollPhysics? scrollPhysics;
/// Builder function for embeddable objects.
final EmbedBuilder embedBuilder; final EmbedBuilder embedBuilder;
final LinkActionPickerDelegate linkActionPickerDelegate;
final CustomStyleBuilder? customStyleBuilder; final CustomStyleBuilder? customStyleBuilder;
final bool floatingCursorDisabled;
@override @override
State<StatefulWidget> createState() => RawEditorState(); State<StatefulWidget> createState() => RawEditorState();
} }
@ -100,13 +227,11 @@ class RawEditorState extends EditorState
AutomaticKeepAliveClientMixin<RawEditor>, AutomaticKeepAliveClientMixin<RawEditor>,
WidgetsBindingObserver, WidgetsBindingObserver,
TickerProviderStateMixin<RawEditor>, TickerProviderStateMixin<RawEditor>,
RawEditorStateKeyboardMixin, TextEditingActionTarget,
RawEditorStateTextInputClientMixin, RawEditorStateTextInputClientMixin,
RawEditorStateSelectionDelegateMixin { RawEditorStateSelectionDelegateMixin {
final GlobalKey _editorKey = GlobalKey(); final GlobalKey _editorKey = GlobalKey();
// Keyboard
late KeyboardListener _keyboardListener;
KeyboardVisibilityController? _keyboardVisibilityController; KeyboardVisibilityController? _keyboardVisibilityController;
StreamSubscription<bool>? _keyboardVisibilitySubscription; StreamSubscription<bool>? _keyboardVisibilitySubscription;
bool _keyboardVisible = false; bool _keyboardVisible = false;
@ -120,6 +245,7 @@ class RawEditorState extends EditorState
ScrollController get scrollController => _scrollController; ScrollController get scrollController => _scrollController;
late ScrollController _scrollController; late ScrollController _scrollController;
// Cursors
late CursorCont _cursorCont; late CursorCont _cursorCont;
// Focus // Focus
@ -127,6 +253,7 @@ class RawEditorState extends EditorState
FocusAttachment? _focusAttachment; FocusAttachment? _focusAttachment;
bool get _hasFocus => widget.focusNode.hasFocus; bool get _hasFocus => widget.focusNode.hasFocus;
// Theme
DefaultStyles? _styles; DefaultStyles? _styles;
final ClipboardStatusNotifier _clipboardStatus = ClipboardStatusNotifier(); final ClipboardStatusNotifier _clipboardStatus = ClipboardStatusNotifier();
@ -156,12 +283,15 @@ class RawEditorState extends EditorState
document: _doc, document: _doc,
selection: widget.controller.selection, selection: widget.controller.selection,
hasFocus: _hasFocus, hasFocus: _hasFocus,
cursorController: _cursorCont,
textDirection: _textDirection, textDirection: _textDirection,
startHandleLayerLink: _startHandleLayerLink, startHandleLayerLink: _startHandleLayerLink,
endHandleLayerLink: _endHandleLayerLink, endHandleLayerLink: _endHandleLayerLink,
onSelectionChanged: _handleSelectionChanged, onSelectionChanged: _handleSelectionChanged,
scrollBottomInset: widget.scrollBottomInset, scrollBottomInset: widget.scrollBottomInset,
padding: widget.padding, padding: widget.padding,
maxContentWidth: widget.maxContentWidth,
floatingCursorDisabled: widget.floatingCursorDisabled,
children: _buildChildren(_doc, context), children: _buildChildren(_doc, context),
), ),
), ),
@ -173,10 +303,29 @@ class RawEditorState extends EditorState
child = BaselineProxy( child = BaselineProxy(
textStyle: _styles!.paragraph!.style, textStyle: _styles!.paragraph!.style,
padding: baselinePadding, padding: baselinePadding,
child: SingleChildScrollView( child: QuillSingleChildScrollView(
controller: _scrollController, controller: _scrollController,
physics: widget.scrollPhysics, physics: widget.scrollPhysics,
child: child, viewportBuilder: (_, offset) => CompositedTransformTarget(
link: _toolbarLayerLink,
child: _Editor(
key: _editorKey,
offset: offset,
document: widget.controller.document,
selection: widget.controller.selection,
hasFocus: _hasFocus,
textDirection: _textDirection,
startHandleLayerLink: _startHandleLayerLink,
endHandleLayerLink: _endHandleLayerLink,
onSelectionChanged: _handleSelectionChanged,
scrollBottomInset: widget.scrollBottomInset,
padding: widget.padding,
maxContentWidth: widget.maxContentWidth,
cursorController: _cursorCont,
floatingCursorDisabled: widget.floatingCursorDisabled,
children: _buildChildren(_doc, context),
),
),
), ),
); );
} }
@ -191,34 +340,46 @@ class RawEditorState extends EditorState
data: _styles!, data: _styles!,
child: MouseRegion( child: MouseRegion(
cursor: SystemMouseCursors.text, cursor: SystemMouseCursors.text,
child: QuillKeyboardListener(
child: Container( child: Container(
constraints: constraints, constraints: constraints,
child: child, child: child,
), ),
), ),
),
); );
} }
void _handleSelectionChanged( void _handleSelectionChanged(
TextSelection selection, SelectionChangedCause cause) { TextSelection selection, SelectionChangedCause cause) {
final oldSelection = widget.controller.selection;
widget.controller.updateSelection(selection, ChangeSource.LOCAL); widget.controller.updateSelection(selection, ChangeSource.LOCAL);
_selectionOverlay?.handlesVisible = _shouldShowSelectionHandles(); _selectionOverlay?.handlesVisible = _shouldShowSelectionHandles();
if (!_keyboardVisible) { if (!_keyboardVisible) {
// This will show the keyboard for all selection changes on the
// editor, not just changes triggered by user gestures.
requestKeyboard(); requestKeyboard();
} }
if (cause == SelectionChangedCause.drag) {
// When user updates the selection while dragging make sure to
// bring the updated position (base or extent) into view.
if (oldSelection.baseOffset != selection.baseOffset) {
bringIntoView(selection.base);
} else if (oldSelection.extentOffset != selection.extentOffset) {
bringIntoView(selection.extent);
}
}
} }
/// Updates the checkbox positioned at [offset] in document /// Updates the checkbox positioned at [offset] in document
/// by changing its attribute according to [value]. /// by changing its attribute according to [value].
void _handleCheckboxTap(int offset, bool value) { void _handleCheckboxTap(int offset, bool value) {
if (!widget.readOnly) { if (!widget.readOnly) {
if (value) { widget.controller.formatText(
widget.controller.formatText(offset, 0, Attribute.checked); offset, 0, value ? Attribute.checked : Attribute.unchecked);
} else {
widget.controller.formatText(offset, 0, Attribute.unchecked);
}
} }
} }
@ -233,6 +394,7 @@ class RawEditorState extends EditorState
final attrs = node.style.attributes; final attrs = node.style.attributes;
final editableTextBlock = EditableTextBlock( final editableTextBlock = EditableTextBlock(
block: node, block: node,
controller: widget.controller,
textDirection: _textDirection, textDirection: _textDirection,
scrollBottomInset: widget.scrollBottomInset, scrollBottomInset: widget.scrollBottomInset,
verticalSpacing: _getVerticalSpacingForBlock(node, _styles), verticalSpacing: _getVerticalSpacingForBlock(node, _styles),
@ -245,6 +407,8 @@ class RawEditorState extends EditorState
? const EdgeInsets.all(16) ? const EdgeInsets.all(16)
: null, : null,
embedBuilder: widget.embedBuilder, embedBuilder: widget.embedBuilder,
linkActionPicker: _linkActionPicker,
onLaunchUrl: widget.onLaunchUrl,
cursorCont: _cursorCont, cursorCont: _cursorCont,
indentLevelCounts: indentLevelCounts, indentLevelCounts: indentLevelCounts,
onCheckboxTap: _handleCheckboxTap, onCheckboxTap: _handleCheckboxTap,
@ -267,6 +431,9 @@ class RawEditorState extends EditorState
customStyleBuilder: widget.customStyleBuilder, customStyleBuilder: widget.customStyleBuilder,
styles: _styles!, styles: _styles!,
readOnly: widget.readOnly, readOnly: widget.readOnly,
controller: widget.controller,
linkActionPicker: _linkActionPicker,
onLaunchUrl: widget.onLaunchUrl,
); );
final editableTextLine = EditableTextLine( final editableTextLine = EditableTextLine(
node, node,
@ -313,8 +480,12 @@ class RawEditorState extends EditorState
return defaultStyles!.code!.verticalSpacing; return defaultStyles!.code!.verticalSpacing;
} else if (attrs.containsKey(Attribute.indent.key)) { } else if (attrs.containsKey(Attribute.indent.key)) {
return defaultStyles!.indent!.verticalSpacing; return defaultStyles!.indent!.verticalSpacing;
} } else if (attrs.containsKey(Attribute.list.key)) {
return defaultStyles!.lists!.verticalSpacing; return defaultStyles!.lists!.verticalSpacing;
} else if (attrs.containsKey(Attribute.align.key)) {
return defaultStyles!.align!.verticalSpacing;
}
return const Tuple2(0, 0);
} }
@override @override
@ -336,11 +507,9 @@ class RawEditorState extends EditorState
tickerProvider: this, tickerProvider: this,
); );
_keyboardListener = KeyboardListener( // Floating cursor
handleCursorMovement, _floatingCursorResetController = AnimationController(vsync: this);
handleShortcut, _floatingCursorResetController.addListener(onFloatingCursorResetTick);
handleDelete,
);
if (defaultTargetPlatform == TargetPlatform.windows || if (defaultTargetPlatform == TargetPlatform.windows ||
defaultTargetPlatform == TargetPlatform.macOS || defaultTargetPlatform == TargetPlatform.macOS ||
@ -354,13 +523,12 @@ class RawEditorState extends EditorState
_keyboardVisibilityController?.onChange.listen((visible) { _keyboardVisibilityController?.onChange.listen((visible) {
_keyboardVisible = visible; _keyboardVisible = visible;
if (visible) { if (visible) {
_onChangeTextEditingValue(); _onChangeTextEditingValue(!_hasFocus);
} }
}); });
} }
_focusAttachment = widget.focusNode.attach(context, _focusAttachment = widget.focusNode.attach(context);
onKey: (node, event) => _keyboardListener.handleRawKeyEvent(event));
widget.focusNode.addListener(_handleFocusChanged); widget.focusNode.addListener(_handleFocusChanged);
} }
@ -405,8 +573,7 @@ class RawEditorState extends EditorState
if (widget.focusNode != oldWidget.focusNode) { if (widget.focusNode != oldWidget.focusNode) {
oldWidget.focusNode.removeListener(_handleFocusChanged); oldWidget.focusNode.removeListener(_handleFocusChanged);
_focusAttachment?.detach(); _focusAttachment?.detach();
_focusAttachment = widget.focusNode.attach(context, _focusAttachment = widget.focusNode.attach(context);
onKey: (node, event) => _keyboardListener.handleRawKeyEvent(event));
widget.focusNode.addListener(_handleFocusChanged); widget.focusNode.addListener(_handleFocusChanged);
updateKeepAlive(); updateKeepAlive();
} }
@ -448,7 +615,7 @@ class RawEditorState extends EditorState
} }
void _updateSelectionOverlayForScroll() { void _updateSelectionOverlayForScroll() {
_selectionOverlay?.markNeedsBuild(); _selectionOverlay?.updateForScroll();
} }
void _didChangeTextEditingValue([bool ignoreFocus = false]) { void _didChangeTextEditingValue([bool ignoreFocus = false]) {
@ -482,11 +649,19 @@ class RawEditorState extends EditorState
_cursorCont.startOrStopCursorTimerIfNeeded( _cursorCont.startOrStopCursorTimerIfNeeded(
_hasFocus, widget.controller.selection); _hasFocus, widget.controller.selection);
if (hasConnection) { if (hasConnection) {
// To keep the cursor from blinking while typing, we want to restart the
// cursor timer every time a new character is typed.
_cursorCont _cursorCont
..stopCursorTimer(resetCharTicks: false) ..stopCursorTimer(resetCharTicks: false)
..startCursorTimer(); ..startCursorTimer();
} }
// Refresh selection overlay after the build step had a chance to
// update and register all children of RenderEditor. Otherwise this will
// fail in situations where a new line of text is entered, which adds
// a new RenderEditableBox child. If we try to update selection overlay
// immediately it'll not be able to find the new child since it hasn't been
// built yet.
SchedulerBinding.instance!.addPostFrameCallback((_) { SchedulerBinding.instance!.addPostFrameCallback((_) {
if (!mounted) { if (!mounted) {
return; return;
@ -503,7 +678,7 @@ class RawEditorState extends EditorState
void _updateOrDisposeSelectionOverlayIfNeeded() { void _updateOrDisposeSelectionOverlayIfNeeded() {
if (_selectionOverlay != null) { if (_selectionOverlay != null) {
if (_hasFocus) { if (_hasFocus && !textEditingValue.selection.isCollapsed) {
_selectionOverlay!.update(textEditingValue); _selectionOverlay!.update(textEditingValue);
} else { } else {
_selectionOverlay!.dispose(); _selectionOverlay!.dispose();
@ -511,22 +686,18 @@ class RawEditorState extends EditorState
} }
} else if (_hasFocus) { } else if (_hasFocus) {
_selectionOverlay?.hide(); _selectionOverlay?.hide();
_selectionOverlay = null;
_selectionOverlay = EditorTextSelectionOverlay( _selectionOverlay = EditorTextSelectionOverlay(
textEditingValue, value: textEditingValue,
false, context: context,
context, debugRequiredFor: widget,
widget, toolbarLayerLink: _toolbarLayerLink,
_toolbarLayerLink, startHandleLayerLink: _startHandleLayerLink,
_startHandleLayerLink, endHandleLayerLink: _endHandleLayerLink,
_endHandleLayerLink, renderObject: getRenderEditor(),
getRenderEditor(), selectionCtrls: widget.selectionCtrls,
widget.selectionCtrls, selectionDelegate: this,
this, clipboardStatus: _clipboardStatus,
DragStartBehavior.start,
null,
_clipboardStatus,
); );
_selectionOverlay!.handlesVisible = _shouldShowSelectionHandles(); _selectionOverlay!.handlesVisible = _shouldShowSelectionHandles();
_selectionOverlay!.showHandles(); _selectionOverlay!.showHandles();
@ -555,6 +726,11 @@ class RawEditorState extends EditorState
}); });
} }
Future<LinkMenuAction> _linkActionPicker(Node linkNode) async {
final link = linkNode.style.attributes[Attribute.link.key]!.value!;
return widget.linkActionPickerDelegate(context, link);
}
bool _showCaretOnScreenScheduled = false; bool _showCaretOnScreenScheduled = false;
void _showCaretOnScreen() { void _showCaretOnScreen() {
@ -564,7 +740,7 @@ class RawEditorState extends EditorState
_showCaretOnScreenScheduled = true; _showCaretOnScreenScheduled = true;
SchedulerBinding.instance!.addPostFrameCallback((_) { SchedulerBinding.instance!.addPostFrameCallback((_) {
if (widget.scrollable) { if (widget.scrollable || _scrollController.hasClients) {
_showCaretOnScreenScheduled = false; _showCaretOnScreenScheduled = false;
final renderEditor = getRenderEditor(); final renderEditor = getRenderEditor();
@ -585,7 +761,7 @@ class RawEditorState extends EditorState
if (offset != null) { if (offset != null) {
_scrollController.animateTo( _scrollController.animateTo(
offset, math.min(offset, _scrollController.position.maxScrollExtent),
duration: const Duration(milliseconds: 100), duration: const Duration(milliseconds: 100),
curve: Curves.fastOutSlowIn, curve: Curves.fastOutSlowIn,
); );
@ -599,68 +775,35 @@ class RawEditorState extends EditorState
return _editorKey.currentContext?.findRenderObject() as RenderEditor?; return _editorKey.currentContext?.findRenderObject() as RenderEditor?;
} }
@override
TextEditingValue getTextEditingValue() {
return widget.controller.plainTextEditingValue;
}
@override @override
void requestKeyboard() { void requestKeyboard() {
if (_hasFocus) { if (_hasFocus) {
openConnectionIfNeeded(); openConnectionIfNeeded();
_showCaretOnScreen();
} else { } else {
widget.focusNode.requestFocus(); widget.focusNode.requestFocus();
} }
} }
@override @override
void setTextEditingValue(TextEditingValue value) { void setTextEditingValue(
if (value.text == textEditingValue.text) { TextEditingValue value, SelectionChangedCause cause) {
widget.controller.updateSelection(value.selection, ChangeSource.LOCAL); if (value == textEditingValue) {
} else { return;
_setEditingValue(value);
}
}
// set editing value from clipboard for mobile
Future<void> _setEditingValue(TextEditingValue value) async {
if (await _isItCut(value)) {
widget.controller.replaceText(
textEditingValue.selection.start,
textEditingValue.text.length - value.text.length,
'',
value.selection,
);
} else {
final value = textEditingValue;
final data = await Clipboard.getData(Clipboard.kTextPlain);
if (data != null) {
final length =
textEditingValue.selection.end - textEditingValue.selection.start;
widget.controller.replaceText(
value.selection.start,
length,
data.text,
value.selection,
);
// move cursor to the end of pasted text selection
widget.controller.updateSelection(
TextSelection.collapsed(
offset: value.selection.start + data.text!.length),
ChangeSource.LOCAL);
}
} }
textEditingValue = value;
userUpdateTextEditingValue(value, cause);
} }
Future<bool> _isItCut(TextEditingValue value) async { @override
final data = await Clipboard.getData(Clipboard.kTextPlain); void debugAssertLayoutUpToDate() {
if (data == null) { getRenderEditor()!.debugAssertLayoutUpToDate();
return false;
}
return textEditingValue.text.length - value.text.length ==
data.text!.length;
} }
/// Shows the selection toolbar at the location of the current cursor.
///
/// Returns `false` if a toolbar couldn't be shown, such as when the toolbar
/// is already shown, or when no text selection currently exists.
@override @override
bool showToolbar() { bool showToolbar() {
// Web is using native dom elements to enable clipboard functionality of the // Web is using native dom elements to enable clipboard functionality of the
@ -679,8 +822,85 @@ class RawEditorState extends EditorState
return true; return true;
} }
@override
void copySelection(SelectionChangedCause cause) {
// Copied straight from EditableTextState
super.copySelection(cause);
if (cause == SelectionChangedCause.toolbar) {
bringIntoView(textEditingValue.selection.extent);
hideToolbar(false);
switch (defaultTargetPlatform) {
case TargetPlatform.iOS:
break;
case TargetPlatform.macOS:
case TargetPlatform.android:
case TargetPlatform.fuchsia:
case TargetPlatform.linux:
case TargetPlatform.windows:
// Collapse the selection and hide the toolbar and handles.
userUpdateTextEditingValue(
TextEditingValue(
text: textEditingValue.text,
selection: TextSelection.collapsed(
offset: textEditingValue.selection.end),
),
SelectionChangedCause.toolbar,
);
break;
}
}
}
@override
void cutSelection(SelectionChangedCause cause) {
// Copied straight from EditableTextState
super.cutSelection(cause);
if (cause == SelectionChangedCause.toolbar) {
bringIntoView(textEditingValue.selection.extent);
hideToolbar();
}
}
@override
Future<void> pasteText(SelectionChangedCause cause) async {
// Copied straight from EditableTextState
super.pasteText(cause); // ignore: unawaited_futures
if (cause == SelectionChangedCause.toolbar) {
bringIntoView(textEditingValue.selection.extent);
hideToolbar();
}
}
@override
void selectAll(SelectionChangedCause cause) {
// Copied straight from EditableTextState
super.selectAll(cause);
if (cause == SelectionChangedCause.toolbar) {
bringIntoView(textEditingValue.selection.extent);
}
}
@override @override
bool get wantKeepAlive => widget.focusNode.hasFocus; bool get wantKeepAlive => widget.focusNode.hasFocus;
@override
bool get obscureText => false;
@override
bool get selectionEnabled => widget.enableInteractiveSelection;
@override
bool get readOnly => widget.readOnly;
@override
TextLayoutMetrics get textLayoutMetrics => getRenderEditor()!;
@override
AnimationController get floatingCursorResetController =>
_floatingCursorResetController;
late AnimationController _floatingCursorResetController;
} }
class _Editor extends MultiChildRenderObjectWidget { class _Editor extends MultiChildRenderObjectWidget {
@ -695,9 +915,14 @@ class _Editor extends MultiChildRenderObjectWidget {
required this.endHandleLayerLink, required this.endHandleLayerLink,
required this.onSelectionChanged, required this.onSelectionChanged,
required this.scrollBottomInset, required this.scrollBottomInset,
required this.cursorController,
required this.floatingCursorDisabled,
this.padding = EdgeInsets.zero, this.padding = EdgeInsets.zero,
this.maxContentWidth,
this.offset,
}) : super(key: key, children: children); }) : super(key: key, children: children);
final ViewportOffset? offset;
final Document document; final Document document;
final TextDirection textDirection; final TextDirection textDirection;
final bool hasFocus; final bool hasFocus;
@ -707,28 +932,33 @@ class _Editor extends MultiChildRenderObjectWidget {
final TextSelectionChangedHandler onSelectionChanged; final TextSelectionChangedHandler onSelectionChanged;
final double scrollBottomInset; final double scrollBottomInset;
final EdgeInsetsGeometry padding; final EdgeInsetsGeometry padding;
final double? maxContentWidth;
final CursorCont cursorController;
final bool floatingCursorDisabled;
@override @override
RenderEditor createRenderObject(BuildContext context) { RenderEditor createRenderObject(BuildContext context) {
return RenderEditor( return RenderEditor(
null, offset: offset,
textDirection, document: document,
scrollBottomInset, textDirection: textDirection,
padding, hasFocus: hasFocus,
document, selection: selection,
selection, startHandleLayerLink: startHandleLayerLink,
hasFocus, endHandleLayerLink: endHandleLayerLink,
onSelectionChanged, onSelectionChanged: onSelectionChanged,
startHandleLayerLink, cursorController: cursorController,
endHandleLayerLink, padding: padding,
const EdgeInsets.fromLTRB(4, 4, 4, 5), maxContentWidth: maxContentWidth,
); scrollBottomInset: scrollBottomInset,
floatingCursorDisabled: floatingCursorDisabled);
} }
@override @override
void updateRenderObject( void updateRenderObject(
BuildContext context, covariant RenderEditor renderObject) { BuildContext context, covariant RenderEditor renderObject) {
renderObject renderObject
..offset = offset
..document = document ..document = document
..setContainer(document.root) ..setContainer(document.root)
..textDirection = textDirection ..textDirection = textDirection
@ -738,6 +968,7 @@ class _Editor extends MultiChildRenderObjectWidget {
..setEndHandleLayerLink(endHandleLayerLink) ..setEndHandleLayerLink(endHandleLayerLink)
..onSelectionChanged = onSelectionChanged ..onSelectionChanged = onSelectionChanged
..setScrollBottomInset(scrollBottomInset) ..setScrollBottomInset(scrollBottomInset)
..setPadding(padding); ..setPadding(padding)
..maxContentWidth = maxContentWidth;
} }
} }

@ -1,355 +0,0 @@
import 'dart:ui';
import 'package:characters/characters.dart';
import 'package:flutter/services.dart';
import '../../models/documents/document.dart';
import '../../utils/diff_delta.dart';
import '../editor.dart';
import '../keyboard_listener.dart';
mixin RawEditorStateKeyboardMixin on EditorState {
// Holds the last cursor location the user selected in the case the user tries
// to select vertically past the end or beginning of the field. If they do,
// then we need to keep the old cursor location so that we can go back to it
// if they change their minds. Only used for moving selection up and down in a
// multiline text field when selecting using the keyboard.
int _cursorResetLocation = -1;
// Whether we should reset the location of the cursor in the case the user
// tries to select vertically past the end or beginning of the field. If they
// do, then we need to keep the old cursor location so that we can go back to
// it if they change their minds. Only used for resetting selection up and
// down in a multiline text field when selecting using the keyboard.
bool _wasSelectingVerticallyWithKeyboard = false;
void handleCursorMovement(
LogicalKeyboardKey key,
bool wordModifier,
bool lineModifier,
bool shift,
) {
if (wordModifier && lineModifier) {
// If both modifiers are down, nothing happens on any of the platforms.
return;
}
final selection = widget.controller.selection;
var newSelection = widget.controller.selection;
final plainText = getTextEditingValue().text;
final rightKey = key == LogicalKeyboardKey.arrowRight,
leftKey = key == LogicalKeyboardKey.arrowLeft,
upKey = key == LogicalKeyboardKey.arrowUp,
downKey = key == LogicalKeyboardKey.arrowDown;
if ((rightKey || leftKey) && !(rightKey && leftKey)) {
newSelection = _jumpToBeginOrEndOfWord(newSelection, wordModifier,
leftKey, rightKey, plainText, lineModifier, shift);
}
if (downKey || upKey) {
newSelection = _handleMovingCursorVertically(
upKey, downKey, shift, selection, newSelection, plainText);
}
if (!shift) {
newSelection =
_placeCollapsedSelection(selection, newSelection, leftKey, rightKey);
}
widget.controller.updateSelection(newSelection, ChangeSource.LOCAL);
}
// Handles shortcut functionality including cut, copy, paste and select all
// using control/command + (X, C, V, A).
// TODO: Add support for formatting shortcuts: Cmd+B (bold), Cmd+I (italic)
// set editing value from clipboard for web
Future<void> handleShortcut(InputShortcut? shortcut) async {
final selection = widget.controller.selection;
final plainText = getTextEditingValue().text;
if (shortcut == InputShortcut.COPY) {
if (!selection.isCollapsed) {
await Clipboard.setData(
ClipboardData(text: selection.textInside(plainText)));
}
return;
}
if (shortcut == InputShortcut.CUT && !widget.readOnly) {
if (!selection.isCollapsed) {
final data = selection.textInside(plainText);
await Clipboard.setData(ClipboardData(text: data));
widget.controller.replaceText(
selection.start,
data.length,
'',
TextSelection.collapsed(offset: selection.start),
);
setTextEditingValue(TextEditingValue(
text:
selection.textBefore(plainText) + selection.textAfter(plainText),
selection: TextSelection.collapsed(offset: selection.start),
));
}
return;
}
if (shortcut == InputShortcut.PASTE && !widget.readOnly) {
final data = await Clipboard.getData(Clipboard.kTextPlain);
if (data != null) {
widget.controller.replaceText(
selection.start,
selection.end - selection.start,
data.text,
TextSelection.collapsed(offset: selection.start + data.text!.length),
);
}
return;
}
if (shortcut == InputShortcut.SELECT_ALL &&
widget.enableInteractiveSelection) {
widget.controller.updateSelection(
selection.copyWith(
baseOffset: 0,
extentOffset: getTextEditingValue().text.length,
),
ChangeSource.REMOTE);
return;
}
}
void handleDelete(bool forward) {
final selection = widget.controller.selection;
final plainText = getTextEditingValue().text;
var cursorPosition = selection.start;
var textBefore = selection.textBefore(plainText);
var textAfter = selection.textAfter(plainText);
if (selection.isCollapsed) {
if (!forward && textBefore.isNotEmpty) {
final characterBoundary =
_previousCharacter(textBefore.length, textBefore, true);
textBefore = textBefore.substring(0, characterBoundary);
cursorPosition = characterBoundary;
}
if (forward && textAfter.isNotEmpty && textAfter != '\n') {
final deleteCount = _nextCharacter(0, textAfter, true);
textAfter = textAfter.substring(deleteCount);
}
}
final newSelection = TextSelection.collapsed(offset: cursorPosition);
final newText = textBefore + textAfter;
final size = plainText.length - newText.length;
widget.controller.replaceText(
cursorPosition,
size,
'',
newSelection,
);
}
TextSelection _jumpToBeginOrEndOfWord(
TextSelection newSelection,
bool wordModifier,
bool leftKey,
bool rightKey,
String plainText,
bool lineModifier,
bool shift) {
if (wordModifier) {
if (leftKey) {
final textSelection = getRenderEditor()!.selectWordAtPosition(
TextPosition(
offset: _previousCharacter(
newSelection.extentOffset, plainText, false)));
return newSelection.copyWith(extentOffset: textSelection.baseOffset);
}
final textSelection = getRenderEditor()!.selectWordAtPosition(
TextPosition(
offset:
_nextCharacter(newSelection.extentOffset, plainText, false)));
return newSelection.copyWith(extentOffset: textSelection.extentOffset);
} else if (lineModifier) {
if (leftKey) {
final textSelection = getRenderEditor()!.selectLineAtPosition(
TextPosition(
offset: _previousCharacter(
newSelection.extentOffset, plainText, false)));
return newSelection.copyWith(extentOffset: textSelection.baseOffset);
}
final startPoint = newSelection.extentOffset;
if (startPoint < plainText.length) {
final textSelection = getRenderEditor()!
.selectLineAtPosition(TextPosition(offset: startPoint));
return newSelection.copyWith(extentOffset: textSelection.extentOffset);
}
return newSelection;
}
if (rightKey && newSelection.extentOffset < plainText.length) {
final nextExtent =
_nextCharacter(newSelection.extentOffset, plainText, true);
final distance = nextExtent - newSelection.extentOffset;
newSelection = newSelection.copyWith(extentOffset: nextExtent);
if (shift) {
_cursorResetLocation += distance;
}
return newSelection;
}
if (leftKey && newSelection.extentOffset > 0) {
final previousExtent =
_previousCharacter(newSelection.extentOffset, plainText, true);
final distance = newSelection.extentOffset - previousExtent;
newSelection = newSelection.copyWith(extentOffset: previousExtent);
if (shift) {
_cursorResetLocation -= distance;
}
return newSelection;
}
return newSelection;
}
/// Returns the index into the string of the next character boundary after the
/// given index.
///
/// The character boundary is determined by the characters package, so
/// surrogate pairs and extended grapheme clusters are considered.
///
/// The index must be between 0 and string.length, inclusive. If given
/// string.length, string.length is returned.
///
/// Setting includeWhitespace to false will only return the index of non-space
/// characters.
int _nextCharacter(int index, String string, bool includeWhitespace) {
assert(index >= 0 && index <= string.length);
if (index == string.length) {
return string.length;
}
var count = 0;
final remain = string.characters.skipWhile((currentString) {
if (count <= index) {
count += currentString.length;
return true;
}
if (includeWhitespace) {
return false;
}
return WHITE_SPACE.contains(currentString.codeUnitAt(0));
});
return string.length - remain.toString().length;
}
/// Returns the index into the string of the previous character boundary
/// before the given index.
///
/// The character boundary is determined by the characters package, so
/// surrogate pairs and extended grapheme clusters are considered.
///
/// The index must be between 0 and string.length, inclusive. If index is 0,
/// 0 will be returned.
///
/// Setting includeWhitespace to false will only return the index of non-space
/// characters.
int _previousCharacter(int index, String string, includeWhitespace) {
assert(index >= 0 && index <= string.length);
if (index == 0) {
return 0;
}
var count = 0;
int? lastNonWhitespace;
for (final currentString in string.characters) {
if (!includeWhitespace &&
!WHITE_SPACE.contains(
currentString.characters.first.toString().codeUnitAt(0))) {
lastNonWhitespace = count;
}
if (count + currentString.length >= index) {
return includeWhitespace ? count : lastNonWhitespace ?? 0;
}
count += currentString.length;
}
return 0;
}
TextSelection _handleMovingCursorVertically(
bool upKey,
bool downKey,
bool shift,
TextSelection selection,
TextSelection newSelection,
String plainText) {
final originPosition = TextPosition(
offset: upKey ? selection.baseOffset : selection.extentOffset);
final child = getRenderEditor()!.childAtPosition(originPosition);
final localPosition = TextPosition(
offset: originPosition.offset - child.getContainer().documentOffset);
var position = upKey
? child.getPositionAbove(localPosition)
: child.getPositionBelow(localPosition);
if (position == null) {
final sibling = upKey
? getRenderEditor()!.childBefore(child)
: getRenderEditor()!.childAfter(child);
if (sibling == null) {
position = TextPosition(offset: upKey ? 0 : plainText.length - 1);
} else {
final finalOffset = Offset(
child.getOffsetForCaret(localPosition).dx,
sibling
.getOffsetForCaret(TextPosition(
offset: upKey ? sibling.getContainer().length - 1 : 0))
.dy);
final siblingPosition = sibling.getPositionForOffset(finalOffset);
position = TextPosition(
offset:
sibling.getContainer().documentOffset + siblingPosition.offset);
}
} else {
position = TextPosition(
offset: child.getContainer().documentOffset + position.offset);
}
if (position.offset == newSelection.extentOffset) {
if (downKey) {
newSelection = newSelection.copyWith(extentOffset: plainText.length);
} else if (upKey) {
newSelection = newSelection.copyWith(extentOffset: 0);
}
_wasSelectingVerticallyWithKeyboard = shift;
return newSelection;
}
if (_wasSelectingVerticallyWithKeyboard && shift) {
newSelection = newSelection.copyWith(extentOffset: _cursorResetLocation);
_wasSelectingVerticallyWithKeyboard = false;
return newSelection;
}
newSelection = newSelection.copyWith(extentOffset: position.offset);
_cursorResetLocation = newSelection.extentOffset;
return newSelection;
}
TextSelection _placeCollapsedSelection(TextSelection selection,
TextSelection newSelection, bool leftKey, bool rightKey) {
var newOffset = newSelection.extentOffset;
if (!selection.isCollapsed) {
if (leftKey) {
newOffset = newSelection.baseOffset < newSelection.extentOffset
? newSelection.baseOffset
: newSelection.extentOffset;
} else if (rightKey) {
newOffset = newSelection.baseOffset > newSelection.extentOffset
? newSelection.baseOffset
: newSelection.extentOffset;
}
}
return TextSelection.fromPosition(TextPosition(offset: newOffset));
}
}

@ -1,20 +1,46 @@
import 'dart:math'; import 'dart:math' as math;
import 'package:flutter/rendering.dart'; import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import '../../../utils/diff_delta.dart';
import '../editor.dart'; import '../editor.dart';
mixin RawEditorStateSelectionDelegateMixin on EditorState mixin RawEditorStateSelectionDelegateMixin on EditorState
implements TextSelectionDelegate { implements TextSelectionDelegate {
@override @override
TextEditingValue get textEditingValue { TextEditingValue get textEditingValue {
return getTextEditingValue(); return widget.controller.plainTextEditingValue;
} }
@override @override
set textEditingValue(TextEditingValue value) { set textEditingValue(TextEditingValue value) {
setTextEditingValue(value); final cursorPosition = value.selection.extentOffset;
final oldText = widget.controller.document.toPlainText();
final newText = value.text;
final diff = getDiff(oldText, newText, cursorPosition);
final insertedText = _adjustInsertedText(diff.inserted);
widget.controller.replaceText(
diff.start, diff.deleted.length, insertedText, value.selection);
}
String _adjustInsertedText(String text) {
// For clip from editor, it may contain image, a.k.a 65532.
// For clip from browser, image is directly ignore.
// Here we skip image when pasting.
if (!text.codeUnits.contains(65532)) {
return text;
}
final sb = StringBuffer();
for (var i = 0; i < text.length; i++) {
if (text.codeUnitAt(i) == 65532) {
continue;
}
sb.write(text[i]);
}
return sb.toString();
} }
@override @override
@ -50,8 +76,8 @@ mixin RawEditorStateSelectionDelegateMixin on EditorState
final expandedRect = Rect.fromCenter( final expandedRect = Rect.fromCenter(
center: rect.center, center: rect.center,
width: rect.width, width: rect.width,
height: height: math.max(
max(rect.height, getRenderEditor()!.preferredLineHeight(position)), rect.height, getRenderEditor()!.preferredLineHeight(position)),
); );
additionalOffset = expandedRect.height >= editableSize.height additionalOffset = expandedRect.height >= editableSize.height
@ -81,10 +107,8 @@ mixin RawEditorStateSelectionDelegateMixin on EditorState
@override @override
void userUpdateTextEditingValue( void userUpdateTextEditingValue(
TextEditingValue value, TextEditingValue value, SelectionChangedCause cause) {
SelectionChangedCause cause, textEditingValue = value;
) {
setTextEditingValue(value);
} }
@override @override

@ -1,13 +1,15 @@
import 'dart:ui';
import 'package:flutter/animation.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import '../../models/documents/document.dart';
import '../../utils/diff_delta.dart'; import '../../utils/diff_delta.dart';
import '../editor.dart'; import '../editor.dart';
mixin RawEditorStateTextInputClientMixin on EditorState mixin RawEditorStateTextInputClientMixin on EditorState
implements TextInputClient { implements TextInputClient {
final List<TextEditingValue> _sentRemoteValues = [];
TextInputConnection? _textInputConnection; TextInputConnection? _textInputConnection;
TextEditingValue? _lastKnownRemoteTextEditingValue; TextEditingValue? _lastKnownRemoteTextEditingValue;
@ -46,7 +48,7 @@ mixin RawEditorStateTextInputClientMixin on EditorState
} }
if (!hasConnection) { if (!hasConnection) {
_lastKnownRemoteTextEditingValue = getTextEditingValue(); _lastKnownRemoteTextEditingValue = textEditingValue;
_textInputConnection = TextInput.attach( _textInputConnection = TextInput.attach(
this, this,
TextInputConfiguration( TextInputConfiguration(
@ -74,7 +76,6 @@ mixin RawEditorStateTextInputClientMixin on EditorState
_textInputConnection!.close(); _textInputConnection!.close();
_textInputConnection = null; _textInputConnection = null;
_lastKnownRemoteTextEditingValue = null; _lastKnownRemoteTextEditingValue = null;
_sentRemoteValues.clear();
} }
/// Updates remote value based on current state of [document] and /// Updates remote value based on current state of [document] and
@ -87,12 +88,14 @@ mixin RawEditorStateTextInputClientMixin on EditorState
return; return;
} }
final value = textEditingValue;
// Since we don't keep track of the composing range in value provided // Since we don't keep track of the composing range in value provided
// by the Controller we need to add it here manually before comparing // by the Controller we need to add it here manually before comparing
// with the last known remote value. // with the last known remote value.
// It is important to prevent excessive remote updates as it can cause // It is important to prevent excessive remote updates as it can cause
// race conditions. // race conditions.
final actualValue = getTextEditingValue().copyWith( final actualValue = value.copyWith(
composing: _lastKnownRemoteTextEditingValue!.composing, composing: _lastKnownRemoteTextEditingValue!.composing,
); );
@ -100,18 +103,12 @@ mixin RawEditorStateTextInputClientMixin on EditorState
return; return;
} }
final shouldRemember =
getTextEditingValue().text != _lastKnownRemoteTextEditingValue!.text;
_lastKnownRemoteTextEditingValue = actualValue; _lastKnownRemoteTextEditingValue = actualValue;
_textInputConnection!.setEditingState( _textInputConnection!.setEditingState(
// Set composing to (-1, -1), otherwise an exception will be thrown if // Set composing to (-1, -1), otherwise an exception will be thrown if
// the values are different. // the values are different.
actualValue.copyWith(composing: const TextRange(start: -1, end: -1)), actualValue.copyWith(composing: const TextRange(start: -1, end: -1)),
); );
if (shouldRemember) {
// Only keep track if text changed (selection changes are not relevant)
_sentRemoteValues.add(actualValue);
}
} }
@override @override
@ -128,22 +125,6 @@ mixin RawEditorStateTextInputClientMixin on EditorState
return; return;
} }
if (_sentRemoteValues.contains(value)) {
/// There is a race condition in Flutter text input plugin where sending
/// updates to native side too often results in broken behavior.
/// TextInputConnection.setEditingValue is an async call to native side.
/// For each such call native side _always_ sends an update which triggers
/// this method (updateEditingValue) with the same value we've sent it.
/// If multiple calls to setEditingValue happen too fast and we only
/// track the last sent value then there is no way for us to filter out
/// automatic callbacks from native side.
/// Therefore we have to keep track of all values we send to the native
/// side and when we see this same value appear here we skip it.
/// This is fragile but it's probably the only available option.
_sentRemoteValues.remove(value);
return;
}
if (_lastKnownRemoteTextEditingValue == value) { if (_lastKnownRemoteTextEditingValue == value) {
// There is no difference between this value and the last known value. // There is no difference between this value and the last known value.
return; return;
@ -167,9 +148,13 @@ mixin RawEditorStateTextInputClientMixin on EditorState
final text = value.text; final text = value.text;
final cursorPosition = value.selection.extentOffset; final cursorPosition = value.selection.extentOffset;
final diff = getDiff(oldText, text, cursorPosition); final diff = getDiff(oldText, text, cursorPosition);
if (diff.deleted.isEmpty && diff.inserted.isEmpty) {
widget.controller.updateSelection(value.selection, ChangeSource.LOCAL);
} else {
widget.controller.replaceText( widget.controller.replaceText(
diff.start, diff.deleted.length, diff.inserted, value.selection); diff.start, diff.deleted.length, diff.inserted, value.selection);
} }
}
@override @override
void performAction(TextInputAction action) { void performAction(TextInputAction action) {
@ -181,9 +166,119 @@ mixin RawEditorStateTextInputClientMixin on EditorState
// no-op // no-op
} }
// The time it takes for the floating cursor to snap to the text aligned
// cursor position after the user has finished placing it.
static const Duration _floatingCursorResetTime = Duration(milliseconds: 125);
// The original position of the caret on FloatingCursorDragState.start.
Rect? _startCaretRect;
// The most recent text position as determined by the location of the floating
// cursor.
TextPosition? _lastTextPosition;
// The offset of the floating cursor as determined from the start call.
Offset? _pointOffsetOrigin;
// The most recent position of the floating cursor.
Offset? _lastBoundedOffset;
// Because the center of the cursor is preferredLineHeight / 2 below the touch
// origin, but the touch origin is used to determine which line the cursor is
// on, we need this offset to correctly render and move the cursor.
Offset _floatingCursorOffset(TextPosition textPosition) =>
Offset(0, getRenderEditor()!.preferredLineHeight(textPosition) / 2);
@override @override
void updateFloatingCursor(RawFloatingCursorPoint point) { void updateFloatingCursor(RawFloatingCursorPoint point) {
throw UnimplementedError(); switch (point.state) {
case FloatingCursorDragState.Start:
if (floatingCursorResetController.isAnimating) {
floatingCursorResetController.stop();
onFloatingCursorResetTick();
}
// We want to send in points that are centered around a (0,0) origin, so
// we cache the position.
_pointOffsetOrigin = point.offset;
final currentTextPosition =
TextPosition(offset: getRenderEditor()!.selection.baseOffset);
_startCaretRect =
getRenderEditor()!.getLocalRectForCaret(currentTextPosition);
_lastBoundedOffset = _startCaretRect!.center -
_floatingCursorOffset(currentTextPosition);
_lastTextPosition = currentTextPosition;
getRenderEditor()!.setFloatingCursor(
point.state, _lastBoundedOffset!, _lastTextPosition!);
break;
case FloatingCursorDragState.Update:
assert(_lastTextPosition != null, 'Last text position was not set');
final floatingCursorOffset = _floatingCursorOffset(_lastTextPosition!);
final centeredPoint = point.offset! - _pointOffsetOrigin!;
final rawCursorOffset =
_startCaretRect!.center + centeredPoint - floatingCursorOffset;
final preferredLineHeight =
getRenderEditor()!.preferredLineHeight(_lastTextPosition!);
_lastBoundedOffset =
getRenderEditor()!.calculateBoundedFloatingCursorOffset(
rawCursorOffset,
preferredLineHeight,
);
_lastTextPosition = getRenderEditor()!.getPositionForOffset(
getRenderEditor()!
.localToGlobal(_lastBoundedOffset! + floatingCursorOffset));
getRenderEditor()!.setFloatingCursor(
point.state, _lastBoundedOffset!, _lastTextPosition!);
final newSelection = TextSelection.collapsed(
offset: _lastTextPosition!.offset,
affinity: _lastTextPosition!.affinity);
// Setting selection as floating cursor moves will have scroll view
// bring background cursor into view
getRenderEditor()!
.onSelectionChanged(newSelection, SelectionChangedCause.forcePress);
break;
case FloatingCursorDragState.End:
// We skip animation if no update has happened.
if (_lastTextPosition != null && _lastBoundedOffset != null) {
floatingCursorResetController
..value = 0.0
..animateTo(1,
duration: _floatingCursorResetTime, curve: Curves.decelerate);
}
break;
}
}
/// Specifies the floating cursor dimensions and position based
/// the animation controller value.
/// The floating cursor is resized
/// (see [RenderAbstractEditor.setFloatingCursor])
/// and repositioned (linear interpolation between position of floating cursor
/// and current position of background cursor)
void onFloatingCursorResetTick() {
final finalPosition =
getRenderEditor()!.getLocalRectForCaret(_lastTextPosition!).centerLeft -
_floatingCursorOffset(_lastTextPosition!);
if (floatingCursorResetController.isCompleted) {
getRenderEditor()!.setFloatingCursor(
FloatingCursorDragState.End, finalPosition, _lastTextPosition!);
_startCaretRect = null;
_lastTextPosition = null;
_pointOffsetOrigin = null;
_lastBoundedOffset = null;
} else {
final lerpValue = floatingCursorResetController.value;
final lerpX =
lerpDouble(_lastBoundedOffset!.dx, finalPosition.dx, lerpValue)!;
final lerpY =
lerpDouble(_lastBoundedOffset!.dy, finalPosition.dy, lerpValue)!;
getRenderEditor()!.setFloatingCursor(FloatingCursorDragState.Update,
Offset(lerpX, lerpY), _lastTextPosition!,
resetLerpValue: lerpValue);
}
} }
@override @override
@ -199,6 +294,5 @@ mixin RawEditorStateTextInputClientMixin on EditorState
_textInputConnection!.connectionClosedReceived(); _textInputConnection!.connectionClosedReceived();
_textInputConnection = null; _textInputConnection = null;
_lastKnownRemoteTextEditingValue = null; _lastKnownRemoteTextEditingValue = null;
_sentRemoteValues.clear();
} }
} }

@ -1,358 +0,0 @@
import 'dart:convert';
import 'dart:io' as io;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:string_validator/string_validator.dart';
import 'package:tuple/tuple.dart';
import '../models/documents/attribute.dart';
import '../models/documents/document.dart';
import '../models/documents/nodes/block.dart';
import '../models/documents/nodes/leaf.dart' as leaf;
import '../models/documents/nodes/line.dart';
import 'controller.dart';
import 'cursor.dart';
import 'default_styles.dart';
import 'delegate.dart';
import 'editor.dart';
import 'text_block.dart';
import 'text_line.dart';
import 'video_app.dart';
import 'youtube_video_app.dart';
class QuillSimpleViewer extends StatefulWidget {
const QuillSimpleViewer({
required this.controller,
required this.readOnly,
this.customStyles,
this.truncate = false,
this.truncateScale,
this.truncateAlignment,
this.truncateHeight,
this.truncateWidth,
this.scrollBottomInset = 0,
this.padding = EdgeInsets.zero,
this.embedBuilder,
Key? key,
}) : assert(truncate ||
((truncateScale == null) &&
(truncateAlignment == null) &&
(truncateHeight == null) &&
(truncateWidth == null))),
super(key: key);
final QuillController controller;
final DefaultStyles? customStyles;
final bool truncate;
final double? truncateScale;
final Alignment? truncateAlignment;
final double? truncateHeight;
final double? truncateWidth;
final double scrollBottomInset;
final EdgeInsetsGeometry padding;
final EmbedBuilder? embedBuilder;
final bool readOnly;
@override
_QuillSimpleViewerState createState() => _QuillSimpleViewerState();
}
class _QuillSimpleViewerState extends State<QuillSimpleViewer>
with SingleTickerProviderStateMixin {
late DefaultStyles _styles;
final LayerLink _toolbarLayerLink = LayerLink();
final LayerLink _startHandleLayerLink = LayerLink();
final LayerLink _endHandleLayerLink = LayerLink();
late CursorCont _cursorCont;
@override
void initState() {
super.initState();
_cursorCont = CursorCont(
show: ValueNotifier<bool>(false),
style: const CursorStyle(
color: Colors.black,
backgroundColor: Colors.grey,
width: 2,
radius: Radius.zero,
offset: Offset.zero,
),
tickerProvider: this,
);
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
final parentStyles = QuillStyles.getStyles(context, true);
final defaultStyles = DefaultStyles.getInstance(context);
_styles = (parentStyles != null)
? defaultStyles.merge(parentStyles)
: defaultStyles;
if (widget.customStyles != null) {
_styles = _styles.merge(widget.customStyles!);
}
}
EmbedBuilder get embedBuilder => widget.embedBuilder ?? _defaultEmbedBuilder;
Widget _defaultEmbedBuilder(
BuildContext context, leaf.Embed node, bool readOnly) {
assert(!kIsWeb, 'Please provide EmbedBuilder for Web');
switch (node.value.type) {
case 'image':
final imageUrl = _standardizeImageUrl(node.value.data);
return imageUrl.startsWith('http')
? Image.network(imageUrl)
: isBase64(imageUrl)
? Image.memory(base64.decode(imageUrl))
: Image.file(io.File(imageUrl));
case 'video':
final videoUrl = node.value.data;
if (videoUrl.contains('youtube.com') || videoUrl.contains('youtu.be')) {
return YoutubeVideoApp(
videoUrl: videoUrl, context: context, readOnly: readOnly);
}
return VideoApp(
videoUrl: videoUrl, context: context, readOnly: readOnly);
default:
throw UnimplementedError(
'Embeddable type "${node.value.type}" is not supported by default '
'embed builder of QuillEditor. You must pass your own builder '
'function to embedBuilder property of QuillEditor or QuillField '
'widgets.',
);
}
}
String _standardizeImageUrl(String url) {
if (url.contains('base64')) {
return url.split(',')[1];
}
return url;
}
@override
Widget build(BuildContext context) {
final _doc = widget.controller.document;
// if (_doc.isEmpty() &&
// !widget.focusNode.hasFocus &&
// widget.placeholder != null) {
// _doc = Document.fromJson(jsonDecode(
// '[{"attributes":{"placeholder":true},"insert":"${widget.placeholder}\\n"}]'));
// }
Widget child = CompositedTransformTarget(
link: _toolbarLayerLink,
child: Semantics(
child: _SimpleViewer(
document: _doc,
textDirection: _textDirection,
startHandleLayerLink: _startHandleLayerLink,
endHandleLayerLink: _endHandleLayerLink,
onSelectionChanged: _nullSelectionChanged,
scrollBottomInset: widget.scrollBottomInset,
padding: widget.padding,
children: _buildChildren(_doc, context),
),
),
);
if (widget.truncate) {
if (widget.truncateScale != null) {
child = Container(
height: widget.truncateHeight,
child: Align(
heightFactor: widget.truncateScale,
widthFactor: widget.truncateScale,
alignment: widget.truncateAlignment ?? Alignment.topLeft,
child: Container(
width: widget.truncateWidth! / widget.truncateScale!,
child: SingleChildScrollView(
physics: const NeverScrollableScrollPhysics(),
child: Transform.scale(
scale: widget.truncateScale!,
alignment:
widget.truncateAlignment ?? Alignment.topLeft,
child: child)))));
} else {
child = Container(
height: widget.truncateHeight,
width: widget.truncateWidth,
child: SingleChildScrollView(
physics: const NeverScrollableScrollPhysics(), child: child));
}
}
return QuillStyles(data: _styles, child: child);
}
List<Widget> _buildChildren(Document doc, BuildContext context) {
final result = <Widget>[];
final indentLevelCounts = <int, int>{};
for (final node in doc.root.children) {
if (node is Line) {
final editableTextLine = _getEditableTextLineFromNode(node, context);
result.add(editableTextLine);
} else if (node is Block) {
final attrs = node.style.attributes;
final editableTextBlock = EditableTextBlock(
block: node,
textDirection: _textDirection,
scrollBottomInset: widget.scrollBottomInset,
verticalSpacing: _getVerticalSpacingForBlock(node, _styles),
textSelection: widget.controller.selection,
color: Colors.black,
styles: _styles,
enableInteractiveSelection: false,
hasFocus: false,
contentPadding: attrs.containsKey(Attribute.codeBlock.key)
? const EdgeInsets.all(16)
: null,
embedBuilder: embedBuilder,
cursorCont: _cursorCont,
indentLevelCounts: indentLevelCounts,
onCheckboxTap: _handleCheckboxTap,
readOnly: widget.readOnly);
result.add(editableTextBlock);
} else {
throw StateError('Unreachable.');
}
}
return result;
}
/// Updates the checkbox positioned at [offset] in document
/// by changing its attribute according to [value].
void _handleCheckboxTap(int offset, bool value) {
// readonly - do nothing
}
TextDirection get _textDirection {
final result = Directionality.of(context);
return result;
}
EditableTextLine _getEditableTextLineFromNode(
Line node, BuildContext context) {
final textLine = TextLine(
line: node,
textDirection: _textDirection,
embedBuilder: embedBuilder,
styles: _styles,
readOnly: widget.readOnly,
);
final editableTextLine = EditableTextLine(
node,
null,
textLine,
0,
_getVerticalSpacingForLine(node, _styles),
_textDirection,
widget.controller.selection,
Colors.black,
//widget.selectionColor,
false,
//enableInteractiveSelection,
false,
//_hasFocus,
MediaQuery.of(context).devicePixelRatio,
_cursorCont);
return editableTextLine;
}
Tuple2<double, double> _getVerticalSpacingForLine(
Line line, DefaultStyles? defaultStyles) {
final attrs = line.style.attributes;
if (attrs.containsKey(Attribute.header.key)) {
final int? level = attrs[Attribute.header.key]!.value;
switch (level) {
case 1:
return defaultStyles!.h1!.verticalSpacing;
case 2:
return defaultStyles!.h2!.verticalSpacing;
case 3:
return defaultStyles!.h3!.verticalSpacing;
default:
throw 'Invalid level $level';
}
}
return defaultStyles!.paragraph!.verticalSpacing;
}
Tuple2<double, double> _getVerticalSpacingForBlock(
Block node, DefaultStyles? defaultStyles) {
final attrs = node.style.attributes;
if (attrs.containsKey(Attribute.blockQuote.key)) {
return defaultStyles!.quote!.verticalSpacing;
} else if (attrs.containsKey(Attribute.codeBlock.key)) {
return defaultStyles!.code!.verticalSpacing;
} else if (attrs.containsKey(Attribute.indent.key)) {
return defaultStyles!.indent!.verticalSpacing;
}
return defaultStyles!.lists!.verticalSpacing;
}
void _nullSelectionChanged(
TextSelection selection, SelectionChangedCause cause) {}
}
class _SimpleViewer extends MultiChildRenderObjectWidget {
_SimpleViewer({
required List<Widget> children,
required this.document,
required this.textDirection,
required this.startHandleLayerLink,
required this.endHandleLayerLink,
required this.onSelectionChanged,
required this.scrollBottomInset,
this.padding = EdgeInsets.zero,
Key? key,
}) : super(key: key, children: children);
final Document document;
final TextDirection textDirection;
final LayerLink startHandleLayerLink;
final LayerLink endHandleLayerLink;
final TextSelectionChangedHandler onSelectionChanged;
final double scrollBottomInset;
final EdgeInsetsGeometry padding;
@override
RenderEditor createRenderObject(BuildContext context) {
return RenderEditor(
null,
textDirection,
scrollBottomInset,
padding,
document,
const TextSelection(baseOffset: 0, extentOffset: 0),
false,
// hasFocus,
onSelectionChanged,
startHandleLayerLink,
endHandleLayerLink,
const EdgeInsets.fromLTRB(4, 4, 4, 5),
);
}
@override
void updateRenderObject(
BuildContext context, covariant RenderEditor renderObject) {
renderObject
..document = document
..setContainer(document.root)
..textDirection = textDirection
..setStartHandleLayerLink(startHandleLayerLink)
..setEndHandleLayerLink(endHandleLayerLink)
..onSelectionChanged = onSelectionChanged
..setScrollBottomInset(scrollBottomInset)
..setPadding(padding);
}
}

@ -0,0 +1,22 @@
import 'package:flutter/material.dart';
class QuillBulletPoint extends StatelessWidget {
const QuillBulletPoint({
required this.style,
required this.width,
Key? key,
}) : super(key: key);
final TextStyle style;
final double width;
@override
Widget build(BuildContext context) {
return Container(
alignment: AlignmentDirectional.topEnd,
width: width,
padding: const EdgeInsetsDirectional.only(end: 13),
child: Text('', style: style),
);
}
}

@ -0,0 +1,78 @@
import 'package:flutter/material.dart';
class CheckboxPoint extends StatefulWidget {
const CheckboxPoint({
required this.size,
required this.value,
required this.enabled,
required this.onChanged,
this.uiBuilder,
Key? key,
}) : super(key: key);
final double size;
final bool value;
final bool enabled;
final ValueChanged<bool> onChanged;
final QuillCheckboxBuilder? uiBuilder;
@override
_CheckboxPointState createState() => _CheckboxPointState();
}
class _CheckboxPointState extends State<CheckboxPoint> {
@override
Widget build(BuildContext context) {
if (widget.uiBuilder != null) {
return widget.uiBuilder!.build(
context: context,
isChecked: widget.value,
onChanged: widget.onChanged,
);
}
final theme = Theme.of(context);
final fillColor = widget.value
? (widget.enabled
? theme.colorScheme.primary
: theme.colorScheme.onSurface.withOpacity(0.5))
: theme.colorScheme.surface;
final borderColor = widget.value
? (widget.enabled
? theme.colorScheme.primary
: theme.colorScheme.onSurface.withOpacity(0))
: (widget.enabled
? theme.colorScheme.onSurface.withOpacity(0.5)
: theme.colorScheme.onSurface.withOpacity(0.3));
return Center(
child: SizedBox(
width: widget.size,
height: widget.size,
child: Material(
color: fillColor,
shape: RoundedRectangleBorder(
side: BorderSide(
color: borderColor,
),
borderRadius: BorderRadius.circular(2),
),
child: InkWell(
onTap:
widget.enabled ? () => widget.onChanged(!widget.value) : null,
child: widget.value
? Icon(Icons.check,
size: widget.size, color: theme.colorScheme.onPrimary)
: null,
),
),
),
);
}
}
abstract class QuillCheckboxBuilder {
Widget build({
required BuildContext context,
required bool isChecked,
required ValueChanged<bool> onChanged,
});
}

@ -0,0 +1,108 @@
import 'package:flutter/material.dart';
import '../../models/documents/attribute.dart';
import '../text_block.dart';
class QuillNumberPoint extends StatelessWidget {
const QuillNumberPoint({
required this.index,
required this.indentLevelCounts,
required this.count,
required this.style,
required this.width,
required this.attrs,
this.withDot = true,
this.padding = 0.0,
Key? key,
}) : super(key: key);
final int index;
final Map<int?, int> indentLevelCounts;
final int count;
final TextStyle style;
final double width;
final Map<String, Attribute> attrs;
final bool withDot;
final double padding;
@override
Widget build(BuildContext context) {
var s = index.toString();
int? level = 0;
if (!attrs.containsKey(Attribute.indent.key) &&
!indentLevelCounts.containsKey(1)) {
indentLevelCounts.clear();
return Container(
alignment: AlignmentDirectional.topEnd,
width: width,
padding: EdgeInsetsDirectional.only(end: padding),
child: Text(withDot ? '$s.' : s, style: style),
);
}
if (attrs.containsKey(Attribute.indent.key)) {
level = attrs[Attribute.indent.key]!.value;
} else {
// first level but is back from previous indent level
// supposed to be "2."
indentLevelCounts[0] = 1;
}
if (indentLevelCounts.containsKey(level! + 1)) {
// last visited level is done, going up
indentLevelCounts.remove(level + 1);
}
final count = (indentLevelCounts[level] ?? 0) + 1;
indentLevelCounts[level] = count;
s = count.toString();
if (level % 3 == 1) {
// a. b. c. d. e. ...
s = _toExcelSheetColumnTitle(count);
} else if (level % 3 == 2) {
// i. ii. iii. ...
s = _intToRoman(count);
}
// level % 3 == 0 goes back to 1. 2. 3.
return Container(
alignment: AlignmentDirectional.topEnd,
width: width,
padding: EdgeInsetsDirectional.only(end: padding),
child: Text(withDot ? '$s.' : s, style: style),
);
}
String _toExcelSheetColumnTitle(int n) {
final result = StringBuffer();
while (n > 0) {
n--;
result.write(String.fromCharCode((n % 26).floor() + 97));
n = (n / 26).floor();
}
return result.toString().split('').reversed.join();
}
String _intToRoman(int input) {
var num = input;
if (num < 0) {
return '';
} else if (num == 0) {
return 'nulla';
}
final builder = StringBuffer();
for (var a = 0; a < arabianRomanNumbers.length; a++) {
final times = (num / arabianRomanNumbers[a])
.truncate(); // equals 1 only when arabianRomanNumbers[a] = num
// executes n times where n is the number of times you have to add
// the current roman number value to reach current num.
builder.write(romanNumbers[a] * times);
num -= times *
arabianRomanNumbers[
a]; // subtract previous roman number value from num
}
return builder.toString().toLowerCase();
}
}

@ -0,0 +1,3 @@
export 'bullet_point.dart';
export 'checkbox_point.dart';
export 'number_point.dart';

@ -1,16 +1,14 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart'; import 'package:flutter/rendering.dart';
import 'package:tuple/tuple.dart'; import 'package:tuple/tuple.dart';
import '../models/documents/attribute.dart'; import '../../flutter_quill.dart';
import '../models/documents/nodes/block.dart'; import '../models/documents/nodes/block.dart';
import '../models/documents/nodes/line.dart'; import '../models/documents/nodes/line.dart';
import 'box.dart'; import 'box.dart';
import 'cursor.dart'; import 'cursor.dart';
import 'default_styles.dart';
import 'delegate.dart'; import 'delegate.dart';
import 'editor.dart'; import 'link.dart';
import 'text_line.dart'; import 'text_line.dart';
import 'text_selection.dart'; import 'text_selection.dart';
@ -49,6 +47,7 @@ const List<String> romanNumbers = [
class EditableTextBlock extends StatelessWidget { class EditableTextBlock extends StatelessWidget {
const EditableTextBlock( const EditableTextBlock(
{required this.block, {required this.block,
required this.controller,
required this.textDirection, required this.textDirection,
required this.scrollBottomInset, required this.scrollBottomInset,
required this.verticalSpacing, required this.verticalSpacing,
@ -59,14 +58,17 @@ class EditableTextBlock extends StatelessWidget {
required this.hasFocus, required this.hasFocus,
required this.contentPadding, required this.contentPadding,
required this.embedBuilder, required this.embedBuilder,
required this.linkActionPicker,
required this.cursorCont, required this.cursorCont,
required this.indentLevelCounts, required this.indentLevelCounts,
required this.onCheckboxTap, required this.onCheckboxTap,
required this.readOnly, required this.readOnly,
this.onLaunchUrl,
this.customStyleBuilder, this.customStyleBuilder,
Key? key}); Key? key});
final Block block; final Block block;
final QuillController controller;
final TextDirection textDirection; final TextDirection textDirection;
final double scrollBottomInset; final double scrollBottomInset;
final Tuple2 verticalSpacing; final Tuple2 verticalSpacing;
@ -77,6 +79,8 @@ class EditableTextBlock extends StatelessWidget {
final bool hasFocus; final bool hasFocus;
final EdgeInsets? contentPadding; final EdgeInsets? contentPadding;
final EmbedBuilder embedBuilder; final EmbedBuilder embedBuilder;
final LinkActionPicker linkActionPicker;
final ValueChanged<String>? onLaunchUrl;
final CustomStyleBuilder? customStyleBuilder; final CustomStyleBuilder? customStyleBuilder;
final CursorCont cursorCont; final CursorCont cursorCont;
final Map<int, int> indentLevelCounts; final Map<int, int> indentLevelCounts;
@ -128,6 +132,9 @@ class EditableTextBlock extends StatelessWidget {
customStyleBuilder: customStyleBuilder, customStyleBuilder: customStyleBuilder,
styles: styles!, styles: styles!,
readOnly: readOnly, readOnly: readOnly,
controller: controller,
linkActionPicker: linkActionPicker,
onLaunchUrl: onLaunchUrl,
), ),
_getIndentWidth(), _getIndentWidth(),
_getSpacingForLine(line, index, count, defaultStyles), _getSpacingForLine(line, index, count, defaultStyles),
@ -148,7 +155,7 @@ class EditableTextBlock extends StatelessWidget {
final defaultStyles = QuillStyles.getStyles(context, false); final defaultStyles = QuillStyles.getStyles(context, false);
final attrs = line.style.attributes; final attrs = line.style.attributes;
if (attrs[Attribute.list.key] == Attribute.ol) { if (attrs[Attribute.list.key] == Attribute.ol) {
return _NumberPoint( return QuillNumberPoint(
index: index, index: index,
indentLevelCounts: indentLevelCounts, indentLevelCounts: indentLevelCounts,
count: count, count: count,
@ -160,7 +167,7 @@ class EditableTextBlock extends StatelessWidget {
} }
if (attrs[Attribute.list.key] == Attribute.ul) { if (attrs[Attribute.list.key] == Attribute.ul) {
return _BulletPoint( return QuillBulletPoint(
style: style:
defaultStyles!.leading!.style.copyWith(fontWeight: FontWeight.bold), defaultStyles!.leading!.style.copyWith(fontWeight: FontWeight.bold),
width: 32, width: 32,
@ -168,28 +175,25 @@ class EditableTextBlock extends StatelessWidget {
} }
if (attrs[Attribute.list.key] == Attribute.checked) { if (attrs[Attribute.list.key] == Attribute.checked) {
return _Checkbox( return CheckboxPoint(
key: UniqueKey(), size: 14,
style: defaultStyles!.leading!.style, value: true,
width: 32, enabled: !readOnly,
isChecked: true, onChanged: (checked) => onCheckboxTap(line.documentOffset, checked),
offset: block.offset + line.offset,
onTap: onCheckboxTap,
); );
} }
if (attrs[Attribute.list.key] == Attribute.unchecked) { if (attrs[Attribute.list.key] == Attribute.unchecked) {
return _Checkbox( return CheckboxPoint(
key: UniqueKey(), size: 14,
style: defaultStyles!.leading!.style, value: false,
width: 32, enabled: !readOnly,
offset: block.offset + line.offset, onChanged: (checked) => onCheckboxTap(line.documentOffset, checked),
onTap: onCheckboxTap,
); );
} }
if (attrs.containsKey(Attribute.codeBlock.key)) { if (attrs.containsKey(Attribute.codeBlock.key)) {
return _NumberPoint( return QuillNumberPoint(
index: index, index: index,
indentLevelCounts: indentLevelCounts, indentLevelCounts: indentLevelCounts,
count: count, count: count,
@ -217,7 +221,14 @@ class EditableTextBlock extends StatelessWidget {
return 16.0 + extraIndent; return 16.0 + extraIndent;
} }
return 32.0 + extraIndent; var baseIndent = 0.0;
if (attrs.containsKey(Attribute.list.key) ||
attrs.containsKey(Attribute.codeBlock.key)) {
baseIndent = 32.0;
}
return baseIndent + extraIndent;
} }
Tuple2 _getSpacingForLine( Tuple2 _getSpacingForLine(
@ -551,6 +562,16 @@ class RenderEditableTextBlock extends RenderEditableContainerBox
affinity: position.affinity, affinity: position.affinity,
); );
} }
@override
Rect getCaretPrototype(TextPosition position) {
final child = childAtPosition(position);
final localPosition = TextPosition(
offset: position.offset - child.getContainer().offset,
affinity: position.affinity,
);
return child.getCaretPrototype(localPosition);
}
} }
class _EditableBlock extends MultiChildRenderObjectWidget { class _EditableBlock extends MultiChildRenderObjectWidget {
@ -600,166 +621,3 @@ class _EditableBlock extends MultiChildRenderObjectWidget {
..contentPadding = _contentPadding; ..contentPadding = _contentPadding;
} }
} }
class _NumberPoint extends StatelessWidget {
const _NumberPoint({
required this.index,
required this.indentLevelCounts,
required this.count,
required this.style,
required this.width,
required this.attrs,
this.withDot = true,
this.padding = 0.0,
Key? key,
}) : super(key: key);
final int index;
final Map<int?, int> indentLevelCounts;
final int count;
final TextStyle style;
final double width;
final Map<String, Attribute> attrs;
final bool withDot;
final double padding;
@override
Widget build(BuildContext context) {
var s = index.toString();
int? level = 0;
if (!attrs.containsKey(Attribute.indent.key) &&
!indentLevelCounts.containsKey(1)) {
indentLevelCounts.clear();
return Container(
alignment: AlignmentDirectional.topEnd,
width: width,
padding: EdgeInsetsDirectional.only(end: padding),
child: Text(withDot ? '$s.' : s, style: style),
);
}
if (attrs.containsKey(Attribute.indent.key)) {
level = attrs[Attribute.indent.key]!.value;
} else {
// first level but is back from previous indent level
// supposed to be "2."
indentLevelCounts[0] = 1;
}
if (indentLevelCounts.containsKey(level! + 1)) {
// last visited level is done, going up
indentLevelCounts.remove(level + 1);
}
final count = (indentLevelCounts[level] ?? 0) + 1;
indentLevelCounts[level] = count;
s = count.toString();
if (level % 3 == 1) {
// a. b. c. d. e. ...
s = _toExcelSheetColumnTitle(count);
} else if (level % 3 == 2) {
// i. ii. iii. ...
s = _intToRoman(count);
}
// level % 3 == 0 goes back to 1. 2. 3.
return Container(
alignment: AlignmentDirectional.topEnd,
width: width,
padding: EdgeInsetsDirectional.only(end: padding),
child: Text(withDot ? '$s.' : s, style: style),
);
}
String _toExcelSheetColumnTitle(int n) {
final result = StringBuffer();
while (n > 0) {
n--;
result.write(String.fromCharCode((n % 26).floor() + 97));
n = (n / 26).floor();
}
return result.toString().split('').reversed.join();
}
String _intToRoman(int input) {
var num = input;
if (num < 0) {
return '';
} else if (num == 0) {
return 'nulla';
}
final builder = StringBuffer();
for (var a = 0; a < arabianRomanNumbers.length; a++) {
final times = (num / arabianRomanNumbers[a])
.truncate(); // equals 1 only when arabianRomanNumbers[a] = num
// executes n times where n is the number of times you have to add
// the current roman number value to reach current num.
builder.write(romanNumbers[a] * times);
num -= times *
arabianRomanNumbers[
a]; // subtract previous roman number value from num
}
return builder.toString().toLowerCase();
}
}
class _BulletPoint extends StatelessWidget {
const _BulletPoint({
required this.style,
required this.width,
Key? key,
}) : super(key: key);
final TextStyle style;
final double width;
@override
Widget build(BuildContext context) {
return Container(
alignment: AlignmentDirectional.topEnd,
width: width,
padding: const EdgeInsetsDirectional.only(end: 13),
child: Text('', style: style),
);
}
}
class _Checkbox extends StatelessWidget {
const _Checkbox({
Key? key,
this.style,
this.width,
this.isChecked = false,
this.offset,
this.onTap,
}) : super(key: key);
final TextStyle? style;
final double? width;
final bool isChecked;
final int? offset;
final Function(int, bool)? onTap;
void _onCheckboxClicked(bool? newValue) {
if (onTap != null && newValue != null && offset != null) {
onTap!(offset!, newValue);
}
}
@override
Widget build(BuildContext context) {
return Container(
alignment: AlignmentDirectional.topEnd,
width: width,
padding: const EdgeInsetsDirectional.only(end: 13),
child: GestureDetector(
onLongPress: () => _onCheckboxClicked(!isChecked),
child: Checkbox(
value: isChecked,
onChanged: _onCheckboxClicked,
),
),
);
}
}

@ -2,30 +2,37 @@ import 'dart:collection';
import 'dart:math' as math; import 'dart:math' as math;
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart'; import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:tuple/tuple.dart'; import 'package:tuple/tuple.dart';
import 'package:url_launcher/url_launcher.dart';
import '../models/documents/attribute.dart'; import '../../flutter_quill.dart';
import '../models/documents/nodes/container.dart' as container; import '../models/documents/nodes/container.dart' as container;
import '../models/documents/nodes/leaf.dart' as leaf; import '../models/documents/nodes/leaf.dart' as leaf;
import '../models/documents/nodes/leaf.dart';
import '../models/documents/nodes/line.dart'; import '../models/documents/nodes/line.dart';
import '../models/documents/nodes/node.dart'; import '../models/documents/nodes/node.dart';
import '../models/documents/style.dart';
import '../utils/color.dart'; import '../utils/color.dart';
import 'box.dart'; import 'box.dart';
import 'cursor.dart'; import 'cursor.dart';
import 'default_styles.dart';
import 'delegate.dart'; import 'delegate.dart';
import 'keyboard_listener.dart';
import 'link.dart';
import 'proxy.dart'; import 'proxy.dart';
import 'text_selection.dart'; import 'text_selection.dart';
class TextLine extends StatelessWidget { class TextLine extends StatefulWidget {
const TextLine({ const TextLine({
required this.line, required this.line,
required this.embedBuilder, required this.embedBuilder,
required this.styles, required this.styles,
required this.readOnly, required this.readOnly,
required this.controller,
required this.onLaunchUrl,
required this.linkActionPicker,
this.textDirection, this.textDirection,
this.customStyleBuilder, this.customStyleBuilder,
Key? key, Key? key,
@ -36,56 +43,141 @@ class TextLine extends StatelessWidget {
final EmbedBuilder embedBuilder; final EmbedBuilder embedBuilder;
final DefaultStyles styles; final DefaultStyles styles;
final bool readOnly; final bool readOnly;
final QuillController controller;
final CustomStyleBuilder? customStyleBuilder; final CustomStyleBuilder? customStyleBuilder;
final ValueChanged<String>? onLaunchUrl;
final LinkActionPicker linkActionPicker;
@override
State<TextLine> createState() => _TextLineState();
}
class _TextLineState extends State<TextLine> {
bool _metaOrControlPressed = false;
UniqueKey _richTextKey = UniqueKey();
final _linkRecognizers = <Node, GestureRecognizer>{};
QuillPressedKeys? _pressedKeys;
void _pressedKeysChanged() {
final newValue = _pressedKeys!.metaPressed || _pressedKeys!.controlPressed;
if (_metaOrControlPressed != newValue) {
setState(() {
_metaOrControlPressed = newValue;
_richTextKey = UniqueKey();
});
}
}
bool get isDesktop => {
TargetPlatform.macOS,
TargetPlatform.linux,
TargetPlatform.windows
}.contains(defaultTargetPlatform);
bool get canLaunchLinks {
// In readOnly mode users can launch links
// by simply tapping (clicking) on them
if (widget.readOnly) return true;
// In editing mode it depends on the platform:
// Desktop platforms (macos, linux, windows):
// only allow Meta(Control)+Click combinations
if (isDesktop) {
return _metaOrControlPressed;
}
// Mobile platforms (ios, android): always allow but we install a
// long-press handler instead of a tap one. LongPress is followed by a
// context menu with actions.
return true;
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (_pressedKeys == null) {
_pressedKeys = QuillPressedKeys.of(context);
_pressedKeys!.addListener(_pressedKeysChanged);
} else {
_pressedKeys!.removeListener(_pressedKeysChanged);
_pressedKeys = QuillPressedKeys.of(context);
_pressedKeys!.addListener(_pressedKeysChanged);
}
}
@override
void didUpdateWidget(covariant TextLine oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.readOnly != widget.readOnly) {
_richTextKey = UniqueKey();
_linkRecognizers
..forEach((key, value) {
value.dispose();
})
..clear();
}
}
@override
void dispose() {
_pressedKeys?.removeListener(_pressedKeysChanged);
_linkRecognizers
..forEach((key, value) => value.dispose())
..clear();
super.dispose();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
assert(debugCheckHasMediaQuery(context)); assert(debugCheckHasMediaQuery(context));
if (line.hasEmbed && line.childCount == 1) { if (widget.line.hasEmbed && widget.line.childCount == 1) {
// For video, it is always single child // For video, it is always single child
final embed = line.children.single as Embed; final embed = widget.line.children.single as Embed;
return EmbedProxy(embedBuilder(context, embed, readOnly)); return EmbedProxy(widget.embedBuilder(context, embed, widget.readOnly));
} }
final textSpan = _getTextSpanForWholeLine(context); final textSpan = _getTextSpanForWholeLine(context);
final strutStyle = StrutStyle.fromTextStyle(textSpan.style!); final strutStyle = StrutStyle.fromTextStyle(textSpan.style!);
final textAlign = _getTextAlign(); final textAlign = _getTextAlign();
final child = RichText( final child = RichText(
key: _richTextKey,
text: textSpan, text: textSpan,
textAlign: textAlign, textAlign: textAlign,
textDirection: textDirection, textDirection: widget.textDirection,
strutStyle: strutStyle, strutStyle: strutStyle,
textScaleFactor: MediaQuery.textScaleFactorOf(context), textScaleFactor: MediaQuery.textScaleFactorOf(context),
); );
return RichTextProxy( return RichTextProxy(
child, textStyle: textSpan.style!,
textSpan.style!, textAlign: textAlign,
textAlign, textDirection: widget.textDirection!,
textDirection!, strutStyle: strutStyle,
1, locale: Localizations.localeOf(context),
Localizations.localeOf(context), child: child);
strutStyle,
TextWidthBasis.parent,
null);
} }
InlineSpan _getTextSpanForWholeLine(BuildContext context) { InlineSpan _getTextSpanForWholeLine(BuildContext context) {
final lineStyle = _getLineStyle(styles); final lineStyle = _getLineStyle(widget.styles);
if (!line.hasEmbed) { if (!widget.line.hasEmbed) {
return _buildTextSpan(styles, line.children, lineStyle); return _buildTextSpan(widget.styles, widget.line.children, lineStyle);
} }
// The line could contain more than one Embed & more than one Text // The line could contain more than one Embed & more than one Text
final textSpanChildren = <InlineSpan>[]; final textSpanChildren = <InlineSpan>[];
var textNodes = LinkedList<Node>(); var textNodes = LinkedList<Node>();
for (final child in line.children) { for (final child in widget.line.children) {
if (child is Embed) { if (child is Embed) {
if (textNodes.isNotEmpty) { if (textNodes.isNotEmpty) {
textSpanChildren.add(_buildTextSpan(styles, textNodes, lineStyle)); textSpanChildren
.add(_buildTextSpan(widget.styles, textNodes, lineStyle));
textNodes = LinkedList<Node>(); textNodes = LinkedList<Node>();
} }
// Here it should be image // Here it should be image
final embed = WidgetSpan( final embed = WidgetSpan(
child: EmbedProxy(embedBuilder(context, child, readOnly))); child: EmbedProxy(
widget.embedBuilder(context, child, widget.readOnly)));
textSpanChildren.add(embed); textSpanChildren.add(embed);
continue; continue;
} }
@ -95,20 +187,20 @@ class TextLine extends StatelessWidget {
} }
if (textNodes.isNotEmpty) { if (textNodes.isNotEmpty) {
textSpanChildren.add(_buildTextSpan(styles, textNodes, lineStyle)); textSpanChildren.add(_buildTextSpan(widget.styles, textNodes, lineStyle));
} }
return TextSpan(style: lineStyle, children: textSpanChildren); return TextSpan(style: lineStyle, children: textSpanChildren);
} }
TextAlign _getTextAlign() { TextAlign _getTextAlign() {
final alignment = line.style.attributes[Attribute.align.key]; final alignment = widget.line.style.attributes[Attribute.align.key];
if (alignment == Attribute.leftAlignment) { if (alignment == Attribute.leftAlignment) {
return TextAlign.left; return TextAlign.start;
} else if (alignment == Attribute.centerAlignment) { } else if (alignment == Attribute.centerAlignment) {
return TextAlign.center; return TextAlign.center;
} else if (alignment == Attribute.rightAlignment) { } else if (alignment == Attribute.rightAlignment) {
return TextAlign.right; return TextAlign.end;
} else if (alignment == Attribute.justifyAlignment) { } else if (alignment == Attribute.justifyAlignment) {
return TextAlign.justify; return TextAlign.justify;
} }
@ -118,7 +210,8 @@ class TextLine extends StatelessWidget {
TextSpan _buildTextSpan(DefaultStyles defaultStyles, LinkedList<Node> nodes, TextSpan _buildTextSpan(DefaultStyles defaultStyles, LinkedList<Node> nodes,
TextStyle lineStyle) { TextStyle lineStyle) {
final children = nodes final children = nodes
.map((node) => _getTextSpanFromNode(defaultStyles, node)) .map((node) =>
_getTextSpanFromNode(defaultStyles, node, widget.line.style))
.toList(growable: false); .toList(growable: false);
return TextSpan(children: children, style: lineStyle); return TextSpan(children: children, style: lineStyle);
@ -127,11 +220,11 @@ class TextLine extends StatelessWidget {
TextStyle _getLineStyle(DefaultStyles defaultStyles) { TextStyle _getLineStyle(DefaultStyles defaultStyles) {
var textStyle = const TextStyle(); var textStyle = const TextStyle();
if (line.style.containsKey(Attribute.placeholder.key)) { if (widget.line.style.containsKey(Attribute.placeholder.key)) {
return defaultStyles.placeHolder!.style; return defaultStyles.placeHolder!.style;
} }
final header = line.style.attributes[Attribute.header.key]; final header = widget.line.style.attributes[Attribute.header.key];
final m = <Attribute, TextStyle>{ final m = <Attribute, TextStyle>{
Attribute.h1: defaultStyles.h1!.style, Attribute.h1: defaultStyles.h1!.style,
Attribute.h2: defaultStyles.h2!.style, Attribute.h2: defaultStyles.h2!.style,
@ -140,52 +233,75 @@ class TextLine extends StatelessWidget {
textStyle = textStyle.merge(m[header] ?? defaultStyles.paragraph!.style); textStyle = textStyle.merge(m[header] ?? defaultStyles.paragraph!.style);
final block = line.style.getBlockExceptHeader(); // Only retrieve exclusive block format for the line style purpose
Attribute? block;
widget.line.style.getBlocksExceptHeader().forEach((key, value) {
if (Attribute.exclusiveBlockKeys.contains(key)) {
block = value;
}
});
TextStyle? toMerge; TextStyle? toMerge;
if (block == Attribute.blockQuote) { if (block == Attribute.blockQuote) {
toMerge = defaultStyles.quote!.style; toMerge = defaultStyles.quote!.style;
} else if (block == Attribute.codeBlock) { } else if (block == Attribute.codeBlock) {
toMerge = defaultStyles.code!.style; toMerge = defaultStyles.code!.style;
} else if (block != null) { } else if (block == Attribute.list) {
toMerge = defaultStyles.lists!.style; toMerge = defaultStyles.lists!.style;
} }
textStyle = textStyle.merge(toMerge); textStyle = textStyle.merge(toMerge);
textStyle = _applyCustomAttributes(textStyle, line.style.attributes); textStyle = _applyCustomAttributes(textStyle, widget.line.style.attributes);
return textStyle; return textStyle;
} }
TextStyle _applyCustomAttributes( TextStyle _applyCustomAttributes(
TextStyle textStyle, Map<String, Attribute> attributes) { TextStyle textStyle, Map<String, Attribute> attributes) {
if (customStyleBuilder == null) { if (widget.customStyleBuilder == null) {
return textStyle; return textStyle;
} }
attributes.keys.forEach((key) { attributes.keys.forEach((key) {
final attr = attributes[key]; final attr = attributes[key];
if (attr != null) { if (attr != null) {
/// Custom Attribute /// Custom Attribute
final customAttr = customStyleBuilder!.call(attr); final customAttr = widget.customStyleBuilder!.call(attr);
textStyle = textStyle.merge(customAttr); textStyle = textStyle.merge(customAttr);
} }
}); });
return textStyle; return textStyle;
} }
TextSpan _getTextSpanFromNode(DefaultStyles defaultStyles, Node node) { TextSpan _getTextSpanFromNode(
DefaultStyles defaultStyles, Node node, Style lineStyle) {
final textNode = node as leaf.Text; final textNode = node as leaf.Text;
final style = textNode.style; final nodeStyle = textNode.style;
final isLink = nodeStyle.containsKey(Attribute.link.key) &&
nodeStyle.attributes[Attribute.link.key]!.value != null;
return TextSpan(
text: textNode.value,
style: _getInlineTextStyle(
textNode, defaultStyles, nodeStyle, lineStyle, isLink),
recognizer: isLink && canLaunchLinks ? _getRecognizer(node) : null,
mouseCursor: isLink && canLaunchLinks ? SystemMouseCursors.click : null,
);
}
TextStyle _getInlineTextStyle(leaf.Text textNode, DefaultStyles defaultStyles,
Style nodeStyle, Style lineStyle, bool isLink) {
var res = const TextStyle(); // This is inline text style var res = const TextStyle(); // This is inline text style
final color = textNode.style.attributes[Attribute.color.key]; final color = textNode.style.attributes[Attribute.color.key];
<String, TextStyle?>{ <String, TextStyle?>{
Attribute.bold.key: defaultStyles.bold, Attribute.bold.key: defaultStyles.bold,
Attribute.italic.key: defaultStyles.italic, Attribute.italic.key: defaultStyles.italic,
Attribute.small.key: defaultStyles.small,
Attribute.link.key: defaultStyles.link, Attribute.link.key: defaultStyles.link,
Attribute.underline.key: defaultStyles.underline, Attribute.underline.key: defaultStyles.underline,
Attribute.strikeThrough.key: defaultStyles.strikeThrough, Attribute.strikeThrough.key: defaultStyles.strikeThrough,
}.forEach((k, s) { }.forEach((k, s) {
if (style.values.any((v) => v.key == k)) { if (nodeStyle.values.any((v) => v.key == k)) {
if (k == Attribute.underline.key || k == Attribute.strikeThrough.key) { if (k == Attribute.underline.key || k == Attribute.strikeThrough.key) {
var textColor = defaultStyles.color; var textColor = defaultStyles.color;
if (color?.value is String) { if (color?.value is String) {
@ -193,12 +309,19 @@ class TextLine extends StatelessWidget {
} }
res = _merge(res.copyWith(decorationColor: textColor), res = _merge(res.copyWith(decorationColor: textColor),
s!.copyWith(decorationColor: textColor)); s!.copyWith(decorationColor: textColor));
} else if (k == Attribute.link.key && !isLink) {
// null value for link should be ignored
// i.e. nodeStyle.attributes[Attribute.link.key]!.value == null
} else { } else {
res = _merge(res, s!); res = _merge(res, s!);
} }
} }
}); });
if (nodeStyle.containsKey(Attribute.inlineCode.key)) {
res = _merge(res, defaultStyles.inlineCode!.styleFor(lineStyle));
}
final font = textNode.style.attributes[Attribute.font.key]; final font = textNode.style.attributes[Attribute.font.key];
if (font != null && font.value != null) { if (font != null && font.value != null) {
res = res.merge(TextStyle(fontFamily: font.value)); res = res.merge(TextStyle(fontFamily: font.value));
@ -217,7 +340,14 @@ class TextLine extends StatelessWidget {
res = res.merge(defaultStyles.sizeHuge); res = res.merge(defaultStyles.sizeHuge);
break; break;
default: default:
final fontSize = double.tryParse(size.value); double? fontSize;
if (size.value is double) {
fontSize = size.value;
} else if (size.value is int) {
fontSize = size.value.toDouble();
} else if (size.value is String) {
fontSize = double.tryParse(size.value);
}
if (fontSize != null) { if (fontSize != null) {
res = res.merge(TextStyle(fontSize: fontSize)); res = res.merge(TextStyle(fontSize: fontSize));
} else { } else {
@ -243,7 +373,96 @@ class TextLine extends StatelessWidget {
} }
res = _applyCustomAttributes(res, textNode.style.attributes); res = _applyCustomAttributes(res, textNode.style.attributes);
return TextSpan(text: textNode.value, style: res); return res;
}
GestureRecognizer _getRecognizer(Node segment) {
if (_linkRecognizers.containsKey(segment)) {
return _linkRecognizers[segment]!;
}
if (isDesktop || widget.readOnly) {
_linkRecognizers[segment] = TapGestureRecognizer()
..onTap = () => _tapNodeLink(segment);
} else {
_linkRecognizers[segment] = LongPressGestureRecognizer()
..onLongPress = () => _longPressLink(segment);
}
return _linkRecognizers[segment]!;
}
Future<void> _launchUrl(String url) async {
await launch(url);
}
void _tapNodeLink(Node node) {
final link = node.style.attributes[Attribute.link.key]!.value;
_tapLink(link);
}
void _tapLink(String? link) {
if (!widget.readOnly || link == null) {
return;
}
var launchUrl = widget.onLaunchUrl;
launchUrl ??= _launchUrl;
link = link.trim();
if (!linkPrefixes
.any((linkPrefix) => link!.toLowerCase().startsWith(linkPrefix))) {
link = 'https://$link';
}
launchUrl(link);
}
Future<void> _longPressLink(Node node) async {
final link = node.style.attributes[Attribute.link.key]!.value!;
final action = await widget.linkActionPicker(node);
switch (action) {
case LinkMenuAction.launch:
_tapLink(link);
break;
case LinkMenuAction.copy:
// ignore: unawaited_futures
Clipboard.setData(ClipboardData(text: link));
break;
case LinkMenuAction.remove:
final range = _getLinkRange(node);
widget.controller
.formatText(range.start, range.end - range.start, Attribute.link);
break;
case LinkMenuAction.none:
break;
}
}
TextRange _getLinkRange(Node node) {
var start = node.documentOffset;
var length = node.length;
var prev = node.previous;
final linkAttr = node.style.attributes[Attribute.link.key]!;
while (prev != null) {
if (prev.style.attributes[Attribute.link.key] == linkAttr) {
start = prev.documentOffset;
length += prev.length;
prev = prev.previous;
} else {
break;
}
}
var next = node.next;
while (next != null) {
if (next.style.attributes[Attribute.link.key] == linkAttr) {
length += next.length;
next = next.next;
} else {
break;
}
}
return TextRange(start: start, end: start + length);
} }
TextStyle _merge(TextStyle a, TextStyle b) { TextStyle _merge(TextStyle a, TextStyle b) {
@ -296,6 +515,7 @@ class EditableTextLine extends RenderObjectWidget {
@override @override
RenderObject createRenderObject(BuildContext context) { RenderObject createRenderObject(BuildContext context) {
final defaultStyles = DefaultStyles.getInstance(context);
return RenderEditableTextLine( return RenderEditableTextLine(
line, line,
textDirection, textDirection,
@ -305,12 +525,14 @@ class EditableTextLine extends RenderObjectWidget {
devicePixelRatio, devicePixelRatio,
_getPadding(), _getPadding(),
color, color,
cursorCont); cursorCont,
defaultStyles.inlineCode!);
} }
@override @override
void updateRenderObject( void updateRenderObject(
BuildContext context, covariant RenderEditableTextLine renderObject) { BuildContext context, covariant RenderEditableTextLine renderObject) {
final defaultStyles = DefaultStyles.getInstance(context);
renderObject renderObject
..setLine(line) ..setLine(line)
..setPadding(_getPadding()) ..setPadding(_getPadding())
@ -320,7 +542,8 @@ class EditableTextLine extends RenderObjectWidget {
..setEnableInteractiveSelection(enableInteractiveSelection) ..setEnableInteractiveSelection(enableInteractiveSelection)
..hasFocus = hasFocus ..hasFocus = hasFocus
..setDevicePixelRatio(devicePixelRatio) ..setDevicePixelRatio(devicePixelRatio)
..setCursorCont(cursorCont); ..setCursorCont(cursorCont)
..setInlineCodeStyle(defaultStyles.inlineCode!);
} }
EdgeInsetsGeometry _getPadding() { EdgeInsetsGeometry _getPadding() {
@ -334,6 +557,7 @@ class EditableTextLine extends RenderObjectWidget {
enum TextLineSlot { LEADING, BODY } enum TextLineSlot { LEADING, BODY }
class RenderEditableTextLine extends RenderEditableBox { class RenderEditableTextLine extends RenderEditableBox {
/// Creates new editable paragraph render box.
RenderEditableTextLine( RenderEditableTextLine(
this.line, this.line,
this.textDirection, this.textDirection,
@ -344,7 +568,7 @@ class RenderEditableTextLine extends RenderEditableBox {
this.padding, this.padding,
this.color, this.color,
this.cursorCont, this.cursorCont,
); this.inlineCodeStyle);
RenderBox? _leading; RenderBox? _leading;
RenderContentProxyBox? _body; RenderContentProxyBox? _body;
@ -360,7 +584,8 @@ class RenderEditableTextLine extends RenderEditableBox {
EdgeInsets? _resolvedPadding; EdgeInsets? _resolvedPadding;
bool? _containsCursor; bool? _containsCursor;
List<TextBox>? _selectedRects; List<TextBox>? _selectedRects;
Rect? _caretPrototype; late Rect _caretPrototype;
InlineCodeStyle inlineCodeStyle;
final Map<TextLineSlot, RenderBox> children = <TextLineSlot, RenderBox>{}; final Map<TextLineSlot, RenderBox> children = <TextLineSlot, RenderBox>{};
Iterable<RenderBox> get _children sync* { Iterable<RenderBox> get _children sync* {
@ -404,7 +629,7 @@ class RenderEditableTextLine extends RenderEditableBox {
color = c; color = c;
if (containsTextSelection()) { if (containsTextSelection()) {
markNeedsPaint(); safeMarkNeedsPaint();
} }
} }
@ -414,9 +639,10 @@ class RenderEditableTextLine extends RenderEditableBox {
} }
final containsSelection = containsTextSelection(); final containsSelection = containsTextSelection();
if (attached && containsCursor()) { if (_attachedToCursorController) {
cursorCont.removeListener(markNeedsLayout); cursorCont.removeListener(markNeedsLayout);
cursorCont.color.removeListener(markNeedsPaint); cursorCont.color.removeListener(safeMarkNeedsPaint);
_attachedToCursorController = false;
} }
textSelection = t; textSelection = t;
@ -424,11 +650,12 @@ class RenderEditableTextLine extends RenderEditableBox {
_containsCursor = null; _containsCursor = null;
if (attached && containsCursor()) { if (attached && containsCursor()) {
cursorCont.addListener(markNeedsLayout); cursorCont.addListener(markNeedsLayout);
cursorCont.color.addListener(markNeedsPaint); cursorCont.color.addListener(safeMarkNeedsPaint);
_attachedToCursorController = true;
} }
if (containsSelection || containsTextSelection()) { if (containsSelection || containsTextSelection()) {
markNeedsPaint(); safeMarkNeedsPaint();
} }
} }
@ -468,13 +695,24 @@ class RenderEditableTextLine extends RenderEditableBox {
_body = _updateChild(_body, b, TextLineSlot.BODY) as RenderContentProxyBox?; _body = _updateChild(_body, b, TextLineSlot.BODY) as RenderContentProxyBox?;
} }
void setInlineCodeStyle(InlineCodeStyle newStyle) {
if (inlineCodeStyle == newStyle) return;
inlineCodeStyle = newStyle;
markNeedsLayout();
}
// Start selection implementation
bool containsTextSelection() { bool containsTextSelection() {
return line.documentOffset <= textSelection.end && return line.documentOffset <= textSelection.end &&
textSelection.start <= line.documentOffset + line.length - 1; textSelection.start <= line.documentOffset + line.length - 1;
} }
bool containsCursor() { bool containsCursor() {
return _containsCursor ??= textSelection.isCollapsed && return _containsCursor ??= cursorCont.isFloatingCursorActive
? line
.containsOffset(cursorCont.floatingCursorTextPosition.value!.offset)
: textSelection.isCollapsed &&
line.containsOffset(textSelection.baseOffset); line.containsOffset(textSelection.baseOffset);
} }
@ -570,6 +808,9 @@ class RenderEditableTextLine extends RenderEditableBox {
return _getPosition(position, 1.5); return _getPosition(position, 1.5);
} }
@override
bool get isRepaintBoundary => true;
TextPosition? _getPosition(TextPosition textPosition, double dyScale) { TextPosition? _getPosition(TextPosition textPosition, double dyScale) {
assert(textPosition.offset < line.length); assert(textPosition.offset < line.length);
final offset = getOffsetForCaret(textPosition) final offset = getOffsetForCaret(textPosition)
@ -608,6 +849,17 @@ class RenderEditableTextLine extends RenderEditableBox {
cursorCont.style.height ?? cursorCont.style.height ??
preferredLineHeight(const TextPosition(offset: 0)); preferredLineHeight(const TextPosition(offset: 0));
// TODO: This is no longer producing the highest-fidelity caret
// heights for Android, especially when non-alphabetic languages
// are involved. The current implementation overrides the height set
// here with the full measured height of the text on Android which looks
// superior (subjectively and in terms of fidelity) in _paintCaret. We
// should rework this properly to once again match the platform. The constant
// _kCaretHeightOffset scales poorly for small font sizes.
//
/// On iOS, the cursor is taller than the cursor on Android. The height
/// of the cursor for iOS is approximate and obtained through an eyeball
/// comparison.
void _computeCaretPrototype() { void _computeCaretPrototype() {
switch (defaultTargetPlatform) { switch (defaultTargetPlatform) {
case TargetPlatform.iOS: case TargetPlatform.iOS:
@ -625,15 +877,30 @@ class RenderEditableTextLine extends RenderEditableBox {
} }
} }
void _onFloatingCursorChange() {
_containsCursor = null;
markNeedsPaint();
}
// End caret implementation
//
// Start render box overrides
bool _attachedToCursorController = false;
@override @override
void attach(covariant PipelineOwner owner) { void attach(covariant PipelineOwner owner) {
super.attach(owner); super.attach(owner);
for (final child in _children) { for (final child in _children) {
child.attach(owner); child.attach(owner);
} }
cursorCont.floatingCursorTextPosition.addListener(_onFloatingCursorChange);
if (containsCursor()) { if (containsCursor()) {
cursorCont.addListener(markNeedsLayout); cursorCont.addListener(markNeedsLayout);
cursorCont.color.addListener(markNeedsPaint); cursorCont.color.addListener(safeMarkNeedsPaint);
_attachedToCursorController = true;
} }
} }
@ -643,9 +910,12 @@ class RenderEditableTextLine extends RenderEditableBox {
for (final child in _children) { for (final child in _children) {
child.detach(); child.detach();
} }
if (containsCursor()) { cursorCont.floatingCursorTextPosition
.removeListener(_onFloatingCursorChange);
if (_attachedToCursorController) {
cursorCont.removeListener(markNeedsLayout); cursorCont.removeListener(markNeedsLayout);
cursorCont.color.removeListener(markNeedsPaint); cursorCont.color.removeListener(safeMarkNeedsPaint);
_attachedToCursorController = false;
} }
} }
@ -785,11 +1055,13 @@ class RenderEditableTextLine extends RenderEditableBox {
} }
CursorPainter get _cursorPainter => CursorPainter( CursorPainter get _cursorPainter => CursorPainter(
_body, editable: _body,
cursorCont.style, style: cursorCont.style,
_caretPrototype!, prototype: _caretPrototype,
cursorCont.color.value, color: cursorCont.isFloatingCursorActive
devicePixelRatio, ? cursorCont.style.backgroundColor
: cursorCont.color.value,
devicePixelRatio: devicePixelRatio,
); );
@override @override
@ -803,21 +1075,37 @@ class RenderEditableTextLine extends RenderEditableBox {
if (_body != null) { if (_body != null) {
final parentData = _body!.parentData as BoxParentData; final parentData = _body!.parentData as BoxParentData;
final effectiveOffset = offset + parentData.offset; final effectiveOffset = offset + parentData.offset;
if (enableInteractiveSelection &&
line.documentOffset <= textSelection.end && if (inlineCodeStyle.backgroundColor != null) {
textSelection.start <= line.documentOffset + line.length - 1) { for (final item in line.children) {
final local = localSelection(line, textSelection, false); if (item is! leaf.Text ||
_selectedRects ??= _body!.getBoxesForSelection( !item.style.containsKey(Attribute.inlineCode.key)) {
local, continue;
); }
_paintSelection(context, effectiveOffset); final textRange = TextSelection(
baseOffset: item.offset, extentOffset: item.offset + item.length);
final rects = _body!.getBoxesForSelection(textRange);
final paint = Paint()..color = inlineCodeStyle.backgroundColor!;
for (final box in rects) {
final rect = box.toRect().translate(0, 1).shift(effectiveOffset);
if (inlineCodeStyle.radius == null) {
final paintRect = Rect.fromLTRB(
rect.left - 2, rect.top, rect.right + 2, rect.bottom);
context.canvas.drawRect(paintRect, paint);
} else {
final paintRect = RRect.fromLTRBR(rect.left - 2, rect.top,
rect.right + 2, rect.bottom, inlineCodeStyle.radius!);
context.canvas.drawRRect(paintRect, paint);
}
}
}
} }
if (hasFocus && if (hasFocus &&
cursorCont.show.value && cursorCont.show.value &&
containsCursor() && containsCursor() &&
!cursorCont.style.paintAboveText) { !cursorCont.style.paintAboveText) {
_paintCursor(context, effectiveOffset); _paintCursor(context, effectiveOffset, line.hasEmbed);
} }
context.paintChild(_body!, effectiveOffset); context.paintChild(_body!, effectiveOffset);
@ -826,7 +1114,18 @@ class RenderEditableTextLine extends RenderEditableBox {
cursorCont.show.value && cursorCont.show.value &&
containsCursor() && containsCursor() &&
cursorCont.style.paintAboveText) { cursorCont.style.paintAboveText) {
_paintCursor(context, effectiveOffset); _paintCursor(context, effectiveOffset, line.hasEmbed);
}
// paint the selection on the top
if (enableInteractiveSelection &&
line.documentOffset <= textSelection.end &&
textSelection.start <= line.documentOffset + line.length - 1) {
final local = localSelection(line, textSelection, false);
_selectedRects ??= _body!.getBoxesForSelection(
local,
);
_paintSelection(context, effectiveOffset);
} }
} }
} }
@ -839,17 +1138,41 @@ class RenderEditableTextLine extends RenderEditableBox {
} }
} }
void _paintCursor(PaintingContext context, Offset effectiveOffset) { void _paintCursor(
final position = TextPosition( PaintingContext context, Offset effectiveOffset, bool lineHasEmbed) {
final position = cursorCont.isFloatingCursorActive
? TextPosition(
offset: cursorCont.floatingCursorTextPosition.value!.offset -
line.documentOffset,
affinity: cursorCont.floatingCursorTextPosition.value!.affinity)
: TextPosition(
offset: textSelection.extentOffset - line.documentOffset, offset: textSelection.extentOffset - line.documentOffset,
affinity: textSelection.base.affinity, affinity: textSelection.base.affinity);
); _cursorPainter.paint(
_cursorPainter.paint(context.canvas, effectiveOffset, position); context.canvas, effectiveOffset, position, lineHasEmbed);
} }
@override @override
bool hitTestChildren(BoxHitTestResult result, {required Offset position}) { bool hitTestChildren(BoxHitTestResult result, {required Offset position}) {
return _children.first.hitTest(result, position: position); if (_leading != null) {
final childParentData = _leading!.parentData as BoxParentData;
final isHit = result.addWithPaintOffset(
offset: childParentData.offset,
position: position,
hitTest: (result, transformed) {
assert(transformed == position - childParentData.offset);
return _leading!.hitTest(result, position: transformed);
});
if (isHit) return true;
}
if (_body == null) return false;
final parentData = _body!.parentData as BoxParentData;
return result.addWithPaintOffset(
offset: parentData.offset,
position: position,
hitTest: (result, position) {
return _body!.hitTest(result, position: position);
});
} }
@override @override
@ -872,6 +1195,17 @@ class RenderEditableTextLine extends RenderEditableBox {
affinity: position.affinity, affinity: position.affinity,
); );
} }
void safeMarkNeedsPaint() {
if (!attached) {
//Should not paint if it was unattached.
return;
}
markNeedsPaint();
}
@override
Rect getCaretPrototype(TextPosition position) => _caretPrototype;
} }
class _TextLineElement extends RenderObjectElement { class _TextLineElement extends RenderObjectElement {

@ -1,11 +1,9 @@
import 'dart:async'; import 'dart:async';
import 'dart:math' as math; import 'dart:math' as math;
import 'package:flutter/cupertino.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart'; import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/scheduler.dart'; import 'package:flutter/scheduler.dart';
import '../models/documents/nodes/node.dart'; import '../models/documents/nodes/node.dart';
@ -21,6 +19,8 @@ TextSelection localSelection(Node node, TextSelection selection, fromParent) {
extentOffset: math.min(selection.end - offset, node.length - 1)); extentOffset: math.min(selection.end - offset, node.length - 1));
} }
/// The text position that a give selection handle manipulates. Dragging the
/// [start] handle always moves the [start]/[baseOffset] of the selection.
enum _TextSelectionHandlePosition { START, END } enum _TextSelectionHandlePosition { START, END }
/// internal use, used to get drag direction information /// internal use, used to get drag direction information
@ -58,22 +58,29 @@ class DragTextSelection extends TextSelection {
} }
} }
/// An object that manages a pair of text selection handles.
///
/// The selection handles are displayed in the [Overlay] that most closely
/// encloses the given [BuildContext].
class EditorTextSelectionOverlay { class EditorTextSelectionOverlay {
EditorTextSelectionOverlay( /// Creates an object that manages overlay entries for selection handles.
this.value, ///
this.handlesVisible, /// The [context] must not be null and must have an [Overlay] as an ancestor.
this.context, EditorTextSelectionOverlay({
this.debugRequiredFor, required this.value,
this.toolbarLayerLink, required this.context,
this.startHandleLayerLink, required this.toolbarLayerLink,
this.endHandleLayerLink, required this.startHandleLayerLink,
this.renderObject, required this.endHandleLayerLink,
this.selectionCtrls, required this.renderObject,
this.selectionDelegate, required this.debugRequiredFor,
this.dragStartBehavior, required this.selectionCtrls,
required this.selectionDelegate,
required this.clipboardStatus,
this.onSelectionHandleTapped, this.onSelectionHandleTapped,
this.clipboardStatus, this.dragStartBehavior = DragStartBehavior.start,
) { this.handlesVisible = false,
}) {
final overlay = Overlay.of(context, rootOverlay: true)!; final overlay = Overlay.of(context, rootOverlay: true)!;
_toolbarController = AnimationController( _toolbarController = AnimationController(
@ -81,20 +88,94 @@ class EditorTextSelectionOverlay {
} }
TextEditingValue value; TextEditingValue value;
/// Whether selection handles are visible.
///
/// Set to false if you want to hide the handles. Use this property to show or
/// hide the handle without rebuilding them.
///
/// If this method is called while the [SchedulerBinding.schedulerPhase] is
/// [SchedulerPhase.persistentCallbacks], i.e. during the build, layout, or
/// paint phases (see [WidgetsBinding.drawFrame]), then the update is delayed
/// until the post-frame callbacks phase. Otherwise the update is done
/// synchronously. This means that it is safe to call during builds, but also
/// that if you do call this during a build, the UI will not update until the
/// next frame (i.e. many milliseconds later).
///
/// Defaults to false.
bool handlesVisible = false; bool handlesVisible = false;
/// The context in which the selection handles should appear.
///
/// This context must have an [Overlay] as an ancestor because this object
/// will display the text selection handles in that [Overlay].
final BuildContext context; final BuildContext context;
/// Debugging information for explaining why the [Overlay] is required.
final Widget debugRequiredFor; final Widget debugRequiredFor;
/// The object supplied to the [CompositedTransformTarget] that wraps the text
/// field.
final LayerLink toolbarLayerLink; final LayerLink toolbarLayerLink;
/// The objects supplied to the [CompositedTransformTarget] that wraps the
/// location of start selection handle.
final LayerLink startHandleLayerLink; final LayerLink startHandleLayerLink;
/// The objects supplied to the [CompositedTransformTarget] that wraps the
/// location of end selection handle.
final LayerLink endHandleLayerLink; final LayerLink endHandleLayerLink;
/// The editable line in which the selected text is being displayed.
final RenderEditor? renderObject; final RenderEditor? renderObject;
/// Builds text selection handles and toolbar.
final TextSelectionControls selectionCtrls; final TextSelectionControls selectionCtrls;
/// The delegate for manipulating the current selection in the owning
/// text field.
final TextSelectionDelegate selectionDelegate; final TextSelectionDelegate selectionDelegate;
/// Determines the way that drag start behavior is handled.
///
/// If set to [DragStartBehavior.start], handle drag behavior will
/// begin upon the detection of a drag gesture. If set to
/// [DragStartBehavior.down] it will begin when a down event is first
/// detected.
///
/// In general, setting this to [DragStartBehavior.start] will make drag
/// animation smoother and setting it to [DragStartBehavior.down] will make
/// drag behavior feel slightly more reactive.
///
/// By default, the drag start behavior is [DragStartBehavior.start].
///
/// See also:
///
/// * [DragGestureRecognizer.dragStartBehavior],
/// which gives an example for the different behaviors.
final DragStartBehavior dragStartBehavior; final DragStartBehavior dragStartBehavior;
/// {@template flutter.widgets.textSelection.onSelectionHandleTapped}
/// A callback that's invoked when a selection handle is tapped.
///
/// Both regular taps and long presses invoke this callback, but a drag
/// gesture won't.
/// {@endtemplate}
final VoidCallback? onSelectionHandleTapped; final VoidCallback? onSelectionHandleTapped;
/// Maintains the status of the clipboard for determining if its contents can
/// be pasted or not.
///
/// Useful because the actual value of the clipboard can only be checked
/// asynchronously (see [Clipboard.getData]).
final ClipboardStatusNotifier clipboardStatus; final ClipboardStatusNotifier clipboardStatus;
late AnimationController _toolbarController; late AnimationController _toolbarController;
/// A pair of handles. If this is non-null, there are always 2, though the
/// second is hidden when the selection is collapsed.
List<OverlayEntry>? _handles; List<OverlayEntry>? _handles;
/// A copy/paste toolbar.
OverlayEntry? toolbar; OverlayEntry? toolbar;
TextSelection get _selection => value.selection; TextSelection get _selection => value.selection;
@ -106,6 +187,8 @@ class EditorTextSelectionOverlay {
return; return;
} }
handlesVisible = visible; handlesVisible = visible;
// If we are in build state, it will be too late to update visibility.
// We will need to schedule the build in next frame.
if (SchedulerBinding.instance!.schedulerPhase == if (SchedulerBinding.instance!.schedulerPhase ==
SchedulerPhase.persistentCallbacks) { SchedulerPhase.persistentCallbacks) {
SchedulerBinding.instance!.addPostFrameCallback(markNeedsBuild); SchedulerBinding.instance!.addPostFrameCallback(markNeedsBuild);
@ -114,6 +197,7 @@ class EditorTextSelectionOverlay {
} }
} }
/// Destroys the handles by removing them from overlay.
void hideHandles() { void hideHandles() {
if (_handles == null) { if (_handles == null) {
return; return;
@ -123,6 +207,9 @@ class EditorTextSelectionOverlay {
_handles = null; _handles = null;
} }
/// Hides the toolbar part of the overlay.
///
/// To hide the whole overlay, see [hide].
void hideToolbar() { void hideToolbar() {
assert(toolbar != null); assert(toolbar != null);
_toolbarController.stop(); _toolbarController.stop();
@ -130,6 +217,7 @@ class EditorTextSelectionOverlay {
toolbar = null; toolbar = null;
} }
/// Shows the toolbar by inserting it into the [context]'s overlay.
void showToolbar() { void showToolbar() {
assert(toolbar == null); assert(toolbar == null);
toolbar = OverlayEntry(builder: _buildToolbar); toolbar = OverlayEntry(builder: _buildToolbar);
@ -161,6 +249,15 @@ class EditorTextSelectionOverlay {
)); ));
} }
/// Updates the overlay after the selection has changed.
///
/// If this method is called while the [SchedulerBinding.schedulerPhase] is
/// [SchedulerPhase.persistentCallbacks], i.e. during the build, layout, or
/// paint phases (see [WidgetsBinding.drawFrame]), then the update is delayed
/// until the post-frame callbacks phase. Otherwise the update is done
/// synchronously. This means that it is safe to call during builds, but also
/// that if you do call this during a build, the UI will not update until the
/// next frame (i.e. many milliseconds later).
void update(TextEditingValue newValue) { void update(TextEditingValue newValue) {
if (value == newValue) { if (value == newValue) {
return; return;
@ -210,6 +307,7 @@ class EditorTextSelectionOverlay {
} }
Widget _buildToolbar(BuildContext context) { Widget _buildToolbar(BuildContext context) {
// Find the horizontal midpoint, just above the selected text.
final endpoints = renderObject!.getEndpointsForSelection(_selection); final endpoints = renderObject!.getEndpointsForSelection(_selection);
final editingRegion = Rect.fromPoints( final editingRegion = Rect.fromPoints(
@ -260,6 +358,7 @@ class EditorTextSelectionOverlay {
toolbar?.markNeedsBuild(); toolbar?.markNeedsBuild();
} }
/// Hides the entire overlay including the toolbar and the handles.
void hide() { void hide() {
if (_handles != null) { if (_handles != null) {
_handles![0].remove(); _handles![0].remove();
@ -271,11 +370,13 @@ class EditorTextSelectionOverlay {
} }
} }
/// Final cleanup.
void dispose() { void dispose() {
hide(); hide();
_toolbarController.dispose(); _toolbarController.dispose();
} }
/// Builds the handles by inserting them into the [context]'s overlay.
void showHandles() { void showHandles() {
assert(_handles == null); assert(_handles == null);
_handles = <OverlayEntry>[ _handles = <OverlayEntry>[
@ -290,8 +391,17 @@ class EditorTextSelectionOverlay {
Overlay.of(context, rootOverlay: true, debugRequiredFor: debugRequiredFor)! Overlay.of(context, rootOverlay: true, debugRequiredFor: debugRequiredFor)!
.insertAll(_handles!); .insertAll(_handles!);
} }
/// Causes the overlay to update its rendering.
///
/// This is intended to be called when the [renderObject] may have changed its
/// text metrics (e.g. because the text was scrolled).
void updateForScroll() {
markNeedsBuild();
}
} }
/// This widget represents a single draggable text selection handle.
class _TextSelectionHandleOverlay extends StatefulWidget { class _TextSelectionHandleOverlay extends StatefulWidget {
const _TextSelectionHandleOverlay({ const _TextSelectionHandleOverlay({
required this.selection, required this.selection,
@ -407,6 +517,10 @@ class _TextSelectionHandleOverlayState
break; break;
} }
if (newSelection.baseOffset >= newSelection.extentOffset) {
return; // don't allow order swapping.
}
widget.onSelectionHandleChanged(newSelection); widget.onSelectionHandleChanged(newSelection);
} }
@ -711,9 +825,12 @@ class _EditorTextSelectionGestureDetectorState
Widget build(BuildContext context) { Widget build(BuildContext context) {
final gestures = <Type, GestureRecognizerFactory>{}; final gestures = <Type, GestureRecognizerFactory>{};
gestures[TapGestureRecognizer] = // Use _TransparentTapGestureRecognizer so that TextSelectionGestureDetector
GestureRecognizerFactoryWithHandlers<TapGestureRecognizer>( // can receive the same tap events that a selection handle placed visually
() => TapGestureRecognizer(debugOwner: this), // on top of it also receives.
gestures[_TransparentTapGestureRecognizer] =
GestureRecognizerFactoryWithHandlers<_TransparentTapGestureRecognizer>(
() => _TransparentTapGestureRecognizer(debugOwner: this),
(instance) { (instance) {
instance instance
..onTapDown = _handleTapDown ..onTapDown = _handleTapDown
@ -728,7 +845,8 @@ class _EditorTextSelectionGestureDetectorState
gestures[LongPressGestureRecognizer] = gestures[LongPressGestureRecognizer] =
GestureRecognizerFactoryWithHandlers<LongPressGestureRecognizer>( GestureRecognizerFactoryWithHandlers<LongPressGestureRecognizer>(
() => LongPressGestureRecognizer( () => LongPressGestureRecognizer(
debugOwner: this, kind: PointerDeviceKind.touch), debugOwner: this,
supportedDevices: <PointerDeviceKind>{PointerDeviceKind.touch}),
(instance) { (instance) {
instance instance
..onLongPressStart = _handleLongPressStart ..onLongPressStart = _handleLongPressStart
@ -744,7 +862,8 @@ class _EditorTextSelectionGestureDetectorState
gestures[HorizontalDragGestureRecognizer] = gestures[HorizontalDragGestureRecognizer] =
GestureRecognizerFactoryWithHandlers<HorizontalDragGestureRecognizer>( GestureRecognizerFactoryWithHandlers<HorizontalDragGestureRecognizer>(
() => HorizontalDragGestureRecognizer( () => HorizontalDragGestureRecognizer(
debugOwner: this, kind: PointerDeviceKind.mouse), debugOwner: this,
supportedDevices: <PointerDeviceKind>{PointerDeviceKind.mouse}),
(instance) { (instance) {
instance instance
..dragStartBehavior = DragStartBehavior.down ..dragStartBehavior = DragStartBehavior.down
@ -776,3 +895,32 @@ class _EditorTextSelectionGestureDetectorState
); );
} }
} }
// A TapGestureRecognizer which allows other GestureRecognizers to win in the
// GestureArena. This means both _TransparentTapGestureRecognizer and other
// GestureRecognizers can handle the same event.
//
// This enables proper handling of events on both the selection handle and the
// underlying input, since there is significant overlap between the two given
// the handle's padded hit area. For example, the selection handle needs to
// handle single taps on itself, but double taps need to be handled by the
// underlying input.
class _TransparentTapGestureRecognizer extends TapGestureRecognizer {
_TransparentTapGestureRecognizer({
Object? debugOwner,
}) : super(debugOwner: debugOwner);
@override
void rejectGesture(int pointer) {
// Accept new gestures that another recognizer has already won.
// Specifically, this needs to accept taps on the text selection handle on
// behalf of the text field in order to handle double tap to select. It must
// not accept other gestures like longpresses and drags that end outside of
// the text field.
if (state == GestureRecognizerState.ready) {
acceptGesture(pointer);
} else {
super.rejectGesture(pointer);
}
}
}

@ -1,8 +1,11 @@
import 'dart:io'; import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:i18n_extension/i18n_widget.dart';
import '../models/documents/attribute.dart'; import '../models/documents/attribute.dart';
import '../models/themes/quill_dialog_theme.dart';
import '../models/themes/quill_icon_theme.dart';
import '../utils/media_pick_setting.dart'; import '../utils/media_pick_setting.dart';
import 'controller.dart'; import 'controller.dart';
import 'toolbar/arrow_indicated_button_list.dart'; import 'toolbar/arrow_indicated_button_list.dart';
@ -14,6 +17,7 @@ import 'toolbar/image_button.dart';
import 'toolbar/indent_button.dart'; import 'toolbar/indent_button.dart';
import 'toolbar/insert_embed_button.dart'; import 'toolbar/insert_embed_button.dart';
import 'toolbar/link_style_button.dart'; import 'toolbar/link_style_button.dart';
import 'toolbar/select_alignment_button.dart';
import 'toolbar/select_header_style_button.dart'; import 'toolbar/select_header_style_button.dart';
import 'toolbar/toggle_check_list_button.dart'; import 'toolbar/toggle_check_list_button.dart';
import 'toolbar/toggle_style_button.dart'; import 'toolbar/toggle_style_button.dart';
@ -29,6 +33,7 @@ export 'toolbar/insert_embed_button.dart';
export 'toolbar/link_style_button.dart'; export 'toolbar/link_style_button.dart';
export 'toolbar/quill_dropdown_button.dart'; export 'toolbar/quill_dropdown_button.dart';
export 'toolbar/quill_icon_button.dart'; export 'toolbar/quill_icon_button.dart';
export 'toolbar/select_alignment_button.dart';
export 'toolbar/select_header_style_button.dart'; export 'toolbar/select_header_style_button.dart';
export 'toolbar/toggle_check_list_button.dart'; export 'toolbar/toggle_check_list_button.dart';
export 'toolbar/toggle_style_button.dart'; export 'toolbar/toggle_style_button.dart';
@ -53,23 +58,36 @@ const double kIconButtonFactor = 1.77;
class QuillToolbar extends StatelessWidget implements PreferredSizeWidget { class QuillToolbar extends StatelessWidget implements PreferredSizeWidget {
const QuillToolbar({ const QuillToolbar({
required this.children, required this.children,
this.toolBarHeight = 36, this.toolbarHeight = 36,
this.toolbarIconAlignment = WrapAlignment.center,
this.toolbarSectionSpacing = 4,
this.multiRowsDisplay = true,
this.color, this.color,
this.filePickImpl, this.filePickImpl,
this.multiRowsDisplay, this.locale,
Key? key, Key? key,
}) : super(key: key); }) : super(key: key);
factory QuillToolbar.basic({ factory QuillToolbar.basic({
required QuillController controller, required QuillController controller,
double toolbarIconSize = kDefaultIconSize, double toolbarIconSize = kDefaultIconSize,
double toolbarSectionSpacing = 4,
WrapAlignment toolbarIconAlignment = WrapAlignment.center,
bool showDividers = true,
bool showBoldButton = true, bool showBoldButton = true,
bool showItalicButton = true, bool showItalicButton = true,
bool showSmallButton = false,
bool showUnderLineButton = true, bool showUnderLineButton = true,
bool showStrikeThrough = true, bool showStrikeThrough = true,
bool showInlineCode = true,
bool showColorButton = true, bool showColorButton = true,
bool showBackgroundColorButton = true, bool showBackgroundColorButton = true,
bool showClearFormat = true, bool showClearFormat = true,
bool showAlignmentButtons = false,
bool showLeftAlignment = true,
bool showCenterAlignment = true,
bool showRightAlignment = true,
bool showJustifyAlignment = true,
bool showHeaderStyle = true, bool showHeaderStyle = true,
bool showListNumbers = true, bool showListNumbers = true,
bool showListBullets = true, bool showListBullets = true,
@ -90,19 +108,42 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget {
FilePickImpl? filePickImpl, FilePickImpl? filePickImpl,
WebImagePickImpl? webImagePickImpl, WebImagePickImpl? webImagePickImpl,
WebVideoPickImpl? webVideoPickImpl, WebVideoPickImpl? webVideoPickImpl,
///The theme to use for the icons in the toolbar, uses type [QuillIconTheme]
QuillIconTheme? iconTheme,
///The theme to use for the theming of the [LinkDialog()],
///shown when embedding an image, for example
QuillDialogTheme? dialogTheme,
///The locale to use for the editor toolbar, defaults to system locale
///Currently the supported locales are:
/// * Locale('en')
/// * Locale('de')
/// * Locale('fr')
/// * Locale('zh')
/// and more https://github.com/singerdmx/flutter-quill#translation-of-toolbar
Locale? locale,
Key? key, Key? key,
}) { }) {
final isButtonGroupShown = [ final isButtonGroupShown = [
showHistory || showHistory ||
showBoldButton || showBoldButton ||
showItalicButton || showItalicButton ||
showSmallButton ||
showUnderLineButton || showUnderLineButton ||
showStrikeThrough || showStrikeThrough ||
showInlineCode ||
showColorButton || showColorButton ||
showBackgroundColorButton || showBackgroundColorButton ||
showClearFormat || showClearFormat ||
onImagePickCallback != null || onImagePickCallback != null ||
onVideoPickCallback != null, onVideoPickCallback != null,
showAlignmentButtons,
showLeftAlignment,
showCenterAlignment,
showRightAlignment,
showJustifyAlignment,
showHeaderStyle, showHeaderStyle,
showListNumbers || showListBullets || showListCheck || showCodeBlock, showListNumbers || showListBullets || showListCheck || showCodeBlock,
showQuote || showIndent, showQuote || showIndent,
@ -111,8 +152,11 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget {
return QuillToolbar( return QuillToolbar(
key: key, key: key,
toolBarHeight: toolbarIconSize * 2, toolbarHeight: toolbarIconSize * 2,
toolbarSectionSpacing: toolbarSectionSpacing,
toolbarIconAlignment: toolbarIconAlignment,
multiRowsDisplay: multiRowsDisplay, multiRowsDisplay: multiRowsDisplay,
locale: locale,
children: [ children: [
if (showHistory) if (showHistory)
HistoryButton( HistoryButton(
@ -120,6 +164,7 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget {
iconSize: toolbarIconSize, iconSize: toolbarIconSize,
controller: controller, controller: controller,
undo: true, undo: true,
iconTheme: iconTheme,
), ),
if (showHistory) if (showHistory)
HistoryButton( HistoryButton(
@ -127,6 +172,7 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget {
iconSize: toolbarIconSize, iconSize: toolbarIconSize,
controller: controller, controller: controller,
undo: false, undo: false,
iconTheme: iconTheme,
), ),
if (showBoldButton) if (showBoldButton)
ToggleStyleButton( ToggleStyleButton(
@ -134,6 +180,7 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget {
icon: Icons.format_bold, icon: Icons.format_bold,
iconSize: toolbarIconSize, iconSize: toolbarIconSize,
controller: controller, controller: controller,
iconTheme: iconTheme,
), ),
if (showItalicButton) if (showItalicButton)
ToggleStyleButton( ToggleStyleButton(
@ -141,6 +188,15 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget {
icon: Icons.format_italic, icon: Icons.format_italic,
iconSize: toolbarIconSize, iconSize: toolbarIconSize,
controller: controller, controller: controller,
iconTheme: iconTheme,
),
if (showSmallButton)
ToggleStyleButton(
attribute: Attribute.small,
icon: Icons.format_size,
iconSize: toolbarIconSize,
controller: controller,
iconTheme: iconTheme,
), ),
if (showUnderLineButton) if (showUnderLineButton)
ToggleStyleButton( ToggleStyleButton(
@ -148,6 +204,7 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget {
icon: Icons.format_underline, icon: Icons.format_underline,
iconSize: toolbarIconSize, iconSize: toolbarIconSize,
controller: controller, controller: controller,
iconTheme: iconTheme,
), ),
if (showStrikeThrough) if (showStrikeThrough)
ToggleStyleButton( ToggleStyleButton(
@ -155,6 +212,15 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget {
icon: Icons.format_strikethrough, icon: Icons.format_strikethrough,
iconSize: toolbarIconSize, iconSize: toolbarIconSize,
controller: controller, controller: controller,
iconTheme: iconTheme,
),
if (showInlineCode)
ToggleStyleButton(
attribute: Attribute.inlineCode,
icon: Icons.code,
iconSize: toolbarIconSize,
controller: controller,
iconTheme: iconTheme,
), ),
if (showColorButton) if (showColorButton)
ColorButton( ColorButton(
@ -162,6 +228,7 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget {
iconSize: toolbarIconSize, iconSize: toolbarIconSize,
controller: controller, controller: controller,
background: false, background: false,
iconTheme: iconTheme,
), ),
if (showBackgroundColorButton) if (showBackgroundColorButton)
ColorButton( ColorButton(
@ -169,12 +236,14 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget {
iconSize: toolbarIconSize, iconSize: toolbarIconSize,
controller: controller, controller: controller,
background: true, background: true,
iconTheme: iconTheme,
), ),
if (showClearFormat) if (showClearFormat)
ClearFormatButton( ClearFormatButton(
icon: Icons.format_clear, icon: Icons.format_clear,
iconSize: toolbarIconSize, iconSize: toolbarIconSize,
controller: controller, controller: controller,
iconTheme: iconTheme,
), ),
if (showImageButton) if (showImageButton)
ImageButton( ImageButton(
@ -185,6 +254,8 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget {
filePickImpl: filePickImpl, filePickImpl: filePickImpl,
webImagePickImpl: webImagePickImpl, webImagePickImpl: webImagePickImpl,
mediaPickSettingSelector: mediaPickSettingSelector, mediaPickSettingSelector: mediaPickSettingSelector,
iconTheme: iconTheme,
dialogTheme: dialogTheme,
), ),
if (showVideoButton) if (showVideoButton)
VideoButton( VideoButton(
@ -195,6 +266,8 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget {
filePickImpl: filePickImpl, filePickImpl: filePickImpl,
webVideoPickImpl: webImagePickImpl, webVideoPickImpl: webImagePickImpl,
mediaPickSettingSelector: mediaPickSettingSelector, mediaPickSettingSelector: mediaPickSettingSelector,
iconTheme: iconTheme,
dialogTheme: dialogTheme,
), ),
if ((onImagePickCallback != null || onVideoPickCallback != null) && if ((onImagePickCallback != null || onVideoPickCallback != null) &&
showCameraButton) showCameraButton)
@ -206,26 +279,54 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget {
onVideoPickCallback: onVideoPickCallback, onVideoPickCallback: onVideoPickCallback,
filePickImpl: filePickImpl, filePickImpl: filePickImpl,
webImagePickImpl: webImagePickImpl, webImagePickImpl: webImagePickImpl,
webVideoPickImpl: webVideoPickImpl), webVideoPickImpl: webVideoPickImpl,
if (isButtonGroupShown[0] && iconTheme: iconTheme,
),
if (showDividers &&
isButtonGroupShown[0] &&
(isButtonGroupShown[1] || (isButtonGroupShown[1] ||
isButtonGroupShown[2] || isButtonGroupShown[2] ||
isButtonGroupShown[3] || isButtonGroupShown[3] ||
isButtonGroupShown[4])) isButtonGroupShown[4] ||
isButtonGroupShown[5]))
VerticalDivider( VerticalDivider(
indent: 12, indent: 12,
endIndent: 12, endIndent: 12,
color: Colors.grey.shade400, color: Colors.grey.shade400,
), ),
if (showHeaderStyle) if (showAlignmentButtons)
SelectHeaderStyleButton( SelectAlignmentButton(
controller: controller, controller: controller,
iconSize: toolbarIconSize, iconSize: toolbarIconSize,
iconTheme: iconTheme,
showLeftAlignment: showLeftAlignment,
showCenterAlignment: showCenterAlignment,
showRightAlignment: showRightAlignment,
showJustifyAlignment: showJustifyAlignment,
), ),
if (isButtonGroupShown[1] && if (showDividers &&
isButtonGroupShown[1] &&
(isButtonGroupShown[2] || (isButtonGroupShown[2] ||
isButtonGroupShown[3] || isButtonGroupShown[3] ||
isButtonGroupShown[4])) isButtonGroupShown[4] ||
isButtonGroupShown[5]))
VerticalDivider(
indent: 12,
endIndent: 12,
color: Colors.grey.shade400,
),
if (showHeaderStyle)
SelectHeaderStyleButton(
controller: controller,
iconSize: toolbarIconSize,
iconTheme: iconTheme,
),
if (showDividers &&
showHeaderStyle &&
isButtonGroupShown[2] &&
(isButtonGroupShown[3] ||
isButtonGroupShown[4] ||
isButtonGroupShown[5]))
VerticalDivider( VerticalDivider(
indent: 12, indent: 12,
endIndent: 12, endIndent: 12,
@ -237,6 +338,7 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget {
controller: controller, controller: controller,
icon: Icons.format_list_numbered, icon: Icons.format_list_numbered,
iconSize: toolbarIconSize, iconSize: toolbarIconSize,
iconTheme: iconTheme,
), ),
if (showListBullets) if (showListBullets)
ToggleStyleButton( ToggleStyleButton(
@ -244,6 +346,7 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget {
controller: controller, controller: controller,
icon: Icons.format_list_bulleted, icon: Icons.format_list_bulleted,
iconSize: toolbarIconSize, iconSize: toolbarIconSize,
iconTheme: iconTheme,
), ),
if (showListCheck) if (showListCheck)
ToggleCheckListButton( ToggleCheckListButton(
@ -251,6 +354,7 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget {
controller: controller, controller: controller,
icon: Icons.check_box, icon: Icons.check_box,
iconSize: toolbarIconSize, iconSize: toolbarIconSize,
iconTheme: iconTheme,
), ),
if (showCodeBlock) if (showCodeBlock)
ToggleStyleButton( ToggleStyleButton(
@ -258,9 +362,11 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget {
controller: controller, controller: controller,
icon: Icons.code, icon: Icons.code,
iconSize: toolbarIconSize, iconSize: toolbarIconSize,
iconTheme: iconTheme,
), ),
if (isButtonGroupShown[2] && if (showDividers &&
(isButtonGroupShown[3] || isButtonGroupShown[4])) isButtonGroupShown[3] &&
(isButtonGroupShown[4] || isButtonGroupShown[5]))
VerticalDivider( VerticalDivider(
indent: 12, indent: 12,
endIndent: 12, endIndent: 12,
@ -272,6 +378,7 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget {
controller: controller, controller: controller,
icon: Icons.format_quote, icon: Icons.format_quote,
iconSize: toolbarIconSize, iconSize: toolbarIconSize,
iconTheme: iconTheme,
), ),
if (showIndent) if (showIndent)
IndentButton( IndentButton(
@ -279,6 +386,7 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget {
iconSize: toolbarIconSize, iconSize: toolbarIconSize,
controller: controller, controller: controller,
isIncrease: true, isIncrease: true,
iconTheme: iconTheme,
), ),
if (showIndent) if (showIndent)
IndentButton( IndentButton(
@ -286,8 +394,9 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget {
iconSize: toolbarIconSize, iconSize: toolbarIconSize,
controller: controller, controller: controller,
isIncrease: false, isIncrease: false,
iconTheme: iconTheme,
), ),
if (isButtonGroupShown[3] && isButtonGroupShown[4]) if (showDividers && isButtonGroupShown[4] && isButtonGroupShown[5])
VerticalDivider( VerticalDivider(
indent: 12, indent: 12,
endIndent: 12, endIndent: 12,
@ -297,20 +406,25 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget {
LinkStyleButton( LinkStyleButton(
controller: controller, controller: controller,
iconSize: toolbarIconSize, iconSize: toolbarIconSize,
iconTheme: iconTheme,
dialogTheme: dialogTheme,
), ),
if (showHorizontalRule) if (showHorizontalRule)
InsertEmbedButton( InsertEmbedButton(
controller: controller, controller: controller,
icon: Icons.horizontal_rule, icon: Icons.horizontal_rule,
iconSize: toolbarIconSize, iconSize: toolbarIconSize,
iconTheme: iconTheme,
), ),
], ],
); );
} }
final List<Widget> children; final List<Widget> children;
final double toolBarHeight; final double toolbarHeight;
final bool? multiRowsDisplay; final double toolbarSectionSpacing;
final WrapAlignment toolbarIconAlignment;
final bool multiRowsDisplay;
/// The color of the toolbar. /// The color of the toolbar.
/// ///
@ -320,23 +434,35 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget {
final FilePickImpl? filePickImpl; final FilePickImpl? filePickImpl;
///The locale to use for the editor toolbar, defaults to system locale
///Currently the supported locales are:
/// * Locale('en')
/// * Locale('de')
/// * Locale('fr')
/// * Locale('zh', 'CN')
/// and more https://github.com/singerdmx/flutter-quill#translation-of-toolbar
final Locale? locale;
@override @override
Size get preferredSize => Size.fromHeight(toolBarHeight); Size get preferredSize => Size.fromHeight(toolbarHeight);
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (multiRowsDisplay ?? true) { return I18n(
return Wrap( initialLocale: locale,
alignment: WrapAlignment.center, child: multiRowsDisplay
? Wrap(
alignment: toolbarIconAlignment,
runSpacing: 4, runSpacing: 4,
spacing: 4, spacing: toolbarSectionSpacing,
children: children, children: children,
); )
} : Container(
return Container( constraints:
constraints: BoxConstraints.tightFor(height: preferredSize.height), BoxConstraints.tightFor(height: preferredSize.height),
color: color ?? Theme.of(context).canvasColor, color: color ?? Theme.of(context).canvasColor,
child: ArrowIndicatedButtonList(buttons: children), child: ArrowIndicatedButtonList(buttons: children),
),
); );
} }
} }

@ -60,6 +60,8 @@ class _ArrowIndicatedButtonListState extends State<ArrowIndicatedButtonList>
} }
void _handleScroll() { void _handleScroll() {
if (!mounted) return;
setState(() { setState(() {
_showLeftArrow = _showLeftArrow =
_controller.position.minScrollExtent != _controller.position.pixels; _controller.position.minScrollExtent != _controller.position.pixels;

@ -1,11 +1,10 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart'; import 'package:image_picker/image_picker.dart';
import '../../models/themes/quill_icon_theme.dart';
import '../controller.dart'; import '../controller.dart';
import '../toolbar.dart'; import '../toolbar.dart';
import 'image_video_utils.dart'; import 'image_video_utils.dart';
import 'quill_icon_button.dart';
class CameraButton extends StatelessWidget { class CameraButton extends StatelessWidget {
const CameraButton({ const CameraButton({
@ -18,6 +17,7 @@ class CameraButton extends StatelessWidget {
this.filePickImpl, this.filePickImpl,
this.webImagePickImpl, this.webImagePickImpl,
this.webVideoPickImpl, this.webVideoPickImpl,
this.iconTheme,
Key? key, Key? key,
}) : super(key: key); }) : super(key: key);
@ -38,16 +38,22 @@ class CameraButton extends StatelessWidget {
final FilePickImpl? filePickImpl; final FilePickImpl? filePickImpl;
final QuillIconTheme? iconTheme;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final iconColor = iconTheme?.iconUnselectedColor ?? theme.iconTheme.color;
final iconFillColor =
iconTheme?.iconUnselectedFillColor ?? (fillColor ?? theme.canvasColor);
return QuillIconButton( return QuillIconButton(
icon: Icon(icon, size: iconSize, color: theme.iconTheme.color), icon: Icon(icon, size: iconSize, color: iconColor),
highlightElevation: 0, highlightElevation: 0,
hoverElevation: 0, hoverElevation: 0,
size: iconSize * 1.77, size: iconSize * 1.77,
fillColor: fillColor ?? theme.canvasColor, fillColor: iconFillColor,
onPressed: () => _handleCameraButtonTap(context, controller, onPressed: () => _handleCameraButtonTap(context, controller,
onImagePickCallback: onImagePickCallback, onImagePickCallback: onImagePickCallback,
onVideoPickCallback: onVideoPickCallback, onVideoPickCallback: onVideoPickCallback,

@ -1,13 +1,13 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../../flutter_quill.dart'; import '../../../flutter_quill.dart';
import 'quill_icon_button.dart';
class ClearFormatButton extends StatefulWidget { class ClearFormatButton extends StatefulWidget {
const ClearFormatButton({ const ClearFormatButton({
required this.icon, required this.icon,
required this.controller, required this.controller,
this.iconSize = kDefaultIconSize, this.iconSize = kDefaultIconSize,
this.iconTheme,
Key? key, Key? key,
}) : super(key: key); }) : super(key: key);
@ -16,6 +16,8 @@ class ClearFormatButton extends StatefulWidget {
final QuillController controller; final QuillController controller;
final QuillIconTheme? iconTheme;
@override @override
_ClearFormatButtonState createState() => _ClearFormatButtonState(); _ClearFormatButtonState createState() => _ClearFormatButtonState();
} }
@ -24,8 +26,10 @@ class _ClearFormatButtonState extends State<ClearFormatButton> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final iconColor = theme.iconTheme.color; final iconColor =
final fillColor = theme.canvasColor; widget.iconTheme?.iconUnselectedColor ?? theme.iconTheme.color;
final fillColor =
widget.iconTheme?.iconUnselectedFillColor ?? theme.canvasColor;
return QuillIconButton( return QuillIconButton(
highlightElevation: 0, highlightElevation: 0,
hoverElevation: 0, hoverElevation: 0,

@ -3,10 +3,11 @@ import 'package:flutter_colorpicker/flutter_colorpicker.dart';
import '../../models/documents/attribute.dart'; import '../../models/documents/attribute.dart';
import '../../models/documents/style.dart'; import '../../models/documents/style.dart';
import '../../models/themes/quill_icon_theme.dart';
import '../../translations/toolbar.i18n.dart';
import '../../utils/color.dart'; import '../../utils/color.dart';
import '../controller.dart'; import '../controller.dart';
import '../toolbar.dart'; import '../toolbar.dart';
import 'quill_icon_button.dart';
/// Controls color styles. /// Controls color styles.
/// ///
@ -18,6 +19,7 @@ class ColorButton extends StatefulWidget {
required this.controller, required this.controller,
required this.background, required this.background,
this.iconSize = kDefaultIconSize, this.iconSize = kDefaultIconSize,
this.iconTheme,
Key? key, Key? key,
}) : super(key: key); }) : super(key: key);
@ -25,6 +27,7 @@ class ColorButton extends StatefulWidget {
final double iconSize; final double iconSize;
final bool background; final bool background;
final QuillController controller; final QuillController controller;
final QuillIconTheme? iconTheme;
@override @override
_ColorButtonState createState() => _ColorButtonState(); _ColorButtonState createState() => _ColorButtonState();
@ -98,20 +101,20 @@ class _ColorButtonState extends State<ColorButton> {
final theme = Theme.of(context); final theme = Theme.of(context);
final iconColor = _isToggledColor && !widget.background && !_isWhite final iconColor = _isToggledColor && !widget.background && !_isWhite
? stringToColor(_selectionStyle.attributes['color']!.value) ? stringToColor(_selectionStyle.attributes['color']!.value)
: theme.iconTheme.color; : (widget.iconTheme?.iconUnselectedColor ?? theme.iconTheme.color);
final iconColorBackground = final iconColorBackground =
_isToggledBackground && widget.background && !_isWhitebackground _isToggledBackground && widget.background && !_isWhitebackground
? stringToColor(_selectionStyle.attributes['background']!.value) ? stringToColor(_selectionStyle.attributes['background']!.value)
: theme.iconTheme.color; : (widget.iconTheme?.iconUnselectedColor ?? theme.iconTheme.color);
final fillColor = _isToggledColor && !widget.background && _isWhite final fillColor = _isToggledColor && !widget.background && _isWhite
? stringToColor('#ffffff') ? stringToColor('#ffffff')
: theme.canvasColor; : (widget.iconTheme?.iconUnselectedFillColor ?? theme.canvasColor);
final fillColorBackground = final fillColorBackground =
_isToggledBackground && widget.background && _isWhitebackground _isToggledBackground && widget.background && _isWhitebackground
? stringToColor('#ffffff') ? stringToColor('#ffffff')
: theme.canvasColor; : (widget.iconTheme?.iconUnselectedFillColor ?? theme.canvasColor);
return QuillIconButton( return QuillIconButton(
highlightElevation: 0, highlightElevation: 0,
@ -140,7 +143,7 @@ class _ColorButtonState extends State<ColorButton> {
showDialog( showDialog(
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
title: const Text('Select Color'), title: Text('Select Color'.i18n),
backgroundColor: Theme.of(context).canvasColor, backgroundColor: Theme.of(context).canvasColor,
content: SingleChildScrollView( content: SingleChildScrollView(
child: MaterialPicker( child: MaterialPicker(

@ -1,7 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../../flutter_quill.dart'; import '../../../flutter_quill.dart';
import 'quill_icon_button.dart';
class HistoryButton extends StatefulWidget { class HistoryButton extends StatefulWidget {
const HistoryButton({ const HistoryButton({
@ -9,6 +8,7 @@ class HistoryButton extends StatefulWidget {
required this.controller, required this.controller,
required this.undo, required this.undo,
this.iconSize = kDefaultIconSize, this.iconSize = kDefaultIconSize,
this.iconTheme,
Key? key, Key? key,
}) : super(key: key); }) : super(key: key);
@ -16,6 +16,7 @@ class HistoryButton extends StatefulWidget {
final double iconSize; final double iconSize;
final bool undo; final bool undo;
final QuillController controller; final QuillController controller;
final QuillIconTheme? iconTheme;
@override @override
_HistoryButtonState createState() => _HistoryButtonState(); _HistoryButtonState createState() => _HistoryButtonState();
@ -30,7 +31,8 @@ class _HistoryButtonState extends State<HistoryButton> {
theme = Theme.of(context); theme = Theme.of(context);
_setIconColor(); _setIconColor();
final fillColor = theme.canvasColor; final fillColor =
widget.iconTheme?.iconUnselectedFillColor ?? theme.canvasColor;
widget.controller.changes.listen((event) async { widget.controller.changes.listen((event) async {
_setIconColor(); _setIconColor();
}); });
@ -50,14 +52,14 @@ class _HistoryButtonState extends State<HistoryButton> {
if (widget.undo) { if (widget.undo) {
setState(() { setState(() {
_iconColor = widget.controller.hasUndo _iconColor = widget.controller.hasUndo
? theme.iconTheme.color ? widget.iconTheme?.iconUnselectedColor ?? theme.iconTheme.color
: theme.disabledColor; : widget.iconTheme?.disabledIconColor ?? theme.disabledColor;
}); });
} else { } else {
setState(() { setState(() {
_iconColor = widget.controller.hasRedo _iconColor = widget.controller.hasRedo
? theme.iconTheme.color ? widget.iconTheme?.iconUnselectedColor ?? theme.iconTheme.color
: theme.disabledColor; : widget.iconTheme?.disabledIconColor ?? theme.disabledColor;
}); });
} }
} }

@ -2,12 +2,12 @@ import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart'; import 'package:image_picker/image_picker.dart';
import '../../models/documents/nodes/embed.dart'; import '../../models/documents/nodes/embed.dart';
import '../../utils/media_pick_setting.dart'; import '../../models/themes/quill_dialog_theme.dart';
import '../../models/themes/quill_icon_theme.dart';
import '../controller.dart'; import '../controller.dart';
import '../link_dialog.dart'; import '../link_dialog.dart';
import '../toolbar.dart'; import '../toolbar.dart';
import 'image_video_utils.dart'; import 'image_video_utils.dart';
import 'quill_icon_button.dart';
class ImageButton extends StatelessWidget { class ImageButton extends StatelessWidget {
const ImageButton({ const ImageButton({
@ -19,6 +19,8 @@ class ImageButton extends StatelessWidget {
this.filePickImpl, this.filePickImpl,
this.webImagePickImpl, this.webImagePickImpl,
this.mediaPickSettingSelector, this.mediaPickSettingSelector,
this.iconTheme,
this.dialogTheme,
Key? key, Key? key,
}) : super(key: key); }) : super(key: key);
@ -37,16 +39,24 @@ class ImageButton extends StatelessWidget {
final MediaPickSettingSelector? mediaPickSettingSelector; final MediaPickSettingSelector? mediaPickSettingSelector;
final QuillIconTheme? iconTheme;
final QuillDialogTheme? dialogTheme;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final iconColor = iconTheme?.iconUnselectedColor ?? theme.iconTheme.color;
final iconFillColor =
iconTheme?.iconUnselectedFillColor ?? (fillColor ?? theme.canvasColor);
return QuillIconButton( return QuillIconButton(
icon: Icon(icon, size: iconSize, color: theme.iconTheme.color), icon: Icon(icon, size: iconSize, color: iconColor),
highlightElevation: 0, highlightElevation: 0,
hoverElevation: 0, hoverElevation: 0,
size: iconSize * 1.77, size: iconSize * 1.77,
fillColor: fillColor ?? theme.canvasColor, fillColor: iconFillColor,
onPressed: () => _onPressedHandler(context), onPressed: () => _onPressedHandler(context),
); );
} }
@ -80,7 +90,7 @@ class ImageButton extends StatelessWidget {
void _typeLink(BuildContext context) { void _typeLink(BuildContext context) {
showDialog<String>( showDialog<String>(
context: context, context: context,
builder: (_) => const LinkDialog(), builder: (_) => LinkDialog(dialogTheme: dialogTheme),
).then(_linkSubmitted); ).then(_linkSubmitted);
} }

@ -5,7 +5,7 @@ import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart'; import 'package:image_picker/image_picker.dart';
import '../../models/documents/nodes/embed.dart'; import '../../models/documents/nodes/embed.dart';
import '../../utils/media_pick_setting.dart'; import '../../translations/toolbar.i18n.dart';
import '../controller.dart'; import '../controller.dart';
import '../toolbar.dart'; import '../toolbar.dart';
@ -26,7 +26,7 @@ class ImageVideoUtils {
Icons.collections, Icons.collections,
color: Colors.orangeAccent, color: Colors.orangeAccent,
), ),
label: const Text('Gallery'), label: Text('Gallery'.i18n),
onPressed: () => Navigator.pop(ctx, MediaPickSetting.Gallery), onPressed: () => Navigator.pop(ctx, MediaPickSetting.Gallery),
), ),
TextButton.icon( TextButton.icon(
@ -34,7 +34,7 @@ class ImageVideoUtils {
Icons.link, Icons.link,
color: Colors.cyanAccent, color: Colors.cyanAccent,
), ),
label: const Text('Link'), label: Text('Link'.i18n),
onPressed: () => Navigator.pop(ctx, MediaPickSetting.Link), onPressed: () => Navigator.pop(ctx, MediaPickSetting.Link),
) )
], ],

@ -1,7 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../../flutter_quill.dart'; import '../../../flutter_quill.dart';
import 'quill_icon_button.dart';
class IndentButton extends StatefulWidget { class IndentButton extends StatefulWidget {
const IndentButton({ const IndentButton({
@ -9,6 +8,7 @@ class IndentButton extends StatefulWidget {
required this.controller, required this.controller,
required this.isIncrease, required this.isIncrease,
this.iconSize = kDefaultIconSize, this.iconSize = kDefaultIconSize,
this.iconTheme,
Key? key, Key? key,
}) : super(key: key); }) : super(key: key);
@ -17,6 +17,8 @@ class IndentButton extends StatefulWidget {
final QuillController controller; final QuillController controller;
final bool isIncrease; final bool isIncrease;
final QuillIconTheme? iconTheme;
@override @override
_IndentButtonState createState() => _IndentButtonState(); _IndentButtonState createState() => _IndentButtonState();
} }
@ -25,14 +27,17 @@ class _IndentButtonState extends State<IndentButton> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final iconColor = theme.iconTheme.color;
final fillColor = theme.canvasColor; final iconColor =
widget.iconTheme?.iconUnselectedColor ?? theme.iconTheme.color;
final iconFillColor =
widget.iconTheme?.iconUnselectedFillColor ?? theme.canvasColor;
return QuillIconButton( return QuillIconButton(
highlightElevation: 0, highlightElevation: 0,
hoverElevation: 0, hoverElevation: 0,
size: widget.iconSize * 1.77, size: widget.iconSize * 1.77,
icon: Icon(widget.icon, size: widget.iconSize, color: iconColor), icon: Icon(widget.icon, size: widget.iconSize, color: iconColor),
fillColor: fillColor, fillColor: iconFillColor,
onPressed: () { onPressed: () {
final indent = widget.controller final indent = widget.controller
.getSelectionStyle() .getSelectionStyle()

@ -1,9 +1,9 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../models/documents/nodes/embed.dart'; import '../../models/documents/nodes/embed.dart';
import '../../models/themes/quill_icon_theme.dart';
import '../controller.dart'; import '../controller.dart';
import '../toolbar.dart'; import '../toolbar.dart';
import 'quill_icon_button.dart';
class InsertEmbedButton extends StatelessWidget { class InsertEmbedButton extends StatelessWidget {
const InsertEmbedButton({ const InsertEmbedButton({
@ -11,6 +11,7 @@ class InsertEmbedButton extends StatelessWidget {
required this.icon, required this.icon,
this.iconSize = kDefaultIconSize, this.iconSize = kDefaultIconSize,
this.fillColor, this.fillColor,
this.iconTheme,
Key? key, Key? key,
}) : super(key: key); }) : super(key: key);
@ -18,9 +19,16 @@ class InsertEmbedButton extends StatelessWidget {
final IconData icon; final IconData icon;
final double iconSize; final double iconSize;
final Color? fillColor; final Color? fillColor;
final QuillIconTheme? iconTheme;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context);
final iconColor = iconTheme?.iconUnselectedColor ?? theme.iconTheme.color;
final iconFillColor =
iconTheme?.iconUnselectedFillColor ?? (fillColor ?? theme.canvasColor);
return QuillIconButton( return QuillIconButton(
highlightElevation: 0, highlightElevation: 0,
hoverElevation: 0, hoverElevation: 0,
@ -28,9 +36,9 @@ class InsertEmbedButton extends StatelessWidget {
icon: Icon( icon: Icon(
icon, icon,
size: iconSize, size: iconSize,
color: Theme.of(context).iconTheme.color, color: iconColor,
), ),
fillColor: fillColor ?? Theme.of(context).canvasColor, fillColor: iconFillColor,
onPressed: () { onPressed: () {
final index = controller.selection.baseOffset; final index = controller.selection.baseOffset;
final length = controller.selection.extentOffset - index; final length = controller.selection.extentOffset - index;

@ -1,22 +1,28 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../models/documents/attribute.dart'; import '../../models/documents/attribute.dart';
import '../../models/themes/quill_dialog_theme.dart';
import '../../models/themes/quill_icon_theme.dart';
import '../../translations/toolbar.i18n.dart';
import '../controller.dart'; import '../controller.dart';
import '../link_dialog.dart'; import '../link_dialog.dart';
import '../toolbar.dart'; import '../toolbar.dart';
import 'quill_icon_button.dart';
class LinkStyleButton extends StatefulWidget { class LinkStyleButton extends StatefulWidget {
const LinkStyleButton({ const LinkStyleButton({
required this.controller, required this.controller,
this.iconSize = kDefaultIconSize, this.iconSize = kDefaultIconSize,
this.icon, this.icon,
this.iconTheme,
this.dialogTheme,
Key? key, Key? key,
}) : super(key: key); }) : super(key: key);
final QuillController controller; final QuillController controller;
final IconData? icon; final IconData? icon;
final double iconSize; final double iconSize;
final QuillIconTheme? iconTheme;
final QuillDialogTheme? dialogTheme;
@override @override
_LinkStyleButtonState createState() => _LinkStyleButtonState(); _LinkStyleButtonState createState() => _LinkStyleButtonState();
@ -48,22 +54,44 @@ class _LinkStyleButtonState extends State<LinkStyleButton> {
widget.controller.removeListener(_didChangeSelection); widget.controller.removeListener(_didChangeSelection);
} }
final GlobalKey _toolTipKey = GlobalKey();
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final isEnabled = !widget.controller.selection.isCollapsed; final isEnabled = !widget.controller.selection.isCollapsed;
final pressedHandler = isEnabled ? () => _openLinkDialog(context) : null; final pressedHandler = isEnabled ? () => _openLinkDialog(context) : null;
return QuillIconButton( return GestureDetector(
onTap: () async {
final dynamic tooltip = _toolTipKey.currentState;
tooltip.ensureTooltipVisible();
Future.delayed(
const Duration(
seconds: 3,
),
tooltip.deactivate,
);
},
child: Tooltip(
key: _toolTipKey,
message: 'Please first select some text to transform into a link.'.i18n,
child: QuillIconButton(
highlightElevation: 0, highlightElevation: 0,
hoverElevation: 0, hoverElevation: 0,
size: widget.iconSize * kIconButtonFactor, size: widget.iconSize * kIconButtonFactor,
icon: Icon( icon: Icon(
widget.icon ?? Icons.link, widget.icon ?? Icons.link,
size: widget.iconSize, size: widget.iconSize,
color: isEnabled ? theme.iconTheme.color : theme.disabledColor, color: isEnabled
? (widget.iconTheme?.iconUnselectedColor ??
theme.iconTheme.color)
: (widget.iconTheme?.disabledIconColor ?? theme.disabledColor),
), ),
fillColor: Theme.of(context).canvasColor, fillColor:
widget.iconTheme?.iconUnselectedFillColor ?? theme.canvasColor,
onPressed: pressedHandler, onPressed: pressedHandler,
),
),
); );
} }
@ -71,7 +99,7 @@ class _LinkStyleButtonState extends State<LinkStyleButton> {
showDialog<String>( showDialog<String>(
context: context, context: context,
builder: (ctx) { builder: (ctx) {
return const LinkDialog(); return LinkDialog(dialogTheme: widget.dialogTheme);
}, },
).then(_linkSubmitted); ).then(_linkSubmitted);
} }

@ -0,0 +1,154 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import '../../models/documents/attribute.dart';
import '../../models/documents/style.dart';
import '../../models/themes/quill_icon_theme.dart';
import '../controller.dart';
import '../toolbar.dart';
class SelectAlignmentButton extends StatefulWidget {
const SelectAlignmentButton({
required this.controller,
this.iconSize = kDefaultIconSize,
this.iconTheme,
this.showLeftAlignment,
this.showCenterAlignment,
this.showRightAlignment,
this.showJustifyAlignment,
Key? key,
}) : super(key: key);
final QuillController controller;
final double iconSize;
final QuillIconTheme? iconTheme;
final bool? showLeftAlignment;
final bool? showCenterAlignment;
final bool? showRightAlignment;
final bool? showJustifyAlignment;
@override
_SelectAlignmentButtonState createState() => _SelectAlignmentButtonState();
}
class _SelectAlignmentButtonState extends State<SelectAlignmentButton> {
Attribute? _value;
Style get _selectionStyle => widget.controller.getSelectionStyle();
@override
void initState() {
super.initState();
setState(() {
_value = _selectionStyle.attributes[Attribute.align.key] ??
Attribute.leftAlignment;
});
widget.controller.addListener(_didChangeEditingValue);
}
@override
Widget build(BuildContext context) {
final _valueToText = <Attribute, String>{
if (widget.showLeftAlignment!)
Attribute.leftAlignment: Attribute.leftAlignment.value!,
if (widget.showCenterAlignment!)
Attribute.centerAlignment: Attribute.centerAlignment.value!,
if (widget.showRightAlignment!)
Attribute.rightAlignment: Attribute.rightAlignment.value!,
if (widget.showJustifyAlignment!)
Attribute.justifyAlignment: Attribute.justifyAlignment.value!,
};
final _valueAttribute = <Attribute>[
if (widget.showLeftAlignment!) Attribute.leftAlignment,
if (widget.showCenterAlignment!) Attribute.centerAlignment,
if (widget.showRightAlignment!) Attribute.rightAlignment,
if (widget.showJustifyAlignment!) Attribute.justifyAlignment
];
final _valueString = <String>[
if (widget.showLeftAlignment!) Attribute.leftAlignment.value!,
if (widget.showCenterAlignment!) Attribute.centerAlignment.value!,
if (widget.showRightAlignment!) Attribute.rightAlignment.value!,
if (widget.showJustifyAlignment!) Attribute.justifyAlignment.value!,
];
final theme = Theme.of(context);
final buttonCount = ((widget.showLeftAlignment!) ? 1 : 0) +
((widget.showCenterAlignment!) ? 1 : 0) +
((widget.showRightAlignment!) ? 1 : 0) +
((widget.showJustifyAlignment!) ? 1 : 0);
return Row(
mainAxisSize: MainAxisSize.min,
children: List.generate(buttonCount, (index) {
return Padding(
padding: EdgeInsets.symmetric(horizontal: !kIsWeb ? 1.0 : 5.0),
child: ConstrainedBox(
constraints: BoxConstraints.tightFor(
width: widget.iconSize * kIconButtonFactor,
height: widget.iconSize * kIconButtonFactor,
),
child: RawMaterialButton(
hoverElevation: 0,
highlightElevation: 0,
elevation: 0,
visualDensity: VisualDensity.compact,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(2)),
fillColor: _valueToText[_value] == _valueString[index]
? (widget.iconTheme?.iconSelectedFillColor ??
theme.toggleableActiveColor)
: (widget.iconTheme?.iconUnselectedFillColor ??
theme.canvasColor),
onPressed: () => _valueAttribute[index] == Attribute.leftAlignment
? widget.controller
.formatSelection(Attribute.clone(Attribute.align, null))
: widget.controller.formatSelection(_valueAttribute[index]),
child: Icon(
_valueString[index] == Attribute.leftAlignment.value
? Icons.format_align_left
: _valueString[index] == Attribute.centerAlignment.value
? Icons.format_align_center
: _valueString[index] == Attribute.rightAlignment.value
? Icons.format_align_right
: Icons.format_align_justify,
size: widget.iconSize,
color: _valueToText[_value] == _valueString[index]
? (widget.iconTheme?.iconSelectedColor ??
theme.primaryIconTheme.color)
: (widget.iconTheme?.iconUnselectedColor ??
theme.iconTheme.color),
),
),
),
);
}),
);
}
void _didChangeEditingValue() {
setState(() {
_value = _selectionStyle.attributes[Attribute.align.key] ??
Attribute.leftAlignment;
});
}
@override
void didUpdateWidget(covariant SelectAlignmentButton oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.controller != widget.controller) {
oldWidget.controller.removeListener(_didChangeEditingValue);
widget.controller.addListener(_didChangeEditingValue);
_value = _selectionStyle.attributes[Attribute.align.key] ??
Attribute.leftAlignment;
}
}
@override
void dispose() {
widget.controller.removeListener(_didChangeEditingValue);
super.dispose();
}
}

@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import '../../models/documents/attribute.dart'; import '../../models/documents/attribute.dart';
import '../../models/documents/style.dart'; import '../../models/documents/style.dart';
import '../../models/themes/quill_icon_theme.dart';
import '../controller.dart'; import '../controller.dart';
import '../toolbar.dart'; import '../toolbar.dart';
@ -10,12 +11,15 @@ class SelectHeaderStyleButton extends StatefulWidget {
const SelectHeaderStyleButton({ const SelectHeaderStyleButton({
required this.controller, required this.controller,
this.iconSize = kDefaultIconSize, this.iconSize = kDefaultIconSize,
this.iconTheme,
Key? key, Key? key,
}) : super(key: key); }) : super(key: key);
final QuillController controller; final QuillController controller;
final double iconSize; final double iconSize;
final QuillIconTheme? iconTheme;
@override @override
_SelectHeaderStyleButtonState createState() => _SelectHeaderStyleButtonState createState() =>
_SelectHeaderStyleButtonState(); _SelectHeaderStyleButtonState();
@ -63,7 +67,7 @@ class _SelectHeaderStyleButtonState extends State<SelectHeaderStyleButton> {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: List.generate(4, (index) { children: List.generate(4, (index) {
return Padding( return Padding(
padding: const EdgeInsets.symmetric(horizontal: !kIsWeb ? 1.0 : 5.0), padding: EdgeInsets.symmetric(horizontal: !kIsWeb ? 1.0 : 5.0),
child: ConstrainedBox( child: ConstrainedBox(
constraints: BoxConstraints.tightFor( constraints: BoxConstraints.tightFor(
width: widget.iconSize * kIconButtonFactor, width: widget.iconSize * kIconButtonFactor,
@ -77,16 +81,20 @@ class _SelectHeaderStyleButtonState extends State<SelectHeaderStyleButton> {
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(2)), borderRadius: BorderRadius.circular(2)),
fillColor: _valueToText[_value] == _valueString[index] fillColor: _valueToText[_value] == _valueString[index]
? theme.toggleableActiveColor ? (widget.iconTheme?.iconSelectedFillColor ??
: theme.canvasColor, theme.toggleableActiveColor)
: (widget.iconTheme?.iconUnselectedFillColor ??
theme.canvasColor),
onPressed: () => onPressed: () =>
widget.controller.formatSelection(_valueAttribute[index]), widget.controller.formatSelection(_valueAttribute[index]),
child: Text( child: Text(
_valueString[index], _valueString[index],
style: style.copyWith( style: style.copyWith(
color: _valueToText[_value] == _valueString[index] color: _valueToText[_value] == _valueString[index]
? theme.primaryIconTheme.color ? (widget.iconTheme?.iconSelectedColor ??
: theme.iconTheme.color, theme.primaryIconTheme.color)
: (widget.iconTheme?.iconUnselectedColor ??
theme.iconTheme.color),
), ),
), ),
), ),

@ -2,9 +2,9 @@ import 'package:flutter/material.dart';
import '../../models/documents/attribute.dart'; import '../../models/documents/attribute.dart';
import '../../models/documents/style.dart'; import '../../models/documents/style.dart';
import '../../models/themes/quill_icon_theme.dart';
import '../controller.dart'; import '../controller.dart';
import '../toolbar.dart'; import '../toolbar.dart';
import 'toggle_style_button.dart';
class ToggleCheckListButton extends StatefulWidget { class ToggleCheckListButton extends StatefulWidget {
const ToggleCheckListButton({ const ToggleCheckListButton({
@ -14,6 +14,7 @@ class ToggleCheckListButton extends StatefulWidget {
this.iconSize = kDefaultIconSize, this.iconSize = kDefaultIconSize,
this.fillColor, this.fillColor,
this.childBuilder = defaultToggleStyleButtonBuilder, this.childBuilder = defaultToggleStyleButtonBuilder,
this.iconTheme,
Key? key, Key? key,
}) : super(key: key); }) : super(key: key);
@ -28,6 +29,8 @@ class ToggleCheckListButton extends StatefulWidget {
final Attribute attribute; final Attribute attribute;
final QuillIconTheme? iconTheme;
@override @override
_ToggleCheckListButtonState createState() => _ToggleCheckListButtonState(); _ToggleCheckListButtonState createState() => _ToggleCheckListButtonState();
} }
@ -81,18 +84,15 @@ class _ToggleCheckListButtonState extends State<ToggleCheckListButton> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isInCodeBlock =
_selectionStyle.attributes.containsKey(Attribute.codeBlock.key);
final isEnabled =
!isInCodeBlock || Attribute.list.key == Attribute.codeBlock.key;
return widget.childBuilder( return widget.childBuilder(
context, context,
Attribute.unchecked, Attribute.unchecked,
widget.icon, widget.icon,
widget.fillColor, widget.fillColor,
_isToggled, _isToggled,
isEnabled ? _toggleAttribute : null, _toggleAttribute,
widget.iconSize, widget.iconSize,
widget.iconTheme,
); );
} }

@ -2,9 +2,9 @@ import 'package:flutter/material.dart';
import '../../models/documents/attribute.dart'; import '../../models/documents/attribute.dart';
import '../../models/documents/style.dart'; import '../../models/documents/style.dart';
import '../../models/themes/quill_icon_theme.dart';
import '../controller.dart'; import '../controller.dart';
import '../toolbar.dart'; import '../toolbar.dart';
import 'quill_icon_button.dart';
typedef ToggleStyleButtonBuilder = Widget Function( typedef ToggleStyleButtonBuilder = Widget Function(
BuildContext context, BuildContext context,
@ -14,6 +14,7 @@ typedef ToggleStyleButtonBuilder = Widget Function(
bool? isToggled, bool? isToggled,
VoidCallback? onPressed, [ VoidCallback? onPressed, [
double iconSize, double iconSize,
QuillIconTheme? iconTheme,
]); ]);
class ToggleStyleButton extends StatefulWidget { class ToggleStyleButton extends StatefulWidget {
@ -24,6 +25,7 @@ class ToggleStyleButton extends StatefulWidget {
this.iconSize = kDefaultIconSize, this.iconSize = kDefaultIconSize,
this.fillColor, this.fillColor,
this.childBuilder = defaultToggleStyleButtonBuilder, this.childBuilder = defaultToggleStyleButtonBuilder,
this.iconTheme,
Key? key, Key? key,
}) : super(key: key); }) : super(key: key);
@ -38,6 +40,9 @@ class ToggleStyleButton extends StatefulWidget {
final ToggleStyleButtonBuilder childBuilder; final ToggleStyleButtonBuilder childBuilder;
///Specify an icon theme for the icons in the toolbar
final QuillIconTheme? iconTheme;
@override @override
_ToggleStyleButtonState createState() => _ToggleStyleButtonState(); _ToggleStyleButtonState createState() => _ToggleStyleButtonState();
} }
@ -56,18 +61,15 @@ class _ToggleStyleButtonState extends State<ToggleStyleButton> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isInCodeBlock =
_selectionStyle.attributes.containsKey(Attribute.codeBlock.key);
final isEnabled =
!isInCodeBlock || widget.attribute.key == Attribute.codeBlock.key;
return widget.childBuilder( return widget.childBuilder(
context, context,
widget.attribute, widget.attribute,
widget.icon, widget.icon,
widget.fillColor, widget.fillColor,
_isToggled, _isToggled,
isEnabled ? _toggleAttribute : null, _toggleAttribute,
widget.iconSize, widget.iconSize,
widget.iconTheme,
); );
} }
@ -117,17 +119,25 @@ Widget defaultToggleStyleButtonBuilder(
bool? isToggled, bool? isToggled,
VoidCallback? onPressed, [ VoidCallback? onPressed, [
double iconSize = kDefaultIconSize, double iconSize = kDefaultIconSize,
QuillIconTheme? iconTheme,
]) { ]) {
final theme = Theme.of(context); final theme = Theme.of(context);
final isEnabled = onPressed != null; final isEnabled = onPressed != null;
final iconColor = isEnabled final iconColor = isEnabled
? isToggled == true ? isToggled == true
? theme.primaryIconTheme.color ? (iconTheme?.iconSelectedColor ??
: theme.iconTheme.color theme
: theme.disabledColor; .primaryIconTheme.color) //You can specify your own icon color
final fill = isToggled == true : (iconTheme?.iconUnselectedColor ?? theme.iconTheme.color)
? theme.toggleableActiveColor : (iconTheme?.disabledIconColor ?? theme.disabledColor);
: fillColor ?? theme.canvasColor; final fill = isEnabled
? isToggled == true
? (iconTheme?.iconSelectedFillColor ??
theme.toggleableActiveColor) //Selected icon fill color
: (iconTheme?.iconUnselectedFillColor ??
theme.canvasColor) //Unselected icon fill color :
: (iconTheme?.disabledIconFillColor ??
(fillColor ?? theme.canvasColor)); //Disabled icon fill color
return QuillIconButton( return QuillIconButton(
highlightElevation: 0, highlightElevation: 0,
hoverElevation: 0, hoverElevation: 0,

@ -2,12 +2,12 @@ import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart'; import 'package:image_picker/image_picker.dart';
import '../../models/documents/nodes/embed.dart'; import '../../models/documents/nodes/embed.dart';
import '../../utils/media_pick_setting.dart'; import '../../models/themes/quill_dialog_theme.dart';
import '../../models/themes/quill_icon_theme.dart';
import '../controller.dart'; import '../controller.dart';
import '../link_dialog.dart'; import '../link_dialog.dart';
import '../toolbar.dart'; import '../toolbar.dart';
import 'image_video_utils.dart'; import 'image_video_utils.dart';
import 'quill_icon_button.dart';
class VideoButton extends StatelessWidget { class VideoButton extends StatelessWidget {
const VideoButton({ const VideoButton({
@ -19,6 +19,8 @@ class VideoButton extends StatelessWidget {
this.filePickImpl, this.filePickImpl,
this.webVideoPickImpl, this.webVideoPickImpl,
this.mediaPickSettingSelector, this.mediaPickSettingSelector,
this.iconTheme,
this.dialogTheme,
Key? key, Key? key,
}) : super(key: key); }) : super(key: key);
@ -37,16 +39,24 @@ class VideoButton extends StatelessWidget {
final MediaPickSettingSelector? mediaPickSettingSelector; final MediaPickSettingSelector? mediaPickSettingSelector;
final QuillIconTheme? iconTheme;
final QuillDialogTheme? dialogTheme;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final iconColor = iconTheme?.iconUnselectedColor ?? theme.iconTheme.color;
final iconFillColor =
iconTheme?.iconUnselectedFillColor ?? (fillColor ?? theme.canvasColor);
return QuillIconButton( return QuillIconButton(
icon: Icon(icon, size: iconSize, color: theme.iconTheme.color), icon: Icon(icon, size: iconSize, color: iconColor),
highlightElevation: 0, highlightElevation: 0,
hoverElevation: 0, hoverElevation: 0,
size: iconSize * 1.77, size: iconSize * 1.77,
fillColor: fillColor ?? theme.canvasColor, fillColor: iconFillColor,
onPressed: () => _onPressedHandler(context), onPressed: () => _onPressedHandler(context),
); );
} }
@ -80,7 +90,7 @@ class VideoButton extends StatelessWidget {
void _typeLink(BuildContext context) { void _typeLink(BuildContext context) {
showDialog<String>( showDialog<String>(
context: context, context: context,
builder: (_) => const LinkDialog(), builder: (_) => LinkDialog(dialogTheme: dialogTheme),
).then(_linkSubmitted); ).then(_linkSubmitted);
} }

@ -35,13 +35,16 @@ class _VideoAppState extends State<VideoApp> {
// Ensure the first frame is shown after the video is initialized, // Ensure the first frame is shown after the video is initialized,
// even before the play button has been pressed. // even before the play button has been pressed.
setState(() {}); setState(() {});
}).catchError((error) {
setState(() {});
}); });
;
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final defaultStyles = DefaultStyles.getInstance(context); final defaultStyles = DefaultStyles.getInstance(context);
if (!_controller.value.isInitialized || _controller.value.hasError) { if (_controller.value.hasError) {
if (widget.readOnly) { if (widget.readOnly) {
return RichText( return RichText(
text: TextSpan( text: TextSpan(
@ -54,6 +57,12 @@ class _VideoAppState extends State<VideoApp> {
return RichText( return RichText(
text: TextSpan(text: widget.videoUrl, style: defaultStyles.link)); text: TextSpan(text: widget.videoUrl, style: defaultStyles.link));
} else if (!_controller.value.isInitialized) {
return VideoProgressIndicator(
_controller,
allowScrubbing: true,
colors: const VideoProgressColors(playedColor: Colors.blue),
);
} }
return Container( return Container(

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

@ -1,3 +0,0 @@
/// TODO: Remove this file in the next breaking release, because implementation
/// files should be located in the src folder, https://bit.ly/3fA23Yz.
export '../src/widgets/keyboard_listener.dart';

@ -1,3 +0,0 @@
/// TODO: Remove this file in the next breaking release, because implementation
/// files should be located in the src folder, https://bit.ly/3fA23Yz.
export '../../src/widgets/raw_editor/raw_editor_state_keyboard_mixin.dart';

@ -1,3 +0,0 @@
/// TODO: Remove this file in the next breaking release, because implementation
/// files should be located in the src folder, https://bit.ly/3fA23Yz.
export '../src/widgets/simple_viewer.dart';

@ -1,22 +1,22 @@
name: flutter_quill name: flutter_quill
description: A rich text editor supporting mobile and web (Demo App @ bulletjournal.us) description: A rich text editor supporting mobile and web (Demo App @ bulletjournal.us)
version: 1.9.4 version: 3.0.4
#author: bulletjournal #author: bulletjournal
homepage: https://bulletjournal.us/home/index.html homepage: https://bulletjournal.us/home/index.html
repository: https://github.com/singerdmx/flutter-quill repository: https://github.com/singerdmx/flutter-quill
environment: environment:
sdk: ">=2.12.0 <3.0.0" sdk: ">=2.12.0 <3.0.0"
flutter: ">=1.17.0" flutter: ">=2.5.3"
dependencies: dependencies:
flutter: flutter:
sdk: flutter sdk: flutter
collection: ^1.15.0 collection: ^1.15.0
flutter_colorpicker: ^0.4.0 flutter_colorpicker: ^1.0.3
flutter_keyboard_visibility: ^5.0.0 flutter_keyboard_visibility: ^5.0.0
image_picker: ^0.8.2 image_picker: ^0.8.2
photo_view: ^0.12.0 photo_view: ^0.13.0
quiver: ^3.0.0 quiver: ^3.0.0
string_validator: ^0.3.0 string_validator: ^0.3.0
tuple: ^2.0.0 tuple: ^2.0.0
@ -30,6 +30,7 @@ dependencies:
characters: ^1.1.0 characters: ^1.1.0
youtube_player_flutter: ^8.0.0 youtube_player_flutter: ^8.0.0
diff_match_patch: ^0.4.1 diff_match_patch: ^0.4.1
i18n_extension: ^4.1.3
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:

Loading…
Cancel
Save