fix: hide continue writing when document is empty (#7498)

* fix: hide continue writing when document is empty

* chore: code clean up and add documentation
This commit is contained in:
Richard Shiue 2025-03-12 13:54:00 +08:00 committed by GitHub
parent 070cde9ecb
commit b59eba76a6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 156 additions and 26 deletions

View File

@ -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_plugins/transaction_handler/editor_transaction_service.dart';
import 'package:appflowy/plugins/document/presentation/editor_style.dart'; import 'package:appflowy/plugins/document/presentation/editor_style.dart';
import 'package:appflowy/shared/flowy_error_page.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/workspace/application/view_info/view_info_bloc.dart';
import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
@ -71,9 +72,16 @@ class _RowEditor extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BlocProvider( return MultiBlocProvider(
create: (_) => providers: [
DocumentBloc(documentId: view.id)..add(const DocumentEvent.initial()), BlocProvider(
create: (_) => DocumentBloc(documentId: view.id)
..add(const DocumentEvent.initial()),
),
BlocProvider(
create: (_) => ViewBloc(view: view)..add(const ViewEvent.initial()),
),
],
child: BlocConsumer<DocumentBloc, DocumentState>( child: BlocConsumer<DocumentBloc, DocumentState>(
listenWhen: (previous, current) => listenWhen: (previous, current) =>
previous.isDocumentEmpty != current.isDocumentEmpty, previous.isDocumentEmpty != current.isDocumentEmpty,

View File

@ -21,6 +21,8 @@ import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.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. // 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 { class DatabaseDocumentPage extends StatefulWidget {
@ -72,6 +74,10 @@ class _DatabaseDocumentPageState extends State<DatabaseDocumentPage> {
documentId: widget.documentId, documentId: widget.documentId,
)..add(const DocumentEvent.initial()), )..add(const DocumentEvent.initial()),
), ),
BlocProvider(
create: (_) =>
ViewBloc(view: widget.view)..add(const ViewEvent.initial()),
),
], ],
child: BlocBuilder<DocumentBloc, DocumentState>( child: BlocBuilder<DocumentBloc, DocumentState>(
builder: (context, state) { builder: (context, state) {

View File

@ -92,6 +92,10 @@ class _DocumentPageState extends State<DocumentPage>
ViewLockStatusEvent.initial(), ViewLockStatusEvent.initial(),
), ),
), ),
BlocProvider(
create: (_) =>
ViewBloc(view: widget.view)..add(const ViewEvent.initial()),
),
], ],
child: BlocConsumer<ViewLockStatusBloc, ViewLockStatusState>( child: BlocConsumer<ViewLockStatusBloc, ViewLockStatusState>(
listenWhen: (prev, curr) => curr.isLocked != prev.isLocked, listenWhen: (prev, curr) => curr.isLocked != prev.isLocked,

View File

@ -15,6 +15,7 @@ import 'package:appflowy/plugins/inline_actions/inline_actions_service.dart';
import 'package:appflowy/shared/feature_flags.dart'; import 'package:appflowy/shared/feature_flags.dart';
import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.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/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/view_lock_status_bloc.dart';
import 'package:appflowy/workspace/application/view_info/view_info_bloc.dart'; import 'package:appflowy/workspace/application/view_info/view_info_bloc.dart';
import 'package:appflowy/workspace/presentation/home/af_focus_manager.dart'; import 'package:appflowy/workspace/presentation/home/af_focus_manager.dart';
@ -437,11 +438,13 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage>
}) { }) {
final documentBloc = context.read<DocumentBloc>(); final documentBloc = context.read<DocumentBloc>();
final isLocalMode = documentBloc.isLocalMode; final isLocalMode = documentBloc.isLocalMode;
final view = context.read<ViewBloc>().state.view;
return slashMenuItemsBuilder( return slashMenuItemsBuilder(
editorState: editorState, editorState: editorState,
node: node, node: node,
isLocalMode: isLocalMode, isLocalMode: isLocalMode,
documentBloc: documentBloc, documentBloc: documentBloc,
view: view,
); );
} }

View File

