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 'dart:collection';
|
||||||
import 'package:flowy_editor/document/path.dart';
|
import 'package:flowy_editor/document/path.dart';
|
||||||
|
|
||||||
typedef Attributes = Map<String, Object>;
|
typedef Attributes = Map<String, dynamic>;
|
||||||
|
|
||||||
class Node extends LinkedListEntry<Node> {
|
class Node extends LinkedListEntry<Node> {
|
||||||
Node? parent;
|
Node? parent;
|
||||||
|
@ -1 +1,7 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
typedef Path = List<int>;
|
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/node.dart';
|
||||||
import 'package:flowy_editor/document/state_tree.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/services.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
@ -61,4 +65,69 @@ void main() {
|
|||||||
expect(updatedNode != null, true);
|
expect(updatedNode != null, true);
|
||||||
expect(updatedNode!.attributes['text-type'], 'heading1');
|
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…
x
Reference in New Issue
Block a user