feat: add outline block (#2750)

This commit is contained in:
Aman Negi 2023-06-29 17:58:30 +05:30 committed by GitHub
parent 30b52a29fd
commit 95d4fb6865
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 328 additions and 3 deletions

View File

@ -385,6 +385,9 @@
"createANewCalendar": "Create a new Calendar"
}
},
"selectionMenu": {
"outline": "Outline"
},
"plugins": {
"referencedBoard": "Referenced Board",
"referencedGrid": "Referenced Grid",

View File

@ -0,0 +1,114 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/outline/outline_block_component.dart';
import 'package:appflowy_backend/protobuf/flowy-folder2/protobuf.dart';
import 'package:easy_localization/easy_localization.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();
group('outline block test', () {
const location = 'outline_test';
setUp(() async {
await TestFolder.cleanTestLocation(location);
await TestFolder.setTestLocation(location);
});
tearDown(() async {
await TestFolder.cleanTestLocation(null);
});
testWidgets('insert an outline block', (tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();
await tester.createNewPageWithName(
ViewLayoutPB.Document,
'outline_test',
);
await tester.editor.tapLineOfEditorAt(0);
await insertOutlineInDocument(tester);
// validate the outline is inserted
expect(find.byType(OutlineBlockWidget), findsOneWidget);
});
testWidgets('insert an outline block and check if headings are visible',
(tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();
await tester.createNewPageWithName(
ViewLayoutPB.Document,
'outline_test',
);
await tester.editor.tapLineOfEditorAt(0);
await tester.ime.insertText('# Heading 1\n');
await tester.ime.insertText('## Heading 2\n');
await tester.ime.insertText('### Heading 3\n');
/* Results in:
* # Heading 1
* ## Heading 2
* ### Heading 3
*/
await tester.editor.tapLineOfEditorAt(3);
await insertOutlineInDocument(tester);
expect(
find.descendant(
of: find.byType(OutlineBlockWidget),
matching: find.text('Heading 1'),
),
findsOneWidget,
);
// Heading 2 is prefixed with a bullet
expect(
find.descendant(
of: find.byType(OutlineBlockWidget),
matching: find.text('Heading 2'),
),
findsOneWidget,
);
// Heading 3 is prefixed with a dash
expect(
find.descendant(
of: find.byType(OutlineBlockWidget),
matching: find.text('Heading 3'),
),
findsOneWidget,
);
// update the Heading 1 to Heading 1Hello world
await tester.editor.tapLineOfEditorAt(0);
await tester.ime.insertText('Hello world');
expect(
find.descendant(
of: find.byType(OutlineBlockWidget),
matching: find.text('Heading 1Hello world'),
),
findsOneWidget,
);
});
});
}
/// Inserts an outline block in the document
Future<void> insertOutlineInDocument(WidgetTester tester) async {
// open the actions menu and insert the outline block
await tester.editor.showSlashMenu();
await tester.editor.tapSlashMenuItemWithName(
LocaleKeys.document_selectionMenu_outline.tr(),
);
await tester.pumpAndSettle();
}

View File

