Skip to content

Commit

Permalink
feat: turn into multiple lines (#6558)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
LucasXu0 authored Oct 16, 2024
1 parent b1682e4 commit a8bcab7
Show file tree
Hide file tree
Showing 7 changed files with 526 additions and 159 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ void main() {
expect(editorState.getNodeAtPath([2])?.delta?.toPlainText(), isEmpty);
});

testWidgets('turn into', (tester) async {
testWidgets('turn into - single line', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();

Expand Down Expand Up @@ -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,
);
}
});
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,11 @@ void _customBlockOptionActions(
if (UniversalPlatform.isDesktop) {
builder.showActions =
(node) => node.parent?.type != TableCellBlockKeys.type;
builder.configuration = builder.configuration.copyWith(
blockSelectionAreaMargin: (_) => const EdgeInsets.symmetric(
vertical: 1,
),
);

builder.actionBuilder = (context, state) {
final top = builder.configuration.padding(context.node).top;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -209,44 +209,60 @@ class BlockActionOptionCubit extends Cubit<BlockActionOptionState> {
Node node, {
int? level,
}) async {
final selection = editorState.selection;
if (selection == null) {
return false;
}

final toType = type;

Log.info(
'Turn into block: from ${node.type} to $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');

if (type == node.type && type != HeadingBlockKeys.type) {
Log.info('Block type is the same');
return false;
}
final insertedNode = <Node>[];

Node afterNode = node.copyWith(
type: type,
attributes: {
if (toType == HeadingBlockKeys.type) HeadingBlockKeys.level: level,
if (toType == TodoListBlockKeys.type) TodoListBlockKeys.checked: false,
blockComponentBackgroundColor:
node.attributes[blockComponentBackgroundColor],
blockComponentTextDirection:
node.attributes[blockComponentTextDirection],
blockComponentDelta: (node.delta ?? Delta()).toJson(),
},
);
final insertedNode = [];
// heading block and callout block should not have children
if ([HeadingBlockKeys.type, CalloutBlockKeys.type].contains(toType)) {
afterNode = afterNode.copyWith(
children: [],
for (final node in selectedNodes) {
Log.info(
'Turn into block: from ${node.type} to $type',
);

Node afterNode = node.copyWith(
type: type,
attributes: {
if (toType == HeadingBlockKeys.type) HeadingBlockKeys.level: level,
if (toType == TodoListBlockKeys.type)
TodoListBlockKeys.checked: false,
blockComponentBackgroundColor:
node.attributes[blockComponentBackgroundColor],
blockComponentTextDirection:
node.attributes[blockComponentTextDirection],
blockComponentDelta: (node.delta ?? Delta()).toJson(),
},
);
insertedNode.addAll(node.children.map((e) => e.copyWith()));

// heading block and callout block should not have children
if ([HeadingBlockKeys.type, CalloutBlockKeys.type, QuoteBlockKeys.type]
.contains(toType)) {
afterNode = afterNode.copyWith(
children: [],
);
insertedNode.add(afterNode);
insertedNode.addAll(node.children.map((e) => e.copyWith()));
} else {
insertedNode.add(afterNode);
}
}

final transaction = editorState.transaction;
transaction.insertNodes(node.path, [
afterNode,
...insertedNode,
]);
transaction.deleteNode(node);
transaction.insertNodes(
node.path,
insertedNode,
);
transaction.deleteNodes(selectedNodes);
await editorState.apply(transaction);

return true;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import 'package:flutter/material.dart';

import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_notification.dart';
Expand All @@ -16,6 +14,7 @@ import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';

// this flag is used to disable the tooltip of the block when it is dragged
Expand Down Expand Up @@ -248,7 +247,7 @@ class _OptionButtonFeedbackState extends State<_OptionButtonFeedback> {
}
}

class _OptionButton extends StatelessWidget {
class _OptionButton extends StatefulWidget {
const _OptionButton({
required this.controller,
required this.editorState,
Expand All @@ -261,10 +260,45 @@ class _OptionButton extends StatelessWidget {
final BlockComponentContext blockComponentContext;
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
Widget build(BuildContext context) {
return ValueListenableBuilder(
valueListenable: isDragging,
valueListenable: widget.isDragging,
builder: (context, isDragging, child) {
return BlockActionButton(
svg: FlowySvgs.drag_element_s,
Expand All @@ -291,7 +325,11 @@ class _OptionButton extends StatelessWidget {
],
),
onTap: () {
controller.show();
if (widget.editorState.selection != null) {
beforeSelection = widget.editorState.selection;
}

widget.controller.show();

// update selection
_updateBlockSelection();
Expand All @@ -302,23 +340,35 @@ class _OptionButton extends StatelessWidget {
}

void _updateBlockSelection() {
final startNode = blockComponentContext.node;
var endNode = startNode;
while (endNode.children.isNotEmpty) {
endNode = endNode.children.last;
if (beforeSelection == null) {
final path = widget.blockComponentContext.node.path;
final selection = Selection.collapsed(
Position(path: path),
);
widget.editorState.updateSelectionWithReason(
selection,
customSelectionType: SelectionType.block,
);
} else {
widget.editorState.updateSelectionWithReason(
beforeSelection!,
customSelectionType: SelectionType.block,
);
}
}

final start = Position(path: startNode.path);
final end = endNode.selectable?.end() ??
Position(
path: endNode.path,
offset: endNode.delta?.length ?? 0,
);
bool _isTapInBounds(Offset offset) {
if (renderBox == null) {
return false;
}

editorState.selectionType = SelectionType.block;
editorState.selection = Selection(
start: start,
end: end,
);
final localPosition = renderBox!.globalToLocal(offset);
final result = renderBox!.paintBounds.contains(localPosition);
if (result) {
beforeSelection = widget.editorState.selection;
} else {
beforeSelection = null;
}
return result;
}
}
16 changes: 8 additions & 8 deletions frontend/appflowy_flutter/pubspec.lock
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,8 @@ packages:
dependency: "direct main"
description:
path: "."
ref: a0b3c72
resolved-ref: a0b3c7289b8c4073b47793f665e70a511324f9b9
ref: bcd1208
resolved-ref: bcd12082fea75cdbbea8b090bf938aae7dc9f4ad
url: "https://github.com/AppFlowy-IO/appflowy-editor.git"
source: git
version: "4.0.0"
Expand Down Expand Up @@ -1535,10 +1535,10 @@ packages:
dependency: transitive
description:
name: platform
sha256: "12220bb4b65720483f8fa9450b4332347737cf8213dd2840d8b2c823e47243ec"
sha256: "9b71283fc13df574056616011fb138fd3b793ea47cc509c189a6c3fa5f8a1a65"
url: "https://pub.dev"
source: hosted
version: "3.1.4"
version: "3.1.5"
plugin_platform_interface:
dependency: "direct dev"
description:
Expand Down Expand Up @@ -1933,10 +1933,10 @@ packages:
dependency: transitive
description:
name: string_scanner
sha256: "556692adab6cfa87322a115640c11f13cb77b3f076ddcc5d6ae3c20242bedcde"
sha256: "688af5ed3402a4bde5b3a6c15fd768dbf2621a614950b17f04626c431ab3c4c3"
url: "https://pub.dev"
source: hosted
version: "1.2.0"
version: "1.3.0"
string_validator:
dependency: "direct main"
description:
Expand Down Expand Up @@ -2238,10 +2238,10 @@ packages:
dependency: transitive
description:
name: vm_service
sha256: "3923c89304b715fb1eb6423f017651664a03bf5f4b29983627c4da791f74a4ec"
sha256: "5c5f338a667b4c644744b661f309fb8080bb94b18a7e91ef1dbd343bed00ed6d"
url: "https://pub.dev"
source: hosted
version: "14.2.1"
version: "14.2.5"
watcher:
dependency: transitive
description:
Expand Down
5 changes: 3 additions & 2 deletions frontend/appflowy_flutter/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ dependencies:

# Desktop Drop uses Cross File (XFile) data type
desktop_drop: ^0.4.4
device_info_plus: ^10.1.0
device_info_plus:
dotted_border: ^2.0.0+3
easy_localization: ^3.0.2
envied: ^0.5.2
Expand Down Expand Up @@ -161,6 +161,7 @@ dev_dependencies:

dependency_overrides:
http: ^1.0.0
device_info_plus: ^10.1.0

url_protocol:
git:
Expand All @@ -170,7 +171,7 @@ dependency_overrides:
appflowy_editor:
git:
url: https://github.com/AppFlowy-IO/appflowy-editor.git
ref: "a0b3c72"
ref: "bcd1208"

appflowy_editor_plugins:
git:
Expand Down
Loading

0 comments on commit a8bcab7

Please sign in to comment.