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/position.dart';
|
||||||
export 'src/document/selection.dart';
|
export 'src/document/selection.dart';
|
||||||
export 'src/document/state_tree.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/core/document/attributes.dart';
|
||||||
export 'src/document/built_in_attribute_keys.dart';
|
export 'src/document/built_in_attribute_keys.dart';
|
||||||
export 'src/editor_state.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/attributes.dart';
|
||||||
import 'package:appflowy_editor/src/core/document/path.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/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> {
|
class Node extends ChangeNotifier with LinkedListEntry<Node> {
|
||||||
Node({
|
Node({
|
||||||
@ -232,7 +232,7 @@ class TextNode extends Node {
|
|||||||
);
|
);
|
||||||
|
|
||||||
TextNode.empty({Attributes? attributes})
|
TextNode.empty({Attributes? attributes})
|
||||||
: _delta = Delta([TextInsert('')]),
|
: _delta = Delta(operations: [TextInsert('')]),
|
||||||
super(
|
super(
|
||||||
type: 'text',
|
type: 'text',
|
||||||
attributes: attributes ?? {},
|
attributes: attributes ?? {},
|
||||||
|
@ -1,170 +1,472 @@
|
|||||||
import 'dart:collection';
|
import 'dart:collection';
|
||||||
import 'dart:math';
|
import 'dart:math';
|
||||||
|
|
||||||
import 'package:appflowy_editor/src/core/document/attributes.dart';
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
import 'package:appflowy_editor/src/core/document/attributes.dart';
|
||||||
|
|
||||||
// constant number: 2^53 - 1
|
// constant number: 2^53 - 1
|
||||||
const int _maxInt = 9007199254740991;
|
const int _maxInt = 9007199254740991;
|
||||||
|
|
||||||
abstract class TextOperation {
|
List<int> stringIndexes(String text) {
|
||||||
bool get isEmpty => length == 0;
|
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;
|
int get length;
|
||||||
|
|
||||||
Attributes? get attributes => null;
|
bool get isEmpty => length == 0;
|
||||||
|
|
||||||
Map<String, dynamic> toJson();
|
Map<String, dynamic> toJson();
|
||||||
}
|
}
|
||||||
|
|
||||||
class TextInsert extends TextOperation {
|
class TextInsert extends TextOperation {
|
||||||
String content;
|
TextInsert(
|
||||||
|
this.text, {
|
||||||
|
Attributes? attributes,
|
||||||
|
}) : _attributes = attributes;
|
||||||
|
|
||||||
|
String text;
|
||||||
final Attributes? _attributes;
|
final Attributes? _attributes;
|
||||||
|
|
||||||
TextInsert(this.content, [Attributes? attrs]) : _attributes = attrs;
|
@override
|
||||||
|
int get length => text.length;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get length {
|
Attributes? get attributes => _attributes != null ? {..._attributes!} : null;
|
||||||
return content.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Attributes? get attributes {
|
Map<String, dynamic> toJson() {
|
||||||
return _attributes;
|
final result = <String, dynamic>{
|
||||||
|
'insert': text,
|
||||||
|
};
|
||||||
|
if (_attributes != null) {
|
||||||
|
result['attributes'] = attributes;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool operator ==(Object other) {
|
bool operator ==(Object other) {
|
||||||
if (other is! TextInsert) {
|
if (identical(this, other)) return true;
|
||||||
return false;
|
|
||||||
}
|
return other is TextInsert &&
|
||||||
return content == other.content &&
|
other.text == text &&
|
||||||
mapEquals(_attributes, other._attributes);
|
mapEquals(_attributes, other._attributes);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get hashCode {
|
int get hashCode => text.hashCode ^ _attributes.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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class TextRetain extends TextOperation {
|
class TextRetain extends TextOperation {
|
||||||
int _length;
|
TextRetain(
|
||||||
|
this.length, {
|
||||||
|
Attributes? attributes,
|
||||||
|
}) : _attributes = attributes;
|
||||||
|
|
||||||
|
@override
|
||||||
|
int length;
|
||||||
final Attributes? _attributes;
|
final Attributes? _attributes;
|
||||||
|
|
||||||
TextRetain(length, [Attributes? attributes])
|
|
||||||
: _length = length,
|
|
||||||
_attributes = attributes;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool get isEmpty {
|
Attributes? get attributes => _attributes != null ? {..._attributes!} : null;
|
||||||
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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Map<String, dynamic> toJson() {
|
Map<String, dynamic> toJson() {
|
||||||
final result = <String, dynamic>{
|
final result = <String, dynamic>{
|
||||||
'retain': _length,
|
'retain': length,
|
||||||
};
|
};
|
||||||
final attrs = _attributes;
|
if (_attributes != null) {
|
||||||
if (attrs != null) {
|
result['attributes'] = attributes;
|
||||||
result['attributes'] = {...attrs};
|
|
||||||
}
|
}
|
||||||
return result;
|
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
|
@override
|
||||||
bool operator ==(Object other) {
|
bool operator ==(Object other) {
|
||||||
if (other is! TextDelete) {
|
if (identical(this, other)) return true;
|
||||||
return false;
|
|
||||||
}
|
return other is TextRetain &&
|
||||||
return _length == other.length;
|
other.length == length &&
|
||||||
|
mapEquals(_attributes, other._attributes);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get hashCode {
|
int get hashCode => length.hashCode ^ _attributes.hashCode;
|
||||||
return _length.hashCode;
|
}
|
||||||
}
|
|
||||||
|
class TextDelete extends TextOperation {
|
||||||
|
TextDelete({
|
||||||
|
required this.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
int length;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Attributes? get attributes => null;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Map<String, dynamic> toJson() {
|
Map<String, dynamic> toJson() {
|
||||||
return {
|
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 {
|
class _OpIterator {
|
||||||
|
_OpIterator(
|
||||||
|
Iterable<TextOperation> operations,
|
||||||
|
) : _operations = UnmodifiableListView(operations);
|
||||||
|
|
||||||
final UnmodifiableListView<TextOperation> _operations;
|
final UnmodifiableListView<TextOperation> _operations;
|
||||||
int _index = 0;
|
int _index = 0;
|
||||||
int _offset = 0;
|
int _offset = 0;
|
||||||
|
|
||||||
_OpIterator(List<TextOperation> operations)
|
|
||||||
: _operations = UnmodifiableListView(operations);
|
|
||||||
|
|
||||||
bool get hasNext {
|
bool get hasNext {
|
||||||
return peekLength() < _maxInt;
|
return peekLength() < _maxInt;
|
||||||
}
|
}
|
||||||
@ -204,20 +506,17 @@ class _OpIterator {
|
|||||||
_offset += length;
|
_offset += length;
|
||||||
}
|
}
|
||||||
if (nextOp is TextDelete) {
|
if (nextOp is TextDelete) {
|
||||||
return TextDelete(length);
|
return TextDelete(length: length);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (nextOp is TextRetain) {
|
if (nextOp is TextRetain) {
|
||||||
return TextRetain(
|
return TextRetain(length, attributes: nextOp.attributes);
|
||||||
length,
|
|
||||||
nextOp.attributes,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (nextOp is TextInsert) {
|
if (nextOp is TextInsert) {
|
||||||
return TextInsert(
|
return TextInsert(
|
||||||
nextOp.content.substring(offset, offset + length),
|
nextOp.text.substring(offset, offset + length),
|
||||||
nextOp.attributes,
|
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/node.dart';
|
||||||
import 'package:appflowy_editor/src/core/document/path.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';
|
import '../core/document/attributes.dart';
|
||||||
|
|
||||||
class StateTree {
|
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/core/document/path.dart';
|
||||||
import 'package:appflowy_editor/src/document/position.dart';
|
import 'package:appflowy_editor/src/document/position.dart';
|
||||||
import 'package:appflowy_editor/src/document/selection.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';
|
import 'package:appflowy_editor/src/document/built_in_attribute_keys.dart';
|
||||||
|
|
||||||
extension TextNodeExtension on TextNode {
|
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/attributes.dart';
|
||||||
import 'package:appflowy_editor/src/core/document/node.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:appflowy_editor/src/extensions/color_extension.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:html/parser.dart' show parse;
|
import 'package:html/parser.dart' show parse;
|
||||||
@ -218,24 +218,29 @@ class HTMLToNodesConverter {
|
|||||||
|
|
||||||
_handleRichTextElement(Delta delta, html.Element element) {
|
_handleRichTextElement(Delta delta, html.Element element) {
|
||||||
if (element.localName == HTMLTag.span) {
|
if (element.localName == HTMLTag.span) {
|
||||||
delta.insert(element.text,
|
delta.insert(
|
||||||
_getDeltaAttributesFromHtmlAttributes(element.attributes));
|
element.text,
|
||||||
|
attributes: _getDeltaAttributesFromHtmlAttributes(element.attributes),
|
||||||
|
);
|
||||||
} else if (element.localName == HTMLTag.anchor) {
|
} else if (element.localName == HTMLTag.anchor) {
|
||||||
final hyperLink = element.attributes["href"];
|
final hyperLink = element.attributes["href"];
|
||||||
Map<String, dynamic>? attributes;
|
Map<String, dynamic>? attributes;
|
||||||
if (hyperLink != null) {
|
if (hyperLink != null) {
|
||||||
attributes = {"href": hyperLink};
|
attributes = {"href": hyperLink};
|
||||||
}
|
}
|
||||||
delta.insert(element.text, attributes);
|
delta.insert(element.text, attributes: attributes);
|
||||||
} else if (element.localName == HTMLTag.strong ||
|
} else if (element.localName == HTMLTag.strong ||
|
||||||
element.localName == HTMLTag.bold) {
|
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) {
|
} 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) {
|
} 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) {
|
} else if (element.localName == HTMLTag.del) {
|
||||||
delta.insert(element.text, {BuiltInAttributeKey.strikethrough: true});
|
delta.insert(element.text,
|
||||||
|
attributes: {BuiltInAttributeKey.strikethrough: true});
|
||||||
} else {
|
} else {
|
||||||
delta.insert(element.text);
|
delta.insert(element.text);
|
||||||
}
|
}
|
||||||
@ -535,22 +540,22 @@ class NodesToHTMLConverter {
|
|||||||
if (attributes.length == 1 &&
|
if (attributes.length == 1 &&
|
||||||
attributes[BuiltInAttributeKey.bold] == true) {
|
attributes[BuiltInAttributeKey.bold] == true) {
|
||||||
final strong = html.Element.tag(HTMLTag.strong);
|
final strong = html.Element.tag(HTMLTag.strong);
|
||||||
strong.append(html.Text(op.content));
|
strong.append(html.Text(op.text));
|
||||||
childNodes.add(strong);
|
childNodes.add(strong);
|
||||||
} else if (attributes.length == 1 &&
|
} else if (attributes.length == 1 &&
|
||||||
attributes[BuiltInAttributeKey.underline] == true) {
|
attributes[BuiltInAttributeKey.underline] == true) {
|
||||||
final strong = html.Element.tag(HTMLTag.underline);
|
final strong = html.Element.tag(HTMLTag.underline);
|
||||||
strong.append(html.Text(op.content));
|
strong.append(html.Text(op.text));
|
||||||
childNodes.add(strong);
|
childNodes.add(strong);
|
||||||
} else if (attributes.length == 1 &&
|
} else if (attributes.length == 1 &&
|
||||||
attributes[BuiltInAttributeKey.italic] == true) {
|
attributes[BuiltInAttributeKey.italic] == true) {
|
||||||
final strong = html.Element.tag(HTMLTag.italic);
|
final strong = html.Element.tag(HTMLTag.italic);
|
||||||
strong.append(html.Text(op.content));
|
strong.append(html.Text(op.text));
|
||||||
childNodes.add(strong);
|
childNodes.add(strong);
|
||||||
} else if (attributes.length == 1 &&
|
} else if (attributes.length == 1 &&
|
||||||
attributes[BuiltInAttributeKey.strikethrough] == true) {
|
attributes[BuiltInAttributeKey.strikethrough] == true) {
|
||||||
final strong = html.Element.tag(HTMLTag.del);
|
final strong = html.Element.tag(HTMLTag.del);
|
||||||
strong.append(html.Text(op.content));
|
strong.append(html.Text(op.text));
|
||||||
childNodes.add(strong);
|
childNodes.add(strong);
|
||||||
} else {
|
} else {
|
||||||
final span = html.Element.tag(HTMLTag.span);
|
final span = html.Element.tag(HTMLTag.span);
|
||||||
@ -558,11 +563,11 @@ class NodesToHTMLConverter {
|
|||||||
if (cssString.isNotEmpty) {
|
if (cssString.isNotEmpty) {
|
||||||
span.attributes["style"] = cssString;
|
span.attributes["style"] = cssString;
|
||||||
}
|
}
|
||||||
span.append(html.Text(op.content));
|
span.append(html.Text(op.text));
|
||||||
childNodes.add(span);
|
childNodes.add(span);
|
||||||
}
|
}
|
||||||
} else {
|
} 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/core/document/path.dart';
|
||||||
import 'package:appflowy_editor/src/document/position.dart';
|
import 'package:appflowy_editor/src/document/position.dart';
|
||||||
import 'package:appflowy_editor/src/document/selection.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/editor_state.dart';
|
||||||
import 'package:appflowy_editor/src/operation/operation.dart';
|
import 'package:appflowy_editor/src/operation/operation.dart';
|
||||||
import 'package:appflowy_editor/src/operation/transaction.dart';
|
import 'package:appflowy_editor/src/operation/transaction.dart';
|
||||||
@ -134,7 +134,7 @@ class TransactionBuilder {
|
|||||||
..retain(index)
|
..retain(index)
|
||||||
..insert(
|
..insert(
|
||||||
content,
|
content,
|
||||||
newAttributes,
|
attributes: newAttributes,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
afterSelection = Selection.collapsed(
|
afterSelection = Selection.collapsed(
|
||||||
@ -148,7 +148,7 @@ class TransactionBuilder {
|
|||||||
node,
|
node,
|
||||||
() => Delta()
|
() => Delta()
|
||||||
..retain(index)
|
..retain(index)
|
||||||
..retain(length, attributes));
|
..retain(length, attributes: attributes));
|
||||||
afterSelection = beforeSelection;
|
afterSelection = beforeSelection;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -177,7 +177,7 @@ class TransactionBuilder {
|
|||||||
() => Delta()
|
() => Delta()
|
||||||
..retain(index)
|
..retain(index)
|
||||||
..delete(length)
|
..delete(length)
|
||||||
..insert(content, newAttributes),
|
..insert(content, attributes: newAttributes),
|
||||||
);
|
);
|
||||||
afterSelection = Selection.collapsed(
|
afterSelection = Selection.collapsed(
|
||||||
Position(
|
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/core/document/path.dart';
|
||||||
import 'package:appflowy_editor/src/document/position.dart';
|
import 'package:appflowy_editor/src/document/position.dart';
|
||||||
import 'package:appflowy_editor/src/document/selection.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/editor_state.dart';
|
||||||
import 'package:appflowy_editor/src/extensions/url_launcher_extension.dart';
|
import 'package:appflowy_editor/src/extensions/url_launcher_extension.dart';
|
||||||
import 'package:appflowy_editor/src/extensions/text_style_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;
|
offset += textInsert.length;
|
||||||
textSpans.add(
|
textSpans.add(
|
||||||
TextSpan(
|
TextSpan(
|
||||||
text: textInsert.content,
|
text: textInsert.text,
|
||||||
style: textStyle,
|
style: textStyle,
|
||||||
recognizer: recognizer,
|
recognizer: recognizer,
|
||||||
),
|
),
|
||||||
|
@ -222,7 +222,7 @@ Delta _lineContentToDelta(String lineContent) {
|
|||||||
delta.insert(lineContent.substring(lastUrlEndOffset, match.start));
|
delta.insert(lineContent.substring(lastUrlEndOffset, match.start));
|
||||||
}
|
}
|
||||||
final linkContent = lineContent.substring(match.start, match.end);
|
final linkContent = lineContent.substring(match.start, match.end);
|
||||||
delta.insert(linkContent, {"href": linkContent});
|
delta.insert(linkContent, attributes: {"href": linkContent});
|
||||||
lastUrlEndOffset = match.end;
|
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}) {
|
void insertTextNode(String? text, {Attributes? attributes, Delta? delta}) {
|
||||||
insert(
|
insert(
|
||||||
TextNode(
|
TextNode(
|
||||||
delta: delta ?? Delta([TextInsert(text ?? 'Test')]),
|
delta: delta ?? Delta(operations: [TextInsert(text ?? 'Test')]),
|
||||||
attributes: attributes,
|
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.subtype: BuiltInAttributeKey.checkbox,
|
||||||
BuiltInAttributeKey.checkbox: false,
|
BuiltInAttributeKey.checkbox: false,
|
||||||
},
|
},
|
||||||
delta: Delta([
|
delta: Delta(operations: [
|
||||||
TextInsert(text, {
|
TextInsert(text, attributes: {
|
||||||
BuiltInAttributeKey.bold: true,
|
BuiltInAttributeKey.bold: true,
|
||||||
BuiltInAttributeKey.italic: true,
|
BuiltInAttributeKey.italic: true,
|
||||||
BuiltInAttributeKey.underline: true,
|
BuiltInAttributeKey.underline: true,
|
||||||
|
@ -63,9 +63,9 @@ void main() async {
|
|||||||
..insertTextNode(text)
|
..insertTextNode(text)
|
||||||
..insertTextNode(
|
..insertTextNode(
|
||||||
null,
|
null,
|
||||||
delta: Delta([
|
delta: Delta(operations: [
|
||||||
TextInsert(text),
|
TextInsert(text),
|
||||||
TextInsert(text, attributes),
|
TextInsert(text, attributes: attributes),
|
||||||
TextInsert(text),
|
TextInsert(text),
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
@ -171,8 +171,8 @@ void main() async {
|
|||||||
BuiltInAttributeKey.subtype: BuiltInAttributeKey.heading,
|
BuiltInAttributeKey.subtype: BuiltInAttributeKey.heading,
|
||||||
BuiltInAttributeKey.heading: BuiltInAttributeKey.h1,
|
BuiltInAttributeKey.heading: BuiltInAttributeKey.h1,
|
||||||
},
|
},
|
||||||
delta: Delta([
|
delta: Delta(operations: [
|
||||||
TextInsert(text, {
|
TextInsert(text, attributes: {
|
||||||
BuiltInAttributeKey.bold: true,
|
BuiltInAttributeKey.bold: true,
|
||||||
})
|
})
|
||||||
]),
|
]),
|
||||||
|
Loading…
Reference in New Issue
Block a user