Sfoglia il codice sorgente

feat: add checkbox and heading style

Lucas.Xu 2 anni fa
parent
commit
734b642fcc

+ 1 - 1
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 }
           }
         ],

+ 62 - 0
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"
         }
       },

+ 4 - 0
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;
   }
 

+ 132 - 0
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<CheckboxNodeWidget> createState() => _CheckboxNodeWidgetState();
+}
+
+class _CheckboxNodeWidgetState extends State<CheckboxNodeWidget>
+    with Selectable, DefaultSelectable {
+  final _checkboxKey = GlobalKey(debugLabel: 'checkbox');
+  final _richTextKey = GlobalKey(debugLabel: 'checkbox_text');
+
+  @override
+  Selectable<StatefulWidget> get forward =>
+      _richTextKey.currentState as Selectable;
+
+  @override
+  Offset get baseOffset {
+    final width = _checkboxKey.currentContext
+        ?.findRenderObject()
+        ?.unwrapOrNull<RenderBox>()
+        ?.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(),
+            )
+          ],
+        )
+      ],
+    );
+  }
+}

+ 28 - 0
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<Rect> 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();
+}

+ 12 - 12
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<FlowyRichText> createState() => _FlowyRichTextState();
@@ -70,7 +74,7 @@ class _FlowyRichTextState extends State<FlowyRichText> 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<FlowyRichText> 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<FlowyRichText> 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<FlowyRichText> 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,
     );
   }
 

+ 97 - 0
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<HeadingTextNodeWidget> createState() => _HeadingTextNodeWidgetState();
+}
+
+// customize
+
+class _HeadingTextNodeWidgetState extends State<HeadingTextNodeWidget>
+    with Selectable, DefaultSelectable {
+  final _richTextKey = GlobalKey(debugLabel: 'heading_text');
+  final topPadding = 5.0;
+  final bottomPadding = 2.0;
+
+  @override
+  Selectable<StatefulWidget> 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<TextSpan>()
+          .map(
+            (span) => TextSpan(
+              text: span.text,
+              style: span.style?.copyWith(
+                fontSize: widget.textNode.attributes.fontSize,
+              ),
+              recognizer: span.recognizer,
+            ),
+          )
+          .toList(),
+    );
+  }
+}

+ 11 - 1
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 {

+ 1 - 1
frontend/app_flowy/packages/flowy_editor/lib/service/editor_service.dart

@@ -23,7 +23,7 @@ class FlowyEditor extends StatefulWidget {
   final EditorState editorState;
   final List<FlowyKeyEventHandler> keyEventHandlers;
 
-  /// Shortcusts
+  /// shortcuts
   final FloatingShortcuts shortcuts;
 
   @override

+ 1 - 1
frontend/app_flowy/packages/flowy_editor/lib/service/selection_service.dart

@@ -165,7 +165,7 @@ class _FlowySelectionState extends State<FlowySelection>
           (recognizer) {
             recognizer.onTapDown = _onTapDown;
           },
-        )
+        ),
       },
       child: widget.child,
     );