feat: implement text delta operation

This commit is contained in:
Vincent Chan 2022-07-12 20:36:04 +08:00
parent 1b0c29ea09
commit 2881edd505
6 changed files with 604 additions and 6 deletions

View File

@ -12,7 +12,7 @@ class Position {
});
@override
bool operator==(Object other) {
bool operator ==(Object other) {
if (other is! Position) {
return false;
}
@ -22,7 +22,6 @@ class Position {
@override
int get hashCode {
final pathHash = hashList(path);
return pathHash ^ offset;
return Object.hash(pathHash, offset);
}
}

View File

@ -13,7 +13,7 @@ class Selection {
return Selection(start: pos, end: pos);
}
Selection collapse({ bool atStart = false }) {
Selection collapse({bool atStart = false}) {
if (atStart) {
return Selection(start: start, end: start);
} else {
@ -24,5 +24,4 @@ class Selection {
bool isCollapsed() {
return start == end;
}
}

View File

@ -0,0 +1,413 @@
import 'dart:math';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import './node.dart';
// constant number: 2^53 - 1
const int _maxInt = 9007199254740991;
class TextOperation {
bool get isEmpty {
return length == 0;
}
int get length {
return 0;
}
Attributes? get attributes {
return null;
}
}
int _hashAttributes(Attributes attributes) {
return Object.hashAllUnordered(attributes.entries);
}
class TextInsert extends TextOperation {
String content;
final Attributes? _attributes;
TextInsert(this.content, [Attributes? attrs]) : _attributes = attrs;
@override
int get length {
return content.length;
}
@override
Attributes? get attributes {
return _attributes;
}
@override
bool operator ==(Object other) {
if (other is! TextInsert) {
return false;
}
return content == other.content &&
mapEquals(_attributes, other._attributes);
}
@override
int get hashCode {
final contentHash = content.hashCode;
final attrs = _attributes;
return Object.hash(
contentHash, attrs == null ? null : _hashAttributes(attrs));
}
}
class TextRetain extends TextOperation {
int _length;
final Attributes? _attributes;
TextRetain({
required length,
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 ? null : _hashAttributes(attrs));
}
}
class TextDelete extends TextOperation {
int _length;
TextDelete({
required int length,
}) : _length = length;
@override
bool get isEmpty {
return length == 0;
}
@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;
}
@override
int get hashCode {
return _length.hashCode;
}
}
class _OpIterator {
final List<TextOperation> _operations;
int _index = 0;
int _offset = 0;
_OpIterator(List<TextOperation> operations) : _operations = operations;
bool get hasNext {
return peekLength() < _maxInt;
}
TextOperation? peek() {
if (_index >= _operations.length) {
return null;
}
return _operations[_index];
}
int peekLength() {
if (_index < _operations.length) {
final op = _operations[_index];
return op.length - _offset;
}
return _maxInt;
}
TextOperation next([int? length]) {
length ??= _maxInt;
if (_index >= _operations.length) {
return TextRetain(length: _maxInt);
}
final nextOp = _operations[_index];
final offset = _offset;
final opLength = nextOp.length;
if (length >= opLength - offset) {
length = opLength - offset;
_index += 1;
_offset = 0;
} else {
_offset += length;
}
if (nextOp is TextDelete) {
return TextDelete(length: length);
}
if (nextOp is TextRetain) {
return TextRetain(length: length, attributes: nextOp.attributes);
}
if (nextOp is TextInsert) {
return TextInsert(
nextOp.content.substring(offset, offset + length), nextOp.attributes);
}
return TextRetain(length: _maxInt);
}
List<TextOperation> rest() {
if (!hasNext) {
return [];
} else if (_offset == 0) {
return _operations.sublist(_index);
} else {
final offset = _offset;
final index = _index;
final _next = next();
final rest = _operations.sublist(_index);
_offset = offset;
_index = index;
return [_next] + rest;
}
}
}
// basically copy from: https://github.com/quilljs/delta
class Delta {
final List<TextOperation> operations;
Delta([List<TextOperation>? ops]) : operations = ops ?? <TextOperation>[];
Delta add(TextOperation textOp) {
if (textOp.isEmpty) {
return this;
}
if (operations.isNotEmpty) {
final lastOp = operations.last;
if (lastOp is TextDelete && textOp is TextDelete) {
lastOp.length += textOp.length;
return this;
}
if (mapEquals(lastOp.attributes, textOp.attributes)) {
if (lastOp is TextInsert && textOp is TextInsert) {
lastOp.content += textOp.content;
return this;
}
// 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 this;
}
if (lastOp is TextRetain && textOp is TextRetain) {
lastOp.length += textOp.length;
return this;
}
}
}
operations.add(textOp);
return this;
}
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;
}
Delta insert(String content, [Attributes? attributes]) {
final op = TextInsert(content, attributes);
return add(op);
}
Delta retain(int length, [Attributes? attributes]) {
final op = TextRetain(length: length, attributes: attributes);
return add(op);
}
Delta delete(int length) {
final op = TextDelete(length: length);
return add(op);
}
int get length {
return operations.fold(
0, (previousValue, element) => previousValue + element.length);
}
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 = _composeMap(thisOp.attributes, otherOp.attributes);
if (otherOp is TextRetain && otherOp.length > 0) {
TextOperation? newOp;
if (thisOp is TextRetain) {
newOp = TextRetain(length: otherOp.length, attributes: 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[delta.operations.length - 1] == newOp) {
final rest = Delta(thisIter.rest());
return delta.concat(rest).chop();
}
} else if (otherOp is TextDelete && (thisOp is TextRetain)) {
delta.add(otherOp);
}
}
}
return delta.chop();
}
Delta concat(Delta other) {
var ops = [...operations];
if (other.operations.isNotEmpty) {
ops.add(other.operations[0]);
ops = ops.sublist(1);
}
return Delta(ops);
}
Delta chop() {
if (operations.isEmpty) {
return this;
}
final lastOp = operations.last;
if (lastOp is TextRetain && (lastOp.attributes?.length ?? 0) == 0) {
operations.removeLast();
}
return this;
}
@override
bool operator ==(Object other) {
if (other is! Delta) {
return false;
}
return listEquals(operations, other.operations);
}
@override
int get hashCode {
return hashList(operations);
}
}
Attributes? _composeMap(Attributes? a, Attributes? b) {
a ??= {};
b ??= {};
final attributes = <String, Object>{};
attributes.addAll(b);
if (attributes.isEmpty) {
return null;
}
for (final entry in a.entries) {
if (!b.containsKey(entry.key)) {
attributes[entry.key] = entry.value;
}
}
return attributes;
}

View File

@ -0,0 +1,13 @@
import './text_delta.dart';
import './node.dart';
class TextNode extends Node {
final Delta delta;
TextNode(
{required super.type,
required super.children,
required super.attributes,
required this.delta});
}

View File

@ -28,5 +28,4 @@ class EditorState {
document.delete(op.path);
}
}
}

View File

@ -0,0 +1,175 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:flowy_editor/document/text_delta.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
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.operations, <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);
// });
}