From 6ab025ff8c797979a0cac9151301dbbd2c4d2797 Mon Sep 17 00:00:00 2001 From: Beatriz Bernardo Date: Mon, 13 Apr 2026 21:19:39 +0100 Subject: [PATCH] solved [FR] Add hashtag function #8658 --- .../document/presentation/editor_page.dart | 10 +++ .../delta/text_delta_extension.dart | 21 +++-- .../editor_plugins/hashtag/hashtag_block.dart | 38 +++++++++ .../hashtag/hashtag_block_keys.dart | 19 +++++ .../hashtag/hashtag_transaction_handler.dart | 34 ++++++++ .../parsers/custom_paragraph_node_parser.dart | 46 ++++++++--- .../shortcuts/character_shortcuts.dart | 7 ++ .../editor_transaction_service.dart | 2 + .../document/presentation/editor_style.dart | 16 ++++ .../handlers/hashtag_reference.dart | 77 ++++++++++++++++++ .../hashtag_actions_command.dart | 79 +++++++++++++++++++ 11 files changed, 334 insertions(+), 15 deletions(-) create mode 100644 frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/hashtag/hashtag_block.dart create mode 100644 frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/hashtag/hashtag_block_keys.dart create mode 100644 frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/hashtag/hashtag_transaction_handler.dart create mode 100644 frontend/appflowy_flutter/lib/plugins/inline_actions/handlers/hashtag_reference.dart create mode 100644 frontend/appflowy_flutter/lib/plugins/inline_actions/hashtag_actions_command.dart diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart index 404f087c59153..103d98d21fdc7 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart @@ -28,6 +28,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:universal_platform/universal_platform.dart'; +import '../../inline_actions/handlers/hashtag_reference.dart'; import 'editor_plugins/desktop_toolbar/desktop_floating_toolbar.dart'; import 'editor_plugins/toolbar_item/custom_format_toolbar_items.dart'; import 'editor_plugins/toolbar_item/custom_hightlight_color_toolbar_item.dart'; @@ -88,6 +89,13 @@ class _AppFlowyEditorPageState extends State ], ); + late final InlineActionsService hashtagActionsService = InlineActionsService( + context: context, + handlers: [ + HashtagReferenceService(), + ], + ); + late final List commandShortcuts = [ ...commandShortcutEvents, ..._buildFindAndReplaceCommands(), @@ -121,6 +129,7 @@ class _AppFlowyEditorPageState extends State documentBloc, styleCustomizer, inlineActionsService, + hashtagActionsService, (editorState, node) => _customSlashMenuItems( editorState: editorState, node: node, @@ -329,6 +338,7 @@ class _AppFlowyEditorPageState extends State effectiveScrollController.dispose(); } inlineActionsService.dispose(); + hashtagActionsService.dispose(); editorScrollController.dispose(); super.dispose(); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/delta/text_delta_extension.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/delta/text_delta_extension.dart index 905c033bda979..af990c1fc3069 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/delta/text_delta_extension.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/delta/text_delta_extension.dart @@ -1,3 +1,4 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/hashtag/hashtag_block_keys.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/custom_link_parser.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_block.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; @@ -22,13 +23,14 @@ extension TextDeltaExtension on Delta { while (ops.moveNext()) { final op = ops.current; final attributes = op.attributes; + if (op is TextInsert) { - // if the text is '\$', it means the block text is empty, - // the real data is in the attributes + // Mentions if (op.text == MentionBlockKeys.mentionChar) { final mention = attributes?[MentionBlockKeys.mention]; final mentionPageId = mention?[MentionBlockKeys.pageId]; final mentionType = mention?[MentionBlockKeys.type]; + if (mentionPageId != null) { text += await getMentionPageName(mentionPageId); continue; @@ -40,14 +42,23 @@ extension TextDeltaExtension on Delta { } } + // Hashtags + if (op.text == HashtagBlockKeys.hashtagChar) { + final hashtag = attributes?[HashtagBlockKeys.hashtag]; + final name = hashtag?[HashtagBlockKeys.name]; + + if (name is String && name.isNotEmpty) { + text += '#$name'; + continue; + } + } + text += op.text; } else { - // if the delta contains other types of operations, - // return the default plain text return defaultPlainText; } } return text; } -} +} \ No newline at end of file diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/hashtag/hashtag_block.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/hashtag/hashtag_block.dart new file mode 100644 index 0000000000000..1c0f7cafc28cc --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/hashtag/hashtag_block.dart @@ -0,0 +1,38 @@ +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/material.dart'; + +import 'hashtag_block_keys.dart'; + +class HashtagBlock extends StatelessWidget { + const HashtagBlock({ + super.key, + required this.data, + required this.textStyle, + }); + + final Map data; + final TextStyle? textStyle; + + @override + Widget build(BuildContext context) { + final tag = data[HashtagBlockKeys.name] as String? ?? ''; + + if (tag.isEmpty) { + return const SizedBox.shrink(); + } + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: Colors.blue.withValues(alpha: 0.10), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + '#$tag', + style: textStyle?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ); + } +} \ No newline at end of file diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/hashtag/hashtag_block_keys.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/hashtag/hashtag_block_keys.dart new file mode 100644 index 0000000000000..518f69e2947bf --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/hashtag/hashtag_block_keys.dart @@ -0,0 +1,19 @@ +class HashtagBlockKeys { + const HashtagBlockKeys._(); + + static const hashtag = 'hashtag'; + static const name = 'name'; + + // caractere interno invisível/placeholder, tal como o mention usa '$' + static const hashtagChar = '%'; + + static Map buildHashtagAttributes({ + required String name, + }) { + return { + hashtag: { + HashtagBlockKeys.name: name, + }, + }; + } +} \ No newline at end of file diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/hashtag/hashtag_transaction_handler.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/hashtag/hashtag_transaction_handler.dart new file mode 100644 index 0000000000000..6b5bef8485d5a --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/hashtag/hashtag_transaction_handler.dart @@ -0,0 +1,34 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/transaction_handler/editor_transaction_handler.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:flutter/material.dart'; + +import 'hashtag_block_keys.dart'; + +class HashtagTransactionHandler extends EditorTransactionHandler> { + HashtagTransactionHandler() + : super( + type: HashtagBlockKeys.hashtag, + livesInDelta: true, + ); + + @override + Future onTransaction( + BuildContext context, + String viewId, + EditorState editorState, + List> added, + List> removed, { + bool isCut = false, + bool isUndoRedo = false, + bool isPaste = false, + bool isDraggingNode = false, + bool isTurnInto = false, + String? parentViewId, + }) async { + // Versão inicial: + // não faz sync com backend nem efeitos laterais. + // Serve apenas para o editor reconhecer oficialmente este tipo + // e para termos um ponto de extensão preparado. + return; + } +} \ No newline at end of file diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/parsers/custom_paragraph_node_parser.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/parsers/custom_paragraph_node_parser.dart index b7d76741370e3..7c514ac386d0c 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/parsers/custom_paragraph_node_parser.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/parsers/custom_paragraph_node_parser.dart @@ -1,3 +1,4 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/hashtag/hashtag_block_keys.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; @@ -11,27 +12,52 @@ class CustomParagraphNodeParser extends NodeParser { @override String transform(Node node, DocumentMarkdownEncoder? encoder) { final delta = node.delta; - if (delta != null) { - for (final o in delta) { - final attribute = o.attributes ?? {}; - final Map? mention = attribute[MentionBlockKeys.mention] ?? {}; - if (mention == null) continue; + if (delta == null) { + return const TextNodeParser().transform(node, encoder); + } + + String text = ''; + + for (final o in delta) { + final attributes = o.attributes ?? {}; + // Mentions + final Map? mention = attributes[MentionBlockKeys.mention]; + if (mention != null) { /// filter date reminder node, and return it final String date = mention[MentionBlockKeys.date] ?? ''; if (date.isNotEmpty) { final dateTime = DateTime.tryParse(date); - if (dateTime == null) continue; - return '${DateFormat.yMMMd().format(dateTime)}\n'; + if (dateTime != null) { + text += DateFormat.yMMMd().format(dateTime); + continue; + } } /// filter reference page final String pageId = mention[MentionBlockKeys.pageId] ?? ''; if (pageId.isNotEmpty) { - return '[]($pageId)\n'; + text += '[]($pageId)'; + continue; } } + + // Hashtags + final Map? hashtag = attributes[HashtagBlockKeys.hashtag]; + if (hashtag != null) { + final String name = hashtag[HashtagBlockKeys.name] ?? ''; + if (name.isNotEmpty) { + text += '#$name'; + continue; + } + } + + // Plain text + if (o is TextInsert) { + text += o.text; + } } - return const TextNodeParser().transform(node, encoder); + + return '$text\n'; } -} +} \ No newline at end of file diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/shortcuts/character_shortcuts.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/shortcuts/character_shortcuts.dart index 13b2fea5ee36b..f482d99bf380c 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/shortcuts/character_shortcuts.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/shortcuts/character_shortcuts.dart @@ -13,12 +13,15 @@ import 'package:appflowy/plugins/inline_actions/inline_actions_service.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; import 'package:flutter/material.dart'; +import 'package:appflowy/plugins/inline_actions/hashtag_actions_command.dart'; + List buildCharacterShortcutEvents( BuildContext context, DocumentBloc documentBloc, EditorStyleCustomizer styleCustomizer, InlineActionsService inlineActionsService, + InlineActionsService hashtagActionsService, SlashMenuItemsBuilder slashMenuItemsBuilder, ) { return [ @@ -69,6 +72,10 @@ List buildCharacterShortcutEvents( style: styleCustomizer.inlineActionsMenuStyleBuilder(), ), + hashtagActionsCommand( + hashtagActionsService, + style: styleCustomizer.inlineActionsMenuStyleBuilder(), + ), /// Inline page menu /// - Using `[[` pageReferenceShortcutBrackets( diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/transaction_handler/editor_transaction_service.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/transaction_handler/editor_transaction_service.dart index b56066ae8bb3f..21fb88448d79f 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/transaction_handler/editor_transaction_service.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/transaction_handler/editor_transaction_service.dart @@ -10,6 +10,7 @@ import 'package:appflowy/shared/feature_flags.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/hashtag/hashtag_transaction_handler.dart'; import 'mention_transaction_handler.dart'; @@ -19,6 +20,7 @@ final _transactionHandlers = [ ChildPageTransactionHandler(), ], DateTransactionHandler(), + HashtagTransactionHandler(), ]; /// Handles delegating transactions to appropriate handlers. diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_style.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_style.dart index b8ec8495f2b91..a90b2d80720d9 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_style.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_style.dart @@ -26,6 +26,8 @@ import 'package:google_fonts/google_fonts.dart'; import 'package:universal_platform/universal_platform.dart'; import 'editor_plugins/desktop_toolbar/link/link_hover_menu.dart'; +import 'editor_plugins/hashtag/hashtag_block.dart'; +import 'editor_plugins/hashtag/hashtag_block_keys.dart'; import 'editor_plugins/toolbar_item/more_option_toolbar_item.dart'; class EditorStyleCustomizer { @@ -412,6 +414,20 @@ class EditorStyleCustomizer { ); } + final hashtag = + attributes[HashtagBlockKeys.hashtag] as Map?; + if (hashtag != null) { + return WidgetSpan( + alignment: PlaceholderAlignment.middle, + style: newStyle, + child: HashtagBlock( + key: ValueKey(hashtag[HashtagBlockKeys.name]), + data: hashtag, + textStyle: newStyle, + ), + ); + } + // customize the inline math equation block final formula = attributes[InlineMathEquationKeys.formula]; if (formula is String) { diff --git a/frontend/appflowy_flutter/lib/plugins/inline_actions/handlers/hashtag_reference.dart b/frontend/appflowy_flutter/lib/plugins/inline_actions/handlers/hashtag_reference.dart new file mode 100644 index 0000000000000..2f34e0cd5af71 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/inline_actions/handlers/hashtag_reference.dart @@ -0,0 +1,77 @@ +import 'package:appflowy_editor/appflowy_editor.dart'; + +import '../../document/presentation/editor_plugins/hashtag/hashtag_block_keys.dart'; +import '../inline_actions_result.dart'; +import '../service_handler.dart'; + +class HashtagReferenceService extends InlineActionsDelegate { + HashtagReferenceService({ + this.initialTags = const [ + 'todo', + 'idea', + 'bug', + 'feature', + 'important', + ], + }); + + final List initialTags; + + @override + Future search([String? search]) async { + final term = (search ?? '').toLowerCase(); + + final tags = initialTags + .where((tag) => term.isEmpty || tag.toLowerCase().contains(term)) + .map( + (tag) => InlineActionsMenuItem( + label: '#$tag', + keywords: [tag], + onSelected: (context, editorState, menu, replace) async { + final selection = editorState.selection; + if (selection == null || !selection.isCollapsed) { + return; + } + + final node = editorState.getNodeAtPath(selection.start.path); + if (node == null) return; + + final start = replace.$1; + final length = replace.$2; + + final transaction = editorState.transaction + ..replaceText( + node, + start, + length, + HashtagBlockKeys.hashtagChar, + attributes: HashtagBlockKeys.buildHashtagAttributes( + name: tag, + ), + ) + ..insertText( + node, + start + 1, + ' ', + ) + ..afterSelection = Selection.collapsed( + Position( + path: node.path, + offset: start + 2, + ), + ); + + menu.dismiss(); + await editorState.apply(transaction); + }, + ), + ) + .toList(); + + return InlineActionsResult( + title: 'Hashtags', + results: tags, + startsWithKeywords: const ['#'], + ); + } +} \ No newline at end of file diff --git a/frontend/appflowy_flutter/lib/plugins/inline_actions/hashtag_actions_command.dart b/frontend/appflowy_flutter/lib/plugins/inline_actions/hashtag_actions_command.dart new file mode 100644 index 0000000000000..2dfe1059179d1 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/inline_actions/hashtag_actions_command.dart @@ -0,0 +1,79 @@ +import 'package:appflowy/mobile/presentation/inline_actions/mobile_inline_actions_menu.dart'; +import 'package:appflowy/plugins/inline_actions/inline_actions_menu.dart'; +import 'package:appflowy/plugins/inline_actions/inline_actions_result.dart'; +import 'package:appflowy/plugins/inline_actions/inline_actions_service.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:universal_platform/universal_platform.dart'; + +const hashtagActionCharacter = '#'; + +InlineActionsMenuService? selectionMenuService; + +CharacterShortcutEvent hashtagActionsCommand( + InlineActionsService inlineActionsService, { + InlineActionsMenuStyle style = const InlineActionsMenuStyle.light(), +}) => + CharacterShortcutEvent( + key: 'Opens Hashtag Menu', + character: hashtagActionCharacter, + handler: (editorState) => hashtagActionsCommandHandler( + editorState, + inlineActionsService, + style, + ), + ); + +Future hashtagActionsCommandHandler( + EditorState editorState, + InlineActionsService service, + InlineActionsMenuStyle style, +) async { + final selection = editorState.selection; + if (selection == null) { + return false; + } + + if (!selection.isCollapsed) { + await editorState.deleteSelection(selection); + } + + await editorState.insertTextAtPosition( + hashtagActionCharacter, + position: selection.start, + ); + + final List initialResults = []; + for (final handler in service.handlers) { + final group = await handler.search(null); + + if (group.results.isNotEmpty) { + initialResults.add(group); + } + } + + if (service.context != null) { + keepEditorFocusNotifier.increase(); + selectionMenuService?.dismiss(); + selectionMenuService = UniversalPlatform.isMobile + ? MobileInlineActionsMenu( + context: service.context!, + editorState: editorState, + service: service, + initialResults: initialResults, + style: style, + ) + : InlineActionsMenu( + context: service.context!, + editorState: editorState, + service: service, + initialResults: initialResults, + style: style, + ); + + editorState.service.keyboardService?.disable(); + await selectionMenuService?.show(); + editorState.service.keyboardService?.enable(); + } + + return true; +} \ No newline at end of file