@ -30,7 +30,8 @@ class EditorOperations {
/// Tap the line of editor at [index]
Future<void> tapLineOfEditorAt(int index) async {
final textBlocks = find.byType(TextBlockComponentWidget);
final textBlocks = find.byType(FlowyRichText);
index = index.clamp(0, textBlocks.evaluate().length - 1);
await tester.tapAt(tester.getTopRight(textBlocks.at(index)));
await tester.pumpAndSettle();
}

View File

@ -1,7 +1,6 @@
import 'package:appflowy/plugins/document/application/doc_bloc.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/option_action.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/block_action_list.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/database/referenced_database_menu_tem.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
import 'package:appflowy/plugins/document/presentation/editor_style.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/inline_page/inline_page_reference.dart';
@ -68,6 +67,7 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
codeBlockItem,
emojiMenuItem,
autoGeneratorMenuItem,
outlineItem,
];
late final Map<String, BlockComponentBuilder> blockComponentBuilders =
@ -255,6 +255,7 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
AutoCompletionBlockKeys.type: AutoCompletionBlockComponentBuilder(),
SmartEditBlockKeys.type: SmartEditBlockComponentBuilder(),
ToggleListBlockKeys.type: ToggleListBlockComponentBuilder(),
OutlineBlockKeys.type: OutlineBlockComponentBuilder(),
};
final builders = {
@ -277,7 +278,8 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
NumberedListBlockKeys.type,
QuoteBlockKeys.type,
TodoListBlockKeys.type,
CalloutBlockKeys.type
CalloutBlockKeys.type,
OutlineBlockKeys.type,
];
final supportAlignBuilderType = [

View File

@ -0,0 +1,203 @@
import 'dart:async';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flowy_infra_ui/style_widget/hover.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class OutlineBlockKeys {
const OutlineBlockKeys._();
static const String type = 'outline';
static const String backgroundColor = blockComponentBackgroundColor;
}
// defining the callout block menu item for selection
SelectionMenuItem outlineItem = SelectionMenuItem.node(
name: LocaleKeys.document_selectionMenu_outline.tr(),
iconData: Icons.list_alt,
keywords: ['outline', 'table of contents'],
nodeBuilder: (editorState) => outlineBlockNode(),
replace: (_, node) => node.delta?.isEmpty ?? false,
);
Node outlineBlockNode() {
return Node(
type: OutlineBlockKeys.type,
);
}
class OutlineBlockComponentBuilder extends BlockComponentBuilder {
OutlineBlockComponentBuilder({
this.configuration = const BlockComponentConfiguration(),
});
@override
final BlockComponentConfiguration configuration;
@override
BlockComponentWidget build(BlockComponentContext blockComponentContext) {
final node = blockComponentContext.node;
return OutlineBlockWidget(
key: node.key,
node: node,
configuration: configuration,
showActions: showActions(node),
actionBuilder: (context, state) => actionBuilder(
blockComponentContext,
state,
),
);
}
@override
bool validate(Node node) => node.children.isEmpty;
}
class OutlineBlockWidget extends BlockComponentStatefulWidget {
const OutlineBlockWidget({
super.key,
required super.node,
super.showActions,
super.actionBuilder,
super.configuration = const BlockComponentConfiguration(),
});
@override
State<OutlineBlockWidget> createState() => _OutlineBlockWidgetState();
}
class _OutlineBlockWidgetState extends State<OutlineBlockWidget>
with BlockComponentConfigurable {
@override
BlockComponentConfiguration get configuration => widget.configuration;
@override
Node get node => widget.node;
// get the background color of the note block from the node's attributes
Color get backgroundColor {
final colorString =
node.attributes[OutlineBlockKeys.backgroundColor] as String?;
if (colorString == null) {
return Colors.transparent;
}
return colorString.toColor();
}
late EditorState editorState = context.read<EditorState>();
late Stream<(TransactionTime, Transaction)> stream =
editorState.transactionStream;
@override
Widget build(BuildContext context) {
return StreamBuilder(
stream: stream,
builder: (context, snapshot) {
if (widget.showActions && widget.actionBuilder != null) {
return BlockComponentActionWrapper(
node: widget.node,
actionBuilder: widget.actionBuilder!,
child: _buildOutlineBlock(),
);
}
return _buildOutlineBlock();
},
);
}
Widget _buildOutlineBlock() {
final children = getHeadingNodes()
.map(
(e) => Container(
padding: const EdgeInsets.only(
bottom: 4.0,
),
width: double.infinity,
child: OutlineItemWidget(node: e),
),
)
.toList();
return Container(
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(8.0)),
color: backgroundColor,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.max,
children: children,
),
);
}
Iterable<Node> getHeadingNodes() {
final children = editorState.document.root.children;
return children.where((element) => element.type == HeadingBlockKeys.type);
}
}
class OutlineItemWidget extends StatelessWidget {
OutlineItemWidget({
super.key,
required this.node,
}) {
assert(node.type == HeadingBlockKeys.type);
}
final Node node;
@override
Widget build(BuildContext context) {
final editorState = context.read<EditorState>();
final textStyle = editorState.editorStyle.textStyleConfiguration;
final style = textStyle.href.combine(textStyle.text);
return FlowyHover(
style: HoverStyle(
hoverColor: Colors.grey.withOpacity(0.2), // TODO: use theme color.
),
child: GestureDetector(
onTap: () => updateBlockSelection(context),
child: Container(
padding: EdgeInsets.only(left: node.leftIndent),
child: Text(
node.outlineItemText,
style: style,
),
),
),
);
}
void updateBlockSelection(BuildContext context) {
final editorState = context.read<EditorState>();
editorState.selectionType = SelectionType.block;
editorState.selection = Selection.collapse(
node.path,
node.delta?.length ?? 0,
);
editorState.selectionType = null;
}
}
extension on Node {
double get leftIndent {
assert(type != HeadingBlockKeys.type);
if (type != HeadingBlockKeys.type) {
return 0.0;
}
final level = attributes[HeadingBlockKeys.level];
if (level == 2) {
return 20;
} else if (level == 3) {
return 40;
}
return 0;
}
String get outlineItemText {
return delta?.toPlainText() ?? '';
}
}

View File

@ -7,6 +7,7 @@ export 'header/custom_cover_picker.dart';
export 'emoji_picker/emoji_menu_item.dart';
export 'extensions/flowy_tint_extension.dart';
export 'database/inline_database_menu_item.dart';
export 'database/referenced_database_menu_item.dart';
export 'database/database_view_block_component.dart';
export 'math_equation/math_equation_block_component.dart';
export 'openai/widgets/auto_completion_node_widget.dart';
@ -14,3 +15,4 @@ export 'openai/widgets/smart_edit_node_widget.dart';
export 'openai/widgets/smart_edit_toolbar_item.dart';
export 'toggle/toggle_block_component.dart';
export 'toggle/toggle_block_shortcut_event.dart';
export 'outline/outline_block_component.dart';