Merge pull request #609 from LucasXu0/feat/flowy_editor

combine render plugins and editor state
This commit is contained in:
Nathan.fooo 2022-07-13 21:29:53 +08:00 committed by GitHub
commit a4c66c4db0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 215 additions and 94 deletions

View File

@ -8,6 +8,7 @@ on:
pull_request: pull_request:
branches: branches:
- 'main' - 'main'
- 'feat/flowy_editor'
env: env:
CARGO_TERM_COLOR: always CARGO_TERM_COLOR: always
@ -71,3 +72,8 @@ jobs:
flutter pub get flutter pub get
flutter test flutter test
- name: Run FlowyEditor tests
working-directory: frontend/app_flowy/packages/flowy_editor
run: |
flutter pub get
flutter test

View File

@ -20,7 +20,7 @@
"subtype": "with-checkbox", "subtype": "with-checkbox",
"text-type": "heading1", "text-type": "heading1",
"font-size": 30, "font-size": 30,
"content": "bbbbbbbbbbbbbbbbbbbbbbb", "content": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
"checkbox": false "checkbox": false
} }
}, },

View File

@ -92,13 +92,12 @@ class _MyHomePageState extends State<MyHomePage> {
); );
} else { } else {
final data = Map<String, Object>.from(json.decode(snapshot.data!)); final data = Map<String, Object>.from(json.decode(snapshot.data!));
final stateTree = StateTree.fromJson(data); final document = StateTree.fromJson(data);
return renderPlugins.buildWidget( final editorState = EditorState(
context: NodeWidgetContext( document: document,
buildContext: context, renderPlugins: renderPlugins,
node: stateTree.root,
),
); );
return editorState.build(context);
} }
}, },
), ),

View File

