feat: turn into multiple lines (#6558)

* feat: select multiple lines with block selection style

* feat: multiple nodes conversion

* fix: exclude children for the block can't contain children

* chore: update editor version

* fix: unit test

* test: convert nested list to heading/quote/callout

* test: transform nodes at the same level into another block type

* test: add undo redo for turn into

* test: add multi lines integration test

* chore: remove debug logs

* fix: integration test
This commit is contained in:
Lucas 2024-10-16 22:19:18 +08:00 committed by GitHub
parent b1682e4f54
commit a8bcab7770
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 528 additions and 161 deletions

View File

@ -59,7 +59,7 @@ void main() {
expect(editorState.getNodeAtPath([2])?.delta?.toPlainText(), isEmpty); expect(editorState.getNodeAtPath([2])?.delta?.toPlainText(), isEmpty);
}); });
testWidgets('turn into', (tester) async { testWidgets('turn into - single line', (tester) async {
await tester.initializeAppFlowy(); await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton(); await tester.tapAnonymousSignInButton();
@ -97,5 +97,51 @@ void main() {
); );
} }
}); });
testWidgets('turn into - multi lines', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
const name = 'Test Document';
await tester.createNewPageWithNameUnderParent(name: name);
await tester.openPage(name);
await tester.editor.tapLineOfEditorAt(0);
await tester.ime.insertText('turn into 1');
await tester.ime.insertCharacter('\n');
await tester.ime.insertText('turn into 2');
// click the block option button to convert it to another blocks
final values = {
LocaleKeys.document_slashMenu_name_heading1.tr(): HeadingBlockKeys.type,
LocaleKeys.document_slashMenu_name_heading2.tr(): HeadingBlockKeys.type,
LocaleKeys.document_slashMenu_name_heading3.tr(): HeadingBlockKeys.type,
LocaleKeys.document_slashMenu_name_bulletedList.tr():
BulletedListBlockKeys.type,
LocaleKeys.document_slashMenu_name_numberedList.tr():
NumberedListBlockKeys.type,
LocaleKeys.document_slashMenu_name_quote.tr(): QuoteBlockKeys.type,
LocaleKeys.document_slashMenu_name_todoList.tr():
TodoListBlockKeys.type,
LocaleKeys.document_slashMenu_name_callout.tr(): CalloutBlockKeys.type,
LocaleKeys.document_slashMenu_name_text.tr(): ParagraphBlockKeys.type,
};
for (final value in values.entries) {
final editorState = tester.editor.getCurrentEditorState();
editorState.selection = Selection(
start: Position(path: [0]),
end: Position(path: [1], offset: 2),
);
final menuText = value.key;
final afterType = value.value;
await turnIntoBlock(
tester,
[0],
menuText: menuText,
afterType: afterType,
);
}
});
}); });
} }

View File

