From 9c73a8cd9ab86e931d796a1df31c3d7ae79bfaa7 Mon Sep 17 00:00:00 2001 From: Vincent Chan Date: Tue, 12 Jul 2022 11:54:06 +0800 Subject: [PATCH 1/6] feat: position and selection --- .../flowy_editor/lib/document/path.dart | 6 ++ .../flowy_editor/lib/document/position.dart | 28 ++++++++ .../flowy_editor/lib/document/selection.dart | 28 ++++++++ .../flowy_editor/test/flowy_editor_test.dart | 69 +++++++++++++++++++ 4 files changed, 131 insertions(+) create mode 100644 frontend/app_flowy/packages/flowy_editor/lib/document/position.dart create mode 100644 frontend/app_flowy/packages/flowy_editor/lib/document/selection.dart diff --git a/frontend/app_flowy/packages/flowy_editor/lib/document/path.dart b/frontend/app_flowy/packages/flowy_editor/lib/document/path.dart index 35ac1f467e..a8163f094d 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/document/path.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/document/path.dart @@ -1 +1,7 @@ +import 'package:flutter/foundation.dart'; + typedef Path = List; + +bool pathEquals(Path path1, Path path2) { + return listEquals(path1, path2); +} diff --git a/frontend/app_flowy/packages/flowy_editor/lib/document/position.dart b/frontend/app_flowy/packages/flowy_editor/lib/document/position.dart new file mode 100644 index 0000000000..2c7d85f908 --- /dev/null +++ b/frontend/app_flowy/packages/flowy_editor/lib/document/position.dart @@ -0,0 +1,28 @@ +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 pathHash ^ offset; + } + +} diff --git a/frontend/app_flowy/packages/flowy_editor/lib/document/selection.dart b/frontend/app_flowy/packages/flowy_editor/lib/document/selection.dart new file mode 100644 index 0000000000..a03ccae37f --- /dev/null +++ b/frontend/app_flowy/packages/flowy_editor/lib/document/selection.dart @@ -0,0 +1,28 @@ +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; + } + +} diff --git a/frontend/app_flowy/packages/flowy_editor/test/flowy_editor_test.dart b/frontend/app_flowy/packages/flowy_editor/test/flowy_editor_test.dart index e8f14bc9c7..cb67f61d96 100644 --- a/frontend/app_flowy/packages/flowy_editor/test/flowy_editor_test.dart +++ b/frontend/app_flowy/packages/flowy_editor/test/flowy_editor_test.dart @@ -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 = [1]; + final path2 = [1]; + expect(pathEquals(path1, path2), true); + + expect(hashList(path1), hashList(path2)); + }); + + test('test path utils 2', () { + final path1 = [1]; + final path2 = [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); + }); } From 1b0c29ea09ac8b5a9e702bd332c18cba6feb7256 Mon Sep 17 00:00:00 2001 From: Vincent Chan Date: Tue, 12 Jul 2022 13:34:40 +0800 Subject: [PATCH 2/6] feat: implement editor state operation --- .../flowy_editor/lib/editor_state.dart | 32 ++++++++++ .../flowy_editor/lib/operation/operation.dart | 58 +++++++++++++++++++ .../lib/operation/transaction.dart | 6 ++ 3 files changed, 96 insertions(+) create mode 100644 frontend/app_flowy/packages/flowy_editor/lib/editor_state.dart create mode 100644 frontend/app_flowy/packages/flowy_editor/lib/operation/operation.dart create mode 100644 frontend/app_flowy/packages/flowy_editor/lib/operation/transaction.dart diff --git a/frontend/app_flowy/packages/flowy_editor/lib/editor_state.dart b/frontend/app_flowy/packages/flowy_editor/lib/editor_state.dart new file mode 100644 index 0000000000..d0f8c39847 --- /dev/null +++ b/frontend/app_flowy/packages/flowy_editor/lib/editor_state.dart @@ -0,0 +1,32 @@ +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); + } + } + +} diff --git a/frontend/app_flowy/packages/flowy_editor/lib/operation/operation.dart b/frontend/app_flowy/packages/flowy_editor/lib/operation/operation.dart new file mode 100644 index 0000000000..b5d71b57d4 --- /dev/null +++ b/frontend/app_flowy/packages/flowy_editor/lib/operation/operation.dart @@ -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); + } + +} diff --git a/frontend/app_flowy/packages/flowy_editor/lib/operation/transaction.dart b/frontend/app_flowy/packages/flowy_editor/lib/operation/transaction.dart new file mode 100644 index 0000000000..c6fbed63aa --- /dev/null +++ b/frontend/app_flowy/packages/flowy_editor/lib/operation/transaction.dart @@ -0,0 +1,6 @@ +import './operation.dart'; + +class Transaction { + final List operations = []; + +} From 2881edd50529c3e2bd8fda4b4552feeb73d18618 Mon Sep 17 00:00:00 2001 From: Vincent Chan Date: Tue, 12 Jul 2022 20:36:04 +0800 Subject: [PATCH 3/6] feat: implement text delta operation --- .../flowy_editor/lib/document/position.dart | 5 +- .../flowy_editor/lib/document/selection.dart | 3 +- .../flowy_editor/lib/document/text_delta.dart | 413 ++++++++++++++++++ .../flowy_editor/lib/document/text_node.dart | 13 + .../flowy_editor/lib/editor_state.dart | 1 - .../flowy_editor/test/delta_test.dart | 175 ++++++++ 6 files changed, 604 insertions(+), 6 deletions(-) create mode 100644 frontend/app_flowy/packages/flowy_editor/lib/document/text_delta.dart create mode 100644 frontend/app_flowy/packages/flowy_editor/lib/document/text_node.dart create mode 100644 frontend/app_flowy/packages/flowy_editor/test/delta_test.dart diff --git a/frontend/app_flowy/packages/flowy_editor/lib/document/position.dart b/frontend/app_flowy/packages/flowy_editor/lib/document/position.dart index 2c7d85f908..88941cd82e 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/document/position.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/document/position.dart @@ -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); } - } diff --git a/frontend/app_flowy/packages/flowy_editor/lib/document/selection.dart b/frontend/app_flowy/packages/flowy_editor/lib/document/selection.dart index a03ccae37f..dea3a2b752 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/document/selection.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/document/selection.dart @@ -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; } - } diff --git a/frontend/app_flowy/packages/flowy_editor/lib/document/text_delta.dart b/frontend/app_flowy/packages/flowy_editor/lib/document/text_delta.dart new file mode 100644 index 0000000000..8d05099291 --- /dev/null +++ b/frontend/app_flowy/packages/flowy_editor/lib/document/text_delta.dart @@ -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 _operations; + int _index = 0; + int _offset = 0; + + _OpIterator(List 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 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 operations; + + Delta([List? ops]) : operations = ops ?? []; + + 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 = []; + + 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 = {}; + 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; +} diff --git a/frontend/app_flowy/packages/flowy_editor/lib/document/text_node.dart b/frontend/app_flowy/packages/flowy_editor/lib/document/text_node.dart new file mode 100644 index 0000000000..2e12deb939 --- /dev/null +++ b/frontend/app_flowy/packages/flowy_editor/lib/document/text_node.dart @@ -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}); +} diff --git a/frontend/app_flowy/packages/flowy_editor/lib/editor_state.dart b/frontend/app_flowy/packages/flowy_editor/lib/editor_state.dart index d0f8c39847..15e8353725 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/editor_state.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/editor_state.dart @@ -28,5 +28,4 @@ class EditorState { document.delete(op.path); } } - } diff --git a/frontend/app_flowy/packages/flowy_editor/test/delta_test.dart b/frontend/app_flowy/packages/flowy_editor/test/delta_test.dart new file mode 100644 index 0000000000..1123ad1bc0 --- /dev/null +++ b/frontend/app_flowy/packages/flowy_editor/test/delta_test.dart @@ -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([ + 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, [ + 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); + // }); +} From 8bd748d7cd614a1f88b49e4c69f6e6580774cc45 Mon Sep 17 00:00:00 2001 From: Vincent Chan Date: Wed, 13 Jul 2022 15:27:05 +0800 Subject: [PATCH 4/6] fix: unit tests --- .../flowy_editor/lib/document/text_delta.dart | 15 ++-- .../flowy_editor/test/delta_test.dart | 82 +++++++++---------- 2 files changed, 49 insertions(+), 48 deletions(-) diff --git a/frontend/app_flowy/packages/flowy_editor/lib/document/text_delta.dart b/frontend/app_flowy/packages/flowy_editor/lib/document/text_delta.dart index 8d05099291..c53d243137 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/document/text_delta.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/document/text_delta.dart @@ -22,7 +22,8 @@ class TextOperation { } int _hashAttributes(Attributes attributes) { - return Object.hashAllUnordered(attributes.entries); + return Object.hashAllUnordered( + attributes.entries.map((e) => Object.hash(e.key, e.value))); } class TextInsert extends TextOperation { @@ -335,7 +336,7 @@ class Delta { if (otherOp is TextRetain && otherOp.length > 0) { TextOperation? newOp; if (thisOp is TextRetain) { - newOp = TextRetain(length: otherOp.length, attributes: attributes); + newOp = TextRetain(length: length, attributes: attributes); } else if (thisOp is TextInsert) { newOp = TextInsert(thisOp.content, attributes); } @@ -363,7 +364,7 @@ class Delta { var ops = [...operations]; if (other.operations.isNotEmpty) { ops.add(other.operations[0]); - ops = ops.sublist(1); + ops.addAll(other.operations.sublist(1)); } return Delta(ops); } @@ -399,15 +400,15 @@ Attributes? _composeMap(Attributes? a, Attributes? b) { final attributes = {}; attributes.addAll(b); - if (attributes.isEmpty) { - return null; - } - for (final entry in a.entries) { if (!b.containsKey(entry.key)) { attributes[entry.key] = entry.value; } } + if (attributes.isEmpty) { + return null; + } + return attributes; } diff --git a/frontend/app_flowy/packages/flowy_editor/test/delta_test.dart b/frontend/app_flowy/packages/flowy_editor/test/delta_test.dart index 1123ad1bc0..f90d487b18 100644 --- a/frontend/app_flowy/packages/flowy_editor/test/delta_test.dart +++ b/frontend/app_flowy/packages/flowy_editor/test/delta_test.dart @@ -73,16 +73,16 @@ void main() { 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 + 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', @@ -105,12 +105,12 @@ void main() { 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 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'); @@ -147,29 +147,29 @@ void main() { .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); - // }); + 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); + }); } From f2c477e89feb0905bb0ece8ecb5a090f8cec64a8 Mon Sep 17 00:00:00 2001 From: Vincent Chan Date: Wed, 13 Jul 2022 16:01:48 +0800 Subject: [PATCH 5/6] feat: change attributes map to dynamic --- frontend/app_flowy/packages/flowy_editor/lib/document/node.dart | 2 +- .../packages/flowy_editor/lib/document/text_delta.dart | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/app_flowy/packages/flowy_editor/lib/document/node.dart b/frontend/app_flowy/packages/flowy_editor/lib/document/node.dart index e4ff84b99c..fd93da0bdd 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/document/node.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/document/node.dart @@ -1,7 +1,7 @@ import 'dart:collection'; import 'package:flowy_editor/document/path.dart'; -typedef Attributes = Map; +typedef Attributes = Map; class Node extends LinkedListEntry { Node? parent; diff --git a/frontend/app_flowy/packages/flowy_editor/lib/document/text_delta.dart b/frontend/app_flowy/packages/flowy_editor/lib/document/text_delta.dart index c53d243137..c799fa65c2 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/document/text_delta.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/document/text_delta.dart @@ -397,7 +397,7 @@ class Delta { Attributes? _composeMap(Attributes? a, Attributes? b) { a ??= {}; b ??= {}; - final attributes = {}; + final Attributes attributes = {}; attributes.addAll(b); for (final entry in a.entries) { From de507001f4c54b0d4727094aec6415d37eb00471 Mon Sep 17 00:00:00 2001 From: Vincent Chan Date: Wed, 13 Jul 2022 16:24:17 +0800 Subject: [PATCH 6/6] fix: use UnmodifiableListView for OpIterator --- .../packages/flowy_editor/lib/document/text_delta.dart | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/frontend/app_flowy/packages/flowy_editor/lib/document/text_delta.dart b/frontend/app_flowy/packages/flowy_editor/lib/document/text_delta.dart index c799fa65c2..30d3b81b9f 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/document/text_delta.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/document/text_delta.dart @@ -1,3 +1,4 @@ +import 'dart:collection'; import 'dart:math'; import 'package:flutter/foundation.dart'; @@ -140,11 +141,11 @@ class TextDelete extends TextOperation { } class _OpIterator { - final List _operations; + final UnmodifiableListView _operations; int _index = 0; int _offset = 0; - _OpIterator(List operations) : _operations = operations; + _OpIterator(List operations) : _operations = UnmodifiableListView(operations); bool get hasNext { return peekLength() < _maxInt;