feat: multi list indentation (#903)

* add muliple list indentation at once

* feat: filter out the children node when executing indent command

* feat: keeping only immediate children nodes when executing outdent command

* feat: ignore outdent command if any of the selected nodes have path 1

* add test cases for indent and outdent commands

* fix: linting issues and removed dead code

* chore: remove unecessary comments in test file

---------

Co-authored-by: Lucas.Xu <lucas.xu@appflowy.io>
This commit is contained in:
Ahad Patel 2024-10-08 17:21:18 +05:30 • committed by GitHub
commit 647a53e257
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 439 additions and 49 deletions

View file

@ -23,41 +23,69 @@ final CommandShortcutEvent indentCommand = CommandShortcutEvent(
bool isIndentable(EditorState editorState) { bool isIndentable(EditorState editorState) {
final selection = editorState.selection; final selection = editorState.selection;
if (selection == null || !selection.isSingle) { if (selection == null) {
return false; return false;
} }
final node = editorState.getNodeAtPath(selection.end.path);
final previous = node?.previous; List<Node> nodes = editorState.getNodesInSelection(selection).normalized;
if (node == null || if (nodes.isEmpty) {
previous == null ||
!indentableBlockTypes.contains(previous.type) ||
!indentableBlockTypes.contains(node.type)) {
return false; return false;
} }
final previous = nodes.firstOrNull?.previous;
if (previous == null || !indentableBlockTypes.contains(previous.type)) {
return false;
}
// there's no need to consider the child nodes
// since we are ignoring child nodes, all nodes will be on same level
nodes =
nodes.where((node) => node.path.length == previous.path.length).toList();
final isAllIndentable = nodes.every(
(node) => indentableBlockTypes.contains(node.type),
);
if (!isAllIndentable) {
return false;
}
return true; return true;
} }
CommandShortcutEventHandler _indentCommandHandler = (editorState) { CommandShortcutEventHandler _indentCommandHandler = (editorState) {
final selection = editorState.selection; final selection = editorState.selection?.normalized;
if (selection == null || !selection.isSingle) {
if (selection == null) {
return KeyEventResult.ignored; return KeyEventResult.ignored;
} }
final node = editorState.getNodeAtPath(selection.end.path);
final previous = node?.previous; if (!isIndentable(editorState)) {
if (node == null || // ignore the system default tab behavior
previous == null || return KeyEventResult.handled;
!indentableBlockTypes.contains(previous.type) ||
!indentableBlockTypes.contains(node.type)) {
return KeyEventResult.handled; // ignore the system default tab behavior
} }
final path = previous.path + [previous.children.length];
List<Node> nodes = editorState.getNodesInSelection(selection).normalized;
final previous = nodes.firstOrNull?.previous;
if (previous == null) {
return KeyEventResult.ignored;
}
// keep the nodes in the same level as the previous block
nodes =
nodes.where((node) => node.path.length == previous.path.length).toList();
final startPath = previous.path + [previous.children.length];
final endPath = previous.path + [previous.children.length + nodes.length - 1];
final afterSelection = Selection( final afterSelection = Selection(
start: selection.start.copyWith(path: path), start: selection.start.copyWith(path: startPath),
end: selection.end.copyWith(path: path), end: selection.end.copyWith(path: endPath),
); );
final transaction = editorState.transaction final transaction = editorState.transaction
..deleteNode(node) ..deleteNodes(nodes)
..insertNode(path, node, deepCopy: true) ..insertNodes(startPath, nodes, deepCopy: true)
..afterSelection = afterSelection; ..afterSelection = afterSelection;
editorState.apply(transaction); editorState.apply(transaction);

View file

@ -16,50 +16,78 @@ final CommandShortcutEvent outdentCommand = CommandShortcutEvent(
bool isOutdentable(EditorState editorState) { bool isOutdentable(EditorState editorState) {
final selection = editorState.selection; final selection = editorState.selection;
if (selection == null || !selection.isSingle) { if (selection == null) {
return false; return false;
} }
final node = editorState.getNodeAtPath(selection.end.path);
final parent = node?.parent; List<Node> nodes = editorState.getNodesInSelection(selection).normalized;
if (node == null || if (nodes.isEmpty) {
parent == null || return false;
!indentableBlockTypes.contains(node.type) || }
!indentableBlockTypes.contains(parent.type) ||
node.path.length == 1) { final parent = nodes.firstOrNull?.parent;
// if the current node is having a path which is of size 1. if (parent == null || !indentableBlockTypes.contains(parent.type)) {
return false;
}
if (nodes.any((node) => node.path.length == 1)) {
// if the any nodes is having a path which is of size 1.
// for example [0], then that means, it is not indented // for example [0], then that means, it is not indented
// thus we ignore this event. // thus we ignore this event.
return false; return false;
} }
// keep only immediate children nodes of parent
// since we are keeping only immediate children nodes, all nodes will be on same level
nodes = nodes
.where((node) => node.path.length == parent.path.length + 1)
.toList();
final isAllIndentable =
nodes.every((node) => indentableBlockTypes.contains(node.type));
if (!isAllIndentable) {
return false;
}
return true; return true;
} }
CommandShortcutEventHandler _outdentCommandHandler = (editorState) { CommandShortcutEventHandler _outdentCommandHandler = (editorState) {
final selection = editorState.selection; final selection = editorState.selection?.normalized;
if (selection == null || !selection.isSingle) { if (selection == null) {
return KeyEventResult.ignored; return KeyEventResult.ignored;
} }
final node = editorState.getNodeAtPath(selection.end.path);
final parent = node?.parent; if (!isOutdentable(editorState)) {
if (node == null || // ignore the system default tab behavior
parent == null || return KeyEventResult.handled;
!indentableBlockTypes.contains(node.type) ||
!indentableBlockTypes.contains(parent.type) ||
node.path.length == 1) {
// if the current node is having a path which is of size 1.
// for example [0], then that means, it is not indented
// thus we ignore this event.
return KeyEventResult.handled; // ignore the system default tab behavior
} }
final path = node.path.sublist(0, node.path.length - 1)..last += 1; List<Node> nodes = editorState.getNodesInSelection(selection).normalized;
final parent = nodes.firstOrNull?.parent;
if (parent == null) {
return KeyEventResult.ignored;
}
// keep the nodes of the immediate children of parent node
nodes = nodes
.where((node) => node.path.length == parent.path.length + 1)
.toList();
final startPath = nodes.first.path.sublist(0, nodes.first.path.length - 1)
..last += 1;
final endPath = nodes.last.path.sublist(0, nodes.last.path.length - 1)
..last += nodes.length;
final afterSelection = Selection( final afterSelection = Selection(
start: selection.start.copyWith(path: path), start: selection.start.copyWith(path: startPath),
end: selection.end.copyWith(path: path), end: selection.end.copyWith(path: endPath),
); );
final transaction = editorState.transaction final transaction = editorState.transaction
..deleteNode(node) ..deleteNodes(nodes)
..insertNode(path, node, deepCopy: true) ..insertNodes(startPath, nodes, deepCopy: true)
..afterSelection = afterSelection; ..afterSelection = afterSelection;
editorState.apply(transaction); editorState.apply(transaction);

View file

@ -139,6 +139,151 @@ void main() async {
expect(nestedBlockAfter?.indentPadding.left, _padding); expect(nestedBlockAfter?.indentPadding.left, _padding);
expect(nestedBlockAfter?.indentPadding.right, 0); expect(nestedBlockAfter?.indentPadding.right, 0);
await editor.dispose();
});
});
group('indentCommand (multi-line) - widget test multi-line indent padding',
() {
testWidgets("indent 2 LTR lines under LTR line", (tester) async {
final editor = await multiLineIndentTestHelper(
tester,
('Hello', blockComponentTextDirectionLTR),
('Will indent this', blockComponentTextDirectionLTR),
('Third Line', blockComponentTextDirectionLTR),
);
final node = editor.nodeAtPath([0])!;
final nestedBlock = node.key.currentState!
.unwrapOrNull<NestedBlockComponentStatefulWidgetMixin>();
expect(nestedBlock?.indentPadding.left, _padding);
expect(nestedBlock?.indentPadding.right, 0);
await editor.dispose();
});
testWidgets("indent two LTR lines under RTL line", (tester) async {
final editor = await multiLineIndentTestHelper(
tester,
('سلام', blockComponentTextDirectionRTL),
('Will indent this', blockComponentTextDirectionLTR),
('Third Line', blockComponentTextDirectionLTR),
);
final node = editor.nodeAtPath([0])!;
final nestedBlock = node.key.currentState!
.unwrapOrNull<NestedBlockComponentStatefulWidgetMixin>();
expect(nestedBlock?.indentPadding.left, _padding);
expect(nestedBlock?.indentPadding.right, 0);
await editor.dispose();
});
testWidgets("indent two RTL lines under RTL line", (tester) async {
final editor = await multiLineIndentTestHelper(
tester,
('سلام', blockComponentTextDirectionRTL),
('خط دوم', blockComponentTextDirectionRTL),
('خط سوم', blockComponentTextDirectionRTL),
);
await editor.pressKey(key: LogicalKeyboardKey.tab);
await tester.pumpAndSettle();
final node = editor.nodeAtPath([0])!;
final nestedBlock = node.key.currentState!
.unwrapOrNull<NestedBlockComponentStatefulWidgetMixin>();
expect(nestedBlock?.indentPadding.left, 0);
expect(nestedBlock?.indentPadding.right, _padding);
await editor.dispose();
});
testWidgets("indent two RTL lines under LTR line", (tester) async {
final editor = await multiLineIndentTestHelper(
tester,
('Hello', blockComponentTextDirectionLTR),
('خط دوم', blockComponentTextDirectionRTL),
('خط سوم', blockComponentTextDirectionRTL),
);
await editor.pressKey(key: LogicalKeyboardKey.tab);
await tester.pumpAndSettle();
final node = editor.nodeAtPath([0])!;
final nestedBlock = node.key.currentState!
.unwrapOrNull<NestedBlockComponentStatefulWidgetMixin>();
expect(nestedBlock?.indentPadding.left, 0);
expect(nestedBlock?.indentPadding.right, _padding);
await editor.dispose();
});
testWidgets("indent one RTL line and one LTR line under LTR line",
(tester) async {
final editor = await multiLineIndentTestHelper(
tester,
('Hello', blockComponentTextDirectionLTR),
('خط دوم', blockComponentTextDirectionRTL),
('Third Line', blockComponentTextDirectionLTR),
);
await editor.pressKey(key: LogicalKeyboardKey.tab);
await tester.pumpAndSettle();
final node = editor.nodeAtPath([0])!;
final nestedBlock = node.key.currentState!
.unwrapOrNull<NestedBlockComponentStatefulWidgetMixin>();
expect(nestedBlock?.indentPadding.left, 0);
expect(nestedBlock?.indentPadding.right, _padding);
await editor.dispose();
});
testWidgets("indent one LTR line and one RTL lines under RTL line",
(tester) async {
final editor = await multiLineIndentTestHelper(
tester,
('سلام', blockComponentTextDirectionRTL),
('Will indent this', blockComponentTextDirectionLTR),
('خط سوم', blockComponentTextDirectionRTL),
);
await editor.pressKey(key: LogicalKeyboardKey.tab);
await tester.pumpAndSettle();
final node = editor.nodeAtPath([0])!;
final nestedBlock = node.key.currentState!
.unwrapOrNull<NestedBlockComponentStatefulWidgetMixin>();
expect(nestedBlock?.indentPadding.left, _padding);
expect(nestedBlock?.indentPadding.right, 0);
await editor.dispose();
});
testWidgets("indent AUTO line under AUTO line", (tester) async {
final editor = await multiLineIndentTestHelper(
tester,
('سلام', blockComponentTextDirectionAuto),
('خط دوم', blockComponentTextDirectionAuto),
('خط سوم', blockComponentTextDirectionAuto),
);
await editor.pressKey(key: LogicalKeyboardKey.tab);
await tester.pumpAndSettle();
final node = editor.nodeAtPath([0])!;
final nestedBlock = node.key.currentState!
.unwrapOrNull<NestedBlockComponentStatefulWidgetMixin>();
expect(nestedBlock?.indentPadding.left, 0);
expect(nestedBlock?.indentPadding.right, _padding);
await editor.dispose(); await editor.dispose();
}); });
}); });
@ -170,3 +315,31 @@ Future<TestableEditor> indentTestHelper(
return editor; return editor;
} }
Future<TestableEditor> multiLineIndentTestHelper(
WidgetTester tester,
TestLine firstLine,
TestLine secondLine,
TestLine thirdLine,
) async {
final editor = tester.editor
..addNode(paragraphNode(text: firstLine.$1, textDirection: firstLine.$2))
..addNode(paragraphNode(text: secondLine.$1, textDirection: secondLine.$2))
..addNode(paragraphNode(text: thirdLine.$1, textDirection: thirdLine.$2));
await editor.startTesting();
final selection = Selection(
start: Position(path: [1], offset: 1),
end: Position(path: [2], offset: 1),
);
await editor.updateSelection(selection);
await editor.pressKey(key: LogicalKeyboardKey.tab);
await tester.pumpAndSettle();
final node = editor.nodeAtPath([0])!;
expect(node.delta?.toPlainText(), firstLine.$1);
expect(node.children.every((element) => element.level == 2), true);
return editor;
}

