mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2024-08-30 18:12:39 +00:00
refactor: move text_delta to core/document
This commit is contained in:
parent
b9788bce09
commit
e095fd4181
@ -8,7 +8,7 @@ export 'src/core/document/path.dart';
|
||||
export 'src/document/position.dart';
|
||||
export 'src/document/selection.dart';
|
||||
export 'src/document/state_tree.dart';
|
||||
export 'src/document/text_delta.dart';
|
||||
export 'src/core/document/text_delta.dart';
|
||||
export 'src/core/document/attributes.dart';
|
||||
export 'src/document/built_in_attribute_keys.dart';
|
||||
export 'src/editor_state.dart';
|
||||
|
@ -5,7 +5,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:appflowy_editor/src/core/document/attributes.dart';
|
||||
import 'package:appflowy_editor/src/core/document/path.dart';
|
||||
import 'package:appflowy_editor/src/document/built_in_attribute_keys.dart';
|
||||
import 'package:appflowy_editor/src/document/text_delta.dart';
|
||||
import 'package:appflowy_editor/src/core/document/text_delta.dart';
|
||||
|
||||
class Node extends ChangeNotifier with LinkedListEntry<Node> {
|
||||
Node({
|
||||
@ -232,7 +232,7 @@ class TextNode extends Node {
|
||||
);
|
||||
|
||||
TextNode.empty({Attributes? attributes})
|
||||
: _delta = Delta([TextInsert('')]),
|
||||
: _delta = Delta(operations: [TextInsert('')]),
|
||||
super(
|
||||
type: 'text',
|
||||
attributes: attributes ?? {},
|
||||
|
@ -1,170 +1,472 @@
|
||||
import 'dart:collection';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:appflowy_editor/src/core/document/attributes.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'package:appflowy_editor/src/core/document/attributes.dart';
|
||||
|
||||
// constant number: 2^53 - 1
|
||||
const int _maxInt = 9007199254740991;
|
||||
|
||||
abstract class TextOperation {
|
||||
bool get isEmpty => length == 0;
|
||||
List<int> stringIndexes(String text) {
|
||||
final indexes = List<int>.filled(text.length, 0);
|
||||
final iterator = text.runes.iterator;
|
||||
|
||||
while (iterator.moveNext()) {
|
||||
for (var i = 0; i < iterator.currentSize; i++) {
|
||||
indexes[iterator.rawIndex + i] = iterator.rawIndex;
|
||||
}
|
||||
}
|
||||
|
||||
return indexes;
|
||||
}
|
||||
|
||||
abstract class TextOperation {
|
||||
Attributes? get attributes;
|
||||
int get length;
|
||||
|
||||
Attributes? get attributes => null;
|
||||
bool get isEmpty => length == 0;
|
||||
|
||||
Map<String, dynamic> toJson();
|
||||
}
|
||||
|
||||
class TextInsert extends TextOperation {
|
||||
String content;
|
||||
TextInsert(
|
||||
this.text, {
|
||||
Attributes? attributes,
|
||||
}) : _attributes = attributes;
|
||||
|
||||
String text;
|
||||
final Attributes? _attributes;
|
||||
|
||||
TextInsert(this.content, [Attributes? attrs]) : _attributes = attrs;
|
||||
@override
|
||||
int get length => text.length;
|
||||
|
||||
@override
|
||||
int get length {
|
||||
return content.length;
|
||||
}
|
||||
Attributes? get attributes => _attributes != null ? {..._attributes!} : null;
|
||||
|
||||
@override
|
||||
Attributes? get attributes {
|
||||
return _attributes;
|
||||
Map<String, dynamic> toJson() {
|
||||
final result = <String, dynamic>{
|
||||
'insert': text,
|
||||
};
|
||||
if (_attributes != null) {
|
||||
result['attributes'] = attributes;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (other is! TextInsert) {
|
||||
return false;
|
||||
}
|
||||
return content == other.content &&
|
||||
if (identical(this, other)) return true;
|
||||
|
||||
return other is TextInsert &&
|
||||
other.text == text &&
|
||||
mapEquals(_attributes, other._attributes);
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
final contentHash = content.hashCode;
|
||||
final attrs = _attributes;
|
||||
return Object.hash(
|
||||
contentHash,
|
||||
attrs != null ? hashAttributes(attrs) : null,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
final result = <String, dynamic>{
|
||||
'insert': content,
|
||||
};
|
||||
final attrs = _attributes;
|
||||
if (attrs != null) {
|
||||
result['attributes'] = {...attrs};
|
||||
}
|
||||
return result;
|
||||
}
|
||||
int get hashCode => text.hashCode ^ _attributes.hashCode;
|
||||
}
|
||||
|
||||
class TextRetain extends TextOperation {
|
||||
int _length;
|
||||
TextRetain(
|
||||
this.length, {
|
||||
Attributes? attributes,
|
||||
}) : _attributes = attributes;
|
||||
|
||||
@override
|
||||
int length;
|
||||
final Attributes? _attributes;
|
||||
|
||||
TextRetain(length, [Attributes? attributes])
|
||||
: _length = length,
|
||||
_attributes = attributes;
|
||||
|
||||
@override
|
||||
bool get isEmpty {
|
||||
return length == 0;
|
||||
}
|
||||
|
||||
@override
|
||||
int get length {
|
||||
return _length;
|
||||
}
|
||||
|
||||
set length(int v) {
|
||||
_length = v;
|
||||
}
|
||||
|
||||
@override
|
||||
Attributes? get attributes {
|
||||
return _attributes;
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (other is! TextRetain) {
|
||||
return false;
|
||||
}
|
||||
return _length == other.length && mapEquals(_attributes, other._attributes);
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
final attrs = _attributes;
|
||||
return Object.hash(
|
||||
_length,
|
||||
attrs != null ? hashAttributes(attrs) : null,
|
||||
);
|
||||
}
|
||||
Attributes? get attributes => _attributes != null ? {..._attributes!} : null;
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
final result = <String, dynamic>{
|
||||
'retain': _length,
|
||||
'retain': length,
|
||||
};
|
||||
final attrs = _attributes;
|
||||
if (attrs != null) {
|
||||
result['attributes'] = {...attrs};
|
||||
if (_attributes != null) {
|
||||
result['attributes'] = attributes;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
class TextDelete extends TextOperation {
|
||||
int _length;
|
||||
|
||||
TextDelete(int length) : _length = length;
|
||||
|
||||
@override
|
||||
int get length {
|
||||
return _length;
|
||||
}
|
||||
|
||||
set length(int v) {
|
||||
_length = v;
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (other is! TextDelete) {
|
||||
return false;
|
||||
}
|
||||
return _length == other.length;
|
||||
if (identical(this, other)) return true;
|
||||
|
||||
return other is TextRetain &&
|
||||
other.length == length &&
|
||||
mapEquals(_attributes, other._attributes);
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return _length.hashCode;
|
||||
}
|
||||
int get hashCode => length.hashCode ^ _attributes.hashCode;
|
||||
}
|
||||
|
||||
class TextDelete extends TextOperation {
|
||||
TextDelete({
|
||||
required this.length,
|
||||
});
|
||||
|
||||
@override
|
||||
int length;
|
||||
|
||||
@override
|
||||
Attributes? get attributes => null;
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'delete': _length,
|
||||
'delete': length,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
|
||||
return other is TextDelete && other.length == length;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => length.hashCode;
|
||||
}
|
||||
|
||||
/// Deltas are a simple, yet expressive format that can be used to describe contents and changes.
|
||||
/// The format is JSON based, and is human readable, yet easily parsible by machines.
|
||||
/// Deltas can describe any rich text document, includes all text and formatting information, without the ambiguity and complexity of HTML.
|
||||
///
|
||||
|
||||
/// Basically borrowed from: https://github.com/quilljs/delta
|
||||
class Delta extends Iterable<TextOperation> {
|
||||
Delta({
|
||||
List<TextOperation>? operations,
|
||||
}) : _operations = operations ?? <TextOperation>[];
|
||||
|
||||
factory Delta.fromJson(List<dynamic> list) {
|
||||
final operations = <TextOperation>[];
|
||||
|
||||
for (final value in list) {
|
||||
if (value is Map<String, dynamic>) {
|
||||
final op = _textOperationFromJson(value);
|
||||
if (op != null) {
|
||||
operations.add(op);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Delta(operations: operations);
|
||||
}
|
||||
|
||||
final List<TextOperation> _operations;
|
||||
String? _plainText;
|
||||
List<int>? _runeIndexes;
|
||||
|
||||
void addAll(Iterable<TextOperation> textOperations) {
|
||||
textOperations.forEach(add);
|
||||
}
|
||||
|
||||
void add(TextOperation textOperation) {
|
||||
if (textOperation.isEmpty) {
|
||||
return;
|
||||
}
|
||||
_plainText = null;
|
||||
|
||||
if (_operations.isNotEmpty) {
|
||||
final lastOp = _operations.last;
|
||||
if (lastOp is TextDelete && textOperation is TextDelete) {
|
||||
lastOp.length += textOperation.length;
|
||||
return;
|
||||
}
|
||||
if (mapEquals(lastOp.attributes, textOperation.attributes)) {
|
||||
if (lastOp is TextInsert && textOperation is TextInsert) {
|
||||
lastOp.text += textOperation.text;
|
||||
return;
|
||||
}
|
||||
// if there is an delete before the insert
|
||||
// swap the order
|
||||
if (lastOp is TextDelete && textOperation is TextInsert) {
|
||||
_operations.removeLast();
|
||||
_operations.add(textOperation);
|
||||
_operations.add(lastOp);
|
||||
return;
|
||||
}
|
||||
if (lastOp is TextRetain && textOperation is TextRetain) {
|
||||
lastOp.length += textOperation.length;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_operations.add(textOperation);
|
||||
}
|
||||
|
||||
/// The slice() method does not change the original string.
|
||||
/// The start and end parameters specifies the part of the string to extract.
|
||||
/// The end position is optional.
|
||||
Delta slice(int start, [int? end]) {
|
||||
final result = Delta();
|
||||
final iterator = _OpIterator(_operations);
|
||||
int index = 0;
|
||||
|
||||
while ((end == null || index < end) && iterator.hasNext) {
|
||||
TextOperation? nextOp;
|
||||
if (index < start) {
|
||||
nextOp = iterator._next(start - index);
|
||||
} else {
|
||||
nextOp = iterator._next(end == null ? null : end - index);
|
||||
result.add(nextOp);
|
||||
}
|
||||
|
||||
index += nextOp.length;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Insert operations have an `insert` key defined.
|
||||
/// A String value represents inserting text.
|
||||
void insert(String text, {Attributes? attributes}) =>
|
||||
add(TextInsert(text, attributes: attributes));
|
||||
|
||||
/// Retain operations have a Number `retain` key defined representing the number of characters to keep (other libraries might use the name keep or skip).
|
||||
/// An optional `attributes` key can be defined with an Object to describe formatting changes to the character range.
|
||||
/// A value of `null` in the `attributes` Object represents removal of that key.
|
||||
///
|
||||
/// *Note: It is not necessary to retain the last characters of a document as this is implied.*
|
||||
void retain(int length, {Attributes? attributes}) =>
|
||||
add(TextRetain(length, attributes: attributes));
|
||||
|
||||
/// Delete operations have a Number `delete` key defined representing the number of characters to delete.
|
||||
void delete(int length) => add(TextDelete(length: length));
|
||||
|
||||
/// The length of the string fo the [Delta].
|
||||
@override
|
||||
int get length {
|
||||
return _operations.fold(
|
||||
0, (previousValue, element) => previousValue + element.length);
|
||||
}
|
||||
|
||||
/// Returns a Delta that is equivalent to applying the operations of own Delta, followed by another Delta.
|
||||
Delta compose(Delta other) {
|
||||
final thisIter = _OpIterator(_operations);
|
||||
final otherIter = _OpIterator(other._operations);
|
||||
final operations = <TextOperation>[];
|
||||
|
||||
final firstOther = otherIter.peek();
|
||||
if (firstOther != null &&
|
||||
firstOther is TextRetain &&
|
||||
firstOther.attributes == null) {
|
||||
int firstLeft = firstOther.length;
|
||||
while (
|
||||
thisIter.peek() is TextInsert && thisIter.peekLength() <= firstLeft) {
|
||||
firstLeft -= thisIter.peekLength();
|
||||
final next = thisIter._next();
|
||||
operations.add(next);
|
||||
}
|
||||
if (firstOther.length - firstLeft > 0) {
|
||||
otherIter._next(firstOther.length - firstLeft);
|
||||
}
|
||||
}
|
||||
|
||||
final delta = Delta(operations: operations);
|
||||
while (thisIter.hasNext || otherIter.hasNext) {
|
||||
if (otherIter.peek() is TextInsert) {
|
||||
final next = otherIter._next();
|
||||
delta.add(next);
|
||||
} else if (thisIter.peek() is TextDelete) {
|
||||
final next = thisIter._next();
|
||||
delta.add(next);
|
||||
} else {
|
||||
// otherIs
|
||||
final length = min(thisIter.peekLength(), otherIter.peekLength());
|
||||
final thisOp = thisIter._next(length);
|
||||
final otherOp = otherIter._next(length);
|
||||
final attributes = composeAttributes(
|
||||
thisOp.attributes,
|
||||
otherOp.attributes,
|
||||
keepNull: thisOp is TextRetain,
|
||||
);
|
||||
|
||||
if (otherOp is TextRetain && otherOp.length > 0) {
|
||||
TextOperation? newOp;
|
||||
if (thisOp is TextRetain) {
|
||||
newOp = TextRetain(length, attributes: attributes);
|
||||
} else if (thisOp is TextInsert) {
|
||||
newOp = TextInsert(thisOp.text, attributes: attributes);
|
||||
}
|
||||
|
||||
if (newOp != null) {
|
||||
delta.add(newOp);
|
||||
}
|
||||
|
||||
// Optimization if rest of other is just retain
|
||||
if (!otherIter.hasNext &&
|
||||
delta._operations.isNotEmpty &&
|
||||
delta._operations.last == newOp) {
|
||||
final rest = Delta(operations: thisIter.rest());
|
||||
return (delta + rest)..chop();
|
||||
}
|
||||
} else if (otherOp is TextDelete && (thisOp is TextRetain)) {
|
||||
delta.add(otherOp);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return delta..chop();
|
||||
}
|
||||
|
||||
/// This method joins two Delta together.
|
||||
Delta operator +(Delta other) {
|
||||
var operations = [..._operations];
|
||||
if (other._operations.isNotEmpty) {
|
||||
operations.add(other._operations[0]);
|
||||
operations.addAll(other._operations.sublist(1));
|
||||
}
|
||||
return Delta(operations: operations);
|
||||
}
|
||||
|
||||
void chop() {
|
||||
if (_operations.isEmpty) {
|
||||
return;
|
||||
}
|
||||
_plainText = null;
|
||||
final lastOp = _operations.last;
|
||||
if (lastOp is TextRetain && (lastOp.attributes?.length ?? 0) == 0) {
|
||||
_operations.removeLast();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (other is! Delta) {
|
||||
return false;
|
||||
}
|
||||
return listEquals(_operations, other._operations);
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return Object.hashAll(_operations);
|
||||
}
|
||||
|
||||
/// Returned an inverted delta that has the opposite effect of against a base document delta.
|
||||
Delta invert(Delta base) {
|
||||
final inverted = Delta();
|
||||
_operations.fold(0, (int previousValue, op) {
|
||||
if (op is TextInsert) {
|
||||
inverted.delete(op.length);
|
||||
} else if (op is TextRetain && op.attributes == null) {
|
||||
inverted.retain(op.length);
|
||||
return previousValue + op.length;
|
||||
} else if (op is TextDelete || op is TextRetain) {
|
||||
final length = op.length;
|
||||
final slice = base.slice(previousValue, previousValue + length);
|
||||
for (final baseOp in slice._operations) {
|
||||
if (op is TextDelete) {
|
||||
inverted.add(baseOp);
|
||||
} else if (op is TextRetain && op.attributes != null) {
|
||||
inverted.retain(
|
||||
baseOp.length,
|
||||
attributes: invertAttributes(baseOp.attributes, op.attributes),
|
||||
);
|
||||
}
|
||||
}
|
||||
return previousValue + length;
|
||||
}
|
||||
return previousValue;
|
||||
});
|
||||
return inverted..chop();
|
||||
}
|
||||
|
||||
List<dynamic> toJson() {
|
||||
return _operations.map((e) => e.toJson()).toList();
|
||||
}
|
||||
|
||||
/// This method will return the position of the previous rune.
|
||||
///
|
||||
/// Since the encoding of the [String] in Dart is UTF-16.
|
||||
/// If you want to find the previous character of a position,
|
||||
/// you can' just use the `position - 1` simply.
|
||||
///
|
||||
/// This method can help you to compute the position of the previous character.
|
||||
int prevRunePosition(int pos) {
|
||||
if (pos == 0) {
|
||||
return pos - 1;
|
||||
}
|
||||
_plainText ??=
|
||||
_operations.whereType<TextInsert>().map((op) => op.text).join();
|
||||
_runeIndexes ??= stringIndexes(_plainText!);
|
||||
return _runeIndexes![pos - 1];
|
||||
}
|
||||
|
||||
/// This method will return the position of the next rune.
|
||||
///
|
||||
/// Since the encoding of the [String] in Dart is UTF-16.
|
||||
/// If you want to find the previous character of a position,
|
||||
/// you can' just use the `position + 1` simply.
|
||||
///
|
||||
/// This method can help you to compute the position of the next character.
|
||||
int nextRunePosition(int pos) {
|
||||
final stringContent = toPlainText();
|
||||
if (pos >= stringContent.length - 1) {
|
||||
return stringContent.length;
|
||||
}
|
||||
_runeIndexes ??= stringIndexes(_plainText!);
|
||||
|
||||
for (var i = pos + 1; i < _runeIndexes!.length; i++) {
|
||||
if (_runeIndexes![i] != pos) {
|
||||
return _runeIndexes![i];
|
||||
}
|
||||
}
|
||||
|
||||
return stringContent.length;
|
||||
}
|
||||
|
||||
String toPlainText() {
|
||||
_plainText ??=
|
||||
_operations.whereType<TextInsert>().map((op) => op.text).join();
|
||||
return _plainText!;
|
||||
}
|
||||
|
||||
@override
|
||||
Iterator<TextOperation> get iterator => _operations.iterator;
|
||||
|
||||
static TextOperation? _textOperationFromJson(Map<String, dynamic> json) {
|
||||
TextOperation? operation;
|
||||
|
||||
if (json['insert'] is String) {
|
||||
final attributes = json['attributes'] as Map<String, dynamic>?;
|
||||
operation = TextInsert(
|
||||
json['insert'] as String,
|
||||
attributes: attributes != null ? {...attributes} : null,
|
||||
);
|
||||
} else if (json['retain'] is int) {
|
||||
final attrs = json['attributes'] as Map<String, dynamic>?;
|
||||
operation = TextRetain(
|
||||
json['retain'] as int,
|
||||
attributes: attrs != null ? {...attrs} : null,
|
||||
);
|
||||
} else if (json['delete'] is int) {
|
||||
operation = TextDelete(length: json['delete'] as int);
|
||||
}
|
||||
|
||||
return operation;
|
||||
}
|
||||
}
|
||||
|
||||
class _OpIterator {
|
||||
_OpIterator(
|
||||
Iterable<TextOperation> operations,
|
||||
) : _operations = UnmodifiableListView(operations);
|
||||
|
||||
final UnmodifiableListView<TextOperation> _operations;
|
||||
int _index = 0;
|
||||
int _offset = 0;
|
||||
|
||||
_OpIterator(List<TextOperation> operations)
|
||||
: _operations = UnmodifiableListView(operations);
|
||||
|
||||
bool get hasNext {
|
||||
return peekLength() < _maxInt;
|
||||
}
|
||||
@ -204,20 +506,17 @@ class _OpIterator {
|
||||
_offset += length;
|
||||
}
|
||||
if (nextOp is TextDelete) {
|
||||
return TextDelete(length);
|
||||
return TextDelete(length: length);
|
||||
}
|
||||
|
||||
if (nextOp is TextRetain) {
|
||||
return TextRetain(
|
||||
length,
|
||||
nextOp.attributes,
|
||||
);
|
||||
return TextRetain(length, attributes: nextOp.attributes);
|
||||
}
|
||||
|
||||
if (nextOp is TextInsert) {
|
||||
return TextInsert(
|
||||
nextOp.content.substring(offset, offset + length),
|
||||
nextOp.attributes,
|
||||
nextOp.text.substring(offset, offset + length),
|
||||
attributes: nextOp.attributes,
|
||||
);
|
||||
}
|
||||
|
||||
@ -240,331 +539,3 @@ class _OpIterator {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TextOperation? _textOperationFromJson(Map<String, dynamic> json) {
|
||||
TextOperation? result;
|
||||
|
||||
if (json['insert'] is String) {
|
||||
final attrs = json['attributes'] as Map<String, dynamic>?;
|
||||
result =
|
||||
TextInsert(json['insert'] as String, attrs == null ? null : {...attrs});
|
||||
} else if (json['retain'] is int) {
|
||||
final attrs = json['attributes'] as Map<String, dynamic>?;
|
||||
result =
|
||||
TextRetain(json['retain'] as int, attrs == null ? null : {...attrs});
|
||||
} else if (json['delete'] is int) {
|
||||
result = TextDelete(json['delete'] as int);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Deltas are a simple, yet expressive format that can be used to describe contents and changes.
|
||||
/// The format is JSON based, and is human readable, yet easily parsible by machines.
|
||||
/// Deltas can describe any rich text document, includes all text and formatting information, without the ambiguity and complexity of HTML.
|
||||
///
|
||||
|
||||
/// Basically borrowed from: https://github.com/quilljs/delta
|
||||
class Delta extends Iterable<TextOperation> {
|
||||
final List<TextOperation> _operations;
|
||||
String? _rawString;
|
||||
List<int>? _runeIndexes;
|
||||
|
||||
factory Delta.fromJson(List<dynamic> list) {
|
||||
final operations = <TextOperation>[];
|
||||
|
||||
for (final obj in list) {
|
||||
final op = _textOperationFromJson(obj as Map<String, dynamic>);
|
||||
if (op != null) {
|
||||
operations.add(op);
|
||||
}
|
||||
}
|
||||
|
||||
return Delta(operations);
|
||||
}
|
||||
|
||||
Delta([List<TextOperation>? ops]) : _operations = ops ?? <TextOperation>[];
|
||||
|
||||
void addAll(Iterable<TextOperation> textOps) {
|
||||
textOps.forEach(add);
|
||||
}
|
||||
|
||||
void add(TextOperation textOp) {
|
||||
if (textOp.isEmpty) {
|
||||
return;
|
||||
}
|
||||
_rawString = null;
|
||||
|
||||
if (_operations.isNotEmpty) {
|
||||
final lastOp = _operations.last;
|
||||
if (lastOp is TextDelete && textOp is TextDelete) {
|
||||
lastOp.length += textOp.length;
|
||||
return;
|
||||
}
|
||||
if (mapEquals(lastOp.attributes, textOp.attributes)) {
|
||||
if (lastOp is TextInsert && textOp is TextInsert) {
|
||||
lastOp.content += textOp.content;
|
||||
return;
|
||||
}
|
||||
// if there is an delete before the insert
|
||||
// swap the order
|
||||
if (lastOp is TextDelete && textOp is TextInsert) {
|
||||
_operations.removeLast();
|
||||
_operations.add(textOp);
|
||||
_operations.add(lastOp);
|
||||
return;
|
||||
}
|
||||
if (lastOp is TextRetain && textOp is TextRetain) {
|
||||
lastOp.length += textOp.length;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_operations.add(textOp);
|
||||
}
|
||||
|
||||
/// The slice() method does not change the original string.
|
||||
/// The start and end parameters specifies the part of the string to extract.
|
||||
/// The end position is optional.
|
||||
Delta slice(int start, [int? end]) {
|
||||
final result = Delta();
|
||||
final iterator = _OpIterator(_operations);
|
||||
int index = 0;
|
||||
|
||||
while ((end == null || index < end) && iterator.hasNext) {
|
||||
TextOperation? nextOp;
|
||||
if (index < start) {
|
||||
nextOp = iterator._next(start - index);
|
||||
} else {
|
||||
nextOp = iterator._next(end == null ? null : end - index);
|
||||
result.add(nextOp);
|
||||
}
|
||||
|
||||
index += nextOp.length;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Insert operations have an `insert` key defined.
|
||||
/// A String value represents inserting text.
|
||||
void insert(String content, [Attributes? attributes]) =>
|
||||
add(TextInsert(content, attributes));
|
||||
|
||||
/// Retain operations have a Number `retain` key defined representing the number of characters to keep (other libraries might use the name keep or skip).
|
||||
/// An optional `attributes` key can be defined with an Object to describe formatting changes to the character range.
|
||||
/// A value of `null` in the `attributes` Object represents removal of that key.
|
||||
///
|
||||
/// *Note: It is not necessary to retain the last characters of a document as this is implied.*
|
||||
void retain(int length, [Attributes? attributes]) =>
|
||||
add(TextRetain(length, attributes));
|
||||
|
||||
/// Delete operations have a Number `delete` key defined representing the number of characters to delete.
|
||||
void delete(int length) => add(TextDelete(length));
|
||||
|
||||
/// The length of the string fo the [Delta].
|
||||
@override
|
||||
int get length {
|
||||
return _operations.fold(
|
||||
0, (previousValue, element) => previousValue + element.length);
|
||||
}
|
||||
|
||||
/// Returns a Delta that is equivalent to applying the operations of own Delta, followed by another Delta.
|
||||
Delta compose(Delta other) {
|
||||
final thisIter = _OpIterator(_operations);
|
||||
final otherIter = _OpIterator(other._operations);
|
||||
final ops = <TextOperation>[];
|
||||
|
||||
final firstOther = otherIter.peek();
|
||||
if (firstOther != null &&
|
||||
firstOther is TextRetain &&
|
||||
firstOther.attributes == null) {
|
||||
int firstLeft = firstOther.length;
|
||||
while (
|
||||
thisIter.peek() is TextInsert && thisIter.peekLength() <= firstLeft) {
|
||||
firstLeft -= thisIter.peekLength();
|
||||
final next = thisIter._next();
|
||||
ops.add(next);
|
||||
}
|
||||
if (firstOther.length - firstLeft > 0) {
|
||||
otherIter._next(firstOther.length - firstLeft);
|
||||
}
|
||||
}
|
||||
|
||||
final delta = Delta(ops);
|
||||
while (thisIter.hasNext || otherIter.hasNext) {
|
||||
if (otherIter.peek() is TextInsert) {
|
||||
final next = otherIter._next();
|
||||
delta.add(next);
|
||||
} else if (thisIter.peek() is TextDelete) {
|
||||
final next = thisIter._next();
|
||||
delta.add(next);
|
||||
} else {
|
||||
// otherIs
|
||||
final length = min(thisIter.peekLength(), otherIter.peekLength());
|
||||
final thisOp = thisIter._next(length);
|
||||
final otherOp = otherIter._next(length);
|
||||
final attributes = composeAttributes(
|
||||
thisOp.attributes,
|
||||
otherOp.attributes,
|
||||
keepNull: thisOp is TextRetain,
|
||||
);
|
||||
|
||||
if (otherOp is TextRetain && otherOp.length > 0) {
|
||||
TextOperation? newOp;
|
||||
if (thisOp is TextRetain) {
|
||||
newOp = TextRetain(length, attributes);
|
||||
} else if (thisOp is TextInsert) {
|
||||
newOp = TextInsert(thisOp.content, attributes);
|
||||
}
|
||||
|
||||
if (newOp != null) {
|
||||
delta.add(newOp);
|
||||
}
|
||||
|
||||
// Optimization if rest of other is just retain
|
||||
if (!otherIter.hasNext &&
|
||||
delta._operations.isNotEmpty &&
|
||||
delta._operations.last == newOp) {
|
||||
final rest = Delta(thisIter.rest());
|
||||
return (delta + rest)..chop();
|
||||
}
|
||||
} else if (otherOp is TextDelete && (thisOp is TextRetain)) {
|
||||
delta.add(otherOp);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return delta..chop();
|
||||
}
|
||||
|
||||
/// This method joins two Delta together.
|
||||
Delta operator +(Delta other) {
|
||||
var ops = [..._operations];
|
||||
if (other._operations.isNotEmpty) {
|
||||
ops.add(other._operations[0]);
|
||||
ops.addAll(other._operations.sublist(1));
|
||||
}
|
||||
return Delta(ops);
|
||||
}
|
||||
|
||||
void chop() {
|
||||
if (_operations.isEmpty) {
|
||||
return;
|
||||
}
|
||||
_rawString = null;
|
||||
final lastOp = _operations.last;
|
||||
if (lastOp is TextRetain && (lastOp.attributes?.length ?? 0) == 0) {
|
||||
_operations.removeLast();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (other is! Delta) {
|
||||
return false;
|
||||
}
|
||||
return listEquals(_operations, other._operations);
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return Object.hashAll(_operations);
|
||||
}
|
||||
|
||||
/// Returned an inverted delta that has the opposite effect of against a base document delta.
|
||||
Delta invert(Delta base) {
|
||||
final inverted = Delta();
|
||||
_operations.fold(0, (int previousValue, op) {
|
||||
if (op is TextInsert) {
|
||||
inverted.delete(op.length);
|
||||
} else if (op is TextRetain && op.attributes == null) {
|
||||
inverted.retain(op.length);
|
||||
return previousValue + op.length;
|
||||
} else if (op is TextDelete || op is TextRetain) {
|
||||
final length = op.length;
|
||||
final slice = base.slice(previousValue, previousValue + length);
|
||||
for (final baseOp in slice._operations) {
|
||||
if (op is TextDelete) {
|
||||
inverted.add(baseOp);
|
||||
} else if (op is TextRetain && op.attributes != null) {
|
||||
inverted.retain(
|
||||
baseOp.length,
|
||||
invertAttributes(baseOp.attributes, op.attributes),
|
||||
);
|
||||
}
|
||||
}
|
||||
return previousValue + length;
|
||||
}
|
||||
return previousValue;
|
||||
});
|
||||
return inverted..chop();
|
||||
}
|
||||
|
||||
List<dynamic> toJson() {
|
||||
return _operations.map((e) => e.toJson()).toList();
|
||||
}
|
||||
|
||||
/// This method will return the position of the previous rune.
|
||||
///
|
||||
/// Since the encoding of the [String] in Dart is UTF-16.
|
||||
/// If you want to find the previous character of a position,
|
||||
/// you can' just use the `position - 1` simply.
|
||||
///
|
||||
/// This method can help you to compute the position of the previous character.
|
||||
int prevRunePosition(int pos) {
|
||||
if (pos == 0) {
|
||||
return pos - 1;
|
||||
}
|
||||
_rawString ??=
|
||||
_operations.whereType<TextInsert>().map((op) => op.content).join();
|
||||
_runeIndexes ??= stringIndexes(_rawString!);
|
||||
return _runeIndexes![pos - 1];
|
||||
}
|
||||
|
||||
/// This method will return the position of the next rune.
|
||||
///
|
||||
/// Since the encoding of the [String] in Dart is UTF-16.
|
||||
/// If you want to find the previous character of a position,
|
||||
/// you can' just use the `position + 1` simply.
|
||||
///
|
||||
/// This method can help you to compute the position of the next character.
|
||||
int nextRunePosition(int pos) {
|
||||
final stringContent = toPlainText();
|
||||
if (pos >= stringContent.length - 1) {
|
||||
return stringContent.length;
|
||||
}
|
||||
_runeIndexes ??= stringIndexes(_rawString!);
|
||||
|
||||
for (var i = pos + 1; i < _runeIndexes!.length; i++) {
|
||||
if (_runeIndexes![i] != pos) {
|
||||
return _runeIndexes![i];
|
||||
}
|
||||
}
|
||||
|
||||
return stringContent.length;
|
||||
}
|
||||
|
||||
String toPlainText() {
|
||||
_rawString ??=
|
||||
_operations.whereType<TextInsert>().map((op) => op.content).join();
|
||||
return _rawString!;
|
||||
}
|
||||
|
||||
@override
|
||||
Iterator<TextOperation> get iterator => _operations.iterator;
|
||||
}
|
||||
|
||||
List<int> stringIndexes(String content) {
|
||||
final indexes = List<int>.filled(content.length, 0);
|
||||
final iterator = content.runes.iterator;
|
||||
|
||||
while (iterator.moveNext()) {
|
||||
for (var i = 0; i < iterator.currentSize; i++) {
|
||||
indexes[iterator.rawIndex + i] = iterator.rawIndex;
|
||||
}
|
||||
}
|
||||
|
||||
return indexes;
|
||||
}
|
@ -2,7 +2,7 @@ import 'dart:math';
|
||||
|
||||
import 'package:appflowy_editor/src/core/document/node.dart';
|
||||
import 'package:appflowy_editor/src/core/document/path.dart';
|
||||
import 'package:appflowy_editor/src/document/text_delta.dart';
|
||||
import 'package:appflowy_editor/src/core/document/text_delta.dart';
|
||||
import '../core/document/attributes.dart';
|
||||
|
||||
class StateTree {
|
||||
|
@ -2,7 +2,7 @@ import 'package:appflowy_editor/src/core/document/node.dart';
|
||||
import 'package:appflowy_editor/src/core/document/path.dart';
|
||||
import 'package:appflowy_editor/src/document/position.dart';
|
||||
import 'package:appflowy_editor/src/document/selection.dart';
|
||||
import 'package:appflowy_editor/src/document/text_delta.dart';
|
||||
import 'package:appflowy_editor/src/core/document/text_delta.dart';
|
||||
import 'package:appflowy_editor/src/document/built_in_attribute_keys.dart';
|
||||
|
||||
extension TextNodeExtension on TextNode {
|
||||
|
@ -3,7 +3,7 @@ import 'dart:ui';
|
||||
|
||||
import 'package:appflowy_editor/src/core/document/attributes.dart';
|
||||
import 'package:appflowy_editor/src/core/document/node.dart';
|
||||
import 'package:appflowy_editor/src/document/text_delta.dart';
|
||||
import 'package:appflowy_editor/src/core/document/text_delta.dart';
|
||||
import 'package:appflowy_editor/src/extensions/color_extension.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:html/parser.dart' show parse;
|
||||
@ -218,24 +218,29 @@ class HTMLToNodesConverter {
|
||||
|
||||
_handleRichTextElement(Delta delta, html.Element element) {
|
||||
if (element.localName == HTMLTag.span) {
|
||||
delta.insert(element.text,
|
||||
_getDeltaAttributesFromHtmlAttributes(element.attributes));
|
||||
delta.insert(
|
||||
element.text,
|
||||
attributes: _getDeltaAttributesFromHtmlAttributes(element.attributes),
|
||||
);
|
||||
} else if (element.localName == HTMLTag.anchor) {
|
||||
final hyperLink = element.attributes["href"];
|
||||
Map<String, dynamic>? attributes;
|
||||
if (hyperLink != null) {
|
||||
attributes = {"href": hyperLink};
|
||||
}
|
||||
delta.insert(element.text, attributes);
|
||||
delta.insert(element.text, attributes: attributes);
|
||||
} else if (element.localName == HTMLTag.strong ||
|
||||
element.localName == HTMLTag.bold) {
|
||||
delta.insert(element.text, {BuiltInAttributeKey.bold: true});
|
||||
delta.insert(element.text, attributes: {BuiltInAttributeKey.bold: true});
|
||||
} else if (element.localName == HTMLTag.underline) {
|
||||
delta.insert(element.text, {BuiltInAttributeKey.underline: true});
|
||||
delta.insert(element.text,
|
||||
attributes: {BuiltInAttributeKey.underline: true});
|
||||
} else if (element.localName == HTMLTag.italic) {
|
||||
delta.insert(element.text, {BuiltInAttributeKey.italic: true});
|
||||
delta
|
||||
.insert(element.text, attributes: {BuiltInAttributeKey.italic: true});
|
||||
} else if (element.localName == HTMLTag.del) {
|
||||
delta.insert(element.text, {BuiltInAttributeKey.strikethrough: true});
|
||||
delta.insert(element.text,
|
||||
attributes: {BuiltInAttributeKey.strikethrough: true});
|
||||
} else {
|
||||
delta.insert(element.text);
|
||||
}
|
||||
@ -535,22 +540,22 @@ class NodesToHTMLConverter {
|
||||
if (attributes.length == 1 &&
|
||||
attributes[BuiltInAttributeKey.bold] == true) {
|
||||
final strong = html.Element.tag(HTMLTag.strong);
|
||||
strong.append(html.Text(op.content));
|
||||
strong.append(html.Text(op.text));
|
||||
childNodes.add(strong);
|
||||
} else if (attributes.length == 1 &&
|
||||
attributes[BuiltInAttributeKey.underline] == true) {
|
||||
final strong = html.Element.tag(HTMLTag.underline);
|
||||
strong.append(html.Text(op.content));
|
||||
strong.append(html.Text(op.text));
|
||||
childNodes.add(strong);
|
||||
} else if (attributes.length == 1 &&
|
||||
attributes[BuiltInAttributeKey.italic] == true) {
|
||||
final strong = html.Element.tag(HTMLTag.italic);
|
||||
strong.append(html.Text(op.content));
|
||||
strong.append(html.Text(op.text));
|
||||
childNodes.add(strong);
|
||||
} else if (attributes.length == 1 &&
|
||||
attributes[BuiltInAttributeKey.strikethrough] == true) {
|
||||
final strong = html.Element.tag(HTMLTag.del);
|
||||
strong.append(html.Text(op.content));
|
||||
strong.append(html.Text(op.text));
|
||||
childNodes.add(strong);
|
||||
} else {
|
||||
final span = html.Element.tag(HTMLTag.span);
|
||||
@ -558,11 +563,11 @@ class NodesToHTMLConverter {
|
||||
if (cssString.isNotEmpty) {
|
||||
span.attributes["style"] = cssString;
|
||||
}
|
||||
span.append(html.Text(op.content));
|
||||
span.append(html.Text(op.text));
|
||||
childNodes.add(span);
|
||||
}
|
||||
} else {
|
||||
childNodes.add(html.Text(op.content));
|
||||
childNodes.add(html.Text(op.text));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -6,7 +6,7 @@ import 'package:appflowy_editor/src/core/document/node.dart';
|
||||
import 'package:appflowy_editor/src/core/document/path.dart';
|
||||
import 'package:appflowy_editor/src/document/position.dart';
|
||||
import 'package:appflowy_editor/src/document/selection.dart';
|
||||
import 'package:appflowy_editor/src/document/text_delta.dart';
|
||||
import 'package:appflowy_editor/src/core/document/text_delta.dart';
|
||||
import 'package:appflowy_editor/src/editor_state.dart';
|
||||
import 'package:appflowy_editor/src/operation/operation.dart';
|
||||
import 'package:appflowy_editor/src/operation/transaction.dart';
|
||||
@ -134,7 +134,7 @@ class TransactionBuilder {
|
||||
..retain(index)
|
||||
..insert(
|
||||
content,
|
||||
newAttributes,
|
||||
attributes: newAttributes,
|
||||
),
|
||||
);
|
||||
afterSelection = Selection.collapsed(
|
||||
@ -148,7 +148,7 @@ class TransactionBuilder {
|
||||
node,
|
||||
() => Delta()
|
||||
..retain(index)
|
||||
..retain(length, attributes));
|
||||
..retain(length, attributes: attributes));
|
||||
afterSelection = beforeSelection;
|
||||
}
|
||||
|
||||
@ -177,7 +177,7 @@ class TransactionBuilder {
|
||||
() => Delta()
|
||||
..retain(index)
|
||||
..delete(length)
|
||||
..insert(content, newAttributes),
|
||||
..insert(content, attributes: newAttributes),
|
||||
);
|
||||
afterSelection = Selection.collapsed(
|
||||
Position(
|
||||
|
@ -9,7 +9,7 @@ import 'package:appflowy_editor/src/core/document/node.dart';
|
||||
import 'package:appflowy_editor/src/core/document/path.dart';
|
||||
import 'package:appflowy_editor/src/document/position.dart';
|
||||
import 'package:appflowy_editor/src/document/selection.dart';
|
||||
import 'package:appflowy_editor/src/document/text_delta.dart';
|
||||
import 'package:appflowy_editor/src/core/document/text_delta.dart';
|
||||
import 'package:appflowy_editor/src/editor_state.dart';
|
||||
import 'package:appflowy_editor/src/extensions/url_launcher_extension.dart';
|
||||
import 'package:appflowy_editor/src/extensions/text_style_extension.dart';
|
||||
@ -257,7 +257,7 @@ class _FlowyRichTextState extends State<FlowyRichText> with SelectableMixin {
|
||||
offset += textInsert.length;
|
||||
textSpans.add(
|
||||
TextSpan(
|
||||
text: textInsert.content,
|
||||
text: textInsert.text,
|
||||
style: textStyle,
|
||||
recognizer: recognizer,
|
||||
),
|
||||
|
@ -222,7 +222,7 @@ Delta _lineContentToDelta(String lineContent) {
|
||||
delta.insert(lineContent.substring(lastUrlEndOffset, match.start));
|
||||
}
|
||||
final linkContent = lineContent.substring(match.start, match.end);
|
||||
delta.insert(linkContent, {"href": linkContent});
|
||||
delta.insert(linkContent, attributes: {"href": linkContent});
|
||||
lastUrlEndOffset = match.end;
|
||||
}
|
||||
|
||||
|
@ -0,0 +1,332 @@
|
||||
import 'package:appflowy_editor/src/core/document/attributes.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:appflowy_editor/src/core/document/text_delta.dart';
|
||||
|
||||
void main() {
|
||||
group('text_delta.dart', () {
|
||||
group('compose', () {
|
||||
test('test delta', () {
|
||||
final delta = Delta(operations: <TextOperation>[
|
||||
TextInsert('Gandalf', attributes: {
|
||||
'bold': true,
|
||||
}),
|
||||
TextInsert(' the '),
|
||||
TextInsert('Grey', attributes: {
|
||||
'color': '#ccc',
|
||||
})
|
||||
]);
|
||||
|
||||
final death = Delta()
|
||||
..retain(12)
|
||||
..insert("White", attributes: {
|
||||
'color': '#fff',
|
||||
})
|
||||
..delete(4);
|
||||
|
||||
final restores = delta.compose(death);
|
||||
expect(restores.toList(), <TextOperation>[
|
||||
TextInsert('Gandalf', attributes: {'bold': true}),
|
||||
TextInsert(' the '),
|
||||
TextInsert('White', attributes: {'color': '#fff'}),
|
||||
]);
|
||||
});
|
||||
test('compose()', () {
|
||||
final a = Delta()..insert('A');
|
||||
final b = Delta()..insert('B');
|
||||
final expected = Delta()
|
||||
..insert('B')
|
||||
..insert('A');
|
||||
expect(a.compose(b), expected);
|
||||
});
|
||||
test('insert + retain', () {
|
||||
final a = Delta()..insert('A');
|
||||
final b = Delta()
|
||||
..retain(1, attributes: {
|
||||
'bold': true,
|
||||
'color': 'red',
|
||||
});
|
||||
final expected = Delta()
|
||||
..insert('A', attributes: {
|
||||
'bold': true,
|
||||
'color': 'red',
|
||||
});
|
||||
expect(a.compose(b), expected);
|
||||
});
|
||||
test('insert + delete', () {
|
||||
final a = Delta()..insert('A');
|
||||
final b = Delta()..delete(1);
|
||||
final expected = Delta();
|
||||
expect(a.compose(b), expected);
|
||||
});
|
||||
test('delete + insert', () {
|
||||
final a = Delta()..delete(1);
|
||||
final b = Delta()..insert('B');
|
||||
final expected = Delta()
|
||||
..insert('B')
|
||||
..delete(1);
|
||||
expect(a.compose(b), expected);
|
||||
});
|
||||
test('delete + retain', () {
|
||||
final a = Delta()..delete(1);
|
||||
final b = Delta()
|
||||
..retain(1, attributes: {
|
||||
'bold': true,
|
||||
'color': 'red',
|
||||
});
|
||||
final expected = Delta()
|
||||
..delete(1)
|
||||
..retain(1, attributes: {
|
||||
'bold': true,
|
||||
'color': 'red',
|
||||
});
|
||||
expect(a.compose(b), expected);
|
||||
});
|
||||
test('delete + delete', () {
|
||||
final a = Delta()..delete(1);
|
||||
final b = Delta()..delete(1);
|
||||
final expected = Delta()..delete(2);
|
||||
expect(a.compose(b), expected);
|
||||
});
|
||||
test('retain + insert', () {
|
||||
final a = Delta()..retain(1, attributes: {'color': 'blue'});
|
||||
final b = Delta()..insert('B');
|
||||
final expected = Delta()
|
||||
..insert('B')
|
||||
..retain(1, attributes: {
|
||||
'color': 'blue',
|
||||
});
|
||||
expect(a.compose(b), expected);
|
||||
});
|
||||
test('retain + retain', () {
|
||||
final a = Delta()
|
||||
..retain(1, attributes: {
|
||||
'color': 'blue',
|
||||
});
|
||||
final b = Delta()
|
||||
..retain(1, attributes: {
|
||||
'bold': true,
|
||||
'color': 'red',
|
||||
});
|
||||
final expected = Delta()
|
||||
..retain(1, attributes: {
|
||||
'bold': true,
|
||||
'color': 'red',
|
||||
});
|
||||
expect(a.compose(b), expected);
|
||||
});
|
||||
test('retain + delete', () {
|
||||
final a = Delta()
|
||||
..retain(1, attributes: {
|
||||
'color': 'blue',
|
||||
});
|
||||
final b = Delta()..delete(1);
|
||||
final expected = Delta()..delete(1);
|
||||
expect(a.compose(b), expected);
|
||||
});
|
||||
test('insert in middle of text', () {
|
||||
final a = Delta()..insert('Hello');
|
||||
final b = Delta()
|
||||
..retain(3)
|
||||
..insert('X');
|
||||
final expected = Delta()..insert('HelXlo');
|
||||
expect(a.compose(b), expected);
|
||||
});
|
||||
test('insert and delete ordering', () {
|
||||
final a = Delta()..insert('Hello');
|
||||
final b = Delta()..insert('Hello');
|
||||
final insertFirst = Delta()
|
||||
..retain(3)
|
||||
..insert('X')
|
||||
..delete(1);
|
||||
final deleteFirst = Delta()
|
||||
..retain(3)
|
||||
..delete(1)
|
||||
..insert('X');
|
||||
final expected = Delta()..insert('HelXo');
|
||||
expect(a.compose(insertFirst), expected);
|
||||
expect(b.compose(deleteFirst), expected);
|
||||
});
|
||||
test('delete entire text', () {
|
||||
final a = Delta()
|
||||
..retain(4)
|
||||
..insert('Hello');
|
||||
final b = Delta()..delete(9);
|
||||
final expected = Delta()..delete(4);
|
||||
expect(a.compose(b), expected);
|
||||
});
|
||||
test('retain more than length of text', () {
|
||||
final a = Delta()..insert('Hello');
|
||||
final b = Delta()..retain(10);
|
||||
final expected = Delta()..insert('Hello');
|
||||
expect(a.compose(b), expected);
|
||||
});
|
||||
test('retain start optimization', () {
|
||||
final a = Delta()
|
||||
..insert('A', attributes: {'bold': true})
|
||||
..insert('B')
|
||||
..insert('C', attributes: {'bold': true})
|
||||
..delete(1);
|
||||
final b = Delta()
|
||||
..retain(3)
|
||||
..insert('D');
|
||||
final expected = Delta()
|
||||
..insert('A', attributes: {'bold': true})
|
||||
..insert('B')
|
||||
..insert('C', attributes: {'bold': true})
|
||||
..insert('D')
|
||||
..delete(1);
|
||||
expect(a.compose(b), expected);
|
||||
});
|
||||
test('retain end optimization', () {
|
||||
final a = Delta()
|
||||
..insert('A', attributes: {'bold': true})
|
||||
..insert('B')
|
||||
..insert('C', attributes: {'bold': true});
|
||||
final b = Delta()..delete(1);
|
||||
final expected = Delta()
|
||||
..insert('B')
|
||||
..insert('C', attributes: {'bold': true});
|
||||
expect(a.compose(b), expected);
|
||||
});
|
||||
test('retain end optimization join', () {
|
||||
final a = Delta()
|
||||
..insert('A', attributes: {'bold': true})
|
||||
..insert('B')
|
||||
..insert('C', attributes: {'bold': true})
|
||||
..insert('D')
|
||||
..insert('E', attributes: {'bold': true})
|
||||
..insert('F');
|
||||
final b = Delta()
|
||||
..retain(1)
|
||||
..delete(1);
|
||||
final expected = Delta()
|
||||
..insert('AC', attributes: {'bold': true})
|
||||
..insert('D')
|
||||
..insert('E', attributes: {'bold': true})
|
||||
..insert('F');
|
||||
expect(a.compose(b), expected);
|
||||
});
|
||||
});
|
||||
group('invert', () {
|
||||
test('insert', () {
|
||||
final delta = Delta()
|
||||
..retain(2)
|
||||
..insert('A');
|
||||
final base = Delta()..insert('12346');
|
||||
final expected = Delta()
|
||||
..retain(2)
|
||||
..delete(1);
|
||||
final inverted = delta.invert(base);
|
||||
expect(expected, inverted);
|
||||
expect(base.compose(delta).compose(inverted), base);
|
||||
});
|
||||
test('delete', () {
|
||||
final delta = Delta()
|
||||
..retain(2)
|
||||
..delete(3);
|
||||
final base = Delta()..insert('123456');
|
||||
final expected = Delta()
|
||||
..retain(2)
|
||||
..insert('345');
|
||||
final inverted = delta.invert(base);
|
||||
expect(expected, inverted);
|
||||
expect(base.compose(delta).compose(inverted), base);
|
||||
});
|
||||
test('retain', () {
|
||||
final delta = Delta()
|
||||
..retain(2)
|
||||
..retain(3, attributes: {'bold': true});
|
||||
final base = Delta()..insert('123456');
|
||||
final expected = Delta()
|
||||
..retain(2)
|
||||
..retain(3, attributes: {'bold': null});
|
||||
final inverted = delta.invert(base);
|
||||
expect(expected, inverted);
|
||||
final t = base.compose(delta).compose(inverted);
|
||||
expect(t, base);
|
||||
});
|
||||
});
|
||||
group('json', () {
|
||||
test('toJson()', () {
|
||||
final delta = Delta()
|
||||
..retain(2)
|
||||
..insert('A')
|
||||
..delete(3);
|
||||
expect(delta.toJson(), [
|
||||
{'retain': 2},
|
||||
{'insert': 'A'},
|
||||
{'delete': 3}
|
||||
]);
|
||||
});
|
||||
test('attributes', () {
|
||||
final delta = Delta()
|
||||
..retain(2, attributes: {'bold': true})
|
||||
..insert('A', attributes: {'italic': true});
|
||||
expect(delta.toJson(), [
|
||||
{
|
||||
'retain': 2,
|
||||
'attributes': {'bold': true},
|
||||
},
|
||||
{
|
||||
'insert': 'A',
|
||||
'attributes': {'italic': true},
|
||||
},
|
||||
]);
|
||||
});
|
||||
test('fromJson()', () {
|
||||
final delta = Delta.fromJson([
|
||||
{'retain': 2},
|
||||
{'insert': 'A'},
|
||||
{'delete': 3},
|
||||
]);
|
||||
final expected = Delta()
|
||||
..retain(2)
|
||||
..insert('A')
|
||||
..delete(3);
|
||||
expect(delta, expected);
|
||||
});
|
||||
});
|
||||
group('runes', () {
|
||||
test("stringIndexes", () {
|
||||
final indexes = stringIndexes('😊');
|
||||
expect(indexes[0], 0);
|
||||
expect(indexes[1], 0);
|
||||
});
|
||||
test("next rune 1", () {
|
||||
final delta = Delta()..insert('😊');
|
||||
expect(delta.nextRunePosition(0), 2);
|
||||
});
|
||||
test("next rune 2", () {
|
||||
final delta = Delta()..insert('😊a');
|
||||
expect(delta.nextRunePosition(0), 2);
|
||||
});
|
||||
test("next rune 3", () {
|
||||
final delta = Delta()..insert('😊陈');
|
||||
expect(delta.nextRunePosition(2), 3);
|
||||
});
|
||||
test("prev rune 1", () {
|
||||
final delta = Delta()..insert('😊陈');
|
||||
expect(delta.prevRunePosition(2), 0);
|
||||
});
|
||||
test("prev rune 2", () {
|
||||
final delta = Delta()..insert('😊');
|
||||
expect(delta.prevRunePosition(2), 0);
|
||||
});
|
||||
test("prev rune 3", () {
|
||||
final delta = Delta()..insert('😊');
|
||||
expect(delta.prevRunePosition(0), -1);
|
||||
});
|
||||
});
|
||||
group("attributes", () {
|
||||
test("compose", () {
|
||||
final attrs =
|
||||
composeAttributes({'a': null}, {'b': null}, keepNull: true);
|
||||
expect(attrs != null, true);
|
||||
expect(attrs?.containsKey("a"), true);
|
||||
expect(attrs?.containsKey("b"), true);
|
||||
expect(attrs?["a"], null);
|
||||
expect(attrs?["b"], null);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
@ -63,7 +63,7 @@ class EditorWidgetTester {
|
||||
void insertTextNode(String? text, {Attributes? attributes, Delta? delta}) {
|
||||
insert(
|
||||
TextNode(
|
||||
delta: delta ?? Delta([TextInsert(text ?? 'Test')]),
|
||||
delta: delta ?? Delta(operations: [TextInsert(text ?? 'Test')]),
|
||||
attributes: attributes,
|
||||
),
|
||||
);
|
||||
|
@ -1,329 +0,0 @@
|
||||
import 'package:appflowy_editor/src/core/document/attributes.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:appflowy_editor/src/document/text_delta.dart';
|
||||
|
||||
void main() {
|
||||
group('compose', () {
|
||||
test('test delta', () {
|
||||
final delta = Delta(<TextOperation>[
|
||||
TextInsert('Gandalf', {
|
||||
'bold': true,
|
||||
}),
|
||||
TextInsert(' the '),
|
||||
TextInsert('Grey', {
|
||||
'color': '#ccc',
|
||||
})
|
||||
]);
|
||||
|
||||
final death = Delta()
|
||||
..retain(12)
|
||||
..insert("White", {
|
||||
'color': '#fff',
|
||||
})
|
||||
..delete(4);
|
||||
|
||||
final restores = delta.compose(death);
|
||||
expect(restores.toList(), <TextOperation>[
|
||||
TextInsert('Gandalf', {'bold': true}),
|
||||
TextInsert(' the '),
|
||||
TextInsert('White', {'color': '#fff'}),
|
||||
]);
|
||||
});
|
||||
test('compose()', () {
|
||||
final a = Delta()..insert('A');
|
||||
final b = Delta()..insert('B');
|
||||
final expected = Delta()
|
||||
..insert('B')
|
||||
..insert('A');
|
||||
expect(a.compose(b), expected);
|
||||
});
|
||||
test('insert + retain', () {
|
||||
final a = Delta()..insert('A');
|
||||
final b = Delta()
|
||||
..retain(1, {
|
||||
'bold': true,
|
||||
'color': 'red',
|
||||
});
|
||||
final expected = Delta()
|
||||
..insert('A', {
|
||||
'bold': true,
|
||||
'color': 'red',
|
||||
});
|
||||
expect(a.compose(b), expected);
|
||||
});
|
||||
test('insert + delete', () {
|
||||
final a = Delta()..insert('A');
|
||||
final b = Delta()..delete(1);
|
||||
final expected = Delta();
|
||||
expect(a.compose(b), expected);
|
||||
});
|
||||
test('delete + insert', () {
|
||||
final a = Delta()..delete(1);
|
||||
final b = Delta()..insert('B');
|
||||
final expected = Delta()
|
||||
..insert('B')
|
||||
..delete(1);
|
||||
expect(a.compose(b), expected);
|
||||
});
|
||||
test('delete + retain', () {
|
||||
final a = Delta()..delete(1);
|
||||
final b = Delta()
|
||||
..retain(1, {
|
||||
'bold': true,
|
||||
'color': 'red',
|
||||
});
|
||||
final expected = Delta()
|
||||
..delete(1)
|
||||
..retain(1, {
|
||||
'bold': true,
|
||||
'color': 'red',
|
||||
});
|
||||
expect(a.compose(b), expected);
|
||||
});
|
||||
test('delete + delete', () {
|
||||
final a = Delta()..delete(1);
|
||||
final b = Delta()..delete(1);
|
||||
final expected = Delta()..delete(2);
|
||||
expect(a.compose(b), expected);
|
||||
});
|
||||
test('retain + insert', () {
|
||||
final a = Delta()..retain(1, {'color': 'blue'});
|
||||
final b = Delta()..insert('B');
|
||||
final expected = Delta()
|
||||
..insert('B')
|
||||
..retain(1, {
|
||||
'color': 'blue',
|
||||
});
|
||||
expect(a.compose(b), expected);
|
||||
});
|
||||
test('retain + retain', () {
|
||||
final a = Delta()
|
||||
..retain(1, {
|
||||
'color': 'blue',
|
||||
});
|
||||
final b = Delta()
|
||||
..retain(1, {
|
||||
'bold': true,
|
||||
'color': 'red',
|
||||
});
|
||||
final expected = Delta()
|
||||
..retain(1, {
|
||||
'bold': true,
|
||||
'color': 'red',
|
||||
});
|
||||
expect(a.compose(b), expected);
|
||||
});
|
||||
test('retain + delete', () {
|
||||
final a = Delta()
|
||||
..retain(1, {
|
||||
'color': 'blue',
|
||||
});
|
||||
final b = Delta()..delete(1);
|
||||
final expected = Delta()..delete(1);
|
||||
expect(a.compose(b), expected);
|
||||
});
|
||||
test('insert in middle of text', () {
|
||||
final a = Delta()..insert('Hello');
|
||||
final b = Delta()
|
||||
..retain(3)
|
||||
..insert('X');
|
||||
final expected = Delta()..insert('HelXlo');
|
||||
expect(a.compose(b), expected);
|
||||
});
|
||||
test('insert and delete ordering', () {
|
||||
final a = Delta()..insert('Hello');
|
||||
final b = Delta()..insert('Hello');
|
||||
final insertFirst = Delta()
|
||||
..retain(3)
|
||||
..insert('X')
|
||||
..delete(1);
|
||||
final deleteFirst = Delta()
|
||||
..retain(3)
|
||||
..delete(1)
|
||||
..insert('X');
|
||||
final expected = Delta()..insert('HelXo');
|
||||
expect(a.compose(insertFirst), expected);
|
||||
expect(b.compose(deleteFirst), expected);
|
||||
});
|
||||
test('delete entire text', () {
|
||||
final a = Delta()
|
||||
..retain(4)
|
||||
..insert('Hello');
|
||||
final b = Delta()..delete(9);
|
||||
final expected = Delta()..delete(4);
|
||||
expect(a.compose(b), expected);
|
||||
});
|
||||
test('retain more than length of text', () {
|
||||
final a = Delta()..insert('Hello');
|
||||
final b = Delta()..retain(10);
|
||||
final expected = Delta()..insert('Hello');
|
||||
expect(a.compose(b), expected);
|
||||
});
|
||||
test('retain start optimization', () {
|
||||
final a = Delta()
|
||||
..insert('A', {'bold': true})
|
||||
..insert('B')
|
||||
..insert('C', {'bold': true})
|
||||
..delete(1);
|
||||
final b = Delta()
|
||||
..retain(3)
|
||||
..insert('D');
|
||||
final expected = Delta()
|
||||
..insert('A', {'bold': true})
|
||||
..insert('B')
|
||||
..insert('C', {'bold': true})
|
||||
..insert('D')
|
||||
..delete(1);
|
||||
expect(a.compose(b), expected);
|
||||
});
|
||||
test('retain end optimization', () {
|
||||
final a = Delta()
|
||||
..insert('A', {'bold': true})
|
||||
..insert('B')
|
||||
..insert('C', {'bold': true});
|
||||
final b = Delta()..delete(1);
|
||||
final expected = Delta()
|
||||
..insert('B')
|
||||
..insert('C', {'bold': true});
|
||||
expect(a.compose(b), expected);
|
||||
});
|
||||
test('retain end optimization join', () {
|
||||
final a = Delta()
|
||||
..insert('A', {'bold': true})
|
||||
..insert('B')
|
||||
..insert('C', {'bold': true})
|
||||
..insert('D')
|
||||
..insert('E', {'bold': true})
|
||||
..insert('F');
|
||||
final b = Delta()
|
||||
..retain(1)
|
||||
..delete(1);
|
||||
final expected = Delta()
|
||||
..insert('AC', {'bold': true})
|
||||
..insert('D')
|
||||
..insert('E', {'bold': true})
|
||||
..insert('F');
|
||||
expect(a.compose(b), expected);
|
||||
});
|
||||
});
|
||||
group('invert', () {
|
||||
test('insert', () {
|
||||
final delta = Delta()
|
||||
..retain(2)
|
||||
..insert('A');
|
||||
final base = Delta()..insert('12346');
|
||||
final expected = Delta()
|
||||
..retain(2)
|
||||
..delete(1);
|
||||
final inverted = delta.invert(base);
|
||||
expect(expected, inverted);
|
||||
expect(base.compose(delta).compose(inverted), base);
|
||||
});
|
||||
test('delete', () {
|
||||
final delta = Delta()
|
||||
..retain(2)
|
||||
..delete(3);
|
||||
final base = Delta()..insert('123456');
|
||||
final expected = Delta()
|
||||
..retain(2)
|
||||
..insert('345');
|
||||
final inverted = delta.invert(base);
|
||||
expect(expected, inverted);
|
||||
expect(base.compose(delta).compose(inverted), base);
|
||||
});
|
||||
test('retain', () {
|
||||
final delta = Delta()
|
||||
..retain(2)
|
||||
..retain(3, {'bold': true});
|
||||
final base = Delta()..insert('123456');
|
||||
final expected = Delta()
|
||||
..retain(2)
|
||||
..retain(3, {'bold': null});
|
||||
final inverted = delta.invert(base);
|
||||
expect(expected, inverted);
|
||||
final t = base.compose(delta).compose(inverted);
|
||||
expect(t, base);
|
||||
});
|
||||
});
|
||||
group('json', () {
|
||||
test('toJson()', () {
|
||||
final delta = Delta()
|
||||
..retain(2)
|
||||
..insert('A')
|
||||
..delete(3);
|
||||
expect(delta.toJson(), [
|
||||
{'retain': 2},
|
||||
{'insert': 'A'},
|
||||
{'delete': 3}
|
||||
]);
|
||||
});
|
||||
test('attributes', () {
|
||||
final delta = Delta()
|
||||
..retain(2, {'bold': true})
|
||||
..insert('A', {'italic': true});
|
||||
expect(delta.toJson(), [
|
||||
{
|
||||
'retain': 2,
|
||||
'attributes': {'bold': true},
|
||||
},
|
||||
{
|
||||
'insert': 'A',
|
||||
'attributes': {'italic': true},
|
||||
},
|
||||
]);
|
||||
});
|
||||
test('fromJson()', () {
|
||||
final delta = Delta.fromJson([
|
||||
{'retain': 2},
|
||||
{'insert': 'A'},
|
||||
{'delete': 3},
|
||||
]);
|
||||
final expected = Delta()
|
||||
..retain(2)
|
||||
..insert('A')
|
||||
..delete(3);
|
||||
expect(delta, expected);
|
||||
});
|
||||
});
|
||||
group('runes', () {
|
||||
test("stringIndexes", () {
|
||||
final indexes = stringIndexes('😊');
|
||||
expect(indexes[0], 0);
|
||||
expect(indexes[1], 0);
|
||||
});
|
||||
test("next rune 1", () {
|
||||
final delta = Delta()..insert('😊');
|
||||
expect(delta.nextRunePosition(0), 2);
|
||||
});
|
||||
test("next rune 2", () {
|
||||
final delta = Delta()..insert('😊a');
|
||||
expect(delta.nextRunePosition(0), 2);
|
||||
});
|
||||
test("next rune 3", () {
|
||||
final delta = Delta()..insert('😊陈');
|
||||
expect(delta.nextRunePosition(2), 3);
|
||||
});
|
||||
test("prev rune 1", () {
|
||||
final delta = Delta()..insert('😊陈');
|
||||
expect(delta.prevRunePosition(2), 0);
|
||||
});
|
||||
test("prev rune 2", () {
|
||||
final delta = Delta()..insert('😊');
|
||||
expect(delta.prevRunePosition(2), 0);
|
||||
});
|
||||
test("prev rune 3", () {
|
||||
final delta = Delta()..insert('😊');
|
||||
expect(delta.prevRunePosition(0), -1);
|
||||
});
|
||||
});
|
||||
group("attributes", () {
|
||||
test("compose", () {
|
||||
final attrs = composeAttributes({'a': null}, {'b': null}, keepNull: true);
|
||||
expect(attrs != null, true);
|
||||
expect(attrs?.containsKey("a"), true);
|
||||
expect(attrs?.containsKey("b"), true);
|
||||
expect(attrs?["a"], null);
|
||||
expect(attrs?["b"], null);
|
||||
});
|
||||
});
|
||||
}
|
@ -26,8 +26,8 @@ void main() async {
|
||||
BuiltInAttributeKey.subtype: BuiltInAttributeKey.checkbox,
|
||||
BuiltInAttributeKey.checkbox: false,
|
||||
},
|
||||
delta: Delta([
|
||||
TextInsert(text, {
|
||||
delta: Delta(operations: [
|
||||
TextInsert(text, attributes: {
|
||||
BuiltInAttributeKey.bold: true,
|
||||
BuiltInAttributeKey.italic: true,
|
||||
BuiltInAttributeKey.underline: true,
|
||||
|
@ -63,9 +63,9 @@ void main() async {
|
||||
..insertTextNode(text)
|
||||
..insertTextNode(
|
||||
null,
|
||||
delta: Delta([
|
||||
delta: Delta(operations: [
|
||||
TextInsert(text),
|
||||
TextInsert(text, attributes),
|
||||
TextInsert(text, attributes: attributes),
|
||||
TextInsert(text),
|
||||
]),
|
||||
);
|
||||
@ -171,8 +171,8 @@ void main() async {
|
||||
BuiltInAttributeKey.subtype: BuiltInAttributeKey.heading,
|
||||
BuiltInAttributeKey.heading: BuiltInAttributeKey.h1,
|
||||
},
|
||||
delta: Delta([
|
||||
TextInsert(text, {
|
||||
delta: Delta(operations: [
|
||||
TextInsert(text, attributes: {
|
||||
BuiltInAttributeKey.bold: true,
|
||||
})
|
||||
]),
|
||||
|
Loading…
Reference in New Issue
Block a user