commit
dd2b866aa1
96 changed files with 11862 additions and 10896 deletions
@ -1 +1,11 @@ |
||||
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/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,273 +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(); |
||||
|
||||
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}) { |
||||
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); |
||||
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) { |
||||
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); |
||||
} |
||||
|
||||
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) { |
||||
assert(!_observer.isClosed); |
||||
delta.trim(); |
||||
assert(delta.isNotEmpty); |
||||
|
||||
var offset = 0; |
||||
delta = _transform(delta); |
||||
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) { |
||||
final res = Delta(); |
||||
final ops = delta.toList(); |
||||
for (var i = 0; i < ops.length; i++) { |
||||
final op = ops[i]; |
||||
res.push(op); |
||||
_handleImageInsert(i, ops, op, res); |
||||
} |
||||
return res; |
||||
} |
||||
|
||||
static void _handleImageInsert( |
||||
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 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,71 +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); |
||||
|
||||
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; |
||||
|
||||
Delta apply(RuleType ruleType, Document document, int index, |
||||
{int? len, Object? data, Attribute? attribute}) { |
||||
final delta = document.toDelta(); |
||||
for (final rule in _rules) { |
||||
if (rule.type != ruleType) { |
||||
continue; |
||||
} |
||||
try { |
||||
final result = rule.apply(delta, index, |
||||
len: len, data: data, attribute: attribute); |
||||
if (result != null) { |
||||
return result..trim(); |
||||
} |
||||
} catch (e) { |
||||
rethrow; |
||||
} |
||||
} |
||||
throw 'Apply rules failed'; |
||||
} |
||||
} |
||||
/// TODO: Remove this file in the next breaking release, because implementation |
||||
/// files should be located in the src folder, https://bit.ly/3fA23Yz. |
||||
export '../../src/models/rules/rule.dart'; |
||||
|
@ -0,0 +1,292 @@ |
||||
import 'dart:collection'; |
||||
|
||||
import 'package:quiver/core.dart'; |
||||
|
||||
enum AttributeScope { |
||||
INLINE, // refer to https://quilljs.com/docs/formats/#inline |
||||
BLOCK, // refer to https://quilljs.com/docs/formats/#block |
||||
EMBEDS, // refer to https://quilljs.com/docs/formats/#embeds |
||||
IGNORE, // attributes that can be ignored |
||||
} |
||||
|
||||
class Attribute<T> { |
||||
Attribute(this.key, this.scope, this.value); |
||||
|
||||
final String key; |
||||
final AttributeScope scope; |
||||
final T value; |
||||
|
||||
static final Map<String, Attribute> _registry = LinkedHashMap.of({ |
||||
Attribute.bold.key: Attribute.bold, |
||||
Attribute.italic.key: Attribute.italic, |
||||
Attribute.underline.key: Attribute.underline, |
||||
Attribute.strikeThrough.key: Attribute.strikeThrough, |
||||
Attribute.font.key: Attribute.font, |
||||
Attribute.size.key: Attribute.size, |
||||
Attribute.link.key: Attribute.link, |
||||
Attribute.color.key: Attribute.color, |
||||
Attribute.background.key: Attribute.background, |
||||
Attribute.placeholder.key: Attribute.placeholder, |
||||
Attribute.header.key: Attribute.header, |
||||
Attribute.align.key: Attribute.align, |
||||
Attribute.list.key: Attribute.list, |
||||
Attribute.codeBlock.key: Attribute.codeBlock, |
||||
Attribute.blockQuote.key: Attribute.blockQuote, |
||||
Attribute.indent.key: Attribute.indent, |
||||
Attribute.width.key: Attribute.width, |
||||
Attribute.height.key: Attribute.height, |
||||
Attribute.style.key: Attribute.style, |
||||
Attribute.token.key: Attribute.token, |
||||
}); |
||||
|
||||
static final BoldAttribute bold = BoldAttribute(); |
||||
|
||||
static final ItalicAttribute italic = ItalicAttribute(); |
||||
|
||||
static final UnderlineAttribute underline = UnderlineAttribute(); |
||||
|
||||
static final StrikeThroughAttribute strikeThrough = StrikeThroughAttribute(); |
||||
|
||||
static final FontAttribute font = FontAttribute(null); |
||||
|
||||
static final SizeAttribute size = SizeAttribute(null); |
||||
|
||||
static final LinkAttribute link = LinkAttribute(null); |
||||
|
||||
static final ColorAttribute color = ColorAttribute(null); |
||||
|
||||
static final BackgroundAttribute background = BackgroundAttribute(null); |
||||
|
||||
static final PlaceholderAttribute placeholder = PlaceholderAttribute(); |
||||
|
||||
static final HeaderAttribute header = HeaderAttribute(); |
||||
|
||||
static final IndentAttribute indent = IndentAttribute(); |
||||
|
||||
static final AlignAttribute align = AlignAttribute(null); |
||||
|
||||
static final ListAttribute list = ListAttribute(null); |
||||
|
||||
static final CodeBlockAttribute codeBlock = CodeBlockAttribute(); |
||||
|
||||
static final BlockQuoteAttribute blockQuote = BlockQuoteAttribute(); |
||||
|
||||
static final WidthAttribute width = WidthAttribute(null); |
||||
|
||||
static final HeightAttribute height = HeightAttribute(null); |
||||
|
||||
static final StyleAttribute style = StyleAttribute(null); |
||||
|
||||
static final TokenAttribute token = TokenAttribute(''); |
||||
|
||||
static final Set<String> inlineKeys = { |
||||
Attribute.bold.key, |
||||
Attribute.italic.key, |
||||
Attribute.underline.key, |
||||
Attribute.strikeThrough.key, |
||||
Attribute.link.key, |
||||
Attribute.color.key, |
||||
Attribute.background.key, |
||||
Attribute.placeholder.key, |
||||
}; |
||||
|
||||
static final Set<String> blockKeys = LinkedHashSet.of({ |
||||
Attribute.header.key, |
||||
Attribute.align.key, |
||||
Attribute.list.key, |
||||
Attribute.codeBlock.key, |
||||
Attribute.blockQuote.key, |
||||
Attribute.indent.key, |
||||
}); |
||||
|
||||
static final Set<String> blockKeysExceptHeader = LinkedHashSet.of({ |
||||
Attribute.list.key, |
||||
Attribute.align.key, |
||||
Attribute.codeBlock.key, |
||||
Attribute.blockQuote.key, |
||||
Attribute.indent.key, |
||||
}); |
||||
|
||||
static Attribute<int?> get h1 => HeaderAttribute(level: 1); |
||||
|
||||
static Attribute<int?> get h2 => HeaderAttribute(level: 2); |
||||
|
||||
static Attribute<int?> get h3 => HeaderAttribute(level: 3); |
||||
|
||||
// "attributes":{"align":"left"} |
||||
static Attribute<String?> get leftAlignment => AlignAttribute('left'); |
||||
|
||||
// "attributes":{"align":"center"} |
||||
static Attribute<String?> get centerAlignment => AlignAttribute('center'); |
||||
|
||||
// "attributes":{"align":"right"} |
||||
static Attribute<String?> get rightAlignment => AlignAttribute('right'); |
||||
|
||||
// "attributes":{"align":"justify"} |
||||
static Attribute<String?> get justifyAlignment => AlignAttribute('justify'); |
||||
|
||||
// "attributes":{"list":"bullet"} |
||||
static Attribute<String?> get ul => ListAttribute('bullet'); |
||||
|
||||
// "attributes":{"list":"ordered"} |
||||
static Attribute<String?> get ol => ListAttribute('ordered'); |
||||
|
||||
// "attributes":{"list":"checked"} |
||||
static Attribute<String?> get checked => ListAttribute('checked'); |
||||
|
||||
// "attributes":{"list":"unchecked"} |
||||
static Attribute<String?> get unchecked => ListAttribute('unchecked'); |
||||
|
||||
// "attributes":{"indent":1"} |
||||
static Attribute<int?> get indentL1 => IndentAttribute(level: 1); |
||||
|
||||
// "attributes":{"indent":2"} |
||||
static Attribute<int?> get indentL2 => IndentAttribute(level: 2); |
||||
|
||||
// "attributes":{"indent":3"} |
||||
static Attribute<int?> get indentL3 => IndentAttribute(level: 3); |
||||
|
||||
static Attribute<int?> getIndentLevel(int? level) { |
||||
if (level == 1) { |
||||
return indentL1; |
||||
} |
||||
if (level == 2) { |
||||
return indentL2; |
||||
} |
||||
if (level == 3) { |
||||
return indentL3; |
||||
} |
||||
return IndentAttribute(level: level); |
||||
} |
||||
|
||||
bool get isInline => scope == AttributeScope.INLINE; |
||||
|
||||
bool get isBlockExceptHeader => blockKeysExceptHeader.contains(key); |
||||
|
||||
Map<String, dynamic> toJson() => <String, dynamic>{key: value}; |
||||
|
||||
static Attribute fromKeyValue(String key, dynamic value) { |
||||
if (!_registry.containsKey(key)) { |
||||
throw ArgumentError.value(key, 'key "$key" not found.'); |
||||
} |
||||
final origin = _registry[key]!; |
||||
final attribute = clone(origin, value); |
||||
return attribute; |
||||
} |
||||
|
||||
static int getRegistryOrder(Attribute attribute) { |
||||
var order = 0; |
||||
for (final attr in _registry.values) { |
||||
if (attr.key == attribute.key) { |
||||
break; |
||||
} |
||||
order++; |
||||
} |
||||
|
||||
return order; |
||||
} |
||||
|
||||
static Attribute clone(Attribute origin, dynamic value) { |
||||
return Attribute(origin.key, origin.scope, value); |
||||
} |
||||
|
||||
@override |
||||
bool operator ==(Object other) { |
||||
if (identical(this, other)) return true; |
||||
if (other is! Attribute) return false; |
||||
final typedOther = other; |
||||
return key == typedOther.key && |
||||
scope == typedOther.scope && |
||||
value == typedOther.value; |
||||
} |
||||
|
||||
@override |
||||
int get hashCode => hash3(key, scope, value); |
||||
|
||||
@override |
||||
String toString() { |
||||
return 'Attribute{key: $key, scope: $scope, value: $value}'; |
||||
} |
||||
} |
||||
|
||||
class BoldAttribute extends Attribute<bool> { |
||||
BoldAttribute() : super('bold', AttributeScope.INLINE, true); |
||||
} |
||||
|
||||
class ItalicAttribute extends Attribute<bool> { |
||||
ItalicAttribute() : super('italic', AttributeScope.INLINE, true); |
||||
} |
||||
|
||||
class UnderlineAttribute extends Attribute<bool> { |
||||
UnderlineAttribute() : super('underline', AttributeScope.INLINE, true); |
||||
} |
||||
|
||||
class StrikeThroughAttribute extends Attribute<bool> { |
||||
StrikeThroughAttribute() : super('strike', AttributeScope.INLINE, true); |
||||
} |
||||
|
||||
class FontAttribute extends Attribute<String?> { |
||||
FontAttribute(String? val) : super('font', AttributeScope.INLINE, val); |
||||
} |
||||
|
||||
class SizeAttribute extends Attribute<String?> { |
||||
SizeAttribute(String? val) : super('size', AttributeScope.INLINE, val); |
||||
} |
||||
|
||||
class LinkAttribute extends Attribute<String?> { |
||||
LinkAttribute(String? val) : super('link', AttributeScope.INLINE, val); |
||||
} |
||||
|
||||
class ColorAttribute extends Attribute<String?> { |
||||
ColorAttribute(String? val) : super('color', AttributeScope.INLINE, val); |
||||
} |
||||
|
||||
class BackgroundAttribute extends Attribute<String?> { |
||||
BackgroundAttribute(String? val) |
||||
: super('background', AttributeScope.INLINE, val); |
||||
} |
||||
|
||||
/// This is custom attribute for hint |
||||
class PlaceholderAttribute extends Attribute<bool> { |
||||
PlaceholderAttribute() : super('placeholder', AttributeScope.INLINE, true); |
||||
} |
||||
|
||||
class HeaderAttribute extends Attribute<int?> { |
||||
HeaderAttribute({int? level}) : super('header', AttributeScope.BLOCK, level); |
||||
} |
||||
|
||||
class IndentAttribute extends Attribute<int?> { |
||||
IndentAttribute({int? level}) : super('indent', AttributeScope.BLOCK, level); |
||||
} |
||||
|
||||
class AlignAttribute extends Attribute<String?> { |
||||
AlignAttribute(String? val) : super('align', AttributeScope.BLOCK, val); |
||||
} |
||||
|
||||
class ListAttribute extends Attribute<String?> { |
||||
ListAttribute(String? val) : super('list', AttributeScope.BLOCK, val); |
||||
} |
||||
|
||||
class CodeBlockAttribute extends Attribute<bool> { |
||||
CodeBlockAttribute() : super('code-block', AttributeScope.BLOCK, true); |
||||
} |
||||
|
||||
class BlockQuoteAttribute extends Attribute<bool> { |
||||
BlockQuoteAttribute() : super('blockquote', AttributeScope.BLOCK, true); |
||||
} |
||||
|
||||
class WidthAttribute extends Attribute<String?> { |
||||
WidthAttribute(String? val) : super('width', AttributeScope.IGNORE, val); |
||||
} |
||||
|
||||
class HeightAttribute extends Attribute<String?> { |
||||
HeightAttribute(String? val) : super('height', AttributeScope.IGNORE, val); |
||||
} |
||||
|
||||
class StyleAttribute extends Attribute<String?> { |
||||
StyleAttribute(String? val) : super('style', AttributeScope.IGNORE, val); |
||||
} |
||||
|
||||
class TokenAttribute extends Attribute<String> { |
||||
TokenAttribute(String val) : super('token', AttributeScope.IGNORE, val); |
||||
} |
@ -0,0 +1,290 @@ |
||||
import 'dart:async'; |
||||
|
||||
import 'package:tuple/tuple.dart'; |
||||
|
||||
import '../quill_delta.dart'; |
||||
import '../rules/rule.dart'; |
||||
import 'attribute.dart'; |
||||
import 'history.dart'; |
||||
import 'nodes/block.dart'; |
||||
import 'nodes/container.dart'; |
||||
import 'nodes/embed.dart'; |
||||
import 'nodes/line.dart'; |
||||
import 'nodes/node.dart'; |
||||
import 'style.dart'; |
||||
|
||||
/// The rich text document |
||||
class Document { |
||||
Document() : _delta = Delta()..insert('\n') { |
||||
_loadDocument(_delta); |
||||
} |
||||
|
||||
Document.fromJson(List data) : _delta = _transform(Delta.fromJson(data)) { |
||||
_loadDocument(_delta); |
||||
} |
||||
|
||||
Document.fromDelta(Delta delta) : _delta = delta { |
||||
_loadDocument(delta); |
||||
} |
||||
|
||||
/// The root node of the document tree |
||||
final Root _root = Root(); |
||||
|
||||
Root get root => _root; |
||||
|
||||
int get length => _root.length; |
||||
|
||||
Delta _delta; |
||||
|
||||
Delta toDelta() => Delta.from(_delta); |
||||
|
||||
final Rules _rules = Rules.getInstance(); |
||||
|
||||
void setCustomRules(List<Rule> customRules) { |
||||
_rules.setCustomRules(customRules); |
||||
} |
||||
|
||||
final StreamController<Tuple3<Delta, Delta, ChangeSource>> _observer = |
||||
StreamController.broadcast(); |
||||
|
||||
final History _history = History(); |
||||
|
||||
Stream<Tuple3<Delta, Delta, ChangeSource>> get changes => _observer.stream; |
||||
|
||||
Delta insert(int index, Object? data, |
||||
{int replaceLength = 0, bool autoAppendNewlineAfterImage = true}) { |
||||
assert(index >= 0); |
||||
assert(data is String || data is Embeddable); |
||||
if (data is Embeddable) { |
||||
data = data.toJson(); |
||||
} else if ((data as String).isEmpty) { |
||||
return Delta(); |
||||
} |
||||
|
||||
final delta = _rules.apply(RuleType.INSERT, this, index, |
||||
data: data, len: replaceLength); |
||||
compose(delta, ChangeSource.LOCAL, |
||||
autoAppendNewlineAfterImage: autoAppendNewlineAfterImage); |
||||
return delta; |
||||
} |
||||
|
||||
Delta delete(int index, int len) { |
||||
assert(index >= 0 && len > 0); |
||||
final delta = _rules.apply(RuleType.DELETE, this, index, len: len); |
||||
if (delta.isNotEmpty) { |
||||
compose(delta, ChangeSource.LOCAL); |
||||
} |
||||
return delta; |
||||
} |
||||
|
||||
Delta replace(int index, int len, Object? data, |
||||
{bool autoAppendNewlineAfterImage = true}) { |
||||
assert(index >= 0); |
||||
assert(data is String || data is Embeddable); |
||||
|
||||
final dataIsNotEmpty = (data is String) ? data.isNotEmpty : true; |
||||
|
||||
assert(dataIsNotEmpty || len > 0); |
||||
|
||||
var delta = Delta(); |
||||
|
||||
// We have to insert before applying delete rules |
||||
// Otherwise delete would be operating on stale document snapshot. |
||||
if (dataIsNotEmpty) { |
||||
delta = insert(index, data, |
||||
replaceLength: len, |
||||
autoAppendNewlineAfterImage: autoAppendNewlineAfterImage); |
||||
} |
||||
|
||||
if (len > 0) { |
||||
final deleteDelta = delete(index, len); |
||||
delta = delta.compose(deleteDelta); |
||||
} |
||||
|
||||
return delta; |
||||
} |
||||
|
||||
Delta format(int index, int len, Attribute? attribute) { |
||||
assert(index >= 0 && len >= 0 && attribute != null); |
||||
|
||||
var delta = Delta(); |
||||
|
||||
final formatDelta = _rules.apply(RuleType.FORMAT, this, index, |
||||
len: len, attribute: attribute); |
||||
if (formatDelta.isNotEmpty) { |
||||
compose(formatDelta, ChangeSource.LOCAL); |
||||
delta = delta.compose(formatDelta); |
||||
} |
||||
|
||||
return delta; |
||||
} |
||||
|
||||
Style collectStyle(int index, int len) { |
||||
final res = queryChild(index); |
||||
return (res.node as Line).collectStyle(res.offset, len); |
||||
} |
||||
|
||||
ChildQuery queryChild(int offset) { |
||||
final res = _root.queryChild(offset, true); |
||||
if (res.node is Line) { |
||||
return res; |
||||
} |
||||
final block = res.node as Block; |
||||
return block.queryChild(res.offset, true); |
||||
} |
||||
|
||||
void compose(Delta delta, ChangeSource changeSource, |
||||
{bool autoAppendNewlineAfterImage = true}) { |
||||
assert(!_observer.isClosed); |
||||
delta.trim(); |
||||
assert(delta.isNotEmpty); |
||||
|
||||
var offset = 0; |
||||
delta = _transform(delta, |
||||
autoAppendNewlineAfterImage: autoAppendNewlineAfterImage); |
||||
final originalDelta = toDelta(); |
||||
for (final op in delta.toList()) { |
||||
final style = |
||||
op.attributes != null ? Style.fromJson(op.attributes) : null; |
||||
|
||||
if (op.isInsert) { |
||||
_root.insert(offset, _normalize(op.data), style); |
||||
} else if (op.isDelete) { |
||||
_root.delete(offset, op.length); |
||||
} else if (op.attributes != null) { |
||||
_root.retain(offset, op.length, style); |
||||
} |
||||
|
||||
if (!op.isDelete) { |
||||
offset += op.length!; |
||||
} |
||||
} |
||||
try { |
||||
_delta = _delta.compose(delta); |
||||
} catch (e) { |
||||
throw '_delta compose failed'; |
||||
} |
||||
|
||||
if (_delta != _root.toDelta()) { |
||||
throw 'Compose failed'; |
||||
} |
||||
final change = Tuple3(originalDelta, delta, changeSource); |
||||
_observer.add(change); |
||||
_history.handleDocChange(change); |
||||
} |
||||
|
||||
Tuple2 undo() { |
||||
return _history.undo(this); |
||||
} |
||||
|
||||
Tuple2 redo() { |
||||
return _history.redo(this); |
||||
} |
||||
|
||||
bool get hasUndo => _history.hasUndo; |
||||
|
||||
bool get hasRedo => _history.hasRedo; |
||||
|
||||
static Delta _transform(Delta delta, |
||||
{bool autoAppendNewlineAfterImage = true}) { |
||||
final res = Delta(); |
||||
final ops = delta.toList(); |
||||
for (var i = 0; i < ops.length; i++) { |
||||
final op = ops[i]; |
||||
res.push(op); |
||||
if (autoAppendNewlineAfterImage) { |
||||
_autoAppendNewlineAfterImage(i, ops, op, res); |
||||
} |
||||
} |
||||
return res; |
||||
} |
||||
|
||||
static void _autoAppendNewlineAfterImage( |
||||
int i, List<Operation> ops, Operation op, Delta res) { |
||||
final nextOpIsImage = |
||||
i + 1 < ops.length && ops[i + 1].isInsert && ops[i + 1].data is! String; |
||||
if (nextOpIsImage && |
||||
op.data is String && |
||||
(op.data as String).isNotEmpty && |
||||
!(op.data as String).endsWith('\n')) { |
||||
res.push(Operation.insert('\n')); |
||||
} |
||||
// Currently embed is equivalent to image and hence `is! String` |
||||
final opInsertImage = op.isInsert && op.data is! String; |
||||
final nextOpIsLineBreak = i + 1 < ops.length && |
||||
ops[i + 1].isInsert && |
||||
ops[i + 1].data is String && |
||||
(ops[i + 1].data as String).startsWith('\n'); |
||||
if (opInsertImage && (i + 1 == ops.length - 1 || !nextOpIsLineBreak)) { |
||||
// automatically append '\n' for image |
||||
res.push(Operation.insert('\n')); |
||||
} |
||||
} |
||||
|
||||
Object _normalize(Object? data) { |
||||
if (data is String) { |
||||
return data; |
||||
} |
||||
|
||||
if (data is Embeddable) { |
||||
return data; |
||||
} |
||||
return Embeddable.fromJson(data as Map<String, dynamic>); |
||||
} |
||||
|
||||
void close() { |
||||
_observer.close(); |
||||
_history.clear(); |
||||
} |
||||
|
||||
String toPlainText() => _root.children.map((e) => e.toPlainText()).join(); |
||||
|
||||
void _loadDocument(Delta doc) { |
||||
if (doc.isEmpty) { |
||||
throw ArgumentError.value(doc, 'Document Delta cannot be empty.'); |
||||
} |
||||
|
||||
assert((doc.last.data as String).endsWith('\n')); |
||||
|
||||
var offset = 0; |
||||
for (final op in doc.toList()) { |
||||
if (!op.isInsert) { |
||||
throw ArgumentError.value(doc, |
||||
'Document 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 [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,42 @@ |
||||
/// An object which can be embedded into a Quill document. |
||||
/// |
||||
/// See also: |
||||
/// |
||||
/// * [BlockEmbed] which represents a block embed. |
||||
class Embeddable { |
||||
const 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 { |
||||
const BlockEmbed(String type, String data) : super(type, data); |
||||
|
||||
static const String horizontalRuleType = 'divider'; |
||||
static BlockEmbed horizontalRule = const BlockEmbed(horizontalRuleType, 'hr'); |
||||
|
||||
static const String imageType = 'image'; |
||||
static BlockEmbed image(String imageUrl) => BlockEmbed(imageType, 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,134 @@ |
||||
import 'dart:collection'; |
||||
|
||||
import '../../quill_delta.dart'; |
||||
import '../attribute.dart'; |
||||
import '../style.dart'; |
||||
import 'container.dart'; |
||||
import 'line.dart'; |
||||
|
||||
/// An abstract node in a document tree. |
||||
/// |
||||
/// Represents a segment of a Quill document with specified [offset] |
||||
/// and [length]. |
||||
/// |
||||
/// The [offset] property is relative to [parent]. See also [documentOffset] |
||||
/// which provides absolute offset of this node within the document. |
||||
/// |
||||
/// The current parent node is exposed by the [parent] property. |
||||
abstract class Node extends LinkedListEntry<Node> { |
||||
/// Current parent of this node. May be null if this node is not mounted. |
||||
Container? parent; |
||||
|
||||
Style get style => _style; |
||||
Style _style = Style(); |
||||
|
||||
/// Returns `true` if this node is the first node in the [parent] list. |
||||
bool get isFirst => list!.first == this; |
||||
|
||||
/// Returns `true` if this node is the last node in the [parent] list. |
||||
bool get isLast => list!.last == this; |
||||
|
||||
/// Length of this node in characters. |
||||
int get length; |
||||
|
||||
Node clone() => newInstance()..applyStyle(style); |
||||
|
||||
/// Offset in characters of this node relative to [parent] node. |
||||
/// |
||||
/// To get offset of this node in the document see [documentOffset]. |
||||
int get offset { |
||||
var offset = 0; |
||||
|
||||
if (list == null || isFirst) { |
||||
return offset; |
||||
} |
||||
|
||||
var cur = this; |
||||
do { |
||||
cur = cur.previous!; |
||||
offset += cur.length; |
||||
} while (!cur.isFirst); |
||||
return offset; |
||||
} |
||||
|
||||
/// Offset in characters of this node in the document. |
||||
int get documentOffset { |
||||
if (parent == null) { |
||||
return offset; |
||||
} |
||||
final parentOffset = (parent is! Root) ? parent!.documentOffset : 0; |
||||
return parentOffset + offset; |
||||
} |
||||
|
||||
/// Returns `true` if this node contains character at specified [offset] in |
||||
/// the document. |
||||
bool containsOffset(int offset) { |
||||
final o = documentOffset; |
||||
return o <= offset && offset < o + length; |
||||
} |
||||
|
||||
void applyAttribute(Attribute attribute) { |
||||
_style = _style.merge(attribute); |
||||
} |
||||
|
||||
void applyStyle(Style value) { |
||||
_style = _style.mergeAll(value); |
||||
} |
||||
|
||||
void clearStyle() { |
||||
_style = Style(); |
||||
} |
||||
|
||||
@override |
||||
void insertBefore(Node entry) { |
||||
assert(entry.parent == null && parent != null); |
||||
entry.parent = parent; |
||||
super.insertBefore(entry); |
||||
} |
||||
|
||||
@override |
||||
void insertAfter(Node entry) { |
||||
assert(entry.parent == null && parent != null); |
||||
entry.parent = parent; |
||||
super.insertAfter(entry); |
||||
} |
||||
|
||||
@override |
||||
void unlink() { |
||||
assert(parent != null); |
||||
parent = null; |
||||
super.unlink(); |
||||
} |
||||
|
||||
void adjust() {/* no-op */} |
||||
|
||||
/// abstract methods begin |
||||
|
||||
Node newInstance(); |
||||
|
||||
String toPlainText(); |
||||
|
||||
Delta toDelta(); |
||||
|
||||
void insert(int index, Object data, Style? style); |
||||
|
||||
void retain(int index, int? len, Style? style); |
||||
|
||||
void delete(int index, int? len); |
||||
|
||||
/// abstract methods end |
||||
} |
||||
|
||||
/// Root node of document tree. |
||||
class Root extends Container<Container<Node?>> { |
||||
@override |
||||
Node newInstance() => Root(); |
||||
|
||||
@override |
||||
Container<Node?> get defaultChild => Line(); |
||||
|
||||
@override |
||||
Delta toDelta() => children |
||||
.map((child) => child.toDelta()) |
||||
.fold(Delta(), (a, b) => a.concat(b)); |
||||
} |
@ -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,685 @@ |
||||
// 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,414 @@ |
||||
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(Attribute.blockKeysExceptHeader.contains); |
||||
attributes[k] = null; |
||||
// retain(1) should be '\n', set it with no attribute |
||||
return Delta()..retain(index + (len ?? 0))..retain(1, attributes); |
||||
} |
||||
} |
||||
|
||||
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,103 @@ |
||||
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,233 @@ |
||||
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 TextSelection selection, |
||||
}) : _selection = selection; |
||||
|
||||
factory QuillController.basic() { |
||||
return QuillController( |
||||
document: Document(), |
||||
selection: const TextSelection.collapsed(offset: 0), |
||||
); |
||||
} |
||||
|
||||
/// Document managed by this controller. |
||||
final Document document; |
||||
|
||||
/// Currently selected text within the [document]. |
||||
TextSelection get selection => _selection; |
||||
TextSelection _selection; |
||||
|
||||
/// Store any styles attribute that got toggled by the tap of a button |
||||
/// and that has not been applied yet. |
||||
/// It gets reset after each format action within the [document]. |
||||
Style toggledStyle = Style(); |
||||
|
||||
bool ignoreFocusOnTextChange = false; |
||||
|
||||
/// True when this [QuillController] instance has been disposed. |
||||
/// |
||||
/// A safety mechanism to ensure 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,330 @@ |
||||
import 'dart:async'; |
||||
|
||||
import 'package:flutter/foundation.dart'; |
||||
import 'package:flutter/widgets.dart'; |
||||
|
||||
import 'box.dart'; |
||||
|
||||
/// Style properties of editing cursor. |
||||
class CursorStyle { |
||||
const CursorStyle({ |
||||
required this.color, |
||||
required this.backgroundColor, |
||||
this.width = 1.0, |
||||
this.height, |
||||
this.radius, |
||||
this.offset, |
||||
this.opacityAnimates = false, |
||||
this.paintAboveText = false, |
||||
}); |
||||
|
||||
/// The color to use when painting the cursor. |
||||
final Color color; |
||||
|
||||
/// The color to use when painting the background cursor aligned with the text |
||||
/// while rendering the floating cursor. |
||||
final Color backgroundColor; |
||||
|
||||
/// How thick the cursor will be. |
||||
/// |
||||
/// The cursor will draw under the text. The cursor width will extend |
||||
/// to the right of the boundary between characters for left-to-right text |
||||
/// and to the left for right-to-left text. This corresponds to extending |
||||
/// downstream relative to the selected position. Negative values may be used |
||||
/// to reverse this behavior. |
||||
final double width; |
||||
|
||||
/// How tall the cursor will be. |
||||
/// |
||||
/// By default, the cursor height is set to the preferred line height of the |
||||
/// text. |
||||
final double? height; |
||||
|
||||
/// How rounded the corners of the cursor should be. |
||||
/// |
||||
/// By default, the cursor has no radius. |
||||
final Radius? radius; |
||||
|
||||
/// The offset that is used, in pixels, when painting the cursor on screen. |
||||
/// |
||||
/// By default, the cursor position should be set to an offset of |
||||
/// (-[cursorWidth] * 0.5, 0.0) on iOS platforms and (0, 0) on Android |
||||
/// platforms. The origin from where the offset is applied to is the arbitrary |
||||
/// location where the cursor ends up being rendered from by default. |
||||
final Offset? offset; |
||||
|
||||
/// Whether the cursor will animate from fully transparent to fully opaque |
||||
/// during each cursor blink. |
||||
/// |
||||
/// By default, the cursor opacity will animate on iOS platforms and will not |
||||
/// animate on Android platforms. |
||||
final bool opacityAnimates; |
||||
|
||||
/// If the cursor should be painted on top of the text or underneath it. |
||||
/// |
||||
/// By default, the cursor should be painted on top for iOS platforms and |
||||
/// underneath for Android platforms. |
||||
final bool paintAboveText; |
||||
|
||||
@override |
||||
bool operator ==(Object other) => |
||||
identical(this, other) || |
||||
other is CursorStyle && |
||||
runtimeType == other.runtimeType && |
||||
color == other.color && |
||||
backgroundColor == other.backgroundColor && |
||||
width == other.width && |
||||
height == other.height && |
||||
radius == other.radius && |
||||
offset == other.offset && |
||||
opacityAnimates == other.opacityAnimates && |
||||
paintAboveText == other.paintAboveText; |
||||
|
||||
@override |
||||
int get hashCode => |
||||
color.hashCode ^ |
||||
backgroundColor.hashCode ^ |
||||
width.hashCode ^ |
||||
height.hashCode ^ |
||||
radius.hashCode ^ |
||||
offset.hashCode ^ |
||||
opacityAnimates.hashCode ^ |
||||
paintAboveText.hashCode; |
||||
} |
||||
|
||||
/// Controls the cursor of an editable widget. |
||||
/// |
||||
/// This class is a [ChangeNotifier] and allows to listen for updates on the |
||||
/// cursor [style]. |
||||
class CursorCont extends ChangeNotifier { |
||||
CursorCont({ |
||||
required this.show, |
||||
required CursorStyle style, |
||||
required TickerProvider tickerProvider, |
||||
}) : _style = style, |
||||
blink = ValueNotifier(false), |
||||
color = ValueNotifier(style.color) { |
||||
_blinkOpacityController = |
||||
AnimationController(vsync: tickerProvider, duration: _fadeDuration); |
||||
_blinkOpacityController.addListener(_onColorTick); |
||||
} |
||||
|
||||
// The time it takes for the cursor to fade from fully opaque to fully |
||||
// transparent and vice versa. A full cursor blink, from transparent to opaque |
||||
// to transparent, is twice this duration. |
||||
static const Duration _blinkHalfPeriod = Duration(milliseconds: 500); |
||||
|
||||
// The time the cursor is static in opacity before animating to become |
||||
// transparent. |
||||
static const Duration _blinkWaitForStart = Duration(milliseconds: 150); |
||||
|
||||
// This value is an eyeball estimation of the time it takes for the iOS cursor |
||||
// to ease in and out. |
||||
static const Duration _fadeDuration = Duration(milliseconds: 250); |
||||
|
||||
final ValueNotifier<bool> show; |
||||
final ValueNotifier<Color> color; |
||||
final ValueNotifier<bool> blink; |
||||
|
||||
late final AnimationController _blinkOpacityController; |
||||
|
||||
Timer? _cursorTimer; |
||||
bool _targetCursorVisibility = false; |
||||
|
||||
CursorStyle _style; |
||||
CursorStyle get style => _style; |
||||
set style(CursorStyle value) { |
||||
if (_style == value) return; |
||||
_style = value; |
||||
notifyListeners(); |
||||
} |
||||
|
||||
/// True when this [CursorCont] instance has been disposed. |
||||
/// |
||||
/// A safety mechanism to prevent the value of a disposed controller from |
||||
/// getting set. |
||||
bool _isDisposed = false; |
||||
|
||||
@override |
||||
void dispose() { |
||||
_blinkOpacityController.removeListener(_onColorTick); |
||||
stopCursorTimer(); |
||||
|
||||
_isDisposed = true; |
||||
_blinkOpacityController.dispose(); |
||||
show.dispose(); |
||||
blink.dispose(); |
||||
color.dispose(); |
||||
assert(_cursorTimer == null); |
||||
super.dispose(); |
||||
} |
||||
|
||||
void _cursorTick(Timer timer) { |
||||
_targetCursorVisibility = !_targetCursorVisibility; |
||||
final targetOpacity = _targetCursorVisibility ? 1.0 : 0.0; |
||||
if (style.opacityAnimates) { |
||||
// If we want to show the cursor, we will animate the opacity to the value |
||||
// of 1.0, and likewise if we want to make it disappear, to 0.0. An easing |
||||
// curve is used for the animation to mimic the aesthetics of the native |
||||
// iOS cursor. |
||||
// |
||||
// These values and curves have been obtained through eyeballing, so are |
||||
// likely not exactly the same as the values for native iOS. |
||||
_blinkOpacityController.animateTo(targetOpacity, curve: Curves.easeOut); |
||||
} else { |
||||
_blinkOpacityController.value = targetOpacity; |
||||
} |
||||
} |
||||
|
||||
void _waitForStart(Timer timer) { |
||||
_cursorTimer?.cancel(); |
||||
_cursorTimer = Timer.periodic(_blinkHalfPeriod, _cursorTick); |
||||
} |
||||
|
||||
void startCursorTimer() { |
||||
if (_isDisposed) { |
||||
return; |
||||
} |
||||
|
||||
_targetCursorVisibility = true; |
||||
_blinkOpacityController.value = 1.0; |
||||
|
||||
if (style.opacityAnimates) { |
||||
_cursorTimer = Timer.periodic(_blinkWaitForStart, _waitForStart); |
||||
} else { |
||||
_cursorTimer = Timer.periodic(_blinkHalfPeriod, _cursorTick); |
||||
} |
||||
} |
||||
|
||||
void stopCursorTimer({bool resetCharTicks = true}) { |
||||
_cursorTimer?.cancel(); |
||||
_cursorTimer = null; |
||||
_targetCursorVisibility = false; |
||||
_blinkOpacityController.value = 0.0; |
||||
|
||||
if (style.opacityAnimates) { |
||||
_blinkOpacityController |
||||
..stop() |
||||
..value = 0.0; |
||||
} |
||||
} |
||||
|
||||
void startOrStopCursorTimerIfNeeded(bool hasFocus, TextSelection selection) { |
||||
if (show.value && |
||||
_cursorTimer == null && |
||||
hasFocus && |
||||
selection.isCollapsed) { |
||||
startCursorTimer(); |
||||
} else if (_cursorTimer != null && (!hasFocus || !selection.isCollapsed)) { |
||||
stopCursorTimer(); |
||||
} |
||||
} |
||||
|
||||
void _onColorTick() { |
||||
color.value = _style.color.withOpacity(_blinkOpacityController.value); |
||||
blink.value = show.value && _blinkOpacityController.value > 0; |
||||
} |
||||
} |
||||
|
||||
/// Paints the editing cursor. |
||||
class CursorPainter { |
||||
CursorPainter( |
||||
this.editable, |
||||
this.style, |
||||
this.prototype, |
||||
this.color, |
||||
this.devicePixelRatio, |
||||
); |
||||
|
||||
final RenderContentProxyBox? editable; |
||||
final CursorStyle style; |
||||
final Rect prototype; |
||||
final Color color; |
||||
final double devicePixelRatio; |
||||
|
||||
/// Paints cursor on [canvas] at specified [position]. |
||||
void paint(Canvas canvas, Offset offset, TextPosition position) { |
||||
final caretOffset = |
||||
editable!.getOffsetForCaret(position, prototype) + offset; |
||||
var caretRect = prototype.shift(caretOffset); |
||||
if (style.offset != null) { |
||||
caretRect = caretRect.shift(style.offset!); |
||||
} |
||||
|
||||
if (caretRect.left < 0.0) { |
||||
// For iOS the cursor may get clipped by the scroll view when |
||||
// it's located at a beginning of a line. We ensure that this |
||||
// does not happen here. This may result in the cursor being painted |
||||
// closer to the character on the right, but it's arguably better |
||||
// then painting clipped cursor (or even cursor completely hidden). |
||||
caretRect = caretRect.shift(Offset(-caretRect.left, 0)); |
||||
} |
||||
|
||||
final caretHeight = editable!.getFullHeightForCaret(position); |
||||
if (caretHeight != null) { |
||||
switch (defaultTargetPlatform) { |
||||
case TargetPlatform.android: |
||||
case TargetPlatform.fuchsia: |
||||
case TargetPlatform.linux: |
||||
case TargetPlatform.windows: |
||||
// Override the height to take the full height of the glyph at the |
||||
// TextPosition when not on iOS. iOS has special handling that |
||||
// creates a taller caret. |
||||
caretRect = Rect.fromLTWH( |
||||
caretRect.left, |
||||
caretRect.top - 2.0, |
||||
caretRect.width, |
||||
caretHeight, |
||||
); |
||||
break; |
||||
case TargetPlatform.iOS: |
||||
case TargetPlatform.macOS: |
||||
// Center the caret vertically along the text. |
||||
caretRect = Rect.fromLTWH( |
||||
caretRect.left, |
||||
caretRect.top + (caretHeight - caretRect.height) / 2, |
||||
caretRect.width, |
||||
caretRect.height, |
||||
); |
||||
break; |
||||
default: |
||||
throw UnimplementedError(); |
||||
} |
||||
} |
||||
|
||||
final pixelPerfectOffset = |
||||
_getPixelPerfectCursorOffset(editable!, caretRect, devicePixelRatio); |
||||
if (!pixelPerfectOffset.isFinite) { |
||||
return; |
||||
} |
||||
caretRect = caretRect.shift(pixelPerfectOffset); |
||||
|
||||
final paint = Paint()..color = color; |
||||
if (style.radius == null) { |
||||
canvas.drawRect(caretRect, paint); |
||||
} else { |
||||
final caretRRect = RRect.fromRectAndRadius(caretRect, style.radius!); |
||||
canvas.drawRRect(caretRRect, paint); |
||||
} |
||||
} |
||||
|
||||
Offset _getPixelPerfectCursorOffset( |
||||
RenderContentProxyBox editable, |
||||
Rect caretRect, |
||||
double devicePixelRatio, |
||||
) { |
||||
final caretPosition = editable.localToGlobal(caretRect.topLeft); |
||||
final pixelMultiple = 1.0 / devicePixelRatio; |
||||
|
||||
final pixelPerfectOffsetX = caretPosition.dx.isFinite |
||||
? (caretPosition.dx / pixelMultiple).round() * pixelMultiple - |
||||
caretPosition.dx |
||||
: caretPosition.dx; |
||||
final pixelPerfectOffsetY = caretPosition.dy.isFinite |
||||
? (caretPosition.dy / pixelMultiple).round() * pixelMultiple - |
||||
caretPosition.dy |
||||
: caretPosition.dy; |
||||
|
||||
return Offset(pixelPerfectOffsetX, pixelPerfectOffsetY); |
||||
} |
||||
} |
@ -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 ignore the key because it's already processed. |
||||
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,738 @@ |
||||
import 'dart:async'; |
||||
import 'dart:convert'; |
||||
|
||||
import 'package:flutter/cupertino.dart'; |
||||
import 'package:flutter/foundation.dart'; |
||||
import 'package:flutter/gestures.dart'; |
||||
import 'package:flutter/material.dart'; |
||||
import 'package:flutter/rendering.dart'; |
||||
import 'package:flutter/scheduler.dart'; |
||||
import 'package:flutter/services.dart'; |
||||
import 'package:flutter_keyboard_visibility/flutter_keyboard_visibility.dart'; |
||||
import 'package:tuple/tuple.dart'; |
||||
|
||||
import '../models/documents/attribute.dart'; |
||||
import '../models/documents/document.dart'; |
||||
import '../models/documents/nodes/block.dart'; |
||||
import '../models/documents/nodes/line.dart'; |
||||
import 'controller.dart'; |
||||
import 'cursor.dart'; |
||||
import 'default_styles.dart'; |
||||
import 'delegate.dart'; |
||||
import 'editor.dart'; |
||||
import 'keyboard_listener.dart'; |
||||
import 'proxy.dart'; |
||||
import 'raw_editor/raw_editor_state_keyboard_mixin.dart'; |
||||
import 'raw_editor/raw_editor_state_selection_delegate_mixin.dart'; |
||||
import 'raw_editor/raw_editor_state_text_input_client_mixin.dart'; |
||||
import 'text_block.dart'; |
||||
import 'text_line.dart'; |
||||
import 'text_selection.dart'; |
||||
|
||||
class RawEditor extends StatefulWidget { |
||||
const RawEditor( |
||||
Key key, |
||||
this.controller, |
||||
this.focusNode, |
||||
this.scrollController, |
||||
this.scrollable, |
||||
this.scrollBottomInset, |
||||
this.padding, |
||||
this.readOnly, |
||||
this.placeholder, |
||||
this.onLaunchUrl, |
||||
this.toolbarOptions, |
||||
this.showSelectionHandles, |
||||
bool? showCursor, |
||||
this.cursorStyle, |
||||
this.textCapitalization, |
||||
this.maxHeight, |
||||
this.minHeight, |
||||
this.customStyles, |
||||
this.expands, |
||||
this.autoFocus, |
||||
this.selectionColor, |
||||
this.selectionCtrls, |
||||
this.keyboardAppearance, |
||||
this.enableInteractiveSelection, |
||||
this.scrollPhysics, |
||||
this.embedBuilder, |
||||
) : assert(maxHeight == null || maxHeight > 0, 'maxHeight cannot be null'), |
||||
assert(minHeight == null || minHeight >= 0, 'minHeight cannot be null'), |
||||
assert(maxHeight == null || minHeight == null || maxHeight >= minHeight, |
||||
'maxHeight cannot be null'), |
||||
showCursor = showCursor ?? true, |
||||
super(key: key); |
||||
|
||||
final QuillController controller; |
||||
final FocusNode focusNode; |
||||
final ScrollController scrollController; |
||||
final bool scrollable; |
||||
final double scrollBottomInset; |
||||
final EdgeInsetsGeometry padding; |
||||
final bool readOnly; |
||||
final String? placeholder; |
||||
final ValueChanged<String>? onLaunchUrl; |
||||
final ToolbarOptions toolbarOptions; |
||||
final bool showSelectionHandles; |
||||
final bool showCursor; |
||||
final CursorStyle cursorStyle; |
||||
final TextCapitalization textCapitalization; |
||||
final double? maxHeight; |
||||
final double? minHeight; |
||||
final DefaultStyles? customStyles; |
||||
final bool expands; |
||||
final bool autoFocus; |
||||
final Color selectionColor; |
||||
final TextSelectionControls selectionCtrls; |
||||
final Brightness keyboardAppearance; |
||||
final bool enableInteractiveSelection; |
||||
final ScrollPhysics? scrollPhysics; |
||||
final EmbedBuilder embedBuilder; |
||||
|
||||
@override |
||||
State<StatefulWidget> createState() => RawEditorState(); |
||||
} |
||||
|
||||
class RawEditorState extends EditorState |
||||
with |
||||
AutomaticKeepAliveClientMixin<RawEditor>, |
||||
WidgetsBindingObserver, |
||||
TickerProviderStateMixin<RawEditor>, |
||||
RawEditorStateKeyboardMixin, |
||||
RawEditorStateTextInputClientMixin, |
||||
RawEditorStateSelectionDelegateMixin { |
||||
final GlobalKey _editorKey = GlobalKey(); |
||||
|
||||
// Keyboard |
||||
late KeyboardListener _keyboardListener; |
||||
KeyboardVisibilityController? _keyboardVisibilityController; |
||||
StreamSubscription<bool>? _keyboardVisibilitySubscription; |
||||
bool _keyboardVisible = false; |
||||
|
||||
// Selection overlay |
||||
@override |
||||
EditorTextSelectionOverlay? getSelectionOverlay() => _selectionOverlay; |
||||
EditorTextSelectionOverlay? _selectionOverlay; |
||||
|
||||
ScrollController? _scrollController; |
||||
|
||||
late CursorCont _cursorCont; |
||||
|
||||
// Focus |
||||
bool _didAutoFocus = false; |
||||
FocusAttachment? _focusAttachment; |
||||
bool get _hasFocus => widget.focusNode.hasFocus; |
||||
|
||||
DefaultStyles? _styles; |
||||
|
||||
final ClipboardStatusNotifier _clipboardStatus = ClipboardStatusNotifier(); |
||||
final LayerLink _toolbarLayerLink = LayerLink(); |
||||
final LayerLink _startHandleLayerLink = LayerLink(); |
||||
final LayerLink _endHandleLayerLink = LayerLink(); |
||||
|
||||
TextDirection get _textDirection => Directionality.of(context); |
||||
|
||||
@override |
||||
Widget build(BuildContext context) { |
||||
assert(debugCheckHasMediaQuery(context)); |
||||
_focusAttachment!.reparent(); |
||||
super.build(context); |
||||
|
||||
var _doc = widget.controller.document; |
||||
if (_doc.isEmpty() && |
||||
!widget.focusNode.hasFocus && |
||||
widget.placeholder != null) { |
||||
_doc = Document.fromJson(jsonDecode( |
||||
'[{"attributes":{"placeholder":true},"insert":"${widget.placeholder}\\n"}]')); |
||||
} |
||||
|
||||
Widget child = CompositedTransformTarget( |
||||
link: _toolbarLayerLink, |
||||
child: Semantics( |
||||
child: _Editor( |
||||
key: _editorKey, |
||||
document: _doc, |
||||
selection: widget.controller.selection, |
||||
hasFocus: _hasFocus, |
||||
textDirection: _textDirection, |
||||
startHandleLayerLink: _startHandleLayerLink, |
||||
endHandleLayerLink: _endHandleLayerLink, |
||||
onSelectionChanged: _handleSelectionChanged, |
||||
scrollBottomInset: widget.scrollBottomInset, |
||||
padding: widget.padding, |
||||
children: _buildChildren(_doc, context), |
||||
), |
||||
), |
||||
); |
||||
|
||||
if (widget.scrollable) { |
||||
final baselinePadding = |
||||
EdgeInsets.only(top: _styles!.paragraph!.verticalSpacing.item1); |
||||
child = BaselineProxy( |
||||
textStyle: _styles!.paragraph!.style, |
||||
padding: baselinePadding, |
||||
child: SingleChildScrollView( |
||||
controller: _scrollController, |
||||
physics: widget.scrollPhysics, |
||||
child: child, |
||||
), |
||||
); |
||||
} |
||||
|
||||
final constraints = widget.expands |
||||
? const BoxConstraints.expand() |
||||
: BoxConstraints( |
||||
minHeight: widget.minHeight ?? 0.0, |
||||
maxHeight: widget.maxHeight ?? double.infinity); |
||||
|
||||
return QuillStyles( |
||||
data: _styles!, |
||||
child: MouseRegion( |
||||
cursor: SystemMouseCursors.text, |
||||
child: Container( |
||||
constraints: constraints, |
||||
child: child, |
||||
), |
||||
), |
||||
); |
||||
} |
||||
|
||||
void _handleSelectionChanged( |
||||
TextSelection selection, SelectionChangedCause cause) { |
||||
widget.controller.updateSelection(selection, ChangeSource.LOCAL); |
||||
|
||||
_selectionOverlay?.handlesVisible = _shouldShowSelectionHandles(); |
||||
|
||||
if (!_keyboardVisible) { |
||||
requestKeyboard(); |
||||
} |
||||
} |
||||
|
||||
/// Updates the checkbox positioned at [offset] in document |
||||
/// by changing its attribute according to [value]. |
||||
void _handleCheckboxTap(int offset, bool value) { |
||||
if (!widget.readOnly) { |
||||
if (value) { |
||||
widget.controller.formatText(offset, 0, Attribute.checked); |
||||
} else { |
||||
widget.controller.formatText(offset, 0, Attribute.unchecked); |
||||
} |
||||
} |
||||
} |
||||
|
||||
List<Widget> _buildChildren(Document doc, BuildContext context) { |
||||
final result = <Widget>[]; |
||||
final indentLevelCounts = <int, int>{}; |
||||
for (final node in doc.root.children) { |
||||
if (node is Line) { |
||||
final editableTextLine = _getEditableTextLineFromNode(node, context); |
||||
result.add(editableTextLine); |
||||
} else if (node is Block) { |
||||
final attrs = node.style.attributes; |
||||
final editableTextBlock = EditableTextBlock( |
||||
node, |
||||
_textDirection, |
||||
widget.scrollBottomInset, |
||||
_getVerticalSpacingForBlock(node, _styles), |
||||
widget.controller.selection, |
||||
widget.selectionColor, |
||||
_styles, |
||||
widget.enableInteractiveSelection, |
||||
_hasFocus, |
||||
attrs.containsKey(Attribute.codeBlock.key) |
||||
? const EdgeInsets.all(16) |
||||
: null, |
||||
widget.embedBuilder, |
||||
_cursorCont, |
||||
indentLevelCounts, |
||||
_handleCheckboxTap, |
||||
); |
||||
result.add(editableTextBlock); |
||||
} else { |
||||
throw StateError('Unreachable.'); |
||||
} |
||||
} |
||||
return result; |
||||
} |
||||
|
||||
EditableTextLine _getEditableTextLineFromNode( |
||||
Line node, BuildContext context) { |
||||
final textLine = TextLine( |
||||
line: node, |
||||
textDirection: _textDirection, |
||||
embedBuilder: widget.embedBuilder, |
||||
styles: _styles!, |
||||
); |
||||
final editableTextLine = EditableTextLine( |
||||
node, |
||||
null, |
||||
textLine, |
||||
0, |
||||
_getVerticalSpacingForLine(node, _styles), |
||||
_textDirection, |
||||
widget.controller.selection, |
||||
widget.selectionColor, |
||||
widget.enableInteractiveSelection, |
||||
_hasFocus, |
||||
MediaQuery.of(context).devicePixelRatio, |
||||
_cursorCont); |
||||
return editableTextLine; |
||||
} |
||||
|
||||
Tuple2<double, double> _getVerticalSpacingForLine( |
||||
Line line, DefaultStyles? defaultStyles) { |
||||
final attrs = line.style.attributes; |
||||
if (attrs.containsKey(Attribute.header.key)) { |
||||
final int? level = attrs[Attribute.header.key]!.value; |
||||
switch (level) { |
||||
case 1: |
||||
return defaultStyles!.h1!.verticalSpacing; |
||||
case 2: |
||||
return defaultStyles!.h2!.verticalSpacing; |
||||
case 3: |
||||
return defaultStyles!.h3!.verticalSpacing; |
||||
default: |
||||
throw 'Invalid level $level'; |
||||
} |
||||
} |
||||
|
||||
return defaultStyles!.paragraph!.verticalSpacing; |
||||
} |
||||
|
||||
Tuple2<double, double> _getVerticalSpacingForBlock( |
||||
Block node, DefaultStyles? defaultStyles) { |
||||
final attrs = node.style.attributes; |
||||
if (attrs.containsKey(Attribute.blockQuote.key)) { |
||||
return defaultStyles!.quote!.verticalSpacing; |
||||
} else if (attrs.containsKey(Attribute.codeBlock.key)) { |
||||
return defaultStyles!.code!.verticalSpacing; |
||||
} else if (attrs.containsKey(Attribute.indent.key)) { |
||||
return defaultStyles!.indent!.verticalSpacing; |
||||
} |
||||
return defaultStyles!.lists!.verticalSpacing; |
||||
} |
||||
|
||||
@override |
||||
void initState() { |
||||
super.initState(); |
||||
|
||||
_clipboardStatus.addListener(_onChangedClipboardStatus); |
||||
|
||||
widget.controller.addListener(() { |
||||
_didChangeTextEditingValue(widget.controller.ignoreFocusOnTextChange); |
||||
}); |
||||
|
||||
_scrollController = widget.scrollController; |
||||
_scrollController!.addListener(_updateSelectionOverlayForScroll); |
||||
|
||||
_cursorCont = CursorCont( |
||||
show: ValueNotifier<bool>(widget.showCursor), |
||||
style: widget.cursorStyle, |
||||
tickerProvider: this, |
||||
); |
||||
|
||||
_keyboardListener = KeyboardListener( |
||||
handleCursorMovement, |
||||
handleShortcut, |
||||
handleDelete, |
||||
); |
||||
|
||||
if (defaultTargetPlatform == TargetPlatform.windows || |
||||
defaultTargetPlatform == TargetPlatform.macOS || |
||||
defaultTargetPlatform == TargetPlatform.linux || |
||||
defaultTargetPlatform == TargetPlatform.fuchsia) { |
||||
_keyboardVisible = true; |
||||
} else { |
||||
_keyboardVisibilityController = KeyboardVisibilityController(); |
||||
_keyboardVisible = _keyboardVisibilityController!.isVisible; |
||||
_keyboardVisibilitySubscription = |
||||
_keyboardVisibilityController?.onChange.listen((visible) { |
||||
_keyboardVisible = visible; |
||||
if (visible) { |
||||
_onChangeTextEditingValue(); |
||||
} |
||||
}); |
||||
} |
||||
|
||||
_focusAttachment = widget.focusNode.attach(context, |
||||
onKey: (node, event) => _keyboardListener.handleRawKeyEvent(event)); |
||||
widget.focusNode.addListener(_handleFocusChanged); |
||||
} |
||||
|
||||
@override |
||||
void didChangeDependencies() { |
||||
super.didChangeDependencies(); |
||||
final parentStyles = QuillStyles.getStyles(context, true); |
||||
final defaultStyles = DefaultStyles.getInstance(context); |
||||
_styles = (parentStyles != null) |
||||
? defaultStyles.merge(parentStyles) |
||||
: defaultStyles; |
||||
|
||||
if (widget.customStyles != null) { |
||||
_styles = _styles!.merge(widget.customStyles!); |
||||
} |
||||
|
||||
if (!_didAutoFocus && widget.autoFocus) { |
||||
FocusScope.of(context).autofocus(widget.focusNode); |
||||
_didAutoFocus = true; |
||||
} |
||||
} |
||||
|
||||
@override |
||||
void didUpdateWidget(RawEditor oldWidget) { |
||||
super.didUpdateWidget(oldWidget); |
||||
|
||||
_cursorCont.show.value = widget.showCursor; |
||||
_cursorCont.style = widget.cursorStyle; |
||||
|
||||
if (widget.controller != oldWidget.controller) { |
||||
oldWidget.controller.removeListener(_didChangeTextEditingValue); |
||||
widget.controller.addListener(_didChangeTextEditingValue); |
||||
updateRemoteValueIfNeeded(); |
||||
} |
||||
|
||||
if (widget.scrollController != _scrollController) { |
||||
_scrollController!.removeListener(_updateSelectionOverlayForScroll); |
||||
_scrollController = widget.scrollController; |
||||
_scrollController!.addListener(_updateSelectionOverlayForScroll); |
||||
} |
||||
|
||||
if (widget.focusNode != oldWidget.focusNode) { |
||||
oldWidget.focusNode.removeListener(_handleFocusChanged); |
||||
_focusAttachment?.detach(); |
||||
_focusAttachment = widget.focusNode.attach(context, |
||||
onKey: (node, event) => _keyboardListener.handleRawKeyEvent(event)); |
||||
widget.focusNode.addListener(_handleFocusChanged); |
||||
updateKeepAlive(); |
||||
} |
||||
|
||||
if (widget.controller.selection != oldWidget.controller.selection) { |
||||
_selectionOverlay?.update(textEditingValue); |
||||
} |
||||
|
||||
_selectionOverlay?.handlesVisible = _shouldShowSelectionHandles(); |
||||
if (!shouldCreateInputConnection) { |
||||
closeConnectionIfNeeded(); |
||||
} else { |
||||
if (oldWidget.readOnly && _hasFocus) { |
||||
openConnectionIfNeeded(); |
||||
} |
||||
} |
||||
} |
||||
|
||||
bool _shouldShowSelectionHandles() { |
||||
return widget.showSelectionHandles && |
||||
!widget.controller.selection.isCollapsed; |
||||
} |
||||
|
||||
@override |
||||
void dispose() { |
||||
closeConnectionIfNeeded(); |
||||
_keyboardVisibilitySubscription?.cancel(); |
||||
assert(!hasConnection); |
||||
_selectionOverlay?.dispose(); |
||||
_selectionOverlay = null; |
||||
widget.controller.removeListener(_didChangeTextEditingValue); |
||||
widget.focusNode.removeListener(_handleFocusChanged); |
||||
_focusAttachment!.detach(); |
||||
_cursorCont.dispose(); |
||||
_clipboardStatus |
||||
..removeListener(_onChangedClipboardStatus) |
||||
..dispose(); |
||||
super.dispose(); |
||||
} |
||||
|
||||
void _updateSelectionOverlayForScroll() { |
||||
_selectionOverlay?.markNeedsBuild(); |
||||
} |
||||
|
||||
void _didChangeTextEditingValue([bool ignoreFocus = false]) { |
||||
if (kIsWeb) { |
||||
_onChangeTextEditingValue(ignoreFocus); |
||||
if (!ignoreFocus) { |
||||
requestKeyboard(); |
||||
} |
||||
return; |
||||
} |
||||
|
||||
if (ignoreFocus || _keyboardVisible) { |
||||
_onChangeTextEditingValue(ignoreFocus); |
||||
} else { |
||||
requestKeyboard(); |
||||
if (mounted) { |
||||
setState(() { |
||||
// Use widget.controller.value in build() |
||||
// Trigger build and updateChildren |
||||
}); |
||||
} |
||||
} |
||||
} |
||||
|
||||
void _onChangeTextEditingValue([bool ignoreCaret = false]) { |
||||
updateRemoteValueIfNeeded(); |
||||
if (ignoreCaret) { |
||||
return; |
||||
} |
||||
_showCaretOnScreen(); |
||||
_cursorCont.startOrStopCursorTimerIfNeeded( |
||||
_hasFocus, widget.controller.selection); |
||||
if (hasConnection) { |
||||
_cursorCont |
||||
..stopCursorTimer(resetCharTicks: false) |
||||
..startCursorTimer(); |
||||
} |
||||
|
||||
SchedulerBinding.instance!.addPostFrameCallback((_) { |
||||
if (!mounted) { |
||||
return; |
||||
} |
||||
_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 renderEditor = getRenderEditor(); |
||||
if (renderEditor == null) { |
||||
return; |
||||
} |
||||
|
||||
final viewport = RenderAbstractViewport.of(renderEditor); |
||||
final editorOffset = |
||||
renderEditor.localToGlobal(const Offset(0, 0), ancestor: viewport); |
||||
final offsetInViewport = _scrollController!.offset + editorOffset.dy; |
||||
|
||||
final offset = renderEditor.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; |
||||
} |
||||
|
||||
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,48 @@ |
||||
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 |
||||
void userUpdateTextEditingValue( |
||||
TextEditingValue value, |
||||
SelectionChangedCause cause, |
||||
) { |
||||
setTextEditingValue(value); |
||||
} |
||||
|
||||
@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,204 @@ |
||||
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( |
||||
// Set composing to (-1, -1), otherwise an exception will be thrown if |
||||
// the values are different. |
||||
actualValue.copyWith(composing: const TextRange(start: -1, end: -1)), |
||||
); |
||||
if (shouldRemember) { |
||||
// Only keep track if text changed (selection changes are not relevant) |
||||
_sentRemoteValues.add(actualValue); |
||||
} |
||||
} |
||||
|
||||
@override |
||||
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,346 @@ |
||||
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,738 @@ |
||||
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,898 @@ |
||||
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)); |
||||
|
||||
// In rare circumstances, the line could contain an Embed & a Text of |
||||
// newline, which is unexpected and probably we should find out the |
||||
// root cause |
||||
final childCount = line.childCount; |
||||
if (line.hasEmbed || (childCount > 1 && line.children.first is Embed)) { |
||||
final embed = line.children.first as Embed; |
||||
return EmbedProxy(embedBuilder(context, embed)); |
||||
} |
||||
|
||||
final textSpan = _buildTextSpan(context); |
||||
final strutStyle = StrutStyle.fromTextStyle(textSpan.style!); |
||||
final textAlign = _getTextAlign(); |
||||
final child = RichText( |
||||
text: textSpan, |
||||
textAlign: textAlign, |
||||
textDirection: textDirection, |
||||
strutStyle: strutStyle, |
||||
textScaleFactor: MediaQuery.textScaleFactorOf(context), |
||||
); |
||||
return RichTextProxy( |
||||
child, |
||||
textSpan.style!, |
||||
textAlign, |
||||
textDirection!, |
||||
1, |
||||
Localizations.localeOf(context), |
||||
strutStyle, |
||||
TextWidthBasis.parent, |
||||
null); |
||||
} |
||||
|
||||
TextAlign _getTextAlign() { |
||||
final alignment = line.style.attributes[Attribute.align.key]; |
||||
if (alignment == Attribute.leftAlignment) { |
||||
return TextAlign.left; |
||||
} else if (alignment == Attribute.centerAlignment) { |
||||
return TextAlign.center; |
||||
} else if (alignment == Attribute.rightAlignment) { |
||||
return TextAlign.right; |
||||
} else if (alignment == Attribute.justifyAlignment) { |
||||
return TextAlign.justify; |
||||
} |
||||
return TextAlign.start; |
||||
} |
||||
|
||||
TextSpan _buildTextSpan(BuildContext context) { |
||||
final defaultStyles = styles; |
||||
final children = line.children |
||||
.map((node) => _getTextSpanFromNode(defaultStyles, node)) |
||||
.toList(growable: false); |
||||
|
||||
var textStyle = const TextStyle(); |
||||
|
||||
if (line.style.containsKey(Attribute.placeholder.key)) { |
||||
textStyle = defaultStyles.placeHolder!.style; |
||||
return TextSpan(children: children, style: textStyle); |
||||
} |
||||
|
||||
final header = line.style.attributes[Attribute.header.key]; |
||||
final m = <Attribute, TextStyle>{ |
||||
Attribute.h1: defaultStyles.h1!.style, |
||||
Attribute.h2: defaultStyles.h2!.style, |
||||
Attribute.h3: defaultStyles.h3!.style, |
||||
}; |
||||
|
||||
textStyle = textStyle.merge(m[header] ?? defaultStyles.paragraph!.style); |
||||
|
||||
final block = line.style.getBlockExceptHeader(); |
||||
TextStyle? toMerge; |
||||
if (block == Attribute.blockQuote) { |
||||
toMerge = defaultStyles.quote!.style; |
||||
} else if (block == Attribute.codeBlock) { |
||||
toMerge = defaultStyles.code!.style; |
||||
} else if (block != null) { |
||||
toMerge = defaultStyles.lists!.style; |
||||
} |
||||
|
||||
textStyle = textStyle.merge(toMerge); |
||||
|
||||
return TextSpan(children: children, style: textStyle); |
||||
} |
||||
|
||||
TextSpan _getTextSpanFromNode(DefaultStyles defaultStyles, Node node) { |
||||
final textNode = node as leaf.Text; |
||||
final style = textNode.style; |
||||
var res = const TextStyle(); |
||||
final color = textNode.style.attributes[Attribute.color.key]; |
||||
|
||||
<String, TextStyle?>{ |
||||
Attribute.bold.key: defaultStyles.bold, |
||||
Attribute.italic.key: defaultStyles.italic, |
||||
Attribute.link.key: defaultStyles.link, |
||||
Attribute.underline.key: defaultStyles.underline, |
||||
Attribute.strikeThrough.key: defaultStyles.strikeThrough, |
||||
}.forEach((k, s) { |
||||
if (style.values.any((v) => v.key == k)) { |
||||
if (k == Attribute.underline.key || k == Attribute.strikeThrough.key) { |
||||
var textColor = defaultStyles.color; |
||||
if (color?.value is String) { |
||||
textColor = stringToColor(color?.value); |
||||
} |
||||
res = _merge(res.copyWith(decorationColor: textColor), |
||||
s!.copyWith(decorationColor: textColor)); |
||||
} else { |
||||
res = _merge(res, s!); |
||||
} |
||||
} |
||||
}); |
||||
|
||||
final font = textNode.style.attributes[Attribute.font.key]; |
||||
if (font != null && font.value != null) { |
||||
res = res.merge(TextStyle(fontFamily: font.value)); |
||||
} |
||||
|
||||
final size = textNode.style.attributes[Attribute.size.key]; |
||||
if (size != null && size.value != null) { |
||||
switch (size.value) { |
||||
case 'small': |
||||
res = res.merge(defaultStyles.sizeSmall); |
||||
break; |
||||
case 'large': |
||||
res = res.merge(defaultStyles.sizeLarge); |
||||
break; |
||||
case 'huge': |
||||
res = res.merge(defaultStyles.sizeHuge); |
||||
break; |
||||
default: |
||||
final fontSize = double.tryParse(size.value); |
||||
if (fontSize != null) { |
||||
res = res.merge(TextStyle(fontSize: fontSize)); |
||||
} else { |
||||
throw 'Invalid size ${size.value}'; |
||||
} |
||||
} |
||||
} |
||||
|
||||
if (color != null && color.value != null) { |
||||
var textColor = defaultStyles.color; |
||||
if (color.value is String) { |
||||
textColor = stringToColor(color.value); |
||||
} |
||||
if (textColor != null) { |
||||
res = res.merge(TextStyle(color: textColor)); |
||||
} |
||||
} |
||||
|
||||
final background = textNode.style.attributes[Attribute.background.key]; |
||||
if (background != null && background.value != null) { |
||||
final backgroundColor = stringToColor(background.value); |
||||
res = res.merge(TextStyle(backgroundColor: backgroundColor)); |
||||
} |
||||
|
||||
return TextSpan(text: textNode.value, style: res); |
||||
} |
||||
|
||||
TextStyle _merge(TextStyle a, TextStyle b) { |
||||
final decorations = <TextDecoration?>[]; |
||||
if (a.decoration != null) { |
||||
decorations.add(a.decoration); |
||||
} |
||||
if (b.decoration != null) { |
||||
decorations.add(b.decoration); |
||||
} |
||||
return a.merge(b).apply( |
||||
decoration: TextDecoration.combine( |
||||
List.castFrom<dynamic, TextDecoration>(decorations))); |
||||
} |
||||
} |
||||
|
||||
class EditableTextLine extends RenderObjectWidget { |
||||
const EditableTextLine( |
||||
this.line, |
||||
this.leading, |
||||
this.body, |
||||
this.indentWidth, |
||||
this.verticalSpacing, |
||||
this.textDirection, |
||||
this.textSelection, |
||||
this.color, |
||||
this.enableInteractiveSelection, |
||||
this.hasFocus, |
||||
this.devicePixelRatio, |
||||
this.cursorCont, |
||||
); |
||||
|
||||
final Line line; |
||||
final Widget? leading; |
||||
final Widget body; |
||||
final double indentWidth; |
||||
final Tuple2 verticalSpacing; |
||||
final TextDirection textDirection; |
||||
final TextSelection textSelection; |
||||
final Color color; |
||||
final bool enableInteractiveSelection; |
||||
final bool hasFocus; |
||||
final double devicePixelRatio; |
||||
final CursorCont cursorCont; |
||||
|
||||
@override |
||||
RenderObjectElement createElement() { |
||||
return _TextLineElement(this); |
||||
} |
||||
|
||||
@override |
||||
RenderObject createRenderObject(BuildContext context) { |
||||
return RenderEditableTextLine( |
||||
line, |
||||
textDirection, |
||||
textSelection, |
||||
enableInteractiveSelection, |
||||
hasFocus, |
||||
devicePixelRatio, |
||||
_getPadding(), |
||||
color, |
||||
cursorCont); |
||||
} |
||||
|
||||
@override |
||||
void updateRenderObject( |
||||
BuildContext context, covariant RenderEditableTextLine renderObject) { |
||||
renderObject |
||||
..setLine(line) |
||||
..setPadding(_getPadding()) |
||||
..setTextDirection(textDirection) |
||||
..setTextSelection(textSelection) |
||||
..setColor(color) |
||||
..setEnableInteractiveSelection(enableInteractiveSelection) |
||||
..hasFocus = hasFocus |
||||
..setDevicePixelRatio(devicePixelRatio) |
||||
..setCursorCont(cursorCont); |
||||
} |
||||
|
||||
EdgeInsetsGeometry _getPadding() { |
||||
return EdgeInsetsDirectional.only( |
||||
start: indentWidth, |
||||
top: verticalSpacing.item1, |
||||
bottom: verticalSpacing.item2); |
||||
} |
||||
} |
||||
|
||||
enum TextLineSlot { LEADING, BODY } |
||||
|
||||
class RenderEditableTextLine extends RenderEditableBox { |
||||
RenderEditableTextLine( |
||||
this.line, |
||||
this.textDirection, |
||||
this.textSelection, |
||||
this.enableInteractiveSelection, |
||||
this.hasFocus, |
||||
this.devicePixelRatio, |
||||
this.padding, |
||||
this.color, |
||||
this.cursorCont, |
||||
); |
||||
|
||||
RenderBox? _leading; |
||||
RenderContentProxyBox? _body; |
||||
Line line; |
||||
TextDirection textDirection; |
||||
TextSelection textSelection; |
||||
Color color; |
||||
bool enableInteractiveSelection; |
||||
bool hasFocus = false; |
||||
double devicePixelRatio; |
||||
EdgeInsetsGeometry padding; |
||||
CursorCont cursorCont; |
||||
EdgeInsets? _resolvedPadding; |
||||
bool? _containsCursor; |
||||
List<TextBox>? _selectedRects; |
||||
Rect? _caretPrototype; |
||||
final Map<TextLineSlot, RenderBox> children = <TextLineSlot, RenderBox>{}; |
||||
|
||||
Iterable<RenderBox> get _children sync* { |
||||
if (_leading != null) { |
||||
yield _leading!; |
||||
} |
||||
if (_body != null) { |
||||
yield _body!; |
||||
} |
||||
} |
||||
|
||||
void setCursorCont(CursorCont c) { |
||||
if (cursorCont == c) { |
||||
return; |
||||
} |
||||
cursorCont = c; |
||||
markNeedsLayout(); |
||||
} |
||||
|
||||
void setDevicePixelRatio(double d) { |
||||
if (devicePixelRatio == d) { |
||||
return; |
||||
} |
||||
devicePixelRatio = d; |
||||
markNeedsLayout(); |
||||
} |
||||
|
||||
void setEnableInteractiveSelection(bool val) { |
||||
if (enableInteractiveSelection == val) { |
||||
return; |
||||
} |
||||
|
||||
markNeedsLayout(); |
||||
markNeedsSemanticsUpdate(); |
||||
} |
||||
|
||||
void setColor(Color c) { |
||||
if (color == c) { |
||||
return; |
||||
} |
||||
|
||||
color = c; |
||||
if (containsTextSelection()) { |
||||
markNeedsPaint(); |
||||
} |
||||
} |
||||
|
||||
void setTextSelection(TextSelection t) { |
||||
if (textSelection == t) { |
||||
return; |
||||
} |
||||
|
||||
final containsSelection = containsTextSelection(); |
||||
if (attached && containsCursor()) { |
||||
cursorCont.removeListener(markNeedsLayout); |
||||
cursorCont.color.removeListener(markNeedsPaint); |
||||
} |
||||
|
||||
textSelection = t; |
||||
_selectedRects = null; |
||||
_containsCursor = null; |
||||
if (attached && containsCursor()) { |
||||
cursorCont.addListener(markNeedsLayout); |
||||
cursorCont.color.addListener(markNeedsPaint); |
||||
} |
||||
|
||||
if (containsSelection || containsTextSelection()) { |
||||
markNeedsPaint(); |
||||
} |
||||
} |
||||
|
||||
void setTextDirection(TextDirection t) { |
||||
if (textDirection == t) { |
||||
return; |
||||
} |
||||
textDirection = t; |
||||
_resolvedPadding = null; |
||||
markNeedsLayout(); |
||||
} |
||||
|
||||
void setLine(Line l) { |
||||
if (line == l) { |
||||
return; |
||||
} |
||||
line = l; |
||||
_containsCursor = null; |
||||
markNeedsLayout(); |
||||
} |
||||
|
||||
void setPadding(EdgeInsetsGeometry p) { |
||||
assert(p.isNonNegative); |
||||
if (padding == p) { |
||||
return; |
||||
} |
||||
padding = p; |
||||
_resolvedPadding = null; |
||||
markNeedsLayout(); |
||||
} |
||||
|
||||
void setLeading(RenderBox? l) { |
||||
_leading = _updateChild(_leading, l, TextLineSlot.LEADING); |
||||
} |
||||
|
||||
void setBody(RenderContentProxyBox? b) { |
||||
_body = _updateChild(_body, b, TextLineSlot.BODY) as RenderContentProxyBox?; |
||||
} |
||||
|
||||
bool containsTextSelection() { |
||||
return line.documentOffset <= textSelection.end && |
||||
textSelection.start <= line.documentOffset + line.length - 1; |
||||
} |
||||
|
||||
bool containsCursor() { |
||||
return _containsCursor ??= textSelection.isCollapsed && |
||||
line.containsOffset(textSelection.baseOffset); |
||||
} |
||||
|
||||
RenderBox? _updateChild( |
||||
RenderBox? old, RenderBox? newChild, TextLineSlot slot) { |
||||
if (old != null) { |
||||
dropChild(old); |
||||
children.remove(slot); |
||||
} |
||||
if (newChild != null) { |
||||
children[slot] = newChild; |
||||
adoptChild(newChild); |
||||
} |
||||
return newChild; |
||||
} |
||||
|
||||
List<TextBox> _getBoxes(TextSelection textSelection) { |
||||
final parentData = _body!.parentData as BoxParentData?; |
||||
return _body!.getBoxesForSelection(textSelection).map((box) { |
||||
return TextBox.fromLTRBD( |
||||
box.left + parentData!.offset.dx, |
||||
box.top + parentData.offset.dy, |
||||
box.right + parentData.offset.dx, |
||||
box.bottom + parentData.offset.dy, |
||||
box.direction, |
||||
); |
||||
}).toList(growable: false); |
||||
} |
||||
|
||||
void _resolvePadding() { |
||||
if (_resolvedPadding != null) { |
||||
return; |
||||
} |
||||
_resolvedPadding = padding.resolve(textDirection); |
||||
assert(_resolvedPadding!.isNonNegative); |
||||
} |
||||
|
||||
@override |
||||
TextSelectionPoint getBaseEndpointForSelection(TextSelection textSelection) { |
||||
return _getEndpointForSelection(textSelection, true); |
||||
} |
||||
|
||||
@override |
||||
TextSelectionPoint getExtentEndpointForSelection( |
||||
TextSelection textSelection) { |
||||
return _getEndpointForSelection(textSelection, false); |
||||
} |
||||
|
||||
TextSelectionPoint _getEndpointForSelection( |
||||
TextSelection textSelection, bool first) { |
||||
if (textSelection.isCollapsed) { |
||||
return TextSelectionPoint( |
||||
Offset(0, preferredLineHeight(textSelection.extent)) + |
||||
getOffsetForCaret(textSelection.extent), |
||||
null); |
||||
} |
||||
final boxes = _getBoxes(textSelection); |
||||
assert(boxes.isNotEmpty); |
||||
final targetBox = first ? boxes.first : boxes.last; |
||||
return TextSelectionPoint( |
||||
Offset(first ? targetBox.start : targetBox.end, targetBox.bottom), |
||||
targetBox.direction); |
||||
} |
||||
|
||||
@override |
||||
TextRange getLineBoundary(TextPosition position) { |
||||
final lineDy = getOffsetForCaret(position) |
||||
.translate(0, 0.5 * preferredLineHeight(position)) |
||||
.dy; |
||||
final lineBoxes = |
||||
_getBoxes(TextSelection(baseOffset: 0, extentOffset: line.length - 1)) |
||||
.where((element) => element.top < lineDy && element.bottom > lineDy) |
||||
.toList(growable: false); |
||||
return TextRange( |
||||
start: |
||||
getPositionForOffset(Offset(lineBoxes.first.left, lineDy)).offset, |
||||
end: getPositionForOffset(Offset(lineBoxes.last.right, lineDy)).offset); |
||||
} |
||||
|
||||
@override |
||||
Offset getOffsetForCaret(TextPosition position) { |
||||
return _body!.getOffsetForCaret(position, _caretPrototype) + |
||||
(_body!.parentData as BoxParentData).offset; |
||||
} |
||||
|
||||
@override |
||||
TextPosition? getPositionAbove(TextPosition position) { |
||||
return _getPosition(position, -0.5); |
||||
} |
||||
|
||||
@override |
||||
TextPosition? getPositionBelow(TextPosition position) { |
||||
return _getPosition(position, 1.5); |
||||
} |
||||
|
||||
TextPosition? _getPosition(TextPosition textPosition, double dyScale) { |
||||
assert(textPosition.offset < line.length); |
||||
final offset = getOffsetForCaret(textPosition) |
||||
.translate(0, dyScale * preferredLineHeight(textPosition)); |
||||
if (_body!.size |
||||
.contains(offset - (_body!.parentData as BoxParentData).offset)) { |
||||
return getPositionForOffset(offset); |
||||
} |
||||
return null; |
||||
} |
||||
|
||||
@override |
||||
TextPosition getPositionForOffset(Offset offset) { |
||||
return _body!.getPositionForOffset( |
||||
offset - (_body!.parentData as BoxParentData).offset); |
||||
} |
||||
|
||||
@override |
||||
TextRange getWordBoundary(TextPosition position) { |
||||
return _body!.getWordBoundary(position); |
||||
} |
||||
|
||||
@override |
||||
double preferredLineHeight(TextPosition position) { |
||||
return _body!.getPreferredLineHeight(); |
||||
} |
||||
|
||||
@override |
||||
container.Container getContainer() { |
||||
return line; |
||||
} |
||||
|
||||
double get cursorWidth => cursorCont.style.width; |
||||
|
||||
double get cursorHeight => |
||||
cursorCont.style.height ?? |
||||
preferredLineHeight(const TextPosition(offset: 0)); |
||||
|
||||
void _computeCaretPrototype() { |
||||
switch (defaultTargetPlatform) { |
||||
case TargetPlatform.iOS: |
||||
case TargetPlatform.macOS: |
||||
_caretPrototype = Rect.fromLTWH(0, 0, cursorWidth, cursorHeight + 2); |
||||
break; |
||||
case TargetPlatform.android: |
||||
case TargetPlatform.fuchsia: |
||||
case TargetPlatform.linux: |
||||
case TargetPlatform.windows: |
||||
_caretPrototype = Rect.fromLTWH(0, 2, cursorWidth, cursorHeight - 4.0); |
||||
break; |
||||
default: |
||||
throw 'Invalid platform'; |
||||
} |
||||
} |
||||
|
||||
@override |
||||
void attach(covariant PipelineOwner owner) { |
||||
super.attach(owner); |
||||
for (final child in _children) { |
||||
child.attach(owner); |
||||
} |
||||
if (containsCursor()) { |
||||
cursorCont.addListener(markNeedsLayout); |
||||
cursorCont.color.addListener(markNeedsPaint); |
||||
} |
||||
} |
||||
|
||||
@override |
||||
void detach() { |
||||
super.detach(); |
||||
for (final child in _children) { |
||||
child.detach(); |
||||
} |
||||
if (containsCursor()) { |
||||
cursorCont.removeListener(markNeedsLayout); |
||||
cursorCont.color.removeListener(markNeedsPaint); |
||||
} |
||||
} |
||||
|
||||
@override |
||||
void redepthChildren() { |
||||
_children.forEach(redepthChild); |
||||
} |
||||
|
||||
@override |
||||
void visitChildren(RenderObjectVisitor visitor) { |
||||
_children.forEach(visitor); |
||||
} |
||||
|
||||
@override |
||||
List<DiagnosticsNode> debugDescribeChildren() { |
||||
final value = <DiagnosticsNode>[]; |
||||
void add(RenderBox? child, String name) { |
||||
if (child != null) { |
||||
value.add(child.toDiagnosticsNode(name: name)); |
||||
} |
||||
} |
||||
|
||||
add(_leading, 'leading'); |
||||
add(_body, 'body'); |
||||
return value; |
||||
} |
||||
|
||||
@override |
||||
bool get sizedByParent => false; |
||||
|
||||
@override |
||||
double computeMinIntrinsicWidth(double height) { |
||||
_resolvePadding(); |
||||
final horizontalPadding = _resolvedPadding!.left + _resolvedPadding!.right; |
||||
final verticalPadding = _resolvedPadding!.top + _resolvedPadding!.bottom; |
||||
final leadingWidth = _leading == null |
||||
? 0 |
||||
: _leading!.getMinIntrinsicWidth(height - verticalPadding).ceil(); |
||||
final bodyWidth = _body == null |
||||
? 0 |
||||
: _body! |
||||
.getMinIntrinsicWidth(math.max(0, height - verticalPadding)) |
||||
.ceil(); |
||||
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).ceil(); |
||||
final bodyWidth = _body == null |
||||
? 0 |
||||
: _body! |
||||
.getMaxIntrinsicWidth(math.max(0, height - verticalPadding)) |
||||
.ceil(); |
||||
return horizontalPadding + leadingWidth + bodyWidth; |
||||
} |
||||
|
||||
@override |
||||
double computeMinIntrinsicHeight(double width) { |
||||
_resolvePadding(); |
||||
final horizontalPadding = _resolvedPadding!.left + _resolvedPadding!.right; |
||||
final verticalPadding = _resolvedPadding!.top + _resolvedPadding!.bottom; |
||||
if (_body != null) { |
||||
return _body! |
||||
.getMinIntrinsicHeight(math.max(0, width - horizontalPadding)) + |
||||
verticalPadding; |
||||
} |
||||
return verticalPadding; |
||||
} |
||||
|
||||
@override |
||||
double computeMaxIntrinsicHeight(double width) { |
||||
_resolvePadding(); |
||||
final horizontalPadding = _resolvedPadding!.left + _resolvedPadding!.right; |
||||
final verticalPadding = _resolvedPadding!.top + _resolvedPadding!.bottom; |
||||
if (_body != null) { |
||||
return _body! |
||||
.getMaxIntrinsicHeight(math.max(0, width - horizontalPadding)) + |
||||
verticalPadding; |
||||
} |
||||
return verticalPadding; |
||||
} |
||||
|
||||
@override |
||||
double computeDistanceToActualBaseline(TextBaseline baseline) { |
||||
_resolvePadding(); |
||||
return _body!.getDistanceToActualBaseline(baseline)! + |
||||
_resolvedPadding!.top; |
||||
} |
||||
|
||||
@override |
||||
void performLayout() { |
||||
final constraints = this.constraints; |
||||
_selectedRects = null; |
||||
|
||||
_resolvePadding(); |
||||
assert(_resolvedPadding != null); |
||||
|
||||
if (_body == null && _leading == null) { |
||||
size = constraints.constrain(Size( |
||||
_resolvedPadding!.left + _resolvedPadding!.right, |
||||
_resolvedPadding!.top + _resolvedPadding!.bottom, |
||||
)); |
||||
return; |
||||
} |
||||
final innerConstraints = constraints.deflate(_resolvedPadding!); |
||||
|
||||
final indentWidth = textDirection == TextDirection.ltr |
||||
? _resolvedPadding!.left |
||||
: _resolvedPadding!.right; |
||||
|
||||
_body!.layout(innerConstraints, parentUsesSize: true); |
||||
(_body!.parentData as BoxParentData).offset = |
||||
Offset(_resolvedPadding!.left, _resolvedPadding!.top); |
||||
|
||||
if (_leading != null) { |
||||
final leadingConstraints = innerConstraints.copyWith( |
||||
minWidth: indentWidth, |
||||
maxWidth: indentWidth, |
||||
maxHeight: _body!.size.height); |
||||
_leading!.layout(leadingConstraints, parentUsesSize: true); |
||||
(_leading!.parentData as BoxParentData).offset = |
||||
Offset(0, _resolvedPadding!.top); |
||||
} |
||||
|
||||
size = constraints.constrain(Size( |
||||
_resolvedPadding!.left + _body!.size.width + _resolvedPadding!.right, |
||||
_resolvedPadding!.top + _body!.size.height + _resolvedPadding!.bottom, |
||||
)); |
||||
|
||||
_computeCaretPrototype(); |
||||
} |
||||
|
||||
CursorPainter get _cursorPainter => CursorPainter( |
||||
_body, |
||||
cursorCont.style, |
||||
_caretPrototype!, |
||||
cursorCont.color.value, |
||||
devicePixelRatio, |
||||
); |
||||
|
||||
@override |
||||
void paint(PaintingContext context, Offset offset) { |
||||
if (_leading != null) { |
||||
final parentData = _leading!.parentData as BoxParentData; |
||||
final effectiveOffset = offset + parentData.offset; |
||||
context.paintChild(_leading!, effectiveOffset); |
||||
} |
||||
|
||||
if (_body != null) { |
||||
final parentData = _body!.parentData as BoxParentData; |
||||
final effectiveOffset = offset + parentData.offset; |
||||
if (enableInteractiveSelection && |
||||
line.documentOffset <= textSelection.end && |
||||
textSelection.start <= line.documentOffset + line.length - 1) { |
||||
final local = localSelection(line, textSelection, false); |
||||
_selectedRects ??= _body!.getBoxesForSelection( |
||||
local, |
||||
); |
||||
_paintSelection(context, effectiveOffset); |
||||
} |
||||
|
||||
if (hasFocus && |
||||
cursorCont.show.value && |
||||
containsCursor() && |
||||
!cursorCont.style.paintAboveText) { |
||||
_paintCursor(context, effectiveOffset); |
||||
} |
||||
|
||||
context.paintChild(_body!, effectiveOffset); |
||||
|
||||
if (hasFocus && |
||||
cursorCont.show.value && |
||||
containsCursor() && |
||||
cursorCont.style.paintAboveText) { |
||||
_paintCursor(context, effectiveOffset); |
||||
} |
||||
} |
||||
} |
||||
|
||||
void _paintSelection(PaintingContext context, Offset effectiveOffset) { |
||||
assert(_selectedRects != null); |
||||
final paint = Paint()..color = color; |
||||
for (final box in _selectedRects!) { |
||||
context.canvas.drawRect(box.toRect().shift(effectiveOffset), paint); |
||||
} |
||||
} |
||||
|
||||
void _paintCursor(PaintingContext context, Offset effectiveOffset) { |
||||
final position = TextPosition( |
||||
offset: textSelection.extentOffset - line.documentOffset, |
||||
affinity: textSelection.base.affinity, |
||||
); |
||||
_cursorPainter.paint(context.canvas, effectiveOffset, position); |
||||
} |
||||
|
||||
@override |
||||
bool hitTestChildren(BoxHitTestResult result, {required Offset position}) { |
||||
return _children.first.hitTest(result, position: position); |
||||
} |
||||
} |
||||
|
||||
class _TextLineElement extends RenderObjectElement { |
||||
_TextLineElement(EditableTextLine line) : super(line); |
||||
|
||||
final Map<TextLineSlot, Element> _slotToChildren = <TextLineSlot, Element>{}; |
||||
|
||||
@override |
||||
EditableTextLine get widget => super.widget as EditableTextLine; |
||||
|
||||
@override |
||||
RenderEditableTextLine get renderObject => |
||||
super.renderObject as RenderEditableTextLine; |
||||
|
||||
@override |
||||
void visitChildren(ElementVisitor visitor) { |
||||
_slotToChildren.values.forEach(visitor); |
||||
} |
||||
|
||||
@override |
||||
void forgetChild(Element child) { |
||||
assert(_slotToChildren.containsValue(child)); |
||||
assert(child.slot is TextLineSlot); |
||||
assert(_slotToChildren.containsKey(child.slot)); |
||||
_slotToChildren.remove(child.slot); |
||||
super.forgetChild(child); |
||||
} |
||||
|
||||
@override |
||||
void mount(Element? parent, dynamic newSlot) { |
||||
super.mount(parent, newSlot); |
||||
_mountChild(widget.leading, TextLineSlot.LEADING); |
||||
_mountChild(widget.body, TextLineSlot.BODY); |
||||
} |
||||
|
||||
@override |
||||
void update(EditableTextLine newWidget) { |
||||
super.update(newWidget); |
||||
assert(widget == newWidget); |
||||
_updateChild(widget.leading, TextLineSlot.LEADING); |
||||
_updateChild(widget.body, TextLineSlot.BODY); |
||||
} |
||||
|
||||
@override |
||||
void insertRenderObjectChild(RenderBox child, TextLineSlot? slot) { |
||||
// assert(child is RenderBox); |
||||
_updateRenderObject(child, slot); |
||||
assert(renderObject.children.keys.contains(slot)); |
||||
} |
||||
|
||||
@override |
||||
void removeRenderObjectChild(RenderObject child, TextLineSlot? slot) { |
||||
assert(child is RenderBox); |
||||
assert(renderObject.children[slot!] == child); |
||||
_updateRenderObject(null, slot); |
||||
assert(!renderObject.children.keys.contains(slot)); |
||||
} |
||||
|
||||
@override |
||||
void moveRenderObjectChild( |
||||
RenderObject child, dynamic oldSlot, dynamic newSlot) { |
||||
throw UnimplementedError(); |
||||
} |
||||
|
||||
void _mountChild(Widget? widget, TextLineSlot slot) { |
||||
final oldChild = _slotToChildren[slot]; |
||||
final newChild = updateChild(oldChild, widget, slot); |
||||
if (oldChild != null) { |
||||
_slotToChildren.remove(slot); |
||||
} |
||||
if (newChild != null) { |
||||
_slotToChildren[slot] = newChild; |
||||
} |
||||
} |
||||
|
||||
void _updateRenderObject(RenderBox? child, TextLineSlot? slot) { |
||||
switch (slot) { |
||||
case TextLineSlot.LEADING: |
||||
renderObject.setLeading(child); |
||||
break; |
||||
case TextLineSlot.BODY: |
||||
renderObject.setBody(child as RenderContentProxyBox?); |
||||
break; |
||||
default: |
||||
throw UnimplementedError(); |
||||
} |
||||
} |
||||
|
||||
void _updateChild(Widget? widget, TextLineSlot slot) { |
||||
final oldChild = _slotToChildren[slot]; |
||||
final newChild = updateChild(oldChild, widget, slot); |
||||
if (oldChild != null) { |
||||
_slotToChildren.remove(slot); |
||||
} |
||||
if (newChild != null) { |
||||
_slotToChildren[slot] = newChild; |
||||
} |
||||
} |
||||
} |
@ -0,0 +1,726 @@ |
||||
import 'dart:async'; |
||||
import 'dart:math' as math; |
||||
|
||||
import 'package:flutter/cupertino.dart'; |
||||
import 'package:flutter/foundation.dart'; |
||||
import 'package:flutter/gestures.dart'; |
||||
import 'package:flutter/material.dart'; |
||||
import 'package:flutter/rendering.dart'; |
||||
import 'package:flutter/scheduler.dart'; |
||||
|
||||
import '../models/documents/nodes/node.dart'; |
||||
import 'editor.dart'; |
||||
|
||||
TextSelection localSelection(Node node, TextSelection selection, fromParent) { |
||||
final base = fromParent ? node.offset : node.documentOffset; |
||||
assert(base <= selection.end && selection.start <= base + node.length - 1); |
||||
|
||||
final offset = fromParent ? node.offset : node.documentOffset; |
||||
return selection.copyWith( |
||||
baseOffset: math.max(selection.start - offset, 0), |
||||
extentOffset: math.min(selection.end - offset, node.length - 1)); |
||||
} |
||||
|
||||
enum _TextSelectionHandlePosition { START, END } |
||||
|
||||
class EditorTextSelectionOverlay { |
||||
EditorTextSelectionOverlay( |
||||
this.value, |
||||
this.handlesVisible, |
||||
this.context, |
||||
this.debugRequiredFor, |
||||
this.toolbarLayerLink, |
||||
this.startHandleLayerLink, |
||||
this.endHandleLayerLink, |
||||
this.renderObject, |
||||
this.selectionCtrls, |
||||
this.selectionDelegate, |
||||
this.dragStartBehavior, |
||||
this.onSelectionHandleTapped, |
||||
this.clipboardStatus, |
||||
) { |
||||
final overlay = Overlay.of(context, rootOverlay: true)!; |
||||
|
||||
_toolbarController = AnimationController( |
||||
duration: const Duration(milliseconds: 150), vsync: overlay); |
||||
} |
||||
|
||||
TextEditingValue value; |
||||
bool handlesVisible = false; |
||||
final BuildContext context; |
||||
final Widget debugRequiredFor; |
||||
final LayerLink toolbarLayerLink; |
||||
final LayerLink startHandleLayerLink; |
||||
final LayerLink endHandleLayerLink; |
||||
final RenderEditor? renderObject; |
||||
final TextSelectionControls selectionCtrls; |
||||
final TextSelectionDelegate selectionDelegate; |
||||
final DragStartBehavior dragStartBehavior; |
||||
final VoidCallback? onSelectionHandleTapped; |
||||
final ClipboardStatusNotifier clipboardStatus; |
||||
late AnimationController _toolbarController; |
||||
List<OverlayEntry>? _handles; |
||||
OverlayEntry? toolbar; |
||||
|
||||
TextSelection get _selection => value.selection; |
||||
|
||||
Animation<double> get _toolbarOpacity => _toolbarController.view; |
||||
|
||||
void setHandlesVisible(bool visible) { |
||||
if (handlesVisible == visible) { |
||||
return; |
||||
} |
||||
handlesVisible = visible; |
||||
if (SchedulerBinding.instance!.schedulerPhase == |
||||
SchedulerPhase.persistentCallbacks) { |
||||
SchedulerBinding.instance!.addPostFrameCallback(markNeedsBuild); |
||||
} else { |
||||
markNeedsBuild(); |
||||
} |
||||
} |
||||
|
||||
void hideHandles() { |
||||
if (_handles == null) { |
||||
return; |
||||
} |
||||
_handles![0].remove(); |
||||
_handles![1].remove(); |
||||
_handles = null; |
||||
} |
||||
|
||||
void hideToolbar() { |
||||
assert(toolbar != null); |
||||
_toolbarController.stop(); |
||||
toolbar!.remove(); |
||||
toolbar = null; |
||||
} |
||||
|
||||
void showToolbar() { |
||||
assert(toolbar == null); |
||||
toolbar = OverlayEntry(builder: _buildToolbar); |
||||
Overlay.of(context, rootOverlay: true, debugRequiredFor: debugRequiredFor)! |
||||
.insert(toolbar!); |
||||
_toolbarController.forward(from: 0); |
||||
} |
||||
|
||||
Widget _buildHandle( |
||||
BuildContext context, _TextSelectionHandlePosition position) { |
||||
if (_selection.isCollapsed && |
||||
position == _TextSelectionHandlePosition.END) { |
||||
return Container(); |
||||
} |
||||
return Visibility( |
||||
visible: handlesVisible, |
||||
child: _TextSelectionHandleOverlay( |
||||
onSelectionHandleChanged: (newSelection) { |
||||
_handleSelectionHandleChanged(newSelection, position); |
||||
}, |
||||
onSelectionHandleTapped: onSelectionHandleTapped, |
||||
startHandleLayerLink: startHandleLayerLink, |
||||
endHandleLayerLink: endHandleLayerLink, |
||||
renderObject: renderObject, |
||||
selection: _selection, |
||||
selectionControls: selectionCtrls, |
||||
position: position, |
||||
dragStartBehavior: dragStartBehavior, |
||||
)); |
||||
} |
||||
|
||||
void update(TextEditingValue newValue) { |
||||
if (value == newValue) { |
||||
return; |
||||
} |
||||
value = newValue; |
||||
if (SchedulerBinding.instance!.schedulerPhase == |
||||
SchedulerPhase.persistentCallbacks) { |
||||
SchedulerBinding.instance!.addPostFrameCallback(markNeedsBuild); |
||||
} else { |
||||
markNeedsBuild(); |
||||
} |
||||
} |
||||
|
||||
void _handleSelectionHandleChanged( |
||||
TextSelection? newSelection, _TextSelectionHandlePosition position) { |
||||
TextPosition textPosition; |
||||
switch (position) { |
||||
case _TextSelectionHandlePosition.START: |
||||
textPosition = newSelection != null |
||||
? newSelection.base |
||||
: const TextPosition(offset: 0); |
||||
break; |
||||
case _TextSelectionHandlePosition.END: |
||||
textPosition = newSelection != null |
||||
? newSelection.extent |
||||
: const TextPosition(offset: 0); |
||||
break; |
||||
default: |
||||
throw 'Invalid position'; |
||||
} |
||||
selectionDelegate |
||||
..textEditingValue = |
||||
value.copyWith(selection: newSelection, composing: TextRange.empty) |
||||
..bringIntoView(textPosition); |
||||
} |
||||
|
||||
Widget _buildToolbar(BuildContext context) { |
||||
final endpoints = renderObject!.getEndpointsForSelection(_selection); |
||||
|
||||
final editingRegion = Rect.fromPoints( |
||||
renderObject!.localToGlobal(Offset.zero), |
||||
renderObject!.localToGlobal(renderObject!.size.bottomRight(Offset.zero)), |
||||
); |
||||
|
||||
final baseLineHeight = renderObject!.preferredLineHeight(_selection.base); |
||||
final extentLineHeight = |
||||
renderObject!.preferredLineHeight(_selection.extent); |
||||
final smallestLineHeight = math.min(baseLineHeight, extentLineHeight); |
||||
final isMultiline = endpoints.last.point.dy - endpoints.first.point.dy > |
||||
smallestLineHeight / 2; |
||||
|
||||
final midX = isMultiline |
||||
? editingRegion.width / 2 |
||||
: (endpoints.first.point.dx + endpoints.last.point.dx) / 2; |
||||
|
||||
final midpoint = Offset( |
||||
midX, |
||||
endpoints[0].point.dy - baseLineHeight, |
||||
); |
||||
|
||||
return FadeTransition( |
||||
opacity: _toolbarOpacity, |
||||
child: CompositedTransformFollower( |
||||
link: toolbarLayerLink, |
||||
showWhenUnlinked: false, |
||||
offset: -editingRegion.topLeft, |
||||
child: selectionCtrls.buildToolbar( |
||||
context, |
||||
editingRegion, |
||||
baseLineHeight, |
||||
midpoint, |
||||
endpoints, |
||||
selectionDelegate, |
||||
clipboardStatus, |
||||
const Offset(0, 0)), |
||||
), |
||||
); |
||||
} |
||||
|
||||
void markNeedsBuild([Duration? duration]) { |
||||
if (_handles != null) { |
||||
_handles![0].markNeedsBuild(); |
||||
_handles![1].markNeedsBuild(); |
||||
} |
||||
toolbar?.markNeedsBuild(); |
||||
} |
||||
|
||||
void hide() { |
||||
if (_handles != null) { |
||||
_handles![0].remove(); |
||||
_handles![1].remove(); |
||||
_handles = null; |
||||
} |
||||
if (toolbar != null) { |
||||
hideToolbar(); |
||||
} |
||||
} |
||||
|
||||
void dispose() { |
||||
hide(); |
||||
_toolbarController.dispose(); |
||||
} |
||||
|
||||
void showHandles() { |
||||
assert(_handles == null); |
||||
_handles = <OverlayEntry>[ |
||||
OverlayEntry( |
||||
builder: (context) => |
||||
_buildHandle(context, _TextSelectionHandlePosition.START)), |
||||
OverlayEntry( |
||||
builder: (context) => |
||||
_buildHandle(context, _TextSelectionHandlePosition.END)), |
||||
]; |
||||
|
||||
Overlay.of(context, rootOverlay: true, debugRequiredFor: debugRequiredFor)! |
||||
.insertAll(_handles!); |
||||
} |
||||
} |
||||
|
||||
class _TextSelectionHandleOverlay extends StatefulWidget { |
||||
const _TextSelectionHandleOverlay({ |
||||
required this.selection, |
||||
required this.position, |
||||
required this.startHandleLayerLink, |
||||
required this.endHandleLayerLink, |
||||
required this.renderObject, |
||||
required this.onSelectionHandleChanged, |
||||
required this.onSelectionHandleTapped, |
||||
required this.selectionControls, |
||||
this.dragStartBehavior = DragStartBehavior.start, |
||||
Key? key, |
||||
}) : super(key: key); |
||||
|
||||
final TextSelection selection; |
||||
final _TextSelectionHandlePosition position; |
||||
final LayerLink startHandleLayerLink; |
||||
final LayerLink endHandleLayerLink; |
||||
final RenderEditor? renderObject; |
||||
final ValueChanged<TextSelection?> onSelectionHandleChanged; |
||||
final VoidCallback? onSelectionHandleTapped; |
||||
final TextSelectionControls selectionControls; |
||||
final DragStartBehavior dragStartBehavior; |
||||
|
||||
@override |
||||
_TextSelectionHandleOverlayState createState() => |
||||
_TextSelectionHandleOverlayState(); |
||||
|
||||
ValueListenable<bool>? get _visibility { |
||||
switch (position) { |
||||
case _TextSelectionHandlePosition.START: |
||||
return renderObject!.selectionStartInViewport; |
||||
case _TextSelectionHandlePosition.END: |
||||
return renderObject!.selectionEndInViewport; |
||||
} |
||||
} |
||||
} |
||||
|
||||
class _TextSelectionHandleOverlayState |
||||
extends State<_TextSelectionHandleOverlay> |
||||
with SingleTickerProviderStateMixin { |
||||
late AnimationController _controller; |
||||
|
||||
Animation<double> get _opacity => _controller.view; |
||||
|
||||
@override |
||||
void initState() { |
||||
super.initState(); |
||||
|
||||
_controller = AnimationController( |
||||
duration: const Duration(milliseconds: 150), vsync: this); |
||||
|
||||
_handleVisibilityChanged(); |
||||
widget._visibility!.addListener(_handleVisibilityChanged); |
||||
} |
||||
|
||||
void _handleVisibilityChanged() { |
||||
if (widget._visibility!.value) { |
||||
_controller.forward(); |
||||
} else { |
||||
_controller.reverse(); |
||||
} |
||||
} |
||||
|
||||
@override |
||||
void didUpdateWidget(_TextSelectionHandleOverlay oldWidget) { |
||||
super.didUpdateWidget(oldWidget); |
||||
oldWidget._visibility!.removeListener(_handleVisibilityChanged); |
||||
_handleVisibilityChanged(); |
||||
widget._visibility!.addListener(_handleVisibilityChanged); |
||||
} |
||||
|
||||
@override |
||||
void dispose() { |
||||
widget._visibility!.removeListener(_handleVisibilityChanged); |
||||
_controller.dispose(); |
||||
super.dispose(); |
||||
} |
||||
|
||||
void _handleDragStart(DragStartDetails details) {} |
||||
|
||||
void _handleDragUpdate(DragUpdateDetails details) { |
||||
final position = |
||||
widget.renderObject!.getPositionForOffset(details.globalPosition); |
||||
if (widget.selection.isCollapsed) { |
||||
widget.onSelectionHandleChanged(TextSelection.fromPosition(position)); |
||||
return; |
||||
} |
||||
|
||||
final isNormalized = |
||||
widget.selection.extentOffset >= widget.selection.baseOffset; |
||||
TextSelection? newSelection; |
||||
switch (widget.position) { |
||||
case _TextSelectionHandlePosition.START: |
||||
newSelection = TextSelection( |
||||
baseOffset: |
||||
isNormalized ? position.offset : widget.selection.baseOffset, |
||||
extentOffset: |
||||
isNormalized ? widget.selection.extentOffset : position.offset, |
||||
); |
||||
break; |
||||
case _TextSelectionHandlePosition.END: |
||||
newSelection = TextSelection( |
||||
baseOffset: |
||||
isNormalized ? widget.selection.baseOffset : position.offset, |
||||
extentOffset: |
||||
isNormalized ? position.offset : widget.selection.extentOffset, |
||||
); |
||||
break; |
||||
} |
||||
|
||||
widget.onSelectionHandleChanged(newSelection); |
||||
} |
||||
|
||||
void _handleTap() { |
||||
if (widget.onSelectionHandleTapped != null) { |
||||
widget.onSelectionHandleTapped!(); |
||||
} |
||||
} |
||||
|
||||
@override |
||||
Widget build(BuildContext context) { |
||||
late LayerLink layerLink; |
||||
TextSelectionHandleType? type; |
||||
|
||||
switch (widget.position) { |
||||
case _TextSelectionHandlePosition.START: |
||||
layerLink = widget.startHandleLayerLink; |
||||
type = _chooseType( |
||||
widget.renderObject!.textDirection, |
||||
TextSelectionHandleType.left, |
||||
TextSelectionHandleType.right, |
||||
); |
||||
break; |
||||
case _TextSelectionHandlePosition.END: |
||||
assert(!widget.selection.isCollapsed); |
||||
layerLink = widget.endHandleLayerLink; |
||||
type = _chooseType( |
||||
widget.renderObject!.textDirection, |
||||
TextSelectionHandleType.right, |
||||
TextSelectionHandleType.left, |
||||
); |
||||
break; |
||||
} |
||||
|
||||
final textPosition = widget.position == _TextSelectionHandlePosition.START |
||||
? widget.selection.base |
||||
: widget.selection.extent; |
||||
final lineHeight = widget.renderObject!.preferredLineHeight(textPosition); |
||||
final handleAnchor = |
||||
widget.selectionControls.getHandleAnchor(type!, lineHeight); |
||||
final handleSize = widget.selectionControls.getHandleSize(lineHeight); |
||||
|
||||
final handleRect = Rect.fromLTWH( |
||||
-handleAnchor.dx, |
||||
-handleAnchor.dy, |
||||
handleSize.width, |
||||
handleSize.height, |
||||
); |
||||
|
||||
final interactiveRect = handleRect.expandToInclude( |
||||
Rect.fromCircle( |
||||
center: handleRect.center, radius: kMinInteractiveDimension / 2), |
||||
); |
||||
final padding = RelativeRect.fromLTRB( |
||||
math.max((interactiveRect.width - handleRect.width) / 2, 0), |
||||
math.max((interactiveRect.height - handleRect.height) / 2, 0), |
||||
math.max((interactiveRect.width - handleRect.width) / 2, 0), |
||||
math.max((interactiveRect.height - handleRect.height) / 2, 0), |
||||
); |
||||
|
||||
return CompositedTransformFollower( |
||||
link: layerLink, |
||||
offset: interactiveRect.topLeft, |
||||
showWhenUnlinked: false, |
||||
child: FadeTransition( |
||||
opacity: _opacity, |
||||
child: Container( |
||||
alignment: Alignment.topLeft, |
||||
width: interactiveRect.width, |
||||
height: interactiveRect.height, |
||||
child: GestureDetector( |
||||
behavior: HitTestBehavior.translucent, |
||||
dragStartBehavior: widget.dragStartBehavior, |
||||
onPanStart: _handleDragStart, |
||||
onPanUpdate: _handleDragUpdate, |
||||
onTap: _handleTap, |
||||
child: Padding( |
||||
padding: EdgeInsets.only( |
||||
left: padding.left, |
||||
top: padding.top, |
||||
right: padding.right, |
||||
bottom: padding.bottom, |
||||
), |
||||
child: widget.selectionControls.buildHandle( |
||||
context, |
||||
type, |
||||
lineHeight, |
||||
), |
||||
), |
||||
), |
||||
), |
||||
), |
||||
); |
||||
} |
||||
|
||||
TextSelectionHandleType? _chooseType( |
||||
TextDirection textDirection, |
||||
TextSelectionHandleType ltrType, |
||||
TextSelectionHandleType rtlType, |
||||
) { |
||||
if (widget.selection.isCollapsed) return TextSelectionHandleType.collapsed; |
||||
|
||||
switch (textDirection) { |
||||
case TextDirection.ltr: |
||||
return ltrType; |
||||
case TextDirection.rtl: |
||||
return rtlType; |
||||
} |
||||
} |
||||
} |
||||
|
||||
class EditorTextSelectionGestureDetector extends StatefulWidget { |
||||
const EditorTextSelectionGestureDetector({ |
||||
required this.child, |
||||
this.onTapDown, |
||||
this.onForcePressStart, |
||||
this.onForcePressEnd, |
||||
this.onSingleTapUp, |
||||
this.onSingleTapCancel, |
||||
this.onSingleLongTapStart, |
||||
this.onSingleLongTapMoveUpdate, |
||||
this.onSingleLongTapEnd, |
||||
this.onDoubleTapDown, |
||||
this.onDragSelectionStart, |
||||
this.onDragSelectionUpdate, |
||||
this.onDragSelectionEnd, |
||||
this.behavior, |
||||
Key? key, |
||||
}) : super(key: key); |
||||
|
||||
final GestureTapDownCallback? onTapDown; |
||||
|
||||
final GestureForcePressStartCallback? onForcePressStart; |
||||
|
||||
final GestureForcePressEndCallback? onForcePressEnd; |
||||
|
||||
final GestureTapUpCallback? onSingleTapUp; |
||||
|
||||
final GestureTapCancelCallback? onSingleTapCancel; |
||||
|
||||
final GestureLongPressStartCallback? onSingleLongTapStart; |
||||
|
||||
final GestureLongPressMoveUpdateCallback? onSingleLongTapMoveUpdate; |
||||
|
||||
final GestureLongPressEndCallback? onSingleLongTapEnd; |
||||
|
||||
final GestureTapDownCallback? onDoubleTapDown; |
||||
|
||||
final GestureDragStartCallback? onDragSelectionStart; |
||||
|
||||
final DragSelectionUpdateCallback? onDragSelectionUpdate; |
||||
|
||||
final GestureDragEndCallback? onDragSelectionEnd; |
||||
|
||||
final HitTestBehavior? behavior; |
||||
|
||||
final Widget child; |
||||
|
||||
@override |
||||
State<StatefulWidget> createState() => |
||||
_EditorTextSelectionGestureDetectorState(); |
||||
} |
||||
|
||||
class _EditorTextSelectionGestureDetectorState |
||||
extends State<EditorTextSelectionGestureDetector> { |
||||
Timer? _doubleTapTimer; |
||||
Offset? _lastTapOffset; |
||||
bool _isDoubleTap = false; |
||||
|
||||
@override |
||||
void dispose() { |
||||
_doubleTapTimer?.cancel(); |
||||
_dragUpdateThrottleTimer?.cancel(); |
||||
super.dispose(); |
||||
} |
||||
|
||||
void _handleTapDown(TapDownDetails details) { |
||||
// renderObject.resetTapDownStatus(); |
||||
if (widget.onTapDown != null) { |
||||
widget.onTapDown!(details); |
||||
} |
||||
if (_doubleTapTimer != null && |
||||
_isWithinDoubleTapTolerance(details.globalPosition)) { |
||||
if (widget.onDoubleTapDown != null) { |
||||
widget.onDoubleTapDown!(details); |
||||
} |
||||
|
||||
_doubleTapTimer!.cancel(); |
||||
_doubleTapTimeout(); |
||||
_isDoubleTap = true; |
||||
} |
||||
} |
||||
|
||||
void _handleTapUp(TapUpDetails details) { |
||||
if (!_isDoubleTap) { |
||||
if (widget.onSingleTapUp != null) { |
||||
widget.onSingleTapUp!(details); |
||||
} |
||||
_lastTapOffset = details.globalPosition; |
||||
_doubleTapTimer = Timer(kDoubleTapTimeout, _doubleTapTimeout); |
||||
} |
||||
_isDoubleTap = false; |
||||
} |
||||
|
||||
void _handleTapCancel() { |
||||
if (widget.onSingleTapCancel != null) { |
||||
widget.onSingleTapCancel!(); |
||||
} |
||||
} |
||||
|
||||
DragStartDetails? _lastDragStartDetails; |
||||
DragUpdateDetails? _lastDragUpdateDetails; |
||||
Timer? _dragUpdateThrottleTimer; |
||||
|
||||
void _handleDragStart(DragStartDetails details) { |
||||
assert(_lastDragStartDetails == null); |
||||
_lastDragStartDetails = details; |
||||
if (widget.onDragSelectionStart != null) { |
||||
widget.onDragSelectionStart!(details); |
||||
} |
||||
} |
||||
|
||||
void _handleDragUpdate(DragUpdateDetails details) { |
||||
_lastDragUpdateDetails = details; |
||||
_dragUpdateThrottleTimer ??= |
||||
Timer(const Duration(milliseconds: 50), _handleDragUpdateThrottled); |
||||
} |
||||
|
||||
void _handleDragUpdateThrottled() { |
||||
assert(_lastDragStartDetails != null); |
||||
assert(_lastDragUpdateDetails != null); |
||||
if (widget.onDragSelectionUpdate != null) { |
||||
widget.onDragSelectionUpdate!( |
||||
_lastDragStartDetails!, _lastDragUpdateDetails!); |
||||
} |
||||
_dragUpdateThrottleTimer = null; |
||||
_lastDragUpdateDetails = null; |
||||
} |
||||
|
||||
void _handleDragEnd(DragEndDetails details) { |
||||
assert(_lastDragStartDetails != null); |
||||
if (_dragUpdateThrottleTimer != null) { |
||||
_dragUpdateThrottleTimer!.cancel(); |
||||
_handleDragUpdateThrottled(); |
||||
} |
||||
if (widget.onDragSelectionEnd != null) { |
||||
widget.onDragSelectionEnd!(details); |
||||
} |
||||
_dragUpdateThrottleTimer = null; |
||||
_lastDragStartDetails = null; |
||||
_lastDragUpdateDetails = null; |
||||
} |
||||
|
||||
void _forcePressStarted(ForcePressDetails details) { |
||||
_doubleTapTimer?.cancel(); |
||||
_doubleTapTimer = null; |
||||
if (widget.onForcePressStart != null) { |
||||
widget.onForcePressStart!(details); |
||||
} |
||||
} |
||||
|
||||
void _forcePressEnded(ForcePressDetails details) { |
||||
if (widget.onForcePressEnd != null) { |
||||
widget.onForcePressEnd!(details); |
||||
} |
||||
} |
||||
|
||||
void _handleLongPressStart(LongPressStartDetails details) { |
||||
if (!_isDoubleTap && widget.onSingleLongTapStart != null) { |
||||
widget.onSingleLongTapStart!(details); |
||||
} |
||||
} |
||||
|
||||
void _handleLongPressMoveUpdate(LongPressMoveUpdateDetails details) { |
||||
if (!_isDoubleTap && widget.onSingleLongTapMoveUpdate != null) { |
||||
widget.onSingleLongTapMoveUpdate!(details); |
||||
} |
||||
} |
||||
|
||||
void _handleLongPressEnd(LongPressEndDetails details) { |
||||
if (!_isDoubleTap && widget.onSingleLongTapEnd != null) { |
||||
widget.onSingleLongTapEnd!(details); |
||||
} |
||||
_isDoubleTap = false; |
||||
} |
||||
|
||||
void _doubleTapTimeout() { |
||||
_doubleTapTimer = null; |
||||
_lastTapOffset = null; |
||||
} |
||||
|
||||
bool _isWithinDoubleTapTolerance(Offset secondTapOffset) { |
||||
if (_lastTapOffset == null) { |
||||
return false; |
||||
} |
||||
|
||||
return (secondTapOffset - _lastTapOffset!).distance <= kDoubleTapSlop; |
||||
} |
||||
|
||||
@override |
||||
Widget build(BuildContext context) { |
||||
final gestures = <Type, GestureRecognizerFactory>{}; |
||||
|
||||
gestures[TapGestureRecognizer] = |
||||
GestureRecognizerFactoryWithHandlers<TapGestureRecognizer>( |
||||
() => TapGestureRecognizer(debugOwner: this), |
||||
(instance) { |
||||
instance |
||||
..onTapDown = _handleTapDown |
||||
..onTapUp = _handleTapUp |
||||
..onTapCancel = _handleTapCancel; |
||||
}, |
||||
); |
||||
|
||||
if (widget.onSingleLongTapStart != null || |
||||
widget.onSingleLongTapMoveUpdate != null || |
||||
widget.onSingleLongTapEnd != null) { |
||||
gestures[LongPressGestureRecognizer] = |
||||
GestureRecognizerFactoryWithHandlers<LongPressGestureRecognizer>( |
||||
() => LongPressGestureRecognizer( |
||||
debugOwner: this, kind: PointerDeviceKind.touch), |
||||
(instance) { |
||||
instance |
||||
..onLongPressStart = _handleLongPressStart |
||||
..onLongPressMoveUpdate = _handleLongPressMoveUpdate |
||||
..onLongPressEnd = _handleLongPressEnd; |
||||
}, |
||||
); |
||||
} |
||||
|
||||
if (widget.onDragSelectionStart != null || |
||||
widget.onDragSelectionUpdate != null || |
||||
widget.onDragSelectionEnd != null) { |
||||
gestures[HorizontalDragGestureRecognizer] = |
||||
GestureRecognizerFactoryWithHandlers<HorizontalDragGestureRecognizer>( |
||||
() => HorizontalDragGestureRecognizer( |
||||
debugOwner: this, kind: PointerDeviceKind.mouse), |
||||
(instance) { |
||||
instance |
||||
..dragStartBehavior = DragStartBehavior.down |
||||
..onStart = _handleDragStart |
||||
..onUpdate = _handleDragUpdate |
||||
..onEnd = _handleDragEnd; |
||||
}, |
||||
); |
||||
} |
||||
|
||||
if (widget.onForcePressStart != null || widget.onForcePressEnd != null) { |
||||
gestures[ForcePressGestureRecognizer] = |
||||
GestureRecognizerFactoryWithHandlers<ForcePressGestureRecognizer>( |
||||
() => ForcePressGestureRecognizer(debugOwner: this), |
||||
(instance) { |
||||
instance |
||||
..onStart = |
||||
widget.onForcePressStart != null ? _forcePressStarted : null |
||||
..onEnd = widget.onForcePressEnd != null ? _forcePressEnded : null; |
||||
}, |
||||
); |
||||
} |
||||
|
||||
return RawGestureDetector( |
||||
gestures: gestures, |
||||
excludeFromSemantics: true, |
||||
behavior: widget.behavior, |
||||
child: widget.child, |
||||
); |
||||
} |
||||
} |
@ -0,0 +1,293 @@ |
||||
import 'dart:io'; |
||||
|
||||
import 'package:flutter/foundation.dart'; |
||||
import 'package:flutter/material.dart'; |
||||
import 'package:image_picker/image_picker.dart'; |
||||
|
||||
import '../models/documents/attribute.dart'; |
||||
import 'controller.dart'; |
||||
import 'toolbar/arrow_indicated_button_list.dart'; |
||||
import 'toolbar/clear_format_button.dart'; |
||||
import 'toolbar/color_button.dart'; |
||||
import 'toolbar/history_button.dart'; |
||||
import 'toolbar/image_button.dart'; |
||||
import 'toolbar/indent_button.dart'; |
||||
import 'toolbar/insert_embed_button.dart'; |
||||
import 'toolbar/link_style_button.dart'; |
||||
import 'toolbar/select_header_style_button.dart'; |
||||
import 'toolbar/toggle_check_list_button.dart'; |
||||
import 'toolbar/toggle_style_button.dart'; |
||||
|
||||
export 'toolbar/clear_format_button.dart'; |
||||
export 'toolbar/color_button.dart'; |
||||
export 'toolbar/history_button.dart'; |
||||
export 'toolbar/image_button.dart'; |
||||
export 'toolbar/indent_button.dart'; |
||||
export 'toolbar/insert_embed_button.dart'; |
||||
export 'toolbar/link_style_button.dart'; |
||||
export 'toolbar/quill_dropdown_button.dart'; |
||||
export 'toolbar/quill_icon_button.dart'; |
||||
export 'toolbar/select_header_style_button.dart'; |
||||
export 'toolbar/toggle_check_list_button.dart'; |
||||
export 'toolbar/toggle_style_button.dart'; |
||||
|
||||
typedef OnImagePickCallback = Future<String> Function(File file); |
||||
typedef ImagePickImpl = Future<String?> Function(ImageSource source); |
||||
|
||||
// The default size of the icon of a button. |
||||
const double kDefaultIconSize = 18; |
||||
|
||||
// The factor of how much larger the button is in relation to the icon. |
||||
const double kIconButtonFactor = 1.77; |
||||
|
||||
class QuillToolbar extends StatelessWidget implements PreferredSizeWidget { |
||||
const QuillToolbar({ |
||||
required this.children, |
||||
this.toolBarHeight = 36, |
||||
this.color, |
||||
Key? key, |
||||
}) : super(key: key); |
||||
|
||||
factory QuillToolbar.basic({ |
||||
required QuillController controller, |
||||
double toolbarIconSize = kDefaultIconSize, |
||||
bool showBoldButton = true, |
||||
bool showItalicButton = true, |
||||
bool showUnderLineButton = true, |
||||
bool showStrikeThrough = true, |
||||
bool showColorButton = true, |
||||
bool showBackgroundColorButton = true, |
||||
bool showClearFormat = true, |
||||
bool showHeaderStyle = true, |
||||
bool showListNumbers = true, |
||||
bool showListBullets = true, |
||||
bool showListCheck = true, |
||||
bool showCodeBlock = true, |
||||
bool showQuote = true, |
||||
bool showIndent = true, |
||||
bool showLink = true, |
||||
bool showHistory = true, |
||||
bool showHorizontalRule = false, |
||||
OnImagePickCallback? onImagePickCallback, |
||||
Key? key, |
||||
}) { |
||||
final isButtonGroupShown = [ |
||||
showHistory || |
||||
showBoldButton || |
||||
showItalicButton || |
||||
showUnderLineButton || |
||||
showStrikeThrough || |
||||
showColorButton || |
||||
showBackgroundColorButton || |
||||
showClearFormat || |
||||
onImagePickCallback != null, |
||||
showHeaderStyle, |
||||
showListNumbers || showListBullets || showListCheck || showCodeBlock, |
||||
showQuote || showIndent, |
||||
showLink || showHorizontalRule |
||||
]; |
||||
|
||||
return QuillToolbar( |
||||
key: key, |
||||
toolBarHeight: toolbarIconSize * 2, |
||||
children: [ |
||||
if (showHistory) |
||||
HistoryButton( |
||||
icon: Icons.undo_outlined, |
||||
iconSize: toolbarIconSize, |
||||
controller: controller, |
||||
undo: true, |
||||
), |
||||
if (showHistory) |
||||
HistoryButton( |
||||
icon: Icons.redo_outlined, |
||||
iconSize: toolbarIconSize, |
||||
controller: controller, |
||||
undo: false, |
||||
), |
||||
if (showBoldButton) |
||||
ToggleStyleButton( |
||||
attribute: Attribute.bold, |
||||
icon: Icons.format_bold, |
||||
iconSize: toolbarIconSize, |
||||
controller: controller, |
||||
), |
||||
if (showItalicButton) |
||||
ToggleStyleButton( |
||||
attribute: Attribute.italic, |
||||
icon: Icons.format_italic, |
||||
iconSize: toolbarIconSize, |
||||
controller: controller, |
||||
), |
||||
if (showUnderLineButton) |
||||
ToggleStyleButton( |
||||
attribute: Attribute.underline, |
||||
icon: Icons.format_underline, |
||||
iconSize: toolbarIconSize, |
||||
controller: controller, |
||||
), |
||||
if (showStrikeThrough) |
||||
ToggleStyleButton( |
||||
attribute: Attribute.strikeThrough, |
||||
icon: Icons.format_strikethrough, |
||||
iconSize: toolbarIconSize, |
||||
controller: controller, |
||||
), |
||||
if (showColorButton) |
||||
ColorButton( |
||||
icon: Icons.color_lens, |
||||
iconSize: toolbarIconSize, |
||||
controller: controller, |
||||
background: false, |
||||
), |
||||
if (showBackgroundColorButton) |
||||
ColorButton( |
||||
icon: Icons.format_color_fill, |
||||
iconSize: toolbarIconSize, |
||||
controller: controller, |
||||
background: true, |
||||
), |
||||
if (showClearFormat) |
||||
ClearFormatButton( |
||||
icon: Icons.format_clear, |
||||
iconSize: toolbarIconSize, |
||||
controller: controller, |
||||
), |
||||
if (onImagePickCallback != null) |
||||
ImageButton( |
||||
icon: Icons.image, |
||||
iconSize: toolbarIconSize, |
||||
controller: controller, |
||||
imageSource: ImageSource.gallery, |
||||
onImagePickCallback: onImagePickCallback, |
||||
), |
||||
if (onImagePickCallback != null) |
||||
ImageButton( |
||||
icon: Icons.photo_camera, |
||||
iconSize: toolbarIconSize, |
||||
controller: controller, |
||||
imageSource: ImageSource.camera, |
||||
onImagePickCallback: onImagePickCallback, |
||||
), |
||||
if (isButtonGroupShown[0] && |
||||
(isButtonGroupShown[1] || |
||||
isButtonGroupShown[2] || |
||||
isButtonGroupShown[3] || |
||||
isButtonGroupShown[4])) |
||||
VerticalDivider( |
||||
indent: 12, |
||||
endIndent: 12, |
||||
color: Colors.grey.shade400, |
||||
), |
||||
if (showHeaderStyle) |
||||
SelectHeaderStyleButton( |
||||
controller: controller, |
||||
iconSize: toolbarIconSize, |
||||
), |
||||
if (isButtonGroupShown[1] && |
||||
(isButtonGroupShown[2] || |
||||
isButtonGroupShown[3] || |
||||
isButtonGroupShown[4])) |
||||
VerticalDivider( |
||||
indent: 12, |
||||
endIndent: 12, |
||||
color: Colors.grey.shade400, |
||||
), |
||||
if (showListNumbers) |
||||
ToggleStyleButton( |
||||
attribute: Attribute.ol, |
||||
controller: controller, |
||||
icon: Icons.format_list_numbered, |
||||
iconSize: toolbarIconSize, |
||||
), |
||||
if (showListBullets) |
||||
ToggleStyleButton( |
||||
attribute: Attribute.ul, |
||||
controller: controller, |
||||
icon: Icons.format_list_bulleted, |
||||
iconSize: toolbarIconSize, |
||||
), |
||||
if (showListCheck) |
||||
ToggleCheckListButton( |
||||
attribute: Attribute.unchecked, |
||||
controller: controller, |
||||
icon: Icons.check_box, |
||||
iconSize: toolbarIconSize, |
||||
), |
||||
if (showCodeBlock) |
||||
ToggleStyleButton( |
||||
attribute: Attribute.codeBlock, |
||||
controller: controller, |
||||
icon: Icons.code, |
||||
iconSize: toolbarIconSize, |
||||
), |
||||
if (isButtonGroupShown[2] && |
||||
(isButtonGroupShown[3] || isButtonGroupShown[4])) |
||||
VerticalDivider( |
||||
indent: 12, |
||||
endIndent: 12, |
||||
color: Colors.grey.shade400, |
||||
), |
||||
if (showQuote) |
||||
ToggleStyleButton( |
||||
attribute: Attribute.blockQuote, |
||||
controller: controller, |
||||
icon: Icons.format_quote, |
||||
iconSize: toolbarIconSize, |
||||
), |
||||
if (showIndent) |
||||
IndentButton( |
||||
icon: Icons.format_indent_increase, |
||||
iconSize: toolbarIconSize, |
||||
controller: controller, |
||||
isIncrease: true, |
||||
), |
||||
if (showIndent) |
||||
IndentButton( |
||||
icon: Icons.format_indent_decrease, |
||||
iconSize: toolbarIconSize, |
||||
controller: controller, |
||||
isIncrease: false, |
||||
), |
||||
if (isButtonGroupShown[3] && isButtonGroupShown[4]) |
||||
VerticalDivider( |
||||
indent: 12, |
||||
endIndent: 12, |
||||
color: Colors.grey.shade400, |
||||
), |
||||
if (showLink) |
||||
LinkStyleButton( |
||||
controller: controller, |
||||
iconSize: toolbarIconSize, |
||||
), |
||||
if (showHorizontalRule) |
||||
InsertEmbedButton( |
||||
controller: controller, |
||||
icon: Icons.horizontal_rule, |
||||
iconSize: toolbarIconSize, |
||||
), |
||||
], |
||||
); |
||||
} |
||||
|
||||
final List<Widget> children; |
||||
final double toolBarHeight; |
||||
|
||||
/// The color of the toolbar. |
||||
/// |
||||
/// Defaults to [ThemeData.canvasColor] of the current [Theme] if no color |
||||
/// is given. |
||||
final Color? color; |
||||
|
||||
@override |
||||
Size get preferredSize => Size.fromHeight(toolBarHeight); |
||||
|
||||
@override |
||||
Widget build(BuildContext context) { |
||||
return Container( |
||||
constraints: BoxConstraints.tightFor(height: preferredSize.height), |
||||
color: color ?? Theme.of(context).canvasColor, |
||||
child: ArrowIndicatedButtonList(buttons: children), |
||||
); |
||||
} |
||||
} |
@ -0,0 +1,125 @@ |
||||
import 'dart:async'; |
||||
|
||||
import 'package:flutter/material.dart'; |
||||
|
||||
/// Scrollable list with arrow indicators. |
||||
/// |
||||
/// The arrow indicators are automatically hidden if the list is not |
||||
/// scrollable in the direction of the respective arrow. |
||||
class ArrowIndicatedButtonList extends StatefulWidget { |
||||
const ArrowIndicatedButtonList({required this.buttons, Key? key}) |
||||
: super(key: key); |
||||
|
||||
final List<Widget> buttons; |
||||
|
||||
@override |
||||
_ArrowIndicatedButtonListState createState() => |
||||
_ArrowIndicatedButtonListState(); |
||||
} |
||||
|
||||
class _ArrowIndicatedButtonListState extends State<ArrowIndicatedButtonList> |
||||
with WidgetsBindingObserver { |
||||
final ScrollController _controller = ScrollController(); |
||||
bool _showLeftArrow = false; |
||||
bool _showRightArrow = false; |
||||
|
||||
@override |
||||
void initState() { |
||||
super.initState(); |
||||
_controller.addListener(_handleScroll); |
||||
|
||||
// Listening to the WidgetsBinding instance is necessary so that we can |
||||
// hide the arrows when the window gets a new size and thus the toolbar |
||||
// becomes scrollable/unscrollable. |
||||
WidgetsBinding.instance!.addObserver(this); |
||||
|
||||
// Workaround to allow the scroll controller attach to our ListView so that |
||||
// we can detect if overflow arrows need to be shown on init. |
||||
Timer.run(_handleScroll); |
||||
} |
||||
|
||||
@override |
||||
Widget build(BuildContext context) { |
||||
return Row( |
||||
children: <Widget>[ |
||||
_buildLeftArrow(), |
||||
_buildScrollableList(), |
||||
_buildRightColor(), |
||||
], |
||||
); |
||||
} |
||||
|
||||
@override |
||||
void didChangeMetrics() => _handleScroll(); |
||||
|
||||
@override |
||||
void dispose() { |
||||
_controller.dispose(); |
||||
WidgetsBinding.instance!.removeObserver(this); |
||||
super.dispose(); |
||||
} |
||||
|
||||
void _handleScroll() { |
||||
setState(() { |
||||
_showLeftArrow = |
||||
_controller.position.minScrollExtent != _controller.position.pixels; |
||||
_showRightArrow = |
||||
_controller.position.maxScrollExtent != _controller.position.pixels; |
||||
}); |
||||
} |
||||
|
||||
Widget _buildLeftArrow() { |
||||
return SizedBox( |
||||
width: 8, |
||||
child: Transform.translate( |
||||
// Move the icon a few pixels to center it |
||||
offset: const Offset(-5, 0), |
||||
child: _showLeftArrow ? const Icon(Icons.arrow_left, size: 18) : null, |
||||
), |
||||
); |
||||
} |
||||
|
||||
Widget _buildScrollableList() { |
||||
return Expanded( |
||||
child: ScrollConfiguration( |
||||
// Remove the glowing effect, as we already have the arrow indicators |
||||
behavior: _NoGlowBehavior(), |
||||
// The CustomScrollView is necessary so that the children are not |
||||
// stretched to the height of the toolbar, https://bit.ly/3uC3bjI |
||||
child: CustomScrollView( |
||||
scrollDirection: Axis.horizontal, |
||||
controller: _controller, |
||||
physics: const ClampingScrollPhysics(), |
||||
slivers: [ |
||||
SliverFillRemaining( |
||||
hasScrollBody: false, |
||||
child: Row( |
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly, |
||||
children: widget.buttons, |
||||
), |
||||
) |
||||
], |
||||
), |
||||
), |
||||
); |
||||
} |
||||
|
||||
Widget _buildRightColor() { |
||||
return SizedBox( |
||||
width: 8, |
||||
child: Transform.translate( |
||||
// Move the icon a few pixels to center it |
||||
offset: const Offset(-5, 0), |
||||
child: _showRightArrow ? const Icon(Icons.arrow_right, size: 18) : null, |
||||
), |
||||
); |
||||
} |
||||
} |
||||
|
||||
/// ScrollBehavior without the Material glow effect. |
||||
class _NoGlowBehavior extends ScrollBehavior { |
||||
@override |
||||
Widget buildViewportChrome(BuildContext _, Widget child, AxisDirection __) { |
||||
return child; |
||||
} |
||||
} |
@ -0,0 +1,42 @@ |
||||
import 'package:flutter/material.dart'; |
||||
|
||||
import '../../../flutter_quill.dart'; |
||||
import 'quill_icon_button.dart'; |
||||
|
||||
class ClearFormatButton extends StatefulWidget { |
||||
const ClearFormatButton({ |
||||
required this.icon, |
||||
required this.controller, |
||||
this.iconSize = kDefaultIconSize, |
||||
Key? key, |
||||
}) : super(key: key); |
||||
|
||||
final IconData icon; |
||||
final double iconSize; |
||||
|
||||
final QuillController controller; |
||||
|
||||
@override |
||||
_ClearFormatButtonState createState() => _ClearFormatButtonState(); |
||||
} |
||||
|
||||
class _ClearFormatButtonState extends State<ClearFormatButton> { |
||||
@override |
||||
Widget build(BuildContext context) { |
||||
final theme = Theme.of(context); |
||||
final iconColor = theme.iconTheme.color; |
||||
final fillColor = theme.canvasColor; |
||||
return QuillIconButton( |
||||
highlightElevation: 0, |
||||
hoverElevation: 0, |
||||
size: widget.iconSize * kIconButtonFactor, |
||||
icon: Icon(widget.icon, size: widget.iconSize, color: iconColor), |
||||
fillColor: fillColor, |
||||
onPressed: () { |
||||
for (final k |
||||
in widget.controller.getSelectionStyle().attributes.values) { |
||||
widget.controller.formatSelection(Attribute.clone(k, null)); |
||||
} |
||||
}); |
||||
} |
||||
} |
@ -0,0 +1,154 @@ |
||||
import 'package:flutter/material.dart'; |
||||
import 'package:flutter_colorpicker/flutter_colorpicker.dart'; |
||||
|
||||
import '../../models/documents/attribute.dart'; |
||||
import '../../models/documents/style.dart'; |
||||
import '../../utils/color.dart'; |
||||
import '../controller.dart'; |
||||
import '../toolbar.dart'; |
||||
import 'quill_icon_button.dart'; |
||||
|
||||
/// Controls color styles. |
||||
/// |
||||
/// When pressed, this button displays overlay toolbar with |
||||
/// buttons for each color. |
||||
class ColorButton extends StatefulWidget { |
||||
const ColorButton({ |
||||
required this.icon, |
||||
required this.controller, |
||||
required this.background, |
||||
this.iconSize = kDefaultIconSize, |
||||
Key? key, |
||||
}) : super(key: key); |
||||
|
||||
final IconData icon; |
||||
final double iconSize; |
||||
final bool background; |
||||
final QuillController controller; |
||||
|
||||
@override |
||||
_ColorButtonState createState() => _ColorButtonState(); |
||||
} |
||||
|
||||
class _ColorButtonState extends State<ColorButton> { |
||||
late bool _isToggledColor; |
||||
late bool _isToggledBackground; |
||||
late bool _isWhite; |
||||
late bool _isWhitebackground; |
||||
|
||||
Style get _selectionStyle => widget.controller.getSelectionStyle(); |
||||
|
||||
void _didChangeEditingValue() { |
||||
setState(() { |
||||
_isToggledColor = |
||||
_getIsToggledColor(widget.controller.getSelectionStyle().attributes); |
||||
_isToggledBackground = _getIsToggledBackground( |
||||
widget.controller.getSelectionStyle().attributes); |
||||
_isWhite = _isToggledColor && |
||||
_selectionStyle.attributes['color']!.value == '#ffffff'; |
||||
_isWhitebackground = _isToggledBackground && |
||||
_selectionStyle.attributes['background']!.value == '#ffffff'; |
||||
}); |
||||
} |
||||
|
||||
@override |
||||
void initState() { |
||||
super.initState(); |
||||
_isToggledColor = _getIsToggledColor(_selectionStyle.attributes); |
||||
_isToggledBackground = _getIsToggledBackground(_selectionStyle.attributes); |
||||
_isWhite = _isToggledColor && |
||||
_selectionStyle.attributes['color']!.value == '#ffffff'; |
||||
_isWhitebackground = _isToggledBackground && |
||||
_selectionStyle.attributes['background']!.value == '#ffffff'; |
||||
widget.controller.addListener(_didChangeEditingValue); |
||||
} |
||||
|
||||
bool _getIsToggledColor(Map<String, Attribute> attrs) { |
||||
return attrs.containsKey(Attribute.color.key); |
||||
} |
||||
|
||||
bool _getIsToggledBackground(Map<String, Attribute> attrs) { |
||||
return attrs.containsKey(Attribute.background.key); |
||||
} |
||||
|
||||
@override |
||||
void didUpdateWidget(covariant ColorButton oldWidget) { |
||||
super.didUpdateWidget(oldWidget); |
||||
if (oldWidget.controller != widget.controller) { |
||||
oldWidget.controller.removeListener(_didChangeEditingValue); |
||||
widget.controller.addListener(_didChangeEditingValue); |
||||
_isToggledColor = _getIsToggledColor(_selectionStyle.attributes); |
||||
_isToggledBackground = |
||||
_getIsToggledBackground(_selectionStyle.attributes); |
||||
_isWhite = _isToggledColor && |
||||
_selectionStyle.attributes['color']!.value == '#ffffff'; |
||||
_isWhitebackground = _isToggledBackground && |
||||
_selectionStyle.attributes['background']!.value == '#ffffff'; |
||||
} |
||||
} |
||||
|
||||
@override |
||||
void dispose() { |
||||
widget.controller.removeListener(_didChangeEditingValue); |
||||
super.dispose(); |
||||
} |
||||
|
||||
@override |
||||
Widget build(BuildContext context) { |
||||
final theme = Theme.of(context); |
||||
final iconColor = _isToggledColor && !widget.background && !_isWhite |
||||
? stringToColor(_selectionStyle.attributes['color']!.value) |
||||
: theme.iconTheme.color; |
||||
|
||||
final iconColorBackground = |
||||
_isToggledBackground && widget.background && !_isWhitebackground |
||||
? stringToColor(_selectionStyle.attributes['background']!.value) |
||||
: theme.iconTheme.color; |
||||
|
||||
final fillColor = _isToggledColor && !widget.background && _isWhite |
||||
? stringToColor('#ffffff') |
||||
: theme.canvasColor; |
||||
final fillColorBackground = |
||||
_isToggledBackground && widget.background && _isWhitebackground |
||||
? stringToColor('#ffffff') |
||||
: theme.canvasColor; |
||||
|
||||
return QuillIconButton( |
||||
highlightElevation: 0, |
||||
hoverElevation: 0, |
||||
size: widget.iconSize * kIconButtonFactor, |
||||
icon: Icon(widget.icon, |
||||
size: widget.iconSize, |
||||
color: widget.background ? iconColorBackground : iconColor), |
||||
fillColor: widget.background ? fillColorBackground : fillColor, |
||||
onPressed: _showColorPicker, |
||||
); |
||||
} |
||||
|
||||
void _changeColor(BuildContext context, Color color) { |
||||
var hex = color.value.toRadixString(16); |
||||
if (hex.startsWith('ff')) { |
||||
hex = hex.substring(2); |
||||
} |
||||
hex = '#$hex'; |
||||
widget.controller.formatSelection( |
||||
widget.background ? BackgroundAttribute(hex) : ColorAttribute(hex)); |
||||
Navigator.of(context).pop(); |
||||
} |
||||
|
||||
void _showColorPicker() { |
||||
showDialog( |
||||
context: context, |
||||
builder: (context) => AlertDialog( |
||||
title: const Text('Select Color'), |
||||
backgroundColor: Theme.of(context).canvasColor, |
||||
content: SingleChildScrollView( |
||||
child: MaterialPicker( |
||||
pickerColor: const Color(0x00000000), |
||||
onColorChanged: (color) => _changeColor(context, color), |
||||
), |
||||
), |
||||
), |
||||
); |
||||
} |
||||
} |
@ -0,0 +1,78 @@ |
||||
import 'package:flutter/material.dart'; |
||||
|
||||
import '../../../flutter_quill.dart'; |
||||
import 'quill_icon_button.dart'; |
||||
|
||||
class HistoryButton extends StatefulWidget { |
||||
const HistoryButton({ |
||||
required this.icon, |
||||
required this.controller, |
||||
required this.undo, |
||||
this.iconSize = kDefaultIconSize, |
||||
Key? key, |
||||
}) : super(key: key); |
||||
|
||||
final IconData icon; |
||||
final double iconSize; |
||||
final bool undo; |
||||
final QuillController controller; |
||||
|
||||
@override |
||||
_HistoryButtonState createState() => _HistoryButtonState(); |
||||
} |
||||
|
||||
class _HistoryButtonState extends State<HistoryButton> { |
||||
Color? _iconColor; |
||||
late ThemeData theme; |
||||
|
||||
@override |
||||
Widget build(BuildContext context) { |
||||
theme = Theme.of(context); |
||||
_setIconColor(); |
||||
|
||||
final fillColor = theme.canvasColor; |
||||
widget.controller.changes.listen((event) async { |
||||
_setIconColor(); |
||||
}); |
||||
return QuillIconButton( |
||||
highlightElevation: 0, |
||||
hoverElevation: 0, |
||||
size: widget.iconSize * 1.77, |
||||
icon: Icon(widget.icon, size: widget.iconSize, color: _iconColor), |
||||
fillColor: fillColor, |
||||
onPressed: _changeHistory, |
||||
); |
||||
} |
||||
|
||||
void _setIconColor() { |
||||
if (!mounted) return; |
||||
|
||||
if (widget.undo) { |
||||
setState(() { |
||||
_iconColor = widget.controller.hasUndo |
||||
? theme.iconTheme.color |
||||
: theme.disabledColor; |
||||
}); |
||||
} else { |
||||
setState(() { |
||||
_iconColor = widget.controller.hasRedo |
||||
? theme.iconTheme.color |
||||
: theme.disabledColor; |
||||
}); |
||||
} |
||||
} |
||||
|
||||
void _changeHistory() { |
||||
if (widget.undo) { |
||||
if (widget.controller.hasUndo) { |
||||
widget.controller.undo(); |
||||
} |
||||
} else { |
||||
if (widget.controller.hasRedo) { |
||||
widget.controller.redo(); |
||||
} |
||||
} |
||||
|
||||
_setIconColor(); |
||||
} |
||||
} |
@ -0,0 +1,110 @@ |
||||
import 'dart:io'; |
||||
|
||||
import 'package:file_picker/file_picker.dart'; |
||||
import 'package:filesystem_picker/filesystem_picker.dart'; |
||||
import 'package:flutter/foundation.dart'; |
||||
import 'package:flutter/material.dart'; |
||||
import 'package:image_picker/image_picker.dart'; |
||||
import 'package:path_provider/path_provider.dart'; |
||||
|
||||
import '../../models/documents/nodes/embed.dart'; |
||||
import '../controller.dart'; |
||||
import '../toolbar.dart'; |
||||
import 'quill_icon_button.dart'; |
||||
|
||||
class ImageButton extends StatelessWidget { |
||||
const ImageButton({ |
||||
required this.icon, |
||||
required this.controller, |
||||
required this.imageSource, |
||||
this.iconSize = kDefaultIconSize, |
||||
this.fillColor, |
||||
this.onImagePickCallback, |
||||
this.imagePickImpl, |
||||
Key? key, |
||||
}) : super(key: key); |
||||
|
||||
final IconData icon; |
||||
final double iconSize; |
||||
|
||||
final Color? fillColor; |
||||
|
||||
final QuillController controller; |
||||
|
||||
final OnImagePickCallback? onImagePickCallback; |
||||
|
||||
final ImagePickImpl? imagePickImpl; |
||||
|
||||
final ImageSource imageSource; |
||||
|
||||
@override |
||||
Widget build(BuildContext context) { |
||||
final theme = Theme.of(context); |
||||
|
||||
return QuillIconButton( |
||||
icon: Icon(icon, size: iconSize, color: theme.iconTheme.color), |
||||
highlightElevation: 0, |
||||
hoverElevation: 0, |
||||
size: iconSize * 1.77, |
||||
fillColor: fillColor ?? theme.canvasColor, |
||||
onPressed: () => _handleImageButtonTap(context), |
||||
); |
||||
} |
||||
|
||||
Future<void> _handleImageButtonTap(BuildContext context) async { |
||||
final index = controller.selection.baseOffset; |
||||
final length = controller.selection.extentOffset - index; |
||||
|
||||
String? imageUrl; |
||||
if (imagePickImpl != null) { |
||||
imageUrl = await imagePickImpl!(imageSource); |
||||
} else { |
||||
if (kIsWeb) { |
||||
imageUrl = await _pickImageWeb(); |
||||
} else if (Platform.isAndroid || Platform.isIOS) { |
||||
imageUrl = await _pickImage(imageSource); |
||||
} else { |
||||
imageUrl = await _pickImageDesktop(context); |
||||
} |
||||
} |
||||
|
||||
if (imageUrl != null) { |
||||
controller.replaceText(index, length, BlockEmbed.image(imageUrl), null); |
||||
} |
||||
} |
||||
|
||||
Future<String?> _pickImageWeb() async { |
||||
final result = await FilePicker.platform.pickFiles(); |
||||
if (result == null) { |
||||
return null; |
||||
} |
||||
|
||||
// Take first, because we don't allow picking multiple files. |
||||
final fileName = result.files.first.name; |
||||
final file = File(fileName); |
||||
|
||||
return onImagePickCallback!(file); |
||||
} |
||||
|
||||
Future<String?> _pickImage(ImageSource source) async { |
||||
final pickedFile = await ImagePicker().getImage(source: source); |
||||
if (pickedFile == null) { |
||||
return null; |
||||
} |
||||
|
||||
return onImagePickCallback!(File(pickedFile.path)); |
||||
} |
||||
|
||||
Future<String?> _pickImageDesktop(BuildContext context) async { |
||||
final filePath = await FilesystemPicker.open( |
||||
context: context, |
||||
rootDirectory: await getApplicationDocumentsDirectory(), |
||||
fsType: FilesystemType.file, |
||||
fileTileSelectMode: FileTileSelectMode.wholeTile, |
||||
); |
||||
if (filePath == null || filePath.isEmpty) return null; |
||||
|
||||
final file = File(filePath); |
||||
return onImagePickCallback!(file); |
||||
} |
||||
} |
@ -0,0 +1,61 @@ |
||||
import 'package:flutter/material.dart'; |
||||
|
||||
import '../../../flutter_quill.dart'; |
||||
import 'quill_icon_button.dart'; |
||||
|
||||
class IndentButton extends StatefulWidget { |
||||
const IndentButton({ |
||||
required this.icon, |
||||
required this.controller, |
||||
required this.isIncrease, |
||||
this.iconSize = kDefaultIconSize, |
||||
Key? key, |
||||
}) : super(key: key); |
||||
|
||||
final IconData icon; |
||||
final double iconSize; |
||||
final QuillController controller; |
||||
final bool isIncrease; |
||||
|
||||
@override |
||||
_IndentButtonState createState() => _IndentButtonState(); |
||||
} |
||||
|
||||
class _IndentButtonState extends State<IndentButton> { |
||||
@override |
||||
Widget build(BuildContext context) { |
||||
final theme = Theme.of(context); |
||||
final iconColor = theme.iconTheme.color; |
||||
final fillColor = theme.canvasColor; |
||||
return QuillIconButton( |
||||
highlightElevation: 0, |
||||
hoverElevation: 0, |
||||
size: widget.iconSize * 1.77, |
||||
icon: Icon(widget.icon, size: widget.iconSize, color: iconColor), |
||||
fillColor: fillColor, |
||||
onPressed: () { |
||||
final indent = widget.controller |
||||
.getSelectionStyle() |
||||
.attributes[Attribute.indent.key]; |
||||
if (indent == null) { |
||||
if (widget.isIncrease) { |
||||
widget.controller.formatSelection(Attribute.indentL1); |
||||
} |
||||
return; |
||||
} |
||||
if (indent.value == 1 && !widget.isIncrease) { |
||||
widget.controller |
||||
.formatSelection(Attribute.clone(Attribute.indentL1, null)); |
||||
return; |
||||
} |
||||
if (widget.isIncrease) { |
||||
widget.controller |
||||
.formatSelection(Attribute.getIndentLevel(indent.value + 1)); |
||||
return; |
||||
} |
||||
widget.controller |
||||
.formatSelection(Attribute.getIndentLevel(indent.value - 1)); |
||||
}, |
||||
); |
||||
} |
||||
} |
@ -0,0 +1,41 @@ |
||||
import 'package:flutter/material.dart'; |
||||
|
||||
import '../../models/documents/nodes/embed.dart'; |
||||
import '../controller.dart'; |
||||
import '../toolbar.dart'; |
||||
import 'quill_icon_button.dart'; |
||||
|
||||
class InsertEmbedButton extends StatelessWidget { |
||||
const InsertEmbedButton({ |
||||
required this.controller, |
||||
required this.icon, |
||||
this.iconSize = kDefaultIconSize, |
||||
this.fillColor, |
||||
Key? key, |
||||
}) : super(key: key); |
||||
|
||||
final QuillController controller; |
||||
final IconData icon; |
||||
final double iconSize; |
||||
final Color? fillColor; |
||||
|
||||
@override |
||||
Widget build(BuildContext context) { |
||||
return QuillIconButton( |
||||
highlightElevation: 0, |
||||
hoverElevation: 0, |
||||
size: iconSize * kIconButtonFactor, |
||||
icon: Icon( |
||||
icon, |
||||
size: iconSize, |
||||
color: Theme.of(context).iconTheme.color, |
||||
), |
||||
fillColor: fillColor ?? Theme.of(context).canvasColor, |
||||
onPressed: () { |
||||
final index = controller.selection.baseOffset; |
||||
final length = controller.selection.extentOffset - index; |
||||
controller.replaceText(index, length, BlockEmbed.horizontalRule, null); |
||||
}, |
||||
); |
||||
} |
||||
} |
@ -0,0 +1,122 @@ |
||||
import 'package:flutter/material.dart'; |
||||
|
||||
import '../../models/documents/attribute.dart'; |
||||
import '../controller.dart'; |
||||
import '../toolbar.dart'; |
||||
import 'quill_icon_button.dart'; |
||||
|
||||
class LinkStyleButton extends StatefulWidget { |
||||
const LinkStyleButton({ |
||||
required this.controller, |
||||
this.iconSize = kDefaultIconSize, |
||||
this.icon, |
||||
Key? key, |
||||
}) : super(key: key); |
||||
|
||||
final QuillController controller; |
||||
final IconData? icon; |
||||
final double iconSize; |
||||
|
||||
@override |
||||
_LinkStyleButtonState createState() => _LinkStyleButtonState(); |
||||
} |
||||
|
||||
class _LinkStyleButtonState extends State<LinkStyleButton> { |
||||
void _didChangeSelection() { |
||||
setState(() {}); |
||||
} |
||||
|
||||
@override |
||||
void initState() { |
||||
super.initState(); |
||||
widget.controller.addListener(_didChangeSelection); |
||||
} |
||||
|
||||
@override |
||||
void didUpdateWidget(covariant LinkStyleButton oldWidget) { |
||||
super.didUpdateWidget(oldWidget); |
||||
if (oldWidget.controller != widget.controller) { |
||||
oldWidget.controller.removeListener(_didChangeSelection); |
||||
widget.controller.addListener(_didChangeSelection); |
||||
} |
||||
} |
||||
|
||||
@override |
||||
void dispose() { |
||||
super.dispose(); |
||||
widget.controller.removeListener(_didChangeSelection); |
||||
} |
||||
|
||||
@override |
||||
Widget build(BuildContext context) { |
||||
final theme = Theme.of(context); |
||||
final isEnabled = !widget.controller.selection.isCollapsed; |
||||
final pressedHandler = isEnabled ? () => _openLinkDialog(context) : null; |
||||
return QuillIconButton( |
||||
highlightElevation: 0, |
||||
hoverElevation: 0, |
||||
size: widget.iconSize * kIconButtonFactor, |
||||
icon: Icon( |
||||
widget.icon ?? Icons.link, |
||||
size: widget.iconSize, |
||||
color: isEnabled ? theme.iconTheme.color : theme.disabledColor, |
||||
), |
||||
fillColor: Theme.of(context).canvasColor, |
||||
onPressed: pressedHandler, |
||||
); |
||||
} |
||||
|
||||
void _openLinkDialog(BuildContext context) { |
||||
showDialog<String>( |
||||
context: context, |
||||
builder: (ctx) { |
||||
return const _LinkDialog(); |
||||
}, |
||||
).then(_linkSubmitted); |
||||
} |
||||
|
||||
void _linkSubmitted(String? value) { |
||||
if (value == null || value.isEmpty) { |
||||
return; |
||||
} |
||||
widget.controller.formatSelection(LinkAttribute(value)); |
||||
} |
||||
} |
||||
|
||||
class _LinkDialog extends StatefulWidget { |
||||
const _LinkDialog({Key? key}) : super(key: key); |
||||
|
||||
@override |
||||
_LinkDialogState createState() => _LinkDialogState(); |
||||
} |
||||
|
||||
class _LinkDialogState extends State<_LinkDialog> { |
||||
String _link = ''; |
||||
|
||||
@override |
||||
Widget build(BuildContext context) { |
||||
return AlertDialog( |
||||
content: TextField( |
||||
decoration: const InputDecoration(labelText: 'Paste a link'), |
||||
autofocus: true, |
||||
onChanged: _linkChanged, |
||||
), |
||||
actions: [ |
||||
TextButton( |
||||
onPressed: _link.isNotEmpty ? _applyLink : null, |
||||
child: const Text('Apply'), |
||||
), |
||||
], |
||||
); |
||||
} |
||||
|
||||
void _linkChanged(String value) { |
||||
setState(() { |
||||
_link = value; |
||||
}); |
||||
} |
||||
|
||||
void _applyLink() { |
||||
Navigator.pop(context, _link); |
||||
} |
||||
} |
@ -0,0 +1,96 @@ |
||||
import 'package:flutter/material.dart'; |
||||
|
||||
class QuillDropdownButton<T> extends StatefulWidget { |
||||
const QuillDropdownButton({ |
||||
required this.child, |
||||
required this.initialValue, |
||||
required this.items, |
||||
required this.onSelected, |
||||
this.height = 40, |
||||
this.fillColor, |
||||
this.hoverElevation = 1, |
||||
this.highlightElevation = 1, |
||||
Key? key, |
||||
}) : super(key: key); |
||||
|
||||
final double height; |
||||
final Color? fillColor; |
||||
final double hoverElevation; |
||||
final double highlightElevation; |
||||
final Widget child; |
||||
final T initialValue; |
||||
final List<PopupMenuEntry<T>> items; |
||||
final ValueChanged<T> onSelected; |
||||
|
||||
@override |
||||
_QuillDropdownButtonState<T> createState() => _QuillDropdownButtonState<T>(); |
||||
} |
||||
|
||||
class _QuillDropdownButtonState<T> extends State<QuillDropdownButton<T>> { |
||||
@override |
||||
Widget build(BuildContext context) { |
||||
return ConstrainedBox( |
||||
constraints: BoxConstraints.tightFor(height: widget.height), |
||||
child: RawMaterialButton( |
||||
visualDensity: VisualDensity.compact, |
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(2)), |
||||
fillColor: widget.fillColor, |
||||
elevation: 0, |
||||
hoverElevation: widget.hoverElevation, |
||||
highlightElevation: widget.hoverElevation, |
||||
onPressed: _showMenu, |
||||
child: _buildContent(context), |
||||
), |
||||
); |
||||
} |
||||
|
||||
void _showMenu() { |
||||
final popupMenuTheme = PopupMenuTheme.of(context); |
||||
final button = context.findRenderObject() as RenderBox; |
||||
final overlay = |
||||
Overlay.of(context)!.context.findRenderObject() as RenderBox; |
||||
final position = RelativeRect.fromRect( |
||||
Rect.fromPoints( |
||||
button.localToGlobal(Offset.zero, ancestor: overlay), |
||||
button.localToGlobal(button.size.bottomLeft(Offset.zero), |
||||
ancestor: overlay), |
||||
), |
||||
Offset.zero & overlay.size, |
||||
); |
||||
showMenu<T>( |
||||
context: context, |
||||
elevation: 4, |
||||
// widget.elevation ?? popupMenuTheme.elevation, |
||||
initialValue: widget.initialValue, |
||||
items: widget.items, |
||||
position: position, |
||||
shape: popupMenuTheme.shape, |
||||
// widget.shape ?? popupMenuTheme.shape, |
||||
color: popupMenuTheme.color, // widget.color ?? popupMenuTheme.color, |
||||
// captureInheritedThemes: widget.captureInheritedThemes, |
||||
).then((newValue) { |
||||
if (!mounted) return null; |
||||
if (newValue == null) { |
||||
// if (widget.onCanceled != null) widget.onCanceled(); |
||||
return null; |
||||
} |
||||
widget.onSelected(newValue); |
||||
}); |
||||
} |
||||
|
||||
Widget _buildContent(BuildContext context) { |
||||
return ConstrainedBox( |
||||
constraints: const BoxConstraints.tightFor(width: 110), |
||||
child: Padding( |
||||
padding: const EdgeInsets.symmetric(horizontal: 8), |
||||
child: Row( |
||||
children: [ |
||||
widget.child, |
||||
Expanded(child: Container()), |
||||
const Icon(Icons.arrow_drop_down, size: 15) |
||||
], |
||||
), |
||||
), |
||||
); |
||||
} |
||||
} |
@ -0,0 +1,37 @@ |
||||
import 'package:flutter/material.dart'; |
||||
|
||||
class QuillIconButton extends StatelessWidget { |
||||
const QuillIconButton({ |
||||
required this.onPressed, |
||||
this.icon, |
||||
this.size = 40, |
||||
this.fillColor, |
||||
this.hoverElevation = 1, |
||||
this.highlightElevation = 1, |
||||
Key? key, |
||||
}) : super(key: key); |
||||
|
||||
final VoidCallback? onPressed; |
||||
final Widget? icon; |
||||
final double size; |
||||
final Color? fillColor; |
||||
final double hoverElevation; |
||||
final double highlightElevation; |
||||
|
||||
@override |
||||
Widget build(BuildContext context) { |
||||
return ConstrainedBox( |
||||
constraints: BoxConstraints.tightFor(width: size, height: size), |
||||
child: RawMaterialButton( |
||||
visualDensity: VisualDensity.compact, |
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(2)), |
||||
fillColor: fillColor, |
||||
elevation: 0, |
||||
hoverElevation: hoverElevation, |
||||
highlightElevation: hoverElevation, |
||||
onPressed: onPressed, |
||||
child: icon, |
||||
), |
||||
); |
||||
} |
||||
} |
@ -0,0 +1,122 @@ |
||||
import 'package:flutter/foundation.dart'; |
||||
import 'package:flutter/material.dart'; |
||||
|
||||
import '../../models/documents/attribute.dart'; |
||||
import '../../models/documents/style.dart'; |
||||
import '../controller.dart'; |
||||
import '../toolbar.dart'; |
||||
|
||||
class SelectHeaderStyleButton extends StatefulWidget { |
||||
const SelectHeaderStyleButton({ |
||||
required this.controller, |
||||
this.iconSize = kDefaultIconSize, |
||||
Key? key, |
||||
}) : super(key: key); |
||||
|
||||
final QuillController controller; |
||||
final double iconSize; |
||||
|
||||
@override |
||||
_SelectHeaderStyleButtonState createState() => |
||||
_SelectHeaderStyleButtonState(); |
||||
} |
||||
|
||||
class _SelectHeaderStyleButtonState extends State<SelectHeaderStyleButton> { |
||||
Attribute? _value; |
||||
|
||||
Style get _selectionStyle => widget.controller.getSelectionStyle(); |
||||
|
||||
@override |
||||
void initState() { |
||||
super.initState(); |
||||
setState(() { |
||||
_value = |
||||
_selectionStyle.attributes[Attribute.header.key] ?? Attribute.header; |
||||
}); |
||||
widget.controller.addListener(_didChangeEditingValue); |
||||
} |
||||
|
||||
@override |
||||
Widget build(BuildContext context) { |
||||
final _valueToText = <Attribute, String>{ |
||||
Attribute.header: 'N', |
||||
Attribute.h1: 'H1', |
||||
Attribute.h2: 'H2', |
||||
Attribute.h3: 'H3', |
||||
}; |
||||
|
||||
final _valueAttribute = <Attribute>[ |
||||
Attribute.header, |
||||
Attribute.h1, |
||||
Attribute.h2, |
||||
Attribute.h3 |
||||
]; |
||||
final _valueString = <String>['N', 'H1', 'H2', 'H3']; |
||||
|
||||
final theme = Theme.of(context); |
||||
final style = TextStyle( |
||||
fontWeight: FontWeight.w600, |
||||
fontSize: widget.iconSize * 0.7, |
||||
); |
||||
|
||||
return Row( |
||||
mainAxisSize: MainAxisSize.min, |
||||
children: List.generate(4, (index) { |
||||
return Padding( |
||||
padding: const EdgeInsets.symmetric(horizontal: !kIsWeb ? 1.0 : 5.0), |
||||
child: ConstrainedBox( |
||||
constraints: BoxConstraints.tightFor( |
||||
width: widget.iconSize * kIconButtonFactor, |
||||
height: widget.iconSize * kIconButtonFactor, |
||||
), |
||||
child: RawMaterialButton( |
||||
hoverElevation: 0, |
||||
highlightElevation: 0, |
||||
elevation: 0, |
||||
visualDensity: VisualDensity.compact, |
||||
shape: RoundedRectangleBorder( |
||||
borderRadius: BorderRadius.circular(2)), |
||||
fillColor: _valueToText[_value] == _valueString[index] |
||||
? theme.toggleableActiveColor |
||||
: theme.canvasColor, |
||||
onPressed: () => |
||||
widget.controller.formatSelection(_valueAttribute[index]), |
||||
child: Text( |
||||
_valueString[index], |
||||
style: style.copyWith( |
||||
color: _valueToText[_value] == _valueString[index] |
||||
? theme.primaryIconTheme.color |
||||
: theme.iconTheme.color, |
||||
), |
||||
), |
||||
), |
||||
), |
||||
); |
||||
}), |
||||
); |
||||
} |
||||
|
||||
void _didChangeEditingValue() { |
||||
setState(() { |
||||
_value = |
||||
_selectionStyle.attributes[Attribute.header.key] ?? Attribute.header; |
||||
}); |
||||
} |
||||
|
||||
@override |
||||
void didUpdateWidget(covariant SelectHeaderStyleButton oldWidget) { |
||||
super.didUpdateWidget(oldWidget); |
||||
if (oldWidget.controller != widget.controller) { |
||||
oldWidget.controller.removeListener(_didChangeEditingValue); |
||||
widget.controller.addListener(_didChangeEditingValue); |
||||
_value = |
||||
_selectionStyle.attributes[Attribute.header.key] ?? Attribute.header; |
||||
} |
||||
} |
||||
|
||||
@override |
||||
void dispose() { |
||||
widget.controller.removeListener(_didChangeEditingValue); |
||||
super.dispose(); |
||||
} |
||||
} |
@ -0,0 +1,104 @@ |
||||
import 'package:flutter/material.dart'; |
||||
|
||||
import '../../models/documents/attribute.dart'; |
||||
import '../../models/documents/style.dart'; |
||||
import '../controller.dart'; |
||||
import '../toolbar.dart'; |
||||
import 'toggle_style_button.dart'; |
||||
|
||||
class ToggleCheckListButton extends StatefulWidget { |
||||
const ToggleCheckListButton({ |
||||
required this.icon, |
||||
required this.controller, |
||||
required this.attribute, |
||||
this.iconSize = kDefaultIconSize, |
||||
this.fillColor, |
||||
this.childBuilder = defaultToggleStyleButtonBuilder, |
||||
Key? key, |
||||
}) : super(key: key); |
||||
|
||||
final IconData icon; |
||||
final double iconSize; |
||||
|
||||
final Color? fillColor; |
||||
|
||||
final QuillController controller; |
||||
|
||||
final ToggleStyleButtonBuilder childBuilder; |
||||
|
||||
final Attribute attribute; |
||||
|
||||
@override |
||||
_ToggleCheckListButtonState createState() => _ToggleCheckListButtonState(); |
||||
} |
||||
|
||||
class _ToggleCheckListButtonState extends State<ToggleCheckListButton> { |
||||
bool? _isToggled; |
||||
|
||||
Style get _selectionStyle => widget.controller.getSelectionStyle(); |
||||
|
||||
void _didChangeEditingValue() { |
||||
setState(() { |
||||
_isToggled = |
||||
_getIsToggled(widget.controller.getSelectionStyle().attributes); |
||||
}); |
||||
} |
||||
|
||||
@override |
||||
void initState() { |
||||
super.initState(); |
||||
_isToggled = _getIsToggled(_selectionStyle.attributes); |
||||
widget.controller.addListener(_didChangeEditingValue); |
||||
} |
||||
|
||||
bool _getIsToggled(Map<String, Attribute> attrs) { |
||||
if (widget.attribute.key == Attribute.list.key) { |
||||
final attribute = attrs[widget.attribute.key]; |
||||
if (attribute == null) { |
||||
return false; |
||||
} |
||||
return attribute.value == widget.attribute.value || |
||||
attribute.value == Attribute.checked.value; |
||||
} |
||||
return attrs.containsKey(widget.attribute.key); |
||||
} |
||||
|
||||
@override |
||||
void didUpdateWidget(covariant ToggleCheckListButton oldWidget) { |
||||
super.didUpdateWidget(oldWidget); |
||||
if (oldWidget.controller != widget.controller) { |
||||
oldWidget.controller.removeListener(_didChangeEditingValue); |
||||
widget.controller.addListener(_didChangeEditingValue); |
||||
_isToggled = _getIsToggled(_selectionStyle.attributes); |
||||
} |
||||
} |
||||
|
||||
@override |
||||
void dispose() { |
||||
widget.controller.removeListener(_didChangeEditingValue); |
||||
super.dispose(); |
||||
} |
||||
|
||||
@override |
||||
Widget build(BuildContext context) { |
||||
final isInCodeBlock = |
||||
_selectionStyle.attributes.containsKey(Attribute.codeBlock.key); |
||||
final isEnabled = |
||||
!isInCodeBlock || Attribute.list.key == Attribute.codeBlock.key; |
||||
return widget.childBuilder( |
||||
context, |
||||
Attribute.unchecked, |
||||
widget.icon, |
||||
widget.fillColor, |
||||
_isToggled, |
||||
isEnabled ? _toggleAttribute : null, |
||||
widget.iconSize, |
||||
); |
||||
} |
||||
|
||||
void _toggleAttribute() { |
||||
widget.controller.formatSelection(_isToggled! |
||||
? Attribute.clone(Attribute.unchecked, null) |
||||
: Attribute.unchecked); |
||||
} |
||||
} |
@ -0,0 +1,139 @@ |
||||
import 'package:flutter/material.dart'; |
||||
|
||||
import '../../models/documents/attribute.dart'; |
||||
import '../../models/documents/style.dart'; |
||||
import '../controller.dart'; |
||||
import '../toolbar.dart'; |
||||
import 'quill_icon_button.dart'; |
||||
|
||||
typedef ToggleStyleButtonBuilder = Widget Function( |
||||
BuildContext context, |
||||
Attribute attribute, |
||||
IconData icon, |
||||
Color? fillColor, |
||||
bool? isToggled, |
||||
VoidCallback? onPressed, [ |
||||
double iconSize, |
||||
]); |
||||
|
||||
class ToggleStyleButton extends StatefulWidget { |
||||
const ToggleStyleButton({ |
||||
required this.attribute, |
||||
required this.icon, |
||||
required this.controller, |
||||
this.iconSize = kDefaultIconSize, |
||||
this.fillColor, |
||||
this.childBuilder = defaultToggleStyleButtonBuilder, |
||||
Key? key, |
||||
}) : super(key: key); |
||||
|
||||
final Attribute attribute; |
||||
|
||||
final IconData icon; |
||||
final double iconSize; |
||||
|
||||
final Color? fillColor; |
||||
|
||||
final QuillController controller; |
||||
|
||||
final ToggleStyleButtonBuilder childBuilder; |
||||
|
||||
@override |
||||
_ToggleStyleButtonState createState() => _ToggleStyleButtonState(); |
||||
} |
||||
|
||||
class _ToggleStyleButtonState extends State<ToggleStyleButton> { |
||||
bool? _isToggled; |
||||
|
||||
Style get _selectionStyle => widget.controller.getSelectionStyle(); |
||||
|
||||
@override |
||||
void initState() { |
||||
super.initState(); |
||||
_isToggled = _getIsToggled(_selectionStyle.attributes); |
||||
widget.controller.addListener(_didChangeEditingValue); |
||||
} |
||||
|
||||
@override |
||||
Widget build(BuildContext context) { |
||||
final isInCodeBlock = |
||||
_selectionStyle.attributes.containsKey(Attribute.codeBlock.key); |
||||
final isEnabled = |
||||
!isInCodeBlock || widget.attribute.key == Attribute.codeBlock.key; |
||||
return widget.childBuilder( |
||||
context, |
||||
widget.attribute, |
||||
widget.icon, |
||||
widget.fillColor, |
||||
_isToggled, |
||||
isEnabled ? _toggleAttribute : null, |
||||
widget.iconSize, |
||||
); |
||||
} |
||||
|
||||
@override |
||||
void didUpdateWidget(covariant ToggleStyleButton oldWidget) { |
||||
super.didUpdateWidget(oldWidget); |
||||
if (oldWidget.controller != widget.controller) { |
||||
oldWidget.controller.removeListener(_didChangeEditingValue); |
||||
widget.controller.addListener(_didChangeEditingValue); |
||||
_isToggled = _getIsToggled(_selectionStyle.attributes); |
||||
} |
||||
} |
||||
|
||||
@override |
||||
void dispose() { |
||||
widget.controller.removeListener(_didChangeEditingValue); |
||||
super.dispose(); |
||||
} |
||||
|
||||
void _didChangeEditingValue() { |
||||
setState(() => _isToggled = _getIsToggled(_selectionStyle.attributes)); |
||||
} |
||||
|
||||
bool _getIsToggled(Map<String, Attribute> attrs) { |
||||
if (widget.attribute.key == Attribute.list.key) { |
||||
final attribute = attrs[widget.attribute.key]; |
||||
if (attribute == null) { |
||||
return false; |
||||
} |
||||
return attribute.value == widget.attribute.value; |
||||
} |
||||
return attrs.containsKey(widget.attribute.key); |
||||
} |
||||
|
||||
void _toggleAttribute() { |
||||
widget.controller.formatSelection(_isToggled! |
||||
? Attribute.clone(widget.attribute, null) |
||||
: widget.attribute); |
||||
} |
||||
} |
||||
|
||||
Widget defaultToggleStyleButtonBuilder( |
||||
BuildContext context, |
||||
Attribute attribute, |
||||
IconData icon, |
||||
Color? fillColor, |
||||
bool? isToggled, |
||||
VoidCallback? onPressed, [ |
||||
double iconSize = kDefaultIconSize, |
||||
]) { |
||||
final theme = Theme.of(context); |
||||
final isEnabled = onPressed != null; |
||||
final iconColor = isEnabled |
||||
? isToggled == true |
||||
? theme.primaryIconTheme.color |
||||
: theme.iconTheme.color |
||||
: theme.disabledColor; |
||||
final fill = isToggled == true |
||||
? theme.toggleableActiveColor |
||||
: fillColor ?? theme.canvasColor; |
||||
return QuillIconButton( |
||||
highlightElevation: 0, |
||||
hoverElevation: 0, |
||||
size: iconSize * kIconButtonFactor, |
||||
icon: Icon(icon, size: iconSize, color: iconColor), |
||||
fillColor: fill, |
||||
onPressed: onPressed, |
||||
); |
||||
} |
@ -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}) { |
||||
assert(data is String || data is Embeddable); |
||||
|
||||
Delta? delta; |
||||
if (len > 0 || data is! String || data.isNotEmpty) { |
||||
delta = document.replace(index, len, data); |
||||
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,106 +1,3 @@ |
||||
import 'package:flutter/foundation.dart'; |
||||
import 'package:flutter/services.dart'; |
||||
import 'package:flutter/widgets.dart'; |
||||
|
||||
enum InputShortcut { CUT, COPY, PASTE, SELECT_ALL } |
||||
|
||||
typedef CursorMoveCallback = void Function( |
||||
LogicalKeyboardKey key, bool wordModifier, bool lineModifier, bool shift); |
||||
typedef InputShortcutCallback = void Function(InputShortcut? shortcut); |
||||
typedef OnDeleteCallback = void Function(bool forward); |
||||
|
||||
class KeyboardListener { |
||||
KeyboardListener(this.onCursorMove, this.onShortcut, this.onDelete); |
||||
|
||||
final CursorMoveCallback onCursorMove; |
||||
final InputShortcutCallback onShortcut; |
||||
final OnDeleteCallback onDelete; |
||||
|
||||
static final Set<LogicalKeyboardKey> _moveKeys = <LogicalKeyboardKey>{ |
||||
LogicalKeyboardKey.arrowRight, |
||||
LogicalKeyboardKey.arrowLeft, |
||||
LogicalKeyboardKey.arrowUp, |
||||
LogicalKeyboardKey.arrowDown, |
||||
}; |
||||
|
||||
static final Set<LogicalKeyboardKey> _shortcutKeys = <LogicalKeyboardKey>{ |
||||
LogicalKeyboardKey.keyA, |
||||
LogicalKeyboardKey.keyC, |
||||
LogicalKeyboardKey.keyV, |
||||
LogicalKeyboardKey.keyX, |
||||
LogicalKeyboardKey.delete, |
||||
LogicalKeyboardKey.backspace, |
||||
}; |
||||
|
||||
static final Set<LogicalKeyboardKey> _nonModifierKeys = <LogicalKeyboardKey>{ |
||||
..._shortcutKeys, |
||||
..._moveKeys, |
||||
}; |
||||
|
||||
static final Set<LogicalKeyboardKey> _modifierKeys = <LogicalKeyboardKey>{ |
||||
LogicalKeyboardKey.shift, |
||||
LogicalKeyboardKey.control, |
||||
LogicalKeyboardKey.alt, |
||||
}; |
||||
|
||||
static final Set<LogicalKeyboardKey> _macOsModifierKeys = |
||||
<LogicalKeyboardKey>{ |
||||
LogicalKeyboardKey.shift, |
||||
LogicalKeyboardKey.meta, |
||||
LogicalKeyboardKey.alt, |
||||
}; |
||||
|
||||
static final Set<LogicalKeyboardKey> _interestingKeys = <LogicalKeyboardKey>{ |
||||
..._modifierKeys, |
||||
..._macOsModifierKeys, |
||||
..._nonModifierKeys, |
||||
}; |
||||
|
||||
static final Map<LogicalKeyboardKey, InputShortcut> _keyToShortcut = { |
||||
LogicalKeyboardKey.keyX: InputShortcut.CUT, |
||||
LogicalKeyboardKey.keyC: InputShortcut.COPY, |
||||
LogicalKeyboardKey.keyV: InputShortcut.PASTE, |
||||
LogicalKeyboardKey.keyA: InputShortcut.SELECT_ALL, |
||||
}; |
||||
|
||||
KeyEventResult handleRawKeyEvent(RawKeyEvent event) { |
||||
if (kIsWeb) { |
||||
// On web platform, we should ignore the key because it's processed already. |
||||
return KeyEventResult.ignored; |
||||
} |
||||
|
||||
if (event is! RawKeyDownEvent) { |
||||
return KeyEventResult.ignored; |
||||
} |
||||
|
||||
final keysPressed = |
||||
LogicalKeyboardKey.collapseSynonyms(RawKeyboard.instance.keysPressed); |
||||
final key = event.logicalKey; |
||||
final isMacOS = event.data is RawKeyEventDataMacOs; |
||||
if (!_nonModifierKeys.contains(key) || |
||||
keysPressed |
||||
.difference(isMacOS ? _macOsModifierKeys : _modifierKeys) |
||||
.length > |
||||
1 || |
||||
keysPressed.difference(_interestingKeys).isNotEmpty) { |
||||
return KeyEventResult.ignored; |
||||
} |
||||
|
||||
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 KeyEventResult.ignored; |
||||
} |
||||
} |
||||
/// 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'; |
||||
|
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,3 @@ |
||||
/// 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'; |
@ -0,0 +1,3 @@ |
||||
/// 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'; |
@ -0,0 +1,3 @@ |
||||
/// 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'; |
@ -0,0 +1,3 @@ |
||||
/// 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,731 +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, |
||||
); |
||||
|
||||
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; |
||||
|
||||
@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( |
||||
style: defaultStyles!.leading!.style, width: 32, isChecked: true); |
||||
} |
||||
|
||||
if (attrs[Attribute.list.key] == Attribute.unchecked) { |
||||
return _Checkbox( |
||||
style: defaultStyles!.leading!.style, width: 32, isChecked: false); |
||||
} |
||||
|
||||
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 StatefulWidget { |
||||
const _Checkbox({Key? key, this.style, this.width, this.isChecked}) |
||||
: super(key: key); |
||||
|
||||
final TextStyle? style; |
||||
final double? width; |
||||
final bool? isChecked; |
||||
|
||||
@override |
||||
__CheckboxState createState() => __CheckboxState(); |
||||
} |
||||
|
||||
class __CheckboxState extends State<_Checkbox> { |
||||
bool? isChecked; |
||||
|
||||
void _onCheckboxClicked(bool? newValue) => setState(() { |
||||
isChecked = newValue; |
||||
|
||||
if (isChecked!) { |
||||
// check list |
||||
} else { |
||||
// uncheck list |
||||
} |
||||
}); |
||||
|
||||
@override |
||||
void initState() { |
||||
super.initState(); |
||||
isChecked = widget.isChecked; |
||||
} |
||||
|
||||
@override |
||||
Widget build(BuildContext context) { |
||||
return Container( |
||||
alignment: AlignmentDirectional.topEnd, |
||||
width: widget.width, |
||||
padding: const EdgeInsetsDirectional.only(end: 13), |
||||
child: Checkbox( |
||||
value: widget.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,729 +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.userUpdateTextEditingValue( |
||||
value.copyWith(selection: newSelection, composing: TextRange.empty), |
||||
SelectionChangedCause.drag, |
||||
); |
||||
|
||||
selectionDelegate.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