feat: observe mention block change and support block navigation (#6568)

* feat: observe mentioned block changes and navigate to block

* test: add delete mentioned block test

* chore: update editor version

* feat: navigate block in same page

* fix: sometimes turn into menu doesn't work

* test: add test

* fix: integration test
This commit is contained in:
Lucas 2024-10-17 13:29:34 +08:00 committed by GitHub
parent 7cad04bbf4
commit 0413100e2b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 296 additions and 34 deletions

View File

@ -1,9 +1,12 @@
import 'package:appflowy/env/cloud_env.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/document_page.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_block.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart';
import 'package:appflowy/shared/patterns/common_patterns.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
@ -99,6 +102,27 @@ void main() {
// tap the mention block to jump to the page
await tester.tapButton(find.byType(MentionPageBlock));
await tester.pumpAndSettle();
// expect to go to the getting started page
final documentPage = find.byType(DocumentPage);
expect(documentPage, findsOneWidget);
expect(
tester.widget<DocumentPage>(documentPage).view.name,
Constants.gettingStartedPageName,
);
// and the block is selected
expect(
tester.widget<DocumentPage>(documentPage).initialBlockId,
mention[MentionBlockKeys.blockId],
);
expect(
tester.editor.getCurrentEditorState().selection,
Selection.collapsed(
Position(
path: [0],
),
),
);
});
testWidgets('copy link to block(same page) and paste it in doc',
@ -168,6 +192,84 @@ void main() {
),
findsNWidgets(2),
);
// tap the mention block
await tester.tapButton(find.byType(MentionPageBlock));
expect(
tester.editor.getCurrentEditorState().selection,
Selection.collapsed(
Position(
path: [0],
),
),
);
});
testWidgets('''1. copy link to block from another page
2. paste the link to the new page
3. delete the original page
4. check the content of the block, it should be no access to the page
''', (tester) async {
await tester.initializeAppFlowy(
cloudType: AuthenticatorType.appflowyCloudSelfHost,
);
await tester.tapGoogleLoginInButton();
await tester.expectToSeeHomePageWithGetStartedPage();
// open getting started page
await tester.openPage(Constants.gettingStartedPageName);
await tester.editor.copyLinkToBlock([0]);
// create a new page and paste it
const pageName = 'copy link to block';
await tester.createNewPageInSpace(
spaceName: Constants.generalSpaceName,
layout: ViewLayoutPB.Document,
pageName: pageName,
);
// paste the link to the new page
await tester.editor.tapLineOfEditorAt(0);
await tester.editor.paste();
await tester.pumpAndSettle();
// tap the mention block to jump to the page
await tester.tapButton(find.byType(MentionPageBlock));
await tester.pumpAndSettle();
// expect to go to the getting started page
final documentPage = find.byType(DocumentPage);
expect(documentPage, findsOneWidget);
expect(
tester.widget<DocumentPage>(documentPage).view.name,
Constants.gettingStartedPageName,
);
// delete the getting started page
await tester.hoverOnPageName(
Constants.gettingStartedPageName,
onHover: () async => tester.tapDeletePageButton(),
);
tester.expectToSeeDocumentBanner();
tester.expectNotToSeePageName(gettingStarted);
// delete the page permanently
await tester.tapDeletePermanentlyButton();
// go back the page
await tester.openPage(pageName);
await tester.pumpAndSettle();
// check the content of the block
// it should be no access to the page
expect(
find.descendant(
of: find.byType(AppFlowyEditor),
matching: find.findTextInFlowyText(
LocaleKeys.document_mention_noAccess.tr(),
),
),
findsOneWidget,
);
});
});
}

View File

@ -51,6 +51,7 @@ class DocumentPlugin extends Plugin {
required ViewPB view,
required PluginType pluginType,
this.initialSelection,
this.initialBlockId,
}) : notifier = ViewPluginNotifier(view: view) {
_pluginType = pluginType;
}
@ -61,13 +62,18 @@ class DocumentPlugin extends Plugin {
@override
final ViewPluginNotifier notifier;
// the initial selection of the document
final Selection? initialSelection;
// the initial block id of the document
final String? initialBlockId;
@override
PluginWidgetBuilder get widgetBuilder => DocumentPluginWidgetBuilder(
bloc: _viewInfoBloc,
notifier: notifier,
initialSelection: initialSelection,
initialBlockId: initialBlockId,
);
@override
@ -95,6 +101,7 @@ class DocumentPluginWidgetBuilder extends PluginWidgetBuilder
required this.bloc,
required this.notifier,
this.initialSelection,
this.initialBlockId,
});
final ViewInfoBloc bloc;
@ -102,6 +109,7 @@ class DocumentPluginWidgetBuilder extends PluginWidgetBuilder
ViewPB get view => notifier.view;
int? deletedViewIndex;
final Selection? initialSelection;
final String? initialBlockId;
@override
EdgeInsets get contentPadding => EdgeInsets.zero;
@ -129,6 +137,7 @@ class DocumentPluginWidgetBuilder extends PluginWidgetBuilder
view: view,
onDeleted: () => context.onDeleted?.call(view, deletedViewIndex),
initialSelection: initialSelection,
initialBlockId: initialBlockId,
fixedTitle: fixedTitle,
),
),

