diff --git a/.github/workflows/dart_test.yml b/.github/workflows/dart_test.yml index 74b20a2425..f0b42a506d 100644 --- a/.github/workflows/dart_test.yml +++ b/.github/workflows/dart_test.yml @@ -8,6 +8,7 @@ on: pull_request: branches: - 'main' + - 'feat/flowy_editor' env: CARGO_TERM_COLOR: always @@ -71,3 +72,8 @@ jobs: flutter pub get flutter test + - name: Run FlowyEditor tests + working-directory: frontend/app_flowy/packages/flowy_editor + run: | + flutter pub get + flutter test \ No newline at end of file diff --git a/frontend/app_flowy/packages/flowy_editor/example/assets/document.json b/frontend/app_flowy/packages/flowy_editor/example/assets/document.json index f41ec18130..2ddedf70b3 100644 --- a/frontend/app_flowy/packages/flowy_editor/example/assets/document.json +++ b/frontend/app_flowy/packages/flowy_editor/example/assets/document.json @@ -20,7 +20,7 @@ "subtype": "with-checkbox", "text-type": "heading1", "font-size": 30, - "content": "bbbbbbbbbbbbbbbbbbbbbbb", + "content": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "checkbox": false } }, diff --git a/frontend/app_flowy/packages/flowy_editor/example/lib/main.dart b/frontend/app_flowy/packages/flowy_editor/example/lib/main.dart index 845b352a23..593cfa2871 100644 --- a/frontend/app_flowy/packages/flowy_editor/example/lib/main.dart +++ b/frontend/app_flowy/packages/flowy_editor/example/lib/main.dart @@ -92,13 +92,12 @@ class _MyHomePageState extends State { ); } else { final data = Map.from(json.decode(snapshot.data!)); - final stateTree = StateTree.fromJson(data); - return renderPlugins.buildWidget( - context: NodeWidgetContext( - buildContext: context, - node: stateTree.root, - ), + final document = StateTree.fromJson(data); + final editorState = EditorState( + document: document, + renderPlugins: renderPlugins, ); + return editorState.build(context); } }, ), 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 f960f877df..11607d7bd6 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 @@ -5,55 +5,74 @@ import 'package:provider/provider.dart'; class ImageNodeBuilder extends NodeWidgetBuilder { ImageNodeBuilder.create({ required super.node, - required super.renderPlugins, + required super.editorState, }) : super.create(); + @override + Widget build(BuildContext buildContext) { + return _ImageNodeWidget( + node: node, + editorState: editorState, + ); + } +} + +class _ImageNodeWidget extends StatelessWidget { + final Node node; + final EditorState editorState; + + const _ImageNodeWidget({ + Key? key, + required this.node, + required this.editorState, + }) : super(key: key); + String get src => node.attributes['image_src'] as String; @override - Widget build(BuildContext buildContext) { - Future.delayed(const Duration(seconds: 5), () { - node.updateAttributes({ - 'image_src': - "https://images.pexels.com/photos/9995076/pexels-photo-9995076.png?cs=srgb&dl=pexels-temmuz-uzun-9995076.jpg&fm=jpg&w=640&h=400" - }); - }); - return ChangeNotifierProvider.value( - value: node, - builder: (context, child) { - return Consumer( - builder: (context, value, child) { - return _build(context); - }, + Widget build(BuildContext context) { + return GestureDetector( + child: ChangeNotifierProvider.value( + value: node, + builder: (_, __) => Consumer( + builder: ((context, value, child) => _build(context)), + ), + ), + onTap: () { + editorState.update( + node, + Attributes.from(node.attributes) + ..addAll( + { + 'image_src': + "https://images.pexels.com/photos/9995076/pexels-photo-9995076.png?cs=srgb&dl=pexels-temmuz-uzun-9995076.jpg&fm=jpg&w=640&h=400", + }, + ), ); }, ); } - Widget _build(BuildContext buildContext) { - final image = Image.network(src); - Widget? children; - if (node.children.isNotEmpty) { - children = Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: node.children - .map( - (e) => renderPlugins.buildWidget( - context: NodeWidgetContext(buildContext: buildContext, node: e), - ), - ) - .toList(), - ); - } - if (children != null) { - return Column( - children: [ - image, - children, - ], - ); - } else { - return image; - } + Widget _build(BuildContext context) { + return Column( + children: [ + Image.network(src), + if (node.children.isNotEmpty) + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: node.children + .map( + (e) => editorState.renderPlugins.buildWidget( + context: NodeWidgetContext( + buildContext: context, + node: e, + editorState: editorState, + ), + ), + ) + .toList(), + ), + ], + ); } } 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 825f55a4ab..482e6855f3 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 @@ -4,7 +4,7 @@ import 'package:flowy_editor/flowy_editor.dart'; class TextNodeBuilder extends NodeWidgetBuilder { TextNodeBuilder.create({ required super.node, - required super.renderPlugins, + required super.editorState, }) : super.create(); String get content => node.attributes['content'] as String; @@ -25,7 +25,11 @@ class TextNodeBuilder extends NodeWidgetBuilder { children: node.children .map( (e) => renderPlugins.buildWidget( - context: NodeWidgetContext(buildContext: buildContext, node: e), + context: NodeWidgetContext( + buildContext: buildContext, + node: e, + editorState: editorState, + ), ), ) .toList(), diff --git a/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/text_with_check_box_node_widget.dart b/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/text_with_check_box_node_widget.dart index 79dddaa665..37a30fb6be 100644 --- a/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/text_with_check_box_node_widget.dart +++ b/frontend/app_flowy/packages/flowy_editor/example/lib/plugin/text_with_check_box_node_widget.dart @@ -4,7 +4,7 @@ import 'package:flutter/material.dart'; class TextWithCheckBoxNodeBuilder extends NodeWidgetBuilder { TextWithCheckBoxNodeBuilder.create({ required super.node, - required super.renderPlugins, + required super.editorState, }) : super.create(); // TODO: check the type @@ -13,11 +13,16 @@ class TextWithCheckBoxNodeBuilder extends NodeWidgetBuilder { @override Widget build(BuildContext buildContext) { return Row( + crossAxisAlignment: CrossAxisAlignment.start, children: [ Checkbox(value: isCompleted, onChanged: (value) {}), Expanded( child: renderPlugins.buildWidget( - context: NodeWidgetContext(buildContext: buildContext, node: node), + context: NodeWidgetContext( + buildContext: buildContext, + node: node, + editorState: editorState, + ), withSubtype: false, ), ) diff --git a/frontend/app_flowy/packages/flowy_editor/example/pubspec.lock b/frontend/app_flowy/packages/flowy_editor/example/pubspec.lock index 63ade5e65c..1420c9b23d 100644 --- a/frontend/app_flowy/packages/flowy_editor/example/pubspec.lock +++ b/frontend/app_flowy/packages/flowy_editor/example/pubspec.lock @@ -185,5 +185,5 @@ packages: source: hosted version: "2.1.2" sdks: - dart: ">=2.17.3 <3.0.0" + dart: ">=2.17.0 <3.0.0" flutter: ">=1.17.0" diff --git a/frontend/app_flowy/packages/flowy_editor/example/pubspec.yaml b/frontend/app_flowy/packages/flowy_editor/example/pubspec.yaml index 1a788cfb7d..d514607eac 100644 --- a/frontend/app_flowy/packages/flowy_editor/example/pubspec.yaml +++ b/frontend/app_flowy/packages/flowy_editor/example/pubspec.yaml @@ -18,7 +18,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev version: 1.0.0+1 environment: - sdk: ">=2.17.3 <3.0.0" + sdk: ">=2.17.0 <3.0.0" # Dependencies specify other packages that your package needs in order to work. # To automatically upgrade your package dependencies to the latest versions 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 1e5200501f..ad49d9c8a2 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/document/node.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/document/node.dart @@ -84,6 +84,20 @@ class Node extends ChangeNotifier with LinkedListEntry { return childAtIndex(path.first)?.childAtPath(path.sublist(1)); } + Path path([Path previous = const []]) { + if (parent == null) { + return previous; + } + var index = 0; + for (var child in parent!.children) { + if (child == this) { + break; + } + index += 1; + } + return parent!.path([index, ...previous]); + } + @override void insertAfter(Node entry) { entry.parent = 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 368b575c90..af343f54a0 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 @@ -4,7 +4,9 @@ import 'package:flowy_editor/document/path.dart'; class StateTree { final Node root; - StateTree({required this.root}); + StateTree({ + required this.root, + }); factory StateTree.fromJson(Attributes json) { assert(json['document'] is Map); 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 30d3b81b9f..a52a96426a 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 @@ -24,7 +24,10 @@ class TextOperation { int _hashAttributes(Attributes attributes) { return Object.hashAllUnordered( - attributes.entries.map((e) => Object.hash(e.key, e.value))); + attributes.entries.map( + (e) => Object.hash(e.key, e.value), + ), + ); } class TextInsert extends TextOperation { @@ -145,7 +148,8 @@ class _OpIterator { int _index = 0; int _offset = 0; - _OpIterator(List operations) : _operations = UnmodifiableListView(operations); + _OpIterator(List operations) + : _operations = UnmodifiableListView(operations); bool get hasNext { return peekLength() < _maxInt; @@ -186,16 +190,23 @@ class _OpIterator { _offset += length; } if (nextOp is TextDelete) { - return TextDelete(length: length); + return TextDelete( + length: length, + ); } if (nextOp is TextRetain) { - return TextRetain(length: length, attributes: nextOp.attributes); + return TextRetain( + length: length, + attributes: nextOp.attributes, + ); } if (nextOp is TextInsert) { return TextInsert( - nextOp.content.substring(offset, offset + length), nextOp.attributes); + nextOp.content.substring(offset, offset + length), + nextOp.attributes, + ); } return TextRetain(length: _maxInt); 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 index 2e12deb939..535431b615 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/document/text_node.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/document/text_node.dart @@ -1,13 +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}); + 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 index 15e8353725..d60c06a497 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/editor_state.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/editor_state.dart @@ -1,24 +1,52 @@ +import 'package:flowy_editor/document/node.dart'; import 'package:flowy_editor/operation/operation.dart'; +import 'package:flutter/material.dart'; import './document/state_tree.dart'; import './document/selection.dart'; import './operation/operation.dart'; import './operation/transaction.dart'; +import './render/render_plugins.dart'; class EditorState { final StateTree document; + final RenderPlugins renderPlugins; Selection? cursorSelection; EditorState({ required this.document, + required this.renderPlugins, }); - apply(Transaction transaction) { + /// TODO: move to a better place. + Widget build(BuildContext context) { + return renderPlugins.buildWidget( + context: NodeWidgetContext( + buildContext: context, + node: document.root, + editorState: this, + ), + ); + } + + void apply(Transaction transaction) { for (final op in transaction.operations) { _applyOperation(op); } } + // TODO: move to a better place. + void update( + Node node, + Attributes attributes, + ) { + _applyOperation(UpdateOperation( + path: node.path(), + attributes: attributes, + oldAttributes: node.attributes, + )); + } + _applyOperation(Operation op) { if (op is InsertOperation) { document.insert(op.path, op.value); diff --git a/frontend/app_flowy/packages/flowy_editor/lib/flowy_editor.dart b/frontend/app_flowy/packages/flowy_editor/lib/flowy_editor.dart index b16fe82273..d4c0a8d70a 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/flowy_editor.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/flowy_editor.dart @@ -5,3 +5,6 @@ export 'package:flowy_editor/document/node.dart'; export 'package:flowy_editor/document/path.dart'; export 'package:flowy_editor/render/render_plugins.dart'; export 'package:flowy_editor/render/node_widget_builder.dart'; +export 'package:flowy_editor/operation/transaction.dart'; +export 'package:flowy_editor/operation/operation.dart'; +export 'package:flowy_editor/editor_state.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 b5d71b57d4..4ca7b9ca83 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/operation/operation.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/operation/operation.dart @@ -2,9 +2,7 @@ import 'package:flowy_editor/document/path.dart'; import 'package:flowy_editor/document/node.dart'; abstract class Operation { - Operation invert(); - } class InsertOperation extends Operation { @@ -18,9 +16,11 @@ class InsertOperation extends Operation { @override Operation invert() { - return DeleteOperation(path: path, removedValue: value); + return DeleteOperation( + path: path, + removedValue: value, + ); } - } class UpdateOperation extends Operation { @@ -36,9 +36,12 @@ class UpdateOperation extends Operation { @override Operation invert() { - return UpdateOperation(path: path, attributes: oldAttributes, oldAttributes: attributes); + return UpdateOperation( + path: path, + attributes: oldAttributes, + oldAttributes: attributes, + ); } - } class DeleteOperation extends Operation { @@ -52,7 +55,9 @@ class DeleteOperation extends Operation { @override Operation invert() { - return InsertOperation(path: path, value: removedValue); + 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 index c6fbed63aa..1a2c4bcdb5 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/operation/transaction.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/operation/transaction.dart @@ -1,6 +1,6 @@ import './operation.dart'; class Transaction { - final List operations = []; - + final List operations; + Transaction([this.operations = const []]); } diff --git a/frontend/app_flowy/packages/flowy_editor/lib/render/node_widget_builder.dart b/frontend/app_flowy/packages/flowy_editor/lib/render/node_widget_builder.dart index 484b38ceb4..0dad14f821 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/render/node_widget_builder.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/render/node_widget_builder.dart @@ -1,17 +1,22 @@ +import 'package:flowy_editor/editor_state.dart'; +import 'package:flowy_editor/document/node.dart'; +import 'package:flowy_editor/render/render_plugins.dart'; import 'package:flutter/material.dart'; -import '../document/node.dart'; -import '../render/render_plugins.dart'; - class NodeWidgetBuilder { + final EditorState editorState; final T node; - final RenderPlugins renderPlugins; - NodeWidgetBuilder.create({required this.node, required this.renderPlugins}); + RenderPlugins get renderPlugins => editorState.renderPlugins; - Widget call(BuildContext buildContext) => build(buildContext); + NodeWidgetBuilder.create({ + required this.editorState, + required this.node, + }); /// Render the current [Node] /// and the layout style of [Node.Children]. Widget build(BuildContext buildContext) => throw UnimplementedError(); + + Widget call(BuildContext buildContext) => build(buildContext); } diff --git a/frontend/app_flowy/packages/flowy_editor/lib/render/render_plugins.dart b/frontend/app_flowy/packages/flowy_editor/lib/render/render_plugins.dart index 4dfe3a88ba..a9bbd8b070 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/render/render_plugins.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/render/render_plugins.dart @@ -1,17 +1,24 @@ import 'package:flutter/material.dart'; import '../document/node.dart'; -import 'node_widget_builder.dart'; +import './node_widget_builder.dart'; +import 'package:flowy_editor/editor_state.dart'; class NodeWidgetContext { - BuildContext buildContext; - Node node; - NodeWidgetContext({required this.buildContext, required this.node}); + final BuildContext buildContext; + final Node node; + final EditorState editorState; + + NodeWidgetContext({ + required this.buildContext, + required this.node, + required this.editorState, + }); } typedef NodeWidgetBuilderF = A Function({ required T node, - required RenderPlugins renderPlugins, + required EditorState editorState, }); // unused @@ -56,8 +63,10 @@ class RenderPlugins { name += '/${node.subtype}'; } final nodeWidgetBuilder = _nodeWidgetBuilder(name); - return nodeWidgetBuilder(node: context.node, renderPlugins: this)( - context.buildContext); + return nodeWidgetBuilder( + node: context.node, + editorState: context.editorState, + )(context.buildContext); } NodeWidgetBuilderF _nodeWidgetBuilder(String name) { diff --git a/frontend/app_flowy/packages/flowy_editor/pubspec.yaml b/frontend/app_flowy/packages/flowy_editor/pubspec.yaml index a8c4f0c430..6a6d32d580 100644 --- a/frontend/app_flowy/packages/flowy_editor/pubspec.yaml +++ b/frontend/app_flowy/packages/flowy_editor/pubspec.yaml @@ -4,7 +4,7 @@ version: 0.0.1 homepage: environment: - sdk: ">=2.17.3 <3.0.0" + sdk: ">=2.17.0 <3.0.0" flutter: ">=1.17.0" dependencies: 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 f90d487b18..3ddd01efb7 100644 --- a/frontend/app_flowy/packages/flowy_editor/test/delta_test.dart +++ b/frontend/app_flowy/packages/flowy_editor/test/delta_test.dart @@ -74,9 +74,7 @@ void main() { expect(a.compose(b), expected); }); test('retain + insert', () { - final a = Delta().retain(1, { - 'color': 'blue' - }); + final a = Delta().retain(1, {'color': 'blue'}); final b = Delta().insert('B'); final expected = Delta().insert('B').retain(1, { 'color': 'blue', 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 b284b8608d..6635f46827 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,4 +1,5 @@ import 'dart:convert'; +import 'dart:math'; import 'package:flowy_editor/document/node.dart'; import 'package:flowy_editor/document/state_tree.dart'; @@ -20,7 +21,7 @@ void main() { expect(stateTree.root.toJson(), data['document']); }); - test('search node in state tree', () async { + 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); @@ -30,6 +31,18 @@ void main() { expect(textType != null, true); }); + 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]); + expect(checkBoxNode != null, true); + final textType = checkBoxNode!.attributes['text-type']; + expect(textType != null, true); + final path = checkBoxNode.path([]); + expect(pathEquals(path, [1, 0]), true); + }); + test('insert node in state tree', () async { final String response = await rootBundle.loadString('assets/document.json'); final data = Map.from(json.decode(response));