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 a60f04e89b..a87064d85a 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/document/position.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/document/position.dart @@ -34,4 +34,11 @@ class Position { @override String toString() => 'path = $path, offset = $offset'; + + Map toJson() { + return { + "path": path.toList(), + "offset": 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 a3919a21f6..f1fa0682f6 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/document/selection.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/document/selection.dart @@ -48,4 +48,11 @@ class Selection { @override String toString() => '[Selection] start = $start, end = $end'; + + Map toJson() { + return { + "start": start.toJson(), + "end": end.toJson(), + }; + } } 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 22f4b88c24..cf49f48ac8 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 @@ -22,17 +22,21 @@ class StateTree { return root.childAtPath(path); } - bool insert(Path path, Node node) { + bool insert(Path path, List nodes) { if (path.isEmpty) { return false; } - final insertedNode = root.childAtPath( + Node? insertedNode = root.childAtPath( path.sublist(0, path.length - 1) + [path.last - 1], ); if (insertedNode == null) { return false; } - insertedNode.insertAfter(node); + for (var i = 0; i < nodes.length; i++) { + final node = nodes[i]; + insertedNode!.insertAfter(node); + insertedNode = node; + } return true; } @@ -48,13 +52,17 @@ class StateTree { return false; } - Node? delete(Path path) { + delete(Path path, [int length = 1]) { if (path.isEmpty) { return null; } - final deletedNode = root.childAtPath(path); - deletedNode?.unlink(); - return deletedNode; + var deletedNode = root.childAtPath(path); + while (deletedNode != null && length > 0) { + final next = deletedNode.next; + deletedNode.unlink(); + length--; + deletedNode = next; + } } Attributes? update(Path path, Attributes 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 1019ad3510..277b742604 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/editor_state.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/editor_state.dart @@ -94,11 +94,11 @@ class EditorState { _applyOperation(Operation op) { if (op is InsertOperation) { - document.insert(op.path, op.value); + document.insert(op.path, op.nodes); } else if (op is UpdateOperation) { document.update(op.path, op.attributes); } else if (op is DeleteOperation) { - document.delete(op.path); + document.delete(op.path, op.nodes.length); } else if (op is TextEditOperation) { document.textEdit(op.path, op.delta); } 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 eafa4a31da..e07c196768 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/operation/operation.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/operation/operation.dart @@ -2,22 +2,41 @@ import 'package:flowy_editor/document/attributes.dart'; import 'package:flowy_editor/flowy_editor.dart'; abstract class Operation { + factory Operation.fromJson(Map map) { + String t = map["type"] as String; + if (t == "insert-operation") { + return InsertOperation.fromJson(map); + } else if (t == "update-operation") { + return UpdateOperation.fromJson(map); + } else if (t == "delete-operation") { + return DeleteOperation.fromJson(map); + } else if (t == "text-edit-operation") { + return TextEditOperation.fromJson(map); + } + + throw ArgumentError('unexpected type $t'); + } final Path path; - Operation({required this.path}); + Operation(this.path); Operation copyWithPath(Path path); Operation invert(); + Map toJson(); } class InsertOperation extends Operation { - final Node value; + final List nodes; - InsertOperation({ - required super.path, - required this.value, - }); + 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 copyWith({Path? path, Node? value}) => - InsertOperation(path: path ?? this.path, value: value ?? this.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); @@ -25,28 +44,42 @@ class InsertOperation extends Operation { @override Operation invert() { return DeleteOperation( - path: path, - removedValue: value, + path, + nodes, ); } + + @override + Map toJson() { + return { + "type": "insert-operation", + "path": path.toList(), + "nodes": nodes.map((n) => n.toJson()), + }; + } } class UpdateOperation extends Operation { final Attributes attributes; final Attributes oldAttributes; - UpdateOperation({ - required super.path, - required this.attributes, - required this.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: path ?? this.path, - attributes: attributes ?? this.attributes, - oldAttributes: oldAttributes ?? this.oldAttributes); + UpdateOperation(path ?? this.path, attributes ?? this.attributes, + oldAttributes ?? this.oldAttributes); @override Operation copyWithPath(Path path) => copyWith(path: path); @@ -54,33 +87,56 @@ class UpdateOperation extends Operation { @override Operation invert() { return UpdateOperation( - path: path, - attributes: oldAttributes, - oldAttributes: attributes, + path, + oldAttributes, + attributes, ); } + + @override + Map toJson() { + return { + "type": "update-operation", + "path": path.toList(), + "attributes": {...attributes}, + "oldAttributes": {...oldAttributes}, + }; + } } class DeleteOperation extends Operation { - final Node removedValue; + final List nodes; - DeleteOperation({ - required super.path, - required this.removedValue, - }); + 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 copyWith({Path? path, Node? removedValue}) => DeleteOperation( - path: path ?? this.path, removedValue: removedValue ?? this.removedValue); + 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: path, - value: removedValue, - ); + return InsertOperation(path, nodes); + } + + @override + Map toJson() { + return { + "type": "delete-operation", + "path": path.toList(), + "nodes": nodes.map((n) => n.toJson()), + }; } } @@ -88,24 +144,39 @@ class TextEditOperation extends Operation { final Delta delta; final Delta inverted; - TextEditOperation({ - required super.path, - required this.delta, - required this.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: path ?? this.path, - delta: delta ?? this.delta, - inverted: inverted ?? this.inverted); + path ?? this.path, delta ?? this.delta, inverted ?? this.inverted); @override Operation copyWithPath(Path path) => copyWith(path: path); @override Operation invert() { - return TextEditOperation(path: path, delta: inverted, inverted: delta); + return TextEditOperation(path, inverted, delta); + } + + @override + Map toJson() { + return { + "type": "text-edit-operation", + "path": path.toList(), + "delta": delta.toJson(), + "invert": inverted.toJson(), + }; } } diff --git a/frontend/app_flowy/packages/flowy_editor/lib/operation/transaction.dart b/frontend/app_flowy/packages/flowy_editor/lib/operation/transaction.dart index 85bc43f537..5dcf167628 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/operation/transaction.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/operation/transaction.dart @@ -23,4 +23,17 @@ class Transaction { this.beforeSelection, this.afterSelection, }); + + Map toJson() { + final Map result = { + "operations": operations.map((e) => e.toJson()), + }; + if (beforeSelection != null) { + result["beforeSelection"] = beforeSelection!.toJson(); + } + if (afterSelection != null) { + result["afterSelection"] = afterSelection!.toJson(); + } + return result; + } } diff --git a/frontend/app_flowy/packages/flowy_editor/lib/operation/transaction_builder.dart b/frontend/app_flowy/packages/flowy_editor/lib/operation/transaction_builder.dart index 9233a1b08a..dd531d5d5e 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/operation/transaction_builder.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/operation/transaction_builder.dart @@ -29,32 +29,50 @@ class TransactionBuilder { } insertNode(Path path, Node node) { - beforeSelection = state.service.selectionService.currentSelection; - add(InsertOperation(path: path, value: node)); + insertNodes(path, [node]); // FIXME: Not exactly correct, needs to be customized. afterSelection = Selection.collapsed( Position(path: path, offset: 0), ); } + insertNodes(Path path, List nodes) { + beforeSelection = state.service.selectionService.currentSelection; + add(InsertOperation(path, nodes)); + } + updateNode(Node node, Attributes attributes) { beforeSelection = state.cursorSelection; add(UpdateOperation( - path: node.path, - attributes: Attributes.from(node.attributes)..addAll(attributes), - oldAttributes: node.attributes, + node.path, + Attributes.from(node.attributes)..addAll(attributes), + node.attributes, )); } deleteNode(Node node) { - beforeSelection = state.cursorSelection; - add(DeleteOperation(path: node.path, removedValue: node)); + deleteNodesAtPath(node.path); } deleteNodes(List nodes) { nodes.forEach(deleteNode); } + deleteNodesAtPath(Path path, [int length = 1]) { + if (path.isEmpty) { + return; + } + final nodes = []; + final prefix = path.sublist(0, path.length - 1); + final last = path.last; + for (var i = 0; i < length; i++) { + final node = state.document.nodeAtPath(prefix + [last + i])!; + nodes.add(node); + } + + add(DeleteOperation(path, nodes)); + } + textEdit(TextNode node, Delta Function() f) { beforeSelection = state.service.selectionService.currentSelection; final path = node.path; @@ -63,7 +81,7 @@ class TransactionBuilder { final inverted = delta.invert(node.delta); - add(TextEditOperation(path: path, delta: delta, inverted: inverted)); + add(TextEditOperation(path, delta, inverted)); } mergeText(TextNode firstNode, TextNode secondNode, @@ -123,9 +141,9 @@ class TransactionBuilder { last is TextEditOperation && pathEquals(op.path, last.path)) { final newOp = TextEditOperation( - path: op.path, - delta: last.delta.compose(op.delta), - inverted: op.inverted.compose(last.inverted), + op.path, + last.delta.compose(op.delta), + op.inverted.compose(last.inverted), ); operations[operations.length - 1] = newOp; return; diff --git a/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/enter_in_edge_of_text_node_handler.dart b/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/enter_in_edge_of_text_node_handler.dart index 8b0c7c5423..b08dcb1f80 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/enter_in_edge_of_text_node_handler.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/enter_in_edge_of_text_node_handler.dart @@ -37,7 +37,7 @@ FlowyKeyEventHandler enterInEdgeOfTextNodeHandler = (editorState, event) { children: LinkedList(), delta: Delta([TextInsert(' ')]), attributes: - needCopyAttributes ? {StyleKey.subtype: textNode.subtype} : null, + needCopyAttributes ? {StyleKey.subtype: textNode.subtype} : {}, ), ) ..commit(); 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 16ccadb079..49d0fd00f5 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 @@ -1,5 +1,4 @@ import 'dart:convert'; -import 'dart:math'; import 'package:flowy_editor/document/node.dart'; import 'package:flowy_editor/document/state_tree.dart'; @@ -50,7 +49,7 @@ void main() { final insertNode = Node.fromJson({ 'type': 'text', }); - bool result = stateTree.insert([1, 1], insertNode); + bool result = stateTree.insert([1, 1], [insertNode]); expect(result, true); expect(identical(insertNode, stateTree.nodeAtPath([1, 1])), true); }); @@ -59,9 +58,7 @@ void main() { final String response = await rootBundle.loadString('assets/document.json'); final data = Map.from(json.decode(response)); final stateTree = StateTree.fromJson(data); - final deletedNode = stateTree.delete([1, 1]); - expect(deletedNode != null, true); - expect(deletedNode!.attributes['text-type'], 'checkbox'); + stateTree.delete([1, 1], 1); final node = stateTree.nodeAtPath([1, 1]); expect(node != null, true); expect(node!.attributes['tag'], '**'); diff --git a/frontend/app_flowy/packages/flowy_editor/test/operation_test.dart b/frontend/app_flowy/packages/flowy_editor/test/operation_test.dart index b0b6cec141..7507cb65bf 100644 --- a/frontend/app_flowy/packages/flowy_editor/test/operation_test.dart +++ b/frontend/app_flowy/packages/flowy_editor/test/operation_test.dart @@ -27,26 +27,18 @@ void main() { group('transform operation', () { test('insert + insert', () { final t = transformOperation( - InsertOperation(path: [ - 0, - 1 - ], value: Node(type: "node", attributes: {}, children: LinkedList())), - InsertOperation( - path: [0, 1], - value: - Node(type: "node", attributes: {}, children: LinkedList()))); + InsertOperation([0, 1], + [Node(type: "node", attributes: {}, children: LinkedList())]), + InsertOperation([0, 1], + [Node(type: "node", attributes: {}, children: LinkedList())])); expect(t.path, [0, 2]); }); test('delete + delete', () { final t = transformOperation( - DeleteOperation( - path: [0, 1], - removedValue: - Node(type: "node", attributes: {}, children: LinkedList())), - DeleteOperation( - path: [0, 2], - removedValue: - Node(type: "node", attributes: {}, children: LinkedList()))); + DeleteOperation([0, 1], + [Node(type: "node", attributes: {}, children: LinkedList())]), + DeleteOperation([0, 2], + [Node(type: "node", attributes: {}, children: LinkedList())])); expect(t.path, [0, 1]); }); }); @@ -78,4 +70,48 @@ void main() { expect(transaction.operations[1].path, [0]); expect(transaction.operations[2].path, [0]); }); + group("toJson", () { + test("insert", () { + final root = Node(type: "root", attributes: {}, children: LinkedList()); + final state = EditorState(document: StateTree(root: root)); + + final item1 = Node(type: "node", attributes: {}, children: LinkedList()); + final tb = TransactionBuilder(state); + tb.insertNode([0], item1); + + final transaction = tb.finish(); + expect(transaction.toJson(), { + "operations": [ + { + "type": "insert-operation", + "path": [0], + "nodes": [item1.toJson()], + } + ], + }); + }); + test("delete", () { + final item1 = Node(type: "node", attributes: {}, children: LinkedList()); + final root = Node( + type: "root", + attributes: {}, + children: LinkedList() + ..addAll([ + item1, + ])); + final state = EditorState(document: StateTree(root: root)); + final tb = TransactionBuilder(state); + tb.deleteNode(item1); + final transaction = tb.finish(); + expect(transaction.toJson(), { + "operations": [ + { + "type": "delete-operation", + "path": [0], + "nodes": [item1.toJson()], + } + ], + }); + }); + }); }