فهرست منبع

Merge pull request #991 from AppFlowy-IO/feat/number-list

Feat: number list
Lucas.Xu 2 سال پیش
والد
کامیت
84a71674f6

+ 5 - 3
frontend/app_flowy/packages/appflowy_editor/lib/src/operation/transaction_builder.dart

@@ -193,7 +193,7 @@ class TransactionBuilder {
   ///
   /// Also, this method will transform the path of the operations
   /// to avoid conflicts.
-  add(Operation op) {
+  add(Operation op, {bool transform = true}) {
     final Operation? last = operations.isEmpty ? null : operations.last;
     if (last != null) {
       if (op is TextEditOperation &&
@@ -208,8 +208,10 @@ class TransactionBuilder {
         return;
       }
     }
-    for (var i = 0; i < operations.length; i++) {
-      op = transformOperation(operations[i], op);
+    if (transform) {
+      for (var i = 0; i < operations.length; i++) {
+        op = transformOperation(operations[i], op);
+      }
     }
     if (op is TextEditOperation && op.delta.isEmpty) {
       return;

+ 8 - 7
frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/number_list_text.dart

@@ -40,6 +40,7 @@ class NumberListTextNodeWidget extends StatefulWidget {
 }
 
 // customize
+const double _numberHorizontalPadding = 8;
 
 class _NumberListTextNodeWidgetState extends State<NumberListTextNodeWidget>
     with SelectableMixin, DefaultSelectable {
@@ -47,8 +48,6 @@ class _NumberListTextNodeWidgetState extends State<NumberListTextNodeWidget>
   final iconKey = GlobalKey();
 
   final _richTextKey = GlobalKey(debugLabel: 'number_list_text');
-  final _iconWidth = 20.0;
-  final _iconRightPadding = 5.0;
 
   @override
   SelectableMixin<StatefulWidget> get forward =>
@@ -61,12 +60,14 @@ class _NumberListTextNodeWidgetState extends State<NumberListTextNodeWidget>
         child: Row(
           crossAxisAlignment: CrossAxisAlignment.start,
           children: [
-            FlowySvg(
+            Padding(
               key: iconKey,
-              width: _iconWidth,
-              height: _iconWidth,
-              padding: EdgeInsets.only(right: _iconRightPadding),
-              number: widget.textNode.attributes.number,
+              padding: const EdgeInsets.symmetric(
+                  horizontal: _numberHorizontalPadding, vertical: 0),
+              child: Text(
+                '${widget.textNode.attributes.number.toString()}.',
+                style: const TextStyle(fontSize: 16),
+              ),
             ),
             Flexible(
               child: FlowyRichText(

+ 131 - 42
frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/backspace_handler.dart

@@ -1,4 +1,5 @@
 import 'package:appflowy_editor/src/render/rich_text/rich_text_style.dart';
+import 'package:appflowy_editor/src/service/internal_key_event_handlers/number_list_helper.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter/services.dart';
 
@@ -25,10 +26,11 @@ KeyEventResult _handleBackspace(EditorState editorState, RawKeyEvent event) {
   nodes = selection.isBackward ? nodes : nodes.reversed.toList(growable: false);
   selection = selection.isBackward ? selection : selection.reversed;
   final textNodes = nodes.whereType<TextNode>().toList();
-  final nonTextNodes =
+  final List<Node> nonTextNodes =
       nodes.where((node) => node is! TextNode).toList(growable: false);
 
   final transactionBuilder = TransactionBuilder(editorState);
+  List<int>? cancelNumberListPath;
 
   if (nonTextNodes.isNotEmpty) {
     transactionBuilder.deleteNodes(nonTextNodes);
@@ -40,6 +42,9 @@ KeyEventResult _handleBackspace(EditorState editorState, RawKeyEvent event) {
     if (index < 0 && selection.isCollapsed) {
       // 1. style
       if (textNode.subtype != null) {
+        if (textNode.subtype == StyleKey.numberList) {
+          cancelNumberListPath = textNode.path;
+        }
         transactionBuilder
           ..updateNode(textNode, {
             StyleKey.subtype: null,
@@ -54,23 +59,13 @@ KeyEventResult _handleBackspace(EditorState editorState, RawKeyEvent event) {
       } else {
         // 2. non-style
         // find previous text node.
-        var previous = textNode.previous;
-        while (previous != null) {
-          if (previous is TextNode) {
-            transactionBuilder
-              ..mergeText(previous, textNode)
-              ..deleteNode(textNode)
-              ..afterSelection = Selection.collapsed(
-                Position(
-                  path: previous.path,
-                  offset: previous.toRawString().length,
-                ),
-              );
-            break;
-          } else {
-            previous = previous.previous;
-          }
-        }
+        return _backDeleteToPreviousTextNode(
+          editorState,
+          textNode,
+          transactionBuilder,
+          nonTextNodes,
+          selection,
+        );
       }
     } else {
       if (selection.isCollapsed) {
@@ -88,9 +83,22 @@ KeyEventResult _handleBackspace(EditorState editorState, RawKeyEvent event) {
       }
     }
   } else {
-    if (textNodes.isNotEmpty) {
-      _deleteTextNodes(transactionBuilder, textNodes, selection);
+    if (textNodes.isEmpty) {
+      return KeyEventResult.handled;
+    }
+    final startPosition = selection.start;
+    final nodeAtStart = editorState.document.nodeAtPath(startPosition.path)!;
+    _deleteTextNodes(transactionBuilder, textNodes, selection);
+    transactionBuilder.commit();
+
+    if (nodeAtStart is TextNode && nodeAtStart.subtype == StyleKey.numberList) {
+      makeFollowingNodesIncremental(
+        editorState,
+        startPosition.path,
+        transactionBuilder.afterSelection!,
+      );
     }
+    return KeyEventResult.handled;
   }
 
   if (transactionBuilder.operations.isNotEmpty) {
@@ -100,6 +108,59 @@ KeyEventResult _handleBackspace(EditorState editorState, RawKeyEvent event) {
     transactionBuilder.commit();
   }
 
+  if (cancelNumberListPath != null) {
+    makeFollowingNodesIncremental(
+      editorState,
+      cancelNumberListPath,
+      Selection.collapsed(selection.start),
+      beginNum: 0,
+    );
+  }
+
+  return KeyEventResult.handled;
+}
+
+KeyEventResult _backDeleteToPreviousTextNode(
+    EditorState editorState,
+    TextNode textNode,
+    TransactionBuilder transactionBuilder,
+    List<Node> nonTextNodes,
+    Selection selection) {
+  var previous = textNode.previous;
+  bool prevIsNumberList = false;
+  while (previous != null) {
+    if (previous is TextNode) {
+      if (previous.subtype == StyleKey.numberList) {
+        prevIsNumberList = true;
+      }
+
+      transactionBuilder
+        ..mergeText(previous, textNode)
+        ..deleteNode(textNode)
+        ..afterSelection = Selection.collapsed(
+          Position(
+            path: previous.path,
+            offset: previous.toRawString().length,
+          ),
+        );
+      break;
+    } else {
+      previous = previous.previous;
+    }
+  }
+
+  if (transactionBuilder.operations.isNotEmpty) {
+    if (nonTextNodes.isNotEmpty) {
+      transactionBuilder.afterSelection = Selection.collapsed(selection.start);
+    }
+    transactionBuilder.commit();
+  }
+
+  if (prevIsNumberList) {
+    makeFollowingNodesIncremental(
+        editorState, previous!.path, transactionBuilder.afterSelection!);
+  }
+
   return KeyEventResult.handled;
 }
 
@@ -120,37 +181,65 @@ KeyEventResult _handleDelete(EditorState editorState, RawKeyEvent event) {
   final transactionBuilder = TransactionBuilder(editorState);
   if (textNodes.length == 1) {
     final textNode = textNodes.first;
+    // The cursor is at the end of the line,
+    // merge next line into this line.
     if (selection.start.offset >= textNode.delta.length) {
-      final nextNode = textNode.next;
-      if (nextNode == null) {
-        return KeyEventResult.ignored;
-      }
-      if (nextNode is TextNode) {
-        transactionBuilder.mergeText(textNode, nextNode);
-      }
-      transactionBuilder.deleteNode(nextNode);
+      return _mergeNextLineIntoThisLine(
+        editorState,
+        textNode,
+        transactionBuilder,
+        selection,
+      );
+    }
+    final index = textNode.delta.nextRunePosition(selection.start.offset);
+    if (selection.isCollapsed) {
+      transactionBuilder.deleteText(
+        textNode,
+        selection.start.offset,
+        index - selection.start.offset,
+      );
     } else {
-      final index = textNode.delta.nextRunePosition(selection.start.offset);
-      if (selection.isCollapsed) {
-        transactionBuilder.deleteText(
-          textNode,
-          selection.start.offset,
-          index - selection.start.offset,
-        );
-      } else {
-        transactionBuilder.deleteText(
-          textNode,
-          selection.start.offset,
-          selection.end.offset - selection.start.offset,
-        );
-      }
+      transactionBuilder.deleteText(
+        textNode,
+        selection.start.offset,
+        selection.end.offset - selection.start.offset,
+      );
     }
+    transactionBuilder.commit();
   } else {
+    final startPosition = selection.start;
+    final nodeAtStart = editorState.document.nodeAtPath(startPosition.path)!;
     _deleteTextNodes(transactionBuilder, textNodes, selection);
+    transactionBuilder.commit();
+
+    if (nodeAtStart is TextNode && nodeAtStart.subtype == StyleKey.numberList) {
+      makeFollowingNodesIncremental(
+          editorState, startPosition.path, transactionBuilder.afterSelection!);
+    }
   }
 
+  return KeyEventResult.handled;
+}
+
+KeyEventResult _mergeNextLineIntoThisLine(
+    EditorState editorState,
+    TextNode textNode,
+    TransactionBuilder transactionBuilder,
+    Selection selection) {
+  final nextNode = textNode.next;
+  if (nextNode == null) {
+    return KeyEventResult.ignored;
+  }
+  if (nextNode is TextNode) {
+    transactionBuilder.mergeText(textNode, nextNode);
+  }
+  transactionBuilder.deleteNode(nextNode);
   transactionBuilder.commit();
 
+  if (textNode.subtype == StyleKey.numberList) {
+    makeFollowingNodesIncremental(editorState, textNode.path, selection);
+  }
+
   return KeyEventResult.handled;
 }
 

+ 66 - 22
frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/copy_paste_handler.dart

@@ -1,10 +1,31 @@
 import 'package:appflowy_editor/appflowy_editor.dart';
 import 'package:appflowy_editor/src/infra/html_converter.dart';
 import 'package:appflowy_editor/src/document/node_iterator.dart';
+import 'package:appflowy_editor/src/render/rich_text/rich_text_style.dart';
+import 'package:appflowy_editor/src/service/internal_key_event_handlers/number_list_helper.dart';
 import 'package:flutter/material.dart';
 import 'package:rich_clipboard/rich_clipboard.dart';
 
-_handleCopy(EditorState editorState) async {
+int _textLengthOfNode(Node node) {
+  if (node is TextNode) {
+    return node.delta.length;
+  }
+
+  return 0;
+}
+
+Selection _computeSelectionAfterPasteMultipleNodes(
+    EditorState editorState, List<Node> nodes) {
+  final currentSelection = editorState.cursorSelection!;
+  final currentCursor = currentSelection.start;
+  final currentPath = [...currentCursor.path];
+  currentPath[currentPath.length - 1] += nodes.length;
+  int lenOfLastNode = _textLengthOfNode(nodes.last);
+  return Selection.collapsed(
+      Position(path: currentPath, offset: lenOfLastNode));
+}
+
+void _handleCopy(EditorState editorState) async {
   final selection = editorState.cursorSelection?.normalize;
   if (selection == null || selection.isCollapsed) {
     return;
@@ -40,7 +61,7 @@ _handleCopy(EditorState editorState) async {
   RichClipboard.setData(RichClipboardData(html: copyString));
 }
 
-_pasteHTML(EditorState editorState, String html) {
+void _pasteHTML(EditorState editorState, String html) {
   final selection = editorState.cursorSelection?.normalize;
   if (selection == null) {
     return;
@@ -78,7 +99,7 @@ _pasteHTML(EditorState editorState, String html) {
   _pasteMultipleLinesInText(editorState, path, selection.start.offset, nodes);
 }
 
-_pasteMultipleLinesInText(
+void _pasteMultipleLinesInText(
     EditorState editorState, List<int> path, int offset, List<Node> nodes) {
   final tb = TransactionBuilder(editorState);
 
@@ -86,6 +107,11 @@ _pasteMultipleLinesInText(
   final nodeAtPath = editorState.document.nodeAtPath(path)!;
 
   if (nodeAtPath.type == "text" && firstNode.type == "text") {
+    int? startNumber;
+    if (nodeAtPath.subtype == StyleKey.numberList) {
+      startNumber = nodeAtPath.attributes[StyleKey.number] as int;
+    }
+
     // split and merge
     final textNodeAtPath = nodeAtPath as TextNode;
     final firstTextNode = firstNode as TextNode;
@@ -100,7 +126,12 @@ _pasteMultipleLinesInText(
             firstTextNode.delta);
 
     final tailNodes = nodes.sublist(1);
+    final originalPath = [...path];
     path[path.length - 1]++;
+
+    final afterSelection =
+        _computeSelectionAfterPasteMultipleNodes(editorState, tailNodes);
+
     if (tailNodes.isNotEmpty) {
       if (tailNodes.last.type == "text") {
         final tailTextNode = tailNodes.last as TextNode;
@@ -112,17 +143,27 @@ _pasteMultipleLinesInText(
       tailNodes.add(TextNode(type: "text", delta: remain));
     }
 
+    tb.setAfterSelection(afterSelection);
     tb.insertNodes(path, tailNodes);
     tb.commit();
+
+    if (startNumber != null) {
+      makeFollowingNodesIncremental(editorState, originalPath, afterSelection,
+          beginNum: startNumber);
+    }
     return;
   }
 
+  final afterSelection =
+      _computeSelectionAfterPasteMultipleNodes(editorState, nodes);
+
   path[path.length - 1]++;
+  tb.setAfterSelection(afterSelection);
   tb.insertNodes(path, nodes);
   tb.commit();
 }
 
-_handlePaste(EditorState editorState) async {
+void _handlePaste(EditorState editorState) async {
   final data = await RichClipboard.getData();
 
   if (editorState.cursorSelection?.isCollapsed ?? false) {
@@ -137,7 +178,7 @@ _handlePaste(EditorState editorState) async {
   });
 }
 
-_pastRichClipboard(EditorState editorState, RichClipboardData data) {
+void _pastRichClipboard(EditorState editorState, RichClipboardData data) {
   if (data.html != null) {
     _pasteHTML(editorState, data.html!);
     return;
@@ -148,7 +189,8 @@ _pastRichClipboard(EditorState editorState, RichClipboardData data) {
   }
 }
 
-_pasteSingleLine(EditorState editorState, Selection selection, String line) {
+void _pasteSingleLine(
+    EditorState editorState, Selection selection, String line) {
   final node = editorState.document.nodeAtPath(selection.end.path)! as TextNode;
   final beginOffset = selection.end.offset;
   TransactionBuilder(editorState)
@@ -188,7 +230,7 @@ Delta _lineContentToDelta(String lineContent) {
   return delta;
 }
 
-_handlePastePlainText(EditorState editorState, String plainText) {
+void _handlePastePlainText(EditorState editorState, String plainText) {
   final selection = editorState.cursorSelection?.normalize;
   if (selection == null) {
     return;
@@ -219,16 +261,21 @@ _handlePastePlainText(EditorState editorState, String plainText) {
     final insertedLineSuffix = node.delta.slice(beginOffset);
 
     path[path.length - 1]++;
-    var index = 0;
     final tb = TransactionBuilder(editorState);
-    final nodes = remains.map((e) {
-      if (index++ == remains.length - 1) {
-        return TextNode(
-            type: "text",
-            delta: _lineContentToDelta(e)..addAll(insertedLineSuffix));
-      }
-      return TextNode(type: "text", delta: _lineContentToDelta(e));
-    }).toList();
+    final List<TextNode> nodes = remains
+        .map((e) => TextNode(type: "text", delta: _lineContentToDelta(e)))
+        .toList();
+
+    final afterSelection =
+        _computeSelectionAfterPasteMultipleNodes(editorState, nodes);
+
+    // append remain text to the last line
+    if (nodes.isNotEmpty) {
+      final last = nodes.last;
+      nodes[nodes.length - 1] =
+          TextNode(type: "text", delta: last.delta..addAll(insertedLineSuffix));
+    }
+
     // insert first line
     tb.textEdit(
         node,
@@ -238,22 +285,19 @@ _handlePastePlainText(EditorState editorState, String plainText) {
           ..delete(node.delta.length - beginOffset));
     // insert remains
     tb.insertNodes(path, nodes);
+    tb.setAfterSelection(afterSelection);
     tb.commit();
-
-    // fixme: don't set the cursor manually
-    editorState.updateCursorSelection(Selection.collapsed(
-        Position(path: nodes.last.path, offset: lines.last.length)));
   }
 }
 
 /// 1. copy the selected content
 /// 2. delete selected content
-_handleCut(EditorState editorState) {
+void _handleCut(EditorState editorState) {
   _handleCopy(editorState);
   _deleteSelectedContent(editorState);
 }
 
-_deleteSelectedContent(EditorState editorState) {
+void _deleteSelectedContent(EditorState editorState) {
   final selection = editorState.cursorSelection?.normalize;
   if (selection == null || selection.isCollapsed) {
     return;

+ 82 - 24
frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/enter_without_shift_in_text_node_handler.dart

@@ -1,14 +1,10 @@
+import 'package:appflowy_editor/appflowy_editor.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter/services.dart';
 
-import 'package:appflowy_editor/src/document/attributes.dart';
-import 'package:appflowy_editor/src/document/node.dart';
-import 'package:appflowy_editor/src/document/position.dart';
-import 'package:appflowy_editor/src/document/selection.dart';
 import 'package:appflowy_editor/src/extensions/path_extensions.dart';
-import 'package:appflowy_editor/src/operation/transaction_builder.dart';
 import 'package:appflowy_editor/src/render/rich_text/rich_text_style.dart';
-import 'package:appflowy_editor/src/service/shortcut_event/shortcut_event_handler.dart';
+import './number_list_helper.dart';
 
 /// Handle some cases where enter is pressed and shift is not pressed.
 ///
@@ -41,6 +37,7 @@ ShortcutEventHandler enterWithoutShiftInTextNodesHandler =
 
   // Multiple selection
   if (!selection.isSingle) {
+    final startNode = editorState.document.nodeAtPath(selection.start.path)!;
     final length = textNodes.length;
     final List<TextNode> subTextNodes =
         length >= 3 ? textNodes.sublist(1, textNodes.length - 1) : [];
@@ -61,6 +58,12 @@ ShortcutEventHandler enterWithoutShiftInTextNodesHandler =
       )
       ..afterSelection = afterSelection
       ..commit();
+
+    if (startNode is TextNode && startNode.subtype == StyleKey.numberList) {
+      makeFollowingNodesIncremental(
+          editorState, selection.start.path, afterSelection);
+    }
+
     return KeyEventResult.handled;
   }
 
@@ -87,36 +90,57 @@ ShortcutEventHandler enterWithoutShiftInTextNodesHandler =
             ))
         ..afterSelection = afterSelection
         ..commit();
+
+      final nextNode = textNode.next;
+      if (nextNode is TextNode && nextNode.subtype == StyleKey.numberList) {
+        makeFollowingNodesIncremental(
+            editorState, textNode.path, afterSelection,
+            beginNum: 0);
+      }
     } else {
+      final subtype = textNode.subtype;
       final afterSelection = Selection.collapsed(
         Position(path: textNode.path.next, offset: 0),
       );
-      TransactionBuilder(editorState)
-        ..insertNode(
-          textNode.path,
-          TextNode.empty(),
-        )
-        ..afterSelection = afterSelection
-        ..commit();
+
+      if (subtype == StyleKey.numberList) {
+        final prevNumber = textNode.attributes[StyleKey.number] as int;
+        final newNode = TextNode.empty();
+        newNode.attributes[StyleKey.subtype] = StyleKey.numberList;
+        newNode.attributes[StyleKey.number] = prevNumber;
+        final insertPath = textNode.path;
+        TransactionBuilder(editorState)
+          ..insertNode(
+            insertPath,
+            newNode,
+          )
+          ..afterSelection = afterSelection
+          ..commit();
+
+        makeFollowingNodesIncremental(editorState, insertPath, afterSelection,
+            beginNum: prevNumber);
+      } else {
+        TransactionBuilder(editorState)
+          ..insertNode(
+            textNode.path,
+            TextNode.empty(),
+          )
+          ..afterSelection = afterSelection
+          ..commit();
+      }
     }
     return KeyEventResult.handled;
   }
 
   // Otherwise,
   //  split the node into two nodes with style
-  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;
-    }
-  }
+  Attributes attributes = _attributesFromPreviousLine(textNode);
+
+  final nextPath = textNode.path.next;
   final afterSelection = Selection.collapsed(
-    Position(path: textNode.path.next, offset: 0),
+    Position(path: nextPath, offset: 0),
   );
+
   TransactionBuilder(editorState)
     ..insertNode(
       textNode.path.next,
@@ -132,5 +156,39 @@ ShortcutEventHandler enterWithoutShiftInTextNodesHandler =
     )
     ..afterSelection = afterSelection
     ..commit();
+
+  // If the new type of a text node is number list,
+  // the numbers of the following nodes should be incremental.
+  if (textNode.subtype == StyleKey.numberList) {
+    makeFollowingNodesIncremental(editorState, nextPath, afterSelection);
+  }
+
   return KeyEventResult.handled;
 };
+
+Attributes _attributesFromPreviousLine(TextNode textNode) {
+  final prevAttributes = textNode.attributes;
+  final subType = textNode.subtype;
+  if (subType == null || subType == StyleKey.heading) {
+    return {};
+  }
+
+  final copy = Attributes.from(prevAttributes);
+  if (subType == StyleKey.numberList) {
+    return _nextNumberAttributesFromPreviousLine(copy, textNode);
+  }
+
+  if (subType == StyleKey.checkbox) {
+    copy[StyleKey.checkbox] = false;
+    return copy;
+  }
+
+  return copy;
+}
+
+Attributes _nextNumberAttributesFromPreviousLine(
+    Attributes copy, TextNode textNode) {
+  final prevNum = textNode.attributes[StyleKey.number] as int?;
+  copy[StyleKey.number] = prevNum == null ? 1 : prevNum + 1;
+  return copy;
+}

+ 38 - 0
frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/number_list_helper.dart

@@ -0,0 +1,38 @@
+import 'package:appflowy_editor/src/document/selection.dart';
+import 'package:appflowy_editor/src/editor_state.dart';
+import 'package:appflowy_editor/src/render/rich_text/rich_text_style.dart';
+import 'package:appflowy_editor/src/operation/transaction_builder.dart';
+import 'package:appflowy_editor/src/document/attributes.dart';
+
+void makeFollowingNodesIncremental(
+    EditorState editorState, List<int> insertPath, Selection afterSelection,
+    {int? beginNum}) {
+  final insertNode = editorState.document.nodeAtPath(insertPath);
+  if (insertNode == null) {
+    return;
+  }
+  beginNum ??= insertNode.attributes[StyleKey.number] as int;
+
+  int numPtr = beginNum + 1;
+  var ptr = insertNode.next;
+
+  final builder = TransactionBuilder(editorState);
+
+  while (ptr != null) {
+    if (ptr.subtype != StyleKey.numberList) {
+      break;
+    }
+    final currentNum = ptr.attributes[StyleKey.number] as int;
+    if (currentNum != numPtr) {
+      Attributes updateAttributes = {};
+      updateAttributes[StyleKey.number] = numPtr;
+      builder.updateNode(ptr, updateAttributes);
+    }
+
+    ptr = ptr.next;
+    numPtr++;
+  }
+
+  builder.afterSelection = afterSelection;
+  builder.commit();
+}

+ 59 - 0
frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/whitespace_handler.dart

@@ -8,6 +8,7 @@ import 'package:appflowy_editor/src/document/selection.dart';
 import 'package:appflowy_editor/src/editor_state.dart';
 import 'package:appflowy_editor/src/operation/transaction_builder.dart';
 import 'package:appflowy_editor/src/render/rich_text/rich_text_style.dart';
+import './number_list_helper.dart';
 
 @visibleForTesting
 List<String> get checkboxListSymbols => _checkboxListSymbols;
@@ -20,6 +21,8 @@ const _bulletedListSymbols = ['*', '-'];
 const _checkboxListSymbols = ['[x]', '-[x]'];
 const _unCheckboxListSymbols = ['[]', '-[]'];
 
+final _numberRegex = RegExp(r'^(\d+)\.');
+
 ShortcutEventHandler whiteSpaceHandler = (editorState, event) {
   if (event.logicalKey != LogicalKeyboardKey.space) {
     return KeyEventResult.ignored;
@@ -42,6 +45,16 @@ ShortcutEventHandler whiteSpaceHandler = (editorState, event) {
 
   final textNode = textNodes.first;
   final text = textNode.toRawString();
+
+  final numberMatch = _numberRegex.firstMatch(text);
+  if (numberMatch != null) {
+    final matchText = numberMatch.group(0);
+    final numText = numberMatch.group(1);
+    if (matchText != null && numText != null) {
+      return _toNumberList(editorState, textNode, matchText, numText);
+    }
+  }
+
   if ((_checkboxListSymbols + _unCheckboxListSymbols).any(text.startsWith)) {
     return _toCheckboxList(editorState, textNode);
   } else if (_bulletedListSymbols.any(text.startsWith)) {
@@ -53,6 +66,52 @@ ShortcutEventHandler whiteSpaceHandler = (editorState, event) {
   return KeyEventResult.ignored;
 };
 
+KeyEventResult _toNumberList(EditorState editorState, TextNode textNode,
+    String matchText, String numText) {
+  if (textNode.subtype == StyleKey.bulletedList) {
+    return KeyEventResult.ignored;
+  }
+
+  final numValue = int.tryParse(numText);
+  if (numValue == null) {
+    return KeyEventResult.ignored;
+  }
+
+  // The user types number + . + space, he wants to turn
+  // this line into number list, but we should check if previous line
+  // is number list.
+  //
+  // Check whether the number input by the user is the successor of the previous
+  // line. If it's not, ignore it.
+  final prevNode = textNode.previous;
+  if (prevNode != null &&
+      prevNode is TextNode &&
+      prevNode.attributes[StyleKey.subtype] == StyleKey.numberList) {
+    final prevNumber = prevNode.attributes[StyleKey.number] as int;
+    if (numValue != prevNumber + 1) {
+      return KeyEventResult.ignored;
+    }
+  }
+
+  final afterSelection = Selection.collapsed(Position(
+    path: textNode.path,
+    offset: 0,
+  ));
+
+  final insertPath = textNode.path;
+
+  TransactionBuilder(editorState)
+    ..deleteText(textNode, 0, matchText.length)
+    ..updateNode(textNode,
+        {StyleKey.subtype: StyleKey.numberList, StyleKey.number: numValue})
+    ..afterSelection = afterSelection
+    ..commit();
+
+  makeFollowingNodesIncremental(editorState, insertPath, afterSelection);
+
+  return KeyEventResult.handled;
+}
+
 KeyEventResult _toBulletedList(EditorState editorState, TextNode textNode) {
   if (textNode.subtype == StyleKey.bulletedList) {
     return KeyEventResult.ignored;

+ 13 - 11
frontend/app_flowy/packages/appflowy_editor/lib/src/undo_manager.dart

@@ -43,7 +43,7 @@ class HistoryItem extends LinkedListEntry<HistoryItem> {
     for (var i = operations.length - 1; i >= 0; i--) {
       final operation = operations[i];
       final inverted = operation.invert();
-      builder.add(inverted);
+      builder.add(inverted, transform: false);
     }
     builder.afterSelection = beforeSelection;
     builder.beforeSelection = afterSelection;
@@ -123,11 +123,12 @@ class UndoManager {
     }
     final transaction = historyItem.toTransaction(s);
     s.apply(
-        transaction,
-        const ApplyOptions(
-          recordUndo: false,
-          recordRedo: true,
-        ));
+      transaction,
+      const ApplyOptions(
+        recordUndo: false,
+        recordRedo: true,
+      ),
+    );
   }
 
   redo() {
@@ -142,10 +143,11 @@ class UndoManager {
     }
     final transaction = historyItem.toTransaction(s);
     s.apply(
-        transaction,
-        const ApplyOptions(
-          recordUndo: true,
-          recordRedo: false,
-        ));
+      transaction,
+      const ApplyOptions(
+        recordUndo: true,
+        recordRedo: false,
+      ),
+    );
   }
 }

+ 76 - 0
frontend/app_flowy/packages/appflowy_editor/test/legacy/undo_manager_test.dart

@@ -0,0 +1,76 @@
+import 'dart:collection';
+import 'package:appflowy_editor/appflowy_editor.dart';
+import 'package:appflowy_editor/src/undo_manager.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+void main() async {
+  setUpAll(() {
+    TestWidgetsFlutterBinding.ensureInitialized();
+  });
+
+  Node _createEmptyEditorRoot() {
+    return Node(
+      type: 'editor',
+      children: LinkedList(),
+      attributes: {},
+    );
+  }
+
+  test("HistoryItem #1", () {
+    final document = StateTree(root: _createEmptyEditorRoot());
+    final editorState = EditorState(document: document);
+
+    final historyItem = HistoryItem();
+    historyItem.add(DeleteOperation(
+        [0], [TextNode(type: 'text', delta: Delta()..insert('0'))]));
+    historyItem.add(DeleteOperation(
+        [0], [TextNode(type: 'text', delta: Delta()..insert('1'))]));
+    historyItem.add(DeleteOperation(
+        [0], [TextNode(type: 'text', delta: Delta()..insert('2'))]));
+
+    final transaction = historyItem.toTransaction(editorState);
+    assert(isInsertAndPathEqual(transaction.operations[0], [0], '2'));
+    assert(isInsertAndPathEqual(transaction.operations[1], [0], '1'));
+    assert(isInsertAndPathEqual(transaction.operations[2], [0], '0'));
+  });
+
+  test("HistoryItem #2", () {
+    final document = StateTree(root: _createEmptyEditorRoot());
+    final editorState = EditorState(document: document);
+
+    final historyItem = HistoryItem();
+    historyItem.add(DeleteOperation(
+        [0], [TextNode(type: 'text', delta: Delta()..insert('0'))]));
+    historyItem
+        .add(UpdateOperation([0], {"subType": "number"}, {"subType": null}));
+    historyItem.add(DeleteOperation([0], [TextNode.empty(), TextNode.empty()]));
+    historyItem.add(DeleteOperation([0], [TextNode.empty()]));
+
+    final transaction = historyItem.toTransaction(editorState);
+    assert(isInsertAndPathEqual(transaction.operations[0], [0]));
+    assert(isInsertAndPathEqual(transaction.operations[1], [0]));
+    assert(transaction.operations[2] is UpdateOperation);
+    assert(isInsertAndPathEqual(transaction.operations[3], [0], '0'));
+  });
+}
+
+bool isInsertAndPathEqual(Operation operation, Path path, [String? content]) {
+  if (operation is! InsertOperation) {
+    return false;
+  }
+
+  if (!pathEquals(operation.path, path)) {
+    return false;
+  }
+
+  final firstNode = operation.nodes[0];
+  if (firstNode is! TextNode) {
+    return false;
+  }
+
+  if (content == null) {
+    return true;
+  }
+
+  return firstNode.delta.toRawString() == content;
+}