mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2024-08-30 18:12:39 +00:00
Merge pull request #606 from vincentdchan/feat/flowy_editor
feat: position and selection
This commit is contained in:
commit
bfca0a17e0
@ -1,7 +1,7 @@
|
||||
import 'dart:collection';
|
||||
import 'package:flowy_editor/document/path.dart';
|
||||
|
||||
typedef Attributes = Map<String, Object>;
|
||||
typedef Attributes = Map<String, dynamic>;
|
||||
|
||||
class Node extends LinkedListEntry<Node> {
|
||||
Node? parent;
|
||||
|
@ -1 +1,7 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
typedef Path = List<int>;
|
||||
|
||||
bool pathEquals(Path path1, Path path2) {
|
||||
return listEquals(path1, path2);
|
||||
}
|
||||
|
@ -0,0 +1,27 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import './path.dart';
|
||||
|
||||
class Position {
|
||||
final Path path;
|
||||
final int offset;
|
||||
|
||||
Position({
|
||||
required this.path,
|
||||
this.offset = 0,
|
||||
});
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (other is! Position) {
|
||||
return false;
|
||||
}
|
||||
return pathEquals(path, other.path) && offset == other.offset;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
final pathHash = hashList(path);
|
||||
return Object.hash(pathHash, offset);
|
||||
}
|
||||
}
|
@ -0,0 +1,27 @@
|
||||
import './position.dart';
|
||||
|
||||
class Selection {
|
||||
final Position start;
|
||||
final Position end;
|
||||
|
||||
Selection({
|
||||
required this.start,
|
||||
required this.end,
|
||||
});
|
||||
|
||||
factory Selection.collapsed(Position pos) {
|
||||
return Selection(start: pos, end: pos);
|
||||
}
|
||||
|
||||
Selection collapse({bool atStart = false}) {
|
||||
if (atStart) {
|
||||
return Selection(start: start, end: start);
|
||||
} else {
|
||||
return Selection(start: end, end: end);
|
||||
}
|
||||
}
|
||||
|
||||
bool isCollapsed() {
|
||||
return start == end;
|
||||
}
|
||||
}
|
@ -0,0 +1,415 @@
|
||||
import 'dart:collection';
|
||||
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.map((e) => Object.hash(e.key, e.value)));
|
||||
}
|
||||
|
||||
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 UnmodifiableListView<TextOperation> _operations;
|
||||
int _index = 0;
|
||||
int _offset = 0;
|
||||
|
||||
_OpIterator(List<TextOperation> operations) : _operations = UnmodifiableListView(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: 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.addAll(other.operations.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 attributes = {};
|
||||
attributes.addAll(b);
|
||||
|
||||
for (final entry in a.entries) {
|
||||
if (!b.containsKey(entry.key)) {
|
||||
attributes[entry.key] = entry.value;
|
||||
}
|
||||
}
|
||||
|
||||
if (attributes.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return attributes;
|
||||
}
|
@ -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});
|
||||
}
|
@ -0,0 +1,31 @@
|
||||
import 'package:flowy_editor/operation/operation.dart';
|
||||
|
||||
import './document/state_tree.dart';
|
||||
import './document/selection.dart';
|
||||
import './operation/operation.dart';
|
||||
import './operation/transaction.dart';
|
||||
|
||||
class EditorState {
|
||||
final StateTree document;
|
||||
Selection? cursorSelection;
|
||||
|
||||
EditorState({
|
||||
required this.document,
|
||||
});
|
||||
|
||||
apply(Transaction transaction) {
|
||||
for (final op in transaction.operations) {
|
||||
_applyOperation(op);
|
||||
}
|
||||
}
|
||||
|
||||
_applyOperation(Operation op) {
|
||||
if (op is InsertOperation) {
|
||||
document.insert(op.path, op.value);
|
||||
} else if (op is UpdateOperation) {
|
||||
document.update(op.path, op.attributes);
|
||||
} else if (op is DeleteOperation) {
|
||||
document.delete(op.path);
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,58 @@
|
||||
import 'package:flowy_editor/document/path.dart';
|
||||
import 'package:flowy_editor/document/node.dart';
|
||||
|
||||
abstract class Operation {
|
||||
|
||||
Operation invert();
|
||||
|
||||
}
|
||||
|
||||
class InsertOperation extends Operation {
|
||||
final Path path;
|
||||
final Node value;
|
||||
|
||||
InsertOperation({
|
||||
required this.path,
|
||||
required this.value,
|
||||
});
|
||||
|
||||
@override
|
||||
Operation invert() {
|
||||
return DeleteOperation(path: path, removedValue: value);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class UpdateOperation extends Operation {
|
||||
final Path path;
|
||||
final Attributes attributes;
|
||||
final Attributes oldAttributes;
|
||||
|
||||
UpdateOperation({
|
||||
required this.path,
|
||||
required this.attributes,
|
||||
required this.oldAttributes,
|
||||
});
|
||||
|
||||
@override
|
||||
Operation invert() {
|
||||
return UpdateOperation(path: path, attributes: oldAttributes, oldAttributes: attributes);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class DeleteOperation extends Operation {
|
||||
final Path path;
|
||||
final Node removedValue;
|
||||
|
||||
DeleteOperation({
|
||||
required this.path,
|
||||
required this.removedValue,
|
||||
});
|
||||
|
||||
@override
|
||||
Operation invert() {
|
||||
return InsertOperation(path: path, value: removedValue);
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,6 @@
|
||||
import './operation.dart';
|
||||
|
||||
class Transaction {
|
||||
final List<Operation> operations = [];
|
||||
|
||||
}
|
175
frontend/app_flowy/packages/flowy_editor/test/delta_test.dart
Normal file
175
frontend/app_flowy/packages/flowy_editor/test/delta_test.dart
Normal 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);
|
||||
});
|
||||
}
|
@ -2,6 +2,10 @@ import 'dart:convert';
|
||||
|
||||
import 'package:flowy_editor/document/node.dart';
|
||||
import 'package:flowy_editor/document/state_tree.dart';
|
||||
import 'package:flowy_editor/document/path.dart';
|
||||
import 'package:flowy_editor/document/position.dart';
|
||||
import 'package:flowy_editor/document/selection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
@ -61,4 +65,69 @@ void main() {
|
||||
expect(updatedNode != null, true);
|
||||
expect(updatedNode!.attributes['text-type'], 'heading1');
|
||||
});
|
||||
|
||||
test('test path utils 1', () {
|
||||
final path1 = <int>[1];
|
||||
final path2 = <int>[1];
|
||||
expect(pathEquals(path1, path2), true);
|
||||
|
||||
expect(hashList(path1), hashList(path2));
|
||||
});
|
||||
|
||||
test('test path utils 2', () {
|
||||
final path1 = <int>[1];
|
||||
final path2 = <int>[2];
|
||||
expect(pathEquals(path1, path2), false);
|
||||
|
||||
expect(hashList(path1) != hashList(path2), true);
|
||||
});
|
||||
|
||||
test('test position comparator', () {
|
||||
final pos1 = Position(path: [1], offset: 0);
|
||||
final pos2 = Position(path: [1], offset: 0);
|
||||
expect(pos1 == pos2, true);
|
||||
expect(pos1.hashCode == pos2.hashCode, true);
|
||||
});
|
||||
|
||||
test('test position comparator with offset', () {
|
||||
final pos1 = Position(path: [1, 1, 1, 1, 1], offset: 100);
|
||||
final pos2 = Position(path: [1, 1, 1, 1, 1], offset: 100);
|
||||
expect(pos1, pos2);
|
||||
expect(pos1.hashCode, pos2.hashCode);
|
||||
});
|
||||
|
||||
test('test position comparator false', () {
|
||||
final pos1 = Position(path: [1, 1, 1, 1, 1], offset: 100);
|
||||
final pos2 = Position(path: [1, 1, 2, 1, 1], offset: 100);
|
||||
expect(pos1 == pos2, false);
|
||||
expect(pos1.hashCode == pos2.hashCode, false);
|
||||
});
|
||||
|
||||
test('test position comparator with offset false', () {
|
||||
final pos1 = Position(path: [1, 1, 1, 1, 1], offset: 100);
|
||||
final pos2 = Position(path: [1, 1, 1, 1, 1], offset: 101);
|
||||
expect(pos1 == pos2, false);
|
||||
expect(pos1.hashCode == pos2.hashCode, false);
|
||||
});
|
||||
|
||||
test('test selection comparator', () {
|
||||
final pos = Position(path: [0], offset: 0);
|
||||
final sel = Selection.collapsed(pos);
|
||||
expect(sel.start, sel.end);
|
||||
expect(sel.isCollapsed(), true);
|
||||
});
|
||||
|
||||
test('test selection collapse', () {
|
||||
final start = Position(path: [0], offset: 0);
|
||||
final end = Position(path: [0], offset: 10);
|
||||
final sel = Selection(start: start, end: end);
|
||||
|
||||
final collapsedSelAtStart = sel.collapse(atStart: true);
|
||||
expect(collapsedSelAtStart.start, start);
|
||||
expect(collapsedSelAtStart.end, start);
|
||||
|
||||
final collapsedSelAtEnd = sel.collapse();
|
||||
expect(collapsedSelAtEnd.start, end);
|
||||
expect(collapsedSelAtEnd.end, end);
|
||||
});
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user