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

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

@ -1,3 +1,139 @@
# [3.0.4]
* Add maxContentWidth constraint to editor.
# [3.0.3]
* Do not show caret on screen when the editor is not focused.
# [3.0.2]
* Fix launch link for read-only mode.
## [3.0.1]
* Handle null value of Attribute.link.
## [3.0.0]
* Launch link improvements.
* Removed QuillSimpleViewer.
## [2.5.2]
* Skip image when pasting.
## [2.5.1]
* Bug fix for Desktop `Shift` + `Click` support.
## [2.5.0]
* Update checkbox list.
## [2.4.1]
* Desktop selection improvements.
## [2.4.0]
* Improve inline code style.
## [2.3.3]
* Improves selection rects to have consistent height regardless of individual segment text styles.
## [2.3.2]
* Allow disabling floating cursor.
## [2.3.1]
* Preserve last newline character on delete.
## [2.3.0]
* Massive changes to support flutter 2.8.
## [2.2.2]
* iOS - floating cursor.
## [2.2.1]
* Bug fix for imports supporting flutter 2.8.
## [2.2.0]
* Support flutter 2.8.
## [2.1.1]
* Add methods of clearing editor and moving cursor.
## [2.1.0]
* Add delete handler.
## [2.0.23]
* Support custom replaceText handler.
## [2.0.22]
* Fix attribute compare and fix font size parsing.
## [2.0.21]
* Handle click on embed object.
## [2.0.20]
* Improved UX/UI of Image widget.
## [2.0.19]
* When uploading a video, applying indicator.
## [2.0.18]
* Make toolbar dividers optional.
## [2.0.17]
* Allow alignment of the toolbar icons to match WrapAlignment.
## [2.0.16]
* Add hide / show alignment buttons.
## [2.0.15]
* Implement change cursor to SystemMouseCursors.click when hovering a link styled text.
## [2.0.14]
* Enable customize the checkbox widget using DefaultListBlockStyle style.
## [2.0.13]
* Improve the scrolling performance by reducing the repaint areas.
## [2.0.12]
* Fix the selection effect can't be seen as the textLine with background color.
## [2.0.11]
* Fix visibility of text selection handlers on scroll.
## [2.0.10]
* cursorConnt.color notify the text_line to repaint if it was disposed.
## [2.0.9]
* Improve UX when trying to add a link.
## [2.0.8]
* Adding translations to the toolbar.
## [2.0.7]
* Added theming options for toolbar icons and LinkDialog.
## [2.0.6]
* Avoid runtime error when placed inside TabBarView.
## [2.0.5]
* Support inline code formatting.
## [2.0.4]
* Enable history shortcuts for desktop.
## [2.0.3]
* Fix cursor when line contains image.
## [2.0.2]
* Address KeyboardListener class name conflict.
## [2.0.1]
* Upgrade flutter_colorpicker to 0.5.0.
## [2.0.0]
* Text Alignment functions + Block Format standards.
## [1.9.6]
* Support putting QuillEditor inside a Scrollable view.
## [1.9.5]
* Skip image when pasting.
## [1.9.4]
* 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);
webImagePickImpl: _webImagePickImpl,
showAlignmentButtons: true,
);
}
if (_isDesktop()) {
toolbar = QuillToolbar.basic(
controller: _controller!,
onImagePickCallback: _onImagePickCallback,
filePickImpl: openFileSystemPickerForDesktop);
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 {
}
}
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: (_) {
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: PhotoView(
imageProvider: imageProvider,
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;
class QuillPressedKeys extends ChangeNotifier {
static QuillPressedKeys of(BuildContext context) {
final widget =
context.dependOnInheritedWidgetOfExactType<_QuillPressedKeysAccess>();
return widget!.pressedKeys;
}
if (event is! RawKeyDownEvent) {
return KeyEventResult.ignored;
bool _metaPressed = false;
bool _controlPressed = false;
/// Whether meta key is currently pressed.
bool get metaPressed => _metaPressed;
/// Whether control key is currently pressed.
bool get controlPressed => _controlPressed;
void _updatePressedKeys(Set<LogicalKeyboardKey> pressedKeys) {
final meta = pressedKeys.contains(LogicalKeyboardKey.metaLeft) ||
pressedKeys.contains(LogicalKeyboardKey.metaRight);
final control = pressedKeys.contains(LogicalKeyboardKey.controlLeft) ||
pressedKeys.contains(LogicalKeyboardKey.controlRight);
if (_metaPressed != meta || _controlPressed != control) {
_metaPressed = meta;
_controlPressed = control;
notifyListeners();
}
}
}
class QuillKeyboardListener extends StatefulWidget {
const QuillKeyboardListener({required this.child, Key? key})
: super(key: key);
final Widget child;
final keysPressed =
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;
@override
QuillKeyboardListenerState createState() => QuillKeyboardListenerState();
}
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);
class QuillKeyboardListenerState extends State<QuillKeyboardListener> {
final QuillPressedKeys _pressedKeys = QuillPressedKeys();
bool _keyEvent(KeyEvent event) {
_pressedKeys
._updatePressedKeys(HardwareKeyboard.instance.logicalKeysPressed);
return false;
}
return KeyEventResult.ignored;
@override
void initState() {
super.initState();
HardwareKeyboard.instance.addHandler(_keyEvent);
_pressedKeys
._updatePressedKeys(HardwareKeyboard.instance.logicalKeysPressed);
}
@override
void dispose() {
HardwareKeyboard.instance.removeHandler(_keyEvent);
_pressedKeys.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return _QuillPressedKeysAccess(
pressedKeys: _pressedKeys,
child: widget.child,
);
}
}
class _QuillPressedKeysAccess extends InheritedWidget {
const _QuillPressedKeysAccess({
required this.pressedKeys,
required Widget child,
Key? key,
}) : super(key: key, child: child);
final QuillPressedKeys pressedKeys;
@override
bool updateShouldNotify(covariant _QuillPressedKeysAccess oldWidget) {
return oldWidget.pressedKeys != pressedKeys;
}
}

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

@ -1,7 +1,12 @@
import 'package:flutter/material.dart';
import '../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,
{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,
) : super(child: child);
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,
{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,
this.showSelectionHandles,
this.toolbarOptions = const ToolbarOptions(
copy: true,
cut: true,
paste: true,
selectAll: true,
),
this.showSelectionHandles = false,
bool? showCursor,
this.cursorStyle,
this.textCapitalization,
this.textCapitalization = TextCapitalization.none,
this.maxHeight,
this.minHeight,
this.maxContentWidth,
this.customStyles,
this.expands,
this.autoFocus,
this.selectionColor,
this.selectionCtrls,
this.keyboardAppearance,
this.enableInteractiveSelection,
this.expands = false,
this.autoFocus = false,
this.keyboardAppearance = Brightness.light,
this.enableInteractiveSelection = true,
this.scrollPhysics,
this.embedBuilder,
this.embedBuilder = defaultEmbedBuilder,
this.linkActionPickerDelegate = defaultLinkActionPickerDelegate,
this.customStyleBuilder,
) : assert(maxHeight == null || maxHeight > 0, 'maxHeight cannot be null'),
this.floatingCursorDisabled = false})
: assert(maxHeight == null || maxHeight > 0, 'maxHeight cannot be null'),
assert(minHeight == null || minHeight >= 0, 'minHeight cannot be null'),
assert(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,34 +340,46 @@ class RawEditorState extends EditorState
data: _styles!,
child: MouseRegion(
cursor: SystemMouseCursors.text,
child: QuillKeyboardListener(
child: Container(
constraints: constraints,
child: child,
),
),
),
);
}
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 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,9 +148,13 @@ mixin RawEditorStateTextInputClientMixin on EditorState
final text = value.text;
final cursorPosition = value.selection.extentOffset;
final diff = getDiff(oldText, text, cursorPosition);
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
void performAction(TextInputAction action) {
@ -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,6 +557,7 @@ class EditableTextLine extends RenderObjectWidget {
enum TextLineSlot { LEADING, BODY }
class RenderEditableTextLine extends RenderEditableBox {
/// Creates new editable paragraph render box.
RenderEditableTextLine(
this.line,
this.textDirection,
@ -344,7 +568,7 @@ class RenderEditableTextLine extends RenderEditableBox {
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,13 +695,24 @@ 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 &&
return _containsCursor ??= cursorCont.isFloatingCursorActive
? line
.containsOffset(cursorCont.floatingCursorTextPosition.value!.offset)
: textSelection.isCollapsed &&
line.containsOffset(textSelection.baseOffset);
}
@ -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(
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);
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,6 +266,8 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget {
filePickImpl: filePickImpl,
webVideoPickImpl: webImagePickImpl,
mediaPickSettingSelector: mediaPickSettingSelector,
iconTheme: iconTheme,
dialogTheme: dialogTheme,
),
if ((onImagePickCallback != null || onVideoPickCallback != null) &&
showCameraButton)
@ -206,26 +279,54 @@ class QuillToolbar extends StatelessWidget implements PreferredSizeWidget {
onVideoPickCallback: onVideoPickCallback,
filePickImpl: filePickImpl,
webImagePickImpl: webImagePickImpl,
webVideoPickImpl: webVideoPickImpl),
if (isButtonGroupShown[0] &&
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,
return I18n(
initialLocale: locale,
child: multiRowsDisplay
? Wrap(
alignment: toolbarIconAlignment,
runSpacing: 4,
spacing: 4,
spacing: toolbarSectionSpacing,
children: children,
);
}
return Container(
constraints: BoxConstraints.tightFor(height: preferredSize.height),
)
: 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(
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 ? theme.iconTheme.color : theme.disabledColor,
color: isEnabled
? (widget.iconTheme?.iconUnselectedColor ??
theme.iconTheme.color)
: (widget.iconTheme?.disabledIconColor ?? theme.disabledColor),
),
fillColor: Theme.of(context).canvasColor,
fillColor:
widget.iconTheme?.iconUnselectedFillColor ?? theme.canvasColor,
onPressed: pressedHandler,
),
),
);
}
@ -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