@ -132,6 +132,11 @@ void _customBlockOptionActions(
if (UniversalPlatform.isDesktop) { if (UniversalPlatform.isDesktop) {
builder.showActions = builder.showActions =
(node) => node.parent?.type != TableCellBlockKeys.type; (node) => node.parent?.type != TableCellBlockKeys.type;
builder.configuration = builder.configuration.copyWith(
blockSelectionAreaMargin: (_) => const EdgeInsets.symmetric(
vertical: 1,
),
);
builder.actionBuilder = (context, state) { builder.actionBuilder = (context, state) {
final top = builder.configuration.padding(context.node).top; final top = builder.configuration.padding(context.node).top;

View File

@ -209,22 +209,33 @@ class BlockActionOptionCubit extends Cubit<BlockActionOptionState> {
Node node, { Node node, {
int? level, int? level,
}) async { }) async {
final selection = editorState.selection;
if (selection == null) {
return false;
}
final toType = type; final toType = type;
// only handle the node in the same depth
final selectedNodes = editorState
.getNodesInSelection(selection.normalized)
.where((e) => e.path.length == node.path.length)
.toList();
Log.info('turnIntoBlock selectedNodes $selectedNodes');
final insertedNode = <Node>[];
for (final node in selectedNodes) {
Log.info( Log.info(
'Turn into block: from ${node.type} to $type', 'Turn into block: from ${node.type} to $type',
); );
if (type == node.type && type != HeadingBlockKeys.type) {
Log.info('Block type is the same');
return false;
}
Node afterNode = node.copyWith( Node afterNode = node.copyWith(
type: type, type: type,
attributes: { attributes: {
if (toType == HeadingBlockKeys.type) HeadingBlockKeys.level: level, if (toType == HeadingBlockKeys.type) HeadingBlockKeys.level: level,
if (toType == TodoListBlockKeys.type) TodoListBlockKeys.checked: false, if (toType == TodoListBlockKeys.type)
TodoListBlockKeys.checked: false,
blockComponentBackgroundColor: blockComponentBackgroundColor:
node.attributes[blockComponentBackgroundColor], node.attributes[blockComponentBackgroundColor],
blockComponentTextDirection: blockComponentTextDirection:
@ -232,21 +243,26 @@ class BlockActionOptionCubit extends Cubit<BlockActionOptionState> {
blockComponentDelta: (node.delta ?? Delta()).toJson(), blockComponentDelta: (node.delta ?? Delta()).toJson(),
}, },
); );
final insertedNode = [];
// heading block and callout block should not have children // heading block and callout block should not have children
if ([HeadingBlockKeys.type, CalloutBlockKeys.type].contains(toType)) { if ([HeadingBlockKeys.type, CalloutBlockKeys.type, QuoteBlockKeys.type]
.contains(toType)) {
afterNode = afterNode.copyWith( afterNode = afterNode.copyWith(
children: [], children: [],
); );
insertedNode.add(afterNode);
insertedNode.addAll(node.children.map((e) => e.copyWith())); insertedNode.addAll(node.children.map((e) => e.copyWith()));
} else {
insertedNode.add(afterNode);
}
} }
final transaction = editorState.transaction; final transaction = editorState.transaction;
transaction.insertNodes(node.path, [ transaction.insertNodes(
afterNode, node.path,
...insertedNode, insertedNode,
]); );
transaction.deleteNode(node); transaction.deleteNodes(selectedNodes);
await editorState.apply(transaction); await editorState.apply(transaction);
return true; return true;

View File

@ -1,5 +1,3 @@
import 'package:flutter/material.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_notification.dart'; import 'package:appflowy/plugins/document/presentation/editor_notification.dart';
@ -16,6 +14,7 @@ import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_popover/appflowy_popover.dart'; import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
// this flag is used to disable the tooltip of the block when it is dragged // this flag is used to disable the tooltip of the block when it is dragged
@ -248,7 +247,7 @@ class _OptionButtonFeedbackState extends State<_OptionButtonFeedback> {
} }
} }
class _OptionButton extends StatelessWidget { class _OptionButton extends StatefulWidget {
const _OptionButton({ const _OptionButton({
required this.controller, required this.controller,
required this.editorState, required this.editorState,
@ -261,10 +260,45 @@ class _OptionButton extends StatelessWidget {
final BlockComponentContext blockComponentContext; final BlockComponentContext blockComponentContext;
final ValueNotifier<bool> isDragging; final ValueNotifier<bool> isDragging;
@override
State<_OptionButton> createState() => _OptionButtonState();
}
const _interceptorKey = 'document_option_button_interceptor';
class _OptionButtonState extends State<_OptionButton> {
late final gestureInterceptor = SelectionGestureInterceptor(
key: _interceptorKey,
canTap: (details) => !_isTapInBounds(details.globalPosition),
);
// the selection will be cleared when tap the option button
// so we need to restore the selection after tap the option button
Selection? beforeSelection;
RenderBox? get renderBox => context.findRenderObject() as RenderBox?;
@override
void initState() {
super.initState();
widget.editorState.service.selectionService.registerGestureInterceptor(
gestureInterceptor,
);
}
@override
void dispose() {
widget.editorState.service.selectionService.unregisterGestureInterceptor(
_interceptorKey,
);
super.dispose();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ValueListenableBuilder( return ValueListenableBuilder(
valueListenable: isDragging, valueListenable: widget.isDragging,
builder: (context, isDragging, child) { builder: (context, isDragging, child) {
return BlockActionButton( return BlockActionButton(
svg: FlowySvgs.drag_element_s, svg: FlowySvgs.drag_element_s,
@ -291,7 +325,11 @@ class _OptionButton extends StatelessWidget {
], ],
), ),
onTap: () { onTap: () {
controller.show(); if (widget.editorState.selection != null) {
beforeSelection = widget.editorState.selection;
}
widget.controller.show();
// update selection // update selection
_updateBlockSelection(); _updateBlockSelection();
@ -302,23 +340,35 @@ class _OptionButton extends StatelessWidget {
} }
void _updateBlockSelection() { void _updateBlockSelection() {
final startNode = blockComponentContext.node; if (beforeSelection == null) {
var endNode = startNode; final path = widget.blockComponentContext.node.path;
while (endNode.children.isNotEmpty) { final selection = Selection.collapsed(
endNode = endNode.children.last; Position(path: path),
}
final start = Position(path: startNode.path);
final end = endNode.selectable?.end() ??
Position(
path: endNode.path,
offset: endNode.delta?.length ?? 0,
); );
widget.editorState.updateSelectionWithReason(
editorState.selectionType = SelectionType.block; selection,
editorState.selection = Selection( customSelectionType: SelectionType.block,
start: start, );
end: end, } else {
widget.editorState.updateSelectionWithReason(
beforeSelection!,
customSelectionType: SelectionType.block,
); );
} }
} }
bool _isTapInBounds(Offset offset) {
if (renderBox == null) {
return false;
}
final localPosition = renderBox!.globalToLocal(offset);
final result = renderBox!.paintBounds.contains(localPosition);
if (result) {
beforeSelection = widget.editorState.selection;
} else {
beforeSelection = null;
}
return result;
}
}

View File

@ -53,8 +53,8 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
path: "." path: "."
ref: a0b3c72 ref: bcd1208
resolved-ref: a0b3c7289b8c4073b47793f665e70a511324f9b9 resolved-ref: bcd12082fea75cdbbea8b090bf938aae7dc9f4ad
url: "https://github.com/AppFlowy-IO/appflowy-editor.git" url: "https://github.com/AppFlowy-IO/appflowy-editor.git"
source: git source: git
version: "4.0.0" version: "4.0.0"
@ -1535,10 +1535,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: platform name: platform
sha256: "12220bb4b65720483f8fa9450b4332347737cf8213dd2840d8b2c823e47243ec" sha256: "9b71283fc13df574056616011fb138fd3b793ea47cc509c189a6c3fa5f8a1a65"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.1.4" version: "3.1.5"
plugin_platform_interface: plugin_platform_interface:
dependency: "direct dev" dependency: "direct dev"
description: description:
@ -1933,10 +1933,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: string_scanner name: string_scanner
sha256: "556692adab6cfa87322a115640c11f13cb77b3f076ddcc5d6ae3c20242bedcde" sha256: "688af5ed3402a4bde5b3a6c15fd768dbf2621a614950b17f04626c431ab3c4c3"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.2.0" version: "1.3.0"
string_validator: string_validator:
dependency: "direct main" dependency: "direct main"
description: description:
@ -2238,10 +2238,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: vm_service name: vm_service
sha256: "3923c89304b715fb1eb6423f017651664a03bf5f4b29983627c4da791f74a4ec" sha256: "5c5f338a667b4c644744b661f309fb8080bb94b18a7e91ef1dbd343bed00ed6d"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "14.2.1" version: "14.2.5"
watcher: watcher:
dependency: transitive dependency: transitive
description: description:

View File

@ -43,7 +43,7 @@ dependencies:
# Desktop Drop uses Cross File (XFile) data type # Desktop Drop uses Cross File (XFile) data type
desktop_drop: ^0.4.4 desktop_drop: ^0.4.4
device_info_plus: ^10.1.0 device_info_plus:
dotted_border: ^2.0.0+3 dotted_border: ^2.0.0+3
easy_localization: ^3.0.2 easy_localization: ^3.0.2
envied: ^0.5.2 envied: ^0.5.2
@ -161,6 +161,7 @@ dev_dependencies:
dependency_overrides: dependency_overrides:
http: ^1.0.0 http: ^1.0.0
device_info_plus: ^10.1.0
url_protocol: url_protocol:
git: git:
@ -170,7 +171,7 @@ dependency_overrides:
appflowy_editor: appflowy_editor:
git: git:
url: https://github.com/AppFlowy-IO/appflowy-editor.git url: https://github.com/AppFlowy-IO/appflowy-editor.git
ref: "a0b3c72" ref: "bcd1208"
appflowy_editor_plugins: appflowy_editor_plugins:
git: git:

View File

@ -2,6 +2,7 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/bl
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/log.dart';
import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
void main() { void main() {
@ -16,6 +17,7 @@ void main() {
Document document, Document document,
String originalType, String originalType,
String originalText, { String originalText, {
Selection? selection,
String? toType, String? toType,
void Function(EditorState editorState, Node node)? afterTurnInto, void Function(EditorState editorState, Node node)? afterTurnInto,
}) async { }) async {
@ -33,6 +35,12 @@ void main() {
continue; continue;
} }
editorState.selectionType = SelectionType.block;
editorState.selection = selection ??
Selection.collapsed(
Position(path: [0]),
);
final node = editorState.getNodeAtPath([0])!; final node = editorState.getNodeAtPath([0])!;
expect(node.type, originalType); expect(node.type, originalType);
final result = await cubit.turnIntoBlock( final result = await cubit.turnIntoBlock(
@ -49,6 +57,11 @@ void main() {
); );
// turn it back the originalType for the next test // turn it back the originalType for the next test
editorState.selectionType = SelectionType.block;
editorState.selection = selection ??
Selection.collapsed(
Position(path: [0]),
);
await cubit.turnIntoBlock( await cubit.turnIntoBlock(
originalType, originalType,
newNode, newNode,
@ -165,7 +178,12 @@ void main() {
); );
}); });
test('from nested list to heading', () async { for (final type in [
HeadingBlockKeys.type,
QuoteBlockKeys.type,
CalloutBlockKeys.type,
]) {
test('from nested bulleted list to $type', () async {
const text = 'bulleted list'; const text = 'bulleted list';
const nestedText1 = 'nested bulleted list 1'; const nestedText1 = 'nested bulleted list 1';
const nestedText2 = 'nested bulleted list 2'; const nestedText2 = 'nested bulleted list 2';
@ -190,9 +208,9 @@ void main() {
document, document,
BulletedListBlockKeys.type, BulletedListBlockKeys.type,
text, text,
toType: HeadingBlockKeys.type, toType: type,
afterTurnInto: (editorState, node) { afterTurnInto: (editorState, node) {
expect(node.type, HeadingBlockKeys.type); expect(node.type, type);
expect(node.children.length, 0); expect(node.children.length, 0);
expect(node.delta!.toPlainText(), text); expect(node.delta!.toPlainText(), text);
@ -224,8 +242,14 @@ void main() {
}, },
); );
}); });
}
test('from numbered list to heading', () async { for (final type in [
HeadingBlockKeys.type,
QuoteBlockKeys.type,
CalloutBlockKeys.type,
]) {
test('from nested numbered list to $type', () async {
const text = 'numbered list'; const text = 'numbered list';
const nestedText1 = 'nested numbered list 1'; const nestedText1 = 'nested numbered list 1';
const nestedText2 = 'nested numbered list 2'; const nestedText2 = 'nested numbered list 2';
@ -250,9 +274,9 @@ void main() {
document, document,
NumberedListBlockKeys.type, NumberedListBlockKeys.type,
text, text,
toType: HeadingBlockKeys.type, toType: type,
afterTurnInto: (editorState, node) { afterTurnInto: (editorState, node) {
expect(node.type, HeadingBlockKeys.type); expect(node.type, type);
expect(node.children.length, 0); expect(node.children.length, 0);
expect(node.delta!.toPlainText(), text); expect(node.delta!.toPlainText(), text);
@ -284,5 +308,230 @@ void main() {
}, },
); );
}); });
}
for (final type in [
HeadingBlockKeys.type,
QuoteBlockKeys.type,
CalloutBlockKeys.type,
]) {
// numbered list, bulleted list, todo list
// before
// - numbered list 1
// - nested list 1
// - bulleted list 2
// - nested list 2
// - todo list 3
// - nested list 3
// after
// - heading 1
// - nested list 1
// - heading 2
// - nested list 2
// - heading 3
// - nested list 3
test('from nested mixed list to $type', () async {
const text1 = 'numbered list 1';
const text2 = 'bulleted list 2';
const text3 = 'todo list 3';
const nestedText1 = 'nested list 1';
const nestedText2 = 'nested list 2';
const nestedText3 = 'nested list 3';
final document = createDocument([
numberedListNode(
delta: Delta()..insert(text1),
children: [
numberedListNode(
delta: Delta()..insert(nestedText1),
),
],
),
bulletedListNode(
delta: Delta()..insert(text2),
children: [
bulletedListNode(
delta: Delta()..insert(nestedText2),
),
],
),
todoListNode(
checked: false,
text: text3,
children: [
todoListNode(
checked: false,
text: nestedText3,
),
],
),
]);
await checkTurnInto(
document,
NumberedListBlockKeys.type,
text1,
toType: type,
selection: Selection(
start: Position(path: [0]),
end: Position(path: [2]),
),
afterTurnInto: (editorState, node) {
final nodes = editorState.document.root.children;
expect(nodes.length, 6);
final texts = [
text1,
nestedText1,
text2,
nestedText2,
text3,
nestedText3,
];
final types = [
type,
NumberedListBlockKeys.type,
type,
BulletedListBlockKeys.type,
type,
TodoListBlockKeys.type,
];
for (var i = 0; i < 6; i++) {
expect(nodes[i].type, types[i]);
expect(nodes[i].children.length, 0);
expect(nodes[i].delta!.toPlainText(), texts[i]);
}
},
);
});
}
for (final type in [
ParagraphBlockKeys.type,
BulletedListBlockKeys.type,
NumberedListBlockKeys.type,
TodoListBlockKeys.type,
]) {
// numbered list, bulleted list, todo list
// before
// - numbered list 1
// - nested list 1
// - bulleted list 2
// - nested list 2
// - todo list 3
// - nested list 3
// after
// - new_list_type
// - nested list 1
// - new_list_type
// - nested list 2
// - new_list_type
// - nested list 3
test('from nested mixed list to $type', () async {
const text1 = 'numbered list 1';
const text2 = 'bulleted list 2';
const text3 = 'todo list 3';
const nestedText1 = 'nested list 1';
const nestedText2 = 'nested list 2';
const nestedText3 = 'nested list 3';
final document = createDocument([
numberedListNode(
delta: Delta()..insert(text1),
children: [
numberedListNode(
delta: Delta()..insert(nestedText1),
),
],
),
bulletedListNode(
delta: Delta()..insert(text2),
children: [
bulletedListNode(
delta: Delta()..insert(nestedText2),
),
],
),
todoListNode(
checked: false,
text: text3,
children: [
todoListNode(
checked: false,
text: nestedText3,
),
],
),
]);
await checkTurnInto(
document,
NumberedListBlockKeys.type,
text1,
toType: type,
selection: Selection(
start: Position(path: [0]),
end: Position(path: [2]),
),
afterTurnInto: (editorState, node) {
final nodes = editorState.document.root.children;
expect(nodes.length, 3);
final texts = [
text1,
text2,
text3,
];
final nestedTexts = [
nestedText1,
nestedText2,
nestedText3,
];
final types = [
NumberedListBlockKeys.type,
BulletedListBlockKeys.type,
TodoListBlockKeys.type,
];
for (var i = 0; i < 3; i++) {
expect(nodes[i].type, type);
expect(nodes[i].children.length, 1);
expect(nodes[i].delta!.toPlainText(), texts[i]);
expect(nodes[i].children[0].type, types[i]);
expect(nodes[i].children[0].delta!.toPlainText(), nestedTexts[i]);
}
},
);
});
}
test('undo, redo', () async {
const text1 = 'numbered list 1';
const nestedText1 = 'nested list 1';
final document = createDocument([
numberedListNode(
delta: Delta()..insert(text1),
children: [
numberedListNode(
delta: Delta()..insert(nestedText1),
),
],
),
]);
await checkTurnInto(
document,
NumberedListBlockKeys.type,
text1,
toType: HeadingBlockKeys.type,
afterTurnInto: (editorState, node) {
expect(editorState.document.root.children.length, 2);
editorState.selection = Selection.collapsed(
Position(path: [0]),
);
KeyEventResult result = undoCommand.execute(editorState);
expect(result, KeyEventResult.handled);
expect(editorState.document.root.children.length, 1);
editorState.selection = Selection.collapsed(
Position(path: [0]),
);
result = redoCommand.execute(editorState);
expect(result, KeyEventResult.handled);
expect(editorState.document.root.children.length, 2);
},
);
});
}); });
} }