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 b90aec8369..307b4bf92f 100644 --- a/frontend/app_flowy/packages/flowy_editor/example/assets/document.json +++ b/frontend/app_flowy/packages/flowy_editor/example/assets/document.json @@ -68,7 +68,7 @@ { "insert": " your ", "attributes": { "bold": true } }, { "insert": "writing", "attributes": { "underline": true } }, { - "insert": " howeverv you like.", + "insert": " however you like.", "attributes": { "strikethrough": true } } ], diff --git a/frontend/app_flowy/packages/flowy_editor/example/assets/example.json b/frontend/app_flowy/packages/flowy_editor/example/assets/example.json index 0dad009cd2..d482ab2450 100644 --- a/frontend/app_flowy/packages/flowy_editor/example/assets/example.json +++ b/frontend/app_flowy/packages/flowy_editor/example/assets/example.json @@ -17,6 +17,7 @@ } ], "attributes": { + "subtype": "heading", "heading": "h1" } }, @@ -28,9 +29,68 @@ } ], "attributes": { + "subtype": "heading", "heading": "h2" } }, + { + "type": "text", + "delta": [ + { + "insert": "Here are the plugin demos:" + } + ], + "attributes": { + "subtype": "heading", + "heading": "h3" + } + }, + { + "type": "text", + "delta": [ + { + "insert": "Checkbox example ......" + } + ], + "attributes": { + "subtype": "checkbox", + "checkbox": false + }, + "children": [ + { + "type": "text", + "delta": [ + { + "insert": "AAA Checkbox example ......\nAAA Checkbox example ......" + } + ], + "attributes": { + "subtype": "checkbox", + "checkbox": false + } + }, + { + "type": "text", + "delta": [ + { + "insert": "BBB Checkbox example ......" + } + ], + "attributes": { + "subtype": "checkbox", + "checkbox": true + } + } + ] + }, + { + "type": "text", + "delta": [ + { + "insert": "Raw text example ......" + } + ] + }, { "type": "text", "delta": [ @@ -39,6 +99,7 @@ } ], "attributes": { + "subtype": "heading", "heading": "h3" } }, @@ -97,6 +158,7 @@ } ], "attributes": { + "subtype": "heading", "heading": "h3" } }, 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 e388ea3661..6bc07078d3 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/editor_state.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/editor_state.dart @@ -1,5 +1,7 @@ import 'dart:async'; +import 'package:flowy_editor/render/rich_text/checkbox_text.dart'; import 'package:flowy_editor/render/rich_text/flowy_rich_text.dart'; +import 'package:flowy_editor/render/rich_text/heading_text.dart'; import 'package:flowy_editor/service/service.dart'; import 'package:flutter/material.dart'; @@ -43,6 +45,8 @@ class EditorState { }) { // FIXME: abstract render plugins as a service. renderPlugins.register('text', RichTextNodeWidgetBuilder.create); + renderPlugins.register('text/checkbox', CheckboxNodeWidgetBuilder.create); + renderPlugins.register('text/heading', HeadingTextNodeWidgetBuilder.create); undoManager.state = this; } diff --git a/frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/checkbox_text.dart b/frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/checkbox_text.dart new file mode 100644 index 0000000000..11f1c9bba1 --- /dev/null +++ b/frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/checkbox_text.dart @@ -0,0 +1,132 @@ +import 'package:flowy_editor/document/node.dart'; +import 'package:flowy_editor/editor_state.dart'; +import 'package:flowy_editor/infra/flowy_svg.dart'; +import 'package:flowy_editor/operation/transaction_builder.dart'; +import 'package:flowy_editor/render/node_widget_builder.dart'; +import 'package:flowy_editor/render/render_plugins.dart'; +import 'package:flowy_editor/render/rich_text/default_selectable.dart'; +import 'package:flowy_editor/render/rich_text/flowy_rich_text.dart'; +import 'package:flowy_editor/render/rich_text/rich_text_style.dart'; +import 'package:flowy_editor/render/selection/selectable.dart'; +import 'package:flowy_editor/extensions/object_extensions.dart'; +import 'package:flutter/material.dart'; + +class CheckboxNodeWidgetBuilder extends NodeWidgetBuilder { + CheckboxNodeWidgetBuilder.create({ + required super.editorState, + required super.node, + required super.key, + }) : super.create(); + + @override + Widget build(BuildContext context) { + return CheckboxNodeWidget( + key: key, + textNode: node as TextNode, + editorState: editorState, + ); + } +} + +class CheckboxNodeWidget extends StatefulWidget { + const CheckboxNodeWidget({ + Key? key, + required this.textNode, + required this.editorState, + }) : super(key: key); + + final TextNode textNode; + final EditorState editorState; + + @override + State createState() => _CheckboxNodeWidgetState(); +} + +class _CheckboxNodeWidgetState extends State + with Selectable, DefaultSelectable { + final _checkboxKey = GlobalKey(debugLabel: 'checkbox'); + final _richTextKey = GlobalKey(debugLabel: 'checkbox_text'); + + @override + Selectable get forward => + _richTextKey.currentState as Selectable; + + @override + Offset get baseOffset { + final width = _checkboxKey.currentContext + ?.findRenderObject() + ?.unwrapOrNull() + ?.size + .width; + if (width != null) { + return Offset(width, 0); + } + return Offset.zero; + } + + @override + Widget build(BuildContext context) { + if (widget.textNode.children.isEmpty) { + return _buildWithSingle(context); + } else { + return _buildWithChildren(context); + } + } + + Widget _buildWithSingle(BuildContext context) { + final check = widget.textNode.attributes.checkbox; + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + GestureDetector( + child: FlowySvg( + key: _checkboxKey, + name: check ? 'check' : 'uncheck', + ), + onTap: () { + debugPrint('[Checkbox] onTap...'); + TransactionBuilder(widget.editorState) + ..updateNode(widget.textNode, { + 'checkbox': !check, + }) + ..commit(); + }, + ), + FlowyRichText( + key: _richTextKey, + textNode: widget.textNode, + editorState: widget.editorState, + ) + ], + ); + } + + Widget _buildWithChildren(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildWithSingle(context), + Row( + children: [ + const SizedBox( + width: 20, + ), + Column( + children: widget.textNode.children + .map( + (child) => widget.editorState.renderPlugins.buildWidget( + context: NodeWidgetContext( + buildContext: context, + node: child, + editorState: widget.editorState, + ), + ), + ) + .toList(), + ) + ], + ) + ], + ); + } +} diff --git a/frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/default_selectable.dart b/frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/default_selectable.dart new file mode 100644 index 0000000000..fe8ba39730 --- /dev/null +++ b/frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/default_selectable.dart @@ -0,0 +1,28 @@ +import 'package:flowy_editor/document/position.dart'; +import 'package:flowy_editor/document/selection.dart'; +import 'package:flowy_editor/render/selection/selectable.dart'; +import 'package:flutter/material.dart'; + +mixin DefaultSelectable { + Selectable get forward; + + Offset get baseOffset; + + Position getPositionInOffset(Offset start) => + forward.getPositionInOffset(start); + + Rect getCursorRectInPosition(Position position) => + forward.getCursorRectInPosition(position).shift(baseOffset); + + List getRectsInSelection(Selection selection) => forward + .getRectsInSelection(selection) + .map((rect) => rect.shift(baseOffset)) + .toList(growable: false); + + Selection getSelectionInRange(Offset start, Offset end) => + forward.getSelectionInRange(start, end); + + Position start() => forward.start(); + + Position end() => forward.end(); +} diff --git a/frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/flowy_rich_text.dart b/frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/flowy_rich_text.dart index 66c87a2dd4..33b1ece074 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/flowy_rich_text.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/flowy_rich_text.dart @@ -32,11 +32,14 @@ class RichTextNodeWidgetBuilder extends NodeWidgetBuilder { } } +typedef FlowyTextSpanDecorator = TextSpan Function(TextSpan textSpan); + class FlowyRichText extends StatefulWidget { const FlowyRichText({ Key? key, this.cursorHeight, this.cursorWidth = 2.0, + this.textSpanDecorator, required this.textNode, required this.editorState, }) : super(key: key); @@ -45,6 +48,7 @@ class FlowyRichText extends StatefulWidget { final double cursorWidth; final TextNode textNode; final EditorState editorState; + final FlowyTextSpanDecorator? textSpanDecorator; @override State createState() => _FlowyRichTextState(); @@ -70,7 +74,7 @@ class _FlowyRichTextState extends State with Selectable { } else if (attributes.quote == true) { return _buildQuotedRichText(context); } else if (attributes.heading != null) { - return _buildHeadingRichText(context); + // return _buildHeadingRichText(context); } else if (attributes.number != null) { return _buildNumberListRichText(context); } @@ -87,14 +91,13 @@ class _FlowyRichTextState extends State with Selectable { @override Rect getCursorRectInPosition(Position position) { final textPosition = TextPosition(offset: position.offset); - final baseRect = frontWidgetRect(); final cursorOffset = _renderParagraph.getOffsetForCaret(textPosition, Rect.zero); final cursorHeight = widget.cursorHeight ?? _renderParagraph.getFullHeightForCaret(textPosition) ?? 5.0; // default height return Rect.fromLTWH( - baseRect.centerRight.dx + cursorOffset.dx - (widget.cursorWidth / 2), + cursorOffset.dx - (widget.cursorWidth / 2), cursorOffset.dy, widget.cursorWidth, cursorHeight, @@ -138,11 +141,7 @@ class _FlowyRichTextState extends State with Selectable { } Widget _buildRichText(BuildContext context) { - if (_textNode.children.isEmpty) { - return _buildSingleRichText(context); - } else { - return _buildRichTextWithChildren(context); - } + return _buildSingleRichText(context); } Widget _buildRichTextWithChildren(BuildContext context) { @@ -166,10 +165,11 @@ class _FlowyRichTextState extends State with Selectable { } Widget _buildSingleRichText(BuildContext context) { - return SizedBox( - width: - MediaQuery.of(context).size.width - 20, // FIXME: use the const value - child: RichText(key: _textKey, text: _decorateTextSpanWithGlobalStyle), + return RichText( + key: _textKey, + text: widget.textSpanDecorator != null + ? widget.textSpanDecorator!(_decorateTextSpanWithGlobalStyle) + : _decorateTextSpanWithGlobalStyle, ); } diff --git a/frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/heading_text.dart b/frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/heading_text.dart new file mode 100644 index 0000000000..ac4746b3e4 --- /dev/null +++ b/frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/heading_text.dart @@ -0,0 +1,97 @@ +import 'package:flowy_editor/document/node.dart'; +import 'package:flowy_editor/editor_state.dart'; +import 'package:flowy_editor/infra/flowy_svg.dart'; +import 'package:flowy_editor/render/node_widget_builder.dart'; +import 'package:flowy_editor/render/render_plugins.dart'; +import 'package:flowy_editor/render/rich_text/default_selectable.dart'; +import 'package:flowy_editor/render/rich_text/flowy_rich_text.dart'; +import 'package:flowy_editor/render/rich_text/rich_text_style.dart'; +import 'package:flowy_editor/render/selection/selectable.dart'; +import 'package:flowy_editor/extensions/object_extensions.dart'; +import 'package:flutter/material.dart'; + +class HeadingTextNodeWidgetBuilder extends NodeWidgetBuilder { + HeadingTextNodeWidgetBuilder.create({ + required super.editorState, + required super.node, + required super.key, + }) : super.create(); + + @override + Widget build(BuildContext context) { + return HeadingTextNodeWidget( + key: key, + textNode: node as TextNode, + editorState: editorState, + ); + } +} + +class HeadingTextNodeWidget extends StatefulWidget { + const HeadingTextNodeWidget({ + Key? key, + required this.textNode, + required this.editorState, + }) : super(key: key); + + final TextNode textNode; + final EditorState editorState; + + @override + State createState() => _HeadingTextNodeWidgetState(); +} + +// customize + +class _HeadingTextNodeWidgetState extends State + with Selectable, DefaultSelectable { + final _richTextKey = GlobalKey(debugLabel: 'heading_text'); + final topPadding = 5.0; + final bottomPadding = 2.0; + + @override + Selectable get forward => + _richTextKey.currentState as Selectable; + + @override + Offset get baseOffset { + return Offset(0, topPadding); + } + + @override + Widget build(BuildContext context) { + return Column( + children: [ + SizedBox( + height: topPadding, + ), + FlowyRichText( + key: _richTextKey, + textSpanDecorator: _textSpanDecorator, + textNode: widget.textNode, + editorState: widget.editorState, + ), + SizedBox( + height: bottomPadding, + ), + ], + ); + } + + TextSpan _textSpanDecorator(TextSpan textSpan) { + return TextSpan( + children: textSpan.children + ?.whereType() + .map( + (span) => TextSpan( + text: span.text, + style: span.style?.copyWith( + fontSize: widget.textNode.attributes.fontSize, + ), + recognizer: span.recognizer, + ), + ) + .toList(), + ); + } +} diff --git a/frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/rich_text_style.dart b/frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/rich_text_style.dart index b4100f9b87..3c11feb20d 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/rich_text_style.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/rich_text_style.dart @@ -25,12 +25,15 @@ class StyleKey { static String font = 'font'; static String href = 'href'; - static String heading = 'heading'; static String quote = 'quote'; static String list = 'list'; static String number = 'number'; static String todo = 'todo'; static String code = 'code'; + + static String subtype = 'subtype'; + static String checkbox = 'checkbox'; + static String heading = 'heading'; } double baseFontSize = 16.0; @@ -100,6 +103,13 @@ extension NodeAttributesExtensions on Attributes { } return false; } + + bool get checkbox { + if (containsKey(StyleKey.checkbox) && this[StyleKey.checkbox] is bool) { + return this[StyleKey.checkbox]; + } + return false; + } } extension DeltaAttributesExtensions on Attributes { diff --git a/frontend/app_flowy/packages/flowy_editor/lib/service/editor_service.dart b/frontend/app_flowy/packages/flowy_editor/lib/service/editor_service.dart index d5223ec36a..68f08ac4ce 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/service/editor_service.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/service/editor_service.dart @@ -23,7 +23,7 @@ class FlowyEditor extends StatefulWidget { final EditorState editorState; final List keyEventHandlers; - /// Shortcusts + /// shortcuts final FloatingShortcuts shortcuts; @override diff --git a/frontend/app_flowy/packages/flowy_editor/lib/service/selection_service.dart b/frontend/app_flowy/packages/flowy_editor/lib/service/selection_service.dart index 07cf2ad902..878fac28b4 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/service/selection_service.dart +++ b/frontend/app_flowy/packages/flowy_editor/lib/service/selection_service.dart @@ -165,7 +165,7 @@ class _FlowySelectionState extends State (recognizer) { recognizer.onTapDown = _onTapDown; }, - ) + ), }, child: widget.child, );