@ -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/ai_chat/presentation/message/ai_markdown_text.dart';
import 'package:appflowy/plugins/document/application/prelude.dart'; import 'package:appflowy/plugins/document/application/prelude.dart';
import 'package:appflowy/util/theme_extension.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/home/menu/sidebar/space/shared_widget.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor/appflowy_editor.dart';
@ -187,6 +188,7 @@ class _AIWriterBlockComponentState extends State<AiWriterBlockComponent> {
), ),
width: constraints.maxWidth, width: constraints.maxWidth,
child: OverlayContent( child: OverlayContent(
editorState: editorState,
node: widget.node, node: widget.node,
), ),
), ),
@ -236,9 +238,11 @@ class _AIWriterBlockComponentState extends State<AiWriterBlockComponent> {
class OverlayContent extends StatelessWidget { class OverlayContent extends StatelessWidget {
const OverlayContent({ const OverlayContent({
super.key, super.key,
required this.editorState,
required this.node, required this.node,
}); });
final EditorState editorState;
final Node node; final Node node;
@override @override
@ -372,28 +376,12 @@ class OverlayContent extends StatelessWidget {
], ],
), ),
), ),
if (showActionPopup) ...[ ..._bottomActions(
const VSpace(4.0 + 1.0), context,
Container( showActionPopup,
padding: EdgeInsets.all(8.0), hasSelection,
constraints: BoxConstraints(minWidth: 240.0), lightBorderColor,
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,
),
),
),
),
],
], ],
); );
}, },
@ -477,6 +465,54 @@ class OverlayContent extends StatelessWidget {
); );
} }
List<Widget> _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<ViewBloc>().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<Widget> _getCommands({required bool hasSelection}) { List<Widget> _getCommands({required bool hasSelection}) {
if (hasSelection) { if (hasSelection) {
return [ return [

View File

@ -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/shared/markdown_to_document.dart';
import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor/appflowy_editor.dart';
@ -87,4 +89,64 @@ extension AiWriterNodeExtension on EditorState {
return res; 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 = <Node>[];
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;
}
} }

View File

@ -1,5 +1,7 @@
import 'package:appflowy/plugins/document/application/document_bloc.dart'; 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/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:appflowy_editor/appflowy_editor.dart';
import 'package:universal_platform/universal_platform.dart'; import 'package:universal_platform/universal_platform.dart';
@ -13,9 +15,16 @@ List<SelectionMenuItem> slashMenuItemsBuilder({
DocumentBloc? documentBloc, DocumentBloc? documentBloc,
EditorState? editorState, EditorState? editorState,
Node? node, Node? node,
ViewPB? view,
}) { }) {
final isInTable = node != null && node.parentTableCellNode != null; final isInTable = node != null && node.parentTableCellNode != null;
final isMobile = UniversalPlatform.isMobile; final isMobile = UniversalPlatform.isMobile;
bool isEmpty = false;
if (editorState == null || editorState.isEmptyForContinueWriting()) {
if (view == null || view.name.isEmpty) {
isEmpty = true;
}
}
if (isMobile) { if (isMobile) {
if (isInTable) { if (isInTable) {
return mobileItemsInTale; return mobileItemsInTale;
@ -29,6 +38,7 @@ List<SelectionMenuItem> slashMenuItemsBuilder({
return _defaultSlashMenuItems( return _defaultSlashMenuItems(
isLocalMode: isLocalMode, isLocalMode: isLocalMode,
documentBloc: documentBloc, documentBloc: documentBloc,
isEmpty: isEmpty,
); );
} }
} }
@ -48,11 +58,12 @@ List<SelectionMenuItem> slashMenuItemsBuilder({
List<SelectionMenuItem> _defaultSlashMenuItems({ List<SelectionMenuItem> _defaultSlashMenuItems({
bool isLocalMode = false, bool isLocalMode = false,
DocumentBloc? documentBloc, DocumentBloc? documentBloc,
bool isEmpty = false,
}) { }) {
return [ return [
// ai // ai
if (!isLocalMode) ...[ if (!isLocalMode) ...[
continueWritingSlashMenuItem, if (!isEmpty) continueWritingSlashMenuItem,
aiWriterSlashMenuItem, aiWriterSlashMenuItem,
], ],