Merge branch 'master' into fix_optional

pull/239/head
Xun Gong 4 years ago
commit 702bed9075
  1. 1
      example/lib/main.dart
  2. 7
      example/lib/pages/home_page.dart
  3. 3
      example/lib/pages/read_only_page.dart
  4. 6
      example/lib/universal_ui/universal_ui.dart
  5. 4
      example/lib/widgets/demo_scaffold.dart
  6. 11
      lib/flutter_quill.dart
  7. 295
      lib/models/documents/attribute.dart
  8. 287
      lib/models/documents/document.dart
  9. 137
      lib/models/documents/history.dart
  10. 75
      lib/models/documents/nodes/block.dart
  11. 163
      lib/models/documents/nodes/container.dart
  12. 43
      lib/models/documents/nodes/embed.dart
  13. 255
      lib/models/documents/nodes/leaf.dart
  14. 374
      lib/models/documents/nodes/line.dart
  15. 137
      lib/models/documents/nodes/node.dart
  16. 130
      lib/models/documents/style.dart
  17. 687
      lib/models/quill_delta.dart
  18. 127
      lib/models/rules/delete.dart
  19. 135
      lib/models/rules/format.dart
  20. 416
      lib/models/rules/insert.dart
  21. 80
      lib/models/rules/rule.dart
  22. 292
      lib/src/models/documents/attribute.dart
  23. 290
      lib/src/models/documents/document.dart
  24. 134
      lib/src/models/documents/history.dart
  25. 72
      lib/src/models/documents/nodes/block.dart
  26. 160
      lib/src/models/documents/nodes/container.dart
  27. 40
      lib/src/models/documents/nodes/embed.dart
  28. 252
      lib/src/models/documents/nodes/leaf.dart
  29. 371
      lib/src/models/documents/nodes/line.dart
  30. 131
      lib/src/models/documents/nodes/node.dart
  31. 127
      lib/src/models/documents/style.dart
  32. 684
      lib/src/models/quill_delta.dart
  33. 124
      lib/src/models/rules/delete.dart
  34. 132
      lib/src/models/rules/format.dart
  35. 413
      lib/src/models/rules/insert.dart
  36. 77
      lib/src/models/rules/rule.dart
  37. 125
      lib/src/utils/color.dart
  38. 102
      lib/src/utils/diff_delta.dart
  39. 39
      lib/src/widgets/box.dart
  40. 229
      lib/src/widgets/controller.dart
  41. 315
      lib/src/widgets/cursor.dart
  42. 223
      lib/src/widgets/default_styles.dart
  43. 148
      lib/src/widgets/delegate.dart
  44. 1145
      lib/src/widgets/editor.dart
  45. 31
      lib/src/widgets/image.dart
  46. 105
      lib/src/widgets/keyboard_listener.dart
  47. 298
      lib/src/widgets/proxy.dart
  48. 736
      lib/src/widgets/raw_editor.dart
  49. 354
      lib/src/widgets/raw_editor/raw_editor_state_keyboard_mixin.dart
  50. 40
      lib/src/widgets/raw_editor/raw_editor_state_selection_delegate_mixin.dart
  51. 200
      lib/src/widgets/raw_editor/raw_editor_state_text_input_client_mixin.dart
  52. 43
      lib/src/widgets/responsive_widget.dart
  53. 344
      lib/src/widgets/simple_viewer.dart
  54. 737
      lib/src/widgets/text_block.dart
  55. 892
      lib/src/widgets/text_line.dart
  56. 726
      lib/src/widgets/text_selection.dart
  57. 1294
      lib/src/widgets/toolbar.dart
  58. 128
      lib/utils/color.dart
  59. 105
      lib/utils/diff_delta.dart
  60. 42
      lib/widgets/box.dart
  61. 231
      lib/widgets/controller.dart
  62. 234
      lib/widgets/cursor.dart
  63. 226
      lib/widgets/default_styles.dart
  64. 151
      lib/widgets/delegate.dart
  65. 1148
      lib/widgets/editor.dart
  66. 34
      lib/widgets/image.dart
  67. 108
      lib/widgets/keyboard_listener.dart
  68. 301
      lib/widgets/proxy.dart
  69. 739
      lib/widgets/raw_editor.dart
  70. 357
      lib/widgets/raw_editor/raw_editor_state_keyboard_mixin.dart
  71. 43
      lib/widgets/raw_editor/raw_editor_state_selection_delegate_mixin.dart
  72. 203
      lib/widgets/raw_editor/raw_editor_state_text_input_client_mixin.dart
  73. 46
      lib/widgets/responsive_widget.dart
  74. 347
      lib/widgets/simple_viewer.dart
  75. 740
      lib/widgets/text_block.dart
  76. 895
      lib/widgets/text_line.dart
  77. 729
      lib/widgets/text_selection.dart
  78. 1297
      lib/widgets/toolbar.dart

@ -1,4 +1,3 @@
// import 'package:app/pages/home_page.dart';
import 'package:flutter/material.dart';
import 'pages/home_page.dart';

@ -5,12 +5,7 @@ import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_quill/models/documents/attribute.dart';
import 'package:flutter_quill/models/documents/document.dart';
import 'package:flutter_quill/widgets/controller.dart';
import 'package:flutter_quill/widgets/default_styles.dart';
import 'package:flutter_quill/widgets/editor.dart';
import 'package:flutter_quill/widgets/toolbar.dart';
import 'package:flutter_quill/flutter_quill.dart' hide Text;
import 'package:path/path.dart';
import 'package:path_provider/path_provider.dart';
import 'package:tuple/tuple.dart';

@ -1,7 +1,6 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_quill/widgets/controller.dart';
import 'package:flutter_quill/widgets/editor.dart';
import 'package:flutter_quill/flutter_quill.dart' hide Text;
import '../universal_ui/universal_ui.dart';
import '../widgets/demo_scaffold.dart';

@ -2,8 +2,8 @@ library universal_ui;
import 'package:flutter/cupertino.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_quill/models/documents/nodes/leaf.dart' as leaf;
import 'package:flutter_quill/widgets/responsive_widget.dart';
import 'package:flutter_quill/flutter_quill.dart';
import 'package:universal_html/html.dart' as html;
import 'fake_ui.dart' if (dart.library.html) 'real_ui.dart' as ui_instance;
@ -25,7 +25,7 @@ class UniversalUI {
var ui = UniversalUI();
Widget defaultEmbedBuilderWeb(BuildContext context, leaf.Embed node) {
Widget defaultEmbedBuilderWeb(BuildContext context, Embed node) {
switch (node.value.type) {
case 'image':
final String imageUrl = node.value.data;

@ -2,9 +2,7 @@ import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_quill/models/documents/document.dart';
import 'package:flutter_quill/widgets/controller.dart';
import 'package:flutter_quill/widgets/toolbar.dart';
import 'package:flutter_quill/flutter_quill.dart' hide Text;
typedef DemoContentBuilder = Widget Function(
BuildContext context, QuillController? controller);

@ -1 +1,12 @@
library flutter_quill;
export 'src/models/documents/attribute.dart';
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/widgets/controller.dart';
export 'src/widgets/default_styles.dart';
export 'src/widgets/editor.dart';
export 'src/widgets/responsive_widget.dart';
export 'src/widgets/toolbar.dart';

@ -1,292 +1,3 @@
import 'dart:collection';
import 'package:quiver/core.dart';
enum AttributeScope {
INLINE, // refer to https://quilljs.com/docs/formats/#inline
BLOCK, // refer to https://quilljs.com/docs/formats/#block
EMBEDS, // refer to https://quilljs.com/docs/formats/#embeds
IGNORE, // attributes that can be ignored
}
class Attribute<T> {
Attribute(this.key, this.scope, this.value);
final String key;
final AttributeScope scope;
final T value;
static final Map<String, Attribute> _registry = LinkedHashMap.of({
Attribute.bold.key: Attribute.bold,
Attribute.italic.key: Attribute.italic,
Attribute.underline.key: Attribute.underline,
Attribute.strikeThrough.key: Attribute.strikeThrough,
Attribute.font.key: Attribute.font,
Attribute.size.key: Attribute.size,
Attribute.link.key: Attribute.link,
Attribute.color.key: Attribute.color,
Attribute.background.key: Attribute.background,
Attribute.placeholder.key: Attribute.placeholder,
Attribute.header.key: Attribute.header,
Attribute.align.key: Attribute.align,
Attribute.list.key: Attribute.list,
Attribute.codeBlock.key: Attribute.codeBlock,
Attribute.blockQuote.key: Attribute.blockQuote,
Attribute.indent.key: Attribute.indent,
Attribute.width.key: Attribute.width,
Attribute.height.key: Attribute.height,
Attribute.style.key: Attribute.style,
Attribute.token.key: Attribute.token,
});
static final BoldAttribute bold = BoldAttribute();
static final ItalicAttribute italic = ItalicAttribute();
static final UnderlineAttribute underline = UnderlineAttribute();
static final StrikeThroughAttribute strikeThrough = StrikeThroughAttribute();
static final FontAttribute font = FontAttribute(null);
static final SizeAttribute size = SizeAttribute(null);
static final LinkAttribute link = LinkAttribute(null);
static final ColorAttribute color = ColorAttribute(null);
static final BackgroundAttribute background = BackgroundAttribute(null);
static final PlaceholderAttribute placeholder = PlaceholderAttribute();
static final HeaderAttribute header = HeaderAttribute();
static final IndentAttribute indent = IndentAttribute();
static final AlignAttribute align = AlignAttribute(null);
static final ListAttribute list = ListAttribute(null);
static final CodeBlockAttribute codeBlock = CodeBlockAttribute();
static final BlockQuoteAttribute blockQuote = BlockQuoteAttribute();
static final WidthAttribute width = WidthAttribute(null);
static final HeightAttribute height = HeightAttribute(null);
static final StyleAttribute style = StyleAttribute(null);
static final TokenAttribute token = TokenAttribute('');
static final Set<String> inlineKeys = {
Attribute.bold.key,
Attribute.italic.key,
Attribute.underline.key,
Attribute.strikeThrough.key,
Attribute.link.key,
Attribute.color.key,
Attribute.background.key,
Attribute.placeholder.key,
};
static final Set<String> blockKeys = LinkedHashSet.of({
Attribute.header.key,
Attribute.align.key,
Attribute.list.key,
Attribute.codeBlock.key,
Attribute.blockQuote.key,
Attribute.indent.key,
});
static final Set<String> blockKeysExceptHeader = LinkedHashSet.of({
Attribute.list.key,
Attribute.align.key,
Attribute.codeBlock.key,
Attribute.blockQuote.key,
Attribute.indent.key,
});
static Attribute<int?> get h1 => HeaderAttribute(level: 1);
static Attribute<int?> get h2 => HeaderAttribute(level: 2);
static Attribute<int?> get h3 => HeaderAttribute(level: 3);
// "attributes":{"align":"left"}
static Attribute<String?> get leftAlignment => AlignAttribute('left');
// "attributes":{"align":"center"}
static Attribute<String?> get centerAlignment => AlignAttribute('center');
// "attributes":{"align":"right"}
static Attribute<String?> get rightAlignment => AlignAttribute('right');
// "attributes":{"align":"justify"}
static Attribute<String?> get justifyAlignment => AlignAttribute('justify');
// "attributes":{"list":"bullet"}
static Attribute<String?> get ul => ListAttribute('bullet');
// "attributes":{"list":"ordered"}
static Attribute<String?> get ol => ListAttribute('ordered');
// "attributes":{"list":"checked"}
static Attribute<String?> get checked => ListAttribute('checked');
// "attributes":{"list":"unchecked"}
static Attribute<String?> get unchecked => ListAttribute('unchecked');
// "attributes":{"indent":1"}
static Attribute<int?> get indentL1 => IndentAttribute(level: 1);
// "attributes":{"indent":2"}
static Attribute<int?> get indentL2 => IndentAttribute(level: 2);
// "attributes":{"indent":3"}
static Attribute<int?> get indentL3 => IndentAttribute(level: 3);
static Attribute<int?> getIndentLevel(int? level) {
if (level == 1) {
return indentL1;
}
if (level == 2) {
return indentL2;
}
if (level == 3) {
return indentL3;
}
return IndentAttribute(level: level);
}
bool get isInline => scope == AttributeScope.INLINE;
bool get isBlockExceptHeader => blockKeysExceptHeader.contains(key);
Map<String, dynamic> toJson() => <String, dynamic>{key: value};
static Attribute fromKeyValue(String key, dynamic value) {
if (!_registry.containsKey(key)) {
throw ArgumentError.value(key, 'key "$key" not found.');
}
final origin = _registry[key]!;
final attribute = clone(origin, value);
return attribute;
}
static int getRegistryOrder(Attribute attribute) {
var order = 0;
for (final attr in _registry.values) {
if (attr.key == attribute.key) {
break;
}
order++;
}
return order;
}
static Attribute clone(Attribute origin, dynamic value) {
return Attribute(origin.key, origin.scope, value);
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
if (other is! Attribute<T>) return false;
final typedOther = other;
return key == typedOther.key &&
scope == typedOther.scope &&
value == typedOther.value;
}
@override
int get hashCode => hash3(key, scope, value);
@override
String toString() {
return 'Attribute{key: $key, scope: $scope, value: $value}';
}
}
class BoldAttribute extends Attribute<bool> {
BoldAttribute() : super('bold', AttributeScope.INLINE, true);
}
class ItalicAttribute extends Attribute<bool> {
ItalicAttribute() : super('italic', AttributeScope.INLINE, true);
}
class UnderlineAttribute extends Attribute<bool> {
UnderlineAttribute() : super('underline', AttributeScope.INLINE, true);
}
class StrikeThroughAttribute extends Attribute<bool> {
StrikeThroughAttribute() : super('strike', AttributeScope.INLINE, true);
}
class FontAttribute extends Attribute<String?> {
FontAttribute(String? val) : super('font', AttributeScope.INLINE, val);
}
class SizeAttribute extends Attribute<String?> {
SizeAttribute(String? val) : super('size', AttributeScope.INLINE, val);
}
class LinkAttribute extends Attribute<String?> {
LinkAttribute(String? val) : super('link', AttributeScope.INLINE, val);
}
class ColorAttribute extends Attribute<String?> {
ColorAttribute(String? val) : super('color', AttributeScope.INLINE, val);
}
class BackgroundAttribute extends Attribute<String?> {
BackgroundAttribute(String? val)
: super('background', AttributeScope.INLINE, val);
}
/// This is custom attribute for hint
class PlaceholderAttribute extends Attribute<bool> {
PlaceholderAttribute() : super('placeholder', AttributeScope.INLINE, true);
}
class HeaderAttribute extends Attribute<int?> {
HeaderAttribute({int? level}) : super('header', AttributeScope.BLOCK, level);
}
class IndentAttribute extends Attribute<int?> {
IndentAttribute({int? level}) : super('indent', AttributeScope.BLOCK, level);
}
class AlignAttribute extends Attribute<String?> {
AlignAttribute(String? val) : super('align', AttributeScope.BLOCK, val);
}
class ListAttribute extends Attribute<String?> {
ListAttribute(String? val) : super('list', AttributeScope.BLOCK, val);
}
class CodeBlockAttribute extends Attribute<bool> {
CodeBlockAttribute() : super('code-block', AttributeScope.BLOCK, true);
}
class BlockQuoteAttribute extends Attribute<bool> {
BlockQuoteAttribute() : super('blockquote', AttributeScope.BLOCK, true);
}
class WidthAttribute extends Attribute<String?> {
WidthAttribute(String? val) : super('width', AttributeScope.IGNORE, val);
}
class HeightAttribute extends Attribute<String?> {
HeightAttribute(String? val) : super('height', AttributeScope.IGNORE, val);
}
class StyleAttribute extends Attribute<String?> {
StyleAttribute(String? val) : super('style', AttributeScope.IGNORE, val);
}
class TokenAttribute extends Attribute<String> {
TokenAttribute(String val) : super('token', AttributeScope.IGNORE, val);
}
/// 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/models/documents/attribute.dart';

@ -1,284 +1,3 @@
import 'dart:async';
import 'package:tuple/tuple.dart';
import '../quill_delta.dart';
import '../rules/rule.dart';
import 'attribute.dart';
import 'history.dart';
import 'nodes/block.dart';
import 'nodes/container.dart';
import 'nodes/embed.dart';
import 'nodes/line.dart';
import 'nodes/node.dart';
import 'style.dart';
/// The rich text document
class Document {
Document() : _delta = Delta()..insert('\n') {
_loadDocument(_delta);
}
Document.fromJson(List data) : _delta = _transform(Delta.fromJson(data)) {
_loadDocument(_delta);
}
Document.fromDelta(Delta delta) : _delta = delta {
_loadDocument(delta);
}
/// The root node of the document tree
final Root _root = Root();
Root get root => _root;
int get length => _root.length;
Delta _delta;
Delta toDelta() => Delta.from(_delta);
final Rules _rules = Rules.getInstance();
void setCustomRules(List<Rule> customRules) {
_rules.setCustomRules(customRules);
}
final StreamController<Tuple3<Delta, Delta, ChangeSource>> _observer =
StreamController.broadcast();
final History _history = History();
Stream<Tuple3<Delta, Delta, ChangeSource>> get changes => _observer.stream;
Delta insert(int index, Object? data, {int replaceLength = 0, bool autoAppendNewlineAfterImage = true}) {
assert(index >= 0);
assert(data is String || data is Embeddable);
if (data is Embeddable) {
data = data.toJson();
} else if ((data as String).isEmpty) {
return Delta();
}
final delta = _rules.apply(RuleType.INSERT, this, index,
data: data, len: replaceLength);
compose(delta, ChangeSource.LOCAL, autoAppendNewlineAfterImage: autoAppendNewlineAfterImage);
return delta;
}
Delta delete(int index, int len) {
assert(index >= 0 && len > 0);
final delta = _rules.apply(RuleType.DELETE, this, index, len: len);
if (delta.isNotEmpty) {
compose(delta, ChangeSource.LOCAL);
}
return delta;
}
Delta replace(int index, int len, Object? data, {bool autoAppendNewlineAfterImage = true}) {
assert(index >= 0);
assert(data is String || data is Embeddable);
final dataIsNotEmpty = (data is String) ? data.isNotEmpty : true;
assert(dataIsNotEmpty || len > 0);
var delta = Delta();
// We have to insert before applying delete rules
// Otherwise delete would be operating on stale document snapshot.
if (dataIsNotEmpty) {
delta = insert(index, data, replaceLength: len,
autoAppendNewlineAfterImage: autoAppendNewlineAfterImage);
}
if (len > 0) {
final deleteDelta = delete(index, len);
delta = delta.compose(deleteDelta);
}
return delta;
}
Delta format(int index, int len, Attribute? attribute) {
assert(index >= 0 && len >= 0 && attribute != null);
var delta = Delta();
final formatDelta = _rules.apply(RuleType.FORMAT, this, index,
len: len, attribute: attribute);
if (formatDelta.isNotEmpty) {
compose(formatDelta, ChangeSource.LOCAL);
delta = delta.compose(formatDelta);
}
return delta;
}
Style collectStyle(int index, int len) {
final res = queryChild(index);
return (res.node as Line).collectStyle(res.offset, len);
}
ChildQuery queryChild(int offset) {
final res = _root.queryChild(offset, true);
if (res.node is Line) {
return res;
}
final block = res.node as Block;
return block.queryChild(res.offset, true);
}
void compose(Delta delta, ChangeSource changeSource, {bool autoAppendNewlineAfterImage = true}) {
assert(!_observer.isClosed);
delta.trim();
assert(delta.isNotEmpty);
var offset = 0;
delta = _transform(delta, autoAppendNewlineAfterImage: autoAppendNewlineAfterImage);
final originalDelta = toDelta();
for (final op in delta.toList()) {
final style =
op.attributes != null ? Style.fromJson(op.attributes) : null;
if (op.isInsert) {
_root.insert(offset, _normalize(op.data), style);
} else if (op.isDelete) {
_root.delete(offset, op.length);
} else if (op.attributes != null) {
_root.retain(offset, op.length, style);
}
if (!op.isDelete) {
offset += op.length!;
}
}
try {
_delta = _delta.compose(delta);
} catch (e) {
throw '_delta compose failed';
}
if (_delta != _root.toDelta()) {
throw 'Compose failed';
}
final change = Tuple3(originalDelta, delta, changeSource);
_observer.add(change);
_history.handleDocChange(change);
}
Tuple2 undo() {
return _history.undo(this);
}
Tuple2 redo() {
return _history.redo(this);
}
bool get hasUndo => _history.hasUndo;
bool get hasRedo => _history.hasRedo;
static Delta _transform(Delta delta, {bool autoAppendNewlineAfterImage = true}) {
final res = Delta();
final ops = delta.toList();
for (var i = 0; i < ops.length; i++) {
final op = ops[i];
res.push(op);
if (autoAppendNewlineAfterImage) {
_autoAppendNewlineAfterImage(i, ops, op, res);
}
}
return res;
}
static void _autoAppendNewlineAfterImage(
int i, List<Operation> ops, Operation op, Delta res) {
final nextOpIsImage =
i + 1 < ops.length && ops[i + 1].isInsert && ops[i + 1].data is! String;
if (nextOpIsImage &&
op.data is String &&
(op.data as String).isNotEmpty &&
!(op.data as String).endsWith('\n'))
{
res.push(Operation.insert('\n'));
}
// Currently embed is equivalent to image and hence `is! String`
final opInsertImage = op.isInsert && op.data is! String;
final nextOpIsLineBreak = i + 1 < ops.length &&
ops[i + 1].isInsert &&
ops[i + 1].data is String &&
(ops[i + 1].data as String).startsWith('\n');
if (opInsertImage && (i + 1 == ops.length - 1 || !nextOpIsLineBreak)) {
// automatically append '\n' for image
res.push(Operation.insert('\n'));
}
}
Object _normalize(Object? data) {
if (data is String) {
return data;
}
if (data is Embeddable) {
return data;
}
return Embeddable.fromJson(data as Map<String, dynamic>);
}
void close() {
_observer.close();
_history.clear();
}
String toPlainText() => _root.children.map((e) => e.toPlainText()).join();
void _loadDocument(Delta doc) {
if (doc.isEmpty) {
throw ArgumentError.value(doc, 'Document Delta cannot be empty.');
}
assert((doc.last.data as String).endsWith('\n'));
var offset = 0;
for (final op in doc.toList()) {
if (!op.isInsert) {
throw ArgumentError.value(doc,
'Document Delta can only contain insert operations but ${op.key} found.');
}
final style =
op.attributes != null ? Style.fromJson(op.attributes) : null;
final data = _normalize(op.data);
_root.insert(offset, data, style);
offset += op.length!;
}
final node = _root.last;
if (node is Line &&
node.parent is! Block &&
node.style.isEmpty &&
_root.childCount > 1) {
_root.remove(node);
}
}
bool isEmpty() {
if (root.children.length != 1) {
return false;
}
final node = root.children.first;
if (!node.isLast) {
return false;
}
final delta = node.toDelta();
return delta.length == 1 &&
delta.first.data == '\n' &&
delta.first.key == 'insert';
}
}
enum ChangeSource {
LOCAL,
REMOTE,
}
/// 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/models/documents/document.dart';

@ -1,134 +1,3 @@
import 'package:tuple/tuple.dart';
import '../quill_delta.dart';
import 'document.dart';
class History {
History({
this.ignoreChange = false,
this.interval = 400,
this.maxStack = 100,
this.userOnly = false,
this.lastRecorded = 0,
});
final HistoryStack stack = HistoryStack.empty();
bool get hasUndo => stack.undo.isNotEmpty;
bool get hasRedo => stack.redo.isNotEmpty;
/// used for disable redo or undo function
bool ignoreChange;
int lastRecorded;
/// Collaborative editing's conditions should be true
final bool userOnly;
///max operation count for undo
final int maxStack;
///record delay
final int interval;
void handleDocChange(Tuple3<Delta, Delta, ChangeSource> change) {
if (ignoreChange) return;
if (!userOnly || change.item3 == ChangeSource.LOCAL) {
record(change.item2, change.item1);
} else {
transform(change.item2);
}
}
void clear() {
stack.clear();
}
void record(Delta change, Delta before) {
if (change.isEmpty) return;
stack.redo.clear();
var undoDelta = change.invert(before);
final timeStamp = DateTime.now().millisecondsSinceEpoch;
if (lastRecorded + interval > timeStamp && stack.undo.isNotEmpty) {
final lastDelta = stack.undo.removeLast();
undoDelta = undoDelta.compose(lastDelta);
} else {
lastRecorded = timeStamp;
}
if (undoDelta.isEmpty) return;
stack.undo.add(undoDelta);
if (stack.undo.length > maxStack) {
stack.undo.removeAt(0);
}
}
///
///It will override pre local undo delta,replaced by remote change
///
void transform(Delta delta) {
transformStack(stack.undo, delta);
transformStack(stack.redo, delta);
}
void transformStack(List<Delta> stack, Delta delta) {
for (var i = stack.length - 1; i >= 0; i -= 1) {
final oldDelta = stack[i];
stack[i] = delta.transform(oldDelta, true);
delta = oldDelta.transform(delta, false);
if (stack[i].length == 0) {
stack.removeAt(i);
}
}
}
Tuple2 _change(Document doc, List<Delta> source, List<Delta> dest) {
if (source.isEmpty) {
return const Tuple2(false, 0);
}
final delta = source.removeLast();
// look for insert or delete
int? len = 0;
final ops = delta.toList();
for (var i = 0; i < ops.length; i++) {
if (ops[i].key == Operation.insertKey) {
len = ops[i].length;
} else if (ops[i].key == Operation.deleteKey) {
len = ops[i].length! * -1;
}
}
final base = Delta.from(doc.toDelta());
final inverseDelta = delta.invert(base);
dest.add(inverseDelta);
lastRecorded = 0;
ignoreChange = true;
doc.compose(delta, ChangeSource.LOCAL);
ignoreChange = false;
return Tuple2(true, len);
}
Tuple2 undo(Document doc) {
return _change(doc, stack.undo, stack.redo);
}
Tuple2 redo(Document doc) {
return _change(doc, stack.redo, stack.undo);
}
}
class HistoryStack {
HistoryStack.empty()
: undo = [],
redo = [];
final List<Delta> undo;
final List<Delta> redo;
void clear() {
undo.clear();
redo.clear();
}
}
/// 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/models/documents/history.dart';

@ -1,72 +1,3 @@
import '../../quill_delta.dart';
import 'container.dart';
import 'line.dart';
import 'node.dart';
/// Represents a group of adjacent [Line]s with the same block style.
///
/// Block elements are:
/// - Blockquote
/// - Header
/// - Indent
/// - List
/// - Text Alignment
/// - Text Direction
/// - Code Block
class Block extends Container<Line?> {
/// Creates new unmounted [Block].
@override
Node newInstance() => Block();
@override
Line get defaultChild => Line();
@override
Delta toDelta() {
return children
.map((child) => child.toDelta())
.fold(Delta(), (a, b) => a.concat(b));
}
@override
void adjust() {
if (isEmpty) {
final sibling = previous;
unlink();
if (sibling != null) {
sibling.adjust();
}
return;
}
var block = this;
final prev = block.previous;
// merging it with previous block if style is the same
if (!block.isFirst &&
block.previous is Block &&
prev!.style == block.style) {
block
..moveChildToNewParent(prev as Container<Node?>?)
..unlink();
block = prev as Block;
}
final next = block.next;
// merging it with next block if style is the same
if (!block.isLast && block.next is Block && next!.style == block.style) {
(next as Block).moveChildToNewParent(block);
next.unlink();
}
}
@override
String toString() {
final block = style.attributes.toString();
final buffer = StringBuffer('§ {$block}\n');
for (final child in children) {
final tree = child.isLast ? '' : '';
buffer.write(' $tree $child');
if (!child.isLast) buffer.writeln();
}
return buffer.toString();
}
}
/// 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/models/documents/nodes/block.dart';

@ -1,160 +1,3 @@
import 'dart:collection';
import '../style.dart';
import 'leaf.dart';
import 'line.dart';
import 'node.dart';
/// Container can accommodate other nodes.
///
/// Delegates insert, retain and delete operations to children nodes. For each
/// operation container looks for a child at specified index position and
/// forwards operation to that child.
///
/// Most of the operation handling logic is implemented by [Line] and [Text].
abstract class Container<T extends Node?> extends Node {
final LinkedList<Node> _children = LinkedList<Node>();
/// List of children.
LinkedList<Node> get children => _children;
/// Returns total number of child nodes in this container.
///
/// To get text length of this container see [length].
int get childCount => _children.length;
/// Returns the first child [Node].
Node get first => _children.first;
/// Returns the last child [Node].
Node get last => _children.last;
/// Returns `true` if this container has no child nodes.
bool get isEmpty => _children.isEmpty;
/// Returns `true` if this container has at least 1 child.
bool get isNotEmpty => _children.isNotEmpty;
/// Returns an instance of default child for this container node.
///
/// Always returns fresh instance.
T get defaultChild;
/// Adds [node] to the end of this container children list.
void add(T node) {
assert(node?.parent == null);
node?.parent = this;
_children.add(node as Node);
}
/// Adds [node] to the beginning of this container children list.
void addFirst(T node) {
assert(node?.parent == null);
node?.parent = this;
_children.addFirst(node as Node);
}
/// Removes [node] from this container.
void remove(T node) {
assert(node?.parent == this);
node?.parent = null;
_children.remove(node as Node);
}
/// Moves children of this node to [newParent].
void moveChildToNewParent(Container? newParent) {
if (isEmpty) {
return;
}
final last = newParent!.isEmpty ? null : newParent.last as T?;
while (isNotEmpty) {
final child = first as T;
child?.unlink();
newParent.add(child);
}
/// In case [newParent] already had children we need to make sure
/// combined list is optimized.
if (last != null) last.adjust();
}
/// Queries the child [Node] at specified character [offset] in this container.
///
/// The result may contain the found node or `null` if no node is found
/// at specified offset.
///
/// [ChildQuery.offset] is set to relative offset within returned child node
/// which points at the same character position in the document as the
/// original [offset].
ChildQuery queryChild(int offset, bool inclusive) {
if (offset < 0 || offset > length) {
return ChildQuery(null, 0);
}
for (final node in children) {
final len = node.length;
if (offset < len || (inclusive && offset == len && (node.isLast))) {
return ChildQuery(node, offset);
}
offset -= len;
}
return ChildQuery(null, 0);
}
@override
String toPlainText() => children.map((child) => child.toPlainText()).join();
/// Content length of this node's children.
///
/// To get number of children in this node use [childCount].
@override
int get length => _children.fold(0, (cur, node) => cur + node.length);
@override
void insert(int index, Object data, Style? style) {
assert(index == 0 || (index > 0 && index < length));
if (isNotEmpty) {
final child = queryChild(index, false);
child.node!.insert(child.offset, data, style);
return;
}
// empty
assert(index == 0);
final node = defaultChild;
add(node);
node?.insert(index, data, style);
}
@override
void retain(int index, int? length, Style? attributes) {
assert(isNotEmpty);
final child = queryChild(index, false);
child.node!.retain(child.offset, length, attributes);
}
@override
void delete(int index, int? length) {
assert(isNotEmpty);
final child = queryChild(index, false);
child.node!.delete(child.offset, length);
}
@override
String toString() => _children.join('\n');
}
/// Result of a child query in a [Container].
class ChildQuery {
ChildQuery(this.node, this.offset);
/// The child node if found, otherwise `null`.
final Node? node;
/// Starting offset within the child [node] which points at the same
/// character in the document as the original offset passed to
/// [Container.queryChild] method.
final int offset;
}
/// 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/models/documents/nodes/container.dart';

@ -1,40 +1,3 @@
/// An object which can be embedded into a Quill document.
///
/// See also:
///
/// * [BlockEmbed] which represents a block embed.
class Embeddable {
Embeddable(this.type, this.data);
/// The type of this object.
final String type;
/// The data payload of this object.
final dynamic data;
Map<String, dynamic> toJson() {
final m = <String, String>{type: data};
return m;
}
static Embeddable fromJson(Map<String, dynamic> json) {
final m = Map<String, dynamic>.from(json);
assert(m.length == 1, 'Embeddable map has one key');
return BlockEmbed(m.keys.first, m.values.first);
}
}
/// An object which occupies an entire line in a document and cannot co-exist
/// inline with regular text.
///
/// There are two built-in embed types supported by Quill documents, however
/// the document model itself does not make any assumptions about the types
/// of embedded objects and allows users to define their own types.
class BlockEmbed extends Embeddable {
BlockEmbed(String type, String data) : super(type, data);
static BlockEmbed horizontalRule = BlockEmbed('divider', 'hr');
static BlockEmbed image(String imageUrl) => BlockEmbed('image', imageUrl);
}
/// 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/models/documents/nodes/embed.dart';

@ -1,252 +1,3 @@
import 'dart:math' as math;
import '../../quill_delta.dart';
import '../style.dart';
import 'embed.dart';
import 'line.dart';
import 'node.dart';
/// A leaf in Quill document tree.
abstract class Leaf extends Node {
/// Creates a new [Leaf] with specified [data].
factory Leaf(Object data) {
if (data is Embeddable) {
return Embed(data);
}
final text = data as String;
assert(text.isNotEmpty);
return Text(text);
}
Leaf.val(Object val) : _value = val;
/// Contents of this node, either a String if this is a [Text] or an
/// [Embed] if this is an [BlockEmbed].
Object get value => _value;
Object _value;
@override
void applyStyle(Style value) {
assert(value.isInline || value.isIgnored || value.isEmpty,
'Unable to apply Style to leaf: $value');
super.applyStyle(value);
}
@override
Line? get parent => super.parent as Line?;
@override
int get length {
if (_value is String) {
return (_value as String).length;
}
// return 1 for embedded object
return 1;
}
@override
Delta toDelta() {
final data =
_value is Embeddable ? (_value as Embeddable).toJson() : _value;
return Delta()..insert(data, style.toJson());
}
@override
void insert(int index, Object data, Style? style) {
assert(index >= 0 && index <= length);
final node = Leaf(data);
if (index < length) {
splitAt(index)!.insertBefore(node);
} else {
insertAfter(node);
}
node.format(style);
}
@override
void retain(int index, int? len, Style? style) {
if (style == null) {
return;
}
final local = math.min(length - index, len!);
final remain = len - local;
final node = _isolate(index, local);
if (remain > 0) {
assert(node.next != null);
node.next!.retain(0, remain, style);
}
node.format(style);
}
@override
void delete(int index, int? len) {
assert(index < length);
final local = math.min(length - index, len!);
final target = _isolate(index, local);
final prev = target.previous as Leaf?;
final next = target.next as Leaf?;
target.unlink();
final remain = len - local;
if (remain > 0) {
assert(next != null);
next!.delete(0, remain);
}
if (prev != null) {
prev.adjust();
}
}
/// Adjust this text node by merging it with adjacent nodes if they share
/// the same style.
@override
void adjust() {
if (this is Embed) {
// Embed nodes cannot be merged with text nor other embeds (in fact,
// there could be no two adjacent embeds on the same line since an
// embed occupies an entire line).
return;
}
// This is a text node and it can only be merged with other text nodes.
var node = this as Text;
// Merging it with previous node if style is the same.
final prev = node.previous;
if (!node.isFirst && prev is Text && prev.style == node.style) {
prev._value = prev.value + node.value;
node.unlink();
node = prev;
}
// Merging it with next node if style is the same.
final next = node.next;
if (!node.isLast && next is Text && next.style == node.style) {
node._value = node.value + next.value;
next.unlink();
}
}
/// Splits this leaf node at [index] and returns new node.
///
/// If this is the last node in its list and [index] equals this node's
/// length then this method returns `null` as there is nothing left to split.
/// If there is another leaf node after this one and [index] equals this
/// node's length then the next leaf node is returned.
///
/// If [index] equals to `0` then this node itself is returned unchanged.
///
/// In case a new node is actually split from this one, it inherits this
/// node's style.
Leaf? splitAt(int index) {
assert(index >= 0 && index <= length);
if (index == 0) {
return this;
}
if (index == length) {
return isLast ? null : next as Leaf?;
}
assert(this is Text);
final text = _value as String;
_value = text.substring(0, index);
final split = Leaf(text.substring(index))..applyStyle(style);
insertAfter(split);
return split;
}
/// Cuts a leaf from [index] to the end of this node and returns new node
/// in detached state (e.g. [mounted] returns `false`).
///
/// Splitting logic is identical to one described in [splitAt], meaning this
/// method may return `null`.
Leaf? cutAt(int index) {
assert(index >= 0 && index <= length);
final cut = splitAt(index);
cut?.unlink();
return cut;
}
/// Formats this node and optimizes it with adjacent leaf nodes if needed.
void format(Style? style) {
if (style != null && style.isNotEmpty) {
applyStyle(style);
}
adjust();
}
/// Isolates a new leaf starting at [index] with specified [length].
///
/// Splitting logic is identical to one described in [splitAt], with one
/// exception that it is required for [index] to always be less than this
/// node's length. As a result this method always returns a [LeafNode]
/// instance. Returned node may still be the same as this node
/// if provided [index] is `0`.
Leaf _isolate(int index, int length) {
assert(
index >= 0 && index < this.length && (index + length <= this.length));
final target = splitAt(index)!..splitAt(length);
return target;
}
}
/// A span of formatted text within a line in a Quill document.
///
/// Text is a leaf node of a document tree.
///
/// Parent of a text node is always a [Line], and as a consequence text
/// node's [value] cannot contain any line-break characters.
///
/// See also:
///
/// * [Embed], a leaf node representing an embeddable object.
/// * [Line], a node representing a line of text.
class Text extends Leaf {
Text([String text = ''])
: assert(!text.contains('\n')),
super.val(text);
@override
Node newInstance() => Text();
@override
String get value => _value as String;
@override
String toPlainText() => value;
}
/// An embed node inside of a line in a Quill document.
///
/// Embed node is a leaf node similar to [Text]. It represents an arbitrary
/// piece of non-textual content embedded into a document, such as, image,
/// horizontal rule, video, or any other object with defined structure,
/// like a tweet, for instance.
///
/// Embed node's length is always `1` character and it is represented with
/// unicode object replacement character in the document text.
///
/// Any inline style can be applied to an embed, however this does not
/// necessarily mean the embed will look according to that style. For instance,
/// applying "bold" style to an image gives no effect, while adding a "link" to
/// an image actually makes the image react to user's action.
class Embed extends Leaf {
Embed(Embeddable data) : super.val(data);
static const kObjectReplacementCharacter = '\uFFFC';
@override
Node newInstance() => throw UnimplementedError();
@override
Embeddable get value => super.value as Embeddable;
/// // Embed nodes are represented as unicode object replacement character in
// plain text.
@override
String toPlainText() => kObjectReplacementCharacter;
}
/// 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/models/documents/nodes/leaf.dart';

@ -1,371 +1,3 @@
import 'dart:math' as math;
import 'package:collection/collection.dart';
import '../../quill_delta.dart';
import '../attribute.dart';
import '../style.dart';
import 'block.dart';
import 'container.dart';
import 'embed.dart';
import 'leaf.dart';
import 'node.dart';
/// A line of rich text in a Quill document.
///
/// Line serves as a container for [Leaf]s, like [Text] and [Embed].
///
/// When a line contains an embed, it fully occupies the line, no other embeds
/// or text nodes are allowed.
class Line extends Container<Leaf?> {
@override
Leaf get defaultChild => Text();
@override
int get length => super.length + 1;
/// Returns `true` if this line contains an embedded object.
bool get hasEmbed {
if (childCount != 1) {
return false;
}
return children.single is Embed;
}
/// Returns next [Line] or `null` if this is the last line in the document.
Line? get nextLine {
if (!isLast) {
return next is Block ? (next as Block).first as Line? : next as Line?;
}
if (parent is! Block) {
return null;
}
if (parent!.isLast) {
return null;
}
return parent!.next is Block
? (parent!.next as Block).first as Line?
: parent!.next as Line?;
}
@override
Node newInstance() => Line();
@override
Delta toDelta() {
final delta = children
.map((child) => child.toDelta())
.fold(Delta(), (dynamic a, b) => a.concat(b));
var attributes = style;
if (parent is Block) {
final block = parent as Block;
attributes = attributes.mergeAll(block.style);
}
delta.insert('\n', attributes.toJson());
return delta;
}
@override
String toPlainText() => '${super.toPlainText()}\n';
@override
String toString() {
final body = children.join('');
final styleString = style.isNotEmpty ? ' $style' : '';
return '$body$styleString';
}
@override
void insert(int index, Object data, Style? style) {
if (data is Embeddable) {
// We do not check whether this line already has any children here as
// inserting an embed into a line with other text is acceptable from the
// Delta format perspective.
// We rely on heuristic rules to ensure that embeds occupy an entire line.
_insertSafe(index, data, style);
return;
}
final text = data as String;
final lineBreak = text.indexOf('\n');
if (lineBreak < 0) {
_insertSafe(index, text, style);
// No need to update line or block format since those attributes can only
// be attached to `\n` character and we already know it's not present.
return;
}
final prefix = text.substring(0, lineBreak);
_insertSafe(index, prefix, style);
if (prefix.isNotEmpty) {
index += prefix.length;
}
// Next line inherits our format.
final nextLine = _getNextLine(index);
// Reset our format and unwrap from a block if needed.
clearStyle();
if (parent is Block) {
_unwrap();
}
// Now we can apply new format and re-layout.
_format(style);
// Continue with remaining part.
final remain = text.substring(lineBreak + 1);
nextLine.insert(0, remain, style);
}
@override
void retain(int index, int? len, Style? style) {
if (style == null) {
return;
}
final thisLength = length;
final local = math.min(thisLength - index, len!);
// If index is at newline character then this is a line/block style update.
final isLineFormat = (index + local == thisLength) && local == 1;
if (isLineFormat) {
assert(style.values.every((attr) => attr.scope == AttributeScope.BLOCK),
'It is not allowed to apply inline attributes to line itself.');
_format(style);
} else {
// Otherwise forward to children as it's an inline format update.
assert(style.values.every((attr) => attr.scope == AttributeScope.INLINE));
assert(index + local != thisLength);
super.retain(index, local, style);
}
final remain = len - local;
if (remain > 0) {
assert(nextLine != null);
nextLine!.retain(0, remain, style);
}
}
@override
void delete(int index, int? len) {
final local = math.min(length - index, len!);
final isLFDeleted = index + local == length; // Line feed
if (isLFDeleted) {
// Our newline character deleted with all style information.
clearStyle();
if (local > 1) {
// Exclude newline character from delete range for children.
super.delete(index, local - 1);
}
} else {
super.delete(index, local);
}
final remaining = len - local;
if (remaining > 0) {
assert(nextLine != null);
nextLine!.delete(0, remaining);
}
if (isLFDeleted && isNotEmpty) {
// Since we lost our line-break and still have child text nodes those must
// migrate to the next line.
// nextLine might have been unmounted since last assert so we need to
// check again we still have a line after us.
assert(nextLine != null);
// Move remaining children in this line to the next line so that all
// attributes of nextLine are preserved.
nextLine!.moveChildToNewParent(this);
moveChildToNewParent(nextLine);
}
if (isLFDeleted) {
// Now we can remove this line.
final block = parent!; // remember reference before un-linking.
unlink();
block.adjust();
}
}
/// Formats this line.
void _format(Style? newStyle) {
if (newStyle == null || newStyle.isEmpty) {
return;
}
applyStyle(newStyle);
final blockStyle = newStyle.getBlockExceptHeader();
if (blockStyle == null) {
return;
} // No block-level changes
if (parent is Block) {
final parentStyle = (parent as Block).style.getBlocksExceptHeader();
if (blockStyle.value == null) {
_unwrap();
} else if (!const MapEquality()
.equals(newStyle.getBlocksExceptHeader(), parentStyle)) {
_unwrap();
_applyBlockStyles(newStyle);
} // else the same style, no-op.
} else if (blockStyle.value != null) {
// Only wrap with a new block if this is not an unset
_applyBlockStyles(newStyle);
}
}
void _applyBlockStyles(Style newStyle) {
var block = Block();
for (final style in newStyle.getBlocksExceptHeader().values) {
block = block..applyAttribute(style);
}
_wrap(block);
block.adjust();
}
/// Wraps this line with new parent [block].
///
/// This line can not be in a [Block] when this method is called.
void _wrap(Block block) {
assert(parent != null && parent is! Block);
insertAfter(block);
unlink();
block.add(this);
}
/// Unwraps this line from it's parent [Block].
///
/// This method asserts if current [parent] of this line is not a [Block].
void _unwrap() {
if (parent is! Block) {
throw ArgumentError('Invalid parent');
}
final block = parent as Block;
assert(block.children.contains(this));
if (isFirst) {
unlink();
block.insertBefore(this);
} else if (isLast) {
unlink();
block.insertAfter(this);
} else {
final before = block.clone() as Block;
block.insertBefore(before);
var child = block.first as Line;
while (child != this) {
child.unlink();
before.add(child);
child = block.first as Line;
}
unlink();
block.insertBefore(this);
}
block.adjust();
}
Line _getNextLine(int index) {
assert(index == 0 || (index > 0 && index < length));
final line = clone() as Line;
insertAfter(line);
if (index == length - 1) {
return line;
}
final query = queryChild(index, false);
while (!query.node!.isLast) {
final next = (last as Leaf)..unlink();
line.addFirst(next);
}
final child = query.node as Leaf;
final cut = child.splitAt(query.offset);
cut?.unlink();
line.addFirst(cut);
return line;
}
void _insertSafe(int index, Object data, Style? style) {
assert(index == 0 || (index > 0 && index < length));
if (data is String) {
assert(!data.contains('\n'));
if (data.isEmpty) {
return;
}
}
if (isEmpty) {
final child = Leaf(data);
add(child);
child.format(style);
} else {
final result = queryChild(index, true);
result.node!.insert(result.offset, data, style);
}
}
/// Returns style for specified text range.
///
/// Only attributes applied to all characters within this range are
/// included in the result. Inline and line level attributes are
/// handled separately, e.g.:
///
/// - line attribute X is included in the result only if it exists for
/// every line within this range (partially included lines are counted).
/// - inline attribute X is included in the result only if it exists
/// for every character within this range (line-break characters excluded).
Style collectStyle(int offset, int len) {
final local = math.min(length - offset, len);
var result = Style();
final excluded = <Attribute>{};
void _handle(Style style) {
if (result.isEmpty) {
excluded.addAll(style.values);
} else {
for (final attr in result.values) {
if (!style.containsKey(attr.key)) {
excluded.add(attr);
}
}
}
final remaining = style.removeAll(excluded);
result = result.removeAll(excluded);
result = result.mergeAll(remaining);
}
final data = queryChild(offset, true);
var node = data.node as Leaf?;
if (node != null) {
result = result.mergeAll(node.style);
var pos = node.length - data.offset;
while (!node!.isLast && pos < local) {
node = node.next as Leaf?;
_handle(node!.style);
pos += node.length;
}
}
result = result.mergeAll(style);
if (parent is Block) {
final block = parent as Block;
result = result.mergeAll(block.style);
}
final remaining = len - local;
if (remaining > 0) {
final rest = nextLine!.collectStyle(0, remaining);
_handle(rest);
}
return result;
}
}
/// 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/models/documents/nodes/line.dart';

@ -1,134 +1,3 @@
import 'dart:collection';
import '../../quill_delta.dart';
import '../attribute.dart';
import '../style.dart';
import 'container.dart';
import 'line.dart';
/// An abstract node in a document tree.
///
/// Represents a segment of a Quill document with specified [offset]
/// and [length].
///
/// 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.
abstract class Node extends LinkedListEntry<Node> {
/// Current parent of this node. May be null if this node is not mounted.
Container? parent;
Style get style => _style;
Style _style = Style();
/// Returns `true` if this node is the first node in the [parent] list.
bool get isFirst => list!.first == this;
/// Returns `true` if this node is the last node in the [parent] list.
bool get isLast => list!.last == this;
/// Length of this node in characters.
int get length;
Node clone() => newInstance()..applyStyle(style);
/// Offset in characters of this node relative to [parent] node.
///
/// To get offset of this node in the document see [documentOffset].
int get offset {
var offset = 0;
if (list == null || isFirst) {
return offset;
}
var cur = this;
do {
cur = cur.previous!;
offset += cur.length;
} while (!cur.isFirst);
return offset;
}
/// Offset in characters of this node in the document.
int get documentOffset {
if (parent == null) {
return offset;
}
final parentOffset = (parent is! Root) ? parent!.documentOffset : 0;
return parentOffset + offset;
}
/// Returns `true` if this node contains character at specified [offset] in
/// the document.
bool containsOffset(int offset) {
final o = documentOffset;
return o <= offset && offset < o + length;
}
void applyAttribute(Attribute attribute) {
_style = _style.merge(attribute);
}
void applyStyle(Style value) {
_style = _style.mergeAll(value);
}
void clearStyle() {
_style = Style();
}
@override
void insertBefore(Node entry) {
assert(entry.parent == null && parent != null);
entry.parent = parent;
super.insertBefore(entry);
}
@override
void insertAfter(Node entry) {
assert(entry.parent == null && parent != null);
entry.parent = parent;
super.insertAfter(entry);
}
@override
void unlink() {
assert(parent != null);
parent = null;
super.unlink();
}
void adjust() {/* no-op */}
/// abstract methods begin
Node newInstance();
String toPlainText();
Delta toDelta();
void insert(int index, Object data, Style? style);
void retain(int index, int? len, Style? style);
void delete(int index, int? len);
/// abstract methods end
}
/// Root node of document tree.
class Root extends Container<Container<Node?>> {
@override
Node newInstance() => Root();
@override
Container<Node?> get defaultChild => Line();
@override
Delta toDelta() => children
.map((child) => child.toDelta())
.fold(Delta(), (a, b) => a.concat(b));
}
/// 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/models/documents/nodes/node.dart';

@ -1,127 +1,3 @@
import 'package:collection/collection.dart';
import 'package:quiver/core.dart';
import 'attribute.dart';
/* Collection of style attributes */
class Style {
Style() : _attributes = <String, Attribute>{};
Style.attr(this._attributes);
final Map<String, Attribute> _attributes;
static Style fromJson(Map<String, dynamic>? attributes) {
if (attributes == null) {
return Style();
}
final result = attributes.map((key, dynamic value) {
final attr = Attribute.fromKeyValue(key, value);
return MapEntry<String, Attribute>(key, attr);
});
return Style.attr(result);
}
Map<String, dynamic>? toJson() => _attributes.isEmpty
? null
: _attributes.map<String, dynamic>((_, attribute) =>
MapEntry<String, dynamic>(attribute.key, attribute.value));
Iterable<String> get keys => _attributes.keys;
Iterable<Attribute> get values => _attributes.values.sorted(
(a, b) => Attribute.getRegistryOrder(a) - Attribute.getRegistryOrder(b));
Map<String, Attribute> get attributes => _attributes;
bool get isEmpty => _attributes.isEmpty;
bool get isNotEmpty => _attributes.isNotEmpty;
bool get isInline => isNotEmpty && values.every((item) => item.isInline);
bool get isIgnored =>
isNotEmpty && values.every((item) => item.scope == AttributeScope.IGNORE);
Attribute get single => _attributes.values.single;
bool containsKey(String key) => _attributes.containsKey(key);
Attribute? getBlockExceptHeader() {
for (final val in values) {
if (val.isBlockExceptHeader && val.value != null) {
return val;
}
}
for (final val in values) {
if (val.isBlockExceptHeader) {
return val;
}
}
return null;
}
Map<String, Attribute> getBlocksExceptHeader() {
final m = <String, Attribute>{};
attributes.forEach((key, value) {
if (Attribute.blockKeysExceptHeader.contains(key)) {
m[key] = value;
}
});
return m;
}
Style merge(Attribute attribute) {
final merged = Map<String, Attribute>.from(_attributes);
if (attribute.value == null) {
merged.remove(attribute.key);
} else {
merged[attribute.key] = attribute;
}
return Style.attr(merged);
}
Style mergeAll(Style other) {
var result = Style.attr(_attributes);
for (final attribute in other.values) {
result = result.merge(attribute);
}
return result;
}
Style removeAll(Set<Attribute> attributes) {
final merged = Map<String, Attribute>.from(_attributes);
attributes.map((item) => item.key).forEach(merged.remove);
return Style.attr(merged);
}
Style put(Attribute attribute) {
final m = Map<String, Attribute>.from(attributes);
m[attribute.key] = attribute;
return Style.attr(m);
}
@override
bool operator ==(Object other) {
if (identical(this, other)) {
return true;
}
if (other is! Style) {
return false;
}
final typedOther = other;
const eq = MapEquality<String, Attribute>();
return eq.equals(_attributes, typedOther._attributes);
}
@override
int get hashCode {
final hashes =
_attributes.entries.map((entry) => hash2(entry.key, entry.value));
return hashObjects(hashes);
}
@override
String toString() => "{${_attributes.values.join(', ')}}";
}
/// 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/models/documents/style.dart';

@ -1,684 +1,3 @@
// Copyright (c) 2018, Anatoly Pulyaevskiy. All rights reserved. Use of this source code
// is governed by a BSD-style license that can be found in the LICENSE file.
/// Implementation of Quill Delta format in Dart.
library quill_delta;
import 'dart:math' as math;
import 'package:collection/collection.dart';
import 'package:quiver/core.dart';
const _attributeEquality = DeepCollectionEquality();
const _valueEquality = DeepCollectionEquality();
/// Decoder function to convert raw `data` object into a user-defined data type.
///
/// Useful with embedded content.
typedef DataDecoder = Object? Function(Object data);
/// Default data decoder which simply passes through the original value.
Object? _passThroughDataDecoder(Object? data) => data;
/// Operation performed on a rich-text document.
class Operation {
Operation._(this.key, this.length, this.data, Map? attributes)
: assert(_validKeys.contains(key), 'Invalid operation key "$key".'),
assert(() {
if (key != Operation.insertKey) return true;
return data is String ? data.length == length : length == 1;
}(), 'Length of insert operation must be equal to the data length.'),
_attributes =
attributes != null ? Map<String, dynamic>.from(attributes) : null;
/// Creates operation which deletes [length] of characters.
factory Operation.delete(int length) =>
Operation._(Operation.deleteKey, length, '', null);
/// Creates operation which inserts [text] with optional [attributes].
factory Operation.insert(dynamic data, [Map<String, dynamic>? attributes]) =>
Operation._(Operation.insertKey, data is String ? data.length : 1, data,
attributes);
/// Creates operation which retains [length] of characters and optionally
/// applies attributes.
factory Operation.retain(int? length, [Map<String, dynamic>? attributes]) =>
Operation._(Operation.retainKey, length, '', attributes);
/// Key of insert operations.
static const String insertKey = 'insert';
/// Key of delete operations.
static const String deleteKey = 'delete';
/// Key of retain operations.
static const String retainKey = 'retain';
/// Key of attributes collection.
static const String attributesKey = 'attributes';
static const List<String> _validKeys = [insertKey, deleteKey, retainKey];
/// Key of this operation, can be "insert", "delete" or "retain".
final String key;
/// Length of this operation.
final int? length;
/// Payload of "insert" operation, for other types is set to empty string.
final Object? data;
/// Rich-text attributes set by this operation, can be `null`.
Map<String, dynamic>? get attributes =>
_attributes == null ? null : Map<String, dynamic>.from(_attributes!);
final Map<String, dynamic>? _attributes;
/// Creates new [Operation] from JSON payload.
///
/// If `dataDecoder` parameter is not null then it is used to additionally
/// decode the operation's data object. Only applied to insert operations.
static Operation fromJson(Map data, {DataDecoder? dataDecoder}) {
dataDecoder ??= _passThroughDataDecoder;
final map = Map<String, dynamic>.from(data);
if (map.containsKey(Operation.insertKey)) {
final data = dataDecoder(map[Operation.insertKey]);
final dataLength = data is String ? data.length : 1;
return Operation._(
Operation.insertKey, dataLength, data, map[Operation.attributesKey]);
} else if (map.containsKey(Operation.deleteKey)) {
final int? length = map[Operation.deleteKey];
return Operation._(Operation.deleteKey, length, '', null);
} else if (map.containsKey(Operation.retainKey)) {
final int? length = map[Operation.retainKey];
return Operation._(
Operation.retainKey, length, '', map[Operation.attributesKey]);
}
throw ArgumentError.value(data, 'Invalid data for Delta operation.');
}
/// Returns JSON-serializable representation of this operation.
Map<String, dynamic> toJson() {
final json = {key: value};
if (_attributes != null) json[Operation.attributesKey] = attributes;
return json;
}
/// Returns value of this operation.
///
/// For insert operations this returns text, for delete and retain - length.
dynamic get value => (key == Operation.insertKey) ? data : length;
/// Returns `true` if this is a delete operation.
bool get isDelete => key == Operation.deleteKey;
/// Returns `true` if this is an insert operation.
bool get isInsert => key == Operation.insertKey;
/// Returns `true` if this is a retain operation.
bool get isRetain => key == Operation.retainKey;
/// Returns `true` if this operation has no attributes, e.g. is plain text.
bool get isPlain => _attributes == null || _attributes!.isEmpty;
/// Returns `true` if this operation sets at least one attribute.
bool get isNotPlain => !isPlain;
/// Returns `true` is this operation is empty.
///
/// An operation is considered empty if its [length] is equal to `0`.
bool get isEmpty => length == 0;
/// Returns `true` is this operation is not empty.
bool get isNotEmpty => length! > 0;
@override
bool operator ==(other) {
if (identical(this, other)) return true;
if (other is! Operation) return false;
final typedOther = other;
return key == typedOther.key &&
length == typedOther.length &&
_valueEquality.equals(data, typedOther.data) &&
hasSameAttributes(typedOther);
}
/// Returns `true` if this operation has attribute specified by [name].
bool hasAttribute(String name) =>
isNotPlain && _attributes!.containsKey(name);
/// Returns `true` if [other] operation has the same attributes as this one.
bool hasSameAttributes(Operation other) {
return _attributeEquality.equals(_attributes, other._attributes);
}
@override
int get hashCode {
if (_attributes != null && _attributes!.isNotEmpty) {
final attrsHash =
hashObjects(_attributes!.entries.map((e) => hash2(e.key, e.value)));
return hash3(key, value, attrsHash);
}
return hash2(key, value);
}
@override
String toString() {
final attr = attributes == null ? '' : ' + $attributes';
final text = isInsert
? (data is String
? (data as String).replaceAll('\n', '')
: data.toString())
: '$length';
return '$key$text$attr';
}
}
/// Delta represents a document or a modification of a document as a sequence of
/// insert, delete and retain operations.
///
/// Delta consisting of only "insert" operations is usually referred to as
/// "document delta". When delta includes also "retain" or "delete" operations
/// it is a "change delta".
class Delta {
/// Creates new empty [Delta].
factory Delta() => Delta._(<Operation>[]);
Delta._(List<Operation> operations) : _operations = operations;
/// Creates new [Delta] from [other].
factory Delta.from(Delta other) =>
Delta._(List<Operation>.from(other._operations));
/// Transforms two attribute sets.
static Map<String, dynamic>? transformAttributes(
Map<String, dynamic>? a, Map<String, dynamic>? b, bool priority) {
if (a == null) return b;
if (b == null) return null;
if (!priority) return b;
final result = b.keys.fold<Map<String, dynamic>>({}, (attributes, key) {
if (!a.containsKey(key)) attributes[key] = b[key];
return attributes;
});
return result.isEmpty ? null : result;
}
/// Composes two attribute sets.
static Map<String, dynamic>? composeAttributes(
Map<String, dynamic>? a, Map<String, dynamic>? b,
{bool keepNull = false}) {
a ??= const {};
b ??= const {};
final result = Map<String, dynamic>.from(a)..addAll(b);
final keys = result.keys.toList(growable: false);
if (!keepNull) {
for (final key in keys) {
if (result[key] == null) result.remove(key);
}
}
return result.isEmpty ? null : result;
}
///get anti-attr result base on base
static Map<String, dynamic> invertAttributes(
Map<String, dynamic>? attr, Map<String, dynamic>? base) {
attr ??= const {};
base ??= const {};
final baseInverted = base.keys.fold({}, (dynamic memo, key) {
if (base![key] != attr![key] && attr.containsKey(key)) {
memo[key] = base[key];
}
return memo;
});
final inverted =
Map<String, dynamic>.from(attr.keys.fold(baseInverted, (memo, key) {
if (base![key] != attr![key] && !base.containsKey(key)) {
memo[key] = null;
}
return memo;
}));
return inverted;
}
final List<Operation> _operations;
int _modificationCount = 0;
/// Creates [Delta] from de-serialized JSON representation.
///
/// If `dataDecoder` parameter is not null then it is used to additionally
/// decode the operation's data object. Only applied to insert operations.
static Delta fromJson(List data, {DataDecoder? dataDecoder}) {
return Delta._(data
.map((op) => Operation.fromJson(op, dataDecoder: dataDecoder))
.toList());
}
/// Returns list of operations in this delta.
List<Operation> toList() => List.from(_operations);
/// Returns JSON-serializable version of this delta.
List toJson() => toList().map((operation) => operation.toJson()).toList();
/// Returns `true` if this delta is empty.
bool get isEmpty => _operations.isEmpty;
/// Returns `true` if this delta is not empty.
bool get isNotEmpty => _operations.isNotEmpty;
/// Returns number of operations in this delta.
int get length => _operations.length;
/// Returns [Operation] at specified [index] in this delta.
Operation operator [](int index) => _operations[index];
/// Returns [Operation] at specified [index] in this delta.
Operation elementAt(int index) => _operations.elementAt(index);
/// Returns the first [Operation] in this delta.
Operation get first => _operations.first;
/// Returns the last [Operation] in this delta.
Operation get last => _operations.last;
@override
bool operator ==(dynamic other) {
if (identical(this, other)) return true;
if (other is! Delta) return false;
final typedOther = other;
const comparator = ListEquality<Operation>(DefaultEquality<Operation>());
return comparator.equals(_operations, typedOther._operations);
}
@override
int get hashCode => hashObjects(_operations);
/// Retain [count] of characters from current position.
void retain(int count, [Map<String, dynamic>? attributes]) {
assert(count >= 0);
if (count == 0) return; // no-op
push(Operation.retain(count, attributes));
}
/// Insert [data] at current position.
void insert(dynamic data, [Map<String, dynamic>? attributes]) {
if (data is String && data.isEmpty) return; // no-op
push(Operation.insert(data, attributes));
}
/// Delete [count] characters from current position.
void delete(int count) {
assert(count >= 0);
if (count == 0) return;
push(Operation.delete(count));
}
void _mergeWithTail(Operation operation) {
assert(isNotEmpty);
assert(last.key == operation.key);
assert(operation.data is String && last.data is String);
final length = operation.length! + last.length!;
final lastText = last.data as String;
final opText = operation.data as String;
final resultText = lastText + opText;
final index = _operations.length;
_operations.replaceRange(index - 1, index, [
Operation._(operation.key, length, resultText, operation.attributes),
]);
}
/// Pushes new operation into this delta.
///
/// Performs compaction by composing [operation] with current tail operation
/// of this delta, when possible. For instance, if current tail is
/// `insert('abc')` and pushed operation is `insert('123')` then existing
/// tail is replaced with `insert('abc123')` - a compound result of the two
/// operations.
void push(Operation operation) {
if (operation.isEmpty) return;
var index = _operations.length;
final lastOp = _operations.isNotEmpty ? _operations.last : null;
if (lastOp != null) {
if (lastOp.isDelete && operation.isDelete) {
_mergeWithTail(operation);
return;
}
if (lastOp.isDelete && operation.isInsert) {
index -= 1; // Always insert before deleting
final nLastOp = (index > 0) ? _operations.elementAt(index - 1) : null;
if (nLastOp == null) {
_operations.insert(0, operation);
return;
}
}
if (lastOp.isInsert && operation.isInsert) {
if (lastOp.hasSameAttributes(operation) &&
operation.data is String &&
lastOp.data is String) {
_mergeWithTail(operation);
return;
}
}
if (lastOp.isRetain && operation.isRetain) {
if (lastOp.hasSameAttributes(operation)) {
_mergeWithTail(operation);
return;
}
}
}
if (index == _operations.length) {
_operations.add(operation);
} else {
final opAtIndex = _operations.elementAt(index);
_operations.replaceRange(index, index + 1, [operation, opAtIndex]);
}
_modificationCount++;
}
/// Composes next operation from [thisIter] and [otherIter].
///
/// Returns new operation or `null` if operations from [thisIter] and
/// [otherIter] nullify each other. For instance, for the pair `insert('abc')`
/// and `delete(3)` composition result would be empty string.
Operation? _composeOperation(
DeltaIterator thisIter, DeltaIterator otherIter) {
if (otherIter.isNextInsert) return otherIter.next();
if (thisIter.isNextDelete) return thisIter.next();
final length = math.min(thisIter.peekLength(), otherIter.peekLength());
final thisOp = thisIter.next(length as int);
final otherOp = otherIter.next(length);
assert(thisOp.length == otherOp.length);
if (otherOp.isRetain) {
final attributes = composeAttributes(
thisOp.attributes,
otherOp.attributes,
keepNull: thisOp.isRetain,
);
if (thisOp.isRetain) {
return Operation.retain(thisOp.length, attributes);
} else if (thisOp.isInsert) {
return Operation.insert(thisOp.data, attributes);
} else {
throw StateError('Unreachable');
}
} else {
// otherOp == delete && thisOp in [retain, insert]
assert(otherOp.isDelete);
if (thisOp.isRetain) return otherOp;
assert(thisOp.isInsert);
// otherOp(delete) + thisOp(insert) => null
}
return null;
}
/// Composes this delta with [other] and returns new [Delta].
///
/// It is not required for this and [other] delta to represent a document
/// delta (consisting only of insert operations).
Delta compose(Delta other) {
final result = Delta();
final thisIter = DeltaIterator(this);
final otherIter = DeltaIterator(other);
while (thisIter.hasNext || otherIter.hasNext) {
final newOp = _composeOperation(thisIter, otherIter);
if (newOp != null) result.push(newOp);
}
return result..trim();
}
/// Transforms next operation from [otherIter] against next operation in
/// [thisIter].
///
/// Returns `null` if both operations nullify each other.
Operation? _transformOperation(
DeltaIterator thisIter, DeltaIterator otherIter, bool priority) {
if (thisIter.isNextInsert && (priority || !otherIter.isNextInsert)) {
return Operation.retain(thisIter.next().length);
} else if (otherIter.isNextInsert) {
return otherIter.next();
}
final length = math.min(thisIter.peekLength(), otherIter.peekLength());
final thisOp = thisIter.next(length as int);
final otherOp = otherIter.next(length);
assert(thisOp.length == otherOp.length);
// At this point only delete and retain operations are possible.
if (thisOp.isDelete) {
// otherOp is either delete or retain, so they nullify each other.
return null;
} else if (otherOp.isDelete) {
return otherOp;
} else {
// Retain otherOp which is either retain or insert.
return Operation.retain(
length,
transformAttributes(thisOp.attributes, otherOp.attributes, priority),
);
}
}
/// Transforms [other] delta against operations in this delta.
Delta transform(Delta other, bool priority) {
final result = Delta();
final thisIter = DeltaIterator(this);
final otherIter = DeltaIterator(other);
while (thisIter.hasNext || otherIter.hasNext) {
final newOp = _transformOperation(thisIter, otherIter, priority);
if (newOp != null) result.push(newOp);
}
return result..trim();
}
/// Removes trailing retain operation with empty attributes, if present.
void trim() {
if (isNotEmpty) {
final last = _operations.last;
if (last.isRetain && last.isPlain) _operations.removeLast();
}
}
/// Concatenates [other] with this delta and returns the result.
Delta concat(Delta other) {
final result = Delta.from(this);
if (other.isNotEmpty) {
// In case first operation of other can be merged with last operation in
// our list.
result.push(other._operations.first);
result._operations.addAll(other._operations.sublist(1));
}
return result;
}
/// Inverts this delta against [base].
///
/// Returns new delta which negates effect of this delta when applied to
/// [base]. This is an equivalent of "undo" operation on deltas.
Delta invert(Delta base) {
final inverted = Delta();
if (base.isEmpty) return inverted;
var baseIndex = 0;
for (final op in _operations) {
if (op.isInsert) {
inverted.delete(op.length!);
} else if (op.isRetain && op.isPlain) {
inverted.retain(op.length!);
baseIndex += op.length!;
} else if (op.isDelete || (op.isRetain && op.isNotPlain)) {
final length = op.length!;
final sliceDelta = base.slice(baseIndex, baseIndex + length);
sliceDelta.toList().forEach((baseOp) {
if (op.isDelete) {
inverted.push(baseOp);
} else if (op.isRetain && op.isNotPlain) {
final invertAttr =
invertAttributes(op.attributes, baseOp.attributes);
inverted.retain(
baseOp.length!, invertAttr.isEmpty ? null : invertAttr);
}
});
baseIndex += length;
} else {
throw StateError('Unreachable');
}
}
inverted.trim();
return inverted;
}
/// Returns slice of this delta from [start] index (inclusive) to [end]
/// (exclusive).
Delta slice(int start, [int? end]) {
final delta = Delta();
var index = 0;
final opIterator = DeltaIterator(this);
final actualEnd = end ?? double.infinity;
while (index < actualEnd && opIterator.hasNext) {
Operation op;
if (index < start) {
op = opIterator.next(start - index);
} else {
op = opIterator.next(actualEnd - index as int);
delta.push(op);
}
index += op.length!;
}
return delta;
}
/// Transforms [index] against this delta.
///
/// Any "delete" operation before specified [index] shifts it backward, as
/// well as any "insert" operation shifts it forward.
///
/// The [force] argument is used to resolve scenarios when there is an
/// insert operation at the same position as [index]. If [force] is set to
/// `true` (default) then position is forced to shift forward, otherwise
/// position stays at the same index. In other words setting [force] to
/// `false` gives higher priority to the transformed position.
///
/// Useful to adjust caret or selection positions.
int transformPosition(int index, {bool force = true}) {
final iter = DeltaIterator(this);
var offset = 0;
while (iter.hasNext && offset <= index) {
final op = iter.next();
if (op.isDelete) {
index -= math.min(op.length!, index - offset);
continue;
} else if (op.isInsert && (offset < index || force)) {
index += op.length!;
}
offset += op.length!;
}
return index;
}
@override
String toString() => _operations.join('\n');
}
/// Specialized iterator for [Delta]s.
class DeltaIterator {
DeltaIterator(this.delta) : _modificationCount = delta._modificationCount;
final Delta delta;
final int _modificationCount;
int _index = 0;
num _offset = 0;
bool get isNextInsert => nextOperationKey == Operation.insertKey;
bool get isNextDelete => nextOperationKey == Operation.deleteKey;
bool get isNextRetain => nextOperationKey == Operation.retainKey;
String? get nextOperationKey {
if (_index < delta.length) {
return delta.elementAt(_index).key;
} else {
return null;
}
}
bool get hasNext => peekLength() < double.infinity;
/// Returns length of next operation without consuming it.
///
/// Returns [double.infinity] if there is no more operations left to iterate.
num peekLength() {
if (_index < delta.length) {
final operation = delta._operations[_index];
return operation.length! - _offset;
}
return double.infinity;
}
/// Consumes and returns next operation.
///
/// Optional [length] specifies maximum length of operation to return. Note
/// that actual length of returned operation may be less than specified value.
Operation next([int length = 4294967296]) {
if (_modificationCount != delta._modificationCount) {
throw ConcurrentModificationError(delta);
}
if (_index < delta.length) {
final op = delta.elementAt(_index);
final opKey = op.key;
final opAttributes = op.attributes;
final _currentOffset = _offset;
final actualLength = math.min(op.length! - _currentOffset, length);
if (actualLength == op.length! - _currentOffset) {
_index++;
_offset = 0;
} else {
_offset += actualLength;
}
final opData = op.isInsert && op.data is String
? (op.data as String).substring(
_currentOffset as int, _currentOffset + (actualLength as int))
: op.data;
final opIsNotEmpty =
opData is String ? opData.isNotEmpty : true; // embeds are never empty
final opLength = opData is String ? opData.length : 1;
final opActualLength = opIsNotEmpty ? opLength : actualLength as int;
return Operation._(opKey, opActualLength, opData, opAttributes);
}
return Operation.retain(length);
}
/// Skips [length] characters in source delta.
///
/// Returns last skipped operation, or `null` if there was nothing to skip.
Operation? skip(int length) {
var skipped = 0;
Operation? op;
while (skipped < length && hasNext) {
final opLength = peekLength();
final skip = math.min(length - skipped, opLength);
op = next(skip as int);
skipped += op.length!;
}
return op;
}
}
/// 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/models/quill_delta.dart';

@ -1,124 +1,3 @@
import '../documents/attribute.dart';
import '../quill_delta.dart';
import 'rule.dart';
abstract class DeleteRule extends Rule {
const DeleteRule();
@override
RuleType get type => RuleType.DELETE;
@override
void validateArgs(int? len, Object? data, Attribute? attribute) {
assert(len != null);
assert(data == null);
assert(attribute == null);
}
}
class CatchAllDeleteRule extends DeleteRule {
const CatchAllDeleteRule();
@override
Delta applyRule(Delta document, int index,
{int? len, Object? data, Attribute? attribute}) {
return Delta()
..retain(index)
..delete(len!);
}
}
class PreserveLineStyleOnMergeRule extends DeleteRule {
const PreserveLineStyleOnMergeRule();
@override
Delta? applyRule(Delta document, int index,
{int? len, Object? data, Attribute? attribute}) {
final itr = DeltaIterator(document)..skip(index);
var op = itr.next(1);
if (op.data != '\n') {
return null;
}
final isNotPlain = op.isNotPlain;
final attrs = op.attributes;
itr.skip(len! - 1);
final delta = Delta()
..retain(index)
..delete(len);
while (itr.hasNext) {
op = itr.next();
final text = op.data is String ? (op.data as String?)! : '';
final lineBreak = text.indexOf('\n');
if (lineBreak == -1) {
delta.retain(op.length!);
continue;
}
var attributes = op.attributes == null
? null
: op.attributes!.map<String, dynamic>(
(key, dynamic value) => MapEntry<String, dynamic>(key, null));
if (isNotPlain) {
attributes ??= <String, dynamic>{};
attributes.addAll(attrs!);
}
delta..retain(lineBreak)..retain(1, attributes);
break;
}
return delta;
}
}
class EnsureEmbedLineRule extends DeleteRule {
const EnsureEmbedLineRule();
@override
Delta? applyRule(Delta document, int index,
{int? len, Object? data, Attribute? attribute}) {
final itr = DeltaIterator(document);
var op = itr.skip(index);
int? indexDelta = 0, lengthDelta = 0, remain = len;
var embedFound = op != null && op.data is! String;
final hasLineBreakBefore =
!embedFound && (op == null || (op.data as String).endsWith('\n'));
if (embedFound) {
var candidate = itr.next(1);
if (remain != null) {
remain--;
if (candidate.data == '\n') {
indexDelta++;
lengthDelta--;
candidate = itr.next(1);
remain--;
if (candidate.data == '\n') {
lengthDelta++;
}
}
}
}
op = itr.skip(remain!);
if (op != null &&
(op.data is String ? op.data as String? : '')!.endsWith('\n')) {
final candidate = itr.next(1);
if (candidate.data is! String && !hasLineBreakBefore) {
embedFound = true;
lengthDelta--;
}
}
if (!embedFound) {
return null;
}
return Delta()
..retain(index + indexDelta)
..delete(len! + lengthDelta);
}
}
/// 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/models/rules/delete.dart';

@ -1,132 +1,3 @@
import '../documents/attribute.dart';
import '../quill_delta.dart';
import 'rule.dart';
abstract class FormatRule extends Rule {
const FormatRule();
@override
RuleType get type => RuleType.FORMAT;
@override
void validateArgs(int? len, Object? data, Attribute? attribute) {
assert(len != null);
assert(data == null);
assert(attribute != null);
}
}
class ResolveLineFormatRule extends FormatRule {
const ResolveLineFormatRule();
@override
Delta? applyRule(Delta document, int index,
{int? len, Object? data, Attribute? attribute}) {
if (attribute!.scope != AttributeScope.BLOCK) {
return null;
}
var delta = 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!);
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);
}
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!);
continue;
}
delta..retain(lineBreak)..retain(1, attribute.toJson());
break;
}
return delta;
}
}
class FormatLinkAtCaretPositionRule extends FormatRule {
const FormatLinkAtCaretPositionRule();
@override
Delta? applyRule(Delta document, int index,
{int? len, Object? data, Attribute? attribute}) {
if (attribute!.key != Attribute.link.key || len! > 0) {
return null;
}
final delta = Delta();
final itr = DeltaIterator(document);
final before = itr.skip(index), after = itr.next();
int? beg = index, retain = 0;
if (before != null && before.hasAttribute(attribute.key)) {
beg -= before.length!;
retain = before.length;
}
if (after.hasAttribute(attribute.key)) {
if (retain != null) retain += after.length!;
}
if (retain == 0) {
return null;
}
delta..retain(beg)..retain(retain!, attribute.toJson());
return delta;
}
}
class ResolveInlineFormatRule extends FormatRule {
const ResolveInlineFormatRule();
@override
Delta? applyRule(Delta document, int index,
{int? len, Object? data, Attribute? attribute}) {
if (attribute!.scope != AttributeScope.INLINE) {
return null;
}
final delta = 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);
final text = op.data is String ? (op.data as String?)! : '';
var lineBreak = text.indexOf('\n');
if (lineBreak < 0) {
delta.retain(op.length!, attribute.toJson());
continue;
}
var pos = 0;
while (lineBreak >= 0) {
delta..retain(lineBreak - pos, attribute.toJson())..retain(1);
pos = lineBreak + 1;
lineBreak = text.indexOf('\n', pos);
}
if (pos < op.length!) {
delta.retain(op.length! - pos, attribute.toJson());
}
}
return delta;
}
}
/// 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/models/rules/format.dart';

@ -1,413 +1,3 @@
import 'package:tuple/tuple.dart';
import '../documents/attribute.dart';
import '../documents/style.dart';
import '../quill_delta.dart';
import 'rule.dart';
abstract class InsertRule extends Rule {
const InsertRule();
@override
RuleType get type => RuleType.INSERT;
@override
void validateArgs(int? len, Object? data, Attribute? attribute) {
assert(data != null);
assert(attribute == null);
}
}
class PreserveLineStyleOnSplitRule extends InsertRule {
const PreserveLineStyleOnSplitRule();
@override
Delta? applyRule(Delta document, int index,
{int? len, Object? data, Attribute? attribute}) {
if (data is! String || data != '\n') {
return null;
}
final itr = DeltaIterator(document);
final before = itr.skip(index);
if (before == null ||
before.data is! String ||
(before.data as String).endsWith('\n')) {
return null;
}
final after = itr.next();
if (after.data is! String || (after.data as String).startsWith('\n')) {
return null;
}
final text = after.data as String;
final delta = Delta()..retain(index + (len ?? 0));
if (text.contains('\n')) {
assert(after.isPlain);
delta.insert('\n');
return delta;
}
final nextNewLine = _getNextNewLine(itr);
final attributes = nextNewLine.item1?.attributes;
return delta..insert('\n', attributes);
}
}
/// Preserves block style when user inserts text containing newlines.
///
/// This rule handles:
///
/// * inserting a new line in a block
/// * pasting text containing multiple lines of text in a block
///
/// This rule may also be activated for changes triggered by auto-correct.
class PreserveBlockStyleOnInsertRule extends InsertRule {
const PreserveBlockStyleOnInsertRule();
@override
Delta? applyRule(Delta document, int index,
{int? len, Object? data, Attribute? attribute}) {
if (data is! String || !data.contains('\n')) {
// Only interested in text containing at least one newline character.
return null;
}
final itr = DeltaIterator(document)..skip(index);
// Look for the next newline.
final nextNewLine = _getNextNewLine(itr);
final lineStyle =
Style.fromJson(nextNewLine.item1?.attributes ?? <String, dynamic>{});
final blockStyle = lineStyle.getBlocksExceptHeader();
// Are we currently in a block? If not then ignore.
if (blockStyle.isEmpty) {
return null;
}
Map<String, dynamic>? resetStyle;
// 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();
}
// Go over each inserted line and ensure block style is applied.
final lines = data.split('\n');
final delta = Delta()..retain(index + (len ?? 0));
for (var i = 0; i < lines.length; i++) {
final line = lines[i];
if (line.isNotEmpty) {
delta.insert(line);
}
if (i == 0) {
// The first line should inherit the lineStyle entirely.
delta.insert('\n', lineStyle.toJson());
} else if (i < lines.length - 1) {
// we don't want to insert a newline after the last chunk of text, so -1
delta.insert('\n', blockStyle);
}
}
// Reset style of the original newline character if needed.
if (resetStyle != null) {
delta
..retain(nextNewLine.item2!)
..retain((nextNewLine.item1!.data as String).indexOf('\n'))
..retain(1, resetStyle);
}
return delta;
}
}
/// Heuristic rule to exit current block when user inserts two consecutive
/// newlines.
///
/// This rule is only applied when the cursor is on the last line of a block.
/// When the cursor is in the middle of a block we allow adding empty lines
/// and preserving the block's style.
class AutoExitBlockRule extends InsertRule {
const AutoExitBlockRule();
bool _isEmptyLine(Operation? before, Operation? after) {
if (before == null) {
return true;
}
return before.data is String &&
(before.data as String).endsWith('\n') &&
after!.data is String &&
(after.data as String).startsWith('\n');
}
@override
Delta? applyRule(Delta document, int index,
{int? len, Object? data, Attribute? attribute}) {
if (data is! String || data != '\n') {
return null;
}
final itr = DeltaIterator(document);
final prev = itr.skip(index), cur = itr.next();
final blockStyle = Style.fromJson(cur.attributes).getBlockExceptHeader();
// We are not in a block, ignore.
if (cur.isPlain || blockStyle == null) {
return null;
}
// We are not on an empty line, ignore.
if (!_isEmptyLine(prev, cur)) {
return null;
}
// We are on an empty line. Now we need to determine if we are on the
// last line of a block.
// First check if `cur` length is greater than 1, this would indicate
// that it contains multiple newline characters which share the same style.
// This would mean we are not on the last line yet.
// `cur.value as String` is safe since we already called isEmptyLine and know it contains a newline
if ((cur.value as String).length > 1) {
// We are not on the last line of this block, ignore.
return null;
}
// Keep looking for the next newline character to see if it shares the same
// block style as `cur`.
final nextNewLine = _getNextNewLine(itr);
if (nextNewLine.item1 != null &&
nextNewLine.item1!.attributes != null &&
Style.fromJson(nextNewLine.item1!.attributes).getBlockExceptHeader() ==
blockStyle) {
// We are not at the end of this block, ignore.
return null;
}
// Here we now know that the line after `cur` is not in the same block
// therefore we can exit this block.
final attributes = cur.attributes ?? <String, dynamic>{};
final k = attributes.keys
.firstWhere((k) => Attribute.blockKeysExceptHeader.contains(k));
attributes[k] = null;
// retain(1) should be '\n', set it with no attribute
return Delta()..retain(index + (len ?? 0))..retain(1, attributes);
}
}
class ResetLineFormatOnNewLineRule extends InsertRule {
const ResetLineFormatOnNewLineRule();
@override
Delta? applyRule(Delta document, int index,
{int? len, Object? data, Attribute? attribute}) {
if (data is! String || data != '\n') {
return null;
}
final itr = DeltaIterator(document)..skip(index);
final cur = itr.next();
if (cur.data is! String || !(cur.data as String).startsWith('\n')) {
return null;
}
Map<String, dynamic>? resetStyle;
if (cur.attributes != null &&
cur.attributes!.containsKey(Attribute.header.key)) {
resetStyle = Attribute.header.toJson();
}
return Delta()
..retain(index + (len ?? 0))
..insert('\n', cur.attributes)
..retain(1, resetStyle)
..trim();
}
}
class InsertEmbedsRule extends InsertRule {
const InsertEmbedsRule();
@override
Delta? applyRule(Delta document, int index,
{int? len, Object? data, Attribute? attribute}) {
if (data is String) {
return null;
}
final delta = Delta()..retain(index + (len ?? 0));
final itr = DeltaIterator(document);
final prev = itr.skip(index), cur = itr.next();
final textBefore = prev?.data is String ? prev!.data as String? : '';
final textAfter = cur.data is String ? (cur.data as String?)! : '';
final isNewlineBefore = prev == null || textBefore!.endsWith('\n');
final isNewlineAfter = textAfter.startsWith('\n');
if (isNewlineBefore && isNewlineAfter) {
return delta..insert(data);
}
Map<String, dynamic>? lineStyle;
if (textAfter.contains('\n')) {
lineStyle = cur.attributes;
} else {
while (itr.hasNext) {
final op = itr.next();
if ((op.data is String ? op.data as String? : '')!.contains('\n')) {
lineStyle = op.attributes;
break;
}
}
}
if (!isNewlineBefore) {
delta.insert('\n', lineStyle);
}
delta.insert(data);
if (!isNewlineAfter) {
delta.insert('\n');
}
return delta;
}
}
class ForceNewlineForInsertsAroundEmbedRule extends InsertRule {
const ForceNewlineForInsertsAroundEmbedRule();
@override
Delta? applyRule(Delta document, int index,
{int? len, Object? data, Attribute? attribute}) {
if (data is! String) {
return null;
}
final text = data;
final itr = DeltaIterator(document);
final prev = itr.skip(index);
final cur = itr.next();
final cursorBeforeEmbed = cur.data is! String;
final cursorAfterEmbed = prev != null && prev.data is! String;
if (!cursorBeforeEmbed && !cursorAfterEmbed) {
return null;
}
final delta = Delta()..retain(index + (len ?? 0));
if (cursorBeforeEmbed && !text.endsWith('\n')) {
return delta..insert(text)..insert('\n');
}
if (cursorAfterEmbed && !text.startsWith('\n')) {
return delta..insert('\n')..insert(text);
}
return delta..insert(text);
}
}
class AutoFormatLinksRule extends InsertRule {
const AutoFormatLinksRule();
@override
Delta? applyRule(Delta document, int index,
{int? len, Object? data, Attribute? attribute}) {
if (data is! String || data != ' ') {
return null;
}
final itr = DeltaIterator(document);
final prev = itr.skip(index);
if (prev == null || prev.data is! String) {
return null;
}
try {
final cand = (prev.data as String).split('\n').last.split(' ').last;
final link = Uri.parse(cand);
if (!['https', 'http'].contains(link.scheme)) {
return null;
}
final attributes = prev.attributes ?? <String, dynamic>{};
if (attributes.containsKey(Attribute.link.key)) {
return null;
}
attributes.addAll(LinkAttribute(link.toString()).toJson());
return Delta()
..retain(index + (len ?? 0) - cand.length)
..retain(cand.length, attributes)
..insert(data, prev.attributes);
} on FormatException {
return null;
}
}
}
class PreserveInlineStylesRule extends InsertRule {
const PreserveInlineStylesRule();
@override
Delta? applyRule(Delta document, int index,
{int? len, Object? data, Attribute? attribute}) {
if (data is! String || data.contains('\n')) {
return null;
}
final itr = DeltaIterator(document);
final prev = itr.skip(index);
if (prev == null ||
prev.data is! String ||
(prev.data as String).contains('\n')) {
return null;
}
final attributes = prev.attributes;
final text = data;
if (attributes == null || !attributes.containsKey(Attribute.link.key)) {
return Delta()
..retain(index + (len ?? 0))
..insert(text, attributes);
}
attributes.remove(Attribute.link.key);
final delta = Delta()
..retain(index + (len ?? 0))
..insert(text, attributes.isEmpty ? null : attributes);
final next = itr.next();
final nextAttributes = next.attributes ?? const <String, dynamic>{};
if (!nextAttributes.containsKey(Attribute.link.key)) {
return delta;
}
if (attributes[Attribute.link.key] == nextAttributes[Attribute.link.key]) {
return Delta()
..retain(index + (len ?? 0))
..insert(text, attributes);
}
return delta;
}
}
class CatchAllInsertRule extends InsertRule {
const CatchAllInsertRule();
@override
Delta applyRule(Delta document, int index,
{int? len, Object? data, Attribute? attribute}) {
return Delta()
..retain(index + (len ?? 0))
..insert(data);
}
}
Tuple2<Operation?, int?> _getNextNewLine(DeltaIterator iterator) {
Operation op;
for (var skipped = 0; iterator.hasNext; skipped += op.length!) {
op = iterator.next();
final lineBreak =
(op.data is String ? op.data as String? : '')!.indexOf('\n');
if (lineBreak >= 0) {
return Tuple2(op, skipped);
}
}
return const Tuple2(null, null);
}
/// 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/models/rules/insert.dart';

@ -1,77 +1,3 @@
import '../documents/attribute.dart';
import '../documents/document.dart';
import '../quill_delta.dart';
import 'delete.dart';
import 'format.dart';
import 'insert.dart';
enum RuleType { INSERT, DELETE, FORMAT }
abstract class Rule {
const Rule();
Delta? apply(Delta document, int index,
{int? len, Object? data, Attribute? attribute}) {
validateArgs(len, data, attribute);
return applyRule(document, index,
len: len, data: data, attribute: attribute);
}
void validateArgs(int? len, Object? data, Attribute? attribute);
Delta? applyRule(Delta document, int index,
{int? len, Object? data, Attribute? attribute});
RuleType get type;
}
class Rules {
Rules(this._rules);
List<Rule> _customRules = [];
final List<Rule> _rules;
static final Rules _instance = Rules([
const FormatLinkAtCaretPositionRule(),
const ResolveLineFormatRule(),
const ResolveInlineFormatRule(),
const InsertEmbedsRule(),
const ForceNewlineForInsertsAroundEmbedRule(),
const AutoExitBlockRule(),
const PreserveBlockStyleOnInsertRule(),
const PreserveLineStyleOnSplitRule(),
const ResetLineFormatOnNewLineRule(),
const AutoFormatLinksRule(),
const PreserveInlineStylesRule(),
const CatchAllInsertRule(),
const EnsureEmbedLineRule(),
const PreserveLineStyleOnMergeRule(),
const CatchAllDeleteRule(),
]);
static Rules getInstance() => _instance;
void setCustomRules(List<Rule> customRules) {
_customRules = customRules;
}
Delta apply(RuleType ruleType, Document document, int index,
{int? len, Object? data, Attribute? attribute}) {
final delta = document.toDelta();
for (final rule in _customRules + _rules) {
if (rule.type != ruleType) {
continue;
}
try {
final result = rule.apply(delta, index,
len: len, data: data, attribute: attribute);
if (result != null) {
return result..trim();
}
} catch (e) {
rethrow;
}
}
throw 'Apply rules failed';
}
}
/// 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/models/rules/rule.dart';

@ -0,0 +1,292 @@
import 'dart:collection';
import 'package:quiver/core.dart';
enum AttributeScope {
INLINE, // refer to https://quilljs.com/docs/formats/#inline
BLOCK, // refer to https://quilljs.com/docs/formats/#block
EMBEDS, // refer to https://quilljs.com/docs/formats/#embeds
IGNORE, // attributes that can be ignored
}
class Attribute<T> {
Attribute(this.key, this.scope, this.value);
final String key;
final AttributeScope scope;
final T value;
static final Map<String, Attribute> _registry = LinkedHashMap.of({
Attribute.bold.key: Attribute.bold,
Attribute.italic.key: Attribute.italic,
Attribute.underline.key: Attribute.underline,
Attribute.strikeThrough.key: Attribute.strikeThrough,
Attribute.font.key: Attribute.font,
Attribute.size.key: Attribute.size,
Attribute.link.key: Attribute.link,
Attribute.color.key: Attribute.color,
Attribute.background.key: Attribute.background,
Attribute.placeholder.key: Attribute.placeholder,
Attribute.header.key: Attribute.header,
Attribute.align.key: Attribute.align,
Attribute.list.key: Attribute.list,
Attribute.codeBlock.key: Attribute.codeBlock,
Attribute.blockQuote.key: Attribute.blockQuote,
Attribute.indent.key: Attribute.indent,
Attribute.width.key: Attribute.width,
Attribute.height.key: Attribute.height,
Attribute.style.key: Attribute.style,
Attribute.token.key: Attribute.token,
});
static final BoldAttribute bold = BoldAttribute();
static final ItalicAttribute italic = ItalicAttribute();
static final UnderlineAttribute underline = UnderlineAttribute();
static final StrikeThroughAttribute strikeThrough = StrikeThroughAttribute();
static final FontAttribute font = FontAttribute(null);
static final SizeAttribute size = SizeAttribute(null);
static final LinkAttribute link = LinkAttribute(null);
static final ColorAttribute color = ColorAttribute(null);
static final BackgroundAttribute background = BackgroundAttribute(null);
static final PlaceholderAttribute placeholder = PlaceholderAttribute();
static final HeaderAttribute header = HeaderAttribute();
static final IndentAttribute indent = IndentAttribute();
static final AlignAttribute align = AlignAttribute(null);
static final ListAttribute list = ListAttribute(null);
static final CodeBlockAttribute codeBlock = CodeBlockAttribute();
static final BlockQuoteAttribute blockQuote = BlockQuoteAttribute();
static final WidthAttribute width = WidthAttribute(null);
static final HeightAttribute height = HeightAttribute(null);
static final StyleAttribute style = StyleAttribute(null);
static final TokenAttribute token = TokenAttribute('');
static final Set<String> inlineKeys = {
Attribute.bold.key,
Attribute.italic.key,
Attribute.underline.key,
Attribute.strikeThrough.key,
Attribute.link.key,
Attribute.color.key,
Attribute.background.key,
Attribute.placeholder.key,
};
static final Set<String> blockKeys = LinkedHashSet.of({
Attribute.header.key,
Attribute.align.key,
Attribute.list.key,
Attribute.codeBlock.key,
Attribute.blockQuote.key,
Attribute.indent.key,
});
static final Set<String> blockKeysExceptHeader = LinkedHashSet.of({
Attribute.list.key,
Attribute.align.key,
Attribute.codeBlock.key,
Attribute.blockQuote.key,
Attribute.indent.key,
});
static Attribute<int?> get h1 => HeaderAttribute(level: 1);
static Attribute<int?> get h2 => HeaderAttribute(level: 2);
static Attribute<int?> get h3 => HeaderAttribute(level: 3);
// "attributes":{"align":"left"}
static Attribute<String?> get leftAlignment => AlignAttribute('left');
// "attributes":{"align":"center"}
static Attribute<String?> get centerAlignment => AlignAttribute('center');
// "attributes":{"align":"right"}
static Attribute<String?> get rightAlignment => AlignAttribute('right');
// "attributes":{"align":"justify"}
static Attribute<String?> get justifyAlignment => AlignAttribute('justify');
// "attributes":{"list":"bullet"}
static Attribute<String?> get ul => ListAttribute('bullet');
// "attributes":{"list":"ordered"}
static Attribute<String?> get ol => ListAttribute('ordered');
// "attributes":{"list":"checked"}
static Attribute<String?> get checked => ListAttribute('checked');
// "attributes":{"list":"unchecked"}
static Attribute<String?> get unchecked => ListAttribute('unchecked');
// "attributes":{"indent":1"}
static Attribute<int?> get indentL1 => IndentAttribute(level: 1);
// "attributes":{"indent":2"}
static Attribute<int?> get indentL2 => IndentAttribute(level: 2);
// "attributes":{"indent":3"}
static Attribute<int?> get indentL3 => IndentAttribute(level: 3);
static Attribute<int?> getIndentLevel(int? level) {
if (level == 1) {
return indentL1;
}
if (level == 2) {
return indentL2;
}
if (level == 3) {
return indentL3;
}
return IndentAttribute(level: level);
}
bool get isInline => scope == AttributeScope.INLINE;
bool get isBlockExceptHeader => blockKeysExceptHeader.contains(key);
Map<String, dynamic> toJson() => <String, dynamic>{key: value};
static Attribute fromKeyValue(String key, dynamic value) {
if (!_registry.containsKey(key)) {
throw ArgumentError.value(key, 'key "$key" not found.');
}
final origin = _registry[key]!;
final attribute = clone(origin, value);
return attribute;
}
static int getRegistryOrder(Attribute attribute) {
var order = 0;
for (final attr in _registry.values) {
if (attr.key == attribute.key) {
break;
}
order++;
}
return order;
}
static Attribute clone(Attribute origin, dynamic value) {
return Attribute(origin.key, origin.scope, value);
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
if (other is! Attribute) return false;
final typedOther = other;
return key == typedOther.key &&
scope == typedOther.scope &&
value == typedOther.value;
}
@override
int get hashCode => hash3(key, scope, value);
@override
String toString() {
return 'Attribute{key: $key, scope: $scope, value: $value}';
}
}
class BoldAttribute extends Attribute<bool> {
BoldAttribute() : super('bold', AttributeScope.INLINE, true);
}
class ItalicAttribute extends Attribute<bool> {
ItalicAttribute() : super('italic', AttributeScope.INLINE, true);
}
class UnderlineAttribute extends Attribute<bool> {
UnderlineAttribute() : super('underline', AttributeScope.INLINE, true);
}
class StrikeThroughAttribute extends Attribute<bool> {
StrikeThroughAttribute() : super('strike', AttributeScope.INLINE, true);
}
class FontAttribute extends Attribute<String?> {
FontAttribute(String? val) : super('font', AttributeScope.INLINE, val);
}
class SizeAttribute extends Attribute<String?> {
SizeAttribute(String? val) : super('size', AttributeScope.INLINE, val);
}
class LinkAttribute extends Attribute<String?> {
LinkAttribute(String? val) : super('link', AttributeScope.INLINE, val);
}
class ColorAttribute extends Attribute<String?> {
ColorAttribute(String? val) : super('color', AttributeScope.INLINE, val);
}
class BackgroundAttribute extends Attribute<String?> {
BackgroundAttribute(String? val)
: super('background', AttributeScope.INLINE, val);
}
/// This is custom attribute for hint
class PlaceholderAttribute extends Attribute<bool> {
PlaceholderAttribute() : super('placeholder', AttributeScope.INLINE, true);
}
class HeaderAttribute extends Attribute<int?> {
HeaderAttribute({int? level}) : super('header', AttributeScope.BLOCK, level);
}
class IndentAttribute extends Attribute<int?> {
IndentAttribute({int? level}) : super('indent', AttributeScope.BLOCK, level);
}
class AlignAttribute extends Attribute<String?> {
AlignAttribute(String? val) : super('align', AttributeScope.BLOCK, val);
}
class ListAttribute extends Attribute<String?> {
ListAttribute(String? val) : super('list', AttributeScope.BLOCK, val);
}
class CodeBlockAttribute extends Attribute<bool> {
CodeBlockAttribute() : super('code-block', AttributeScope.BLOCK, true);
}
class BlockQuoteAttribute extends Attribute<bool> {
BlockQuoteAttribute() : super('blockquote', AttributeScope.BLOCK, true);
}
class WidthAttribute extends Attribute<String?> {
WidthAttribute(String? val) : super('width', AttributeScope.IGNORE, val);
}
class HeightAttribute extends Attribute<String?> {
HeightAttribute(String? val) : super('height', AttributeScope.IGNORE, val);
}
class StyleAttribute extends Attribute<String?> {
StyleAttribute(String? val) : super('style', AttributeScope.IGNORE, val);
}
class TokenAttribute extends Attribute<String> {
TokenAttribute(String val) : super('token', AttributeScope.IGNORE, val);
}

@ -0,0 +1,290 @@
import 'dart:async';
import 'package:tuple/tuple.dart';
import '../quill_delta.dart';
import '../rules/rule.dart';
import 'attribute.dart';
import 'history.dart';
import 'nodes/block.dart';
import 'nodes/container.dart';
import 'nodes/embed.dart';
import 'nodes/line.dart';
import 'nodes/node.dart';
import 'style.dart';
/// The rich text document
class Document {
Document() : _delta = Delta()..insert('\n') {
_loadDocument(_delta);
}
Document.fromJson(List data) : _delta = _transform(Delta.fromJson(data)) {
_loadDocument(_delta);
}
Document.fromDelta(Delta delta) : _delta = delta {
_loadDocument(delta);
}
/// The root node of the document tree
final Root _root = Root();
Root get root => _root;
int get length => _root.length;
Delta _delta;
Delta toDelta() => Delta.from(_delta);
final Rules _rules = Rules.getInstance();
void setCustomRules(List<Rule> customRules) {
_rules.setCustomRules(customRules);
}
final StreamController<Tuple3<Delta, Delta, ChangeSource>> _observer =
StreamController.broadcast();
final History _history = History();
Stream<Tuple3<Delta, Delta, ChangeSource>> get changes => _observer.stream;
Delta insert(int index, Object? data,
{int replaceLength = 0, bool autoAppendNewlineAfterImage = true}) {
assert(index >= 0);
assert(data is String || data is Embeddable);
if (data is Embeddable) {
data = data.toJson();
} else if ((data as String).isEmpty) {
return Delta();
}
final delta = _rules.apply(RuleType.INSERT, this, index,
data: data, len: replaceLength);
compose(delta, ChangeSource.LOCAL,
autoAppendNewlineAfterImage: autoAppendNewlineAfterImage);
return delta;
}
Delta delete(int index, int len) {
assert(index >= 0 && len > 0);
final delta = _rules.apply(RuleType.DELETE, this, index, len: len);
if (delta.isNotEmpty) {
compose(delta, ChangeSource.LOCAL);
}
return delta;
}
Delta replace(int index, int len, Object? data,
{bool autoAppendNewlineAfterImage = true}) {
assert(index >= 0);
assert(data is String || data is Embeddable);
final dataIsNotEmpty = (data is String) ? data.isNotEmpty : true;
assert(dataIsNotEmpty || len > 0);
var delta = Delta();
// We have to insert before applying delete rules
// Otherwise delete would be operating on stale document snapshot.
if (dataIsNotEmpty) {
delta = insert(index, data,
replaceLength: len,
autoAppendNewlineAfterImage: autoAppendNewlineAfterImage);
}
if (len > 0) {
final deleteDelta = delete(index, len);
delta = delta.compose(deleteDelta);
}
return delta;
}
Delta format(int index, int len, Attribute? attribute) {
assert(index >= 0 && len >= 0 && attribute != null);
var delta = Delta();
final formatDelta = _rules.apply(RuleType.FORMAT, this, index,
len: len, attribute: attribute);
if (formatDelta.isNotEmpty) {
compose(formatDelta, ChangeSource.LOCAL);
delta = delta.compose(formatDelta);
}
return delta;
}
Style collectStyle(int index, int len) {
final res = queryChild(index);
return (res.node as Line).collectStyle(res.offset, len);
}
ChildQuery queryChild(int offset) {
final res = _root.queryChild(offset, true);
if (res.node is Line) {
return res;
}
final block = res.node as Block;
return block.queryChild(res.offset, true);
}
void compose(Delta delta, ChangeSource changeSource,
{bool autoAppendNewlineAfterImage = true}) {
assert(!_observer.isClosed);
delta.trim();
assert(delta.isNotEmpty);
var offset = 0;
delta = _transform(delta,
autoAppendNewlineAfterImage: autoAppendNewlineAfterImage);
final originalDelta = toDelta();
for (final op in delta.toList()) {
final style =
op.attributes != null ? Style.fromJson(op.attributes) : null;
if (op.isInsert) {
_root.insert(offset, _normalize(op.data), style);
} else if (op.isDelete) {
_root.delete(offset, op.length);
} else if (op.attributes != null) {
_root.retain(offset, op.length, style);
}
if (!op.isDelete) {
offset += op.length!;
}
}
try {
_delta = _delta.compose(delta);
} catch (e) {
throw '_delta compose failed';
}
if (_delta != _root.toDelta()) {
throw 'Compose failed';
}
final change = Tuple3(originalDelta, delta, changeSource);
_observer.add(change);
_history.handleDocChange(change);
}
Tuple2 undo() {
return _history.undo(this);
}
Tuple2 redo() {
return _history.redo(this);
}
bool get hasUndo => _history.hasUndo;
bool get hasRedo => _history.hasRedo;
static Delta _transform(Delta delta,
{bool autoAppendNewlineAfterImage = true}) {
final res = Delta();
final ops = delta.toList();
for (var i = 0; i < ops.length; i++) {
final op = ops[i];
res.push(op);
if (autoAppendNewlineAfterImage) {
_autoAppendNewlineAfterImage(i, ops, op, res);
}
}
return res;
}
static void _autoAppendNewlineAfterImage(
int i, List<Operation> ops, Operation op, Delta res) {
final nextOpIsImage =
i + 1 < ops.length && ops[i + 1].isInsert && ops[i + 1].data is! String;
if (nextOpIsImage &&
op.data is String &&
(op.data as String).isNotEmpty &&
!(op.data as String).endsWith('\n')) {
res.push(Operation.insert('\n'));
}
// Currently embed is equivalent to image and hence `is! String`
final opInsertImage = op.isInsert && op.data is! String;
final nextOpIsLineBreak = i + 1 < ops.length &&
ops[i + 1].isInsert &&
ops[i + 1].data is String &&
(ops[i + 1].data as String).startsWith('\n');
if (opInsertImage && (i + 1 == ops.length - 1 || !nextOpIsLineBreak)) {
// automatically append '\n' for image
res.push(Operation.insert('\n'));
}
}
Object _normalize(Object? data) {
if (data is String) {
return data;
}
if (data is Embeddable) {
return data;
}
return Embeddable.fromJson(data as Map<String, dynamic>);
}
void close() {
_observer.close();
_history.clear();
}
String toPlainText() => _root.children.map((e) => e.toPlainText()).join();
void _loadDocument(Delta doc) {
if (doc.isEmpty) {
throw ArgumentError.value(doc, 'Document Delta cannot be empty.');
}
assert((doc.last.data as String).endsWith('\n'));
var offset = 0;
for (final op in doc.toList()) {
if (!op.isInsert) {
throw ArgumentError.value(doc,
'Document Delta can only contain insert operations but ${op.key} found.');
}
final style =
op.attributes != null ? Style.fromJson(op.attributes) : null;
final data = _normalize(op.data);
_root.insert(offset, data, style);
offset += op.length!;
}
final node = _root.last;
if (node is Line &&
node.parent is! Block &&
node.style.isEmpty &&
_root.childCount > 1) {
_root.remove(node);
}
}
bool isEmpty() {
if (root.children.length != 1) {
return false;
}
final node = root.children.first;
if (!node.isLast) {
return false;
}
final delta = node.toDelta();
return delta.length == 1 &&
delta.first.data == '\n' &&
delta.first.key == 'insert';
}
}
enum ChangeSource {
LOCAL,
REMOTE,
}

@ -0,0 +1,134 @@
import 'package:tuple/tuple.dart';
import '../quill_delta.dart';
import 'document.dart';
class History {
History({
this.ignoreChange = false,
this.interval = 400,
this.maxStack = 100,
this.userOnly = false,
this.lastRecorded = 0,
});
final HistoryStack stack = HistoryStack.empty();
bool get hasUndo => stack.undo.isNotEmpty;
bool get hasRedo => stack.redo.isNotEmpty;
/// used for disable redo or undo function
bool ignoreChange;
int lastRecorded;
/// Collaborative editing's conditions should be true
final bool userOnly;
///max operation count for undo
final int maxStack;
///record delay
final int interval;
void handleDocChange(Tuple3<Delta, Delta, ChangeSource> change) {
if (ignoreChange) return;
if (!userOnly || change.item3 == ChangeSource.LOCAL) {
record(change.item2, change.item1);
} else {
transform(change.item2);
}
}
void clear() {
stack.clear();
}
void record(Delta change, Delta before) {
if (change.isEmpty) return;
stack.redo.clear();
var undoDelta = change.invert(before);
final timeStamp = DateTime.now().millisecondsSinceEpoch;
if (lastRecorded + interval > timeStamp && stack.undo.isNotEmpty) {
final lastDelta = stack.undo.removeLast();
undoDelta = undoDelta.compose(lastDelta);
} else {
lastRecorded = timeStamp;
}
if (undoDelta.isEmpty) return;
stack.undo.add(undoDelta);
if (stack.undo.length > maxStack) {
stack.undo.removeAt(0);
}
}
///
///It will override pre local undo delta,replaced by remote change
///
void transform(Delta delta) {
transformStack(stack.undo, delta);
transformStack(stack.redo, delta);
}
void transformStack(List<Delta> stack, Delta delta) {
for (var i = stack.length - 1; i >= 0; i -= 1) {
final oldDelta = stack[i];
stack[i] = delta.transform(oldDelta, true);
delta = oldDelta.transform(delta, false);
if (stack[i].length == 0) {
stack.removeAt(i);
}
}
}
Tuple2 _change(Document doc, List<Delta> source, List<Delta> dest) {
if (source.isEmpty) {
return const Tuple2(false, 0);
}
final delta = source.removeLast();
// look for insert or delete
int? len = 0;
final ops = delta.toList();
for (var i = 0; i < ops.length; i++) {
if (ops[i].key == Operation.insertKey) {
len = ops[i].length;
} else if (ops[i].key == Operation.deleteKey) {
len = ops[i].length! * -1;
}
}
final base = Delta.from(doc.toDelta());
final inverseDelta = delta.invert(base);
dest.add(inverseDelta);
lastRecorded = 0;
ignoreChange = true;
doc.compose(delta, ChangeSource.LOCAL);
ignoreChange = false;
return Tuple2(true, len);
}
Tuple2 undo(Document doc) {
return _change(doc, stack.undo, stack.redo);
}
Tuple2 redo(Document doc) {
return _change(doc, stack.redo, stack.undo);
}
}
class HistoryStack {
HistoryStack.empty()
: undo = [],
redo = [];
final List<Delta> undo;
final List<Delta> redo;
void clear() {
undo.clear();
redo.clear();
}
}

@ -0,0 +1,72 @@
import '../../quill_delta.dart';
import 'container.dart';
import 'line.dart';
import 'node.dart';
/// Represents a group of adjacent [Line]s with the same block style.
///
/// Block elements are:
/// - Blockquote
/// - Header
/// - Indent
/// - List
/// - Text Alignment
/// - Text Direction
/// - Code Block
class Block extends Container<Line?> {
/// Creates new unmounted [Block].
@override
Node newInstance() => Block();
@override
Line get defaultChild => Line();
@override
Delta toDelta() {
return children
.map((child) => child.toDelta())
.fold(Delta(), (a, b) => a.concat(b));
}
@override
void adjust() {
if (isEmpty) {
final sibling = previous;
unlink();
if (sibling != null) {
sibling.adjust();
}
return;
}
var block = this;
final prev = block.previous;
// merging it with previous block if style is the same
if (!block.isFirst &&
block.previous is Block &&
prev!.style == block.style) {
block
..moveChildToNewParent(prev as Container<Node?>?)
..unlink();
block = prev as Block;
}
final next = block.next;
// merging it with next block if style is the same
if (!block.isLast && block.next is Block && next!.style == block.style) {
(next as Block).moveChildToNewParent(block);
next.unlink();
}
}
@override
String toString() {
final block = style.attributes.toString();
final buffer = StringBuffer('§ {$block}\n');
for (final child in children) {
final tree = child.isLast ? '' : '';
buffer.write(' $tree $child');
if (!child.isLast) buffer.writeln();
}
return buffer.toString();
}
}

@ -0,0 +1,160 @@
import 'dart:collection';
import '../style.dart';
import 'leaf.dart';
import 'line.dart';
import 'node.dart';
/// Container can accommodate other nodes.
///
/// Delegates insert, retain and delete operations to children nodes. For each
/// operation container looks for a child at specified index position and
/// forwards operation to that child.
///
/// Most of the operation handling logic is implemented by [Line] and [Text].
abstract class Container<T extends Node?> extends Node {
final LinkedList<Node> _children = LinkedList<Node>();
/// List of children.
LinkedList<Node> get children => _children;
/// Returns total number of child nodes in this container.
///
/// To get text length of this container see [length].
int get childCount => _children.length;
/// Returns the first child [Node].
Node get first => _children.first;
/// Returns the last child [Node].
Node get last => _children.last;
/// Returns `true` if this container has no child nodes.
bool get isEmpty => _children.isEmpty;
/// Returns `true` if this container has at least 1 child.
bool get isNotEmpty => _children.isNotEmpty;
/// Returns an instance of default child for this container node.
///
/// Always returns fresh instance.
T get defaultChild;
/// Adds [node] to the end of this container children list.
void add(T node) {
assert(node?.parent == null);
node?.parent = this;
_children.add(node as Node);
}
/// Adds [node] to the beginning of this container children list.
void addFirst(T node) {
assert(node?.parent == null);
node?.parent = this;
_children.addFirst(node as Node);
}
/// Removes [node] from this container.
void remove(T node) {
assert(node?.parent == this);
node?.parent = null;
_children.remove(node as Node);
}
/// Moves children of this node to [newParent].
void moveChildToNewParent(Container? newParent) {
if (isEmpty) {
return;
}
final last = newParent!.isEmpty ? null : newParent.last as T?;
while (isNotEmpty) {
final child = first as T;
child?.unlink();
newParent.add(child);
}
/// In case [newParent] already had children we need to make sure
/// combined list is optimized.
if (last != null) last.adjust();
}
/// Queries the child [Node] at specified character [offset] in this container.
///
/// The result may contain the found node or `null` if no node is found
/// at specified offset.
///
/// [ChildQuery.offset] is set to relative offset within returned child node
/// which points at the same character position in the document as the
/// original [offset].
ChildQuery queryChild(int offset, bool inclusive) {
if (offset < 0 || offset > length) {
return ChildQuery(null, 0);
}
for (final node in children) {
final len = node.length;
if (offset < len || (inclusive && offset == len && (node.isLast))) {
return ChildQuery(node, offset);
}
offset -= len;
}
return ChildQuery(null, 0);
}
@override
String toPlainText() => children.map((child) => child.toPlainText()).join();
/// Content length of this node's children.
///
/// To get number of children in this node use [childCount].
@override
int get length => _children.fold(0, (cur, node) => cur + node.length);
@override
void insert(int index, Object data, Style? style) {
assert(index == 0 || (index > 0 && index < length));
if (isNotEmpty) {
final child = queryChild(index, false);
child.node!.insert(child.offset, data, style);
return;
}
// empty
assert(index == 0);
final node = defaultChild;
add(node);
node?.insert(index, data, style);
}
@override
void retain(int index, int? length, Style? attributes) {
assert(isNotEmpty);
final child = queryChild(index, false);
child.node!.retain(child.offset, length, attributes);
}
@override
void delete(int index, int? length) {
assert(isNotEmpty);
final child = queryChild(index, false);
child.node!.delete(child.offset, length);
}
@override
String toString() => _children.join('\n');
}
/// Result of a child query in a [Container].
class ChildQuery {
ChildQuery(this.node, this.offset);
/// The child node if found, otherwise `null`.
final Node? node;
/// Starting offset within the child [node] which points at the same
/// character in the document as the original offset passed to
/// [Container.queryChild] method.
final int offset;
}

@ -0,0 +1,40 @@
/// An object which can be embedded into a Quill document.
///
/// See also:
///
/// * [BlockEmbed] which represents a block embed.
class Embeddable {
Embeddable(this.type, this.data);
/// The type of this object.
final String type;
/// The data payload of this object.
final dynamic data;
Map<String, dynamic> toJson() {
final m = <String, String>{type: data};
return m;
}
static Embeddable fromJson(Map<String, dynamic> json) {
final m = Map<String, dynamic>.from(json);
assert(m.length == 1, 'Embeddable map has one key');
return BlockEmbed(m.keys.first, m.values.first);
}
}
/// An object which occupies an entire line in a document and cannot co-exist
/// inline with regular text.
///
/// There are two built-in embed types supported by Quill documents, however
/// the document model itself does not make any assumptions about the types
/// of embedded objects and allows users to define their own types.
class BlockEmbed extends Embeddable {
BlockEmbed(String type, String data) : super(type, data);
static BlockEmbed horizontalRule = BlockEmbed('divider', 'hr');
static BlockEmbed image(String imageUrl) => BlockEmbed('image', imageUrl);
}

@ -0,0 +1,252 @@
import 'dart:math' as math;
import '../../quill_delta.dart';
import '../style.dart';
import 'embed.dart';
import 'line.dart';
import 'node.dart';
/// A leaf in Quill document tree.
abstract class Leaf extends Node {
/// Creates a new [Leaf] with specified [data].
factory Leaf(Object data) {
if (data is Embeddable) {
return Embed(data);
}
final text = data as String;
assert(text.isNotEmpty);
return Text(text);
}
Leaf.val(Object val) : _value = val;
/// Contents of this node, either a String if this is a [Text] or an
/// [Embed] if this is an [BlockEmbed].
Object get value => _value;
Object _value;
@override
void applyStyle(Style value) {
assert(value.isInline || value.isIgnored || value.isEmpty,
'Unable to apply Style to leaf: $value');
super.applyStyle(value);
}
@override
Line? get parent => super.parent as Line?;
@override
int get length {
if (_value is String) {
return (_value as String).length;
}
// return 1 for embedded object
return 1;
}
@override
Delta toDelta() {
final data =
_value is Embeddable ? (_value as Embeddable).toJson() : _value;
return Delta()..insert(data, style.toJson());
}
@override
void insert(int index, Object data, Style? style) {
assert(index >= 0 && index <= length);
final node = Leaf(data);
if (index < length) {
splitAt(index)!.insertBefore(node);
} else {
insertAfter(node);
}
node.format(style);
}
@override
void retain(int index, int? len, Style? style) {
if (style == null) {
return;
}
final local = math.min(length - index, len!);
final remain = len - local;
final node = _isolate(index, local);
if (remain > 0) {
assert(node.next != null);
node.next!.retain(0, remain, style);
}
node.format(style);
}
@override
void delete(int index, int? len) {
assert(index < length);
final local = math.min(length - index, len!);
final target = _isolate(index, local);
final prev = target.previous as Leaf?;
final next = target.next as Leaf?;
target.unlink();
final remain = len - local;
if (remain > 0) {
assert(next != null);
next!.delete(0, remain);
}
if (prev != null) {
prev.adjust();
}
}
/// Adjust this text node by merging it with adjacent nodes if they share
/// the same style.
@override
void adjust() {
if (this is Embed) {
// Embed nodes cannot be merged with text nor other embeds (in fact,
// there could be no two adjacent embeds on the same line since an
// embed occupies an entire line).
return;
}
// This is a text node and it can only be merged with other text nodes.
var node = this as Text;
// Merging it with previous node if style is the same.
final prev = node.previous;
if (!node.isFirst && prev is Text && prev.style == node.style) {
prev._value = prev.value + node.value;
node.unlink();
node = prev;
}
// Merging it with next node if style is the same.
final next = node.next;
if (!node.isLast && next is Text && next.style == node.style) {
node._value = node.value + next.value;
next.unlink();
}
}
/// Splits this leaf node at [index] and returns new node.
///
/// If this is the last node in its list and [index] equals this node's
/// length then this method returns `null` as there is nothing left to split.
/// If there is another leaf node after this one and [index] equals this
/// node's length then the next leaf node is returned.
///
/// If [index] equals to `0` then this node itself is returned unchanged.
///
/// In case a new node is actually split from this one, it inherits this
/// node's style.
Leaf? splitAt(int index) {
assert(index >= 0 && index <= length);
if (index == 0) {
return this;
}
if (index == length) {
return isLast ? null : next as Leaf?;
}
assert(this is Text);
final text = _value as String;
_value = text.substring(0, index);
final split = Leaf(text.substring(index))..applyStyle(style);
insertAfter(split);
return split;
}
/// Cuts a leaf from [index] to the end of this node and returns new node
/// in detached state (e.g. [mounted] returns `false`).
///
/// Splitting logic is identical to one described in [splitAt], meaning this
/// method may return `null`.
Leaf? cutAt(int index) {
assert(index >= 0 && index <= length);
final cut = splitAt(index);
cut?.unlink();
return cut;
}
/// Formats this node and optimizes it with adjacent leaf nodes if needed.
void format(Style? style) {
if (style != null && style.isNotEmpty) {
applyStyle(style);
}
adjust();
}
/// Isolates a new leaf starting at [index] with specified [length].
///
/// Splitting logic is identical to one described in [splitAt], with one
/// exception that it is required for [index] to always be less than this
/// node's length. As a result this method always returns a [LeafNode]
/// instance. Returned node may still be the same as this node
/// if provided [index] is `0`.
Leaf _isolate(int index, int length) {
assert(
index >= 0 && index < this.length && (index + length <= this.length));
final target = splitAt(index)!..splitAt(length);
return target;
}
}
/// A span of formatted text within a line in a Quill document.
///
/// Text is a leaf node of a document tree.
///
/// Parent of a text node is always a [Line], and as a consequence text
/// node's [value] cannot contain any line-break characters.
///
/// See also:
///
/// * [Embed], a leaf node representing an embeddable object.
/// * [Line], a node representing a line of text.
class Text extends Leaf {
Text([String text = ''])
: assert(!text.contains('\n')),
super.val(text);
@override
Node newInstance() => Text();
@override
String get value => _value as String;
@override
String toPlainText() => value;
}
/// An embed node inside of a line in a Quill document.
///
/// Embed node is a leaf node similar to [Text]. It represents an arbitrary
/// piece of non-textual content embedded into a document, such as, image,
/// horizontal rule, video, or any other object with defined structure,
/// like a tweet, for instance.
///
/// Embed node's length is always `1` character and it is represented with
/// unicode object replacement character in the document text.
///
/// Any inline style can be applied to an embed, however this does not
/// necessarily mean the embed will look according to that style. For instance,
/// applying "bold" style to an image gives no effect, while adding a "link" to
/// an image actually makes the image react to user's action.
class Embed extends Leaf {
Embed(Embeddable data) : super.val(data);
static const kObjectReplacementCharacter = '\uFFFC';
@override
Node newInstance() => throw UnimplementedError();
@override
Embeddable get value => super.value as Embeddable;
/// // Embed nodes are represented as unicode object replacement character in
// plain text.
@override
String toPlainText() => kObjectReplacementCharacter;
}

@ -0,0 +1,371 @@
import 'dart:math' as math;
import 'package:collection/collection.dart';
import '../../quill_delta.dart';
import '../attribute.dart';
import '../style.dart';
import 'block.dart';
import 'container.dart';
import 'embed.dart';
import 'leaf.dart';
import 'node.dart';
/// A line of rich text in a Quill document.
///
/// Line serves as a container for [Leaf]s, like [Text] and [Embed].
///
/// When a line contains an embed, it fully occupies the line, no other embeds
/// or text nodes are allowed.
class Line extends Container<Leaf?> {
@override
Leaf get defaultChild => Text();
@override
int get length => super.length + 1;
/// Returns `true` if this line contains an embedded object.
bool get hasEmbed {
if (childCount != 1) {
return false;
}
return children.single is Embed;
}
/// Returns next [Line] or `null` if this is the last line in the document.
Line? get nextLine {
if (!isLast) {
return next is Block ? (next as Block).first as Line? : next as Line?;
}
if (parent is! Block) {
return null;
}
if (parent!.isLast) {
return null;
}
return parent!.next is Block
? (parent!.next as Block).first as Line?
: parent!.next as Line?;
}
@override
Node newInstance() => Line();
@override
Delta toDelta() {
final delta = children
.map((child) => child.toDelta())
.fold(Delta(), (dynamic a, b) => a.concat(b));
var attributes = style;
if (parent is Block) {
final block = parent as Block;
attributes = attributes.mergeAll(block.style);
}
delta.insert('\n', attributes.toJson());
return delta;
}
@override
String toPlainText() => '${super.toPlainText()}\n';
@override
String toString() {
final body = children.join('');
final styleString = style.isNotEmpty ? ' $style' : '';
return '$body$styleString';
}
@override
void insert(int index, Object data, Style? style) {
if (data is Embeddable) {
// We do not check whether this line already has any children here as
// inserting an embed into a line with other text is acceptable from the
// Delta format perspective.
// We rely on heuristic rules to ensure that embeds occupy an entire line.
_insertSafe(index, data, style);
return;
}
final text = data as String;
final lineBreak = text.indexOf('\n');
if (lineBreak < 0) {
_insertSafe(index, text, style);
// No need to update line or block format since those attributes can only
// be attached to `\n` character and we already know it's not present.
return;
}
final prefix = text.substring(0, lineBreak);
_insertSafe(index, prefix, style);
if (prefix.isNotEmpty) {
index += prefix.length;
}
// Next line inherits our format.
final nextLine = _getNextLine(index);
// Reset our format and unwrap from a block if needed.
clearStyle();
if (parent is Block) {
_unwrap();
}
// Now we can apply new format and re-layout.
_format(style);
// Continue with remaining part.
final remain = text.substring(lineBreak + 1);
nextLine.insert(0, remain, style);
}
@override
void retain(int index, int? len, Style? style) {
if (style == null) {
return;
}
final thisLength = length;
final local = math.min(thisLength - index, len!);
// If index is at newline character then this is a line/block style update.
final isLineFormat = (index + local == thisLength) && local == 1;
if (isLineFormat) {
assert(style.values.every((attr) => attr.scope == AttributeScope.BLOCK),
'It is not allowed to apply inline attributes to line itself.');
_format(style);
} else {
// Otherwise forward to children as it's an inline format update.
assert(style.values.every((attr) => attr.scope == AttributeScope.INLINE));
assert(index + local != thisLength);
super.retain(index, local, style);
}
final remain = len - local;
if (remain > 0) {
assert(nextLine != null);
nextLine!.retain(0, remain, style);
}
}
@override
void delete(int index, int? len) {
final local = math.min(length - index, len!);
final isLFDeleted = index + local == length; // Line feed
if (isLFDeleted) {
// Our newline character deleted with all style information.
clearStyle();
if (local > 1) {
// Exclude newline character from delete range for children.
super.delete(index, local - 1);
}
} else {
super.delete(index, local);
}
final remaining = len - local;
if (remaining > 0) {
assert(nextLine != null);
nextLine!.delete(0, remaining);
}
if (isLFDeleted && isNotEmpty) {
// Since we lost our line-break and still have child text nodes those must
// migrate to the next line.
// nextLine might have been unmounted since last assert so we need to
// check again we still have a line after us.
assert(nextLine != null);
// Move remaining children in this line to the next line so that all
// attributes of nextLine are preserved.
nextLine!.moveChildToNewParent(this);
moveChildToNewParent(nextLine);
}
if (isLFDeleted) {
// Now we can remove this line.
final block = parent!; // remember reference before un-linking.
unlink();
block.adjust();
}
}
/// Formats this line.
void _format(Style? newStyle) {
if (newStyle == null || newStyle.isEmpty) {
return;
}
applyStyle(newStyle);
final blockStyle = newStyle.getBlockExceptHeader();
if (blockStyle == null) {
return;
} // No block-level changes
if (parent is Block) {
final parentStyle = (parent as Block).style.getBlocksExceptHeader();
if (blockStyle.value == null) {
_unwrap();
} else if (!const MapEquality()
.equals(newStyle.getBlocksExceptHeader(), parentStyle)) {
_unwrap();
_applyBlockStyles(newStyle);
} // else the same style, no-op.
} else if (blockStyle.value != null) {
// Only wrap with a new block if this is not an unset
_applyBlockStyles(newStyle);
}
}
void _applyBlockStyles(Style newStyle) {
var block = Block();
for (final style in newStyle.getBlocksExceptHeader().values) {
block = block..applyAttribute(style);
}
_wrap(block);
block.adjust();
}
/// Wraps this line with new parent [block].
///
/// This line can not be in a [Block] when this method is called.
void _wrap(Block block) {
assert(parent != null && parent is! Block);
insertAfter(block);
unlink();
block.add(this);
}
/// Unwraps this line from it's parent [Block].
///
/// This method asserts if current [parent] of this line is not a [Block].
void _unwrap() {
if (parent is! Block) {
throw ArgumentError('Invalid parent');
}
final block = parent as Block;
assert(block.children.contains(this));
if (isFirst) {
unlink();
block.insertBefore(this);
} else if (isLast) {
unlink();
block.insertAfter(this);
} else {
final before = block.clone() as Block;
block.insertBefore(before);
var child = block.first as Line;
while (child != this) {
child.unlink();
before.add(child);
child = block.first as Line;
}
unlink();
block.insertBefore(this);
}
block.adjust();
}
Line _getNextLine(int index) {
assert(index == 0 || (index > 0 && index < length));
final line = clone() as Line;
insertAfter(line);
if (index == length - 1) {
return line;
}
final query = queryChild(index, false);
while (!query.node!.isLast) {
final next = (last as Leaf)..unlink();
line.addFirst(next);
}
final child = query.node as Leaf;
final cut = child.splitAt(query.offset);
cut?.unlink();
line.addFirst(cut);
return line;
}
void _insertSafe(int index, Object data, Style? style) {
assert(index == 0 || (index > 0 && index < length));
if (data is String) {
assert(!data.contains('\n'));
if (data.isEmpty) {
return;
}
}
if (isEmpty) {
final child = Leaf(data);
add(child);
child.format(style);
} else {
final result = queryChild(index, true);
result.node!.insert(result.offset, data, style);
}
}
/// Returns style for specified text range.
///
/// Only attributes applied to all characters within this range are
/// included in the result. Inline and line level attributes are
/// handled separately, e.g.:
///
/// - line attribute X is included in the result only if it exists for
/// every line within this range (partially included lines are counted).
/// - inline attribute X is included in the result only if it exists
/// for every character within this range (line-break characters excluded).
Style collectStyle(int offset, int len) {
final local = math.min(length - offset, len);
var result = Style();
final excluded = <Attribute>{};
void _handle(Style style) {
if (result.isEmpty) {
excluded.addAll(style.values);
} else {
for (final attr in result.values) {
if (!style.containsKey(attr.key)) {
excluded.add(attr);
}
}
}
final remaining = style.removeAll(excluded);
result = result.removeAll(excluded);
result = result.mergeAll(remaining);
}
final data = queryChild(offset, true);
var node = data.node as Leaf?;
if (node != null) {
result = result.mergeAll(node.style);
var pos = node.length - data.offset;
while (!node!.isLast && pos < local) {
node = node.next as Leaf?;
_handle(node!.style);
pos += node.length;
}
}
result = result.mergeAll(style);
if (parent is Block) {
final block = parent as Block;
result = result.mergeAll(block.style);
}
final remaining = len - local;
if (remaining > 0) {
final rest = nextLine!.collectStyle(0, remaining);
_handle(rest);
}
return result;
}
}

@ -0,0 +1,131 @@
import 'dart:collection';
import '../../quill_delta.dart';
import '../attribute.dart';
import '../style.dart';
import 'container.dart';
import 'line.dart';
/// An abstract node in a document tree.
///
/// Represents a segment of a Quill document with specified [offset]
/// and [length].
///
/// 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.
abstract class Node extends LinkedListEntry<Node> {
/// Current parent of this node. May be null if this node is not mounted.
Container? parent;
Style get style => _style;
Style _style = Style();
/// Returns `true` if this node is the first node in the [parent] list.
bool get isFirst => list!.first == this;
/// Returns `true` if this node is the last node in the [parent] list.
bool get isLast => list!.last == this;
/// Length of this node in characters.
int get length;
Node clone() => newInstance()..applyStyle(style);
/// Offset in characters of this node relative to [parent] node.
///
/// To get offset of this node in the document see [documentOffset].
int get offset {
var offset = 0;
if (list == null || isFirst) {
return offset;
}
var cur = this;
do {
cur = cur.previous!;
offset += cur.length;
} while (!cur.isFirst);
return offset;
}
/// Offset in characters of this node in the document.
int get documentOffset {
final parentOffset = (parent is! Root) ? parent!.documentOffset : 0;
return parentOffset + offset;
}
/// Returns `true` if this node contains character at specified [offset] in
/// the document.
bool containsOffset(int offset) {
final o = documentOffset;
return o <= offset && offset < o + length;
}
void applyAttribute(Attribute attribute) {
_style = _style.merge(attribute);
}
void applyStyle(Style value) {
_style = _style.mergeAll(value);
}
void clearStyle() {
_style = Style();
}
@override
void insertBefore(Node entry) {
assert(entry.parent == null && parent != null);
entry.parent = parent;
super.insertBefore(entry);
}
@override
void insertAfter(Node entry) {
assert(entry.parent == null && parent != null);
entry.parent = parent;
super.insertAfter(entry);
}
@override
void unlink() {
assert(parent != null);
parent = null;
super.unlink();
}
void adjust() {/* no-op */}
/// abstract methods begin
Node newInstance();
String toPlainText();
Delta toDelta();
void insert(int index, Object data, Style? style);
void retain(int index, int? len, Style? style);
void delete(int index, int? len);
/// abstract methods end
}
/// Root node of document tree.
class Root extends Container<Container<Node?>> {
@override
Node newInstance() => Root();
@override
Container<Node?> get defaultChild => Line();
@override
Delta toDelta() => children
.map((child) => child.toDelta())
.fold(Delta(), (a, b) => a.concat(b));
}

@ -0,0 +1,127 @@
import 'package:collection/collection.dart';
import 'package:quiver/core.dart';
import 'attribute.dart';
/* Collection of style attributes */
class Style {
Style() : _attributes = <String, Attribute>{};
Style.attr(this._attributes);
final Map<String, Attribute> _attributes;
static Style fromJson(Map<String, dynamic>? attributes) {
if (attributes == null) {
return Style();
}
final result = attributes.map((key, dynamic value) {
final attr = Attribute.fromKeyValue(key, value);
return MapEntry<String, Attribute>(key, attr);
});
return Style.attr(result);
}
Map<String, dynamic>? toJson() => _attributes.isEmpty
? null
: _attributes.map<String, dynamic>((_, attribute) =>
MapEntry<String, dynamic>(attribute.key, attribute.value));
Iterable<String> get keys => _attributes.keys;
Iterable<Attribute> get values => _attributes.values.sorted(
(a, b) => Attribute.getRegistryOrder(a) - Attribute.getRegistryOrder(b));
Map<String, Attribute> get attributes => _attributes;
bool get isEmpty => _attributes.isEmpty;
bool get isNotEmpty => _attributes.isNotEmpty;
bool get isInline => isNotEmpty && values.every((item) => item.isInline);
bool get isIgnored =>
isNotEmpty && values.every((item) => item.scope == AttributeScope.IGNORE);
Attribute get single => _attributes.values.single;
bool containsKey(String key) => _attributes.containsKey(key);
Attribute? getBlockExceptHeader() {
for (final val in values) {
if (val.isBlockExceptHeader && val.value != null) {
return val;
}
}
for (final val in values) {
if (val.isBlockExceptHeader) {
return val;
}
}
return null;
}
Map<String, Attribute> getBlocksExceptHeader() {
final m = <String, Attribute>{};
attributes.forEach((key, value) {
if (Attribute.blockKeysExceptHeader.contains(key)) {
m[key] = value;
}
});
return m;
}
Style merge(Attribute attribute) {
final merged = Map<String, Attribute>.from(_attributes);
if (attribute.value == null) {
merged.remove(attribute.key);
} else {
merged[attribute.key] = attribute;
}
return Style.attr(merged);
}
Style mergeAll(Style other) {
var result = Style.attr(_attributes);
for (final attribute in other.values) {
result = result.merge(attribute);
}
return result;
}
Style removeAll(Set<Attribute> attributes) {
final merged = Map<String, Attribute>.from(_attributes);
attributes.map((item) => item.key).forEach(merged.remove);
return Style.attr(merged);
}
Style put(Attribute attribute) {
final m = Map<String, Attribute>.from(attributes);
m[attribute.key] = attribute;
return Style.attr(m);
}
@override
bool operator ==(Object other) {
if (identical(this, other)) {
return true;
}
if (other is! Style) {
return false;
}
final typedOther = other;
const eq = MapEquality<String, Attribute>();
return eq.equals(_attributes, typedOther._attributes);
}
@override
int get hashCode {
final hashes =
_attributes.entries.map((entry) => hash2(entry.key, entry.value));
return hashObjects(hashes);
}
@override
String toString() => "{${_attributes.values.join(', ')}}";
}

@ -0,0 +1,684 @@
// Copyright (c) 2018, Anatoly Pulyaevskiy. All rights reserved. Use of this source code
// is governed by a BSD-style license that can be found in the LICENSE file.
/// Implementation of Quill Delta format in Dart.
library quill_delta;
import 'dart:math' as math;
import 'package:collection/collection.dart';
import 'package:quiver/core.dart';
const _attributeEquality = DeepCollectionEquality();
const _valueEquality = DeepCollectionEquality();
/// Decoder function to convert raw `data` object into a user-defined data type.
///
/// Useful with embedded content.
typedef DataDecoder = Object? Function(Object data);
/// Default data decoder which simply passes through the original value.
Object? _passThroughDataDecoder(Object? data) => data;
/// Operation performed on a rich-text document.
class Operation {
Operation._(this.key, this.length, this.data, Map? attributes)
: assert(_validKeys.contains(key), 'Invalid operation key "$key".'),
assert(() {
if (key != Operation.insertKey) return true;
return data is String ? data.length == length : length == 1;
}(), 'Length of insert operation must be equal to the data length.'),
_attributes =
attributes != null ? Map<String, dynamic>.from(attributes) : null;
/// Creates operation which deletes [length] of characters.
factory Operation.delete(int length) =>
Operation._(Operation.deleteKey, length, '', null);
/// Creates operation which inserts [text] with optional [attributes].
factory Operation.insert(dynamic data, [Map<String, dynamic>? attributes]) =>
Operation._(Operation.insertKey, data is String ? data.length : 1, data,
attributes);
/// Creates operation which retains [length] of characters and optionally
/// applies attributes.
factory Operation.retain(int? length, [Map<String, dynamic>? attributes]) =>
Operation._(Operation.retainKey, length, '', attributes);
/// Key of insert operations.
static const String insertKey = 'insert';
/// Key of delete operations.
static const String deleteKey = 'delete';
/// Key of retain operations.
static const String retainKey = 'retain';
/// Key of attributes collection.
static const String attributesKey = 'attributes';
static const List<String> _validKeys = [insertKey, deleteKey, retainKey];
/// Key of this operation, can be "insert", "delete" or "retain".
final String key;
/// Length of this operation.
final int? length;
/// Payload of "insert" operation, for other types is set to empty string.
final Object? data;
/// Rich-text attributes set by this operation, can be `null`.
Map<String, dynamic>? get attributes =>
_attributes == null ? null : Map<String, dynamic>.from(_attributes!);
final Map<String, dynamic>? _attributes;
/// Creates new [Operation] from JSON payload.
///
/// If `dataDecoder` parameter is not null then it is used to additionally
/// decode the operation's data object. Only applied to insert operations.
static Operation fromJson(Map data, {DataDecoder? dataDecoder}) {
dataDecoder ??= _passThroughDataDecoder;
final map = Map<String, dynamic>.from(data);
if (map.containsKey(Operation.insertKey)) {
final data = dataDecoder(map[Operation.insertKey]);
final dataLength = data is String ? data.length : 1;
return Operation._(
Operation.insertKey, dataLength, data, map[Operation.attributesKey]);
} else if (map.containsKey(Operation.deleteKey)) {
final int? length = map[Operation.deleteKey];
return Operation._(Operation.deleteKey, length, '', null);
} else if (map.containsKey(Operation.retainKey)) {
final int? length = map[Operation.retainKey];
return Operation._(
Operation.retainKey, length, '', map[Operation.attributesKey]);
}
throw ArgumentError.value(data, 'Invalid data for Delta operation.');
}
/// Returns JSON-serializable representation of this operation.
Map<String, dynamic> toJson() {
final json = {key: value};
if (_attributes != null) json[Operation.attributesKey] = attributes;
return json;
}
/// Returns value of this operation.
///
/// For insert operations this returns text, for delete and retain - length.
dynamic get value => (key == Operation.insertKey) ? data : length;
/// Returns `true` if this is a delete operation.
bool get isDelete => key == Operation.deleteKey;
/// Returns `true` if this is an insert operation.
bool get isInsert => key == Operation.insertKey;
/// Returns `true` if this is a retain operation.
bool get isRetain => key == Operation.retainKey;
/// Returns `true` if this operation has no attributes, e.g. is plain text.
bool get isPlain => _attributes == null || _attributes!.isEmpty;
/// Returns `true` if this operation sets at least one attribute.
bool get isNotPlain => !isPlain;
/// Returns `true` is this operation is empty.
///
/// An operation is considered empty if its [length] is equal to `0`.
bool get isEmpty => length == 0;
/// Returns `true` is this operation is not empty.
bool get isNotEmpty => length! > 0;
@override
bool operator ==(other) {
if (identical(this, other)) return true;
if (other is! Operation) return false;
final typedOther = other;
return key == typedOther.key &&
length == typedOther.length &&
_valueEquality.equals(data, typedOther.data) &&
hasSameAttributes(typedOther);
}
/// Returns `true` if this operation has attribute specified by [name].
bool hasAttribute(String name) =>
isNotPlain && _attributes!.containsKey(name);
/// Returns `true` if [other] operation has the same attributes as this one.
bool hasSameAttributes(Operation other) {
return _attributeEquality.equals(_attributes, other._attributes);
}
@override
int get hashCode {
if (_attributes != null && _attributes!.isNotEmpty) {
final attrsHash =
hashObjects(_attributes!.entries.map((e) => hash2(e.key, e.value)));
return hash3(key, value, attrsHash);
}
return hash2(key, value);
}
@override
String toString() {
final attr = attributes == null ? '' : ' + $attributes';
final text = isInsert
? (data is String
? (data as String).replaceAll('\n', '')
: data.toString())
: '$length';
return '$key$text$attr';
}
}
/// Delta represents a document or a modification of a document as a sequence of
/// insert, delete and retain operations.
///
/// Delta consisting of only "insert" operations is usually referred to as
/// "document delta". When delta includes also "retain" or "delete" operations
/// it is a "change delta".
class Delta {
/// Creates new empty [Delta].
factory Delta() => Delta._(<Operation>[]);
Delta._(List<Operation> operations) : _operations = operations;
/// Creates new [Delta] from [other].
factory Delta.from(Delta other) =>
Delta._(List<Operation>.from(other._operations));
/// Transforms two attribute sets.
static Map<String, dynamic>? transformAttributes(
Map<String, dynamic>? a, Map<String, dynamic>? b, bool priority) {
if (a == null) return b;
if (b == null) return null;
if (!priority) return b;
final result = b.keys.fold<Map<String, dynamic>>({}, (attributes, key) {
if (!a.containsKey(key)) attributes[key] = b[key];
return attributes;
});
return result.isEmpty ? null : result;
}
/// Composes two attribute sets.
static Map<String, dynamic>? composeAttributes(
Map<String, dynamic>? a, Map<String, dynamic>? b,
{bool keepNull = false}) {
a ??= const {};
b ??= const {};
final result = Map<String, dynamic>.from(a)..addAll(b);
final keys = result.keys.toList(growable: false);
if (!keepNull) {
for (final key in keys) {
if (result[key] == null) result.remove(key);
}
}
return result.isEmpty ? null : result;
}
///get anti-attr result base on base
static Map<String, dynamic> invertAttributes(
Map<String, dynamic>? attr, Map<String, dynamic>? base) {
attr ??= const {};
base ??= const {};
final baseInverted = base.keys.fold({}, (dynamic memo, key) {
if (base![key] != attr![key] && attr.containsKey(key)) {
memo[key] = base[key];
}
return memo;
});
final inverted =
Map<String, dynamic>.from(attr.keys.fold(baseInverted, (memo, key) {
if (base![key] != attr![key] && !base.containsKey(key)) {
memo[key] = null;
}
return memo;
}));
return inverted;
}
final List<Operation> _operations;
int _modificationCount = 0;
/// Creates [Delta] from de-serialized JSON representation.
///
/// If `dataDecoder` parameter is not null then it is used to additionally
/// decode the operation's data object. Only applied to insert operations.
static Delta fromJson(List data, {DataDecoder? dataDecoder}) {
return Delta._(data
.map((op) => Operation.fromJson(op, dataDecoder: dataDecoder))
.toList());
}
/// Returns list of operations in this delta.
List<Operation> toList() => List.from(_operations);
/// Returns JSON-serializable version of this delta.
List toJson() => toList().map((operation) => operation.toJson()).toList();
/// Returns `true` if this delta is empty.
bool get isEmpty => _operations.isEmpty;
/// Returns `true` if this delta is not empty.
bool get isNotEmpty => _operations.isNotEmpty;
/// Returns number of operations in this delta.
int get length => _operations.length;
/// Returns [Operation] at specified [index] in this delta.
Operation operator [](int index) => _operations[index];
/// Returns [Operation] at specified [index] in this delta.
Operation elementAt(int index) => _operations.elementAt(index);
/// Returns the first [Operation] in this delta.
Operation get first => _operations.first;
/// Returns the last [Operation] in this delta.
Operation get last => _operations.last;
@override
bool operator ==(dynamic other) {
if (identical(this, other)) return true;
if (other is! Delta) return false;
final typedOther = other;
const comparator = ListEquality<Operation>(DefaultEquality<Operation>());
return comparator.equals(_operations, typedOther._operations);
}
@override
int get hashCode => hashObjects(_operations);
/// Retain [count] of characters from current position.
void retain(int count, [Map<String, dynamic>? attributes]) {
assert(count >= 0);
if (count == 0) return; // no-op
push(Operation.retain(count, attributes));
}
/// Insert [data] at current position.
void insert(dynamic data, [Map<String, dynamic>? attributes]) {
if (data is String && data.isEmpty) return; // no-op
push(Operation.insert(data, attributes));
}
/// Delete [count] characters from current position.
void delete(int count) {
assert(count >= 0);
if (count == 0) return;
push(Operation.delete(count));
}
void _mergeWithTail(Operation operation) {
assert(isNotEmpty);
assert(last.key == operation.key);
assert(operation.data is String && last.data is String);
final length = operation.length! + last.length!;
final lastText = last.data as String;
final opText = operation.data as String;
final resultText = lastText + opText;
final index = _operations.length;
_operations.replaceRange(index - 1, index, [
Operation._(operation.key, length, resultText, operation.attributes),
]);
}
/// Pushes new operation into this delta.
///
/// Performs compaction by composing [operation] with current tail operation
/// of this delta, when possible. For instance, if current tail is
/// `insert('abc')` and pushed operation is `insert('123')` then existing
/// tail is replaced with `insert('abc123')` - a compound result of the two
/// operations.
void push(Operation operation) {
if (operation.isEmpty) return;
var index = _operations.length;
final lastOp = _operations.isNotEmpty ? _operations.last : null;
if (lastOp != null) {
if (lastOp.isDelete && operation.isDelete) {
_mergeWithTail(operation);
return;
}
if (lastOp.isDelete && operation.isInsert) {
index -= 1; // Always insert before deleting
final nLastOp = (index > 0) ? _operations.elementAt(index - 1) : null;
if (nLastOp == null) {
_operations.insert(0, operation);
return;
}
}
if (lastOp.isInsert && operation.isInsert) {
if (lastOp.hasSameAttributes(operation) &&
operation.data is String &&
lastOp.data is String) {
_mergeWithTail(operation);
return;
}
}
if (lastOp.isRetain && operation.isRetain) {
if (lastOp.hasSameAttributes(operation)) {
_mergeWithTail(operation);
return;
}
}
}
if (index == _operations.length) {
_operations.add(operation);
} else {
final opAtIndex = _operations.elementAt(index);
_operations.replaceRange(index, index + 1, [operation, opAtIndex]);
}
_modificationCount++;
}
/// Composes next operation from [thisIter] and [otherIter].
///
/// Returns new operation or `null` if operations from [thisIter] and
/// [otherIter] nullify each other. For instance, for the pair `insert('abc')`
/// and `delete(3)` composition result would be empty string.
Operation? _composeOperation(
DeltaIterator thisIter, DeltaIterator otherIter) {
if (otherIter.isNextInsert) return otherIter.next();
if (thisIter.isNextDelete) return thisIter.next();
final length = math.min(thisIter.peekLength(), otherIter.peekLength());
final thisOp = thisIter.next(length as int);
final otherOp = otherIter.next(length);
assert(thisOp.length == otherOp.length);
if (otherOp.isRetain) {
final attributes = composeAttributes(
thisOp.attributes,
otherOp.attributes,
keepNull: thisOp.isRetain,
);
if (thisOp.isRetain) {
return Operation.retain(thisOp.length, attributes);
} else if (thisOp.isInsert) {
return Operation.insert(thisOp.data, attributes);
} else {
throw StateError('Unreachable');
}
} else {
// otherOp == delete && thisOp in [retain, insert]
assert(otherOp.isDelete);
if (thisOp.isRetain) return otherOp;
assert(thisOp.isInsert);
// otherOp(delete) + thisOp(insert) => null
}
return null;
}
/// Composes this delta with [other] and returns new [Delta].
///
/// It is not required for this and [other] delta to represent a document
/// delta (consisting only of insert operations).
Delta compose(Delta other) {
final result = Delta();
final thisIter = DeltaIterator(this);
final otherIter = DeltaIterator(other);
while (thisIter.hasNext || otherIter.hasNext) {
final newOp = _composeOperation(thisIter, otherIter);
if (newOp != null) result.push(newOp);
}
return result..trim();
}
/// Transforms next operation from [otherIter] against next operation in
/// [thisIter].
///
/// Returns `null` if both operations nullify each other.
Operation? _transformOperation(
DeltaIterator thisIter, DeltaIterator otherIter, bool priority) {
if (thisIter.isNextInsert && (priority || !otherIter.isNextInsert)) {
return Operation.retain(thisIter.next().length);
} else if (otherIter.isNextInsert) {
return otherIter.next();
}
final length = math.min(thisIter.peekLength(), otherIter.peekLength());
final thisOp = thisIter.next(length as int);
final otherOp = otherIter.next(length);
assert(thisOp.length == otherOp.length);
// At this point only delete and retain operations are possible.
if (thisOp.isDelete) {
// otherOp is either delete or retain, so they nullify each other.
return null;
} else if (otherOp.isDelete) {
return otherOp;
} else {
// Retain otherOp which is either retain or insert.
return Operation.retain(
length,
transformAttributes(thisOp.attributes, otherOp.attributes, priority),
);
}
}
/// Transforms [other] delta against operations in this delta.
Delta transform(Delta other, bool priority) {
final result = Delta();
final thisIter = DeltaIterator(this);
final otherIter = DeltaIterator(other);
while (thisIter.hasNext || otherIter.hasNext) {
final newOp = _transformOperation(thisIter, otherIter, priority);
if (newOp != null) result.push(newOp);
}
return result..trim();
}
/// Removes trailing retain operation with empty attributes, if present.
void trim() {
if (isNotEmpty) {
final last = _operations.last;
if (last.isRetain && last.isPlain) _operations.removeLast();
}
}
/// Concatenates [other] with this delta and returns the result.
Delta concat(Delta other) {
final result = Delta.from(this);
if (other.isNotEmpty) {
// In case first operation of other can be merged with last operation in
// our list.
result.push(other._operations.first);
result._operations.addAll(other._operations.sublist(1));
}
return result;
}
/// Inverts this delta against [base].
///
/// Returns new delta which negates effect of this delta when applied to
/// [base]. This is an equivalent of "undo" operation on deltas.
Delta invert(Delta base) {
final inverted = Delta();
if (base.isEmpty) return inverted;
var baseIndex = 0;
for (final op in _operations) {
if (op.isInsert) {
inverted.delete(op.length!);
} else if (op.isRetain && op.isPlain) {
inverted.retain(op.length!);
baseIndex += op.length!;
} else if (op.isDelete || (op.isRetain && op.isNotPlain)) {
final length = op.length!;
final sliceDelta = base.slice(baseIndex, baseIndex + length);
sliceDelta.toList().forEach((baseOp) {
if (op.isDelete) {
inverted.push(baseOp);
} else if (op.isRetain && op.isNotPlain) {
final invertAttr =
invertAttributes(op.attributes, baseOp.attributes);
inverted.retain(
baseOp.length!, invertAttr.isEmpty ? null : invertAttr);
}
});
baseIndex += length;
} else {
throw StateError('Unreachable');
}
}
inverted.trim();
return inverted;
}
/// Returns slice of this delta from [start] index (inclusive) to [end]
/// (exclusive).
Delta slice(int start, [int? end]) {
final delta = Delta();
var index = 0;
final opIterator = DeltaIterator(this);
final actualEnd = end ?? double.infinity;
while (index < actualEnd && opIterator.hasNext) {
Operation op;
if (index < start) {
op = opIterator.next(start - index);
} else {
op = opIterator.next(actualEnd - index as int);
delta.push(op);
}
index += op.length!;
}
return delta;
}
/// Transforms [index] against this delta.
///
/// Any "delete" operation before specified [index] shifts it backward, as
/// well as any "insert" operation shifts it forward.
///
/// The [force] argument is used to resolve scenarios when there is an
/// insert operation at the same position as [index]. If [force] is set to
/// `true` (default) then position is forced to shift forward, otherwise
/// position stays at the same index. In other words setting [force] to
/// `false` gives higher priority to the transformed position.
///
/// Useful to adjust caret or selection positions.
int transformPosition(int index, {bool force = true}) {
final iter = DeltaIterator(this);
var offset = 0;
while (iter.hasNext && offset <= index) {
final op = iter.next();
if (op.isDelete) {
index -= math.min(op.length!, index - offset);
continue;
} else if (op.isInsert && (offset < index || force)) {
index += op.length!;
}
offset += op.length!;
}
return index;
}
@override
String toString() => _operations.join('\n');
}
/// Specialized iterator for [Delta]s.
class DeltaIterator {
DeltaIterator(this.delta) : _modificationCount = delta._modificationCount;
final Delta delta;
final int _modificationCount;
int _index = 0;
num _offset = 0;
bool get isNextInsert => nextOperationKey == Operation.insertKey;
bool get isNextDelete => nextOperationKey == Operation.deleteKey;
bool get isNextRetain => nextOperationKey == Operation.retainKey;
String? get nextOperationKey {
if (_index < delta.length) {
return delta.elementAt(_index).key;
} else {
return null;
}
}
bool get hasNext => peekLength() < double.infinity;
/// Returns length of next operation without consuming it.
///
/// Returns [double.infinity] if there is no more operations left to iterate.
num peekLength() {
if (_index < delta.length) {
final operation = delta._operations[_index];
return operation.length! - _offset;
}
return double.infinity;
}
/// Consumes and returns next operation.
///
/// Optional [length] specifies maximum length of operation to return. Note
/// that actual length of returned operation may be less than specified value.
Operation next([int length = 4294967296]) {
if (_modificationCount != delta._modificationCount) {
throw ConcurrentModificationError(delta);
}
if (_index < delta.length) {
final op = delta.elementAt(_index);
final opKey = op.key;
final opAttributes = op.attributes;
final _currentOffset = _offset;
final actualLength = math.min(op.length! - _currentOffset, length);
if (actualLength == op.length! - _currentOffset) {
_index++;
_offset = 0;
} else {
_offset += actualLength;
}
final opData = op.isInsert && op.data is String
? (op.data as String).substring(
_currentOffset as int, _currentOffset + (actualLength as int))
: op.data;
final opIsNotEmpty =
opData is String ? opData.isNotEmpty : true; // embeds are never empty
final opLength = opData is String ? opData.length : 1;
final opActualLength = opIsNotEmpty ? opLength : actualLength as int;
return Operation._(opKey, opActualLength, opData, opAttributes);
}
return Operation.retain(length);
}
/// Skips [length] characters in source delta.
///
/// Returns last skipped operation, or `null` if there was nothing to skip.
Operation? skip(int length) {
var skipped = 0;
Operation? op;
while (skipped < length && hasNext) {
final opLength = peekLength();
final skip = math.min(length - skipped, opLength);
op = next(skip as int);
skipped += op.length!;
}
return op;
}
}

@ -0,0 +1,124 @@
import '../documents/attribute.dart';
import '../quill_delta.dart';
import 'rule.dart';
abstract class DeleteRule extends Rule {
const DeleteRule();
@override
RuleType get type => RuleType.DELETE;
@override
void validateArgs(int? len, Object? data, Attribute? attribute) {
assert(len != null);
assert(data == null);
assert(attribute == null);
}
}
class CatchAllDeleteRule extends DeleteRule {
const CatchAllDeleteRule();
@override
Delta applyRule(Delta document, int index,
{int? len, Object? data, Attribute? attribute}) {
return Delta()
..retain(index)
..delete(len!);
}
}
class PreserveLineStyleOnMergeRule extends DeleteRule {
const PreserveLineStyleOnMergeRule();
@override
Delta? applyRule(Delta document, int index,
{int? len, Object? data, Attribute? attribute}) {
final itr = DeltaIterator(document)..skip(index);
var op = itr.next(1);
if (op.data != '\n') {
return null;
}
final isNotPlain = op.isNotPlain;
final attrs = op.attributes;
itr.skip(len! - 1);
final delta = Delta()
..retain(index)
..delete(len);
while (itr.hasNext) {
op = itr.next();
final text = op.data is String ? (op.data as String?)! : '';
final lineBreak = text.indexOf('\n');
if (lineBreak == -1) {
delta.retain(op.length!);
continue;
}
var attributes = op.attributes == null
? null
: op.attributes!.map<String, dynamic>(
(key, dynamic value) => MapEntry<String, dynamic>(key, null));
if (isNotPlain) {
attributes ??= <String, dynamic>{};
attributes.addAll(attrs!);
}
delta..retain(lineBreak)..retain(1, attributes);
break;
}
return delta;
}
}
class EnsureEmbedLineRule extends DeleteRule {
const EnsureEmbedLineRule();
@override
Delta? applyRule(Delta document, int index,
{int? len, Object? data, Attribute? attribute}) {
final itr = DeltaIterator(document);
var op = itr.skip(index);
int? indexDelta = 0, lengthDelta = 0, remain = len;
var embedFound = op != null && op.data is! String;
final hasLineBreakBefore =
!embedFound && (op == null || (op.data as String).endsWith('\n'));
if (embedFound) {
var candidate = itr.next(1);
if (remain != null) {
remain--;
if (candidate.data == '\n') {
indexDelta++;
lengthDelta--;
candidate = itr.next(1);
remain--;
if (candidate.data == '\n') {
lengthDelta++;
}
}
}
}
op = itr.skip(remain!);
if (op != null &&
(op.data is String ? op.data as String? : '')!.endsWith('\n')) {
final candidate = itr.next(1);
if (candidate.data is! String && !hasLineBreakBefore) {
embedFound = true;
lengthDelta--;
}
}
if (!embedFound) {
return null;
}
return Delta()
..retain(index + indexDelta)
..delete(len! + lengthDelta);
}
}

@ -0,0 +1,132 @@
import '../documents/attribute.dart';
import '../quill_delta.dart';
import 'rule.dart';
abstract class FormatRule extends Rule {
const FormatRule();
@override
RuleType get type => RuleType.FORMAT;
@override
void validateArgs(int? len, Object? data, Attribute? attribute) {
assert(len != null);
assert(data == null);
assert(attribute != null);
}
}
class ResolveLineFormatRule extends FormatRule {
const ResolveLineFormatRule();
@override
Delta? applyRule(Delta document, int index,
{int? len, Object? data, Attribute? attribute}) {
if (attribute!.scope != AttributeScope.BLOCK) {
return null;
}
var delta = 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!);
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);
}
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!);
continue;
}
delta..retain(lineBreak)..retain(1, attribute.toJson());
break;
}
return delta;
}
}
class FormatLinkAtCaretPositionRule extends FormatRule {
const FormatLinkAtCaretPositionRule();
@override
Delta? applyRule(Delta document, int index,
{int? len, Object? data, Attribute? attribute}) {
if (attribute!.key != Attribute.link.key || len! > 0) {
return null;
}
final delta = Delta();
final itr = DeltaIterator(document);
final before = itr.skip(index), after = itr.next();
int? beg = index, retain = 0;
if (before != null && before.hasAttribute(attribute.key)) {
beg -= before.length!;
retain = before.length;
}
if (after.hasAttribute(attribute.key)) {
if (retain != null) retain += after.length!;
}
if (retain == 0) {
return null;
}
delta..retain(beg)..retain(retain!, attribute.toJson());
return delta;
}
}
class ResolveInlineFormatRule extends FormatRule {
const ResolveInlineFormatRule();
@override
Delta? applyRule(Delta document, int index,
{int? len, Object? data, Attribute? attribute}) {
if (attribute!.scope != AttributeScope.INLINE) {
return null;
}
final delta = 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);
final text = op.data is String ? (op.data as String?)! : '';
var lineBreak = text.indexOf('\n');
if (lineBreak < 0) {
delta.retain(op.length!, attribute.toJson());
continue;
}
var pos = 0;
while (lineBreak >= 0) {
delta..retain(lineBreak - pos, attribute.toJson())..retain(1);
pos = lineBreak + 1;
lineBreak = text.indexOf('\n', pos);
}
if (pos < op.length!) {
delta.retain(op.length! - pos, attribute.toJson());
}
}
return delta;
}
}

@ -0,0 +1,413 @@
import 'package:tuple/tuple.dart';
import '../documents/attribute.dart';
import '../documents/style.dart';
import '../quill_delta.dart';
import 'rule.dart';
abstract class InsertRule extends Rule {
const InsertRule();
@override
RuleType get type => RuleType.INSERT;
@override
void validateArgs(int? len, Object? data, Attribute? attribute) {
assert(data != null);
assert(attribute == null);
}
}
class PreserveLineStyleOnSplitRule extends InsertRule {
const PreserveLineStyleOnSplitRule();
@override
Delta? applyRule(Delta document, int index,
{int? len, Object? data, Attribute? attribute}) {
if (data is! String || data != '\n') {
return null;
}
final itr = DeltaIterator(document);
final before = itr.skip(index);
if (before == null ||
before.data is! String ||
(before.data as String).endsWith('\n')) {
return null;
}
final after = itr.next();
if (after.data is! String || (after.data as String).startsWith('\n')) {
return null;
}
final text = after.data as String;
final delta = Delta()..retain(index + (len ?? 0));
if (text.contains('\n')) {
assert(after.isPlain);
delta.insert('\n');
return delta;
}
final nextNewLine = _getNextNewLine(itr);
final attributes = nextNewLine.item1?.attributes;
return delta..insert('\n', attributes);
}
}
/// Preserves block style when user inserts text containing newlines.
///
/// This rule handles:
///
/// * inserting a new line in a block
/// * pasting text containing multiple lines of text in a block
///
/// This rule may also be activated for changes triggered by auto-correct.
class PreserveBlockStyleOnInsertRule extends InsertRule {
const PreserveBlockStyleOnInsertRule();
@override
Delta? applyRule(Delta document, int index,
{int? len, Object? data, Attribute? attribute}) {
if (data is! String || !data.contains('\n')) {
// Only interested in text containing at least one newline character.
return null;
}
final itr = DeltaIterator(document)..skip(index);
// Look for the next newline.
final nextNewLine = _getNextNewLine(itr);
final lineStyle =
Style.fromJson(nextNewLine.item1?.attributes ?? <String, dynamic>{});
final blockStyle = lineStyle.getBlocksExceptHeader();
// Are we currently in a block? If not then ignore.
if (blockStyle.isEmpty) {
return null;
}
Map<String, dynamic>? resetStyle;
// 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();
}
// Go over each inserted line and ensure block style is applied.
final lines = data.split('\n');
final delta = Delta()..retain(index + (len ?? 0));
for (var i = 0; i < lines.length; i++) {
final line = lines[i];
if (line.isNotEmpty) {
delta.insert(line);
}
if (i == 0) {
// The first line should inherit the lineStyle entirely.
delta.insert('\n', lineStyle.toJson());
} else if (i < lines.length - 1) {
// we don't want to insert a newline after the last chunk of text, so -1
delta.insert('\n', blockStyle);
}
}
// Reset style of the original newline character if needed.
if (resetStyle != null) {
delta
..retain(nextNewLine.item2!)
..retain((nextNewLine.item1!.data as String).indexOf('\n'))
..retain(1, resetStyle);
}
return delta;
}
}
/// Heuristic rule to exit current block when user inserts two consecutive
/// newlines.
///
/// This rule is only applied when the cursor is on the last line of a block.
/// When the cursor is in the middle of a block we allow adding empty lines
/// and preserving the block's style.
class AutoExitBlockRule extends InsertRule {
const AutoExitBlockRule();
bool _isEmptyLine(Operation? before, Operation? after) {
if (before == null) {
return true;
}
return before.data is String &&
(before.data as String).endsWith('\n') &&
after!.data is String &&
(after.data as String).startsWith('\n');
}
@override
Delta? applyRule(Delta document, int index,
{int? len, Object? data, Attribute? attribute}) {
if (data is! String || data != '\n') {
return null;
}
final itr = DeltaIterator(document);
final prev = itr.skip(index), cur = itr.next();
final blockStyle = Style.fromJson(cur.attributes).getBlockExceptHeader();
// We are not in a block, ignore.
if (cur.isPlain || blockStyle == null) {
return null;
}
// We are not on an empty line, ignore.
if (!_isEmptyLine(prev, cur)) {
return null;
}
// We are on an empty line. Now we need to determine if we are on the
// last line of a block.
// First check if `cur` length is greater than 1, this would indicate
// that it contains multiple newline characters which share the same style.
// This would mean we are not on the last line yet.
// `cur.value as String` is safe since we already called isEmptyLine and know it contains a newline
if ((cur.value as String).length > 1) {
// We are not on the last line of this block, ignore.
return null;
}
// Keep looking for the next newline character to see if it shares the same
// block style as `cur`.
final nextNewLine = _getNextNewLine(itr);
if (nextNewLine.item1 != null &&
nextNewLine.item1!.attributes != null &&
Style.fromJson(nextNewLine.item1!.attributes).getBlockExceptHeader() ==
blockStyle) {
// We are not at the end of this block, ignore.
return null;
}
// Here we now know that the line after `cur` is not in the same block
// therefore we can exit this block.
final attributes = cur.attributes ?? <String, dynamic>{};
final k = attributes.keys
.firstWhere((k) => Attribute.blockKeysExceptHeader.contains(k));
attributes[k] = null;
// retain(1) should be '\n', set it with no attribute
return Delta()..retain(index + (len ?? 0))..retain(1, attributes);
}
}
class ResetLineFormatOnNewLineRule extends InsertRule {
const ResetLineFormatOnNewLineRule();
@override
Delta? applyRule(Delta document, int index,
{int? len, Object? data, Attribute? attribute}) {
if (data is! String || data != '\n') {
return null;
}
final itr = DeltaIterator(document)..skip(index);
final cur = itr.next();
if (cur.data is! String || !(cur.data as String).startsWith('\n')) {
return null;
}
Map<String, dynamic>? resetStyle;
if (cur.attributes != null &&
cur.attributes!.containsKey(Attribute.header.key)) {
resetStyle = Attribute.header.toJson();
}
return Delta()
..retain(index + (len ?? 0))
..insert('\n', cur.attributes)
..retain(1, resetStyle)
..trim();
}
}
class InsertEmbedsRule extends InsertRule {
const InsertEmbedsRule();
@override
Delta? applyRule(Delta document, int index,
{int? len, Object? data, Attribute? attribute}) {
if (data is String) {
return null;
}
final delta = Delta()..retain(index + (len ?? 0));
final itr = DeltaIterator(document);
final prev = itr.skip(index), cur = itr.next();
final textBefore = prev?.data is String ? prev!.data as String? : '';
final textAfter = cur.data is String ? (cur.data as String?)! : '';
final isNewlineBefore = prev == null || textBefore!.endsWith('\n');
final isNewlineAfter = textAfter.startsWith('\n');
if (isNewlineBefore && isNewlineAfter) {
return delta..insert(data);
}
Map<String, dynamic>? lineStyle;
if (textAfter.contains('\n')) {
lineStyle = cur.attributes;
} else {
while (itr.hasNext) {
final op = itr.next();
if ((op.data is String ? op.data as String? : '')!.contains('\n')) {
lineStyle = op.attributes;
break;
}
}
}
if (!isNewlineBefore) {
delta.insert('\n', lineStyle);
}
delta.insert(data);
if (!isNewlineAfter) {
delta.insert('\n');
}
return delta;
}
}
class ForceNewlineForInsertsAroundEmbedRule extends InsertRule {
const ForceNewlineForInsertsAroundEmbedRule();
@override
Delta? applyRule(Delta document, int index,
{int? len, Object? data, Attribute? attribute}) {
if (data is! String) {
return null;
}
final text = data;
final itr = DeltaIterator(document);
final prev = itr.skip(index);
final cur = itr.next();
final cursorBeforeEmbed = cur.data is! String;
final cursorAfterEmbed = prev != null && prev.data is! String;
if (!cursorBeforeEmbed && !cursorAfterEmbed) {
return null;
}
final delta = Delta()..retain(index + (len ?? 0));
if (cursorBeforeEmbed && !text.endsWith('\n')) {
return delta..insert(text)..insert('\n');
}
if (cursorAfterEmbed && !text.startsWith('\n')) {
return delta..insert('\n')..insert(text);
}
return delta..insert(text);
}
}
class AutoFormatLinksRule extends InsertRule {
const AutoFormatLinksRule();
@override
Delta? applyRule(Delta document, int index,
{int? len, Object? data, Attribute? attribute}) {
if (data is! String || data != ' ') {
return null;
}
final itr = DeltaIterator(document);
final prev = itr.skip(index);
if (prev == null || prev.data is! String) {
return null;
}
try {
final cand = (prev.data as String).split('\n').last.split(' ').last;
final link = Uri.parse(cand);
if (!['https', 'http'].contains(link.scheme)) {
return null;
}
final attributes = prev.attributes ?? <String, dynamic>{};
if (attributes.containsKey(Attribute.link.key)) {
return null;
}
attributes.addAll(LinkAttribute(link.toString()).toJson());
return Delta()
..retain(index + (len ?? 0) - cand.length)
..retain(cand.length, attributes)
..insert(data, prev.attributes);
} on FormatException {
return null;
}
}
}
class PreserveInlineStylesRule extends InsertRule {
const PreserveInlineStylesRule();
@override
Delta? applyRule(Delta document, int index,
{int? len, Object? data, Attribute? attribute}) {
if (data is! String || data.contains('\n')) {
return null;
}
final itr = DeltaIterator(document);
final prev = itr.skip(index);
if (prev == null ||
prev.data is! String ||
(prev.data as String).contains('\n')) {
return null;
}
final attributes = prev.attributes;
final text = data;
if (attributes == null || !attributes.containsKey(Attribute.link.key)) {
return Delta()
..retain(index + (len ?? 0))
..insert(text, attributes);
}
attributes.remove(Attribute.link.key);
final delta = Delta()
..retain(index + (len ?? 0))
..insert(text, attributes.isEmpty ? null : attributes);
final next = itr.next();
final nextAttributes = next.attributes ?? const <String, dynamic>{};
if (!nextAttributes.containsKey(Attribute.link.key)) {
return delta;
}
if (attributes[Attribute.link.key] == nextAttributes[Attribute.link.key]) {
return Delta()
..retain(index + (len ?? 0))
..insert(text, attributes);
}
return delta;
}
}
class CatchAllInsertRule extends InsertRule {
const CatchAllInsertRule();
@override
Delta applyRule(Delta document, int index,
{int? len, Object? data, Attribute? attribute}) {
return Delta()
..retain(index + (len ?? 0))
..insert(data);
}
}
Tuple2<Operation?, int?> _getNextNewLine(DeltaIterator iterator) {
Operation op;
for (var skipped = 0; iterator.hasNext; skipped += op.length!) {
op = iterator.next();
final lineBreak =
(op.data is String ? op.data as String? : '')!.indexOf('\n');
if (lineBreak >= 0) {
return Tuple2(op, skipped);
}
}
return const Tuple2(null, null);
}

@ -0,0 +1,77 @@
import '../documents/attribute.dart';
import '../documents/document.dart';
import '../quill_delta.dart';
import 'delete.dart';
import 'format.dart';
import 'insert.dart';
enum RuleType { INSERT, DELETE, FORMAT }
abstract class Rule {
const Rule();
Delta? apply(Delta document, int index,
{int? len, Object? data, Attribute? attribute}) {
validateArgs(len, data, attribute);
return applyRule(document, index,
len: len, data: data, attribute: attribute);
}
void validateArgs(int? len, Object? data, Attribute? attribute);
Delta? applyRule(Delta document, int index,
{int? len, Object? data, Attribute? attribute});
RuleType get type;
}
class Rules {
Rules(this._rules);
List<Rule> _customRules = [];
final List<Rule> _rules;
static final Rules _instance = Rules([
const FormatLinkAtCaretPositionRule(),
const ResolveLineFormatRule(),
const ResolveInlineFormatRule(),
const InsertEmbedsRule(),
const ForceNewlineForInsertsAroundEmbedRule(),
const AutoExitBlockRule(),
const PreserveBlockStyleOnInsertRule(),
const PreserveLineStyleOnSplitRule(),
const ResetLineFormatOnNewLineRule(),
const AutoFormatLinksRule(),
const PreserveInlineStylesRule(),
const CatchAllInsertRule(),
const EnsureEmbedLineRule(),
const PreserveLineStyleOnMergeRule(),
const CatchAllDeleteRule(),
]);
static Rules getInstance() => _instance;
void setCustomRules(List<Rule> customRules) {
_customRules = customRules;
}
Delta apply(RuleType ruleType, Document document, int index,
{int? len, Object? data, Attribute? attribute}) {
final delta = document.toDelta();
for (final rule in _customRules + _rules) {
if (rule.type != ruleType) {
continue;
}
try {
final result = rule.apply(delta, index,
len: len, data: data, attribute: attribute);
if (result != null) {
return result..trim();
}
} catch (e) {
rethrow;
}
}
throw 'Apply rules failed';
}
}

@ -0,0 +1,125 @@
import 'dart:ui';
import 'package:flutter/material.dart';
Color stringToColor(String? s) {
switch (s) {
case 'transparent':
return Colors.transparent;
case 'black':
return Colors.black;
case 'black12':
return Colors.black12;
case 'black26':
return Colors.black26;
case 'black38':
return Colors.black38;
case 'black45':
return Colors.black45;
case 'black54':
return Colors.black54;
case 'black87':
return Colors.black87;
case 'white':
return Colors.white;
case 'white10':
return Colors.white10;
case 'white12':
return Colors.white12;
case 'white24':
return Colors.white24;
case 'white30':
return Colors.white30;
case 'white38':
return Colors.white38;
case 'white54':
return Colors.white54;
case 'white60':
return Colors.white60;
case 'white70':
return Colors.white70;
case 'red':
return Colors.red;
case 'redAccent':
return Colors.redAccent;
case 'amber':
return Colors.amber;
case 'amberAccent':
return Colors.amberAccent;
case 'yellow':
return Colors.yellow;
case 'yellowAccent':
return Colors.yellowAccent;
case 'teal':
return Colors.teal;
case 'tealAccent':
return Colors.tealAccent;
case 'purple':
return Colors.purple;
case 'purpleAccent':
return Colors.purpleAccent;
case 'pink':
return Colors.pink;
case 'pinkAccent':
return Colors.pinkAccent;
case 'orange':
return Colors.orange;
case 'orangeAccent':
return Colors.orangeAccent;
case 'deepOrange':
return Colors.deepOrange;
case 'deepOrangeAccent':
return Colors.deepOrangeAccent;
case 'indigo':
return Colors.indigo;
case 'indigoAccent':
return Colors.indigoAccent;
case 'lime':
return Colors.lime;
case 'limeAccent':
return Colors.limeAccent;
case 'grey':
return Colors.grey;
case 'blueGrey':
return Colors.blueGrey;
case 'green':
return Colors.green;
case 'greenAccent':
return Colors.greenAccent;
case 'lightGreen':
return Colors.lightGreen;
case 'lightGreenAccent':
return Colors.lightGreenAccent;
case 'blue':
return Colors.blue;
case 'blueAccent':
return Colors.blueAccent;
case 'lightBlue':
return Colors.lightBlue;
case 'lightBlueAccent':
return Colors.lightBlueAccent;
case 'cyan':
return Colors.cyan;
case 'cyanAccent':
return Colors.cyanAccent;
case 'brown':
return Colors.brown;
}
if (s!.startsWith('rgba')) {
s = s.substring(5); // trim left 'rgba('
s = s.substring(0, s.length - 1); // trim right ')'
final arr = s.split(',').map((e) => e.trim()).toList();
return Color.fromRGBO(int.parse(arr[0]), int.parse(arr[1]),
int.parse(arr[2]), double.parse(arr[3]));
}
if (!s.startsWith('#')) {
throw 'Color code not supported';
}
var hex = s.replaceFirst('#', '');
hex = hex.length == 6 ? 'ff$hex' : hex;
final val = int.parse(hex, radix: 16);
return Color(val);
}

@ -0,0 +1,102 @@
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);
// Start index in old text at which changes begin.
final int start;
/// The deleted text
final String deleted;
// The inserted text
final String inserted;
@override
String toString() {
return 'Diff[$start, "$deleted", "$inserted"]';
}
}
/* Get diff operation between old text and new text */
Diff getDiff(String oldText, String newText, int cursorPosition) {
var end = oldText.length;
final delta = newText.length - end;
for (final limit = math.max(0, cursorPosition - delta);
end > limit && oldText[end - 1] == newText[end + delta - 1];
end--) {}
var start = 0;
for (final startLimit = cursorPosition - math.max(0, delta);
start < startLimit && oldText[start] == newText[start];
start++) {}
final deleted = (start >= end) ? '' : oldText.substring(start, end);
final inserted = newText.substring(start, end + delta);
return Diff(start, deleted, inserted);
}
int getPositionDelta(Delta user, Delta actual) {
if (actual.isEmpty) {
return 0;
}
final userItr = DeltaIterator(user);
final actualItr = DeltaIterator(actual);
var diff = 0;
while (userItr.hasNext || actualItr.hasNext) {
final length = math.min(userItr.peekLength(), actualItr.peekLength());
final userOperation = userItr.next(length as int);
final actualOperation = actualItr.next(length);
if (userOperation.length != actualOperation.length) {
throw 'userOp ${userOperation.length} does not match actualOp ${actualOperation.length}';
}
if (userOperation.key == actualOperation.key) {
continue;
} else if (userOperation.isInsert && actualOperation.isRetain) {
diff -= userOperation.length!;
} else if (userOperation.isDelete && actualOperation.isRetain) {
diff += userOperation.length!;
} else if (userOperation.isRetain && actualOperation.isInsert) {
String? operationTxt = '';
if (actualOperation.data is String) {
operationTxt = actualOperation.data as String?;
}
if (operationTxt!.startsWith('\n')) {
continue;
}
diff += actualOperation.length!;
}
}
return diff;
}

@ -0,0 +1,39 @@
import 'package:flutter/rendering.dart';
import '../models/documents/nodes/container.dart';
abstract class RenderContentProxyBox implements RenderBox {
double getPreferredLineHeight();
Offset getOffsetForCaret(TextPosition position, Rect? caretPrototype);
TextPosition getPositionForOffset(Offset offset);
double? getFullHeightForCaret(TextPosition position);
TextRange getWordBoundary(TextPosition position);
List<TextBox> getBoxesForSelection(TextSelection textSelection);
}
abstract class RenderEditableBox extends RenderBox {
Container getContainer();
double preferredLineHeight(TextPosition position);
Offset getOffsetForCaret(TextPosition position);
TextPosition getPositionForOffset(Offset offset);
TextPosition? getPositionAbove(TextPosition position);
TextPosition? getPositionBelow(TextPosition position);
TextRange getWordBoundary(TextPosition position);
TextRange getLineBoundary(TextPosition position);
TextSelectionPoint getBaseEndpointForSelection(TextSelection textSelection);
TextSelectionPoint getExtentEndpointForSelection(TextSelection textSelection);
}

@ -0,0 +1,229 @@
import 'dart:math' as math;
import 'package:flutter/cupertino.dart';
import 'package:tuple/tuple.dart';
import '../models/documents/attribute.dart';
import '../models/documents/document.dart';
import '../models/documents/nodes/embed.dart';
import '../models/documents/style.dart';
import '../models/quill_delta.dart';
import '../utils/diff_delta.dart';
class QuillController extends ChangeNotifier {
QuillController(
{required this.document,
required this.selection,
this.iconSize = 18,
this.toolbarHeightFactor = 2});
factory QuillController.basic() {
return QuillController(
document: Document(),
selection: const TextSelection.collapsed(offset: 0),
);
}
final Document document;
TextSelection selection;
double iconSize;
double toolbarHeightFactor;
Style toggledStyle = Style();
bool ignoreFocusOnTextChange = false;
/// Controls whether this [QuillController] instance has already been disposed
/// of
///
/// This is a safe approach to make sure that listeners don't crash when
/// adding, removing or listeners to this instance.
bool _isDisposed = false;
// item1: Document state before [change].
//
// item2: Change delta applied to the document.
//
// item3: The source of this change.
Stream<Tuple3<Delta, Delta, ChangeSource>> get changes => document.changes;
TextEditingValue get plainTextEditingValue => TextEditingValue(
text: document.toPlainText(),
selection: selection,
);
Style getSelectionStyle() {
return document
.collectStyle(selection.start, selection.end - selection.start)
.mergeAll(toggledStyle);
}
void undo() {
final tup = document.undo();
if (tup.item1) {
_handleHistoryChange(tup.item2);
}
}
void _handleHistoryChange(int? len) {
if (len != 0) {
// if (this.selection.extentOffset >= document.length) {
// // cursor exceeds the length of document, position it in the end
// updateSelection(
// TextSelection.collapsed(offset: document.length), ChangeSource.LOCAL);
updateSelection(
TextSelection.collapsed(offset: selection.baseOffset + len!),
ChangeSource.LOCAL);
} else {
// no need to move cursor
notifyListeners();
}
}
void redo() {
final tup = document.redo();
if (tup.item1) {
_handleHistoryChange(tup.item2);
}
}
bool get hasUndo => document.hasUndo;
bool get hasRedo => document.hasRedo;
void replaceText(
int index, int len, Object? data, TextSelection? textSelection,
{bool ignoreFocus = false, bool autoAppendNewlineAfterImage = true}) {
assert(data is String || data is Embeddable);
Delta? delta;
if (len > 0 || data is! String || data.isNotEmpty) {
delta = document.replace(index, len, data,
autoAppendNewlineAfterImage: autoAppendNewlineAfterImage);
var shouldRetainDelta = toggledStyle.isNotEmpty &&
delta.isNotEmpty &&
delta.length <= 2 &&
delta.last.isInsert;
if (shouldRetainDelta &&
toggledStyle.isNotEmpty &&
delta.length == 2 &&
delta.last.data == '\n') {
// if all attributes are inline, shouldRetainDelta should be false
final anyAttributeNotInline =
toggledStyle.values.any((attr) => !attr.isInline);
if (!anyAttributeNotInline) {
shouldRetainDelta = false;
}
}
if (shouldRetainDelta) {
final retainDelta = Delta()
..retain(index)
..retain(data is String ? data.length : 1, toggledStyle.toJson());
document.compose(retainDelta, ChangeSource.LOCAL);
}
}
toggledStyle = Style();
if (textSelection != null) {
if (delta == null || delta.isEmpty) {
_updateSelection(textSelection, ChangeSource.LOCAL);
} else {
final user = Delta()
..retain(index)
..insert(data)
..delete(len);
final positionDelta = getPositionDelta(user, delta);
_updateSelection(
textSelection.copyWith(
baseOffset: textSelection.baseOffset + positionDelta,
extentOffset: textSelection.extentOffset + positionDelta,
),
ChangeSource.LOCAL,
);
}
}
if (ignoreFocus) {
ignoreFocusOnTextChange = true;
}
notifyListeners();
ignoreFocusOnTextChange = false;
}
void formatText(int index, int len, Attribute? attribute) {
if (len == 0 &&
attribute!.isInline &&
attribute.key != Attribute.link.key) {
toggledStyle = toggledStyle.put(attribute);
}
final change = document.format(index, len, attribute);
final adjustedSelection = selection.copyWith(
baseOffset: change.transformPosition(selection.baseOffset),
extentOffset: change.transformPosition(selection.extentOffset));
if (selection != adjustedSelection) {
_updateSelection(adjustedSelection, ChangeSource.LOCAL);
}
notifyListeners();
}
void formatSelection(Attribute? attribute) {
formatText(selection.start, selection.end - selection.start, attribute);
}
void updateSelection(TextSelection textSelection, ChangeSource source) {
_updateSelection(textSelection, source);
notifyListeners();
}
void compose(Delta delta, TextSelection textSelection, ChangeSource source) {
if (delta.isNotEmpty) {
document.compose(delta, source);
}
textSelection = selection.copyWith(
baseOffset: delta.transformPosition(selection.baseOffset, force: false),
extentOffset:
delta.transformPosition(selection.extentOffset, force: false));
if (selection != textSelection) {
_updateSelection(textSelection, source);
}
notifyListeners();
}
@override
void addListener(VoidCallback listener) {
// By using `_isDisposed`, make sure that `addListener` won't be called on a
// disposed `ChangeListener`
if (!_isDisposed) {
super.addListener(listener);
}
}
@override
void removeListener(VoidCallback listener) {
// By using `_isDisposed`, make sure that `removeListener` won't be called
// on a disposed `ChangeListener`
if (!_isDisposed) {
super.removeListener(listener);
}
}
@override
void dispose() {
if (!_isDisposed) {
document.close();
}
_isDisposed = true;
super.dispose();
}
void _updateSelection(TextSelection textSelection, ChangeSource source) {
selection = textSelection;
final end = document.length - 1;
selection = selection.copyWith(
baseOffset: math.min(selection.baseOffset, end),
extentOffset: math.min(selection.extentOffset, end));
}
}

@ -0,0 +1,315 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'box.dart';
/// Style properties of editing cursor.
class CursorStyle {
const CursorStyle({
required this.color,
required this.backgroundColor,
this.width = 1.0,
this.height,
this.radius,
this.offset,
this.opacityAnimates = false,
this.paintAboveText = false,
});
/// The color to use when painting the cursor.
final Color color;
/// The color to use when painting the background cursor aligned with the text
/// while rendering the floating cursor.
final Color backgroundColor;
/// How thick the cursor will be.
///
/// The cursor will draw under the text. The cursor width will extend
/// to the right of the boundary between characters for left-to-right text
/// and to the left for right-to-left text. This corresponds to extending
/// downstream relative to the selected position. Negative values may be used
/// to reverse this behavior.
final double width;
/// How tall the cursor will be.
///
/// By default, the cursor height is set to the preferred line height of the
/// text.
final double? height;
/// How rounded the corners of the cursor should be.
///
/// By default, the cursor has no radius.
final Radius? radius;
/// The offset that is used, in pixels, when painting the cursor on screen.
///
/// By default, the cursor position should be set to an offset of
/// (-[cursorWidth] * 0.5, 0.0) on iOS platforms and (0, 0) on Android
/// platforms. The origin from where the offset is applied to is the arbitrary
/// location where the cursor ends up being rendered from by default.
final Offset? offset;
/// Whether the cursor will animate from fully transparent to fully opaque
/// during each cursor blink.
///
/// By default, the cursor opacity will animate on iOS platforms and will not
/// animate on Android platforms.
final bool opacityAnimates;
/// If the cursor should be painted on top of the text or underneath it.
///
/// By default, the cursor should be painted on top for iOS platforms and
/// underneath for Android platforms.
final bool paintAboveText;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is CursorStyle &&
runtimeType == other.runtimeType &&
color == other.color &&
backgroundColor == other.backgroundColor &&
width == other.width &&
height == other.height &&
radius == other.radius &&
offset == other.offset &&
opacityAnimates == other.opacityAnimates &&
paintAboveText == other.paintAboveText;
@override
int get hashCode =>
color.hashCode ^
backgroundColor.hashCode ^
width.hashCode ^
height.hashCode ^
radius.hashCode ^
offset.hashCode ^
opacityAnimates.hashCode ^
paintAboveText.hashCode;
}
/// Controls the cursor of an editable widget.
///
/// This class is a [ChangeNotifier] and allows to listen for updates on the
/// cursor [style].
class CursorCont extends ChangeNotifier {
CursorCont({
required this.show,
required CursorStyle style,
required TickerProvider tickerProvider,
}) : _style = style,
blink = ValueNotifier(false),
color = ValueNotifier(style.color) {
_blinkOpacityController =
AnimationController(vsync: tickerProvider, duration: _fadeDuration);
_blinkOpacityController.addListener(_onColorTick);
}
// The time it takes for the cursor to fade from fully opaque to fully
// transparent and vice versa. A full cursor blink, from transparent to opaque
// to transparent, is twice this duration.
static const Duration _blinkHalfPeriod = Duration(milliseconds: 500);
// The time the cursor is static in opacity before animating to become
// transparent.
static const Duration _blinkWaitForStart = Duration(milliseconds: 150);
// This value is an eyeball estimation of the time it takes for the iOS cursor
// to ease in and out.
static const Duration _fadeDuration = Duration(milliseconds: 250);
final ValueNotifier<bool> show;
final ValueNotifier<Color> color;
final ValueNotifier<bool> blink;
late final AnimationController _blinkOpacityController;
Timer? _cursorTimer;
bool _targetCursorVisibility = false;
CursorStyle _style;
CursorStyle get style => _style;
set style(CursorStyle value) {
if (_style == value) return;
_style = value;
notifyListeners();
}
/// True when this [CursorCont] instance has been disposed.
///
/// A safety mechanism to prevent the value of a disposed controller from
/// getting set.
bool _isDisposed = false;
@override
void dispose() {
_blinkOpacityController.removeListener(_onColorTick);
stopCursorTimer();
_isDisposed = true;
_blinkOpacityController.dispose();
show.dispose();
blink.dispose();
color.dispose();
assert(_cursorTimer == null);
super.dispose();
}
void _cursorTick(Timer timer) {
_targetCursorVisibility = !_targetCursorVisibility;
final targetOpacity = _targetCursorVisibility ? 1.0 : 0.0;
if (style.opacityAnimates) {
// If we want to show the cursor, we will animate the opacity to the value
// of 1.0, and likewise if we want to make it disappear, to 0.0. An easing
// curve is used for the animation to mimic the aesthetics of the native
// iOS cursor.
//
// These values and curves have been obtained through eyeballing, so are
// likely not exactly the same as the values for native iOS.
_blinkOpacityController.animateTo(targetOpacity, curve: Curves.easeOut);
} else {
_blinkOpacityController.value = targetOpacity;
}
}
void _waitForStart(Timer timer) {
_cursorTimer?.cancel();
_cursorTimer = Timer.periodic(_blinkHalfPeriod, _cursorTick);
}
void startCursorTimer() {
if (_isDisposed) {
return;
}
_targetCursorVisibility = true;
_blinkOpacityController.value = 1.0;
if (style.opacityAnimates) {
_cursorTimer = Timer.periodic(_blinkWaitForStart, _waitForStart);
} else {
_cursorTimer = Timer.periodic(_blinkHalfPeriod, _cursorTick);
}
}
void stopCursorTimer({bool resetCharTicks = true}) {
_cursorTimer?.cancel();
_cursorTimer = null;
_targetCursorVisibility = false;
_blinkOpacityController.value = 0.0;
if (style.opacityAnimates) {
_blinkOpacityController
..stop()
..value = 0.0;
}
}
void startOrStopCursorTimerIfNeeded(bool hasFocus, TextSelection selection) {
if (show.value &&
_cursorTimer == null &&
hasFocus &&
selection.isCollapsed) {
startCursorTimer();
} else if (_cursorTimer != null && (!hasFocus || !selection.isCollapsed)) {
stopCursorTimer();
}
}
void _onColorTick() {
color.value = _style.color.withOpacity(_blinkOpacityController.value);
blink.value = show.value && _blinkOpacityController.value > 0;
}
}
/// Paints the editing cursor.
class CursorPainter {
CursorPainter(
this.editable,
this.style,
this.prototype,
this.color,
this.devicePixelRatio,
);
final RenderContentProxyBox? editable;
final CursorStyle style;
final Rect prototype;
final Color color;
final double devicePixelRatio;
/// Paints cursor on [canvas] at specified [position].
void paint(Canvas canvas, Offset offset, TextPosition position) {
final caretOffset =
editable!.getOffsetForCaret(position, prototype) + offset;
var caretRect = prototype.shift(caretOffset);
if (style.offset != null) {
caretRect = caretRect.shift(style.offset!);
}
if (caretRect.left < 0.0) {
// For iOS the cursor may get clipped by the scroll view when
// it's located at a beginning of a line. We ensure that this
// does not happen here. This may result in the cursor being painted
// closer to the character on the right, but it's arguably better
// then painting clipped cursor (or even cursor completely hidden).
caretRect = caretRect.shift(Offset(-caretRect.left, 0));
}
final caretHeight = editable!.getFullHeightForCaret(position);
if (caretHeight != null) {
switch (defaultTargetPlatform) {
case TargetPlatform.android:
case TargetPlatform.fuchsia:
case TargetPlatform.linux:
case TargetPlatform.windows:
// Override the height to take the full height of the glyph at the TextPosition
// when not on iOS. iOS has special handling that creates a taller caret.
caretRect = Rect.fromLTWH(
caretRect.left,
caretRect.top - 2.0,
caretRect.width,
caretHeight,
);
break;
case TargetPlatform.iOS:
case TargetPlatform.macOS:
// Center the caret vertically along the text.
caretRect = Rect.fromLTWH(
caretRect.left,
caretRect.top + (caretHeight - caretRect.height) / 2,
caretRect.width,
caretRect.height,
);
break;
default:
throw UnimplementedError();
}
}
final caretPosition = editable!.localToGlobal(caretRect.topLeft);
final pixelMultiple = 1.0 / devicePixelRatio;
caretRect = caretRect.shift(Offset(
caretPosition.dx.isFinite
? (caretPosition.dx / pixelMultiple).round() * pixelMultiple -
caretPosition.dx
: caretPosition.dx,
caretPosition.dy.isFinite
? (caretPosition.dy / pixelMultiple).round() * pixelMultiple -
caretPosition.dy
: caretPosition.dy));
final paint = Paint()..color = color;
if (style.radius == null) {
canvas.drawRect(caretRect, paint);
return;
}
final caretRRect = RRect.fromRectAndRadius(caretRect, style.radius!);
canvas.drawRRect(caretRRect, paint);
}
}

@ -0,0 +1,223 @@
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'package:tuple/tuple.dart';
class QuillStyles extends InheritedWidget {
const QuillStyles({
required this.data,
required Widget child,
Key? key,
}) : super(key: key, child: child);
final DefaultStyles data;
@override
bool updateShouldNotify(QuillStyles oldWidget) {
return data != oldWidget.data;
}
static DefaultStyles? getStyles(BuildContext context, bool nullOk) {
final widget = context.dependOnInheritedWidgetOfExactType<QuillStyles>();
if (widget == null && nullOk) {
return null;
}
assert(widget != null);
return widget!.data;
}
}
class DefaultTextBlockStyle {
DefaultTextBlockStyle(
this.style,
this.verticalSpacing,
this.lineSpacing,
this.decoration,
);
final TextStyle style;
final Tuple2<double, double> verticalSpacing;
final Tuple2<double, double> lineSpacing;
final BoxDecoration? decoration;
}
class DefaultStyles {
DefaultStyles({
this.h1,
this.h2,
this.h3,
this.paragraph,
this.bold,
this.italic,
this.underline,
this.strikeThrough,
this.link,
this.color,
this.placeHolder,
this.lists,
this.quote,
this.code,
this.indent,
this.align,
this.leading,
this.sizeSmall,
this.sizeLarge,
this.sizeHuge,
});
final DefaultTextBlockStyle? h1;
final DefaultTextBlockStyle? h2;
final DefaultTextBlockStyle? h3;
final DefaultTextBlockStyle? paragraph;
final TextStyle? bold;
final TextStyle? italic;
final TextStyle? underline;
final TextStyle? strikeThrough;
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 DefaultTextBlockStyle? quote;
final DefaultTextBlockStyle? code;
final DefaultTextBlockStyle? indent;
final DefaultTextBlockStyle? align;
final DefaultTextBlockStyle? leading;
static DefaultStyles getInstance(BuildContext context) {
final themeData = Theme.of(context);
final defaultTextStyle = DefaultTextStyle.of(context);
final baseStyle = defaultTextStyle.style.copyWith(
fontSize: 16,
height: 1.3,
);
const baseSpacing = Tuple2<double, double>(6, 0);
String fontFamily;
switch (themeData.platform) {
case TargetPlatform.iOS:
case TargetPlatform.macOS:
fontFamily = 'Menlo';
break;
case TargetPlatform.android:
case TargetPlatform.fuchsia:
case TargetPlatform.windows:
case TargetPlatform.linux:
fontFamily = 'Roboto Mono';
break;
default:
throw UnimplementedError();
}
return DefaultStyles(
h1: DefaultTextBlockStyle(
defaultTextStyle.style.copyWith(
fontSize: 34,
color: defaultTextStyle.style.color!.withOpacity(0.70),
height: 1.15,
fontWeight: FontWeight.w300,
),
const Tuple2(16, 0),
const Tuple2(0, 0),
null),
h2: DefaultTextBlockStyle(
defaultTextStyle.style.copyWith(
fontSize: 24,
color: defaultTextStyle.style.color!.withOpacity(0.70),
height: 1.15,
fontWeight: FontWeight.normal,
),
const Tuple2(8, 0),
const Tuple2(0, 0),
null),
h3: DefaultTextBlockStyle(
defaultTextStyle.style.copyWith(
fontSize: 20,
color: defaultTextStyle.style.color!.withOpacity(0.70),
height: 1.25,
fontWeight: FontWeight.w500,
),
const Tuple2(8, 0),
const Tuple2(0, 0),
null),
paragraph: DefaultTextBlockStyle(
baseStyle, const Tuple2(0, 0), const Tuple2(0, 0), null),
bold: const TextStyle(fontWeight: FontWeight.bold),
italic: const TextStyle(fontStyle: FontStyle.italic),
underline: const TextStyle(decoration: TextDecoration.underline),
strikeThrough: const TextStyle(decoration: TextDecoration.lineThrough),
link: TextStyle(
color: themeData.accentColor,
decoration: TextDecoration.underline,
),
placeHolder: DefaultTextBlockStyle(
defaultTextStyle.style.copyWith(
fontSize: 20,
height: 1.5,
color: Colors.grey.withOpacity(0.6),
),
const Tuple2(0, 0),
const Tuple2(0, 0),
null),
lists: DefaultTextBlockStyle(
baseStyle, baseSpacing, const Tuple2(0, 6), null),
quote: DefaultTextBlockStyle(
TextStyle(color: baseStyle.color!.withOpacity(0.6)),
baseSpacing,
const Tuple2(6, 2),
BoxDecoration(
border: Border(
left: BorderSide(width: 4, color: Colors.grey.shade300),
),
)),
code: DefaultTextBlockStyle(
TextStyle(
color: Colors.blue.shade900.withOpacity(0.9),
fontFamily: fontFamily,
fontSize: 13,
height: 1.15,
),
baseSpacing,
const Tuple2(0, 0),
BoxDecoration(
color: Colors.grey.shade50,
borderRadius: BorderRadius.circular(2),
)),
indent: DefaultTextBlockStyle(
baseStyle, baseSpacing, const Tuple2(0, 6), null),
align: DefaultTextBlockStyle(
baseStyle, const Tuple2(0, 0), const Tuple2(0, 0), null),
leading: DefaultTextBlockStyle(
baseStyle, const Tuple2(0, 0), const Tuple2(0, 0), null),
sizeSmall: const TextStyle(fontSize: 10),
sizeLarge: const TextStyle(fontSize: 18),
sizeHuge: const TextStyle(fontSize: 22));
}
DefaultStyles merge(DefaultStyles other) {
return DefaultStyles(
h1: other.h1 ?? h1,
h2: other.h2 ?? h2,
h3: other.h3 ?? h3,
paragraph: other.paragraph ?? paragraph,
bold: other.bold ?? bold,
italic: other.italic ?? italic,
underline: other.underline ?? underline,
strikeThrough: other.strikeThrough ?? strikeThrough,
link: other.link ?? link,
color: other.color ?? color,
placeHolder: other.placeHolder ?? placeHolder,
lists: other.lists ?? lists,
quote: other.quote ?? quote,
code: other.code ?? code,
indent: other.indent ?? indent,
align: other.align ?? align,
leading: other.leading ?? leading,
sizeSmall: other.sizeSmall ?? sizeSmall,
sizeLarge: other.sizeLarge ?? sizeLarge,
sizeHuge: other.sizeHuge ?? sizeHuge);
}
}

@ -0,0 +1,148 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import '../models/documents/nodes/leaf.dart';
import 'editor.dart';
import 'text_selection.dart';
typedef EmbedBuilder = Widget Function(BuildContext context, Embed node);
abstract class EditorTextSelectionGestureDetectorBuilderDelegate {
GlobalKey<EditorState> getEditableTextKey();
bool getForcePressEnabled();
bool getSelectionEnabled();
}
class EditorTextSelectionGestureDetectorBuilder {
EditorTextSelectionGestureDetectorBuilder(this.delegate);
final EditorTextSelectionGestureDetectorBuilderDelegate delegate;
bool shouldShowSelectionToolbar = true;
EditorState? getEditor() {
return delegate.getEditableTextKey().currentState;
}
RenderEditor? getRenderEditor() {
return getEditor()!.getRenderEditor();
}
void onTapDown(TapDownDetails details) {
getRenderEditor()!.handleTapDown(details);
final kind = details.kind;
shouldShowSelectionToolbar = kind == null ||
kind == PointerDeviceKind.touch ||
kind == PointerDeviceKind.stylus;
}
void onForcePressStart(ForcePressDetails details) {
assert(delegate.getForcePressEnabled());
shouldShowSelectionToolbar = true;
if (delegate.getSelectionEnabled()) {
getRenderEditor()!.selectWordsInRange(
details.globalPosition,
null,
SelectionChangedCause.forcePress,
);
}
}
void onForcePressEnd(ForcePressDetails details) {
assert(delegate.getForcePressEnabled());
getRenderEditor()!.selectWordsInRange(
details.globalPosition,
null,
SelectionChangedCause.forcePress,
);
if (shouldShowSelectionToolbar) {
getEditor()!.showToolbar();
}
}
void onSingleTapUp(TapUpDetails details) {
if (delegate.getSelectionEnabled()) {
getRenderEditor()!.selectWordEdge(SelectionChangedCause.tap);
}
}
void onSingleTapCancel() {}
void onSingleLongTapStart(LongPressStartDetails details) {
if (delegate.getSelectionEnabled()) {
getRenderEditor()!.selectPositionAt(
details.globalPosition,
null,
SelectionChangedCause.longPress,
);
}
}
void onSingleLongTapMoveUpdate(LongPressMoveUpdateDetails details) {
if (delegate.getSelectionEnabled()) {
getRenderEditor()!.selectPositionAt(
details.globalPosition,
null,
SelectionChangedCause.longPress,
);
}
}
void onSingleLongTapEnd(LongPressEndDetails details) {
if (shouldShowSelectionToolbar) {
getEditor()!.showToolbar();
}
}
void onDoubleTapDown(TapDownDetails details) {
if (delegate.getSelectionEnabled()) {
getRenderEditor()!.selectWord(SelectionChangedCause.tap);
if (shouldShowSelectionToolbar) {
getEditor()!.showToolbar();
}
}
}
void onDragSelectionStart(DragStartDetails details) {
getRenderEditor()!.selectPositionAt(
details.globalPosition,
null,
SelectionChangedCause.drag,
);
}
void onDragSelectionUpdate(
DragStartDetails startDetails, DragUpdateDetails updateDetails) {
getRenderEditor()!.selectPositionAt(
startDetails.globalPosition,
updateDetails.globalPosition,
SelectionChangedCause.drag,
);
}
void onDragSelectionEnd(DragEndDetails details) {}
Widget build(HitTestBehavior behavior, Widget child) {
return EditorTextSelectionGestureDetector(
onTapDown: onTapDown,
onForcePressStart:
delegate.getForcePressEnabled() ? onForcePressStart : null,
onForcePressEnd: delegate.getForcePressEnabled() ? onForcePressEnd : null,
onSingleTapUp: onSingleTapUp,
onSingleTapCancel: onSingleTapCancel,
onSingleLongTapStart: onSingleLongTapStart,
onSingleLongTapMoveUpdate: onSingleLongTapMoveUpdate,
onSingleLongTapEnd: onSingleLongTapEnd,
onDoubleTapDown: onDoubleTapDown,
onDragSelectionStart: onDragSelectionStart,
onDragSelectionUpdate: onDragSelectionUpdate,
onDragSelectionEnd: onDragSelectionEnd,
behavior: behavior,
child: child,
);
}
}

File diff suppressed because it is too large Load Diff

@ -0,0 +1,31 @@
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 {
const ImageTapWrapper({
this.imageProvider,
});
final ImageProvider? imageProvider;
@override
Widget build(BuildContext context) {
return Scaffold(
body: Container(
constraints: BoxConstraints.expand(
height: MediaQuery.of(context).size.height,
),
child: GestureDetector(
onTapDown: (_) {
Navigator.pop(context);
},
child: PhotoView(
imageProvider: imageProvider,
),
),
),
);
}
}

@ -0,0 +1,105 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/services.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,
};
bool handleRawKeyEvent(RawKeyEvent event) {
if (kIsWeb) {
// On web platform, we should ignore the key because it's processed already.
return false;
}
if (event is! RawKeyDownEvent) {
return false;
}
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 false;
}
if (_moveKeys.contains(key)) {
onCursorMove(
key,
isMacOS ? event.isAltPressed : event.isControlPressed,
isMacOS ? event.isMetaPressed : event.isAltPressed,
event.isShiftPressed);
} else if (isMacOS
? event.isMetaPressed
: event.isControlPressed && _shortcutKeys.contains(key)) {
onShortcut(_keyToShortcut[key]);
} else if (key == LogicalKeyboardKey.delete) {
onDelete(true);
} else if (key == LogicalKeyboardKey.backspace) {
onDelete(false);
}
return false;
}
}

@ -0,0 +1,298 @@
import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart';
import 'box.dart';
class BaselineProxy extends SingleChildRenderObjectWidget {
const BaselineProxy({Key? key, Widget? child, this.textStyle, this.padding})
: super(key: key, child: child);
final TextStyle? textStyle;
final EdgeInsets? padding;
@override
RenderBaselineProxy createRenderObject(BuildContext context) {
return RenderBaselineProxy(
null,
textStyle!,
padding,
);
}
@override
void updateRenderObject(
BuildContext context, covariant RenderBaselineProxy renderObject) {
renderObject
..textStyle = textStyle!
..padding = padding!;
}
}
class RenderBaselineProxy extends RenderProxyBox {
RenderBaselineProxy(
RenderParagraph? child,
TextStyle textStyle,
EdgeInsets? padding,
) : _prototypePainter = TextPainter(
text: TextSpan(text: ' ', style: textStyle),
textDirection: TextDirection.ltr,
strutStyle:
StrutStyle.fromTextStyle(textStyle, forceStrutHeight: true)),
super(child);
final TextPainter _prototypePainter;
set textStyle(TextStyle value) {
if (_prototypePainter.text!.style == value) {
return;
}
_prototypePainter.text = TextSpan(text: ' ', style: value);
markNeedsLayout();
}
EdgeInsets? _padding;
set padding(EdgeInsets value) {
if (_padding == value) {
return;
}
_padding = value;
markNeedsLayout();
}
@override
double computeDistanceToActualBaseline(TextBaseline baseline) =>
_prototypePainter.computeDistanceToActualBaseline(baseline);
// SEE What happens + _padding?.top;
@override
void performLayout() {
super.performLayout();
_prototypePainter.layout();
}
}
class EmbedProxy extends SingleChildRenderObjectWidget {
const EmbedProxy(Widget child) : super(child: child);
@override
RenderEmbedProxy createRenderObject(BuildContext context) =>
RenderEmbedProxy(null);
}
class RenderEmbedProxy extends RenderProxyBox implements RenderContentProxyBox {
RenderEmbedProxy(RenderBox? child) : super(child);
@override
List<TextBox> getBoxesForSelection(TextSelection selection) {
if (!selection.isCollapsed) {
return <TextBox>[
TextBox.fromLTRBD(0, 0, size.width, size.height, TextDirection.ltr)
];
}
final left = selection.extentOffset == 0 ? 0.0 : size.width;
final right = selection.extentOffset == 0 ? 0.0 : size.width;
return <TextBox>[
TextBox.fromLTRBD(left, 0, right, size.height, TextDirection.ltr)
];
}
@override
double getFullHeightForCaret(TextPosition position) => size.height;
@override
Offset getOffsetForCaret(TextPosition position, Rect? caretPrototype) {
assert(position.offset <= 1 && position.offset >= 0);
return position.offset == 0 ? Offset.zero : Offset(size.width, 0);
}
@override
TextPosition getPositionForOffset(Offset offset) =>
TextPosition(offset: offset.dx > size.width / 2 ? 1 : 0);
@override
TextRange getWordBoundary(TextPosition position) =>
const TextRange(start: 0, end: 1);
@override
double getPreferredLineHeight() {
return size.height;
}
}
class RichTextProxy extends SingleChildRenderObjectWidget {
const RichTextProxy(
RichText child,
this.textStyle,
this.textAlign,
this.textDirection,
this.textScaleFactor,
this.locale,
this.strutStyle,
this.textWidthBasis,
this.textHeightBehavior,
) : super(child: child);
final TextStyle textStyle;
final TextAlign textAlign;
final TextDirection textDirection;
final double textScaleFactor;
final Locale locale;
final StrutStyle strutStyle;
final TextWidthBasis textWidthBasis;
final TextHeightBehavior? textHeightBehavior;
@override
RenderParagraphProxy createRenderObject(BuildContext context) {
return RenderParagraphProxy(
null,
textStyle,
textAlign,
textDirection,
textScaleFactor,
strutStyle,
locale,
textWidthBasis,
textHeightBehavior);
}
@override
void updateRenderObject(
BuildContext context, covariant RenderParagraphProxy renderObject) {
renderObject
..textStyle = textStyle
..textAlign = textAlign
..textDirection = textDirection
..textScaleFactor = textScaleFactor
..locale = locale
..strutStyle = strutStyle
..textWidthBasis = textWidthBasis
..textHeightBehavior = textHeightBehavior;
}
}
class RenderParagraphProxy extends RenderProxyBox
implements RenderContentProxyBox {
RenderParagraphProxy(
RenderParagraph? child,
TextStyle textStyle,
TextAlign textAlign,
TextDirection textDirection,
double textScaleFactor,
StrutStyle strutStyle,
Locale locale,
TextWidthBasis textWidthBasis,
TextHeightBehavior? textHeightBehavior,
) : _prototypePainter = TextPainter(
text: TextSpan(text: ' ', style: textStyle),
textAlign: textAlign,
textDirection: textDirection,
textScaleFactor: textScaleFactor,
strutStyle: strutStyle,
locale: locale,
textWidthBasis: textWidthBasis,
textHeightBehavior: textHeightBehavior),
super(child);
final TextPainter _prototypePainter;
set textStyle(TextStyle value) {
if (_prototypePainter.text!.style == value) {
return;
}
_prototypePainter.text = TextSpan(text: ' ', style: value);
markNeedsLayout();
}
set textAlign(TextAlign value) {
if (_prototypePainter.textAlign == value) {
return;
}
_prototypePainter.textAlign = value;
markNeedsLayout();
}
set textDirection(TextDirection value) {
if (_prototypePainter.textDirection == value) {
return;
}
_prototypePainter.textDirection = value;
markNeedsLayout();
}
set textScaleFactor(double value) {
if (_prototypePainter.textScaleFactor == value) {
return;
}
_prototypePainter.textScaleFactor = value;
markNeedsLayout();
}
set strutStyle(StrutStyle value) {
if (_prototypePainter.strutStyle == value) {
return;
}
_prototypePainter.strutStyle = value;
markNeedsLayout();
}
set locale(Locale value) {
if (_prototypePainter.locale == value) {
return;
}
_prototypePainter.locale = value;
markNeedsLayout();
}
set textWidthBasis(TextWidthBasis value) {
if (_prototypePainter.textWidthBasis == value) {
return;
}
_prototypePainter.textWidthBasis = value;
markNeedsLayout();
}
set textHeightBehavior(TextHeightBehavior? value) {
if (_prototypePainter.textHeightBehavior == value) {
return;
}
_prototypePainter.textHeightBehavior = value;
markNeedsLayout();
}
@override
RenderParagraph? get child => super.child as RenderParagraph?;
@override
double getPreferredLineHeight() {
return _prototypePainter.preferredLineHeight;
}
@override
Offset getOffsetForCaret(TextPosition position, Rect? caretPrototype) =>
child!.getOffsetForCaret(position, caretPrototype!);
@override
TextPosition getPositionForOffset(Offset offset) =>
child!.getPositionForOffset(offset);
@override
double? getFullHeightForCaret(TextPosition position) =>
child!.getFullHeightForCaret(position);
@override
TextRange getWordBoundary(TextPosition position) =>
child!.getWordBoundary(position);
@override
List<TextBox> getBoxesForSelection(TextSelection selection) =>
child!.getBoxesForSelection(selection);
@override
void performLayout() {
super.performLayout();
_prototypePainter.layout(
minWidth: constraints.minWidth, maxWidth: constraints.maxWidth);
}
}

@ -0,0 +1,736 @@
import 'dart:async';
import 'dart:convert';
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 'package:flutter/services.dart';
import 'package:flutter_keyboard_visibility/flutter_keyboard_visibility.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/line.dart';
import 'controller.dart';
import 'cursor.dart';
import 'default_styles.dart';
import 'delegate.dart';
import 'editor.dart';
import 'keyboard_listener.dart';
import 'proxy.dart';
import 'raw_editor/raw_editor_state_keyboard_mixin.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';
import 'text_line.dart';
import 'text_selection.dart';
class RawEditor extends StatefulWidget {
const RawEditor(
Key key,
this.controller,
this.focusNode,
this.scrollController,
this.scrollable,
this.scrollBottomInset,
this.padding,
this.readOnly,
this.placeholder,
this.onLaunchUrl,
this.toolbarOptions,
this.showSelectionHandles,
bool? showCursor,
this.cursorStyle,
this.textCapitalization,
this.maxHeight,
this.minHeight,
this.customStyles,
this.expands,
this.autoFocus,
this.selectionColor,
this.selectionCtrls,
this.keyboardAppearance,
this.enableInteractiveSelection,
this.scrollPhysics,
this.embedBuilder,
) : 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);
final QuillController controller;
final FocusNode focusNode;
final ScrollController scrollController;
final bool scrollable;
final double scrollBottomInset;
final EdgeInsetsGeometry padding;
final bool readOnly;
final String? placeholder;
final ValueChanged<String>? onLaunchUrl;
final ToolbarOptions toolbarOptions;
final bool showSelectionHandles;
final bool showCursor;
final CursorStyle cursorStyle;
final TextCapitalization textCapitalization;
final double? maxHeight;
final double? minHeight;
final DefaultStyles? customStyles;
final bool expands;
final bool autoFocus;
final Color selectionColor;
final TextSelectionControls selectionCtrls;
final Brightness keyboardAppearance;
final bool enableInteractiveSelection;
final ScrollPhysics? scrollPhysics;
final EmbedBuilder embedBuilder;
@override
State<StatefulWidget> createState() => RawEditorState();
}
class RawEditorState extends EditorState
with
AutomaticKeepAliveClientMixin<RawEditor>,
WidgetsBindingObserver,
TickerProviderStateMixin<RawEditor>,
RawEditorStateKeyboardMixin,
RawEditorStateTextInputClientMixin,
RawEditorStateSelectionDelegateMixin {
final GlobalKey _editorKey = GlobalKey();
// Keyboard
late KeyboardListener _keyboardListener;
KeyboardVisibilityController? _keyboardVisibilityController;
StreamSubscription<bool>? _keyboardVisibilitySubscription;
bool _keyboardVisible = false;
// Selection overlay
@override
EditorTextSelectionOverlay? getSelectionOverlay() => _selectionOverlay;
EditorTextSelectionOverlay? _selectionOverlay;
ScrollController? _scrollController;
late CursorCont _cursorCont;
// Focus
bool _didAutoFocus = false;
FocusAttachment? _focusAttachment;
bool get _hasFocus => widget.focusNode.hasFocus;
DefaultStyles? _styles;
final ClipboardStatusNotifier _clipboardStatus = ClipboardStatusNotifier();
final LayerLink _toolbarLayerLink = LayerLink();
final LayerLink _startHandleLayerLink = LayerLink();
final LayerLink _endHandleLayerLink = LayerLink();
TextDirection get _textDirection => Directionality.of(context);
@override
Widget build(BuildContext context) {
assert(debugCheckHasMediaQuery(context));
_focusAttachment!.reparent();
super.build(context);
var _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: _Editor(
key: _editorKey,
document: _doc,
selection: widget.controller.selection,
hasFocus: _hasFocus,
textDirection: _textDirection,
startHandleLayerLink: _startHandleLayerLink,
endHandleLayerLink: _endHandleLayerLink,
onSelectionChanged: _handleSelectionChanged,
scrollBottomInset: widget.scrollBottomInset,
padding: widget.padding,
children: _buildChildren(_doc, context),
),
),
);
if (widget.scrollable) {
final baselinePadding =
EdgeInsets.only(top: _styles!.paragraph!.verticalSpacing.item1);
child = BaselineProxy(
textStyle: _styles!.paragraph!.style,
padding: baselinePadding,
child: SingleChildScrollView(
controller: _scrollController,
physics: widget.scrollPhysics,
child: child,
),
);
}
final constraints = widget.expands
? const BoxConstraints.expand()
: BoxConstraints(
minHeight: widget.minHeight ?? 0.0,
maxHeight: widget.maxHeight ?? double.infinity);
return QuillStyles(
data: _styles!,
child: MouseRegion(
cursor: SystemMouseCursors.text,
child: Container(
constraints: constraints,
child: child,
),
),
);
}
void _handleSelectionChanged(
TextSelection selection, SelectionChangedCause cause) {
widget.controller.updateSelection(selection, ChangeSource.LOCAL);
_selectionOverlay?.handlesVisible = _shouldShowSelectionHandles();
if (!_keyboardVisible) {
requestKeyboard();
}
}
/// 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);
}
}
}
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(
node,
_textDirection,
widget.scrollBottomInset,
_getVerticalSpacingForBlock(node, _styles),
widget.controller.selection,
widget.selectionColor,
_styles,
widget.enableInteractiveSelection,
_hasFocus,
attrs.containsKey(Attribute.codeBlock.key)
? const EdgeInsets.all(16)
: null,
widget.embedBuilder,
_cursorCont,
indentLevelCounts,
_handleCheckboxTap,
);
result.add(editableTextBlock);
} else {
throw StateError('Unreachable.');
}
}
return result;
}
EditableTextLine _getEditableTextLineFromNode(
Line node, BuildContext context) {
final textLine = TextLine(
line: node,
textDirection: _textDirection,
embedBuilder: widget.embedBuilder,
styles: _styles!,
);
final editableTextLine = EditableTextLine(
node,
null,
textLine,
0,
_getVerticalSpacingForLine(node, _styles),
_textDirection,
widget.controller.selection,
widget.selectionColor,
widget.enableInteractiveSelection,
_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;
}
@override
void initState() {
super.initState();
_clipboardStatus.addListener(_onChangedClipboardStatus);
widget.controller.addListener(() {
_didChangeTextEditingValue(widget.controller.ignoreFocusOnTextChange);
});
_scrollController = widget.scrollController;
_scrollController!.addListener(_updateSelectionOverlayForScroll);
_cursorCont = CursorCont(
show: ValueNotifier<bool>(widget.showCursor),
style: widget.cursorStyle,
tickerProvider: this,
);
_keyboardListener = KeyboardListener(
handleCursorMovement,
handleShortcut,
handleDelete,
);
if (defaultTargetPlatform == TargetPlatform.windows ||
defaultTargetPlatform == TargetPlatform.macOS ||
defaultTargetPlatform == TargetPlatform.linux ||
defaultTargetPlatform == TargetPlatform.fuchsia) {
_keyboardVisible = true;
} else {
_keyboardVisibilityController = KeyboardVisibilityController();
_keyboardVisible = _keyboardVisibilityController!.isVisible;
_keyboardVisibilitySubscription =
_keyboardVisibilityController?.onChange.listen((visible) {
_keyboardVisible = visible;
if (visible) {
_onChangeTextEditingValue();
}
});
}
_focusAttachment = widget.focusNode.attach(context,
onKey: (node, event) => _keyboardListener.handleRawKeyEvent(event));
widget.focusNode.addListener(_handleFocusChanged);
}
@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!);
}
if (!_didAutoFocus && widget.autoFocus) {
FocusScope.of(context).autofocus(widget.focusNode);
_didAutoFocus = true;
}
}
@override
void didUpdateWidget(RawEditor oldWidget) {
super.didUpdateWidget(oldWidget);
_cursorCont.show.value = widget.showCursor;
_cursorCont.style = widget.cursorStyle;
if (widget.controller != oldWidget.controller) {
oldWidget.controller.removeListener(_didChangeTextEditingValue);
widget.controller.addListener(_didChangeTextEditingValue);
updateRemoteValueIfNeeded();
}
if (widget.scrollController != _scrollController) {
_scrollController!.removeListener(_updateSelectionOverlayForScroll);
_scrollController = widget.scrollController;
_scrollController!.addListener(_updateSelectionOverlayForScroll);
}
if (widget.focusNode != oldWidget.focusNode) {
oldWidget.focusNode.removeListener(_handleFocusChanged);
_focusAttachment?.detach();
_focusAttachment = widget.focusNode.attach(context,
onKey: (node, event) => _keyboardListener.handleRawKeyEvent(event));
widget.focusNode.addListener(_handleFocusChanged);
updateKeepAlive();
}
if (widget.controller.selection != oldWidget.controller.selection) {
_selectionOverlay?.update(textEditingValue);
}
_selectionOverlay?.handlesVisible = _shouldShowSelectionHandles();
if (!shouldCreateInputConnection) {
closeConnectionIfNeeded();
} else {
if (oldWidget.readOnly && _hasFocus) {
openConnectionIfNeeded();
}
}
}
bool _shouldShowSelectionHandles() {
return widget.showSelectionHandles &&
!widget.controller.selection.isCollapsed;
}
@override
void dispose() {
closeConnectionIfNeeded();
_keyboardVisibilitySubscription?.cancel();
assert(!hasConnection);
_selectionOverlay?.dispose();
_selectionOverlay = null;
widget.controller.removeListener(_didChangeTextEditingValue);
widget.focusNode.removeListener(_handleFocusChanged);
_focusAttachment!.detach();
_cursorCont.dispose();
_clipboardStatus
..removeListener(_onChangedClipboardStatus)
..dispose();
super.dispose();
}
void _updateSelectionOverlayForScroll() {
_selectionOverlay?.markNeedsBuild();
}
void _didChangeTextEditingValue([bool ignoreFocus = false]) {
if (kIsWeb) {
_onChangeTextEditingValue(ignoreFocus);
if (!ignoreFocus) {
requestKeyboard();
}
return;
}
if (ignoreFocus || _keyboardVisible) {
_onChangeTextEditingValue(ignoreFocus);
} else {
requestKeyboard();
if (mounted) {
setState(() {
// Use widget.controller.value in build()
// Trigger build and updateChildren
});
}
}
}
void _onChangeTextEditingValue([bool ignoreCaret = false]) {
updateRemoteValueIfNeeded();
if (ignoreCaret) {
return;
}
_showCaretOnScreen();
_cursorCont.startOrStopCursorTimerIfNeeded(
_hasFocus, widget.controller.selection);
if (hasConnection) {
_cursorCont
..stopCursorTimer(resetCharTicks: false)
..startCursorTimer();
}
SchedulerBinding.instance!.addPostFrameCallback(
(_) => _updateOrDisposeSelectionOverlayIfNeeded());
if (mounted) {
setState(() {
// Use widget.controller.value in build()
// Trigger build and updateChildren
});
}
}
void _updateOrDisposeSelectionOverlayIfNeeded() {
if (_selectionOverlay != null) {
if (_hasFocus) {
_selectionOverlay!.update(textEditingValue);
} else {
_selectionOverlay!.dispose();
_selectionOverlay = null;
}
} else if (_hasFocus) {
_selectionOverlay?.hide();
_selectionOverlay = null;
_selectionOverlay = EditorTextSelectionOverlay(
textEditingValue,
false,
context,
widget,
_toolbarLayerLink,
_startHandleLayerLink,
_endHandleLayerLink,
getRenderEditor(),
widget.selectionCtrls,
this,
DragStartBehavior.start,
null,
_clipboardStatus,
);
_selectionOverlay!.handlesVisible = _shouldShowSelectionHandles();
_selectionOverlay!.showHandles();
}
}
void _handleFocusChanged() {
openOrCloseConnection();
_cursorCont.startOrStopCursorTimerIfNeeded(
_hasFocus, widget.controller.selection);
_updateOrDisposeSelectionOverlayIfNeeded();
if (_hasFocus) {
WidgetsBinding.instance!.addObserver(this);
_showCaretOnScreen();
} else {
WidgetsBinding.instance!.removeObserver(this);
}
updateKeepAlive();
}
void _onChangedClipboardStatus() {
if (!mounted) return;
setState(() {
// Inform the widget that the value of clipboardStatus has changed.
// Trigger build and updateChildren
});
}
bool _showCaretOnScreenScheduled = false;
void _showCaretOnScreen() {
if (!widget.showCursor || _showCaretOnScreenScheduled) {
return;
}
_showCaretOnScreenScheduled = true;
SchedulerBinding.instance!.addPostFrameCallback((_) {
if (widget.scrollable) {
_showCaretOnScreenScheduled = false;
final viewport = RenderAbstractViewport.of(getRenderEditor());
final editorOffset = getRenderEditor()!
.localToGlobal(const Offset(0, 0), ancestor: viewport);
final offsetInViewport = _scrollController!.offset + editorOffset.dy;
final offset = getRenderEditor()!.getOffsetToRevealCursor(
_scrollController!.position.viewportDimension,
_scrollController!.offset,
offsetInViewport,
);
if (offset != null) {
_scrollController!.animateTo(
offset,
duration: const Duration(milliseconds: 100),
curve: Curves.fastOutSlowIn,
);
}
}
});
}
@override
RenderEditor? getRenderEditor() {
return _editorKey.currentContext!.findRenderObject() as RenderEditor?;
}
@override
TextEditingValue getTextEditingValue() {
return widget.controller.plainTextEditingValue;
}
@override
void requestKeyboard() {
if (_hasFocus) {
openConnectionIfNeeded();
} else {
widget.focusNode.requestFocus();
}
}
@override
void setTextEditingValue(TextEditingValue value) {
if (value.text == textEditingValue.text) {
widget.controller.updateSelection(value.selection, ChangeSource.LOCAL);
} else {
__setEditingValue(value);
}
}
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);
}
}
}
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
bool showToolbar() {
// Web is using native dom elements to enable clipboard functionality of the
// toolbar: copy, paste, select, cut. It might also provide additional
// functionality depending on the browser (such as translate). Due to this
// we should not show a Flutter toolbar for the editable text elements.
if (kIsWeb) {
return false;
}
if (_selectionOverlay == null || _selectionOverlay!.toolbar != null) {
return false;
}
_selectionOverlay!.update(textEditingValue);
_selectionOverlay!.showToolbar();
return true;
}
@override
bool get wantKeepAlive => widget.focusNode.hasFocus;
@override
void userUpdateTextEditingValue(
TextEditingValue value, SelectionChangedCause cause) {
// TODO: implement userUpdateTextEditingValue
}
}
class _Editor extends MultiChildRenderObjectWidget {
_Editor({
required Key key,
required List<Widget> children,
required this.document,
required this.textDirection,
required this.hasFocus,
required this.selection,
required this.startHandleLayerLink,
required this.endHandleLayerLink,
required this.onSelectionChanged,
required this.scrollBottomInset,
this.padding = EdgeInsets.zero,
}) : super(key: key, children: children);
final Document document;
final TextDirection textDirection;
final bool hasFocus;
final TextSelection selection;
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,
selection,
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
..setHasFocus(hasFocus)
..setSelection(selection)
..setStartHandleLayerLink(startHandleLayerLink)
..setEndHandleLayerLink(endHandleLayerLink)
..onSelectionChanged = onSelectionChanged
..setScrollBottomInset(scrollBottomInset)
..setPadding(padding);
}
}

@ -0,0 +1,354 @@
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)
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));
}
}

@ -0,0 +1,40 @@
import 'package:flutter/widgets.dart';
import '../editor.dart';
mixin RawEditorStateSelectionDelegateMixin on EditorState
implements TextSelectionDelegate {
@override
TextEditingValue get textEditingValue {
return getTextEditingValue();
}
@override
set textEditingValue(TextEditingValue value) {
setTextEditingValue(value);
}
@override
void bringIntoView(TextPosition position) {
// TODO: implement bringIntoView
}
@override
void hideToolbar([bool hideHandles = true]) {
if (getSelectionOverlay()?.toolbar != null) {
getSelectionOverlay()?.hideToolbar();
}
}
@override
bool get cutEnabled => widget.toolbarOptions.cut && !widget.readOnly;
@override
bool get copyEnabled => widget.toolbarOptions.copy;
@override
bool get pasteEnabled => widget.toolbarOptions.paste && !widget.readOnly;
@override
bool get selectAllEnabled => widget.toolbarOptions.selectAll;
}

@ -0,0 +1,200 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import '../../utils/diff_delta.dart';
import '../editor.dart';
mixin RawEditorStateTextInputClientMixin on EditorState
implements TextInputClient {
final List<TextEditingValue> _sentRemoteValues = [];
TextInputConnection? _textInputConnection;
TextEditingValue? _lastKnownRemoteTextEditingValue;
/// Whether to create an input connection with the platform for text editing
/// or not.
///
/// Read-only input fields do not need a connection with the platform since
/// there's no need for text editing capabilities (e.g. virtual keyboard).
///
/// On the web, we always need a connection because we want some browser
/// functionalities to continue to work on read-only input fields like:
///
/// - Relevant context menu.
/// - cmd/ctrl+c shortcut to copy.
/// - cmd/ctrl+a to select all.
/// - Changing the selection using a physical keyboard.
bool get shouldCreateInputConnection => kIsWeb || !widget.readOnly;
/// Returns `true` if there is open input connection.
bool get hasConnection =>
_textInputConnection != null && _textInputConnection!.attached;
/// Opens or closes input connection based on the current state of
/// [focusNode] and [value].
void openOrCloseConnection() {
if (widget.focusNode.hasFocus && widget.focusNode.consumeKeyboardToken()) {
openConnectionIfNeeded();
} else if (!widget.focusNode.hasFocus) {
closeConnectionIfNeeded();
}
}
void openConnectionIfNeeded() {
if (!shouldCreateInputConnection) {
return;
}
if (!hasConnection) {
_lastKnownRemoteTextEditingValue = getTextEditingValue();
_textInputConnection = TextInput.attach(
this,
TextInputConfiguration(
inputType: TextInputType.multiline,
readOnly: widget.readOnly,
inputAction: TextInputAction.newline,
enableSuggestions: !widget.readOnly,
keyboardAppearance: widget.keyboardAppearance,
textCapitalization: widget.textCapitalization,
),
);
_textInputConnection!.setEditingState(_lastKnownRemoteTextEditingValue!);
// _sentRemoteValues.add(_lastKnownRemoteTextEditingValue);
}
_textInputConnection!.show();
}
/// Closes input connection if it's currently open. Otherwise does nothing.
void closeConnectionIfNeeded() {
if (!hasConnection) {
return;
}
_textInputConnection!.close();
_textInputConnection = null;
_lastKnownRemoteTextEditingValue = null;
_sentRemoteValues.clear();
}
/// Updates remote value based on current state of [document] and
/// [selection].
///
/// This method may not actually send an update to native side if it thinks
/// remote value is up to date or identical.
void updateRemoteValueIfNeeded() {
if (!hasConnection) {
return;
}
// 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(
composing: _lastKnownRemoteTextEditingValue!.composing,
);
if (actualValue == _lastKnownRemoteTextEditingValue) {
return;
}
final shouldRemember =
getTextEditingValue().text != _lastKnownRemoteTextEditingValue!.text;
_lastKnownRemoteTextEditingValue = actualValue;
_textInputConnection!.setEditingState(actualValue);
if (shouldRemember) {
// Only keep track if text changed (selection changes are not relevant)
_sentRemoteValues.add(actualValue);
}
}
@override
TextEditingValue? get currentTextEditingValue =>
_lastKnownRemoteTextEditingValue;
// autofill is not needed
@override
AutofillScope? get currentAutofillScope => null;
@override
void updateEditingValue(TextEditingValue value) {
if (!shouldCreateInputConnection) {
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;
}
// Check if only composing range changed.
if (_lastKnownRemoteTextEditingValue!.text == value.text &&
_lastKnownRemoteTextEditingValue!.selection == value.selection) {
// This update only modifies composing range. Since we don't keep track
// of composing range we just need to update last known value here.
// This check fixes an issue on Android when it sends
// composing updates separately from regular changes for text and
// selection.
_lastKnownRemoteTextEditingValue = value;
return;
}
final effectiveLastKnownValue = _lastKnownRemoteTextEditingValue!;
_lastKnownRemoteTextEditingValue = value;
final oldText = effectiveLastKnownValue.text;
final text = value.text;
final cursorPosition = value.selection.extentOffset;
final diff = getDiff(oldText, text, cursorPosition);
widget.controller.replaceText(
diff.start, diff.deleted.length, diff.inserted, value.selection);
}
@override
void performAction(TextInputAction action) {
// no-op
}
@override
void performPrivateCommand(String action, Map<String, dynamic> data) {
// no-op
}
@override
void updateFloatingCursor(RawFloatingCursorPoint point) {
throw UnimplementedError();
}
@override
void showAutocorrectionPromptRect(int start, int end) {
throw UnimplementedError();
}
@override
void connectionClosed() {
if (!hasConnection) {
return;
}
_textInputConnection!.connectionClosedReceived();
_textInputConnection = null;
_lastKnownRemoteTextEditingValue = null;
_sentRemoteValues.clear();
}
}

@ -0,0 +1,43 @@
import 'package:flutter/material.dart';
class ResponsiveWidget extends StatelessWidget {
const ResponsiveWidget({
required this.largeScreen,
this.mediumScreen,
this.smallScreen,
Key? key,
}) : super(key: key);
final Widget largeScreen;
final Widget? mediumScreen;
final Widget? smallScreen;
static bool isSmallScreen(BuildContext context) {
return MediaQuery.of(context).size.width < 800;
}
static bool isLargeScreen(BuildContext context) {
return MediaQuery.of(context).size.width > 1200;
}
static bool isMediumScreen(BuildContext context) {
return MediaQuery.of(context).size.width >= 800 &&
MediaQuery.of(context).size.width <= 1200;
}
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
if (constraints.maxWidth > 1200) {
return largeScreen;
} else if (constraints.maxWidth <= 1200 &&
constraints.maxWidth >= 800) {
return mediumScreen ?? largeScreen;
} else {
return smallScreen ?? largeScreen;
}
},
);
}
}

@ -0,0 +1,344 @@
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';
class QuillSimpleViewer extends StatefulWidget {
const QuillSimpleViewer({
required this.controller,
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;
@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) {
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));
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(
node,
_textDirection,
widget.scrollBottomInset,
_getVerticalSpacingForBlock(node, _styles),
widget.controller.selection,
Colors.black,
// selectionColor,
_styles,
false,
// enableInteractiveSelection,
false,
// hasFocus,
attrs.containsKey(Attribute.codeBlock.key)
? const EdgeInsets.all(16)
: null,
embedBuilder,
_cursorCont,
indentLevelCounts,
_handleCheckboxTap);
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,
);
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,737 @@
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 '../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 'text_line.dart';
import 'text_selection.dart';
const List<int> arabianRomanNumbers = [
1000,
900,
500,
400,
100,
90,
50,
40,
10,
9,
5,
4,
1
];
const List<String> romanNumbers = [
'M',
'CM',
'D',
'CD',
'C',
'XC',
'L',
'XL',
'X',
'IX',
'V',
'IV',
'I'
];
class EditableTextBlock extends StatelessWidget {
const EditableTextBlock(
this.block,
this.textDirection,
this.scrollBottomInset,
this.verticalSpacing,
this.textSelection,
this.color,
this.styles,
this.enableInteractiveSelection,
this.hasFocus,
this.contentPadding,
this.embedBuilder,
this.cursorCont,
this.indentLevelCounts,
this.onCheckboxTap,
);
final Block block;
final TextDirection textDirection;
final double scrollBottomInset;
final Tuple2 verticalSpacing;
final TextSelection textSelection;
final Color color;
final DefaultStyles? styles;
final bool enableInteractiveSelection;
final bool hasFocus;
final EdgeInsets? contentPadding;
final EmbedBuilder embedBuilder;
final CursorCont cursorCont;
final Map<int, int> indentLevelCounts;
final Function(int, bool) onCheckboxTap;
@override
Widget build(BuildContext context) {
assert(debugCheckHasMediaQuery(context));
final defaultStyles = QuillStyles.getStyles(context, false);
return _EditableBlock(
block,
textDirection,
verticalSpacing as Tuple2<double, double>,
scrollBottomInset,
_getDecorationForBlock(block, defaultStyles) ?? const BoxDecoration(),
contentPadding,
_buildChildren(context, indentLevelCounts));
}
BoxDecoration? _getDecorationForBlock(
Block node, DefaultStyles? defaultStyles) {
final attrs = block.style.attributes;
if (attrs.containsKey(Attribute.blockQuote.key)) {
return defaultStyles!.quote!.decoration;
}
if (attrs.containsKey(Attribute.codeBlock.key)) {
return defaultStyles!.code!.decoration;
}
return null;
}
List<Widget> _buildChildren(
BuildContext context, Map<int, int> indentLevelCounts) {
final defaultStyles = QuillStyles.getStyles(context, false);
final count = block.children.length;
final children = <Widget>[];
var index = 0;
for (final line in Iterable.castFrom<dynamic, Line>(block.children)) {
index++;
final editableTextLine = EditableTextLine(
line,
_buildLeading(context, line, index, indentLevelCounts, count),
TextLine(
line: line,
textDirection: textDirection,
embedBuilder: embedBuilder,
styles: styles!,
),
_getIndentWidth(),
_getSpacingForLine(line, index, count, defaultStyles),
textDirection,
textSelection,
color,
enableInteractiveSelection,
hasFocus,
MediaQuery.of(context).devicePixelRatio,
cursorCont);
children.add(editableTextLine);
}
return children.toList(growable: false);
}
Widget? _buildLeading(BuildContext context, Line line, int index,
Map<int, int> indentLevelCounts, int count) {
final defaultStyles = QuillStyles.getStyles(context, false);
final attrs = line.style.attributes;
if (attrs[Attribute.list.key] == Attribute.ol) {
return _NumberPoint(
index: index,
indentLevelCounts: indentLevelCounts,
count: count,
style: defaultStyles!.leading!.style,
attrs: attrs,
width: 32,
padding: 8,
);
}
if (attrs[Attribute.list.key] == Attribute.ul) {
return _BulletPoint(
style:
defaultStyles!.leading!.style.copyWith(fontWeight: FontWeight.bold),
width: 32,
);
}
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,
);
}
if (attrs[Attribute.list.key] == Attribute.unchecked) {
return _Checkbox(
key: UniqueKey(),
style: defaultStyles!.leading!.style,
width: 32,
offset: block.offset + line.offset,
onTap: onCheckboxTap,
);
}
if (attrs.containsKey(Attribute.codeBlock.key)) {
return _NumberPoint(
index: index,
indentLevelCounts: indentLevelCounts,
count: count,
style: defaultStyles!.code!.style
.copyWith(color: defaultStyles.code!.style.color!.withOpacity(0.4)),
width: 32,
attrs: attrs,
padding: 16,
withDot: false,
);
}
return null;
}
double _getIndentWidth() {
final attrs = block.style.attributes;
final indent = attrs[Attribute.indent.key];
var extraIndent = 0.0;
if (indent != null && indent.value != null) {
extraIndent = 16.0 * indent.value;
}
if (attrs.containsKey(Attribute.blockQuote.key)) {
return 16.0 + extraIndent;
}
return 32.0 + extraIndent;
}
Tuple2 _getSpacingForLine(
Line node, int index, int count, DefaultStyles? defaultStyles) {
var top = 0.0, bottom = 0.0;
final attrs = block.style.attributes;
if (attrs.containsKey(Attribute.header.key)) {
final level = attrs[Attribute.header.key]!.value;
switch (level) {
case 1:
top = defaultStyles!.h1!.verticalSpacing.item1;
bottom = defaultStyles.h1!.verticalSpacing.item2;
break;
case 2:
top = defaultStyles!.h2!.verticalSpacing.item1;
bottom = defaultStyles.h2!.verticalSpacing.item2;
break;
case 3:
top = defaultStyles!.h3!.verticalSpacing.item1;
bottom = defaultStyles.h3!.verticalSpacing.item2;
break;
default:
throw 'Invalid level $level';
}
} else {
late Tuple2 lineSpacing;
if (attrs.containsKey(Attribute.blockQuote.key)) {
lineSpacing = defaultStyles!.quote!.lineSpacing;
} else if (attrs.containsKey(Attribute.indent.key)) {
lineSpacing = defaultStyles!.indent!.lineSpacing;
} else if (attrs.containsKey(Attribute.list.key)) {
lineSpacing = defaultStyles!.lists!.lineSpacing;
} else if (attrs.containsKey(Attribute.codeBlock.key)) {
lineSpacing = defaultStyles!.code!.lineSpacing;
} else if (attrs.containsKey(Attribute.align.key)) {
lineSpacing = defaultStyles!.align!.lineSpacing;
}
top = lineSpacing.item1;
bottom = lineSpacing.item2;
}
if (index == 1) {
top = 0.0;
}
if (index == count) {
bottom = 0.0;
}
return Tuple2(top, bottom);
}
}
class RenderEditableTextBlock extends RenderEditableContainerBox
implements RenderEditableBox {
RenderEditableTextBlock({
required Block block,
required TextDirection textDirection,
required EdgeInsetsGeometry padding,
required double scrollBottomInset,
required Decoration decoration,
List<RenderEditableBox>? children,
ImageConfiguration configuration = ImageConfiguration.empty,
EdgeInsets contentPadding = EdgeInsets.zero,
}) : _decoration = decoration,
_configuration = configuration,
_savedPadding = padding,
_contentPadding = contentPadding,
super(
children,
block,
textDirection,
scrollBottomInset,
padding.add(contentPadding),
);
EdgeInsetsGeometry _savedPadding;
EdgeInsets _contentPadding;
set contentPadding(EdgeInsets value) {
if (_contentPadding == value) return;
_contentPadding = value;
super.setPadding(_savedPadding.add(_contentPadding));
}
@override
void setPadding(EdgeInsetsGeometry value) {
super.setPadding(value.add(_contentPadding));
_savedPadding = value;
}
BoxPainter? _painter;
Decoration get decoration => _decoration;
Decoration _decoration;
set decoration(Decoration value) {
if (value == _decoration) return;
_painter?.dispose();
_painter = null;
_decoration = value;
markNeedsPaint();
}
ImageConfiguration get configuration => _configuration;
ImageConfiguration _configuration;
set configuration(ImageConfiguration value) {
if (value == _configuration) return;
_configuration = value;
markNeedsPaint();
}
@override
TextRange getLineBoundary(TextPosition position) {
final child = childAtPosition(position);
final rangeInChild = child.getLineBoundary(TextPosition(
offset: position.offset - child.getContainer().offset,
affinity: position.affinity,
));
return TextRange(
start: rangeInChild.start + child.getContainer().offset,
end: rangeInChild.end + child.getContainer().offset,
);
}
@override
Offset getOffsetForCaret(TextPosition position) {
final child = childAtPosition(position);
return child.getOffsetForCaret(TextPosition(
offset: position.offset - child.getContainer().offset,
affinity: position.affinity,
)) +
(child.parentData as BoxParentData).offset;
}
@override
TextPosition getPositionForOffset(Offset offset) {
final child = childAtOffset(offset)!;
final parentData = child.parentData as BoxParentData;
final localPosition =
child.getPositionForOffset(offset - parentData.offset);
return TextPosition(
offset: localPosition.offset + child.getContainer().offset,
affinity: localPosition.affinity,
);
}
@override
TextRange getWordBoundary(TextPosition position) {
final child = childAtPosition(position);
final nodeOffset = child.getContainer().offset;
final childWord = child
.getWordBoundary(TextPosition(offset: position.offset - nodeOffset));
return TextRange(
start: childWord.start + nodeOffset,
end: childWord.end + nodeOffset,
);
}
@override
TextPosition? getPositionAbove(TextPosition position) {
assert(position.offset < getContainer().length);
final child = childAtPosition(position);
final childLocalPosition =
TextPosition(offset: position.offset - child.getContainer().offset);
final result = child.getPositionAbove(childLocalPosition);
if (result != null) {
return TextPosition(offset: result.offset + child.getContainer().offset);
}
final sibling = childBefore(child);
if (sibling == null) {
return null;
}
final caretOffset = child.getOffsetForCaret(childLocalPosition);
final testPosition =
TextPosition(offset: sibling.getContainer().length - 1);
final testOffset = sibling.getOffsetForCaret(testPosition);
final finalOffset = Offset(caretOffset.dx, testOffset.dy);
return TextPosition(
offset: sibling.getContainer().offset +
sibling.getPositionForOffset(finalOffset).offset);
}
@override
TextPosition? getPositionBelow(TextPosition position) {
assert(position.offset < getContainer().length);
final child = childAtPosition(position);
final childLocalPosition =
TextPosition(offset: position.offset - child.getContainer().offset);
final result = child.getPositionBelow(childLocalPosition);
if (result != null) {
return TextPosition(offset: result.offset + child.getContainer().offset);
}
final sibling = childAfter(child);
if (sibling == null) {
return null;
}
final caretOffset = child.getOffsetForCaret(childLocalPosition);
final testOffset = sibling.getOffsetForCaret(const TextPosition(offset: 0));
final finalOffset = Offset(caretOffset.dx, testOffset.dy);
return TextPosition(
offset: sibling.getContainer().offset +
sibling.getPositionForOffset(finalOffset).offset);
}
@override
double preferredLineHeight(TextPosition position) {
final child = childAtPosition(position);
return child.preferredLineHeight(
TextPosition(offset: position.offset - child.getContainer().offset));
}
@override
TextSelectionPoint getBaseEndpointForSelection(TextSelection selection) {
if (selection.isCollapsed) {
return TextSelectionPoint(
Offset(0, preferredLineHeight(selection.extent)) +
getOffsetForCaret(selection.extent),
null);
}
final baseNode = getContainer().queryChild(selection.start, false).node;
var baseChild = firstChild;
while (baseChild != null) {
if (baseChild.getContainer() == baseNode) {
break;
}
baseChild = childAfter(baseChild);
}
assert(baseChild != null);
final basePoint = baseChild!.getBaseEndpointForSelection(
localSelection(baseChild.getContainer(), selection, true));
return TextSelectionPoint(
basePoint.point + (baseChild.parentData as BoxParentData).offset,
basePoint.direction);
}
@override
TextSelectionPoint getExtentEndpointForSelection(TextSelection selection) {
if (selection.isCollapsed) {
return TextSelectionPoint(
Offset(0, preferredLineHeight(selection.extent)) +
getOffsetForCaret(selection.extent),
null);
}
final extentNode = getContainer().queryChild(selection.end, false).node;
var extentChild = firstChild;
while (extentChild != null) {
if (extentChild.getContainer() == extentNode) {
break;
}
extentChild = childAfter(extentChild);
}
assert(extentChild != null);
final extentPoint = extentChild!.getExtentEndpointForSelection(
localSelection(extentChild.getContainer(), selection, true));
return TextSelectionPoint(
extentPoint.point + (extentChild.parentData as BoxParentData).offset,
extentPoint.direction);
}
@override
void detach() {
_painter?.dispose();
_painter = null;
super.detach();
markNeedsPaint();
}
@override
void paint(PaintingContext context, Offset offset) {
_paintDecoration(context, offset);
defaultPaint(context, offset);
}
void _paintDecoration(PaintingContext context, Offset offset) {
_painter ??= _decoration.createBoxPainter(markNeedsPaint);
final decorationPadding = resolvedPadding! - _contentPadding;
final filledConfiguration =
configuration.copyWith(size: decorationPadding.deflateSize(size));
final debugSaveCount = context.canvas.getSaveCount();
final decorationOffset =
offset.translate(decorationPadding.left, decorationPadding.top);
_painter!.paint(context.canvas, decorationOffset, filledConfiguration);
if (debugSaveCount != context.canvas.getSaveCount()) {
throw '${_decoration.runtimeType} painter had mismatching save and restore calls.';
}
if (decoration.isComplex) {
context.setIsComplexHint();
}
}
@override
bool hitTestChildren(BoxHitTestResult result, {required Offset position}) {
return defaultHitTestChildren(result, position: position);
}
}
class _EditableBlock extends MultiChildRenderObjectWidget {
_EditableBlock(
this.block,
this.textDirection,
this.padding,
this.scrollBottomInset,
this.decoration,
this.contentPadding,
List<Widget> children)
: super(children: children);
final Block block;
final TextDirection textDirection;
final Tuple2<double, double> padding;
final double scrollBottomInset;
final Decoration decoration;
final EdgeInsets? contentPadding;
EdgeInsets get _padding =>
EdgeInsets.only(top: padding.item1, bottom: padding.item2);
EdgeInsets get _contentPadding => contentPadding ?? EdgeInsets.zero;
@override
RenderEditableTextBlock createRenderObject(BuildContext context) {
return RenderEditableTextBlock(
block: block,
textDirection: textDirection,
padding: _padding,
scrollBottomInset: scrollBottomInset,
decoration: decoration,
contentPadding: _contentPadding,
);
}
@override
void updateRenderObject(
BuildContext context, covariant RenderEditableTextBlock renderObject) {
renderObject
..setContainer(block)
..textDirection = textDirection
..scrollBottomInset = scrollBottomInset
..setPadding(_padding)
..decoration = decoration
..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,
),
),
);
}
}

@ -0,0 +1,892 @@
import 'dart:math' as math;
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 '../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 '../utils/color.dart';
import 'box.dart';
import 'cursor.dart';
import 'default_styles.dart';
import 'delegate.dart';
import 'proxy.dart';
import 'text_selection.dart';
class TextLine extends StatelessWidget {
const TextLine({
required this.line,
required this.embedBuilder,
required this.styles,
this.textDirection,
Key? key,
}) : super(key: key);
final Line line;
final TextDirection? textDirection;
final EmbedBuilder embedBuilder;
final DefaultStyles styles;
@override
Widget build(BuildContext context) {
assert(debugCheckHasMediaQuery(context));
if (line.hasEmbed) {
final embed = line.children.single as Embed;
return EmbedProxy(embedBuilder(context, embed));
}
final textSpan = _buildTextSpan(context);
final strutStyle = StrutStyle.fromTextStyle(textSpan.style!);
final textAlign = _getTextAlign();
final child = RichText(
text: textSpan,
textAlign: textAlign,
textDirection: textDirection,
strutStyle: strutStyle,
textScaleFactor: MediaQuery.textScaleFactorOf(context),
);
return RichTextProxy(
child,
textSpan.style!,
textAlign,
textDirection!,
1,
Localizations.localeOf(context),
strutStyle,
TextWidthBasis.parent,
null);
}
TextAlign _getTextAlign() {
final alignment = line.style.attributes[Attribute.align.key];
if (alignment == Attribute.leftAlignment) {
return TextAlign.left;
} else if (alignment == Attribute.centerAlignment) {
return TextAlign.center;
} else if (alignment == Attribute.rightAlignment) {
return TextAlign.right;
} else if (alignment == Attribute.justifyAlignment) {
return TextAlign.justify;
}
return TextAlign.start;
}
TextSpan _buildTextSpan(BuildContext context) {
final defaultStyles = styles;
final children = line.children
.map((node) => _getTextSpanFromNode(defaultStyles, node))
.toList(growable: false);
var textStyle = const TextStyle();
if (line.style.containsKey(Attribute.placeholder.key)) {
textStyle = defaultStyles.placeHolder!.style;
return TextSpan(children: children, style: textStyle);
}
final header = line.style.attributes[Attribute.header.key];
final m = <Attribute, TextStyle>{
Attribute.h1: defaultStyles.h1!.style,
Attribute.h2: defaultStyles.h2!.style,
Attribute.h3: defaultStyles.h3!.style,
};
textStyle = textStyle.merge(m[header] ?? defaultStyles.paragraph!.style);
final block = line.style.getBlockExceptHeader();
TextStyle? toMerge;
if (block == Attribute.blockQuote) {
toMerge = defaultStyles.quote!.style;
} else if (block == Attribute.codeBlock) {
toMerge = defaultStyles.code!.style;
} else if (block != null) {
toMerge = defaultStyles.lists!.style;
}
textStyle = textStyle.merge(toMerge);
return TextSpan(children: children, style: textStyle);
}
TextSpan _getTextSpanFromNode(DefaultStyles defaultStyles, Node node) {
final textNode = node as leaf.Text;
final style = textNode.style;
var res = const TextStyle();
final color = textNode.style.attributes[Attribute.color.key];
<String, TextStyle?>{
Attribute.bold.key: defaultStyles.bold,
Attribute.italic.key: defaultStyles.italic,
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 (k == Attribute.underline.key || k == Attribute.strikeThrough.key) {
var textColor = defaultStyles.color;
if (color?.value is String) {
textColor = stringToColor(color?.value);
}
res = _merge(res.copyWith(decorationColor: textColor),
s!.copyWith(decorationColor: textColor));
} else {
res = _merge(res, s!);
}
}
});
final font = textNode.style.attributes[Attribute.font.key];
if (font != null && font.value != null) {
res = res.merge(TextStyle(fontFamily: font.value));
}
final size = textNode.style.attributes[Attribute.size.key];
if (size != null && size.value != null) {
switch (size.value) {
case 'small':
res = res.merge(defaultStyles.sizeSmall);
break;
case 'large':
res = res.merge(defaultStyles.sizeLarge);
break;
case 'huge':
res = res.merge(defaultStyles.sizeHuge);
break;
default:
final fontSize = double.tryParse(size.value);
if (fontSize != null) {
res = res.merge(TextStyle(fontSize: fontSize));
} else {
throw 'Invalid size ${size.value}';
}
}
}
if (color != null && color.value != null) {
var textColor = defaultStyles.color;
if (color.value is String) {
textColor = stringToColor(color.value);
}
if (textColor != null) {
res = res.merge(TextStyle(color: textColor));
}
}
final background = textNode.style.attributes[Attribute.background.key];
if (background != null && background.value != null) {
final backgroundColor = stringToColor(background.value);
res = res.merge(TextStyle(backgroundColor: backgroundColor));
}
return TextSpan(text: textNode.value, style: res);
}
TextStyle _merge(TextStyle a, TextStyle b) {
final decorations = <TextDecoration?>[];
if (a.decoration != null) {
decorations.add(a.decoration);
}
if (b.decoration != null) {
decorations.add(b.decoration);
}
return a.merge(b).apply(
decoration: TextDecoration.combine(
List.castFrom<dynamic, TextDecoration>(decorations)));
}
}
class EditableTextLine extends RenderObjectWidget {
const EditableTextLine(
this.line,
this.leading,
this.body,
this.indentWidth,
this.verticalSpacing,
this.textDirection,
this.textSelection,
this.color,
this.enableInteractiveSelection,
this.hasFocus,
this.devicePixelRatio,
this.cursorCont,
);
final Line line;
final Widget? leading;
final Widget body;
final double indentWidth;
final Tuple2 verticalSpacing;
final TextDirection textDirection;
final TextSelection textSelection;
final Color color;
final bool enableInteractiveSelection;
final bool hasFocus;
final double devicePixelRatio;
final CursorCont cursorCont;
@override
RenderObjectElement createElement() {
return _TextLineElement(this);
}
@override
RenderObject createRenderObject(BuildContext context) {
return RenderEditableTextLine(
line,
textDirection,
textSelection,
enableInteractiveSelection,
hasFocus,
devicePixelRatio,
_getPadding(),
color,
cursorCont);
}
@override
void updateRenderObject(
BuildContext context, covariant RenderEditableTextLine renderObject) {
renderObject
..setLine(line)
..setPadding(_getPadding())
..setTextDirection(textDirection)
..setTextSelection(textSelection)
..setColor(color)
..setEnableInteractiveSelection(enableInteractiveSelection)
..hasFocus = hasFocus
..setDevicePixelRatio(devicePixelRatio)
..setCursorCont(cursorCont);
}
EdgeInsetsGeometry _getPadding() {
return EdgeInsetsDirectional.only(
start: indentWidth,
top: verticalSpacing.item1,
bottom: verticalSpacing.item2);
}
}
enum TextLineSlot { LEADING, BODY }
class RenderEditableTextLine extends RenderEditableBox {
RenderEditableTextLine(
this.line,
this.textDirection,
this.textSelection,
this.enableInteractiveSelection,
this.hasFocus,
this.devicePixelRatio,
this.padding,
this.color,
this.cursorCont,
);
RenderBox? _leading;
RenderContentProxyBox? _body;
Line line;
TextDirection textDirection;
TextSelection textSelection;
Color color;
bool enableInteractiveSelection;
bool hasFocus = false;
double devicePixelRatio;
EdgeInsetsGeometry padding;
CursorCont cursorCont;
EdgeInsets? _resolvedPadding;
bool? _containsCursor;
List<TextBox>? _selectedRects;
Rect? _caretPrototype;
final Map<TextLineSlot, RenderBox> children = <TextLineSlot, RenderBox>{};
Iterable<RenderBox> get _children sync* {
if (_leading != null) {
yield _leading!;
}
if (_body != null) {
yield _body!;
}
}
void setCursorCont(CursorCont c) {
if (cursorCont == c) {
return;
}
cursorCont = c;
markNeedsLayout();
}
void setDevicePixelRatio(double d) {
if (devicePixelRatio == d) {
return;
}
devicePixelRatio = d;
markNeedsLayout();
}
void setEnableInteractiveSelection(bool val) {
if (enableInteractiveSelection == val) {
return;
}
markNeedsLayout();
markNeedsSemanticsUpdate();
}
void setColor(Color c) {
if (color == c) {
return;
}
color = c;
if (containsTextSelection()) {
markNeedsPaint();
}
}
void setTextSelection(TextSelection t) {
if (textSelection == t) {
return;
}
final containsSelection = containsTextSelection();
if (attached && containsCursor()) {
cursorCont.removeListener(markNeedsLayout);
cursorCont.color.removeListener(markNeedsPaint);
}
textSelection = t;
_selectedRects = null;
_containsCursor = null;
if (attached && containsCursor()) {
cursorCont.addListener(markNeedsLayout);
cursorCont.color.addListener(markNeedsPaint);
}
if (containsSelection || containsTextSelection()) {
markNeedsPaint();
}
}
void setTextDirection(TextDirection t) {
if (textDirection == t) {
return;
}
textDirection = t;
_resolvedPadding = null;
markNeedsLayout();
}
void setLine(Line l) {
if (line == l) {
return;
}
line = l;
_containsCursor = null;
markNeedsLayout();
}
void setPadding(EdgeInsetsGeometry p) {
assert(p.isNonNegative);
if (padding == p) {
return;
}
padding = p;
_resolvedPadding = null;
markNeedsLayout();
}
void setLeading(RenderBox? l) {
_leading = _updateChild(_leading, l, TextLineSlot.LEADING);
}
void setBody(RenderContentProxyBox? b) {
_body = _updateChild(_body, b, TextLineSlot.BODY) as RenderContentProxyBox?;
}
bool containsTextSelection() {
return line.documentOffset <= textSelection.end &&
textSelection.start <= line.documentOffset + line.length - 1;
}
bool containsCursor() {
return _containsCursor ??= textSelection.isCollapsed &&
line.containsOffset(textSelection.baseOffset);
}
RenderBox? _updateChild(
RenderBox? old, RenderBox? newChild, TextLineSlot slot) {
if (old != null) {
dropChild(old);
children.remove(slot);
}
if (newChild != null) {
children[slot] = newChild;
adoptChild(newChild);
}
return newChild;
}
List<TextBox> _getBoxes(TextSelection textSelection) {
final parentData = _body!.parentData as BoxParentData?;
return _body!.getBoxesForSelection(textSelection).map((box) {
return TextBox.fromLTRBD(
box.left + parentData!.offset.dx,
box.top + parentData.offset.dy,
box.right + parentData.offset.dx,
box.bottom + parentData.offset.dy,
box.direction,
);
}).toList(growable: false);
}
void _resolvePadding() {
if (_resolvedPadding != null) {
return;
}
_resolvedPadding = padding.resolve(textDirection);
assert(_resolvedPadding!.isNonNegative);
}
@override
TextSelectionPoint getBaseEndpointForSelection(TextSelection textSelection) {
return _getEndpointForSelection(textSelection, true);
}
@override
TextSelectionPoint getExtentEndpointForSelection(
TextSelection textSelection) {
return _getEndpointForSelection(textSelection, false);
}
TextSelectionPoint _getEndpointForSelection(
TextSelection textSelection, bool first) {
if (textSelection.isCollapsed) {
return TextSelectionPoint(
Offset(0, preferredLineHeight(textSelection.extent)) +
getOffsetForCaret(textSelection.extent),
null);
}
final boxes = _getBoxes(textSelection);
assert(boxes.isNotEmpty);
final targetBox = first ? boxes.first : boxes.last;
return TextSelectionPoint(
Offset(first ? targetBox.start : targetBox.end, targetBox.bottom),
targetBox.direction);
}
@override
TextRange getLineBoundary(TextPosition position) {
final lineDy = getOffsetForCaret(position)
.translate(0, 0.5 * preferredLineHeight(position))
.dy;
final lineBoxes =
_getBoxes(TextSelection(baseOffset: 0, extentOffset: line.length - 1))
.where((element) => element.top < lineDy && element.bottom > lineDy)
.toList(growable: false);
return TextRange(
start:
getPositionForOffset(Offset(lineBoxes.first.left, lineDy)).offset,
end: getPositionForOffset(Offset(lineBoxes.last.right, lineDy)).offset);
}
@override
Offset getOffsetForCaret(TextPosition position) {
return _body!.getOffsetForCaret(position, _caretPrototype) +
(_body!.parentData as BoxParentData).offset;
}
@override
TextPosition? getPositionAbove(TextPosition position) {
return _getPosition(position, -0.5);
}
@override
TextPosition? getPositionBelow(TextPosition position) {
return _getPosition(position, 1.5);
}
TextPosition? _getPosition(TextPosition textPosition, double dyScale) {
assert(textPosition.offset < line.length);
final offset = getOffsetForCaret(textPosition)
.translate(0, dyScale * preferredLineHeight(textPosition));
if (_body!.size
.contains(offset - (_body!.parentData as BoxParentData).offset)) {
return getPositionForOffset(offset);
}
return null;
}
@override
TextPosition getPositionForOffset(Offset offset) {
return _body!.getPositionForOffset(
offset - (_body!.parentData as BoxParentData).offset);
}
@override
TextRange getWordBoundary(TextPosition position) {
return _body!.getWordBoundary(position);
}
@override
double preferredLineHeight(TextPosition position) {
return _body!.getPreferredLineHeight();
}
@override
container.Container getContainer() {
return line;
}
double get cursorWidth => cursorCont.style.width;
double get cursorHeight =>
cursorCont.style.height ??
preferredLineHeight(const TextPosition(offset: 0));
void _computeCaretPrototype() {
switch (defaultTargetPlatform) {
case TargetPlatform.iOS:
case TargetPlatform.macOS:
_caretPrototype = Rect.fromLTWH(0, 0, cursorWidth, cursorHeight + 2);
break;
case TargetPlatform.android:
case TargetPlatform.fuchsia:
case TargetPlatform.linux:
case TargetPlatform.windows:
_caretPrototype = Rect.fromLTWH(0, 2, cursorWidth, cursorHeight - 4.0);
break;
default:
throw 'Invalid platform';
}
}
@override
void attach(covariant PipelineOwner owner) {
super.attach(owner);
for (final child in _children) {
child.attach(owner);
}
if (containsCursor()) {
cursorCont.addListener(markNeedsLayout);
cursorCont.color.addListener(markNeedsPaint);
}
}
@override
void detach() {
super.detach();
for (final child in _children) {
child.detach();
}
if (containsCursor()) {
cursorCont.removeListener(markNeedsLayout);
cursorCont.color.removeListener(markNeedsPaint);
}
}
@override
void redepthChildren() {
_children.forEach(redepthChild);
}
@override
void visitChildren(RenderObjectVisitor visitor) {
_children.forEach(visitor);
}
@override
List<DiagnosticsNode> debugDescribeChildren() {
final value = <DiagnosticsNode>[];
void add(RenderBox? child, String name) {
if (child != null) {
value.add(child.toDiagnosticsNode(name: name));
}
}
add(_leading, 'leading');
add(_body, 'body');
return value;
}
@override
bool get sizedByParent => false;
@override
double computeMinIntrinsicWidth(double height) {
_resolvePadding();
final horizontalPadding = _resolvedPadding!.left + _resolvedPadding!.right;
final verticalPadding = _resolvedPadding!.top + _resolvedPadding!.bottom;
final leadingWidth = _leading == null
? 0
: _leading!.getMinIntrinsicWidth(height - verticalPadding) as int;
final bodyWidth = _body == null
? 0
: _body!.getMinIntrinsicWidth(math.max(0, height - verticalPadding))
as int;
return horizontalPadding + leadingWidth + bodyWidth;
}
@override
double computeMaxIntrinsicWidth(double height) {
_resolvePadding();
final horizontalPadding = _resolvedPadding!.left + _resolvedPadding!.right;
final verticalPadding = _resolvedPadding!.top + _resolvedPadding!.bottom;
final leadingWidth = _leading == null
? 0
: _leading!.getMaxIntrinsicWidth(height - verticalPadding) as int;
final bodyWidth = _body == null
? 0
: _body!.getMaxIntrinsicWidth(math.max(0, height - verticalPadding))
as int;
return horizontalPadding + leadingWidth + bodyWidth;
}
@override
double computeMinIntrinsicHeight(double width) {
_resolvePadding();
final horizontalPadding = _resolvedPadding!.left + _resolvedPadding!.right;
final verticalPadding = _resolvedPadding!.top + _resolvedPadding!.bottom;
if (_body != null) {
return _body!
.getMinIntrinsicHeight(math.max(0, width - horizontalPadding)) +
verticalPadding;
}
return verticalPadding;
}
@override
double computeMaxIntrinsicHeight(double width) {
_resolvePadding();
final horizontalPadding = _resolvedPadding!.left + _resolvedPadding!.right;
final verticalPadding = _resolvedPadding!.top + _resolvedPadding!.bottom;
if (_body != null) {
return _body!
.getMaxIntrinsicHeight(math.max(0, width - horizontalPadding)) +
verticalPadding;
}
return verticalPadding;
}
@override
double computeDistanceToActualBaseline(TextBaseline baseline) {
_resolvePadding();
return _body!.getDistanceToActualBaseline(baseline)! +
_resolvedPadding!.top;
}
@override
void performLayout() {
final constraints = this.constraints;
_selectedRects = null;
_resolvePadding();
assert(_resolvedPadding != null);
if (_body == null && _leading == null) {
size = constraints.constrain(Size(
_resolvedPadding!.left + _resolvedPadding!.right,
_resolvedPadding!.top + _resolvedPadding!.bottom,
));
return;
}
final innerConstraints = constraints.deflate(_resolvedPadding!);
final indentWidth = textDirection == TextDirection.ltr
? _resolvedPadding!.left
: _resolvedPadding!.right;
_body!.layout(innerConstraints, parentUsesSize: true);
(_body!.parentData as BoxParentData).offset =
Offset(_resolvedPadding!.left, _resolvedPadding!.top);
if (_leading != null) {
final leadingConstraints = innerConstraints.copyWith(
minWidth: indentWidth,
maxWidth: indentWidth,
maxHeight: _body!.size.height);
_leading!.layout(leadingConstraints, parentUsesSize: true);
(_leading!.parentData as BoxParentData).offset =
Offset(0, _resolvedPadding!.top);
}
size = constraints.constrain(Size(
_resolvedPadding!.left + _body!.size.width + _resolvedPadding!.right,
_resolvedPadding!.top + _body!.size.height + _resolvedPadding!.bottom,
));
_computeCaretPrototype();
}
CursorPainter get _cursorPainter => CursorPainter(
_body,
cursorCont.style,
_caretPrototype!,
cursorCont.color.value,
devicePixelRatio,
);
@override
void paint(PaintingContext context, Offset offset) {
if (_leading != null) {
final parentData = _leading!.parentData as BoxParentData;
final effectiveOffset = offset + parentData.offset;
context.paintChild(_leading!, effectiveOffset);
}
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 (hasFocus &&
cursorCont.show.value &&
containsCursor() &&
!cursorCont.style.paintAboveText) {
_paintCursor(context, effectiveOffset);
}
context.paintChild(_body!, effectiveOffset);
if (hasFocus &&
cursorCont.show.value &&
containsCursor() &&
cursorCont.style.paintAboveText) {
_paintCursor(context, effectiveOffset);
}
}
}
void _paintSelection(PaintingContext context, Offset effectiveOffset) {
assert(_selectedRects != null);
final paint = Paint()..color = color;
for (final box in _selectedRects!) {
context.canvas.drawRect(box.toRect().shift(effectiveOffset), paint);
}
}
void _paintCursor(PaintingContext context, Offset effectiveOffset) {
final position = TextPosition(
offset: textSelection.extentOffset - line.documentOffset,
affinity: textSelection.base.affinity,
);
_cursorPainter.paint(context.canvas, effectiveOffset, position);
}
@override
bool hitTestChildren(BoxHitTestResult result, {required Offset position}) {
return _children.first.hitTest(result, position: position);
}
}
class _TextLineElement extends RenderObjectElement {
_TextLineElement(EditableTextLine line) : super(line);
final Map<TextLineSlot, Element> _slotToChildren = <TextLineSlot, Element>{};
@override
EditableTextLine get widget => super.widget as EditableTextLine;
@override
RenderEditableTextLine get renderObject =>
super.renderObject as RenderEditableTextLine;
@override
void visitChildren(ElementVisitor visitor) {
_slotToChildren.values.forEach(visitor);
}
@override
void forgetChild(Element child) {
assert(_slotToChildren.containsValue(child));
assert(child.slot is TextLineSlot);
assert(_slotToChildren.containsKey(child.slot));
_slotToChildren.remove(child.slot);
super.forgetChild(child);
}
@override
void mount(Element? parent, dynamic newSlot) {
super.mount(parent, newSlot);
_mountChild(widget.leading, TextLineSlot.LEADING);
_mountChild(widget.body, TextLineSlot.BODY);
}
@override
void update(EditableTextLine newWidget) {
super.update(newWidget);
assert(widget == newWidget);
_updateChild(widget.leading, TextLineSlot.LEADING);
_updateChild(widget.body, TextLineSlot.BODY);
}
@override
void insertRenderObjectChild(RenderBox child, TextLineSlot? slot) {
// assert(child is RenderBox);
_updateRenderObject(child, slot);
assert(renderObject.children.keys.contains(slot));
}
@override
void removeRenderObjectChild(RenderObject child, TextLineSlot? slot) {
assert(child is RenderBox);
assert(renderObject.children[slot!] == child);
_updateRenderObject(null, slot);
assert(!renderObject.children.keys.contains(slot));
}
@override
void moveRenderObjectChild(
RenderObject child, dynamic oldSlot, dynamic newSlot) {
throw UnimplementedError();
}
void _mountChild(Widget? widget, TextLineSlot slot) {
final oldChild = _slotToChildren[slot];
final newChild = updateChild(oldChild, widget, slot);
if (oldChild != null) {
_slotToChildren.remove(slot);
}
if (newChild != null) {
_slotToChildren[slot] = newChild;
}
}
void _updateRenderObject(RenderBox? child, TextLineSlot? slot) {
switch (slot) {
case TextLineSlot.LEADING:
renderObject.setLeading(child);
break;
case TextLineSlot.BODY:
renderObject.setBody(child as RenderContentProxyBox?);
break;
default:
throw UnimplementedError();
}
}
void _updateChild(Widget? widget, TextLineSlot slot) {
final oldChild = _slotToChildren[slot];
final newChild = updateChild(oldChild, widget, slot);
if (oldChild != null) {
_slotToChildren.remove(slot);
}
if (newChild != null) {
_slotToChildren[slot] = newChild;
}
}
}

@ -0,0 +1,726 @@
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';
import 'editor.dart';
TextSelection localSelection(Node node, TextSelection selection, fromParent) {
final base = fromParent ? node.offset : node.documentOffset;
assert(base <= selection.end && selection.start <= base + node.length - 1);
final offset = fromParent ? node.offset : node.documentOffset;
return selection.copyWith(
baseOffset: math.max(selection.start - offset, 0),
extentOffset: math.min(selection.end - offset, node.length - 1));
}
enum _TextSelectionHandlePosition { START, END }
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,
this.onSelectionHandleTapped,
this.clipboardStatus,
) {
final overlay = Overlay.of(context, rootOverlay: true)!;
_toolbarController = AnimationController(
duration: const Duration(milliseconds: 150), vsync: overlay);
}
TextEditingValue value;
bool handlesVisible = false;
final BuildContext context;
final Widget debugRequiredFor;
final LayerLink toolbarLayerLink;
final LayerLink startHandleLayerLink;
final LayerLink endHandleLayerLink;
final RenderEditor? renderObject;
final TextSelectionControls selectionCtrls;
final TextSelectionDelegate selectionDelegate;
final DragStartBehavior dragStartBehavior;
final VoidCallback? onSelectionHandleTapped;
final ClipboardStatusNotifier clipboardStatus;
late AnimationController _toolbarController;
List<OverlayEntry>? _handles;
OverlayEntry? toolbar;
TextSelection get _selection => value.selection;
Animation<double> get _toolbarOpacity => _toolbarController.view;
void setHandlesVisible(bool visible) {
if (handlesVisible == visible) {
return;
}
handlesVisible = visible;
if (SchedulerBinding.instance!.schedulerPhase ==
SchedulerPhase.persistentCallbacks) {
SchedulerBinding.instance!.addPostFrameCallback(markNeedsBuild);
} else {
markNeedsBuild();
}
}
void hideHandles() {
if (_handles == null) {
return;
}
_handles![0].remove();
_handles![1].remove();
_handles = null;
}
void hideToolbar() {
assert(toolbar != null);
_toolbarController.stop();
toolbar!.remove();
toolbar = null;
}
void showToolbar() {
assert(toolbar == null);
toolbar = OverlayEntry(builder: _buildToolbar);
Overlay.of(context, rootOverlay: true, debugRequiredFor: debugRequiredFor)!
.insert(toolbar!);
_toolbarController.forward(from: 0);
}
Widget _buildHandle(
BuildContext context, _TextSelectionHandlePosition position) {
if (_selection.isCollapsed &&
position == _TextSelectionHandlePosition.END) {
return Container();
}
return Visibility(
visible: handlesVisible,
child: _TextSelectionHandleOverlay(
onSelectionHandleChanged: (newSelection) {
_handleSelectionHandleChanged(newSelection, position);
},
onSelectionHandleTapped: onSelectionHandleTapped,
startHandleLayerLink: startHandleLayerLink,
endHandleLayerLink: endHandleLayerLink,
renderObject: renderObject,
selection: _selection,
selectionControls: selectionCtrls,
position: position,
dragStartBehavior: dragStartBehavior,
));
}
void update(TextEditingValue newValue) {
if (value == newValue) {
return;
}
value = newValue;
if (SchedulerBinding.instance!.schedulerPhase ==
SchedulerPhase.persistentCallbacks) {
SchedulerBinding.instance!.addPostFrameCallback(markNeedsBuild);
} else {
markNeedsBuild();
}
}
void _handleSelectionHandleChanged(
TextSelection? newSelection, _TextSelectionHandlePosition position) {
TextPosition textPosition;
switch (position) {
case _TextSelectionHandlePosition.START:
textPosition = newSelection != null
? newSelection.base
: const TextPosition(offset: 0);
break;
case _TextSelectionHandlePosition.END:
textPosition = newSelection != null
? newSelection.extent
: const TextPosition(offset: 0);
break;
default:
throw 'Invalid position';
}
selectionDelegate
..textEditingValue =
value.copyWith(selection: newSelection, composing: TextRange.empty)
..bringIntoView(textPosition);
}
Widget _buildToolbar(BuildContext context) {
final endpoints = renderObject!.getEndpointsForSelection(_selection);
final editingRegion = Rect.fromPoints(
renderObject!.localToGlobal(Offset.zero),
renderObject!.localToGlobal(renderObject!.size.bottomRight(Offset.zero)),
);
final baseLineHeight = renderObject!.preferredLineHeight(_selection.base);
final extentLineHeight =
renderObject!.preferredLineHeight(_selection.extent);
final smallestLineHeight = math.min(baseLineHeight, extentLineHeight);
final isMultiline = endpoints.last.point.dy - endpoints.first.point.dy >
smallestLineHeight / 2;
final midX = isMultiline
? editingRegion.width / 2
: (endpoints.first.point.dx + endpoints.last.point.dx) / 2;
final midpoint = Offset(
midX,
endpoints[0].point.dy - baseLineHeight,
);
return FadeTransition(
opacity: _toolbarOpacity,
child: CompositedTransformFollower(
link: toolbarLayerLink,
showWhenUnlinked: false,
offset: -editingRegion.topLeft,
child: selectionCtrls.buildToolbar(
context,
editingRegion,
baseLineHeight,
midpoint,
endpoints,
selectionDelegate,
clipboardStatus,
const Offset(0, 0)),
),
);
}
void markNeedsBuild([Duration? duration]) {
if (_handles != null) {
_handles![0].markNeedsBuild();
_handles![1].markNeedsBuild();
}
toolbar?.markNeedsBuild();
}
void hide() {
if (_handles != null) {
_handles![0].remove();
_handles![1].remove();
_handles = null;
}
if (toolbar != null) {
hideToolbar();
}
}
void dispose() {
hide();
_toolbarController.dispose();
}
void showHandles() {
assert(_handles == null);
_handles = <OverlayEntry>[
OverlayEntry(
builder: (context) =>
_buildHandle(context, _TextSelectionHandlePosition.START)),
OverlayEntry(
builder: (context) =>
_buildHandle(context, _TextSelectionHandlePosition.END)),
];
Overlay.of(context, rootOverlay: true, debugRequiredFor: debugRequiredFor)!
.insertAll(_handles!);
}
}
class _TextSelectionHandleOverlay extends StatefulWidget {
const _TextSelectionHandleOverlay({
required this.selection,
required this.position,
required this.startHandleLayerLink,
required this.endHandleLayerLink,
required this.renderObject,
required this.onSelectionHandleChanged,
required this.onSelectionHandleTapped,
required this.selectionControls,
this.dragStartBehavior = DragStartBehavior.start,
Key? key,
}) : super(key: key);
final TextSelection selection;
final _TextSelectionHandlePosition position;
final LayerLink startHandleLayerLink;
final LayerLink endHandleLayerLink;
final RenderEditor? renderObject;
final ValueChanged<TextSelection?> onSelectionHandleChanged;
final VoidCallback? onSelectionHandleTapped;
final TextSelectionControls selectionControls;
final DragStartBehavior dragStartBehavior;
@override
_TextSelectionHandleOverlayState createState() =>
_TextSelectionHandleOverlayState();
ValueListenable<bool>? get _visibility {
switch (position) {
case _TextSelectionHandlePosition.START:
return renderObject!.selectionStartInViewport;
case _TextSelectionHandlePosition.END:
return renderObject!.selectionEndInViewport;
}
}
}
class _TextSelectionHandleOverlayState
extends State<_TextSelectionHandleOverlay>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
Animation<double> get _opacity => _controller.view;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(milliseconds: 150), vsync: this);
_handleVisibilityChanged();
widget._visibility!.addListener(_handleVisibilityChanged);
}
void _handleVisibilityChanged() {
if (widget._visibility!.value) {
_controller.forward();
} else {
_controller.reverse();
}
}
@override
void didUpdateWidget(_TextSelectionHandleOverlay oldWidget) {
super.didUpdateWidget(oldWidget);
oldWidget._visibility!.removeListener(_handleVisibilityChanged);
_handleVisibilityChanged();
widget._visibility!.addListener(_handleVisibilityChanged);
}
@override
void dispose() {
widget._visibility!.removeListener(_handleVisibilityChanged);
_controller.dispose();
super.dispose();
}
void _handleDragStart(DragStartDetails details) {}
void _handleDragUpdate(DragUpdateDetails details) {
final position =
widget.renderObject!.getPositionForOffset(details.globalPosition);
if (widget.selection.isCollapsed) {
widget.onSelectionHandleChanged(TextSelection.fromPosition(position));
return;
}
final isNormalized =
widget.selection.extentOffset >= widget.selection.baseOffset;
TextSelection? newSelection;
switch (widget.position) {
case _TextSelectionHandlePosition.START:
newSelection = TextSelection(
baseOffset:
isNormalized ? position.offset : widget.selection.baseOffset,
extentOffset:
isNormalized ? widget.selection.extentOffset : position.offset,
);
break;
case _TextSelectionHandlePosition.END:
newSelection = TextSelection(
baseOffset:
isNormalized ? widget.selection.baseOffset : position.offset,
extentOffset:
isNormalized ? position.offset : widget.selection.extentOffset,
);
break;
}
widget.onSelectionHandleChanged(newSelection);
}
void _handleTap() {
if (widget.onSelectionHandleTapped != null) {
widget.onSelectionHandleTapped!();
}
}
@override
Widget build(BuildContext context) {
late LayerLink layerLink;
TextSelectionHandleType? type;
switch (widget.position) {
case _TextSelectionHandlePosition.START:
layerLink = widget.startHandleLayerLink;
type = _chooseType(
widget.renderObject!.textDirection,
TextSelectionHandleType.left,
TextSelectionHandleType.right,
);
break;
case _TextSelectionHandlePosition.END:
assert(!widget.selection.isCollapsed);
layerLink = widget.endHandleLayerLink;
type = _chooseType(
widget.renderObject!.textDirection,
TextSelectionHandleType.right,
TextSelectionHandleType.left,
);
break;
}
final textPosition = widget.position == _TextSelectionHandlePosition.START
? widget.selection.base
: widget.selection.extent;
final lineHeight = widget.renderObject!.preferredLineHeight(textPosition);
final handleAnchor =
widget.selectionControls.getHandleAnchor(type!, lineHeight);
final handleSize = widget.selectionControls.getHandleSize(lineHeight);
final handleRect = Rect.fromLTWH(
-handleAnchor.dx,
-handleAnchor.dy,
handleSize.width,
handleSize.height,
);
final interactiveRect = handleRect.expandToInclude(
Rect.fromCircle(
center: handleRect.center, radius: kMinInteractiveDimension / 2),
);
final padding = RelativeRect.fromLTRB(
math.max((interactiveRect.width - handleRect.width) / 2, 0),
math.max((interactiveRect.height - handleRect.height) / 2, 0),
math.max((interactiveRect.width - handleRect.width) / 2, 0),
math.max((interactiveRect.height - handleRect.height) / 2, 0),
);
return CompositedTransformFollower(
link: layerLink,
offset: interactiveRect.topLeft,
showWhenUnlinked: false,
child: FadeTransition(
opacity: _opacity,
child: Container(
alignment: Alignment.topLeft,
width: interactiveRect.width,
height: interactiveRect.height,
child: GestureDetector(
behavior: HitTestBehavior.translucent,
dragStartBehavior: widget.dragStartBehavior,
onPanStart: _handleDragStart,
onPanUpdate: _handleDragUpdate,
onTap: _handleTap,
child: Padding(
padding: EdgeInsets.only(
left: padding.left,
top: padding.top,
right: padding.right,
bottom: padding.bottom,
),
child: widget.selectionControls.buildHandle(
context,
type,
lineHeight,
),
),
),
),
),
);
}
TextSelectionHandleType? _chooseType(
TextDirection textDirection,
TextSelectionHandleType ltrType,
TextSelectionHandleType rtlType,
) {
if (widget.selection.isCollapsed) return TextSelectionHandleType.collapsed;
switch (textDirection) {
case TextDirection.ltr:
return ltrType;
case TextDirection.rtl:
return rtlType;
}
}
}
class EditorTextSelectionGestureDetector extends StatefulWidget {
const EditorTextSelectionGestureDetector({
required this.child,
this.onTapDown,
this.onForcePressStart,
this.onForcePressEnd,
this.onSingleTapUp,
this.onSingleTapCancel,
this.onSingleLongTapStart,
this.onSingleLongTapMoveUpdate,
this.onSingleLongTapEnd,
this.onDoubleTapDown,
this.onDragSelectionStart,
this.onDragSelectionUpdate,
this.onDragSelectionEnd,
this.behavior,
Key? key,
}) : super(key: key);
final GestureTapDownCallback? onTapDown;
final GestureForcePressStartCallback? onForcePressStart;
final GestureForcePressEndCallback? onForcePressEnd;
final GestureTapUpCallback? onSingleTapUp;
final GestureTapCancelCallback? onSingleTapCancel;
final GestureLongPressStartCallback? onSingleLongTapStart;
final GestureLongPressMoveUpdateCallback? onSingleLongTapMoveUpdate;
final GestureLongPressEndCallback? onSingleLongTapEnd;
final GestureTapDownCallback? onDoubleTapDown;
final GestureDragStartCallback? onDragSelectionStart;
final DragSelectionUpdateCallback? onDragSelectionUpdate;
final GestureDragEndCallback? onDragSelectionEnd;
final HitTestBehavior? behavior;
final Widget child;
@override
State<StatefulWidget> createState() =>
_EditorTextSelectionGestureDetectorState();
}
class _EditorTextSelectionGestureDetectorState
extends State<EditorTextSelectionGestureDetector> {
Timer? _doubleTapTimer;
Offset? _lastTapOffset;
bool _isDoubleTap = false;
@override
void dispose() {
_doubleTapTimer?.cancel();
_dragUpdateThrottleTimer?.cancel();
super.dispose();
}
void _handleTapDown(TapDownDetails details) {
// renderObject.resetTapDownStatus();
if (widget.onTapDown != null) {
widget.onTapDown!(details);
}
if (_doubleTapTimer != null &&
_isWithinDoubleTapTolerance(details.globalPosition)) {
if (widget.onDoubleTapDown != null) {
widget.onDoubleTapDown!(details);
}
_doubleTapTimer!.cancel();
_doubleTapTimeout();
_isDoubleTap = true;
}
}
void _handleTapUp(TapUpDetails details) {
if (!_isDoubleTap) {
if (widget.onSingleTapUp != null) {
widget.onSingleTapUp!(details);
}
_lastTapOffset = details.globalPosition;
_doubleTapTimer = Timer(kDoubleTapTimeout, _doubleTapTimeout);
}
_isDoubleTap = false;
}
void _handleTapCancel() {
if (widget.onSingleTapCancel != null) {
widget.onSingleTapCancel!();
}
}
DragStartDetails? _lastDragStartDetails;
DragUpdateDetails? _lastDragUpdateDetails;
Timer? _dragUpdateThrottleTimer;
void _handleDragStart(DragStartDetails details) {
assert(_lastDragStartDetails == null);
_lastDragStartDetails = details;
if (widget.onDragSelectionStart != null) {
widget.onDragSelectionStart!(details);
}
}
void _handleDragUpdate(DragUpdateDetails details) {
_lastDragUpdateDetails = details;
_dragUpdateThrottleTimer ??=
Timer(const Duration(milliseconds: 50), _handleDragUpdateThrottled);
}
void _handleDragUpdateThrottled() {
assert(_lastDragStartDetails != null);
assert(_lastDragUpdateDetails != null);
if (widget.onDragSelectionUpdate != null) {
widget.onDragSelectionUpdate!(
_lastDragStartDetails!, _lastDragUpdateDetails!);
}
_dragUpdateThrottleTimer = null;
_lastDragUpdateDetails = null;
}
void _handleDragEnd(DragEndDetails details) {
assert(_lastDragStartDetails != null);
if (_dragUpdateThrottleTimer != null) {
_dragUpdateThrottleTimer!.cancel();
_handleDragUpdateThrottled();
}
if (widget.onDragSelectionEnd != null) {
widget.onDragSelectionEnd!(details);
}
_dragUpdateThrottleTimer = null;
_lastDragStartDetails = null;
_lastDragUpdateDetails = null;
}
void _forcePressStarted(ForcePressDetails details) {
_doubleTapTimer?.cancel();
_doubleTapTimer = null;
if (widget.onForcePressStart != null) {
widget.onForcePressStart!(details);
}
}
void _forcePressEnded(ForcePressDetails details) {
if (widget.onForcePressEnd != null) {
widget.onForcePressEnd!(details);
}
}
void _handleLongPressStart(LongPressStartDetails details) {
if (!_isDoubleTap && widget.onSingleLongTapStart != null) {
widget.onSingleLongTapStart!(details);
}
}
void _handleLongPressMoveUpdate(LongPressMoveUpdateDetails details) {
if (!_isDoubleTap && widget.onSingleLongTapMoveUpdate != null) {
widget.onSingleLongTapMoveUpdate!(details);
}
}
void _handleLongPressEnd(LongPressEndDetails details) {
if (!_isDoubleTap && widget.onSingleLongTapEnd != null) {
widget.onSingleLongTapEnd!(details);
}
_isDoubleTap = false;
}
void _doubleTapTimeout() {
_doubleTapTimer = null;
_lastTapOffset = null;
}
bool _isWithinDoubleTapTolerance(Offset secondTapOffset) {
if (_lastTapOffset == null) {
return false;
}
return (secondTapOffset - _lastTapOffset!).distance <= kDoubleTapSlop;
}
@override
Widget build(BuildContext context) {
final gestures = <Type, GestureRecognizerFactory>{};
gestures[TapGestureRecognizer] =
GestureRecognizerFactoryWithHandlers<TapGestureRecognizer>(
() => TapGestureRecognizer(debugOwner: this),
(instance) {
instance
..onTapDown = _handleTapDown
..onTapUp = _handleTapUp
..onTapCancel = _handleTapCancel;
},
);
if (widget.onSingleLongTapStart != null ||
widget.onSingleLongTapMoveUpdate != null ||
widget.onSingleLongTapEnd != null) {
gestures[LongPressGestureRecognizer] =
GestureRecognizerFactoryWithHandlers<LongPressGestureRecognizer>(
() => LongPressGestureRecognizer(
debugOwner: this, kind: PointerDeviceKind.touch),
(instance) {
instance
..onLongPressStart = _handleLongPressStart
..onLongPressMoveUpdate = _handleLongPressMoveUpdate
..onLongPressEnd = _handleLongPressEnd;
},
);
}
if (widget.onDragSelectionStart != null ||
widget.onDragSelectionUpdate != null ||
widget.onDragSelectionEnd != null) {
gestures[HorizontalDragGestureRecognizer] =
GestureRecognizerFactoryWithHandlers<HorizontalDragGestureRecognizer>(
() => HorizontalDragGestureRecognizer(
debugOwner: this, kind: PointerDeviceKind.mouse),
(instance) {
instance
..dragStartBehavior = DragStartBehavior.down
..onStart = _handleDragStart
..onUpdate = _handleDragUpdate
..onEnd = _handleDragEnd;
},
);
}
if (widget.onForcePressStart != null || widget.onForcePressEnd != null) {
gestures[ForcePressGestureRecognizer] =
GestureRecognizerFactoryWithHandlers<ForcePressGestureRecognizer>(
() => ForcePressGestureRecognizer(debugOwner: this),
(instance) {
instance
..onStart =
widget.onForcePressStart != null ? _forcePressStarted : null
..onEnd = widget.onForcePressEnd != null ? _forcePressEnded : null;
},
);
}
return RawGestureDetector(
gestures: gestures,
excludeFromSemantics: true,
behavior: widget.behavior,
child: widget.child,
);
}
}

File diff suppressed because it is too large Load Diff

@ -1,125 +1,3 @@
import 'dart:ui';
import 'package:flutter/material.dart';
Color stringToColor(String? s) {
switch (s) {
case 'transparent':
return Colors.transparent;
case 'black':
return Colors.black;
case 'black12':
return Colors.black12;
case 'black26':
return Colors.black26;
case 'black38':
return Colors.black38;
case 'black45':
return Colors.black45;
case 'black54':
return Colors.black54;
case 'black87':
return Colors.black87;
case 'white':
return Colors.white;
case 'white10':
return Colors.white10;
case 'white12':
return Colors.white12;
case 'white24':
return Colors.white24;
case 'white30':
return Colors.white30;
case 'white38':
return Colors.white38;
case 'white54':
return Colors.white54;
case 'white60':
return Colors.white60;
case 'white70':
return Colors.white70;
case 'red':
return Colors.red;
case 'redAccent':
return Colors.redAccent;
case 'amber':
return Colors.amber;
case 'amberAccent':
return Colors.amberAccent;
case 'yellow':
return Colors.yellow;
case 'yellowAccent':
return Colors.yellowAccent;
case 'teal':
return Colors.teal;
case 'tealAccent':
return Colors.tealAccent;
case 'purple':
return Colors.purple;
case 'purpleAccent':
return Colors.purpleAccent;
case 'pink':
return Colors.pink;
case 'pinkAccent':
return Colors.pinkAccent;
case 'orange':
return Colors.orange;
case 'orangeAccent':
return Colors.orangeAccent;
case 'deepOrange':
return Colors.deepOrange;
case 'deepOrangeAccent':
return Colors.deepOrangeAccent;
case 'indigo':
return Colors.indigo;
case 'indigoAccent':
return Colors.indigoAccent;
case 'lime':
return Colors.lime;
case 'limeAccent':
return Colors.limeAccent;
case 'grey':
return Colors.grey;
case 'blueGrey':
return Colors.blueGrey;
case 'green':
return Colors.green;
case 'greenAccent':
return Colors.greenAccent;
case 'lightGreen':
return Colors.lightGreen;
case 'lightGreenAccent':
return Colors.lightGreenAccent;
case 'blue':
return Colors.blue;
case 'blueAccent':
return Colors.blueAccent;
case 'lightBlue':
return Colors.lightBlue;
case 'lightBlueAccent':
return Colors.lightBlueAccent;
case 'cyan':
return Colors.cyan;
case 'cyanAccent':
return Colors.cyanAccent;
case 'brown':
return Colors.brown;
}
if (s!.startsWith('rgba')) {
s = s.substring(5); // trim left 'rgba('
s = s.substring(0, s.length - 1); // trim right ')'
final arr = s.split(',').map((e) => e.trim()).toList();
return Color.fromRGBO(int.parse(arr[0]), int.parse(arr[1]),
int.parse(arr[2]), double.parse(arr[3]));
}
if (!s.startsWith('#')) {
throw 'Color code not supported';
}
var hex = s.replaceFirst('#', '');
hex = hex.length == 6 ? 'ff$hex' : hex;
final val = int.parse(hex, radix: 16);
return Color(val);
}
/// 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/utils/color.dart';

@ -1,102 +1,3 @@
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);
// Start index in old text at which changes begin.
final int start;
/// The deleted text
final String deleted;
// The inserted text
final String inserted;
@override
String toString() {
return 'Diff[$start, "$deleted", "$inserted"]';
}
}
/* Get diff operation between old text and new text */
Diff getDiff(String oldText, String newText, int cursorPosition) {
var end = oldText.length;
final delta = newText.length - end;
for (final limit = math.max(0, cursorPosition - delta);
end > limit && oldText[end - 1] == newText[end + delta - 1];
end--) {}
var start = 0;
for (final startLimit = cursorPosition - math.max(0, delta);
start < startLimit && oldText[start] == newText[start];
start++) {}
final deleted = (start >= end) ? '' : oldText.substring(start, end);
final inserted = newText.substring(start, end + delta);
return Diff(start, deleted, inserted);
}
int getPositionDelta(Delta user, Delta actual) {
if (actual.isEmpty) {
return 0;
}
final userItr = DeltaIterator(user);
final actualItr = DeltaIterator(actual);
var diff = 0;
while (userItr.hasNext || actualItr.hasNext) {
final length = math.min(userItr.peekLength(), actualItr.peekLength());
final userOperation = userItr.next(length as int);
final actualOperation = actualItr.next(length);
if (userOperation.length != actualOperation.length) {
throw 'userOp ${userOperation.length} does not match actualOp ${actualOperation.length}';
}
if (userOperation.key == actualOperation.key) {
continue;
} else if (userOperation.isInsert && actualOperation.isRetain) {
diff -= userOperation.length!;
} else if (userOperation.isDelete && actualOperation.isRetain) {
diff += userOperation.length!;
} else if (userOperation.isRetain && actualOperation.isInsert) {
String? operationTxt = '';
if (actualOperation.data is String) {
operationTxt = actualOperation.data as String?;
}
if (operationTxt!.startsWith('\n')) {
continue;
}
diff += actualOperation.length!;
}
}
return diff;
}
/// 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/utils/diff_delta.dart';

@ -1,39 +1,3 @@
import 'package:flutter/rendering.dart';
import '../models/documents/nodes/container.dart';
abstract class RenderContentProxyBox implements RenderBox {
double getPreferredLineHeight();
Offset getOffsetForCaret(TextPosition position, Rect? caretPrototype);
TextPosition getPositionForOffset(Offset offset);
double? getFullHeightForCaret(TextPosition position);
TextRange getWordBoundary(TextPosition position);
List<TextBox> getBoxesForSelection(TextSelection textSelection);
}
abstract class RenderEditableBox extends RenderBox {
Container getContainer();
double preferredLineHeight(TextPosition position);
Offset getOffsetForCaret(TextPosition position);
TextPosition getPositionForOffset(Offset offset);
TextPosition? getPositionAbove(TextPosition position);
TextPosition? getPositionBelow(TextPosition position);
TextRange getWordBoundary(TextPosition position);
TextRange getLineBoundary(TextPosition position);
TextSelectionPoint getBaseEndpointForSelection(TextSelection textSelection);
TextSelectionPoint getExtentEndpointForSelection(TextSelection textSelection);
}
/// 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/box.dart';

@ -1,228 +1,3 @@
import 'dart:math' as math;
import 'package:flutter/cupertino.dart';
import 'package:tuple/tuple.dart';
import '../models/documents/attribute.dart';
import '../models/documents/document.dart';
import '../models/documents/nodes/embed.dart';
import '../models/documents/style.dart';
import '../models/quill_delta.dart';
import '../utils/diff_delta.dart';
class QuillController extends ChangeNotifier {
QuillController(
{required this.document,
required this.selection,
this.iconSize = 18,
this.toolbarHeightFactor = 2});
factory QuillController.basic() {
return QuillController(
document: Document(),
selection: const TextSelection.collapsed(offset: 0),
);
}
final Document document;
TextSelection selection;
double iconSize;
double toolbarHeightFactor;
Style toggledStyle = Style();
bool ignoreFocusOnTextChange = false;
/// Controls whether this [QuillController] instance has already been disposed
/// of
///
/// This is a safe approach to make sure that listeners don't crash when
/// adding, removing or listeners to this instance.
bool _isDisposed = false;
// item1: Document state before [change].
//
// item2: Change delta applied to the document.
//
// item3: The source of this change.
Stream<Tuple3<Delta, Delta, ChangeSource>> get changes => document.changes;
TextEditingValue get plainTextEditingValue => TextEditingValue(
text: document.toPlainText(),
selection: selection,
);
Style getSelectionStyle() {
return document
.collectStyle(selection.start, selection.end - selection.start)
.mergeAll(toggledStyle);
}
void undo() {
final tup = document.undo();
if (tup.item1) {
_handleHistoryChange(tup.item2);
}
}
void _handleHistoryChange(int? len) {
if (len != 0) {
// if (this.selection.extentOffset >= document.length) {
// // cursor exceeds the length of document, position it in the end
// updateSelection(
// TextSelection.collapsed(offset: document.length), ChangeSource.LOCAL);
updateSelection(
TextSelection.collapsed(offset: selection.baseOffset + len!),
ChangeSource.LOCAL);
} else {
// no need to move cursor
notifyListeners();
}
}
void redo() {
final tup = document.redo();
if (tup.item1) {
_handleHistoryChange(tup.item2);
}
}
bool get hasUndo => document.hasUndo;
bool get hasRedo => document.hasRedo;
void replaceText(
int index, int len, Object? data, TextSelection? textSelection,
{bool ignoreFocus = false, bool autoAppendNewlineAfterImage = true}) {
assert(data is String || data is Embeddable);
Delta? delta;
if (len > 0 || data is! String || data.isNotEmpty) {
delta = document.replace(index, len, data, autoAppendNewlineAfterImage: autoAppendNewlineAfterImage);
var shouldRetainDelta = toggledStyle.isNotEmpty &&
delta.isNotEmpty &&
delta.length <= 2 &&
delta.last.isInsert;
if (shouldRetainDelta &&
toggledStyle.isNotEmpty &&
delta.length == 2 &&
delta.last.data == '\n') {
// if all attributes are inline, shouldRetainDelta should be false
final anyAttributeNotInline =
toggledStyle.values.any((attr) => !attr.isInline);
if (!anyAttributeNotInline) {
shouldRetainDelta = false;
}
}
if (shouldRetainDelta) {
final retainDelta = Delta()
..retain(index)
..retain(data is String ? data.length : 1, toggledStyle.toJson());
document.compose(retainDelta, ChangeSource.LOCAL);
}
}
toggledStyle = Style();
if (textSelection != null) {
if (delta == null || delta.isEmpty) {
_updateSelection(textSelection, ChangeSource.LOCAL);
} else {
final user = Delta()
..retain(index)
..insert(data)
..delete(len);
final positionDelta = getPositionDelta(user, delta);
_updateSelection(
textSelection.copyWith(
baseOffset: textSelection.baseOffset + positionDelta,
extentOffset: textSelection.extentOffset + positionDelta,
),
ChangeSource.LOCAL,
);
}
}
if (ignoreFocus) {
ignoreFocusOnTextChange = true;
}
notifyListeners();
ignoreFocusOnTextChange = false;
}
void formatText(int index, int len, Attribute? attribute) {
if (len == 0 &&
attribute!.isInline &&
attribute.key != Attribute.link.key) {
toggledStyle = toggledStyle.put(attribute);
}
final change = document.format(index, len, attribute);
final adjustedSelection = selection.copyWith(
baseOffset: change.transformPosition(selection.baseOffset),
extentOffset: change.transformPosition(selection.extentOffset));
if (selection != adjustedSelection) {
_updateSelection(adjustedSelection, ChangeSource.LOCAL);
}
notifyListeners();
}
void formatSelection(Attribute? attribute) {
formatText(selection.start, selection.end - selection.start, attribute);
}
void updateSelection(TextSelection textSelection, ChangeSource source) {
_updateSelection(textSelection, source);
notifyListeners();
}
void compose(Delta delta, TextSelection textSelection, ChangeSource source) {
if (delta.isNotEmpty) {
document.compose(delta, source);
}
textSelection = selection.copyWith(
baseOffset: delta.transformPosition(selection.baseOffset, force: false),
extentOffset:
delta.transformPosition(selection.extentOffset, force: false));
if (selection != textSelection) {
_updateSelection(textSelection, source);
}
notifyListeners();
}
@override
void addListener(VoidCallback listener) {
// By using `_isDisposed`, make sure that `addListener` won't be called on a
// disposed `ChangeListener`
if (!_isDisposed) {
super.addListener(listener);
}
}
@override
void removeListener(VoidCallback listener) {
// By using `_isDisposed`, make sure that `removeListener` won't be called
// on a disposed `ChangeListener`
if (!_isDisposed) {
super.removeListener(listener);
}
}
@override
void dispose() {
if (!_isDisposed) {
document.close();
}
_isDisposed = true;
super.dispose();
}
void _updateSelection(TextSelection textSelection, ChangeSource source) {
selection = textSelection;
final end = document.length - 1;
selection = selection.copyWith(
baseOffset: math.min(selection.baseOffset, end),
extentOffset: math.min(selection.extentOffset, end));
}
}
/// 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/controller.dart';

@ -1,231 +1,3 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'box.dart';
const Duration _FADE_DURATION = Duration(milliseconds: 250);
class CursorStyle {
const CursorStyle({
required this.color,
required this.backgroundColor,
this.width = 1.0,
this.height,
this.radius,
this.offset,
this.opacityAnimates = false,
this.paintAboveText = false,
});
final Color color;
final Color backgroundColor;
final double width;
final double? height;
final Radius? radius;
final Offset? offset;
final bool opacityAnimates;
final bool paintAboveText;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is CursorStyle &&
runtimeType == other.runtimeType &&
color == other.color &&
backgroundColor == other.backgroundColor &&
width == other.width &&
height == other.height &&
radius == other.radius &&
offset == other.offset &&
opacityAnimates == other.opacityAnimates &&
paintAboveText == other.paintAboveText;
@override
int get hashCode =>
color.hashCode ^
backgroundColor.hashCode ^
width.hashCode ^
height.hashCode ^
radius.hashCode ^
offset.hashCode ^
opacityAnimates.hashCode ^
paintAboveText.hashCode;
}
class CursorCont extends ChangeNotifier {
CursorCont({
required this.show,
required CursorStyle style,
required TickerProvider tickerProvider,
}) : _style = style,
_blink = ValueNotifier(false),
color = ValueNotifier(style.color) {
_blinkOpacityCont =
AnimationController(vsync: tickerProvider, duration: _FADE_DURATION);
_blinkOpacityCont.addListener(_onColorTick);
}
final ValueNotifier<bool> show;
final ValueNotifier<bool> _blink;
final ValueNotifier<Color> color;
late AnimationController _blinkOpacityCont;
Timer? _cursorTimer;
bool _targetCursorVisibility = false;
CursorStyle _style;
ValueNotifier<bool> get cursorBlink => _blink;
ValueNotifier<Color> get cursorColor => color;
CursorStyle get style => _style;
set style(CursorStyle value) {
if (_style == value) return;
_style = value;
notifyListeners();
}
@override
void dispose() {
_blinkOpacityCont.removeListener(_onColorTick);
stopCursorTimer();
_blinkOpacityCont.dispose();
assert(_cursorTimer == null);
super.dispose();
}
void _cursorTick(Timer timer) {
_targetCursorVisibility = !_targetCursorVisibility;
final targetOpacity = _targetCursorVisibility ? 1.0 : 0.0;
if (style.opacityAnimates) {
_blinkOpacityCont.animateTo(targetOpacity, curve: Curves.easeOut);
} else {
_blinkOpacityCont.value = targetOpacity;
}
}
void _cursorWaitForStart(Timer timer) {
_cursorTimer?.cancel();
_cursorTimer =
Timer.periodic(const Duration(milliseconds: 500), _cursorTick);
}
void startCursorTimer() {
_targetCursorVisibility = true;
_blinkOpacityCont.value = 1.0;
if (style.opacityAnimates) {
_cursorTimer = Timer.periodic(
const Duration(milliseconds: 150), _cursorWaitForStart);
} else {
_cursorTimer =
Timer.periodic(const Duration(milliseconds: 500), _cursorTick);
}
}
void stopCursorTimer({bool resetCharTicks = true}) {
_cursorTimer?.cancel();
_cursorTimer = null;
_targetCursorVisibility = false;
_blinkOpacityCont.value = 0.0;
if (style.opacityAnimates) {
_blinkOpacityCont
..stop()
..value = 0.0;
}
}
void startOrStopCursorTimerIfNeeded(bool hasFocus, TextSelection selection) {
if (show.value &&
_cursorTimer == null &&
hasFocus &&
selection.isCollapsed) {
startCursorTimer();
} else if (_cursorTimer != null && (!hasFocus || !selection.isCollapsed)) {
stopCursorTimer();
}
}
void _onColorTick() {
color.value = _style.color.withOpacity(_blinkOpacityCont.value);
_blink.value = show.value && _blinkOpacityCont.value > 0;
}
}
class CursorPainter {
CursorPainter(this.editable, this.style, this.prototype, this.color,
this.devicePixelRatio);
final RenderContentProxyBox? editable;
final CursorStyle style;
final Rect? prototype;
final Color color;
final double devicePixelRatio;
void paint(Canvas canvas, Offset offset, TextPosition position) {
assert(prototype != null);
final caretOffset =
editable!.getOffsetForCaret(position, prototype) + offset;
var caretRect = prototype!.shift(caretOffset);
if (style.offset != null) {
caretRect = caretRect.shift(style.offset!);
}
if (caretRect.left < 0.0) {
caretRect = caretRect.shift(Offset(-caretRect.left, 0));
}
final caretHeight = editable!.getFullHeightForCaret(position);
if (caretHeight != null) {
switch (defaultTargetPlatform) {
case TargetPlatform.android:
case TargetPlatform.fuchsia:
case TargetPlatform.linux:
case TargetPlatform.windows:
caretRect = Rect.fromLTWH(
caretRect.left,
caretRect.top - 2.0,
caretRect.width,
caretHeight,
);
break;
case TargetPlatform.iOS:
case TargetPlatform.macOS:
caretRect = Rect.fromLTWH(
caretRect.left,
caretRect.top + (caretHeight - caretRect.height) / 2,
caretRect.width,
caretRect.height,
);
break;
default:
throw UnimplementedError();
}
}
final caretPosition = editable!.localToGlobal(caretRect.topLeft);
final pixelMultiple = 1.0 / devicePixelRatio;
caretRect = caretRect.shift(Offset(
caretPosition.dx.isFinite
? (caretPosition.dx / pixelMultiple).round() * pixelMultiple -
caretPosition.dx
: caretPosition.dx,
caretPosition.dy.isFinite
? (caretPosition.dy / pixelMultiple).round() * pixelMultiple -
caretPosition.dy
: caretPosition.dy));
final paint = Paint()..color = color;
if (style.radius == null) {
canvas.drawRect(caretRect, paint);
return;
}
final caretRRect = RRect.fromRectAndRadius(caretRect, style.radius!);
canvas.drawRRect(caretRRect, paint);
}
}
/// 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/cursor.dart';

@ -1,223 +1,3 @@
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'package:tuple/tuple.dart';
class QuillStyles extends InheritedWidget {
const QuillStyles({
required this.data,
required Widget child,
Key? key,
}) : super(key: key, child: child);
final DefaultStyles data;
@override
bool updateShouldNotify(QuillStyles oldWidget) {
return data != oldWidget.data;
}
static DefaultStyles? getStyles(BuildContext context, bool nullOk) {
final widget = context.dependOnInheritedWidgetOfExactType<QuillStyles>();
if (widget == null && nullOk) {
return null;
}
assert(widget != null);
return widget!.data;
}
}
class DefaultTextBlockStyle {
DefaultTextBlockStyle(
this.style,
this.verticalSpacing,
this.lineSpacing,
this.decoration,
);
final TextStyle style;
final Tuple2<double, double> verticalSpacing;
final Tuple2<double, double> lineSpacing;
final BoxDecoration? decoration;
}
class DefaultStyles {
DefaultStyles({
this.h1,
this.h2,
this.h3,
this.paragraph,
this.bold,
this.italic,
this.underline,
this.strikeThrough,
this.link,
this.color,
this.placeHolder,
this.lists,
this.quote,
this.code,
this.indent,
this.align,
this.leading,
this.sizeSmall,
this.sizeLarge,
this.sizeHuge,
});
final DefaultTextBlockStyle? h1;
final DefaultTextBlockStyle? h2;
final DefaultTextBlockStyle? h3;
final DefaultTextBlockStyle? paragraph;
final TextStyle? bold;
final TextStyle? italic;
final TextStyle? underline;
final TextStyle? strikeThrough;
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 DefaultTextBlockStyle? quote;
final DefaultTextBlockStyle? code;
final DefaultTextBlockStyle? indent;
final DefaultTextBlockStyle? align;
final DefaultTextBlockStyle? leading;
static DefaultStyles getInstance(BuildContext context) {
final themeData = Theme.of(context);
final defaultTextStyle = DefaultTextStyle.of(context);
final baseStyle = defaultTextStyle.style.copyWith(
fontSize: 16,
height: 1.3,
);
const baseSpacing = Tuple2<double, double>(6, 0);
String fontFamily;
switch (themeData.platform) {
case TargetPlatform.iOS:
case TargetPlatform.macOS:
fontFamily = 'Menlo';
break;
case TargetPlatform.android:
case TargetPlatform.fuchsia:
case TargetPlatform.windows:
case TargetPlatform.linux:
fontFamily = 'Roboto Mono';
break;
default:
throw UnimplementedError();
}
return DefaultStyles(
h1: DefaultTextBlockStyle(
defaultTextStyle.style.copyWith(
fontSize: 34,
color: defaultTextStyle.style.color!.withOpacity(0.70),
height: 1.15,
fontWeight: FontWeight.w300,
),
const Tuple2(16, 0),
const Tuple2(0, 0),
null),
h2: DefaultTextBlockStyle(
defaultTextStyle.style.copyWith(
fontSize: 24,
color: defaultTextStyle.style.color!.withOpacity(0.70),
height: 1.15,
fontWeight: FontWeight.normal,
),
const Tuple2(8, 0),
const Tuple2(0, 0),
null),
h3: DefaultTextBlockStyle(
defaultTextStyle.style.copyWith(
fontSize: 20,
color: defaultTextStyle.style.color!.withOpacity(0.70),
height: 1.25,
fontWeight: FontWeight.w500,
),
const Tuple2(8, 0),
const Tuple2(0, 0),
null),
paragraph: DefaultTextBlockStyle(
baseStyle, const Tuple2(0, 0), const Tuple2(0, 0), null),
bold: const TextStyle(fontWeight: FontWeight.bold),
italic: const TextStyle(fontStyle: FontStyle.italic),
underline: const TextStyle(decoration: TextDecoration.underline),
strikeThrough: const TextStyle(decoration: TextDecoration.lineThrough),
link: TextStyle(
color: themeData.accentColor,
decoration: TextDecoration.underline,
),
placeHolder: DefaultTextBlockStyle(
defaultTextStyle.style.copyWith(
fontSize: 20,
height: 1.5,
color: Colors.grey.withOpacity(0.6),
),
const Tuple2(0, 0),
const Tuple2(0, 0),
null),
lists: DefaultTextBlockStyle(
baseStyle, baseSpacing, const Tuple2(0, 6), null),
quote: DefaultTextBlockStyle(
TextStyle(color: baseStyle.color!.withOpacity(0.6)),
baseSpacing,
const Tuple2(6, 2),
BoxDecoration(
border: Border(
left: BorderSide(width: 4, color: Colors.grey.shade300),
),
)),
code: DefaultTextBlockStyle(
TextStyle(
color: Colors.blue.shade900.withOpacity(0.9),
fontFamily: fontFamily,
fontSize: 13,
height: 1.15,
),
baseSpacing,
const Tuple2(0, 0),
BoxDecoration(
color: Colors.grey.shade50,
borderRadius: BorderRadius.circular(2),
)),
indent: DefaultTextBlockStyle(
baseStyle, baseSpacing, const Tuple2(0, 6), null),
align: DefaultTextBlockStyle(
baseStyle, const Tuple2(0, 0), const Tuple2(0, 0), null),
leading: DefaultTextBlockStyle(
baseStyle, const Tuple2(0, 0), const Tuple2(0, 0), null),
sizeSmall: const TextStyle(fontSize: 10),
sizeLarge: const TextStyle(fontSize: 18),
sizeHuge: const TextStyle(fontSize: 22));
}
DefaultStyles merge(DefaultStyles other) {
return DefaultStyles(
h1: other.h1 ?? h1,
h2: other.h2 ?? h2,
h3: other.h3 ?? h3,
paragraph: other.paragraph ?? paragraph,
bold: other.bold ?? bold,
italic: other.italic ?? italic,
underline: other.underline ?? underline,
strikeThrough: other.strikeThrough ?? strikeThrough,
link: other.link ?? link,
color: other.color ?? color,
placeHolder: other.placeHolder ?? placeHolder,
lists: other.lists ?? lists,
quote: other.quote ?? quote,
code: other.code ?? code,
indent: other.indent ?? indent,
align: other.align ?? align,
leading: other.leading ?? leading,
sizeSmall: other.sizeSmall ?? sizeSmall,
sizeLarge: other.sizeLarge ?? sizeLarge,
sizeHuge: other.sizeHuge ?? sizeHuge);
}
}
/// 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/default_styles.dart';

@ -1,148 +1,3 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import '../models/documents/nodes/leaf.dart';
import 'editor.dart';
import 'text_selection.dart';
typedef EmbedBuilder = Widget Function(BuildContext context, Embed node);
abstract class EditorTextSelectionGestureDetectorBuilderDelegate {
GlobalKey<EditorState> getEditableTextKey();
bool getForcePressEnabled();
bool getSelectionEnabled();
}
class EditorTextSelectionGestureDetectorBuilder {
EditorTextSelectionGestureDetectorBuilder(this.delegate);
final EditorTextSelectionGestureDetectorBuilderDelegate delegate;
bool shouldShowSelectionToolbar = true;
EditorState? getEditor() {
return delegate.getEditableTextKey().currentState;
}
RenderEditor? getRenderEditor() {
return getEditor()!.getRenderEditor();
}
void onTapDown(TapDownDetails details) {
getRenderEditor()!.handleTapDown(details);
final kind = details.kind;
shouldShowSelectionToolbar = kind == null ||
kind == PointerDeviceKind.touch ||
kind == PointerDeviceKind.stylus;
}
void onForcePressStart(ForcePressDetails details) {
assert(delegate.getForcePressEnabled());
shouldShowSelectionToolbar = true;
if (delegate.getSelectionEnabled()) {
getRenderEditor()!.selectWordsInRange(
details.globalPosition,
null,
SelectionChangedCause.forcePress,
);
}
}
void onForcePressEnd(ForcePressDetails details) {
assert(delegate.getForcePressEnabled());
getRenderEditor()!.selectWordsInRange(
details.globalPosition,
null,
SelectionChangedCause.forcePress,
);
if (shouldShowSelectionToolbar) {
getEditor()!.showToolbar();
}
}
void onSingleTapUp(TapUpDetails details) {
if (delegate.getSelectionEnabled()) {
getRenderEditor()!.selectWordEdge(SelectionChangedCause.tap);
}
}
void onSingleTapCancel() {}
void onSingleLongTapStart(LongPressStartDetails details) {
if (delegate.getSelectionEnabled()) {
getRenderEditor()!.selectPositionAt(
details.globalPosition,
null,
SelectionChangedCause.longPress,
);
}
}
void onSingleLongTapMoveUpdate(LongPressMoveUpdateDetails details) {
if (delegate.getSelectionEnabled()) {
getRenderEditor()!.selectPositionAt(
details.globalPosition,
null,
SelectionChangedCause.longPress,
);
}
}
void onSingleLongTapEnd(LongPressEndDetails details) {
if (shouldShowSelectionToolbar) {
getEditor()!.showToolbar();
}
}
void onDoubleTapDown(TapDownDetails details) {
if (delegate.getSelectionEnabled()) {
getRenderEditor()!.selectWord(SelectionChangedCause.tap);
if (shouldShowSelectionToolbar) {
getEditor()!.showToolbar();
}
}
}
void onDragSelectionStart(DragStartDetails details) {
getRenderEditor()!.selectPositionAt(
details.globalPosition,
null,
SelectionChangedCause.drag,
);
}
void onDragSelectionUpdate(
DragStartDetails startDetails, DragUpdateDetails updateDetails) {
getRenderEditor()!.selectPositionAt(
startDetails.globalPosition,
updateDetails.globalPosition,
SelectionChangedCause.drag,
);
}
void onDragSelectionEnd(DragEndDetails details) {}
Widget build(HitTestBehavior behavior, Widget child) {
return EditorTextSelectionGestureDetector(
onTapDown: onTapDown,
onForcePressStart:
delegate.getForcePressEnabled() ? onForcePressStart : null,
onForcePressEnd: delegate.getForcePressEnabled() ? onForcePressEnd : null,
onSingleTapUp: onSingleTapUp,
onSingleTapCancel: onSingleTapCancel,
onSingleLongTapStart: onSingleLongTapStart,
onSingleLongTapMoveUpdate: onSingleLongTapMoveUpdate,
onSingleLongTapEnd: onSingleLongTapEnd,
onDoubleTapDown: onDoubleTapDown,
onDragSelectionStart: onDragSelectionStart,
onDragSelectionUpdate: onDragSelectionUpdate,
onDragSelectionEnd: onDragSelectionEnd,
behavior: behavior,
child: child,
);
}
}
/// 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/delegate.dart';

File diff suppressed because it is too large Load Diff

@ -1,31 +1,3 @@
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 {
const ImageTapWrapper({
this.imageProvider,
});
final ImageProvider? imageProvider;
@override
Widget build(BuildContext context) {
return Scaffold(
body: Container(
constraints: BoxConstraints.expand(
height: MediaQuery.of(context).size.height,
),
child: GestureDetector(
onTapDown: (_) {
Navigator.pop(context);
},
child: PhotoView(
imageProvider: imageProvider,
),
),
),
);
}
}
/// 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/image.dart';

@ -1,105 +1,3 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/services.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,
};
bool handleRawKeyEvent(RawKeyEvent event) {
if (kIsWeb) {
// On web platform, we should ignore the key because it's processed already.
return false;
}
if (event is! RawKeyDownEvent) {
return false;
}
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 false;
}
if (_moveKeys.contains(key)) {
onCursorMove(
key,
isMacOS ? event.isAltPressed : event.isControlPressed,
isMacOS ? event.isMetaPressed : event.isAltPressed,
event.isShiftPressed);
} else if (isMacOS
? event.isMetaPressed
: event.isControlPressed && _shortcutKeys.contains(key)) {
onShortcut(_keyToShortcut[key]);
} else if (key == LogicalKeyboardKey.delete) {
onDelete(true);
} else if (key == LogicalKeyboardKey.backspace) {
onDelete(false);
}
return false;
}
}
/// 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,298 +1,3 @@
import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart';
import 'box.dart';
class BaselineProxy extends SingleChildRenderObjectWidget {
const BaselineProxy({Key? key, Widget? child, this.textStyle, this.padding})
: super(key: key, child: child);
final TextStyle? textStyle;
final EdgeInsets? padding;
@override
RenderBaselineProxy createRenderObject(BuildContext context) {
return RenderBaselineProxy(
null,
textStyle!,
padding,
);
}
@override
void updateRenderObject(
BuildContext context, covariant RenderBaselineProxy renderObject) {
renderObject
..textStyle = textStyle!
..padding = padding!;
}
}
class RenderBaselineProxy extends RenderProxyBox {
RenderBaselineProxy(
RenderParagraph? child,
TextStyle textStyle,
EdgeInsets? padding,
) : _prototypePainter = TextPainter(
text: TextSpan(text: ' ', style: textStyle),
textDirection: TextDirection.ltr,
strutStyle:
StrutStyle.fromTextStyle(textStyle, forceStrutHeight: true)),
super(child);
final TextPainter _prototypePainter;
set textStyle(TextStyle value) {
if (_prototypePainter.text!.style == value) {
return;
}
_prototypePainter.text = TextSpan(text: ' ', style: value);
markNeedsLayout();
}
EdgeInsets? _padding;
set padding(EdgeInsets value) {
if (_padding == value) {
return;
}
_padding = value;
markNeedsLayout();
}
@override
double computeDistanceToActualBaseline(TextBaseline baseline) =>
_prototypePainter.computeDistanceToActualBaseline(baseline);
// SEE What happens + _padding?.top;
@override
void performLayout() {
super.performLayout();
_prototypePainter.layout();
}
}
class EmbedProxy extends SingleChildRenderObjectWidget {
const EmbedProxy(Widget child) : super(child: child);
@override
RenderEmbedProxy createRenderObject(BuildContext context) =>
RenderEmbedProxy(null);
}
class RenderEmbedProxy extends RenderProxyBox implements RenderContentProxyBox {
RenderEmbedProxy(RenderBox? child) : super(child);
@override
List<TextBox> getBoxesForSelection(TextSelection selection) {
if (!selection.isCollapsed) {
return <TextBox>[
TextBox.fromLTRBD(0, 0, size.width, size.height, TextDirection.ltr)
];
}
final left = selection.extentOffset == 0 ? 0.0 : size.width;
final right = selection.extentOffset == 0 ? 0.0 : size.width;
return <TextBox>[
TextBox.fromLTRBD(left, 0, right, size.height, TextDirection.ltr)
];
}
@override
double getFullHeightForCaret(TextPosition position) => size.height;
@override
Offset getOffsetForCaret(TextPosition position, Rect? caretPrototype) {
assert(position.offset <= 1 && position.offset >= 0);
return position.offset == 0 ? Offset.zero : Offset(size.width, 0);
}
@override
TextPosition getPositionForOffset(Offset offset) =>
TextPosition(offset: offset.dx > size.width / 2 ? 1 : 0);
@override
TextRange getWordBoundary(TextPosition position) =>
const TextRange(start: 0, end: 1);
@override
double getPreferredLineHeight() {
return size.height;
}
}
class RichTextProxy extends SingleChildRenderObjectWidget {
const RichTextProxy(
RichText child,
this.textStyle,
this.textAlign,
this.textDirection,
this.textScaleFactor,
this.locale,
this.strutStyle,
this.textWidthBasis,
this.textHeightBehavior,
) : super(child: child);
final TextStyle textStyle;
final TextAlign textAlign;
final TextDirection textDirection;
final double textScaleFactor;
final Locale locale;
final StrutStyle strutStyle;
final TextWidthBasis textWidthBasis;
final TextHeightBehavior? textHeightBehavior;
@override
RenderParagraphProxy createRenderObject(BuildContext context) {
return RenderParagraphProxy(
null,
textStyle,
textAlign,
textDirection,
textScaleFactor,
strutStyle,
locale,
textWidthBasis,
textHeightBehavior);
}
@override
void updateRenderObject(
BuildContext context, covariant RenderParagraphProxy renderObject) {
renderObject
..textStyle = textStyle
..textAlign = textAlign
..textDirection = textDirection
..textScaleFactor = textScaleFactor
..locale = locale
..strutStyle = strutStyle
..textWidthBasis = textWidthBasis
..textHeightBehavior = textHeightBehavior;
}
}
class RenderParagraphProxy extends RenderProxyBox
implements RenderContentProxyBox {
RenderParagraphProxy(
RenderParagraph? child,
TextStyle textStyle,
TextAlign textAlign,
TextDirection textDirection,
double textScaleFactor,
StrutStyle strutStyle,
Locale locale,
TextWidthBasis textWidthBasis,
TextHeightBehavior? textHeightBehavior,
) : _prototypePainter = TextPainter(
text: TextSpan(text: ' ', style: textStyle),
textAlign: textAlign,
textDirection: textDirection,
textScaleFactor: textScaleFactor,
strutStyle: strutStyle,
locale: locale,
textWidthBasis: textWidthBasis,
textHeightBehavior: textHeightBehavior),
super(child);
final TextPainter _prototypePainter;
set textStyle(TextStyle value) {
if (_prototypePainter.text!.style == value) {
return;
}
_prototypePainter.text = TextSpan(text: ' ', style: value);
markNeedsLayout();
}
set textAlign(TextAlign value) {
if (_prototypePainter.textAlign == value) {
return;
}
_prototypePainter.textAlign = value;
markNeedsLayout();
}
set textDirection(TextDirection value) {
if (_prototypePainter.textDirection == value) {
return;
}
_prototypePainter.textDirection = value;
markNeedsLayout();
}
set textScaleFactor(double value) {
if (_prototypePainter.textScaleFactor == value) {
return;
}
_prototypePainter.textScaleFactor = value;
markNeedsLayout();
}
set strutStyle(StrutStyle value) {
if (_prototypePainter.strutStyle == value) {
return;
}
_prototypePainter.strutStyle = value;
markNeedsLayout();
}
set locale(Locale value) {
if (_prototypePainter.locale == value) {
return;
}
_prototypePainter.locale = value;
markNeedsLayout();
}
set textWidthBasis(TextWidthBasis value) {
if (_prototypePainter.textWidthBasis == value) {
return;
}
_prototypePainter.textWidthBasis = value;
markNeedsLayout();
}
set textHeightBehavior(TextHeightBehavior? value) {
if (_prototypePainter.textHeightBehavior == value) {
return;
}
_prototypePainter.textHeightBehavior = value;
markNeedsLayout();
}
@override
RenderParagraph? get child => super.child as RenderParagraph?;
@override
double getPreferredLineHeight() {
return _prototypePainter.preferredLineHeight;
}
@override
Offset getOffsetForCaret(TextPosition position, Rect? caretPrototype) =>
child!.getOffsetForCaret(position, caretPrototype!);
@override
TextPosition getPositionForOffset(Offset offset) =>
child!.getPositionForOffset(offset);
@override
double? getFullHeightForCaret(TextPosition position) =>
child!.getFullHeightForCaret(position);
@override
TextRange getWordBoundary(TextPosition position) =>
child!.getWordBoundary(position);
@override
List<TextBox> getBoxesForSelection(TextSelection selection) =>
child!.getBoxesForSelection(selection);
@override
void performLayout() {
super.performLayout();
_prototypePainter.layout(
minWidth: constraints.minWidth, maxWidth: constraints.maxWidth);
}
}
/// 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/proxy.dart';

@ -1,736 +1,3 @@
import 'dart:async';
import 'dart:convert';
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 'package:flutter/services.dart';
import 'package:flutter_keyboard_visibility/flutter_keyboard_visibility.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/line.dart';
import 'controller.dart';
import 'cursor.dart';
import 'default_styles.dart';
import 'delegate.dart';
import 'editor.dart';
import 'keyboard_listener.dart';
import 'proxy.dart';
import 'raw_editor/raw_editor_state_keyboard_mixin.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';
import 'text_line.dart';
import 'text_selection.dart';
class RawEditor extends StatefulWidget {
const RawEditor(
Key key,
this.controller,
this.focusNode,
this.scrollController,
this.scrollable,
this.scrollBottomInset,
this.padding,
this.readOnly,
this.placeholder,
this.onLaunchUrl,
this.toolbarOptions,
this.showSelectionHandles,
bool? showCursor,
this.cursorStyle,
this.textCapitalization,
this.maxHeight,
this.minHeight,
this.customStyles,
this.expands,
this.autoFocus,
this.selectionColor,
this.selectionCtrls,
this.keyboardAppearance,
this.enableInteractiveSelection,
this.scrollPhysics,
this.embedBuilder,
) : 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);
final QuillController controller;
final FocusNode focusNode;
final ScrollController scrollController;
final bool scrollable;
final double scrollBottomInset;
final EdgeInsetsGeometry padding;
final bool readOnly;
final String? placeholder;
final ValueChanged<String>? onLaunchUrl;
final ToolbarOptions toolbarOptions;
final bool showSelectionHandles;
final bool showCursor;
final CursorStyle cursorStyle;
final TextCapitalization textCapitalization;
final double? maxHeight;
final double? minHeight;
final DefaultStyles? customStyles;
final bool expands;
final bool autoFocus;
final Color selectionColor;
final TextSelectionControls selectionCtrls;
final Brightness keyboardAppearance;
final bool enableInteractiveSelection;
final ScrollPhysics? scrollPhysics;
final EmbedBuilder embedBuilder;
@override
State<StatefulWidget> createState() => RawEditorState();
}
class RawEditorState extends EditorState
with
AutomaticKeepAliveClientMixin<RawEditor>,
WidgetsBindingObserver,
TickerProviderStateMixin<RawEditor>,
RawEditorStateKeyboardMixin,
RawEditorStateTextInputClientMixin,
RawEditorStateSelectionDelegateMixin {
final GlobalKey _editorKey = GlobalKey();
// Keyboard
late KeyboardListener _keyboardListener;
KeyboardVisibilityController? _keyboardVisibilityController;
StreamSubscription<bool>? _keyboardVisibilitySubscription;
bool _keyboardVisible = false;
// Selection overlay
@override
EditorTextSelectionOverlay? getSelectionOverlay() => _selectionOverlay;
EditorTextSelectionOverlay? _selectionOverlay;
ScrollController? _scrollController;
late CursorCont _cursorCont;
// Focus
bool _didAutoFocus = false;
FocusAttachment? _focusAttachment;
bool get _hasFocus => widget.focusNode.hasFocus;
DefaultStyles? _styles;
final ClipboardStatusNotifier? _clipboardStatus =
kIsWeb ? null : ClipboardStatusNotifier();
final LayerLink _toolbarLayerLink = LayerLink();
final LayerLink _startHandleLayerLink = LayerLink();
final LayerLink _endHandleLayerLink = LayerLink();
TextDirection get _textDirection => Directionality.of(context);
@override
Widget build(BuildContext context) {
assert(debugCheckHasMediaQuery(context));
_focusAttachment!.reparent();
super.build(context);
var _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: _Editor(
key: _editorKey,
document: _doc,
selection: widget.controller.selection,
hasFocus: _hasFocus,
textDirection: _textDirection,
startHandleLayerLink: _startHandleLayerLink,
endHandleLayerLink: _endHandleLayerLink,
onSelectionChanged: _handleSelectionChanged,
scrollBottomInset: widget.scrollBottomInset,
padding: widget.padding,
children: _buildChildren(_doc, context),
),
),
);
if (widget.scrollable) {
final baselinePadding =
EdgeInsets.only(top: _styles!.paragraph!.verticalSpacing.item1);
child = BaselineProxy(
textStyle: _styles!.paragraph!.style,
padding: baselinePadding,
child: SingleChildScrollView(
controller: _scrollController,
physics: widget.scrollPhysics,
child: child,
),
);
}
final constraints = widget.expands
? const BoxConstraints.expand()
: BoxConstraints(
minHeight: widget.minHeight ?? 0.0,
maxHeight: widget.maxHeight ?? double.infinity);
return QuillStyles(
data: _styles!,
child: MouseRegion(
cursor: SystemMouseCursors.text,
child: Container(
constraints: constraints,
child: child,
),
),
);
}
void _handleSelectionChanged(
TextSelection selection, SelectionChangedCause cause) {
widget.controller.updateSelection(selection, ChangeSource.LOCAL);
_selectionOverlay?.handlesVisible = _shouldShowSelectionHandles();
if (!_keyboardVisible) {
requestKeyboard();
}
}
/// 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);
}
}
}
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(
node,
_textDirection,
widget.scrollBottomInset,
_getVerticalSpacingForBlock(node, _styles),
widget.controller.selection,
widget.selectionColor,
_styles,
widget.enableInteractiveSelection,
_hasFocus,
attrs.containsKey(Attribute.codeBlock.key)
? const EdgeInsets.all(16)
: null,
widget.embedBuilder,
_cursorCont,
indentLevelCounts,
_handleCheckboxTap,
);
result.add(editableTextBlock);
} else {
throw StateError('Unreachable.');
}
}
return result;
}
EditableTextLine _getEditableTextLineFromNode(
Line node, BuildContext context) {
final textLine = TextLine(
line: node,
textDirection: _textDirection,
embedBuilder: widget.embedBuilder,
styles: _styles!,
);
final editableTextLine = EditableTextLine(
node,
null,
textLine,
0,
_getVerticalSpacingForLine(node, _styles),
_textDirection,
widget.controller.selection,
widget.selectionColor,
widget.enableInteractiveSelection,
_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;
}
@override
void initState() {
super.initState();
_clipboardStatus?.addListener(_onChangedClipboardStatus);
widget.controller.addListener(() {
_didChangeTextEditingValue(widget.controller.ignoreFocusOnTextChange);
});
_scrollController = widget.scrollController;
_scrollController!.addListener(_updateSelectionOverlayForScroll);
_cursorCont = CursorCont(
show: ValueNotifier<bool>(widget.showCursor),
style: widget.cursorStyle,
tickerProvider: this,
);
_keyboardListener = KeyboardListener(
handleCursorMovement,
handleShortcut,
handleDelete,
);
if (defaultTargetPlatform == TargetPlatform.windows ||
defaultTargetPlatform == TargetPlatform.macOS ||
defaultTargetPlatform == TargetPlatform.linux ||
defaultTargetPlatform == TargetPlatform.fuchsia) {
_keyboardVisible = true;
} else {
_keyboardVisibilityController = KeyboardVisibilityController();
_keyboardVisible = _keyboardVisibilityController!.isVisible;
_keyboardVisibilitySubscription =
_keyboardVisibilityController?.onChange.listen((visible) {
_keyboardVisible = visible;
if (visible) {
_onChangeTextEditingValue();
}
});
}
_focusAttachment = widget.focusNode.attach(context,
onKey: (node, event) => _keyboardListener.handleRawKeyEvent(event));
widget.focusNode.addListener(_handleFocusChanged);
}
@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!);
}
if (!_didAutoFocus && widget.autoFocus) {
FocusScope.of(context).autofocus(widget.focusNode);
_didAutoFocus = true;
}
}
@override
void didUpdateWidget(RawEditor oldWidget) {
super.didUpdateWidget(oldWidget);
_cursorCont.show.value = widget.showCursor;
_cursorCont.style = widget.cursorStyle;
if (widget.controller != oldWidget.controller) {
oldWidget.controller.removeListener(_didChangeTextEditingValue);
widget.controller.addListener(_didChangeTextEditingValue);
updateRemoteValueIfNeeded();
}
if (widget.scrollController != _scrollController) {
_scrollController!.removeListener(_updateSelectionOverlayForScroll);
_scrollController = widget.scrollController;
_scrollController!.addListener(_updateSelectionOverlayForScroll);
}
if (widget.focusNode != oldWidget.focusNode) {
oldWidget.focusNode.removeListener(_handleFocusChanged);
_focusAttachment?.detach();
_focusAttachment = widget.focusNode.attach(context,
onKey: (node, event) => _keyboardListener.handleRawKeyEvent(event));
widget.focusNode.addListener(_handleFocusChanged);
updateKeepAlive();
}
if (widget.controller.selection != oldWidget.controller.selection) {
_selectionOverlay?.update(textEditingValue);
}
_selectionOverlay?.handlesVisible = _shouldShowSelectionHandles();
if (!shouldCreateInputConnection) {
closeConnectionIfNeeded();
} else {
if (oldWidget.readOnly && _hasFocus) {
openConnectionIfNeeded();
}
}
}
bool _shouldShowSelectionHandles() {
return widget.showSelectionHandles &&
!widget.controller.selection.isCollapsed;
}
@override
void dispose() {
closeConnectionIfNeeded();
_keyboardVisibilitySubscription?.cancel();
assert(!hasConnection);
_selectionOverlay?.dispose();
_selectionOverlay = null;
widget.controller.removeListener(_didChangeTextEditingValue);
widget.focusNode.removeListener(_handleFocusChanged);
_focusAttachment!.detach();
_cursorCont.dispose();
_clipboardStatus?.removeListener(_onChangedClipboardStatus);
_clipboardStatus?.dispose();
super.dispose();
}
void _updateSelectionOverlayForScroll() {
_selectionOverlay?.markNeedsBuild();
}
void _didChangeTextEditingValue([bool ignoreFocus = false]) {
if (kIsWeb) {
_onChangeTextEditingValue(ignoreFocus);
if (!ignoreFocus) {
requestKeyboard();
}
return;
}
if (ignoreFocus || _keyboardVisible) {
_onChangeTextEditingValue(ignoreFocus);
} else {
requestKeyboard();
if (mounted) {
setState(() {
// Use widget.controller.value in build()
// Trigger build and updateChildren
});
}
}
}
void _onChangeTextEditingValue([bool ignoreCaret = false]) {
updateRemoteValueIfNeeded();
if (ignoreCaret) {
return;
}
_showCaretOnScreen();
_cursorCont.startOrStopCursorTimerIfNeeded(
_hasFocus, widget.controller.selection);
if (hasConnection) {
_cursorCont
..stopCursorTimer(resetCharTicks: false)
..startCursorTimer();
}
SchedulerBinding.instance!.addPostFrameCallback(
(_) => _updateOrDisposeSelectionOverlayIfNeeded());
if (mounted) {
setState(() {
// Use widget.controller.value in build()
// Trigger build and updateChildren
});
}
}
void _updateOrDisposeSelectionOverlayIfNeeded() {
if (_selectionOverlay != null) {
if (_hasFocus) {
_selectionOverlay!.update(textEditingValue);
} else {
_selectionOverlay!.dispose();
_selectionOverlay = null;
}
} else if (_hasFocus) {
_selectionOverlay?.hide();
_selectionOverlay = null;
_selectionOverlay = EditorTextSelectionOverlay(
textEditingValue,
false,
context,
widget,
_toolbarLayerLink,
_startHandleLayerLink,
_endHandleLayerLink,
getRenderEditor(),
widget.selectionCtrls,
this,
DragStartBehavior.start,
null,
_clipboardStatus!,
);
_selectionOverlay!.handlesVisible = _shouldShowSelectionHandles();
_selectionOverlay!.showHandles();
}
}
void _handleFocusChanged() {
openOrCloseConnection();
_cursorCont.startOrStopCursorTimerIfNeeded(
_hasFocus, widget.controller.selection);
_updateOrDisposeSelectionOverlayIfNeeded();
if (_hasFocus) {
WidgetsBinding.instance!.addObserver(this);
_showCaretOnScreen();
} else {
WidgetsBinding.instance!.removeObserver(this);
}
updateKeepAlive();
}
void _onChangedClipboardStatus() {
if (!mounted) return;
setState(() {
// Inform the widget that the value of clipboardStatus has changed.
// Trigger build and updateChildren
});
}
bool _showCaretOnScreenScheduled = false;
void _showCaretOnScreen() {
if (!widget.showCursor || _showCaretOnScreenScheduled) {
return;
}
_showCaretOnScreenScheduled = true;
SchedulerBinding.instance!.addPostFrameCallback((_) {
if (widget.scrollable) {
_showCaretOnScreenScheduled = false;
final viewport = RenderAbstractViewport.of(getRenderEditor());
final editorOffset = getRenderEditor()!
.localToGlobal(const Offset(0, 0), ancestor: viewport);
final offsetInViewport = _scrollController!.offset + editorOffset.dy;
final offset = getRenderEditor()!.getOffsetToRevealCursor(
_scrollController!.position.viewportDimension,
_scrollController!.offset,
offsetInViewport,
);
if (offset != null) {
_scrollController!.animateTo(
offset,
duration: const Duration(milliseconds: 100),
curve: Curves.fastOutSlowIn,
);
}
}
});
}
@override
RenderEditor? getRenderEditor() {
return _editorKey.currentContext!.findRenderObject() as RenderEditor?;
}
@override
TextEditingValue getTextEditingValue() {
return widget.controller.plainTextEditingValue;
}
@override
void requestKeyboard() {
if (_hasFocus) {
openConnectionIfNeeded();
} else {
widget.focusNode.requestFocus();
}
}
@override
void setTextEditingValue(TextEditingValue value) {
if (value.text == textEditingValue.text) {
widget.controller.updateSelection(value.selection, ChangeSource.LOCAL);
} else {
__setEditingValue(value);
}
}
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);
}
}
}
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
bool showToolbar() {
// Web is using native dom elements to enable clipboard functionality of the
// toolbar: copy, paste, select, cut. It might also provide additional
// functionality depending on the browser (such as translate). Due to this
// we should not show a Flutter toolbar for the editable text elements.
if (kIsWeb) {
return false;
}
if (_selectionOverlay == null || _selectionOverlay!.toolbar != null) {
return false;
}
_selectionOverlay!.update(textEditingValue);
_selectionOverlay!.showToolbar();
return true;
}
@override
bool get wantKeepAlive => widget.focusNode.hasFocus;
@override
void userUpdateTextEditingValue(
TextEditingValue value, SelectionChangedCause cause) {
// TODO: implement userUpdateTextEditingValue
}
}
class _Editor extends MultiChildRenderObjectWidget {
_Editor({
required Key key,
required List<Widget> children,
required this.document,
required this.textDirection,
required this.hasFocus,
required this.selection,
required this.startHandleLayerLink,
required this.endHandleLayerLink,
required this.onSelectionChanged,
required this.scrollBottomInset,
this.padding = EdgeInsets.zero,
}) : super(key: key, children: children);
final Document document;
final TextDirection textDirection;
final bool hasFocus;
final TextSelection selection;
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,
selection,
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
..setHasFocus(hasFocus)
..setSelection(selection)
..setStartHandleLayerLink(startHandleLayerLink)
..setEndHandleLayerLink(endHandleLayerLink)
..onSelectionChanged = onSelectionChanged
..setScrollBottomInset(scrollBottomInset)
..setPadding(padding);
}
}
/// 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.dart';

@ -1,354 +1,3 @@
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)
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));
}
}
/// 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,40 +1,3 @@
import 'package:flutter/widgets.dart';
import '../editor.dart';
mixin RawEditorStateSelectionDelegateMixin on EditorState
implements TextSelectionDelegate {
@override
TextEditingValue get textEditingValue {
return getTextEditingValue();
}
@override
set textEditingValue(TextEditingValue value) {
setTextEditingValue(value);
}
@override
void bringIntoView(TextPosition position) {
// TODO: implement bringIntoView
}
@override
void hideToolbar([bool hideHandles = true]) {
if (getSelectionOverlay()?.toolbar != null) {
getSelectionOverlay()?.hideToolbar();
}
}
@override
bool get cutEnabled => widget.toolbarOptions.cut && !widget.readOnly;
@override
bool get copyEnabled => widget.toolbarOptions.copy;
@override
bool get pasteEnabled => widget.toolbarOptions.paste && !widget.readOnly;
@override
bool get selectAllEnabled => widget.toolbarOptions.selectAll;
}
/// 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_selection_delegate_mixin.dart';

@ -1,200 +1,3 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import '../../utils/diff_delta.dart';
import '../editor.dart';
mixin RawEditorStateTextInputClientMixin on EditorState
implements TextInputClient {
final List<TextEditingValue> _sentRemoteValues = [];
TextInputConnection? _textInputConnection;
TextEditingValue? _lastKnownRemoteTextEditingValue;
/// Whether to create an input connection with the platform for text editing
/// or not.
///
/// Read-only input fields do not need a connection with the platform since
/// there's no need for text editing capabilities (e.g. virtual keyboard).
///
/// On the web, we always need a connection because we want some browser
/// functionalities to continue to work on read-only input fields like:
///
/// - Relevant context menu.
/// - cmd/ctrl+c shortcut to copy.
/// - cmd/ctrl+a to select all.
/// - Changing the selection using a physical keyboard.
bool get shouldCreateInputConnection => kIsWeb || !widget.readOnly;
/// Returns `true` if there is open input connection.
bool get hasConnection =>
_textInputConnection != null && _textInputConnection!.attached;
/// Opens or closes input connection based on the current state of
/// [focusNode] and [value].
void openOrCloseConnection() {
if (widget.focusNode.hasFocus && widget.focusNode.consumeKeyboardToken()) {
openConnectionIfNeeded();
} else if (!widget.focusNode.hasFocus) {
closeConnectionIfNeeded();
}
}
void openConnectionIfNeeded() {
if (!shouldCreateInputConnection) {
return;
}
if (!hasConnection) {
_lastKnownRemoteTextEditingValue = getTextEditingValue();
_textInputConnection = TextInput.attach(
this,
TextInputConfiguration(
inputType: TextInputType.multiline,
readOnly: widget.readOnly,
inputAction: TextInputAction.newline,
enableSuggestions: !widget.readOnly,
keyboardAppearance: widget.keyboardAppearance,
textCapitalization: widget.textCapitalization,
),
);
_textInputConnection!.setEditingState(_lastKnownRemoteTextEditingValue!);
// _sentRemoteValues.add(_lastKnownRemoteTextEditingValue);
}
_textInputConnection!.show();
}
/// Closes input connection if it's currently open. Otherwise does nothing.
void closeConnectionIfNeeded() {
if (!hasConnection) {
return;
}
_textInputConnection!.close();
_textInputConnection = null;
_lastKnownRemoteTextEditingValue = null;
_sentRemoteValues.clear();
}
/// Updates remote value based on current state of [document] and
/// [selection].
///
/// This method may not actually send an update to native side if it thinks
/// remote value is up to date or identical.
void updateRemoteValueIfNeeded() {
if (!hasConnection) {
return;
}
// 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(
composing: _lastKnownRemoteTextEditingValue!.composing,
);
if (actualValue == _lastKnownRemoteTextEditingValue) {
return;
}
final shouldRemember =
getTextEditingValue().text != _lastKnownRemoteTextEditingValue!.text;
_lastKnownRemoteTextEditingValue = actualValue;
_textInputConnection!.setEditingState(actualValue);
if (shouldRemember) {
// Only keep track if text changed (selection changes are not relevant)
_sentRemoteValues.add(actualValue);
}
}
@override
TextEditingValue? get currentTextEditingValue =>
_lastKnownRemoteTextEditingValue;
// autofill is not needed
@override
AutofillScope? get currentAutofillScope => null;
@override
void updateEditingValue(TextEditingValue value) {
if (!shouldCreateInputConnection) {
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;
}
// Check if only composing range changed.
if (_lastKnownRemoteTextEditingValue!.text == value.text &&
_lastKnownRemoteTextEditingValue!.selection == value.selection) {
// This update only modifies composing range. Since we don't keep track
// of composing range we just need to update last known value here.
// This check fixes an issue on Android when it sends
// composing updates separately from regular changes for text and
// selection.
_lastKnownRemoteTextEditingValue = value;
return;
}
final effectiveLastKnownValue = _lastKnownRemoteTextEditingValue!;
_lastKnownRemoteTextEditingValue = value;
final oldText = effectiveLastKnownValue.text;
final text = value.text;
final cursorPosition = value.selection.extentOffset;
final diff = getDiff(oldText, text, cursorPosition);
widget.controller.replaceText(
diff.start, diff.deleted.length, diff.inserted, value.selection);
}
@override
void performAction(TextInputAction action) {
// no-op
}
@override
void performPrivateCommand(String action, Map<String, dynamic> data) {
// no-op
}
@override
void updateFloatingCursor(RawFloatingCursorPoint point) {
throw UnimplementedError();
}
@override
void showAutocorrectionPromptRect(int start, int end) {
throw UnimplementedError();
}
@override
void connectionClosed() {
if (!hasConnection) {
return;
}
_textInputConnection!.connectionClosedReceived();
_textInputConnection = null;
_lastKnownRemoteTextEditingValue = null;
_sentRemoteValues.clear();
}
}
/// 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_text_input_client_mixin.dart';

@ -1,43 +1,3 @@
import 'package:flutter/material.dart';
class ResponsiveWidget extends StatelessWidget {
const ResponsiveWidget({
required this.largeScreen,
this.mediumScreen,
this.smallScreen,
Key? key,
}) : super(key: key);
final Widget largeScreen;
final Widget? mediumScreen;
final Widget? smallScreen;
static bool isSmallScreen(BuildContext context) {
return MediaQuery.of(context).size.width < 800;
}
static bool isLargeScreen(BuildContext context) {
return MediaQuery.of(context).size.width > 1200;
}
static bool isMediumScreen(BuildContext context) {
return MediaQuery.of(context).size.width >= 800 &&
MediaQuery.of(context).size.width <= 1200;
}
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
if (constraints.maxWidth > 1200) {
return largeScreen;
} else if (constraints.maxWidth <= 1200 &&
constraints.maxWidth >= 800) {
return mediumScreen ?? largeScreen;
} else {
return smallScreen ?? largeScreen;
}
},
);
}
}
/// 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/responsive_widget.dart';

@ -1,344 +1,3 @@
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';
class QuillSimpleViewer extends StatefulWidget {
const QuillSimpleViewer({
required this.controller,
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;
@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) {
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));
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(
node,
_textDirection,
widget.scrollBottomInset,
_getVerticalSpacingForBlock(node, _styles),
widget.controller.selection,
Colors.black,
// selectionColor,
_styles,
false,
// enableInteractiveSelection,
false,
// hasFocus,
attrs.containsKey(Attribute.codeBlock.key)
? const EdgeInsets.all(16)
: null,
embedBuilder,
_cursorCont,
indentLevelCounts,
_handleCheckboxTap);
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,
);
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);
}
}
/// 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,737 +1,3 @@
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 '../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 'text_line.dart';
import 'text_selection.dart';
const List<int> arabianRomanNumbers = [
1000,
900,
500,
400,
100,
90,
50,
40,
10,
9,
5,
4,
1
];
const List<String> romanNumbers = [
'M',
'CM',
'D',
'CD',
'C',
'XC',
'L',
'XL',
'X',
'IX',
'V',
'IV',
'I'
];
class EditableTextBlock extends StatelessWidget {
const EditableTextBlock(
this.block,
this.textDirection,
this.scrollBottomInset,
this.verticalSpacing,
this.textSelection,
this.color,
this.styles,
this.enableInteractiveSelection,
this.hasFocus,
this.contentPadding,
this.embedBuilder,
this.cursorCont,
this.indentLevelCounts,
this.onCheckboxTap,
);
final Block block;
final TextDirection textDirection;
final double scrollBottomInset;
final Tuple2 verticalSpacing;
final TextSelection textSelection;
final Color color;
final DefaultStyles? styles;
final bool enableInteractiveSelection;
final bool hasFocus;
final EdgeInsets? contentPadding;
final EmbedBuilder embedBuilder;
final CursorCont cursorCont;
final Map<int, int> indentLevelCounts;
final Function(int, bool) onCheckboxTap;
@override
Widget build(BuildContext context) {
assert(debugCheckHasMediaQuery(context));
final defaultStyles = QuillStyles.getStyles(context, false);
return _EditableBlock(
block,
textDirection,
verticalSpacing as Tuple2<double, double>,
scrollBottomInset,
_getDecorationForBlock(block, defaultStyles) ?? const BoxDecoration(),
contentPadding,
_buildChildren(context, indentLevelCounts));
}
BoxDecoration? _getDecorationForBlock(
Block node, DefaultStyles? defaultStyles) {
final attrs = block.style.attributes;
if (attrs.containsKey(Attribute.blockQuote.key)) {
return defaultStyles!.quote!.decoration;
}
if (attrs.containsKey(Attribute.codeBlock.key)) {
return defaultStyles!.code!.decoration;
}
return null;
}
List<Widget> _buildChildren(
BuildContext context, Map<int, int> indentLevelCounts) {
final defaultStyles = QuillStyles.getStyles(context, false);
final count = block.children.length;
final children = <Widget>[];
var index = 0;
for (final line in Iterable.castFrom<dynamic, Line>(block.children)) {
index++;
final editableTextLine = EditableTextLine(
line,
_buildLeading(context, line, index, indentLevelCounts, count),
TextLine(
line: line,
textDirection: textDirection,
embedBuilder: embedBuilder,
styles: styles!,
),
_getIndentWidth(),
_getSpacingForLine(line, index, count, defaultStyles),
textDirection,
textSelection,
color,
enableInteractiveSelection,
hasFocus,
MediaQuery.of(context).devicePixelRatio,
cursorCont);
children.add(editableTextLine);
}
return children.toList(growable: false);
}
Widget? _buildLeading(BuildContext context, Line line, int index,
Map<int, int> indentLevelCounts, int count) {
final defaultStyles = QuillStyles.getStyles(context, false);
final attrs = line.style.attributes;
if (attrs[Attribute.list.key] == Attribute.ol) {
return _NumberPoint(
index: index,
indentLevelCounts: indentLevelCounts,
count: count,
style: defaultStyles!.leading!.style,
attrs: attrs,
width: 32,
padding: 8,
);
}
if (attrs[Attribute.list.key] == Attribute.ul) {
return _BulletPoint(
style:
defaultStyles!.leading!.style.copyWith(fontWeight: FontWeight.bold),
width: 32,
);
}
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,
);
}
if (attrs[Attribute.list.key] == Attribute.unchecked) {
return _Checkbox(
key: UniqueKey(),
style: defaultStyles!.leading!.style,
width: 32,
offset: block.offset + line.offset,
onTap: onCheckboxTap,
);
}
if (attrs.containsKey(Attribute.codeBlock.key)) {
return _NumberPoint(
index: index,
indentLevelCounts: indentLevelCounts,
count: count,
style: defaultStyles!.code!.style
.copyWith(color: defaultStyles.code!.style.color!.withOpacity(0.4)),
width: 32,
attrs: attrs,
padding: 16,
withDot: false,
);
}
return null;
}
double _getIndentWidth() {
final attrs = block.style.attributes;
final indent = attrs[Attribute.indent.key];
var extraIndent = 0.0;
if (indent != null && indent.value != null) {
extraIndent = 16.0 * indent.value;
}
if (attrs.containsKey(Attribute.blockQuote.key)) {
return 16.0 + extraIndent;
}
return 32.0 + extraIndent;
}
Tuple2 _getSpacingForLine(
Line node, int index, int count, DefaultStyles? defaultStyles) {
var top = 0.0, bottom = 0.0;
final attrs = block.style.attributes;
if (attrs.containsKey(Attribute.header.key)) {
final level = attrs[Attribute.header.key]!.value;
switch (level) {
case 1:
top = defaultStyles!.h1!.verticalSpacing.item1;
bottom = defaultStyles.h1!.verticalSpacing.item2;
break;
case 2:
top = defaultStyles!.h2!.verticalSpacing.item1;
bottom = defaultStyles.h2!.verticalSpacing.item2;
break;
case 3:
top = defaultStyles!.h3!.verticalSpacing.item1;
bottom = defaultStyles.h3!.verticalSpacing.item2;
break;
default:
throw 'Invalid level $level';
}
} else {
late Tuple2 lineSpacing;
if (attrs.containsKey(Attribute.blockQuote.key)) {
lineSpacing = defaultStyles!.quote!.lineSpacing;
} else if (attrs.containsKey(Attribute.indent.key)) {
lineSpacing = defaultStyles!.indent!.lineSpacing;
} else if (attrs.containsKey(Attribute.list.key)) {
lineSpacing = defaultStyles!.lists!.lineSpacing;
} else if (attrs.containsKey(Attribute.codeBlock.key)) {
lineSpacing = defaultStyles!.code!.lineSpacing;
} else if (attrs.containsKey(Attribute.align.key)) {
lineSpacing = defaultStyles!.align!.lineSpacing;
}
top = lineSpacing.item1;
bottom = lineSpacing.item2;
}
if (index == 1) {
top = 0.0;
}
if (index == count) {
bottom = 0.0;
}
return Tuple2(top, bottom);
}
}
class RenderEditableTextBlock extends RenderEditableContainerBox
implements RenderEditableBox {
RenderEditableTextBlock({
required Block block,
required TextDirection textDirection,
required EdgeInsetsGeometry padding,
required double scrollBottomInset,
required Decoration decoration,
List<RenderEditableBox>? children,
ImageConfiguration configuration = ImageConfiguration.empty,
EdgeInsets contentPadding = EdgeInsets.zero,
}) : _decoration = decoration,
_configuration = configuration,
_savedPadding = padding,
_contentPadding = contentPadding,
super(
children,
block,
textDirection,
scrollBottomInset,
padding.add(contentPadding),
);
EdgeInsetsGeometry _savedPadding;
EdgeInsets _contentPadding;
set contentPadding(EdgeInsets value) {
if (_contentPadding == value) return;
_contentPadding = value;
super.setPadding(_savedPadding.add(_contentPadding));
}
@override
void setPadding(EdgeInsetsGeometry value) {
super.setPadding(value.add(_contentPadding));
_savedPadding = value;
}
BoxPainter? _painter;
Decoration get decoration => _decoration;
Decoration _decoration;
set decoration(Decoration value) {
if (value == _decoration) return;
_painter?.dispose();
_painter = null;
_decoration = value;
markNeedsPaint();
}
ImageConfiguration get configuration => _configuration;
ImageConfiguration _configuration;
set configuration(ImageConfiguration value) {
if (value == _configuration) return;
_configuration = value;
markNeedsPaint();
}
@override
TextRange getLineBoundary(TextPosition position) {
final child = childAtPosition(position);
final rangeInChild = child.getLineBoundary(TextPosition(
offset: position.offset - child.getContainer().offset,
affinity: position.affinity,
));
return TextRange(
start: rangeInChild.start + child.getContainer().offset,
end: rangeInChild.end + child.getContainer().offset,
);
}
@override
Offset getOffsetForCaret(TextPosition position) {
final child = childAtPosition(position);
return child.getOffsetForCaret(TextPosition(
offset: position.offset - child.getContainer().offset,
affinity: position.affinity,
)) +
(child.parentData as BoxParentData).offset;
}
@override
TextPosition getPositionForOffset(Offset offset) {
final child = childAtOffset(offset)!;
final parentData = child.parentData as BoxParentData;
final localPosition =
child.getPositionForOffset(offset - parentData.offset);
return TextPosition(
offset: localPosition.offset + child.getContainer().offset,
affinity: localPosition.affinity,
);
}
@override
TextRange getWordBoundary(TextPosition position) {
final child = childAtPosition(position);
final nodeOffset = child.getContainer().offset;
final childWord = child
.getWordBoundary(TextPosition(offset: position.offset - nodeOffset));
return TextRange(
start: childWord.start + nodeOffset,
end: childWord.end + nodeOffset,
);
}
@override
TextPosition? getPositionAbove(TextPosition position) {
assert(position.offset < getContainer().length);
final child = childAtPosition(position);
final childLocalPosition =
TextPosition(offset: position.offset - child.getContainer().offset);
final result = child.getPositionAbove(childLocalPosition);
if (result != null) {
return TextPosition(offset: result.offset + child.getContainer().offset);
}
final sibling = childBefore(child);
if (sibling == null) {
return null;
}
final caretOffset = child.getOffsetForCaret(childLocalPosition);
final testPosition =
TextPosition(offset: sibling.getContainer().length - 1);
final testOffset = sibling.getOffsetForCaret(testPosition);
final finalOffset = Offset(caretOffset.dx, testOffset.dy);
return TextPosition(
offset: sibling.getContainer().offset +
sibling.getPositionForOffset(finalOffset).offset);
}
@override
TextPosition? getPositionBelow(TextPosition position) {
assert(position.offset < getContainer().length);
final child = childAtPosition(position);
final childLocalPosition =
TextPosition(offset: position.offset - child.getContainer().offset);
final result = child.getPositionBelow(childLocalPosition);
if (result != null) {
return TextPosition(offset: result.offset + child.getContainer().offset);
}
final sibling = childAfter(child);
if (sibling == null) {
return null;
}
final caretOffset = child.getOffsetForCaret(childLocalPosition);
final testOffset = sibling.getOffsetForCaret(const TextPosition(offset: 0));
final finalOffset = Offset(caretOffset.dx, testOffset.dy);
return TextPosition(
offset: sibling.getContainer().offset +
sibling.getPositionForOffset(finalOffset).offset);
}
@override
double preferredLineHeight(TextPosition position) {
final child = childAtPosition(position);
return child.preferredLineHeight(
TextPosition(offset: position.offset - child.getContainer().offset));
}
@override
TextSelectionPoint getBaseEndpointForSelection(TextSelection selection) {
if (selection.isCollapsed) {
return TextSelectionPoint(
Offset(0, preferredLineHeight(selection.extent)) +
getOffsetForCaret(selection.extent),
null);
}
final baseNode = getContainer().queryChild(selection.start, false).node;
var baseChild = firstChild;
while (baseChild != null) {
if (baseChild.getContainer() == baseNode) {
break;
}
baseChild = childAfter(baseChild);
}
assert(baseChild != null);
final basePoint = baseChild!.getBaseEndpointForSelection(
localSelection(baseChild.getContainer(), selection, true));
return TextSelectionPoint(
basePoint.point + (baseChild.parentData as BoxParentData).offset,
basePoint.direction);
}
@override
TextSelectionPoint getExtentEndpointForSelection(TextSelection selection) {
if (selection.isCollapsed) {
return TextSelectionPoint(
Offset(0, preferredLineHeight(selection.extent)) +
getOffsetForCaret(selection.extent),
null);
}
final extentNode = getContainer().queryChild(selection.end, false).node;
var extentChild = firstChild;
while (extentChild != null) {
if (extentChild.getContainer() == extentNode) {
break;
}
extentChild = childAfter(extentChild);
}
assert(extentChild != null);
final extentPoint = extentChild!.getExtentEndpointForSelection(
localSelection(extentChild.getContainer(), selection, true));
return TextSelectionPoint(
extentPoint.point + (extentChild.parentData as BoxParentData).offset,
extentPoint.direction);
}
@override
void detach() {
_painter?.dispose();
_painter = null;
super.detach();
markNeedsPaint();
}
@override
void paint(PaintingContext context, Offset offset) {
_paintDecoration(context, offset);
defaultPaint(context, offset);
}
void _paintDecoration(PaintingContext context, Offset offset) {
_painter ??= _decoration.createBoxPainter(markNeedsPaint);
final decorationPadding = resolvedPadding! - _contentPadding;
final filledConfiguration =
configuration.copyWith(size: decorationPadding.deflateSize(size));
final debugSaveCount = context.canvas.getSaveCount();
final decorationOffset =
offset.translate(decorationPadding.left, decorationPadding.top);
_painter!.paint(context.canvas, decorationOffset, filledConfiguration);
if (debugSaveCount != context.canvas.getSaveCount()) {
throw '${_decoration.runtimeType} painter had mismatching save and restore calls.';
}
if (decoration.isComplex) {
context.setIsComplexHint();
}
}
@override
bool hitTestChildren(BoxHitTestResult result, {required Offset position}) {
return defaultHitTestChildren(result, position: position);
}
}
class _EditableBlock extends MultiChildRenderObjectWidget {
_EditableBlock(
this.block,
this.textDirection,
this.padding,
this.scrollBottomInset,
this.decoration,
this.contentPadding,
List<Widget> children)
: super(children: children);
final Block block;
final TextDirection textDirection;
final Tuple2<double, double> padding;
final double scrollBottomInset;
final Decoration decoration;
final EdgeInsets? contentPadding;
EdgeInsets get _padding =>
EdgeInsets.only(top: padding.item1, bottom: padding.item2);
EdgeInsets get _contentPadding => contentPadding ?? EdgeInsets.zero;
@override
RenderEditableTextBlock createRenderObject(BuildContext context) {
return RenderEditableTextBlock(
block: block,
textDirection: textDirection,
padding: _padding,
scrollBottomInset: scrollBottomInset,
decoration: decoration,
contentPadding: _contentPadding,
);
}
@override
void updateRenderObject(
BuildContext context, covariant RenderEditableTextBlock renderObject) {
renderObject
..setContainer(block)
..textDirection = textDirection
..scrollBottomInset = scrollBottomInset
..setPadding(_padding)
..decoration = decoration
..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,
),
),
);
}
}
/// 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/text_block.dart';

@ -1,892 +1,3 @@
import 'dart:math' as math;
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 '../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 '../utils/color.dart';
import 'box.dart';
import 'cursor.dart';
import 'default_styles.dart';
import 'delegate.dart';
import 'proxy.dart';
import 'text_selection.dart';
class TextLine extends StatelessWidget {
const TextLine({
required this.line,
required this.embedBuilder,
required this.styles,
this.textDirection,
Key? key,
}) : super(key: key);
final Line line;
final TextDirection? textDirection;
final EmbedBuilder embedBuilder;
final DefaultStyles styles;
@override
Widget build(BuildContext context) {
assert(debugCheckHasMediaQuery(context));
if (line.hasEmbed) {
final embed = line.children.single as Embed;
return EmbedProxy(embedBuilder(context, embed));
}
final textSpan = _buildTextSpan(context);
final strutStyle = StrutStyle.fromTextStyle(textSpan.style!);
final textAlign = _getTextAlign();
final child = RichText(
text: textSpan,
textAlign: textAlign,
textDirection: textDirection,
strutStyle: strutStyle,
textScaleFactor: MediaQuery.textScaleFactorOf(context),
);
return RichTextProxy(
child,
textSpan.style!,
textAlign,
textDirection!,
1,
Localizations.localeOf(context),
strutStyle,
TextWidthBasis.parent,
null);
}
TextAlign _getTextAlign() {
final alignment = line.style.attributes[Attribute.align.key];
if (alignment == Attribute.leftAlignment) {
return TextAlign.left;
} else if (alignment == Attribute.centerAlignment) {
return TextAlign.center;
} else if (alignment == Attribute.rightAlignment) {
return TextAlign.right;
} else if (alignment == Attribute.justifyAlignment) {
return TextAlign.justify;
}
return TextAlign.start;
}
TextSpan _buildTextSpan(BuildContext context) {
final defaultStyles = styles;
final children = line.children
.map((node) => _getTextSpanFromNode(defaultStyles, node))
.toList(growable: false);
var textStyle = const TextStyle();
if (line.style.containsKey(Attribute.placeholder.key)) {
textStyle = defaultStyles.placeHolder!.style;
return TextSpan(children: children, style: textStyle);
}
final header = line.style.attributes[Attribute.header.key];
final m = <Attribute, TextStyle>{
Attribute.h1: defaultStyles.h1!.style,
Attribute.h2: defaultStyles.h2!.style,
Attribute.h3: defaultStyles.h3!.style,
};
textStyle = textStyle.merge(m[header] ?? defaultStyles.paragraph!.style);
final block = line.style.getBlockExceptHeader();
TextStyle? toMerge;
if (block == Attribute.blockQuote) {
toMerge = defaultStyles.quote!.style;
} else if (block == Attribute.codeBlock) {
toMerge = defaultStyles.code!.style;
} else if (block != null) {
toMerge = defaultStyles.lists!.style;
}
textStyle = textStyle.merge(toMerge);
return TextSpan(children: children, style: textStyle);
}
TextSpan _getTextSpanFromNode(DefaultStyles defaultStyles, Node node) {
final textNode = node as leaf.Text;
final style = textNode.style;
var res = const TextStyle();
final color = textNode.style.attributes[Attribute.color.key];
<String, TextStyle?>{
Attribute.bold.key: defaultStyles.bold,
Attribute.italic.key: defaultStyles.italic,
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 (k == Attribute.underline.key || k == Attribute.strikeThrough.key) {
var textColor = defaultStyles.color;
if (color?.value is String) {
textColor = stringToColor(color?.value);
}
res = _merge(res.copyWith(decorationColor: textColor),
s!.copyWith(decorationColor: textColor));
} else {
res = _merge(res, s!);
}
}
});
final font = textNode.style.attributes[Attribute.font.key];
if (font != null && font.value != null) {
res = res.merge(TextStyle(fontFamily: font.value));
}
final size = textNode.style.attributes[Attribute.size.key];
if (size != null && size.value != null) {
switch (size.value) {
case 'small':
res = res.merge(defaultStyles.sizeSmall);
break;
case 'large':
res = res.merge(defaultStyles.sizeLarge);
break;
case 'huge':
res = res.merge(defaultStyles.sizeHuge);
break;
default:
final fontSize = double.tryParse(size.value);
if (fontSize != null) {
res = res.merge(TextStyle(fontSize: fontSize));
} else {
throw 'Invalid size ${size.value}';
}
}
}
if (color != null && color.value != null) {
var textColor = defaultStyles.color;
if (color.value is String) {
textColor = stringToColor(color.value);
}
if (textColor != null) {
res = res.merge(TextStyle(color: textColor));
}
}
final background = textNode.style.attributes[Attribute.background.key];
if (background != null && background.value != null) {
final backgroundColor = stringToColor(background.value);
res = res.merge(TextStyle(backgroundColor: backgroundColor));
}
return TextSpan(text: textNode.value, style: res);
}
TextStyle _merge(TextStyle a, TextStyle b) {
final decorations = <TextDecoration?>[];
if (a.decoration != null) {
decorations.add(a.decoration);
}
if (b.decoration != null) {
decorations.add(b.decoration);
}
return a.merge(b).apply(
decoration: TextDecoration.combine(
List.castFrom<dynamic, TextDecoration>(decorations)));
}
}
class EditableTextLine extends RenderObjectWidget {
const EditableTextLine(
this.line,
this.leading,
this.body,
this.indentWidth,
this.verticalSpacing,
this.textDirection,
this.textSelection,
this.color,
this.enableInteractiveSelection,
this.hasFocus,
this.devicePixelRatio,
this.cursorCont,
);
final Line line;
final Widget? leading;
final Widget body;
final double indentWidth;
final Tuple2 verticalSpacing;
final TextDirection textDirection;
final TextSelection textSelection;
final Color color;
final bool enableInteractiveSelection;
final bool hasFocus;
final double devicePixelRatio;
final CursorCont cursorCont;
@override
RenderObjectElement createElement() {
return _TextLineElement(this);
}
@override
RenderObject createRenderObject(BuildContext context) {
return RenderEditableTextLine(
line,
textDirection,
textSelection,
enableInteractiveSelection,
hasFocus,
devicePixelRatio,
_getPadding(),
color,
cursorCont);
}
@override
void updateRenderObject(
BuildContext context, covariant RenderEditableTextLine renderObject) {
renderObject
..setLine(line)
..setPadding(_getPadding())
..setTextDirection(textDirection)
..setTextSelection(textSelection)
..setColor(color)
..setEnableInteractiveSelection(enableInteractiveSelection)
..hasFocus = hasFocus
..setDevicePixelRatio(devicePixelRatio)
..setCursorCont(cursorCont);
}
EdgeInsetsGeometry _getPadding() {
return EdgeInsetsDirectional.only(
start: indentWidth,
top: verticalSpacing.item1,
bottom: verticalSpacing.item2);
}
}
enum TextLineSlot { LEADING, BODY }
class RenderEditableTextLine extends RenderEditableBox {
RenderEditableTextLine(
this.line,
this.textDirection,
this.textSelection,
this.enableInteractiveSelection,
this.hasFocus,
this.devicePixelRatio,
this.padding,
this.color,
this.cursorCont,
);
RenderBox? _leading;
RenderContentProxyBox? _body;
Line line;
TextDirection textDirection;
TextSelection textSelection;
Color color;
bool enableInteractiveSelection;
bool hasFocus = false;
double devicePixelRatio;
EdgeInsetsGeometry padding;
CursorCont cursorCont;
EdgeInsets? _resolvedPadding;
bool? _containsCursor;
List<TextBox>? _selectedRects;
Rect? _caretPrototype;
final Map<TextLineSlot, RenderBox> children = <TextLineSlot, RenderBox>{};
Iterable<RenderBox> get _children sync* {
if (_leading != null) {
yield _leading!;
}
if (_body != null) {
yield _body!;
}
}
void setCursorCont(CursorCont c) {
if (cursorCont == c) {
return;
}
cursorCont = c;
markNeedsLayout();
}
void setDevicePixelRatio(double d) {
if (devicePixelRatio == d) {
return;
}
devicePixelRatio = d;
markNeedsLayout();
}
void setEnableInteractiveSelection(bool val) {
if (enableInteractiveSelection == val) {
return;
}
markNeedsLayout();
markNeedsSemanticsUpdate();
}
void setColor(Color c) {
if (color == c) {
return;
}
color = c;
if (containsTextSelection()) {
markNeedsPaint();
}
}
void setTextSelection(TextSelection t) {
if (textSelection == t) {
return;
}
final containsSelection = containsTextSelection();
if (attached && containsCursor()) {
cursorCont.removeListener(markNeedsLayout);
cursorCont.color.removeListener(markNeedsPaint);
}
textSelection = t;
_selectedRects = null;
_containsCursor = null;
if (attached && containsCursor()) {
cursorCont.addListener(markNeedsLayout);
cursorCont.color.addListener(markNeedsPaint);
}
if (containsSelection || containsTextSelection()) {
markNeedsPaint();
}
}
void setTextDirection(TextDirection t) {
if (textDirection == t) {
return;
}
textDirection = t;
_resolvedPadding = null;
markNeedsLayout();
}
void setLine(Line l) {
if (line == l) {
return;
}
line = l;
_containsCursor = null;
markNeedsLayout();
}
void setPadding(EdgeInsetsGeometry p) {
assert(p.isNonNegative);
if (padding == p) {
return;
}
padding = p;
_resolvedPadding = null;
markNeedsLayout();
}
void setLeading(RenderBox? l) {
_leading = _updateChild(_leading, l, TextLineSlot.LEADING);
}
void setBody(RenderContentProxyBox? b) {
_body = _updateChild(_body, b, TextLineSlot.BODY) as RenderContentProxyBox?;
}
bool containsTextSelection() {
return line.documentOffset <= textSelection.end &&
textSelection.start <= line.documentOffset + line.length - 1;
}
bool containsCursor() {
return _containsCursor ??= textSelection.isCollapsed &&
line.containsOffset(textSelection.baseOffset);
}
RenderBox? _updateChild(
RenderBox? old, RenderBox? newChild, TextLineSlot slot) {
if (old != null) {
dropChild(old);
children.remove(slot);
}
if (newChild != null) {
children[slot] = newChild;
adoptChild(newChild);
}
return newChild;
}
List<TextBox> _getBoxes(TextSelection textSelection) {
final parentData = _body!.parentData as BoxParentData?;
return _body!.getBoxesForSelection(textSelection).map((box) {
return TextBox.fromLTRBD(
box.left + parentData!.offset.dx,
box.top + parentData.offset.dy,
box.right + parentData.offset.dx,
box.bottom + parentData.offset.dy,
box.direction,
);
}).toList(growable: false);
}
void _resolvePadding() {
if (_resolvedPadding != null) {
return;
}
_resolvedPadding = padding.resolve(textDirection);
assert(_resolvedPadding!.isNonNegative);
}
@override
TextSelectionPoint getBaseEndpointForSelection(TextSelection textSelection) {
return _getEndpointForSelection(textSelection, true);
}
@override
TextSelectionPoint getExtentEndpointForSelection(
TextSelection textSelection) {
return _getEndpointForSelection(textSelection, false);
}
TextSelectionPoint _getEndpointForSelection(
TextSelection textSelection, bool first) {
if (textSelection.isCollapsed) {
return TextSelectionPoint(
Offset(0, preferredLineHeight(textSelection.extent)) +
getOffsetForCaret(textSelection.extent),
null);
}
final boxes = _getBoxes(textSelection);
assert(boxes.isNotEmpty);
final targetBox = first ? boxes.first : boxes.last;
return TextSelectionPoint(
Offset(first ? targetBox.start : targetBox.end, targetBox.bottom),
targetBox.direction);
}
@override
TextRange getLineBoundary(TextPosition position) {
final lineDy = getOffsetForCaret(position)
.translate(0, 0.5 * preferredLineHeight(position))
.dy;
final lineBoxes =
_getBoxes(TextSelection(baseOffset: 0, extentOffset: line.length - 1))
.where((element) => element.top < lineDy && element.bottom > lineDy)
.toList(growable: false);
return TextRange(
start:
getPositionForOffset(Offset(lineBoxes.first.left, lineDy)).offset,
end: getPositionForOffset(Offset(lineBoxes.last.right, lineDy)).offset);
}
@override
Offset getOffsetForCaret(TextPosition position) {
return _body!.getOffsetForCaret(position, _caretPrototype) +
(_body!.parentData as BoxParentData).offset;
}
@override
TextPosition? getPositionAbove(TextPosition position) {
return _getPosition(position, -0.5);
}
@override
TextPosition? getPositionBelow(TextPosition position) {
return _getPosition(position, 1.5);
}
TextPosition? _getPosition(TextPosition textPosition, double dyScale) {
assert(textPosition.offset < line.length);
final offset = getOffsetForCaret(textPosition)
.translate(0, dyScale * preferredLineHeight(textPosition));
if (_body!.size
.contains(offset - (_body!.parentData as BoxParentData).offset)) {
return getPositionForOffset(offset);
}
return null;
}
@override
TextPosition getPositionForOffset(Offset offset) {
return _body!.getPositionForOffset(
offset - (_body!.parentData as BoxParentData).offset);
}
@override
TextRange getWordBoundary(TextPosition position) {
return _body!.getWordBoundary(position);
}
@override
double preferredLineHeight(TextPosition position) {
return _body!.getPreferredLineHeight();
}
@override
container.Container getContainer() {
return line;
}
double get cursorWidth => cursorCont.style.width;
double get cursorHeight =>
cursorCont.style.height ??
preferredLineHeight(const TextPosition(offset: 0));
void _computeCaretPrototype() {
switch (defaultTargetPlatform) {
case TargetPlatform.iOS:
case TargetPlatform.macOS:
_caretPrototype = Rect.fromLTWH(0, 0, cursorWidth, cursorHeight + 2);
break;
case TargetPlatform.android:
case TargetPlatform.fuchsia:
case TargetPlatform.linux:
case TargetPlatform.windows:
_caretPrototype = Rect.fromLTWH(0, 2, cursorWidth, cursorHeight - 4.0);
break;
default:
throw 'Invalid platform';
}
}
@override
void attach(covariant PipelineOwner owner) {
super.attach(owner);
for (final child in _children) {
child.attach(owner);
}
if (containsCursor()) {
cursorCont.addListener(markNeedsLayout);
cursorCont.cursorColor.addListener(markNeedsPaint);
}
}
@override
void detach() {
super.detach();
for (final child in _children) {
child.detach();
}
if (containsCursor()) {
cursorCont.removeListener(markNeedsLayout);
cursorCont.cursorColor.removeListener(markNeedsPaint);
}
}
@override
void redepthChildren() {
_children.forEach(redepthChild);
}
@override
void visitChildren(RenderObjectVisitor visitor) {
_children.forEach(visitor);
}
@override
List<DiagnosticsNode> debugDescribeChildren() {
final value = <DiagnosticsNode>[];
void add(RenderBox? child, String name) {
if (child != null) {
value.add(child.toDiagnosticsNode(name: name));
}
}
add(_leading, 'leading');
add(_body, 'body');
return value;
}
@override
bool get sizedByParent => false;
@override
double computeMinIntrinsicWidth(double height) {
_resolvePadding();
final horizontalPadding = _resolvedPadding!.left + _resolvedPadding!.right;
final verticalPadding = _resolvedPadding!.top + _resolvedPadding!.bottom;
final leadingWidth = _leading == null
? 0
: _leading!.getMinIntrinsicWidth(height - verticalPadding) as int;
final bodyWidth = _body == null
? 0
: _body!.getMinIntrinsicWidth(math.max(0, height - verticalPadding))
as int;
return horizontalPadding + leadingWidth + bodyWidth;
}
@override
double computeMaxIntrinsicWidth(double height) {
_resolvePadding();
final horizontalPadding = _resolvedPadding!.left + _resolvedPadding!.right;
final verticalPadding = _resolvedPadding!.top + _resolvedPadding!.bottom;
final leadingWidth = _leading == null
? 0
: _leading!.getMaxIntrinsicWidth(height - verticalPadding) as int;
final bodyWidth = _body == null
? 0
: _body!.getMaxIntrinsicWidth(math.max(0, height - verticalPadding))
as int;
return horizontalPadding + leadingWidth + bodyWidth;
}
@override
double computeMinIntrinsicHeight(double width) {
_resolvePadding();
final horizontalPadding = _resolvedPadding!.left + _resolvedPadding!.right;
final verticalPadding = _resolvedPadding!.top + _resolvedPadding!.bottom;
if (_body != null) {
return _body!
.getMinIntrinsicHeight(math.max(0, width - horizontalPadding)) +
verticalPadding;
}
return verticalPadding;
}
@override
double computeMaxIntrinsicHeight(double width) {
_resolvePadding();
final horizontalPadding = _resolvedPadding!.left + _resolvedPadding!.right;
final verticalPadding = _resolvedPadding!.top + _resolvedPadding!.bottom;
if (_body != null) {
return _body!
.getMaxIntrinsicHeight(math.max(0, width - horizontalPadding)) +
verticalPadding;
}
return verticalPadding;
}
@override
double computeDistanceToActualBaseline(TextBaseline baseline) {
_resolvePadding();
return _body!.getDistanceToActualBaseline(baseline)! +
_resolvedPadding!.top;
}
@override
void performLayout() {
final constraints = this.constraints;
_selectedRects = null;
_resolvePadding();
assert(_resolvedPadding != null);
if (_body == null && _leading == null) {
size = constraints.constrain(Size(
_resolvedPadding!.left + _resolvedPadding!.right,
_resolvedPadding!.top + _resolvedPadding!.bottom,
));
return;
}
final innerConstraints = constraints.deflate(_resolvedPadding!);
final indentWidth = textDirection == TextDirection.ltr
? _resolvedPadding!.left
: _resolvedPadding!.right;
_body!.layout(innerConstraints, parentUsesSize: true);
(_body!.parentData as BoxParentData).offset =
Offset(_resolvedPadding!.left, _resolvedPadding!.top);
if (_leading != null) {
final leadingConstraints = innerConstraints.copyWith(
minWidth: indentWidth,
maxWidth: indentWidth,
maxHeight: _body!.size.height);
_leading!.layout(leadingConstraints, parentUsesSize: true);
(_leading!.parentData as BoxParentData).offset =
Offset(0, _resolvedPadding!.top);
}
size = constraints.constrain(Size(
_resolvedPadding!.left + _body!.size.width + _resolvedPadding!.right,
_resolvedPadding!.top + _body!.size.height + _resolvedPadding!.bottom,
));
_computeCaretPrototype();
}
CursorPainter get _cursorPainter => CursorPainter(
_body,
cursorCont.style,
_caretPrototype,
cursorCont.cursorColor.value,
devicePixelRatio,
);
@override
void paint(PaintingContext context, Offset offset) {
if (_leading != null) {
final parentData = _leading!.parentData as BoxParentData;
final effectiveOffset = offset + parentData.offset;
context.paintChild(_leading!, effectiveOffset);
}
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 (hasFocus &&
cursorCont.show.value &&
containsCursor() &&
!cursorCont.style.paintAboveText) {
_paintCursor(context, effectiveOffset);
}
context.paintChild(_body!, effectiveOffset);
if (hasFocus &&
cursorCont.show.value &&
containsCursor() &&
cursorCont.style.paintAboveText) {
_paintCursor(context, effectiveOffset);
}
}
}
void _paintSelection(PaintingContext context, Offset effectiveOffset) {
assert(_selectedRects != null);
final paint = Paint()..color = color;
for (final box in _selectedRects!) {
context.canvas.drawRect(box.toRect().shift(effectiveOffset), paint);
}
}
void _paintCursor(PaintingContext context, Offset effectiveOffset) {
final position = TextPosition(
offset: textSelection.extentOffset - line.documentOffset,
affinity: textSelection.base.affinity,
);
_cursorPainter.paint(context.canvas, effectiveOffset, position);
}
@override
bool hitTestChildren(BoxHitTestResult result, {required Offset position}) {
return _children.first.hitTest(result, position: position);
}
}
class _TextLineElement extends RenderObjectElement {
_TextLineElement(EditableTextLine line) : super(line);
final Map<TextLineSlot, Element> _slotToChildren = <TextLineSlot, Element>{};
@override
EditableTextLine get widget => super.widget as EditableTextLine;
@override
RenderEditableTextLine get renderObject =>
super.renderObject as RenderEditableTextLine;
@override
void visitChildren(ElementVisitor visitor) {
_slotToChildren.values.forEach(visitor);
}
@override
void forgetChild(Element child) {
assert(_slotToChildren.containsValue(child));
assert(child.slot is TextLineSlot);
assert(_slotToChildren.containsKey(child.slot));
_slotToChildren.remove(child.slot);
super.forgetChild(child);
}
@override
void mount(Element? parent, dynamic newSlot) {
super.mount(parent, newSlot);
_mountChild(widget.leading, TextLineSlot.LEADING);
_mountChild(widget.body, TextLineSlot.BODY);
}
@override
void update(EditableTextLine newWidget) {
super.update(newWidget);
assert(widget == newWidget);
_updateChild(widget.leading, TextLineSlot.LEADING);
_updateChild(widget.body, TextLineSlot.BODY);
}
@override
void insertRenderObjectChild(RenderBox child, TextLineSlot? slot) {
// assert(child is RenderBox);
_updateRenderObject(child, slot);
assert(renderObject.children.keys.contains(slot));
}
@override
void removeRenderObjectChild(RenderObject child, TextLineSlot? slot) {
assert(child is RenderBox);
assert(renderObject.children[slot!] == child);
_updateRenderObject(null, slot);
assert(!renderObject.children.keys.contains(slot));
}
@override
void moveRenderObjectChild(
RenderObject child, dynamic oldSlot, dynamic newSlot) {
throw UnimplementedError();
}
void _mountChild(Widget? widget, TextLineSlot slot) {
final oldChild = _slotToChildren[slot];
final newChild = updateChild(oldChild, widget, slot);
if (oldChild != null) {
_slotToChildren.remove(slot);
}
if (newChild != null) {
_slotToChildren[slot] = newChild;
}
}
void _updateRenderObject(RenderBox? child, TextLineSlot? slot) {
switch (slot) {
case TextLineSlot.LEADING:
renderObject.setLeading(child);
break;
case TextLineSlot.BODY:
renderObject.setBody(child as RenderContentProxyBox?);
break;
default:
throw UnimplementedError();
}
}
void _updateChild(Widget? widget, TextLineSlot slot) {
final oldChild = _slotToChildren[slot];
final newChild = updateChild(oldChild, widget, slot);
if (oldChild != null) {
_slotToChildren.remove(slot);
}
if (newChild != null) {
_slotToChildren[slot] = newChild;
}
}
}
/// 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/text_line.dart';

@ -1,726 +1,3 @@
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';
import 'editor.dart';
TextSelection localSelection(Node node, TextSelection selection, fromParent) {
final base = fromParent ? node.offset : node.documentOffset;
assert(base <= selection.end && selection.start <= base + node.length - 1);
final offset = fromParent ? node.offset : node.documentOffset;
return selection.copyWith(
baseOffset: math.max(selection.start - offset, 0),
extentOffset: math.min(selection.end - offset, node.length - 1));
}
enum _TextSelectionHandlePosition { START, END }
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,
this.onSelectionHandleTapped,
this.clipboardStatus,
) {
final overlay = Overlay.of(context, rootOverlay: true)!;
_toolbarController = AnimationController(
duration: const Duration(milliseconds: 150), vsync: overlay);
}
TextEditingValue value;
bool handlesVisible = false;
final BuildContext context;
final Widget debugRequiredFor;
final LayerLink toolbarLayerLink;
final LayerLink startHandleLayerLink;
final LayerLink endHandleLayerLink;
final RenderEditor? renderObject;
final TextSelectionControls selectionCtrls;
final TextSelectionDelegate selectionDelegate;
final DragStartBehavior dragStartBehavior;
final VoidCallback? onSelectionHandleTapped;
final ClipboardStatusNotifier clipboardStatus;
late AnimationController _toolbarController;
List<OverlayEntry>? _handles;
OverlayEntry? toolbar;
TextSelection get _selection => value.selection;
Animation<double> get _toolbarOpacity => _toolbarController.view;
void setHandlesVisible(bool visible) {
if (handlesVisible == visible) {
return;
}
handlesVisible = visible;
if (SchedulerBinding.instance!.schedulerPhase ==
SchedulerPhase.persistentCallbacks) {
SchedulerBinding.instance!.addPostFrameCallback(markNeedsBuild);
} else {
markNeedsBuild();
}
}
void hideHandles() {
if (_handles == null) {
return;
}
_handles![0].remove();
_handles![1].remove();
_handles = null;
}
void hideToolbar() {
assert(toolbar != null);
_toolbarController.stop();
toolbar!.remove();
toolbar = null;
}
void showToolbar() {
assert(toolbar == null);
toolbar = OverlayEntry(builder: _buildToolbar);
Overlay.of(context, rootOverlay: true, debugRequiredFor: debugRequiredFor)!
.insert(toolbar!);
_toolbarController.forward(from: 0);
}
Widget _buildHandle(
BuildContext context, _TextSelectionHandlePosition position) {
if (_selection.isCollapsed &&
position == _TextSelectionHandlePosition.END) {
return Container();
}
return Visibility(
visible: handlesVisible,
child: _TextSelectionHandleOverlay(
onSelectionHandleChanged: (newSelection) {
_handleSelectionHandleChanged(newSelection, position);
},
onSelectionHandleTapped: onSelectionHandleTapped,
startHandleLayerLink: startHandleLayerLink,
endHandleLayerLink: endHandleLayerLink,
renderObject: renderObject,
selection: _selection,
selectionControls: selectionCtrls,
position: position,
dragStartBehavior: dragStartBehavior,
));
}
void update(TextEditingValue newValue) {
if (value == newValue) {
return;
}
value = newValue;
if (SchedulerBinding.instance!.schedulerPhase ==
SchedulerPhase.persistentCallbacks) {
SchedulerBinding.instance!.addPostFrameCallback(markNeedsBuild);
} else {
markNeedsBuild();
}
}
void _handleSelectionHandleChanged(
TextSelection? newSelection, _TextSelectionHandlePosition position) {
TextPosition textPosition;
switch (position) {
case _TextSelectionHandlePosition.START:
textPosition = newSelection != null
? newSelection.base
: const TextPosition(offset: 0);
break;
case _TextSelectionHandlePosition.END:
textPosition = newSelection != null
? newSelection.extent
: const TextPosition(offset: 0);
break;
default:
throw 'Invalid position';
}
selectionDelegate
..textEditingValue =
value.copyWith(selection: newSelection, composing: TextRange.empty)
..bringIntoView(textPosition);
}
Widget _buildToolbar(BuildContext context) {
final endpoints = renderObject!.getEndpointsForSelection(_selection);
final editingRegion = Rect.fromPoints(
renderObject!.localToGlobal(Offset.zero),
renderObject!.localToGlobal(renderObject!.size.bottomRight(Offset.zero)),
);
final baseLineHeight = renderObject!.preferredLineHeight(_selection.base);
final extentLineHeight =
renderObject!.preferredLineHeight(_selection.extent);
final smallestLineHeight = math.min(baseLineHeight, extentLineHeight);
final isMultiline = endpoints.last.point.dy - endpoints.first.point.dy >
smallestLineHeight / 2;
final midX = isMultiline
? editingRegion.width / 2
: (endpoints.first.point.dx + endpoints.last.point.dx) / 2;
final midpoint = Offset(
midX,
endpoints[0].point.dy - baseLineHeight,
);
return FadeTransition(
opacity: _toolbarOpacity,
child: CompositedTransformFollower(
link: toolbarLayerLink,
showWhenUnlinked: false,
offset: -editingRegion.topLeft,
child: selectionCtrls.buildToolbar(
context,
editingRegion,
baseLineHeight,
midpoint,
endpoints,
selectionDelegate,
clipboardStatus,
const Offset(0, 0)),
),
);
}
void markNeedsBuild([Duration? duration]) {
if (_handles != null) {
_handles![0].markNeedsBuild();
_handles![1].markNeedsBuild();
}
toolbar?.markNeedsBuild();
}
void hide() {
if (_handles != null) {
_handles![0].remove();
_handles![1].remove();
_handles = null;
}
if (toolbar != null) {
hideToolbar();
}
}
void dispose() {
hide();
_toolbarController.dispose();
}
void showHandles() {
assert(_handles == null);
_handles = <OverlayEntry>[
OverlayEntry(
builder: (context) =>
_buildHandle(context, _TextSelectionHandlePosition.START)),
OverlayEntry(
builder: (context) =>
_buildHandle(context, _TextSelectionHandlePosition.END)),
];
Overlay.of(context, rootOverlay: true, debugRequiredFor: debugRequiredFor)!
.insertAll(_handles!);
}
}
class _TextSelectionHandleOverlay extends StatefulWidget {
const _TextSelectionHandleOverlay({
required this.selection,
required this.position,
required this.startHandleLayerLink,
required this.endHandleLayerLink,
required this.renderObject,
required this.onSelectionHandleChanged,
required this.onSelectionHandleTapped,
required this.selectionControls,
this.dragStartBehavior = DragStartBehavior.start,
Key? key,
}) : super(key: key);
final TextSelection selection;
final _TextSelectionHandlePosition position;
final LayerLink startHandleLayerLink;
final LayerLink endHandleLayerLink;
final RenderEditor? renderObject;
final ValueChanged<TextSelection?> onSelectionHandleChanged;
final VoidCallback? onSelectionHandleTapped;
final TextSelectionControls selectionControls;
final DragStartBehavior dragStartBehavior;
@override
_TextSelectionHandleOverlayState createState() =>
_TextSelectionHandleOverlayState();
ValueListenable<bool>? get _visibility {
switch (position) {
case _TextSelectionHandlePosition.START:
return renderObject!.selectionStartInViewport;
case _TextSelectionHandlePosition.END:
return renderObject!.selectionEndInViewport;
}
}
}
class _TextSelectionHandleOverlayState
extends State<_TextSelectionHandleOverlay>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
Animation<double> get _opacity => _controller.view;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(milliseconds: 150), vsync: this);
_handleVisibilityChanged();
widget._visibility!.addListener(_handleVisibilityChanged);
}
void _handleVisibilityChanged() {
if (widget._visibility!.value) {
_controller.forward();
} else {
_controller.reverse();
}
}
@override
void didUpdateWidget(_TextSelectionHandleOverlay oldWidget) {
super.didUpdateWidget(oldWidget);
oldWidget._visibility!.removeListener(_handleVisibilityChanged);
_handleVisibilityChanged();
widget._visibility!.addListener(_handleVisibilityChanged);
}
@override
void dispose() {
widget._visibility!.removeListener(_handleVisibilityChanged);
_controller.dispose();
super.dispose();
}
void _handleDragStart(DragStartDetails details) {}
void _handleDragUpdate(DragUpdateDetails details) {
final position =
widget.renderObject!.getPositionForOffset(details.globalPosition);
if (widget.selection.isCollapsed) {
widget.onSelectionHandleChanged(TextSelection.fromPosition(position));
return;
}
final isNormalized =
widget.selection.extentOffset >= widget.selection.baseOffset;
TextSelection? newSelection;
switch (widget.position) {
case _TextSelectionHandlePosition.START:
newSelection = TextSelection(
baseOffset:
isNormalized ? position.offset : widget.selection.baseOffset,
extentOffset:
isNormalized ? widget.selection.extentOffset : position.offset,
);
break;
case _TextSelectionHandlePosition.END:
newSelection = TextSelection(
baseOffset:
isNormalized ? widget.selection.baseOffset : position.offset,
extentOffset:
isNormalized ? position.offset : widget.selection.extentOffset,
);
break;
}
widget.onSelectionHandleChanged(newSelection);
}
void _handleTap() {
if (widget.onSelectionHandleTapped != null) {
widget.onSelectionHandleTapped!();
}
}
@override
Widget build(BuildContext context) {
late LayerLink layerLink;
TextSelectionHandleType? type;
switch (widget.position) {
case _TextSelectionHandlePosition.START:
layerLink = widget.startHandleLayerLink;
type = _chooseType(
widget.renderObject!.textDirection,
TextSelectionHandleType.left,
TextSelectionHandleType.right,
);
break;
case _TextSelectionHandlePosition.END:
assert(!widget.selection.isCollapsed);
layerLink = widget.endHandleLayerLink;
type = _chooseType(
widget.renderObject!.textDirection,
TextSelectionHandleType.right,
TextSelectionHandleType.left,
);
break;
}
final textPosition = widget.position == _TextSelectionHandlePosition.START
? widget.selection.base
: widget.selection.extent;
final lineHeight = widget.renderObject!.preferredLineHeight(textPosition);
final handleAnchor =
widget.selectionControls.getHandleAnchor(type!, lineHeight);
final handleSize = widget.selectionControls.getHandleSize(lineHeight);
final handleRect = Rect.fromLTWH(
-handleAnchor.dx,
-handleAnchor.dy,
handleSize.width,
handleSize.height,
);
final interactiveRect = handleRect.expandToInclude(
Rect.fromCircle(
center: handleRect.center, radius: kMinInteractiveDimension / 2),
);
final padding = RelativeRect.fromLTRB(
math.max((interactiveRect.width - handleRect.width) / 2, 0),
math.max((interactiveRect.height - handleRect.height) / 2, 0),
math.max((interactiveRect.width - handleRect.width) / 2, 0),
math.max((interactiveRect.height - handleRect.height) / 2, 0),
);
return CompositedTransformFollower(
link: layerLink,
offset: interactiveRect.topLeft,
showWhenUnlinked: false,
child: FadeTransition(
opacity: _opacity,
child: Container(
alignment: Alignment.topLeft,
width: interactiveRect.width,
height: interactiveRect.height,
child: GestureDetector(
behavior: HitTestBehavior.translucent,
dragStartBehavior: widget.dragStartBehavior,
onPanStart: _handleDragStart,
onPanUpdate: _handleDragUpdate,
onTap: _handleTap,
child: Padding(
padding: EdgeInsets.only(
left: padding.left,
top: padding.top,
right: padding.right,
bottom: padding.bottom,
),
child: widget.selectionControls.buildHandle(
context,
type,
lineHeight,
),
),
),
),
),
);
}
TextSelectionHandleType? _chooseType(
TextDirection textDirection,
TextSelectionHandleType ltrType,
TextSelectionHandleType rtlType,
) {
if (widget.selection.isCollapsed) return TextSelectionHandleType.collapsed;
switch (textDirection) {
case TextDirection.ltr:
return ltrType;
case TextDirection.rtl:
return rtlType;
}
}
}
class EditorTextSelectionGestureDetector extends StatefulWidget {
const EditorTextSelectionGestureDetector({
required this.child,
this.onTapDown,
this.onForcePressStart,
this.onForcePressEnd,
this.onSingleTapUp,
this.onSingleTapCancel,
this.onSingleLongTapStart,
this.onSingleLongTapMoveUpdate,
this.onSingleLongTapEnd,
this.onDoubleTapDown,
this.onDragSelectionStart,
this.onDragSelectionUpdate,
this.onDragSelectionEnd,
this.behavior,
Key? key,
}) : super(key: key);
final GestureTapDownCallback? onTapDown;
final GestureForcePressStartCallback? onForcePressStart;
final GestureForcePressEndCallback? onForcePressEnd;
final GestureTapUpCallback? onSingleTapUp;
final GestureTapCancelCallback? onSingleTapCancel;
final GestureLongPressStartCallback? onSingleLongTapStart;
final GestureLongPressMoveUpdateCallback? onSingleLongTapMoveUpdate;
final GestureLongPressEndCallback? onSingleLongTapEnd;
final GestureTapDownCallback? onDoubleTapDown;
final GestureDragStartCallback? onDragSelectionStart;
final DragSelectionUpdateCallback? onDragSelectionUpdate;
final GestureDragEndCallback? onDragSelectionEnd;
final HitTestBehavior? behavior;
final Widget child;
@override
State<StatefulWidget> createState() =>
_EditorTextSelectionGestureDetectorState();
}
class _EditorTextSelectionGestureDetectorState
extends State<EditorTextSelectionGestureDetector> {
Timer? _doubleTapTimer;
Offset? _lastTapOffset;
bool _isDoubleTap = false;
@override
void dispose() {
_doubleTapTimer?.cancel();
_dragUpdateThrottleTimer?.cancel();
super.dispose();
}
void _handleTapDown(TapDownDetails details) {
// renderObject.resetTapDownStatus();
if (widget.onTapDown != null) {
widget.onTapDown!(details);
}
if (_doubleTapTimer != null &&
_isWithinDoubleTapTolerance(details.globalPosition)) {
if (widget.onDoubleTapDown != null) {
widget.onDoubleTapDown!(details);
}
_doubleTapTimer!.cancel();
_doubleTapTimeout();
_isDoubleTap = true;
}
}
void _handleTapUp(TapUpDetails details) {
if (!_isDoubleTap) {
if (widget.onSingleTapUp != null) {
widget.onSingleTapUp!(details);
}
_lastTapOffset = details.globalPosition;
_doubleTapTimer = Timer(kDoubleTapTimeout, _doubleTapTimeout);
}
_isDoubleTap = false;
}
void _handleTapCancel() {
if (widget.onSingleTapCancel != null) {
widget.onSingleTapCancel!();
}
}
DragStartDetails? _lastDragStartDetails;
DragUpdateDetails? _lastDragUpdateDetails;
Timer? _dragUpdateThrottleTimer;
void _handleDragStart(DragStartDetails details) {
assert(_lastDragStartDetails == null);
_lastDragStartDetails = details;
if (widget.onDragSelectionStart != null) {
widget.onDragSelectionStart!(details);
}
}
void _handleDragUpdate(DragUpdateDetails details) {
_lastDragUpdateDetails = details;
_dragUpdateThrottleTimer ??=
Timer(const Duration(milliseconds: 50), _handleDragUpdateThrottled);
}
void _handleDragUpdateThrottled() {
assert(_lastDragStartDetails != null);
assert(_lastDragUpdateDetails != null);
if (widget.onDragSelectionUpdate != null) {
widget.onDragSelectionUpdate!(
_lastDragStartDetails!, _lastDragUpdateDetails!);
}
_dragUpdateThrottleTimer = null;
_lastDragUpdateDetails = null;
}
void _handleDragEnd(DragEndDetails details) {
assert(_lastDragStartDetails != null);
if (_dragUpdateThrottleTimer != null) {
_dragUpdateThrottleTimer!.cancel();
_handleDragUpdateThrottled();
}
if (widget.onDragSelectionEnd != null) {
widget.onDragSelectionEnd!(details);
}
_dragUpdateThrottleTimer = null;
_lastDragStartDetails = null;
_lastDragUpdateDetails = null;
}
void _forcePressStarted(ForcePressDetails details) {
_doubleTapTimer?.cancel();
_doubleTapTimer = null;
if (widget.onForcePressStart != null) {
widget.onForcePressStart!(details);
}
}
void _forcePressEnded(ForcePressDetails details) {
if (widget.onForcePressEnd != null) {
widget.onForcePressEnd!(details);
}
}
void _handleLongPressStart(LongPressStartDetails details) {
if (!_isDoubleTap && widget.onSingleLongTapStart != null) {
widget.onSingleLongTapStart!(details);
}
}
void _handleLongPressMoveUpdate(LongPressMoveUpdateDetails details) {
if (!_isDoubleTap && widget.onSingleLongTapMoveUpdate != null) {
widget.onSingleLongTapMoveUpdate!(details);
}
}
void _handleLongPressEnd(LongPressEndDetails details) {
if (!_isDoubleTap && widget.onSingleLongTapEnd != null) {
widget.onSingleLongTapEnd!(details);
}
_isDoubleTap = false;
}
void _doubleTapTimeout() {
_doubleTapTimer = null;
_lastTapOffset = null;
}
bool _isWithinDoubleTapTolerance(Offset secondTapOffset) {
if (_lastTapOffset == null) {
return false;
}
return (secondTapOffset - _lastTapOffset!).distance <= kDoubleTapSlop;
}
@override
Widget build(BuildContext context) {
final gestures = <Type, GestureRecognizerFactory>{};
gestures[TapGestureRecognizer] =
GestureRecognizerFactoryWithHandlers<TapGestureRecognizer>(
() => TapGestureRecognizer(debugOwner: this),
(instance) {
instance
..onTapDown = _handleTapDown
..onTapUp = _handleTapUp
..onTapCancel = _handleTapCancel;
},
);
if (widget.onSingleLongTapStart != null ||
widget.onSingleLongTapMoveUpdate != null ||
widget.onSingleLongTapEnd != null) {
gestures[LongPressGestureRecognizer] =
GestureRecognizerFactoryWithHandlers<LongPressGestureRecognizer>(
() => LongPressGestureRecognizer(
debugOwner: this, kind: PointerDeviceKind.touch),
(instance) {
instance
..onLongPressStart = _handleLongPressStart
..onLongPressMoveUpdate = _handleLongPressMoveUpdate
..onLongPressEnd = _handleLongPressEnd;
},
);
}
if (widget.onDragSelectionStart != null ||
widget.onDragSelectionUpdate != null ||
widget.onDragSelectionEnd != null) {
gestures[HorizontalDragGestureRecognizer] =
GestureRecognizerFactoryWithHandlers<HorizontalDragGestureRecognizer>(
() => HorizontalDragGestureRecognizer(
debugOwner: this, kind: PointerDeviceKind.mouse),
(instance) {
instance
..dragStartBehavior = DragStartBehavior.down
..onStart = _handleDragStart
..onUpdate = _handleDragUpdate
..onEnd = _handleDragEnd;
},
);
}
if (widget.onForcePressStart != null || widget.onForcePressEnd != null) {
gestures[ForcePressGestureRecognizer] =
GestureRecognizerFactoryWithHandlers<ForcePressGestureRecognizer>(
() => ForcePressGestureRecognizer(debugOwner: this),
(instance) {
instance
..onStart =
widget.onForcePressStart != null ? _forcePressStarted : null
..onEnd = widget.onForcePressEnd != null ? _forcePressEnded : null;
},
);
}
return RawGestureDetector(
gestures: gestures,
excludeFromSemantics: true,
behavior: widget.behavior,
child: widget.child,
);
}
}
/// 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/text_selection.dart';

File diff suppressed because it is too large Load Diff
Loading…
Cancel
Save