From b59eba76a65c476ab9e24d214cbc5fe37f6cc8f9 Mon Sep 17 00:00:00 2001 From: Richard Shiue <71320345+richardshiue@users.noreply.github.com> Date: Wed, 12 Mar 2025 13:54:00 +0800 Subject: [PATCH] fix: hide continue writing when document is empty (#7498) * fix: hide continue writing when document is empty * chore: code clean up and add documentation --- .../database/widgets/row/row_document.dart | 14 +++- .../database_document_page.dart | 6 ++ .../lib/plugins/document/document_page.dart | 4 + .../document/presentation/editor_page.dart | 3 + .../ai/ai_writer_block_component.dart | 80 ++++++++++++++----- .../operations/ai_writer_node_extension.dart | 62 ++++++++++++++ .../slash_menu/slash_menu_items_builder.dart | 13 ++- 7 files changed, 156 insertions(+), 26 deletions(-) diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/row/row_document.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/row/row_document.dart index 0489db8907..8e229f6b21 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/row/row_document.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/row/row_document.dart @@ -9,6 +9,7 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/shared_con import 'package:appflowy/plugins/document/presentation/editor_plugins/transaction_handler/editor_transaction_service.dart'; import 'package:appflowy/plugins/document/presentation/editor_style.dart'; import 'package:appflowy/shared/flowy_error_page.dart'; +import 'package:appflowy/workspace/application/view/view_bloc.dart'; import 'package:appflowy/workspace/application/view_info/view_info_bloc.dart'; import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; @@ -71,9 +72,16 @@ class _RowEditor extends StatelessWidget { @override Widget build(BuildContext context) { - return BlocProvider( - create: (_) => - DocumentBloc(documentId: view.id)..add(const DocumentEvent.initial()), + return MultiBlocProvider( + providers: [ + BlocProvider( + create: (_) => DocumentBloc(documentId: view.id) + ..add(const DocumentEvent.initial()), + ), + BlocProvider( + create: (_) => ViewBloc(view: view)..add(const ViewEvent.initial()), + ), + ], child: BlocConsumer( listenWhen: (previous, current) => previous.isDocumentEmpty != current.isDocumentEmpty, diff --git a/frontend/appflowy_flutter/lib/plugins/database_document/database_document_page.dart b/frontend/appflowy_flutter/lib/plugins/database_document/database_document_page.dart index eaa82b22e9..186671b427 100644 --- a/frontend/appflowy_flutter/lib/plugins/database_document/database_document_page.dart +++ b/frontend/appflowy_flutter/lib/plugins/database_document/database_document_page.dart @@ -21,6 +21,8 @@ import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import '../../workspace/application/view/view_bloc.dart'; + // This widget is largely copied from `plugins/document/document_page.dart` intentionally instead of opting for an abstraction. We can make an abstraction after the view refactor is done and there's more clarity in that department. class DatabaseDocumentPage extends StatefulWidget { @@ -72,6 +74,10 @@ class _DatabaseDocumentPageState extends State { documentId: widget.documentId, )..add(const DocumentEvent.initial()), ), + BlocProvider( + create: (_) => + ViewBloc(view: widget.view)..add(const ViewEvent.initial()), + ), ], child: BlocBuilder( builder: (context, state) { diff --git a/frontend/appflowy_flutter/lib/plugins/document/document_page.dart b/frontend/appflowy_flutter/lib/plugins/document/document_page.dart index 5c695fa508..4e15128788 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/document_page.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/document_page.dart @@ -92,6 +92,10 @@ class _DocumentPageState extends State ViewLockStatusEvent.initial(), ), ), + BlocProvider( + create: (_) => + ViewBloc(view: widget.view)..add(const ViewEvent.initial()), + ), ], child: BlocConsumer( listenWhen: (prev, curr) => curr.isLocked != prev.isLocked, 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 b5f32d9f3c..9c5302bcd8 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_page.dart @@ -15,6 +15,7 @@ import 'package:appflowy/plugins/inline_actions/inline_actions_service.dart'; import 'package:appflowy/shared/feature_flags.dart'; import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart'; import 'package:appflowy/workspace/application/settings/shortcuts/settings_shortcuts_service.dart'; +import 'package:appflowy/workspace/application/view/view_bloc.dart'; import 'package:appflowy/workspace/application/view/view_lock_status_bloc.dart'; import 'package:appflowy/workspace/application/view_info/view_info_bloc.dart'; import 'package:appflowy/workspace/presentation/home/af_focus_manager.dart'; @@ -437,11 +438,13 @@ class _AppFlowyEditorPageState extends State }) { final documentBloc = context.read(); final isLocalMode = documentBloc.isLocalMode; + final view = context.read().state.view; return slashMenuItemsBuilder( editorState: editorState, node: node, isLocalMode: isLocalMode, documentBloc: documentBloc, + view: view, ); } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/ai_writer_block_component.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/ai_writer_block_component.dart index ee6cf16909..ed77fadae4 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/ai_writer_block_component.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/ai_writer_block_component.dart @@ -4,6 +4,7 @@ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/ai_chat/presentation/message/ai_markdown_text.dart'; import 'package:appflowy/plugins/document/application/prelude.dart'; import 'package:appflowy/util/theme_extension.dart'; +import 'package:appflowy/workspace/application/view/view_bloc.dart'; import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/shared_widget.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; @@ -187,6 +188,7 @@ class _AIWriterBlockComponentState extends State { ), width: constraints.maxWidth, child: OverlayContent( + editorState: editorState, node: widget.node, ), ), @@ -236,9 +238,11 @@ class _AIWriterBlockComponentState extends State { class OverlayContent extends StatelessWidget { const OverlayContent({ super.key, + required this.editorState, required this.node, }); + final EditorState editorState; final Node node; @override @@ -372,28 +376,12 @@ class OverlayContent extends StatelessWidget { ], ), ), - if (showActionPopup) ...[ - const VSpace(4.0 + 1.0), - Container( - padding: EdgeInsets.all(8.0), - constraints: BoxConstraints(minWidth: 240.0), - decoration: _getModalDecoration( - context, - color: Theme.of(context).colorScheme.surface, - borderColor: lightBorderColor, - borderRadius: BorderRadius.all(Radius.circular(8.0)), - ), - child: IntrinsicWidth( - child: SeparatedColumn( - separatorBuilder: () => const VSpace(4.0), - crossAxisAlignment: CrossAxisAlignment.start, - children: _getCommands( - hasSelection: hasSelection, - ), - ), - ), - ), - ], + ..._bottomActions( + context, + showActionPopup, + hasSelection, + lightBorderColor, + ), ], ); }, @@ -477,6 +465,54 @@ class OverlayContent extends StatelessWidget { ); } + List _bottomActions( + BuildContext context, + bool showActionPopup, + bool hasSelection, + Color borderColor, + ) { + if (!showActionPopup) { + return []; + } + + if (editorState.isEmptyForContinueWriting()) { + final documentContext = editorState.document.root.context; + if (documentContext == null) { + return []; + } + final view = documentContext.read().state.view; + if (view.name.isEmpty) { + return []; + } + } + + return [ + // add one here to take into account the border of the main message box. + // It is configured to be on the outside to hide some graphical + // artifacts. + const VSpace(4.0 + 1.0), + Container( + padding: EdgeInsets.all(8.0), + constraints: BoxConstraints(minWidth: 240.0), + decoration: _getModalDecoration( + context, + color: Theme.of(context).colorScheme.surface, + borderColor: borderColor, + borderRadius: BorderRadius.all(Radius.circular(8.0)), + ), + child: IntrinsicWidth( + child: SeparatedColumn( + separatorBuilder: () => const VSpace(4.0), + crossAxisAlignment: CrossAxisAlignment.start, + children: _getCommands( + hasSelection: hasSelection, + ), + ), + ), + ), + ]; + } + List _getCommands({required bool hasSelection}) { if (hasSelection) { return [ diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_node_extension.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_node_extension.dart index 1335f51df5..1119917e62 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_node_extension.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_node_extension.dart @@ -1,3 +1,5 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/parsers/document_markdown_parsers.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/parsers/sub_page_node_parser.dart'; import 'package:appflowy/shared/markdown_to_document.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; @@ -87,4 +89,64 @@ extension AiWriterNodeExtension on EditorState { return res; } + + /// Determines whether the document is empty up to the selection + /// + /// If empty and the title is also empty, the continue writing option will be disabled. + bool isEmptyForContinueWriting({ + Selection? selection, + }) { + if (selection != null && !selection.isCollapsed) { + return false; + } + + final effectiveSelection = Selection( + start: Position(path: [0]), + end: selection?.normalized.end ?? + this.selection?.normalized.end ?? + Position(path: [0]), + ); + + // if the selected nodes are not entirely selected, slice the nodes + final slicedNodes = []; + final nodes = getNodesInSelection(effectiveSelection); + + for (final node in nodes) { + final delta = node.delta; + if (delta == null) { + continue; + } + + final slicedDelta = delta.slice( + node == nodes.first ? effectiveSelection.startIndex : 0, + node == nodes.last ? effectiveSelection.endIndex : delta.length, + ); + + final copiedNode = node.copyWith( + attributes: { + ...node.attributes, + blockComponentDelta: slicedDelta.toJson(), + }, + ); + + slicedNodes.add(copiedNode); + } + + // using less custom parsers to avoid futures + final markdown = documentToMarkdown( + Document.blank()..insert([0], slicedNodes), + customParsers: [ + const MathEquationNodeParser(), + const CalloutNodeParser(), + const ToggleListNodeParser(), + const CustomParagraphNodeParser(), + const SubPageNodeParser(), + const SimpleTableNodeParser(), + const LinkPreviewNodeParser(), + const FileBlockNodeParser(), + ], + ); + + return markdown.trim().isEmpty; + } } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items_builder.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items_builder.dart index e953694966..137f592902 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items_builder.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items_builder.dart @@ -1,5 +1,7 @@ import 'package:appflowy/plugins/document/application/document_bloc.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_node_extension.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:universal_platform/universal_platform.dart'; @@ -13,9 +15,16 @@ List slashMenuItemsBuilder({ DocumentBloc? documentBloc, EditorState? editorState, Node? node, + ViewPB? view, }) { final isInTable = node != null && node.parentTableCellNode != null; final isMobile = UniversalPlatform.isMobile; + bool isEmpty = false; + if (editorState == null || editorState.isEmptyForContinueWriting()) { + if (view == null || view.name.isEmpty) { + isEmpty = true; + } + } if (isMobile) { if (isInTable) { return mobileItemsInTale; @@ -29,6 +38,7 @@ List slashMenuItemsBuilder({ return _defaultSlashMenuItems( isLocalMode: isLocalMode, documentBloc: documentBloc, + isEmpty: isEmpty, ); } } @@ -48,11 +58,12 @@ List slashMenuItemsBuilder({ List _defaultSlashMenuItems({ bool isLocalMode = false, DocumentBloc? documentBloc, + bool isEmpty = false, }) { return [ // ai if (!isLocalMode) ...[ - continueWritingSlashMenuItem, + if (!isEmpty) continueWritingSlashMenuItem, aiWriterSlashMenuItem, ],