diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/appflowy_editor.dart b/frontend/app_flowy/packages/appflowy_editor/lib/appflowy_editor.dart index a328b0d596..b1c5ba3ed1 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/appflowy_editor.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/appflowy_editor.dart @@ -12,7 +12,7 @@ export 'src/core/document/text_delta.dart'; export 'src/core/document/attributes.dart'; export 'src/core/legacy/built_in_attribute_keys.dart'; export 'src/editor_state.dart'; -export 'src/operation/operation.dart'; +export 'src/core/transform/operation.dart'; export 'src/operation/transaction.dart'; export 'src/operation/transaction_builder.dart'; export 'src/render/selection/selectable.dart'; diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/core/document/document.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/core/document/document.dart index 7553e424ca..b172e78554 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/core/document/document.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/core/document/document.dart @@ -42,7 +42,7 @@ class Document { } /// Inserts a [Node]s at the given [Path]. - bool insert(Path path, List nodes) { + bool insert(Path path, Iterable nodes) { if (path.isEmpty || nodes.isEmpty) { return false; } diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/core/transform/operation.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/core/transform/operation.dart new file mode 100644 index 0000000000..7966b8864a --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/core/transform/operation.dart @@ -0,0 +1,216 @@ +import 'package:appflowy_editor/src/core/document/attributes.dart'; +import 'package:appflowy_editor/src/core/document/node.dart'; +import 'package:appflowy_editor/src/core/document/path.dart'; +import 'package:appflowy_editor/src/core/document/text_delta.dart'; + +/// [Operation] represents a change to a [Document]. +abstract class Operation { + Operation( + this.path, + ); + + factory Operation.fromJson() => throw UnimplementedError(); + + final Path path; + + /// Inverts the operation. + /// + /// Returns the inverted operation. + Operation invert(); + + /// Returns the JSON representation of the operation. + Map toJson(); + + Operation copyWith({Path? path}); +} + +/// [InsertOperation] represents an insert operation. +class InsertOperation extends Operation { + InsertOperation( + super.path, + this.nodes, + ); + + factory InsertOperation.fromJson(Map json) { + final path = json['path'] as Path; + final nodes = (json['nodes'] as List).map((n) => Node.fromJson(n)); + return InsertOperation(path, nodes); + } + + final Iterable nodes; + + @override + Operation invert() => DeleteOperation(path, nodes); + + @override + Map toJson() { + return { + 'op': 'insert', + 'path': path, + 'nodes': nodes.map((n) => n.toJson()), + }; + } + + @override + Operation copyWith({Path? path}) { + return InsertOperation(path ?? this.path, nodes); + } +} + +/// [DeleteOperation] represents a delete operation. +class DeleteOperation extends Operation { + DeleteOperation( + super.path, + this.nodes, + ); + + factory DeleteOperation.fromJson(Map json) { + final path = json['path'] as Path; + final nodes = (json['nodes'] as List).map((n) => Node.fromJson(n)); + return DeleteOperation(path, nodes); + } + + final Iterable nodes; + + @override + Operation invert() => InsertOperation(path, nodes); + + @override + Map toJson() { + return { + 'op': 'delete', + 'path': path, + 'nodes': nodes.map((n) => n.toJson()), + }; + } + + @override + Operation copyWith({Path? path}) { + return DeleteOperation(path ?? this.path, nodes); + } +} + +/// [UpdateOperation] represents an attributes update operation. +class UpdateOperation extends Operation { + UpdateOperation( + super.path, + this.attributes, + this.oldAttributes, + ); + + factory UpdateOperation.fromJson(Map json) { + final path = json['path'] as Path; + final oldAttributes = json['oldAttributes'] as Attributes; + final attributes = json['attributes'] as Attributes; + return UpdateOperation( + path, + attributes, + oldAttributes, + ); + } + + final Attributes attributes; + final Attributes oldAttributes; + + @override + Operation invert() => UpdateOperation( + path, + oldAttributes, + attributes, + ); + + @override + Map toJson() { + return { + 'op': 'update', + 'path': path, + 'attributes': {...attributes}, + 'oldAttributes': {...oldAttributes}, + }; + } + + @override + Operation copyWith({Path? path}) { + return UpdateOperation( + path ?? this.path, + {...attributes}, + {...oldAttributes}, + ); + } +} + +/// [UpdateTextOperation] represents a text update operation. +class UpdateTextOperation extends Operation { + UpdateTextOperation( + super.path, + this.delta, + this.inverted, + ); + + factory UpdateTextOperation.fromJson(Map json) { + final path = json['path'] as Path; + final delta = Delta.fromJson(json['delta']); + final inverted = Delta.fromJson(json['invert']); + return UpdateTextOperation(path, delta, inverted); + } + + final Delta delta; + final Delta inverted; + + @override + Operation invert() => UpdateTextOperation(path, inverted, delta); + + @override + Map toJson() { + return { + 'op': 'update_text', + 'path': path, + 'delta': delta.toJson(), + 'inverted': inverted.toJson(), + }; + } + + @override + Operation copyWith({Path? path}) { + return UpdateTextOperation(path ?? this.path, delta, inverted); + } +} + +// TODO(Lucas.Xu): refactor this part +Path transformPath(Path preInsertPath, Path b, [int delta = 1]) { + if (preInsertPath.length > b.length) { + return b; + } + if (preInsertPath.isEmpty || b.isEmpty) { + return b; + } + // check the prefix + for (var i = 0; i < preInsertPath.length - 1; i++) { + if (preInsertPath[i] != b[i]) { + return b; + } + } + final prefix = preInsertPath.sublist(0, preInsertPath.length - 1); + final suffix = b.sublist(preInsertPath.length); + final preInsertLast = preInsertPath.last; + final bAtIndex = b[preInsertPath.length - 1]; + if (preInsertLast <= bAtIndex) { + prefix.add(bAtIndex + delta); + } else { + prefix.add(bAtIndex); + } + prefix.addAll(suffix); + return prefix; +} + +Operation transformOperation(Operation a, Operation b) { + if (a is InsertOperation) { + final newPath = transformPath(a.path, b.path, a.nodes.length); + return b.copyWith(path: newPath); + } else if (a is DeleteOperation) { + final newPath = transformPath(a.path, b.path, -1 * a.nodes.length); + return b.copyWith(path: newPath); + } + // TODO: transform update and textedit + return b; +} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/editor_state.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/editor_state.dart index e72f171c7b..aad862cf9a 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/editor_state.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/editor_state.dart @@ -7,7 +7,7 @@ import 'package:flutter/material.dart'; import 'package:appflowy_editor/src/core/location/selection.dart'; import 'package:appflowy_editor/src/core/document/document.dart'; -import 'package:appflowy_editor/src/operation/operation.dart'; +import 'package:appflowy_editor/src/core/transform/operation.dart'; import 'package:appflowy_editor/src/operation/transaction.dart'; import 'package:appflowy_editor/src/undo_manager.dart'; @@ -166,7 +166,7 @@ class EditorState { document.update(op.path, op.attributes); } else if (op is DeleteOperation) { document.delete(op.path, op.nodes.length); - } else if (op is TextEditOperation) { + } else if (op is UpdateTextOperation) { document.updateText(op.path, op.delta); } _observer.add(op); diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/operation/operation.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/operation/operation.dart deleted file mode 100644 index af2ec831d4..0000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/operation/operation.dart +++ /dev/null @@ -1,218 +0,0 @@ -import 'package:appflowy_editor/appflowy_editor.dart'; - -abstract class Operation { - factory Operation.fromJson(Map map) { - String t = map["op"] as String; - if (t == "insert") { - return InsertOperation.fromJson(map); - } else if (t == "update") { - return UpdateOperation.fromJson(map); - } else if (t == "delete") { - return DeleteOperation.fromJson(map); - } else if (t == "text-edit") { - return TextEditOperation.fromJson(map); - } - - throw ArgumentError('unexpected type $t'); - } - final Path path; - Operation(this.path); - Operation copyWithPath(Path path); - Operation invert(); - Map toJson(); -} - -class InsertOperation extends Operation { - final List nodes; - - factory InsertOperation.fromJson(Map map) { - final path = map["path"] as List; - final value = - (map["nodes"] as List).map((n) => Node.fromJson(n)).toList(); - return InsertOperation(path, value); - } - - InsertOperation(Path path, this.nodes) : super(path); - - InsertOperation copyWith({Path? path, List? nodes}) => - InsertOperation(path ?? this.path, nodes ?? this.nodes); - - @override - Operation copyWithPath(Path path) => copyWith(path: path); - - @override - Operation invert() { - return DeleteOperation( - path, - nodes, - ); - } - - @override - Map toJson() { - return { - "op": "insert", - "path": path.toList(), - "nodes": nodes.map((n) => n.toJson()), - }; - } -} - -class UpdateOperation extends Operation { - final Attributes attributes; - final Attributes oldAttributes; - - factory UpdateOperation.fromJson(Map map) { - final path = map["path"] as List; - final attributes = map["attributes"] as Map; - final oldAttributes = map["oldAttributes"] as Map; - return UpdateOperation(path, attributes, oldAttributes); - } - - UpdateOperation( - Path path, - this.attributes, - this.oldAttributes, - ) : super(path); - - UpdateOperation copyWith( - {Path? path, Attributes? attributes, Attributes? oldAttributes}) => - UpdateOperation(path ?? this.path, attributes ?? this.attributes, - oldAttributes ?? this.oldAttributes); - - @override - Operation copyWithPath(Path path) => copyWith(path: path); - - @override - Operation invert() { - return UpdateOperation( - path, - oldAttributes, - attributes, - ); - } - - @override - Map toJson() { - return { - "op": "update", - "path": path.toList(), - "attributes": {...attributes}, - "oldAttributes": {...oldAttributes}, - }; - } -} - -class DeleteOperation extends Operation { - final List nodes; - - factory DeleteOperation.fromJson(Map map) { - final path = map["path"] as List; - final List nodes = - (map["nodes"] as List).map((e) => Node.fromJson(e)).toList(); - return DeleteOperation(path, nodes); - } - - DeleteOperation( - Path path, - this.nodes, - ) : super(path); - - DeleteOperation copyWith({Path? path, List? nodes}) => - DeleteOperation(path ?? this.path, nodes ?? this.nodes); - - @override - Operation copyWithPath(Path path) => copyWith(path: path); - - @override - Operation invert() { - return InsertOperation(path, nodes); - } - - @override - Map toJson() { - return { - "op": "delete", - "path": path.toList(), - "nodes": nodes.map((n) => n.toJson()), - }; - } -} - -class TextEditOperation extends Operation { - final Delta delta; - final Delta inverted; - - factory TextEditOperation.fromJson(Map map) { - final path = map["path"] as List; - final delta = Delta.fromJson(map["delta"]); - final invert = Delta.fromJson(map["invert"]); - return TextEditOperation(path, delta, invert); - } - - TextEditOperation( - Path path, - this.delta, - this.inverted, - ) : super(path); - - TextEditOperation copyWith({Path? path, Delta? delta, Delta? inverted}) => - TextEditOperation( - path ?? this.path, delta ?? this.delta, inverted ?? this.inverted); - - @override - Operation copyWithPath(Path path) => copyWith(path: path); - - @override - Operation invert() { - return TextEditOperation(path, inverted, delta); - } - - @override - Map toJson() { - return { - "op": "text-edit", - "path": path.toList(), - "delta": delta.toJson(), - "invert": inverted.toJson(), - }; - } -} - -Path transformPath(Path preInsertPath, Path b, [int delta = 1]) { - if (preInsertPath.length > b.length) { - return b; - } - if (preInsertPath.isEmpty || b.isEmpty) { - return b; - } - // check the prefix - for (var i = 0; i < preInsertPath.length - 1; i++) { - if (preInsertPath[i] != b[i]) { - return b; - } - } - final prefix = preInsertPath.sublist(0, preInsertPath.length - 1); - final suffix = b.sublist(preInsertPath.length); - final preInsertLast = preInsertPath.last; - final bAtIndex = b[preInsertPath.length - 1]; - if (preInsertLast <= bAtIndex) { - prefix.add(bAtIndex + delta); - } else { - prefix.add(bAtIndex); - } - prefix.addAll(suffix); - return prefix; -} - -Operation transformOperation(Operation a, Operation b) { - if (a is InsertOperation) { - final newPath = transformPath(a.path, b.path, a.nodes.length); - return b.copyWithPath(newPath); - } else if (a is DeleteOperation) { - final newPath = transformPath(a.path, b.path, -1 * a.nodes.length); - return b.copyWithPath(newPath); - } - // TODO: transform update and textedit - return b; -} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/operation/transaction.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/operation/transaction.dart index 145112fde9..2b86160718 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/operation/transaction.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/operation/transaction.dart @@ -1,7 +1,7 @@ import 'dart:collection'; import 'package:flutter/material.dart'; import 'package:appflowy_editor/src/core/location/selection.dart'; -import './operation.dart'; +import '../core/transform/operation.dart'; /// A [Transaction] has a list of [Operation] objects that will be applied /// to the editor. It is an immutable class and used to store and transmit. diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/operation/transaction_builder.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/operation/transaction_builder.dart index 101c2bc9cf..8ae43bd8d8 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/operation/transaction_builder.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/operation/transaction_builder.dart @@ -8,7 +8,7 @@ import 'package:appflowy_editor/src/core/location/position.dart'; import 'package:appflowy_editor/src/core/location/selection.dart'; import 'package:appflowy_editor/src/core/document/text_delta.dart'; import 'package:appflowy_editor/src/editor_state.dart'; -import 'package:appflowy_editor/src/operation/operation.dart'; +import 'package:appflowy_editor/src/core/transform/operation.dart'; import 'package:appflowy_editor/src/operation/transaction.dart'; /// A [TransactionBuilder] is used to build the transaction from the state. @@ -85,7 +85,7 @@ class TransactionBuilder { final inverted = delta.invert(node.delta); - add(TextEditOperation(path, delta, inverted)); + add(UpdateTextOperation(path, delta, inverted)); } setAfterSelection(Selection sel) { @@ -195,10 +195,10 @@ class TransactionBuilder { add(Operation op, {bool transform = true}) { final Operation? last = operations.isEmpty ? null : operations.last; if (last != null) { - if (op is TextEditOperation && - last is TextEditOperation && + if (op is UpdateTextOperation && + last is UpdateTextOperation && op.path.equals(last.path)) { - final newOp = TextEditOperation( + final newOp = UpdateTextOperation( op.path, last.delta.compose(op.delta), op.inverted.compose(last.inverted), @@ -212,7 +212,7 @@ class TransactionBuilder { op = transformOperation(operations[i], op); } } - if (op is TextEditOperation && op.delta.isEmpty) { + if (op is UpdateTextOperation && op.delta.isEmpty) { return; } operations.add(op); diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/undo_manager.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/undo_manager.dart index bfc90e27b2..6a5a59e10f 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/undo_manager.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/undo_manager.dart @@ -2,7 +2,7 @@ import 'dart:collection'; import 'package:appflowy_editor/src/core/location/selection.dart'; import 'package:appflowy_editor/src/infra/log.dart'; -import 'package:appflowy_editor/src/operation/operation.dart'; +import 'package:appflowy_editor/src/core/transform/operation.dart'; import 'package:appflowy_editor/src/operation/transaction_builder.dart'; import 'package:appflowy_editor/src/operation/transaction.dart'; import 'package:appflowy_editor/src/editor_state.dart'; diff --git a/frontend/app_flowy/packages/appflowy_editor/test/legacy/operation_test.dart b/frontend/app_flowy/packages/appflowy_editor/test/legacy/operation_test.dart index 30813865a8..e7d9774d7d 100644 --- a/frontend/app_flowy/packages/appflowy_editor/test/legacy/operation_test.dart +++ b/frontend/app_flowy/packages/appflowy_editor/test/legacy/operation_test.dart @@ -2,7 +2,7 @@ import 'dart:collection'; import 'package:appflowy_editor/src/core/document/node.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:appflowy_editor/src/operation/operation.dart'; +import 'package:appflowy_editor/src/core/transform/operation.dart'; import 'package:appflowy_editor/src/operation/transaction_builder.dart'; import 'package:appflowy_editor/src/editor_state.dart'; import 'package:appflowy_editor/src/core/document/document.dart'; diff --git a/frontend/app_flowy/packages/appflowy_editor/test/legacy/undo_manager_test.dart b/frontend/app_flowy/packages/appflowy_editor/test/legacy/undo_manager_test.dart index 934aa5ce24..bd173ce15f 100644 --- a/frontend/app_flowy/packages/appflowy_editor/test/legacy/undo_manager_test.dart +++ b/frontend/app_flowy/packages/appflowy_editor/test/legacy/undo_manager_test.dart @@ -63,7 +63,7 @@ bool isInsertAndPathEqual(Operation operation, Path path, [String? content]) { return false; } - final firstNode = operation.nodes[0]; + final firstNode = operation.nodes.first; if (firstNode is! TextNode) { return false; }