commit
702bed9075
78 changed files with 11550 additions and 11352 deletions
@ -1 +1,12 @@ |
|||||||
library flutter_quill; |
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'; |
/// TODO: Remove this file in the next breaking release, because implementation |
||||||
|
/// files should be located in the src folder, https://bit.ly/3fA23Yz. |
||||||
import 'package:quiver/core.dart'; |
export '../../../src/models/documents/attribute.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); |
|
||||||
} |
|
||||||
|
@ -1,284 +1,3 @@ |
|||||||
import 'dart:async'; |
/// TODO: Remove this file in the next breaking release, because implementation |
||||||
|
/// files should be located in the src folder, https://bit.ly/3fA23Yz. |
||||||
import 'package:tuple/tuple.dart'; |
export '../../../src/models/documents/document.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, |
|
||||||
} |
|
||||||
|
@ -1,134 +1,3 @@ |
|||||||
import 'package:tuple/tuple.dart'; |
/// TODO: Remove this file in the next breaking release, because implementation |
||||||
|
/// files should be located in the src folder, https://bit.ly/3fA23Yz. |
||||||
import '../quill_delta.dart'; |
export '../../../src/models/documents/history.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(); |
|
||||||
} |
|
||||||
} |
|
||||||
|
@ -1,72 +1,3 @@ |
|||||||
import '../../quill_delta.dart'; |
/// TODO: Remove this file in the next breaking release, because implementation |
||||||
import 'container.dart'; |
/// files should be located in the src folder, https://bit.ly/3fA23Yz. |
||||||
import 'line.dart'; |
export '../../../src/models/documents/nodes/block.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(); |
|
||||||
} |
|
||||||
} |
|
||||||
|
@ -1,160 +1,3 @@ |
|||||||
import 'dart:collection'; |
/// TODO: Remove this file in the next breaking release, because implementation |
||||||
|
/// files should be located in the src folder, https://bit.ly/3fA23Yz. |
||||||
import '../style.dart'; |
export '../../../src/models/documents/nodes/container.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; |
|
||||||
} |
|
||||||
|
@ -1,40 +1,3 @@ |
|||||||
/// An object which can be embedded into a Quill document. |
/// TODO: Remove this file in the next breaking release, because implementation |
||||||
/// |
/// files should be located in the src folder, https://bit.ly/3fA23Yz. |
||||||
/// See also: |
export '../../../src/models/documents/nodes/embed.dart'; |
||||||
/// |
|
||||||
/// * [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); |
|
||||||
} |
|
||||||
|
@ -1,252 +1,3 @@ |
|||||||
import 'dart:math' as math; |
/// TODO: Remove this file in the next breaking release, because implementation |
||||||
|
/// files should be located in the src folder, https://bit.ly/3fA23Yz. |
||||||
import '../../quill_delta.dart'; |
export '../../../src/models/documents/nodes/leaf.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; |
|
||||||
} |
|
||||||
|
@ -1,371 +1,3 @@ |
|||||||
import 'dart:math' as math; |
/// TODO: Remove this file in the next breaking release, because implementation |
||||||
|
/// files should be located in the src folder, https://bit.ly/3fA23Yz. |
||||||
import 'package:collection/collection.dart'; |
export '../../../src/models/documents/nodes/line.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; |
|
||||||
} |
|
||||||
} |
|
||||||
|
@ -1,134 +1,3 @@ |
|||||||
import 'dart:collection'; |
/// TODO: Remove this file in the next breaking release, because implementation |
||||||
|
/// files should be located in the src folder, https://bit.ly/3fA23Yz. |
||||||
import '../../quill_delta.dart'; |
export '../../../src/models/documents/nodes/node.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)); |
|
||||||
} |
|
||||||
|
@ -1,127 +1,3 @@ |
|||||||
import 'package:collection/collection.dart'; |
/// TODO: Remove this file in the next breaking release, because implementation |
||||||
import 'package:quiver/core.dart'; |
/// files should be located in the src folder, https://bit.ly/3fA23Yz. |
||||||
|
export '../../../src/models/documents/style.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(', ')}}"; |
|
||||||
} |
|
||||||
|
@ -1,684 +1,3 @@ |
|||||||
// Copyright (c) 2018, Anatoly Pulyaevskiy. All rights reserved. Use of this source code |
/// TODO: Remove this file in the next breaking release, because implementation |
||||||
// is governed by a BSD-style license that can be found in the LICENSE file. |
/// files should be located in the src folder, https://bit.ly/3fA23Yz. |
||||||
|
export '../../../src/models/quill_delta.dart'; |
||||||
/// 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; |
|
||||||
} |
|
||||||
} |
|
||||||
|
@ -1,124 +1,3 @@ |
|||||||
import '../documents/attribute.dart'; |
/// TODO: Remove this file in the next breaking release, because implementation |
||||||
import '../quill_delta.dart'; |
/// files should be located in the src folder, https://bit.ly/3fA23Yz. |
||||||
import 'rule.dart'; |
export '../../../src/models/rules/delete.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); |
|
||||||
} |
|
||||||
} |
|
||||||
|
@ -1,132 +1,3 @@ |
|||||||
import '../documents/attribute.dart'; |
/// TODO: Remove this file in the next breaking release, because implementation |
||||||
import '../quill_delta.dart'; |
/// files should be located in the src folder, https://bit.ly/3fA23Yz. |
||||||
import 'rule.dart'; |
export '../../../src/models/rules/format.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; |
|
||||||
} |
|
||||||
} |
|
||||||
|
@ -1,413 +1,3 @@ |
|||||||
import 'package:tuple/tuple.dart'; |
/// TODO: Remove this file in the next breaking release, because implementation |
||||||
|
/// files should be located in the src folder, https://bit.ly/3fA23Yz. |
||||||
import '../documents/attribute.dart'; |
export '../../../src/models/rules/insert.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); |
|
||||||
} |
|
||||||
|
@ -1,77 +1,3 @@ |
|||||||
import '../documents/attribute.dart'; |
/// TODO: Remove this file in the next breaking release, because implementation |
||||||
import '../documents/document.dart'; |
/// files should be located in the src folder, https://bit.ly/3fA23Yz. |
||||||
import '../quill_delta.dart'; |
export '../../../src/models/rules/rule.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,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'; |
/// TODO: Remove this file in the next breaking release, because implementation |
||||||
|
/// files should be located in the src folder, https://bit.ly/3fA23Yz. |
||||||
import 'package:flutter/material.dart'; |
export '../../../src/utils/color.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); |
|
||||||
} |
|
||||||
|
@ -1,102 +1,3 @@ |
|||||||
import 'dart:math' as math; |
/// TODO: Remove this file in the next breaking release, because implementation |
||||||
|
/// files should be located in the src folder, https://bit.ly/3fA23Yz. |
||||||
import '../models/quill_delta.dart'; |
export '../../../src/utils/diff_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; |
|
||||||
} |
|
||||||
|
@ -1,39 +1,3 @@ |
|||||||
import 'package:flutter/rendering.dart'; |
/// TODO: Remove this file in the next breaking release, because implementation |
||||||
|
/// files should be located in the src folder, https://bit.ly/3fA23Yz. |
||||||
import '../models/documents/nodes/container.dart'; |
export '../../src/widgets/box.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); |
|
||||||
} |
|
||||||
|
@ -1,228 +1,3 @@ |
|||||||
import 'dart:math' as math; |
/// TODO: Remove this file in the next breaking release, because implementation |
||||||
|
/// files should be located in the src folder, https://bit.ly/3fA23Yz. |
||||||
import 'package:flutter/cupertino.dart'; |
export '../../src/widgets/controller.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)); |
|
||||||
} |
|
||||||
} |
|
||||||
|
@ -1,231 +1,3 @@ |
|||||||
import 'dart:async'; |
/// TODO: Remove this file in the next breaking release, because implementation |
||||||
|
/// files should be located in the src folder, https://bit.ly/3fA23Yz. |
||||||
import 'package:flutter/foundation.dart'; |
export '../../src/widgets/cursor.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); |
|
||||||
} |
|
||||||
} |
|
||||||
|
@ -1,223 +1,3 @@ |
|||||||
import 'package:flutter/material.dart'; |
/// TODO: Remove this file in the next breaking release, because implementation |
||||||
import 'package:flutter/widgets.dart'; |
/// files should be located in the src folder, https://bit.ly/3fA23Yz. |
||||||
import 'package:tuple/tuple.dart'; |
export '../../src/widgets/default_styles.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); |
|
||||||
} |
|
||||||
} |
|
||||||
|
@ -1,148 +1,3 @@ |
|||||||
import 'package:flutter/cupertino.dart'; |
/// TODO: Remove this file in the next breaking release, because implementation |
||||||
import 'package:flutter/gestures.dart'; |
/// files should be located in the src folder, https://bit.ly/3fA23Yz. |
||||||
import 'package:flutter/material.dart'; |
export '../../src/widgets/delegate.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
@ -1,31 +1,3 @@ |
|||||||
import 'package:flutter/cupertino.dart'; |
/// TODO: Remove this file in the next breaking release, because implementation |
||||||
import 'package:flutter/material.dart'; |
/// files should be located in the src folder, https://bit.ly/3fA23Yz. |
||||||
import 'package:flutter/rendering.dart'; |
export '../../src/widgets/image.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, |
|
||||||
), |
|
||||||
), |
|
||||||
), |
|
||||||
); |
|
||||||
} |
|
||||||
} |
|
||||||
|
@ -1,105 +1,3 @@ |
|||||||
import 'package:flutter/foundation.dart'; |
/// TODO: Remove this file in the next breaking release, because implementation |
||||||
import 'package:flutter/services.dart'; |
/// files should be located in the src folder, https://bit.ly/3fA23Yz. |
||||||
|
export '../../src/widgets/keyboard_listener.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; |
|
||||||
} |
|
||||||
} |
|
||||||
|
@ -1,298 +1,3 @@ |
|||||||
import 'package:flutter/rendering.dart'; |
/// TODO: Remove this file in the next breaking release, because implementation |
||||||
import 'package:flutter/widgets.dart'; |
/// files should be located in the src folder, https://bit.ly/3fA23Yz. |
||||||
|
export '../../src/widgets/proxy.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); |
|
||||||
} |
|
||||||
} |
|
||||||
|
@ -1,736 +1,3 @@ |
|||||||
import 'dart:async'; |
/// TODO: Remove this file in the next breaking release, because implementation |
||||||
import 'dart:convert'; |
/// files should be located in the src folder, https://bit.ly/3fA23Yz. |
||||||
|
export '../../src/widgets/raw_editor.dart'; |
||||||
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); |
|
||||||
} |
|
||||||
} |
|
||||||
|
@ -1,354 +1,3 @@ |
|||||||
import 'dart:ui'; |
/// TODO: Remove this file in the next breaking release, because implementation |
||||||
|
/// files should be located in the src folder, https://bit.ly/3fA23Yz. |
||||||
import 'package:characters/characters.dart'; |
export '../../src/widgets/raw_editor/raw_editor_state_keyboard_mixin.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)); |
|
||||||
} |
|
||||||
} |
|
||||||
|
@ -1,40 +1,3 @@ |
|||||||
import 'package:flutter/widgets.dart'; |
/// TODO: Remove this file in the next breaking release, because implementation |
||||||
|
/// files should be located in the src folder, https://bit.ly/3fA23Yz. |
||||||
import '../editor.dart'; |
export '../../src/widgets/raw_editor/raw_editor_state_selection_delegate_mixin.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; |
|
||||||
} |
|
||||||
|
@ -1,200 +1,3 @@ |
|||||||
import 'package:flutter/foundation.dart'; |
/// TODO: Remove this file in the next breaking release, because implementation |
||||||
import 'package:flutter/services.dart'; |
/// files should be located in the src folder, https://bit.ly/3fA23Yz. |
||||||
import 'package:flutter/widgets.dart'; |
export '../../src/widgets/raw_editor/raw_editor_state_text_input_client_mixin.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(); |
|
||||||
} |
|
||||||
} |
|
||||||
|
@ -1,43 +1,3 @@ |
|||||||
import 'package:flutter/material.dart'; |
/// TODO: Remove this file in the next breaking release, because implementation |
||||||
|
/// files should be located in the src folder, https://bit.ly/3fA23Yz. |
||||||
class ResponsiveWidget extends StatelessWidget { |
export '../../src/widgets/responsive_widget.dart'; |
||||||
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; |
|
||||||
} |
|
||||||
}, |
|
||||||
); |
|
||||||
} |
|
||||||
} |
|
||||||
|
@ -1,344 +1,3 @@ |
|||||||
import 'dart:convert'; |
/// TODO: Remove this file in the next breaking release, because implementation |
||||||
import 'dart:io' as io; |
/// files should be located in the src folder, https://bit.ly/3fA23Yz. |
||||||
|
export '../../src/widgets/simple_viewer.dart'; |
||||||
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); |
|
||||||
} |
|
||||||
} |
|
||||||
|
@ -1,737 +1,3 @@ |
|||||||
import 'package:flutter/foundation.dart'; |
/// TODO: Remove this file in the next breaking release, because implementation |
||||||
import 'package:flutter/material.dart'; |
/// files should be located in the src folder, https://bit.ly/3fA23Yz. |
||||||
import 'package:flutter/rendering.dart'; |
export '../../src/widgets/text_block.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, |
|
||||||
), |
|
||||||
), |
|
||||||
); |
|
||||||
} |
|
||||||
} |
|
||||||
|
@ -1,892 +1,3 @@ |
|||||||
import 'dart:math' as math; |
/// TODO: Remove this file in the next breaking release, because implementation |
||||||
|
/// files should be located in the src folder, https://bit.ly/3fA23Yz. |
||||||
import 'package:flutter/foundation.dart'; |
export '../../src/widgets/text_line.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; |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
|
@ -1,726 +1,3 @@ |
|||||||
import 'dart:async'; |
/// TODO: Remove this file in the next breaking release, because implementation |
||||||
import 'dart:math' as math; |
/// files should be located in the src folder, https://bit.ly/3fA23Yz. |
||||||
|
export '../../src/widgets/text_selection.dart'; |
||||||
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
Loading…
Reference in new issue