View File

@ -1,7 +1,5 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart';
import 'package:appflowy/plugins/document/application/document_appearance_cubit.dart';
import 'package:appflowy/plugins/document/application/document_bloc.dart';
@ -21,6 +19,7 @@ import 'package:appflowy/workspace/application/view/prelude.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:provider/provider.dart';
import 'package:universal_platform/universal_platform.dart';
@ -31,12 +30,14 @@ class DocumentPage extends StatefulWidget {
required this.view,
required this.onDeleted,
this.initialSelection,
this.initialBlockId,
this.fixedTitle,
});
final ViewPB view;
final VoidCallback onDeleted;
final Selection? initialSelection;
final String? initialBlockId;
final String? fixedTitle;
@override
@ -124,13 +125,18 @@ class _DocumentPageState extends State<DocumentPage>
BuildContext context,
DocumentState state,
) {
final editorState = state.editorState;
if (editorState == null) {
return const SizedBox.shrink();
}
final width = context.read<DocumentAppearanceCubit>().state.width;
final Widget child;
if (UniversalPlatform.isMobile) {
child = BlocBuilder<DocumentPageStyleBloc, DocumentPageStyleState>(
builder: (context, styleState) => AppFlowyEditorPage(
editorState: state.editorState!,
editorState: editorState,
// if the view's name is empty, focus on the title
autoFocus: widget.view.name.isEmpty ? false : null,
styleCustomizer: EditorStyleCustomizer(
@ -145,10 +151,10 @@ class _DocumentPageState extends State<DocumentPage>
} else {
child = EditorDropHandler(
viewId: widget.view.id,
editorState: state.editorState!,
editorState: editorState,
isLocalMode: context.read<DocumentBloc>().isLocalMode,
child: AppFlowyEditorPage(
editorState: state.editorState!,
editorState: editorState,
// if the view's name is empty, focus on the title
autoFocus: widget.view.name.isEmpty ? false : null,
styleCustomizer: EditorStyleCustomizer(
@ -157,7 +163,7 @@ class _DocumentPageState extends State<DocumentPage>
padding: EditorStyleCustomizer.documentPadding,
),
header: buildCoverAndIcon(context, state),
initialSelection: widget.initialSelection,
initialSelection: _calculateInitialSelection(editorState),
),
);
}
@ -242,16 +248,54 @@ class _DocumentPageState extends State<DocumentPage>
BuildContext context,
ActionNavigationState state,
) {
if (state.action != null && state.action!.type == ActionType.jumpToBlock) {
final path = state.action?.arguments?[ActionArgumentKeys.nodePath];
final action = state.action;
if (action == null ||
action.type != ActionType.jumpToBlock ||
action.objectId != widget.view.id) {
return;
}
final editorState = context.read<DocumentBloc>().state.editorState;
if (editorState != null && widget.view.id == state.action?.objectId) {
editorState.updateSelectionWithReason(
Selection.collapsed(Position(path: [path])),
);
final editorState = context.read<DocumentBloc>().state.editorState;
if (editorState == null) {
return;
}
final Path? path = _getPathFromAction(action, editorState);
if (path != null) {
debugPrint('jump to block: $path');
editorState.updateSelectionWithReason(
Selection.collapsed(Position(path: path)),
);
}
}
Path? _getPathFromAction(NavigationAction action, EditorState editorState) {
Path? path = action.arguments?[ActionArgumentKeys.nodePath];
if (path == null || path.isEmpty) {
final blockId = action.arguments?[ActionArgumentKeys.blockId];
if (blockId != null) {
path = _findNodePathByBlockId(editorState, blockId);
}
}
return path;
}
Path? _findNodePathByBlockId(EditorState editorState, String blockId) {
final document = editorState.document;
final startNode = document.root.children.firstOrNull;
if (startNode == null) {
return null;
}
final nodeIterator = NodeIterator(document: document, startNode: startNode);
while (nodeIterator.moveNext()) {
final node = nodeIterator.current;
if (node.id == blockId) {
return node.path;
}
}
return null;
}
bool shouldRebuildDocument(DocumentState previous, DocumentState current) {
@ -362,4 +406,24 @@ class _DocumentPageState extends State<DocumentPage>
isPaste = false;
}
}
Selection? _calculateInitialSelection(EditorState editorState) {
if (widget.initialSelection != null) {
return widget.initialSelection;
}
if (widget.initialBlockId != null) {
final path = _findNodePathByBlockId(editorState, widget.initialBlockId!);
if (path != null) {
editorState.selectionType = SelectionType.block;
return Selection.collapsed(
Position(
path: path,
),
);
}
}
return null;
}
}

View File

@ -177,8 +177,14 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage>
focusManager = AFFocusManager.maybeOf(context);
focusManager?.loseFocusNotifier.addListener(_loseFocus);
if (widget.initialSelection != null) {
widget.editorState.updateSelectionWithReason(widget.initialSelection);
final initialSelection = widget.initialSelection;
final path = initialSelection?.start.path;
if (initialSelection != null && path != null && path.isNotEmpty) {
editorScrollController.itemScrollController.jumpTo(
index: path.first,
alignment: 0.5,
);
widget.editorState.updateSelectionWithReason(initialSelection);
}
widget.editorState.service.keyboardService?.registerInterceptor(

View File

@ -12,12 +12,14 @@ class BlockActionButton extends StatelessWidget {
required this.richMessage,
required this.onTap,
this.showTooltip = true,
this.onPointerDown,
});
final FlowySvgData svg;
final bool showTooltip;
final InlineSpan richMessage;
final VoidCallback onTap;
final VoidCallback? onPointerDown;
@override
Widget build(BuildContext context) {
@ -26,6 +28,7 @@ class BlockActionButton extends StatelessWidget {
? SystemMouseCursors.click
: SystemMouseCursors.grab,
child: IgnoreParentGestureWidget(
onPress: onPointerDown,
child: GestureDetector(
onTap: onTap,
behavior: HitTestBehavior.deferToChild,

View File

@ -324,6 +324,11 @@ class _OptionButtonState extends State<_OptionButton> {
),
],
),
onPointerDown: () {
if (widget.editorState.selection != null) {
beforeSelection = widget.editorState.selection;
}
},
onTap: () {
if (widget.editorState.selection != null) {
beforeSelection = widget.editorState.selection;
@ -369,6 +374,7 @@ class _OptionButtonState extends State<_OptionButton> {
} else {
beforeSelection = null;
}
return result;
}
}

View File

@ -7,12 +7,24 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/me
import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mobile_page_selector_sheet.dart';
import 'package:appflowy/plugins/trash/application/trash_service.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart';
import 'package:appflowy/workspace/application/action_navigation/action_navigation_bloc.dart';
import 'package:appflowy/workspace/application/action_navigation/navigation_action.dart';
import 'package:appflowy/workspace/application/view/prelude.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
import 'package:appflowy_editor/appflowy_editor.dart'
show Delta, EditorState, Node, TextInsert, TextTransaction, paragraphNode;
show
Delta,
EditorState,
Node,
TextInsert,
TextTransaction,
paragraphNode,
NodeIterator,
Path,
Selection,
Position,
SelectionType;
import 'package:collection/collection.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
@ -116,14 +128,37 @@ class _MentionPageBlockState extends State<MentionPageBlock> {
}
Future<void> handleTap(ViewPB view) async {
final blockId = widget.blockId;
final currentViewId = context.read<DocumentBloc>().documentId;
if (currentViewId == widget.pageId && blockId != null) {
// same page
final path = _findNodePathByBlockId(editorState, blockId);
if (path != null) {
editorState.scrollService?.jumpTo(path.first);
await editorState.updateSelectionWithReason(
Selection.collapsed(Position(path: path)),
customSelectionType: SelectionType.block,
);
}
return;
}
if (UniversalPlatform.isMobile) {
final currentViewId = context.read<DocumentBloc>().documentId;
if (mounted && currentViewId != widget.pageId) {
await context.pushView(view);
}
} else {
getIt<TabsBloc>().add(
TabsEvent.openPlugin(plugin: view.plugin(), view: view),
final action = NavigationAction(
objectId: view.id,
arguments: {
ActionArgumentKeys.view: view,
ActionArgumentKeys.blockId: blockId,
},
);
getIt<ActionNavigationBloc>().add(
ActionNavigationEvent.performAction(
action: action,
),
);
}
}
@ -188,6 +223,27 @@ class _MentionPageBlockState extends State<MentionPageBlock> {
);
});
}
Path? _findNodePathByBlockId(EditorState editorState, String blockId) {
final document = editorState.document;
final startNode = document.root.children.firstOrNull;
if (startNode == null) {
return null;
}
final nodeIterator = NodeIterator(
document: document,
startNode: startNode,
);
while (nodeIterator.moveNext()) {
final node = nodeIterator.current;
if (node.id == blockId) {
return node.path;
}
}
return null;
}
}
class _MentionPageBlockContent extends StatelessWidget {
@ -225,12 +281,15 @@ class _MentionPageBlockContent extends StatelessWidget {
),
],
const HSpace(2),
FlowyText(
text,
decoration: TextDecoration.underline,
fontSize: textStyle?.fontSize,
fontWeight: textStyle?.fontWeight,
lineHeight: textStyle?.height,
Flexible(
child: FlowyText(
text,
decoration: TextDecoration.underline,
fontSize: textStyle?.fontSize,
fontWeight: textStyle?.fontWeight,
lineHeight: textStyle?.height,
overflow: TextOverflow.ellipsis,
),
),
const HSpace(4),
],

View File

@ -17,6 +17,7 @@ import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy/workspace/presentation/command_palette/command_palette.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/theme.dart';
@ -176,12 +177,17 @@ class _ApplicationWidgetState extends State<ApplicationWidget> {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (action?.type == ActionType.openView &&
UniversalPlatform.isDesktop) {
final view = action!.arguments?[ActionArgumentKeys.view];
final view =
action!.arguments?[ActionArgumentKeys.view] as ViewPB?;
final nodePath = action.arguments?[ActionArgumentKeys.nodePath];
final blockId = action.arguments?[ActionArgumentKeys.blockId];
if (view != null) {
getIt<TabsBloc>().openPlugin(
view.plugin(),
arguments: {PluginArgumentKeys.selection: nodePath},
view,
arguments: {
PluginArgumentKeys.selection: nodePath,
PluginArgumentKeys.blockId: blockId,
},
);
}
} else if (action?.type == ActionType.openRow &&

View File

@ -7,6 +7,7 @@ enum ActionType {
class ActionArgumentKeys {
static String view = "view";
static String nodePath = "node_path";
static String blockId = "block_id";
static String rowId = "row_id";
}

View File

@ -22,6 +22,7 @@ import 'package:flutter/material.dart';
class PluginArgumentKeys {
static String selection = "selection";
static String rowId = "row_id";
static String blockId = "block_id";
}
class ViewExtKeys {
@ -90,11 +91,13 @@ extension ViewExtension on ViewPB {
case ViewLayoutPB.Document:
final Selection? initialSelection =
arguments[PluginArgumentKeys.selection];
final String? initialBlockId = arguments[PluginArgumentKeys.blockId];
return DocumentPlugin(
view: this,
pluginType: pluginType,
initialSelection: initialSelection,
initialBlockId: initialBlockId,
);
case ViewLayoutPB.Chat:
return AIChatPagePlugin(view: this);

View File

@ -219,6 +219,11 @@ class HomeSideBar extends StatelessWidget {
);
}
final blockId = action.arguments?[ActionArgumentKeys.blockId];
if (blockId != null) {
arguments[PluginArgumentKeys.blockId] = blockId;
}
final rowId = action.arguments?[ActionArgumentKeys.rowId];
if (rowId != null) {
arguments[PluginArgumentKeys.rowId] = rowId;

View File

@ -78,10 +78,8 @@ class _AccountDeletionButtonState extends State<AccountDeletionButton> {
radius: Corners.s8Border,
hoverColor: Theme.of(context).colorScheme.error.withOpacity(0.1),
fontColor: Theme.of(context).colorScheme.error,
fontHoverColor: Colors.white,
fontSize: 12,
isDangerous: true,
lineHeight: 18.0 / 12.0,
onPressed: () {
isCheckedNotifier.value = false;
textEditingController.clear();

View File

@ -53,8 +53,8 @@ packages:
dependency: "direct main"
description:
path: "."
ref: bcd1208
resolved-ref: bcd12082fea75cdbbea8b090bf938aae7dc9f4ad
ref: "4509123"
resolved-ref: "450912305a2cdf98776b09ad4dbdb11498bdf9ce"
url: "https://github.com/AppFlowy-IO/appflowy-editor.git"
source: git
version: "4.0.0"

View File

@ -171,7 +171,7 @@ dependency_overrides:
appflowy_editor:
git:
url: https://github.com/AppFlowy-IO/appflowy-editor.git
ref: "bcd1208"
ref: "4509123"
appflowy_editor_plugins:
git: