refactor: move text_delta to core/document

This commit is contained in:
Lucas.Xu 2022-10-10 00:31:15 +08:00
parent b9788bce09
commit e095fd4181
14 changed files with 780 additions and 801 deletions

View File

@ -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';

View File

@ -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 ?? {},

View File

@ -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;
}

View File

@ -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 {

View File

@ -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 {

View File

@ -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));
}
}
}

View File

@ -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(

View File

@ -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,
),

View File

@ -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;
}

View File

@ -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);
});
});
});
}

View File

@ -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,
),
);

View File

@ -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);
});
});
}

View File

@ -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,

View File

@ -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,
})
]),