Procházet zdrojové kódy

Merge pull request #801 from LucasXu0/feat/text_style

Feat/text style
Nathan.fooo před 2 roky
rodič
revize
7f249ebae2

+ 9 - 0
frontend/app_flowy/packages/flowy_editor/lib/infra/flowy_svg.dart

@@ -8,15 +8,24 @@ class FlowySvg extends StatelessWidget {
     this.size = const Size(20, 20),
     this.color,
     this.number,
+    this.padding,
   }) : super(key: key);
 
   final String? name;
   final Size size;
   final Color? color;
   final int? number;
+  final EdgeInsets? padding;
 
   @override
   Widget build(BuildContext context) {
+    return Padding(
+      padding: padding ?? const EdgeInsets.all(0),
+      child: _buildSvg(),
+    );
+  }
+
+  Widget _buildSvg() {
     if (name != null) {
       return SizedBox.fromSize(
         size: size,

+ 26 - 3
frontend/app_flowy/packages/flowy_editor/lib/operation/transaction_builder.dart

@@ -1,4 +1,5 @@
 import 'dart:collection';
+import 'dart:math';
 
 import 'package:flowy_editor/document/attributes.dart';
 import 'package:flowy_editor/document/node.dart';
@@ -105,7 +106,21 @@ class TransactionBuilder {
 
   insertText(TextNode node, int index, String content,
       [Attributes? attributes]) {
-    textEdit(node, () => Delta().retain(index).insert(content, attributes));
+    var newAttributes = attributes;
+    if (index != 0 && attributes == null) {
+      newAttributes = node.delta
+          .slice(max(index - 1, 0), index)
+          .operations
+          .first
+          .attributes;
+    }
+    textEdit(
+      node,
+      () => Delta().retain(index).insert(
+            content,
+            newAttributes,
+          ),
+    );
     afterSelection = Selection.collapsed(
         Position(path: node.path, offset: index + content.length));
   }
@@ -121,10 +136,18 @@ class TransactionBuilder {
         Selection.collapsed(Position(path: node.path, offset: index));
   }
 
-  replaceText(TextNode node, int index, int length, String content) {
+  replaceText(TextNode node, int index, int length, String content,
+      [Attributes? attributes]) {
+    var newAttributes = attributes;
+    if (attributes == null) {
+      final ops = node.delta.slice(index, index + length).operations;
+      if (ops.isNotEmpty) {
+        newAttributes = ops.first.attributes;
+      }
+    }
     textEdit(
       node,
-      () => Delta().retain(index).delete(length).insert(content),
+      () => Delta().retain(index).delete(length).insert(content, newAttributes),
     );
     afterSelection = Selection.collapsed(
       Position(

+ 29 - 22
frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/bulleted_list_text.dart

@@ -43,38 +43,45 @@ class BulletedListTextNodeWidget extends StatefulWidget {
 
 class _BulletedListTextNodeWidgetState extends State<BulletedListTextNodeWidget>
     with Selectable, DefaultSelectable {
+  @override
+  final iconKey = GlobalKey();
+
   final _richTextKey = GlobalKey(debugLabel: 'bulleted_list_text');
-  final leftPadding = 20.0;
+  final _iconSize = 20.0;
+  final _iconRightPadding = 5.0;
 
   @override
   Selectable<StatefulWidget> get forward =>
       _richTextKey.currentState as Selectable;
 
-  @override
-  Offset get baseOffset {
-    return Offset(leftPadding, 0);
-  }
-
   @override
   Widget build(BuildContext context) {
+    final topPadding = RichTextStyle.fromTextNode(widget.textNode).topPadding;
+
     return SizedBox(
-      width: maxTextNodeWidth,
-      child: Row(
-        crossAxisAlignment: CrossAxisAlignment.start,
-        children: [
-          FlowySvg(
-            size: Size.square(leftPadding),
-            name: 'point',
-          ),
-          Expanded(
-            child: FlowyRichText(
-              key: _richTextKey,
-              placeholderText: 'List',
-              textNode: widget.textNode,
-              editorState: widget.editorState,
+      width: defaultMaxTextNodeWidth,
+      child: Padding(
+        padding: EdgeInsets.only(bottom: defaultLinePadding),
+        child: Row(
+          crossAxisAlignment: CrossAxisAlignment.start,
+          children: [
+            FlowySvg(
+              key: iconKey,
+              size: Size.square(_iconSize),
+              padding:
+                  EdgeInsets.only(top: topPadding, right: _iconRightPadding),
+              name: 'point',
+            ),
+            Expanded(
+              child: FlowyRichText(
+                key: _richTextKey,
+                placeholderText: 'List',
+                textNode: widget.textNode,
+                editorState: widget.editorState,
+              ),
             ),
-          ),
-        ],
+          ],
+        ),
       ),
     );
   }

+ 41 - 36
frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/checkbox_text.dart

@@ -41,19 +41,17 @@ class CheckboxNodeWidget extends StatefulWidget {
 
 class _CheckboxNodeWidgetState extends State<CheckboxNodeWidget>
     with Selectable, DefaultSelectable {
-  final _richTextKey = GlobalKey(debugLabel: 'checkbox_text');
+  @override
+  final iconKey = GlobalKey();
 
-  final leftPadding = 20.0;
+  final _richTextKey = GlobalKey(debugLabel: 'checkbox_text');
+  final _iconSize = 20.0;
+  final _iconRightPadding = 5.0;
 
   @override
   Selectable<StatefulWidget> get forward =>
       _richTextKey.currentState as Selectable;
 
-  @override
-  Offset get baseOffset {
-    return Offset(leftPadding, 0);
-  }
-
   @override
   Widget build(BuildContext context) {
     if (widget.textNode.children.isEmpty) {
@@ -65,37 +63,44 @@ class _CheckboxNodeWidgetState extends State<CheckboxNodeWidget>
 
   Widget _buildWithSingle(BuildContext context) {
     final check = widget.textNode.attributes.check;
+    final topPadding = RichTextStyle.fromTextNode(widget.textNode).topPadding;
     return SizedBox(
-      width: maxTextNodeWidth,
-      child: Row(
-        crossAxisAlignment: CrossAxisAlignment.start,
-        children: [
-          GestureDetector(
-            child: FlowySvg(
-              size: Size.square(leftPadding),
-              name: check ? 'check' : 'uncheck',
-            ),
-            onTap: () {
-              debugPrint('[Checkbox] onTap...');
-              TransactionBuilder(widget.editorState)
-                ..updateNode(widget.textNode, {
-                  StyleKey.checkbox: !check,
-                })
-                ..commit();
-            },
+        width: defaultMaxTextNodeWidth,
+        child: Padding(
+          padding: EdgeInsets.only(bottom: defaultLinePadding),
+          child: Row(
+            crossAxisAlignment: CrossAxisAlignment.start,
+            children: [
+              GestureDetector(
+                child: FlowySvg(
+                  key: iconKey,
+                  size: Size.square(_iconSize),
+                  padding: EdgeInsets.only(
+                      top: topPadding, right: _iconRightPadding),
+                  name: check ? 'check' : 'uncheck',
+                ),
+                onTap: () {
+                  debugPrint('[Checkbox] onTap...');
+                  TransactionBuilder(widget.editorState)
+                    ..updateNode(widget.textNode, {
+                      StyleKey.checkbox: !check,
+                    })
+                    ..commit();
+                },
+              ),
+              Expanded(
+                child: FlowyRichText(
+                  key: _richTextKey,
+                  placeholderText: 'To-do',
+                  textNode: widget.textNode,
+                  textSpanDecorator: _textSpanDecorator,
+                  placeholderTextSpanDecorator: _textSpanDecorator,
+                  editorState: widget.editorState,
+                ),
+              ),
+            ],
           ),
-          Expanded(
-            child: FlowyRichText(
-              key: _richTextKey,
-              placeholderText: 'To-do',
-              textNode: widget.textNode,
-              textSpanDecorator: _textSpanDecorator,
-              editorState: widget.editorState,
-            ),
-          ),
-        ],
-      ),
-    );
+        ));
   }
 
   Widget _buildWithChildren(BuildContext context) {

+ 11 - 1
frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/default_selectable.dart

@@ -6,7 +6,17 @@ import 'package:flutter/material.dart';
 mixin DefaultSelectable {
   Selectable get forward;
 
-  Offset get baseOffset;
+  GlobalKey? get iconKey;
+
+  Offset get baseOffset {
+    if (iconKey != null) {
+      final renderBox = iconKey!.currentContext?.findRenderObject();
+      if (renderBox is RenderBox) {
+        return Offset(renderBox.size.width, 0);
+      }
+    }
+    return Offset.zero;
+  }
 
   Position getPositionInOffset(Offset start) =>
       forward.getPositionInOffset(start);

+ 4 - 0
frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/flowy_rich_text.dart

@@ -41,6 +41,8 @@ class _FlowyRichTextState extends State<FlowyRichText> with Selectable {
   final _textKey = GlobalKey();
   final _placeholderTextKey = GlobalKey();
 
+  final lineHeight = 1.5;
+
   RenderParagraph get _renderParagraph =>
       _textKey.currentContext?.findRenderObject() as RenderParagraph;
 
@@ -145,6 +147,7 @@ class _FlowyRichTextState extends State<FlowyRichText> with Selectable {
                 ? Colors.transparent
                 : Colors.grey,
             fontSize: baseFontSize,
+            height: lineHeight,
           ),
         ),
       ],
@@ -200,6 +203,7 @@ class _FlowyRichTextState extends State<FlowyRichText> with Selectable {
             .map((insert) => RichTextStyle(
                   attributes: insert.attributes ?? {},
                   text: insert.content,
+                  height: lineHeight,
                 ).toTextSpan())
             .toList(growable: false),
       );

+ 12 - 10
frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/heading_text.dart

@@ -41,9 +41,11 @@ class HeadingTextNodeWidget extends StatefulWidget {
 
 class _HeadingTextNodeWidgetState extends State<HeadingTextNodeWidget>
     with Selectable, DefaultSelectable {
+  @override
+  GlobalKey? get iconKey => null;
+
   final _richTextKey = GlobalKey(debugLabel: 'heading_text');
-  final topPadding = 5.0;
-  final bottomPadding = 2.0;
+  final _topPadding = 5.0;
 
   @override
   Selectable<StatefulWidget> get forward =>
@@ -51,18 +53,18 @@ class _HeadingTextNodeWidgetState extends State<HeadingTextNodeWidget>
 
   @override
   Offset get baseOffset {
-    return Offset(0, topPadding);
+    return Offset(0, _topPadding);
   }
 
   @override
   Widget build(BuildContext context) {
-    return SizedBox(
-      width: maxTextNodeWidth,
-      child: Padding(
-        padding: EdgeInsets.only(
-          top: topPadding,
-          bottom: bottomPadding,
-        ),
+    return Padding(
+      padding: EdgeInsets.only(
+        top: _topPadding,
+        bottom: defaultLinePadding,
+      ),
+      child: SizedBox(
+        width: defaultMaxTextNodeWidth,
         child: FlowyRichText(
           key: _richTextKey,
           placeholderText: 'Heading',

+ 30 - 25
frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/number_list_text.dart

@@ -43,39 +43,44 @@ class NumberListTextNodeWidget extends StatefulWidget {
 
 class _NumberListTextNodeWidgetState extends State<NumberListTextNodeWidget>
     with Selectable, DefaultSelectable {
+  @override
+  final iconKey = GlobalKey();
+
   final _richTextKey = GlobalKey(debugLabel: 'number_list_text');
-  final leftPadding = 20.0;
+  final _iconSize = 20.0;
+  final _iconRightPadding = 5.0;
 
   @override
   Selectable<StatefulWidget> get forward =>
       _richTextKey.currentState as Selectable;
 
-  @override
-  Offset get baseOffset {
-    return Offset(leftPadding, 0);
-  }
-
   @override
   Widget build(BuildContext context) {
-    return SizedBox(
-      width: maxTextNodeWidth,
-      child: Row(
-        crossAxisAlignment: CrossAxisAlignment.start,
-        children: [
-          FlowySvg(
-            size: Size.square(leftPadding),
-            number: widget.textNode.attributes.number,
-          ),
-          Expanded(
-            child: FlowyRichText(
-              key: _richTextKey,
-              placeholderText: 'List',
-              textNode: widget.textNode,
-              editorState: widget.editorState,
-            ),
+    final topPadding = RichTextStyle.fromTextNode(widget.textNode).topPadding;
+    return Padding(
+        padding: EdgeInsets.only(bottom: defaultLinePadding),
+        child: SizedBox(
+          width: defaultMaxTextNodeWidth,
+          child: Row(
+            crossAxisAlignment: CrossAxisAlignment.start,
+            children: [
+              FlowySvg(
+                key: iconKey,
+                size: Size.square(_iconSize),
+                padding:
+                    EdgeInsets.only(top: topPadding, right: _iconRightPadding),
+                number: widget.textNode.attributes.number,
+              ),
+              Expanded(
+                child: FlowyRichText(
+                  key: _richTextKey,
+                  placeholderText: 'List',
+                  textNode: widget.textNode,
+                  editorState: widget.editorState,
+                ),
+              ),
+            ],
           ),
-        ],
-      ),
-    );
+        ));
   }
 }

+ 30 - 28
frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/quoted_text.dart

@@ -42,48 +42,50 @@ class QuotedTextNodeWidget extends StatefulWidget {
 
 class _QuotedTextNodeWidgetState extends State<QuotedTextNodeWidget>
     with Selectable, DefaultSelectable {
+  @override
+  final iconKey = GlobalKey();
+
   final _richTextKey = GlobalKey(debugLabel: 'quoted_text');
-  final leftPadding = 20.0;
+  final _iconSize = 20.0;
+  final _iconRightPadding = 5.0;
 
   @override
   Selectable<StatefulWidget> get forward =>
       _richTextKey.currentState as Selectable;
 
-  @override
-  Offset get baseOffset {
-    return Offset(leftPadding, 0);
-  }
-
   @override
   Widget build(BuildContext context) {
+    final topPadding = RichTextStyle.fromTextNode(widget.textNode).topPadding;
     return SizedBox(
-      width: maxTextNodeWidth,
-      child: Row(
-        crossAxisAlignment: CrossAxisAlignment.start,
-        children: [
-          FlowySvg(
-            size: Size(
-              leftPadding,
-              _quoteHeight,
-            ),
-            name: 'quote',
-          ),
-          Expanded(
-            child: FlowyRichText(
-              key: _richTextKey,
-              placeholderText: 'Quote',
-              textNode: widget.textNode,
-              editorState: widget.editorState,
-            ),
+        width: defaultMaxTextNodeWidth,
+        child: Padding(
+          padding: EdgeInsets.only(bottom: defaultLinePadding),
+          child: Row(
+            crossAxisAlignment: CrossAxisAlignment.start,
+            children: [
+              FlowySvg(
+                key: iconKey,
+                size: Size(_iconSize, _quoteHeight),
+                padding:
+                    EdgeInsets.only(top: topPadding, right: _iconRightPadding),
+                name: 'quote',
+              ),
+              Expanded(
+                child: FlowyRichText(
+                  key: _richTextKey,
+                  placeholderText: 'Quote',
+                  textNode: widget.textNode,
+                  editorState: widget.editorState,
+                ),
+              ),
+            ],
           ),
-        ],
-      ),
-    );
+        ));
   }
 
   double get _quoteHeight {
     final lines =
         widget.textNode.toRawString().characters.where((c) => c == '\n').length;
-    return (lines + 1) * leftPadding;
+    return (lines + 1) * _iconSize;
   }
 }

+ 11 - 11
frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/rich_text.dart

@@ -42,26 +42,26 @@ class RichTextNodeWidget extends StatefulWidget {
 
 class _RichTextNodeWidgetState extends State<RichTextNodeWidget>
     with Selectable, DefaultSelectable {
+  @override
+  GlobalKey? get iconKey => null;
+
   final _richTextKey = GlobalKey(debugLabel: 'rich_text');
-  final leftPadding = 20.0;
 
   @override
   Selectable<StatefulWidget> get forward =>
       _richTextKey.currentState as Selectable;
 
-  @override
-  Offset get baseOffset {
-    return Offset.zero;
-  }
-
   @override
   Widget build(BuildContext context) {
     return SizedBox(
-      width: maxTextNodeWidth,
-      child: FlowyRichText(
-        key: _richTextKey,
-        textNode: widget.textNode,
-        editorState: widget.editorState,
+      width: defaultMaxTextNodeWidth,
+      child: Padding(
+        padding: EdgeInsets.only(bottom: defaultLinePadding),
+        child: FlowyRichText(
+          key: _richTextKey,
+          textNode: widget.textNode,
+          editorState: widget.editorState,
+        ),
       ),
     );
   }

+ 27 - 2
frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/rich_text_style.dart

@@ -1,4 +1,5 @@
 import 'package:flowy_editor/document/attributes.dart';
+import 'package:flowy_editor/document/node.dart';
 import 'package:flutter/gestures.dart';
 import 'package:flutter/material.dart';
 
@@ -50,6 +51,7 @@ class StyleKey {
   ];
 
   static List<String> globalStyleKeys = [
+    StyleKey.subtype,
     StyleKey.heading,
     StyleKey.checkbox,
     StyleKey.bulletedList,
@@ -60,7 +62,8 @@ class StyleKey {
 }
 
 // TODO: customize
-double maxTextNodeWidth = 780.0;
+double defaultMaxTextNodeWidth = 780.0;
+double defaultLinePadding = 8.0;
 double baseFontSize = 16.0;
 // TODO: customize.
 Map<String, double> headingToFontSize = {
@@ -176,12 +179,33 @@ class RichTextStyle {
   RichTextStyle({
     required this.attributes,
     required this.text,
+    this.height = 1.5,
   });
 
+  RichTextStyle.fromTextNode(TextNode textNode)
+      : this(attributes: textNode.attributes, text: textNode.toRawString());
+
   final Attributes attributes;
   final String text;
+  final double height;
+
+  TextSpan toTextSpan() => _toTextSpan(height);
+
+  double get topPadding {
+    if (height == 1.0) {
+      return 0;
+    }
+    // TODO: Need to be optimized.
+    final painter =
+        TextPainter(text: _toTextSpan(height), textDirection: TextDirection.ltr)
+          ..layout();
+    final basePainter =
+        TextPainter(text: _toTextSpan(null), textDirection: TextDirection.ltr)
+          ..layout();
+    return painter.height - basePainter.height;
+  }
 
-  TextSpan toTextSpan() {
+  TextSpan _toTextSpan(double? height) {
     return TextSpan(
       text: text,
       style: TextStyle(
@@ -191,6 +215,7 @@ class RichTextStyle {
         color: _textColor,
         decoration: _textDecoration,
         background: _background,
+        height: height,
       ),
       recognizer: _recognizer,
     );

+ 33 - 12
frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/enter_without_shift_in_text_node_handler.dart

@@ -67,16 +67,31 @@ FlowyKeyEventHandler enterWithoutShiftInTextNodesHandler =
   // If selection is collapsed and position.start.offset == 0,
   //  insert a empty text node before.
   if (selection.isCollapsed && selection.start.offset == 0) {
-    final afterSelection = Selection.collapsed(
-      Position(path: textNode.path.next, offset: 0),
-    );
-    TransactionBuilder(editorState)
-      ..insertNode(
-        textNode.path,
-        TextNode.empty(),
-      )
-      ..afterSelection = afterSelection
-      ..commit();
+    if (textNode.toRawString().isEmpty) {
+      final afterSelection = Selection.collapsed(
+        Position(path: textNode.path, offset: 0),
+      );
+      TransactionBuilder(editorState)
+        ..updateNode(
+            textNode,
+            Attributes.fromIterable(
+              StyleKey.globalStyleKeys,
+              value: (_) => null,
+            ))
+        ..afterSelection = afterSelection
+        ..commit();
+    } else {
+      final afterSelection = Selection.collapsed(
+        Position(path: textNode.path.next, offset: 0),
+      );
+      TransactionBuilder(editorState)
+        ..insertNode(
+          textNode.path,
+          TextNode.empty(),
+        )
+        ..afterSelection = afterSelection
+        ..commit();
+    }
     return KeyEventResult.handled;
   }
 
@@ -85,6 +100,13 @@ FlowyKeyEventHandler enterWithoutShiftInTextNodesHandler =
   final needCopyAttributes = StyleKey.globalStyleKeys
       .where((key) => key != StyleKey.heading)
       .contains(textNode.subtype);
+  Attributes attributes = {};
+  if (needCopyAttributes) {
+    attributes = Attributes.from(textNode.attributes);
+    if (attributes.check) {
+      attributes[StyleKey.checkbox] = false;
+    }
+  }
   final afterSelection = Selection.collapsed(
     Position(path: textNode.path.next, offset: 0),
   );
@@ -92,8 +114,7 @@ FlowyKeyEventHandler enterWithoutShiftInTextNodesHandler =
     ..insertNode(
       textNode.path.next,
       textNode.copyWith(
-        attributes:
-            needCopyAttributes ? Attributes.from(textNode.attributes) : {},
+        attributes: attributes,
         delta: textNode.delta.slice(selection.end.offset),
       ),
     )

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

@@ -326,6 +326,9 @@ class _FlowySelectionState extends State<FlowySelection>
       return;
     }
 
+    editorState.service.keyboardService?.enable();
+    editorState.service.scrollService?.enable();
+
     panEndOffset = details.globalPosition;
     final dy = editorState.service.scrollService?.dy;
     var panStartOffsetWithScrollDyGap = panStartOffset!;
@@ -356,9 +359,10 @@ class _FlowySelectionState extends State<FlowySelection>
           start: isDownward ? start : end, end: isDownward ? end : start);
       debugPrint('[_onPanUpdate] isDownward = $isDownward, $selection');
       editorState.updateCursorSelection(selection);
+
+      _scrollUpOrDownIfNeeded(panEndOffset!, isDownward);
     }
 
-    _scrollUpOrDownIfNeeded(panEndOffset!);
     _showDebugLayerIfNeeded();
   }
 
@@ -483,7 +487,7 @@ class _FlowySelectionState extends State<FlowySelection>
     return NodeIterator(stateTree, startNode, endNode).toList();
   }
 
-  void _scrollUpOrDownIfNeeded(Offset offset) {
+  void _scrollUpOrDownIfNeeded(Offset offset, bool isDownward) {
     final dy = editorState.service.scrollService?.dy;
     if (dy == null) {
       assert(false, 'Dy could not be null');
@@ -495,10 +499,10 @@ class _FlowySelectionState extends State<FlowySelection>
     /// TODO: It is necessary to calculate the relative speed
     ///   according to the gap and move forward more gently.
     final distance = 10.0;
-    if (offset.dy <= topLimit) {
+    if (offset.dy <= topLimit && !isDownward) {
       // up
       editorState.service.scrollService?.scrollTo(dy - distance);
-    } else if (offset.dy >= bottomLimit) {
+    } else if (offset.dy >= bottomLimit && isDownward) {
       //down
       editorState.service.scrollService?.scrollTo(dy + distance);
     }