diff --git a/frontend/app_flowy/packages/appflowy_editor/example/lib/main.dart b/frontend/app_flowy/packages/appflowy_editor/example/lib/main.dart index f6c2fd21ff..89f4977b21 100644 --- a/frontend/app_flowy/packages/appflowy_editor/example/lib/main.dart +++ b/frontend/app_flowy/packages/appflowy_editor/example/lib/main.dart @@ -98,7 +98,7 @@ class _MyHomePageState extends State { if (snapshot.hasData && snapshot.connectionState == ConnectionState.done) { _editorState ??= EditorState( - document: StateTree.fromJson( + document: Document.fromJson( Map.from( json.decode(snapshot.data!), ), 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 8cb220401c..bccfd7cb16 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/appflowy_editor.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/appflowy_editor.dart @@ -7,7 +7,7 @@ export 'src/core/document/node.dart'; export 'src/core/document/path.dart'; export 'src/core/location/position.dart'; export 'src/core/location/selection.dart'; -export 'src/document/state_tree.dart'; +export 'src/core/state/document.dart'; export 'src/core/document/text_delta.dart'; export 'src/core/document/attributes.dart'; export 'src/document/built_in_attribute_keys.dart'; diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/core/document/node_iterator.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/core/document/node_iterator.dart index 345bda3eda..9f611177a1 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/core/document/node_iterator.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/core/document/node_iterator.dart @@ -1,15 +1,15 @@ import 'package:appflowy_editor/src/core/document/node.dart'; -import 'package:appflowy_editor/src/document/state_tree.dart'; +import 'package:appflowy_editor/src/core/state/document.dart'; /// [NodeIterator] is used to traverse the nodes in visual order. class NodeIterator implements Iterator { NodeIterator({ - required this.stateTree, + required this.document, required this.startNode, this.endNode, }); - final StateTree stateTree; + final Document document; final Node startNode; final Node? endNode; diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/core/document/path.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/core/document/path.dart index 36d20a1f80..5e411407d5 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/core/document/path.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/core/document/path.dart @@ -69,4 +69,22 @@ extension PathExtensions on Path { ..removeLast() ..add(last + 1); } + + Path get previous { + Path previousPath = Path.from(this, growable: true); + if (isEmpty) { + return previousPath; + } + final last = previousPath.last; + return previousPath + ..removeLast() + ..add(max(0, last - 1)); + } + + Path get parent { + if (isEmpty) { + return this; + } + return Path.from(this, growable: true)..removeLast(); + } } diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/core/state/document.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/core/state/document.dart new file mode 100644 index 0000000000..3fc5043aa2 --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/core/state/document.dart @@ -0,0 +1,118 @@ +import 'dart:collection'; + +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'; +import '../document/attributes.dart'; + +/// [Document] reprensents a AppFlowy Editor document structure. +/// +/// It stores the root of the document. +/// +/// DO NOT directly mutate the properties of a [Document] object. +class Document { + Document({ + required this.root, + }); + + factory Document.fromJson(Map json) { + assert(json['document'] is Map); + + final document = Map.from(json['document'] as Map); + final root = Node.fromJson(document); + return Document(root: root); + } + + /// Creates a empty document with a single text node. + factory Document.empty() { + final root = Node( + type: 'editor', + children: LinkedList()..add(TextNode.empty()), + ); + return Document( + root: root, + ); + } + + final Node root; + + /// Returns the node at the given [path]. + Node? nodeAtPath(Path path) { + return root.childAtPath(path); + } + + /// Inserts a [Node]s at the given [Path]. + bool insert(Path path, List nodes) { + if (path.isEmpty || nodes.isEmpty) { + return false; + } + + final target = nodeAtPath(path); + if (target != null) { + for (final node in nodes) { + target.insertBefore(node); + } + return true; + } + + final parent = nodeAtPath(path.parent); + if (parent != null) { + for (final node in nodes) { + parent.insert(node, index: path.last); + } + return true; + } + + return false; + } + + /// Deletes the [Node]s at the given [Path]. + bool delete(Path path, [int length = 1]) { + if (path.isEmpty || length <= 0) { + return false; + } + var target = nodeAtPath(path); + if (target == null) { + return false; + } + while (target != null && length > 0) { + final next = target.next; + target.unlink(); + target = next; + length--; + } + return true; + } + + /// Updates the [Node] at the given [Path] + bool update(Path path, Attributes attributes) { + if (path.isEmpty) { + return false; + } + final target = nodeAtPath(path); + if (target == null) { + return false; + } + target.updateAttributes(attributes); + return true; + } + + /// Updates the [TextNode] at the given [Path] + bool updateText(Path path, Delta delta) { + if (path.isEmpty) { + return false; + } + final target = nodeAtPath(path); + if (target == null || target is! TextNode) { + return false; + } + target.delta = target.delta.compose(delta); + return true; + } + + Map toJson() { + return { + 'document': root.toJson(), + }; + } +} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/document/state_tree.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/document/state_tree.dart deleted file mode 100644 index 77996d2024..0000000000 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/document/state_tree.dart +++ /dev/null @@ -1,116 +0,0 @@ -import 'dart:math'; - -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'; -import '../core/document/attributes.dart'; - -class StateTree { - final Node root; - - StateTree({ - required this.root, - }); - - factory StateTree.empty() { - return StateTree( - root: Node.fromJson({ - 'type': 'editor', - 'children': [ - { - 'type': 'text', - } - ] - }), - ); - } - - factory StateTree.fromJson(Attributes json) { - assert(json['document'] is Map); - - final document = Map.from(json['document'] as Map); - final root = Node.fromJson(document); - return StateTree(root: root); - } - - Map toJson() { - return { - 'document': root.toJson(), - }; - } - - Node? nodeAtPath(Path path) { - return root.childAtPath(path); - } - - bool insert(Path path, List nodes) { - if (path.isEmpty) { - return false; - } - Node? insertedNode = root.childAtPath( - path.sublist(0, path.length - 1) + [max(0, path.last - 1)], - ); - if (insertedNode == null) { - final insertedNode = root.childAtPath( - path.sublist(0, path.length - 1), - ); - if (insertedNode != null) { - for (final node in nodes) { - insertedNode.insert(node); - } - return true; - } - return false; - } - if (path.last <= 0) { - for (var i = 0; i < nodes.length; i++) { - final node = nodes[i]; - insertedNode.insertBefore(node); - } - } else { - for (var i = 0; i < nodes.length; i++) { - final node = nodes[i]; - insertedNode!.insertAfter(node); - insertedNode = node; - } - } - return true; - } - - bool textEdit(Path path, Delta delta) { - if (path.isEmpty) { - return false; - } - final node = root.childAtPath(path); - if (node == null || node is! TextNode) { - return false; - } - node.delta = node.delta.compose(delta); - return false; - } - - delete(Path path, [int length = 1]) { - if (path.isEmpty) { - return null; - } - var deletedNode = root.childAtPath(path); - while (deletedNode != null && length > 0) { - final next = deletedNode.next; - deletedNode.unlink(); - length--; - deletedNode = next; - } - } - - bool update(Path path, Attributes attributes) { - if (path.isEmpty) { - return false; - } - final updatedNode = root.childAtPath(path); - if (updatedNode == null) { - return false; - } - updatedNode.updateAttributes(attributes); - return true; - } -} 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 995ffcbdd7..3dfb5e56e5 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 @@ -6,7 +6,7 @@ import 'package:appflowy_editor/src/service/service.dart'; import 'package:flutter/material.dart'; import 'package:appflowy_editor/src/core/location/selection.dart'; -import 'package:appflowy_editor/src/document/state_tree.dart'; +import 'package:appflowy_editor/src/core/state/document.dart'; import 'package:appflowy_editor/src/operation/operation.dart'; import 'package:appflowy_editor/src/operation/transaction.dart'; import 'package:appflowy_editor/src/undo_manager.dart'; @@ -46,7 +46,7 @@ enum CursorUpdateReason { /// /// Mutating the document with document's API is not recommended. class EditorState { - final StateTree document; + final Document document; // Service reference. final service = FlowyService(); @@ -105,7 +105,7 @@ class EditorState { } factory EditorState.empty() { - return EditorState(document: StateTree.empty()); + return EditorState(document: Document.empty()); } /// Apply the transaction to the state. @@ -167,7 +167,7 @@ class EditorState { } else if (op is DeleteOperation) { document.delete(op.path, op.nodes.length); } else if (op is TextEditOperation) { - document.textEdit(op.path, op.delta); + document.updateText(op.path, op.delta); } _observer.add(op); } diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/copy_paste_handler.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/copy_paste_handler.dart index 0121b474d0..1dd500b5ab 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/copy_paste_handler.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/copy_paste_handler.dart @@ -50,7 +50,7 @@ void _handleCopy(EditorState editorState) async { final endNode = editorState.document.nodeAtPath(selection.end.path)!; final nodes = NodeIterator( - stateTree: editorState.document, + document: editorState.document, startNode: beginNode, endNode: endNode, ).toList(); @@ -321,7 +321,7 @@ void _deleteSelectedContent(EditorState editorState) { return; } final traverser = NodeIterator( - stateTree: editorState.document, + document: editorState.document, startNode: beginNode, endNode: endNode, ); diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/selection_service.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/selection_service.dart index ed0bc369a5..a5f27e9664 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/selection_service.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/selection_service.dart @@ -180,7 +180,7 @@ class _AppFlowySelectionState extends State final endNode = editorState.document.nodeAtPath(end); if (startNode != null && endNode != null) { final nodes = NodeIterator( - stateTree: editorState.document, + document: editorState.document, startNode: startNode, endNode: endNode, ).toList(); diff --git a/frontend/app_flowy/packages/appflowy_editor/test/core/document/node_iterator_test.dart b/frontend/app_flowy/packages/appflowy_editor/test/core/document/node_iterator_test.dart index 75de6dda35..05a0090ec3 100644 --- a/frontend/app_flowy/packages/appflowy_editor/test/core/document/node_iterator_test.dart +++ b/frontend/app_flowy/packages/appflowy_editor/test/core/document/node_iterator_test.dart @@ -14,7 +14,7 @@ void main() async { root.insert(node); } final nodes = NodeIterator( - stateTree: StateTree(root: root), + document: Document(root: root), startNode: root.childAtPath([0])!, endNode: root.childAtPath([10, 10]), ); diff --git a/frontend/app_flowy/packages/appflowy_editor/test/core/state/document_test.dart b/frontend/app_flowy/packages/appflowy_editor/test/core/state/document_test.dart new file mode 100644 index 0000000000..a8059d584a --- /dev/null +++ b/frontend/app_flowy/packages/appflowy_editor/test/core/state/document_test.dart @@ -0,0 +1,77 @@ +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() async { + group('documemnt.dart', () { + test('insert', () { + final document = Document.empty(); + + expect(document.insert([-1], []), false); + expect(document.insert([100], []), false); + + final node0 = Node(type: '0'); + final node1 = Node(type: '1'); + expect(document.insert([0], [node0, node1]), true); + expect(document.nodeAtPath([0])?.type, '0'); + expect(document.nodeAtPath([1])?.type, '1'); + }); + + test('delete', () { + final document = Document(root: Node(type: 'root')); + + expect(document.delete([-1], 1), false); + expect(document.delete([100], 1), false); + + for (var i = 0; i < 10; i++) { + final node = Node(type: '$i'); + document.insert([i], [node]); + } + + document.delete([0], 10); + expect(document.root.children.isEmpty, true); + }); + + test('update', () { + final node = Node(type: 'example', attributes: {'a': 'a'}); + final document = Document(root: Node(type: 'root')); + document.insert([0], [node]); + + final attributes = { + 'a': 'b', + 'b': 'c', + }; + + expect(document.update([0], attributes), true); + expect(document.nodeAtPath([0])?.attributes, attributes); + + expect(document.update([-1], attributes), false); + }); + + test('updateText', () { + final delta = Delta()..insert('Editor'); + final textNode = TextNode(delta: delta); + final document = Document(root: Node(type: 'root')); + document.insert([0], [textNode]); + document.updateText([0], Delta()..insert('AppFlowy')); + expect((document.nodeAtPath([0]) as TextNode).toPlainText(), + 'AppFlowyEditor'); + }); + + test('serialize', () { + final json = { + 'document': { + 'type': 'editor', + 'children': [ + { + 'type': 'text', + 'delta': [], + } + ], + 'attributes': {'a': 'a'} + } + }; + final document = Document.fromJson(json); + expect(document.toJson(), json); + }); + }); +} diff --git a/frontend/app_flowy/packages/appflowy_editor/test/infra/test_editor.dart b/frontend/app_flowy/packages/appflowy_editor/test/infra/test_editor.dart index 09860c9346..6396e47ebe 100644 --- a/frontend/app_flowy/packages/appflowy_editor/test/infra/test_editor.dart +++ b/frontend/app_flowy/packages/appflowy_editor/test/infra/test_editor.dart @@ -19,7 +19,7 @@ class EditorWidgetTester { EditorState get editorState => _editorState; Node get root => _editorState.document.root; - StateTree get document => _editorState.document; + Document get document => _editorState.document; int get documentLength => _editorState.document.root.children.length; Selection? get documentSelection => _editorState.service.selectionService.currentSelection.value; @@ -155,7 +155,7 @@ class EditorWidgetTester { EditorState _createEmptyDocument() { return EditorState( - document: StateTree( + document: Document( root: _createEmptyEditorRoot(), ), )..disableSealTimer = true; diff --git a/frontend/app_flowy/packages/appflowy_editor/test/legacy/flowy_editor_test.dart b/frontend/app_flowy/packages/appflowy_editor/test/legacy/flowy_editor_test.dart index aba15f02c7..998ea974f7 100644 --- a/frontend/app_flowy/packages/appflowy_editor/test/legacy/flowy_editor_test.dart +++ b/frontend/app_flowy/packages/appflowy_editor/test/legacy/flowy_editor_test.dart @@ -9,16 +9,16 @@ void main() { test('create state tree', () async { // final String response = await rootBundle.loadString('assets/document.json'); // final data = Map.from(json.decode(response)); - // final stateTree = StateTree.fromJson(data); - // expect(stateTree.root.type, 'root'); - // expect(stateTree.root.toJson(), data['document']); + // final document = StateTree.fromJson(data); + // expect(document.root.type, 'root'); + // expect(document.root.toJson(), data['document']); }); test('search node by Path in state tree', () async { // final String response = await rootBundle.loadString('assets/document.json'); // final data = Map.from(json.decode(response)); - // final stateTree = StateTree.fromJson(data); - // final checkBoxNode = stateTree.root.childAtPath([1, 0]); + // final document = StateTree.fromJson(data); + // final checkBoxNode = document.root.childAtPath([1, 0]); // expect(checkBoxNode != null, true); // final textType = checkBoxNode!.attributes['text-type']; // expect(textType != null, true); @@ -27,8 +27,8 @@ void main() { test('search node by Self in state tree', () async { // final String response = await rootBundle.loadString('assets/document.json'); // final data = Map.from(json.decode(response)); - // final stateTree = StateTree.fromJson(data); - // final checkBoxNode = stateTree.root.childAtPath([1, 0]); + // final document = StateTree.fromJson(data); + // final checkBoxNode = document.root.childAtPath([1, 0]); // expect(checkBoxNode != null, true); // final textType = checkBoxNode!.attributes['text-type']; // expect(textType != null, true); @@ -39,21 +39,21 @@ void main() { test('insert node in state tree', () async { // final String response = await rootBundle.loadString('assets/document.json'); // final data = Map.from(json.decode(response)); - // final stateTree = StateTree.fromJson(data); + // final document = StateTree.fromJson(data); // final insertNode = Node.fromJson({ // 'type': 'text', // }); - // bool result = stateTree.insert([1, 1], [insertNode]); + // bool result = document.insert([1, 1], [insertNode]); // expect(result, true); - // expect(identical(insertNode, stateTree.nodeAtPath([1, 1])), true); + // expect(identical(insertNode, document.nodeAtPath([1, 1])), true); }); test('delete node in state tree', () async { // final String response = await rootBundle.loadString('assets/document.json'); // final data = Map.from(json.decode(response)); - // final stateTree = StateTree.fromJson(data); - // stateTree.delete([1, 1], 1); - // final node = stateTree.nodeAtPath([1, 1]); + // final document = StateTree.fromJson(data); + // document.delete([1, 1], 1); + // final node = document.nodeAtPath([1, 1]); // expect(node != null, true); // expect(node!.attributes['tag'], '**'); }); @@ -61,10 +61,10 @@ void main() { test('update node in state tree', () async { // final String response = await rootBundle.loadString('assets/document.json'); // final data = Map.from(json.decode(response)); - // final stateTree = StateTree.fromJson(data); - // final test = stateTree.update([1, 1], {'text-type': 'heading1'}); + // final document = StateTree.fromJson(data); + // final test = document.update([1, 1], {'text-type': 'heading1'}); // expect(test, true); - // final updatedNode = stateTree.nodeAtPath([1, 1]); + // final updatedNode = document.nodeAtPath([1, 1]); // expect(updatedNode != null, true); // expect(updatedNode!.attributes['text-type'], 'heading1'); }); 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 369ba8650f..98b8310595 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 @@ -5,7 +5,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:appflowy_editor/src/operation/operation.dart'; import 'package:appflowy_editor/src/operation/transaction_builder.dart'; import 'package:appflowy_editor/src/editor_state.dart'; -import 'package:appflowy_editor/src/document/state_tree.dart'; +import 'package:appflowy_editor/src/core/state/document.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -56,7 +56,7 @@ void main() { item2, item3, ])); - final state = EditorState(document: StateTree(root: root)); + final state = EditorState(document: Document(root: root)); expect(item1.path, [0]); expect(item2.path, [1]); @@ -74,7 +74,7 @@ void main() { group("toJson", () { test("insert", () { final root = Node(type: "root", attributes: {}, children: LinkedList()); - final state = EditorState(document: StateTree(root: root)); + final state = EditorState(document: Document(root: root)); final item1 = Node(type: "node", attributes: {}, children: LinkedList()); final tb = TransactionBuilder(state); @@ -100,7 +100,7 @@ void main() { ..addAll([ item1, ])); - final state = EditorState(document: StateTree(root: root)); + final state = EditorState(document: Document(root: root)); final tb = TransactionBuilder(state); tb.deleteNode(item1); final transaction = tb.finish(); 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 c912d6a687..934aa5ce24 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 @@ -17,7 +17,7 @@ void main() async { } test("HistoryItem #1", () { - final document = StateTree(root: _createEmptyEditorRoot()); + final document = Document(root: _createEmptyEditorRoot()); final editorState = EditorState(document: document); final historyItem = HistoryItem(); @@ -35,7 +35,7 @@ void main() async { }); test("HistoryItem #2", () { - final document = StateTree(root: _createEmptyEditorRoot()); + final document = Document(root: _createEmptyEditorRoot()); final editorState = EditorState(document: document); final historyItem = HistoryItem();