Merge pull request #606 from vincentdchan/feat/flowy_editor

feat: position and selection
This commit is contained in:
Nathan.fooo 2022-07-13 16:28:36 +08:00 committed by GitHub
commit bfca0a17e0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 828 additions and 1 deletions

View File

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

View File

@ -1 +1,7 @@
import 'package:flutter/foundation.dart';
typedef Path = List<int>;
bool pathEquals(Path path1, Path path2) {
return listEquals(path1, path2);
}

View File

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

View File

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

View File

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

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

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

View File

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

View File

@ -0,0 +1,6 @@
import './operation.dart';
class Transaction {
final List<Operation> operations = [];
}

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

View File

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