diff --git a/frontend/app_flowy/packages/flowy_editor/lib/infra/flowy_svg.dart b/frontend/app_flowy/packages/flowy_editor/lib/infra/flowy_svg.dart index d38fe2d16d..d40a198b1a 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/infra/flowy_svg.dart +++ b/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, diff --git a/frontend/app_flowy/packages/flowy_editor/lib/operation/transaction_builder.dart b/frontend/app_flowy/packages/flowy_editor/lib/operation/transaction_builder.dart index 8fa67687c2..cececec924 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/operation/transaction_builder.dart +++ b/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( diff --git a/frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/bulleted_list_text.dart b/frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/bulleted_list_text.dart index b962f63f3d..2607be26ed 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/bulleted_list_text.dart +++ b/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 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 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, + ), + ), + ], + ), ), ); } 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 index e5b02eb32d..073c339ed6 100644 --- 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 @@ -41,19 +41,17 @@ class CheckboxNodeWidget extends StatefulWidget { class _CheckboxNodeWidgetState extends State 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 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 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) { 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 index e218fdcaf6..7ea93509c1 100644 --- 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 @@ -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); 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 c83ef5c31a..a041d179b9 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 @@ -41,6 +41,8 @@ class _FlowyRichTextState extends State 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 with Selectable { ? Colors.transparent : Colors.grey, fontSize: baseFontSize, + height: lineHeight, ), ), ], @@ -200,6 +203,7 @@ class _FlowyRichTextState extends State with Selectable { .map((insert) => RichTextStyle( attributes: insert.attributes ?? {}, text: insert.content, + height: lineHeight, ).toTextSpan()) .toList(growable: false), ); 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 index f74064fac6..c010ad4833 100644 --- 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 @@ -41,9 +41,11 @@ class HeadingTextNodeWidget extends StatefulWidget { class _HeadingTextNodeWidgetState extends State 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 get forward => @@ -51,18 +53,18 @@ class _HeadingTextNodeWidgetState extends State @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', diff --git a/frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/number_list_text.dart b/frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/number_list_text.dart index 65b41e8e9b..4ffd587470 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/number_list_text.dart +++ b/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 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 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, + 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, + ), + ), + ], ), - Expanded( - child: FlowyRichText( - key: _richTextKey, - placeholderText: 'List', - textNode: widget.textNode, - editorState: widget.editorState, - ), - ), - ], - ), - ); + )); } } diff --git a/frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/quoted_text.dart b/frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/quoted_text.dart index 0bb259de14..09004f7f9d 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/quoted_text.dart +++ b/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 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 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', + 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, + ), + ), + ], ), - 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; } } diff --git a/frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/rich_text.dart b/frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/rich_text.dart index b6d79a2358..bfb4c217a7 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/render/rich_text/rich_text.dart +++ b/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 with Selectable, DefaultSelectable { + @override + GlobalKey? get iconKey => null; + final _richTextKey = GlobalKey(debugLabel: 'rich_text'); - final leftPadding = 20.0; @override Selectable 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, + ), ), ); } 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 2fb12d68ac..c44fd8dac1 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 @@ -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 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 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() { + 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(double? height) { return TextSpan( text: text, style: TextStyle( @@ -191,6 +215,7 @@ class RichTextStyle { color: _textColor, decoration: _textDecoration, background: _background, + height: height, ), recognizer: _recognizer, ); diff --git a/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/enter_without_shift_in_text_node_handler.dart b/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/enter_without_shift_in_text_node_handler.dart index d2d3b14450..51e593a20b 100644 --- a/frontend/app_flowy/packages/flowy_editor/lib/service/internal_key_event_handlers/enter_without_shift_in_text_node_handler.dart +++ b/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), ), ) 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 82088561cb..73a66dbc7a 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 @@ -326,6 +326,9 @@ class _FlowySelectionState extends State 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 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 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 /// 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); }