diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/operation/transaction_builder.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/operation/transaction_builder.dart index 1390b23918..c990a3921f 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/operation/transaction_builder.dart +++ b/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; diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/number_list_text.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/number_list_text.dart index 36d3d93ed9..2085b3a48a 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/render/rich_text/number_list_text.dart +++ b/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 with SelectableMixin, DefaultSelectable { @@ -47,8 +48,6 @@ class _NumberListTextNodeWidgetState extends State final iconKey = GlobalKey(); final _richTextKey = GlobalKey(debugLabel: 'number_list_text'); - final _iconWidth = 20.0; - final _iconRightPadding = 5.0; @override SelectableMixin get forward => @@ -61,12 +60,14 @@ class _NumberListTextNodeWidgetState extends State 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( diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/backspace_handler.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/backspace_handler.dart index ee3f80f197..82d7f7f3b3 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/backspace_handler.dart +++ b/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().toList(); - final nonTextNodes = + final List nonTextNodes = nodes.where((node) => node is! TextNode).toList(growable: false); final transactionBuilder = TransactionBuilder(editorState); + List? 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,8 +83,69 @@ 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) { + if (nonTextNodes.isNotEmpty) { + transactionBuilder.afterSelection = Selection.collapsed(selection.start); + } + 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 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; } } @@ -100,6 +156,11 @@ KeyEventResult _handleBackspace(EditorState editorState, RawKeyEvent event) { 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); - } 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, - ); - } + 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 { + 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; } diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/copy_paste_handler.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/copy_paste_handler.dart index 15a7081653..df54753e86 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/copy_paste_handler.dart +++ b/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 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 path, int offset, List 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 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; diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/enter_without_shift_in_text_node_handler.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/enter_without_shift_in_text_node_handler.dart index 8f12dbb9e6..4ed21f21f8 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/enter_without_shift_in_text_node_handler.dart +++ b/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 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; +} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/number_list_helper.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/number_list_helper.dart new file mode 100644 index 0000000000..4e726fc86e --- /dev/null +++ b/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 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(); +} diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/whitespace_handler.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/whitespace_handler.dart index c6046ba6dd..fb78fce1b3 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/service/internal_key_event_handlers/whitespace_handler.dart +++ b/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 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; diff --git a/frontend/app_flowy/packages/appflowy_editor/lib/src/undo_manager.dart b/frontend/app_flowy/packages/appflowy_editor/lib/src/undo_manager.dart index cfa3f75688..737076e930 100644 --- a/frontend/app_flowy/packages/appflowy_editor/lib/src/undo_manager.dart +++ b/frontend/app_flowy/packages/appflowy_editor/lib/src/undo_manager.dart @@ -43,7 +43,7 @@ class HistoryItem extends LinkedListEntry { 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, + ), + ); } } diff --git a/frontend/app_flowy/packages/appflowy_editor/test/legacy/undo_manager_test.dart b/frontend/app_flowy/packages/appflowy_editor/test/legacy/undo_manager_test.dart new file mode 100644 index 0000000000..f77e404273 --- /dev/null +++ b/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; +}