diff --git a/lib/src/editor/block_component/block_component.dart b/lib/src/editor/block_component/block_component.dart index 19f630be3..adc08a1c5 100644 --- a/lib/src/editor/block_component/block_component.dart +++ b/lib/src/editor/block_component/block_component.dart @@ -26,6 +26,11 @@ export 'heading_block_component/heading_character_shortcut.dart'; // image export 'image_block_component/image_block_component.dart'; +// divider +export 'divider_block_component/divider_block_component.dart'; +export 'divider_block_component/divider_character_shortcut.dart'; +export 'divider_block_component/divider_menu_item.dart'; + // base export 'base_component/convert_to_paragraph_command.dart'; export 'base_component/insert_newline_in_type_command.dart'; diff --git a/lib/src/editor/block_component/divider_block_component/divider_block_component.dart b/lib/src/editor/block_component/divider_block_component/divider_block_component.dart new file mode 100644 index 000000000..535b5c131 --- /dev/null +++ b/lib/src/editor/block_component/divider_block_component/divider_block_component.dart @@ -0,0 +1,125 @@ +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/material.dart'; + +class DividerBlockKeys { + const DividerBlockKeys._(); + + static const String type = 'divider'; +} + +// creating a new callout node +Node dividerNode() { + return Node( + type: DividerBlockKeys.type, + ); +} + +class DividerBlockComponentBuilder extends BlockComponentBuilder { + DividerBlockComponentBuilder({ + this.configuration = const BlockComponentConfiguration(), + this.lineColor = Colors.grey, + }); + + @override + final BlockComponentConfiguration configuration; + + final Color lineColor; + + @override + BlockComponentWidget build(BlockComponentContext blockComponentContext) { + final node = blockComponentContext.node; + return DividerBlockComponentWidget( + key: node.key, + node: node, + configuration: configuration, + lineColor: lineColor, + showActions: showActions(node), + actionBuilder: (context, state) => actionBuilder( + blockComponentContext, + state, + ), + ); + } + + @override + bool validate(Node node) => node.children.isEmpty; +} + +class DividerBlockComponentWidget extends BlockComponentStatefulWidget { + const DividerBlockComponentWidget({ + super.key, + required super.node, + super.showActions, + super.actionBuilder, + super.configuration = const BlockComponentConfiguration(), + this.lineColor = Colors.grey, + }); + + final Color lineColor; + + @override + State createState() => + _DividerBlockComponentWidgetState(); +} + +class _DividerBlockComponentWidgetState + extends State with SelectableMixin { + RenderBox get _renderBox => context.findRenderObject() as RenderBox; + + @override + Widget build(BuildContext context) { + Widget child = Container( + height: 10, + alignment: Alignment.center, + child: Divider( + color: widget.lineColor, + thickness: 1, + ), + ); + + if (widget.showActions && widget.actionBuilder != null) { + child = BlockComponentActionWrapper( + node: widget.node, + actionBuilder: widget.actionBuilder!, + child: child, + ); + } + + return child; + } + + @override + Position start() => Position(path: widget.node.path, offset: 0); + + @override + Position end() => Position(path: widget.node.path, offset: 1); + + @override + Position getPositionInOffset(Offset start) => end(); + + @override + bool get shouldCursorBlink => false; + + @override + CursorStyle get cursorStyle => CursorStyle.cover; + + @override + Rect? getCursorRectInPosition(Position position) { + final size = _renderBox.size; + return Rect.fromLTWH(-size.width / 2.0, 0, size.width, size.height); + } + + @override + List getRectsInSelection(Selection selection) => + [Offset.zero & _renderBox.size]; + + @override + Selection getSelectionInRange(Offset start, Offset end) => Selection.single( + path: widget.node.path, + startOffset: 0, + endOffset: 1, + ); + + @override + Offset localToGlobal(Offset offset) => _renderBox.localToGlobal(offset); +} diff --git a/lib/src/editor/block_component/divider_block_component/divider_character_shortcut.dart b/lib/src/editor/block_component/divider_block_component/divider_character_shortcut.dart new file mode 100644 index 000000000..31356a19b --- /dev/null +++ b/lib/src/editor/block_component/divider_block_component/divider_character_shortcut.dart @@ -0,0 +1,46 @@ +import 'package:appflowy_editor/appflowy_editor.dart'; + +/// insert divider into a document by typing three minuses(-). +/// +/// - support +/// - desktop +/// - web +/// - mobile +/// +final CharacterShortcutEvent convertMinusesToDivider = CharacterShortcutEvent( + key: 'convert minuses to a divider', + character: '-', + handler: (editorState) => _convertSyntaxToDivider(editorState, '--'), +); + +final CharacterShortcutEvent convertStarsToDivider = CharacterShortcutEvent( + key: 'convert starts to a divider', + character: '*', + handler: (editorState) => _convertSyntaxToDivider(editorState, '**'), +); + +Future _convertSyntaxToDivider( + EditorState editorState, + String syntax, +) async { + final selection = editorState.selection; + if (selection == null || !selection.isCollapsed) { + return false; + } + final path = selection.end.path; + final node = editorState.getNodeAtPath(path); + final delta = node?.delta; + if (node == null || delta == null) { + return false; + } + if (delta.toPlainText() != syntax) { + return false; + } + final transaction = editorState.transaction + ..insertNode(path, dividerNode()) + ..insertNode(path, paragraphNode()) + ..deleteNode(node) + ..afterSelection = Selection.collapse(path, 0); + editorState.apply(transaction); + return true; +} diff --git a/lib/src/editor/block_component/divider_block_component/divider_menu_item.dart b/lib/src/editor/block_component/divider_block_component/divider_menu_item.dart new file mode 100644 index 000000000..d0617d4a9 --- /dev/null +++ b/lib/src/editor/block_component/divider_block_component/divider_menu_item.dart @@ -0,0 +1,31 @@ +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_editor/src/render/selection_menu/selection_menu_icon.dart'; +import 'package:flutter/material.dart'; + +SelectionMenuItem dividerMenuItem = SelectionMenuItem( + name: 'Divider', + icon: (editorState, isSelected, style) => SelectionMenuIconWidget( + icon: Icons.horizontal_rule, + isSelected: isSelected, + style: style, + ), + keywords: ['horizontal rule', 'divider'], + handler: (editorState, _, __) { + final selection = editorState.selection; + if (selection == null || !selection.isCollapsed) { + return; + } + final path = selection.end.path; + final node = editorState.getNodeAtPath(path); + final delta = node?.delta; + if (node == null || delta == null) { + return; + } + final insertedPath = delta.isEmpty ? path : path.next; + final transaction = editorState.transaction + ..insertNode(insertedPath, dividerNode()) + ..insertNode(insertedPath, paragraphNode()) + ..afterSelection = Selection.collapse(insertedPath.next, 0); + editorState.apply(transaction); + }, +); diff --git a/lib/src/render/selection_menu/selection_menu_icon.dart b/lib/src/render/selection_menu/selection_menu_icon.dart new file mode 100644 index 000000000..2a548bbad --- /dev/null +++ b/lib/src/render/selection_menu/selection_menu_icon.dart @@ -0,0 +1,42 @@ +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/material.dart'; + +class SelectionMenuIconWidget extends StatelessWidget { + SelectionMenuIconWidget({ + super.key, + this.name, + this.icon, + required this.isSelected, + required this.style, + }) { + assert((name == null && icon != null) || ((name != null && icon == null))); + } + + final String? name; + final IconData? icon; + final bool isSelected; + final SelectionMenuStyle style; + + @override + Widget build(BuildContext context) { + if (icon != null) { + return Icon( + icon, + size: 18.0, + color: isSelected + ? style.selectionMenuItemSelectedIconColor + : style.selectionMenuItemIconColor, + ); + } else if (name != null) { + return FlowySvg( + name: 'selection_menu/$name', + width: 18.0, + height: 18.0, + color: isSelected + ? style.selectionMenuItemSelectedIconColor + : style.selectionMenuItemIconColor, + ); + } + throw UnimplementedError(); + } +} diff --git a/lib/src/render/selection_menu/selection_menu_service.dart b/lib/src/render/selection_menu/selection_menu_service.dart index 84dff80f8..4f0a4de4a 100644 --- a/lib/src/render/selection_menu/selection_menu_service.dart +++ b/lib/src/render/selection_menu/selection_menu_service.dart @@ -1,5 +1,6 @@ import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor/src/editor/block_component/image_block_component/image_upload_widget.dart'; +import 'package:appflowy_editor/src/render/selection_menu/selection_menu_icon.dart'; import 'package:flutter/material.dart'; // TODO: this file is too long, need to refactor. @@ -200,8 +201,11 @@ class SelectionMenu extends SelectionMenuService { final List standardSelectionMenuItems = [ SelectionMenuItem( name: AppFlowyEditorLocalizations.current.text, - icon: (editorState, onSelected, style) => - _selectionMenuIcon('text', editorState, onSelected, style), + icon: (editorState, isSelected, style) => SelectionMenuIconWidget( + name: 'text', + isSelected: isSelected, + style: style, + ), keywords: ['text'], handler: (editorState, _, __) { insertNodeAfterSelection(editorState, paragraphNode()); @@ -209,8 +213,11 @@ final List standardSelectionMenuItems = [ ), SelectionMenuItem( name: AppFlowyEditorLocalizations.current.heading1, - icon: (editorState, onSelected, style) => - _selectionMenuIcon('h1', editorState, onSelected, style), + icon: (editorState, isSelected, style) => SelectionMenuIconWidget( + name: 'h1', + isSelected: isSelected, + style: style, + ), keywords: ['heading 1, h1'], handler: (editorState, _, __) { insertHeadingAfterSelection(editorState, 1); @@ -218,8 +225,11 @@ final List standardSelectionMenuItems = [ ), SelectionMenuItem( name: AppFlowyEditorLocalizations.current.heading2, - icon: (editorState, onSelected, style) => - _selectionMenuIcon('h2', editorState, onSelected, style), + icon: (editorState, isSelected, style) => SelectionMenuIconWidget( + name: 'h2', + isSelected: isSelected, + style: style, + ), keywords: ['heading 2, h2'], handler: (editorState, _, __) { insertHeadingAfterSelection(editorState, 2); @@ -227,8 +237,11 @@ final List standardSelectionMenuItems = [ ), SelectionMenuItem( name: AppFlowyEditorLocalizations.current.heading3, - icon: (editorState, onSelected, style) => - _selectionMenuIcon('h3', editorState, onSelected, style), + icon: (editorState, isSelected, style) => SelectionMenuIconWidget( + name: 'h3', + isSelected: isSelected, + style: style, + ), keywords: ['heading 3, h3'], handler: (editorState, _, __) { insertHeadingAfterSelection(editorState, 3); @@ -236,8 +249,11 @@ final List standardSelectionMenuItems = [ ), SelectionMenuItem( name: AppFlowyEditorLocalizations.current.image, - icon: (editorState, onSelected, style) => - _selectionMenuIcon('image', editorState, onSelected, style), + icon: (editorState, isSelected, style) => SelectionMenuIconWidget( + name: 'image', + isSelected: isSelected, + style: style, + ), keywords: ['image'], handler: (editorState, menuService, context) { final container = Overlay.of(context); @@ -246,8 +262,11 @@ final List standardSelectionMenuItems = [ ), SelectionMenuItem( name: AppFlowyEditorLocalizations.current.bulletedList, - icon: (editorState, onSelected, style) => - _selectionMenuIcon('bulleted_list', editorState, onSelected, style), + icon: (editorState, isSelected, style) => SelectionMenuIconWidget( + name: 'bulleted_list', + isSelected: isSelected, + style: style, + ), keywords: ['bulleted list', 'list', 'unordered list'], handler: (editorState, _, __) { insertBulletedListAfterSelection(editorState); @@ -255,8 +274,11 @@ final List standardSelectionMenuItems = [ ), SelectionMenuItem( name: AppFlowyEditorLocalizations.current.numberedList, - icon: (editorState, onSelected, style) => - _selectionMenuIcon('number', editorState, onSelected, style), + icon: (editorState, isSelected, style) => SelectionMenuIconWidget( + name: 'number', + isSelected: isSelected, + style: style, + ), keywords: ['numbered list', 'list', 'ordered list'], handler: (editorState, _, __) { insertNumberedListAfterSelection(editorState); @@ -264,8 +286,11 @@ final List standardSelectionMenuItems = [ ), SelectionMenuItem( name: AppFlowyEditorLocalizations.current.checkbox, - icon: (editorState, onSelected, style) => - _selectionMenuIcon('checkbox', editorState, onSelected, style), + icon: (editorState, isSelected, style) => SelectionMenuIconWidget( + name: 'checkbox', + isSelected: isSelected, + style: style, + ), keywords: ['todo list', 'list', 'checkbox list'], handler: (editorState, _, __) { insertCheckboxAfterSelection(editorState); @@ -273,27 +298,15 @@ final List standardSelectionMenuItems = [ ), SelectionMenuItem( name: AppFlowyEditorLocalizations.current.quote, - icon: (editorState, onSelected, style) => - _selectionMenuIcon('quote', editorState, onSelected, style), + icon: (editorState, isSelected, style) => SelectionMenuIconWidget( + name: 'quote', + isSelected: isSelected, + style: style, + ), keywords: ['quote', 'refer'], handler: (editorState, _, __) { insertQuoteAfterSelection(editorState); }, ), + dividerMenuItem, ]; - -Widget _selectionMenuIcon( - String name, - EditorState editorState, - bool onSelected, - SelectionMenuStyle style, -) { - return FlowySvg( - name: 'selection_menu/$name', - color: onSelected - ? style.selectionMenuItemSelectedIconColor - : style.selectionMenuItemIconColor, - width: 18.0, - height: 18.0, - ); -} diff --git a/lib/src/service/standard_block_components.dart b/lib/src/service/standard_block_components.dart index 133641a06..9d6f96ec9 100644 --- a/lib/src/service/standard_block_components.dart +++ b/lib/src/service/standard_block_components.dart @@ -1,4 +1,5 @@ import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/material.dart'; const standardBlockComponentConfiguration = BlockComponentConfiguration(); @@ -34,6 +35,11 @@ final Map standardBlockComponentBuilderMap = { ), ), ImageBlockKeys.type: ImageBlockComponentBuilder(), + DividerBlockKeys.type: DividerBlockComponentBuilder( + configuration: standardBlockComponentConfiguration.copyWith( + padding: (node) => EdgeInsets.symmetric(vertical: 8.0), + ), + ), }; final List standardCharacterShortcutEvents = [ @@ -68,6 +74,10 @@ final List standardCharacterShortcutEvents = [ // slash slashCommand, + // divider + convertMinusesToDivider, + convertStarsToDivider, + // markdown syntax ...markdownSyntaxShortcutEvents, ]; diff --git a/test/new/block_component/divider_block_component/divider_character_shortcut_test.dart b/test/new/block_component/divider_block_component/divider_character_shortcut_test.dart new file mode 100644 index 000000000..a2197ea84 --- /dev/null +++ b/test/new/block_component/divider_block_component/divider_character_shortcut_test.dart @@ -0,0 +1,65 @@ +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../util/util.dart'; +import '../test_character_shortcut.dart'; + +void main() async { + group( + 'divider_character_shortcut.dart', + () { + setUpAll(() { + if (kDebugMode) { + activateLog(); + } + }); + + tearDownAll( + () { + if (kDebugMode) { + deactivateLog(); + } + }, + ); + + // Before + // -- + // After + // [divider] + test('--- to divider', () async { + const text = ''; + testFormatCharacterShortcut( + convertMinusesToDivider, + '--', + 2, + (result, before, after) { + expect(result, true); + expect(after.delta, null); + expect(after.type, DividerBlockKeys.type); + }, + text: text, + ); + }); + + // Before + // ** + // After + // [divider] + test('*** to divider', () async { + const text = ''; + testFormatCharacterShortcut( + convertStarsToDivider, + '**', + 2, + (result, before, after) { + expect(result, true); + expect(after.delta, null); + expect(after.type, DividerBlockKeys.type); + }, + text: text, + ); + }); + }, + ); +}