Explorar o código

Merge pull request #813 from LucasXu0/fix/#811

#811, #814, #818
Lucas.Xu %!s(int64=2) %!d(string=hai) anos
pai
achega
42fe2f675a

+ 3 - 6
frontend/app_flowy/packages/flowy_editor/example/lib/main.dart

@@ -21,7 +21,6 @@ class MyApp extends StatelessWidget {
   @override
   Widget build(BuildContext context) {
     return MaterialApp(
-      title: 'Flutter Demo',
       theme: ThemeData(
         // This is the theme of your application.
         //
@@ -64,12 +63,10 @@ class _MyHomePageState extends State<MyHomePage> {
   @override
   Widget build(BuildContext context) {
     return Scaffold(
-      appBar: AppBar(
-        // Here we take the value from the MyHomePage object that was created by
-        // the App.build method, and use it to set our appbar title.
-        title: Text(widget.title),
+      body: Container(
+        alignment: Alignment.topCenter,
+        child: _buildBody(),
       ),
-      body: _buildBody(),
       floatingActionButton: _buildExpandableFab(),
     );
   }

+ 2 - 3
frontend/app_flowy/packages/flowy_editor/lib/src/document/node.dart

@@ -1,7 +1,6 @@
 import 'dart:collection';
 import 'package:flowy_editor/src/document/path.dart';
 import 'package:flowy_editor/src/document/text_delta.dart';
-import 'package:flowy_editor/src/operation/operation.dart';
 import 'package:flutter/material.dart';
 import './attributes.dart';
 
@@ -182,12 +181,12 @@ class TextNode extends Node {
   })  : _delta = delta,
         super(children: children ?? LinkedList(), attributes: attributes ?? {});
 
-  TextNode.empty()
+  TextNode.empty({Attributes? attributes})
       : _delta = Delta([TextInsert('')]),
         super(
           type: 'text',
           children: LinkedList(),
-          attributes: {},
+          attributes: attributes ?? {},
         );
 
   Delta get delta {

+ 27 - 22
frontend/app_flowy/packages/flowy_editor/lib/src/render/rich_text/flowy_rich_text.dart

@@ -1,3 +1,5 @@
+import 'dart:ui';
+
 import 'package:flutter/material.dart';
 import 'package:flutter/rendering.dart';
 
@@ -17,7 +19,7 @@ class FlowyRichText extends StatefulWidget {
   const FlowyRichText({
     Key? key,
     this.cursorHeight,
-    this.cursorWidth = 2.0,
+    this.cursorWidth = 1.0,
     this.textSpanDecorator,
     this.placeholderText = ' ',
     this.placeholderTextSpanDecorator,
@@ -41,7 +43,7 @@ class _FlowyRichTextState extends State<FlowyRichText> with Selectable {
   final _textKey = GlobalKey();
   final _placeholderTextKey = GlobalKey();
 
-  final lineHeight = 1.5;
+  final _lineHeight = 1.5;
 
   RenderParagraph get _renderParagraph =>
       _textKey.currentContext?.findRenderObject() as RenderParagraph;
@@ -69,13 +71,15 @@ class _FlowyRichTextState extends State<FlowyRichText> with Selectable {
     final cursorHeight = widget.cursorHeight ??
         _renderParagraph.getFullHeightForCaret(textPosition) ??
         _placeholderRenderParagraph.getFullHeightForCaret(textPosition) ??
-        18.0; // default height
-    return Rect.fromLTWH(
+        16.0; // default height
+
+    final rect = Rect.fromLTWH(
       cursorOffset.dx - (widget.cursorWidth / 2),
       cursorOffset.dy,
       widget.cursorWidth,
       cursorHeight,
     );
+    return rect;
   }
 
   @override
@@ -105,7 +109,7 @@ class _FlowyRichTextState extends State<FlowyRichText> with Selectable {
       extentOffset: selection.end.offset,
     );
     return _renderParagraph
-        .getBoxesForSelection(textSelection)
+        .getBoxesForSelection(textSelection, boxHeightStyle: BoxHeightStyle.max)
         .map((box) => box.toRect())
         .toList();
   }
@@ -138,24 +142,13 @@ class _FlowyRichTextState extends State<FlowyRichText> with Selectable {
   }
 
   Widget _buildPlaceholderText(BuildContext context) {
-    final textSpan = TextSpan(
-      children: [
-        TextSpan(
-          text: widget.placeholderText,
-          style: TextStyle(
-            color: widget.textNode.toRawString().isNotEmpty
-                ? Colors.transparent
-                : Colors.grey,
-            fontSize: baseFontSize,
-            height: lineHeight,
-          ),
-        ),
-      ],
-    );
+    final textSpan = _placeholderTextSpan;
     return RichText(
       key: _placeholderTextKey,
-      text: widget.placeholderTextSpanDecorator != null
-          ? widget.placeholderTextSpanDecorator!(textSpan)
+      textHeightBehavior: const TextHeightBehavior(
+          applyHeightToFirstAscent: false, applyHeightToLastDescent: false),
+      text: widget.textSpanDecorator != null
+          ? widget.textSpanDecorator!(textSpan)
           : textSpan,
     );
   }
@@ -164,6 +157,8 @@ class _FlowyRichTextState extends State<FlowyRichText> with Selectable {
     final textSpan = _textSpan;
     return RichText(
       key: _textKey,
+      textHeightBehavior: const TextHeightBehavior(
+          applyHeightToFirstAscent: false, applyHeightToLastDescent: false),
       text: widget.textSpanDecorator != null
           ? widget.textSpanDecorator!(textSpan)
           : textSpan,
@@ -203,8 +198,18 @@ class _FlowyRichTextState extends State<FlowyRichText> with Selectable {
             .map((insert) => RichTextStyle(
                   attributes: insert.attributes ?? {},
                   text: insert.content,
-                  height: lineHeight,
+                  height: _lineHeight,
                 ).toTextSpan())
             .toList(growable: false),
       );
+
+  TextSpan get _placeholderTextSpan => TextSpan(children: [
+        RichTextStyle(
+          text: widget.placeholderText,
+          attributes: {
+            StyleKey.color: '0xFF707070',
+          },
+          height: _lineHeight,
+        ).toTextSpan()
+      ]);
 }

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

@@ -192,17 +192,7 @@ class RichTextStyle {
   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;
+    return 0;
   }
 
   TextSpan _toTextSpan(double? height) {

+ 55 - 0
frontend/app_flowy/packages/flowy_editor/lib/src/service/default_text_operations/format_rich_text_style.dart

@@ -4,9 +4,64 @@ import 'package:flowy_editor/src/document/position.dart';
 import 'package:flowy_editor/src/document/selection.dart';
 import 'package:flowy_editor/src/editor_state.dart';
 import 'package:flowy_editor/src/extensions/text_node_extensions.dart';
+import 'package:flowy_editor/src/extensions/path_extensions.dart';
 import 'package:flowy_editor/src/operation/transaction_builder.dart';
 import 'package:flowy_editor/src/render/rich_text/rich_text_style.dart';
 
+void insertHeadingAfterSelection(EditorState editorState, String heading) {
+  insertTextNodeAfterSelection(editorState, {
+    StyleKey.subtype: StyleKey.heading,
+    StyleKey.heading: heading,
+  });
+}
+
+void insertQuoteAfterSelection(EditorState editorState) {
+  insertTextNodeAfterSelection(editorState, {
+    StyleKey.subtype: StyleKey.quote,
+  });
+}
+
+void insertCheckboxAfterSelection(EditorState editorState) {
+  insertTextNodeAfterSelection(editorState, {
+    StyleKey.subtype: StyleKey.checkbox,
+    StyleKey.checkbox: false,
+  });
+}
+
+void insertBulletedListAfterSelection(EditorState editorState) {
+  insertTextNodeAfterSelection(editorState, {
+    StyleKey.subtype: StyleKey.bulletedList,
+  });
+}
+
+bool insertTextNodeAfterSelection(
+    EditorState editorState, Attributes attributes) {
+  final selection = editorState.service.selectionService.currentSelection.value;
+  final nodes = editorState.service.selectionService.currentSelectedNodes;
+  if (selection == null || nodes.isEmpty) {
+    return false;
+  }
+
+  final node = nodes.first;
+  if (node is TextNode && node.delta.length == 0) {
+    formatTextNodes(editorState, attributes);
+  } else {
+    final next = selection.end.path.next;
+    final builder = TransactionBuilder(editorState);
+    builder
+      ..insertNode(
+        next,
+        TextNode.empty(attributes: attributes),
+      )
+      ..afterSelection = Selection.collapsed(
+        Position(path: next, offset: 0),
+      )
+      ..commit();
+  }
+
+  return true;
+}
+
 void formatText(EditorState editorState) {
   formatTextNodes(editorState, {});
 }

+ 149 - 41
frontend/app_flowy/packages/flowy_editor/lib/src/service/internal_key_event_handlers/slash_handler.dart

@@ -14,43 +14,56 @@ import 'package:flutter/services.dart';
 final List<PopupListItem> _popupListItems = [
   PopupListItem(
     text: 'Text',
+    keywords: ['text'],
     icon: _popupListIcon('text'),
-    handler: (editorState) => formatText(editorState),
+    handler: (editorState) {
+      insertTextNodeAfterSelection(editorState, {});
+    },
   ),
   PopupListItem(
     text: 'Heading 1',
+    keywords: ['h1', 'heading 1'],
     icon: _popupListIcon('h1'),
-    handler: (editorState) => formatHeading(editorState, StyleKey.h1),
+    handler: (editorState) =>
+        insertHeadingAfterSelection(editorState, StyleKey.h1),
   ),
   PopupListItem(
     text: 'Heading 2',
+    keywords: ['h2', 'heading 2'],
     icon: _popupListIcon('h2'),
-    handler: (editorState) => formatHeading(editorState, StyleKey.h2),
+    handler: (editorState) =>
+        insertHeadingAfterSelection(editorState, StyleKey.h2),
   ),
   PopupListItem(
     text: 'Heading 3',
+    keywords: ['h3', 'heading 3'],
     icon: _popupListIcon('h3'),
-    handler: (editorState) => formatHeading(editorState, StyleKey.h3),
+    handler: (editorState) =>
+        insertHeadingAfterSelection(editorState, StyleKey.h3),
   ),
   PopupListItem(
-    text: 'Bullets',
+    text: 'Bulleted List',
+    keywords: ['bulleted list'],
     icon: _popupListIcon('bullets'),
-    handler: (editorState) => formatBulletedList(editorState),
+    handler: (editorState) => insertBulletedListAfterSelection(editorState),
   ),
+  // PopupListItem(
+  //   text: 'Numbered list',
+  //   keywords: ['numbered list'],
+  //   icon: _popupListIcon('number'),
+  //   handler: (editorState) => debugPrint('Not implement yet!'),
+  // ),
   PopupListItem(
-    text: 'Numbered list',
-    icon: _popupListIcon('number'),
-    handler: (editorState) => debugPrint('Not implement yet!'),
-  ),
-  PopupListItem(
-    text: 'Checkboxes',
+    text: 'To-do List',
+    keywords: ['checkbox', 'todo'],
     icon: _popupListIcon('checkbox'),
-    handler: (editorState) => formatCheckbox(editorState),
+    handler: (editorState) => insertCheckboxAfterSelection(editorState),
   ),
 ];
 
 OverlayEntry? _popupListOverlay;
 EditorState? _editorState;
+bool _selectionChangeBySlash = false;
 FlowyKeyEventHandler slashShortcutHandler = (editorState, event) {
   if (event.logicalKey != LogicalKeyboardKey.slash) {
     return KeyEventResult.ignored;
@@ -69,21 +82,19 @@ FlowyKeyEventHandler slashShortcutHandler = (editorState, event) {
   if (selection == null || context == null || selectable == null) {
     return KeyEventResult.ignored;
   }
-
-  final rect = selectable.getCursorRectInPosition(selection.start);
-  if (rect == null) {
+  final selectionRects = editorState.service.selectionService.selectionRects;
+  if (selectionRects.isEmpty) {
     return KeyEventResult.ignored;
   }
-  final offset = selectable.localToGlobal(rect.topLeft);
-
   TransactionBuilder(editorState)
     ..replaceText(textNode, selection.start.offset,
-        selection.end.offset - selection.start.offset, '/')
+        selection.end.offset - selection.start.offset, event.character ?? '')
     ..commit();
 
   _editorState = editorState;
   WidgetsBinding.instance.addPostFrameCallback((_) {
-    showPopupList(context, editorState, offset);
+    _selectionChangeBySlash = false;
+    showPopupList(context, editorState, selectionRects.first.bottomRight);
   });
 
   return KeyEventResult.handled;
@@ -94,8 +105,8 @@ void showPopupList(
   _popupListOverlay?.remove();
   _popupListOverlay = OverlayEntry(
     builder: (context) => Positioned(
-      top: offset.dy + 15.0,
-      left: offset.dx + 5.0,
+      top: offset.dy,
+      left: offset.dx,
       child: PopupListWidget(
         editorState: editorState,
         items: _popupListItems,
@@ -117,6 +128,15 @@ void clearPopupList() {
   if (_popupListOverlay == null || _editorState == null) {
     return;
   }
+  final selection =
+      _editorState?.service.selectionService.currentSelection.value;
+  if (selection == null) {
+    return;
+  }
+  if (_selectionChangeBySlash) {
+    _selectionChangeBySlash = false;
+    return;
+  }
   _popupListOverlay?.remove();
   _popupListOverlay = null;
 
@@ -142,21 +162,55 @@ class PopupListWidget extends StatefulWidget {
 }
 
 class _PopupListWidgetState extends State<PopupListWidget> {
-  final focusNode = FocusNode(debugLabel: 'popup_list_widget');
-  var selectedIndex = 0;
+  final _focusNode = FocusNode(debugLabel: 'popup_list_widget');
+  int _selectedIndex = 0;
+  List<PopupListItem> _items = [];
+
+  int _maxKeywordLength = 0;
+
+  String __keyword = '';
+  String get _keyword => __keyword;
+  set _keyword(String keyword) {
+    __keyword = keyword;
+
+    final items = widget.items
+        .where((item) =>
+            item.keywords.any((keyword) => keyword.contains(_keyword)))
+        .toList(growable: false);
+    if (items.isNotEmpty) {
+      var maxKeywordLength = 0;
+      for (var item in _items) {
+        for (var keyword in item.keywords) {
+          maxKeywordLength = max(maxKeywordLength, keyword.length);
+        }
+      }
+      _maxKeywordLength = maxKeywordLength;
+    }
+
+    if (keyword.length >= _maxKeywordLength + 2) {
+      clearPopupList();
+    } else {
+      setState(() {
+        _selectedIndex = 0;
+        _items = items;
+      });
+    }
+  }
 
   @override
   void initState() {
     super.initState();
 
+    _items = widget.items;
+
     WidgetsBinding.instance.addPostFrameCallback((_) {
-      focusNode.requestFocus();
+      _focusNode.requestFocus();
     });
   }
 
   @override
   void dispose() {
-    focusNode.dispose();
+    _focusNode.dispose();
 
     super.dispose();
   }
@@ -164,7 +218,7 @@ class _PopupListWidgetState extends State<PopupListWidget> {
   @override
   Widget build(BuildContext context) {
     return Focus(
-      focusNode: focusNode,
+      focusNode: _focusNode,
       onKey: _onKey,
       child: Container(
         decoration: BoxDecoration(
@@ -178,9 +232,26 @@ class _PopupListWidgetState extends State<PopupListWidget> {
           ],
           borderRadius: BorderRadius.circular(6.0),
         ),
-        child: Row(
-          crossAxisAlignment: CrossAxisAlignment.start,
-          children: _buildColumns(widget.items, selectedIndex),
+        child: _items.isEmpty
+            ? _buildNoResultsWidget(context)
+            : Row(
+                crossAxisAlignment: CrossAxisAlignment.start,
+                children: _buildColumns(_items, _selectedIndex),
+              ),
+      ),
+    );
+  }
+
+  Widget _buildNoResultsWidget(BuildContext context) {
+    return const Align(
+      alignment: Alignment.centerLeft,
+      child: Material(
+        child: Padding(
+          padding: EdgeInsets.all(12.0),
+          child: Text(
+            'No results',
+            style: TextStyle(color: Colors.grey),
+          ),
         ),
       ),
     );
@@ -214,26 +285,43 @@ class _PopupListWidgetState extends State<PopupListWidget> {
   }
 
   KeyEventResult _onKey(FocusNode node, RawKeyEvent event) {
+    debugPrint('slash on key $event');
     if (event is! RawKeyDownEvent) {
       return KeyEventResult.ignored;
     }
 
+    final arrowKeys = [
+      LogicalKeyboardKey.arrowLeft,
+      LogicalKeyboardKey.arrowRight,
+      LogicalKeyboardKey.arrowUp,
+      LogicalKeyboardKey.arrowDown
+    ];
+
     if (event.logicalKey == LogicalKeyboardKey.enter) {
-      if (0 <= selectedIndex && selectedIndex < widget.items.length) {
-        _deleteSlash();
-        widget.items[selectedIndex].handler(widget.editorState);
+      if (0 <= _selectedIndex && _selectedIndex < _items.length) {
+        _deleteLastCharacters(length: _keyword.length + 1);
+        _items[_selectedIndex].handler(widget.editorState);
         return KeyEventResult.handled;
       }
     } else if (event.logicalKey == LogicalKeyboardKey.escape) {
       clearPopupList();
       return KeyEventResult.handled;
     } else if (event.logicalKey == LogicalKeyboardKey.backspace) {
-      clearPopupList();
-      _deleteSlash();
+      if (_keyword.isEmpty) {
+        clearPopupList();
+      } else {
+        _keyword = _keyword.substring(0, _keyword.length - 1);
+      }
+      _deleteLastCharacters();
+      return KeyEventResult.handled;
+    } else if (event.character != null &&
+        !arrowKeys.contains(event.logicalKey)) {
+      _keyword += event.character!;
+      _insertText(event.character!);
       return KeyEventResult.handled;
     }
 
-    var newSelectedIndex = selectedIndex;
+    var newSelectedIndex = _selectedIndex;
     if (event.logicalKey == LogicalKeyboardKey.arrowLeft) {
       newSelectedIndex -= widget.maxItemInRow;
     } else if (event.logicalKey == LogicalKeyboardKey.arrowRight) {
@@ -243,26 +331,44 @@ class _PopupListWidgetState extends State<PopupListWidget> {
     } else if (event.logicalKey == LogicalKeyboardKey.arrowDown) {
       newSelectedIndex += 1;
     }
-    if (newSelectedIndex != selectedIndex) {
+    if (newSelectedIndex != _selectedIndex) {
       setState(() {
-        selectedIndex = max(0, min(widget.items.length - 1, newSelectedIndex));
+        _selectedIndex = max(0, min(_items.length - 1, newSelectedIndex));
       });
       return KeyEventResult.handled;
     }
     return KeyEventResult.ignored;
   }
 
-  void _deleteSlash() {
+  void _deleteLastCharacters({int length = 1}) {
     final selection =
         widget.editorState.service.selectionService.currentSelection.value;
     final nodes =
         widget.editorState.service.selectionService.currentSelectedNodes;
     if (selection != null && nodes.length == 1) {
+      _selectionChangeBySlash = true;
       TransactionBuilder(widget.editorState)
         ..deleteText(
           nodes.first as TextNode,
-          selection.start.offset - 1,
-          1,
+          selection.start.offset - length,
+          length,
+        )
+        ..commit();
+    }
+  }
+
+  void _insertText(String text) {
+    final selection =
+        widget.editorState.service.selectionService.currentSelection.value;
+    final nodes =
+        widget.editorState.service.selectionService.currentSelectedNodes;
+    if (selection != null && nodes.length == 1) {
+      _selectionChangeBySlash = true;
+      TransactionBuilder(widget.editorState)
+        ..insertText(
+          nodes.first as TextNode,
+          selection.end.offset,
+          text,
         )
         ..commit();
     }
@@ -318,12 +424,14 @@ class _PopupListItemWidget extends StatelessWidget {
 class PopupListItem {
   PopupListItem({
     required this.text,
+    required this.keywords,
     this.message = '',
     required this.icon,
     required this.handler,
   });
 
   final String text;
+  final List<String> keywords;
   final String message;
   final Widget icon;
   final void Function(EditorState editorState) handler;

+ 10 - 6
frontend/app_flowy/packages/flowy_editor/lib/src/service/internal_key_event_handlers/whitespace_handler.dart

@@ -39,8 +39,8 @@ FlowyKeyEventHandler whiteSpaceHandler = (editorState, event) {
     return _toCheckboxList(editorState, textNode);
   } else if (_bulletedListSymbols.any(text.startsWith)) {
     return _toBulletedList(editorState, textNode);
-  } else if (_countOfSign(text) != 0) {
-    return _toHeadingStyle(editorState, textNode);
+  } else if (_countOfSign(text, selection) != 0) {
+    return _toHeadingStyle(editorState, textNode, selection);
   }
 
   return KeyEventResult.ignored;
@@ -99,8 +99,12 @@ KeyEventResult _toCheckboxList(EditorState editorState, TextNode textNode) {
   return KeyEventResult.handled;
 }
 
-KeyEventResult _toHeadingStyle(EditorState editorState, TextNode textNode) {
-  final x = _countOfSign(textNode.toRawString());
+KeyEventResult _toHeadingStyle(
+    EditorState editorState, TextNode textNode, Selection selection) {
+  final x = _countOfSign(
+    textNode.toRawString(),
+    selection,
+  );
   final hX = 'h$x';
   if (textNode.attributes.heading == hX) {
     return KeyEventResult.ignored;
@@ -121,9 +125,9 @@ KeyEventResult _toHeadingStyle(EditorState editorState, TextNode textNode) {
   return KeyEventResult.handled;
 }
 
-int _countOfSign(String text) {
+int _countOfSign(String text, Selection selection) {
   for (var i = 6; i >= 0; i--) {
-    if (text.startsWith('#' * i)) {
+    if (text.substring(0, selection.end.offset).startsWith('#' * i)) {
       return i;
     }
   }