mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2024-08-30 18:12:39 +00:00
feat: add outline block (#2750)
This commit is contained in:
parent
30b52a29fd
commit
95d4fb6865
@ -385,6 +385,9 @@
|
||||
"createANewCalendar": "Create a new Calendar"
|
||||
}
|
||||
},
|
||||
"selectionMenu": {
|
||||
"outline": "Outline"
|
||||
},
|
||||
"plugins": {
|
||||
"referencedBoard": "Referenced Board",
|
||||
"referencedGrid": "Referenced Grid",
|
||||
|
@ -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();
|
||||
}
|
@ -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();
|
||||
}
|
||||
|
@ -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 = [
|
||||
|
@ -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() ?? '';
|
||||
}
|
||||
}
|
@ -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';
|
||||
|
Loading…
Reference in New Issue
Block a user