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/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..88941cd82e --- /dev/null +++ b/frontend/app_flowy/packages/flowy_editor/lib/document/position.dart @@ -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); + } +} 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..dea3a2b752 --- /dev/null +++ b/frontend/app_flowy/packages/flowy_editor/lib/document/selection.dart @@ -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; + } +} 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..30d3b81b9f --- /dev/null +++ b/frontend/app_flowy/packages/flowy_editor/lib/document/text_delta.dart @@ -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 _operations; + int _index = 0; + int _offset = 0; + + _OpIterator(List 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 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: 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; +} 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 new file mode 100644 index 0000000000..15e8353725 --- /dev/null +++ b/frontend/app_flowy/packages/flowy_editor/lib/editor_state.dart @@ -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); + } + } +} 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 = []; + +} 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..f90d487b18 --- /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); + }); +} 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); + }); }