View file

@ -0,0 +1,376 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../../infra/testable_editor.dart';
void main() async {
setUpAll(() {
TestWidgetsFlutterBinding.ensureInitialized();
});
group('outdent_command.dart', () {
testWidgets("press shift tab in plain text", (tester) async {
const text = 'Welcome to Appflowy 😁';
final editor = tester.editor..addParagraphs(3, initialText: text);
await editor.startTesting();
final snapshotDocument = editor.document;
await editor.updateSelection(Selection.single(path: [0], startOffset: 0));
await editor.pressKey(
key: LogicalKeyboardKey.tab,
isShiftPressed: true,
);
// nothing happens
expect(
editor.selection,
Selection.single(path: [0], startOffset: 0),
);
expect(editor.document.toJson(), snapshotDocument.toJson());
});
testWidgets("press shift tab where previous element is not list element",
(tester) async {
const text = 'Welcome to Appflowy 😁';
final editor = tester.editor
..addParagraph(initialText: text)
..addNode(bulletedListNode(delta: Delta()..insert(text)))
..addNode(bulletedListNode(delta: Delta()..insert(text)));
await editor.startTesting();
final snapshotDocument = editor.document;
final selection = Selection.single(path: [1], startOffset: 0);
await editor.updateSelection(selection);
await editor.pressKey(
key: LogicalKeyboardKey.tab,
isShiftPressed: true,
);
// nothing happens
expect(
editor.selection,
Selection.single(path: [1], startOffset: 0),
);
expect(editor.document.toJson(), snapshotDocument.toJson());
await editor.dispose();
});
testWidgets(
"press shift tab in indented list with multiple nodes in same sub-level",
(tester) async {
const text = 'Welcome to Appflowy 😁';
final editor = tester.editor
..addNode(todoListNode(checked: false, delta: Delta()..insert(text)))
..addNode(todoListNode(checked: false, delta: Delta()..insert(text)))
..addNode(todoListNode(checked: false, delta: Delta()..insert(text)));
await editor.startTesting();
final selection = Selection.collapsed(Position(path: [1]));
await editor.updateSelection(selection);
await editor.pressKey(key: LogicalKeyboardKey.tab);
await editor.updateSelection(selection);
await editor.pressKey(key: LogicalKeyboardKey.tab);
// Before
// [] Welcome to Appflowy 😁
// [] Welcome to Appflowy 😁
// [] Welcome to Appflowy 😁
// After
// [] Welcome to Appflowy 😁
// [] Welcome to Appflowy 😁
// [] Welcome to Appflowy 😁
expect(
editor.selection,
Selection.collapsed(Position(path: [0, 1])),
);
expect(
editor.nodeAtPath([0])!.type,
'todo_list',
);
expect(editor.nodeAtPath([1]), null);
expect(editor.nodeAtPath([2]), null);
expect(
editor.nodeAtPath([0, 0])!.type,
'todo_list',
);
expect(
editor.nodeAtPath([0, 1])!.type,
'todo_list',
);
await editor.updateSelection(
Selection.single(path: [0, 1], startOffset: 0),
);
await editor.pressKey(
key: LogicalKeyboardKey.tab,
isShiftPressed: true,
);
// Before
// [] Welcome to Appflowy 😁
// [] Welcome to Appflowy 😁
// [] Welcome to Appflowy 😁
// After
// [] Welcome to Appflowy 😁
// [] Welcome to Appflowy 😁
// [] Welcome to Appflowy 😁
expect(
editor.nodeAtPath([1])!.type,
'todo_list',
);
expect(
editor.nodeAtPath([0, 0])!.type,
'todo_list',
);
expect(editor.nodeAtPath([0, 1]), null);
await editor.dispose();
},
);
testWidgets(
"press shift tab in indented list with only one node in same sub-level",
(tester) async {
const text = 'Welcome to Appflowy 😁';
final editor = tester.editor
..addNode(bulletedListNode(delta: Delta()..insert(text)))
..addNode(bulletedListNode(delta: Delta()..insert(text)))
..addNode(bulletedListNode(delta: Delta()..insert(text)));
await editor.startTesting();
var selection = Selection.single(path: [1], startOffset: 0);
await editor.updateSelection(selection);
await editor.pressKey(key: LogicalKeyboardKey.tab);
// Before
// * Welcome to Appflowy 😁
// * Welcome to Appflowy 😁
// * Welcome to Appflowy 😁
// After
// * Welcome to Appflowy 😁
// * Welcome to Appflowy 😁
// * Welcome to Appflowy 😁
expect(
editor.selection,
Selection.single(path: [0, 0], startOffset: 0),
);
expect(
editor.nodeAtPath([0])!.type,
'bulleted_list',
);
expect(
editor.nodeAtPath([0, 0])!.type,
'bulleted_list',
);
expect(
editor.nodeAtPath([1])!.type,
'bulleted_list',
);
expect(editor.nodeAtPath([2]), null);
await editor
.updateSelection(Selection.single(path: [0, 0], startOffset: 0));
await editor.pressKey(
key: LogicalKeyboardKey.tab,
isShiftPressed: true,
);
// Before
// * Welcome to Appflowy 😁
// * Welcome to Appflowy 😁
// * Welcome to Appflowy 😁
// After
// * Welcome to Appflowy 😁
// * Welcome to Appflowy 😁
// * Welcome to Appflowy 😁
expect(
editor.nodeAtPath([0])!.type,
'bulleted_list',
);
expect(
editor.nodeAtPath([1])!.type,
'bulleted_list',
);
expect(
editor.nodeAtPath([2])!.type,
'bulleted_list',
);
expect(editor.nodeAtPath([0, 0]), null);
await editor.dispose();
},
);
});
group('outdentCommand (multi-line) - widget test multi-line outdent padding',
() {
testWidgets("indent two items of same level then outdent it",
(tester) async {
const text = 'Welcome to Appflowy 😁';
final editor = tester.editor
..addNode(bulletedListNode(text: text))
..addNode(bulletedListNode(text: text))
..addNode(bulletedListNode(text: text));
await editor.startTesting();
var selection = Selection(
start: Position(path: [1], offset: 1),
end: Position(path: [2], offset: 1),
);
await editor.updateSelection(selection);
await editor.pressKey(key: LogicalKeyboardKey.tab);
// Before
// • Welcome to Appflowy 😁
// • Welcome to Appflowy 😁
// • Welcome to Appflowy 😁
// After
// • Welcome to Appflowy 😁
// ○ Welcome to Appflowy 😁
// ○ Welcome to Appflowy 😁
expect(
editor.nodeAtPath([0])!.type,
'bulleted_list',
);
expect(
editor.nodeAtPath([0, 0])!.type,
'bulleted_list',
);
expect(
editor.nodeAtPath([0, 1])!.type,
'bulleted_list',
);
expect(editor.nodeAtPath([1]), null);
expect(editor.nodeAtPath([2]), null);
selection = Selection(
start: Position(path: [0, 0], offset: 1),
end: Position(path: [0, 1], offset: 1),
);
await editor.updateSelection(selection);
await editor.pressKey(
key: LogicalKeyboardKey.tab,
isShiftPressed: true,
);
// Before
// • Welcome to Appflowy 😁
// ○ Welcome to Appflowy 😁
// ○ Welcome to Appflowy 😁
// After
// • Welcome to Appflowy 😁
// • Welcome to Appflowy 😁
// • Welcome to Appflowy 😁
expect(
editor.nodeAtPath([0])!.type,
'bulleted_list',
);
expect(
editor.nodeAtPath([1])!.type,
'bulleted_list',
);
expect(
editor.nodeAtPath([2])!.type,
'bulleted_list',
);
expect(editor.nodeAtPath([0, 0]), null);
await editor.dispose();
});
testWidgets("ignore if any bullet list of level 1 selected",
(tester) async {
const text = 'Welcome to Appflowy 😁';
final editor = tester.editor
..addNode(bulletedListNode(text: text))
..addNode(bulletedListNode(text: text))
..addNode(bulletedListNode(text: text));
await editor.startTesting();
var selection = Selection.single(path: [1], startOffset: 0);
await editor.updateSelection(selection);
await editor.pressKey(key: LogicalKeyboardKey.tab);
// Before
// • Welcome to Appflowy 😁
// • Welcome to Appflowy 😁
// • Welcome to Appflowy 😁
// After
// • Welcome to Appflowy 😁
// ○ Welcome to Appflowy 😁
// • Welcome to Appflowy 😁
expect(
editor.nodeAtPath([0])!.type,
'bulleted_list',
);
expect(
editor.nodeAtPath([1])!.type,
'bulleted_list',
);
expect(
editor.nodeAtPath([0, 0])!.type,
'bulleted_list',
);
expect(editor.nodeAtPath([2]), null);
selection = Selection(
start: Position(path: [1], offset: 1),
end: Position(path: [0, 0], offset: 1),
);
await editor.updateSelection(selection);
await editor.pressKey(
key: LogicalKeyboardKey.tab,
isShiftPressed: true,
);
// list will remain same as node with path [1] which is on level = 1,
// Before
// • Welcome to Appflowy 😁
// ○ Welcome to Appflowy 😁
// • Welcome to Appflowy 😁
// After
// • Welcome to Appflowy 😁
// ○ Welcome to Appflowy 😁
// • Welcome to Appflowy 😁
expect(
editor.nodeAtPath([0])!.type,
'bulleted_list',
);
expect(
editor.nodeAtPath([0, 0])!.type,
'bulleted_list',
);
expect(
editor.nodeAtPath([1])!.type,
'bulleted_list',
);
await editor.dispose();
});
});
}