feat: support toggle list (#3016)

This commit is contained in:
Lucas.Xu 2023-07-18 09:45:20 +07:00 committed by GitHub
parent a00dd5498e
commit 2da37122e4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 426 additions and 57 deletions

View File

@ -7,6 +7,7 @@ import 'document_with_database_test.dart' as document_with_database_test;
import 'document_with_inline_math_equation_test.dart'
as document_with_inline_math_equation_test;
import 'document_with_inline_page_test.dart' as document_with_inline_page_test;
import 'document_with_toggle_list_test.dart' as document_with_toggle_list_test;
import 'edit_document_test.dart' as document_edit_test;
void startTesting() {
@ -19,4 +20,5 @@ void startTesting() {
document_with_inline_page_test.main();
document_with_inline_math_equation_test.main();
document_with_cover_image_test.main();
document_with_toggle_list_test.main();
}

View File

@ -0,0 +1,237 @@
import 'dart:io';
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
import 'package:appflowy_backend/protobuf/flowy-folder2/protobuf.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import '../util/ime.dart';
import '../util/util.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
TestWidgetsFlutterBinding.ensureInitialized();
group('toggle list in document', () {
void expectToggleListOpened() {
expect(find.byIcon(Icons.arrow_drop_down), findsOneWidget);
expect(find.byIcon(Icons.arrow_right), findsNothing);
}
void expectToggleListClosed() {
expect(find.byIcon(Icons.arrow_drop_down), findsNothing);
expect(find.byIcon(Icons.arrow_right), findsOneWidget);
}
testWidgets('convert > to toggle list, and click the icon to close it',
(tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();
// create a new document
await tester.createNewPageWithName(
ViewLayoutPB.Document,
);
// tap the first line of the document
await tester.editor.tapLineOfEditorAt(0);
// insert a toggle list
const text = 'This is a toggle list sample';
await tester.ime.insertText('> $text');
await tester.editor.updateSelection(
Selection.single(
path: [0],
startOffset: 0,
endOffset: text.length,
),
);
final editorState = tester.editor.getCurrentEditorState();
final toggleList = editorState.document.nodeAtPath([0])!;
expect(
toggleList.type,
ToggleListBlockKeys.type,
);
expect(
toggleList.attributes[ToggleListBlockKeys.collapsed],
false,
);
expect(
toggleList.delta!.toPlainText(),
text,
);
// Press the arrow down key to move the cursor to the next line
await tester.simulateKeyEvent(
LogicalKeyboardKey.arrowDown,
);
const text2 = 'This is a child node';
await tester.ime.insertText(text2);
expect(find.text(text2, findRichText: true), findsOneWidget);
// Click the toggle list icon to close it
final toggleListIcon = find.byIcon(Icons.arrow_drop_down);
await tester.tapButton(toggleListIcon);
// expect the toggle list to be closed
expect(find.text(text2, findRichText: true), findsNothing);
});
testWidgets('press enter key when the toggle list is closed',
(tester) async {
// if the toggle list is closed, press enter key will insert a new toggle list after it
await tester.initializeAppFlowy();
await tester.tapGoButton();
// create a new document
await tester.createNewPageWithName(
ViewLayoutPB.Document,
);
// tap the first line of the document
await tester.editor.tapLineOfEditorAt(0);
// insert a toggle list
const text = 'Hello AppFlowy';
await tester.ime.insertText('> $text');
// Click the toggle list icon to close it
final toggleListIcon = find.byIcon(Icons.arrow_drop_down);
await tester.tapButton(toggleListIcon);
// Press the enter key
await tester.editor.updateSelection(
Selection.collapse(
[0],
'Hello '.length,
),
);
await tester.ime.insertCharacter('\n');
final editorState = tester.editor.getCurrentEditorState();
final node0 = editorState.getNodeAtPath([0])!;
final node1 = editorState.getNodeAtPath([1])!;
expect(node0.type, ToggleListBlockKeys.type);
expect(node0.attributes[ToggleListBlockKeys.collapsed], true);
expect(node0.delta!.toPlainText(), 'Hello ');
expect(node1.type, ToggleListBlockKeys.type);
expect(node1.delta!.toPlainText(), 'AppFlowy');
});
testWidgets('press enter key when the toggle list is open', (tester) async {
// if the toggle list is open, press enter key will insert a new paragraph inside it
await tester.initializeAppFlowy();
await tester.tapGoButton();
// create a new document
await tester.createNewPageWithName(
ViewLayoutPB.Document,
);
// tap the first line of the document
await tester.editor.tapLineOfEditorAt(0);
// insert a toggle list
const text = 'Hello AppFlowy';
await tester.ime.insertText('> $text');
// Press the enter key
await tester.editor.updateSelection(
Selection.collapse(
[0],
'Hello '.length,
),
);
await tester.ime.insertCharacter('\n');
final editorState = tester.editor.getCurrentEditorState();
final node0 = editorState.getNodeAtPath([0])!;
final node00 = editorState.getNodeAtPath([0, 0])!;
final node1 = editorState.getNodeAtPath([1]);
expect(node0.type, ToggleListBlockKeys.type);
expect(node0.attributes[ToggleListBlockKeys.collapsed], false);
expect(node0.delta!.toPlainText(), 'Hello ');
expect(node00.type, ParagraphBlockKeys.type);
expect(node00.delta!.toPlainText(), 'AppFlowy');
expect(node1, isNull);
});
testWidgets('clear the format if toggle list if empty', (tester) async {
// if the toggle list is open, press enter key will insert a new paragraph inside it
await tester.initializeAppFlowy();
await tester.tapGoButton();
// create a new document
await tester.createNewPageWithName(
ViewLayoutPB.Document,
);
// tap the first line of the document
await tester.editor.tapLineOfEditorAt(0);
// insert a toggle list
await tester.ime.insertText('> ');
// Press the enter key
// Click the toggle list icon to close it
final toggleListIcon = find.byIcon(Icons.arrow_drop_down);
await tester.tapButton(toggleListIcon);
await tester.editor.updateSelection(
Selection.collapse(
[0],
0,
),
);
await tester.ime.insertCharacter('\n');
final editorState = tester.editor.getCurrentEditorState();
final node0 = editorState.getNodeAtPath([0])!;
expect(node0.type, ParagraphBlockKeys.type);
});
testWidgets('use cmd/ctrl + enter to open/close the toggle list',
(tester) async {
// if the toggle list is open, press enter key will insert a new paragraph inside it
await tester.initializeAppFlowy();
await tester.tapGoButton();
// create a new document
await tester.createNewPageWithName(
ViewLayoutPB.Document,
);
// tap the first line of the document
await tester.editor.tapLineOfEditorAt(0);
// insert a toggle list
await tester.ime.insertText('> Hello');
expectToggleListOpened();
await tester.editor.updateSelection(
Selection.collapse(
[0],
0,
),
);
await tester.simulateKeyEvent(
LogicalKeyboardKey.enter,
isMetaPressed: Platform.isMacOS,
isControlPressed: Platform.isLinux || Platform.isWindows,
);
expectToggleListClosed();
await tester.simulateKeyEvent(
LogicalKeyboardKey.enter,
isMetaPressed: Platform.isMacOS,
isControlPressed: Platform.isLinux || Platform.isWindows,
);
expectToggleListOpened();
});
});
}

View File

@ -37,6 +37,7 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
final inlinePageReferenceService = InlinePageReferenceService();
final List<CommandShortcutEvent> commandShortcutEvents = [
toggleToggleListCommand,
...codeBlockCommands,
...standardCommandShortcutEvents,
];
@ -68,7 +69,8 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
...codeBlockCharacterEvents,
// toggle list
// formatGreaterToToggleList,
formatGreaterToToggleList,
insertChildNodeInsideToggleList,
// customize the slash menu command
customSlashCommand(
@ -107,6 +109,7 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
void initState() {
super.initState();
indentableBlockTypes.add(ToggleListBlockKeys.type);
slashMenuItems = _customSlashMenuItems();
effectiveScrollController = widget.scrollController ?? ScrollController();
@ -286,6 +289,7 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
TodoListBlockKeys.type,
CalloutBlockKeys.type,
OutlineBlockKeys.type,
ToggleListBlockKeys.type,
];
final supportAlignBuilderType = [
@ -313,7 +317,7 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
final top = builder.configuration.padding(context.node).top;
final padding = context.node.type == HeadingBlockKeys.type
? EdgeInsets.only(top: top + 8.0)
: EdgeInsets.only(top: top);
: EdgeInsets.only(top: top + 2.0);
return Padding(
padding: padding,
child: BlockActionList(

View File

@ -1,7 +1,6 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class ToggleListBlockKeys {
const ToggleListBlockKeys._();
@ -11,24 +10,35 @@ class ToggleListBlockKeys {
/// The content of a code block.
///
/// The value is a String.
static const String delta = 'delta';
static const String delta = blockComponentDelta;
static const String backgroundColor = blockComponentBackgroundColor;
static const String textDirection = blockComponentTextDirection;
/// The value is a bool.
static const String collapsed = 'collapsed';
}
Node toggleListBlockNode({
String? text,
Delta? delta,
bool collapsed = false,
String? textDirection,
Attributes? attributes,
Iterable<Node>? children,
}) {
final attributes = {
ToggleListBlockKeys.delta: (delta ?? Delta()).toJson(),
ToggleListBlockKeys.collapsed: collapsed,
};
return Node(
type: ToggleListBlockKeys.type,
attributes: attributes,
children: [paragraphNode()],
attributes: {
ToggleListBlockKeys.collapsed: collapsed,
ToggleListBlockKeys.delta:
(delta ?? (Delta()..insert(text ?? ''))).toJson(),
if (attributes != null) ...attributes,
if (textDirection != null)
ToggleListBlockKeys.textDirection: textDirection,
},
children: children ?? [paragraphNode()],
);
}
@ -86,7 +96,9 @@ class _ToggleListBlockComponentWidgetState
SelectableMixin,
DefaultSelectableMixin,
BlockComponentConfigurable,
BlockComponentBackgroundColorMixin {
BlockComponentBackgroundColorMixin,
NestedBlockComponentStatefulWidgetMixin,
BlockComponentTextDirectionMixin {
// the key used to forward focus to the richtext child
@override
final forwardKey = GlobalKey(debugLabel: 'flowy_rich_text');
@ -105,63 +117,65 @@ class _ToggleListBlockComponentWidgetState
@override
Node get node => widget.node;
bool get collapsed => node.attributes[ToggleListBlockKeys.collapsed] ?? false;
@override
EdgeInsets get indentPadding => configuration.indentPadding(
node,
calculateTextDirection(
defaultTextDirection: Directionality.maybeOf(context),
),
);
late final editorState = context.read<EditorState>();
bool get collapsed => node.attributes[ToggleListBlockKeys.collapsed] ?? false;
@override
Widget build(BuildContext context) {
return collapsed
? buildToggleListBlockComponent(context)
: buildToggleListBlockComponentWithChildren(context);
? buildComponent(context)
: buildComponentWithChildren(context);
}
Widget buildToggleListBlockComponentWithChildren(BuildContext context) {
return Container(
color: backgroundColor,
child: NestedListWidget(
children: editorState.renderer.buildList(
context,
widget.node.children,
),
child: buildToggleListBlockComponent(context),
),
@override
Widget buildComponent(BuildContext context) {
final textDirection = calculateTextDirection(
defaultTextDirection: Directionality.maybeOf(context),
);
}
// build the richtext child
Widget buildToggleListBlockComponent(BuildContext context) {
Widget child = Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// the emoji picker button for the note
FlowyIconButton(
width: 24.0,
icon: Icon(
collapsed ? Icons.arrow_right : Icons.arrow_drop_down,
),
onPressed: onCollapsed,
),
const SizedBox(
width: 4.0,
),
Expanded(
child: AppFlowyRichText(
key: forwardKey,
node: widget.node,
editorState: editorState,
placeholderText: placeholderText,
lineHeight: 1.5,
textSpanDecorator: (textSpan) => textSpan.updateTextStyle(
textStyle,
Widget child = Container(
color: backgroundColor,
width: double.infinity,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// the emoji picker button for the note
FlowyIconButton(
width: 24.0,
icon: Icon(
collapsed ? Icons.arrow_right : Icons.arrow_drop_down,
),
placeholderTextSpanDecorator: (textSpan) =>
textSpan.updateTextStyle(
placeholderTextStyle,
onPressed: onCollapsed,
),
const SizedBox(
width: 4.0,
),
Expanded(
child: AppFlowyRichText(
key: forwardKey,
node: widget.node,
editorState: editorState,
placeholderText: placeholderText,
lineHeight: 1.5,
textSpanDecorator: (textSpan) => textSpan.updateTextStyle(
textStyle,
),
placeholderTextSpanDecorator: (textSpan) =>
textSpan.updateTextStyle(
placeholderTextStyle,
),
textDirection: textDirection,
),
),
),
],
],
),
);
child = Padding(

View File

@ -1,5 +1,6 @@
import 'package:appflowy/plugins/document/presentation/editor_plugins/toggle/toggle_block_component.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
const _greater = '>';
@ -22,3 +23,107 @@ CharacterShortcutEvent formatGreaterToToggleList = CharacterShortcutEvent(
),
),
);
/// Press enter key to insert child node inside the toggle list
///
/// - support
/// - desktop
/// - mobile
/// - web
CharacterShortcutEvent insertChildNodeInsideToggleList = CharacterShortcutEvent(
key: 'insert child node inside toggle list',
character: '\n',
handler: (editorState) async {
final selection = editorState.selection;
if (selection == null || !selection.isCollapsed) {
return false;
}
final node = editorState.getNodeAtPath(selection.start.path);
final delta = node?.delta;
if (node == null ||
node.type != ToggleListBlockKeys.type ||
delta == null) {
return false;
}
final slicedDelta = delta.slice(selection.start.offset);
final transaction = editorState.transaction;
final collapsed = node.attributes[ToggleListBlockKeys.collapsed] as bool;
if (collapsed) {
// if the delta is empty, clear the format
if (delta.isEmpty) {
transaction
..insertNode(
selection.start.path.next,
paragraphNode(),
)
..deleteNode(node)
..afterSelection = Selection.collapse(selection.start.path, 0);
} else {
// insert a toggle list block below the current toggle list block
transaction
..deleteText(node, selection.startIndex, slicedDelta.length)
..insertNode(
selection.start.path.next,
toggleListBlockNode(collapsed: true, delta: slicedDelta),
)
..afterSelection = Selection.collapse(selection.start.path.next, 0);
}
} else {
// insert a paragraph block inside the current toggle list block
transaction
..deleteText(node, selection.startIndex, slicedDelta.length)
..insertNode(
selection.start.path + [0],
paragraphNode(delta: slicedDelta),
)
..afterSelection = Selection.collapse(selection.start.path + [0], 0);
}
await editorState.apply(transaction);
return true;
},
);
/// cmd/ctrl + enter to close or open the toggle list
///
/// - support
/// - desktop
/// - web
///
// toggle the todo list
final CommandShortcutEvent toggleToggleListCommand = CommandShortcutEvent(
key: 'toggle the toggle list',
command: 'ctrl+enter',
macOSCommand: 'cmd+enter',
handler: _toggleToggleListCommandHandler,
);
CommandShortcutEventHandler _toggleToggleListCommandHandler = (editorState) {
if (PlatformExtension.isMobile) {
assert(false, 'enter key is not supported on mobile platform.');
return KeyEventResult.ignored;
}
final selection = editorState.selection;
if (selection == null) {
return KeyEventResult.ignored;
}
final nodes = editorState.getNodesInSelection(selection);
if (nodes.isEmpty || nodes.length > 1) {
return KeyEventResult.ignored;
}
final node = nodes.first;
if (node.type != ToggleListBlockKeys.type) {
return KeyEventResult.ignored;
}
final collapsed = node.attributes[ToggleListBlockKeys.collapsed] as bool;
final transaction = editorState.transaction;
transaction.updateNode(node, {
ToggleListBlockKeys.collapsed: !collapsed,
});
transaction.afterSelection = selection;
editorState.apply(transaction);
return KeyEventResult.handled;
};

View File

@ -45,7 +45,7 @@ dependencies:
appflowy_editor:
git:
url: https://github.com/AppFlowy-IO/appflowy-editor.git
ref: 33b18d9
ref: 023f3c8
appflowy_popover:
path: packages/appflowy_popover

7
frontend/flowy-server-config/Cargo.lock generated Normal file
View File

@ -0,0 +1,7 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 3
[[package]]
name = "flowy-server-config"
version = "0.1.0"