mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2024-08-30 18:12:39 +00:00
Merge pull request #991 from AppFlowy-IO/feat/number-list
Feat: number list
This commit is contained in:
commit
84a71674f6
@ -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;
|
||||
|
@ -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(
|
||||
|
@ -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,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<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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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();
|
||||
}
|
@ -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;
|
||||
|
@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
Loading…
Reference in New Issue
Block a user