@ -5,55 +5,74 @@ import 'package:provider/provider.dart';
class ImageNodeBuilder extends NodeWidgetBuilder { class ImageNodeBuilder extends NodeWidgetBuilder {
ImageNodeBuilder.create({ ImageNodeBuilder.create({
required super.node, required super.node,
required super.renderPlugins, required super.editorState,
}) : super.create(); }) : 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; String get src => node.attributes['image_src'] as String;
@override @override
Widget build(BuildContext buildContext) { Widget build(BuildContext context) {
Future.delayed(const Duration(seconds: 5), () { return GestureDetector(
node.updateAttributes({ child: ChangeNotifierProvider.value(
'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, value: node,
builder: (context, child) { builder: (_, __) => Consumer<Node>(
return Consumer<Node>( builder: ((context, value, child) => _build(context)),
builder: (context, value, child) { ),
return _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) { Widget _build(BuildContext context) {
final image = Image.network(src); return Column(
Widget? children; children: [
if (node.children.isNotEmpty) { Image.network(src),
children = Column( if (node.children.isNotEmpty)
Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: node.children children: node.children
.map( .map(
(e) => renderPlugins.buildWidget( (e) => editorState.renderPlugins.buildWidget(
context: NodeWidgetContext(buildContext: buildContext, node: e), context: NodeWidgetContext(
buildContext: context,
node: e,
editorState: editorState,
),
), ),
) )
.toList(), .toList(),
); ),
}
if (children != null) {
return Column(
children: [
image,
children,
], ],
); );
} else {
return image;
}
} }
} }

View File

@ -4,7 +4,7 @@ import 'package:flowy_editor/flowy_editor.dart';
class TextNodeBuilder extends NodeWidgetBuilder { class TextNodeBuilder extends NodeWidgetBuilder {
TextNodeBuilder.create({ TextNodeBuilder.create({
required super.node, required super.node,
required super.renderPlugins, required super.editorState,
}) : super.create(); }) : super.create();
String get content => node.attributes['content'] as String; String get content => node.attributes['content'] as String;
@ -25,7 +25,11 @@ class TextNodeBuilder extends NodeWidgetBuilder {
children: node.children children: node.children
.map( .map(
(e) => renderPlugins.buildWidget( (e) => renderPlugins.buildWidget(
context: NodeWidgetContext(buildContext: buildContext, node: e), context: NodeWidgetContext(
buildContext: buildContext,
node: e,
editorState: editorState,
),
), ),
) )
.toList(), .toList(),

View File

@ -4,7 +4,7 @@ import 'package:flutter/material.dart';
class TextWithCheckBoxNodeBuilder extends NodeWidgetBuilder { class TextWithCheckBoxNodeBuilder extends NodeWidgetBuilder {
TextWithCheckBoxNodeBuilder.create({ TextWithCheckBoxNodeBuilder.create({
required super.node, required super.node,
required super.renderPlugins, required super.editorState,
}) : super.create(); }) : super.create();
// TODO: check the type // TODO: check the type
@ -13,11 +13,16 @@ class TextWithCheckBoxNodeBuilder extends NodeWidgetBuilder {
@override @override
Widget build(BuildContext buildContext) { Widget build(BuildContext buildContext) {
return Row( return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Checkbox(value: isCompleted, onChanged: (value) {}), Checkbox(value: isCompleted, onChanged: (value) {}),
Expanded( Expanded(
child: renderPlugins.buildWidget( child: renderPlugins.buildWidget(
context: NodeWidgetContext(buildContext: buildContext, node: node), context: NodeWidgetContext(
buildContext: buildContext,
node: node,
editorState: editorState,
),
withSubtype: false, withSubtype: false,
), ),
) )

View File

@ -185,5 +185,5 @@ packages:
source: hosted source: hosted
version: "2.1.2" version: "2.1.2"
sdks: sdks:
dart: ">=2.17.3 <3.0.0" dart: ">=2.17.0 <3.0.0"
flutter: ">=1.17.0" flutter: ">=1.17.0"

View File

@ -18,7 +18,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
version: 1.0.0+1 version: 1.0.0+1
environment: 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. # Dependencies specify other packages that your package needs in order to work.
# To automatically upgrade your package dependencies to the latest versions # To automatically upgrade your package dependencies to the latest versions

View File

@ -84,6 +84,20 @@ class Node extends ChangeNotifier with LinkedListEntry<Node> {
return childAtIndex(path.first)?.childAtPath(path.sublist(1)); 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 @override
void insertAfter(Node entry) { void insertAfter(Node entry) {
entry.parent = parent; entry.parent = parent;

View File

@ -4,7 +4,9 @@ import 'package:flowy_editor/document/path.dart';
class StateTree { class StateTree {
final Node root; final Node root;
StateTree({required this.root}); StateTree({
required this.root,
});
factory StateTree.fromJson(Attributes json) { factory StateTree.fromJson(Attributes json) {
assert(json['document'] is Map); assert(json['document'] is Map);

View File

@ -24,7 +24,10 @@ class TextOperation {
int _hashAttributes(Attributes attributes) { int _hashAttributes(Attributes attributes) {
return Object.hashAllUnordered( 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 { class TextInsert extends TextOperation {
@ -145,7 +148,8 @@ class _OpIterator {
int _index = 0; int _index = 0;
int _offset = 0; int _offset = 0;
_OpIterator(List<TextOperation> operations) : _operations = UnmodifiableListView(operations); _OpIterator(List<TextOperation> operations)
: _operations = UnmodifiableListView(operations);
bool get hasNext { bool get hasNext {
return peekLength() < _maxInt; return peekLength() < _maxInt;
@ -186,16 +190,23 @@ class _OpIterator {
_offset += length; _offset += length;
} }
if (nextOp is TextDelete) { if (nextOp is TextDelete) {
return TextDelete(length: length); return TextDelete(
length: length,
);
} }
if (nextOp is TextRetain) { if (nextOp is TextRetain) {
return TextRetain(length: length, attributes: nextOp.attributes); return TextRetain(
length: length,
attributes: nextOp.attributes,
);
} }
if (nextOp is TextInsert) { if (nextOp is TextInsert) {
return TextInsert( return TextInsert(
nextOp.content.substring(offset, offset + length), nextOp.attributes); nextOp.content.substring(offset, offset + length),
nextOp.attributes,
);
} }
return TextRetain(length: _maxInt); return TextRetain(length: _maxInt);

View File

@ -1,13 +1,13 @@
import './text_delta.dart'; import './text_delta.dart';
import './node.dart'; import './node.dart';
class TextNode extends Node { class TextNode extends Node {
final Delta delta; final Delta delta;
TextNode( TextNode({
{required super.type, required super.type,
required super.children, required super.children,
required super.attributes, required super.attributes,
required this.delta}); required this.delta,
});
} }

View File

@ -1,24 +1,52 @@
import 'package:flowy_editor/document/node.dart';
import 'package:flowy_editor/operation/operation.dart'; import 'package:flowy_editor/operation/operation.dart';
import 'package:flutter/material.dart';
import './document/state_tree.dart'; import './document/state_tree.dart';
import './document/selection.dart'; import './document/selection.dart';
import './operation/operation.dart'; import './operation/operation.dart';
import './operation/transaction.dart'; import './operation/transaction.dart';
import './render/render_plugins.dart';
class EditorState { class EditorState {
final StateTree document; final StateTree document;
final RenderPlugins renderPlugins;
Selection? cursorSelection; Selection? cursorSelection;
EditorState({ EditorState({
required this.document, 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) { for (final op in transaction.operations) {
_applyOperation(op); _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) { _applyOperation(Operation op) {
if (op is InsertOperation) { if (op is InsertOperation) {
document.insert(op.path, op.value); document.insert(op.path, op.value);

View File

@ -5,3 +5,6 @@ export 'package:flowy_editor/document/node.dart';
export 'package:flowy_editor/document/path.dart'; export 'package:flowy_editor/document/path.dart';
export 'package:flowy_editor/render/render_plugins.dart'; export 'package:flowy_editor/render/render_plugins.dart';
export 'package:flowy_editor/render/node_widget_builder.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';

View File

@ -2,9 +2,7 @@ import 'package:flowy_editor/document/path.dart';
import 'package:flowy_editor/document/node.dart'; import 'package:flowy_editor/document/node.dart';
abstract class Operation { abstract class Operation {
Operation invert(); Operation invert();
} }
class InsertOperation extends Operation { class InsertOperation extends Operation {
@ -18,9 +16,11 @@ class InsertOperation extends Operation {
@override @override
Operation invert() { Operation invert() {
return DeleteOperation(path: path, removedValue: value); return DeleteOperation(
path: path,
removedValue: value,
);
} }
} }
class UpdateOperation extends Operation { class UpdateOperation extends Operation {
@ -36,9 +36,12 @@ class UpdateOperation extends Operation {
@override @override
Operation invert() { Operation invert() {
return UpdateOperation(path: path, attributes: oldAttributes, oldAttributes: attributes); return UpdateOperation(
path: path,
attributes: oldAttributes,
oldAttributes: attributes,
);
} }
} }
class DeleteOperation extends Operation { class DeleteOperation extends Operation {
@ -52,7 +55,9 @@ class DeleteOperation extends Operation {
@override @override
Operation invert() { Operation invert() {
return InsertOperation(path: path, value: removedValue); return InsertOperation(
path: path,
value: removedValue,
);
} }
} }

View File

@ -1,6 +1,6 @@
import './operation.dart'; import './operation.dart';
class Transaction { class Transaction {
final List<Operation> operations = []; final List<Operation> operations;
Transaction([this.operations = const []]);
} }

View File

@ -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 'package:flutter/material.dart';
import '../document/node.dart';
import '../render/render_plugins.dart';
class NodeWidgetBuilder<T extends Node> { class NodeWidgetBuilder<T extends Node> {
final EditorState editorState;
final T node; 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] /// Render the current [Node]
/// and the layout style of [Node.Children]. /// and the layout style of [Node.Children].
Widget build(BuildContext buildContext) => throw UnimplementedError(); Widget build(BuildContext buildContext) => throw UnimplementedError();
Widget call(BuildContext buildContext) => build(buildContext);
} }

View File

@ -1,17 +1,24 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../document/node.dart'; import '../document/node.dart';
import 'node_widget_builder.dart'; import './node_widget_builder.dart';
import 'package:flowy_editor/editor_state.dart';
class NodeWidgetContext { class NodeWidgetContext {
BuildContext buildContext; final BuildContext buildContext;
Node node; final Node node;
NodeWidgetContext({required this.buildContext, required this.node}); final EditorState editorState;
NodeWidgetContext({
required this.buildContext,
required this.node,
required this.editorState,
});
} }
typedef NodeWidgetBuilderF<T extends Node, A extends NodeWidgetBuilder> = A typedef NodeWidgetBuilderF<T extends Node, A extends NodeWidgetBuilder> = A
Function({ Function({
required T node, required T node,
required RenderPlugins renderPlugins, required EditorState editorState,
}); });
// unused // unused
@ -56,8 +63,10 @@ class RenderPlugins {
name += '/${node.subtype}'; name += '/${node.subtype}';
} }
final nodeWidgetBuilder = _nodeWidgetBuilder(name); final nodeWidgetBuilder = _nodeWidgetBuilder(name);
return nodeWidgetBuilder(node: context.node, renderPlugins: this)( return nodeWidgetBuilder(
context.buildContext); node: context.node,
editorState: context.editorState,
)(context.buildContext);
} }
NodeWidgetBuilderF _nodeWidgetBuilder(String name) { NodeWidgetBuilderF _nodeWidgetBuilder(String name) {

View File

@ -4,7 +4,7 @@ version: 0.0.1
homepage: homepage:
environment: environment:
sdk: ">=2.17.3 <3.0.0" sdk: ">=2.17.0 <3.0.0"
flutter: ">=1.17.0" flutter: ">=1.17.0"
dependencies: dependencies:

View File

@ -74,9 +74,7 @@ void main() {
expect(a.compose(b), expected); expect(a.compose(b), expected);
}); });
test('retain + insert', () { test('retain + insert', () {
final a = Delta().retain(1, { final a = Delta().retain(1, {'color': 'blue'});
'color': 'blue'
});
final b = Delta().insert('B'); final b = Delta().insert('B');
final expected = Delta().insert('B').retain(1, { final expected = Delta().insert('B').retain(1, {
'color': 'blue', 'color': 'blue',

View File

@ -1,4 +1,5 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:math';
import 'package:flowy_editor/document/node.dart'; import 'package:flowy_editor/document/node.dart';
import 'package:flowy_editor/document/state_tree.dart'; import 'package:flowy_editor/document/state_tree.dart';
@ -20,7 +21,7 @@ void main() {
expect(stateTree.root.toJson(), data['document']); 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 String response = await rootBundle.loadString('assets/document.json');
final data = Map<String, Object>.from(json.decode(response)); final data = Map<String, Object>.from(json.decode(response));
final stateTree = StateTree.fromJson(data); final stateTree = StateTree.fromJson(data);
@ -30,6 +31,18 @@ void main() {
expect(textType != null, true); 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<String, Object>.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 { test('insert node in state tree', () async {
final String response = await rootBundle.loadString('assets/document.json'); final String response = await rootBundle.loadString('assets/document.json');
final data = Map<String, Object>.from(json.decode(response)); final data = Map<String, Object>.from(json.decode(response));