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. 17
      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. 94
      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. 60
      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. 924
      lib/src/widgets/editor.dart
  30. 31
      lib/src/widgets/float_cursor.dart
  31. 55
      lib/src/widgets/image.dart
  32. 181
      lib/src/widgets/keyboard_listener.dart
  33. 170
      lib/src/widgets/link.dart
  34. 19
      lib/src/widgets/link_dialog.dart
  35. 28
      lib/src/widgets/proxy.dart
  36. 354
      lib/src/widgets/quill_single_child_scroll_view.dart
  37. 495
      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. 156
      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. 504
      lib/src/widgets/text_line.dart
  48. 190
      lib/src/widgets/text_selection.dart
  49. 196
      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. 52
      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]
* 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
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).
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
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
@ -110,9 +110,26 @@ Define `mobileWidth`, `mobileHeight`, `mobileMargin`, `mobileAlignment` as follo
}
```
## Migrate Zefyr Data
Check out [code](https://github.com/jwehrle/zefyr_quill_convert) and [doc](https://docs.google.com/document/d/1FUSrpbarHnilb7uDN5J5DDahaI0v1RMXBjj4fFSpSuY/edit?usp=sharing).
## Translation of toolbar
The package offers translations for the quill toolbar, it will follow the system locale unless you set your own locale with:
```
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,
// uncomment to provide a custom "pick from" dialog.
// mediaPickSettingSelector: _selectMediaPickSetting,
showAlignmentButtons: true,
);
if (kIsWeb) {
toolbar = QuillToolbar.basic(
controller: _controller!,
onImagePickCallback: _onImagePickCallback,
webImagePickImpl: _webImagePickImpl);
controller: _controller!,
onImagePickCallback: _onImagePickCallback,
webImagePickImpl: _webImagePickImpl,
showAlignmentButtons: true,
);
}
if (_isDesktop()) {
toolbar = QuillToolbar.basic(
controller: _controller!,
onImagePickCallback: _onImagePickCallback,
filePickImpl: openFileSystemPickerForDesktop);
controller: _controller!,
onImagePickCallback: _onImagePickCallback,
filePickImpl: openFileSystemPickerForDesktop,
showAlignmentButtons: true,
);
}
return SafeArea(

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

@ -5,9 +5,9 @@
// 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.
import 'package:app/main.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:app/main.dart';
void main() {
testWidgets('Counter increments smoke test', (tester) async {

@ -4,9 +4,9 @@
#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) {
UrlLauncherPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("UrlLauncherPlugin"));
UrlLauncherWindowsRegisterWithRegistrar(
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/leaf.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/default_styles.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';

@ -19,8 +19,10 @@ class Attribute<T> {
static final Map<String, Attribute> _registry = LinkedHashMap.of({
Attribute.bold.key: Attribute.bold,
Attribute.italic.key: Attribute.italic,
Attribute.small.key: Attribute.small,
Attribute.underline.key: Attribute.underline,
Attribute.strikeThrough.key: Attribute.strikeThrough,
Attribute.inlineCode.key: Attribute.inlineCode,
Attribute.font.key: Attribute.font,
Attribute.size.key: Attribute.size,
Attribute.link.key: Attribute.link,
@ -37,16 +39,21 @@ class Attribute<T> {
Attribute.height.key: Attribute.height,
Attribute.style.key: Attribute.style,
Attribute.token.key: Attribute.token,
Attribute.script.key: Attribute.script,
});
static final BoldAttribute bold = BoldAttribute();
static final ItalicAttribute italic = ItalicAttribute();
static final SmallAttribute small = SmallAttribute();
static final UnderlineAttribute underline = UnderlineAttribute();
static final StrikeThroughAttribute strikeThrough = StrikeThroughAttribute();
static final InlineCodeAttribute inlineCode = InlineCodeAttribute();
static final FontAttribute font = FontAttribute(null);
static final SizeAttribute size = SizeAttribute(null);
@ -79,9 +86,12 @@ class Attribute<T> {
static final TokenAttribute token = TokenAttribute('');
static final ScriptAttribute script = ScriptAttribute('');
static final Set<String> inlineKeys = {
Attribute.bold.key,
Attribute.italic.key,
Attribute.small.key,
Attribute.underline.key,
Attribute.strikeThrough.key,
Attribute.link.key,
@ -107,6 +117,13 @@ class Attribute<T> {
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 h2 => HeaderAttribute(level: 2);
@ -217,6 +234,10 @@ class ItalicAttribute extends Attribute<bool> {
ItalicAttribute() : super('italic', AttributeScope.INLINE, true);
}
class SmallAttribute extends Attribute<bool> {
SmallAttribute() : super('small', AttributeScope.INLINE, true);
}
class UnderlineAttribute extends Attribute<bool> {
UnderlineAttribute() : super('underline', AttributeScope.INLINE, true);
}
@ -225,6 +246,10 @@ class StrikeThroughAttribute extends Attribute<bool> {
StrikeThroughAttribute() : super('strike', AttributeScope.INLINE, true);
}
class InlineCodeAttribute extends Attribute<bool> {
InlineCodeAttribute() : super('code', AttributeScope.INLINE, true);
}
class FontAttribute extends Attribute<String?> {
FontAttribute(String? val) : super('font', AttributeScope.INLINE, val);
}
@ -290,3 +315,8 @@ class StyleAttribute extends Attribute<String?> {
class TokenAttribute extends Attribute<String> {
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) {
final len = node.length;
if (offset < len || (inclusive && offset == len && node.isLast)) {
if (offset < len || (inclusive && offset == len)) {
return ChildQuery(node, offset);
}
offset -= len;

@ -19,7 +19,7 @@ class Embeddable {
static Embeddable fromJson(Map<String, dynamic> 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);
}

@ -202,11 +202,26 @@ class Line extends Container<Leaf?> {
if (parent is Block) {
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();
} else if (!const MapEquality()
.equals(newStyle.getBlocksExceptHeader(), parentStyle)) {
_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);
} // else the same style, no-op.
} else if (blockStyle.value != null) {
@ -344,8 +359,8 @@ class Line extends Container<Leaf?> {
result = result.mergeAll(node.style);
var pos = node.length - data.offset;
while (!node!.isLast && pos < local) {
node = node.next as Leaf?;
_handle(node!.style);
node = node.next as Leaf;
_handle(node.style);
pos += node.length;
}
}
@ -376,8 +391,8 @@ class Line extends Container<Leaf?> {
result.add(node.style);
var pos = node.length - data.offset;
while (!node!.isLast && pos < local) {
node = node.next as Leaf?;
result.add(node!.style);
node = node.next as Leaf;
result.add(node.style);
pos += node.length;
}
}

@ -14,7 +14,8 @@ import 'line.dart';
/// The [offset] property is relative to [parent]. See also [documentOffset]
/// 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> {
/// Current parent of this node. May be null if this node is not mounted.
Container? parent;

@ -150,6 +150,11 @@ class Operation {
/// Returns `true` if [other] operation has the same attributes as this one.
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);
}
@ -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.
Delta concat(Delta other) {
Delta concat(Delta other, {bool trimNewLine = false}) {
final result = Delta.from(this);
if (trimNewLine) {
result._trimNewLine();
}
if (other.isNotEmpty) {
// In case first operation of other can be merged with last operation in
// our list.

@ -2,6 +2,7 @@ import '../documents/attribute.dart';
import '../quill_delta.dart';
import 'rule.dart';
/// A heuristic rule for delete operations.
abstract class DeleteRule extends Rule {
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 {
const CatchAllDeleteRule();
@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(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 {
const PreserveLineStyleOnMergeRule();
@ -44,6 +69,14 @@ class PreserveLineStyleOnMergeRule extends DeleteRule {
final attrs = op.attributes;
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()
..retain(index)
..delete(len);
@ -66,13 +99,16 @@ class PreserveLineStyleOnMergeRule extends DeleteRule {
attributes ??= <String, dynamic>{};
attributes.addAll(attrs!);
}
delta..retain(lineBreak)..retain(1, attributes);
delta
..retain(lineBreak)
..retain(1, attributes);
break;
}
return delta;
}
}
/// Prevents user from merging a line containing an embed with other lines.
class EnsureEmbedLineRule extends DeleteRule {
const EnsureEmbedLineRule();

@ -2,6 +2,7 @@ import '../documents/attribute.dart';
import '../quill_delta.dart';
import 'rule.dart';
/// A heuristic rule for format (retain) operations.
abstract class FormatRule extends Rule {
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 {
const ResolveLineFormatRule();
@ -26,44 +29,81 @@ class ResolveLineFormatRule extends FormatRule {
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);
Operation op;
for (var cur = 0; cur < len! && itr.hasNext; cur += op.length!) {
op = itr.next(len - cur);
if (op.data is! String || !(op.data as String).contains('\n')) {
delta.retain(op.length!);
final opText = op.data is String ? op.data as String : '';
if (!opText.contains('\n')) {
result.retain(op.length!);
continue;
}
final text = op.data as String;
final tmp = Delta();
var offset = 0;
for (var lineBreak = text.indexOf('\n');
lineBreak >= 0;
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);
}
final delta = _applyAttribute(opText, op, attribute);
result = result.concat(delta);
}
// And include extra newline after retain
while (itr.hasNext) {
op = itr.next();
final text = op.data is String ? (op.data as String?)! : '';
final lineBreak = text.indexOf('\n');
if (lineBreak < 0) {
delta.retain(op.length!);
final opText = op.data is String ? op.data as String : '';
final lf = opText.indexOf('\n');
if (lf < 0) {
result.retain(op.length!);
continue;
}
delta..retain(lineBreak)..retain(1, attribute.toJson());
final delta = _applyAttribute(opText, op, attribute, firstOnly: true);
result = result.concat(delta);
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 {
const FormatLinkAtCaretPositionRule();
@ -89,11 +129,15 @@ class FormatLinkAtCaretPositionRule extends FormatRule {
return null;
}
delta..retain(beg)..retain(retain!, attribute.toJson());
delta
..retain(beg)
..retain(retain!, attribute.toJson());
return delta;
}
}
/// Produces Delta with inline-level attributes applied too all characters
/// except newlines.
class ResolveInlineFormatRule extends FormatRule {
const ResolveInlineFormatRule();
@ -118,7 +162,9 @@ class ResolveInlineFormatRule extends FormatRule {
}
var pos = 0;
while (lineBreak >= 0) {
delta..retain(lineBreak - pos, attribute.toJson())..retain(1);
delta
..retain(lineBreak - pos, attribute.toJson())
..retain(1);
pos = lineBreak + 1;
lineBreak = text.indexOf('\n', pos);
}

@ -5,6 +5,7 @@ import '../documents/style.dart';
import '../quill_delta.dart';
import 'rule.dart';
/// A heuristic rule for insert operations.
abstract class InsertRule extends Rule {
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 {
const PreserveLineStyleOnSplitRule();
@ -87,12 +92,12 @@ class PreserveBlockStyleOnInsertRule extends InsertRule {
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
// style to the newly inserted line before it and reset style of the
// original line.
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.
@ -113,7 +118,7 @@ class PreserveBlockStyleOnInsertRule extends InsertRule {
}
// Reset style of the original newline character if needed.
if (resetStyle != null) {
if (resetStyle.isNotEmpty) {
delta
..retain(nextNewLine.item2!)
..retain((nextNewLine.item1!.data as String).indexOf('\n'))
@ -192,10 +197,17 @@ class AutoExitBlockRule extends InsertRule {
attributes.keys.firstWhere(Attribute.blockKeysExceptHeader.contains);
attributes[k] = null;
// 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 {
const ResetLineFormatOnNewLineRule();
@ -225,6 +237,7 @@ class ResetLineFormatOnNewLineRule extends InsertRule {
}
}
/// Handles all format operations which manipulate embeds.
class InsertEmbedsRule extends InsertRule {
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 {
const AutoFormatLinksRule();
@ -312,6 +327,7 @@ class AutoFormatLinksRule extends InsertRule {
}
}
/// Preserves inline styles when user inserts text inside formatted segment.
class PreserveInlineStylesRule extends InsertRule {
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 {
const CatchAllInsertRule();

@ -19,6 +19,8 @@ abstract class Rule {
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,
{int? len, Object? data, Attribute? attribute});
@ -46,6 +48,7 @@ class Rules {
const EnsureEmbedLineRule(),
const PreserveLineStyleOnMergeRule(),
const CatchAllDeleteRule(),
const EnsureLastLineBreakDeleteRule()
]);
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';
Color stringToColor(String? s) {

@ -2,35 +2,6 @@ import 'dart:math' as math;
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
class Diff {
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) {
final result = <String, String>{};
final pairs = s.split(';');
@ -14,3 +16,37 @@ Map<String, String> parseKeyValuePairs(String s, Set<String> targetKeys) {
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
/// 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 '../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 {
QuillController({
required this.document,
required TextSelection selection,
}) : _selection = selection;
bool keepStyleOnNewLine = false,
this.onReplaceText,
this.onDelete,
}) : _selection = selection,
_keepStyleOnNewLine = keepStyleOnNewLine;
factory QuillController.basic() {
return QuillController(
@ -26,10 +33,21 @@ class QuillController extends ChangeNotifier {
/// Document managed by this controller.
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].
TextSelection get selection => _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
/// and that has not been applied yet.
/// It gets reset after each format action within the [document].
@ -79,7 +97,7 @@ class QuillController extends ChangeNotifier {
}
void _handleHistoryChange(int? len) {
if (len! > 0) {
if (len! != 0) {
// if (this.selection.extentOffset >= document.length) {
// // cursor exceeds the length of document, position it in the end
// updateSelection(
@ -104,11 +122,21 @@ class QuillController extends ChangeNotifier {
bool get hasRedo => document.hasRedo;
/// clear editor
void clear() {
replaceText(0, plainTextEditingValue.text.length - 1, '',
const TextSelection.collapsed(offset: 0));
}
void replaceText(
int index, int len, Object? data, TextSelection? textSelection,
{bool ignoreFocus = false}) {
assert(data is String || data is Embeddable);
if (onReplaceText != null && !onReplaceText!(index, len, data)) {
return;
}
Delta? delta;
if (len > 0 || data is! String || data.isNotEmpty) {
delta = document.replace(index, len, data);
@ -135,7 +163,14 @@ class QuillController extends ChangeNotifier {
}
}
toggledStyle = Style();
if (_keepStyleOnNewLine) {
final style = getSelectionStyle();
final notInlineStyle = style.attributes.values.where((s) => !s.isInline);
toggledStyle = style.removeAll(notInlineStyle.toSet());
} else {
toggledStyle = Style();
}
if (textSelection != null) {
if (delta == null || delta.isEmpty) {
_updateSelection(textSelection, ChangeSource.LOCAL);
@ -162,6 +197,14 @@ class QuillController extends ChangeNotifier {
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) {
if (len == 0 &&
attribute!.isInline &&
@ -183,6 +226,17 @@ class QuillController extends ChangeNotifier {
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) {
_updateSelection(textSelection, source);
notifyListeners();

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

@ -1,7 +1,9 @@
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'package:tuple/tuple.dart';
import '../../flutter_quill.dart';
import '../../models/documents/style.dart';
class QuillStyles extends InheritedWidget {
const QuillStyles({
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 {
DefaultTextBlockStyle(
this.style,
@ -34,15 +38,100 @@ class DefaultTextBlockStyle {
this.decoration,
);
/// Base text style for a text block.
final TextStyle style;
/// Vertical spacing around a text block.
final Tuple2<double, double> verticalSpacing;
/// Vertical spacing for individual lines within a text block.
///
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;
}
/// 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 {
DefaultStyles({
this.h1,
@ -51,8 +140,10 @@ class DefaultStyles {
this.paragraph,
this.bold,
this.italic,
this.small,
this.underline,
this.strikeThrough,
this.inlineCode,
this.link,
this.color,
this.placeHolder,
@ -73,15 +164,19 @@ class DefaultStyles {
final DefaultTextBlockStyle? paragraph;
final TextStyle? bold;
final TextStyle? italic;
final TextStyle? small;
final TextStyle? underline;
final TextStyle? strikeThrough;
/// Theme of inline code.
final InlineCodeStyle? inlineCode;
final TextStyle? sizeSmall; // 'small'
final TextStyle? sizeLarge; // 'large'
final TextStyle? sizeHuge; // 'huge'
final TextStyle? link;
final Color? color;
final DefaultTextBlockStyle? placeHolder;
final DefaultTextBlockStyle? lists;
final DefaultListBlockStyle? lists;
final DefaultTextBlockStyle? quote;
final DefaultTextBlockStyle? code;
final DefaultTextBlockStyle? indent;
@ -112,6 +207,12 @@ class DefaultStyles {
throw UnimplementedError();
}
final inlineCodeStyle = TextStyle(
fontSize: 14,
color: themeData.colorScheme.primaryVariant.withOpacity(0.8),
fontFamily: fontFamily,
);
return DefaultStyles(
h1: DefaultTextBlockStyle(
defaultTextStyle.style.copyWith(
@ -147,10 +248,25 @@ class DefaultStyles {
baseStyle, const Tuple2(0, 0), const Tuple2(0, 0), null),
bold: const TextStyle(fontWeight: FontWeight.bold),
italic: const TextStyle(fontStyle: FontStyle.italic),
small: const TextStyle(fontSize: 12, color: Colors.black45),
underline: const TextStyle(decoration: TextDecoration.underline),
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(
color: themeData.accentColor,
color: themeData.colorScheme.secondary,
decoration: TextDecoration.underline,
),
placeHolder: DefaultTextBlockStyle(
@ -162,8 +278,8 @@ class DefaultStyles {
const Tuple2(0, 0),
const Tuple2(0, 0),
null),
lists: DefaultTextBlockStyle(
baseStyle, baseSpacing, const Tuple2(0, 6), null),
lists: DefaultListBlockStyle(
baseStyle, baseSpacing, const Tuple2(0, 6), null, null),
quote: DefaultTextBlockStyle(
TextStyle(color: baseStyle.color!.withOpacity(0.6)),
baseSpacing,
@ -205,8 +321,10 @@ class DefaultStyles {
paragraph: other.paragraph ?? paragraph,
bold: other.bold ?? bold,
italic: other.italic ?? italic,
small: other.small ?? small,
underline: other.underline ?? underline,
strikeThrough: other.strikeThrough ?? strikeThrough,
inlineCode: other.inlineCode ?? inlineCode,
link: other.link ?? link,
color: other.color ?? color,
placeHolder: other.placeHolder ?? placeHolder,

@ -1,11 +1,8 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import '../../flutter_quill.dart';
import '../models/documents/nodes/leaf.dart';
import 'editor.dart';
import '../../flutter_quill.dart';
import 'text_selection.dart';
typedef EmbedBuilder = Widget Function(
@ -21,29 +18,94 @@ abstract class EditorTextSelectionGestureDetectorBuilderDelegate {
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 {
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;
/// 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;
/// The [State] of the [EditableText] for which the builder will provide a
/// [EditorTextSelectionGestureDetector].
@protected
EditorState? getEditor() {
return delegate.getEditableTextKey().currentState;
}
/// The [RenderObject] of the [EditableText] for which the builder will
/// provide a [EditorTextSelectionGestureDetector].
@protected
RenderEditor? 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) {
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;
shouldShowSelectionToolbar = kind == null ||
kind == PointerDeviceKind.touch ||
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) {
assert(delegate.getForcePressEnabled());
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) {
assert(delegate.getForcePressEnabled());
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) {
if (delegate.getSelectionEnabled()) {
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) {
if (delegate.getSelectionEnabled()) {
getRenderEditor()!.selectPositionAt(
details.globalPosition,
null,
SelectionChangedCause.longPress,
from: details.globalPosition,
cause: 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) {
if (delegate.getSelectionEnabled()) {
getRenderEditor()!.selectPositionAt(
details.globalPosition,
null,
SelectionChangedCause.longPress,
from: details.globalPosition,
cause: 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) {
if (shouldShowSelectionToolbar) {
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) {
if (delegate.getSelectionEnabled()) {
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) {
getRenderEditor()!.selectPositionAt(
details.globalPosition,
null,
SelectionChangedCause.drag,
);
getRenderEditor()!.handleDragStart(details);
}
/// 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(
DragStartDetails startDetails, DragUpdateDetails updateDetails) {
getRenderEditor()!.selectPositionAt(
startDetails.globalPosition,
updateDetails.globalPosition,
SelectionChangedCause.drag,
);
getRenderEditor()!.extendSelection(updateDetails.globalPosition,
cause: 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(
key: key,
onTapDown: onTapDown,
onForcePressStart:
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/rendering.dart';
import 'package:photo_view/photo_view.dart';
class ImageTapWrapper extends StatelessWidget {
@ -17,13 +15,52 @@ class ImageTapWrapper extends StatelessWidget {
constraints: BoxConstraints.expand(
height: MediaQuery.of(context).size.height,
),
child: GestureDetector(
onTapDown: (_) {
Navigator.pop(context);
},
child: PhotoView(
imageProvider: imageProvider,
),
child: Stack(
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);
},
child: Stack(
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/widgets.dart';
enum InputShortcut { CUT, COPY, PASTE, SELECT_ALL }
typedef CursorMoveCallback = void Function(
LogicalKeyboardKey key, bool wordModifier, bool lineModifier, bool shift);
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) {
return KeyEventResult.ignored;
}
class QuillPressedKeys extends ChangeNotifier {
static QuillPressedKeys of(BuildContext context) {
final widget =
context.dependOnInheritedWidgetOfExactType<_QuillPressedKeysAccess>();
return widget!.pressedKeys;
}
final keysPressed =
LogicalKeyboardKey.collapseSynonyms(RawKeyboard.instance.keysPressed);
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;
}
bool _metaPressed = false;
bool _controlPressed = false;
if (_moveKeys.contains(key)) {
onCursorMove(
key,
isMacOS ? event.isAltPressed : event.isControlPressed,
isMacOS ? event.isMetaPressed : event.isAltPressed,
event.isShiftPressed);
} else if (isMacOS
? event.isMetaPressed
: event.isControlPressed && _shortcutKeys.contains(key)) {
onShortcut(_keyToShortcut[key]);
} else if (key == LogicalKeyboardKey.delete) {
onDelete(true);
} else if (key == LogicalKeyboardKey.backspace) {
onDelete(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();
}
return KeyEventResult.ignored;
}
}
class QuillKeyboardListener extends StatefulWidget {
const QuillKeyboardListener({required this.child, Key? key})
: super(key: key);
final Widget child;
@override
QuillKeyboardListenerState createState() => QuillKeyboardListenerState();
}
class QuillKeyboardListenerState extends State<QuillKeyboardListener> {
final QuillPressedKeys _pressedKeys = QuillPressedKeys();
bool _keyEvent(KeyEvent event) {
_pressedKeys
._updatePressedKeys(HardwareKeyboard.instance.logicalKeysPressed);
return false;
}
@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 '../models/themes/quill_dialog_theme.dart';
import '../translations/toolbar.i18n.dart';
class LinkDialog extends StatefulWidget {
const LinkDialog({Key? key}) : super(key: key);
const LinkDialog({this.dialogTheme, Key? key}) : super(key: key);
final QuillDialogTheme? dialogTheme;
@override
LinkDialogState createState() => LinkDialogState();
@ -13,15 +18,23 @@ class LinkDialogState extends State<LinkDialog> {
@override
Widget build(BuildContext context) {
return AlertDialog(
backgroundColor: widget.dialogTheme?.dialogBackgroundColor,
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,
onChanged: _linkChanged,
),
actions: [
TextButton(
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/widgets.dart';
@ -127,17 +129,19 @@ class RenderEmbedProxy extends RenderProxyBox implements RenderContentProxyBox {
}
class RichTextProxy extends SingleChildRenderObjectWidget {
/// Child argument should be an instance of RichText widget.
const RichTextProxy(
RichText child,
this.textStyle,
this.textAlign,
this.textDirection,
this.textScaleFactor,
this.locale,
this.strutStyle,
this.textWidthBasis,
this.textHeightBehavior,
) : super(child: child);
{required RichText child,
required this.textStyle,
required this.textAlign,
required this.textDirection,
required this.locale,
required this.strutStyle,
this.textScaleFactor = 1.0,
this.textWidthBasis = TextWidthBasis.parent,
this.textHeightBehavior,
Key? key})
: super(key: key, child: child);
final TextStyle textStyle;
final TextAlign textAlign;
@ -291,8 +295,8 @@ class RenderParagraphProxy extends RenderProxyBox
child!.getWordBoundary(position);
@override
List<TextBox> getBoxesForSelection(TextSelection selection) =>
child!.getBoxesForSelection(selection);
List<TextBox> getBoxesForSelection(TextSelection selection) => child!
.getBoxesForSelection(selection, boxHeightStyle: BoxHeightStyle.strut);
@override
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:convert';
import 'dart:math' as math;
import 'package:flutter/cupertino.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/scheduler.dart';
@ -11,6 +11,7 @@ import 'package:flutter/services.dart';
import 'package:flutter_keyboard_visibility/flutter_keyboard_visibility.dart';
import 'package:tuple/tuple.dart';
import '../../models/documents/nodes/node.dart';
import '../models/documents/attribute.dart';
import '../models/documents/document.dart';
import '../models/documents/nodes/block.dart';
@ -21,8 +22,9 @@ import 'default_styles.dart';
import 'delegate.dart';
import 'editor.dart';
import 'keyboard_listener.dart';
import 'link.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_text_input_client_mixin.dart';
import 'text_block.dart';
@ -31,66 +33,191 @@ import 'text_selection.dart';
class RawEditor extends StatefulWidget {
const RawEditor(
Key key,
this.controller,
this.focusNode,
this.scrollController,
this.scrollable,
this.scrollBottomInset,
this.padding,
this.readOnly,
this.placeholder,
this.onLaunchUrl,
this.toolbarOptions,
this.showSelectionHandles,
bool? showCursor,
this.cursorStyle,
this.textCapitalization,
this.maxHeight,
this.minHeight,
this.customStyles,
this.expands,
this.autoFocus,
this.selectionColor,
this.selectionCtrls,
this.keyboardAppearance,
this.enableInteractiveSelection,
this.scrollPhysics,
this.embedBuilder,
this.customStyleBuilder,
) : assert(maxHeight == null || maxHeight > 0, 'maxHeight cannot be null'),
{required this.controller,
required this.focusNode,
required this.scrollController,
required this.scrollBottomInset,
required this.cursorStyle,
required this.selectionColor,
required this.selectionCtrls,
Key? key,
this.scrollable = true,
this.padding = EdgeInsets.zero,
this.readOnly = false,
this.placeholder,
this.onLaunchUrl,
this.toolbarOptions = const ToolbarOptions(
copy: true,
cut: true,
paste: true,
selectAll: true,
),
this.showSelectionHandles = false,
bool? showCursor,
this.textCapitalization = TextCapitalization.none,
this.maxHeight,
this.minHeight,
this.maxContentWidth,
this.customStyles,
this.expands = false,
this.autoFocus = false,
this.keyboardAppearance = Brightness.light,
this.enableInteractiveSelection = true,
this.scrollPhysics,
this.embedBuilder = defaultEmbedBuilder,
this.linkActionPickerDelegate = defaultLinkActionPickerDelegate,
this.customStyleBuilder,
this.floatingCursorDisabled = false})
: assert(maxHeight == null || maxHeight > 0, 'maxHeight cannot be null'),
assert(minHeight == null || minHeight >= 0, 'minHeight cannot be null'),
assert(maxHeight == null || minHeight == null || maxHeight >= minHeight,
'maxHeight cannot be null'),
showCursor = showCursor ?? true,
super(key: key);
/// Controls the document being edited.
final QuillController controller;
/// Controls whether this editor has keyboard focus.
final FocusNode focusNode;
final ScrollController scrollController;
final bool scrollable;
final double scrollBottomInset;
/// Additional space around the editor contents.
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 String? placeholder;
/// Callback which is triggered when the user wants to open a URL from
/// a link in the document.
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;
/// 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;
/// 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;
/// The style to be used for the editing cursor.
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;
/// 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;
/// The minimum height this editor can have.
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;
/// 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;
/// 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;
/// The color to use when painting the selection.
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;
/// The appearance of the keyboard.
///
/// This setting is only honored on iOS devices.
///
/// Defaults to [Brightness.light].
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;
/// 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;
/// Builder function for embeddable objects.
final EmbedBuilder embedBuilder;
final LinkActionPickerDelegate linkActionPickerDelegate;
final CustomStyleBuilder? customStyleBuilder;
final bool floatingCursorDisabled;
@override
State<StatefulWidget> createState() => RawEditorState();
}
@ -100,13 +227,11 @@ class RawEditorState extends EditorState
AutomaticKeepAliveClientMixin<RawEditor>,
WidgetsBindingObserver,
TickerProviderStateMixin<RawEditor>,
RawEditorStateKeyboardMixin,
TextEditingActionTarget,
RawEditorStateTextInputClientMixin,
RawEditorStateSelectionDelegateMixin {
final GlobalKey _editorKey = GlobalKey();
// Keyboard
late KeyboardListener _keyboardListener;
KeyboardVisibilityController? _keyboardVisibilityController;
StreamSubscription<bool>? _keyboardVisibilitySubscription;
bool _keyboardVisible = false;
@ -120,6 +245,7 @@ class RawEditorState extends EditorState
ScrollController get scrollController => _scrollController;
late ScrollController _scrollController;
// Cursors
late CursorCont _cursorCont;
// Focus
@ -127,6 +253,7 @@ class RawEditorState extends EditorState
FocusAttachment? _focusAttachment;
bool get _hasFocus => widget.focusNode.hasFocus;
// Theme
DefaultStyles? _styles;
final ClipboardStatusNotifier _clipboardStatus = ClipboardStatusNotifier();
@ -156,12 +283,15 @@ class RawEditorState extends EditorState
document: _doc,
selection: widget.controller.selection,
hasFocus: _hasFocus,
cursorController: _cursorCont,
textDirection: _textDirection,
startHandleLayerLink: _startHandleLayerLink,
endHandleLayerLink: _endHandleLayerLink,
onSelectionChanged: _handleSelectionChanged,
scrollBottomInset: widget.scrollBottomInset,
padding: widget.padding,
maxContentWidth: widget.maxContentWidth,
floatingCursorDisabled: widget.floatingCursorDisabled,
children: _buildChildren(_doc, context),
),
),
@ -173,10 +303,29 @@ class RawEditorState extends EditorState
child = BaselineProxy(
textStyle: _styles!.paragraph!.style,
padding: baselinePadding,
child: SingleChildScrollView(
child: QuillSingleChildScrollView(
controller: _scrollController,
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,9 +340,11 @@ class RawEditorState extends EditorState
data: _styles!,
child: MouseRegion(
cursor: SystemMouseCursors.text,
child: Container(
constraints: constraints,
child: child,
child: QuillKeyboardListener(
child: Container(
constraints: constraints,
child: child,
),
),
),
);
@ -201,24 +352,34 @@ class RawEditorState extends EditorState
void _handleSelectionChanged(
TextSelection selection, SelectionChangedCause cause) {
final oldSelection = widget.controller.selection;
widget.controller.updateSelection(selection, ChangeSource.LOCAL);
_selectionOverlay?.handlesVisible = _shouldShowSelectionHandles();
if (!_keyboardVisible) {
// This will show the keyboard for all selection changes on the
// editor, not just changes triggered by user gestures.
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
/// by changing its attribute according to [value].
void _handleCheckboxTap(int offset, bool value) {
if (!widget.readOnly) {
if (value) {
widget.controller.formatText(offset, 0, Attribute.checked);
} else {
widget.controller.formatText(offset, 0, Attribute.unchecked);
}
widget.controller.formatText(
offset, 0, value ? Attribute.checked : Attribute.unchecked);
}
}
@ -233,6 +394,7 @@ class RawEditorState extends EditorState
final attrs = node.style.attributes;
final editableTextBlock = EditableTextBlock(
block: node,
controller: widget.controller,
textDirection: _textDirection,
scrollBottomInset: widget.scrollBottomInset,
verticalSpacing: _getVerticalSpacingForBlock(node, _styles),
@ -245,6 +407,8 @@ class RawEditorState extends EditorState
? const EdgeInsets.all(16)
: null,
embedBuilder: widget.embedBuilder,
linkActionPicker: _linkActionPicker,
onLaunchUrl: widget.onLaunchUrl,
cursorCont: _cursorCont,
indentLevelCounts: indentLevelCounts,
onCheckboxTap: _handleCheckboxTap,
@ -267,6 +431,9 @@ class RawEditorState extends EditorState
customStyleBuilder: widget.customStyleBuilder,
styles: _styles!,
readOnly: widget.readOnly,
controller: widget.controller,
linkActionPicker: _linkActionPicker,
onLaunchUrl: widget.onLaunchUrl,
);
final editableTextLine = EditableTextLine(
node,
@ -313,8 +480,12 @@ class RawEditorState extends EditorState
return defaultStyles!.code!.verticalSpacing;
} else if (attrs.containsKey(Attribute.indent.key)) {
return defaultStyles!.indent!.verticalSpacing;
} else if (attrs.containsKey(Attribute.list.key)) {
return defaultStyles!.lists!.verticalSpacing;
} else if (attrs.containsKey(Attribute.align.key)) {
return defaultStyles!.align!.verticalSpacing;
}
return defaultStyles!.lists!.verticalSpacing;
return const Tuple2(0, 0);
}
@override
@ -336,11 +507,9 @@ class RawEditorState extends EditorState
tickerProvider: this,
);
_keyboardListener = KeyboardListener(
handleCursorMovement,
handleShortcut,
handleDelete,
);
// Floating cursor
_floatingCursorResetController = AnimationController(vsync: this);
_floatingCursorResetController.addListener(onFloatingCursorResetTick);
if (defaultTargetPlatform == TargetPlatform.windows ||
defaultTargetPlatform == TargetPlatform.macOS ||
@ -354,13 +523,12 @@ class RawEditorState extends EditorState
_keyboardVisibilityController?.onChange.listen((visible) {
_keyboardVisible = visible;
if (visible) {
_onChangeTextEditingValue();
_onChangeTextEditingValue(!_hasFocus);
}
});
}
_focusAttachment = widget.focusNode.attach(context,
onKey: (node, event) => _keyboardListener.handleRawKeyEvent(event));
_focusAttachment = widget.focusNode.attach(context);
widget.focusNode.addListener(_handleFocusChanged);
}
@ -405,8 +573,7 @@ class RawEditorState extends EditorState
if (widget.focusNode != oldWidget.focusNode) {
oldWidget.focusNode.removeListener(_handleFocusChanged);
_focusAttachment?.detach();
_focusAttachment = widget.focusNode.attach(context,
onKey: (node, event) => _keyboardListener.handleRawKeyEvent(event));
_focusAttachment = widget.focusNode.attach(context);
widget.focusNode.addListener(_handleFocusChanged);
updateKeepAlive();
}
@ -448,7 +615,7 @@ class RawEditorState extends EditorState
}
void _updateSelectionOverlayForScroll() {
_selectionOverlay?.markNeedsBuild();
_selectionOverlay?.updateForScroll();
}
void _didChangeTextEditingValue([bool ignoreFocus = false]) {
@ -482,11 +649,19 @@ class RawEditorState extends EditorState
_cursorCont.startOrStopCursorTimerIfNeeded(
_hasFocus, widget.controller.selection);
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
..stopCursorTimer(resetCharTicks: false)
..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((_) {
if (!mounted) {
return;
@ -503,7 +678,7 @@ class RawEditorState extends EditorState
void _updateOrDisposeSelectionOverlayIfNeeded() {
if (_selectionOverlay != null) {
if (_hasFocus) {
if (_hasFocus && !textEditingValue.selection.isCollapsed) {
_selectionOverlay!.update(textEditingValue);
} else {
_selectionOverlay!.dispose();
@ -511,22 +686,18 @@ class RawEditorState extends EditorState
}
} else if (_hasFocus) {
_selectionOverlay?.hide();
_selectionOverlay = null;
_selectionOverlay = EditorTextSelectionOverlay(
textEditingValue,
false,
context,
widget,
_toolbarLayerLink,
_startHandleLayerLink,
_endHandleLayerLink,
getRenderEditor(),
widget.selectionCtrls,
this,
DragStartBehavior.start,
null,
_clipboardStatus,
value: textEditingValue,
context: context,
debugRequiredFor: widget,
toolbarLayerLink: _toolbarLayerLink,
startHandleLayerLink: _startHandleLayerLink,
endHandleLayerLink: _endHandleLayerLink,
renderObject: getRenderEditor(),
selectionCtrls: widget.selectionCtrls,
selectionDelegate: this,
clipboardStatus: _clipboardStatus,
);
_selectionOverlay!.handlesVisible = _shouldShowSelectionHandles();
_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;
void _showCaretOnScreen() {
@ -564,7 +740,7 @@ class RawEditorState extends EditorState
_showCaretOnScreenScheduled = true;
SchedulerBinding.instance!.addPostFrameCallback((_) {
if (widget.scrollable) {
if (widget.scrollable || _scrollController.hasClients) {
_showCaretOnScreenScheduled = false;
final renderEditor = getRenderEditor();
@ -585,7 +761,7 @@ class RawEditorState extends EditorState
if (offset != null) {
_scrollController.animateTo(
offset,
math.min(offset, _scrollController.position.maxScrollExtent),
duration: const Duration(milliseconds: 100),
curve: Curves.fastOutSlowIn,
);
@ -599,68 +775,35 @@ class RawEditorState extends EditorState
return _editorKey.currentContext?.findRenderObject() as RenderEditor?;
}
@override
TextEditingValue getTextEditingValue() {
return widget.controller.plainTextEditingValue;
}
@override
void requestKeyboard() {
if (_hasFocus) {
openConnectionIfNeeded();
_showCaretOnScreen();
} else {
widget.focusNode.requestFocus();
}
}
@override
void setTextEditingValue(TextEditingValue value) {
if (value.text == textEditingValue.text) {
widget.controller.updateSelection(value.selection, ChangeSource.LOCAL);
} else {
_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);
}
void setTextEditingValue(
TextEditingValue value, SelectionChangedCause cause) {
if (value == textEditingValue) {
return;
}
textEditingValue = value;
userUpdateTextEditingValue(value, cause);
}
Future<bool> _isItCut(TextEditingValue value) async {
final data = await Clipboard.getData(Clipboard.kTextPlain);
if (data == null) {
return false;
}
return textEditingValue.text.length - value.text.length ==
data.text!.length;
@override
void debugAssertLayoutUpToDate() {
getRenderEditor()!.debugAssertLayoutUpToDate();
}
/// 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
bool showToolbar() {
// Web is using native dom elements to enable clipboard functionality of the
@ -679,8 +822,85 @@ class RawEditorState extends EditorState
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
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 {
@ -695,9 +915,14 @@ class _Editor extends MultiChildRenderObjectWidget {
required this.endHandleLayerLink,
required this.onSelectionChanged,
required this.scrollBottomInset,
required this.cursorController,
required this.floatingCursorDisabled,
this.padding = EdgeInsets.zero,
this.maxContentWidth,
this.offset,
}) : super(key: key, children: children);
final ViewportOffset? offset;
final Document document;
final TextDirection textDirection;
final bool hasFocus;
@ -707,28 +932,33 @@ class _Editor extends MultiChildRenderObjectWidget {
final TextSelectionChangedHandler onSelectionChanged;
final double scrollBottomInset;
final EdgeInsetsGeometry padding;
final double? maxContentWidth;
final CursorCont cursorController;
final bool floatingCursorDisabled;
@override
RenderEditor createRenderObject(BuildContext context) {
return RenderEditor(
null,
textDirection,
scrollBottomInset,
padding,
document,
selection,
hasFocus,
onSelectionChanged,
startHandleLayerLink,
endHandleLayerLink,
const EdgeInsets.fromLTRB(4, 4, 4, 5),
);
offset: offset,
document: document,
textDirection: textDirection,
hasFocus: hasFocus,
selection: selection,
startHandleLayerLink: startHandleLayerLink,
endHandleLayerLink: endHandleLayerLink,
onSelectionChanged: onSelectionChanged,
cursorController: cursorController,
padding: padding,
maxContentWidth: maxContentWidth,
scrollBottomInset: scrollBottomInset,
floatingCursorDisabled: floatingCursorDisabled);
}
@override
void updateRenderObject(
BuildContext context, covariant RenderEditor renderObject) {
renderObject
..offset = offset
..document = document
..setContainer(document.root)
..textDirection = textDirection
@ -738,6 +968,7 @@ class _Editor extends MultiChildRenderObjectWidget {
..setEndHandleLayerLink(endHandleLayerLink)
..onSelectionChanged = onSelectionChanged
..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/widgets.dart';
import '../../../utils/diff_delta.dart';
import '../editor.dart';
mixin RawEditorStateSelectionDelegateMixin on EditorState
implements TextSelectionDelegate {
@override
TextEditingValue get textEditingValue {
return getTextEditingValue();
return widget.controller.plainTextEditingValue;
}
@override
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
@ -50,8 +76,8 @@ mixin RawEditorStateSelectionDelegateMixin on EditorState
final expandedRect = Rect.fromCenter(
center: rect.center,
width: rect.width,
height:
max(rect.height, getRenderEditor()!.preferredLineHeight(position)),
height: math.max(
rect.height, getRenderEditor()!.preferredLineHeight(position)),
);
additionalOffset = expandedRect.height >= editableSize.height
@ -81,10 +107,8 @@ mixin RawEditorStateSelectionDelegateMixin on EditorState
@override
void userUpdateTextEditingValue(
TextEditingValue value,
SelectionChangedCause cause,
) {
setTextEditingValue(value);
TextEditingValue value, SelectionChangedCause cause) {
textEditingValue = value;
}
@override

@ -1,13 +1,15 @@
import 'dart:ui';
import 'package:flutter/animation.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import '../../models/documents/document.dart';
import '../../utils/diff_delta.dart';
import '../editor.dart';
mixin RawEditorStateTextInputClientMixin on EditorState
implements TextInputClient {
final List<TextEditingValue> _sentRemoteValues = [];
TextInputConnection? _textInputConnection;
TextEditingValue? _lastKnownRemoteTextEditingValue;
@ -46,7 +48,7 @@ mixin RawEditorStateTextInputClientMixin on EditorState
}
if (!hasConnection) {
_lastKnownRemoteTextEditingValue = getTextEditingValue();
_lastKnownRemoteTextEditingValue = textEditingValue;
_textInputConnection = TextInput.attach(
this,
TextInputConfiguration(
@ -74,7 +76,6 @@ mixin RawEditorStateTextInputClientMixin on EditorState
_textInputConnection!.close();
_textInputConnection = null;
_lastKnownRemoteTextEditingValue = null;
_sentRemoteValues.clear();
}
/// Updates remote value based on current state of [document] and
@ -87,12 +88,14 @@ mixin RawEditorStateTextInputClientMixin on EditorState
return;
}
final value = textEditingValue;
// 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
// with the last known remote value.
// It is important to prevent excessive remote updates as it can cause
// race conditions.
final actualValue = getTextEditingValue().copyWith(
final actualValue = value.copyWith(
composing: _lastKnownRemoteTextEditingValue!.composing,
);
@ -100,18 +103,12 @@ mixin RawEditorStateTextInputClientMixin on EditorState
return;
}
final shouldRemember =
getTextEditingValue().text != _lastKnownRemoteTextEditingValue!.text;
_lastKnownRemoteTextEditingValue = actualValue;
_textInputConnection!.setEditingState(
// Set composing to (-1, -1), otherwise an exception will be thrown if
// the values are different.
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
@ -128,22 +125,6 @@ mixin RawEditorStateTextInputClientMixin on EditorState
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) {
// There is no difference between this value and the last known value.
return;
@ -167,8 +148,12 @@ mixin RawEditorStateTextInputClientMixin on EditorState
final text = value.text;
final cursorPosition = value.selection.extentOffset;
final diff = getDiff(oldText, text, cursorPosition);
widget.controller.replaceText(
diff.start, diff.deleted.length, diff.inserted, value.selection);
if (diff.deleted.isEmpty && diff.inserted.isEmpty) {
widget.controller.updateSelection(value.selection, ChangeSource.LOCAL);
} else {
widget.controller.replaceText(
diff.start, diff.deleted.length, diff.inserted, value.selection);
}
}
@override
@ -181,9 +166,119 @@ mixin RawEditorStateTextInputClientMixin on EditorState
// 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
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
@ -199,6 +294,5 @@ mixin RawEditorStateTextInputClientMixin on EditorState
_textInputConnection!.connectionClosedReceived();
_textInputConnection = 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/rendering.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/line.dart';
import 'box.dart';
import 'cursor.dart';
import 'default_styles.dart';
import 'delegate.dart';
import 'editor.dart';
import 'link.dart';
import 'text_line.dart';
import 'text_selection.dart';
@ -49,6 +47,7 @@ const List<String> romanNumbers = [
class EditableTextBlock extends StatelessWidget {
const EditableTextBlock(
{required this.block,
required this.controller,
required this.textDirection,
required this.scrollBottomInset,
required this.verticalSpacing,
@ -59,14 +58,17 @@ class EditableTextBlock extends StatelessWidget {
required this.hasFocus,
required this.contentPadding,
required this.embedBuilder,
required this.linkActionPicker,
required this.cursorCont,
required this.indentLevelCounts,
required this.onCheckboxTap,
required this.readOnly,
this.onLaunchUrl,
this.customStyleBuilder,
Key? key});
final Block block;
final QuillController controller;
final TextDirection textDirection;
final double scrollBottomInset;
final Tuple2 verticalSpacing;
@ -77,6 +79,8 @@ class EditableTextBlock extends StatelessWidget {
final bool hasFocus;
final EdgeInsets? contentPadding;
final EmbedBuilder embedBuilder;
final LinkActionPicker linkActionPicker;
final ValueChanged<String>? onLaunchUrl;
final CustomStyleBuilder? customStyleBuilder;
final CursorCont cursorCont;
final Map<int, int> indentLevelCounts;
@ -128,6 +132,9 @@ class EditableTextBlock extends StatelessWidget {
customStyleBuilder: customStyleBuilder,
styles: styles!,
readOnly: readOnly,
controller: controller,
linkActionPicker: linkActionPicker,
onLaunchUrl: onLaunchUrl,
),
_getIndentWidth(),
_getSpacingForLine(line, index, count, defaultStyles),
@ -148,7 +155,7 @@ class EditableTextBlock extends StatelessWidget {
final defaultStyles = QuillStyles.getStyles(context, false);
final attrs = line.style.attributes;
if (attrs[Attribute.list.key] == Attribute.ol) {
return _NumberPoint(
return QuillNumberPoint(
index: index,
indentLevelCounts: indentLevelCounts,
count: count,
@ -160,7 +167,7 @@ class EditableTextBlock extends StatelessWidget {
}
if (attrs[Attribute.list.key] == Attribute.ul) {
return _BulletPoint(
return QuillBulletPoint(
style:
defaultStyles!.leading!.style.copyWith(fontWeight: FontWeight.bold),
width: 32,
@ -168,28 +175,25 @@ class EditableTextBlock extends StatelessWidget {
}
if (attrs[Attribute.list.key] == Attribute.checked) {
return _Checkbox(
key: UniqueKey(),
style: defaultStyles!.leading!.style,
width: 32,
isChecked: true,
offset: block.offset + line.offset,
onTap: onCheckboxTap,
return CheckboxPoint(
size: 14,
value: true,
enabled: !readOnly,
onChanged: (checked) => onCheckboxTap(line.documentOffset, checked),
);
}
if (attrs[Attribute.list.key] == Attribute.unchecked) {
return _Checkbox(
key: UniqueKey(),
style: defaultStyles!.leading!.style,
width: 32,
offset: block.offset + line.offset,
onTap: onCheckboxTap,
return CheckboxPoint(
size: 14,
value: false,
enabled: !readOnly,
onChanged: (checked) => onCheckboxTap(line.documentOffset, checked),
);
}
if (attrs.containsKey(Attribute.codeBlock.key)) {
return _NumberPoint(
return QuillNumberPoint(
index: index,
indentLevelCounts: indentLevelCounts,
count: count,
@ -217,7 +221,14 @@ class EditableTextBlock extends StatelessWidget {
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(
@ -551,6 +562,16 @@ class RenderEditableTextBlock extends RenderEditableContainerBox
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 {
@ -600,166 +621,3 @@ class _EditableBlock extends MultiChildRenderObjectWidget {
..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 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:tuple/tuple.dart';
import 'package:url_launcher/url_launcher.dart';
import '../models/documents/attribute.dart';
import '../../flutter_quill.dart';
import '../models/documents/nodes/container.dart' as container;
import '../models/documents/nodes/leaf.dart' as leaf;
import '../models/documents/nodes/leaf.dart';
import '../models/documents/nodes/line.dart';
import '../models/documents/nodes/node.dart';
import '../models/documents/style.dart';
import '../utils/color.dart';
import 'box.dart';
import 'cursor.dart';
import 'default_styles.dart';
import 'delegate.dart';
import 'keyboard_listener.dart';
import 'link.dart';
import 'proxy.dart';
import 'text_selection.dart';
class TextLine extends StatelessWidget {
class TextLine extends StatefulWidget {
const TextLine({
required this.line,
required this.embedBuilder,
required this.styles,
required this.readOnly,
required this.controller,
required this.onLaunchUrl,
required this.linkActionPicker,
this.textDirection,
this.customStyleBuilder,
Key? key,
@ -36,56 +43,141 @@ class TextLine extends StatelessWidget {
final EmbedBuilder embedBuilder;
final DefaultStyles styles;
final bool readOnly;
final QuillController controller;
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
Widget build(BuildContext 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
final embed = line.children.single as Embed;
return EmbedProxy(embedBuilder(context, embed, readOnly));
final embed = widget.line.children.single as Embed;
return EmbedProxy(widget.embedBuilder(context, embed, widget.readOnly));
}
final textSpan = _getTextSpanForWholeLine(context);
final strutStyle = StrutStyle.fromTextStyle(textSpan.style!);
final textAlign = _getTextAlign();
final child = RichText(
key: _richTextKey,
text: textSpan,
textAlign: textAlign,
textDirection: textDirection,
textDirection: widget.textDirection,
strutStyle: strutStyle,
textScaleFactor: MediaQuery.textScaleFactorOf(context),
);
return RichTextProxy(
child,
textSpan.style!,
textAlign,
textDirection!,
1,
Localizations.localeOf(context),
strutStyle,
TextWidthBasis.parent,
null);
textStyle: textSpan.style!,
textAlign: textAlign,
textDirection: widget.textDirection!,
strutStyle: strutStyle,
locale: Localizations.localeOf(context),
child: child);
}
InlineSpan _getTextSpanForWholeLine(BuildContext context) {
final lineStyle = _getLineStyle(styles);
if (!line.hasEmbed) {
return _buildTextSpan(styles, line.children, lineStyle);
final lineStyle = _getLineStyle(widget.styles);
if (!widget.line.hasEmbed) {
return _buildTextSpan(widget.styles, widget.line.children, lineStyle);
}
// The line could contain more than one Embed & more than one Text
final textSpanChildren = <InlineSpan>[];
var textNodes = LinkedList<Node>();
for (final child in line.children) {
for (final child in widget.line.children) {
if (child is Embed) {
if (textNodes.isNotEmpty) {
textSpanChildren.add(_buildTextSpan(styles, textNodes, lineStyle));
textSpanChildren
.add(_buildTextSpan(widget.styles, textNodes, lineStyle));
textNodes = LinkedList<Node>();
}
// Here it should be image
final embed = WidgetSpan(
child: EmbedProxy(embedBuilder(context, child, readOnly)));
child: EmbedProxy(
widget.embedBuilder(context, child, widget.readOnly)));
textSpanChildren.add(embed);
continue;
}
@ -95,20 +187,20 @@ class TextLine extends StatelessWidget {
}
if (textNodes.isNotEmpty) {
textSpanChildren.add(_buildTextSpan(styles, textNodes, lineStyle));
textSpanChildren.add(_buildTextSpan(widget.styles, textNodes, lineStyle));
}
return TextSpan(style: lineStyle, children: textSpanChildren);
}
TextAlign _getTextAlign() {
final alignment = line.style.attributes[Attribute.align.key];
final alignment = widget.line.style.attributes[Attribute.align.key];
if (alignment == Attribute.leftAlignment) {
return TextAlign.left;
return TextAlign.start;
} else if (alignment == Attribute.centerAlignment) {
return TextAlign.center;
} else if (alignment == Attribute.rightAlignment) {
return TextAlign.right;
return TextAlign.end;
} else if (alignment == Attribute.justifyAlignment) {
return TextAlign.justify;
}
@ -118,7 +210,8 @@ class TextLine extends StatelessWidget {
TextSpan _buildTextSpan(DefaultStyles defaultStyles, LinkedList<Node> nodes,
TextStyle lineStyle) {
final children = nodes
.map((node) => _getTextSpanFromNode(defaultStyles, node))
.map((node) =>
_getTextSpanFromNode(defaultStyles, node, widget.line.style))
.toList(growable: false);
return TextSpan(children: children, style: lineStyle);
@ -127,11 +220,11 @@ class TextLine extends StatelessWidget {
TextStyle _getLineStyle(DefaultStyles defaultStyles) {
var textStyle = const TextStyle();
if (line.style.containsKey(Attribute.placeholder.key)) {
if (widget.line.style.containsKey(Attribute.placeholder.key)) {
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>{
Attribute.h1: defaultStyles.h1!.style,
Attribute.h2: defaultStyles.h2!.style,
@ -140,52 +233,75 @@ class TextLine extends StatelessWidget {
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;
if (block == Attribute.blockQuote) {
toMerge = defaultStyles.quote!.style;
} else if (block == Attribute.codeBlock) {
toMerge = defaultStyles.code!.style;
} else if (block != null) {
} else if (block == Attribute.list) {
toMerge = defaultStyles.lists!.style;
}
textStyle = textStyle.merge(toMerge);
textStyle = _applyCustomAttributes(textStyle, line.style.attributes);
textStyle = _applyCustomAttributes(textStyle, widget.line.style.attributes);
return textStyle;
}
TextStyle _applyCustomAttributes(
TextStyle textStyle, Map<String, Attribute> attributes) {
if (customStyleBuilder == null) {
if (widget.customStyleBuilder == null) {
return textStyle;
}
attributes.keys.forEach((key) {
final attr = attributes[key];
if (attr != null) {
/// Custom Attribute
final customAttr = customStyleBuilder!.call(attr);
final customAttr = widget.customStyleBuilder!.call(attr);
textStyle = textStyle.merge(customAttr);
}
});
return textStyle;
}
TextSpan _getTextSpanFromNode(DefaultStyles defaultStyles, Node node) {
TextSpan _getTextSpanFromNode(
DefaultStyles defaultStyles, Node node, Style lineStyle) {
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
final color = textNode.style.attributes[Attribute.color.key];
<String, TextStyle?>{
Attribute.bold.key: defaultStyles.bold,
Attribute.italic.key: defaultStyles.italic,
Attribute.small.key: defaultStyles.small,
Attribute.link.key: defaultStyles.link,
Attribute.underline.key: defaultStyles.underline,
Attribute.strikeThrough.key: defaultStyles.strikeThrough,
}.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) {
var textColor = defaultStyles.color;
if (color?.value is String) {
@ -193,12 +309,19 @@ class TextLine extends StatelessWidget {
}
res = _merge(res.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 {
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];
if (font != null && font.value != null) {
res = res.merge(TextStyle(fontFamily: font.value));
@ -217,7 +340,14 @@ class TextLine extends StatelessWidget {
res = res.merge(defaultStyles.sizeHuge);
break;
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) {
res = res.merge(TextStyle(fontSize: fontSize));
} else {
@ -243,7 +373,96 @@ class TextLine extends StatelessWidget {
}
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) {
@ -296,6 +515,7 @@ class EditableTextLine extends RenderObjectWidget {
@override
RenderObject createRenderObject(BuildContext context) {
final defaultStyles = DefaultStyles.getInstance(context);
return RenderEditableTextLine(
line,
textDirection,
@ -305,12 +525,14 @@ class EditableTextLine extends RenderObjectWidget {
devicePixelRatio,
_getPadding(),
color,
cursorCont);
cursorCont,
defaultStyles.inlineCode!);
}
@override
void updateRenderObject(
BuildContext context, covariant RenderEditableTextLine renderObject) {
final defaultStyles = DefaultStyles.getInstance(context);
renderObject
..setLine(line)
..setPadding(_getPadding())
@ -320,7 +542,8 @@ class EditableTextLine extends RenderObjectWidget {
..setEnableInteractiveSelection(enableInteractiveSelection)
..hasFocus = hasFocus
..setDevicePixelRatio(devicePixelRatio)
..setCursorCont(cursorCont);
..setCursorCont(cursorCont)
..setInlineCodeStyle(defaultStyles.inlineCode!);
}
EdgeInsetsGeometry _getPadding() {
@ -334,17 +557,18 @@ class EditableTextLine extends RenderObjectWidget {
enum TextLineSlot { LEADING, BODY }
class RenderEditableTextLine extends RenderEditableBox {
/// Creates new editable paragraph render box.
RenderEditableTextLine(
this.line,
this.textDirection,
this.textSelection,
this.enableInteractiveSelection,
this.hasFocus,
this.devicePixelRatio,
this.padding,
this.color,
this.cursorCont,
);
this.line,
this.textDirection,
this.textSelection,
this.enableInteractiveSelection,
this.hasFocus,
this.devicePixelRatio,
this.padding,
this.color,
this.cursorCont,
this.inlineCodeStyle);
RenderBox? _leading;
RenderContentProxyBox? _body;
@ -360,7 +584,8 @@ class RenderEditableTextLine extends RenderEditableBox {
EdgeInsets? _resolvedPadding;
bool? _containsCursor;
List<TextBox>? _selectedRects;
Rect? _caretPrototype;
late Rect _caretPrototype;
InlineCodeStyle inlineCodeStyle;
final Map<TextLineSlot, RenderBox> children = <TextLineSlot, RenderBox>{};
Iterable<RenderBox> get _children sync* {
@ -404,7 +629,7 @@ class RenderEditableTextLine extends RenderEditableBox {
color = c;
if (containsTextSelection()) {
markNeedsPaint();
safeMarkNeedsPaint();
}
}
@ -414,9 +639,10 @@ class RenderEditableTextLine extends RenderEditableBox {
}
final containsSelection = containsTextSelection();
if (attached && containsCursor()) {
if (_attachedToCursorController) {
cursorCont.removeListener(markNeedsLayout);
cursorCont.color.removeListener(markNeedsPaint);
cursorCont.color.removeListener(safeMarkNeedsPaint);
_attachedToCursorController = false;
}
textSelection = t;
@ -424,11 +650,12 @@ class RenderEditableTextLine extends RenderEditableBox {
_containsCursor = null;
if (attached && containsCursor()) {
cursorCont.addListener(markNeedsLayout);
cursorCont.color.addListener(markNeedsPaint);
cursorCont.color.addListener(safeMarkNeedsPaint);
_attachedToCursorController = true;
}
if (containsSelection || containsTextSelection()) {
markNeedsPaint();
safeMarkNeedsPaint();
}
}
@ -468,14 +695,25 @@ class RenderEditableTextLine extends RenderEditableBox {
_body = _updateChild(_body, b, TextLineSlot.BODY) as RenderContentProxyBox?;
}
void setInlineCodeStyle(InlineCodeStyle newStyle) {
if (inlineCodeStyle == newStyle) return;
inlineCodeStyle = newStyle;
markNeedsLayout();
}
// Start selection implementation
bool containsTextSelection() {
return line.documentOffset <= textSelection.end &&
textSelection.start <= line.documentOffset + line.length - 1;
}
bool containsCursor() {
return _containsCursor ??= textSelection.isCollapsed &&
line.containsOffset(textSelection.baseOffset);
return _containsCursor ??= cursorCont.isFloatingCursorActive
? line
.containsOffset(cursorCont.floatingCursorTextPosition.value!.offset)
: textSelection.isCollapsed &&
line.containsOffset(textSelection.baseOffset);
}
RenderBox? _updateChild(
@ -570,6 +808,9 @@ class RenderEditableTextLine extends RenderEditableBox {
return _getPosition(position, 1.5);
}
@override
bool get isRepaintBoundary => true;
TextPosition? _getPosition(TextPosition textPosition, double dyScale) {
assert(textPosition.offset < line.length);
final offset = getOffsetForCaret(textPosition)
@ -608,6 +849,17 @@ class RenderEditableTextLine extends RenderEditableBox {
cursorCont.style.height ??
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() {
switch (defaultTargetPlatform) {
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
void attach(covariant PipelineOwner owner) {
super.attach(owner);
for (final child in _children) {
child.attach(owner);
}
cursorCont.floatingCursorTextPosition.addListener(_onFloatingCursorChange);
if (containsCursor()) {
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) {
child.detach();
}
if (containsCursor()) {
cursorCont.floatingCursorTextPosition
.removeListener(_onFloatingCursorChange);
if (_attachedToCursorController) {
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(
_body,
cursorCont.style,
_caretPrototype!,
cursorCont.color.value,
devicePixelRatio,
editable: _body,
style: cursorCont.style,
prototype: _caretPrototype,
color: cursorCont.isFloatingCursorActive
? cursorCont.style.backgroundColor
: cursorCont.color.value,
devicePixelRatio: devicePixelRatio,
);
@override
@ -803,21 +1075,37 @@ class RenderEditableTextLine extends RenderEditableBox {
if (_body != null) {
final parentData = _body!.parentData as BoxParentData;
final effectiveOffset = offset + parentData.offset;
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);
if (inlineCodeStyle.backgroundColor != null) {
for (final item in line.children) {
if (item is! leaf.Text ||
!item.style.containsKey(Attribute.inlineCode.key)) {
continue;
}
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 &&
cursorCont.show.value &&
containsCursor() &&
!cursorCont.style.paintAboveText) {
_paintCursor(context, effectiveOffset);
_paintCursor(context, effectiveOffset, line.hasEmbed);
}
context.paintChild(_body!, effectiveOffset);
@ -826,7 +1114,18 @@ class RenderEditableTextLine extends RenderEditableBox {
cursorCont.show.value &&
containsCursor() &&
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) {
final position = TextPosition(
offset: textSelection.extentOffset - line.documentOffset,
affinity: textSelection.base.affinity,
);
_cursorPainter.paint(context.canvas, effectiveOffset, position);
void _paintCursor(
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,
affinity: textSelection.base.affinity);
_cursorPainter.paint(
context.canvas, effectiveOffset, position, lineHasEmbed);
}
@override
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
@ -872,6 +1195,17 @@ class RenderEditableTextLine extends RenderEditableBox {
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 {

@ -1,11 +1,9 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/cupertino.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/scheduler.dart';
import '../models/documents/nodes/node.dart';
@ -21,6 +19,8 @@ TextSelection localSelection(Node node, TextSelection selection, fromParent) {
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 }
/// 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 {
EditorTextSelectionOverlay(
this.value,
this.handlesVisible,
this.context,
this.debugRequiredFor,
this.toolbarLayerLink,
this.startHandleLayerLink,
this.endHandleLayerLink,
this.renderObject,
this.selectionCtrls,
this.selectionDelegate,
this.dragStartBehavior,
/// Creates an object that manages overlay entries for selection handles.
///
/// The [context] must not be null and must have an [Overlay] as an ancestor.
EditorTextSelectionOverlay({
required this.value,
required this.context,
required this.toolbarLayerLink,
required this.startHandleLayerLink,
required this.endHandleLayerLink,
required this.renderObject,
required this.debugRequiredFor,
required this.selectionCtrls,
required this.selectionDelegate,
required this.clipboardStatus,
this.onSelectionHandleTapped,
this.clipboardStatus,
) {
this.dragStartBehavior = DragStartBehavior.start,
this.handlesVisible = false,
}) {
final overlay = Overlay.of(context, rootOverlay: true)!;
_toolbarController = AnimationController(
@ -81,20 +88,94 @@ class EditorTextSelectionOverlay {
}
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;
/// 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;
/// Debugging information for explaining why the [Overlay] is required.
final Widget debugRequiredFor;
/// The object supplied to the [CompositedTransformTarget] that wraps the text
/// field.
final LayerLink toolbarLayerLink;
/// The objects supplied to the [CompositedTransformTarget] that wraps the
/// location of start selection handle.
final LayerLink startHandleLayerLink;
/// The objects supplied to the [CompositedTransformTarget] that wraps the
/// location of end selection handle.
final LayerLink endHandleLayerLink;
/// The editable line in which the selected text is being displayed.
final RenderEditor? renderObject;
/// Builds text selection handles and toolbar.
final TextSelectionControls selectionCtrls;
/// The delegate for manipulating the current selection in the owning
/// text field.
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;
/// {@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;
/// 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;
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;
/// A copy/paste toolbar.
OverlayEntry? toolbar;
TextSelection get _selection => value.selection;
@ -106,6 +187,8 @@ class EditorTextSelectionOverlay {
return;
}
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 ==
SchedulerPhase.persistentCallbacks) {
SchedulerBinding.instance!.addPostFrameCallback(markNeedsBuild);
@ -114,6 +197,7 @@ class EditorTextSelectionOverlay {
}
}
/// Destroys the handles by removing them from overlay.
void hideHandles() {
if (_handles == null) {
return;
@ -123,6 +207,9 @@ class EditorTextSelectionOverlay {
_handles = null;
}
/// Hides the toolbar part of the overlay.
///
/// To hide the whole overlay, see [hide].
void hideToolbar() {
assert(toolbar != null);
_toolbarController.stop();
@ -130,6 +217,7 @@ class EditorTextSelectionOverlay {
toolbar = null;
}
/// Shows the toolbar by inserting it into the [context]'s overlay.
void showToolbar() {
assert(toolbar == null);
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) {
if (value == newValue) {
return;
@ -210,6 +307,7 @@ class EditorTextSelectionOverlay {
}
Widget _buildToolbar(BuildContext context) {
// Find the horizontal midpoint, just above the selected text.
final endpoints = renderObject!.getEndpointsForSelection(_selection);
final editingRegion = Rect.fromPoints(
@ -260,6 +358,7 @@ class EditorTextSelectionOverlay {
toolbar?.markNeedsBuild();
}
/// Hides the entire overlay including the toolbar and the handles.
void hide() {
if (_handles != null) {
_handles![0].remove();
@ -271,11 +370,13 @@ class EditorTextSelectionOverlay {
}
}
/// Final cleanup.
void dispose() {
hide();
_toolbarController.dispose();
}
/// Builds the handles by inserting them into the [context]'s overlay.
void showHandles() {
assert(_handles == null);
_handles = <OverlayEntry>[
@ -290,8 +391,17 @@ class EditorTextSelectionOverlay {
Overlay.of(context, rootOverlay: true, debugRequiredFor: debugRequiredFor)!
.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 {
const _TextSelectionHandleOverlay({
required this.selection,
@ -407,6 +517,10 @@ class _TextSelectionHandleOverlayState
break;
}
if (newSelection.baseOffset >= newSelection.extentOffset) {
return; // don't allow order swapping.
}
widget.onSelectionHandleChanged(newSelection);
}
@ -711,9 +825,12 @@ class _EditorTextSelectionGestureDetectorState
Widget build(BuildContext context) {
final gestures = <Type, GestureRecognizerFactory>{};
gestures[TapGestureRecognizer] =
GestureRecognizerFactoryWithHandlers<TapGestureRecognizer>(
() => TapGestureRecognizer(debugOwner: this),
// Use _TransparentTapGestureRecognizer so that TextSelectionGestureDetector
// can receive the same tap events that a selection handle placed visually
// on top of it also receives.
gestures[_TransparentTapGestureRecognizer] =
GestureRecognizerFactoryWithHandlers<_TransparentTapGestureRecognizer>(
() => _TransparentTapGestureRecognizer(debugOwner: this),
(instance) {
instance
..onTapDown = _handleTapDown
@ -728,7 +845,8 @@ class _EditorTextSelectionGestureDetectorState
gestures[LongPressGestureRecognizer] =
GestureRecognizerFactoryWithHandlers<LongPressGestureRecognizer>(
() => LongPressGestureRecognizer(
debugOwner: this, kind: PointerDeviceKind.touch),
debugOwner: this,
supportedDevices: <PointerDeviceKind>{PointerDeviceKind.touch}),
(instance) {
instance
..onLongPressStart = _handleLongPressStart
@ -744,7 +862,8 @@ class _EditorTextSelectionGestureDetectorState
gestures[HorizontalDragGestureRecognizer] =
GestureRecognizerFactoryWithHandlers<HorizontalDragGestureRecognizer>(
() => HorizontalDragGestureRecognizer(
debugOwner: this, kind: PointerDeviceKind.mouse),
debugOwner: this,
supportedDevices: <PointerDeviceKind>{PointerDeviceKind.mouse}),
(instance) {
instance
..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 'package:flutter/material.dart';
import 'package:i18n_extension/i18n_widget.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 'controller.dart';
import 'toolbar/arrow_indicated_button_list.dart';
@ -14,6 +17,7 @@ import 'toolbar/image_button.dart';
import 'toolbar/indent_button.dart';
import 'toolbar/insert_embed_button.dart';
import 'toolbar/link_style_button.dart';
import 'toolbar/select_alignment_button.dart';
import 'toolbar/select_header_style_button.dart';
import 'toolbar/toggle_check_list_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/quill_dropdown_button.dart';
export 'toolbar/quill_icon_button.dart';
export 'toolbar/select_alignment_button.dart';
export 'toolbar/select_header_style_button.dart';
export 'toolbar/toggle_check_list_button.dart';
export 'toolbar/toggle_style_button.dart';
@ -53,23 +58,36 @@ const double kIconButtonFactor = 1.77;
class QuillToolbar extends StatelessWidget implements PreferredSizeWidget {
const QuillToolbar({
required this.children,
this.toolBarHeight = 36,
this.toolbarHeight = 36,
this.toolbarIconAlignment = WrapAlignment.center,
this.toolbarSectionSpacing = 4,
this.multiRowsDisplay = true,
this.color,
this.filePickImpl,
this.multiRowsDisplay,
this.locale,
Key? key,
}) : super(key: key);
factory QuillToolbar.basic({
required QuillController controller,
double toolbarIconSize = kDefaultIconSize,
double toolbarSectionSpacing = 4,
WrapAlignment toolbarIconAlignment = WrapAlignment.center,
bool showDividers = true,
bool showBoldButton = true,
bool showItalicButton = true,
bool showSmallButton = false,
bool showUnderLineButton = true,
bool showStrikeThrough = true,
bool showInlineCode = true,
bool showColorButton = true,
bool showBackgroundColorButton = true,
bool showClearFormat = true,
bool showAlignmentButtons = false,
bool showLeftAlignment = true,
bool showCenterAlignment = true,
bool showRightAlignment = true,
bool showJustifyAlignment = true,
bool showHeaderStyle = true,
bool showListNumbers = true,
bool showListBullets = true,
@ -90,19 +108,42 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget {
FilePickImpl? filePickImpl,
WebImagePickImpl? webImagePickImpl,
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,
}) {
final isButtonGroupShown = [
showHistory ||
showBoldButton ||
showItalicButton ||
showSmallButton ||
showUnderLineButton ||
showStrikeThrough ||
showInlineCode ||
showColorButton ||
showBackgroundColorButton ||
showClearFormat ||
onImagePickCallback != null ||
onVideoPickCallback != null,
showAlignmentButtons,
showLeftAlignment,
showCenterAlignment,
showRightAlignment,
showJustifyAlignment,
showHeaderStyle,
showListNumbers || showListBullets || showListCheck || showCodeBlock,
showQuote || showIndent,
@ -111,8 +152,11 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget {
return QuillToolbar(
key: key,
toolBarHeight: toolbarIconSize * 2,
toolbarHeight: toolbarIconSize * 2,
toolbarSectionSpacing: toolbarSectionSpacing,
toolbarIconAlignment: toolbarIconAlignment,
multiRowsDisplay: multiRowsDisplay,
locale: locale,
children: [
if (showHistory)
HistoryButton(
@ -120,6 +164,7 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget {
iconSize: toolbarIconSize,
controller: controller,
undo: true,
iconTheme: iconTheme,
),
if (showHistory)
HistoryButton(
@ -127,6 +172,7 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget {
iconSize: toolbarIconSize,
controller: controller,
undo: false,
iconTheme: iconTheme,
),
if (showBoldButton)
ToggleStyleButton(
@ -134,6 +180,7 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget {
icon: Icons.format_bold,
iconSize: toolbarIconSize,
controller: controller,
iconTheme: iconTheme,
),
if (showItalicButton)
ToggleStyleButton(
@ -141,6 +188,15 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget {
icon: Icons.format_italic,
iconSize: toolbarIconSize,
controller: controller,
iconTheme: iconTheme,
),
if (showSmallButton)
ToggleStyleButton(
attribute: Attribute.small,
icon: Icons.format_size,
iconSize: toolbarIconSize,
controller: controller,
iconTheme: iconTheme,
),
if (showUnderLineButton)
ToggleStyleButton(
@ -148,6 +204,7 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget {
icon: Icons.format_underline,
iconSize: toolbarIconSize,
controller: controller,
iconTheme: iconTheme,
),
if (showStrikeThrough)
ToggleStyleButton(
@ -155,6 +212,15 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget {
icon: Icons.format_strikethrough,
iconSize: toolbarIconSize,
controller: controller,
iconTheme: iconTheme,
),
if (showInlineCode)
ToggleStyleButton(
attribute: Attribute.inlineCode,
icon: Icons.code,
iconSize: toolbarIconSize,
controller: controller,
iconTheme: iconTheme,
),
if (showColorButton)
ColorButton(
@ -162,6 +228,7 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget {
iconSize: toolbarIconSize,
controller: controller,
background: false,
iconTheme: iconTheme,
),
if (showBackgroundColorButton)
ColorButton(
@ -169,12 +236,14 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget {
iconSize: toolbarIconSize,
controller: controller,
background: true,
iconTheme: iconTheme,
),
if (showClearFormat)
ClearFormatButton(
icon: Icons.format_clear,
iconSize: toolbarIconSize,
controller: controller,
iconTheme: iconTheme,
),
if (showImageButton)
ImageButton(
@ -185,6 +254,8 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget {
filePickImpl: filePickImpl,
webImagePickImpl: webImagePickImpl,
mediaPickSettingSelector: mediaPickSettingSelector,
iconTheme: iconTheme,
dialogTheme: dialogTheme,
),
if (showVideoButton)
VideoButton(
@ -195,37 +266,67 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget {
filePickImpl: filePickImpl,
webVideoPickImpl: webImagePickImpl,
mediaPickSettingSelector: mediaPickSettingSelector,
iconTheme: iconTheme,
dialogTheme: dialogTheme,
),
if ((onImagePickCallback != null || onVideoPickCallback != null) &&
showCameraButton)
CameraButton(
icon: Icons.photo_camera,
iconSize: toolbarIconSize,
controller: controller,
onImagePickCallback: onImagePickCallback,
onVideoPickCallback: onVideoPickCallback,
filePickImpl: filePickImpl,
webImagePickImpl: webImagePickImpl,
webVideoPickImpl: webVideoPickImpl),
if (isButtonGroupShown[0] &&
icon: Icons.photo_camera,
iconSize: toolbarIconSize,
controller: controller,
onImagePickCallback: onImagePickCallback,
onVideoPickCallback: onVideoPickCallback,
filePickImpl: filePickImpl,
webImagePickImpl: webImagePickImpl,
webVideoPickImpl: webVideoPickImpl,
iconTheme: iconTheme,
),
if (showDividers &&
isButtonGroupShown[0] &&
(isButtonGroupShown[1] ||
isButtonGroupShown[2] ||
isButtonGroupShown[3] ||
isButtonGroupShown[4]))
isButtonGroupShown[4] ||
isButtonGroupShown[5]))
VerticalDivider(
indent: 12,
endIndent: 12,
color: Colors.grey.shade400,
),
if (showHeaderStyle)
SelectHeaderStyleButton(
if (showAlignmentButtons)
SelectAlignmentButton(
controller: controller,
iconSize: toolbarIconSize,
iconTheme: iconTheme,
showLeftAlignment: showLeftAlignment,
showCenterAlignment: showCenterAlignment,
showRightAlignment: showRightAlignment,
showJustifyAlignment: showJustifyAlignment,
),
if (isButtonGroupShown[1] &&
if (showDividers &&
isButtonGroupShown[1] &&
(isButtonGroupShown[2] ||
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(
indent: 12,
endIndent: 12,
@ -237,6 +338,7 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget {
controller: controller,
icon: Icons.format_list_numbered,
iconSize: toolbarIconSize,
iconTheme: iconTheme,
),
if (showListBullets)
ToggleStyleButton(
@ -244,6 +346,7 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget {
controller: controller,
icon: Icons.format_list_bulleted,
iconSize: toolbarIconSize,
iconTheme: iconTheme,
),
if (showListCheck)
ToggleCheckListButton(
@ -251,6 +354,7 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget {
controller: controller,
icon: Icons.check_box,
iconSize: toolbarIconSize,
iconTheme: iconTheme,
),
if (showCodeBlock)
ToggleStyleButton(
@ -258,9 +362,11 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget {
controller: controller,
icon: Icons.code,
iconSize: toolbarIconSize,
iconTheme: iconTheme,
),
if (isButtonGroupShown[2] &&
(isButtonGroupShown[3] || isButtonGroupShown[4]))
if (showDividers &&
isButtonGroupShown[3] &&
(isButtonGroupShown[4] || isButtonGroupShown[5]))
VerticalDivider(
indent: 12,
endIndent: 12,
@ -272,6 +378,7 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget {
controller: controller,
icon: Icons.format_quote,
iconSize: toolbarIconSize,
iconTheme: iconTheme,
),
if (showIndent)
IndentButton(
@ -279,6 +386,7 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget {
iconSize: toolbarIconSize,
controller: controller,
isIncrease: true,
iconTheme: iconTheme,
),
if (showIndent)
IndentButton(
@ -286,8 +394,9 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget {
iconSize: toolbarIconSize,
controller: controller,
isIncrease: false,
iconTheme: iconTheme,
),
if (isButtonGroupShown[3] && isButtonGroupShown[4])
if (showDividers && isButtonGroupShown[4] && isButtonGroupShown[5])
VerticalDivider(
indent: 12,
endIndent: 12,
@ -297,20 +406,25 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget {
LinkStyleButton(
controller: controller,
iconSize: toolbarIconSize,
iconTheme: iconTheme,
dialogTheme: dialogTheme,
),
if (showHorizontalRule)
InsertEmbedButton(
controller: controller,
icon: Icons.horizontal_rule,
iconSize: toolbarIconSize,
iconTheme: iconTheme,
),
],
);
}
final List<Widget> children;
final double toolBarHeight;
final bool? multiRowsDisplay;
final double toolbarHeight;
final double toolbarSectionSpacing;
final WrapAlignment toolbarIconAlignment;
final bool multiRowsDisplay;
/// The color of the toolbar.
///
@ -320,23 +434,35 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget {
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
Size get preferredSize => Size.fromHeight(toolBarHeight);
Size get preferredSize => Size.fromHeight(toolbarHeight);
@override
Widget build(BuildContext context) {
if (multiRowsDisplay ?? true) {
return Wrap(
alignment: WrapAlignment.center,
runSpacing: 4,
spacing: 4,
children: children,
);
}
return Container(
constraints: BoxConstraints.tightFor(height: preferredSize.height),
color: color ?? Theme.of(context).canvasColor,
child: ArrowIndicatedButtonList(buttons: children),
return I18n(
initialLocale: locale,
child: multiRowsDisplay
? Wrap(
alignment: toolbarIconAlignment,
runSpacing: 4,
spacing: toolbarSectionSpacing,
children: children,
)
: Container(
constraints:
BoxConstraints.tightFor(height: preferredSize.height),
color: color ?? Theme.of(context).canvasColor,
child: ArrowIndicatedButtonList(buttons: children),
),
);
}
}

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

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

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

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

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

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

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

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

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

@ -1,22 +1,28 @@
import 'package:flutter/material.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 '../link_dialog.dart';
import '../toolbar.dart';
import 'quill_icon_button.dart';
class LinkStyleButton extends StatefulWidget {
const LinkStyleButton({
required this.controller,
this.iconSize = kDefaultIconSize,
this.icon,
this.iconTheme,
this.dialogTheme,
Key? key,
}) : super(key: key);
final QuillController controller;
final IconData? icon;
final double iconSize;
final QuillIconTheme? iconTheme;
final QuillDialogTheme? dialogTheme;
@override
_LinkStyleButtonState createState() => _LinkStyleButtonState();
@ -48,22 +54,44 @@ class _LinkStyleButtonState extends State<LinkStyleButton> {
widget.controller.removeListener(_didChangeSelection);
}
final GlobalKey _toolTipKey = GlobalKey();
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isEnabled = !widget.controller.selection.isCollapsed;
final pressedHandler = isEnabled ? () => _openLinkDialog(context) : null;
return QuillIconButton(
highlightElevation: 0,
hoverElevation: 0,
size: widget.iconSize * kIconButtonFactor,
icon: Icon(
widget.icon ?? Icons.link,
size: widget.iconSize,
color: isEnabled ? theme.iconTheme.color : theme.disabledColor,
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,
hoverElevation: 0,
size: widget.iconSize * kIconButtonFactor,
icon: Icon(
widget.icon ?? Icons.link,
size: widget.iconSize,
color: isEnabled
? (widget.iconTheme?.iconUnselectedColor ??
theme.iconTheme.color)
: (widget.iconTheme?.disabledIconColor ?? theme.disabledColor),
),
fillColor:
widget.iconTheme?.iconUnselectedFillColor ?? theme.canvasColor,
onPressed: pressedHandler,
),
),
fillColor: Theme.of(context).canvasColor,
onPressed: pressedHandler,
);
}
@ -71,7 +99,7 @@ class _LinkStyleButtonState extends State<LinkStyleButton> {
showDialog<String>(
context: context,
builder: (ctx) {
return const LinkDialog();
return LinkDialog(dialogTheme: widget.dialogTheme);
},
).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/style.dart';
import '../../models/themes/quill_icon_theme.dart';
import '../controller.dart';
import '../toolbar.dart';
@ -10,12 +11,15 @@ class SelectHeaderStyleButton extends StatefulWidget {
const SelectHeaderStyleButton({
required this.controller,
this.iconSize = kDefaultIconSize,
this.iconTheme,
Key? key,
}) : super(key: key);
final QuillController controller;
final double iconSize;
final QuillIconTheme? iconTheme;
@override
_SelectHeaderStyleButtonState createState() =>
_SelectHeaderStyleButtonState();
@ -63,7 +67,7 @@ class _SelectHeaderStyleButtonState extends State<SelectHeaderStyleButton> {
mainAxisSize: MainAxisSize.min,
children: List.generate(4, (index) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: !kIsWeb ? 1.0 : 5.0),
padding: EdgeInsets.symmetric(horizontal: !kIsWeb ? 1.0 : 5.0),
child: ConstrainedBox(
constraints: BoxConstraints.tightFor(
width: widget.iconSize * kIconButtonFactor,
@ -77,16 +81,20 @@ class _SelectHeaderStyleButtonState extends State<SelectHeaderStyleButton> {
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(2)),
fillColor: _valueToText[_value] == _valueString[index]
? theme.toggleableActiveColor
: theme.canvasColor,
? (widget.iconTheme?.iconSelectedFillColor ??
theme.toggleableActiveColor)
: (widget.iconTheme?.iconUnselectedFillColor ??
theme.canvasColor),
onPressed: () =>
widget.controller.formatSelection(_valueAttribute[index]),
child: Text(
_valueString[index],
style: style.copyWith(
color: _valueToText[_value] == _valueString[index]
? theme.primaryIconTheme.color
: theme.iconTheme.color,
? (widget.iconTheme?.iconSelectedColor ??
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/style.dart';
import '../../models/themes/quill_icon_theme.dart';
import '../controller.dart';
import '../toolbar.dart';
import 'toggle_style_button.dart';
class ToggleCheckListButton extends StatefulWidget {
const ToggleCheckListButton({
@ -14,6 +14,7 @@ class ToggleCheckListButton extends StatefulWidget {
this.iconSize = kDefaultIconSize,
this.fillColor,
this.childBuilder = defaultToggleStyleButtonBuilder,
this.iconTheme,
Key? key,
}) : super(key: key);
@ -28,6 +29,8 @@ class ToggleCheckListButton extends StatefulWidget {
final Attribute attribute;
final QuillIconTheme? iconTheme;
@override
_ToggleCheckListButtonState createState() => _ToggleCheckListButtonState();
}
@ -81,18 +84,15 @@ class _ToggleCheckListButtonState extends State<ToggleCheckListButton> {
@override
Widget build(BuildContext context) {
final isInCodeBlock =
_selectionStyle.attributes.containsKey(Attribute.codeBlock.key);
final isEnabled =
!isInCodeBlock || Attribute.list.key == Attribute.codeBlock.key;
return widget.childBuilder(
context,
Attribute.unchecked,
widget.icon,
widget.fillColor,
_isToggled,
isEnabled ? _toggleAttribute : null,
_toggleAttribute,
widget.iconSize,
widget.iconTheme,
);
}

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

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

@ -35,13 +35,16 @@ class _VideoAppState extends State<VideoApp> {
// Ensure the first frame is shown after the video is initialized,
// even before the play button has been pressed.
setState(() {});
}).catchError((error) {
setState(() {});
});
;
}
@override
Widget build(BuildContext context) {
final defaultStyles = DefaultStyles.getInstance(context);
if (!_controller.value.isInitialized || _controller.value.hasError) {
if (_controller.value.hasError) {
if (widget.readOnly) {
return RichText(
text: TextSpan(
@ -54,6 +57,12 @@ class _VideoAppState extends State<VideoApp> {
return RichText(
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(

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

Loading…
Cancel
Save