diff --git a/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/image_node_widget.dart b/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/image_node_widget.dart index 11607d7bd6..33425eccd5 100644 --- a/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/image_node_widget.dart +++ b/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/image_node_widget.dart @@ -1,6 +1,7 @@ import 'package:flowy_editor/flowy_editor.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import 'package:flowy_editor/document/attributes.dart'; class ImageNodeBuilder extends NodeWidgetBuilder { ImageNodeBuilder.create({ diff --git a/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/text_node_widget.dart b/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/text_node_widget.dart index 66ed5e3779..1af9c7ee7e 100644 --- a/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/text_node_widget.dart +++ b/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/text_node_widget.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flowy_editor/flowy_editor.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; +import 'package:flowy_editor/document/attributes.dart'; class TextNodeBuilder extends NodeWidgetBuilder { TextNodeBuilder.create({ diff --git a/frontend/app_flowy/packages/flowy_editor/lib/document/attributes.dart b/frontend/app_flowy/packages/flowy_editor/lib/document/attributes.dart new file mode 100644 index 0000000000..6e845420ef --- /dev/null +++ b/frontend/app_flowy/packages/flowy_editor/lib/document/attributes.dart @@ -0,0 +1,42 @@ +typedef Attributes = Map; + +int hashAttributes(Attributes attributes) { + return Object.hashAllUnordered( + attributes.entries.map((e) => Object.hash(e.key, e.value))); +} + +Attributes invertAttributes(Attributes? attr, Attributes? base) { + attr ??= {}; + base ??= {}; + final Attributes baseInverted = base.keys.fold({}, (memo, key) { + if (base![key] != attr![key] && attr.containsKey(key)) { + memo[key] = base[key]; + } + return memo; + }); + return attr.keys.fold(baseInverted, (memo, key) { + if (attr![key] != base![key] && base.containsKey(key)) { + memo[key] = null; + } + return memo; + }); +} + +Attributes? composeAttributes(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/node.dart b/frontend/app_flowy/packages/flowy_editor/lib/document/node.dart index 9eccdb7897..84493de0f9 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/document/node.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/document/node.dart @@ -1,8 +1,7 @@ import 'dart:collection'; import 'package:flowy_editor/document/path.dart'; import 'package:flutter/material.dart'; - -typedef Attributes = Map; +import './attributes.dart'; class Node extends ChangeNotifier with LinkedListEntry { Node? parent; diff --git a/frontend/app_flowy/packages/flowy_editor/lib/document/state_tree.dart b/frontend/app_flowy/packages/flowy_editor/lib/document/state_tree.dart index af343f54a0..6fe58d2886 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/document/state_tree.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/document/state_tree.dart @@ -1,5 +1,6 @@ import 'package:flowy_editor/document/node.dart'; import 'package:flowy_editor/document/path.dart'; +import './attributes.dart'; class StateTree { final Node root; 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 a52a96426a..bbf8e20a68 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 @@ -3,7 +3,7 @@ import 'dart:math'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import './node.dart'; +import './attributes.dart'; // constant number: 2^53 - 1 const int _maxInt = 9007199254740991; @@ -22,14 +22,6 @@ class TextOperation { } } -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; @@ -60,7 +52,7 @@ class TextInsert extends TextOperation { final contentHash = content.hashCode; final attrs = _attributes; return Object.hash( - contentHash, attrs == null ? null : _hashAttributes(attrs)); + contentHash, attrs == null ? null : hashAttributes(attrs)); } } @@ -104,7 +96,7 @@ class TextRetain extends TextOperation { @override int get hashCode { final attrs = _attributes; - return Object.hash(_length, attrs == null ? null : _hashAttributes(attrs)); + return Object.hash(_length, attrs == null ? null : hashAttributes(attrs)); } } @@ -344,7 +336,8 @@ class Delta { final length = min(thisIter.peekLength(), otherIter.peekLength()); final thisOp = thisIter.next(length); final otherOp = otherIter.next(length); - final attributes = _composeMap(thisOp.attributes, otherOp.attributes); + final attributes = + composeAttributes(thisOp.attributes, otherOp.attributes); if (otherOp is TextRetain && otherOp.length > 0) { TextOperation? newOp; if (thisOp is TextRetain) { @@ -404,23 +397,30 @@ class Delta { 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; - } + Delta invert(Delta base) { + final inverted = Delta(); + operations.fold(0, (int previousValue, op) { + if (op is TextInsert) { + inverted.delete(op.length); + } else if (op is TextRetain && op.attributes == null) { + inverted.retain(op.length); + return previousValue + op.length; + } else if (op is TextDelete || op is TextRetain) { + final length = op.length; + final slice = base.slice(previousValue, previousValue + length); + for (final baseOp in slice.operations) { + if (op is TextDelete) { + inverted.add(baseOp); + } else if (op is TextRetain && op.attributes != null) { + inverted.retain(baseOp.length, + invertAttributes(op.attributes, baseOp.attributes)); + } + } + return previousValue + length; + } + return previousValue; + }); + return inverted.chop(); } - - if (attributes.isEmpty) { - return null; - } - - return attributes; } 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 d60c06a497..30ffb68009 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/editor_state.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/editor_state.dart @@ -1,5 +1,6 @@ import 'package:flowy_editor/document/node.dart'; import 'package:flowy_editor/operation/operation.dart'; +import 'package:flowy_editor/document/attributes.dart'; import 'package:flutter/material.dart'; import './document/state_tree.dart'; diff --git a/frontend/app_flowy/packages/flowy_editor/lib/operation/operation.dart b/frontend/app_flowy/packages/flowy_editor/lib/operation/operation.dart index 4ca7b9ca83..5fb9a523a8 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/operation/operation.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/operation/operation.dart @@ -1,5 +1,7 @@ import 'package:flowy_editor/document/path.dart'; import 'package:flowy_editor/document/node.dart'; +import 'package:flowy_editor/document/text_delta.dart'; +import 'package:flowy_editor/document/attributes.dart'; abstract class Operation { Operation invert(); @@ -61,3 +63,18 @@ class DeleteOperation extends Operation { ); } } + +class TextEditOperation extends Operation { + final Path path; + final Delta delta; + + TextEditOperation({ + required this.path, + required this.delta, + }); + + @override + Operation invert() { + return TextEditOperation(path: path, delta: delta); + } +} 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 3ddd01efb7..bce2517744 100644 --- a/frontend/app_flowy/packages/flowy_editor/test/delta_test.dart +++ b/frontend/app_flowy/packages/flowy_editor/test/delta_test.dart @@ -2,172 +2,199 @@ 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', { + group('compose', () { + 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, - }), - 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', + 'color': 'red', + }); + final expected = Delta().insert('A', { + 'bold': true, + 'color': 'red', + }); + expect(a.compose(b), expected); }); - final expected = Delta().insert('A', { - 'bold': true, - 'color': 'red', + test('insert + delete', () { + final a = Delta().insert('A'); + final b = Delta().delete(1); + final expected = Delta(); + expect(a.compose(b), expected); }); - 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', + 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); }); - final expected = Delta().delete(1).retain(1, { - 'bold': true, - 'color': 'red', + 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); }); - 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', + test('delete + delete', () { + final a = Delta().delete(1); + final b = Delta().delete(1); + final expected = Delta().delete(2); + expect(a.compose(b), expected); }); - expect(a.compose(b), expected); - }); - test('retain + retain', () { - final a = Delta().retain(1, { - 'color': 'blue', + 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); }); - final b = Delta().retain(1, { - 'bold': true, - 'color': 'red', + 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); }); - final expected = Delta().retain(1, { - 'bold': true, - 'color': 'red', + 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); }); - expect(a.compose(b), expected); - }); - test('retain + delete', () { - final a = Delta().retain(1, { - 'color': 'blue', + 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); }); - 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); + group('invert', () { + test('insert', () { + final delta = Delta().retain(2).insert('A'); + final base = Delta().insert('12346'); + final expected = Delta().retain(2).delete(1); + final inverted = delta.invert(base); + expect(expected, inverted); + expect(base.compose(delta).compose(inverted), base); + }); + test('delete', () { + final delta = Delta().retain(2).delete(3); + final base = Delta().insert('123456'); + final expected = Delta().retain(2).insert('345'); + final inverted = delta.invert(base); + expect(expected, inverted); + expect(base.compose(delta).compose(inverted), base); + }); + // test('retain', () { + // final delta = Delta().retain(2).retain(3, {'bold': true}); + // final base = Delta().insert('123456'); + // final expected = Delta().retain(2).retain(3, {'bold': null}); + // final inverted = delta.invert(base); + // expect(expected, inverted); + // expect(base.compose(delta).compose(inverted), base); + // }); }); }