Hiding the implementation files is best practice according to Dart organising a library package, https://bit.ly/3fA23Yz. It also helps us to know which files we can change without worrying about breaking changes. The PR is not a breaking change, because the classes can still be imported via the old files. In the next breaking change update, these old files can then be removed. No functionality was changed in the PR.pull/233/head
parent
ac68c2373d
commit
d105fb60fd
78 changed files with 11466 additions and 11349 deletions
@ -1 +1,12 @@ |
||||
library flutter_quill; |
||||
|
||||
export 'src/models/documents/attribute.dart'; |
||||
export 'src/models/documents/document.dart'; |
||||
export 'src/models/documents/nodes/embed.dart'; |
||||
export 'src/models/documents/nodes/leaf.dart'; |
||||
export 'src/models/quill_delta.dart'; |
||||
export 'src/widgets/controller.dart'; |
||||
export 'src/widgets/default_styles.dart'; |
||||
export 'src/widgets/editor.dart'; |
||||
export 'src/widgets/responsive_widget.dart'; |
||||
export 'src/widgets/toolbar.dart'; |
||||
|
@ -1,292 +1,3 @@ |
||||
import 'dart:collection'; |
||||
|
||||
import 'package:quiver/core.dart'; |
||||
|
||||
enum AttributeScope { |
||||
INLINE, // refer to https://quilljs.com/docs/formats/#inline |
||||
BLOCK, // refer to https://quilljs.com/docs/formats/#block |
||||
EMBEDS, // refer to https://quilljs.com/docs/formats/#embeds |
||||
IGNORE, // attributes that can be ignored |
||||
} |
||||
|
||||
class Attribute<T> { |
||||
Attribute(this.key, this.scope, this.value); |
||||
|
||||
final String key; |
||||
final AttributeScope scope; |
||||
final T value; |
||||
|
||||
static final Map<String, Attribute> _registry = LinkedHashMap.of({ |
||||
Attribute.bold.key: Attribute.bold, |
||||
Attribute.italic.key: Attribute.italic, |
||||
Attribute.underline.key: Attribute.underline, |
||||
Attribute.strikeThrough.key: Attribute.strikeThrough, |
||||
Attribute.font.key: Attribute.font, |
||||
Attribute.size.key: Attribute.size, |
||||
Attribute.link.key: Attribute.link, |
||||
Attribute.color.key: Attribute.color, |
||||
Attribute.background.key: Attribute.background, |
||||
Attribute.placeholder.key: Attribute.placeholder, |
||||
Attribute.header.key: Attribute.header, |
||||
Attribute.align.key: Attribute.align, |
||||
Attribute.list.key: Attribute.list, |
||||
Attribute.codeBlock.key: Attribute.codeBlock, |
||||
Attribute.blockQuote.key: Attribute.blockQuote, |
||||
Attribute.indent.key: Attribute.indent, |
||||
Attribute.width.key: Attribute.width, |
||||
Attribute.height.key: Attribute.height, |
||||
Attribute.style.key: Attribute.style, |
||||
Attribute.token.key: Attribute.token, |
||||
}); |
||||
|
||||
static final BoldAttribute bold = BoldAttribute(); |
||||
|
||||
static final ItalicAttribute italic = ItalicAttribute(); |
||||
|
||||
static final UnderlineAttribute underline = UnderlineAttribute(); |
||||
|
||||
static final StrikeThroughAttribute strikeThrough = StrikeThroughAttribute(); |
||||
|
||||
static final FontAttribute font = FontAttribute(null); |
||||
|
||||
static final SizeAttribute size = SizeAttribute(null); |
||||
|
||||
static final LinkAttribute link = LinkAttribute(null); |
||||
|
||||
static final ColorAttribute color = ColorAttribute(null); |
||||
|
||||
static final BackgroundAttribute background = BackgroundAttribute(null); |
||||
|
||||
static final PlaceholderAttribute placeholder = PlaceholderAttribute(); |
||||
|
||||
static final HeaderAttribute header = HeaderAttribute(); |
||||
|
||||
static final IndentAttribute indent = IndentAttribute(); |
||||
|
||||
static final AlignAttribute align = AlignAttribute(null); |
||||
|
||||
static final ListAttribute list = ListAttribute(null); |
||||
|
||||
static final CodeBlockAttribute codeBlock = CodeBlockAttribute(); |
||||
|
||||
static final BlockQuoteAttribute blockQuote = BlockQuoteAttribute(); |
||||
|
||||
static final WidthAttribute width = WidthAttribute(null); |
||||
|
||||
static final HeightAttribute height = HeightAttribute(null); |
||||
|
||||
static final StyleAttribute style = StyleAttribute(null); |
||||
|
||||
static final TokenAttribute token = TokenAttribute(''); |
||||
|
||||
static final Set<String> inlineKeys = { |
||||
Attribute.bold.key, |
||||
Attribute.italic.key, |
||||
Attribute.underline.key, |
||||
Attribute.strikeThrough.key, |
||||
Attribute.link.key, |
||||
Attribute.color.key, |
||||
Attribute.background.key, |
||||
Attribute.placeholder.key, |
||||
}; |
||||
|
||||
static final Set<String> blockKeys = LinkedHashSet.of({ |
||||
Attribute.header.key, |
||||
Attribute.align.key, |
||||
Attribute.list.key, |
||||
Attribute.codeBlock.key, |
||||
Attribute.blockQuote.key, |
||||
Attribute.indent.key, |
||||
}); |
||||
|
||||
static final Set<String> blockKeysExceptHeader = LinkedHashSet.of({ |
||||
Attribute.list.key, |
||||
Attribute.align.key, |
||||
Attribute.codeBlock.key, |
||||
Attribute.blockQuote.key, |
||||
Attribute.indent.key, |
||||
}); |
||||
|
||||
static Attribute<int?> get h1 => HeaderAttribute(level: 1); |
||||
|
||||
static Attribute<int?> get h2 => HeaderAttribute(level: 2); |
||||
|
||||
static Attribute<int?> get h3 => HeaderAttribute(level: 3); |
||||
|
||||
// "attributes":{"align":"left"} |
||||
static Attribute<String?> get leftAlignment => AlignAttribute('left'); |
||||
|
||||
// "attributes":{"align":"center"} |
||||
static Attribute<String?> get centerAlignment => AlignAttribute('center'); |
||||
|
||||
// "attributes":{"align":"right"} |
||||
static Attribute<String?> get rightAlignment => AlignAttribute('right'); |
||||
|
||||
// "attributes":{"align":"justify"} |
||||
static Attribute<String?> get justifyAlignment => AlignAttribute('justify'); |
||||
|
||||
// "attributes":{"list":"bullet"} |
||||
static Attribute<String?> get ul => ListAttribute('bullet'); |
||||
|
||||
// "attributes":{"list":"ordered"} |
||||
static Attribute<String?> get ol => ListAttribute('ordered'); |
||||
|
||||
// "attributes":{"list":"checked"} |
||||
static Attribute<String?> get checked => ListAttribute('checked'); |
||||
|
||||
// "attributes":{"list":"unchecked"} |
||||
static Attribute<String?> get unchecked => ListAttribute('unchecked'); |
||||
|
||||
// "attributes":{"indent":1"} |
||||
static Attribute<int?> get indentL1 => IndentAttribute(level: 1); |
||||
|
||||
// "attributes":{"indent":2"} |
||||
static Attribute<int?> get indentL2 => IndentAttribute(level: 2); |
||||
|
||||
// "attributes":{"indent":3"} |
||||
static Attribute<int?> get indentL3 => IndentAttribute(level: 3); |
||||
|
||||
static Attribute<int?> getIndentLevel(int? level) { |
||||
if (level == 1) { |
||||
return indentL1; |
||||
} |
||||
if (level == 2) { |
||||
return indentL2; |
||||
} |
||||
if (level == 3) { |
||||
return indentL3; |
||||
} |
||||
return IndentAttribute(level: level); |
||||
} |
||||
|
||||
bool get isInline => scope == AttributeScope.INLINE; |
||||
|
||||
bool get isBlockExceptHeader => blockKeysExceptHeader.contains(key); |
||||
|
||||
Map<String, dynamic> toJson() => <String, dynamic>{key: value}; |
||||
|
||||
static Attribute fromKeyValue(String key, dynamic value) { |
||||
if (!_registry.containsKey(key)) { |
||||
throw ArgumentError.value(key, 'key "$key" not found.'); |
||||
} |
||||
final origin = _registry[key]!; |
||||
final attribute = clone(origin, value); |
||||
return attribute; |
||||
} |
||||
|
||||
static int getRegistryOrder(Attribute attribute) { |
||||
var order = 0; |
||||
for (final attr in _registry.values) { |
||||
if (attr.key == attribute.key) { |
||||
break; |
||||
} |
||||
order++; |
||||
} |
||||
|
||||
return order; |
||||
} |
||||
|
||||
static Attribute clone(Attribute origin, dynamic value) { |
||||
return Attribute(origin.key, origin.scope, value); |
||||
} |
||||
|
||||
@override |
||||
bool operator ==(Object other) { |
||||
if (identical(this, other)) return true; |
||||
if (other is! Attribute<T>) return false; |
||||
final typedOther = other; |
||||
return key == typedOther.key && |
||||
scope == typedOther.scope && |
||||
value == typedOther.value; |
||||
} |
||||
|
||||
@override |
||||
int get hashCode => hash3(key, scope, value); |
||||
|
||||
@override |
||||
String toString() { |
||||
return 'Attribute{key: $key, scope: $scope, value: $value}'; |
||||
} |
||||
} |
||||
|
||||
class BoldAttribute extends Attribute<bool> { |
||||
BoldAttribute() : super('bold', AttributeScope.INLINE, true); |
||||
} |
||||
|
||||
class ItalicAttribute extends Attribute<bool> { |
||||
ItalicAttribute() : super('italic', AttributeScope.INLINE, true); |
||||
} |
||||
|
||||
class UnderlineAttribute extends Attribute<bool> { |
||||
UnderlineAttribute() : super('underline', AttributeScope.INLINE, true); |
||||
} |
||||
|
||||
class StrikeThroughAttribute extends Attribute<bool> { |
||||
StrikeThroughAttribute() : super('strike', AttributeScope.INLINE, true); |
||||
} |
||||
|
||||
class FontAttribute extends Attribute<String?> { |
||||
FontAttribute(String? val) : super('font', AttributeScope.INLINE, val); |
||||
} |
||||
|
||||
class SizeAttribute extends Attribute<String?> { |
||||
SizeAttribute(String? val) : super('size', AttributeScope.INLINE, val); |
||||
} |
||||
|
||||
class LinkAttribute extends Attribute<String?> { |
||||
LinkAttribute(String? val) : super('link', AttributeScope.INLINE, val); |
||||
} |
||||
|
||||
class ColorAttribute extends Attribute<String?> { |
||||
ColorAttribute(String? val) : super('color', AttributeScope.INLINE, val); |
||||
} |
||||
|
||||
class BackgroundAttribute extends Attribute<String?> { |
||||
BackgroundAttribute(String? val) |
||||
: super('background', AttributeScope.INLINE, val); |
||||
} |
||||
|
||||
/// This is custom attribute for hint |
||||
class PlaceholderAttribute extends Attribute<bool> { |
||||
PlaceholderAttribute() : super('placeholder', AttributeScope.INLINE, true); |
||||
} |
||||
|
||||
class HeaderAttribute extends Attribute<int?> { |
||||
HeaderAttribute({int? level}) : super('header', AttributeScope.BLOCK, level); |
||||
} |
||||
|
||||
class IndentAttribute extends Attribute<int?> { |
||||
IndentAttribute({int? level}) : super('indent', AttributeScope.BLOCK, level); |
||||
} |
||||
|
||||
class AlignAttribute extends Attribute<String?> { |
||||
AlignAttribute(String? val) : super('align', AttributeScope.BLOCK, val); |
||||
} |
||||
|
||||
class ListAttribute extends Attribute<String?> { |
||||
ListAttribute(String? val) : super('list', AttributeScope.BLOCK, val); |
||||
} |
||||
|
||||
class CodeBlockAttribute extends Attribute<bool> { |
||||
CodeBlockAttribute() : super('code-block', AttributeScope.BLOCK, true); |
||||
} |
||||
|
||||
class BlockQuoteAttribute extends Attribute<bool> { |
||||
BlockQuoteAttribute() : super('blockquote', AttributeScope.BLOCK, true); |
||||
} |
||||
|
||||
class WidthAttribute extends Attribute<String?> { |
||||
WidthAttribute(String? val) : super('width', AttributeScope.IGNORE, val); |
||||
} |
||||
|
||||
class HeightAttribute extends Attribute<String?> { |
||||
HeightAttribute(String? val) : super('height', AttributeScope.IGNORE, val); |
||||
} |
||||
|
||||
class StyleAttribute extends Attribute<String?> { |
||||
StyleAttribute(String? val) : super('style', AttributeScope.IGNORE, val); |
||||
} |
||||
|
||||
class TokenAttribute extends Attribute<String> { |
||||
TokenAttribute(String val) : super('token', AttributeScope.IGNORE, val); |
||||
} |
||||
/// TODO: Remove this file in the next breaking release, because implementation |
||||
/// files should be located in the src folder, https://bit.ly/3fA23Yz. |
||||
export '../../../src/models/documents/attribute.dart'; |
||||
|
@ -1,284 +1,3 @@ |
||||
import 'dart:async'; |
||||
|
||||
import 'package:tuple/tuple.dart'; |
||||
|
||||
import '../quill_delta.dart'; |
||||
import '../rules/rule.dart'; |
||||
import 'attribute.dart'; |
||||
import 'history.dart'; |
||||
import 'nodes/block.dart'; |
||||
import 'nodes/container.dart'; |
||||
import 'nodes/embed.dart'; |
||||
import 'nodes/line.dart'; |
||||
import 'nodes/node.dart'; |
||||
import 'style.dart'; |
||||
|
||||
/// The rich text document |
||||
class Document { |
||||
Document() : _delta = Delta()..insert('\n') { |
||||
_loadDocument(_delta); |
||||
} |
||||
|
||||
Document.fromJson(List data) : _delta = _transform(Delta.fromJson(data)) { |
||||
_loadDocument(_delta); |
||||
} |
||||
|
||||
Document.fromDelta(Delta delta) : _delta = delta { |
||||
_loadDocument(delta); |
||||
} |
||||
|
||||
/// The root node of the document tree |
||||
final Root _root = Root(); |
||||
|
||||
Root get root => _root; |
||||
|
||||
int get length => _root.length; |
||||
|
||||
Delta _delta; |
||||
|
||||
Delta toDelta() => Delta.from(_delta); |
||||
|
||||
final Rules _rules = Rules.getInstance(); |
||||
|
||||
void setCustomRules(List<Rule> customRules) { |
||||
_rules.setCustomRules(customRules); |
||||
} |
||||
|
||||
final StreamController<Tuple3<Delta, Delta, ChangeSource>> _observer = |
||||
StreamController.broadcast(); |
||||
|
||||
final History _history = History(); |
||||
|
||||
Stream<Tuple3<Delta, Delta, ChangeSource>> get changes => _observer.stream; |
||||
|
||||
Delta insert(int index, Object? data, {int replaceLength = 0, bool autoAppendNewlineAfterImage = true}) { |
||||
assert(index >= 0); |
||||
assert(data is String || data is Embeddable); |
||||
if (data is Embeddable) { |
||||
data = data.toJson(); |
||||
} else if ((data as String).isEmpty) { |
||||
return Delta(); |
||||
} |
||||
|
||||
final delta = _rules.apply(RuleType.INSERT, this, index, |
||||
data: data, len: replaceLength); |
||||
compose(delta, ChangeSource.LOCAL, autoAppendNewlineAfterImage: autoAppendNewlineAfterImage); |
||||
return delta; |
||||
} |
||||
|
||||
Delta delete(int index, int len) { |
||||
assert(index >= 0 && len > 0); |
||||
final delta = _rules.apply(RuleType.DELETE, this, index, len: len); |
||||
if (delta.isNotEmpty) { |
||||
compose(delta, ChangeSource.LOCAL); |
||||
} |
||||
return delta; |
||||
} |
||||
|
||||
Delta replace(int index, int len, Object? data, {bool autoAppendNewlineAfterImage = true}) { |
||||
assert(index >= 0); |
||||
assert(data is String || data is Embeddable); |
||||
|
||||
final dataIsNotEmpty = (data is String) ? data.isNotEmpty : true; |
||||
|
||||
assert(dataIsNotEmpty || len > 0); |
||||
|
||||
var delta = Delta(); |
||||
|
||||
// We have to insert before applying delete rules |
||||
// Otherwise delete would be operating on stale document snapshot. |
||||
if (dataIsNotEmpty) { |
||||
delta = insert(index, data, replaceLength: len, |
||||
autoAppendNewlineAfterImage: autoAppendNewlineAfterImage); |
||||
} |
||||
|
||||
if (len > 0) { |
||||
final deleteDelta = delete(index, len); |
||||
delta = delta.compose(deleteDelta); |
||||
} |
||||
|
||||
return delta; |
||||
} |
||||
|
||||
Delta format(int index, int len, Attribute? attribute) { |
||||
assert(index >= 0 && len >= 0 && attribute != null); |
||||
|
||||
var delta = Delta(); |
||||
|
||||
final formatDelta = _rules.apply(RuleType.FORMAT, this, index, |
||||
len: len, attribute: attribute); |
||||
if (formatDelta.isNotEmpty) { |
||||
compose(formatDelta, ChangeSource.LOCAL); |
||||
delta = delta.compose(formatDelta); |
||||
} |
||||
|
||||
return delta; |
||||
} |
||||
|
||||
Style collectStyle(int index, int len) { |
||||
final res = queryChild(index); |
||||
return (res.node as Line).collectStyle(res.offset, len); |
||||
} |
||||
|
||||
ChildQuery queryChild(int offset) { |
||||
final res = _root.queryChild(offset, true); |
||||
if (res.node is Line) { |
||||
return res; |
||||
} |
||||
final block = res.node as Block; |
||||
return block.queryChild(res.offset, true); |
||||
} |
||||
|
||||
void compose(Delta delta, ChangeSource changeSource, {bool autoAppendNewlineAfterImage = true}) { |
||||
assert(!_observer.isClosed); |
||||
delta.trim(); |
||||
assert(delta.isNotEmpty); |
||||
|
||||
var offset = 0; |
||||
delta = _transform(delta, autoAppendNewlineAfterImage: autoAppendNewlineAfterImage); |
||||
final originalDelta = toDelta(); |
||||
for (final op in delta.toList()) { |
||||
final style = |
||||
op.attributes != null ? Style.fromJson(op.attributes) : null; |
||||
|
||||
if (op.isInsert) { |
||||
_root.insert(offset, _normalize(op.data), style); |
||||
} else if (op.isDelete) { |
||||
_root.delete(offset, op.length); |
||||
} else if (op.attributes != null) { |
||||
_root.retain(offset, op.length, style); |
||||
} |
||||
|
||||
if (!op.isDelete) { |
||||
offset += op.length!; |
||||
} |
||||
} |
||||
try { |
||||
_delta = _delta.compose(delta); |
||||
} catch (e) { |
||||
throw '_delta compose failed'; |
||||
} |
||||
|
||||
if (_delta != _root.toDelta()) { |
||||
throw 'Compose failed'; |
||||
} |
||||
final change = Tuple3(originalDelta, delta, changeSource); |
||||
_observer.add(change); |
||||
_history.handleDocChange(change); |
||||
} |
||||
|
||||
Tuple2 undo() { |
||||
return _history.undo(this); |
||||
} |
||||
|
||||
Tuple2 redo() { |
||||
return _history.redo(this); |
||||
} |
||||
|
||||
bool get hasUndo => _history.hasUndo; |
||||
|
||||
bool get hasRedo => _history.hasRedo; |
||||
|
||||
static Delta _transform(Delta delta, {bool autoAppendNewlineAfterImage = true}) { |
||||
final res = Delta(); |
||||
final ops = delta.toList(); |
||||
for (var i = 0; i < ops.length; i++) { |
||||
final op = ops[i]; |
||||
res.push(op); |
||||
if (autoAppendNewlineAfterImage) { |
||||
_autoAppendNewlineAfterImage(i, ops, op, res); |
||||
} |
||||
} |
||||
return res; |
||||
} |
||||
|
||||
static void _autoAppendNewlineAfterImage( |
||||
int i, List<Operation> ops, Operation op, Delta res) { |
||||
final nextOpIsImage = |
||||
i + 1 < ops.length && ops[i + 1].isInsert && ops[i + 1].data is! String; |
||||
if (nextOpIsImage && |
||||
op.data is String && |
||||
(op.data as String).isNotEmpty && |
||||
!(op.data as String).endsWith('\n')) |
||||
{ |
||||
res.push(Operation.insert('\n')); |
||||
} |
||||
// Currently embed is equivalent to image and hence `is! String` |
||||
final opInsertImage = op.isInsert && op.data is! String; |
||||
final nextOpIsLineBreak = i + 1 < ops.length && |
||||
ops[i + 1].isInsert && |
||||
ops[i + 1].data is String && |
||||
(ops[i + 1].data as String).startsWith('\n'); |
||||
if (opInsertImage && (i + 1 == ops.length - 1 || !nextOpIsLineBreak)) { |
||||
// automatically append '\n' for image |
||||
res.push(Operation.insert('\n')); |
||||
} |
||||
} |
||||
|
||||
Object _normalize(Object? data) { |
||||
if (data is String) { |
||||
return data; |
||||
} |
||||
|
||||
if (data is Embeddable) { |
||||
return data; |
||||
} |
||||
return Embeddable.fromJson(data as Map<String, dynamic>); |
||||
} |
||||
|
||||
void close() { |
||||
_observer.close(); |
||||
_history.clear(); |
||||
} |
||||
|
||||
String toPlainText() => _root.children.map((e) => e.toPlainText()).join(); |
||||
|
||||
void _loadDocument(Delta doc) { |
||||
if (doc.isEmpty) { |
||||
throw ArgumentError.value(doc, 'Document Delta cannot be empty.'); |
||||
} |
||||
|
||||
assert((doc.last.data as String).endsWith('\n')); |
||||
|
||||
var offset = 0; |
||||
for (final op in doc.toList()) { |
||||
if (!op.isInsert) { |
||||
throw ArgumentError.value(doc, |
||||
'Document Delta can only contain insert operations but ${op.key} found.'); |
||||
} |
||||
final style = |
||||
op.attributes != null ? Style.fromJson(op.attributes) : null; |
||||
final data = _normalize(op.data); |
||||
_root.insert(offset, data, style); |
||||
offset += op.length!; |
||||
} |
||||
final node = _root.last; |
||||
if (node is Line && |
||||
node.parent is! Block && |
||||
node.style.isEmpty && |
||||
_root.childCount > 1) { |
||||
_root.remove(node); |
||||
} |
||||
} |
||||
|
||||
bool isEmpty() { |
||||
if (root.children.length != 1) { |
||||
return false; |
||||
} |
||||
|
||||
final node = root.children.first; |
||||
if (!node.isLast) { |
||||
return false; |
||||
} |
||||
|
||||
final delta = node.toDelta(); |
||||
return delta.length == 1 && |
||||
delta.first.data == '\n' && |
||||
delta.first.key == 'insert'; |
||||
} |
||||
} |
||||
|
||||
enum ChangeSource { |
||||
LOCAL, |
||||
REMOTE, |
||||
} |
||||
/// TODO: Remove this file in the next breaking release, because implementation |
||||
/// files should be located in the src folder, https://bit.ly/3fA23Yz. |
||||
export '../../../src/models/documents/document.dart'; |
||||
|
@ -1,134 +1,3 @@ |
||||
import 'package:tuple/tuple.dart'; |
||||
|
||||
import '../quill_delta.dart'; |
||||
import 'document.dart'; |
||||
|
||||
class History { |
||||
History({ |
||||
this.ignoreChange = false, |
||||
this.interval = 400, |
||||
this.maxStack = 100, |
||||
this.userOnly = false, |
||||
this.lastRecorded = 0, |
||||
}); |
||||
|
||||
final HistoryStack stack = HistoryStack.empty(); |
||||
|
||||
bool get hasUndo => stack.undo.isNotEmpty; |
||||
|
||||
bool get hasRedo => stack.redo.isNotEmpty; |
||||
|
||||
/// used for disable redo or undo function |
||||
bool ignoreChange; |
||||
|
||||
int lastRecorded; |
||||
|
||||
/// Collaborative editing's conditions should be true |
||||
final bool userOnly; |
||||
|
||||
///max operation count for undo |
||||
final int maxStack; |
||||
|
||||
///record delay |
||||
final int interval; |
||||
|
||||
void handleDocChange(Tuple3<Delta, Delta, ChangeSource> change) { |
||||
if (ignoreChange) return; |
||||
if (!userOnly || change.item3 == ChangeSource.LOCAL) { |
||||
record(change.item2, change.item1); |
||||
} else { |
||||
transform(change.item2); |
||||
} |
||||
} |
||||
|
||||
void clear() { |
||||
stack.clear(); |
||||
} |
||||
|
||||
void record(Delta change, Delta before) { |
||||
if (change.isEmpty) return; |
||||
stack.redo.clear(); |
||||
var undoDelta = change.invert(before); |
||||
final timeStamp = DateTime.now().millisecondsSinceEpoch; |
||||
|
||||
if (lastRecorded + interval > timeStamp && stack.undo.isNotEmpty) { |
||||
final lastDelta = stack.undo.removeLast(); |
||||
undoDelta = undoDelta.compose(lastDelta); |
||||
} else { |
||||
lastRecorded = timeStamp; |
||||
} |
||||
|
||||
if (undoDelta.isEmpty) return; |
||||
stack.undo.add(undoDelta); |
||||
|
||||
if (stack.undo.length > maxStack) { |
||||
stack.undo.removeAt(0); |
||||
} |
||||
} |
||||
|
||||
/// |
||||
///It will override pre local undo delta,replaced by remote change |
||||
/// |
||||
void transform(Delta delta) { |
||||
transformStack(stack.undo, delta); |
||||
transformStack(stack.redo, delta); |
||||
} |
||||
|
||||
void transformStack(List<Delta> stack, Delta delta) { |
||||
for (var i = stack.length - 1; i >= 0; i -= 1) { |
||||
final oldDelta = stack[i]; |
||||
stack[i] = delta.transform(oldDelta, true); |
||||
delta = oldDelta.transform(delta, false); |
||||
if (stack[i].length == 0) { |
||||
stack.removeAt(i); |
||||
} |
||||
} |
||||
} |
||||
|
||||
Tuple2 _change(Document doc, List<Delta> source, List<Delta> dest) { |
||||
if (source.isEmpty) { |
||||
return const Tuple2(false, 0); |
||||
} |
||||
final delta = source.removeLast(); |
||||
// look for insert or delete |
||||
int? len = 0; |
||||
final ops = delta.toList(); |
||||
for (var i = 0; i < ops.length; i++) { |
||||
if (ops[i].key == Operation.insertKey) { |
||||
len = ops[i].length; |
||||
} else if (ops[i].key == Operation.deleteKey) { |
||||
len = ops[i].length! * -1; |
||||
} |
||||
} |
||||
final base = Delta.from(doc.toDelta()); |
||||
final inverseDelta = delta.invert(base); |
||||
dest.add(inverseDelta); |
||||
lastRecorded = 0; |
||||
ignoreChange = true; |
||||
doc.compose(delta, ChangeSource.LOCAL); |
||||
ignoreChange = false; |
||||
return Tuple2(true, len); |
||||
} |
||||
|
||||
Tuple2 undo(Document doc) { |
||||
return _change(doc, stack.undo, stack.redo); |
||||
} |
||||
|
||||
Tuple2 redo(Document doc) { |
||||
return _change(doc, stack.redo, stack.undo); |
||||
} |
||||
} |
||||
|
||||
class HistoryStack { |
||||
HistoryStack.empty() |
||||
: undo = [], |
||||
redo = []; |
||||
|
||||
final List<Delta> undo; |
||||
final List<Delta> redo; |
||||
|
||||
void clear() { |
||||
undo.clear(); |
||||
redo.clear(); |
||||
} |
||||
} |
||||
/// TODO: Remove this file in the next breaking release, because implementation |
||||
/// files should be located in the src folder, https://bit.ly/3fA23Yz. |
||||
export '../../../src/models/documents/history.dart'; |
||||
|
@ -1,72 +1,3 @@ |
||||
import '../../quill_delta.dart'; |
||||
import 'container.dart'; |
||||
import 'line.dart'; |
||||
import 'node.dart'; |
||||
|
||||
/// Represents a group of adjacent [Line]s with the same block style. |
||||
/// |
||||
/// Block elements are: |
||||
/// - Blockquote |
||||
/// - Header |
||||
/// - Indent |
||||
/// - List |
||||
/// - Text Alignment |
||||
/// - Text Direction |
||||
/// - Code Block |
||||
class Block extends Container<Line?> { |
||||
/// Creates new unmounted [Block]. |
||||
@override |
||||
Node newInstance() => Block(); |
||||
|
||||
@override |
||||
Line get defaultChild => Line(); |
||||
|
||||
@override |
||||
Delta toDelta() { |
||||
return children |
||||
.map((child) => child.toDelta()) |
||||
.fold(Delta(), (a, b) => a.concat(b)); |
||||
} |
||||
|
||||
@override |
||||
void adjust() { |
||||
if (isEmpty) { |
||||
final sibling = previous; |
||||
unlink(); |
||||
if (sibling != null) { |
||||
sibling.adjust(); |
||||
} |
||||
return; |
||||
} |
||||
|
||||
var block = this; |
||||
final prev = block.previous; |
||||
// merging it with previous block if style is the same |
||||
if (!block.isFirst && |
||||
block.previous is Block && |
||||
prev!.style == block.style) { |
||||
block |
||||
..moveChildToNewParent(prev as Container<Node?>?) |
||||
..unlink(); |
||||
block = prev as Block; |
||||
} |
||||
final next = block.next; |
||||
// merging it with next block if style is the same |
||||
if (!block.isLast && block.next is Block && next!.style == block.style) { |
||||
(next as Block).moveChildToNewParent(block); |
||||
next.unlink(); |
||||
} |
||||
} |
||||
|
||||
@override |
||||
String toString() { |
||||
final block = style.attributes.toString(); |
||||
final buffer = StringBuffer('§ {$block}\n'); |
||||
for (final child in children) { |
||||
final tree = child.isLast ? '└' : '├'; |
||||
buffer.write(' $tree $child'); |
||||
if (!child.isLast) buffer.writeln(); |
||||
} |
||||
return buffer.toString(); |
||||
} |
||||
} |
||||
/// TODO: Remove this file in the next breaking release, because implementation |
||||
/// files should be located in the src folder, https://bit.ly/3fA23Yz. |
||||
export '../../../src/models/documents/nodes/block.dart'; |
||||
|
@ -1,160 +1,3 @@ |
||||
import 'dart:collection'; |
||||
|
||||
import '../style.dart'; |
||||
import 'leaf.dart'; |
||||
import 'line.dart'; |
||||
import 'node.dart'; |
||||
|
||||
/// Container can accommodate other nodes. |
||||
/// |
||||
/// Delegates insert, retain and delete operations to children nodes. For each |
||||
/// operation container looks for a child at specified index position and |
||||
/// forwards operation to that child. |
||||
/// |
||||
/// Most of the operation handling logic is implemented by [Line] and [Text]. |
||||
abstract class Container<T extends Node?> extends Node { |
||||
final LinkedList<Node> _children = LinkedList<Node>(); |
||||
|
||||
/// List of children. |
||||
LinkedList<Node> get children => _children; |
||||
|
||||
/// Returns total number of child nodes in this container. |
||||
/// |
||||
/// To get text length of this container see [length]. |
||||
int get childCount => _children.length; |
||||
|
||||
/// Returns the first child [Node]. |
||||
Node get first => _children.first; |
||||
|
||||
/// Returns the last child [Node]. |
||||
Node get last => _children.last; |
||||
|
||||
/// Returns `true` if this container has no child nodes. |
||||
bool get isEmpty => _children.isEmpty; |
||||
|
||||
/// Returns `true` if this container has at least 1 child. |
||||
bool get isNotEmpty => _children.isNotEmpty; |
||||
|
||||
/// Returns an instance of default child for this container node. |
||||
/// |
||||
/// Always returns fresh instance. |
||||
T get defaultChild; |
||||
|
||||
/// Adds [node] to the end of this container children list. |
||||
void add(T node) { |
||||
assert(node?.parent == null); |
||||
node?.parent = this; |
||||
_children.add(node as Node); |
||||
} |
||||
|
||||
/// Adds [node] to the beginning of this container children list. |
||||
void addFirst(T node) { |
||||
assert(node?.parent == null); |
||||
node?.parent = this; |
||||
_children.addFirst(node as Node); |
||||
} |
||||
|
||||
/// Removes [node] from this container. |
||||
void remove(T node) { |
||||
assert(node?.parent == this); |
||||
node?.parent = null; |
||||
_children.remove(node as Node); |
||||
} |
||||
|
||||
/// Moves children of this node to [newParent]. |
||||
void moveChildToNewParent(Container? newParent) { |
||||
if (isEmpty) { |
||||
return; |
||||
} |
||||
|
||||
final last = newParent!.isEmpty ? null : newParent.last as T?; |
||||
while (isNotEmpty) { |
||||
final child = first as T; |
||||
child?.unlink(); |
||||
newParent.add(child); |
||||
} |
||||
|
||||
/// In case [newParent] already had children we need to make sure |
||||
/// combined list is optimized. |
||||
if (last != null) last.adjust(); |
||||
} |
||||
|
||||
/// Queries the child [Node] at specified character [offset] in this container. |
||||
/// |
||||
/// The result may contain the found node or `null` if no node is found |
||||
/// at specified offset. |
||||
/// |
||||
/// [ChildQuery.offset] is set to relative offset within returned child node |
||||
/// which points at the same character position in the document as the |
||||
/// original [offset]. |
||||
ChildQuery queryChild(int offset, bool inclusive) { |
||||
if (offset < 0 || offset > length) { |
||||
return ChildQuery(null, 0); |
||||
} |
||||
|
||||
for (final node in children) { |
||||
final len = node.length; |
||||
if (offset < len || (inclusive && offset == len && (node.isLast))) { |
||||
return ChildQuery(node, offset); |
||||
} |
||||
offset -= len; |
||||
} |
||||
return ChildQuery(null, 0); |
||||
} |
||||
|
||||
@override |
||||
String toPlainText() => children.map((child) => child.toPlainText()).join(); |
||||
|
||||
/// Content length of this node's children. |
||||
/// |
||||
/// To get number of children in this node use [childCount]. |
||||
@override |
||||
int get length => _children.fold(0, (cur, node) => cur + node.length); |
||||
|
||||
@override |
||||
void insert(int index, Object data, Style? style) { |
||||
assert(index == 0 || (index > 0 && index < length)); |
||||
|
||||
if (isNotEmpty) { |
||||
final child = queryChild(index, false); |
||||
child.node!.insert(child.offset, data, style); |
||||
return; |
||||
} |
||||
|
||||
// empty |
||||
assert(index == 0); |
||||
final node = defaultChild; |
||||
add(node); |
||||
node?.insert(index, data, style); |
||||
} |
||||
|
||||
@override |
||||
void retain(int index, int? length, Style? attributes) { |
||||
assert(isNotEmpty); |
||||
final child = queryChild(index, false); |
||||
child.node!.retain(child.offset, length, attributes); |
||||
} |
||||
|
||||
@override |
||||
void delete(int index, int? length) { |
||||
assert(isNotEmpty); |
||||
final child = queryChild(index, false); |
||||
child.node!.delete(child.offset, length); |
||||
} |
||||
|
||||
@override |
||||
String toString() => _children.join('\n'); |
||||
} |
||||
|
||||
/// Result of a child query in a [Container]. |
||||
class ChildQuery { |
||||
ChildQuery(this.node, this.offset); |
||||
|
||||
/// The child node if found, otherwise `null`. |
||||
final Node? node; |
||||
|
||||
/// Starting offset within the child [node] which points at the same |
||||
/// character in the document as the original offset passed to |
||||
/// [Container.queryChild] method. |
||||
final int offset; |
||||
} |
||||
/// TODO: Remove this file in the next breaking release, because implementation |
||||
/// files should be located in the src folder, https://bit.ly/3fA23Yz. |
||||
export '../../../src/models/documents/nodes/container.dart'; |
||||
|
@ -1,40 +1,3 @@ |
||||
/// An object which can be embedded into a Quill document. |
||||
/// |
||||
/// See also: |
||||
/// |
||||
/// * [BlockEmbed] which represents a block embed. |
||||
class Embeddable { |
||||
Embeddable(this.type, this.data); |
||||
|
||||
/// The type of this object. |
||||
final String type; |
||||
|
||||
/// The data payload of this object. |
||||
final dynamic data; |
||||
|
||||
Map<String, dynamic> toJson() { |
||||
final m = <String, String>{type: data}; |
||||
return m; |
||||
} |
||||
|
||||
static Embeddable fromJson(Map<String, dynamic> json) { |
||||
final m = Map<String, dynamic>.from(json); |
||||
assert(m.length == 1, 'Embeddable map has one key'); |
||||
|
||||
return BlockEmbed(m.keys.first, m.values.first); |
||||
} |
||||
} |
||||
|
||||
/// An object which occupies an entire line in a document and cannot co-exist |
||||
/// inline with regular text. |
||||
/// |
||||
/// There are two built-in embed types supported by Quill documents, however |
||||
/// the document model itself does not make any assumptions about the types |
||||
/// of embedded objects and allows users to define their own types. |
||||
class BlockEmbed extends Embeddable { |
||||
BlockEmbed(String type, String data) : super(type, data); |
||||
|
||||
static BlockEmbed horizontalRule = BlockEmbed('divider', 'hr'); |
||||
|
||||
static BlockEmbed image(String imageUrl) => BlockEmbed('image', imageUrl); |
||||
} |
||||
/// TODO: Remove this file in the next breaking release, because implementation |
||||
/// files should be located in the src folder, https://bit.ly/3fA23Yz. |
||||
export '../../../src/models/documents/nodes/embed.dart'; |
||||
|
@ -1,252 +1,3 @@ |
||||
import 'dart:math' as math; |
||||
|
||||
import '../../quill_delta.dart'; |
||||
import '../style.dart'; |
||||
import 'embed.dart'; |
||||
import 'line.dart'; |
||||
import 'node.dart'; |
||||
|
||||
/// A leaf in Quill document tree. |
||||
abstract class Leaf extends Node { |
||||
/// Creates a new [Leaf] with specified [data]. |
||||
factory Leaf(Object data) { |
||||
if (data is Embeddable) { |
||||
return Embed(data); |
||||
} |
||||
final text = data as String; |
||||
assert(text.isNotEmpty); |
||||
return Text(text); |
||||
} |
||||
|
||||
Leaf.val(Object val) : _value = val; |
||||
|
||||
/// Contents of this node, either a String if this is a [Text] or an |
||||
/// [Embed] if this is an [BlockEmbed]. |
||||
Object get value => _value; |
||||
Object _value; |
||||
|
||||
@override |
||||
void applyStyle(Style value) { |
||||
assert(value.isInline || value.isIgnored || value.isEmpty, |
||||
'Unable to apply Style to leaf: $value'); |
||||
super.applyStyle(value); |
||||
} |
||||
|
||||
@override |
||||
Line? get parent => super.parent as Line?; |
||||
|
||||
@override |
||||
int get length { |
||||
if (_value is String) { |
||||
return (_value as String).length; |
||||
} |
||||
// return 1 for embedded object |
||||
return 1; |
||||
} |
||||
|
||||
@override |
||||
Delta toDelta() { |
||||
final data = |
||||
_value is Embeddable ? (_value as Embeddable).toJson() : _value; |
||||
return Delta()..insert(data, style.toJson()); |
||||
} |
||||
|
||||
@override |
||||
void insert(int index, Object data, Style? style) { |
||||
assert(index >= 0 && index <= length); |
||||
final node = Leaf(data); |
||||
if (index < length) { |
||||
splitAt(index)!.insertBefore(node); |
||||
} else { |
||||
insertAfter(node); |
||||
} |
||||
node.format(style); |
||||
} |
||||
|
||||
@override |
||||
void retain(int index, int? len, Style? style) { |
||||
if (style == null) { |
||||
return; |
||||
} |
||||
|
||||
final local = math.min(length - index, len!); |
||||
final remain = len - local; |
||||
final node = _isolate(index, local); |
||||
|
||||
if (remain > 0) { |
||||
assert(node.next != null); |
||||
node.next!.retain(0, remain, style); |
||||
} |
||||
node.format(style); |
||||
} |
||||
|
||||
@override |
||||
void delete(int index, int? len) { |
||||
assert(index < length); |
||||
|
||||
final local = math.min(length - index, len!); |
||||
final target = _isolate(index, local); |
||||
final prev = target.previous as Leaf?; |
||||
final next = target.next as Leaf?; |
||||
target.unlink(); |
||||
|
||||
final remain = len - local; |
||||
if (remain > 0) { |
||||
assert(next != null); |
||||
next!.delete(0, remain); |
||||
} |
||||
|
||||
if (prev != null) { |
||||
prev.adjust(); |
||||
} |
||||
} |
||||
|
||||
/// Adjust this text node by merging it with adjacent nodes if they share |
||||
/// the same style. |
||||
@override |
||||
void adjust() { |
||||
if (this is Embed) { |
||||
// Embed nodes cannot be merged with text nor other embeds (in fact, |
||||
// there could be no two adjacent embeds on the same line since an |
||||
// embed occupies an entire line). |
||||
return; |
||||
} |
||||
|
||||
// This is a text node and it can only be merged with other text nodes. |
||||
var node = this as Text; |
||||
|
||||
// Merging it with previous node if style is the same. |
||||
final prev = node.previous; |
||||
if (!node.isFirst && prev is Text && prev.style == node.style) { |
||||
prev._value = prev.value + node.value; |
||||
node.unlink(); |
||||
node = prev; |
||||
} |
||||
|
||||
// Merging it with next node if style is the same. |
||||
final next = node.next; |
||||
if (!node.isLast && next is Text && next.style == node.style) { |
||||
node._value = node.value + next.value; |
||||
next.unlink(); |
||||
} |
||||
} |
||||
|
||||
/// Splits this leaf node at [index] and returns new node. |
||||
/// |
||||
/// If this is the last node in its list and [index] equals this node's |
||||
/// length then this method returns `null` as there is nothing left to split. |
||||
/// If there is another leaf node after this one and [index] equals this |
||||
/// node's length then the next leaf node is returned. |
||||
/// |
||||
/// If [index] equals to `0` then this node itself is returned unchanged. |
||||
/// |
||||
/// In case a new node is actually split from this one, it inherits this |
||||
/// node's style. |
||||
Leaf? splitAt(int index) { |
||||
assert(index >= 0 && index <= length); |
||||
if (index == 0) { |
||||
return this; |
||||
} |
||||
if (index == length) { |
||||
return isLast ? null : next as Leaf?; |
||||
} |
||||
|
||||
assert(this is Text); |
||||
final text = _value as String; |
||||
_value = text.substring(0, index); |
||||
final split = Leaf(text.substring(index))..applyStyle(style); |
||||
insertAfter(split); |
||||
return split; |
||||
} |
||||
|
||||
/// Cuts a leaf from [index] to the end of this node and returns new node |
||||
/// in detached state (e.g. [mounted] returns `false`). |
||||
/// |
||||
/// Splitting logic is identical to one described in [splitAt], meaning this |
||||
/// method may return `null`. |
||||
Leaf? cutAt(int index) { |
||||
assert(index >= 0 && index <= length); |
||||
final cut = splitAt(index); |
||||
cut?.unlink(); |
||||
return cut; |
||||
} |
||||
|
||||
/// Formats this node and optimizes it with adjacent leaf nodes if needed. |
||||
void format(Style? style) { |
||||
if (style != null && style.isNotEmpty) { |
||||
applyStyle(style); |
||||
} |
||||
adjust(); |
||||
} |
||||
|
||||
/// Isolates a new leaf starting at [index] with specified [length]. |
||||
/// |
||||
/// Splitting logic is identical to one described in [splitAt], with one |
||||
/// exception that it is required for [index] to always be less than this |
||||
/// node's length. As a result this method always returns a [LeafNode] |
||||
/// instance. Returned node may still be the same as this node |
||||
/// if provided [index] is `0`. |
||||
Leaf _isolate(int index, int length) { |
||||
assert( |
||||
index >= 0 && index < this.length && (index + length <= this.length)); |
||||
final target = splitAt(index)!..splitAt(length); |
||||
return target; |
||||
} |
||||
} |
||||
|
||||
/// A span of formatted text within a line in a Quill document. |
||||
/// |
||||
/// Text is a leaf node of a document tree. |
||||
/// |
||||
/// Parent of a text node is always a [Line], and as a consequence text |
||||
/// node's [value] cannot contain any line-break characters. |
||||
/// |
||||
/// See also: |
||||
/// |
||||
/// * [Embed], a leaf node representing an embeddable object. |
||||
/// * [Line], a node representing a line of text. |
||||
class Text extends Leaf { |
||||
Text([String text = '']) |
||||
: assert(!text.contains('\n')), |
||||
super.val(text); |
||||
|
||||
@override |
||||
Node newInstance() => Text(); |
||||
|
||||
@override |
||||
String get value => _value as String; |
||||
|
||||
@override |
||||
String toPlainText() => value; |
||||
} |
||||
|
||||
/// An embed node inside of a line in a Quill document. |
||||
/// |
||||
/// Embed node is a leaf node similar to [Text]. It represents an arbitrary |
||||
/// piece of non-textual content embedded into a document, such as, image, |
||||
/// horizontal rule, video, or any other object with defined structure, |
||||
/// like a tweet, for instance. |
||||
/// |
||||
/// Embed node's length is always `1` character and it is represented with |
||||
/// unicode object replacement character in the document text. |
||||
/// |
||||
/// Any inline style can be applied to an embed, however this does not |
||||
/// necessarily mean the embed will look according to that style. For instance, |
||||
/// applying "bold" style to an image gives no effect, while adding a "link" to |
||||
/// an image actually makes the image react to user's action. |
||||
class Embed extends Leaf { |
||||
Embed(Embeddable data) : super.val(data); |
||||
|
||||
static const kObjectReplacementCharacter = '\uFFFC'; |
||||
|
||||
@override |
||||
Node newInstance() => throw UnimplementedError(); |
||||
|
||||
@override |
||||
Embeddable get value => super.value as Embeddable; |
||||
|
||||
/// // Embed nodes are represented as unicode object replacement character in |
||||
// plain text. |
||||
@override |
||||
String toPlainText() => kObjectReplacementCharacter; |
||||
} |
||||
/// TODO: Remove this file in the next breaking release, because implementation |
||||
/// files should be located in the src folder, https://bit.ly/3fA23Yz. |
||||
export '../../../src/models/documents/nodes/leaf.dart'; |
||||
|
@ -1,371 +1,3 @@ |
||||
import 'dart:math' as math; |
||||
|
||||
import 'package:collection/collection.dart'; |
||||
|
||||
import '../../quill_delta.dart'; |
||||
import '../attribute.dart'; |
||||
import '../style.dart'; |
||||
import 'block.dart'; |
||||
import 'container.dart'; |
||||
import 'embed.dart'; |
||||
import 'leaf.dart'; |
||||
import 'node.dart'; |
||||
|
||||
/// A line of rich text in a Quill document. |
||||
/// |
||||
/// Line serves as a container for [Leaf]s, like [Text] and [Embed]. |
||||
/// |
||||
/// When a line contains an embed, it fully occupies the line, no other embeds |
||||
/// or text nodes are allowed. |
||||
class Line extends Container<Leaf?> { |
||||
@override |
||||
Leaf get defaultChild => Text(); |
||||
|
||||
@override |
||||
int get length => super.length + 1; |
||||
|
||||
/// Returns `true` if this line contains an embedded object. |
||||
bool get hasEmbed { |
||||
if (childCount != 1) { |
||||
return false; |
||||
} |
||||
|
||||
return children.single is Embed; |
||||
} |
||||
|
||||
/// Returns next [Line] or `null` if this is the last line in the document. |
||||
Line? get nextLine { |
||||
if (!isLast) { |
||||
return next is Block ? (next as Block).first as Line? : next as Line?; |
||||
} |
||||
if (parent is! Block) { |
||||
return null; |
||||
} |
||||
|
||||
if (parent!.isLast) { |
||||
return null; |
||||
} |
||||
return parent!.next is Block |
||||
? (parent!.next as Block).first as Line? |
||||
: parent!.next as Line?; |
||||
} |
||||
|
||||
@override |
||||
Node newInstance() => Line(); |
||||
|
||||
@override |
||||
Delta toDelta() { |
||||
final delta = children |
||||
.map((child) => child.toDelta()) |
||||
.fold(Delta(), (dynamic a, b) => a.concat(b)); |
||||
var attributes = style; |
||||
if (parent is Block) { |
||||
final block = parent as Block; |
||||
attributes = attributes.mergeAll(block.style); |
||||
} |
||||
delta.insert('\n', attributes.toJson()); |
||||
return delta; |
||||
} |
||||
|
||||
@override |
||||
String toPlainText() => '${super.toPlainText()}\n'; |
||||
|
||||
@override |
||||
String toString() { |
||||
final body = children.join(' → '); |
||||
final styleString = style.isNotEmpty ? ' $style' : ''; |
||||
return '¶ $body ⏎$styleString'; |
||||
} |
||||
|
||||
@override |
||||
void insert(int index, Object data, Style? style) { |
||||
if (data is Embeddable) { |
||||
// We do not check whether this line already has any children here as |
||||
// inserting an embed into a line with other text is acceptable from the |
||||
// Delta format perspective. |
||||
// We rely on heuristic rules to ensure that embeds occupy an entire line. |
||||
_insertSafe(index, data, style); |
||||
return; |
||||
} |
||||
|
||||
final text = data as String; |
||||
final lineBreak = text.indexOf('\n'); |
||||
if (lineBreak < 0) { |
||||
_insertSafe(index, text, style); |
||||
// No need to update line or block format since those attributes can only |
||||
// be attached to `\n` character and we already know it's not present. |
||||
return; |
||||
} |
||||
|
||||
final prefix = text.substring(0, lineBreak); |
||||
_insertSafe(index, prefix, style); |
||||
if (prefix.isNotEmpty) { |
||||
index += prefix.length; |
||||
} |
||||
|
||||
// Next line inherits our format. |
||||
final nextLine = _getNextLine(index); |
||||
|
||||
// Reset our format and unwrap from a block if needed. |
||||
clearStyle(); |
||||
if (parent is Block) { |
||||
_unwrap(); |
||||
} |
||||
|
||||
// Now we can apply new format and re-layout. |
||||
_format(style); |
||||
|
||||
// Continue with remaining part. |
||||
final remain = text.substring(lineBreak + 1); |
||||
nextLine.insert(0, remain, style); |
||||
} |
||||
|
||||
@override |
||||
void retain(int index, int? len, Style? style) { |
||||
if (style == null) { |
||||
return; |
||||
} |
||||
final thisLength = length; |
||||
|
||||
final local = math.min(thisLength - index, len!); |
||||
// If index is at newline character then this is a line/block style update. |
||||
final isLineFormat = (index + local == thisLength) && local == 1; |
||||
|
||||
if (isLineFormat) { |
||||
assert(style.values.every((attr) => attr.scope == AttributeScope.BLOCK), |
||||
'It is not allowed to apply inline attributes to line itself.'); |
||||
_format(style); |
||||
} else { |
||||
// Otherwise forward to children as it's an inline format update. |
||||
assert(style.values.every((attr) => attr.scope == AttributeScope.INLINE)); |
||||
assert(index + local != thisLength); |
||||
super.retain(index, local, style); |
||||
} |
||||
|
||||
final remain = len - local; |
||||
if (remain > 0) { |
||||
assert(nextLine != null); |
||||
nextLine!.retain(0, remain, style); |
||||
} |
||||
} |
||||
|
||||
@override |
||||
void delete(int index, int? len) { |
||||
final local = math.min(length - index, len!); |
||||
final isLFDeleted = index + local == length; // Line feed |
||||
if (isLFDeleted) { |
||||
// Our newline character deleted with all style information. |
||||
clearStyle(); |
||||
if (local > 1) { |
||||
// Exclude newline character from delete range for children. |
||||
super.delete(index, local - 1); |
||||
} |
||||
} else { |
||||
super.delete(index, local); |
||||
} |
||||
|
||||
final remaining = len - local; |
||||
if (remaining > 0) { |
||||
assert(nextLine != null); |
||||
nextLine!.delete(0, remaining); |
||||
} |
||||
|
||||
if (isLFDeleted && isNotEmpty) { |
||||
// Since we lost our line-break and still have child text nodes those must |
||||
// migrate to the next line. |
||||
|
||||
// nextLine might have been unmounted since last assert so we need to |
||||
// check again we still have a line after us. |
||||
assert(nextLine != null); |
||||
|
||||
// Move remaining children in this line to the next line so that all |
||||
// attributes of nextLine are preserved. |
||||
nextLine!.moveChildToNewParent(this); |
||||
moveChildToNewParent(nextLine); |
||||
} |
||||
|
||||
if (isLFDeleted) { |
||||
// Now we can remove this line. |
||||
final block = parent!; // remember reference before un-linking. |
||||
unlink(); |
||||
block.adjust(); |
||||
} |
||||
} |
||||
|
||||
/// Formats this line. |
||||
void _format(Style? newStyle) { |
||||
if (newStyle == null || newStyle.isEmpty) { |
||||
return; |
||||
} |
||||
|
||||
applyStyle(newStyle); |
||||
final blockStyle = newStyle.getBlockExceptHeader(); |
||||
if (blockStyle == null) { |
||||
return; |
||||
} // No block-level changes |
||||
|
||||
if (parent is Block) { |
||||
final parentStyle = (parent as Block).style.getBlocksExceptHeader(); |
||||
if (blockStyle.value == null) { |
||||
_unwrap(); |
||||
} else if (!const MapEquality() |
||||
.equals(newStyle.getBlocksExceptHeader(), parentStyle)) { |
||||
_unwrap(); |
||||
_applyBlockStyles(newStyle); |
||||
} // else the same style, no-op. |
||||
} else if (blockStyle.value != null) { |
||||
// Only wrap with a new block if this is not an unset |
||||
_applyBlockStyles(newStyle); |
||||
} |
||||
} |
||||
|
||||
void _applyBlockStyles(Style newStyle) { |
||||
var block = Block(); |
||||
for (final style in newStyle.getBlocksExceptHeader().values) { |
||||
block = block..applyAttribute(style); |
||||
} |
||||
_wrap(block); |
||||
block.adjust(); |
||||
} |
||||
|
||||
/// Wraps this line with new parent [block]. |
||||
/// |
||||
/// This line can not be in a [Block] when this method is called. |
||||
void _wrap(Block block) { |
||||
assert(parent != null && parent is! Block); |
||||
insertAfter(block); |
||||
unlink(); |
||||
block.add(this); |
||||
} |
||||
|
||||
/// Unwraps this line from it's parent [Block]. |
||||
/// |
||||
/// This method asserts if current [parent] of this line is not a [Block]. |
||||
void _unwrap() { |
||||
if (parent is! Block) { |
||||
throw ArgumentError('Invalid parent'); |
||||
} |
||||
final block = parent as Block; |
||||
|
||||
assert(block.children.contains(this)); |
||||
|
||||
if (isFirst) { |
||||
unlink(); |
||||
block.insertBefore(this); |
||||
} else if (isLast) { |
||||
unlink(); |
||||
block.insertAfter(this); |
||||
} else { |
||||
final before = block.clone() as Block; |
||||
block.insertBefore(before); |
||||
|
||||
var child = block.first as Line; |
||||
while (child != this) { |
||||
child.unlink(); |
||||
before.add(child); |
||||
child = block.first as Line; |
||||
} |
||||
unlink(); |
||||
block.insertBefore(this); |
||||
} |
||||
block.adjust(); |
||||
} |
||||
|
||||
Line _getNextLine(int index) { |
||||
assert(index == 0 || (index > 0 && index < length)); |
||||
|
||||
final line = clone() as Line; |
||||
insertAfter(line); |
||||
if (index == length - 1) { |
||||
return line; |
||||
} |
||||
|
||||
final query = queryChild(index, false); |
||||
while (!query.node!.isLast) { |
||||
final next = (last as Leaf)..unlink(); |
||||
line.addFirst(next); |
||||
} |
||||
final child = query.node as Leaf; |
||||
final cut = child.splitAt(query.offset); |
||||
cut?.unlink(); |
||||
line.addFirst(cut); |
||||
return line; |
||||
} |
||||
|
||||
void _insertSafe(int index, Object data, Style? style) { |
||||
assert(index == 0 || (index > 0 && index < length)); |
||||
|
||||
if (data is String) { |
||||
assert(!data.contains('\n')); |
||||
if (data.isEmpty) { |
||||
return; |
||||
} |
||||
} |
||||
|
||||
if (isEmpty) { |
||||
final child = Leaf(data); |
||||
add(child); |
||||
child.format(style); |
||||
} else { |
||||
final result = queryChild(index, true); |
||||
result.node!.insert(result.offset, data, style); |
||||
} |
||||
} |
||||
|
||||
/// Returns style for specified text range. |
||||
/// |
||||
/// Only attributes applied to all characters within this range are |
||||
/// included in the result. Inline and line level attributes are |
||||
/// handled separately, e.g.: |
||||
/// |
||||
/// - line attribute X is included in the result only if it exists for |
||||
/// every line within this range (partially included lines are counted). |
||||
/// - inline attribute X is included in the result only if it exists |
||||
/// for every character within this range (line-break characters excluded). |
||||
Style collectStyle(int offset, int len) { |
||||
final local = math.min(length - offset, len); |
||||
var result = Style(); |
||||
final excluded = <Attribute>{}; |
||||
|
||||
void _handle(Style style) { |
||||
if (result.isEmpty) { |
||||
excluded.addAll(style.values); |
||||
} else { |
||||
for (final attr in result.values) { |
||||
if (!style.containsKey(attr.key)) { |
||||
excluded.add(attr); |
||||
} |
||||
} |
||||
} |
||||
final remaining = style.removeAll(excluded); |
||||
result = result.removeAll(excluded); |
||||
result = result.mergeAll(remaining); |
||||
} |
||||
|
||||
final data = queryChild(offset, true); |
||||
var node = data.node as Leaf?; |
||||
if (node != null) { |
||||
result = result.mergeAll(node.style); |
||||
var pos = node.length - data.offset; |
||||
while (!node!.isLast && pos < local) { |
||||
node = node.next as Leaf?; |
||||
_handle(node!.style); |
||||
pos += node.length; |
||||
} |
||||
} |
||||
|
||||
result = result.mergeAll(style); |
||||
if (parent is Block) { |
||||
final block = parent as Block; |
||||
result = result.mergeAll(block.style); |
||||
} |
||||
|
||||
final remaining = len - local; |
||||
if (remaining > 0) { |
||||
final rest = nextLine!.collectStyle(0, remaining); |
||||
_handle(rest); |
||||
} |
||||
|
||||
return result; |
||||
} |
||||
} |
||||
/// TODO: Remove this file in the next breaking release, because implementation |
||||
/// files should be located in the src folder, https://bit.ly/3fA23Yz. |
||||
export '../../../src/models/documents/nodes/line.dart'; |
||||
|
@ -1,131 +1,3 @@ |
||||
import 'dart:collection'; |
||||
|
||||
import '../../quill_delta.dart'; |
||||
import '../attribute.dart'; |
||||
import '../style.dart'; |
||||
import 'container.dart'; |
||||
import 'line.dart'; |
||||
|
||||
/// An abstract node in a document tree. |
||||
/// |
||||
/// Represents a segment of a Quill document with specified [offset] |
||||
/// and [length]. |
||||
/// |
||||
/// The [offset] property is relative to [parent]. See also [documentOffset] |
||||
/// which provides absolute offset of this node within the document. |
||||
/// |
||||
/// The current parent node is exposed by the [parent] property. |
||||
abstract class Node extends LinkedListEntry<Node> { |
||||
/// Current parent of this node. May be null if this node is not mounted. |
||||
Container? parent; |
||||
|
||||
Style get style => _style; |
||||
Style _style = Style(); |
||||
|
||||
/// Returns `true` if this node is the first node in the [parent] list. |
||||
bool get isFirst => list!.first == this; |
||||
|
||||
/// Returns `true` if this node is the last node in the [parent] list. |
||||
bool get isLast => list!.last == this; |
||||
|
||||
/// Length of this node in characters. |
||||
int get length; |
||||
|
||||
Node clone() => newInstance()..applyStyle(style); |
||||
|
||||
/// Offset in characters of this node relative to [parent] node. |
||||
/// |
||||
/// To get offset of this node in the document see [documentOffset]. |
||||
int get offset { |
||||
var offset = 0; |
||||
|
||||
if (list == null || isFirst) { |
||||
return offset; |
||||
} |
||||
|
||||
var cur = this; |
||||
do { |
||||
cur = cur.previous!; |
||||
offset += cur.length; |
||||
} while (!cur.isFirst); |
||||
return offset; |
||||
} |
||||
|
||||
/// Offset in characters of this node in the document. |
||||
int get documentOffset { |
||||
final parentOffset = (parent is! Root) ? parent!.documentOffset : 0; |
||||
return parentOffset + offset; |
||||
} |
||||
|
||||
/// Returns `true` if this node contains character at specified [offset] in |
||||
/// the document. |
||||
bool containsOffset(int offset) { |
||||
final o = documentOffset; |
||||
return o <= offset && offset < o + length; |
||||
} |
||||
|
||||
void applyAttribute(Attribute attribute) { |
||||
_style = _style.merge(attribute); |
||||
} |
||||
|
||||
void applyStyle(Style value) { |
||||
_style = _style.mergeAll(value); |
||||
} |
||||
|
||||
void clearStyle() { |
||||
_style = Style(); |
||||
} |
||||
|
||||
@override |
||||
void insertBefore(Node entry) { |
||||
assert(entry.parent == null && parent != null); |
||||
entry.parent = parent; |
||||
super.insertBefore(entry); |
||||
} |
||||
|
||||
@override |
||||
void insertAfter(Node entry) { |
||||
assert(entry.parent == null && parent != null); |
||||
entry.parent = parent; |
||||
super.insertAfter(entry); |
||||
} |
||||
|
||||
@override |
||||
void unlink() { |
||||
assert(parent != null); |
||||
parent = null; |
||||
super.unlink(); |
||||
} |
||||
|
||||
void adjust() {/* no-op */} |
||||
|
||||
/// abstract methods begin |
||||
|
||||
Node newInstance(); |
||||
|
||||
String toPlainText(); |
||||
|
||||
Delta toDelta(); |
||||
|
||||
void insert(int index, Object data, Style? style); |
||||
|
||||
void retain(int index, int? len, Style? style); |
||||
|
||||
void delete(int index, int? len); |
||||
|
||||
/// abstract methods end |
||||
} |
||||
|
||||
/// Root node of document tree. |
||||
class Root extends Container<Container<Node?>> { |
||||
@override |
||||
Node newInstance() => Root(); |
||||
|
||||
@override |
||||
Container<Node?> get defaultChild => Line(); |
||||
|
||||
@override |
||||
Delta toDelta() => children |
||||
.map((child) => child.toDelta()) |
||||
.fold(Delta(), (a, b) => a.concat(b)); |
||||
} |
||||
/// TODO: Remove this file in the next breaking release, because implementation |
||||
/// files should be located in the src folder, https://bit.ly/3fA23Yz. |
||||
export '../../../src/models/documents/nodes/node.dart'; |
||||
|
@ -1,127 +1,3 @@ |
||||
import 'package:collection/collection.dart'; |
||||
import 'package:quiver/core.dart'; |
||||
|
||||
import 'attribute.dart'; |
||||
|
||||
/* Collection of style attributes */ |
||||
class Style { |
||||
Style() : _attributes = <String, Attribute>{}; |
||||
|
||||
Style.attr(this._attributes); |
||||
|
||||
final Map<String, Attribute> _attributes; |
||||
|
||||
static Style fromJson(Map<String, dynamic>? attributes) { |
||||
if (attributes == null) { |
||||
return Style(); |
||||
} |
||||
|
||||
final result = attributes.map((key, dynamic value) { |
||||
final attr = Attribute.fromKeyValue(key, value); |
||||
return MapEntry<String, Attribute>(key, attr); |
||||
}); |
||||
return Style.attr(result); |
||||
} |
||||
|
||||
Map<String, dynamic>? toJson() => _attributes.isEmpty |
||||
? null |
||||
: _attributes.map<String, dynamic>((_, attribute) => |
||||
MapEntry<String, dynamic>(attribute.key, attribute.value)); |
||||
|
||||
Iterable<String> get keys => _attributes.keys; |
||||
|
||||
Iterable<Attribute> get values => _attributes.values.sorted( |
||||
(a, b) => Attribute.getRegistryOrder(a) - Attribute.getRegistryOrder(b)); |
||||
|
||||
Map<String, Attribute> get attributes => _attributes; |
||||
|
||||
bool get isEmpty => _attributes.isEmpty; |
||||
|
||||
bool get isNotEmpty => _attributes.isNotEmpty; |
||||
|
||||
bool get isInline => isNotEmpty && values.every((item) => item.isInline); |
||||
|
||||
bool get isIgnored => |
||||
isNotEmpty && values.every((item) => item.scope == AttributeScope.IGNORE); |
||||
|
||||
Attribute get single => _attributes.values.single; |
||||
|
||||
bool containsKey(String key) => _attributes.containsKey(key); |
||||
|
||||
Attribute? getBlockExceptHeader() { |
||||
for (final val in values) { |
||||
if (val.isBlockExceptHeader && val.value != null) { |
||||
return val; |
||||
} |
||||
} |
||||
for (final val in values) { |
||||
if (val.isBlockExceptHeader) { |
||||
return val; |
||||
} |
||||
} |
||||
return null; |
||||
} |
||||
|
||||
Map<String, Attribute> getBlocksExceptHeader() { |
||||
final m = <String, Attribute>{}; |
||||
attributes.forEach((key, value) { |
||||
if (Attribute.blockKeysExceptHeader.contains(key)) { |
||||
m[key] = value; |
||||
} |
||||
}); |
||||
return m; |
||||
} |
||||
|
||||
Style merge(Attribute attribute) { |
||||
final merged = Map<String, Attribute>.from(_attributes); |
||||
if (attribute.value == null) { |
||||
merged.remove(attribute.key); |
||||
} else { |
||||
merged[attribute.key] = attribute; |
||||
} |
||||
return Style.attr(merged); |
||||
} |
||||
|
||||
Style mergeAll(Style other) { |
||||
var result = Style.attr(_attributes); |
||||
for (final attribute in other.values) { |
||||
result = result.merge(attribute); |
||||
} |
||||
return result; |
||||
} |
||||
|
||||
Style removeAll(Set<Attribute> attributes) { |
||||
final merged = Map<String, Attribute>.from(_attributes); |
||||
attributes.map((item) => item.key).forEach(merged.remove); |
||||
return Style.attr(merged); |
||||
} |
||||
|
||||
Style put(Attribute attribute) { |
||||
final m = Map<String, Attribute>.from(attributes); |
||||
m[attribute.key] = attribute; |
||||
return Style.attr(m); |
||||
} |
||||
|
||||
@override |
||||
bool operator ==(Object other) { |
||||
if (identical(this, other)) { |
||||
return true; |
||||
} |
||||
if (other is! Style) { |
||||
return false; |
||||
} |
||||
final typedOther = other; |
||||
const eq = MapEquality<String, Attribute>(); |
||||
return eq.equals(_attributes, typedOther._attributes); |
||||
} |
||||
|
||||
@override |
||||
int get hashCode { |
||||
final hashes = |
||||
_attributes.entries.map((entry) => hash2(entry.key, entry.value)); |
||||
return hashObjects(hashes); |
||||
} |
||||
|
||||
@override |
||||
String toString() => "{${_attributes.values.join(', ')}}"; |
||||
} |
||||
/// TODO: Remove this file in the next breaking release, because implementation |
||||
/// files should be located in the src folder, https://bit.ly/3fA23Yz. |
||||
export '../../../src/models/documents/style.dart'; |
||||
|
@ -1,684 +1,3 @@ |
||||
// Copyright (c) 2018, Anatoly Pulyaevskiy. All rights reserved. Use of this source code |
||||
// is governed by a BSD-style license that can be found in the LICENSE file. |
||||
|
||||
/// Implementation of Quill Delta format in Dart. |
||||
library quill_delta; |
||||
|
||||
import 'dart:math' as math; |
||||
|
||||
import 'package:collection/collection.dart'; |
||||
import 'package:quiver/core.dart'; |
||||
|
||||
const _attributeEquality = DeepCollectionEquality(); |
||||
const _valueEquality = DeepCollectionEquality(); |
||||
|
||||
/// Decoder function to convert raw `data` object into a user-defined data type. |
||||
/// |
||||
/// Useful with embedded content. |
||||
typedef DataDecoder = Object? Function(Object data); |
||||
|
||||
/// Default data decoder which simply passes through the original value. |
||||
Object? _passThroughDataDecoder(Object? data) => data; |
||||
|
||||
/// Operation performed on a rich-text document. |
||||
class Operation { |
||||
Operation._(this.key, this.length, this.data, Map? attributes) |
||||
: assert(_validKeys.contains(key), 'Invalid operation key "$key".'), |
||||
assert(() { |
||||
if (key != Operation.insertKey) return true; |
||||
return data is String ? data.length == length : length == 1; |
||||
}(), 'Length of insert operation must be equal to the data length.'), |
||||
_attributes = |
||||
attributes != null ? Map<String, dynamic>.from(attributes) : null; |
||||
|
||||
/// Creates operation which deletes [length] of characters. |
||||
factory Operation.delete(int length) => |
||||
Operation._(Operation.deleteKey, length, '', null); |
||||
|
||||
/// Creates operation which inserts [text] with optional [attributes]. |
||||
factory Operation.insert(dynamic data, [Map<String, dynamic>? attributes]) => |
||||
Operation._(Operation.insertKey, data is String ? data.length : 1, data, |
||||
attributes); |
||||
|
||||
/// Creates operation which retains [length] of characters and optionally |
||||
/// applies attributes. |
||||
factory Operation.retain(int? length, [Map<String, dynamic>? attributes]) => |
||||
Operation._(Operation.retainKey, length, '', attributes); |
||||
|
||||
/// Key of insert operations. |
||||
static const String insertKey = 'insert'; |
||||
|
||||
/// Key of delete operations. |
||||
static const String deleteKey = 'delete'; |
||||
|
||||
/// Key of retain operations. |
||||
static const String retainKey = 'retain'; |
||||
|
||||
/// Key of attributes collection. |
||||
static const String attributesKey = 'attributes'; |
||||
|
||||
static const List<String> _validKeys = [insertKey, deleteKey, retainKey]; |
||||
|
||||
/// Key of this operation, can be "insert", "delete" or "retain". |
||||
final String key; |
||||
|
||||
/// Length of this operation. |
||||
final int? length; |
||||
|
||||
/// Payload of "insert" operation, for other types is set to empty string. |
||||
final Object? data; |
||||
|
||||
/// Rich-text attributes set by this operation, can be `null`. |
||||
Map<String, dynamic>? get attributes => |
||||
_attributes == null ? null : Map<String, dynamic>.from(_attributes!); |
||||
final Map<String, dynamic>? _attributes; |
||||
|
||||
/// Creates new [Operation] from JSON payload. |
||||
/// |
||||
/// If `dataDecoder` parameter is not null then it is used to additionally |
||||
/// decode the operation's data object. Only applied to insert operations. |
||||
static Operation fromJson(Map data, {DataDecoder? dataDecoder}) { |
||||
dataDecoder ??= _passThroughDataDecoder; |
||||
final map = Map<String, dynamic>.from(data); |
||||
if (map.containsKey(Operation.insertKey)) { |
||||
final data = dataDecoder(map[Operation.insertKey]); |
||||
final dataLength = data is String ? data.length : 1; |
||||
return Operation._( |
||||
Operation.insertKey, dataLength, data, map[Operation.attributesKey]); |
||||
} else if (map.containsKey(Operation.deleteKey)) { |
||||
final int? length = map[Operation.deleteKey]; |
||||
return Operation._(Operation.deleteKey, length, '', null); |
||||
} else if (map.containsKey(Operation.retainKey)) { |
||||
final int? length = map[Operation.retainKey]; |
||||
return Operation._( |
||||
Operation.retainKey, length, '', map[Operation.attributesKey]); |
||||
} |
||||
throw ArgumentError.value(data, 'Invalid data for Delta operation.'); |
||||
} |
||||
|
||||
/// Returns JSON-serializable representation of this operation. |
||||
Map<String, dynamic> toJson() { |
||||
final json = {key: value}; |
||||
if (_attributes != null) json[Operation.attributesKey] = attributes; |
||||
return json; |
||||
} |
||||
|
||||
/// Returns value of this operation. |
||||
/// |
||||
/// For insert operations this returns text, for delete and retain - length. |
||||
dynamic get value => (key == Operation.insertKey) ? data : length; |
||||
|
||||
/// Returns `true` if this is a delete operation. |
||||
bool get isDelete => key == Operation.deleteKey; |
||||
|
||||
/// Returns `true` if this is an insert operation. |
||||
bool get isInsert => key == Operation.insertKey; |
||||
|
||||
/// Returns `true` if this is a retain operation. |
||||
bool get isRetain => key == Operation.retainKey; |
||||
|
||||
/// Returns `true` if this operation has no attributes, e.g. is plain text. |
||||
bool get isPlain => _attributes == null || _attributes!.isEmpty; |
||||
|
||||
/// Returns `true` if this operation sets at least one attribute. |
||||
bool get isNotPlain => !isPlain; |
||||
|
||||
/// Returns `true` is this operation is empty. |
||||
/// |
||||
/// An operation is considered empty if its [length] is equal to `0`. |
||||
bool get isEmpty => length == 0; |
||||
|
||||
/// Returns `true` is this operation is not empty. |
||||
bool get isNotEmpty => length! > 0; |
||||
|
||||
@override |
||||
bool operator ==(other) { |
||||
if (identical(this, other)) return true; |
||||
if (other is! Operation) return false; |
||||
final typedOther = other; |
||||
return key == typedOther.key && |
||||
length == typedOther.length && |
||||
_valueEquality.equals(data, typedOther.data) && |
||||
hasSameAttributes(typedOther); |
||||
} |
||||
|
||||
/// Returns `true` if this operation has attribute specified by [name]. |
||||
bool hasAttribute(String name) => |
||||
isNotPlain && _attributes!.containsKey(name); |
||||
|
||||
/// Returns `true` if [other] operation has the same attributes as this one. |
||||
bool hasSameAttributes(Operation other) { |
||||
return _attributeEquality.equals(_attributes, other._attributes); |
||||
} |
||||
|
||||
@override |
||||
int get hashCode { |
||||
if (_attributes != null && _attributes!.isNotEmpty) { |
||||
final attrsHash = |
||||
hashObjects(_attributes!.entries.map((e) => hash2(e.key, e.value))); |
||||
return hash3(key, value, attrsHash); |
||||
} |
||||
return hash2(key, value); |
||||
} |
||||
|
||||
@override |
||||
String toString() { |
||||
final attr = attributes == null ? '' : ' + $attributes'; |
||||
final text = isInsert |
||||
? (data is String |
||||
? (data as String).replaceAll('\n', '⏎') |
||||
: data.toString()) |
||||
: '$length'; |
||||
return '$key⟨ $text ⟩$attr'; |
||||
} |
||||
} |
||||
|
||||
/// Delta represents a document or a modification of a document as a sequence of |
||||
/// insert, delete and retain operations. |
||||
/// |
||||
/// Delta consisting of only "insert" operations is usually referred to as |
||||
/// "document delta". When delta includes also "retain" or "delete" operations |
||||
/// it is a "change delta". |
||||
class Delta { |
||||
/// Creates new empty [Delta]. |
||||
factory Delta() => Delta._(<Operation>[]); |
||||
|
||||
Delta._(List<Operation> operations) : _operations = operations; |
||||
|
||||
/// Creates new [Delta] from [other]. |
||||
factory Delta.from(Delta other) => |
||||
Delta._(List<Operation>.from(other._operations)); |
||||
|
||||
/// Transforms two attribute sets. |
||||
static Map<String, dynamic>? transformAttributes( |
||||
Map<String, dynamic>? a, Map<String, dynamic>? b, bool priority) { |
||||
if (a == null) return b; |
||||
if (b == null) return null; |
||||
|
||||
if (!priority) return b; |
||||
|
||||
final result = b.keys.fold<Map<String, dynamic>>({}, (attributes, key) { |
||||
if (!a.containsKey(key)) attributes[key] = b[key]; |
||||
return attributes; |
||||
}); |
||||
|
||||
return result.isEmpty ? null : result; |
||||
} |
||||
|
||||
/// Composes two attribute sets. |
||||
static Map<String, dynamic>? composeAttributes( |
||||
Map<String, dynamic>? a, Map<String, dynamic>? b, |
||||
{bool keepNull = false}) { |
||||
a ??= const {}; |
||||
b ??= const {}; |
||||
|
||||
final result = Map<String, dynamic>.from(a)..addAll(b); |
||||
final keys = result.keys.toList(growable: false); |
||||
|
||||
if (!keepNull) { |
||||
for (final key in keys) { |
||||
if (result[key] == null) result.remove(key); |
||||
} |
||||
} |
||||
|
||||
return result.isEmpty ? null : result; |
||||
} |
||||
|
||||
///get anti-attr result base on base |
||||
static Map<String, dynamic> invertAttributes( |
||||
Map<String, dynamic>? attr, Map<String, dynamic>? base) { |
||||
attr ??= const {}; |
||||
base ??= const {}; |
||||
|
||||
final baseInverted = base.keys.fold({}, (dynamic memo, key) { |
||||
if (base![key] != attr![key] && attr.containsKey(key)) { |
||||
memo[key] = base[key]; |
||||
} |
||||
return memo; |
||||
}); |
||||
|
||||
final inverted = |
||||
Map<String, dynamic>.from(attr.keys.fold(baseInverted, (memo, key) { |
||||
if (base![key] != attr![key] && !base.containsKey(key)) { |
||||
memo[key] = null; |
||||
} |
||||
return memo; |
||||
})); |
||||
return inverted; |
||||
} |
||||
|
||||
final List<Operation> _operations; |
||||
|
||||
int _modificationCount = 0; |
||||
|
||||
/// Creates [Delta] from de-serialized JSON representation. |
||||
/// |
||||
/// If `dataDecoder` parameter is not null then it is used to additionally |
||||
/// decode the operation's data object. Only applied to insert operations. |
||||
static Delta fromJson(List data, {DataDecoder? dataDecoder}) { |
||||
return Delta._(data |
||||
.map((op) => Operation.fromJson(op, dataDecoder: dataDecoder)) |
||||
.toList()); |
||||
} |
||||
|
||||
/// Returns list of operations in this delta. |
||||
List<Operation> toList() => List.from(_operations); |
||||
|
||||
/// Returns JSON-serializable version of this delta. |
||||
List toJson() => toList().map((operation) => operation.toJson()).toList(); |
||||
|
||||
/// Returns `true` if this delta is empty. |
||||
bool get isEmpty => _operations.isEmpty; |
||||
|
||||
/// Returns `true` if this delta is not empty. |
||||
bool get isNotEmpty => _operations.isNotEmpty; |
||||
|
||||
/// Returns number of operations in this delta. |
||||
int get length => _operations.length; |
||||
|
||||
/// Returns [Operation] at specified [index] in this delta. |
||||
Operation operator [](int index) => _operations[index]; |
||||
|
||||
/// Returns [Operation] at specified [index] in this delta. |
||||
Operation elementAt(int index) => _operations.elementAt(index); |
||||
|
||||
/// Returns the first [Operation] in this delta. |
||||
Operation get first => _operations.first; |
||||
|
||||
/// Returns the last [Operation] in this delta. |
||||
Operation get last => _operations.last; |
||||
|
||||
@override |
||||
bool operator ==(dynamic other) { |
||||
if (identical(this, other)) return true; |
||||
if (other is! Delta) return false; |
||||
final typedOther = other; |
||||
const comparator = ListEquality<Operation>(DefaultEquality<Operation>()); |
||||
return comparator.equals(_operations, typedOther._operations); |
||||
} |
||||
|
||||
@override |
||||
int get hashCode => hashObjects(_operations); |
||||
|
||||
/// Retain [count] of characters from current position. |
||||
void retain(int count, [Map<String, dynamic>? attributes]) { |
||||
assert(count >= 0); |
||||
if (count == 0) return; // no-op |
||||
push(Operation.retain(count, attributes)); |
||||
} |
||||
|
||||
/// Insert [data] at current position. |
||||
void insert(dynamic data, [Map<String, dynamic>? attributes]) { |
||||
if (data is String && data.isEmpty) return; // no-op |
||||
push(Operation.insert(data, attributes)); |
||||
} |
||||
|
||||
/// Delete [count] characters from current position. |
||||
void delete(int count) { |
||||
assert(count >= 0); |
||||
if (count == 0) return; |
||||
push(Operation.delete(count)); |
||||
} |
||||
|
||||
void _mergeWithTail(Operation operation) { |
||||
assert(isNotEmpty); |
||||
assert(last.key == operation.key); |
||||
assert(operation.data is String && last.data is String); |
||||
|
||||
final length = operation.length! + last.length!; |
||||
final lastText = last.data as String; |
||||
final opText = operation.data as String; |
||||
final resultText = lastText + opText; |
||||
final index = _operations.length; |
||||
_operations.replaceRange(index - 1, index, [ |
||||
Operation._(operation.key, length, resultText, operation.attributes), |
||||
]); |
||||
} |
||||
|
||||
/// Pushes new operation into this delta. |
||||
/// |
||||
/// Performs compaction by composing [operation] with current tail operation |
||||
/// of this delta, when possible. For instance, if current tail is |
||||
/// `insert('abc')` and pushed operation is `insert('123')` then existing |
||||
/// tail is replaced with `insert('abc123')` - a compound result of the two |
||||
/// operations. |
||||
void push(Operation operation) { |
||||
if (operation.isEmpty) return; |
||||
|
||||
var index = _operations.length; |
||||
final lastOp = _operations.isNotEmpty ? _operations.last : null; |
||||
if (lastOp != null) { |
||||
if (lastOp.isDelete && operation.isDelete) { |
||||
_mergeWithTail(operation); |
||||
return; |
||||
} |
||||
|
||||
if (lastOp.isDelete && operation.isInsert) { |
||||
index -= 1; // Always insert before deleting |
||||
final nLastOp = (index > 0) ? _operations.elementAt(index - 1) : null; |
||||
if (nLastOp == null) { |
||||
_operations.insert(0, operation); |
||||
return; |
||||
} |
||||
} |
||||
|
||||
if (lastOp.isInsert && operation.isInsert) { |
||||
if (lastOp.hasSameAttributes(operation) && |
||||
operation.data is String && |
||||
lastOp.data is String) { |
||||
_mergeWithTail(operation); |
||||
return; |
||||
} |
||||
} |
||||
|
||||
if (lastOp.isRetain && operation.isRetain) { |
||||
if (lastOp.hasSameAttributes(operation)) { |
||||
_mergeWithTail(operation); |
||||
return; |
||||
} |
||||
} |
||||
} |
||||
if (index == _operations.length) { |
||||
_operations.add(operation); |
||||
} else { |
||||
final opAtIndex = _operations.elementAt(index); |
||||
_operations.replaceRange(index, index + 1, [operation, opAtIndex]); |
||||
} |
||||
_modificationCount++; |
||||
} |
||||
|
||||
/// Composes next operation from [thisIter] and [otherIter]. |
||||
/// |
||||
/// Returns new operation or `null` if operations from [thisIter] and |
||||
/// [otherIter] nullify each other. For instance, for the pair `insert('abc')` |
||||
/// and `delete(3)` composition result would be empty string. |
||||
Operation? _composeOperation( |
||||
DeltaIterator thisIter, DeltaIterator otherIter) { |
||||
if (otherIter.isNextInsert) return otherIter.next(); |
||||
if (thisIter.isNextDelete) return thisIter.next(); |
||||
|
||||
final length = math.min(thisIter.peekLength(), otherIter.peekLength()); |
||||
final thisOp = thisIter.next(length as int); |
||||
final otherOp = otherIter.next(length); |
||||
assert(thisOp.length == otherOp.length); |
||||
|
||||
if (otherOp.isRetain) { |
||||
final attributes = composeAttributes( |
||||
thisOp.attributes, |
||||
otherOp.attributes, |
||||
keepNull: thisOp.isRetain, |
||||
); |
||||
if (thisOp.isRetain) { |
||||
return Operation.retain(thisOp.length, attributes); |
||||
} else if (thisOp.isInsert) { |
||||
return Operation.insert(thisOp.data, attributes); |
||||
} else { |
||||
throw StateError('Unreachable'); |
||||
} |
||||
} else { |
||||
// otherOp == delete && thisOp in [retain, insert] |
||||
assert(otherOp.isDelete); |
||||
if (thisOp.isRetain) return otherOp; |
||||
assert(thisOp.isInsert); |
||||
// otherOp(delete) + thisOp(insert) => null |
||||
} |
||||
return null; |
||||
} |
||||
|
||||
/// Composes this delta with [other] and returns new [Delta]. |
||||
/// |
||||
/// It is not required for this and [other] delta to represent a document |
||||
/// delta (consisting only of insert operations). |
||||
Delta compose(Delta other) { |
||||
final result = Delta(); |
||||
final thisIter = DeltaIterator(this); |
||||
final otherIter = DeltaIterator(other); |
||||
|
||||
while (thisIter.hasNext || otherIter.hasNext) { |
||||
final newOp = _composeOperation(thisIter, otherIter); |
||||
if (newOp != null) result.push(newOp); |
||||
} |
||||
return result..trim(); |
||||
} |
||||
|
||||
/// Transforms next operation from [otherIter] against next operation in |
||||
/// [thisIter]. |
||||
/// |
||||
/// Returns `null` if both operations nullify each other. |
||||
Operation? _transformOperation( |
||||
DeltaIterator thisIter, DeltaIterator otherIter, bool priority) { |
||||
if (thisIter.isNextInsert && (priority || !otherIter.isNextInsert)) { |
||||
return Operation.retain(thisIter.next().length); |
||||
} else if (otherIter.isNextInsert) { |
||||
return otherIter.next(); |
||||
} |
||||
|
||||
final length = math.min(thisIter.peekLength(), otherIter.peekLength()); |
||||
final thisOp = thisIter.next(length as int); |
||||
final otherOp = otherIter.next(length); |
||||
assert(thisOp.length == otherOp.length); |
||||
|
||||
// At this point only delete and retain operations are possible. |
||||
if (thisOp.isDelete) { |
||||
// otherOp is either delete or retain, so they nullify each other. |
||||
return null; |
||||
} else if (otherOp.isDelete) { |
||||
return otherOp; |
||||
} else { |
||||
// Retain otherOp which is either retain or insert. |
||||
return Operation.retain( |
||||
length, |
||||
transformAttributes(thisOp.attributes, otherOp.attributes, priority), |
||||
); |
||||
} |
||||
} |
||||
|
||||
/// Transforms [other] delta against operations in this delta. |
||||
Delta transform(Delta other, bool priority) { |
||||
final result = Delta(); |
||||
final thisIter = DeltaIterator(this); |
||||
final otherIter = DeltaIterator(other); |
||||
|
||||
while (thisIter.hasNext || otherIter.hasNext) { |
||||
final newOp = _transformOperation(thisIter, otherIter, priority); |
||||
if (newOp != null) result.push(newOp); |
||||
} |
||||
return result..trim(); |
||||
} |
||||
|
||||
/// Removes trailing retain operation with empty attributes, if present. |
||||
void trim() { |
||||
if (isNotEmpty) { |
||||
final last = _operations.last; |
||||
if (last.isRetain && last.isPlain) _operations.removeLast(); |
||||
} |
||||
} |
||||
|
||||
/// Concatenates [other] with this delta and returns the result. |
||||
Delta concat(Delta other) { |
||||
final result = Delta.from(this); |
||||
if (other.isNotEmpty) { |
||||
// In case first operation of other can be merged with last operation in |
||||
// our list. |
||||
result.push(other._operations.first); |
||||
result._operations.addAll(other._operations.sublist(1)); |
||||
} |
||||
return result; |
||||
} |
||||
|
||||
/// Inverts this delta against [base]. |
||||
/// |
||||
/// Returns new delta which negates effect of this delta when applied to |
||||
/// [base]. This is an equivalent of "undo" operation on deltas. |
||||
Delta invert(Delta base) { |
||||
final inverted = Delta(); |
||||
if (base.isEmpty) return inverted; |
||||
|
||||
var baseIndex = 0; |
||||
for (final op in _operations) { |
||||
if (op.isInsert) { |
||||
inverted.delete(op.length!); |
||||
} else if (op.isRetain && op.isPlain) { |
||||
inverted.retain(op.length!); |
||||
baseIndex += op.length!; |
||||
} else if (op.isDelete || (op.isRetain && op.isNotPlain)) { |
||||
final length = op.length!; |
||||
final sliceDelta = base.slice(baseIndex, baseIndex + length); |
||||
sliceDelta.toList().forEach((baseOp) { |
||||
if (op.isDelete) { |
||||
inverted.push(baseOp); |
||||
} else if (op.isRetain && op.isNotPlain) { |
||||
final invertAttr = |
||||
invertAttributes(op.attributes, baseOp.attributes); |
||||
inverted.retain( |
||||
baseOp.length!, invertAttr.isEmpty ? null : invertAttr); |
||||
} |
||||
}); |
||||
baseIndex += length; |
||||
} else { |
||||
throw StateError('Unreachable'); |
||||
} |
||||
} |
||||
inverted.trim(); |
||||
return inverted; |
||||
} |
||||
|
||||
/// Returns slice of this delta from [start] index (inclusive) to [end] |
||||
/// (exclusive). |
||||
Delta slice(int start, [int? end]) { |
||||
final delta = Delta(); |
||||
var index = 0; |
||||
final opIterator = DeltaIterator(this); |
||||
|
||||
final actualEnd = end ?? double.infinity; |
||||
|
||||
while (index < actualEnd && opIterator.hasNext) { |
||||
Operation op; |
||||
if (index < start) { |
||||
op = opIterator.next(start - index); |
||||
} else { |
||||
op = opIterator.next(actualEnd - index as int); |
||||
delta.push(op); |
||||
} |
||||
index += op.length!; |
||||
} |
||||
return delta; |
||||
} |
||||
|
||||
/// Transforms [index] against this delta. |
||||
/// |
||||
/// Any "delete" operation before specified [index] shifts it backward, as |
||||
/// well as any "insert" operation shifts it forward. |
||||
/// |
||||
/// The [force] argument is used to resolve scenarios when there is an |
||||
/// insert operation at the same position as [index]. If [force] is set to |
||||
/// `true` (default) then position is forced to shift forward, otherwise |
||||
/// position stays at the same index. In other words setting [force] to |
||||
/// `false` gives higher priority to the transformed position. |
||||
/// |
||||
/// Useful to adjust caret or selection positions. |
||||
int transformPosition(int index, {bool force = true}) { |
||||
final iter = DeltaIterator(this); |
||||
var offset = 0; |
||||
while (iter.hasNext && offset <= index) { |
||||
final op = iter.next(); |
||||
if (op.isDelete) { |
||||
index -= math.min(op.length!, index - offset); |
||||
continue; |
||||
} else if (op.isInsert && (offset < index || force)) { |
||||
index += op.length!; |
||||
} |
||||
offset += op.length!; |
||||
} |
||||
return index; |
||||
} |
||||
|
||||
@override |
||||
String toString() => _operations.join('\n'); |
||||
} |
||||
|
||||
/// Specialized iterator for [Delta]s. |
||||
class DeltaIterator { |
||||
DeltaIterator(this.delta) : _modificationCount = delta._modificationCount; |
||||
|
||||
final Delta delta; |
||||
final int _modificationCount; |
||||
int _index = 0; |
||||
num _offset = 0; |
||||
|
||||
bool get isNextInsert => nextOperationKey == Operation.insertKey; |
||||
|
||||
bool get isNextDelete => nextOperationKey == Operation.deleteKey; |
||||
|
||||
bool get isNextRetain => nextOperationKey == Operation.retainKey; |
||||
|
||||
String? get nextOperationKey { |
||||
if (_index < delta.length) { |
||||
return delta.elementAt(_index).key; |
||||
} else { |
||||
return null; |
||||
} |
||||
} |
||||
|
||||
bool get hasNext => peekLength() < double.infinity; |
||||
|
||||
/// Returns length of next operation without consuming it. |
||||
/// |
||||
/// Returns [double.infinity] if there is no more operations left to iterate. |
||||
num peekLength() { |
||||
if (_index < delta.length) { |
||||
final operation = delta._operations[_index]; |
||||
return operation.length! - _offset; |
||||
} |
||||
return double.infinity; |
||||
} |
||||
|
||||
/// Consumes and returns next operation. |
||||
/// |
||||
/// Optional [length] specifies maximum length of operation to return. Note |
||||
/// that actual length of returned operation may be less than specified value. |
||||
Operation next([int length = 4294967296]) { |
||||
if (_modificationCount != delta._modificationCount) { |
||||
throw ConcurrentModificationError(delta); |
||||
} |
||||
|
||||
if (_index < delta.length) { |
||||
final op = delta.elementAt(_index); |
||||
final opKey = op.key; |
||||
final opAttributes = op.attributes; |
||||
final _currentOffset = _offset; |
||||
final actualLength = math.min(op.length! - _currentOffset, length); |
||||
if (actualLength == op.length! - _currentOffset) { |
||||
_index++; |
||||
_offset = 0; |
||||
} else { |
||||
_offset += actualLength; |
||||
} |
||||
final opData = op.isInsert && op.data is String |
||||
? (op.data as String).substring( |
||||
_currentOffset as int, _currentOffset + (actualLength as int)) |
||||
: op.data; |
||||
final opIsNotEmpty = |
||||
opData is String ? opData.isNotEmpty : true; // embeds are never empty |
||||
final opLength = opData is String ? opData.length : 1; |
||||
final opActualLength = opIsNotEmpty ? opLength : actualLength as int; |
||||
return Operation._(opKey, opActualLength, opData, opAttributes); |
||||
} |
||||
return Operation.retain(length); |
||||
} |
||||
|
||||
/// Skips [length] characters in source delta. |
||||
/// |
||||
/// Returns last skipped operation, or `null` if there was nothing to skip. |
||||
Operation? skip(int length) { |
||||
var skipped = 0; |
||||
Operation? op; |
||||
while (skipped < length && hasNext) { |
||||
final opLength = peekLength(); |
||||
final skip = math.min(length - skipped, opLength); |
||||
op = next(skip as int); |
||||
skipped += op.length!; |
||||
} |
||||
return op; |
||||
} |
||||
} |
||||
/// TODO: Remove this file in the next breaking release, because implementation |
||||
/// files should be located in the src folder, https://bit.ly/3fA23Yz. |
||||
export '../../../src/models/quill_delta.dart'; |
||||
|
@ -1,124 +1,3 @@ |
||||
import '../documents/attribute.dart'; |
||||
import '../quill_delta.dart'; |
||||
import 'rule.dart'; |
||||
|
||||
abstract class DeleteRule extends Rule { |
||||
const DeleteRule(); |
||||
|
||||
@override |
||||
RuleType get type => RuleType.DELETE; |
||||
|
||||
@override |
||||
void validateArgs(int? len, Object? data, Attribute? attribute) { |
||||
assert(len != null); |
||||
assert(data == null); |
||||
assert(attribute == null); |
||||
} |
||||
} |
||||
|
||||
class CatchAllDeleteRule extends DeleteRule { |
||||
const CatchAllDeleteRule(); |
||||
|
||||
@override |
||||
Delta applyRule(Delta document, int index, |
||||
{int? len, Object? data, Attribute? attribute}) { |
||||
return Delta() |
||||
..retain(index) |
||||
..delete(len!); |
||||
} |
||||
} |
||||
|
||||
class PreserveLineStyleOnMergeRule extends DeleteRule { |
||||
const PreserveLineStyleOnMergeRule(); |
||||
|
||||
@override |
||||
Delta? applyRule(Delta document, int index, |
||||
{int? len, Object? data, Attribute? attribute}) { |
||||
final itr = DeltaIterator(document)..skip(index); |
||||
var op = itr.next(1); |
||||
if (op.data != '\n') { |
||||
return null; |
||||
} |
||||
|
||||
final isNotPlain = op.isNotPlain; |
||||
final attrs = op.attributes; |
||||
|
||||
itr.skip(len! - 1); |
||||
final delta = Delta() |
||||
..retain(index) |
||||
..delete(len); |
||||
|
||||
while (itr.hasNext) { |
||||
op = itr.next(); |
||||
final text = op.data is String ? (op.data as String?)! : ''; |
||||
final lineBreak = text.indexOf('\n'); |
||||
if (lineBreak == -1) { |
||||
delta.retain(op.length!); |
||||
continue; |
||||
} |
||||
|
||||
var attributes = op.attributes == null |
||||
? null |
||||
: op.attributes!.map<String, dynamic>( |
||||
(key, dynamic value) => MapEntry<String, dynamic>(key, null)); |
||||
|
||||
if (isNotPlain) { |
||||
attributes ??= <String, dynamic>{}; |
||||
attributes.addAll(attrs!); |
||||
} |
||||
delta..retain(lineBreak)..retain(1, attributes); |
||||
break; |
||||
} |
||||
return delta; |
||||
} |
||||
} |
||||
|
||||
class EnsureEmbedLineRule extends DeleteRule { |
||||
const EnsureEmbedLineRule(); |
||||
|
||||
@override |
||||
Delta? applyRule(Delta document, int index, |
||||
{int? len, Object? data, Attribute? attribute}) { |
||||
final itr = DeltaIterator(document); |
||||
|
||||
var op = itr.skip(index); |
||||
int? indexDelta = 0, lengthDelta = 0, remain = len; |
||||
var embedFound = op != null && op.data is! String; |
||||
final hasLineBreakBefore = |
||||
!embedFound && (op == null || (op.data as String).endsWith('\n')); |
||||
if (embedFound) { |
||||
var candidate = itr.next(1); |
||||
if (remain != null) { |
||||
remain--; |
||||
if (candidate.data == '\n') { |
||||
indexDelta++; |
||||
lengthDelta--; |
||||
|
||||
candidate = itr.next(1); |
||||
remain--; |
||||
if (candidate.data == '\n') { |
||||
lengthDelta++; |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
op = itr.skip(remain!); |
||||
if (op != null && |
||||
(op.data is String ? op.data as String? : '')!.endsWith('\n')) { |
||||
final candidate = itr.next(1); |
||||
if (candidate.data is! String && !hasLineBreakBefore) { |
||||
embedFound = true; |
||||
lengthDelta--; |
||||
} |
||||
} |
||||
|
||||
if (!embedFound) { |
||||
return null; |
||||
} |
||||
|
||||
return Delta() |
||||
..retain(index + indexDelta) |
||||
..delete(len! + lengthDelta); |
||||
} |
||||
} |
||||
/// TODO: Remove this file in the next breaking release, because implementation |
||||
/// files should be located in the src folder, https://bit.ly/3fA23Yz. |
||||
export '../../../src/models/rules/delete.dart'; |
||||
|
@ -1,132 +1,3 @@ |
||||
import '../documents/attribute.dart'; |
||||
import '../quill_delta.dart'; |
||||
import 'rule.dart'; |
||||
|
||||
abstract class FormatRule extends Rule { |
||||
const FormatRule(); |
||||
|
||||
@override |
||||
RuleType get type => RuleType.FORMAT; |
||||
|
||||
@override |
||||
void validateArgs(int? len, Object? data, Attribute? attribute) { |
||||
assert(len != null); |
||||
assert(data == null); |
||||
assert(attribute != null); |
||||
} |
||||
} |
||||
|
||||
class ResolveLineFormatRule extends FormatRule { |
||||
const ResolveLineFormatRule(); |
||||
|
||||
@override |
||||
Delta? applyRule(Delta document, int index, |
||||
{int? len, Object? data, Attribute? attribute}) { |
||||
if (attribute!.scope != AttributeScope.BLOCK) { |
||||
return null; |
||||
} |
||||
|
||||
var delta = Delta()..retain(index); |
||||
final itr = DeltaIterator(document)..skip(index); |
||||
Operation op; |
||||
for (var cur = 0; cur < len! && itr.hasNext; cur += op.length!) { |
||||
op = itr.next(len - cur); |
||||
if (op.data is! String || !(op.data as String).contains('\n')) { |
||||
delta.retain(op.length!); |
||||
continue; |
||||
} |
||||
final text = op.data as String; |
||||
final tmp = Delta(); |
||||
var offset = 0; |
||||
|
||||
for (var lineBreak = text.indexOf('\n'); |
||||
lineBreak >= 0; |
||||
lineBreak = text.indexOf('\n', offset)) { |
||||
tmp..retain(lineBreak - offset)..retain(1, attribute.toJson()); |
||||
offset = lineBreak + 1; |
||||
} |
||||
tmp.retain(text.length - offset); |
||||
delta = delta.concat(tmp); |
||||
} |
||||
|
||||
while (itr.hasNext) { |
||||
op = itr.next(); |
||||
final text = op.data is String ? (op.data as String?)! : ''; |
||||
final lineBreak = text.indexOf('\n'); |
||||
if (lineBreak < 0) { |
||||
delta.retain(op.length!); |
||||
continue; |
||||
} |
||||
delta..retain(lineBreak)..retain(1, attribute.toJson()); |
||||
break; |
||||
} |
||||
return delta; |
||||
} |
||||
} |
||||
|
||||
class FormatLinkAtCaretPositionRule extends FormatRule { |
||||
const FormatLinkAtCaretPositionRule(); |
||||
|
||||
@override |
||||
Delta? applyRule(Delta document, int index, |
||||
{int? len, Object? data, Attribute? attribute}) { |
||||
if (attribute!.key != Attribute.link.key || len! > 0) { |
||||
return null; |
||||
} |
||||
|
||||
final delta = Delta(); |
||||
final itr = DeltaIterator(document); |
||||
final before = itr.skip(index), after = itr.next(); |
||||
int? beg = index, retain = 0; |
||||
if (before != null && before.hasAttribute(attribute.key)) { |
||||
beg -= before.length!; |
||||
retain = before.length; |
||||
} |
||||
if (after.hasAttribute(attribute.key)) { |
||||
if (retain != null) retain += after.length!; |
||||
} |
||||
if (retain == 0) { |
||||
return null; |
||||
} |
||||
|
||||
delta..retain(beg)..retain(retain!, attribute.toJson()); |
||||
return delta; |
||||
} |
||||
} |
||||
|
||||
class ResolveInlineFormatRule extends FormatRule { |
||||
const ResolveInlineFormatRule(); |
||||
|
||||
@override |
||||
Delta? applyRule(Delta document, int index, |
||||
{int? len, Object? data, Attribute? attribute}) { |
||||
if (attribute!.scope != AttributeScope.INLINE) { |
||||
return null; |
||||
} |
||||
|
||||
final delta = Delta()..retain(index); |
||||
final itr = DeltaIterator(document)..skip(index); |
||||
|
||||
Operation op; |
||||
for (var cur = 0; cur < len! && itr.hasNext; cur += op.length!) { |
||||
op = itr.next(len - cur); |
||||
final text = op.data is String ? (op.data as String?)! : ''; |
||||
var lineBreak = text.indexOf('\n'); |
||||
if (lineBreak < 0) { |
||||
delta.retain(op.length!, attribute.toJson()); |
||||
continue; |
||||
} |
||||
var pos = 0; |
||||
while (lineBreak >= 0) { |
||||
delta..retain(lineBreak - pos, attribute.toJson())..retain(1); |
||||
pos = lineBreak + 1; |
||||
lineBreak = text.indexOf('\n', pos); |
||||
} |
||||
if (pos < op.length!) { |
||||
delta.retain(op.length! - pos, attribute.toJson()); |
||||
} |
||||
} |
||||
|
||||
return delta; |
||||
} |
||||
} |
||||
/// TODO: Remove this file in the next breaking release, because implementation |
||||
/// files should be located in the src folder, https://bit.ly/3fA23Yz. |
||||
export '../../../src/models/rules/format.dart'; |
||||
|
@ -1,413 +1,3 @@ |
||||
import 'package:tuple/tuple.dart'; |
||||
|
||||
import '../documents/attribute.dart'; |
||||
import '../documents/style.dart'; |
||||
import '../quill_delta.dart'; |
||||
import 'rule.dart'; |
||||
|
||||
abstract class InsertRule extends Rule { |
||||
const InsertRule(); |
||||
|
||||
@override |
||||
RuleType get type => RuleType.INSERT; |
||||
|
||||
@override |
||||
void validateArgs(int? len, Object? data, Attribute? attribute) { |
||||
assert(data != null); |
||||
assert(attribute == null); |
||||
} |
||||
} |
||||
|
||||
class PreserveLineStyleOnSplitRule extends InsertRule { |
||||
const PreserveLineStyleOnSplitRule(); |
||||
|
||||
@override |
||||
Delta? applyRule(Delta document, int index, |
||||
{int? len, Object? data, Attribute? attribute}) { |
||||
if (data is! String || data != '\n') { |
||||
return null; |
||||
} |
||||
|
||||
final itr = DeltaIterator(document); |
||||
final before = itr.skip(index); |
||||
if (before == null || |
||||
before.data is! String || |
||||
(before.data as String).endsWith('\n')) { |
||||
return null; |
||||
} |
||||
final after = itr.next(); |
||||
if (after.data is! String || (after.data as String).startsWith('\n')) { |
||||
return null; |
||||
} |
||||
|
||||
final text = after.data as String; |
||||
|
||||
final delta = Delta()..retain(index + (len ?? 0)); |
||||
if (text.contains('\n')) { |
||||
assert(after.isPlain); |
||||
delta.insert('\n'); |
||||
return delta; |
||||
} |
||||
final nextNewLine = _getNextNewLine(itr); |
||||
final attributes = nextNewLine.item1?.attributes; |
||||
|
||||
return delta..insert('\n', attributes); |
||||
} |
||||
} |
||||
|
||||
/// Preserves block style when user inserts text containing newlines. |
||||
/// |
||||
/// This rule handles: |
||||
/// |
||||
/// * inserting a new line in a block |
||||
/// * pasting text containing multiple lines of text in a block |
||||
/// |
||||
/// This rule may also be activated for changes triggered by auto-correct. |
||||
class PreserveBlockStyleOnInsertRule extends InsertRule { |
||||
const PreserveBlockStyleOnInsertRule(); |
||||
|
||||
@override |
||||
Delta? applyRule(Delta document, int index, |
||||
{int? len, Object? data, Attribute? attribute}) { |
||||
if (data is! String || !data.contains('\n')) { |
||||
// Only interested in text containing at least one newline character. |
||||
return null; |
||||
} |
||||
|
||||
final itr = DeltaIterator(document)..skip(index); |
||||
|
||||
// Look for the next newline. |
||||
final nextNewLine = _getNextNewLine(itr); |
||||
final lineStyle = |
||||
Style.fromJson(nextNewLine.item1?.attributes ?? <String, dynamic>{}); |
||||
|
||||
final blockStyle = lineStyle.getBlocksExceptHeader(); |
||||
// Are we currently in a block? If not then ignore. |
||||
if (blockStyle.isEmpty) { |
||||
return null; |
||||
} |
||||
|
||||
Map<String, dynamic>? resetStyle; |
||||
// If current line had heading style applied to it we'll need to move this |
||||
// style to the newly inserted line before it and reset style of the |
||||
// original line. |
||||
if (lineStyle.containsKey(Attribute.header.key)) { |
||||
resetStyle = Attribute.header.toJson(); |
||||
} |
||||
|
||||
// Go over each inserted line and ensure block style is applied. |
||||
final lines = data.split('\n'); |
||||
final delta = Delta()..retain(index + (len ?? 0)); |
||||
for (var i = 0; i < lines.length; i++) { |
||||
final line = lines[i]; |
||||
if (line.isNotEmpty) { |
||||
delta.insert(line); |
||||
} |
||||
if (i == 0) { |
||||
// The first line should inherit the lineStyle entirely. |
||||
delta.insert('\n', lineStyle.toJson()); |
||||
} else if (i < lines.length - 1) { |
||||
// we don't want to insert a newline after the last chunk of text, so -1 |
||||
delta.insert('\n', blockStyle); |
||||
} |
||||
} |
||||
|
||||
// Reset style of the original newline character if needed. |
||||
if (resetStyle != null) { |
||||
delta |
||||
..retain(nextNewLine.item2!) |
||||
..retain((nextNewLine.item1!.data as String).indexOf('\n')) |
||||
..retain(1, resetStyle); |
||||
} |
||||
|
||||
return delta; |
||||
} |
||||
} |
||||
|
||||
/// Heuristic rule to exit current block when user inserts two consecutive |
||||
/// newlines. |
||||
/// |
||||
/// This rule is only applied when the cursor is on the last line of a block. |
||||
/// When the cursor is in the middle of a block we allow adding empty lines |
||||
/// and preserving the block's style. |
||||
class AutoExitBlockRule extends InsertRule { |
||||
const AutoExitBlockRule(); |
||||
|
||||
bool _isEmptyLine(Operation? before, Operation? after) { |
||||
if (before == null) { |
||||
return true; |
||||
} |
||||
return before.data is String && |
||||
(before.data as String).endsWith('\n') && |
||||
after!.data is String && |
||||
(after.data as String).startsWith('\n'); |
||||
} |
||||
|
||||
@override |
||||
Delta? applyRule(Delta document, int index, |
||||
{int? len, Object? data, Attribute? attribute}) { |
||||
if (data is! String || data != '\n') { |
||||
return null; |
||||
} |
||||
|
||||
final itr = DeltaIterator(document); |
||||
final prev = itr.skip(index), cur = itr.next(); |
||||
final blockStyle = Style.fromJson(cur.attributes).getBlockExceptHeader(); |
||||
// We are not in a block, ignore. |
||||
if (cur.isPlain || blockStyle == null) { |
||||
return null; |
||||
} |
||||
// We are not on an empty line, ignore. |
||||
if (!_isEmptyLine(prev, cur)) { |
||||
return null; |
||||
} |
||||
|
||||
// We are on an empty line. Now we need to determine if we are on the |
||||
// last line of a block. |
||||
// First check if `cur` length is greater than 1, this would indicate |
||||
// that it contains multiple newline characters which share the same style. |
||||
// This would mean we are not on the last line yet. |
||||
// `cur.value as String` is safe since we already called isEmptyLine and know it contains a newline |
||||
if ((cur.value as String).length > 1) { |
||||
// We are not on the last line of this block, ignore. |
||||
return null; |
||||
} |
||||
|
||||
// Keep looking for the next newline character to see if it shares the same |
||||
// block style as `cur`. |
||||
final nextNewLine = _getNextNewLine(itr); |
||||
if (nextNewLine.item1 != null && |
||||
nextNewLine.item1!.attributes != null && |
||||
Style.fromJson(nextNewLine.item1!.attributes).getBlockExceptHeader() == |
||||
blockStyle) { |
||||
// We are not at the end of this block, ignore. |
||||
return null; |
||||
} |
||||
|
||||
// Here we now know that the line after `cur` is not in the same block |
||||
// therefore we can exit this block. |
||||
final attributes = cur.attributes ?? <String, dynamic>{}; |
||||
final k = attributes.keys |
||||
.firstWhere((k) => Attribute.blockKeysExceptHeader.contains(k)); |
||||
attributes[k] = null; |
||||
// retain(1) should be '\n', set it with no attribute |
||||
return Delta()..retain(index + (len ?? 0))..retain(1, attributes); |
||||
} |
||||
} |
||||
|
||||
class ResetLineFormatOnNewLineRule extends InsertRule { |
||||
const ResetLineFormatOnNewLineRule(); |
||||
|
||||
@override |
||||
Delta? applyRule(Delta document, int index, |
||||
{int? len, Object? data, Attribute? attribute}) { |
||||
if (data is! String || data != '\n') { |
||||
return null; |
||||
} |
||||
|
||||
final itr = DeltaIterator(document)..skip(index); |
||||
final cur = itr.next(); |
||||
if (cur.data is! String || !(cur.data as String).startsWith('\n')) { |
||||
return null; |
||||
} |
||||
|
||||
Map<String, dynamic>? resetStyle; |
||||
if (cur.attributes != null && |
||||
cur.attributes!.containsKey(Attribute.header.key)) { |
||||
resetStyle = Attribute.header.toJson(); |
||||
} |
||||
return Delta() |
||||
..retain(index + (len ?? 0)) |
||||
..insert('\n', cur.attributes) |
||||
..retain(1, resetStyle) |
||||
..trim(); |
||||
} |
||||
} |
||||
|
||||
class InsertEmbedsRule extends InsertRule { |
||||
const InsertEmbedsRule(); |
||||
|
||||
@override |
||||
Delta? applyRule(Delta document, int index, |
||||
{int? len, Object? data, Attribute? attribute}) { |
||||
if (data is String) { |
||||
return null; |
||||
} |
||||
|
||||
final delta = Delta()..retain(index + (len ?? 0)); |
||||
final itr = DeltaIterator(document); |
||||
final prev = itr.skip(index), cur = itr.next(); |
||||
|
||||
final textBefore = prev?.data is String ? prev!.data as String? : ''; |
||||
final textAfter = cur.data is String ? (cur.data as String?)! : ''; |
||||
|
||||
final isNewlineBefore = prev == null || textBefore!.endsWith('\n'); |
||||
final isNewlineAfter = textAfter.startsWith('\n'); |
||||
|
||||
if (isNewlineBefore && isNewlineAfter) { |
||||
return delta..insert(data); |
||||
} |
||||
|
||||
Map<String, dynamic>? lineStyle; |
||||
if (textAfter.contains('\n')) { |
||||
lineStyle = cur.attributes; |
||||
} else { |
||||
while (itr.hasNext) { |
||||
final op = itr.next(); |
||||
if ((op.data is String ? op.data as String? : '')!.contains('\n')) { |
||||
lineStyle = op.attributes; |
||||
break; |
||||
} |
||||
} |
||||
} |
||||
|
||||
if (!isNewlineBefore) { |
||||
delta.insert('\n', lineStyle); |
||||
} |
||||
delta.insert(data); |
||||
if (!isNewlineAfter) { |
||||
delta.insert('\n'); |
||||
} |
||||
return delta; |
||||
} |
||||
} |
||||
|
||||
class ForceNewlineForInsertsAroundEmbedRule extends InsertRule { |
||||
const ForceNewlineForInsertsAroundEmbedRule(); |
||||
|
||||
@override |
||||
Delta? applyRule(Delta document, int index, |
||||
{int? len, Object? data, Attribute? attribute}) { |
||||
if (data is! String) { |
||||
return null; |
||||
} |
||||
|
||||
final text = data; |
||||
final itr = DeltaIterator(document); |
||||
final prev = itr.skip(index); |
||||
final cur = itr.next(); |
||||
final cursorBeforeEmbed = cur.data is! String; |
||||
final cursorAfterEmbed = prev != null && prev.data is! String; |
||||
|
||||
if (!cursorBeforeEmbed && !cursorAfterEmbed) { |
||||
return null; |
||||
} |
||||
final delta = Delta()..retain(index + (len ?? 0)); |
||||
if (cursorBeforeEmbed && !text.endsWith('\n')) { |
||||
return delta..insert(text)..insert('\n'); |
||||
} |
||||
if (cursorAfterEmbed && !text.startsWith('\n')) { |
||||
return delta..insert('\n')..insert(text); |
||||
} |
||||
return delta..insert(text); |
||||
} |
||||
} |
||||
|
||||
class AutoFormatLinksRule extends InsertRule { |
||||
const AutoFormatLinksRule(); |
||||
|
||||
@override |
||||
Delta? applyRule(Delta document, int index, |
||||
{int? len, Object? data, Attribute? attribute}) { |
||||
if (data is! String || data != ' ') { |
||||
return null; |
||||
} |
||||
|
||||
final itr = DeltaIterator(document); |
||||
final prev = itr.skip(index); |
||||
if (prev == null || prev.data is! String) { |
||||
return null; |
||||
} |
||||
|
||||
try { |
||||
final cand = (prev.data as String).split('\n').last.split(' ').last; |
||||
final link = Uri.parse(cand); |
||||
if (!['https', 'http'].contains(link.scheme)) { |
||||
return null; |
||||
} |
||||
final attributes = prev.attributes ?? <String, dynamic>{}; |
||||
|
||||
if (attributes.containsKey(Attribute.link.key)) { |
||||
return null; |
||||
} |
||||
|
||||
attributes.addAll(LinkAttribute(link.toString()).toJson()); |
||||
return Delta() |
||||
..retain(index + (len ?? 0) - cand.length) |
||||
..retain(cand.length, attributes) |
||||
..insert(data, prev.attributes); |
||||
} on FormatException { |
||||
return null; |
||||
} |
||||
} |
||||
} |
||||
|
||||
class PreserveInlineStylesRule extends InsertRule { |
||||
const PreserveInlineStylesRule(); |
||||
|
||||
@override |
||||
Delta? applyRule(Delta document, int index, |
||||
{int? len, Object? data, Attribute? attribute}) { |
||||
if (data is! String || data.contains('\n')) { |
||||
return null; |
||||
} |
||||
|
||||
final itr = DeltaIterator(document); |
||||
final prev = itr.skip(index); |
||||
if (prev == null || |
||||
prev.data is! String || |
||||
(prev.data as String).contains('\n')) { |
||||
return null; |
||||
} |
||||
|
||||
final attributes = prev.attributes; |
||||
final text = data; |
||||
if (attributes == null || !attributes.containsKey(Attribute.link.key)) { |
||||
return Delta() |
||||
..retain(index + (len ?? 0)) |
||||
..insert(text, attributes); |
||||
} |
||||
|
||||
attributes.remove(Attribute.link.key); |
||||
final delta = Delta() |
||||
..retain(index + (len ?? 0)) |
||||
..insert(text, attributes.isEmpty ? null : attributes); |
||||
final next = itr.next(); |
||||
|
||||
final nextAttributes = next.attributes ?? const <String, dynamic>{}; |
||||
if (!nextAttributes.containsKey(Attribute.link.key)) { |
||||
return delta; |
||||
} |
||||
if (attributes[Attribute.link.key] == nextAttributes[Attribute.link.key]) { |
||||
return Delta() |
||||
..retain(index + (len ?? 0)) |
||||
..insert(text, attributes); |
||||
} |
||||
return delta; |
||||
} |
||||
} |
||||
|
||||
class CatchAllInsertRule extends InsertRule { |
||||
const CatchAllInsertRule(); |
||||
|
||||
@override |
||||
Delta applyRule(Delta document, int index, |
||||
{int? len, Object? data, Attribute? attribute}) { |
||||
return Delta() |
||||
..retain(index + (len ?? 0)) |
||||
..insert(data); |
||||
} |
||||
} |
||||
|
||||
Tuple2<Operation?, int?> _getNextNewLine(DeltaIterator iterator) { |
||||
Operation op; |
||||
for (var skipped = 0; iterator.hasNext; skipped += op.length!) { |
||||
op = iterator.next(); |
||||
final lineBreak = |
||||
(op.data is String ? op.data as String? : '')!.indexOf('\n'); |
||||
if (lineBreak >= 0) { |
||||
return Tuple2(op, skipped); |
||||
} |
||||
} |
||||
return const Tuple2(null, null); |
||||
} |
||||
/// TODO: Remove this file in the next breaking release, because implementation |
||||
/// files should be located in the src folder, https://bit.ly/3fA23Yz. |
||||
export '../../../src/models/rules/insert.dart'; |
||||
|
@ -1,77 +1,3 @@ |
||||
import '../documents/attribute.dart'; |
||||
import '../documents/document.dart'; |
||||
import '../quill_delta.dart'; |
||||
import 'delete.dart'; |
||||
import 'format.dart'; |
||||
import 'insert.dart'; |
||||
|
||||
enum RuleType { INSERT, DELETE, FORMAT } |
||||
|
||||
abstract class Rule { |
||||
const Rule(); |
||||
|
||||
Delta? apply(Delta document, int index, |
||||
{int? len, Object? data, Attribute? attribute}) { |
||||
validateArgs(len, data, attribute); |
||||
return applyRule(document, index, |
||||
len: len, data: data, attribute: attribute); |
||||
} |
||||
|
||||
void validateArgs(int? len, Object? data, Attribute? attribute); |
||||
|
||||
Delta? applyRule(Delta document, int index, |
||||
{int? len, Object? data, Attribute? attribute}); |
||||
|
||||
RuleType get type; |
||||
} |
||||
|
||||
class Rules { |
||||
Rules(this._rules); |
||||
|
||||
List<Rule> _customRules = []; |
||||
|
||||
final List<Rule> _rules; |
||||
static final Rules _instance = Rules([ |
||||
const FormatLinkAtCaretPositionRule(), |
||||
const ResolveLineFormatRule(), |
||||
const ResolveInlineFormatRule(), |
||||
const InsertEmbedsRule(), |
||||
const ForceNewlineForInsertsAroundEmbedRule(), |
||||
const AutoExitBlockRule(), |
||||
const PreserveBlockStyleOnInsertRule(), |
||||
const PreserveLineStyleOnSplitRule(), |
||||
const ResetLineFormatOnNewLineRule(), |
||||
const AutoFormatLinksRule(), |
||||
const PreserveInlineStylesRule(), |
||||
const CatchAllInsertRule(), |
||||
const EnsureEmbedLineRule(), |
||||
const PreserveLineStyleOnMergeRule(), |
||||
const CatchAllDeleteRule(), |
||||
]); |
||||
|
||||
static Rules getInstance() => _instance; |
||||
|
||||
void setCustomRules(List<Rule> customRules) { |
||||
_customRules = customRules; |
||||
} |
||||
|
||||
Delta apply(RuleType ruleType, Document document, int index, |
||||
{int? len, Object? data, Attribute? attribute}) { |
||||
final delta = document.toDelta(); |
||||
for (final rule in _customRules + _rules) { |
||||
if (rule.type != ruleType) { |
||||
continue; |
||||
} |
||||
try { |
||||
final result = rule.apply(delta, index, |
||||
len: len, data: data, attribute: attribute); |
||||
if (result != null) { |
||||
return result..trim(); |
||||
} |
||||
} catch (e) { |
||||
rethrow; |
||||
} |
||||
} |
||||
throw 'Apply rules failed'; |
||||
} |
||||
} |
||||
/// TODO: Remove this file in the next breaking release, because implementation |
||||
/// files should be located in the src folder, https://bit.ly/3fA23Yz. |
||||
export '../../../src/models/rules/rule.dart'; |
||||
|
@ -0,0 +1,292 @@ |
||||
import 'dart:collection'; |
||||
|
||||
import 'package:quiver/core.dart'; |
||||
|
||||
enum AttributeScope { |
||||
INLINE, // refer to https://quilljs.com/docs/formats/#inline |
||||
BLOCK, // refer to https://quilljs.com/docs/formats/#block |
||||
EMBEDS, // refer to https://quilljs.com/docs/formats/#embeds |
||||
IGNORE, // attributes that can be ignored |
||||
} |
||||
|
||||
class Attribute<T> { |
||||
Attribute(this.key, this.scope, this.value); |
||||
|
||||
final String key; |
||||
final AttributeScope scope; |
||||
final T value; |
||||
|
||||
static final Map<String, Attribute> _registry = LinkedHashMap.of({ |
||||
Attribute.bold.key: Attribute.bold, |
||||
Attribute.italic.key: Attribute.italic, |
||||
Attribute.underline.key: Attribute.underline, |
||||
Attribute.strikeThrough.key: Attribute.strikeThrough, |
||||
Attribute.font.key: Attribute.font, |
||||
Attribute.size.key: Attribute.size, |
||||
Attribute.link.key: Attribute.link, |
||||
Attribute.color.key: Attribute.color, |
||||
Attribute.background.key: Attribute.background, |
||||
Attribute.placeholder.key: Attribute.placeholder, |
||||
Attribute.header.key: Attribute.header, |
||||
Attribute.align.key: Attribute.align, |
||||
Attribute.list.key: Attribute.list, |
||||
Attribute.codeBlock.key: Attribute.codeBlock, |
||||
Attribute.blockQuote.key: Attribute.blockQuote, |
||||
Attribute.indent.key: Attribute.indent, |
||||
Attribute.width.key: Attribute.width, |
||||
Attribute.height.key: Attribute.height, |
||||
Attribute.style.key: Attribute.style, |
||||
Attribute.token.key: Attribute.token, |
||||
}); |
||||
|
||||
static final BoldAttribute bold = BoldAttribute(); |
||||
|
||||
static final ItalicAttribute italic = ItalicAttribute(); |
||||
|
||||
static final UnderlineAttribute underline = UnderlineAttribute(); |
||||
|
||||
static final StrikeThroughAttribute strikeThrough = StrikeThroughAttribute(); |
||||
|
||||
static final FontAttribute font = FontAttribute(null); |
||||
|
||||
static final SizeAttribute size = SizeAttribute(null); |
||||
|
||||
static final LinkAttribute link = LinkAttribute(null); |
||||
|
||||
static final ColorAttribute color = ColorAttribute(null); |
||||
|
||||
static final BackgroundAttribute background = BackgroundAttribute(null); |
||||
|
||||
static final PlaceholderAttribute placeholder = PlaceholderAttribute(); |
||||
|
||||
static final HeaderAttribute header = HeaderAttribute(); |
||||
|
||||
static final IndentAttribute indent = IndentAttribute(); |
||||
|
||||
static final AlignAttribute align = AlignAttribute(null); |
||||
|
||||
static final ListAttribute list = ListAttribute(null); |
||||
|
||||
static final CodeBlockAttribute codeBlock = CodeBlockAttribute(); |
||||
|
||||
static final BlockQuoteAttribute blockQuote = BlockQuoteAttribute(); |
||||
|
||||
static final WidthAttribute width = WidthAttribute(null); |
||||
|
||||
static final HeightAttribute height = HeightAttribute(null); |
||||
|
||||
static final StyleAttribute style = StyleAttribute(null); |
||||
|
||||
static final TokenAttribute token = TokenAttribute(''); |
||||
|
||||
static final Set<String> inlineKeys = { |
||||
Attribute.bold.key, |
||||
Attribute.italic.key, |
||||
Attribute.underline.key, |
||||
Attribute.strikeThrough.key, |
||||
Attribute.link.key, |
||||
Attribute.color.key, |
||||
Attribute.background.key, |
||||
Attribute.placeholder.key, |
||||
}; |
||||
|
||||
static final Set<String> blockKeys = LinkedHashSet.of({ |
||||
Attribute.header.key, |
||||
Attribute.align.key, |
||||
Attribute.list.key, |
||||
Attribute.codeBlock.key, |
||||
Attribute.blockQuote.key, |
||||
Attribute.indent.key, |
||||
}); |
||||
|
||||
static final Set<String> blockKeysExceptHeader = LinkedHashSet.of({ |
||||
Attribute.list.key, |
||||
Attribute.align.key, |
||||
Attribute.codeBlock.key, |
||||
Attribute.blockQuote.key, |
||||
Attribute.indent.key, |
||||
}); |
||||
|
||||
static Attribute<int?> get h1 => HeaderAttribute(level: 1); |
||||
|
||||
static Attribute<int?> get h2 => HeaderAttribute(level: 2); |
||||
|
||||
static Attribute<int?> get h3 => HeaderAttribute(level: 3); |
||||
|
||||
// "attributes":{"align":"left"} |
||||
static Attribute<String?> get leftAlignment => AlignAttribute('left'); |
||||
|
||||
// "attributes":{"align":"center"} |
||||
static Attribute<String?> get centerAlignment => AlignAttribute('center'); |
||||
|
||||
// "attributes":{"align":"right"} |
||||
static Attribute<String?> get rightAlignment => AlignAttribute('right'); |
||||
|
||||
// "attributes":{"align":"justify"} |
||||
static Attribute<String?> get justifyAlignment => AlignAttribute('justify'); |
||||
|
||||
// "attributes":{"list":"bullet"} |
||||
static Attribute<String?> get ul => ListAttribute('bullet'); |
||||
|
||||
// "attributes":{"list":"ordered"} |
||||
static Attribute<String?> get ol => ListAttribute('ordered'); |
||||
|
||||
// "attributes":{"list":"checked"} |
||||
static Attribute<String?> get checked => ListAttribute('checked'); |
||||
|
||||
// "attributes":{"list":"unchecked"} |
||||
static Attribute<String?> get unchecked => ListAttribute('unchecked'); |
||||
|
||||
// "attributes":{"indent":1"} |
||||
static Attribute<int?> get indentL1 => IndentAttribute(level: 1); |
||||
|
||||
// "attributes":{"indent":2"} |
||||
static Attribute<int?> get indentL2 => IndentAttribute(level: 2); |
||||
|
||||
// "attributes":{"indent":3"} |
||||
static Attribute<int?> get indentL3 => IndentAttribute(level: 3); |
||||
|
||||
static Attribute<int?> getIndentLevel(int? level) { |
||||
if (level == 1) { |
||||
return indentL1; |
||||
} |
||||
if (level == 2) { |
||||
return indentL2; |
||||
} |
||||
if (level == 3) { |
||||
return indentL3; |
||||
} |
||||
return IndentAttribute(level: level); |
||||
} |
||||
|
||||
bool get isInline => scope == AttributeScope.INLINE; |
||||
|
||||
bool get isBlockExceptHeader => blockKeysExceptHeader.contains(key); |
||||
|
||||
Map<String, dynamic> toJson() => <String, dynamic>{key: value}; |
||||
|
||||
static Attribute fromKeyValue(String key, dynamic value) { |
||||
if (!_registry.containsKey(key)) { |
||||
throw ArgumentError.value(key, 'key "$key" not found.'); |
||||
} |
||||
final origin = _registry[key]!; |
||||
final attribute = clone(origin, value); |
||||
return attribute; |
||||
} |
||||
|
||||
static int getRegistryOrder(Attribute attribute) { |
||||
var order = 0; |
||||
for (final attr in _registry.values) { |
||||
if (attr.key == attribute.key) { |
||||
break; |
||||
} |
||||
order++; |
||||
} |
||||
|
||||
return order; |
||||
} |
||||
|
||||
static Attribute clone(Attribute origin, dynamic value) { |
||||
return Attribute(origin.key, origin.scope, value); |
||||
} |
||||
|
||||
@override |
||||
bool operator ==(Object other) { |
||||
if (identical(this, other)) return true; |
||||
if (other is! Attribute<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); |
||||
} |
@ -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,231 @@ |
||||
import 'dart:async'; |
||||
|
||||
import 'package:flutter/foundation.dart'; |
||||
import 'package:flutter/widgets.dart'; |
||||
|
||||
import 'box.dart'; |
||||
|
||||
const Duration _FADE_DURATION = Duration(milliseconds: 250); |
||||
|
||||
class CursorStyle { |
||||
const CursorStyle({ |
||||
required this.color, |
||||
required this.backgroundColor, |
||||
this.width = 1.0, |
||||
this.height, |
||||
this.radius, |
||||
this.offset, |
||||
this.opacityAnimates = false, |
||||
this.paintAboveText = false, |
||||
}); |
||||
|
||||
final Color color; |
||||
final Color backgroundColor; |
||||
final double width; |
||||
final double? height; |
||||
final Radius? radius; |
||||
final Offset? offset; |
||||
final bool opacityAnimates; |
||||
final bool paintAboveText; |
||||
|
||||
@override |
||||
bool operator ==(Object other) => |
||||
identical(this, other) || |
||||
other is CursorStyle && |
||||
runtimeType == other.runtimeType && |
||||
color == other.color && |
||||
backgroundColor == other.backgroundColor && |
||||
width == other.width && |
||||
height == other.height && |
||||
radius == other.radius && |
||||
offset == other.offset && |
||||
opacityAnimates == other.opacityAnimates && |
||||
paintAboveText == other.paintAboveText; |
||||
|
||||
@override |
||||
int get hashCode => |
||||
color.hashCode ^ |
||||
backgroundColor.hashCode ^ |
||||
width.hashCode ^ |
||||
height.hashCode ^ |
||||
radius.hashCode ^ |
||||
offset.hashCode ^ |
||||
opacityAnimates.hashCode ^ |
||||
paintAboveText.hashCode; |
||||
} |
||||
|
||||
class CursorCont extends ChangeNotifier { |
||||
CursorCont({ |
||||
required this.show, |
||||
required CursorStyle style, |
||||
required TickerProvider tickerProvider, |
||||
}) : _style = style, |
||||
_blink = ValueNotifier(false), |
||||
color = ValueNotifier(style.color) { |
||||
_blinkOpacityCont = |
||||
AnimationController(vsync: tickerProvider, duration: _FADE_DURATION); |
||||
_blinkOpacityCont.addListener(_onColorTick); |
||||
} |
||||
|
||||
final ValueNotifier<bool> show; |
||||
final ValueNotifier<bool> _blink; |
||||
final ValueNotifier<Color> color; |
||||
late AnimationController _blinkOpacityCont; |
||||
Timer? _cursorTimer; |
||||
bool _targetCursorVisibility = false; |
||||
CursorStyle _style; |
||||
|
||||
ValueNotifier<bool> get cursorBlink => _blink; |
||||
|
||||
ValueNotifier<Color> get cursorColor => color; |
||||
|
||||
CursorStyle get style => _style; |
||||
|
||||
set style(CursorStyle value) { |
||||
if (_style == value) return; |
||||
_style = value; |
||||
notifyListeners(); |
||||
} |
||||
|
||||
@override |
||||
void dispose() { |
||||
_blinkOpacityCont.removeListener(_onColorTick); |
||||
stopCursorTimer(); |
||||
_blinkOpacityCont.dispose(); |
||||
assert(_cursorTimer == null); |
||||
super.dispose(); |
||||
} |
||||
|
||||
void _cursorTick(Timer timer) { |
||||
_targetCursorVisibility = !_targetCursorVisibility; |
||||
final targetOpacity = _targetCursorVisibility ? 1.0 : 0.0; |
||||
if (style.opacityAnimates) { |
||||
_blinkOpacityCont.animateTo(targetOpacity, curve: Curves.easeOut); |
||||
} else { |
||||
_blinkOpacityCont.value = targetOpacity; |
||||
} |
||||
} |
||||
|
||||
void _cursorWaitForStart(Timer timer) { |
||||
_cursorTimer?.cancel(); |
||||
_cursorTimer = |
||||
Timer.periodic(const Duration(milliseconds: 500), _cursorTick); |
||||
} |
||||
|
||||
void startCursorTimer() { |
||||
_targetCursorVisibility = true; |
||||
_blinkOpacityCont.value = 1.0; |
||||
|
||||
if (style.opacityAnimates) { |
||||
_cursorTimer = Timer.periodic( |
||||
const Duration(milliseconds: 150), _cursorWaitForStart); |
||||
} else { |
||||
_cursorTimer = |
||||
Timer.periodic(const Duration(milliseconds: 500), _cursorTick); |
||||
} |
||||
} |
||||
|
||||
void stopCursorTimer({bool resetCharTicks = true}) { |
||||
_cursorTimer?.cancel(); |
||||
_cursorTimer = null; |
||||
_targetCursorVisibility = false; |
||||
_blinkOpacityCont.value = 0.0; |
||||
|
||||
if (style.opacityAnimates) { |
||||
_blinkOpacityCont |
||||
..stop() |
||||
..value = 0.0; |
||||
} |
||||
} |
||||
|
||||
void startOrStopCursorTimerIfNeeded(bool hasFocus, TextSelection selection) { |
||||
if (show.value && |
||||
_cursorTimer == null && |
||||
hasFocus && |
||||
selection.isCollapsed) { |
||||
startCursorTimer(); |
||||
} else if (_cursorTimer != null && (!hasFocus || !selection.isCollapsed)) { |
||||
stopCursorTimer(); |
||||
} |
||||
} |
||||
|
||||
void _onColorTick() { |
||||
color.value = _style.color.withOpacity(_blinkOpacityCont.value); |
||||
_blink.value = show.value && _blinkOpacityCont.value > 0; |
||||
} |
||||
} |
||||
|
||||
class CursorPainter { |
||||
CursorPainter(this.editable, this.style, this.prototype, this.color, |
||||
this.devicePixelRatio); |
||||
|
||||
final RenderContentProxyBox? editable; |
||||
final CursorStyle style; |
||||
final Rect? prototype; |
||||
final Color color; |
||||
final double devicePixelRatio; |
||||
|
||||
void paint(Canvas canvas, Offset offset, TextPosition position) { |
||||
assert(prototype != null); |
||||
|
||||
final caretOffset = |
||||
editable!.getOffsetForCaret(position, prototype) + offset; |
||||
var caretRect = prototype!.shift(caretOffset); |
||||
if (style.offset != null) { |
||||
caretRect = caretRect.shift(style.offset!); |
||||
} |
||||
|
||||
if (caretRect.left < 0.0) { |
||||
caretRect = caretRect.shift(Offset(-caretRect.left, 0)); |
||||
} |
||||
|
||||
final caretHeight = editable!.getFullHeightForCaret(position); |
||||
if (caretHeight != null) { |
||||
switch (defaultTargetPlatform) { |
||||
case TargetPlatform.android: |
||||
case TargetPlatform.fuchsia: |
||||
case TargetPlatform.linux: |
||||
case TargetPlatform.windows: |
||||
caretRect = Rect.fromLTWH( |
||||
caretRect.left, |
||||
caretRect.top - 2.0, |
||||
caretRect.width, |
||||
caretHeight, |
||||
); |
||||
break; |
||||
case TargetPlatform.iOS: |
||||
case TargetPlatform.macOS: |
||||
caretRect = Rect.fromLTWH( |
||||
caretRect.left, |
||||
caretRect.top + (caretHeight - caretRect.height) / 2, |
||||
caretRect.width, |
||||
caretRect.height, |
||||
); |
||||
break; |
||||
default: |
||||
throw UnimplementedError(); |
||||
} |
||||
} |
||||
|
||||
final caretPosition = editable!.localToGlobal(caretRect.topLeft); |
||||
final pixelMultiple = 1.0 / devicePixelRatio; |
||||
caretRect = caretRect.shift(Offset( |
||||
caretPosition.dx.isFinite |
||||
? (caretPosition.dx / pixelMultiple).round() * pixelMultiple - |
||||
caretPosition.dx |
||||
: caretPosition.dx, |
||||
caretPosition.dy.isFinite |
||||
? (caretPosition.dy / pixelMultiple).round() * pixelMultiple - |
||||
caretPosition.dy |
||||
: caretPosition.dy)); |
||||
|
||||
final paint = Paint()..color = color; |
||||
if (style.radius == null) { |
||||
canvas.drawRect(caretRect, paint); |
||||
return; |
||||
} |
||||
|
||||
final caretRRect = RRect.fromRectAndRadius(caretRect, style.radius!); |
||||
canvas.drawRRect(caretRRect, paint); |
||||
} |
||||
} |
@ -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 = |
||||
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); |
||||
} |
||||
} |
@ -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.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; |
||||
} |
||||
} |
||||
} |
@ -0,0 +1,726 @@ |
||||
import 'dart:async'; |
||||
import 'dart:math' as math; |
||||
|
||||
import 'package:flutter/cupertino.dart'; |
||||
import 'package:flutter/foundation.dart'; |
||||
import 'package:flutter/gestures.dart'; |
||||
import 'package:flutter/material.dart'; |
||||
import 'package:flutter/rendering.dart'; |
||||
import 'package:flutter/scheduler.dart'; |
||||
|
||||
import '../models/documents/nodes/node.dart'; |
||||
import 'editor.dart'; |
||||
|
||||
TextSelection localSelection(Node node, TextSelection selection, fromParent) { |
||||
final base = fromParent ? node.offset : node.documentOffset; |
||||
assert(base <= selection.end && selection.start <= base + node.length - 1); |
||||
|
||||
final offset = fromParent ? node.offset : node.documentOffset; |
||||
return selection.copyWith( |
||||
baseOffset: math.max(selection.start - offset, 0), |
||||
extentOffset: math.min(selection.end - offset, node.length - 1)); |
||||
} |
||||
|
||||
enum _TextSelectionHandlePosition { START, END } |
||||
|
||||
class EditorTextSelectionOverlay { |
||||
EditorTextSelectionOverlay( |
||||
this.value, |
||||
this.handlesVisible, |
||||
this.context, |
||||
this.debugRequiredFor, |
||||
this.toolbarLayerLink, |
||||
this.startHandleLayerLink, |
||||
this.endHandleLayerLink, |
||||
this.renderObject, |
||||
this.selectionCtrls, |
||||
this.selectionDelegate, |
||||
this.dragStartBehavior, |
||||
this.onSelectionHandleTapped, |
||||
this.clipboardStatus, |
||||
) { |
||||
final overlay = Overlay.of(context, rootOverlay: true)!; |
||||
|
||||
_toolbarController = AnimationController( |
||||
duration: const Duration(milliseconds: 150), vsync: overlay); |
||||
} |
||||
|
||||
TextEditingValue value; |
||||
bool handlesVisible = false; |
||||
final BuildContext context; |
||||
final Widget debugRequiredFor; |
||||
final LayerLink toolbarLayerLink; |
||||
final LayerLink startHandleLayerLink; |
||||
final LayerLink endHandleLayerLink; |
||||
final RenderEditor? renderObject; |
||||
final TextSelectionControls selectionCtrls; |
||||
final TextSelectionDelegate selectionDelegate; |
||||
final DragStartBehavior dragStartBehavior; |
||||
final VoidCallback? onSelectionHandleTapped; |
||||
final ClipboardStatusNotifier clipboardStatus; |
||||
late AnimationController _toolbarController; |
||||
List<OverlayEntry>? _handles; |
||||
OverlayEntry? toolbar; |
||||
|
||||
TextSelection get _selection => value.selection; |
||||
|
||||
Animation<double> get _toolbarOpacity => _toolbarController.view; |
||||
|
||||
void setHandlesVisible(bool visible) { |
||||
if (handlesVisible == visible) { |
||||
return; |
||||
} |
||||
handlesVisible = visible; |
||||
if (SchedulerBinding.instance!.schedulerPhase == |
||||
SchedulerPhase.persistentCallbacks) { |
||||
SchedulerBinding.instance!.addPostFrameCallback(markNeedsBuild); |
||||
} else { |
||||
markNeedsBuild(); |
||||
} |
||||
} |
||||
|
||||
void hideHandles() { |
||||
if (_handles == null) { |
||||
return; |
||||
} |
||||
_handles![0].remove(); |
||||
_handles![1].remove(); |
||||
_handles = null; |
||||
} |
||||
|
||||
void hideToolbar() { |
||||
assert(toolbar != null); |
||||
_toolbarController.stop(); |
||||
toolbar!.remove(); |
||||
toolbar = null; |
||||
} |
||||
|
||||
void showToolbar() { |
||||
assert(toolbar == null); |
||||
toolbar = OverlayEntry(builder: _buildToolbar); |
||||
Overlay.of(context, rootOverlay: true, debugRequiredFor: debugRequiredFor)! |
||||
.insert(toolbar!); |
||||
_toolbarController.forward(from: 0); |
||||
} |
||||
|
||||
Widget _buildHandle( |
||||
BuildContext context, _TextSelectionHandlePosition position) { |
||||
if (_selection.isCollapsed && |
||||
position == _TextSelectionHandlePosition.END) { |
||||
return Container(); |
||||
} |
||||
return Visibility( |
||||
visible: handlesVisible, |
||||
child: _TextSelectionHandleOverlay( |
||||
onSelectionHandleChanged: (newSelection) { |
||||
_handleSelectionHandleChanged(newSelection, position); |
||||
}, |
||||
onSelectionHandleTapped: onSelectionHandleTapped, |
||||
startHandleLayerLink: startHandleLayerLink, |
||||
endHandleLayerLink: endHandleLayerLink, |
||||
renderObject: renderObject, |
||||
selection: _selection, |
||||
selectionControls: selectionCtrls, |
||||
position: position, |
||||
dragStartBehavior: dragStartBehavior, |
||||
)); |
||||
} |
||||
|
||||
void update(TextEditingValue newValue) { |
||||
if (value == newValue) { |
||||
return; |
||||
} |
||||
value = newValue; |
||||
if (SchedulerBinding.instance!.schedulerPhase == |
||||
SchedulerPhase.persistentCallbacks) { |
||||
SchedulerBinding.instance!.addPostFrameCallback(markNeedsBuild); |
||||
} else { |
||||
markNeedsBuild(); |
||||
} |
||||
} |
||||
|
||||
void _handleSelectionHandleChanged( |
||||
TextSelection? newSelection, _TextSelectionHandlePosition position) { |
||||
TextPosition textPosition; |
||||
switch (position) { |
||||
case _TextSelectionHandlePosition.START: |
||||
textPosition = newSelection != null |
||||
? newSelection.base |
||||
: const TextPosition(offset: 0); |
||||
break; |
||||
case _TextSelectionHandlePosition.END: |
||||
textPosition = newSelection != null |
||||
? newSelection.extent |
||||
: const TextPosition(offset: 0); |
||||
break; |
||||
default: |
||||
throw 'Invalid position'; |
||||
} |
||||
selectionDelegate |
||||
..textEditingValue = |
||||
value.copyWith(selection: newSelection, composing: TextRange.empty) |
||||
..bringIntoView(textPosition); |
||||
} |
||||
|
||||
Widget _buildToolbar(BuildContext context) { |
||||
final endpoints = renderObject!.getEndpointsForSelection(_selection); |
||||
|
||||
final editingRegion = Rect.fromPoints( |
||||
renderObject!.localToGlobal(Offset.zero), |
||||
renderObject!.localToGlobal(renderObject!.size.bottomRight(Offset.zero)), |
||||
); |
||||
|
||||
final baseLineHeight = renderObject!.preferredLineHeight(_selection.base); |
||||
final extentLineHeight = |
||||
renderObject!.preferredLineHeight(_selection.extent); |
||||
final smallestLineHeight = math.min(baseLineHeight, extentLineHeight); |
||||
final isMultiline = endpoints.last.point.dy - endpoints.first.point.dy > |
||||
smallestLineHeight / 2; |
||||
|
||||
final midX = isMultiline |
||||
? editingRegion.width / 2 |
||||
: (endpoints.first.point.dx + endpoints.last.point.dx) / 2; |
||||
|
||||
final midpoint = Offset( |
||||
midX, |
||||
endpoints[0].point.dy - baseLineHeight, |
||||
); |
||||
|
||||
return FadeTransition( |
||||
opacity: _toolbarOpacity, |
||||
child: CompositedTransformFollower( |
||||
link: toolbarLayerLink, |
||||
showWhenUnlinked: false, |
||||
offset: -editingRegion.topLeft, |
||||
child: selectionCtrls.buildToolbar( |
||||
context, |
||||
editingRegion, |
||||
baseLineHeight, |
||||
midpoint, |
||||
endpoints, |
||||
selectionDelegate, |
||||
clipboardStatus, |
||||
const Offset(0, 0)), |
||||
), |
||||
); |
||||
} |
||||
|
||||
void markNeedsBuild([Duration? duration]) { |
||||
if (_handles != null) { |
||||
_handles![0].markNeedsBuild(); |
||||
_handles![1].markNeedsBuild(); |
||||
} |
||||
toolbar?.markNeedsBuild(); |
||||
} |
||||
|
||||
void hide() { |
||||
if (_handles != null) { |
||||
_handles![0].remove(); |
||||
_handles![1].remove(); |
||||
_handles = null; |
||||
} |
||||
if (toolbar != null) { |
||||
hideToolbar(); |
||||
} |
||||
} |
||||
|
||||
void dispose() { |
||||
hide(); |
||||
_toolbarController.dispose(); |
||||
} |
||||
|
||||
void showHandles() { |
||||
assert(_handles == null); |
||||
_handles = <OverlayEntry>[ |
||||
OverlayEntry( |
||||
builder: (context) => |
||||
_buildHandle(context, _TextSelectionHandlePosition.START)), |
||||
OverlayEntry( |
||||
builder: (context) => |
||||
_buildHandle(context, _TextSelectionHandlePosition.END)), |
||||
]; |
||||
|
||||
Overlay.of(context, rootOverlay: true, debugRequiredFor: debugRequiredFor)! |
||||
.insertAll(_handles!); |
||||
} |
||||
} |
||||
|
||||
class _TextSelectionHandleOverlay extends StatefulWidget { |
||||
const _TextSelectionHandleOverlay({ |
||||
required this.selection, |
||||
required this.position, |
||||
required this.startHandleLayerLink, |
||||
required this.endHandleLayerLink, |
||||
required this.renderObject, |
||||
required this.onSelectionHandleChanged, |
||||
required this.onSelectionHandleTapped, |
||||
required this.selectionControls, |
||||
this.dragStartBehavior = DragStartBehavior.start, |
||||
Key? key, |
||||
}) : super(key: key); |
||||
|
||||
final TextSelection selection; |
||||
final _TextSelectionHandlePosition position; |
||||
final LayerLink startHandleLayerLink; |
||||
final LayerLink endHandleLayerLink; |
||||
final RenderEditor? renderObject; |
||||
final ValueChanged<TextSelection?> onSelectionHandleChanged; |
||||
final VoidCallback? onSelectionHandleTapped; |
||||
final TextSelectionControls selectionControls; |
||||
final DragStartBehavior dragStartBehavior; |
||||
|
||||
@override |
||||
_TextSelectionHandleOverlayState createState() => |
||||
_TextSelectionHandleOverlayState(); |
||||
|
||||
ValueListenable<bool>? get _visibility { |
||||
switch (position) { |
||||
case _TextSelectionHandlePosition.START: |
||||
return renderObject!.selectionStartInViewport; |
||||
case _TextSelectionHandlePosition.END: |
||||
return renderObject!.selectionEndInViewport; |
||||
} |
||||
} |
||||
} |
||||
|
||||
class _TextSelectionHandleOverlayState |
||||
extends State<_TextSelectionHandleOverlay> |
||||
with SingleTickerProviderStateMixin { |
||||
late AnimationController _controller; |
||||
|
||||
Animation<double> get _opacity => _controller.view; |
||||
|
||||
@override |
||||
void initState() { |
||||
super.initState(); |
||||
|
||||
_controller = AnimationController( |
||||
duration: const Duration(milliseconds: 150), vsync: this); |
||||
|
||||
_handleVisibilityChanged(); |
||||
widget._visibility!.addListener(_handleVisibilityChanged); |
||||
} |
||||
|
||||
void _handleVisibilityChanged() { |
||||
if (widget._visibility!.value) { |
||||
_controller.forward(); |
||||
} else { |
||||
_controller.reverse(); |
||||
} |
||||
} |
||||
|
||||
@override |
||||
void didUpdateWidget(_TextSelectionHandleOverlay oldWidget) { |
||||
super.didUpdateWidget(oldWidget); |
||||
oldWidget._visibility!.removeListener(_handleVisibilityChanged); |
||||
_handleVisibilityChanged(); |
||||
widget._visibility!.addListener(_handleVisibilityChanged); |
||||
} |
||||
|
||||
@override |
||||
void dispose() { |
||||
widget._visibility!.removeListener(_handleVisibilityChanged); |
||||
_controller.dispose(); |
||||
super.dispose(); |
||||
} |
||||
|
||||
void _handleDragStart(DragStartDetails details) {} |
||||
|
||||
void _handleDragUpdate(DragUpdateDetails details) { |
||||
final position = |
||||
widget.renderObject!.getPositionForOffset(details.globalPosition); |
||||
if (widget.selection.isCollapsed) { |
||||
widget.onSelectionHandleChanged(TextSelection.fromPosition(position)); |
||||
return; |
||||
} |
||||
|
||||
final isNormalized = |
||||
widget.selection.extentOffset >= widget.selection.baseOffset; |
||||
TextSelection? newSelection; |
||||
switch (widget.position) { |
||||
case _TextSelectionHandlePosition.START: |
||||
newSelection = TextSelection( |
||||
baseOffset: |
||||
isNormalized ? position.offset : widget.selection.baseOffset, |
||||
extentOffset: |
||||
isNormalized ? widget.selection.extentOffset : position.offset, |
||||
); |
||||
break; |
||||
case _TextSelectionHandlePosition.END: |
||||
newSelection = TextSelection( |
||||
baseOffset: |
||||
isNormalized ? widget.selection.baseOffset : position.offset, |
||||
extentOffset: |
||||
isNormalized ? position.offset : widget.selection.extentOffset, |
||||
); |
||||
break; |
||||
} |
||||
|
||||
widget.onSelectionHandleChanged(newSelection); |
||||
} |
||||
|
||||
void _handleTap() { |
||||
if (widget.onSelectionHandleTapped != null) { |
||||
widget.onSelectionHandleTapped!(); |
||||
} |
||||
} |
||||
|
||||
@override |
||||
Widget build(BuildContext context) { |
||||
late LayerLink layerLink; |
||||
TextSelectionHandleType? type; |
||||
|
||||
switch (widget.position) { |
||||
case _TextSelectionHandlePosition.START: |
||||
layerLink = widget.startHandleLayerLink; |
||||
type = _chooseType( |
||||
widget.renderObject!.textDirection, |
||||
TextSelectionHandleType.left, |
||||
TextSelectionHandleType.right, |
||||
); |
||||
break; |
||||
case _TextSelectionHandlePosition.END: |
||||
assert(!widget.selection.isCollapsed); |
||||
layerLink = widget.endHandleLayerLink; |
||||
type = _chooseType( |
||||
widget.renderObject!.textDirection, |
||||
TextSelectionHandleType.right, |
||||
TextSelectionHandleType.left, |
||||
); |
||||
break; |
||||
} |
||||
|
||||
final textPosition = widget.position == _TextSelectionHandlePosition.START |
||||
? widget.selection.base |
||||
: widget.selection.extent; |
||||
final lineHeight = widget.renderObject!.preferredLineHeight(textPosition); |
||||
final handleAnchor = |
||||
widget.selectionControls.getHandleAnchor(type!, lineHeight); |
||||
final handleSize = widget.selectionControls.getHandleSize(lineHeight); |
||||
|
||||
final handleRect = Rect.fromLTWH( |
||||
-handleAnchor.dx, |
||||
-handleAnchor.dy, |
||||
handleSize.width, |
||||
handleSize.height, |
||||
); |
||||
|
||||
final interactiveRect = handleRect.expandToInclude( |
||||
Rect.fromCircle( |
||||
center: handleRect.center, radius: kMinInteractiveDimension / 2), |
||||
); |
||||
final padding = RelativeRect.fromLTRB( |
||||
math.max((interactiveRect.width - handleRect.width) / 2, 0), |
||||
math.max((interactiveRect.height - handleRect.height) / 2, 0), |
||||
math.max((interactiveRect.width - handleRect.width) / 2, 0), |
||||
math.max((interactiveRect.height - handleRect.height) / 2, 0), |
||||
); |
||||
|
||||
return CompositedTransformFollower( |
||||
link: layerLink, |
||||
offset: interactiveRect.topLeft, |
||||
showWhenUnlinked: false, |
||||
child: FadeTransition( |
||||
opacity: _opacity, |
||||
child: Container( |
||||
alignment: Alignment.topLeft, |
||||
width: interactiveRect.width, |
||||
height: interactiveRect.height, |
||||
child: GestureDetector( |
||||
behavior: HitTestBehavior.translucent, |
||||
dragStartBehavior: widget.dragStartBehavior, |
||||
onPanStart: _handleDragStart, |
||||
onPanUpdate: _handleDragUpdate, |
||||
onTap: _handleTap, |
||||
child: Padding( |
||||
padding: EdgeInsets.only( |
||||
left: padding.left, |
||||
top: padding.top, |
||||
right: padding.right, |
||||
bottom: padding.bottom, |
||||
), |
||||
child: widget.selectionControls.buildHandle( |
||||
context, |
||||
type, |
||||
lineHeight, |
||||
), |
||||
), |
||||
), |
||||
), |
||||
), |
||||
); |
||||
} |
||||
|
||||
TextSelectionHandleType? _chooseType( |
||||
TextDirection textDirection, |
||||
TextSelectionHandleType ltrType, |
||||
TextSelectionHandleType rtlType, |
||||
) { |
||||
if (widget.selection.isCollapsed) return TextSelectionHandleType.collapsed; |
||||
|
||||
switch (textDirection) { |
||||
case TextDirection.ltr: |
||||
return ltrType; |
||||
case TextDirection.rtl: |
||||
return rtlType; |
||||
} |
||||
} |
||||
} |
||||
|
||||
class EditorTextSelectionGestureDetector extends StatefulWidget { |
||||
const EditorTextSelectionGestureDetector({ |
||||
required this.child, |
||||
this.onTapDown, |
||||
this.onForcePressStart, |
||||
this.onForcePressEnd, |
||||
this.onSingleTapUp, |
||||
this.onSingleTapCancel, |
||||
this.onSingleLongTapStart, |
||||
this.onSingleLongTapMoveUpdate, |
||||
this.onSingleLongTapEnd, |
||||
this.onDoubleTapDown, |
||||
this.onDragSelectionStart, |
||||
this.onDragSelectionUpdate, |
||||
this.onDragSelectionEnd, |
||||
this.behavior, |
||||
Key? key, |
||||
}) : super(key: key); |
||||
|
||||
final GestureTapDownCallback? onTapDown; |
||||
|
||||
final GestureForcePressStartCallback? onForcePressStart; |
||||
|
||||
final GestureForcePressEndCallback? onForcePressEnd; |
||||
|
||||
final GestureTapUpCallback? onSingleTapUp; |
||||
|
||||
final GestureTapCancelCallback? onSingleTapCancel; |
||||
|
||||
final GestureLongPressStartCallback? onSingleLongTapStart; |
||||
|
||||
final GestureLongPressMoveUpdateCallback? onSingleLongTapMoveUpdate; |
||||
|
||||
final GestureLongPressEndCallback? onSingleLongTapEnd; |
||||
|
||||
final GestureTapDownCallback? onDoubleTapDown; |
||||
|
||||
final GestureDragStartCallback? onDragSelectionStart; |
||||
|
||||
final DragSelectionUpdateCallback? onDragSelectionUpdate; |
||||
|
||||
final GestureDragEndCallback? onDragSelectionEnd; |
||||
|
||||
final HitTestBehavior? behavior; |
||||
|
||||
final Widget child; |
||||
|
||||
@override |
||||
State<StatefulWidget> createState() => |
||||
_EditorTextSelectionGestureDetectorState(); |
||||
} |
||||
|
||||
class _EditorTextSelectionGestureDetectorState |
||||
extends State<EditorTextSelectionGestureDetector> { |
||||
Timer? _doubleTapTimer; |
||||
Offset? _lastTapOffset; |
||||
bool _isDoubleTap = false; |
||||
|
||||
@override |
||||
void dispose() { |
||||
_doubleTapTimer?.cancel(); |
||||
_dragUpdateThrottleTimer?.cancel(); |
||||
super.dispose(); |
||||
} |
||||
|
||||
void _handleTapDown(TapDownDetails details) { |
||||
// renderObject.resetTapDownStatus(); |
||||
if (widget.onTapDown != null) { |
||||
widget.onTapDown!(details); |
||||
} |
||||
if (_doubleTapTimer != null && |
||||
_isWithinDoubleTapTolerance(details.globalPosition)) { |
||||
if (widget.onDoubleTapDown != null) { |
||||
widget.onDoubleTapDown!(details); |
||||
} |
||||
|
||||
_doubleTapTimer!.cancel(); |
||||
_doubleTapTimeout(); |
||||
_isDoubleTap = true; |
||||
} |
||||
} |
||||
|
||||
void _handleTapUp(TapUpDetails details) { |
||||
if (!_isDoubleTap) { |
||||
if (widget.onSingleTapUp != null) { |
||||
widget.onSingleTapUp!(details); |
||||
} |
||||
_lastTapOffset = details.globalPosition; |
||||
_doubleTapTimer = Timer(kDoubleTapTimeout, _doubleTapTimeout); |
||||
} |
||||
_isDoubleTap = false; |
||||
} |
||||
|
||||
void _handleTapCancel() { |
||||
if (widget.onSingleTapCancel != null) { |
||||
widget.onSingleTapCancel!(); |
||||
} |
||||
} |
||||
|
||||
DragStartDetails? _lastDragStartDetails; |
||||
DragUpdateDetails? _lastDragUpdateDetails; |
||||
Timer? _dragUpdateThrottleTimer; |
||||
|
||||
void _handleDragStart(DragStartDetails details) { |
||||
assert(_lastDragStartDetails == null); |
||||
_lastDragStartDetails = details; |
||||
if (widget.onDragSelectionStart != null) { |
||||
widget.onDragSelectionStart!(details); |
||||
} |
||||
} |
||||
|
||||
void _handleDragUpdate(DragUpdateDetails details) { |
||||
_lastDragUpdateDetails = details; |
||||
_dragUpdateThrottleTimer ??= |
||||
Timer(const Duration(milliseconds: 50), _handleDragUpdateThrottled); |
||||
} |
||||
|
||||
void _handleDragUpdateThrottled() { |
||||
assert(_lastDragStartDetails != null); |
||||
assert(_lastDragUpdateDetails != null); |
||||
if (widget.onDragSelectionUpdate != null) { |
||||
widget.onDragSelectionUpdate!( |
||||
_lastDragStartDetails!, _lastDragUpdateDetails!); |
||||
} |
||||
_dragUpdateThrottleTimer = null; |
||||
_lastDragUpdateDetails = null; |
||||
} |
||||
|
||||
void _handleDragEnd(DragEndDetails details) { |
||||
assert(_lastDragStartDetails != null); |
||||
if (_dragUpdateThrottleTimer != null) { |
||||
_dragUpdateThrottleTimer!.cancel(); |
||||
_handleDragUpdateThrottled(); |
||||
} |
||||
if (widget.onDragSelectionEnd != null) { |
||||
widget.onDragSelectionEnd!(details); |
||||
} |
||||
_dragUpdateThrottleTimer = null; |
||||
_lastDragStartDetails = null; |
||||
_lastDragUpdateDetails = null; |
||||
} |
||||
|
||||
void _forcePressStarted(ForcePressDetails details) { |
||||
_doubleTapTimer?.cancel(); |
||||
_doubleTapTimer = null; |
||||
if (widget.onForcePressStart != null) { |
||||
widget.onForcePressStart!(details); |
||||
} |
||||
} |
||||
|
||||
void _forcePressEnded(ForcePressDetails details) { |
||||
if (widget.onForcePressEnd != null) { |
||||
widget.onForcePressEnd!(details); |
||||
} |
||||
} |
||||
|
||||
void _handleLongPressStart(LongPressStartDetails details) { |
||||
if (!_isDoubleTap && widget.onSingleLongTapStart != null) { |
||||
widget.onSingleLongTapStart!(details); |
||||
} |
||||
} |
||||
|
||||
void _handleLongPressMoveUpdate(LongPressMoveUpdateDetails details) { |
||||
if (!_isDoubleTap && widget.onSingleLongTapMoveUpdate != null) { |
||||
widget.onSingleLongTapMoveUpdate!(details); |
||||
} |
||||
} |
||||
|
||||
void _handleLongPressEnd(LongPressEndDetails details) { |
||||
if (!_isDoubleTap && widget.onSingleLongTapEnd != null) { |
||||
widget.onSingleLongTapEnd!(details); |
||||
} |
||||
_isDoubleTap = false; |
||||
} |
||||
|
||||
void _doubleTapTimeout() { |
||||
_doubleTapTimer = null; |
||||
_lastTapOffset = null; |
||||
} |
||||
|
||||
bool _isWithinDoubleTapTolerance(Offset secondTapOffset) { |
||||
if (_lastTapOffset == null) { |
||||
return false; |
||||
} |
||||
|
||||
return (secondTapOffset - _lastTapOffset!).distance <= kDoubleTapSlop; |
||||
} |
||||
|
||||
@override |
||||
Widget build(BuildContext context) { |
||||
final gestures = <Type, GestureRecognizerFactory>{}; |
||||
|
||||
gestures[TapGestureRecognizer] = |
||||
GestureRecognizerFactoryWithHandlers<TapGestureRecognizer>( |
||||
() => TapGestureRecognizer(debugOwner: this), |
||||
(instance) { |
||||
instance |
||||
..onTapDown = _handleTapDown |
||||
..onTapUp = _handleTapUp |
||||
..onTapCancel = _handleTapCancel; |
||||
}, |
||||
); |
||||
|
||||
if (widget.onSingleLongTapStart != null || |
||||
widget.onSingleLongTapMoveUpdate != null || |
||||
widget.onSingleLongTapEnd != null) { |
||||
gestures[LongPressGestureRecognizer] = |
||||
GestureRecognizerFactoryWithHandlers<LongPressGestureRecognizer>( |
||||
() => LongPressGestureRecognizer( |
||||
debugOwner: this, kind: PointerDeviceKind.touch), |
||||
(instance) { |
||||
instance |
||||
..onLongPressStart = _handleLongPressStart |
||||
..onLongPressMoveUpdate = _handleLongPressMoveUpdate |
||||
..onLongPressEnd = _handleLongPressEnd; |
||||
}, |
||||
); |
||||
} |
||||
|
||||
if (widget.onDragSelectionStart != null || |
||||
widget.onDragSelectionUpdate != null || |
||||
widget.onDragSelectionEnd != null) { |
||||
gestures[HorizontalDragGestureRecognizer] = |
||||
GestureRecognizerFactoryWithHandlers<HorizontalDragGestureRecognizer>( |
||||
() => HorizontalDragGestureRecognizer( |
||||
debugOwner: this, kind: PointerDeviceKind.mouse), |
||||
(instance) { |
||||
instance |
||||
..dragStartBehavior = DragStartBehavior.down |
||||
..onStart = _handleDragStart |
||||
..onUpdate = _handleDragUpdate |
||||
..onEnd = _handleDragEnd; |
||||
}, |
||||
); |
||||
} |
||||
|
||||
if (widget.onForcePressStart != null || widget.onForcePressEnd != null) { |
||||
gestures[ForcePressGestureRecognizer] = |
||||
GestureRecognizerFactoryWithHandlers<ForcePressGestureRecognizer>( |
||||
() => ForcePressGestureRecognizer(debugOwner: this), |
||||
(instance) { |
||||
instance |
||||
..onStart = |
||||
widget.onForcePressStart != null ? _forcePressStarted : null |
||||
..onEnd = widget.onForcePressEnd != null ? _forcePressEnded : null; |
||||
}, |
||||
); |
||||
} |
||||
|
||||
return RawGestureDetector( |
||||
gestures: gestures, |
||||
excludeFromSemantics: true, |
||||
behavior: widget.behavior, |
||||
child: widget.child, |
||||
); |
||||
} |
||||
} |
File diff suppressed because it is too large
Load Diff
@ -1,125 +1,3 @@ |
||||
import 'dart:ui'; |
||||
|
||||
import 'package:flutter/material.dart'; |
||||
|
||||
Color stringToColor(String? s) { |
||||
switch (s) { |
||||
case 'transparent': |
||||
return Colors.transparent; |
||||
case 'black': |
||||
return Colors.black; |
||||
case 'black12': |
||||
return Colors.black12; |
||||
case 'black26': |
||||
return Colors.black26; |
||||
case 'black38': |
||||
return Colors.black38; |
||||
case 'black45': |
||||
return Colors.black45; |
||||
case 'black54': |
||||
return Colors.black54; |
||||
case 'black87': |
||||
return Colors.black87; |
||||
case 'white': |
||||
return Colors.white; |
||||
case 'white10': |
||||
return Colors.white10; |
||||
case 'white12': |
||||
return Colors.white12; |
||||
case 'white24': |
||||
return Colors.white24; |
||||
case 'white30': |
||||
return Colors.white30; |
||||
case 'white38': |
||||
return Colors.white38; |
||||
case 'white54': |
||||
return Colors.white54; |
||||
case 'white60': |
||||
return Colors.white60; |
||||
case 'white70': |
||||
return Colors.white70; |
||||
case 'red': |
||||
return Colors.red; |
||||
case 'redAccent': |
||||
return Colors.redAccent; |
||||
case 'amber': |
||||
return Colors.amber; |
||||
case 'amberAccent': |
||||
return Colors.amberAccent; |
||||
case 'yellow': |
||||
return Colors.yellow; |
||||
case 'yellowAccent': |
||||
return Colors.yellowAccent; |
||||
case 'teal': |
||||
return Colors.teal; |
||||
case 'tealAccent': |
||||
return Colors.tealAccent; |
||||
case 'purple': |
||||
return Colors.purple; |
||||
case 'purpleAccent': |
||||
return Colors.purpleAccent; |
||||
case 'pink': |
||||
return Colors.pink; |
||||
case 'pinkAccent': |
||||
return Colors.pinkAccent; |
||||
case 'orange': |
||||
return Colors.orange; |
||||
case 'orangeAccent': |
||||
return Colors.orangeAccent; |
||||
case 'deepOrange': |
||||
return Colors.deepOrange; |
||||
case 'deepOrangeAccent': |
||||
return Colors.deepOrangeAccent; |
||||
case 'indigo': |
||||
return Colors.indigo; |
||||
case 'indigoAccent': |
||||
return Colors.indigoAccent; |
||||
case 'lime': |
||||
return Colors.lime; |
||||
case 'limeAccent': |
||||
return Colors.limeAccent; |
||||
case 'grey': |
||||
return Colors.grey; |
||||
case 'blueGrey': |
||||
return Colors.blueGrey; |
||||
case 'green': |
||||
return Colors.green; |
||||
case 'greenAccent': |
||||
return Colors.greenAccent; |
||||
case 'lightGreen': |
||||
return Colors.lightGreen; |
||||
case 'lightGreenAccent': |
||||
return Colors.lightGreenAccent; |
||||
case 'blue': |
||||
return Colors.blue; |
||||
case 'blueAccent': |
||||
return Colors.blueAccent; |
||||
case 'lightBlue': |
||||
return Colors.lightBlue; |
||||
case 'lightBlueAccent': |
||||
return Colors.lightBlueAccent; |
||||
case 'cyan': |
||||
return Colors.cyan; |
||||
case 'cyanAccent': |
||||
return Colors.cyanAccent; |
||||
case 'brown': |
||||
return Colors.brown; |
||||
} |
||||
|
||||
if (s!.startsWith('rgba')) { |
||||
s = s.substring(5); // trim left 'rgba(' |
||||
s = s.substring(0, s.length - 1); // trim right ')' |
||||
final arr = s.split(',').map((e) => e.trim()).toList(); |
||||
return Color.fromRGBO(int.parse(arr[0]), int.parse(arr[1]), |
||||
int.parse(arr[2]), double.parse(arr[3])); |
||||
} |
||||
|
||||
if (!s.startsWith('#')) { |
||||
throw 'Color code not supported'; |
||||
} |
||||
|
||||
var hex = s.replaceFirst('#', ''); |
||||
hex = hex.length == 6 ? 'ff$hex' : hex; |
||||
final val = int.parse(hex, radix: 16); |
||||
return Color(val); |
||||
} |
||||
/// TODO: Remove this file in the next breaking release, because implementation |
||||
/// files should be located in the src folder, https://bit.ly/3fA23Yz. |
||||
export '../../../src/utils/color.dart'; |
||||
|
@ -1,102 +1,3 @@ |
||||
import 'dart:math' as math; |
||||
|
||||
import '../models/quill_delta.dart'; |
||||
|
||||
const Set<int> WHITE_SPACE = { |
||||
0x9, |
||||
0xA, |
||||
0xB, |
||||
0xC, |
||||
0xD, |
||||
0x1C, |
||||
0x1D, |
||||
0x1E, |
||||
0x1F, |
||||
0x20, |
||||
0xA0, |
||||
0x1680, |
||||
0x2000, |
||||
0x2001, |
||||
0x2002, |
||||
0x2003, |
||||
0x2004, |
||||
0x2005, |
||||
0x2006, |
||||
0x2007, |
||||
0x2008, |
||||
0x2009, |
||||
0x200A, |
||||
0x202F, |
||||
0x205F, |
||||
0x3000 |
||||
}; |
||||
|
||||
// Diff between two texts - old text and new text |
||||
class Diff { |
||||
Diff(this.start, this.deleted, this.inserted); |
||||
|
||||
// Start index in old text at which changes begin. |
||||
final int start; |
||||
|
||||
/// The deleted text |
||||
final String deleted; |
||||
|
||||
// The inserted text |
||||
final String inserted; |
||||
|
||||
@override |
||||
String toString() { |
||||
return 'Diff[$start, "$deleted", "$inserted"]'; |
||||
} |
||||
} |
||||
|
||||
/* Get diff operation between old text and new text */ |
||||
Diff getDiff(String oldText, String newText, int cursorPosition) { |
||||
var end = oldText.length; |
||||
final delta = newText.length - end; |
||||
for (final limit = math.max(0, cursorPosition - delta); |
||||
end > limit && oldText[end - 1] == newText[end + delta - 1]; |
||||
end--) {} |
||||
var start = 0; |
||||
for (final startLimit = cursorPosition - math.max(0, delta); |
||||
start < startLimit && oldText[start] == newText[start]; |
||||
start++) {} |
||||
final deleted = (start >= end) ? '' : oldText.substring(start, end); |
||||
final inserted = newText.substring(start, end + delta); |
||||
return Diff(start, deleted, inserted); |
||||
} |
||||
|
||||
int getPositionDelta(Delta user, Delta actual) { |
||||
if (actual.isEmpty) { |
||||
return 0; |
||||
} |
||||
|
||||
final userItr = DeltaIterator(user); |
||||
final actualItr = DeltaIterator(actual); |
||||
var diff = 0; |
||||
while (userItr.hasNext || actualItr.hasNext) { |
||||
final length = math.min(userItr.peekLength(), actualItr.peekLength()); |
||||
final userOperation = userItr.next(length as int); |
||||
final actualOperation = actualItr.next(length); |
||||
if (userOperation.length != actualOperation.length) { |
||||
throw 'userOp ${userOperation.length} does not match actualOp ${actualOperation.length}'; |
||||
} |
||||
if (userOperation.key == actualOperation.key) { |
||||
continue; |
||||
} else if (userOperation.isInsert && actualOperation.isRetain) { |
||||
diff -= userOperation.length!; |
||||
} else if (userOperation.isDelete && actualOperation.isRetain) { |
||||
diff += userOperation.length!; |
||||
} else if (userOperation.isRetain && actualOperation.isInsert) { |
||||
String? operationTxt = ''; |
||||
if (actualOperation.data is String) { |
||||
operationTxt = actualOperation.data as String?; |
||||
} |
||||
if (operationTxt!.startsWith('\n')) { |
||||
continue; |
||||
} |
||||
diff += actualOperation.length!; |
||||
} |
||||
} |
||||
return diff; |
||||
} |
||||
/// TODO: Remove this file in the next breaking release, because implementation |
||||
/// files should be located in the src folder, https://bit.ly/3fA23Yz. |
||||
export '../../../src/utils/diff_delta.dart'; |
||||
|
@ -1,39 +1,3 @@ |
||||
import 'package:flutter/rendering.dart'; |
||||
|
||||
import '../models/documents/nodes/container.dart'; |
||||
|
||||
abstract class RenderContentProxyBox implements RenderBox { |
||||
double getPreferredLineHeight(); |
||||
|
||||
Offset getOffsetForCaret(TextPosition position, Rect? caretPrototype); |
||||
|
||||
TextPosition getPositionForOffset(Offset offset); |
||||
|
||||
double? getFullHeightForCaret(TextPosition position); |
||||
|
||||
TextRange getWordBoundary(TextPosition position); |
||||
|
||||
List<TextBox> getBoxesForSelection(TextSelection textSelection); |
||||
} |
||||
|
||||
abstract class RenderEditableBox extends RenderBox { |
||||
Container getContainer(); |
||||
|
||||
double preferredLineHeight(TextPosition position); |
||||
|
||||
Offset getOffsetForCaret(TextPosition position); |
||||
|
||||
TextPosition getPositionForOffset(Offset offset); |
||||
|
||||
TextPosition? getPositionAbove(TextPosition position); |
||||
|
||||
TextPosition? getPositionBelow(TextPosition position); |
||||
|
||||
TextRange getWordBoundary(TextPosition position); |
||||
|
||||
TextRange getLineBoundary(TextPosition position); |
||||
|
||||
TextSelectionPoint getBaseEndpointForSelection(TextSelection textSelection); |
||||
|
||||
TextSelectionPoint getExtentEndpointForSelection(TextSelection textSelection); |
||||
} |
||||
/// TODO: Remove this file in the next breaking release, because implementation |
||||
/// files should be located in the src folder, https://bit.ly/3fA23Yz. |
||||
export '../../src/widgets/box.dart'; |
||||
|
@ -1,228 +1,3 @@ |
||||
import 'dart:math' as math; |
||||
|
||||
import 'package:flutter/cupertino.dart'; |
||||
import 'package:tuple/tuple.dart'; |
||||
|
||||
import '../models/documents/attribute.dart'; |
||||
import '../models/documents/document.dart'; |
||||
import '../models/documents/nodes/embed.dart'; |
||||
import '../models/documents/style.dart'; |
||||
import '../models/quill_delta.dart'; |
||||
import '../utils/diff_delta.dart'; |
||||
|
||||
class QuillController extends ChangeNotifier { |
||||
QuillController( |
||||
{required this.document, |
||||
required this.selection, |
||||
this.iconSize = 18, |
||||
this.toolbarHeightFactor = 2}); |
||||
|
||||
factory QuillController.basic() { |
||||
return QuillController( |
||||
document: Document(), |
||||
selection: const TextSelection.collapsed(offset: 0), |
||||
); |
||||
} |
||||
|
||||
final Document document; |
||||
TextSelection selection; |
||||
double iconSize; |
||||
double toolbarHeightFactor; |
||||
|
||||
Style toggledStyle = Style(); |
||||
bool ignoreFocusOnTextChange = false; |
||||
|
||||
/// Controls whether this [QuillController] instance has already been disposed |
||||
/// of |
||||
/// |
||||
/// This is a safe approach to make sure that listeners don't crash when |
||||
/// adding, removing or listeners to this instance. |
||||
bool _isDisposed = false; |
||||
|
||||
// item1: Document state before [change]. |
||||
// |
||||
// item2: Change delta applied to the document. |
||||
// |
||||
// item3: The source of this change. |
||||
Stream<Tuple3<Delta, Delta, ChangeSource>> get changes => document.changes; |
||||
|
||||
TextEditingValue get plainTextEditingValue => TextEditingValue( |
||||
text: document.toPlainText(), |
||||
selection: selection, |
||||
); |
||||
|
||||
Style getSelectionStyle() { |
||||
return document |
||||
.collectStyle(selection.start, selection.end - selection.start) |
||||
.mergeAll(toggledStyle); |
||||
} |
||||
|
||||
void undo() { |
||||
final tup = document.undo(); |
||||
if (tup.item1) { |
||||
_handleHistoryChange(tup.item2); |
||||
} |
||||
} |
||||
|
||||
void _handleHistoryChange(int? len) { |
||||
if (len != 0) { |
||||
// if (this.selection.extentOffset >= document.length) { |
||||
// // cursor exceeds the length of document, position it in the end |
||||
// updateSelection( |
||||
// TextSelection.collapsed(offset: document.length), ChangeSource.LOCAL); |
||||
updateSelection( |
||||
TextSelection.collapsed(offset: selection.baseOffset + len!), |
||||
ChangeSource.LOCAL); |
||||
} else { |
||||
// no need to move cursor |
||||
notifyListeners(); |
||||
} |
||||
} |
||||
|
||||
void redo() { |
||||
final tup = document.redo(); |
||||
if (tup.item1) { |
||||
_handleHistoryChange(tup.item2); |
||||
} |
||||
} |
||||
|
||||
bool get hasUndo => document.hasUndo; |
||||
|
||||
bool get hasRedo => document.hasRedo; |
||||
|
||||
void replaceText( |
||||
int index, int len, Object? data, TextSelection? textSelection, |
||||
{bool ignoreFocus = false, bool autoAppendNewlineAfterImage = true}) { |
||||
assert(data is String || data is Embeddable); |
||||
|
||||
Delta? delta; |
||||
if (len > 0 || data is! String || data.isNotEmpty) { |
||||
delta = document.replace(index, len, data, autoAppendNewlineAfterImage: autoAppendNewlineAfterImage); |
||||
var shouldRetainDelta = toggledStyle.isNotEmpty && |
||||
delta.isNotEmpty && |
||||
delta.length <= 2 && |
||||
delta.last.isInsert; |
||||
if (shouldRetainDelta && |
||||
toggledStyle.isNotEmpty && |
||||
delta.length == 2 && |
||||
delta.last.data == '\n') { |
||||
// if all attributes are inline, shouldRetainDelta should be false |
||||
final anyAttributeNotInline = |
||||
toggledStyle.values.any((attr) => !attr.isInline); |
||||
if (!anyAttributeNotInline) { |
||||
shouldRetainDelta = false; |
||||
} |
||||
} |
||||
if (shouldRetainDelta) { |
||||
final retainDelta = Delta() |
||||
..retain(index) |
||||
..retain(data is String ? data.length : 1, toggledStyle.toJson()); |
||||
document.compose(retainDelta, ChangeSource.LOCAL); |
||||
} |
||||
} |
||||
|
||||
toggledStyle = Style(); |
||||
if (textSelection != null) { |
||||
if (delta == null || delta.isEmpty) { |
||||
_updateSelection(textSelection, ChangeSource.LOCAL); |
||||
} else { |
||||
final user = Delta() |
||||
..retain(index) |
||||
..insert(data) |
||||
..delete(len); |
||||
final positionDelta = getPositionDelta(user, delta); |
||||
_updateSelection( |
||||
textSelection.copyWith( |
||||
baseOffset: textSelection.baseOffset + positionDelta, |
||||
extentOffset: textSelection.extentOffset + positionDelta, |
||||
), |
||||
ChangeSource.LOCAL, |
||||
); |
||||
} |
||||
} |
||||
|
||||
if (ignoreFocus) { |
||||
ignoreFocusOnTextChange = true; |
||||
} |
||||
notifyListeners(); |
||||
ignoreFocusOnTextChange = false; |
||||
} |
||||
|
||||
void formatText(int index, int len, Attribute? attribute) { |
||||
if (len == 0 && |
||||
attribute!.isInline && |
||||
attribute.key != Attribute.link.key) { |
||||
toggledStyle = toggledStyle.put(attribute); |
||||
} |
||||
|
||||
final change = document.format(index, len, attribute); |
||||
final adjustedSelection = selection.copyWith( |
||||
baseOffset: change.transformPosition(selection.baseOffset), |
||||
extentOffset: change.transformPosition(selection.extentOffset)); |
||||
if (selection != adjustedSelection) { |
||||
_updateSelection(adjustedSelection, ChangeSource.LOCAL); |
||||
} |
||||
notifyListeners(); |
||||
} |
||||
|
||||
void formatSelection(Attribute? attribute) { |
||||
formatText(selection.start, selection.end - selection.start, attribute); |
||||
} |
||||
|
||||
void updateSelection(TextSelection textSelection, ChangeSource source) { |
||||
_updateSelection(textSelection, source); |
||||
notifyListeners(); |
||||
} |
||||
|
||||
void compose(Delta delta, TextSelection textSelection, ChangeSource source) { |
||||
if (delta.isNotEmpty) { |
||||
document.compose(delta, source); |
||||
} |
||||
|
||||
textSelection = selection.copyWith( |
||||
baseOffset: delta.transformPosition(selection.baseOffset, force: false), |
||||
extentOffset: |
||||
delta.transformPosition(selection.extentOffset, force: false)); |
||||
if (selection != textSelection) { |
||||
_updateSelection(textSelection, source); |
||||
} |
||||
|
||||
notifyListeners(); |
||||
} |
||||
|
||||
@override |
||||
void addListener(VoidCallback listener) { |
||||
// By using `_isDisposed`, make sure that `addListener` won't be called on a |
||||
// disposed `ChangeListener` |
||||
if (!_isDisposed) { |
||||
super.addListener(listener); |
||||
} |
||||
} |
||||
|
||||
@override |
||||
void removeListener(VoidCallback listener) { |
||||
// By using `_isDisposed`, make sure that `removeListener` won't be called |
||||
// on a disposed `ChangeListener` |
||||
if (!_isDisposed) { |
||||
super.removeListener(listener); |
||||
} |
||||
} |
||||
|
||||
@override |
||||
void dispose() { |
||||
if (!_isDisposed) { |
||||
document.close(); |
||||
} |
||||
|
||||
_isDisposed = true; |
||||
super.dispose(); |
||||
} |
||||
|
||||
void _updateSelection(TextSelection textSelection, ChangeSource source) { |
||||
selection = textSelection; |
||||
final end = document.length - 1; |
||||
selection = selection.copyWith( |
||||
baseOffset: math.min(selection.baseOffset, end), |
||||
extentOffset: math.min(selection.extentOffset, end)); |
||||
} |
||||
} |
||||
/// TODO: Remove this file in the next breaking release, because implementation |
||||
/// files should be located in the src folder, https://bit.ly/3fA23Yz. |
||||
export '../../src/widgets/controller.dart'; |
||||
|
@ -1,231 +1,3 @@ |
||||
import 'dart:async'; |
||||
|
||||
import 'package:flutter/foundation.dart'; |
||||
import 'package:flutter/widgets.dart'; |
||||
|
||||
import 'box.dart'; |
||||
|
||||
const Duration _FADE_DURATION = Duration(milliseconds: 250); |
||||
|
||||
class CursorStyle { |
||||
const CursorStyle({ |
||||
required this.color, |
||||
required this.backgroundColor, |
||||
this.width = 1.0, |
||||
this.height, |
||||
this.radius, |
||||
this.offset, |
||||
this.opacityAnimates = false, |
||||
this.paintAboveText = false, |
||||
}); |
||||
|
||||
final Color color; |
||||
final Color backgroundColor; |
||||
final double width; |
||||
final double? height; |
||||
final Radius? radius; |
||||
final Offset? offset; |
||||
final bool opacityAnimates; |
||||
final bool paintAboveText; |
||||
|
||||
@override |
||||
bool operator ==(Object other) => |
||||
identical(this, other) || |
||||
other is CursorStyle && |
||||
runtimeType == other.runtimeType && |
||||
color == other.color && |
||||
backgroundColor == other.backgroundColor && |
||||
width == other.width && |
||||
height == other.height && |
||||
radius == other.radius && |
||||
offset == other.offset && |
||||
opacityAnimates == other.opacityAnimates && |
||||
paintAboveText == other.paintAboveText; |
||||
|
||||
@override |
||||
int get hashCode => |
||||
color.hashCode ^ |
||||
backgroundColor.hashCode ^ |
||||
width.hashCode ^ |
||||
height.hashCode ^ |
||||
radius.hashCode ^ |
||||
offset.hashCode ^ |
||||
opacityAnimates.hashCode ^ |
||||
paintAboveText.hashCode; |
||||
} |
||||
|
||||
class CursorCont extends ChangeNotifier { |
||||
CursorCont({ |
||||
required this.show, |
||||
required CursorStyle style, |
||||
required TickerProvider tickerProvider, |
||||
}) : _style = style, |
||||
_blink = ValueNotifier(false), |
||||
color = ValueNotifier(style.color) { |
||||
_blinkOpacityCont = |
||||
AnimationController(vsync: tickerProvider, duration: _FADE_DURATION); |
||||
_blinkOpacityCont.addListener(_onColorTick); |
||||
} |
||||
|
||||
final ValueNotifier<bool> show; |
||||
final ValueNotifier<bool> _blink; |
||||
final ValueNotifier<Color> color; |
||||
late AnimationController _blinkOpacityCont; |
||||
Timer? _cursorTimer; |
||||
bool _targetCursorVisibility = false; |
||||
CursorStyle _style; |
||||
|
||||
ValueNotifier<bool> get cursorBlink => _blink; |
||||
|
||||
ValueNotifier<Color> get cursorColor => color; |
||||
|
||||
CursorStyle get style => _style; |
||||
|
||||
set style(CursorStyle value) { |
||||
if (_style == value) return; |
||||
_style = value; |
||||
notifyListeners(); |
||||
} |
||||
|
||||
@override |
||||
void dispose() { |
||||
_blinkOpacityCont.removeListener(_onColorTick); |
||||
stopCursorTimer(); |
||||
_blinkOpacityCont.dispose(); |
||||
assert(_cursorTimer == null); |
||||
super.dispose(); |
||||
} |
||||
|
||||
void _cursorTick(Timer timer) { |
||||
_targetCursorVisibility = !_targetCursorVisibility; |
||||
final targetOpacity = _targetCursorVisibility ? 1.0 : 0.0; |
||||
if (style.opacityAnimates) { |
||||
_blinkOpacityCont.animateTo(targetOpacity, curve: Curves.easeOut); |
||||
} else { |
||||
_blinkOpacityCont.value = targetOpacity; |
||||
} |
||||
} |
||||
|
||||
void _cursorWaitForStart(Timer timer) { |
||||
_cursorTimer?.cancel(); |
||||
_cursorTimer = |
||||
Timer.periodic(const Duration(milliseconds: 500), _cursorTick); |
||||
} |
||||
|
||||
void startCursorTimer() { |
||||
_targetCursorVisibility = true; |
||||
_blinkOpacityCont.value = 1.0; |
||||
|
||||
if (style.opacityAnimates) { |
||||
_cursorTimer = Timer.periodic( |
||||
const Duration(milliseconds: 150), _cursorWaitForStart); |
||||
} else { |
||||
_cursorTimer = |
||||
Timer.periodic(const Duration(milliseconds: 500), _cursorTick); |
||||
} |
||||
} |
||||
|
||||
void stopCursorTimer({bool resetCharTicks = true}) { |
||||
_cursorTimer?.cancel(); |
||||
_cursorTimer = null; |
||||
_targetCursorVisibility = false; |
||||
_blinkOpacityCont.value = 0.0; |
||||
|
||||
if (style.opacityAnimates) { |
||||
_blinkOpacityCont |
||||
..stop() |
||||
..value = 0.0; |
||||
} |
||||
} |
||||
|
||||
void startOrStopCursorTimerIfNeeded(bool hasFocus, TextSelection selection) { |
||||
if (show.value && |
||||
_cursorTimer == null && |
||||
hasFocus && |
||||
selection.isCollapsed) { |
||||
startCursorTimer(); |
||||
} else if (_cursorTimer != null && (!hasFocus || !selection.isCollapsed)) { |
||||
stopCursorTimer(); |
||||
} |
||||
} |
||||
|
||||
void _onColorTick() { |
||||
color.value = _style.color.withOpacity(_blinkOpacityCont.value); |
||||
_blink.value = show.value && _blinkOpacityCont.value > 0; |
||||
} |
||||
} |
||||
|
||||
class CursorPainter { |
||||
CursorPainter(this.editable, this.style, this.prototype, this.color, |
||||
this.devicePixelRatio); |
||||
|
||||
final RenderContentProxyBox? editable; |
||||
final CursorStyle style; |
||||
final Rect? prototype; |
||||
final Color color; |
||||
final double devicePixelRatio; |
||||
|
||||
void paint(Canvas canvas, Offset offset, TextPosition position) { |
||||
assert(prototype != null); |
||||
|
||||
final caretOffset = |
||||
editable!.getOffsetForCaret(position, prototype) + offset; |
||||
var caretRect = prototype!.shift(caretOffset); |
||||
if (style.offset != null) { |
||||
caretRect = caretRect.shift(style.offset!); |
||||
} |
||||
|
||||
if (caretRect.left < 0.0) { |
||||
caretRect = caretRect.shift(Offset(-caretRect.left, 0)); |
||||
} |
||||
|
||||
final caretHeight = editable!.getFullHeightForCaret(position); |
||||
if (caretHeight != null) { |
||||
switch (defaultTargetPlatform) { |
||||
case TargetPlatform.android: |
||||
case TargetPlatform.fuchsia: |
||||
case TargetPlatform.linux: |
||||
case TargetPlatform.windows: |
||||
caretRect = Rect.fromLTWH( |
||||
caretRect.left, |
||||
caretRect.top - 2.0, |
||||
caretRect.width, |
||||
caretHeight, |
||||
); |
||||
break; |
||||
case TargetPlatform.iOS: |
||||
case TargetPlatform.macOS: |
||||
caretRect = Rect.fromLTWH( |
||||
caretRect.left, |
||||
caretRect.top + (caretHeight - caretRect.height) / 2, |
||||
caretRect.width, |
||||
caretRect.height, |
||||
); |
||||
break; |
||||
default: |
||||
throw UnimplementedError(); |
||||
} |
||||
} |
||||
|
||||
final caretPosition = editable!.localToGlobal(caretRect.topLeft); |
||||
final pixelMultiple = 1.0 / devicePixelRatio; |
||||
caretRect = caretRect.shift(Offset( |
||||
caretPosition.dx.isFinite |
||||
? (caretPosition.dx / pixelMultiple).round() * pixelMultiple - |
||||
caretPosition.dx |
||||
: caretPosition.dx, |
||||
caretPosition.dy.isFinite |
||||
? (caretPosition.dy / pixelMultiple).round() * pixelMultiple - |
||||
caretPosition.dy |
||||
: caretPosition.dy)); |
||||
|
||||
final paint = Paint()..color = color; |
||||
if (style.radius == null) { |
||||
canvas.drawRect(caretRect, paint); |
||||
return; |
||||
} |
||||
|
||||
final caretRRect = RRect.fromRectAndRadius(caretRect, style.radius!); |
||||
canvas.drawRRect(caretRRect, paint); |
||||
} |
||||
} |
||||
/// TODO: Remove this file in the next breaking release, because implementation |
||||
/// files should be located in the src folder, https://bit.ly/3fA23Yz. |
||||
export '../../src/widgets/cursor.dart'; |
||||
|
@ -1,223 +1,3 @@ |
||||
import 'package:flutter/material.dart'; |
||||
import 'package:flutter/widgets.dart'; |
||||
import 'package:tuple/tuple.dart'; |
||||
|
||||
class QuillStyles extends InheritedWidget { |
||||
const QuillStyles({ |
||||
required this.data, |
||||
required Widget child, |
||||
Key? key, |
||||
}) : super(key: key, child: child); |
||||
|
||||
final DefaultStyles data; |
||||
|
||||
@override |
||||
bool updateShouldNotify(QuillStyles oldWidget) { |
||||
return data != oldWidget.data; |
||||
} |
||||
|
||||
static DefaultStyles? getStyles(BuildContext context, bool nullOk) { |
||||
final widget = context.dependOnInheritedWidgetOfExactType<QuillStyles>(); |
||||
if (widget == null && nullOk) { |
||||
return null; |
||||
} |
||||
assert(widget != null); |
||||
return widget!.data; |
||||
} |
||||
} |
||||
|
||||
class DefaultTextBlockStyle { |
||||
DefaultTextBlockStyle( |
||||
this.style, |
||||
this.verticalSpacing, |
||||
this.lineSpacing, |
||||
this.decoration, |
||||
); |
||||
|
||||
final TextStyle style; |
||||
|
||||
final Tuple2<double, double> verticalSpacing; |
||||
|
||||
final Tuple2<double, double> lineSpacing; |
||||
|
||||
final BoxDecoration? decoration; |
||||
} |
||||
|
||||
class DefaultStyles { |
||||
DefaultStyles({ |
||||
this.h1, |
||||
this.h2, |
||||
this.h3, |
||||
this.paragraph, |
||||
this.bold, |
||||
this.italic, |
||||
this.underline, |
||||
this.strikeThrough, |
||||
this.link, |
||||
this.color, |
||||
this.placeHolder, |
||||
this.lists, |
||||
this.quote, |
||||
this.code, |
||||
this.indent, |
||||
this.align, |
||||
this.leading, |
||||
this.sizeSmall, |
||||
this.sizeLarge, |
||||
this.sizeHuge, |
||||
}); |
||||
|
||||
final DefaultTextBlockStyle? h1; |
||||
final DefaultTextBlockStyle? h2; |
||||
final DefaultTextBlockStyle? h3; |
||||
final DefaultTextBlockStyle? paragraph; |
||||
final TextStyle? bold; |
||||
final TextStyle? italic; |
||||
final TextStyle? underline; |
||||
final TextStyle? strikeThrough; |
||||
final TextStyle? sizeSmall; // 'small' |
||||
final TextStyle? sizeLarge; // 'large' |
||||
final TextStyle? sizeHuge; // 'huge' |
||||
final TextStyle? link; |
||||
final Color? color; |
||||
final DefaultTextBlockStyle? placeHolder; |
||||
final DefaultTextBlockStyle? lists; |
||||
final DefaultTextBlockStyle? quote; |
||||
final DefaultTextBlockStyle? code; |
||||
final DefaultTextBlockStyle? indent; |
||||
final DefaultTextBlockStyle? align; |
||||
final DefaultTextBlockStyle? leading; |
||||
|
||||
static DefaultStyles getInstance(BuildContext context) { |
||||
final themeData = Theme.of(context); |
||||
final defaultTextStyle = DefaultTextStyle.of(context); |
||||
final baseStyle = defaultTextStyle.style.copyWith( |
||||
fontSize: 16, |
||||
height: 1.3, |
||||
); |
||||
const baseSpacing = Tuple2<double, double>(6, 0); |
||||
String fontFamily; |
||||
switch (themeData.platform) { |
||||
case TargetPlatform.iOS: |
||||
case TargetPlatform.macOS: |
||||
fontFamily = 'Menlo'; |
||||
break; |
||||
case TargetPlatform.android: |
||||
case TargetPlatform.fuchsia: |
||||
case TargetPlatform.windows: |
||||
case TargetPlatform.linux: |
||||
fontFamily = 'Roboto Mono'; |
||||
break; |
||||
default: |
||||
throw UnimplementedError(); |
||||
} |
||||
|
||||
return DefaultStyles( |
||||
h1: DefaultTextBlockStyle( |
||||
defaultTextStyle.style.copyWith( |
||||
fontSize: 34, |
||||
color: defaultTextStyle.style.color!.withOpacity(0.70), |
||||
height: 1.15, |
||||
fontWeight: FontWeight.w300, |
||||
), |
||||
const Tuple2(16, 0), |
||||
const Tuple2(0, 0), |
||||
null), |
||||
h2: DefaultTextBlockStyle( |
||||
defaultTextStyle.style.copyWith( |
||||
fontSize: 24, |
||||
color: defaultTextStyle.style.color!.withOpacity(0.70), |
||||
height: 1.15, |
||||
fontWeight: FontWeight.normal, |
||||
), |
||||
const Tuple2(8, 0), |
||||
const Tuple2(0, 0), |
||||
null), |
||||
h3: DefaultTextBlockStyle( |
||||
defaultTextStyle.style.copyWith( |
||||
fontSize: 20, |
||||
color: defaultTextStyle.style.color!.withOpacity(0.70), |
||||
height: 1.25, |
||||
fontWeight: FontWeight.w500, |
||||
), |
||||
const Tuple2(8, 0), |
||||
const Tuple2(0, 0), |
||||
null), |
||||
paragraph: DefaultTextBlockStyle( |
||||
baseStyle, const Tuple2(0, 0), const Tuple2(0, 0), null), |
||||
bold: const TextStyle(fontWeight: FontWeight.bold), |
||||
italic: const TextStyle(fontStyle: FontStyle.italic), |
||||
underline: const TextStyle(decoration: TextDecoration.underline), |
||||
strikeThrough: const TextStyle(decoration: TextDecoration.lineThrough), |
||||
link: TextStyle( |
||||
color: themeData.accentColor, |
||||
decoration: TextDecoration.underline, |
||||
), |
||||
placeHolder: DefaultTextBlockStyle( |
||||
defaultTextStyle.style.copyWith( |
||||
fontSize: 20, |
||||
height: 1.5, |
||||
color: Colors.grey.withOpacity(0.6), |
||||
), |
||||
const Tuple2(0, 0), |
||||
const Tuple2(0, 0), |
||||
null), |
||||
lists: DefaultTextBlockStyle( |
||||
baseStyle, baseSpacing, const Tuple2(0, 6), null), |
||||
quote: DefaultTextBlockStyle( |
||||
TextStyle(color: baseStyle.color!.withOpacity(0.6)), |
||||
baseSpacing, |
||||
const Tuple2(6, 2), |
||||
BoxDecoration( |
||||
border: Border( |
||||
left: BorderSide(width: 4, color: Colors.grey.shade300), |
||||
), |
||||
)), |
||||
code: DefaultTextBlockStyle( |
||||
TextStyle( |
||||
color: Colors.blue.shade900.withOpacity(0.9), |
||||
fontFamily: fontFamily, |
||||
fontSize: 13, |
||||
height: 1.15, |
||||
), |
||||
baseSpacing, |
||||
const Tuple2(0, 0), |
||||
BoxDecoration( |
||||
color: Colors.grey.shade50, |
||||
borderRadius: BorderRadius.circular(2), |
||||
)), |
||||
indent: DefaultTextBlockStyle( |
||||
baseStyle, baseSpacing, const Tuple2(0, 6), null), |
||||
align: DefaultTextBlockStyle( |
||||
baseStyle, const Tuple2(0, 0), const Tuple2(0, 0), null), |
||||
leading: DefaultTextBlockStyle( |
||||
baseStyle, const Tuple2(0, 0), const Tuple2(0, 0), null), |
||||
sizeSmall: const TextStyle(fontSize: 10), |
||||
sizeLarge: const TextStyle(fontSize: 18), |
||||
sizeHuge: const TextStyle(fontSize: 22)); |
||||
} |
||||
|
||||
DefaultStyles merge(DefaultStyles other) { |
||||
return DefaultStyles( |
||||
h1: other.h1 ?? h1, |
||||
h2: other.h2 ?? h2, |
||||
h3: other.h3 ?? h3, |
||||
paragraph: other.paragraph ?? paragraph, |
||||
bold: other.bold ?? bold, |
||||
italic: other.italic ?? italic, |
||||
underline: other.underline ?? underline, |
||||
strikeThrough: other.strikeThrough ?? strikeThrough, |
||||
link: other.link ?? link, |
||||
color: other.color ?? color, |
||||
placeHolder: other.placeHolder ?? placeHolder, |
||||
lists: other.lists ?? lists, |
||||
quote: other.quote ?? quote, |
||||
code: other.code ?? code, |
||||
indent: other.indent ?? indent, |
||||
align: other.align ?? align, |
||||
leading: other.leading ?? leading, |
||||
sizeSmall: other.sizeSmall ?? sizeSmall, |
||||
sizeLarge: other.sizeLarge ?? sizeLarge, |
||||
sizeHuge: other.sizeHuge ?? sizeHuge); |
||||
} |
||||
} |
||||
/// TODO: Remove this file in the next breaking release, because implementation |
||||
/// files should be located in the src folder, https://bit.ly/3fA23Yz. |
||||
export '../../src/widgets/default_styles.dart'; |
||||
|
@ -1,148 +1,3 @@ |
||||
import 'package:flutter/cupertino.dart'; |
||||
import 'package:flutter/gestures.dart'; |
||||
import 'package:flutter/material.dart'; |
||||
import 'package:flutter/rendering.dart'; |
||||
|
||||
import '../models/documents/nodes/leaf.dart'; |
||||
import 'editor.dart'; |
||||
import 'text_selection.dart'; |
||||
|
||||
typedef EmbedBuilder = Widget Function(BuildContext context, Embed node); |
||||
|
||||
abstract class EditorTextSelectionGestureDetectorBuilderDelegate { |
||||
GlobalKey<EditorState> getEditableTextKey(); |
||||
|
||||
bool getForcePressEnabled(); |
||||
|
||||
bool getSelectionEnabled(); |
||||
} |
||||
|
||||
class EditorTextSelectionGestureDetectorBuilder { |
||||
EditorTextSelectionGestureDetectorBuilder(this.delegate); |
||||
|
||||
final EditorTextSelectionGestureDetectorBuilderDelegate delegate; |
||||
bool shouldShowSelectionToolbar = true; |
||||
|
||||
EditorState? getEditor() { |
||||
return delegate.getEditableTextKey().currentState; |
||||
} |
||||
|
||||
RenderEditor? getRenderEditor() { |
||||
return getEditor()!.getRenderEditor(); |
||||
} |
||||
|
||||
void onTapDown(TapDownDetails details) { |
||||
getRenderEditor()!.handleTapDown(details); |
||||
|
||||
final kind = details.kind; |
||||
shouldShowSelectionToolbar = kind == null || |
||||
kind == PointerDeviceKind.touch || |
||||
kind == PointerDeviceKind.stylus; |
||||
} |
||||
|
||||
void onForcePressStart(ForcePressDetails details) { |
||||
assert(delegate.getForcePressEnabled()); |
||||
shouldShowSelectionToolbar = true; |
||||
if (delegate.getSelectionEnabled()) { |
||||
getRenderEditor()!.selectWordsInRange( |
||||
details.globalPosition, |
||||
null, |
||||
SelectionChangedCause.forcePress, |
||||
); |
||||
} |
||||
} |
||||
|
||||
void onForcePressEnd(ForcePressDetails details) { |
||||
assert(delegate.getForcePressEnabled()); |
||||
getRenderEditor()!.selectWordsInRange( |
||||
details.globalPosition, |
||||
null, |
||||
SelectionChangedCause.forcePress, |
||||
); |
||||
if (shouldShowSelectionToolbar) { |
||||
getEditor()!.showToolbar(); |
||||
} |
||||
} |
||||
|
||||
void onSingleTapUp(TapUpDetails details) { |
||||
if (delegate.getSelectionEnabled()) { |
||||
getRenderEditor()!.selectWordEdge(SelectionChangedCause.tap); |
||||
} |
||||
} |
||||
|
||||
void onSingleTapCancel() {} |
||||
|
||||
void onSingleLongTapStart(LongPressStartDetails details) { |
||||
if (delegate.getSelectionEnabled()) { |
||||
getRenderEditor()!.selectPositionAt( |
||||
details.globalPosition, |
||||
null, |
||||
SelectionChangedCause.longPress, |
||||
); |
||||
} |
||||
} |
||||
|
||||
void onSingleLongTapMoveUpdate(LongPressMoveUpdateDetails details) { |
||||
if (delegate.getSelectionEnabled()) { |
||||
getRenderEditor()!.selectPositionAt( |
||||
details.globalPosition, |
||||
null, |
||||
SelectionChangedCause.longPress, |
||||
); |
||||
} |
||||
} |
||||
|
||||
void onSingleLongTapEnd(LongPressEndDetails details) { |
||||
if (shouldShowSelectionToolbar) { |
||||
getEditor()!.showToolbar(); |
||||
} |
||||
} |
||||
|
||||
void onDoubleTapDown(TapDownDetails details) { |
||||
if (delegate.getSelectionEnabled()) { |
||||
getRenderEditor()!.selectWord(SelectionChangedCause.tap); |
||||
if (shouldShowSelectionToolbar) { |
||||
getEditor()!.showToolbar(); |
||||
} |
||||
} |
||||
} |
||||
|
||||
void onDragSelectionStart(DragStartDetails details) { |
||||
getRenderEditor()!.selectPositionAt( |
||||
details.globalPosition, |
||||
null, |
||||
SelectionChangedCause.drag, |
||||
); |
||||
} |
||||
|
||||
void onDragSelectionUpdate( |
||||
DragStartDetails startDetails, DragUpdateDetails updateDetails) { |
||||
getRenderEditor()!.selectPositionAt( |
||||
startDetails.globalPosition, |
||||
updateDetails.globalPosition, |
||||
SelectionChangedCause.drag, |
||||
); |
||||
} |
||||
|
||||
void onDragSelectionEnd(DragEndDetails details) {} |
||||
|
||||
Widget build(HitTestBehavior behavior, Widget child) { |
||||
return EditorTextSelectionGestureDetector( |
||||
onTapDown: onTapDown, |
||||
onForcePressStart: |
||||
delegate.getForcePressEnabled() ? onForcePressStart : null, |
||||
onForcePressEnd: delegate.getForcePressEnabled() ? onForcePressEnd : null, |
||||
onSingleTapUp: onSingleTapUp, |
||||
onSingleTapCancel: onSingleTapCancel, |
||||
onSingleLongTapStart: onSingleLongTapStart, |
||||
onSingleLongTapMoveUpdate: onSingleLongTapMoveUpdate, |
||||
onSingleLongTapEnd: onSingleLongTapEnd, |
||||
onDoubleTapDown: onDoubleTapDown, |
||||
onDragSelectionStart: onDragSelectionStart, |
||||
onDragSelectionUpdate: onDragSelectionUpdate, |
||||
onDragSelectionEnd: onDragSelectionEnd, |
||||
behavior: behavior, |
||||
child: child, |
||||
); |
||||
} |
||||
} |
||||
/// TODO: Remove this file in the next breaking release, because implementation |
||||
/// files should be located in the src folder, https://bit.ly/3fA23Yz. |
||||
export '../../src/widgets/delegate.dart'; |
||||
|
File diff suppressed because it is too large
Load Diff
@ -1,31 +1,3 @@ |
||||
import 'package:flutter/cupertino.dart'; |
||||
import 'package:flutter/material.dart'; |
||||
import 'package:flutter/rendering.dart'; |
||||
import 'package:photo_view/photo_view.dart'; |
||||
|
||||
class ImageTapWrapper extends StatelessWidget { |
||||
const ImageTapWrapper({ |
||||
this.imageProvider, |
||||
}); |
||||
|
||||
final ImageProvider? imageProvider; |
||||
|
||||
@override |
||||
Widget build(BuildContext context) { |
||||
return Scaffold( |
||||
body: Container( |
||||
constraints: BoxConstraints.expand( |
||||
height: MediaQuery.of(context).size.height, |
||||
), |
||||
child: GestureDetector( |
||||
onTapDown: (_) { |
||||
Navigator.pop(context); |
||||
}, |
||||
child: PhotoView( |
||||
imageProvider: imageProvider, |
||||
), |
||||
), |
||||
), |
||||
); |
||||
} |
||||
} |
||||
/// TODO: Remove this file in the next breaking release, because implementation |
||||
/// files should be located in the src folder, https://bit.ly/3fA23Yz. |
||||
export '../../src/widgets/image.dart'; |
||||
|
@ -1,105 +1,3 @@ |
||||
import 'package:flutter/foundation.dart'; |
||||
import 'package:flutter/services.dart'; |
||||
|
||||
enum InputShortcut { CUT, COPY, PASTE, SELECT_ALL } |
||||
|
||||
typedef CursorMoveCallback = void Function( |
||||
LogicalKeyboardKey key, bool wordModifier, bool lineModifier, bool shift); |
||||
typedef InputShortcutCallback = void Function(InputShortcut? shortcut); |
||||
typedef OnDeleteCallback = void Function(bool forward); |
||||
|
||||
class KeyboardListener { |
||||
KeyboardListener(this.onCursorMove, this.onShortcut, this.onDelete); |
||||
|
||||
final CursorMoveCallback onCursorMove; |
||||
final InputShortcutCallback onShortcut; |
||||
final OnDeleteCallback onDelete; |
||||
|
||||
static final Set<LogicalKeyboardKey> _moveKeys = <LogicalKeyboardKey>{ |
||||
LogicalKeyboardKey.arrowRight, |
||||
LogicalKeyboardKey.arrowLeft, |
||||
LogicalKeyboardKey.arrowUp, |
||||
LogicalKeyboardKey.arrowDown, |
||||
}; |
||||
|
||||
static final Set<LogicalKeyboardKey> _shortcutKeys = <LogicalKeyboardKey>{ |
||||
LogicalKeyboardKey.keyA, |
||||
LogicalKeyboardKey.keyC, |
||||
LogicalKeyboardKey.keyV, |
||||
LogicalKeyboardKey.keyX, |
||||
LogicalKeyboardKey.delete, |
||||
LogicalKeyboardKey.backspace, |
||||
}; |
||||
|
||||
static final Set<LogicalKeyboardKey> _nonModifierKeys = <LogicalKeyboardKey>{ |
||||
..._shortcutKeys, |
||||
..._moveKeys, |
||||
}; |
||||
|
||||
static final Set<LogicalKeyboardKey> _modifierKeys = <LogicalKeyboardKey>{ |
||||
LogicalKeyboardKey.shift, |
||||
LogicalKeyboardKey.control, |
||||
LogicalKeyboardKey.alt, |
||||
}; |
||||
|
||||
static final Set<LogicalKeyboardKey> _macOsModifierKeys = |
||||
<LogicalKeyboardKey>{ |
||||
LogicalKeyboardKey.shift, |
||||
LogicalKeyboardKey.meta, |
||||
LogicalKeyboardKey.alt, |
||||
}; |
||||
|
||||
static final Set<LogicalKeyboardKey> _interestingKeys = <LogicalKeyboardKey>{ |
||||
..._modifierKeys, |
||||
..._macOsModifierKeys, |
||||
..._nonModifierKeys, |
||||
}; |
||||
|
||||
static final Map<LogicalKeyboardKey, InputShortcut> _keyToShortcut = { |
||||
LogicalKeyboardKey.keyX: InputShortcut.CUT, |
||||
LogicalKeyboardKey.keyC: InputShortcut.COPY, |
||||
LogicalKeyboardKey.keyV: InputShortcut.PASTE, |
||||
LogicalKeyboardKey.keyA: InputShortcut.SELECT_ALL, |
||||
}; |
||||
|
||||
bool handleRawKeyEvent(RawKeyEvent event) { |
||||
if (kIsWeb) { |
||||
// On web platform, we should ignore the key because it's processed already. |
||||
return false; |
||||
} |
||||
|
||||
if (event is! RawKeyDownEvent) { |
||||
return false; |
||||
} |
||||
|
||||
final keysPressed = |
||||
LogicalKeyboardKey.collapseSynonyms(RawKeyboard.instance.keysPressed); |
||||
final key = event.logicalKey; |
||||
final isMacOS = event.data is RawKeyEventDataMacOs; |
||||
if (!_nonModifierKeys.contains(key) || |
||||
keysPressed |
||||
.difference(isMacOS ? _macOsModifierKeys : _modifierKeys) |
||||
.length > |
||||
1 || |
||||
keysPressed.difference(_interestingKeys).isNotEmpty) { |
||||
return false; |
||||
} |
||||
|
||||
if (_moveKeys.contains(key)) { |
||||
onCursorMove( |
||||
key, |
||||
isMacOS ? event.isAltPressed : event.isControlPressed, |
||||
isMacOS ? event.isMetaPressed : event.isAltPressed, |
||||
event.isShiftPressed); |
||||
} else if (isMacOS |
||||
? event.isMetaPressed |
||||
: event.isControlPressed && _shortcutKeys.contains(key)) { |
||||
onShortcut(_keyToShortcut[key]); |
||||
} else if (key == LogicalKeyboardKey.delete) { |
||||
onDelete(true); |
||||
} else if (key == LogicalKeyboardKey.backspace) { |
||||
onDelete(false); |
||||
} |
||||
return false; |
||||
} |
||||
} |
||||
/// TODO: Remove this file in the next breaking release, because implementation |
||||
/// files should be located in the src folder, https://bit.ly/3fA23Yz. |
||||
export '../../src/widgets/keyboard_listener.dart'; |
||||
|
@ -1,298 +1,3 @@ |
||||
import 'package:flutter/rendering.dart'; |
||||
import 'package:flutter/widgets.dart'; |
||||
|
||||
import 'box.dart'; |
||||
|
||||
class BaselineProxy extends SingleChildRenderObjectWidget { |
||||
const BaselineProxy({Key? key, Widget? child, this.textStyle, this.padding}) |
||||
: super(key: key, child: child); |
||||
|
||||
final TextStyle? textStyle; |
||||
final EdgeInsets? padding; |
||||
|
||||
@override |
||||
RenderBaselineProxy createRenderObject(BuildContext context) { |
||||
return RenderBaselineProxy( |
||||
null, |
||||
textStyle!, |
||||
padding, |
||||
); |
||||
} |
||||
|
||||
@override |
||||
void updateRenderObject( |
||||
BuildContext context, covariant RenderBaselineProxy renderObject) { |
||||
renderObject |
||||
..textStyle = textStyle! |
||||
..padding = padding!; |
||||
} |
||||
} |
||||
|
||||
class RenderBaselineProxy extends RenderProxyBox { |
||||
RenderBaselineProxy( |
||||
RenderParagraph? child, |
||||
TextStyle textStyle, |
||||
EdgeInsets? padding, |
||||
) : _prototypePainter = TextPainter( |
||||
text: TextSpan(text: ' ', style: textStyle), |
||||
textDirection: TextDirection.ltr, |
||||
strutStyle: |
||||
StrutStyle.fromTextStyle(textStyle, forceStrutHeight: true)), |
||||
super(child); |
||||
|
||||
final TextPainter _prototypePainter; |
||||
|
||||
set textStyle(TextStyle value) { |
||||
if (_prototypePainter.text!.style == value) { |
||||
return; |
||||
} |
||||
_prototypePainter.text = TextSpan(text: ' ', style: value); |
||||
markNeedsLayout(); |
||||
} |
||||
|
||||
EdgeInsets? _padding; |
||||
|
||||
set padding(EdgeInsets value) { |
||||
if (_padding == value) { |
||||
return; |
||||
} |
||||
_padding = value; |
||||
markNeedsLayout(); |
||||
} |
||||
|
||||
@override |
||||
double computeDistanceToActualBaseline(TextBaseline baseline) => |
||||
_prototypePainter.computeDistanceToActualBaseline(baseline); |
||||
// SEE What happens + _padding?.top; |
||||
|
||||
@override |
||||
void performLayout() { |
||||
super.performLayout(); |
||||
_prototypePainter.layout(); |
||||
} |
||||
} |
||||
|
||||
class EmbedProxy extends SingleChildRenderObjectWidget { |
||||
const EmbedProxy(Widget child) : super(child: child); |
||||
|
||||
@override |
||||
RenderEmbedProxy createRenderObject(BuildContext context) => |
||||
RenderEmbedProxy(null); |
||||
} |
||||
|
||||
class RenderEmbedProxy extends RenderProxyBox implements RenderContentProxyBox { |
||||
RenderEmbedProxy(RenderBox? child) : super(child); |
||||
|
||||
@override |
||||
List<TextBox> getBoxesForSelection(TextSelection selection) { |
||||
if (!selection.isCollapsed) { |
||||
return <TextBox>[ |
||||
TextBox.fromLTRBD(0, 0, size.width, size.height, TextDirection.ltr) |
||||
]; |
||||
} |
||||
|
||||
final left = selection.extentOffset == 0 ? 0.0 : size.width; |
||||
final right = selection.extentOffset == 0 ? 0.0 : size.width; |
||||
return <TextBox>[ |
||||
TextBox.fromLTRBD(left, 0, right, size.height, TextDirection.ltr) |
||||
]; |
||||
} |
||||
|
||||
@override |
||||
double getFullHeightForCaret(TextPosition position) => size.height; |
||||
|
||||
@override |
||||
Offset getOffsetForCaret(TextPosition position, Rect? caretPrototype) { |
||||
assert(position.offset <= 1 && position.offset >= 0); |
||||
return position.offset == 0 ? Offset.zero : Offset(size.width, 0); |
||||
} |
||||
|
||||
@override |
||||
TextPosition getPositionForOffset(Offset offset) => |
||||
TextPosition(offset: offset.dx > size.width / 2 ? 1 : 0); |
||||
|
||||
@override |
||||
TextRange getWordBoundary(TextPosition position) => |
||||
const TextRange(start: 0, end: 1); |
||||
|
||||
@override |
||||
double getPreferredLineHeight() { |
||||
return size.height; |
||||
} |
||||
} |
||||
|
||||
class RichTextProxy extends SingleChildRenderObjectWidget { |
||||
const RichTextProxy( |
||||
RichText child, |
||||
this.textStyle, |
||||
this.textAlign, |
||||
this.textDirection, |
||||
this.textScaleFactor, |
||||
this.locale, |
||||
this.strutStyle, |
||||
this.textWidthBasis, |
||||
this.textHeightBehavior, |
||||
) : super(child: child); |
||||
|
||||
final TextStyle textStyle; |
||||
final TextAlign textAlign; |
||||
final TextDirection textDirection; |
||||
final double textScaleFactor; |
||||
final Locale locale; |
||||
final StrutStyle strutStyle; |
||||
final TextWidthBasis textWidthBasis; |
||||
final TextHeightBehavior? textHeightBehavior; |
||||
|
||||
@override |
||||
RenderParagraphProxy createRenderObject(BuildContext context) { |
||||
return RenderParagraphProxy( |
||||
null, |
||||
textStyle, |
||||
textAlign, |
||||
textDirection, |
||||
textScaleFactor, |
||||
strutStyle, |
||||
locale, |
||||
textWidthBasis, |
||||
textHeightBehavior); |
||||
} |
||||
|
||||
@override |
||||
void updateRenderObject( |
||||
BuildContext context, covariant RenderParagraphProxy renderObject) { |
||||
renderObject |
||||
..textStyle = textStyle |
||||
..textAlign = textAlign |
||||
..textDirection = textDirection |
||||
..textScaleFactor = textScaleFactor |
||||
..locale = locale |
||||
..strutStyle = strutStyle |
||||
..textWidthBasis = textWidthBasis |
||||
..textHeightBehavior = textHeightBehavior; |
||||
} |
||||
} |
||||
|
||||
class RenderParagraphProxy extends RenderProxyBox |
||||
implements RenderContentProxyBox { |
||||
RenderParagraphProxy( |
||||
RenderParagraph? child, |
||||
TextStyle textStyle, |
||||
TextAlign textAlign, |
||||
TextDirection textDirection, |
||||
double textScaleFactor, |
||||
StrutStyle strutStyle, |
||||
Locale locale, |
||||
TextWidthBasis textWidthBasis, |
||||
TextHeightBehavior? textHeightBehavior, |
||||
) : _prototypePainter = TextPainter( |
||||
text: TextSpan(text: ' ', style: textStyle), |
||||
textAlign: textAlign, |
||||
textDirection: textDirection, |
||||
textScaleFactor: textScaleFactor, |
||||
strutStyle: strutStyle, |
||||
locale: locale, |
||||
textWidthBasis: textWidthBasis, |
||||
textHeightBehavior: textHeightBehavior), |
||||
super(child); |
||||
|
||||
final TextPainter _prototypePainter; |
||||
|
||||
set textStyle(TextStyle value) { |
||||
if (_prototypePainter.text!.style == value) { |
||||
return; |
||||
} |
||||
_prototypePainter.text = TextSpan(text: ' ', style: value); |
||||
markNeedsLayout(); |
||||
} |
||||
|
||||
set textAlign(TextAlign value) { |
||||
if (_prototypePainter.textAlign == value) { |
||||
return; |
||||
} |
||||
_prototypePainter.textAlign = value; |
||||
markNeedsLayout(); |
||||
} |
||||
|
||||
set textDirection(TextDirection value) { |
||||
if (_prototypePainter.textDirection == value) { |
||||
return; |
||||
} |
||||
_prototypePainter.textDirection = value; |
||||
markNeedsLayout(); |
||||
} |
||||
|
||||
set textScaleFactor(double value) { |
||||
if (_prototypePainter.textScaleFactor == value) { |
||||
return; |
||||
} |
||||
_prototypePainter.textScaleFactor = value; |
||||
markNeedsLayout(); |
||||
} |
||||
|
||||
set strutStyle(StrutStyle value) { |
||||
if (_prototypePainter.strutStyle == value) { |
||||
return; |
||||
} |
||||
_prototypePainter.strutStyle = value; |
||||
markNeedsLayout(); |
||||
} |
||||
|
||||
set locale(Locale value) { |
||||
if (_prototypePainter.locale == value) { |
||||
return; |
||||
} |
||||
_prototypePainter.locale = value; |
||||
markNeedsLayout(); |
||||
} |
||||
|
||||
set textWidthBasis(TextWidthBasis value) { |
||||
if (_prototypePainter.textWidthBasis == value) { |
||||
return; |
||||
} |
||||
_prototypePainter.textWidthBasis = value; |
||||
markNeedsLayout(); |
||||
} |
||||
|
||||
set textHeightBehavior(TextHeightBehavior? value) { |
||||
if (_prototypePainter.textHeightBehavior == value) { |
||||
return; |
||||
} |
||||
_prototypePainter.textHeightBehavior = value; |
||||
markNeedsLayout(); |
||||
} |
||||
|
||||
@override |
||||
RenderParagraph? get child => super.child as RenderParagraph?; |
||||
|
||||
@override |
||||
double getPreferredLineHeight() { |
||||
return _prototypePainter.preferredLineHeight; |
||||
} |
||||
|
||||
@override |
||||
Offset getOffsetForCaret(TextPosition position, Rect? caretPrototype) => |
||||
child!.getOffsetForCaret(position, caretPrototype!); |
||||
|
||||
@override |
||||
TextPosition getPositionForOffset(Offset offset) => |
||||
child!.getPositionForOffset(offset); |
||||
|
||||
@override |
||||
double? getFullHeightForCaret(TextPosition position) => |
||||
child!.getFullHeightForCaret(position); |
||||
|
||||
@override |
||||
TextRange getWordBoundary(TextPosition position) => |
||||
child!.getWordBoundary(position); |
||||
|
||||
@override |
||||
List<TextBox> getBoxesForSelection(TextSelection selection) => |
||||
child!.getBoxesForSelection(selection); |
||||
|
||||
@override |
||||
void performLayout() { |
||||
super.performLayout(); |
||||
_prototypePainter.layout( |
||||
minWidth: constraints.minWidth, maxWidth: constraints.maxWidth); |
||||
} |
||||
} |
||||
/// TODO: Remove this file in the next breaking release, because implementation |
||||
/// files should be located in the src folder, https://bit.ly/3fA23Yz. |
||||
export '../../src/widgets/proxy.dart'; |
||||
|
@ -1,736 +1,3 @@ |
||||
import 'dart:async'; |
||||
import 'dart:convert'; |
||||
|
||||
import 'package:flutter/cupertino.dart'; |
||||
import 'package:flutter/foundation.dart'; |
||||
import 'package:flutter/gestures.dart'; |
||||
import 'package:flutter/material.dart'; |
||||
import 'package:flutter/rendering.dart'; |
||||
import 'package:flutter/scheduler.dart'; |
||||
import 'package:flutter/services.dart'; |
||||
import 'package:flutter_keyboard_visibility/flutter_keyboard_visibility.dart'; |
||||
import 'package:tuple/tuple.dart'; |
||||
|
||||
import '../models/documents/attribute.dart'; |
||||
import '../models/documents/document.dart'; |
||||
import '../models/documents/nodes/block.dart'; |
||||
import '../models/documents/nodes/line.dart'; |
||||
import 'controller.dart'; |
||||
import 'cursor.dart'; |
||||
import 'default_styles.dart'; |
||||
import 'delegate.dart'; |
||||
import 'editor.dart'; |
||||
import 'keyboard_listener.dart'; |
||||
import 'proxy.dart'; |
||||
import 'raw_editor/raw_editor_state_keyboard_mixin.dart'; |
||||
import 'raw_editor/raw_editor_state_selection_delegate_mixin.dart'; |
||||
import 'raw_editor/raw_editor_state_text_input_client_mixin.dart'; |
||||
import 'text_block.dart'; |
||||
import 'text_line.dart'; |
||||
import 'text_selection.dart'; |
||||
|
||||
class RawEditor extends StatefulWidget { |
||||
const RawEditor( |
||||
Key key, |
||||
this.controller, |
||||
this.focusNode, |
||||
this.scrollController, |
||||
this.scrollable, |
||||
this.scrollBottomInset, |
||||
this.padding, |
||||
this.readOnly, |
||||
this.placeholder, |
||||
this.onLaunchUrl, |
||||
this.toolbarOptions, |
||||
this.showSelectionHandles, |
||||
bool? showCursor, |
||||
this.cursorStyle, |
||||
this.textCapitalization, |
||||
this.maxHeight, |
||||
this.minHeight, |
||||
this.customStyles, |
||||
this.expands, |
||||
this.autoFocus, |
||||
this.selectionColor, |
||||
this.selectionCtrls, |
||||
this.keyboardAppearance, |
||||
this.enableInteractiveSelection, |
||||
this.scrollPhysics, |
||||
this.embedBuilder, |
||||
) : assert(maxHeight == null || maxHeight > 0, 'maxHeight cannot be null'), |
||||
assert(minHeight == null || minHeight >= 0, 'minHeight cannot be null'), |
||||
assert(maxHeight == null || minHeight == null || maxHeight >= minHeight, |
||||
'maxHeight cannot be null'), |
||||
showCursor = showCursor ?? true, |
||||
super(key: key); |
||||
|
||||
final QuillController controller; |
||||
final FocusNode focusNode; |
||||
final ScrollController scrollController; |
||||
final bool scrollable; |
||||
final double scrollBottomInset; |
||||
final EdgeInsetsGeometry padding; |
||||
final bool readOnly; |
||||
final String? placeholder; |
||||
final ValueChanged<String>? onLaunchUrl; |
||||
final ToolbarOptions toolbarOptions; |
||||
final bool showSelectionHandles; |
||||
final bool showCursor; |
||||
final CursorStyle cursorStyle; |
||||
final TextCapitalization textCapitalization; |
||||
final double? maxHeight; |
||||
final double? minHeight; |
||||
final DefaultStyles? customStyles; |
||||
final bool expands; |
||||
final bool autoFocus; |
||||
final Color selectionColor; |
||||
final TextSelectionControls selectionCtrls; |
||||
final Brightness keyboardAppearance; |
||||
final bool enableInteractiveSelection; |
||||
final ScrollPhysics? scrollPhysics; |
||||
final EmbedBuilder embedBuilder; |
||||
|
||||
@override |
||||
State<StatefulWidget> createState() => RawEditorState(); |
||||
} |
||||
|
||||
class RawEditorState extends EditorState |
||||
with |
||||
AutomaticKeepAliveClientMixin<RawEditor>, |
||||
WidgetsBindingObserver, |
||||
TickerProviderStateMixin<RawEditor>, |
||||
RawEditorStateKeyboardMixin, |
||||
RawEditorStateTextInputClientMixin, |
||||
RawEditorStateSelectionDelegateMixin { |
||||
final GlobalKey _editorKey = GlobalKey(); |
||||
|
||||
// Keyboard |
||||
late KeyboardListener _keyboardListener; |
||||
KeyboardVisibilityController? _keyboardVisibilityController; |
||||
StreamSubscription<bool>? _keyboardVisibilitySubscription; |
||||
bool _keyboardVisible = false; |
||||
|
||||
// Selection overlay |
||||
@override |
||||
EditorTextSelectionOverlay? getSelectionOverlay() => _selectionOverlay; |
||||
EditorTextSelectionOverlay? _selectionOverlay; |
||||
|
||||
ScrollController? _scrollController; |
||||
|
||||
late CursorCont _cursorCont; |
||||
|
||||
// Focus |
||||
bool _didAutoFocus = false; |
||||
FocusAttachment? _focusAttachment; |
||||
bool get _hasFocus => widget.focusNode.hasFocus; |
||||
|
||||
DefaultStyles? _styles; |
||||
|
||||
final ClipboardStatusNotifier? _clipboardStatus = |
||||
kIsWeb ? null : ClipboardStatusNotifier(); |
||||
final LayerLink _toolbarLayerLink = LayerLink(); |
||||
final LayerLink _startHandleLayerLink = LayerLink(); |
||||
final LayerLink _endHandleLayerLink = LayerLink(); |
||||
|
||||
TextDirection get _textDirection => Directionality.of(context); |
||||
|
||||
@override |
||||
Widget build(BuildContext context) { |
||||
assert(debugCheckHasMediaQuery(context)); |
||||
_focusAttachment!.reparent(); |
||||
super.build(context); |
||||
|
||||
var _doc = widget.controller.document; |
||||
if (_doc.isEmpty() && |
||||
!widget.focusNode.hasFocus && |
||||
widget.placeholder != null) { |
||||
_doc = Document.fromJson(jsonDecode( |
||||
'[{"attributes":{"placeholder":true},"insert":"${widget.placeholder}\\n"}]')); |
||||
} |
||||
|
||||
Widget child = CompositedTransformTarget( |
||||
link: _toolbarLayerLink, |
||||
child: Semantics( |
||||
child: _Editor( |
||||
key: _editorKey, |
||||
document: _doc, |
||||
selection: widget.controller.selection, |
||||
hasFocus: _hasFocus, |
||||
textDirection: _textDirection, |
||||
startHandleLayerLink: _startHandleLayerLink, |
||||
endHandleLayerLink: _endHandleLayerLink, |
||||
onSelectionChanged: _handleSelectionChanged, |
||||
scrollBottomInset: widget.scrollBottomInset, |
||||
padding: widget.padding, |
||||
children: _buildChildren(_doc, context), |
||||
), |
||||
), |
||||
); |
||||
|
||||
if (widget.scrollable) { |
||||
final baselinePadding = |
||||
EdgeInsets.only(top: _styles!.paragraph!.verticalSpacing.item1); |
||||
child = BaselineProxy( |
||||
textStyle: _styles!.paragraph!.style, |
||||
padding: baselinePadding, |
||||
child: SingleChildScrollView( |
||||
controller: _scrollController, |
||||
physics: widget.scrollPhysics, |
||||
child: child, |
||||
), |
||||
); |
||||
} |
||||
|
||||
final constraints = widget.expands |
||||
? const BoxConstraints.expand() |
||||
: BoxConstraints( |
||||
minHeight: widget.minHeight ?? 0.0, |
||||
maxHeight: widget.maxHeight ?? double.infinity); |
||||
|
||||
return QuillStyles( |
||||
data: _styles!, |
||||
child: MouseRegion( |
||||
cursor: SystemMouseCursors.text, |
||||
child: Container( |
||||
constraints: constraints, |
||||
child: child, |
||||
), |
||||
), |
||||
); |
||||
} |
||||
|
||||
void _handleSelectionChanged( |
||||
TextSelection selection, SelectionChangedCause cause) { |
||||
widget.controller.updateSelection(selection, ChangeSource.LOCAL); |
||||
|
||||
_selectionOverlay?.handlesVisible = _shouldShowSelectionHandles(); |
||||
|
||||
if (!_keyboardVisible) { |
||||
requestKeyboard(); |
||||
} |
||||
} |
||||
|
||||
/// Updates the checkbox positioned at [offset] in document |
||||
/// by changing its attribute according to [value]. |
||||
void _handleCheckboxTap(int offset, bool value) { |
||||
if (!widget.readOnly) { |
||||
if (value) { |
||||
widget.controller.formatText(offset, 0, Attribute.checked); |
||||
} else { |
||||
widget.controller.formatText(offset, 0, Attribute.unchecked); |
||||
} |
||||
} |
||||
} |
||||
|
||||
List<Widget> _buildChildren(Document doc, BuildContext context) { |
||||
final result = <Widget>[]; |
||||
final indentLevelCounts = <int, int>{}; |
||||
for (final node in doc.root.children) { |
||||
if (node is Line) { |
||||
final editableTextLine = _getEditableTextLineFromNode(node, context); |
||||
result.add(editableTextLine); |
||||
} else if (node is Block) { |
||||
final attrs = node.style.attributes; |
||||
final editableTextBlock = EditableTextBlock( |
||||
node, |
||||
_textDirection, |
||||
widget.scrollBottomInset, |
||||
_getVerticalSpacingForBlock(node, _styles), |
||||
widget.controller.selection, |
||||
widget.selectionColor, |
||||
_styles, |
||||
widget.enableInteractiveSelection, |
||||
_hasFocus, |
||||
attrs.containsKey(Attribute.codeBlock.key) |
||||
? const EdgeInsets.all(16) |
||||
: null, |
||||
widget.embedBuilder, |
||||
_cursorCont, |
||||
indentLevelCounts, |
||||
_handleCheckboxTap, |
||||
); |
||||
result.add(editableTextBlock); |
||||
} else { |
||||
throw StateError('Unreachable.'); |
||||
} |
||||
} |
||||
return result; |
||||
} |
||||
|
||||
EditableTextLine _getEditableTextLineFromNode( |
||||
Line node, BuildContext context) { |
||||
final textLine = TextLine( |
||||
line: node, |
||||
textDirection: _textDirection, |
||||
embedBuilder: widget.embedBuilder, |
||||
styles: _styles!, |
||||
); |
||||
final editableTextLine = EditableTextLine( |
||||
node, |
||||
null, |
||||
textLine, |
||||
0, |
||||
_getVerticalSpacingForLine(node, _styles), |
||||
_textDirection, |
||||
widget.controller.selection, |
||||
widget.selectionColor, |
||||
widget.enableInteractiveSelection, |
||||
_hasFocus, |
||||
MediaQuery.of(context).devicePixelRatio, |
||||
_cursorCont); |
||||
return editableTextLine; |
||||
} |
||||
|
||||
Tuple2<double, double> _getVerticalSpacingForLine( |
||||
Line line, DefaultStyles? defaultStyles) { |
||||
final attrs = line.style.attributes; |
||||
if (attrs.containsKey(Attribute.header.key)) { |
||||
final int? level = attrs[Attribute.header.key]!.value; |
||||
switch (level) { |
||||
case 1: |
||||
return defaultStyles!.h1!.verticalSpacing; |
||||
case 2: |
||||
return defaultStyles!.h2!.verticalSpacing; |
||||
case 3: |
||||
return defaultStyles!.h3!.verticalSpacing; |
||||
default: |
||||
throw 'Invalid level $level'; |
||||
} |
||||
} |
||||
|
||||
return defaultStyles!.paragraph!.verticalSpacing; |
||||
} |
||||
|
||||
Tuple2<double, double> _getVerticalSpacingForBlock( |
||||
Block node, DefaultStyles? defaultStyles) { |
||||
final attrs = node.style.attributes; |
||||
if (attrs.containsKey(Attribute.blockQuote.key)) { |
||||
return defaultStyles!.quote!.verticalSpacing; |
||||
} else if (attrs.containsKey(Attribute.codeBlock.key)) { |
||||
return defaultStyles!.code!.verticalSpacing; |
||||
} else if (attrs.containsKey(Attribute.indent.key)) { |
||||
return defaultStyles!.indent!.verticalSpacing; |
||||
} |
||||
return defaultStyles!.lists!.verticalSpacing; |
||||
} |
||||
|
||||
@override |
||||
void initState() { |
||||
super.initState(); |
||||
|
||||
_clipboardStatus?.addListener(_onChangedClipboardStatus); |
||||
|
||||
widget.controller.addListener(() { |
||||
_didChangeTextEditingValue(widget.controller.ignoreFocusOnTextChange); |
||||
}); |
||||
|
||||
_scrollController = widget.scrollController; |
||||
_scrollController!.addListener(_updateSelectionOverlayForScroll); |
||||
|
||||
_cursorCont = CursorCont( |
||||
show: ValueNotifier<bool>(widget.showCursor), |
||||
style: widget.cursorStyle, |
||||
tickerProvider: this, |
||||
); |
||||
|
||||
_keyboardListener = KeyboardListener( |
||||
handleCursorMovement, |
||||
handleShortcut, |
||||
handleDelete, |
||||
); |
||||
|
||||
if (defaultTargetPlatform == TargetPlatform.windows || |
||||
defaultTargetPlatform == TargetPlatform.macOS || |
||||
defaultTargetPlatform == TargetPlatform.linux || |
||||
defaultTargetPlatform == TargetPlatform.fuchsia) { |
||||
_keyboardVisible = true; |
||||
} else { |
||||
_keyboardVisibilityController = KeyboardVisibilityController(); |
||||
_keyboardVisible = _keyboardVisibilityController!.isVisible; |
||||
_keyboardVisibilitySubscription = |
||||
_keyboardVisibilityController?.onChange.listen((visible) { |
||||
_keyboardVisible = visible; |
||||
if (visible) { |
||||
_onChangeTextEditingValue(); |
||||
} |
||||
}); |
||||
} |
||||
|
||||
_focusAttachment = widget.focusNode.attach(context, |
||||
onKey: (node, event) => _keyboardListener.handleRawKeyEvent(event)); |
||||
widget.focusNode.addListener(_handleFocusChanged); |
||||
} |
||||
|
||||
@override |
||||
void didChangeDependencies() { |
||||
super.didChangeDependencies(); |
||||
final parentStyles = QuillStyles.getStyles(context, true); |
||||
final defaultStyles = DefaultStyles.getInstance(context); |
||||
_styles = (parentStyles != null) |
||||
? defaultStyles.merge(parentStyles) |
||||
: defaultStyles; |
||||
|
||||
if (widget.customStyles != null) { |
||||
_styles = _styles!.merge(widget.customStyles!); |
||||
} |
||||
|
||||
if (!_didAutoFocus && widget.autoFocus) { |
||||
FocusScope.of(context).autofocus(widget.focusNode); |
||||
_didAutoFocus = true; |
||||
} |
||||
} |
||||
|
||||
@override |
||||
void didUpdateWidget(RawEditor oldWidget) { |
||||
super.didUpdateWidget(oldWidget); |
||||
|
||||
_cursorCont.show.value = widget.showCursor; |
||||
_cursorCont.style = widget.cursorStyle; |
||||
|
||||
if (widget.controller != oldWidget.controller) { |
||||
oldWidget.controller.removeListener(_didChangeTextEditingValue); |
||||
widget.controller.addListener(_didChangeTextEditingValue); |
||||
updateRemoteValueIfNeeded(); |
||||
} |
||||
|
||||
if (widget.scrollController != _scrollController) { |
||||
_scrollController!.removeListener(_updateSelectionOverlayForScroll); |
||||
_scrollController = widget.scrollController; |
||||
_scrollController!.addListener(_updateSelectionOverlayForScroll); |
||||
} |
||||
|
||||
if (widget.focusNode != oldWidget.focusNode) { |
||||
oldWidget.focusNode.removeListener(_handleFocusChanged); |
||||
_focusAttachment?.detach(); |
||||
_focusAttachment = widget.focusNode.attach(context, |
||||
onKey: (node, event) => _keyboardListener.handleRawKeyEvent(event)); |
||||
widget.focusNode.addListener(_handleFocusChanged); |
||||
updateKeepAlive(); |
||||
} |
||||
|
||||
if (widget.controller.selection != oldWidget.controller.selection) { |
||||
_selectionOverlay?.update(textEditingValue); |
||||
} |
||||
|
||||
_selectionOverlay?.handlesVisible = _shouldShowSelectionHandles(); |
||||
if (!shouldCreateInputConnection) { |
||||
closeConnectionIfNeeded(); |
||||
} else { |
||||
if (oldWidget.readOnly && _hasFocus) { |
||||
openConnectionIfNeeded(); |
||||
} |
||||
} |
||||
} |
||||
|
||||
bool _shouldShowSelectionHandles() { |
||||
return widget.showSelectionHandles && |
||||
!widget.controller.selection.isCollapsed; |
||||
} |
||||
|
||||
@override |
||||
void dispose() { |
||||
closeConnectionIfNeeded(); |
||||
_keyboardVisibilitySubscription?.cancel(); |
||||
assert(!hasConnection); |
||||
_selectionOverlay?.dispose(); |
||||
_selectionOverlay = null; |
||||
widget.controller.removeListener(_didChangeTextEditingValue); |
||||
widget.focusNode.removeListener(_handleFocusChanged); |
||||
_focusAttachment!.detach(); |
||||
_cursorCont.dispose(); |
||||
_clipboardStatus?.removeListener(_onChangedClipboardStatus); |
||||
_clipboardStatus?.dispose(); |
||||
super.dispose(); |
||||
} |
||||
|
||||
void _updateSelectionOverlayForScroll() { |
||||
_selectionOverlay?.markNeedsBuild(); |
||||
} |
||||
|
||||
void _didChangeTextEditingValue([bool ignoreFocus = false]) { |
||||
if (kIsWeb) { |
||||
_onChangeTextEditingValue(ignoreFocus); |
||||
if (!ignoreFocus) { |
||||
requestKeyboard(); |
||||
} |
||||
return; |
||||
} |
||||
|
||||
if (ignoreFocus || _keyboardVisible) { |
||||
_onChangeTextEditingValue(ignoreFocus); |
||||
} else { |
||||
requestKeyboard(); |
||||
if (mounted) { |
||||
setState(() { |
||||
// Use widget.controller.value in build() |
||||
// Trigger build and updateChildren |
||||
}); |
||||
} |
||||
} |
||||
} |
||||
|
||||
void _onChangeTextEditingValue([bool ignoreCaret = false]) { |
||||
updateRemoteValueIfNeeded(); |
||||
if (ignoreCaret) { |
||||
return; |
||||
} |
||||
_showCaretOnScreen(); |
||||
_cursorCont.startOrStopCursorTimerIfNeeded( |
||||
_hasFocus, widget.controller.selection); |
||||
if (hasConnection) { |
||||
_cursorCont |
||||
..stopCursorTimer(resetCharTicks: false) |
||||
..startCursorTimer(); |
||||
} |
||||
|
||||
SchedulerBinding.instance!.addPostFrameCallback( |
||||
(_) => _updateOrDisposeSelectionOverlayIfNeeded()); |
||||
if (mounted) { |
||||
setState(() { |
||||
// Use widget.controller.value in build() |
||||
// Trigger build and updateChildren |
||||
}); |
||||
} |
||||
} |
||||
|
||||
void _updateOrDisposeSelectionOverlayIfNeeded() { |
||||
if (_selectionOverlay != null) { |
||||
if (_hasFocus) { |
||||
_selectionOverlay!.update(textEditingValue); |
||||
} else { |
||||
_selectionOverlay!.dispose(); |
||||
_selectionOverlay = null; |
||||
} |
||||
} else if (_hasFocus) { |
||||
_selectionOverlay?.hide(); |
||||
_selectionOverlay = null; |
||||
|
||||
_selectionOverlay = EditorTextSelectionOverlay( |
||||
textEditingValue, |
||||
false, |
||||
context, |
||||
widget, |
||||
_toolbarLayerLink, |
||||
_startHandleLayerLink, |
||||
_endHandleLayerLink, |
||||
getRenderEditor(), |
||||
widget.selectionCtrls, |
||||
this, |
||||
DragStartBehavior.start, |
||||
null, |
||||
_clipboardStatus!, |
||||
); |
||||
_selectionOverlay!.handlesVisible = _shouldShowSelectionHandles(); |
||||
_selectionOverlay!.showHandles(); |
||||
} |
||||
} |
||||
|
||||
void _handleFocusChanged() { |
||||
openOrCloseConnection(); |
||||
_cursorCont.startOrStopCursorTimerIfNeeded( |
||||
_hasFocus, widget.controller.selection); |
||||
_updateOrDisposeSelectionOverlayIfNeeded(); |
||||
if (_hasFocus) { |
||||
WidgetsBinding.instance!.addObserver(this); |
||||
_showCaretOnScreen(); |
||||
} else { |
||||
WidgetsBinding.instance!.removeObserver(this); |
||||
} |
||||
updateKeepAlive(); |
||||
} |
||||
|
||||
void _onChangedClipboardStatus() { |
||||
if (!mounted) return; |
||||
setState(() { |
||||
// Inform the widget that the value of clipboardStatus has changed. |
||||
// Trigger build and updateChildren |
||||
}); |
||||
} |
||||
|
||||
bool _showCaretOnScreenScheduled = false; |
||||
|
||||
void _showCaretOnScreen() { |
||||
if (!widget.showCursor || _showCaretOnScreenScheduled) { |
||||
return; |
||||
} |
||||
|
||||
_showCaretOnScreenScheduled = true; |
||||
SchedulerBinding.instance!.addPostFrameCallback((_) { |
||||
if (widget.scrollable) { |
||||
_showCaretOnScreenScheduled = false; |
||||
|
||||
final viewport = RenderAbstractViewport.of(getRenderEditor()); |
||||
|
||||
final editorOffset = getRenderEditor()! |
||||
.localToGlobal(const Offset(0, 0), ancestor: viewport); |
||||
final offsetInViewport = _scrollController!.offset + editorOffset.dy; |
||||
|
||||
final offset = getRenderEditor()!.getOffsetToRevealCursor( |
||||
_scrollController!.position.viewportDimension, |
||||
_scrollController!.offset, |
||||
offsetInViewport, |
||||
); |
||||
|
||||
if (offset != null) { |
||||
_scrollController!.animateTo( |
||||
offset, |
||||
duration: const Duration(milliseconds: 100), |
||||
curve: Curves.fastOutSlowIn, |
||||
); |
||||
} |
||||
} |
||||
}); |
||||
} |
||||
|
||||
@override |
||||
RenderEditor? getRenderEditor() { |
||||
return _editorKey.currentContext!.findRenderObject() as RenderEditor?; |
||||
} |
||||
|
||||
@override |
||||
TextEditingValue getTextEditingValue() { |
||||
return widget.controller.plainTextEditingValue; |
||||
} |
||||
|
||||
@override |
||||
void requestKeyboard() { |
||||
if (_hasFocus) { |
||||
openConnectionIfNeeded(); |
||||
} else { |
||||
widget.focusNode.requestFocus(); |
||||
} |
||||
} |
||||
|
||||
@override |
||||
void setTextEditingValue(TextEditingValue value) { |
||||
if (value.text == textEditingValue.text) { |
||||
widget.controller.updateSelection(value.selection, ChangeSource.LOCAL); |
||||
} else { |
||||
__setEditingValue(value); |
||||
} |
||||
} |
||||
|
||||
Future<void> __setEditingValue(TextEditingValue value) async { |
||||
if (await __isItCut(value)) { |
||||
widget.controller.replaceText( |
||||
textEditingValue.selection.start, |
||||
textEditingValue.text.length - value.text.length, |
||||
'', |
||||
value.selection, |
||||
); |
||||
} else { |
||||
final value = textEditingValue; |
||||
final data = await Clipboard.getData(Clipboard.kTextPlain); |
||||
if (data != null) { |
||||
final length = |
||||
textEditingValue.selection.end - textEditingValue.selection.start; |
||||
widget.controller.replaceText( |
||||
value.selection.start, |
||||
length, |
||||
data.text, |
||||
value.selection, |
||||
); |
||||
// move cursor to the end of pasted text selection |
||||
widget.controller.updateSelection( |
||||
TextSelection.collapsed( |
||||
offset: value.selection.start + data.text!.length), |
||||
ChangeSource.LOCAL); |
||||
} |
||||
} |
||||
} |
||||
|
||||
Future<bool> __isItCut(TextEditingValue value) async { |
||||
final data = await Clipboard.getData(Clipboard.kTextPlain); |
||||
if (data == null) { |
||||
return false; |
||||
} |
||||
return textEditingValue.text.length - value.text.length == |
||||
data.text!.length; |
||||
} |
||||
|
||||
@override |
||||
bool showToolbar() { |
||||
// Web is using native dom elements to enable clipboard functionality of the |
||||
// toolbar: copy, paste, select, cut. It might also provide additional |
||||
// functionality depending on the browser (such as translate). Due to this |
||||
// we should not show a Flutter toolbar for the editable text elements. |
||||
if (kIsWeb) { |
||||
return false; |
||||
} |
||||
if (_selectionOverlay == null || _selectionOverlay!.toolbar != null) { |
||||
return false; |
||||
} |
||||
|
||||
_selectionOverlay!.update(textEditingValue); |
||||
_selectionOverlay!.showToolbar(); |
||||
return true; |
||||
} |
||||
|
||||
@override |
||||
bool get wantKeepAlive => widget.focusNode.hasFocus; |
||||
|
||||
@override |
||||
void userUpdateTextEditingValue( |
||||
TextEditingValue value, SelectionChangedCause cause) { |
||||
// TODO: implement userUpdateTextEditingValue |
||||
} |
||||
} |
||||
|
||||
class _Editor extends MultiChildRenderObjectWidget { |
||||
_Editor({ |
||||
required Key key, |
||||
required List<Widget> children, |
||||
required this.document, |
||||
required this.textDirection, |
||||
required this.hasFocus, |
||||
required this.selection, |
||||
required this.startHandleLayerLink, |
||||
required this.endHandleLayerLink, |
||||
required this.onSelectionChanged, |
||||
required this.scrollBottomInset, |
||||
this.padding = EdgeInsets.zero, |
||||
}) : super(key: key, children: children); |
||||
|
||||
final Document document; |
||||
final TextDirection textDirection; |
||||
final bool hasFocus; |
||||
final TextSelection selection; |
||||
final LayerLink startHandleLayerLink; |
||||
final LayerLink endHandleLayerLink; |
||||
final TextSelectionChangedHandler onSelectionChanged; |
||||
final double scrollBottomInset; |
||||
final EdgeInsetsGeometry padding; |
||||
|
||||
@override |
||||
RenderEditor createRenderObject(BuildContext context) { |
||||
return RenderEditor( |
||||
null, |
||||
textDirection, |
||||
scrollBottomInset, |
||||
padding, |
||||
document, |
||||
selection, |
||||
hasFocus, |
||||
onSelectionChanged, |
||||
startHandleLayerLink, |
||||
endHandleLayerLink, |
||||
const EdgeInsets.fromLTRB(4, 4, 4, 5), |
||||
); |
||||
} |
||||
|
||||
@override |
||||
void updateRenderObject( |
||||
BuildContext context, covariant RenderEditor renderObject) { |
||||
renderObject |
||||
..document = document |
||||
..setContainer(document.root) |
||||
..textDirection = textDirection |
||||
..setHasFocus(hasFocus) |
||||
..setSelection(selection) |
||||
..setStartHandleLayerLink(startHandleLayerLink) |
||||
..setEndHandleLayerLink(endHandleLayerLink) |
||||
..onSelectionChanged = onSelectionChanged |
||||
..setScrollBottomInset(scrollBottomInset) |
||||
..setPadding(padding); |
||||
} |
||||
} |
||||
/// TODO: Remove this file in the next breaking release, because implementation |
||||
/// files should be located in the src folder, https://bit.ly/3fA23Yz. |
||||
export '../../src/widgets/raw_editor.dart'; |
||||
|
@ -1,354 +1,3 @@ |
||||
import 'dart:ui'; |
||||
|
||||
import 'package:characters/characters.dart'; |
||||
import 'package:flutter/services.dart'; |
||||
|
||||
import '../../models/documents/document.dart'; |
||||
import '../../utils/diff_delta.dart'; |
||||
import '../editor.dart'; |
||||
import '../keyboard_listener.dart'; |
||||
|
||||
mixin RawEditorStateKeyboardMixin on EditorState { |
||||
// Holds the last cursor location the user selected in the case the user tries |
||||
// to select vertically past the end or beginning of the field. If they do, |
||||
// then we need to keep the old cursor location so that we can go back to it |
||||
// if they change their minds. Only used for moving selection up and down in a |
||||
// multiline text field when selecting using the keyboard. |
||||
int _cursorResetLocation = -1; |
||||
|
||||
// Whether we should reset the location of the cursor in the case the user |
||||
// tries to select vertically past the end or beginning of the field. If they |
||||
// do, then we need to keep the old cursor location so that we can go back to |
||||
// it if they change their minds. Only used for resetting selection up and |
||||
// down in a multiline text field when selecting using the keyboard. |
||||
bool _wasSelectingVerticallyWithKeyboard = false; |
||||
|
||||
void handleCursorMovement( |
||||
LogicalKeyboardKey key, |
||||
bool wordModifier, |
||||
bool lineModifier, |
||||
bool shift, |
||||
) { |
||||
if (wordModifier && lineModifier) { |
||||
// If both modifiers are down, nothing happens on any of the platforms. |
||||
return; |
||||
} |
||||
final selection = widget.controller.selection; |
||||
|
||||
var newSelection = widget.controller.selection; |
||||
|
||||
final plainText = getTextEditingValue().text; |
||||
|
||||
final rightKey = key == LogicalKeyboardKey.arrowRight, |
||||
leftKey = key == LogicalKeyboardKey.arrowLeft, |
||||
upKey = key == LogicalKeyboardKey.arrowUp, |
||||
downKey = key == LogicalKeyboardKey.arrowDown; |
||||
|
||||
if ((rightKey || leftKey) && !(rightKey && leftKey)) { |
||||
newSelection = _jumpToBeginOrEndOfWord(newSelection, wordModifier, |
||||
leftKey, rightKey, plainText, lineModifier, shift); |
||||
} |
||||
|
||||
if (downKey || upKey) { |
||||
newSelection = _handleMovingCursorVertically( |
||||
upKey, downKey, shift, selection, newSelection, plainText); |
||||
} |
||||
|
||||
if (!shift) { |
||||
newSelection = |
||||
_placeCollapsedSelection(selection, newSelection, leftKey, rightKey); |
||||
} |
||||
|
||||
widget.controller.updateSelection(newSelection, ChangeSource.LOCAL); |
||||
} |
||||
|
||||
// Handles shortcut functionality including cut, copy, paste and select all |
||||
// using control/command + (X, C, V, A). |
||||
// TODO: Add support for formatting shortcuts: Cmd+B (bold), Cmd+I (italic) |
||||
Future<void> handleShortcut(InputShortcut? shortcut) async { |
||||
final selection = widget.controller.selection; |
||||
final plainText = getTextEditingValue().text; |
||||
if (shortcut == InputShortcut.COPY) { |
||||
if (!selection.isCollapsed) { |
||||
await Clipboard.setData( |
||||
ClipboardData(text: selection.textInside(plainText))); |
||||
} |
||||
return; |
||||
} |
||||
if (shortcut == InputShortcut.CUT && !widget.readOnly) { |
||||
if (!selection.isCollapsed) { |
||||
final data = selection.textInside(plainText); |
||||
await Clipboard.setData(ClipboardData(text: data)); |
||||
|
||||
widget.controller.replaceText( |
||||
selection.start, |
||||
data.length, |
||||
'', |
||||
TextSelection.collapsed(offset: selection.start), |
||||
); |
||||
|
||||
setTextEditingValue(TextEditingValue( |
||||
text: |
||||
selection.textBefore(plainText) + selection.textAfter(plainText), |
||||
selection: TextSelection.collapsed(offset: selection.start), |
||||
)); |
||||
} |
||||
return; |
||||
} |
||||
if (shortcut == InputShortcut.PASTE && !widget.readOnly) { |
||||
final data = await Clipboard.getData(Clipboard.kTextPlain); |
||||
if (data != null) { |
||||
widget.controller.replaceText( |
||||
selection.start, |
||||
selection.end - selection.start, |
||||
data.text, |
||||
TextSelection.collapsed(offset: selection.start + data.text!.length), |
||||
); |
||||
} |
||||
return; |
||||
} |
||||
if (shortcut == InputShortcut.SELECT_ALL && |
||||
widget.enableInteractiveSelection) { |
||||
widget.controller.updateSelection( |
||||
selection.copyWith( |
||||
baseOffset: 0, |
||||
extentOffset: getTextEditingValue().text.length, |
||||
), |
||||
ChangeSource.REMOTE); |
||||
return; |
||||
} |
||||
} |
||||
|
||||
void handleDelete(bool forward) { |
||||
final selection = widget.controller.selection; |
||||
final plainText = getTextEditingValue().text; |
||||
var cursorPosition = selection.start; |
||||
var textBefore = selection.textBefore(plainText); |
||||
var textAfter = selection.textAfter(plainText); |
||||
if (selection.isCollapsed) { |
||||
if (!forward && textBefore.isNotEmpty) { |
||||
final characterBoundary = |
||||
_previousCharacter(textBefore.length, textBefore, true); |
||||
textBefore = textBefore.substring(0, characterBoundary); |
||||
cursorPosition = characterBoundary; |
||||
} |
||||
if (forward && textAfter.isNotEmpty && textAfter != '\n') { |
||||
final deleteCount = _nextCharacter(0, textAfter, true); |
||||
textAfter = textAfter.substring(deleteCount); |
||||
} |
||||
} |
||||
final newSelection = TextSelection.collapsed(offset: cursorPosition); |
||||
final newText = textBefore + textAfter; |
||||
final size = plainText.length - newText.length; |
||||
widget.controller.replaceText( |
||||
cursorPosition, |
||||
size, |
||||
'', |
||||
newSelection, |
||||
); |
||||
} |
||||
|
||||
TextSelection _jumpToBeginOrEndOfWord( |
||||
TextSelection newSelection, |
||||
bool wordModifier, |
||||
bool leftKey, |
||||
bool rightKey, |
||||
String plainText, |
||||
bool lineModifier, |
||||
bool shift) { |
||||
if (wordModifier) { |
||||
if (leftKey) { |
||||
final textSelection = getRenderEditor()!.selectWordAtPosition( |
||||
TextPosition( |
||||
offset: _previousCharacter( |
||||
newSelection.extentOffset, plainText, false))); |
||||
return newSelection.copyWith(extentOffset: textSelection.baseOffset); |
||||
} |
||||
final textSelection = getRenderEditor()!.selectWordAtPosition( |
||||
TextPosition( |
||||
offset: |
||||
_nextCharacter(newSelection.extentOffset, plainText, false))); |
||||
return newSelection.copyWith(extentOffset: textSelection.extentOffset); |
||||
} else if (lineModifier) { |
||||
if (leftKey) { |
||||
final textSelection = getRenderEditor()!.selectLineAtPosition( |
||||
TextPosition( |
||||
offset: _previousCharacter( |
||||
newSelection.extentOffset, plainText, false))); |
||||
return newSelection.copyWith(extentOffset: textSelection.baseOffset); |
||||
} |
||||
final startPoint = newSelection.extentOffset; |
||||
if (startPoint < plainText.length) { |
||||
final textSelection = getRenderEditor()! |
||||
.selectLineAtPosition(TextPosition(offset: startPoint)); |
||||
return newSelection.copyWith(extentOffset: textSelection.extentOffset); |
||||
} |
||||
return newSelection; |
||||
} |
||||
|
||||
if (rightKey && newSelection.extentOffset < plainText.length) { |
||||
final nextExtent = |
||||
_nextCharacter(newSelection.extentOffset, plainText, true); |
||||
final distance = nextExtent - newSelection.extentOffset; |
||||
newSelection = newSelection.copyWith(extentOffset: nextExtent); |
||||
if (shift) { |
||||
_cursorResetLocation += distance; |
||||
} |
||||
return newSelection; |
||||
} |
||||
|
||||
if (leftKey && newSelection.extentOffset > 0) { |
||||
final previousExtent = |
||||
_previousCharacter(newSelection.extentOffset, plainText, true); |
||||
final distance = newSelection.extentOffset - previousExtent; |
||||
newSelection = newSelection.copyWith(extentOffset: previousExtent); |
||||
if (shift) { |
||||
_cursorResetLocation -= distance; |
||||
} |
||||
return newSelection; |
||||
} |
||||
return newSelection; |
||||
} |
||||
|
||||
/// Returns the index into the string of the next character boundary after the |
||||
/// given index. |
||||
/// |
||||
/// The character boundary is determined by the characters package, so |
||||
/// surrogate pairs and extended grapheme clusters are considered. |
||||
/// |
||||
/// The index must be between 0 and string.length, inclusive. If given |
||||
/// string.length, string.length is returned. |
||||
/// |
||||
/// Setting includeWhitespace to false will only return the index of non-space |
||||
/// characters. |
||||
int _nextCharacter(int index, String string, bool includeWhitespace) { |
||||
assert(index >= 0 && index <= string.length); |
||||
if (index == string.length) { |
||||
return string.length; |
||||
} |
||||
|
||||
var count = 0; |
||||
final remain = string.characters.skipWhile((currentString) { |
||||
if (count <= index) { |
||||
count += currentString.length; |
||||
return true; |
||||
} |
||||
if (includeWhitespace) { |
||||
return false; |
||||
} |
||||
return WHITE_SPACE.contains(currentString.codeUnitAt(0)); |
||||
}); |
||||
return string.length - remain.toString().length; |
||||
} |
||||
|
||||
/// Returns the index into the string of the previous character boundary |
||||
/// before the given index. |
||||
/// |
||||
/// The character boundary is determined by the characters package, so |
||||
/// surrogate pairs and extended grapheme clusters are considered. |
||||
/// |
||||
/// The index must be between 0 and string.length, inclusive. If index is 0, |
||||
/// 0 will be returned. |
||||
/// |
||||
/// Setting includeWhitespace to false will only return the index of non-space |
||||
/// characters. |
||||
int _previousCharacter(int index, String string, includeWhitespace) { |
||||
assert(index >= 0 && index <= string.length); |
||||
if (index == 0) { |
||||
return 0; |
||||
} |
||||
|
||||
var count = 0; |
||||
int? lastNonWhitespace; |
||||
for (final currentString in string.characters) { |
||||
if (!includeWhitespace && |
||||
!WHITE_SPACE.contains( |
||||
currentString.characters.first.toString().codeUnitAt(0))) { |
||||
lastNonWhitespace = count; |
||||
} |
||||
if (count + currentString.length >= index) { |
||||
return includeWhitespace ? count : lastNonWhitespace ?? 0; |
||||
} |
||||
count += currentString.length; |
||||
} |
||||
return 0; |
||||
} |
||||
|
||||
TextSelection _handleMovingCursorVertically( |
||||
bool upKey, |
||||
bool downKey, |
||||
bool shift, |
||||
TextSelection selection, |
||||
TextSelection newSelection, |
||||
String plainText) { |
||||
final originPosition = TextPosition( |
||||
offset: upKey ? selection.baseOffset : selection.extentOffset); |
||||
|
||||
final child = getRenderEditor()!.childAtPosition(originPosition); |
||||
final localPosition = TextPosition( |
||||
offset: originPosition.offset - child.getContainer().documentOffset); |
||||
|
||||
var position = upKey |
||||
? child.getPositionAbove(localPosition) |
||||
: child.getPositionBelow(localPosition); |
||||
|
||||
if (position == null) { |
||||
final sibling = upKey |
||||
? getRenderEditor()!.childBefore(child) |
||||
: getRenderEditor()!.childAfter(child); |
||||
if (sibling == null) { |
||||
position = TextPosition(offset: upKey ? 0 : plainText.length - 1); |
||||
} else { |
||||
final finalOffset = Offset( |
||||
child.getOffsetForCaret(localPosition).dx, |
||||
sibling |
||||
.getOffsetForCaret(TextPosition( |
||||
offset: upKey ? sibling.getContainer().length - 1 : 0)) |
||||
.dy); |
||||
final siblingPosition = sibling.getPositionForOffset(finalOffset); |
||||
position = TextPosition( |
||||
offset: |
||||
sibling.getContainer().documentOffset + siblingPosition.offset); |
||||
} |
||||
} else { |
||||
position = TextPosition( |
||||
offset: child.getContainer().documentOffset + position.offset); |
||||
} |
||||
|
||||
if (position.offset == newSelection.extentOffset) { |
||||
if (downKey) { |
||||
newSelection = newSelection.copyWith(extentOffset: plainText.length); |
||||
} else if (upKey) { |
||||
newSelection = newSelection.copyWith(extentOffset: 0); |
||||
} |
||||
_wasSelectingVerticallyWithKeyboard = shift; |
||||
return newSelection; |
||||
} |
||||
|
||||
if (_wasSelectingVerticallyWithKeyboard && shift) { |
||||
newSelection = newSelection.copyWith(extentOffset: _cursorResetLocation); |
||||
_wasSelectingVerticallyWithKeyboard = false; |
||||
return newSelection; |
||||
} |
||||
newSelection = newSelection.copyWith(extentOffset: position.offset); |
||||
_cursorResetLocation = newSelection.extentOffset; |
||||
return newSelection; |
||||
} |
||||
|
||||
TextSelection _placeCollapsedSelection(TextSelection selection, |
||||
TextSelection newSelection, bool leftKey, bool rightKey) { |
||||
var newOffset = newSelection.extentOffset; |
||||
if (!selection.isCollapsed) { |
||||
if (leftKey) { |
||||
newOffset = newSelection.baseOffset < newSelection.extentOffset |
||||
? newSelection.baseOffset |
||||
: newSelection.extentOffset; |
||||
} else if (rightKey) { |
||||
newOffset = newSelection.baseOffset > newSelection.extentOffset |
||||
? newSelection.baseOffset |
||||
: newSelection.extentOffset; |
||||
} |
||||
} |
||||
return TextSelection.fromPosition(TextPosition(offset: newOffset)); |
||||
} |
||||
} |
||||
/// TODO: Remove this file in the next breaking release, because implementation |
||||
/// files should be located in the src folder, https://bit.ly/3fA23Yz. |
||||
export '../../src/widgets/raw_editor/raw_editor_state_keyboard_mixin.dart'; |
||||
|
@ -1,40 +1,3 @@ |
||||
import 'package:flutter/widgets.dart'; |
||||
|
||||
import '../editor.dart'; |
||||
|
||||
mixin RawEditorStateSelectionDelegateMixin on EditorState |
||||
implements TextSelectionDelegate { |
||||
@override |
||||
TextEditingValue get textEditingValue { |
||||
return getTextEditingValue(); |
||||
} |
||||
|
||||
@override |
||||
set textEditingValue(TextEditingValue value) { |
||||
setTextEditingValue(value); |
||||
} |
||||
|
||||
@override |
||||
void bringIntoView(TextPosition position) { |
||||
// TODO: implement bringIntoView |
||||
} |
||||
|
||||
@override |
||||
void hideToolbar([bool hideHandles = true]) { |
||||
if (getSelectionOverlay()?.toolbar != null) { |
||||
getSelectionOverlay()?.hideToolbar(); |
||||
} |
||||
} |
||||
|
||||
@override |
||||
bool get cutEnabled => widget.toolbarOptions.cut && !widget.readOnly; |
||||
|
||||
@override |
||||
bool get copyEnabled => widget.toolbarOptions.copy; |
||||
|
||||
@override |
||||
bool get pasteEnabled => widget.toolbarOptions.paste && !widget.readOnly; |
||||
|
||||
@override |
||||
bool get selectAllEnabled => widget.toolbarOptions.selectAll; |
||||
} |
||||
/// TODO: Remove this file in the next breaking release, because implementation |
||||
/// files should be located in the src folder, https://bit.ly/3fA23Yz. |
||||
export '../../src/widgets/raw_editor/raw_editor_state_selection_delegate_mixin.dart'; |
||||
|
@ -1,200 +1,3 @@ |
||||
import 'package:flutter/foundation.dart'; |
||||
import 'package:flutter/services.dart'; |
||||
import 'package:flutter/widgets.dart'; |
||||
|
||||
import '../../utils/diff_delta.dart'; |
||||
import '../editor.dart'; |
||||
|
||||
mixin RawEditorStateTextInputClientMixin on EditorState |
||||
implements TextInputClient { |
||||
final List<TextEditingValue> _sentRemoteValues = []; |
||||
TextInputConnection? _textInputConnection; |
||||
TextEditingValue? _lastKnownRemoteTextEditingValue; |
||||
|
||||
/// Whether to create an input connection with the platform for text editing |
||||
/// or not. |
||||
/// |
||||
/// Read-only input fields do not need a connection with the platform since |
||||
/// there's no need for text editing capabilities (e.g. virtual keyboard). |
||||
/// |
||||
/// On the web, we always need a connection because we want some browser |
||||
/// functionalities to continue to work on read-only input fields like: |
||||
/// |
||||
/// - Relevant context menu. |
||||
/// - cmd/ctrl+c shortcut to copy. |
||||
/// - cmd/ctrl+a to select all. |
||||
/// - Changing the selection using a physical keyboard. |
||||
bool get shouldCreateInputConnection => kIsWeb || !widget.readOnly; |
||||
|
||||
/// Returns `true` if there is open input connection. |
||||
bool get hasConnection => |
||||
_textInputConnection != null && _textInputConnection!.attached; |
||||
|
||||
/// Opens or closes input connection based on the current state of |
||||
/// [focusNode] and [value]. |
||||
void openOrCloseConnection() { |
||||
if (widget.focusNode.hasFocus && widget.focusNode.consumeKeyboardToken()) { |
||||
openConnectionIfNeeded(); |
||||
} else if (!widget.focusNode.hasFocus) { |
||||
closeConnectionIfNeeded(); |
||||
} |
||||
} |
||||
|
||||
void openConnectionIfNeeded() { |
||||
if (!shouldCreateInputConnection) { |
||||
return; |
||||
} |
||||
|
||||
if (!hasConnection) { |
||||
_lastKnownRemoteTextEditingValue = getTextEditingValue(); |
||||
_textInputConnection = TextInput.attach( |
||||
this, |
||||
TextInputConfiguration( |
||||
inputType: TextInputType.multiline, |
||||
readOnly: widget.readOnly, |
||||
inputAction: TextInputAction.newline, |
||||
enableSuggestions: !widget.readOnly, |
||||
keyboardAppearance: widget.keyboardAppearance, |
||||
textCapitalization: widget.textCapitalization, |
||||
), |
||||
); |
||||
|
||||
_textInputConnection!.setEditingState(_lastKnownRemoteTextEditingValue!); |
||||
// _sentRemoteValues.add(_lastKnownRemoteTextEditingValue); |
||||
} |
||||
|
||||
_textInputConnection!.show(); |
||||
} |
||||
|
||||
/// Closes input connection if it's currently open. Otherwise does nothing. |
||||
void closeConnectionIfNeeded() { |
||||
if (!hasConnection) { |
||||
return; |
||||
} |
||||
_textInputConnection!.close(); |
||||
_textInputConnection = null; |
||||
_lastKnownRemoteTextEditingValue = null; |
||||
_sentRemoteValues.clear(); |
||||
} |
||||
|
||||
/// Updates remote value based on current state of [document] and |
||||
/// [selection]. |
||||
/// |
||||
/// This method may not actually send an update to native side if it thinks |
||||
/// remote value is up to date or identical. |
||||
void updateRemoteValueIfNeeded() { |
||||
if (!hasConnection) { |
||||
return; |
||||
} |
||||
|
||||
// Since we don't keep track of the composing range in value provided |
||||
// by the Controller we need to add it here manually before comparing |
||||
// with the last known remote value. |
||||
// It is important to prevent excessive remote updates as it can cause |
||||
// race conditions. |
||||
final actualValue = getTextEditingValue().copyWith( |
||||
composing: _lastKnownRemoteTextEditingValue!.composing, |
||||
); |
||||
|
||||
if (actualValue == _lastKnownRemoteTextEditingValue) { |
||||
return; |
||||
} |
||||
|
||||
final shouldRemember = |
||||
getTextEditingValue().text != _lastKnownRemoteTextEditingValue!.text; |
||||
_lastKnownRemoteTextEditingValue = actualValue; |
||||
_textInputConnection!.setEditingState(actualValue); |
||||
if (shouldRemember) { |
||||
// Only keep track if text changed (selection changes are not relevant) |
||||
_sentRemoteValues.add(actualValue); |
||||
} |
||||
} |
||||
|
||||
@override |
||||
TextEditingValue? get currentTextEditingValue => |
||||
_lastKnownRemoteTextEditingValue; |
||||
|
||||
// autofill is not needed |
||||
@override |
||||
AutofillScope? get currentAutofillScope => null; |
||||
|
||||
@override |
||||
void updateEditingValue(TextEditingValue value) { |
||||
if (!shouldCreateInputConnection) { |
||||
return; |
||||
} |
||||
|
||||
if (_sentRemoteValues.contains(value)) { |
||||
/// There is a race condition in Flutter text input plugin where sending |
||||
/// updates to native side too often results in broken behavior. |
||||
/// TextInputConnection.setEditingValue is an async call to native side. |
||||
/// For each such call native side _always_ sends an update which triggers |
||||
/// this method (updateEditingValue) with the same value we've sent it. |
||||
/// If multiple calls to setEditingValue happen too fast and we only |
||||
/// track the last sent value then there is no way for us to filter out |
||||
/// automatic callbacks from native side. |
||||
/// Therefore we have to keep track of all values we send to the native |
||||
/// side and when we see this same value appear here we skip it. |
||||
/// This is fragile but it's probably the only available option. |
||||
_sentRemoteValues.remove(value); |
||||
return; |
||||
} |
||||
|
||||
if (_lastKnownRemoteTextEditingValue == value) { |
||||
// There is no difference between this value and the last known value. |
||||
return; |
||||
} |
||||
|
||||
// Check if only composing range changed. |
||||
if (_lastKnownRemoteTextEditingValue!.text == value.text && |
||||
_lastKnownRemoteTextEditingValue!.selection == value.selection) { |
||||
// This update only modifies composing range. Since we don't keep track |
||||
// of composing range we just need to update last known value here. |
||||
// This check fixes an issue on Android when it sends |
||||
// composing updates separately from regular changes for text and |
||||
// selection. |
||||
_lastKnownRemoteTextEditingValue = value; |
||||
return; |
||||
} |
||||
|
||||
final effectiveLastKnownValue = _lastKnownRemoteTextEditingValue!; |
||||
_lastKnownRemoteTextEditingValue = value; |
||||
final oldText = effectiveLastKnownValue.text; |
||||
final text = value.text; |
||||
final cursorPosition = value.selection.extentOffset; |
||||
final diff = getDiff(oldText, text, cursorPosition); |
||||
widget.controller.replaceText( |
||||
diff.start, diff.deleted.length, diff.inserted, value.selection); |
||||
} |
||||
|
||||
@override |
||||
void performAction(TextInputAction action) { |
||||
// no-op |
||||
} |
||||
|
||||
@override |
||||
void performPrivateCommand(String action, Map<String, dynamic> data) { |
||||
// no-op |
||||
} |
||||
|
||||
@override |
||||
void updateFloatingCursor(RawFloatingCursorPoint point) { |
||||
throw UnimplementedError(); |
||||
} |
||||
|
||||
@override |
||||
void showAutocorrectionPromptRect(int start, int end) { |
||||
throw UnimplementedError(); |
||||
} |
||||
|
||||
@override |
||||
void connectionClosed() { |
||||
if (!hasConnection) { |
||||
return; |
||||
} |
||||
_textInputConnection!.connectionClosedReceived(); |
||||
_textInputConnection = null; |
||||
_lastKnownRemoteTextEditingValue = null; |
||||
_sentRemoteValues.clear(); |
||||
} |
||||
} |
||||
/// TODO: Remove this file in the next breaking release, because implementation |
||||
/// files should be located in the src folder, https://bit.ly/3fA23Yz. |
||||
export '../../src/widgets/raw_editor/raw_editor_state_text_input_client_mixin.dart'; |
||||
|
@ -1,43 +1,3 @@ |
||||
import 'package:flutter/material.dart'; |
||||
|
||||
class ResponsiveWidget extends StatelessWidget { |
||||
const ResponsiveWidget({ |
||||
required this.largeScreen, |
||||
this.mediumScreen, |
||||
this.smallScreen, |
||||
Key? key, |
||||
}) : super(key: key); |
||||
|
||||
final Widget largeScreen; |
||||
final Widget? mediumScreen; |
||||
final Widget? smallScreen; |
||||
|
||||
static bool isSmallScreen(BuildContext context) { |
||||
return MediaQuery.of(context).size.width < 800; |
||||
} |
||||
|
||||
static bool isLargeScreen(BuildContext context) { |
||||
return MediaQuery.of(context).size.width > 1200; |
||||
} |
||||
|
||||
static bool isMediumScreen(BuildContext context) { |
||||
return MediaQuery.of(context).size.width >= 800 && |
||||
MediaQuery.of(context).size.width <= 1200; |
||||
} |
||||
|
||||
@override |
||||
Widget build(BuildContext context) { |
||||
return LayoutBuilder( |
||||
builder: (context, constraints) { |
||||
if (constraints.maxWidth > 1200) { |
||||
return largeScreen; |
||||
} else if (constraints.maxWidth <= 1200 && |
||||
constraints.maxWidth >= 800) { |
||||
return mediumScreen ?? largeScreen; |
||||
} else { |
||||
return smallScreen ?? largeScreen; |
||||
} |
||||
}, |
||||
); |
||||
} |
||||
} |
||||
/// TODO: Remove this file in the next breaking release, because implementation |
||||
/// files should be located in the src folder, https://bit.ly/3fA23Yz. |
||||
export '../../src/widgets/responsive_widget.dart'; |
||||
|
@ -1,344 +1,3 @@ |
||||
import 'dart:convert'; |
||||
import 'dart:io' as io; |
||||
|
||||
import 'package:flutter/foundation.dart'; |
||||
import 'package:flutter/material.dart'; |
||||
import 'package:flutter/rendering.dart'; |
||||
import 'package:flutter/services.dart'; |
||||
import 'package:string_validator/string_validator.dart'; |
||||
import 'package:tuple/tuple.dart'; |
||||
|
||||
import '../models/documents/attribute.dart'; |
||||
import '../models/documents/document.dart'; |
||||
import '../models/documents/nodes/block.dart'; |
||||
import '../models/documents/nodes/leaf.dart' as leaf; |
||||
import '../models/documents/nodes/line.dart'; |
||||
import 'controller.dart'; |
||||
import 'cursor.dart'; |
||||
import 'default_styles.dart'; |
||||
import 'delegate.dart'; |
||||
import 'editor.dart'; |
||||
import 'text_block.dart'; |
||||
import 'text_line.dart'; |
||||
|
||||
class QuillSimpleViewer extends StatefulWidget { |
||||
const QuillSimpleViewer({ |
||||
required this.controller, |
||||
this.customStyles, |
||||
this.truncate = false, |
||||
this.truncateScale, |
||||
this.truncateAlignment, |
||||
this.truncateHeight, |
||||
this.truncateWidth, |
||||
this.scrollBottomInset = 0, |
||||
this.padding = EdgeInsets.zero, |
||||
this.embedBuilder, |
||||
Key? key, |
||||
}) : assert(truncate || |
||||
((truncateScale == null) && |
||||
(truncateAlignment == null) && |
||||
(truncateHeight == null) && |
||||
(truncateWidth == null))), |
||||
super(key: key); |
||||
|
||||
final QuillController controller; |
||||
final DefaultStyles? customStyles; |
||||
final bool truncate; |
||||
final double? truncateScale; |
||||
final Alignment? truncateAlignment; |
||||
final double? truncateHeight; |
||||
final double? truncateWidth; |
||||
final double scrollBottomInset; |
||||
final EdgeInsetsGeometry padding; |
||||
final EmbedBuilder? embedBuilder; |
||||
|
||||
@override |
||||
_QuillSimpleViewerState createState() => _QuillSimpleViewerState(); |
||||
} |
||||
|
||||
class _QuillSimpleViewerState extends State<QuillSimpleViewer> |
||||
with SingleTickerProviderStateMixin { |
||||
late DefaultStyles _styles; |
||||
final LayerLink _toolbarLayerLink = LayerLink(); |
||||
final LayerLink _startHandleLayerLink = LayerLink(); |
||||
final LayerLink _endHandleLayerLink = LayerLink(); |
||||
late CursorCont _cursorCont; |
||||
|
||||
@override |
||||
void initState() { |
||||
super.initState(); |
||||
|
||||
_cursorCont = CursorCont( |
||||
show: ValueNotifier<bool>(false), |
||||
style: const CursorStyle( |
||||
color: Colors.black, |
||||
backgroundColor: Colors.grey, |
||||
width: 2, |
||||
radius: Radius.zero, |
||||
offset: Offset.zero, |
||||
), |
||||
tickerProvider: this, |
||||
); |
||||
} |
||||
|
||||
@override |
||||
void didChangeDependencies() { |
||||
super.didChangeDependencies(); |
||||
final parentStyles = QuillStyles.getStyles(context, true); |
||||
final defaultStyles = DefaultStyles.getInstance(context); |
||||
_styles = (parentStyles != null) |
||||
? defaultStyles.merge(parentStyles) |
||||
: defaultStyles; |
||||
|
||||
if (widget.customStyles != null) { |
||||
_styles = _styles.merge(widget.customStyles!); |
||||
} |
||||
} |
||||
|
||||
EmbedBuilder get embedBuilder => widget.embedBuilder ?? _defaultEmbedBuilder; |
||||
|
||||
Widget _defaultEmbedBuilder(BuildContext context, leaf.Embed node) { |
||||
assert(!kIsWeb, 'Please provide EmbedBuilder for Web'); |
||||
switch (node.value.type) { |
||||
case 'image': |
||||
final imageUrl = _standardizeImageUrl(node.value.data); |
||||
return imageUrl.startsWith('http') |
||||
? Image.network(imageUrl) |
||||
: isBase64(imageUrl) |
||||
? Image.memory(base64.decode(imageUrl)) |
||||
: Image.file(io.File(imageUrl)); |
||||
default: |
||||
throw UnimplementedError( |
||||
'Embeddable type "${node.value.type}" is not supported by default embed ' |
||||
'builder of QuillEditor. You must pass your own builder function to ' |
||||
'embedBuilder property of QuillEditor or QuillField widgets.'); |
||||
} |
||||
} |
||||
|
||||
String _standardizeImageUrl(String url) { |
||||
if (url.contains('base64')) { |
||||
return url.split(',')[1]; |
||||
} |
||||
return url; |
||||
} |
||||
|
||||
@override |
||||
Widget build(BuildContext context) { |
||||
final _doc = widget.controller.document; |
||||
// if (_doc.isEmpty() && |
||||
// !widget.focusNode.hasFocus && |
||||
// widget.placeholder != null) { |
||||
// _doc = Document.fromJson(jsonDecode( |
||||
// '[{"attributes":{"placeholder":true},"insert":"${widget.placeholder}\\n"}]')); |
||||
// } |
||||
|
||||
Widget child = CompositedTransformTarget( |
||||
link: _toolbarLayerLink, |
||||
child: Semantics( |
||||
child: _SimpleViewer( |
||||
document: _doc, |
||||
textDirection: _textDirection, |
||||
startHandleLayerLink: _startHandleLayerLink, |
||||
endHandleLayerLink: _endHandleLayerLink, |
||||
onSelectionChanged: _nullSelectionChanged, |
||||
scrollBottomInset: widget.scrollBottomInset, |
||||
padding: widget.padding, |
||||
children: _buildChildren(_doc, context), |
||||
), |
||||
), |
||||
); |
||||
|
||||
if (widget.truncate) { |
||||
if (widget.truncateScale != null) { |
||||
child = Container( |
||||
height: widget.truncateHeight, |
||||
child: Align( |
||||
heightFactor: widget.truncateScale, |
||||
widthFactor: widget.truncateScale, |
||||
alignment: widget.truncateAlignment ?? Alignment.topLeft, |
||||
child: Container( |
||||
width: widget.truncateWidth! / widget.truncateScale!, |
||||
child: SingleChildScrollView( |
||||
physics: const NeverScrollableScrollPhysics(), |
||||
child: Transform.scale( |
||||
scale: widget.truncateScale!, |
||||
alignment: |
||||
widget.truncateAlignment ?? Alignment.topLeft, |
||||
child: child))))); |
||||
} else { |
||||
child = Container( |
||||
height: widget.truncateHeight, |
||||
width: widget.truncateWidth, |
||||
child: SingleChildScrollView( |
||||
physics: const NeverScrollableScrollPhysics(), child: child)); |
||||
} |
||||
} |
||||
|
||||
return QuillStyles(data: _styles, child: child); |
||||
} |
||||
|
||||
List<Widget> _buildChildren(Document doc, BuildContext context) { |
||||
final result = <Widget>[]; |
||||
final indentLevelCounts = <int, int>{}; |
||||
for (final node in doc.root.children) { |
||||
if (node is Line) { |
||||
final editableTextLine = _getEditableTextLineFromNode(node, context); |
||||
result.add(editableTextLine); |
||||
} else if (node is Block) { |
||||
final attrs = node.style.attributes; |
||||
final editableTextBlock = EditableTextBlock( |
||||
node, |
||||
_textDirection, |
||||
widget.scrollBottomInset, |
||||
_getVerticalSpacingForBlock(node, _styles), |
||||
widget.controller.selection, |
||||
Colors.black, |
||||
// selectionColor, |
||||
_styles, |
||||
false, |
||||
// enableInteractiveSelection, |
||||
false, |
||||
// hasFocus, |
||||
attrs.containsKey(Attribute.codeBlock.key) |
||||
? const EdgeInsets.all(16) |
||||
: null, |
||||
embedBuilder, |
||||
_cursorCont, |
||||
indentLevelCounts, |
||||
_handleCheckboxTap); |
||||
result.add(editableTextBlock); |
||||
} else { |
||||
throw StateError('Unreachable.'); |
||||
} |
||||
} |
||||
return result; |
||||
} |
||||
|
||||
/// Updates the checkbox positioned at [offset] in document |
||||
/// by changing its attribute according to [value]. |
||||
void _handleCheckboxTap(int offset, bool value) { |
||||
// readonly - do nothing |
||||
} |
||||
|
||||
TextDirection get _textDirection { |
||||
final result = Directionality.of(context); |
||||
return result; |
||||
} |
||||
|
||||
EditableTextLine _getEditableTextLineFromNode( |
||||
Line node, BuildContext context) { |
||||
final textLine = TextLine( |
||||
line: node, |
||||
textDirection: _textDirection, |
||||
embedBuilder: embedBuilder, |
||||
styles: _styles, |
||||
); |
||||
final editableTextLine = EditableTextLine( |
||||
node, |
||||
null, |
||||
textLine, |
||||
0, |
||||
_getVerticalSpacingForLine(node, _styles), |
||||
_textDirection, |
||||
widget.controller.selection, |
||||
Colors.black, |
||||
//widget.selectionColor, |
||||
false, |
||||
//enableInteractiveSelection, |
||||
false, |
||||
//_hasFocus, |
||||
MediaQuery.of(context).devicePixelRatio, |
||||
_cursorCont); |
||||
return editableTextLine; |
||||
} |
||||
|
||||
Tuple2<double, double> _getVerticalSpacingForLine( |
||||
Line line, DefaultStyles? defaultStyles) { |
||||
final attrs = line.style.attributes; |
||||
if (attrs.containsKey(Attribute.header.key)) { |
||||
final int? level = attrs[Attribute.header.key]!.value; |
||||
switch (level) { |
||||
case 1: |
||||
return defaultStyles!.h1!.verticalSpacing; |
||||
case 2: |
||||
return defaultStyles!.h2!.verticalSpacing; |
||||
case 3: |
||||
return defaultStyles!.h3!.verticalSpacing; |
||||
default: |
||||
throw 'Invalid level $level'; |
||||
} |
||||
} |
||||
|
||||
return defaultStyles!.paragraph!.verticalSpacing; |
||||
} |
||||
|
||||
Tuple2<double, double> _getVerticalSpacingForBlock( |
||||
Block node, DefaultStyles? defaultStyles) { |
||||
final attrs = node.style.attributes; |
||||
if (attrs.containsKey(Attribute.blockQuote.key)) { |
||||
return defaultStyles!.quote!.verticalSpacing; |
||||
} else if (attrs.containsKey(Attribute.codeBlock.key)) { |
||||
return defaultStyles!.code!.verticalSpacing; |
||||
} else if (attrs.containsKey(Attribute.indent.key)) { |
||||
return defaultStyles!.indent!.verticalSpacing; |
||||
} |
||||
return defaultStyles!.lists!.verticalSpacing; |
||||
} |
||||
|
||||
void _nullSelectionChanged( |
||||
TextSelection selection, SelectionChangedCause cause) {} |
||||
} |
||||
|
||||
class _SimpleViewer extends MultiChildRenderObjectWidget { |
||||
_SimpleViewer({ |
||||
required List<Widget> children, |
||||
required this.document, |
||||
required this.textDirection, |
||||
required this.startHandleLayerLink, |
||||
required this.endHandleLayerLink, |
||||
required this.onSelectionChanged, |
||||
required this.scrollBottomInset, |
||||
this.padding = EdgeInsets.zero, |
||||
Key? key, |
||||
}) : super(key: key, children: children); |
||||
|
||||
final Document document; |
||||
final TextDirection textDirection; |
||||
final LayerLink startHandleLayerLink; |
||||
final LayerLink endHandleLayerLink; |
||||
final TextSelectionChangedHandler onSelectionChanged; |
||||
final double scrollBottomInset; |
||||
final EdgeInsetsGeometry padding; |
||||
|
||||
@override |
||||
RenderEditor createRenderObject(BuildContext context) { |
||||
return RenderEditor( |
||||
null, |
||||
textDirection, |
||||
scrollBottomInset, |
||||
padding, |
||||
document, |
||||
const TextSelection(baseOffset: 0, extentOffset: 0), |
||||
false, |
||||
// hasFocus, |
||||
onSelectionChanged, |
||||
startHandleLayerLink, |
||||
endHandleLayerLink, |
||||
const EdgeInsets.fromLTRB(4, 4, 4, 5), |
||||
); |
||||
} |
||||
|
||||
@override |
||||
void updateRenderObject( |
||||
BuildContext context, covariant RenderEditor renderObject) { |
||||
renderObject |
||||
..document = document |
||||
..setContainer(document.root) |
||||
..textDirection = textDirection |
||||
..setStartHandleLayerLink(startHandleLayerLink) |
||||
..setEndHandleLayerLink(endHandleLayerLink) |
||||
..onSelectionChanged = onSelectionChanged |
||||
..setScrollBottomInset(scrollBottomInset) |
||||
..setPadding(padding); |
||||
} |
||||
} |
||||
/// TODO: Remove this file in the next breaking release, because implementation |
||||
/// files should be located in the src folder, https://bit.ly/3fA23Yz. |
||||
export '../../src/widgets/simple_viewer.dart'; |
||||
|
@ -1,737 +1,3 @@ |
||||
import 'package:flutter/foundation.dart'; |
||||
import 'package:flutter/material.dart'; |
||||
import 'package:flutter/rendering.dart'; |
||||
import 'package:tuple/tuple.dart'; |
||||
|
||||
import '../models/documents/attribute.dart'; |
||||
import '../models/documents/nodes/block.dart'; |
||||
import '../models/documents/nodes/line.dart'; |
||||
import 'box.dart'; |
||||
import 'cursor.dart'; |
||||
import 'default_styles.dart'; |
||||
import 'delegate.dart'; |
||||
import 'editor.dart'; |
||||
import 'text_line.dart'; |
||||
import 'text_selection.dart'; |
||||
|
||||
const List<int> arabianRomanNumbers = [ |
||||
1000, |
||||
900, |
||||
500, |
||||
400, |
||||
100, |
||||
90, |
||||
50, |
||||
40, |
||||
10, |
||||
9, |
||||
5, |
||||
4, |
||||
1 |
||||
]; |
||||
|
||||
const List<String> romanNumbers = [ |
||||
'M', |
||||
'CM', |
||||
'D', |
||||
'CD', |
||||
'C', |
||||
'XC', |
||||
'L', |
||||
'XL', |
||||
'X', |
||||
'IX', |
||||
'V', |
||||
'IV', |
||||
'I' |
||||
]; |
||||
|
||||
class EditableTextBlock extends StatelessWidget { |
||||
const EditableTextBlock( |
||||
this.block, |
||||
this.textDirection, |
||||
this.scrollBottomInset, |
||||
this.verticalSpacing, |
||||
this.textSelection, |
||||
this.color, |
||||
this.styles, |
||||
this.enableInteractiveSelection, |
||||
this.hasFocus, |
||||
this.contentPadding, |
||||
this.embedBuilder, |
||||
this.cursorCont, |
||||
this.indentLevelCounts, |
||||
this.onCheckboxTap, |
||||
); |
||||
|
||||
final Block block; |
||||
final TextDirection textDirection; |
||||
final double scrollBottomInset; |
||||
final Tuple2 verticalSpacing; |
||||
final TextSelection textSelection; |
||||
final Color color; |
||||
final DefaultStyles? styles; |
||||
final bool enableInteractiveSelection; |
||||
final bool hasFocus; |
||||
final EdgeInsets? contentPadding; |
||||
final EmbedBuilder embedBuilder; |
||||
final CursorCont cursorCont; |
||||
final Map<int, int> indentLevelCounts; |
||||
final Function(int, bool) onCheckboxTap; |
||||
|
||||
@override |
||||
Widget build(BuildContext context) { |
||||
assert(debugCheckHasMediaQuery(context)); |
||||
|
||||
final defaultStyles = QuillStyles.getStyles(context, false); |
||||
return _EditableBlock( |
||||
block, |
||||
textDirection, |
||||
verticalSpacing as Tuple2<double, double>, |
||||
scrollBottomInset, |
||||
_getDecorationForBlock(block, defaultStyles) ?? const BoxDecoration(), |
||||
contentPadding, |
||||
_buildChildren(context, indentLevelCounts)); |
||||
} |
||||
|
||||
BoxDecoration? _getDecorationForBlock( |
||||
Block node, DefaultStyles? defaultStyles) { |
||||
final attrs = block.style.attributes; |
||||
if (attrs.containsKey(Attribute.blockQuote.key)) { |
||||
return defaultStyles!.quote!.decoration; |
||||
} |
||||
if (attrs.containsKey(Attribute.codeBlock.key)) { |
||||
return defaultStyles!.code!.decoration; |
||||
} |
||||
return null; |
||||
} |
||||
|
||||
List<Widget> _buildChildren( |
||||
BuildContext context, Map<int, int> indentLevelCounts) { |
||||
final defaultStyles = QuillStyles.getStyles(context, false); |
||||
final count = block.children.length; |
||||
final children = <Widget>[]; |
||||
var index = 0; |
||||
for (final line in Iterable.castFrom<dynamic, Line>(block.children)) { |
||||
index++; |
||||
final editableTextLine = EditableTextLine( |
||||
line, |
||||
_buildLeading(context, line, index, indentLevelCounts, count), |
||||
TextLine( |
||||
line: line, |
||||
textDirection: textDirection, |
||||
embedBuilder: embedBuilder, |
||||
styles: styles!, |
||||
), |
||||
_getIndentWidth(), |
||||
_getSpacingForLine(line, index, count, defaultStyles), |
||||
textDirection, |
||||
textSelection, |
||||
color, |
||||
enableInteractiveSelection, |
||||
hasFocus, |
||||
MediaQuery.of(context).devicePixelRatio, |
||||
cursorCont); |
||||
children.add(editableTextLine); |
||||
} |
||||
return children.toList(growable: false); |
||||
} |
||||
|
||||
Widget? _buildLeading(BuildContext context, Line line, int index, |
||||
Map<int, int> indentLevelCounts, int count) { |
||||
final defaultStyles = QuillStyles.getStyles(context, false); |
||||
final attrs = line.style.attributes; |
||||
if (attrs[Attribute.list.key] == Attribute.ol) { |
||||
return _NumberPoint( |
||||
index: index, |
||||
indentLevelCounts: indentLevelCounts, |
||||
count: count, |
||||
style: defaultStyles!.leading!.style, |
||||
attrs: attrs, |
||||
width: 32, |
||||
padding: 8, |
||||
); |
||||
} |
||||
|
||||
if (attrs[Attribute.list.key] == Attribute.ul) { |
||||
return _BulletPoint( |
||||
style: |
||||
defaultStyles!.leading!.style.copyWith(fontWeight: FontWeight.bold), |
||||
width: 32, |
||||
); |
||||
} |
||||
|
||||
if (attrs[Attribute.list.key] == Attribute.checked) { |
||||
return _Checkbox( |
||||
key: UniqueKey(), |
||||
style: defaultStyles!.leading!.style, |
||||
width: 32, |
||||
isChecked: true, |
||||
offset: block.offset + line.offset, |
||||
onTap: onCheckboxTap, |
||||
); |
||||
} |
||||
|
||||
if (attrs[Attribute.list.key] == Attribute.unchecked) { |
||||
return _Checkbox( |
||||
key: UniqueKey(), |
||||
style: defaultStyles!.leading!.style, |
||||
width: 32, |
||||
offset: block.offset + line.offset, |
||||
onTap: onCheckboxTap, |
||||
); |
||||
} |
||||
|
||||
if (attrs.containsKey(Attribute.codeBlock.key)) { |
||||
return _NumberPoint( |
||||
index: index, |
||||
indentLevelCounts: indentLevelCounts, |
||||
count: count, |
||||
style: defaultStyles!.code!.style |
||||
.copyWith(color: defaultStyles.code!.style.color!.withOpacity(0.4)), |
||||
width: 32, |
||||
attrs: attrs, |
||||
padding: 16, |
||||
withDot: false, |
||||
); |
||||
} |
||||
return null; |
||||
} |
||||
|
||||
double _getIndentWidth() { |
||||
final attrs = block.style.attributes; |
||||
|
||||
final indent = attrs[Attribute.indent.key]; |
||||
var extraIndent = 0.0; |
||||
if (indent != null && indent.value != null) { |
||||
extraIndent = 16.0 * indent.value; |
||||
} |
||||
|
||||
if (attrs.containsKey(Attribute.blockQuote.key)) { |
||||
return 16.0 + extraIndent; |
||||
} |
||||
|
||||
return 32.0 + extraIndent; |
||||
} |
||||
|
||||
Tuple2 _getSpacingForLine( |
||||
Line node, int index, int count, DefaultStyles? defaultStyles) { |
||||
var top = 0.0, bottom = 0.0; |
||||
|
||||
final attrs = block.style.attributes; |
||||
if (attrs.containsKey(Attribute.header.key)) { |
||||
final level = attrs[Attribute.header.key]!.value; |
||||
switch (level) { |
||||
case 1: |
||||
top = defaultStyles!.h1!.verticalSpacing.item1; |
||||
bottom = defaultStyles.h1!.verticalSpacing.item2; |
||||
break; |
||||
case 2: |
||||
top = defaultStyles!.h2!.verticalSpacing.item1; |
||||
bottom = defaultStyles.h2!.verticalSpacing.item2; |
||||
break; |
||||
case 3: |
||||
top = defaultStyles!.h3!.verticalSpacing.item1; |
||||
bottom = defaultStyles.h3!.verticalSpacing.item2; |
||||
break; |
||||
default: |
||||
throw 'Invalid level $level'; |
||||
} |
||||
} else { |
||||
late Tuple2 lineSpacing; |
||||
if (attrs.containsKey(Attribute.blockQuote.key)) { |
||||
lineSpacing = defaultStyles!.quote!.lineSpacing; |
||||
} else if (attrs.containsKey(Attribute.indent.key)) { |
||||
lineSpacing = defaultStyles!.indent!.lineSpacing; |
||||
} else if (attrs.containsKey(Attribute.list.key)) { |
||||
lineSpacing = defaultStyles!.lists!.lineSpacing; |
||||
} else if (attrs.containsKey(Attribute.codeBlock.key)) { |
||||
lineSpacing = defaultStyles!.code!.lineSpacing; |
||||
} else if (attrs.containsKey(Attribute.align.key)) { |
||||
lineSpacing = defaultStyles!.align!.lineSpacing; |
||||
} |
||||
top = lineSpacing.item1; |
||||
bottom = lineSpacing.item2; |
||||
} |
||||
|
||||
if (index == 1) { |
||||
top = 0.0; |
||||
} |
||||
|
||||
if (index == count) { |
||||
bottom = 0.0; |
||||
} |
||||
|
||||
return Tuple2(top, bottom); |
||||
} |
||||
} |
||||
|
||||
class RenderEditableTextBlock extends RenderEditableContainerBox |
||||
implements RenderEditableBox { |
||||
RenderEditableTextBlock({ |
||||
required Block block, |
||||
required TextDirection textDirection, |
||||
required EdgeInsetsGeometry padding, |
||||
required double scrollBottomInset, |
||||
required Decoration decoration, |
||||
List<RenderEditableBox>? children, |
||||
ImageConfiguration configuration = ImageConfiguration.empty, |
||||
EdgeInsets contentPadding = EdgeInsets.zero, |
||||
}) : _decoration = decoration, |
||||
_configuration = configuration, |
||||
_savedPadding = padding, |
||||
_contentPadding = contentPadding, |
||||
super( |
||||
children, |
||||
block, |
||||
textDirection, |
||||
scrollBottomInset, |
||||
padding.add(contentPadding), |
||||
); |
||||
|
||||
EdgeInsetsGeometry _savedPadding; |
||||
EdgeInsets _contentPadding; |
||||
|
||||
set contentPadding(EdgeInsets value) { |
||||
if (_contentPadding == value) return; |
||||
_contentPadding = value; |
||||
super.setPadding(_savedPadding.add(_contentPadding)); |
||||
} |
||||
|
||||
@override |
||||
void setPadding(EdgeInsetsGeometry value) { |
||||
super.setPadding(value.add(_contentPadding)); |
||||
_savedPadding = value; |
||||
} |
||||
|
||||
BoxPainter? _painter; |
||||
|
||||
Decoration get decoration => _decoration; |
||||
Decoration _decoration; |
||||
|
||||
set decoration(Decoration value) { |
||||
if (value == _decoration) return; |
||||
_painter?.dispose(); |
||||
_painter = null; |
||||
_decoration = value; |
||||
markNeedsPaint(); |
||||
} |
||||
|
||||
ImageConfiguration get configuration => _configuration; |
||||
ImageConfiguration _configuration; |
||||
|
||||
set configuration(ImageConfiguration value) { |
||||
if (value == _configuration) return; |
||||
_configuration = value; |
||||
markNeedsPaint(); |
||||
} |
||||
|
||||
@override |
||||
TextRange getLineBoundary(TextPosition position) { |
||||
final child = childAtPosition(position); |
||||
final rangeInChild = child.getLineBoundary(TextPosition( |
||||
offset: position.offset - child.getContainer().offset, |
||||
affinity: position.affinity, |
||||
)); |
||||
return TextRange( |
||||
start: rangeInChild.start + child.getContainer().offset, |
||||
end: rangeInChild.end + child.getContainer().offset, |
||||
); |
||||
} |
||||
|
||||
@override |
||||
Offset getOffsetForCaret(TextPosition position) { |
||||
final child = childAtPosition(position); |
||||
return child.getOffsetForCaret(TextPosition( |
||||
offset: position.offset - child.getContainer().offset, |
||||
affinity: position.affinity, |
||||
)) + |
||||
(child.parentData as BoxParentData).offset; |
||||
} |
||||
|
||||
@override |
||||
TextPosition getPositionForOffset(Offset offset) { |
||||
final child = childAtOffset(offset)!; |
||||
final parentData = child.parentData as BoxParentData; |
||||
final localPosition = |
||||
child.getPositionForOffset(offset - parentData.offset); |
||||
return TextPosition( |
||||
offset: localPosition.offset + child.getContainer().offset, |
||||
affinity: localPosition.affinity, |
||||
); |
||||
} |
||||
|
||||
@override |
||||
TextRange getWordBoundary(TextPosition position) { |
||||
final child = childAtPosition(position); |
||||
final nodeOffset = child.getContainer().offset; |
||||
final childWord = child |
||||
.getWordBoundary(TextPosition(offset: position.offset - nodeOffset)); |
||||
return TextRange( |
||||
start: childWord.start + nodeOffset, |
||||
end: childWord.end + nodeOffset, |
||||
); |
||||
} |
||||
|
||||
@override |
||||
TextPosition? getPositionAbove(TextPosition position) { |
||||
assert(position.offset < getContainer().length); |
||||
|
||||
final child = childAtPosition(position); |
||||
final childLocalPosition = |
||||
TextPosition(offset: position.offset - child.getContainer().offset); |
||||
final result = child.getPositionAbove(childLocalPosition); |
||||
if (result != null) { |
||||
return TextPosition(offset: result.offset + child.getContainer().offset); |
||||
} |
||||
|
||||
final sibling = childBefore(child); |
||||
if (sibling == null) { |
||||
return null; |
||||
} |
||||
|
||||
final caretOffset = child.getOffsetForCaret(childLocalPosition); |
||||
final testPosition = |
||||
TextPosition(offset: sibling.getContainer().length - 1); |
||||
final testOffset = sibling.getOffsetForCaret(testPosition); |
||||
final finalOffset = Offset(caretOffset.dx, testOffset.dy); |
||||
return TextPosition( |
||||
offset: sibling.getContainer().offset + |
||||
sibling.getPositionForOffset(finalOffset).offset); |
||||
} |
||||
|
||||
@override |
||||
TextPosition? getPositionBelow(TextPosition position) { |
||||
assert(position.offset < getContainer().length); |
||||
|
||||
final child = childAtPosition(position); |
||||
final childLocalPosition = |
||||
TextPosition(offset: position.offset - child.getContainer().offset); |
||||
final result = child.getPositionBelow(childLocalPosition); |
||||
if (result != null) { |
||||
return TextPosition(offset: result.offset + child.getContainer().offset); |
||||
} |
||||
|
||||
final sibling = childAfter(child); |
||||
if (sibling == null) { |
||||
return null; |
||||
} |
||||
|
||||
final caretOffset = child.getOffsetForCaret(childLocalPosition); |
||||
final testOffset = sibling.getOffsetForCaret(const TextPosition(offset: 0)); |
||||
final finalOffset = Offset(caretOffset.dx, testOffset.dy); |
||||
return TextPosition( |
||||
offset: sibling.getContainer().offset + |
||||
sibling.getPositionForOffset(finalOffset).offset); |
||||
} |
||||
|
||||
@override |
||||
double preferredLineHeight(TextPosition position) { |
||||
final child = childAtPosition(position); |
||||
return child.preferredLineHeight( |
||||
TextPosition(offset: position.offset - child.getContainer().offset)); |
||||
} |
||||
|
||||
@override |
||||
TextSelectionPoint getBaseEndpointForSelection(TextSelection selection) { |
||||
if (selection.isCollapsed) { |
||||
return TextSelectionPoint( |
||||
Offset(0, preferredLineHeight(selection.extent)) + |
||||
getOffsetForCaret(selection.extent), |
||||
null); |
||||
} |
||||
|
||||
final baseNode = getContainer().queryChild(selection.start, false).node; |
||||
var baseChild = firstChild; |
||||
while (baseChild != null) { |
||||
if (baseChild.getContainer() == baseNode) { |
||||
break; |
||||
} |
||||
baseChild = childAfter(baseChild); |
||||
} |
||||
assert(baseChild != null); |
||||
|
||||
final basePoint = baseChild!.getBaseEndpointForSelection( |
||||
localSelection(baseChild.getContainer(), selection, true)); |
||||
return TextSelectionPoint( |
||||
basePoint.point + (baseChild.parentData as BoxParentData).offset, |
||||
basePoint.direction); |
||||
} |
||||
|
||||
@override |
||||
TextSelectionPoint getExtentEndpointForSelection(TextSelection selection) { |
||||
if (selection.isCollapsed) { |
||||
return TextSelectionPoint( |
||||
Offset(0, preferredLineHeight(selection.extent)) + |
||||
getOffsetForCaret(selection.extent), |
||||
null); |
||||
} |
||||
|
||||
final extentNode = getContainer().queryChild(selection.end, false).node; |
||||
|
||||
var extentChild = firstChild; |
||||
while (extentChild != null) { |
||||
if (extentChild.getContainer() == extentNode) { |
||||
break; |
||||
} |
||||
extentChild = childAfter(extentChild); |
||||
} |
||||
assert(extentChild != null); |
||||
|
||||
final extentPoint = extentChild!.getExtentEndpointForSelection( |
||||
localSelection(extentChild.getContainer(), selection, true)); |
||||
return TextSelectionPoint( |
||||
extentPoint.point + (extentChild.parentData as BoxParentData).offset, |
||||
extentPoint.direction); |
||||
} |
||||
|
||||
@override |
||||
void detach() { |
||||
_painter?.dispose(); |
||||
_painter = null; |
||||
super.detach(); |
||||
markNeedsPaint(); |
||||
} |
||||
|
||||
@override |
||||
void paint(PaintingContext context, Offset offset) { |
||||
_paintDecoration(context, offset); |
||||
defaultPaint(context, offset); |
||||
} |
||||
|
||||
void _paintDecoration(PaintingContext context, Offset offset) { |
||||
_painter ??= _decoration.createBoxPainter(markNeedsPaint); |
||||
|
||||
final decorationPadding = resolvedPadding! - _contentPadding; |
||||
|
||||
final filledConfiguration = |
||||
configuration.copyWith(size: decorationPadding.deflateSize(size)); |
||||
final debugSaveCount = context.canvas.getSaveCount(); |
||||
|
||||
final decorationOffset = |
||||
offset.translate(decorationPadding.left, decorationPadding.top); |
||||
_painter!.paint(context.canvas, decorationOffset, filledConfiguration); |
||||
if (debugSaveCount != context.canvas.getSaveCount()) { |
||||
throw '${_decoration.runtimeType} painter had mismatching save and restore calls.'; |
||||
} |
||||
if (decoration.isComplex) { |
||||
context.setIsComplexHint(); |
||||
} |
||||
} |
||||
|
||||
@override |
||||
bool hitTestChildren(BoxHitTestResult result, {required Offset position}) { |
||||
return defaultHitTestChildren(result, position: position); |
||||
} |
||||
} |
||||
|
||||
class _EditableBlock extends MultiChildRenderObjectWidget { |
||||
_EditableBlock( |
||||
this.block, |
||||
this.textDirection, |
||||
this.padding, |
||||
this.scrollBottomInset, |
||||
this.decoration, |
||||
this.contentPadding, |
||||
List<Widget> children) |
||||
: super(children: children); |
||||
|
||||
final Block block; |
||||
final TextDirection textDirection; |
||||
final Tuple2<double, double> padding; |
||||
final double scrollBottomInset; |
||||
final Decoration decoration; |
||||
final EdgeInsets? contentPadding; |
||||
|
||||
EdgeInsets get _padding => |
||||
EdgeInsets.only(top: padding.item1, bottom: padding.item2); |
||||
|
||||
EdgeInsets get _contentPadding => contentPadding ?? EdgeInsets.zero; |
||||
|
||||
@override |
||||
RenderEditableTextBlock createRenderObject(BuildContext context) { |
||||
return RenderEditableTextBlock( |
||||
block: block, |
||||
textDirection: textDirection, |
||||
padding: _padding, |
||||
scrollBottomInset: scrollBottomInset, |
||||
decoration: decoration, |
||||
contentPadding: _contentPadding, |
||||
); |
||||
} |
||||
|
||||
@override |
||||
void updateRenderObject( |
||||
BuildContext context, covariant RenderEditableTextBlock renderObject) { |
||||
renderObject |
||||
..setContainer(block) |
||||
..textDirection = textDirection |
||||
..scrollBottomInset = scrollBottomInset |
||||
..setPadding(_padding) |
||||
..decoration = decoration |
||||
..contentPadding = _contentPadding; |
||||
} |
||||
} |
||||
|
||||
class _NumberPoint extends StatelessWidget { |
||||
const _NumberPoint({ |
||||
required this.index, |
||||
required this.indentLevelCounts, |
||||
required this.count, |
||||
required this.style, |
||||
required this.width, |
||||
required this.attrs, |
||||
this.withDot = true, |
||||
this.padding = 0.0, |
||||
Key? key, |
||||
}) : super(key: key); |
||||
|
||||
final int index; |
||||
final Map<int?, int> indentLevelCounts; |
||||
final int count; |
||||
final TextStyle style; |
||||
final double width; |
||||
final Map<String, Attribute> attrs; |
||||
final bool withDot; |
||||
final double padding; |
||||
|
||||
@override |
||||
Widget build(BuildContext context) { |
||||
var s = index.toString(); |
||||
int? level = 0; |
||||
if (!attrs.containsKey(Attribute.indent.key) && |
||||
!indentLevelCounts.containsKey(1)) { |
||||
indentLevelCounts.clear(); |
||||
return Container( |
||||
alignment: AlignmentDirectional.topEnd, |
||||
width: width, |
||||
padding: EdgeInsetsDirectional.only(end: padding), |
||||
child: Text(withDot ? '$s.' : s, style: style), |
||||
); |
||||
} |
||||
if (attrs.containsKey(Attribute.indent.key)) { |
||||
level = attrs[Attribute.indent.key]!.value; |
||||
} else { |
||||
// first level but is back from previous indent level |
||||
// supposed to be "2." |
||||
indentLevelCounts[0] = 1; |
||||
} |
||||
if (indentLevelCounts.containsKey(level! + 1)) { |
||||
// last visited level is done, going up |
||||
indentLevelCounts.remove(level + 1); |
||||
} |
||||
final count = (indentLevelCounts[level] ?? 0) + 1; |
||||
indentLevelCounts[level] = count; |
||||
|
||||
s = count.toString(); |
||||
if (level % 3 == 1) { |
||||
// a. b. c. d. e. ... |
||||
s = _toExcelSheetColumnTitle(count); |
||||
} else if (level % 3 == 2) { |
||||
// i. ii. iii. ... |
||||
s = _intToRoman(count); |
||||
} |
||||
// level % 3 == 0 goes back to 1. 2. 3. |
||||
|
||||
return Container( |
||||
alignment: AlignmentDirectional.topEnd, |
||||
width: width, |
||||
padding: EdgeInsetsDirectional.only(end: padding), |
||||
child: Text(withDot ? '$s.' : s, style: style), |
||||
); |
||||
} |
||||
|
||||
String _toExcelSheetColumnTitle(int n) { |
||||
final result = StringBuffer(); |
||||
while (n > 0) { |
||||
n--; |
||||
result.write(String.fromCharCode((n % 26).floor() + 97)); |
||||
n = (n / 26).floor(); |
||||
} |
||||
|
||||
return result.toString().split('').reversed.join(); |
||||
} |
||||
|
||||
String _intToRoman(int input) { |
||||
var num = input; |
||||
|
||||
if (num < 0) { |
||||
return ''; |
||||
} else if (num == 0) { |
||||
return 'nulla'; |
||||
} |
||||
|
||||
final builder = StringBuffer(); |
||||
for (var a = 0; a < arabianRomanNumbers.length; a++) { |
||||
final times = (num / arabianRomanNumbers[a]) |
||||
.truncate(); // equals 1 only when arabianRomanNumbers[a] = num |
||||
// executes n times where n is the number of times you have to add |
||||
// the current roman number value to reach current num. |
||||
builder.write(romanNumbers[a] * times); |
||||
num -= times * |
||||
arabianRomanNumbers[ |
||||
a]; // subtract previous roman number value from num |
||||
} |
||||
|
||||
return builder.toString().toLowerCase(); |
||||
} |
||||
} |
||||
|
||||
class _BulletPoint extends StatelessWidget { |
||||
const _BulletPoint({ |
||||
required this.style, |
||||
required this.width, |
||||
Key? key, |
||||
}) : super(key: key); |
||||
|
||||
final TextStyle style; |
||||
final double width; |
||||
|
||||
@override |
||||
Widget build(BuildContext context) { |
||||
return Container( |
||||
alignment: AlignmentDirectional.topEnd, |
||||
width: width, |
||||
padding: const EdgeInsetsDirectional.only(end: 13), |
||||
child: Text('•', style: style), |
||||
); |
||||
} |
||||
} |
||||
|
||||
class _Checkbox extends StatelessWidget { |
||||
const _Checkbox({ |
||||
Key? key, |
||||
this.style, |
||||
this.width, |
||||
this.isChecked = false, |
||||
this.offset, |
||||
this.onTap, |
||||
}) : super(key: key); |
||||
final TextStyle? style; |
||||
final double? width; |
||||
final bool isChecked; |
||||
final int? offset; |
||||
final Function(int, bool)? onTap; |
||||
|
||||
void _onCheckboxClicked(bool? newValue) { |
||||
if (onTap != null && newValue != null && offset != null) { |
||||
onTap!(offset!, newValue); |
||||
} |
||||
} |
||||
|
||||
@override |
||||
Widget build(BuildContext context) { |
||||
return Container( |
||||
alignment: AlignmentDirectional.topEnd, |
||||
width: width, |
||||
padding: const EdgeInsetsDirectional.only(end: 13), |
||||
child: GestureDetector( |
||||
onLongPress: () => _onCheckboxClicked(!isChecked), |
||||
child: Checkbox( |
||||
value: isChecked, |
||||
onChanged: _onCheckboxClicked, |
||||
), |
||||
), |
||||
); |
||||
} |
||||
} |
||||
/// TODO: Remove this file in the next breaking release, because implementation |
||||
/// files should be located in the src folder, https://bit.ly/3fA23Yz. |
||||
export '../../src/widgets/text_block.dart'; |
||||
|
@ -1,892 +1,3 @@ |
||||
import 'dart:math' as math; |
||||
|
||||
import 'package:flutter/foundation.dart'; |
||||
import 'package:flutter/material.dart'; |
||||
import 'package:flutter/rendering.dart'; |
||||
import 'package:tuple/tuple.dart'; |
||||
|
||||
import '../models/documents/attribute.dart'; |
||||
import '../models/documents/nodes/container.dart' as container; |
||||
import '../models/documents/nodes/leaf.dart' as leaf; |
||||
import '../models/documents/nodes/leaf.dart'; |
||||
import '../models/documents/nodes/line.dart'; |
||||
import '../models/documents/nodes/node.dart'; |
||||
import '../utils/color.dart'; |
||||
import 'box.dart'; |
||||
import 'cursor.dart'; |
||||
import 'default_styles.dart'; |
||||
import 'delegate.dart'; |
||||
import 'proxy.dart'; |
||||
import 'text_selection.dart'; |
||||
|
||||
class TextLine extends StatelessWidget { |
||||
const TextLine({ |
||||
required this.line, |
||||
required this.embedBuilder, |
||||
required this.styles, |
||||
this.textDirection, |
||||
Key? key, |
||||
}) : super(key: key); |
||||
|
||||
final Line line; |
||||
final TextDirection? textDirection; |
||||
final EmbedBuilder embedBuilder; |
||||
final DefaultStyles styles; |
||||
|
||||
@override |
||||
Widget build(BuildContext context) { |
||||
assert(debugCheckHasMediaQuery(context)); |
||||
|
||||
if (line.hasEmbed) { |
||||
final embed = line.children.single as Embed; |
||||
return EmbedProxy(embedBuilder(context, embed)); |
||||
} |
||||
|
||||
final textSpan = _buildTextSpan(context); |
||||
final strutStyle = StrutStyle.fromTextStyle(textSpan.style!); |
||||
final textAlign = _getTextAlign(); |
||||
final child = RichText( |
||||
text: textSpan, |
||||
textAlign: textAlign, |
||||
textDirection: textDirection, |
||||
strutStyle: strutStyle, |
||||
textScaleFactor: MediaQuery.textScaleFactorOf(context), |
||||
); |
||||
return RichTextProxy( |
||||
child, |
||||
textSpan.style!, |
||||
textAlign, |
||||
textDirection!, |
||||
1, |
||||
Localizations.localeOf(context), |
||||
strutStyle, |
||||
TextWidthBasis.parent, |
||||
null); |
||||
} |
||||
|
||||
TextAlign _getTextAlign() { |
||||
final alignment = line.style.attributes[Attribute.align.key]; |
||||
if (alignment == Attribute.leftAlignment) { |
||||
return TextAlign.left; |
||||
} else if (alignment == Attribute.centerAlignment) { |
||||
return TextAlign.center; |
||||
} else if (alignment == Attribute.rightAlignment) { |
||||
return TextAlign.right; |
||||
} else if (alignment == Attribute.justifyAlignment) { |
||||
return TextAlign.justify; |
||||
} |
||||
return TextAlign.start; |
||||
} |
||||
|
||||
TextSpan _buildTextSpan(BuildContext context) { |
||||
final defaultStyles = styles; |
||||
final children = line.children |
||||
.map((node) => _getTextSpanFromNode(defaultStyles, node)) |
||||
.toList(growable: false); |
||||
|
||||
var textStyle = const TextStyle(); |
||||
|
||||
if (line.style.containsKey(Attribute.placeholder.key)) { |
||||
textStyle = defaultStyles.placeHolder!.style; |
||||
return TextSpan(children: children, style: textStyle); |
||||
} |
||||
|
||||
final header = line.style.attributes[Attribute.header.key]; |
||||
final m = <Attribute, TextStyle>{ |
||||
Attribute.h1: defaultStyles.h1!.style, |
||||
Attribute.h2: defaultStyles.h2!.style, |
||||
Attribute.h3: defaultStyles.h3!.style, |
||||
}; |
||||
|
||||
textStyle = textStyle.merge(m[header] ?? defaultStyles.paragraph!.style); |
||||
|
||||
final block = line.style.getBlockExceptHeader(); |
||||
TextStyle? toMerge; |
||||
if (block == Attribute.blockQuote) { |
||||
toMerge = defaultStyles.quote!.style; |
||||
} else if (block == Attribute.codeBlock) { |
||||
toMerge = defaultStyles.code!.style; |
||||
} else if (block != null) { |
||||
toMerge = defaultStyles.lists!.style; |
||||
} |
||||
|
||||
textStyle = textStyle.merge(toMerge); |
||||
|
||||
return TextSpan(children: children, style: textStyle); |
||||
} |
||||
|
||||
TextSpan _getTextSpanFromNode(DefaultStyles defaultStyles, Node node) { |
||||
final textNode = node as leaf.Text; |
||||
final style = textNode.style; |
||||
var res = const TextStyle(); |
||||
final color = textNode.style.attributes[Attribute.color.key]; |
||||
|
||||
<String, TextStyle?>{ |
||||
Attribute.bold.key: defaultStyles.bold, |
||||
Attribute.italic.key: defaultStyles.italic, |
||||
Attribute.link.key: defaultStyles.link, |
||||
Attribute.underline.key: defaultStyles.underline, |
||||
Attribute.strikeThrough.key: defaultStyles.strikeThrough, |
||||
}.forEach((k, s) { |
||||
if (style.values.any((v) => v.key == k)) { |
||||
if (k == Attribute.underline.key || k == Attribute.strikeThrough.key) { |
||||
var textColor = defaultStyles.color; |
||||
if (color?.value is String) { |
||||
textColor = stringToColor(color?.value); |
||||
} |
||||
res = _merge(res.copyWith(decorationColor: textColor), |
||||
s!.copyWith(decorationColor: textColor)); |
||||
} else { |
||||
res = _merge(res, s!); |
||||
} |
||||
} |
||||
}); |
||||
|
||||
final font = textNode.style.attributes[Attribute.font.key]; |
||||
if (font != null && font.value != null) { |
||||
res = res.merge(TextStyle(fontFamily: font.value)); |
||||
} |
||||
|
||||
final size = textNode.style.attributes[Attribute.size.key]; |
||||
if (size != null && size.value != null) { |
||||
switch (size.value) { |
||||
case 'small': |
||||
res = res.merge(defaultStyles.sizeSmall); |
||||
break; |
||||
case 'large': |
||||
res = res.merge(defaultStyles.sizeLarge); |
||||
break; |
||||
case 'huge': |
||||
res = res.merge(defaultStyles.sizeHuge); |
||||
break; |
||||
default: |
||||
final fontSize = double.tryParse(size.value); |
||||
if (fontSize != null) { |
||||
res = res.merge(TextStyle(fontSize: fontSize)); |
||||
} else { |
||||
throw 'Invalid size ${size.value}'; |
||||
} |
||||
} |
||||
} |
||||
|
||||
if (color != null && color.value != null) { |
||||
var textColor = defaultStyles.color; |
||||
if (color.value is String) { |
||||
textColor = stringToColor(color.value); |
||||
} |
||||
if (textColor != null) { |
||||
res = res.merge(TextStyle(color: textColor)); |
||||
} |
||||
} |
||||
|
||||
final background = textNode.style.attributes[Attribute.background.key]; |
||||
if (background != null && background.value != null) { |
||||
final backgroundColor = stringToColor(background.value); |
||||
res = res.merge(TextStyle(backgroundColor: backgroundColor)); |
||||
} |
||||
|
||||
return TextSpan(text: textNode.value, style: res); |
||||
} |
||||
|
||||
TextStyle _merge(TextStyle a, TextStyle b) { |
||||
final decorations = <TextDecoration?>[]; |
||||
if (a.decoration != null) { |
||||
decorations.add(a.decoration); |
||||
} |
||||
if (b.decoration != null) { |
||||
decorations.add(b.decoration); |
||||
} |
||||
return a.merge(b).apply( |
||||
decoration: TextDecoration.combine( |
||||
List.castFrom<dynamic, TextDecoration>(decorations))); |
||||
} |
||||
} |
||||
|
||||
class EditableTextLine extends RenderObjectWidget { |
||||
const EditableTextLine( |
||||
this.line, |
||||
this.leading, |
||||
this.body, |
||||
this.indentWidth, |
||||
this.verticalSpacing, |
||||
this.textDirection, |
||||
this.textSelection, |
||||
this.color, |
||||
this.enableInteractiveSelection, |
||||
this.hasFocus, |
||||
this.devicePixelRatio, |
||||
this.cursorCont, |
||||
); |
||||
|
||||
final Line line; |
||||
final Widget? leading; |
||||
final Widget body; |
||||
final double indentWidth; |
||||
final Tuple2 verticalSpacing; |
||||
final TextDirection textDirection; |
||||
final TextSelection textSelection; |
||||
final Color color; |
||||
final bool enableInteractiveSelection; |
||||
final bool hasFocus; |
||||
final double devicePixelRatio; |
||||
final CursorCont cursorCont; |
||||
|
||||
@override |
||||
RenderObjectElement createElement() { |
||||
return _TextLineElement(this); |
||||
} |
||||
|
||||
@override |
||||
RenderObject createRenderObject(BuildContext context) { |
||||
return RenderEditableTextLine( |
||||
line, |
||||
textDirection, |
||||
textSelection, |
||||
enableInteractiveSelection, |
||||
hasFocus, |
||||
devicePixelRatio, |
||||
_getPadding(), |
||||
color, |
||||
cursorCont); |
||||
} |
||||
|
||||
@override |
||||
void updateRenderObject( |
||||
BuildContext context, covariant RenderEditableTextLine renderObject) { |
||||
renderObject |
||||
..setLine(line) |
||||
..setPadding(_getPadding()) |
||||
..setTextDirection(textDirection) |
||||
..setTextSelection(textSelection) |
||||
..setColor(color) |
||||
..setEnableInteractiveSelection(enableInteractiveSelection) |
||||
..hasFocus = hasFocus |
||||
..setDevicePixelRatio(devicePixelRatio) |
||||
..setCursorCont(cursorCont); |
||||
} |
||||
|
||||
EdgeInsetsGeometry _getPadding() { |
||||
return EdgeInsetsDirectional.only( |
||||
start: indentWidth, |
||||
top: verticalSpacing.item1, |
||||
bottom: verticalSpacing.item2); |
||||
} |
||||
} |
||||
|
||||
enum TextLineSlot { LEADING, BODY } |
||||
|
||||
class RenderEditableTextLine extends RenderEditableBox { |
||||
RenderEditableTextLine( |
||||
this.line, |
||||
this.textDirection, |
||||
this.textSelection, |
||||
this.enableInteractiveSelection, |
||||
this.hasFocus, |
||||
this.devicePixelRatio, |
||||
this.padding, |
||||
this.color, |
||||
this.cursorCont, |
||||
); |
||||
|
||||
RenderBox? _leading; |
||||
RenderContentProxyBox? _body; |
||||
Line line; |
||||
TextDirection textDirection; |
||||
TextSelection textSelection; |
||||
Color color; |
||||
bool enableInteractiveSelection; |
||||
bool hasFocus = false; |
||||
double devicePixelRatio; |
||||
EdgeInsetsGeometry padding; |
||||
CursorCont cursorCont; |
||||
EdgeInsets? _resolvedPadding; |
||||
bool? _containsCursor; |
||||
List<TextBox>? _selectedRects; |
||||
Rect? _caretPrototype; |
||||
final Map<TextLineSlot, RenderBox> children = <TextLineSlot, RenderBox>{}; |
||||
|
||||
Iterable<RenderBox> get _children sync* { |
||||
if (_leading != null) { |
||||
yield _leading!; |
||||
} |
||||
if (_body != null) { |
||||
yield _body!; |
||||
} |
||||
} |
||||
|
||||
void setCursorCont(CursorCont c) { |
||||
if (cursorCont == c) { |
||||
return; |
||||
} |
||||
cursorCont = c; |
||||
markNeedsLayout(); |
||||
} |
||||
|
||||
void setDevicePixelRatio(double d) { |
||||
if (devicePixelRatio == d) { |
||||
return; |
||||
} |
||||
devicePixelRatio = d; |
||||
markNeedsLayout(); |
||||
} |
||||
|
||||
void setEnableInteractiveSelection(bool val) { |
||||
if (enableInteractiveSelection == val) { |
||||
return; |
||||
} |
||||
|
||||
markNeedsLayout(); |
||||
markNeedsSemanticsUpdate(); |
||||
} |
||||
|
||||
void setColor(Color c) { |
||||
if (color == c) { |
||||
return; |
||||
} |
||||
|
||||
color = c; |
||||
if (containsTextSelection()) { |
||||
markNeedsPaint(); |
||||
} |
||||
} |
||||
|
||||
void setTextSelection(TextSelection t) { |
||||
if (textSelection == t) { |
||||
return; |
||||
} |
||||
|
||||
final containsSelection = containsTextSelection(); |
||||
if (attached && containsCursor()) { |
||||
cursorCont.removeListener(markNeedsLayout); |
||||
cursorCont.color.removeListener(markNeedsPaint); |
||||
} |
||||
|
||||
textSelection = t; |
||||
_selectedRects = null; |
||||
_containsCursor = null; |
||||
if (attached && containsCursor()) { |
||||
cursorCont.addListener(markNeedsLayout); |
||||
cursorCont.color.addListener(markNeedsPaint); |
||||
} |
||||
|
||||
if (containsSelection || containsTextSelection()) { |
||||
markNeedsPaint(); |
||||
} |
||||
} |
||||
|
||||
void setTextDirection(TextDirection t) { |
||||
if (textDirection == t) { |
||||
return; |
||||
} |
||||
textDirection = t; |
||||
_resolvedPadding = null; |
||||
markNeedsLayout(); |
||||
} |
||||
|
||||
void setLine(Line l) { |
||||
if (line == l) { |
||||
return; |
||||
} |
||||
line = l; |
||||
_containsCursor = null; |
||||
markNeedsLayout(); |
||||
} |
||||
|
||||
void setPadding(EdgeInsetsGeometry p) { |
||||
assert(p.isNonNegative); |
||||
if (padding == p) { |
||||
return; |
||||
} |
||||
padding = p; |
||||
_resolvedPadding = null; |
||||
markNeedsLayout(); |
||||
} |
||||
|
||||
void setLeading(RenderBox? l) { |
||||
_leading = _updateChild(_leading, l, TextLineSlot.LEADING); |
||||
} |
||||
|
||||
void setBody(RenderContentProxyBox? b) { |
||||
_body = _updateChild(_body, b, TextLineSlot.BODY) as RenderContentProxyBox?; |
||||
} |
||||
|
||||
bool containsTextSelection() { |
||||
return line.documentOffset <= textSelection.end && |
||||
textSelection.start <= line.documentOffset + line.length - 1; |
||||
} |
||||
|
||||
bool containsCursor() { |
||||
return _containsCursor ??= textSelection.isCollapsed && |
||||
line.containsOffset(textSelection.baseOffset); |
||||
} |
||||
|
||||
RenderBox? _updateChild( |
||||
RenderBox? old, RenderBox? newChild, TextLineSlot slot) { |
||||
if (old != null) { |
||||
dropChild(old); |
||||
children.remove(slot); |
||||
} |
||||
if (newChild != null) { |
||||
children[slot] = newChild; |
||||
adoptChild(newChild); |
||||
} |
||||
return newChild; |
||||
} |
||||
|
||||
List<TextBox> _getBoxes(TextSelection textSelection) { |
||||
final parentData = _body!.parentData as BoxParentData?; |
||||
return _body!.getBoxesForSelection(textSelection).map((box) { |
||||
return TextBox.fromLTRBD( |
||||
box.left + parentData!.offset.dx, |
||||
box.top + parentData.offset.dy, |
||||
box.right + parentData.offset.dx, |
||||
box.bottom + parentData.offset.dy, |
||||
box.direction, |
||||
); |
||||
}).toList(growable: false); |
||||
} |
||||
|
||||
void _resolvePadding() { |
||||
if (_resolvedPadding != null) { |
||||
return; |
||||
} |
||||
_resolvedPadding = padding.resolve(textDirection); |
||||
assert(_resolvedPadding!.isNonNegative); |
||||
} |
||||
|
||||
@override |
||||
TextSelectionPoint getBaseEndpointForSelection(TextSelection textSelection) { |
||||
return _getEndpointForSelection(textSelection, true); |
||||
} |
||||
|
||||
@override |
||||
TextSelectionPoint getExtentEndpointForSelection( |
||||
TextSelection textSelection) { |
||||
return _getEndpointForSelection(textSelection, false); |
||||
} |
||||
|
||||
TextSelectionPoint _getEndpointForSelection( |
||||
TextSelection textSelection, bool first) { |
||||
if (textSelection.isCollapsed) { |
||||
return TextSelectionPoint( |
||||
Offset(0, preferredLineHeight(textSelection.extent)) + |
||||
getOffsetForCaret(textSelection.extent), |
||||
null); |
||||
} |
||||
final boxes = _getBoxes(textSelection); |
||||
assert(boxes.isNotEmpty); |
||||
final targetBox = first ? boxes.first : boxes.last; |
||||
return TextSelectionPoint( |
||||
Offset(first ? targetBox.start : targetBox.end, targetBox.bottom), |
||||
targetBox.direction); |
||||
} |
||||
|
||||
@override |
||||
TextRange getLineBoundary(TextPosition position) { |
||||
final lineDy = getOffsetForCaret(position) |
||||
.translate(0, 0.5 * preferredLineHeight(position)) |
||||
.dy; |
||||
final lineBoxes = |
||||
_getBoxes(TextSelection(baseOffset: 0, extentOffset: line.length - 1)) |
||||
.where((element) => element.top < lineDy && element.bottom > lineDy) |
||||
.toList(growable: false); |
||||
return TextRange( |
||||
start: |
||||
getPositionForOffset(Offset(lineBoxes.first.left, lineDy)).offset, |
||||
end: getPositionForOffset(Offset(lineBoxes.last.right, lineDy)).offset); |
||||
} |
||||
|
||||
@override |
||||
Offset getOffsetForCaret(TextPosition position) { |
||||
return _body!.getOffsetForCaret(position, _caretPrototype) + |
||||
(_body!.parentData as BoxParentData).offset; |
||||
} |
||||
|
||||
@override |
||||
TextPosition? getPositionAbove(TextPosition position) { |
||||
return _getPosition(position, -0.5); |
||||
} |
||||
|
||||
@override |
||||
TextPosition? getPositionBelow(TextPosition position) { |
||||
return _getPosition(position, 1.5); |
||||
} |
||||
|
||||
TextPosition? _getPosition(TextPosition textPosition, double dyScale) { |
||||
assert(textPosition.offset < line.length); |
||||
final offset = getOffsetForCaret(textPosition) |
||||
.translate(0, dyScale * preferredLineHeight(textPosition)); |
||||
if (_body!.size |
||||
.contains(offset - (_body!.parentData as BoxParentData).offset)) { |
||||
return getPositionForOffset(offset); |
||||
} |
||||
return null; |
||||
} |
||||
|
||||
@override |
||||
TextPosition getPositionForOffset(Offset offset) { |
||||
return _body!.getPositionForOffset( |
||||
offset - (_body!.parentData as BoxParentData).offset); |
||||
} |
||||
|
||||
@override |
||||
TextRange getWordBoundary(TextPosition position) { |
||||
return _body!.getWordBoundary(position); |
||||
} |
||||
|
||||
@override |
||||
double preferredLineHeight(TextPosition position) { |
||||
return _body!.getPreferredLineHeight(); |
||||
} |
||||
|
||||
@override |
||||
container.Container getContainer() { |
||||
return line; |
||||
} |
||||
|
||||
double get cursorWidth => cursorCont.style.width; |
||||
|
||||
double get cursorHeight => |
||||
cursorCont.style.height ?? |
||||
preferredLineHeight(const TextPosition(offset: 0)); |
||||
|
||||
void _computeCaretPrototype() { |
||||
switch (defaultTargetPlatform) { |
||||
case TargetPlatform.iOS: |
||||
case TargetPlatform.macOS: |
||||
_caretPrototype = Rect.fromLTWH(0, 0, cursorWidth, cursorHeight + 2); |
||||
break; |
||||
case TargetPlatform.android: |
||||
case TargetPlatform.fuchsia: |
||||
case TargetPlatform.linux: |
||||
case TargetPlatform.windows: |
||||
_caretPrototype = Rect.fromLTWH(0, 2, cursorWidth, cursorHeight - 4.0); |
||||
break; |
||||
default: |
||||
throw 'Invalid platform'; |
||||
} |
||||
} |
||||
|
||||
@override |
||||
void attach(covariant PipelineOwner owner) { |
||||
super.attach(owner); |
||||
for (final child in _children) { |
||||
child.attach(owner); |
||||
} |
||||
if (containsCursor()) { |
||||
cursorCont.addListener(markNeedsLayout); |
||||
cursorCont.cursorColor.addListener(markNeedsPaint); |
||||
} |
||||
} |
||||
|
||||
@override |
||||
void detach() { |
||||
super.detach(); |
||||
for (final child in _children) { |
||||
child.detach(); |
||||
} |
||||
if (containsCursor()) { |
||||
cursorCont.removeListener(markNeedsLayout); |
||||
cursorCont.cursorColor.removeListener(markNeedsPaint); |
||||
} |
||||
} |
||||
|
||||
@override |
||||
void redepthChildren() { |
||||
_children.forEach(redepthChild); |
||||
} |
||||
|
||||
@override |
||||
void visitChildren(RenderObjectVisitor visitor) { |
||||
_children.forEach(visitor); |
||||
} |
||||
|
||||
@override |
||||
List<DiagnosticsNode> debugDescribeChildren() { |
||||
final value = <DiagnosticsNode>[]; |
||||
void add(RenderBox? child, String name) { |
||||
if (child != null) { |
||||
value.add(child.toDiagnosticsNode(name: name)); |
||||
} |
||||
} |
||||
|
||||
add(_leading, 'leading'); |
||||
add(_body, 'body'); |
||||
return value; |
||||
} |
||||
|
||||
@override |
||||
bool get sizedByParent => false; |
||||
|
||||
@override |
||||
double computeMinIntrinsicWidth(double height) { |
||||
_resolvePadding(); |
||||
final horizontalPadding = _resolvedPadding!.left + _resolvedPadding!.right; |
||||
final verticalPadding = _resolvedPadding!.top + _resolvedPadding!.bottom; |
||||
final leadingWidth = _leading == null |
||||
? 0 |
||||
: _leading!.getMinIntrinsicWidth(height - verticalPadding) as int; |
||||
final bodyWidth = _body == null |
||||
? 0 |
||||
: _body!.getMinIntrinsicWidth(math.max(0, height - verticalPadding)) |
||||
as int; |
||||
return horizontalPadding + leadingWidth + bodyWidth; |
||||
} |
||||
|
||||
@override |
||||
double computeMaxIntrinsicWidth(double height) { |
||||
_resolvePadding(); |
||||
final horizontalPadding = _resolvedPadding!.left + _resolvedPadding!.right; |
||||
final verticalPadding = _resolvedPadding!.top + _resolvedPadding!.bottom; |
||||
final leadingWidth = _leading == null |
||||
? 0 |
||||
: _leading!.getMaxIntrinsicWidth(height - verticalPadding) as int; |
||||
final bodyWidth = _body == null |
||||
? 0 |
||||
: _body!.getMaxIntrinsicWidth(math.max(0, height - verticalPadding)) |
||||
as int; |
||||
return horizontalPadding + leadingWidth + bodyWidth; |
||||
} |
||||
|
||||
@override |
||||
double computeMinIntrinsicHeight(double width) { |
||||
_resolvePadding(); |
||||
final horizontalPadding = _resolvedPadding!.left + _resolvedPadding!.right; |
||||
final verticalPadding = _resolvedPadding!.top + _resolvedPadding!.bottom; |
||||
if (_body != null) { |
||||
return _body! |
||||
.getMinIntrinsicHeight(math.max(0, width - horizontalPadding)) + |
||||
verticalPadding; |
||||
} |
||||
return verticalPadding; |
||||
} |
||||
|
||||
@override |
||||
double computeMaxIntrinsicHeight(double width) { |
||||
_resolvePadding(); |
||||
final horizontalPadding = _resolvedPadding!.left + _resolvedPadding!.right; |
||||
final verticalPadding = _resolvedPadding!.top + _resolvedPadding!.bottom; |
||||
if (_body != null) { |
||||
return _body! |
||||
.getMaxIntrinsicHeight(math.max(0, width - horizontalPadding)) + |
||||
verticalPadding; |
||||
} |
||||
return verticalPadding; |
||||
} |
||||
|
||||
@override |
||||
double computeDistanceToActualBaseline(TextBaseline baseline) { |
||||
_resolvePadding(); |
||||
return _body!.getDistanceToActualBaseline(baseline)! + |
||||
_resolvedPadding!.top; |
||||
} |
||||
|
||||
@override |
||||
void performLayout() { |
||||
final constraints = this.constraints; |
||||
_selectedRects = null; |
||||
|
||||
_resolvePadding(); |
||||
assert(_resolvedPadding != null); |
||||
|
||||
if (_body == null && _leading == null) { |
||||
size = constraints.constrain(Size( |
||||
_resolvedPadding!.left + _resolvedPadding!.right, |
||||
_resolvedPadding!.top + _resolvedPadding!.bottom, |
||||
)); |
||||
return; |
||||
} |
||||
final innerConstraints = constraints.deflate(_resolvedPadding!); |
||||
|
||||
final indentWidth = textDirection == TextDirection.ltr |
||||
? _resolvedPadding!.left |
||||
: _resolvedPadding!.right; |
||||
|
||||
_body!.layout(innerConstraints, parentUsesSize: true); |
||||
(_body!.parentData as BoxParentData).offset = |
||||
Offset(_resolvedPadding!.left, _resolvedPadding!.top); |
||||
|
||||
if (_leading != null) { |
||||
final leadingConstraints = innerConstraints.copyWith( |
||||
minWidth: indentWidth, |
||||
maxWidth: indentWidth, |
||||
maxHeight: _body!.size.height); |
||||
_leading!.layout(leadingConstraints, parentUsesSize: true); |
||||
(_leading!.parentData as BoxParentData).offset = |
||||
Offset(0, _resolvedPadding!.top); |
||||
} |
||||
|
||||
size = constraints.constrain(Size( |
||||
_resolvedPadding!.left + _body!.size.width + _resolvedPadding!.right, |
||||
_resolvedPadding!.top + _body!.size.height + _resolvedPadding!.bottom, |
||||
)); |
||||
|
||||
_computeCaretPrototype(); |
||||
} |
||||
|
||||
CursorPainter get _cursorPainter => CursorPainter( |
||||
_body, |
||||
cursorCont.style, |
||||
_caretPrototype, |
||||
cursorCont.cursorColor.value, |
||||
devicePixelRatio, |
||||
); |
||||
|
||||
@override |
||||
void paint(PaintingContext context, Offset offset) { |
||||
if (_leading != null) { |
||||
final parentData = _leading!.parentData as BoxParentData; |
||||
final effectiveOffset = offset + parentData.offset; |
||||
context.paintChild(_leading!, effectiveOffset); |
||||
} |
||||
|
||||
if (_body != null) { |
||||
final parentData = _body!.parentData as BoxParentData; |
||||
final effectiveOffset = offset + parentData.offset; |
||||
if (enableInteractiveSelection && |
||||
line.documentOffset <= textSelection.end && |
||||
textSelection.start <= line.documentOffset + line.length - 1) { |
||||
final local = localSelection(line, textSelection, false); |
||||
_selectedRects ??= _body!.getBoxesForSelection( |
||||
local, |
||||
); |
||||
_paintSelection(context, effectiveOffset); |
||||
} |
||||
|
||||
if (hasFocus && |
||||
cursorCont.show.value && |
||||
containsCursor() && |
||||
!cursorCont.style.paintAboveText) { |
||||
_paintCursor(context, effectiveOffset); |
||||
} |
||||
|
||||
context.paintChild(_body!, effectiveOffset); |
||||
|
||||
if (hasFocus && |
||||
cursorCont.show.value && |
||||
containsCursor() && |
||||
cursorCont.style.paintAboveText) { |
||||
_paintCursor(context, effectiveOffset); |
||||
} |
||||
} |
||||
} |
||||
|
||||
void _paintSelection(PaintingContext context, Offset effectiveOffset) { |
||||
assert(_selectedRects != null); |
||||
final paint = Paint()..color = color; |
||||
for (final box in _selectedRects!) { |
||||
context.canvas.drawRect(box.toRect().shift(effectiveOffset), paint); |
||||
} |
||||
} |
||||
|
||||
void _paintCursor(PaintingContext context, Offset effectiveOffset) { |
||||
final position = TextPosition( |
||||
offset: textSelection.extentOffset - line.documentOffset, |
||||
affinity: textSelection.base.affinity, |
||||
); |
||||
_cursorPainter.paint(context.canvas, effectiveOffset, position); |
||||
} |
||||
|
||||
@override |
||||
bool hitTestChildren(BoxHitTestResult result, {required Offset position}) { |
||||
return _children.first.hitTest(result, position: position); |
||||
} |
||||
} |
||||
|
||||
class _TextLineElement extends RenderObjectElement { |
||||
_TextLineElement(EditableTextLine line) : super(line); |
||||
|
||||
final Map<TextLineSlot, Element> _slotToChildren = <TextLineSlot, Element>{}; |
||||
|
||||
@override |
||||
EditableTextLine get widget => super.widget as EditableTextLine; |
||||
|
||||
@override |
||||
RenderEditableTextLine get renderObject => |
||||
super.renderObject as RenderEditableTextLine; |
||||
|
||||
@override |
||||
void visitChildren(ElementVisitor visitor) { |
||||
_slotToChildren.values.forEach(visitor); |
||||
} |
||||
|
||||
@override |
||||
void forgetChild(Element child) { |
||||
assert(_slotToChildren.containsValue(child)); |
||||
assert(child.slot is TextLineSlot); |
||||
assert(_slotToChildren.containsKey(child.slot)); |
||||
_slotToChildren.remove(child.slot); |
||||
super.forgetChild(child); |
||||
} |
||||
|
||||
@override |
||||
void mount(Element? parent, dynamic newSlot) { |
||||
super.mount(parent, newSlot); |
||||
_mountChild(widget.leading, TextLineSlot.LEADING); |
||||
_mountChild(widget.body, TextLineSlot.BODY); |
||||
} |
||||
|
||||
@override |
||||
void update(EditableTextLine newWidget) { |
||||
super.update(newWidget); |
||||
assert(widget == newWidget); |
||||
_updateChild(widget.leading, TextLineSlot.LEADING); |
||||
_updateChild(widget.body, TextLineSlot.BODY); |
||||
} |
||||
|
||||
@override |
||||
void insertRenderObjectChild(RenderBox child, TextLineSlot? slot) { |
||||
// assert(child is RenderBox); |
||||
_updateRenderObject(child, slot); |
||||
assert(renderObject.children.keys.contains(slot)); |
||||
} |
||||
|
||||
@override |
||||
void removeRenderObjectChild(RenderObject child, TextLineSlot? slot) { |
||||
assert(child is RenderBox); |
||||
assert(renderObject.children[slot!] == child); |
||||
_updateRenderObject(null, slot); |
||||
assert(!renderObject.children.keys.contains(slot)); |
||||
} |
||||
|
||||
@override |
||||
void moveRenderObjectChild( |
||||
RenderObject child, dynamic oldSlot, dynamic newSlot) { |
||||
throw UnimplementedError(); |
||||
} |
||||
|
||||
void _mountChild(Widget? widget, TextLineSlot slot) { |
||||
final oldChild = _slotToChildren[slot]; |
||||
final newChild = updateChild(oldChild, widget, slot); |
||||
if (oldChild != null) { |
||||
_slotToChildren.remove(slot); |
||||
} |
||||
if (newChild != null) { |
||||
_slotToChildren[slot] = newChild; |
||||
} |
||||
} |
||||
|
||||
void _updateRenderObject(RenderBox? child, TextLineSlot? slot) { |
||||
switch (slot) { |
||||
case TextLineSlot.LEADING: |
||||
renderObject.setLeading(child); |
||||
break; |
||||
case TextLineSlot.BODY: |
||||
renderObject.setBody(child as RenderContentProxyBox?); |
||||
break; |
||||
default: |
||||
throw UnimplementedError(); |
||||
} |
||||
} |
||||
|
||||
void _updateChild(Widget? widget, TextLineSlot slot) { |
||||
final oldChild = _slotToChildren[slot]; |
||||
final newChild = updateChild(oldChild, widget, slot); |
||||
if (oldChild != null) { |
||||
_slotToChildren.remove(slot); |
||||
} |
||||
if (newChild != null) { |
||||
_slotToChildren[slot] = newChild; |
||||
} |
||||
} |
||||
} |
||||
/// TODO: Remove this file in the next breaking release, because implementation |
||||
/// files should be located in the src folder, https://bit.ly/3fA23Yz. |
||||
export '../../src/widgets/text_line.dart'; |
||||
|
@ -1,726 +1,3 @@ |
||||
import 'dart:async'; |
||||
import 'dart:math' as math; |
||||
|
||||
import 'package:flutter/cupertino.dart'; |
||||
import 'package:flutter/foundation.dart'; |
||||
import 'package:flutter/gestures.dart'; |
||||
import 'package:flutter/material.dart'; |
||||
import 'package:flutter/rendering.dart'; |
||||
import 'package:flutter/scheduler.dart'; |
||||
|
||||
import '../models/documents/nodes/node.dart'; |
||||
import 'editor.dart'; |
||||
|
||||
TextSelection localSelection(Node node, TextSelection selection, fromParent) { |
||||
final base = fromParent ? node.offset : node.documentOffset; |
||||
assert(base <= selection.end && selection.start <= base + node.length - 1); |
||||
|
||||
final offset = fromParent ? node.offset : node.documentOffset; |
||||
return selection.copyWith( |
||||
baseOffset: math.max(selection.start - offset, 0), |
||||
extentOffset: math.min(selection.end - offset, node.length - 1)); |
||||
} |
||||
|
||||
enum _TextSelectionHandlePosition { START, END } |
||||
|
||||
class EditorTextSelectionOverlay { |
||||
EditorTextSelectionOverlay( |
||||
this.value, |
||||
this.handlesVisible, |
||||
this.context, |
||||
this.debugRequiredFor, |
||||
this.toolbarLayerLink, |
||||
this.startHandleLayerLink, |
||||
this.endHandleLayerLink, |
||||
this.renderObject, |
||||
this.selectionCtrls, |
||||
this.selectionDelegate, |
||||
this.dragStartBehavior, |
||||
this.onSelectionHandleTapped, |
||||
this.clipboardStatus, |
||||
) { |
||||
final overlay = Overlay.of(context, rootOverlay: true)!; |
||||
|
||||
_toolbarController = AnimationController( |
||||
duration: const Duration(milliseconds: 150), vsync: overlay); |
||||
} |
||||
|
||||
TextEditingValue value; |
||||
bool handlesVisible = false; |
||||
final BuildContext context; |
||||
final Widget debugRequiredFor; |
||||
final LayerLink toolbarLayerLink; |
||||
final LayerLink startHandleLayerLink; |
||||
final LayerLink endHandleLayerLink; |
||||
final RenderEditor? renderObject; |
||||
final TextSelectionControls selectionCtrls; |
||||
final TextSelectionDelegate selectionDelegate; |
||||
final DragStartBehavior dragStartBehavior; |
||||
final VoidCallback? onSelectionHandleTapped; |
||||
final ClipboardStatusNotifier clipboardStatus; |
||||
late AnimationController _toolbarController; |
||||
List<OverlayEntry>? _handles; |
||||
OverlayEntry? toolbar; |
||||
|
||||
TextSelection get _selection => value.selection; |
||||
|
||||
Animation<double> get _toolbarOpacity => _toolbarController.view; |
||||
|
||||
void setHandlesVisible(bool visible) { |
||||
if (handlesVisible == visible) { |
||||
return; |
||||
} |
||||
handlesVisible = visible; |
||||
if (SchedulerBinding.instance!.schedulerPhase == |
||||
SchedulerPhase.persistentCallbacks) { |
||||
SchedulerBinding.instance!.addPostFrameCallback(markNeedsBuild); |
||||
} else { |
||||
markNeedsBuild(); |
||||
} |
||||
} |
||||
|
||||
void hideHandles() { |
||||
if (_handles == null) { |
||||
return; |
||||
} |
||||
_handles![0].remove(); |
||||
_handles![1].remove(); |
||||
_handles = null; |
||||
} |
||||
|
||||
void hideToolbar() { |
||||
assert(toolbar != null); |
||||
_toolbarController.stop(); |
||||
toolbar!.remove(); |
||||
toolbar = null; |
||||
} |
||||
|
||||
void showToolbar() { |
||||
assert(toolbar == null); |
||||
toolbar = OverlayEntry(builder: _buildToolbar); |
||||
Overlay.of(context, rootOverlay: true, debugRequiredFor: debugRequiredFor)! |
||||
.insert(toolbar!); |
||||
_toolbarController.forward(from: 0); |
||||
} |
||||
|
||||
Widget _buildHandle( |
||||
BuildContext context, _TextSelectionHandlePosition position) { |
||||
if (_selection.isCollapsed && |
||||
position == _TextSelectionHandlePosition.END) { |
||||
return Container(); |
||||
} |
||||
return Visibility( |
||||
visible: handlesVisible, |
||||
child: _TextSelectionHandleOverlay( |
||||
onSelectionHandleChanged: (newSelection) { |
||||
_handleSelectionHandleChanged(newSelection, position); |
||||
}, |
||||
onSelectionHandleTapped: onSelectionHandleTapped, |
||||
startHandleLayerLink: startHandleLayerLink, |
||||
endHandleLayerLink: endHandleLayerLink, |
||||
renderObject: renderObject, |
||||
selection: _selection, |
||||
selectionControls: selectionCtrls, |
||||
position: position, |
||||
dragStartBehavior: dragStartBehavior, |
||||
)); |
||||
} |
||||
|
||||
void update(TextEditingValue newValue) { |
||||
if (value == newValue) { |
||||
return; |
||||
} |
||||
value = newValue; |
||||
if (SchedulerBinding.instance!.schedulerPhase == |
||||
SchedulerPhase.persistentCallbacks) { |
||||
SchedulerBinding.instance!.addPostFrameCallback(markNeedsBuild); |
||||
} else { |
||||
markNeedsBuild(); |
||||
} |
||||
} |
||||
|
||||
void _handleSelectionHandleChanged( |
||||
TextSelection? newSelection, _TextSelectionHandlePosition position) { |
||||
TextPosition textPosition; |
||||
switch (position) { |
||||
case _TextSelectionHandlePosition.START: |
||||
textPosition = newSelection != null |
||||
? newSelection.base |
||||
: const TextPosition(offset: 0); |
||||
break; |
||||
case _TextSelectionHandlePosition.END: |
||||
textPosition = newSelection != null |
||||
? newSelection.extent |
||||
: const TextPosition(offset: 0); |
||||
break; |
||||
default: |
||||
throw 'Invalid position'; |
||||
} |
||||
selectionDelegate |
||||
..textEditingValue = |
||||
value.copyWith(selection: newSelection, composing: TextRange.empty) |
||||
..bringIntoView(textPosition); |
||||
} |
||||
|
||||
Widget _buildToolbar(BuildContext context) { |
||||
final endpoints = renderObject!.getEndpointsForSelection(_selection); |
||||
|
||||
final editingRegion = Rect.fromPoints( |
||||
renderObject!.localToGlobal(Offset.zero), |
||||
renderObject!.localToGlobal(renderObject!.size.bottomRight(Offset.zero)), |
||||
); |
||||
|
||||
final baseLineHeight = renderObject!.preferredLineHeight(_selection.base); |
||||
final extentLineHeight = |
||||
renderObject!.preferredLineHeight(_selection.extent); |
||||
final smallestLineHeight = math.min(baseLineHeight, extentLineHeight); |
||||
final isMultiline = endpoints.last.point.dy - endpoints.first.point.dy > |
||||
smallestLineHeight / 2; |
||||
|
||||
final midX = isMultiline |
||||
? editingRegion.width / 2 |
||||
: (endpoints.first.point.dx + endpoints.last.point.dx) / 2; |
||||
|
||||
final midpoint = Offset( |
||||
midX, |
||||
endpoints[0].point.dy - baseLineHeight, |
||||
); |
||||
|
||||
return FadeTransition( |
||||
opacity: _toolbarOpacity, |
||||
child: CompositedTransformFollower( |
||||
link: toolbarLayerLink, |
||||
showWhenUnlinked: false, |
||||
offset: -editingRegion.topLeft, |
||||
child: selectionCtrls.buildToolbar( |
||||
context, |
||||
editingRegion, |
||||
baseLineHeight, |
||||
midpoint, |
||||
endpoints, |
||||
selectionDelegate, |
||||
clipboardStatus, |
||||
const Offset(0, 0)), |
||||
), |
||||
); |
||||
} |
||||
|
||||
void markNeedsBuild([Duration? duration]) { |
||||
if (_handles != null) { |
||||
_handles![0].markNeedsBuild(); |
||||
_handles![1].markNeedsBuild(); |
||||
} |
||||
toolbar?.markNeedsBuild(); |
||||
} |
||||
|
||||
void hide() { |
||||
if (_handles != null) { |
||||
_handles![0].remove(); |
||||
_handles![1].remove(); |
||||
_handles = null; |
||||
} |
||||
if (toolbar != null) { |
||||
hideToolbar(); |
||||
} |
||||
} |
||||
|
||||
void dispose() { |
||||
hide(); |
||||
_toolbarController.dispose(); |
||||
} |
||||
|
||||
void showHandles() { |
||||
assert(_handles == null); |
||||
_handles = <OverlayEntry>[ |
||||
OverlayEntry( |
||||
builder: (context) => |
||||
_buildHandle(context, _TextSelectionHandlePosition.START)), |
||||
OverlayEntry( |
||||
builder: (context) => |
||||
_buildHandle(context, _TextSelectionHandlePosition.END)), |
||||
]; |
||||
|
||||
Overlay.of(context, rootOverlay: true, debugRequiredFor: debugRequiredFor)! |
||||
.insertAll(_handles!); |
||||
} |
||||
} |
||||
|
||||
class _TextSelectionHandleOverlay extends StatefulWidget { |
||||
const _TextSelectionHandleOverlay({ |
||||
required this.selection, |
||||
required this.position, |
||||
required this.startHandleLayerLink, |
||||
required this.endHandleLayerLink, |
||||
required this.renderObject, |
||||
required this.onSelectionHandleChanged, |
||||
required this.onSelectionHandleTapped, |
||||
required this.selectionControls, |
||||
this.dragStartBehavior = DragStartBehavior.start, |
||||
Key? key, |
||||
}) : super(key: key); |
||||
|
||||
final TextSelection selection; |
||||
final _TextSelectionHandlePosition position; |
||||
final LayerLink startHandleLayerLink; |
||||
final LayerLink endHandleLayerLink; |
||||
final RenderEditor? renderObject; |
||||
final ValueChanged<TextSelection?> onSelectionHandleChanged; |
||||
final VoidCallback? onSelectionHandleTapped; |
||||
final TextSelectionControls selectionControls; |
||||
final DragStartBehavior dragStartBehavior; |
||||
|
||||
@override |
||||
_TextSelectionHandleOverlayState createState() => |
||||
_TextSelectionHandleOverlayState(); |
||||
|
||||
ValueListenable<bool>? get _visibility { |
||||
switch (position) { |
||||
case _TextSelectionHandlePosition.START: |
||||
return renderObject!.selectionStartInViewport; |
||||
case _TextSelectionHandlePosition.END: |
||||
return renderObject!.selectionEndInViewport; |
||||
} |
||||
} |
||||
} |
||||
|
||||
class _TextSelectionHandleOverlayState |
||||
extends State<_TextSelectionHandleOverlay> |
||||
with SingleTickerProviderStateMixin { |
||||
late AnimationController _controller; |
||||
|
||||
Animation<double> get _opacity => _controller.view; |
||||
|
||||
@override |
||||
void initState() { |
||||
super.initState(); |
||||
|
||||
_controller = AnimationController( |
||||
duration: const Duration(milliseconds: 150), vsync: this); |
||||
|
||||
_handleVisibilityChanged(); |
||||
widget._visibility!.addListener(_handleVisibilityChanged); |
||||
} |
||||
|
||||
void _handleVisibilityChanged() { |
||||
if (widget._visibility!.value) { |
||||
_controller.forward(); |
||||
} else { |
||||
_controller.reverse(); |
||||
} |
||||
} |
||||
|
||||
@override |
||||
void didUpdateWidget(_TextSelectionHandleOverlay oldWidget) { |
||||
super.didUpdateWidget(oldWidget); |
||||
oldWidget._visibility!.removeListener(_handleVisibilityChanged); |
||||
_handleVisibilityChanged(); |
||||
widget._visibility!.addListener(_handleVisibilityChanged); |
||||
} |
||||
|
||||
@override |
||||
void dispose() { |
||||
widget._visibility!.removeListener(_handleVisibilityChanged); |
||||
_controller.dispose(); |
||||
super.dispose(); |
||||
} |
||||
|
||||
void _handleDragStart(DragStartDetails details) {} |
||||
|
||||
void _handleDragUpdate(DragUpdateDetails details) { |
||||
final position = |
||||
widget.renderObject!.getPositionForOffset(details.globalPosition); |
||||
if (widget.selection.isCollapsed) { |
||||
widget.onSelectionHandleChanged(TextSelection.fromPosition(position)); |
||||
return; |
||||
} |
||||
|
||||
final isNormalized = |
||||
widget.selection.extentOffset >= widget.selection.baseOffset; |
||||
TextSelection? newSelection; |
||||
switch (widget.position) { |
||||
case _TextSelectionHandlePosition.START: |
||||
newSelection = TextSelection( |
||||
baseOffset: |
||||
isNormalized ? position.offset : widget.selection.baseOffset, |
||||
extentOffset: |
||||
isNormalized ? widget.selection.extentOffset : position.offset, |
||||
); |
||||
break; |
||||
case _TextSelectionHandlePosition.END: |
||||
newSelection = TextSelection( |
||||
baseOffset: |
||||
isNormalized ? widget.selection.baseOffset : position.offset, |
||||
extentOffset: |
||||
isNormalized ? position.offset : widget.selection.extentOffset, |
||||
); |
||||
break; |
||||
} |
||||
|
||||
widget.onSelectionHandleChanged(newSelection); |
||||
} |
||||
|
||||
void _handleTap() { |
||||
if (widget.onSelectionHandleTapped != null) { |
||||
widget.onSelectionHandleTapped!(); |
||||
} |
||||
} |
||||
|
||||
@override |
||||
Widget build(BuildContext context) { |
||||
late LayerLink layerLink; |
||||
TextSelectionHandleType? type; |
||||
|
||||
switch (widget.position) { |
||||
case _TextSelectionHandlePosition.START: |
||||
layerLink = widget.startHandleLayerLink; |
||||
type = _chooseType( |
||||
widget.renderObject!.textDirection, |
||||
TextSelectionHandleType.left, |
||||
TextSelectionHandleType.right, |
||||
); |
||||
break; |
||||
case _TextSelectionHandlePosition.END: |
||||
assert(!widget.selection.isCollapsed); |
||||
layerLink = widget.endHandleLayerLink; |
||||
type = _chooseType( |
||||
widget.renderObject!.textDirection, |
||||
TextSelectionHandleType.right, |
||||
TextSelectionHandleType.left, |
||||
); |
||||
break; |
||||
} |
||||
|
||||
final textPosition = widget.position == _TextSelectionHandlePosition.START |
||||
? widget.selection.base |
||||
: widget.selection.extent; |
||||
final lineHeight = widget.renderObject!.preferredLineHeight(textPosition); |
||||
final handleAnchor = |
||||
widget.selectionControls.getHandleAnchor(type!, lineHeight); |
||||
final handleSize = widget.selectionControls.getHandleSize(lineHeight); |
||||
|
||||
final handleRect = Rect.fromLTWH( |
||||
-handleAnchor.dx, |
||||
-handleAnchor.dy, |
||||
handleSize.width, |
||||
handleSize.height, |
||||
); |
||||
|
||||
final interactiveRect = handleRect.expandToInclude( |
||||
Rect.fromCircle( |
||||
center: handleRect.center, radius: kMinInteractiveDimension / 2), |
||||
); |
||||
final padding = RelativeRect.fromLTRB( |
||||
math.max((interactiveRect.width - handleRect.width) / 2, 0), |
||||
math.max((interactiveRect.height - handleRect.height) / 2, 0), |
||||
math.max((interactiveRect.width - handleRect.width) / 2, 0), |
||||
math.max((interactiveRect.height - handleRect.height) / 2, 0), |
||||
); |
||||
|
||||
return CompositedTransformFollower( |
||||
link: layerLink, |
||||
offset: interactiveRect.topLeft, |
||||
showWhenUnlinked: false, |
||||
child: FadeTransition( |
||||
opacity: _opacity, |
||||
child: Container( |
||||
alignment: Alignment.topLeft, |
||||
width: interactiveRect.width, |
||||
height: interactiveRect.height, |
||||
child: GestureDetector( |
||||
behavior: HitTestBehavior.translucent, |
||||
dragStartBehavior: widget.dragStartBehavior, |
||||
onPanStart: _handleDragStart, |
||||
onPanUpdate: _handleDragUpdate, |
||||
onTap: _handleTap, |
||||
child: Padding( |
||||
padding: EdgeInsets.only( |
||||
left: padding.left, |
||||
top: padding.top, |
||||
right: padding.right, |
||||
bottom: padding.bottom, |
||||
), |
||||
child: widget.selectionControls.buildHandle( |
||||
context, |
||||
type, |
||||
lineHeight, |
||||
), |
||||
), |
||||
), |
||||
), |
||||
), |
||||
); |
||||
} |
||||
|
||||
TextSelectionHandleType? _chooseType( |
||||
TextDirection textDirection, |
||||
TextSelectionHandleType ltrType, |
||||
TextSelectionHandleType rtlType, |
||||
) { |
||||
if (widget.selection.isCollapsed) return TextSelectionHandleType.collapsed; |
||||
|
||||
switch (textDirection) { |
||||
case TextDirection.ltr: |
||||
return ltrType; |
||||
case TextDirection.rtl: |
||||
return rtlType; |
||||
} |
||||
} |
||||
} |
||||
|
||||
class EditorTextSelectionGestureDetector extends StatefulWidget { |
||||
const EditorTextSelectionGestureDetector({ |
||||
required this.child, |
||||
this.onTapDown, |
||||
this.onForcePressStart, |
||||
this.onForcePressEnd, |
||||
this.onSingleTapUp, |
||||
this.onSingleTapCancel, |
||||
this.onSingleLongTapStart, |
||||
this.onSingleLongTapMoveUpdate, |
||||
this.onSingleLongTapEnd, |
||||
this.onDoubleTapDown, |
||||
this.onDragSelectionStart, |
||||
this.onDragSelectionUpdate, |
||||
this.onDragSelectionEnd, |
||||
this.behavior, |
||||
Key? key, |
||||
}) : super(key: key); |
||||
|
||||
final GestureTapDownCallback? onTapDown; |
||||
|
||||
final GestureForcePressStartCallback? onForcePressStart; |
||||
|
||||
final GestureForcePressEndCallback? onForcePressEnd; |
||||
|
||||
final GestureTapUpCallback? onSingleTapUp; |
||||
|
||||
final GestureTapCancelCallback? onSingleTapCancel; |
||||
|
||||
final GestureLongPressStartCallback? onSingleLongTapStart; |
||||
|
||||
final GestureLongPressMoveUpdateCallback? onSingleLongTapMoveUpdate; |
||||
|
||||
final GestureLongPressEndCallback? onSingleLongTapEnd; |
||||
|
||||
final GestureTapDownCallback? onDoubleTapDown; |
||||
|
||||
final GestureDragStartCallback? onDragSelectionStart; |
||||
|
||||
final DragSelectionUpdateCallback? onDragSelectionUpdate; |
||||
|
||||
final GestureDragEndCallback? onDragSelectionEnd; |
||||
|
||||
final HitTestBehavior? behavior; |
||||
|
||||
final Widget child; |
||||
|
||||
@override |
||||
State<StatefulWidget> createState() => |
||||
_EditorTextSelectionGestureDetectorState(); |
||||
} |
||||
|
||||
class _EditorTextSelectionGestureDetectorState |
||||
extends State<EditorTextSelectionGestureDetector> { |
||||
Timer? _doubleTapTimer; |
||||
Offset? _lastTapOffset; |
||||
bool _isDoubleTap = false; |
||||
|
||||
@override |
||||
void dispose() { |
||||
_doubleTapTimer?.cancel(); |
||||
_dragUpdateThrottleTimer?.cancel(); |
||||
super.dispose(); |
||||
} |
||||
|
||||
void _handleTapDown(TapDownDetails details) { |
||||
// renderObject.resetTapDownStatus(); |
||||
if (widget.onTapDown != null) { |
||||
widget.onTapDown!(details); |
||||
} |
||||
if (_doubleTapTimer != null && |
||||
_isWithinDoubleTapTolerance(details.globalPosition)) { |
||||
if (widget.onDoubleTapDown != null) { |
||||
widget.onDoubleTapDown!(details); |
||||
} |
||||
|
||||
_doubleTapTimer!.cancel(); |
||||
_doubleTapTimeout(); |
||||
_isDoubleTap = true; |
||||
} |
||||
} |
||||
|
||||
void _handleTapUp(TapUpDetails details) { |
||||
if (!_isDoubleTap) { |
||||
if (widget.onSingleTapUp != null) { |
||||
widget.onSingleTapUp!(details); |
||||
} |
||||
_lastTapOffset = details.globalPosition; |
||||
_doubleTapTimer = Timer(kDoubleTapTimeout, _doubleTapTimeout); |
||||
} |
||||
_isDoubleTap = false; |
||||
} |
||||
|
||||
void _handleTapCancel() { |
||||
if (widget.onSingleTapCancel != null) { |
||||
widget.onSingleTapCancel!(); |
||||
} |
||||
} |
||||
|
||||
DragStartDetails? _lastDragStartDetails; |
||||
DragUpdateDetails? _lastDragUpdateDetails; |
||||
Timer? _dragUpdateThrottleTimer; |
||||
|
||||
void _handleDragStart(DragStartDetails details) { |
||||
assert(_lastDragStartDetails == null); |
||||
_lastDragStartDetails = details; |
||||
if (widget.onDragSelectionStart != null) { |
||||
widget.onDragSelectionStart!(details); |
||||
} |
||||
} |
||||
|
||||
void _handleDragUpdate(DragUpdateDetails details) { |
||||
_lastDragUpdateDetails = details; |
||||
_dragUpdateThrottleTimer ??= |
||||
Timer(const Duration(milliseconds: 50), _handleDragUpdateThrottled); |
||||
} |
||||
|
||||
void _handleDragUpdateThrottled() { |
||||
assert(_lastDragStartDetails != null); |
||||
assert(_lastDragUpdateDetails != null); |
||||
if (widget.onDragSelectionUpdate != null) { |
||||
widget.onDragSelectionUpdate!( |
||||
_lastDragStartDetails!, _lastDragUpdateDetails!); |
||||
} |
||||
_dragUpdateThrottleTimer = null; |
||||
_lastDragUpdateDetails = null; |
||||
} |
||||
|
||||
void _handleDragEnd(DragEndDetails details) { |
||||
assert(_lastDragStartDetails != null); |
||||
if (_dragUpdateThrottleTimer != null) { |
||||
_dragUpdateThrottleTimer!.cancel(); |
||||
_handleDragUpdateThrottled(); |
||||
} |
||||
if (widget.onDragSelectionEnd != null) { |
||||
widget.onDragSelectionEnd!(details); |
||||
} |
||||
_dragUpdateThrottleTimer = null; |
||||
_lastDragStartDetails = null; |
||||
_lastDragUpdateDetails = null; |
||||
} |
||||
|
||||
void _forcePressStarted(ForcePressDetails details) { |
||||
_doubleTapTimer?.cancel(); |
||||
_doubleTapTimer = null; |
||||
if (widget.onForcePressStart != null) { |
||||
widget.onForcePressStart!(details); |
||||
} |
||||
} |
||||
|
||||
void _forcePressEnded(ForcePressDetails details) { |
||||
if (widget.onForcePressEnd != null) { |
||||
widget.onForcePressEnd!(details); |
||||
} |
||||
} |
||||
|
||||
void _handleLongPressStart(LongPressStartDetails details) { |
||||
if (!_isDoubleTap && widget.onSingleLongTapStart != null) { |
||||
widget.onSingleLongTapStart!(details); |
||||
} |
||||
} |
||||
|
||||
void _handleLongPressMoveUpdate(LongPressMoveUpdateDetails details) { |
||||
if (!_isDoubleTap && widget.onSingleLongTapMoveUpdate != null) { |
||||
widget.onSingleLongTapMoveUpdate!(details); |
||||
} |
||||
} |
||||
|
||||
void _handleLongPressEnd(LongPressEndDetails details) { |
||||
if (!_isDoubleTap && widget.onSingleLongTapEnd != null) { |
||||
widget.onSingleLongTapEnd!(details); |
||||
} |
||||
_isDoubleTap = false; |
||||
} |
||||
|
||||
void _doubleTapTimeout() { |
||||
_doubleTapTimer = null; |
||||
_lastTapOffset = null; |
||||
} |
||||
|
||||
bool _isWithinDoubleTapTolerance(Offset secondTapOffset) { |
||||
if (_lastTapOffset == null) { |
||||
return false; |
||||
} |
||||
|
||||
return (secondTapOffset - _lastTapOffset!).distance <= kDoubleTapSlop; |
||||
} |
||||
|
||||
@override |
||||
Widget build(BuildContext context) { |
||||
final gestures = <Type, GestureRecognizerFactory>{}; |
||||
|
||||
gestures[TapGestureRecognizer] = |
||||
GestureRecognizerFactoryWithHandlers<TapGestureRecognizer>( |
||||
() => TapGestureRecognizer(debugOwner: this), |
||||
(instance) { |
||||
instance |
||||
..onTapDown = _handleTapDown |
||||
..onTapUp = _handleTapUp |
||||
..onTapCancel = _handleTapCancel; |
||||
}, |
||||
); |
||||
|
||||
if (widget.onSingleLongTapStart != null || |
||||
widget.onSingleLongTapMoveUpdate != null || |
||||
widget.onSingleLongTapEnd != null) { |
||||
gestures[LongPressGestureRecognizer] = |
||||
GestureRecognizerFactoryWithHandlers<LongPressGestureRecognizer>( |
||||
() => LongPressGestureRecognizer( |
||||
debugOwner: this, kind: PointerDeviceKind.touch), |
||||
(instance) { |
||||
instance |
||||
..onLongPressStart = _handleLongPressStart |
||||
..onLongPressMoveUpdate = _handleLongPressMoveUpdate |
||||
..onLongPressEnd = _handleLongPressEnd; |
||||
}, |
||||
); |
||||
} |
||||
|
||||
if (widget.onDragSelectionStart != null || |
||||
widget.onDragSelectionUpdate != null || |
||||
widget.onDragSelectionEnd != null) { |
||||
gestures[HorizontalDragGestureRecognizer] = |
||||
GestureRecognizerFactoryWithHandlers<HorizontalDragGestureRecognizer>( |
||||
() => HorizontalDragGestureRecognizer( |
||||
debugOwner: this, kind: PointerDeviceKind.mouse), |
||||
(instance) { |
||||
instance |
||||
..dragStartBehavior = DragStartBehavior.down |
||||
..onStart = _handleDragStart |
||||
..onUpdate = _handleDragUpdate |
||||
..onEnd = _handleDragEnd; |
||||
}, |
||||
); |
||||
} |
||||
|
||||
if (widget.onForcePressStart != null || widget.onForcePressEnd != null) { |
||||
gestures[ForcePressGestureRecognizer] = |
||||
GestureRecognizerFactoryWithHandlers<ForcePressGestureRecognizer>( |
||||
() => ForcePressGestureRecognizer(debugOwner: this), |
||||
(instance) { |
||||
instance |
||||
..onStart = |
||||
widget.onForcePressStart != null ? _forcePressStarted : null |
||||
..onEnd = widget.onForcePressEnd != null ? _forcePressEnded : null; |
||||
}, |
||||
); |
||||
} |
||||
|
||||
return RawGestureDetector( |
||||
gestures: gestures, |
||||
excludeFromSemantics: true, |
||||
behavior: widget.behavior, |
||||
child: widget.child, |
||||
); |
||||
} |
||||
} |
||||
/// TODO: Remove this file in the next breaking release, because implementation |
||||
/// files should be located in the src folder, https://bit.ly/3fA23Yz. |
||||
export '../../src/widgets/text_selection.dart'; |
||||
|
File diff suppressed because it is too large
Load Diff
Loading…
Reference in new issue