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"
|
"createANewCalendar": "Create a new Calendar"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"selectionMenu": {
|
||||||
|
"outline": "Outline"
|
||||||
|
},
|
||||||
"plugins": {
|
"plugins": {
|
||||||
"referencedBoard": "Referenced Board",
|
"referencedBoard": "Referenced Board",
|
||||||
"referencedGrid": "Referenced Grid",
|
"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]
|
/// Tap the line of editor at [index]
|
||||||
Future<void> tapLineOfEditorAt(int index) async {
|
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.tapAt(tester.getTopRight(textBlocks.at(index)));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
}
|
}
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
import 'package:appflowy/plugins/document/application/doc_bloc.dart';
|
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/option_action.dart';
|
||||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/block_action_list.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_plugins/plugins.dart';
|
||||||
import 'package:appflowy/plugins/document/presentation/editor_style.dart';
|
import 'package:appflowy/plugins/document/presentation/editor_style.dart';
|
||||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/inline_page/inline_page_reference.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,
|
codeBlockItem,
|
||||||
emojiMenuItem,
|
emojiMenuItem,
|
||||||
autoGeneratorMenuItem,
|
autoGeneratorMenuItem,
|
||||||
|
outlineItem,
|
||||||
];
|
];
|
||||||
|
|
||||||
late final Map<String, BlockComponentBuilder> blockComponentBuilders =
|
late final Map<String, BlockComponentBuilder> blockComponentBuilders =
|
||||||
@ -255,6 +255,7 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
|
|||||||
AutoCompletionBlockKeys.type: AutoCompletionBlockComponentBuilder(),
|
AutoCompletionBlockKeys.type: AutoCompletionBlockComponentBuilder(),
|
||||||
SmartEditBlockKeys.type: SmartEditBlockComponentBuilder(),
|
SmartEditBlockKeys.type: SmartEditBlockComponentBuilder(),
|
||||||
ToggleListBlockKeys.type: ToggleListBlockComponentBuilder(),
|
ToggleListBlockKeys.type: ToggleListBlockComponentBuilder(),
|
||||||
|
OutlineBlockKeys.type: OutlineBlockComponentBuilder(),
|
||||||
};
|
};
|
||||||
|
|
||||||
final builders = {
|
final builders = {
|
||||||
@ -277,7 +278,8 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
|
|||||||
NumberedListBlockKeys.type,
|
NumberedListBlockKeys.type,
|
||||||
QuoteBlockKeys.type,
|
QuoteBlockKeys.type,
|
||||||
TodoListBlockKeys.type,
|
TodoListBlockKeys.type,
|
||||||
CalloutBlockKeys.type
|
CalloutBlockKeys.type,
|
||||||
|
OutlineBlockKeys.type,
|
||||||
];
|
];
|
||||||
|
|
||||||
final supportAlignBuilderType = [
|
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 'emoji_picker/emoji_menu_item.dart';
|
||||||
export 'extensions/flowy_tint_extension.dart';
|
export 'extensions/flowy_tint_extension.dart';
|
||||||
export 'database/inline_database_menu_item.dart';
|
export 'database/inline_database_menu_item.dart';
|
||||||
|
export 'database/referenced_database_menu_item.dart';
|
||||||
export 'database/database_view_block_component.dart';
|
export 'database/database_view_block_component.dart';
|
||||||
export 'math_equation/math_equation_block_component.dart';
|
export 'math_equation/math_equation_block_component.dart';
|
||||||
export 'openai/widgets/auto_completion_node_widget.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 'openai/widgets/smart_edit_toolbar_item.dart';
|
||||||
export 'toggle/toggle_block_component.dart';
|
export 'toggle/toggle_block_component.dart';
|
||||||
export 'toggle/toggle_block_shortcut_event.dart';
|
export 'toggle/toggle_block_shortcut_event.dart';
|
||||||
|
export 'outline/outline_block_component.dart';
|
||||||
|
Loading…
x
Reference in New Issue
Block a user