feat: sub page block (#6427)

* feat: base subpage block and behavior

* fix: do not record undo

* refactor: add BlockTransactionHandler

* test: start adding coverage

* test: delete w/ backspace

* test: add to runner

* fix: rebuild issue on create

* test: copy+paste base test

* fix: conflict behavior and test coverage

* fix: after merge

* test: add wait duration

* test: more tests

* fix: cut behavior + tests

* fix: refactor copy+paste and more test cov

* fix: localization + test coverage + cleanup

* test: add drag subpageblock node test

* test: remove backspace test
This commit is contained in:
Mathias Mogensen 2024-10-03 04:58:08 +02:00 committed by GitHub
parent 3b48ca0f4b
commit 0d69b895aa
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
38 changed files with 1766 additions and 100 deletions

View File

@ -1,5 +1,7 @@
import 'dart:io';
import 'package:flutter/services.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/block_menu/block_menu_button.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart';
@ -9,7 +11,6 @@ import 'package:appflowy/startup/startup.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:universal_platform/universal_platform.dart';

View File

@ -0,0 +1,607 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/sub_page/sub_page_block_component.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_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import '../../shared/util.dart';
// Test cases for the Document SubPageBlock that needs to be covered:
// - [x] Insert a new SubPageBlock from Slash menu items (Expect it will create a child view under current view)
// - [x] Delete a SubPageBlock from Block Action Menu (Expect the view is moved to trash / deleted)
// - [x] Delete a SubPageBlock with backspace when selected (Expect the view is moved to trash / deleted)
// - [x] Copy+paste a SubPageBlock in same Document (Expect a new view is created under current view with same content and name)
// - [x] Copy+paste a SubPageBlock in different Document (Expect a new view is created under current view with same content and name)
// - [x] Cut+paste a SubPageBlock in same Document (Expect the view to be deleted on Cut, and brought back on Paste)
// - [x] Cut+paste a SubPageBlock in different Document (Expect the view to be deleted on Cut, and brought back on Paste)
// - [x] Undo adding a SubPageBlock (Expect the view to be deleted)
// - [x] Undo delete of a SubPageBlock (Expect the view to be brought back to original position)
// - [x] Redo adding a SubPageBlock (Expect the view to be restored)
// - [x] Redo delete of a SubPageBlock (Expect the view to be moved to trash again)
// - [x] Renaming a child view (Expect the view name to be updated in the document)
// - [x] Deleting a view (to trash) linked to a SubPageBlock shows a hint that the view is in trash (Expect a hint to be shown)
// - [x] Deleting a view (in trash) linked to a SubPageBlock deletes the SubPageBlock (Expect the SubPageBlock to be deleted)
// - [x] Duplicating a SubPageBlock node from Action Menu (Expect a new view is created under current view with same content and name + (copy))
// - [x] Dragging a SubPageBlock node to a new position in the document (Expect everything to be normal)
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
group('Document SubPageBlock tests', () {
testWidgets('Insert a new SubPageBlock from Slash menu items',
(tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
await tester.createNewPageWithNameUnderParent(name: 'SubPageBlock');
await tester.insertSubPageFromSlashMenu();
await tester.expandOrCollapsePage(
pageName: 'SubPageBlock',
layout: ViewLayoutPB.Document,
);
expect(
find.text(LocaleKeys.menuAppHeader_defaultNewPageName.tr()),
findsNWidgets(3),
);
});
testWidgets('Rename and then Delete a SubPageBlock from Block Action Menu',
(tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
await tester.createNewPageWithNameUnderParent(name: 'SubPageBlock');
await tester.insertSubPageFromSlashMenu();
await tester.expandOrCollapsePage(
pageName: 'SubPageBlock',
layout: ViewLayoutPB.Document,
);
await tester
.hoverOnPageName(LocaleKeys.menuAppHeader_defaultNewPageName.tr());
await tester.renamePage('Child page');
await tester.pumpAndSettle();
expect(find.text('Child page'), findsNWidgets(2));
await tester.editor.hoverAndClickOptionMenuButton([0]);
await tester.tapButtonWithName(LocaleKeys.button_delete.tr());
await tester.pumpAndSettle();
expect(find.text('Child page'), findsNothing);
});
testWidgets('Copy+paste a SubPageBlock in same Document', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
await tester.createNewPageWithNameUnderParent(name: 'SubPageBlock');
await tester.insertSubPageFromSlashMenu();
await tester.expandOrCollapsePage(
pageName: 'SubPageBlock',
layout: ViewLayoutPB.Document,
);
await tester
.hoverOnPageName(LocaleKeys.menuAppHeader_defaultNewPageName.tr());
await tester.renamePage('Child page');
await tester.pumpAndSettle();
expect(find.text('Child page'), findsNWidgets(2));
await tester.editor.hoverAndClickOptionAddButton([0], false);
await tester.editor.tapLineOfEditorAt(1);
// This is a workaround to allow CTRL+A and CTRL+C to work to copy
// the SubPageBlock as well.
await tester.ime.insertText('ABC');
await tester.simulateKeyEvent(
LogicalKeyboardKey.keyA,
isControlPressed: Platform.isLinux || Platform.isWindows,
isMetaPressed: Platform.isMacOS,
);
await tester.pumpAndSettle();
await tester.simulateKeyEvent(
LogicalKeyboardKey.keyC,
isControlPressed: Platform.isLinux || Platform.isWindows,
isMetaPressed: Platform.isMacOS,
);
await tester.pumpAndSettle();
await tester.editor.hoverAndClickOptionAddButton([1], false);
await tester.editor.tapLineOfEditorAt(2);
await tester.pumpAndSettle();
await tester.simulateKeyEvent(
LogicalKeyboardKey.keyV,
isControlPressed: Platform.isLinux || Platform.isWindows,
isMetaPressed: Platform.isMacOS,
);
await tester.pumpAndSettle(const Duration(seconds: 5));
expect(find.byType(SubPageBlockComponent), findsNWidgets(2));
expect(find.text('Child page'), findsNWidgets(2));
expect(find.text('Child page (copy)'), findsNWidgets(2));
});
testWidgets('Copy+paste a SubPageBlock in different Document',
(tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
await tester.createNewPageWithNameUnderParent(name: 'SubPageBlock');
await tester.insertSubPageFromSlashMenu();
await tester.expandOrCollapsePage(
pageName: 'SubPageBlock',
layout: ViewLayoutPB.Document,
);
await tester
.hoverOnPageName(LocaleKeys.menuAppHeader_defaultNewPageName.tr());
await tester.renamePage('Child page');
await tester.pumpAndSettle();
expect(find.text('Child page'), findsNWidgets(2));
await tester.editor.hoverAndClickOptionAddButton([0], false);
await tester.editor.tapLineOfEditorAt(1);
// This is a workaround to allow CTRL+A and CTRL+C to work to copy
// the SubPageBlock as well.
await tester.ime.insertText('ABC');
await tester.simulateKeyEvent(
LogicalKeyboardKey.keyA,
isControlPressed: Platform.isLinux || Platform.isWindows,
isMetaPressed: Platform.isMacOS,
);
await tester.pumpAndSettle();
await tester.simulateKeyEvent(
LogicalKeyboardKey.keyC,
isControlPressed: Platform.isLinux || Platform.isWindows,
isMetaPressed: Platform.isMacOS,
);
await tester.pumpAndSettle();
await tester.createNewPageWithNameUnderParent(name: 'SubPageBlock-2');
await tester.editor.tapLineOfEditorAt(0);
await tester.pumpAndSettle();
await tester.simulateKeyEvent(
LogicalKeyboardKey.keyV,
isControlPressed: Platform.isLinux || Platform.isWindows,
isMetaPressed: Platform.isMacOS,
);
await tester.pumpAndSettle();
await tester.expandOrCollapsePage(
pageName: 'SubPageBlock-2',
layout: ViewLayoutPB.Document,
);
expect(find.byType(SubPageBlockComponent), findsOneWidget);
expect(find.text('Child page'), findsOneWidget);
expect(find.text('Child page (copy)'), findsNWidgets(2));
});
testWidgets('Cut+paste a SubPageBlock in same Document', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
await tester.createNewPageWithNameUnderParent(name: 'SubPageBlock');
await tester.insertSubPageFromSlashMenu();
await tester.expandOrCollapsePage(
pageName: 'SubPageBlock',
layout: ViewLayoutPB.Document,
);
await tester
.hoverOnPageName(LocaleKeys.menuAppHeader_defaultNewPageName.tr());
await tester.renamePage('Child page');
await tester.pumpAndSettle();
expect(find.text('Child page'), findsNWidgets(2));
await tester.editor
.updateSelection(Selection.single(path: [0], startOffset: 0));
await tester.simulateKeyEvent(
LogicalKeyboardKey.keyX,
isControlPressed: Platform.isLinux || Platform.isWindows,
isMetaPressed: Platform.isMacOS,
);
await tester.pumpAndSettle();
expect(find.byType(SubPageBlockComponent), findsNothing);
expect(find.text('Child page'), findsNothing);
await tester.editor.tapLineOfEditorAt(0);
await tester.simulateKeyEvent(
LogicalKeyboardKey.keyV,
isControlPressed: Platform.isLinux || Platform.isWindows,
isMetaPressed: Platform.isMacOS,
);
await tester.pumpAndSettle();
expect(find.byType(SubPageBlockComponent), findsOneWidget);
expect(find.text('Child page'), findsNWidgets(2));
});
testWidgets('Cut+paste a SubPageBlock in different Document',
(tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
await tester.createNewPageWithNameUnderParent(name: 'SubPageBlock');
await tester.insertSubPageFromSlashMenu();
await tester.expandOrCollapsePage(
pageName: 'SubPageBlock',
layout: ViewLayoutPB.Document,
);
await tester
.hoverOnPageName(LocaleKeys.menuAppHeader_defaultNewPageName.tr());
await tester.renamePage('Child page');
await tester.pumpAndSettle();
expect(find.text('Child page'), findsNWidgets(2));
await tester.editor
.updateSelection(Selection.single(path: [0], startOffset: 0));
await tester.simulateKeyEvent(
LogicalKeyboardKey.keyX,
isControlPressed: Platform.isLinux || Platform.isWindows,
isMetaPressed: Platform.isMacOS,
);
await tester.pumpAndSettle();
expect(find.byType(SubPageBlockComponent), findsNothing);
expect(find.text('Child page'), findsNothing);
await tester.createNewPageWithNameUnderParent(name: 'SubPageBlock-2');
await tester.editor.tapLineOfEditorAt(0);
await tester.pumpAndSettle();
await tester.simulateKeyEvent(
LogicalKeyboardKey.keyV,
isControlPressed: Platform.isLinux || Platform.isWindows,
isMetaPressed: Platform.isMacOS,
);
await tester.pumpAndSettle();
await tester.expandOrCollapsePage(
pageName: 'SubPageBlock-2',
layout: ViewLayoutPB.Document,
);
expect(find.byType(SubPageBlockComponent), findsOneWidget);
expect(find.text('Child page'), findsNWidgets(2));
expect(find.text('Child page (copy)'), findsNothing);
});
testWidgets('Undo adding a SubPageBlock', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
await tester.createNewPageWithNameUnderParent(name: 'SubPageBlock');
await tester.insertSubPageFromSlashMenu(true);
await tester.expandOrCollapsePage(
pageName: 'SubPageBlock',
layout: ViewLayoutPB.Document,
);
await tester
.hoverOnPageName(LocaleKeys.menuAppHeader_defaultNewPageName.tr());
await tester.renamePage('Child page');
await tester.pumpAndSettle();
expect(find.text('Child page'), findsNWidgets(2));
await tester.editor.tapLineOfEditorAt(0);
// Undo
await tester.simulateKeyEvent(
LogicalKeyboardKey.keyZ,
isControlPressed: Platform.isLinux || Platform.isWindows,
isMetaPressed: Platform.isMacOS,
);
await tester.pumpAndSettle();
expect(find.byType(SubPageBlockComponent), findsNothing);
expect(find.text('Child page'), findsNothing);
});
testWidgets('Undo delete of a SubPageBlock', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
await tester.createNewPageWithNameUnderParent(name: 'SubPageBlock');
await tester.insertSubPageFromSlashMenu();
await tester.expandOrCollapsePage(
pageName: 'SubPageBlock',
layout: ViewLayoutPB.Document,
);
await tester
.hoverOnPageName(LocaleKeys.menuAppHeader_defaultNewPageName.tr());
await tester.renamePage('Child page');
await tester.pumpAndSettle();
expect(find.text('Child page'), findsNWidgets(2));
await tester.editor.hoverAndClickOptionMenuButton([0]);
await tester.tapButtonWithName(LocaleKeys.button_delete.tr());
await tester.pumpAndSettle();
expect(find.text('Child page'), findsNothing);
expect(find.byType(SubPageBlockComponent), findsNothing);
await tester.simulateKeyEvent(
LogicalKeyboardKey.keyZ,
isControlPressed: Platform.isLinux || Platform.isWindows,
isMetaPressed: Platform.isMacOS,
);
await tester.pumpAndSettle();
expect(find.text('Child page'), findsNWidgets(2));
expect(find.byType(SubPageBlockComponent), findsOneWidget);
});
// Redo: undoing adding a subpage block, then redoing to bring it back
// -> Add a subpage block
// -> Undo
// -> Redo
testWidgets('Redo adding of a SubPageBlock', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
await tester.createNewPageWithNameUnderParent(name: 'SubPageBlock');
await tester.insertSubPageFromSlashMenu(true);
await tester.expandOrCollapsePage(
pageName: 'SubPageBlock',
layout: ViewLayoutPB.Document,
);
await tester
.hoverOnPageName(LocaleKeys.menuAppHeader_defaultNewPageName.tr());
await tester.renamePage('Child page');
await tester.pumpAndSettle();
expect(find.text('Child page'), findsNWidgets(2));
await tester.editor.tapLineOfEditorAt(0);
// Undo
await tester.simulateKeyEvent(
LogicalKeyboardKey.keyZ,
isControlPressed: Platform.isLinux || Platform.isWindows,
isMetaPressed: Platform.isMacOS,
);
await tester.pumpAndSettle();
expect(find.byType(SubPageBlockComponent), findsNothing);
expect(find.text('Child page'), findsNothing);
// Redo
await tester.simulateKeyEvent(
LogicalKeyboardKey.keyZ,
isShiftPressed: true,
isControlPressed: Platform.isLinux || Platform.isWindows,
isMetaPressed: Platform.isMacOS,
);
await tester.pumpAndSettle();
expect(find.byType(SubPageBlockComponent), findsOneWidget);
expect(find.text('Child page'), findsNWidgets(2));
});
// Redo: undoing deleting a subpage block, then redoing to delete it again
// -> Add a subpage block
// -> Delete
// -> Undo
// -> Redo
testWidgets('Redo delete of a SubPageBlock', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
await tester.createNewPageWithNameUnderParent(name: 'SubPageBlock');
await tester.insertSubPageFromSlashMenu(true);
await tester.expandOrCollapsePage(
pageName: 'SubPageBlock',
layout: ViewLayoutPB.Document,
);
await tester
.hoverOnPageName(LocaleKeys.menuAppHeader_defaultNewPageName.tr());
await tester.renamePage('Child page');
await tester.pumpAndSettle();
expect(find.text('Child page'), findsNWidgets(2));
// Delete
await tester.editor.hoverAndClickOptionMenuButton([1]);
await tester.tapButtonWithName(LocaleKeys.button_delete.tr());
await tester.pumpAndSettle();
expect(find.text('Child page'), findsNothing);
expect(find.byType(SubPageBlockComponent), findsNothing);
await tester.editor.tapLineOfEditorAt(0);
// Undo
await tester.simulateKeyEvent(
LogicalKeyboardKey.keyZ,
isControlPressed: Platform.isLinux || Platform.isWindows,
isMetaPressed: Platform.isMacOS,
);
await tester.pumpAndSettle();
expect(find.byType(SubPageBlockComponent), findsOneWidget);
expect(find.text('Child page'), findsNWidgets(2));
// Redo
await tester.simulateKeyEvent(
LogicalKeyboardKey.keyZ,
isShiftPressed: true,
isControlPressed: Platform.isLinux || Platform.isWindows,
isMetaPressed: Platform.isMacOS,
);
await tester.pumpAndSettle();
expect(find.byType(SubPageBlockComponent), findsNothing);
expect(find.text('Child page'), findsNothing);
});
testWidgets('Delete a view first to trash, then from trash',
(tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
await tester.createNewPageWithNameUnderParent(name: 'SubPageBlock');
await tester.insertSubPageFromSlashMenu();
await tester.expandOrCollapsePage(
pageName: 'SubPageBlock',
layout: ViewLayoutPB.Document,
);
await tester
.hoverOnPageName(LocaleKeys.menuAppHeader_defaultNewPageName.tr());
await tester.renamePage('Child page');
await tester.pumpAndSettle();
expect(find.text('Child page'), findsNWidgets(2));
expect(find.byType(SubPageBlockComponent), findsOneWidget);
final hintText = LocaleKeys.document_plugins_subPage_inTrashHint.tr();
expect(find.text(hintText), findsNothing);
await tester.hoverOnPageName('Child page');
await tester.tapDeletePageButton();
await tester.pumpAndSettle(const Duration(seconds: 1));
expect(find.text(hintText), findsOne);
// Go to trash
await tester.tapTrashButton();
await tester.pumpAndSettle();
// Tap on delete all button
await tester.tap(find.text(LocaleKeys.trash_deleteAll.tr()));
await tester.pumpAndSettle();
// Tap ok to delete app pages in trash
await tester.tap(find.text(LocaleKeys.button_ok.tr()));
await tester.pumpAndSettle();
await tester.openPage('SubPageBlock');
await tester.pumpAndSettle();
expect(find.text('Child page'), findsNothing);
expect(find.byType(SubPageBlockComponent), findsNothing);
});
testWidgets('Duplicate SubPageBlock from Block Menu', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
await tester.createNewPageWithNameUnderParent(name: 'SubPageBlock');
await tester.insertSubPageFromSlashMenu();
await tester.expandOrCollapsePage(
pageName: 'SubPageBlock',
layout: ViewLayoutPB.Document,
);
await tester
.hoverOnPageName(LocaleKeys.menuAppHeader_defaultNewPageName.tr());
await tester.renamePage('Child page');
await tester.pumpAndSettle();
expect(find.text('Child page'), findsNWidgets(2));
await tester.editor.hoverAndClickOptionMenuButton([0]);
await tester.tapButtonWithName(LocaleKeys.button_duplicate.tr());
await tester.pumpAndSettle();
expect(find.text('Child page'), findsNWidgets(2));
expect(find.text('Child page (copy)'), findsNWidgets(2));
expect(find.byType(SubPageBlockComponent), findsNWidgets(2));
});
testWidgets('Drag SubPageBlock to top of Document', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
await tester.createNewPageWithNameUnderParent(name: 'SubPageBlock');
await tester.insertSubPageFromSlashMenu(true);
await tester.expandOrCollapsePage(
pageName: 'SubPageBlock',
layout: ViewLayoutPB.Document,
);
expect(find.byType(SubPageBlockComponent), findsOneWidget);
final beforeNode = tester.editor.getNodeAtPath([1]);
await tester.editor.dragBlock([1], const Offset(20, -45));
await tester.pumpAndSettle(Durations.long1);
final afterNode = tester.editor.getNodeAtPath([0]);
expect(afterNode.type, SubPageBlockKeys.type);
expect(afterNode.type, beforeNode.type);
expect(find.byType(SubPageBlockComponent), findsOneWidget);
expect(
find.text(LocaleKeys.document_plugins_subPage_inTrashHint.tr()),
findsNothing,
);
});
});
}
extension _SubPageTestHelper on WidgetTester {
Future<void> insertSubPageFromSlashMenu([bool withTextNode = false]) async {
await editor.tapLineOfEditorAt(0);
if (withTextNode) {
await ime.insertText('ABC');
await editor.getCurrentEditorState().insertNewLine();
await pumpAndSettle();
}
await editor.showSlashMenu();
await editor.tapSlashMenuItemWithName(
LocaleKeys.document_slashMenu_subPage_name.tr(),
offset: 100,
);
await pumpUntilFound(find.byType(SubPageBlockComponent));
}
}

View File

@ -10,13 +10,14 @@ import 'package:integration_test/integration_test.dart';
import '../../shared/database_test_op.dart';
import '../../shared/util.dart';
import 'grid_test_extensions.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
group('grid create row test:', () {
testWidgets('from the bottom', (tester) async {
group('grid row test:', () {
testWidgets('create from the bottom', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
@ -33,7 +34,7 @@ void main() {
tester.assertNumberOfRowsInGridPage(4);
});
testWidgets('from a row\'s menu', (tester) async {
testWidgets('create from a row\'s menu', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
@ -51,7 +52,7 @@ void main() {
tester.assertNumberOfRowsInGridPage(4);
});
testWidgets('with sort configured', (tester) async {
testWidgets('create with sort configured', (tester) async {
await tester.openTestDatabase(v069GridFileName);
// get grid data
@ -104,7 +105,7 @@ void main() {
tester.assertNumberOfRowsInGridPage(14);
});
testWidgets('with filter configured', (tester) async {
testWidgets('create with filter configured', (tester) async {
await tester.openTestDatabase(v069GridFileName);
// get grid data
@ -182,7 +183,6 @@ void main() {
expect(actual, orderedEquals(original));
});
// TODO(RS): move to somewhere else
testWidgets('delete row of the grid', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();

View File

@ -4,7 +4,7 @@ import 'desktop/board/board_test_runner.dart' as board_test_runner;
import 'desktop/database/database_row_cover_test.dart'
as database_row_cover_test;
import 'desktop/document/document_title_test.dart' as document_title_test;
import 'desktop/grid/grid_create_row_test.dart' as grid_create_row_test_runner;
import 'desktop/document/document_sub_page_test.dart' as document_sub_page_test;
import 'desktop/grid/grid_filter_and_sort_test.dart'
as grid_filter_and_sort_test_runner;
import 'desktop/grid/grid_reopen_test.dart' as grid_reopen_test_runner;
@ -12,6 +12,7 @@ import 'desktop/grid/grid_reorder_row_test.dart'
as grid_reorder_row_test_runner;
import 'desktop/grid/grid_edit_row_test.dart' as grid_edit_row_test_runner;
import 'desktop/settings/settings_runner.dart' as settings_test_runner;
import 'desktop/grid/grid_row_test.dart' as grid_create_row_test_runner;
import 'desktop/sidebar/sidebar_test_runner.dart' as sidebar_test_runner;
import 'desktop/uncategorized/emoji_shortcut_test.dart' as emoji_shortcut_test;
import 'desktop/uncategorized/empty_test.dart' as first_test;
@ -49,4 +50,5 @@ Future<void> runIntegration3OnDesktop() async {
grid_edit_row_test_runner.main();
zoom_in_out_test.main();
document_title_test.main();
document_sub_page_test.main();
}

View File

@ -1,5 +1,10 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:appflowy/core/config/kv.dart';
import 'package:appflowy/core/config/kv_keys.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
@ -11,6 +16,7 @@ import 'package:appflowy/shared/feature_flags.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/user/presentation/screens/screens.dart';
import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/widgets.dart';
import 'package:appflowy/workspace/presentation/home/menu/sidebar/footer/sidebar_footer.dart';
import 'package:appflowy/workspace/presentation/home/menu/sidebar/shared/sidebar_new_page_button.dart';
import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/shared_widget.dart';
import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/sidebar_space_header.dart';
@ -33,10 +39,6 @@ import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/widget/buttons/primary_button.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'emoji.dart';
@ -237,6 +239,10 @@ extension CommonOperations on WidgetTester {
await tapOKButton();
}
Future<void> tapTrashButton() async {
await tap(find.byType(SidebarTrashButton));
}
Future<void> tapOKButton() async {
final okButton = find.byWidgetPredicate(
(widget) =>
@ -253,7 +259,10 @@ extension CommonOperations on WidgetTester {
}) async {
final page = findPageName(pageName, layout: layout);
await hoverOnWidget(page);
final expandButton = find.byType(ViewItemDefaultLeftIcon);
final expandButton = find.descendant(
of: page,
matching: find.byType(ViewItemDefaultLeftIcon),
);
await tapButton(expandButton.first);
}

View File

@ -1,6 +1,8 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:appflowy/plugins/document/application/doc_sync_state_listener.dart';
import 'package:appflowy/plugins/document/application/document_awareness_metadata.dart';
import 'package:appflowy/plugins/document/application/document_collab_adapter.dart';
@ -32,7 +34,6 @@ import 'package:appflowy_editor/appflowy_editor.dart'
Position,
paragraphNode;
import 'package:appflowy_result/appflowy_result.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
@ -156,7 +157,7 @@ class DocumentBloc extends Bloc<DocumentEvent, DocumentState> {
},
restorePage: () async {
if (databaseViewId == null && rowId == null) {
final result = await _trashService.putback(documentId);
final result = await TrashService.putback(documentId);
final isDeleted = result.fold((l) => false, (r) => true);
emit(state.copyWith(isDeleted: isDeleted));
}

View File

@ -1,3 +1,7 @@
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';
@ -24,7 +28,6 @@ import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:cross_file/cross_file.dart';
import 'package:desktop_drop/desktop_drop.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';
@ -60,18 +63,25 @@ class _DocumentPageState extends State<DocumentPage>
late final documentBloc = DocumentBloc(documentId: widget.view.id)
..add(const DocumentEvent.initial());
StreamSubscription<(TransactionTime, Transaction)>? transactionSubscription;
bool isUndoRedo = false;
bool isPaste = false;
bool isDraggingNode = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
EditorNotification.addListener(_onEditorNotification);
EditorNotification.addListener(onEditorNotification);
}
@override
void dispose() {
EditorNotification.removeListener(_onEditorNotification);
EditorNotification.removeListener(onEditorNotification);
WidgetsBinding.instance.removeObserver(this);
documentBloc.close();
transactionSubscription?.cancel();
super.dispose();
}
@ -105,7 +115,7 @@ class _DocumentPageState extends State<DocumentPage>
BlocProvider.value(value: documentBloc),
],
child: BlocBuilder<DocumentBloc, DocumentState>(
buildWhen: _shouldRebuildDocument,
buildWhen: shouldRebuildDocument,
builder: (context, state) {
if (state.isLoading) {
return const Center(child: CircularProgressIndicator.adaptive());
@ -116,11 +126,7 @@ class _DocumentPageState extends State<DocumentPage>
final error = state.error;
if (error != null || editorState == null) {
Log.error(error);
return Center(
child: AppFlowyErrorPage(
error: error,
),
);
return Center(child: AppFlowyErrorPage(error: error));
}
if (state.forceClose) {
@ -128,12 +134,14 @@ class _DocumentPageState extends State<DocumentPage>
return const SizedBox.shrink();
}
editorState.transactionStream.listen(onEditorTransaction);
return BlocListener<ActionNavigationBloc, ActionNavigationState>(
listenWhen: (_, curr) => curr.action != null,
listener: _onNotificationAction,
listener: onNotificationAction,
child: Consumer<EditorDropManagerState>(
builder: (context, dropState, _) =>
_buildEditorPage(context, state, dropState),
buildEditorPage(context, state, dropState),
),
);
},
@ -142,7 +150,7 @@ class _DocumentPageState extends State<DocumentPage>
);
}
Widget _buildEditorPage(
Widget buildEditorPage(
BuildContext context,
DocumentState state,
EditorDropManagerState dropState,
@ -152,18 +160,16 @@ class _DocumentPageState extends State<DocumentPage>
final Widget child;
if (UniversalPlatform.isMobile) {
child = BlocBuilder<DocumentPageStyleBloc, DocumentPageStyleState>(
builder: (context, styleState) {
return AppFlowyEditorPage(
editorState: state.editorState!,
styleCustomizer: EditorStyleCustomizer(
context: context,
width: width,
padding: EditorStyleCustomizer.documentPadding,
),
header: _buildCoverAndIcon(context, state),
initialSelection: widget.initialSelection,
);
},
builder: (context, styleState) => AppFlowyEditorPage(
editorState: state.editorState!,
styleCustomizer: EditorStyleCustomizer(
context: context,
width: width,
padding: EditorStyleCustomizer.documentPadding,
),
header: buildCoverAndIcon(context, state),
initialSelection: widget.initialSelection,
),
);
} else {
child = DropTarget(
@ -252,7 +258,7 @@ class _DocumentPageState extends State<DocumentPage>
width: width,
padding: EditorStyleCustomizer.documentPadding,
),
header: _buildCoverAndIcon(context, state),
header: buildCoverAndIcon(context, state),
initialSelection: widget.initialSelection,
),
);
@ -262,25 +268,24 @@ class _DocumentPageState extends State<DocumentPage>
create: (_) => SharedEditorContext(),
child: Column(
children: [
if (state.isDeleted) _buildBanner(context),
if (state.isDeleted) buildBanner(context),
Expanded(child: child),
],
),
);
}
Widget _buildBanner(BuildContext context) {
Widget buildBanner(BuildContext context) {
return DocumentBanner(
onRestore: () => context.read<DocumentBloc>().add(
const DocumentEvent.restorePage(),
),
onRestore: () =>
context.read<DocumentBloc>().add(const DocumentEvent.restorePage()),
onDelete: () => context
.read<DocumentBloc>()
.add(const DocumentEvent.deletePermanently()),
);
}
Widget _buildCoverAndIcon(BuildContext context, DocumentState state) {
Widget buildCoverAndIcon(BuildContext context, DocumentState state) {
final editorState = state.editorState;
final userProfilePB = state.userProfilePB;
if (editorState == null || userProfilePB == null) {
@ -307,11 +312,23 @@ class _DocumentPageState extends State<DocumentPage>
);
}
void _onEditorNotification(EditorNotificationType type) {
void onEditorNotification(EditorNotificationType type) {
final editorState = this.editorState;
if (editorState == null) {
return;
}
if ([EditorNotificationType.undo, EditorNotificationType.redo]
.contains(type)) {
isUndoRedo = true;
} else if (type == EditorNotificationType.paste) {
isPaste = true;
} else if (type == EditorNotificationType.dragStart) {
isDraggingNode = true;
} else if (type == EditorNotificationType.dragEnd) {
isDraggingNode = false;
}
if (type == EditorNotificationType.undo) {
undoCommand.execute(editorState);
} else if (type == EditorNotificationType.redo) {
@ -322,7 +339,7 @@ class _DocumentPageState extends State<DocumentPage>
}
}
void _onNotificationAction(
void onNotificationAction(
BuildContext context,
ActionNavigationState state,
) {
@ -338,7 +355,7 @@ class _DocumentPageState extends State<DocumentPage>
}
}
bool _shouldRebuildDocument(DocumentState previous, DocumentState current) {
bool shouldRebuildDocument(DocumentState previous, DocumentState current) {
// only rebuild the document page when the below fields are changed
// this is to prevent unnecessary rebuilds
//
@ -364,4 +381,86 @@ class _DocumentPageState extends State<DocumentPage>
return false;
}
List<Node> collectMatchingNodes(Node node, String type) {
final List<Node> matchingNodes = [];
if (node.type == type) {
matchingNodes.add(node);
}
for (final child in node.children) {
matchingNodes.addAll(collectMatchingNodes(child, type));
}
return matchingNodes;
}
void onEditorTransaction((TransactionTime, Transaction) event) {
if (editorState == null || event.$1 == TransactionTime.before) {
return;
}
final Map<String, List<Node>> addedNodes = {
for (final handler in SharedEditorContext.transactionHandlers)
handler.blockType: [],
};
final Map<String, List<Node>> removedNodes = {
for (final handler in SharedEditorContext.transactionHandlers)
handler.blockType: [],
};
final transactionHandlerTypes = SharedEditorContext.transactionHandlers
.map((h) => h.blockType)
.toList();
// Collect all matching nodes in a performant way for each handler type.
for (final op in event.$2.operations) {
if (op is InsertOperation) {
for (final n in op.nodes) {
for (final handlerType in transactionHandlerTypes) {
if (n.type == handlerType) {
addedNodes[handlerType]!
.addAll(collectMatchingNodes(n, handlerType));
}
}
}
} else if (op is DeleteOperation) {
for (final n in op.nodes) {
for (final handlerType in transactionHandlerTypes) {
if (n.type == handlerType) {
removedNodes[handlerType]!
.addAll(collectMatchingNodes(n, handlerType));
}
}
}
}
}
if (removedNodes.isEmpty && addedNodes.isEmpty) {
return;
}
for (final handler in SharedEditorContext.transactionHandlers) {
final added = addedNodes[handler.blockType] ?? [];
final removed = removedNodes[handler.blockType] ?? [];
if (added.isEmpty && removed.isEmpty) {
continue;
}
handler.onTransaction(
context,
editorState!,
added,
removed,
isUndoRedo: isUndoRedo,
isPaste: isPaste,
isDraggingNode: isDraggingNode,
parentViewId: widget.view.id,
);
isUndoRedo = false;
isPaste = false;
}
}
}

View File

@ -10,6 +10,7 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/code_block
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/custom_image_block_component/custom_image_block_component.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_block_component.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/sub_page/sub_page_block_component.dart';
import 'package:appflowy/plugins/document/presentation/editor_style.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart';
@ -257,6 +258,9 @@ Map<String, BlockComponentBuilder> getEditorBuilderMap({
),
),
FileBlockKeys.type: FileBlockComponentBuilder(configuration: configuration),
SubPageBlockKeys.type: SubPageBlockComponentBuilder(
configuration: configuration,
),
errorBlockComponentBuilderKey: ErrorBlockComponentBuilder(
configuration: configuration,
),

View File

@ -2,7 +2,15 @@ import 'package:flutter/material.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
enum EditorNotificationType { none, undo, redo, exitEditing }
enum EditorNotificationType {
none,
undo,
redo,
exitEditing,
paste,
dragStart,
dragEnd
}
class EditorNotification {
const EditorNotification({required this.type});
@ -10,6 +18,9 @@ class EditorNotification {
EditorNotification.undo() : type = EditorNotificationType.undo;
EditorNotification.redo() : type = EditorNotificationType.redo;
EditorNotification.exitEditing() : type = EditorNotificationType.exitEditing;
EditorNotification.paste() : type = EditorNotificationType.paste;
EditorNotification.dragStart() : type = EditorNotificationType.dragStart;
EditorNotification.dragEnd() : type = EditorNotificationType.dragEnd;
static final PropertyValueNotifier<EditorNotificationType> _notifier =
PropertyValueNotifier(EditorNotificationType.none);

View File

@ -1,5 +1,8 @@
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:appflowy/plugins/document/application/document_bloc.dart';
import 'package:appflowy/plugins/document/presentation/editor_configuration.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/background_color/theme_background_color.dart';
@ -18,8 +21,6 @@ import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:collection/collection.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:universal_platform/universal_platform.dart';
@ -346,6 +347,7 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
dateOrReminderSlashMenuItem,
photoGallerySlashMenuItem,
fileSlashMenuItem,
subPageSlashMenuItem,
];
}

View File

@ -1,10 +1,18 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/option_action.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/sub_page/sub_page_block_component.dart';
import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart';
import 'package:appflowy/workspace/application/view/prelude.dart';
import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:flutter/material.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/style_widget/snap_bar.dart';
import 'package:provider/provider.dart';
import 'drag_to_reorder/draggable_option_button.dart';
@ -92,7 +100,10 @@ class _BlockOptionButtonState extends State<BlockOptionButton> {
);
}
void _onSelectAction(BuildContext context, OptionAction action) {
Future<void> _onSelectAction(
BuildContext context,
OptionAction action,
) async {
final node = widget.blockComponentContext.node;
final transaction = widget.editorState.transaction;
switch (action) {
@ -100,7 +111,7 @@ class _BlockOptionButtonState extends State<BlockOptionButton> {
transaction.deleteNode(node);
break;
case OptionAction.duplicate:
_duplicateBlock(context, transaction, node);
await _duplicateBlock(context, transaction, node);
break;
case OptionAction.turnInto:
break;
@ -116,14 +127,15 @@ class _BlockOptionButtonState extends State<BlockOptionButton> {
case OptionAction.depth:
throw UnimplementedError();
}
widget.editorState.apply(transaction);
await widget.editorState.apply(transaction);
}
void _duplicateBlock(
Future<void> _duplicateBlock(
BuildContext context,
Transaction transaction,
Node node,
) {
) async {
// 1. verify the node integrity
final type = node.type;
final builder = widget.editorState.renderer.blockComponentBuilder(type);
@ -140,13 +152,20 @@ class _BlockOptionButtonState extends State<BlockOptionButton> {
// 2. duplicate the node
// the _copyBlock will fix the table block
final newNode = _copyBlock(context, node);
Node newNode = _copyBlock(context, node);
// 3. insert the node to the next of the current node
transaction.insertNode(
node.path.next,
newNode,
);
// 3. if the node is sub page, duplicate the view
if (node.type == SubPageBlockKeys.type) {
final viewId = await _handleDuplicateSubPage(context, node);
if (viewId == null) {
return;
}
newNode = newNode.copyWith(attributes: {SubPageBlockKeys.viewId: viewId});
}
// 4. insert the node to the next of the current node
transaction.insertNode(node.path.next, newNode);
}
Node _copyBlock(BuildContext context, Node node) {
@ -214,4 +233,46 @@ class _BlockOptionButtonState extends State<BlockOptionButton> {
},
);
}
/// Handles duplicating a SubPage.
///
/// If the duplication fails for any reason, this method will return false, and inserting
/// the duplicate node should be aborted.
///
Future<String?> _handleDuplicateSubPage(
BuildContext context,
Node node,
) async {
final viewId = node.attributes[SubPageBlockKeys.viewId];
if (viewId == null) {
return null;
}
final view = (await ViewBackendService.getView(viewId)).toNullable();
if (view == null) {
return null;
}
final result = await ViewBackendService.duplicate(
view: view,
openAfterDuplicate: false,
includeChildren: true,
parentViewId: view.parentViewId,
syncAfterDuplicate: true,
);
return result.fold(
(view) => view.id,
(error) {
Log.error(error);
if (context.mounted) {
showSnapBar(
context,
LocaleKeys.document_plugins_subPage_errors_failedDuplicatePage.tr(),
);
}
return null;
},
);
}
}

View File

@ -1,5 +1,8 @@
import 'package:flutter/material.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_notification.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/block_action_button.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/drag_to_reorder/util.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/drag_to_reorder/visual_drag_area.dart';
@ -13,7 +16,6 @@ import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
// this flag is used to disable the tooltip of the block when it is dragged
@ -81,6 +83,7 @@ class _DraggableOptionButtonState extends State<DraggableOptionButton> {
}
void _onDragStart() {
EditorNotification.dragStart().post();
isDraggingAppFlowyEditorBlock.value = true;
widget.editorState.selectionService.removeDropTarget();
}
@ -123,7 +126,9 @@ class _DraggableOptionButtonState extends State<DraggableOptionButton> {
node: widget.blockComponentContext.node,
acceptedPath: data?.cursorNode?.path,
dragOffset: globalPosition!,
);
).then((_) {
EditorNotification.dragEnd().post();
});
}
}

View File

@ -0,0 +1,39 @@
import 'package:flutter/material.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
/// A handler for transactions that involve a Block Component.
///
abstract class BlockTransactionHandler {
const BlockTransactionHandler({required this.blockType});
/// The type of the block that this handler is built for.
/// It's used to determine whether to call any of the handlers on certain transactions.
///
final String blockType;
Future<void> onTransaction(
BuildContext context,
EditorState editorState,
List<Node> added,
List<Node> removed, {
bool isUndoRedo = false,
bool isPaste = false,
bool isDraggingNode = false,
String? parentViewId,
});
void onUndo(
BuildContext context,
EditorState editorState,
List<Node> before,
List<Node> after,
);
void onRedo(
BuildContext context,
EditorState editorState,
List<Node> before,
List<Node> after,
);
}

View File

@ -1,9 +1,11 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/sub_page/sub_page_block_component.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
/// Copy.
///
@ -20,7 +22,13 @@ final CommandShortcutEvent customCopyCommand = CommandShortcutEvent(
handler: _copyCommandHandler,
);
CommandShortcutEventHandler _copyCommandHandler = (editorState) {
CommandShortcutEventHandler _copyCommandHandler =
(editorState) => handleCopyCommand(editorState);
KeyEventResult handleCopyCommand(
EditorState editorState, {
bool isCut = false,
}) {
final selection = editorState.selection?.normalized;
if (selection == null) {
return KeyEventResult.ignored;
@ -41,7 +49,8 @@ CommandShortcutEventHandler _copyCommandHandler = (editorState) {
text = node.delta?.toPlainText();
// in app json
final document = Document.blank()..insert([0], [node.copyWith()]);
final document = Document.blank()
..insert([0], [_handleNode(node.copyWith(), isCut)]);
inAppJson = jsonEncode(document.toJson());
// html
@ -50,7 +59,8 @@ CommandShortcutEventHandler _copyCommandHandler = (editorState) {
// plain text.
text = editorState.getTextInSelection(selection).join('\n');
final nodes = editorState.getSelectedNodes(selection: selection);
final selectedNodes = editorState.getSelectedNodes(selection: selection);
final nodes = _handleSubPageNodes(selectedNodes, isCut);
final document = Document.blank()..insert([0], nodes);
// in app json
@ -71,4 +81,34 @@ CommandShortcutEventHandler _copyCommandHandler = (editorState) {
}();
return KeyEventResult.handled;
};
}
List<Node> _handleSubPageNodes(List<Node> nodes, [bool isCut = false]) {
final handled = <Node>[];
for (final node in nodes) {
handled.add(_handleNode(node, isCut));
}
return handled;
}
Node _handleNode(Node node, [bool isCut = false]) {
if (!isCut) {
return node.copyWith();
}
final newChildren = node.children.map(_handleNode).toList();
if (node.type == SubPageBlockKeys.type) {
return node.copyWith(
attributes: {
...node.attributes,
SubPageBlockKeys.wasCopied: !isCut,
SubPageBlockKeys.wasCut: isCut,
},
children: newChildren,
);
}
return node.copyWith(children: newChildren);
}

View File

@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
/// cut.
///
@ -23,7 +24,7 @@ CommandShortcutEventHandler _cutCommandHandler = (editorState) {
return KeyEventResult.ignored;
}
customCopyCommand.execute(editorState);
handleCopyCommand(editorState, isCut: true);
if (!selection.isCollapsed) {
editorState.deleteSelectionIfNeeded();

View File

@ -1,4 +1,7 @@
import 'package:flutter/material.dart';
import 'package:appflowy/plugins/document/application/document_bloc.dart';
import 'package:appflowy/plugins/document/presentation/editor_notification.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_html.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_image.dart';
@ -9,7 +12,6 @@ import 'package:appflowy/util/default_extensions.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:string_validator/string_validator.dart';
@ -34,6 +36,8 @@ CommandShortcutEventHandler _pasteCommandHandler = (editorState) {
// because the event handler is not async, so we need to use wrap the async function here
() async {
EditorNotification.paste().post();
// dispatch the paste event
final data = await getIt<ClipboardService>().getData();
final inAppJson = data.inAppJson;

View File

@ -1,10 +1,11 @@
import 'package:flutter/material.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flowy_infra_ui/style_widget/text_input.dart';
import 'package:flutter/material.dart';
class FindAndReplaceMenuWidget extends StatefulWidget {
const FindAndReplaceMenuWidget({
@ -149,9 +150,11 @@ class _FindMenuState extends State<FindMenu> {
// will request focus, here's a workaround to request the
// focus back to the findTextField
Future.delayed(const Duration(milliseconds: 50), () {
FocusScope.of(context).requestFocus(
findTextFieldFocusNode,
);
if (context.mounted) {
FocusScope.of(context).requestFocus(
findTextFieldFocusNode,
);
}
});
},
controller: findTextEditingController,
@ -267,9 +270,11 @@ class _ReplaceMenuState extends State<ReplaceMenu> {
// will request focus, here's a workaround to request the
// focus back to the findTextField
Future.delayed(const Duration(milliseconds: 50), () {
FocusScope.of(context).requestFocus(
replaceTextFieldFocusNode,
);
if (context.mounted) {
FocusScope.of(context).requestFocus(
replaceTextFieldFocusNode,
);
}
});
},
controller: replaceTextEditingController,

View File

@ -1,9 +1,10 @@
import 'package:flutter/material.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_date_block.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart';
import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/reminder_selector.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
enum MentionType {

View File

@ -1,3 +1,5 @@
import 'package:flutter/material.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/application/mobile_router.dart';
@ -17,7 +19,6 @@ import 'package:collection/collection.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flowy_infra_ui/style_widget/hover.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';

View File

@ -1,5 +1,10 @@
import 'package:flutter/widgets.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/block_transaction_handler/block_transaction_handler.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/sub_page/block_transaction_handler.dart';
final _transactionHandlers = [SubPageBlockTransactionHandler()];
/// Shared context for the editor plugins.
///
/// For example, the backspace command requires the focus node of the cover title.
@ -7,6 +12,9 @@ import 'package:flutter/widgets.dart';
class SharedEditorContext {
SharedEditorContext();
static List<BlockTransactionHandler> get transactionHandlers =>
_transactionHandlers;
// The focus node of the cover title.
// It's null when the cover title is not focused.
FocusNode? coverTitleFocusNode;

View File

@ -1,6 +1,7 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/align_toolbar_item/custom_text_align_command.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/undo_redo/custom_undo_redo_commands.dart';
import 'package:appflowy/workspace/presentation/settings/widgets/emoji_picker/emoji_picker.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart';
@ -24,6 +25,8 @@ List<CommandShortcutEvent> commandShortcutEvents = [
customCopyCommand,
customPasteCommand,
customCutCommand,
customUndoCommand,
customRedoCommand,
...customTextAlignCommands,
@ -35,6 +38,8 @@ List<CommandShortcutEvent> commandShortcutEvents = [
cutCommand,
pasteCommand,
toggleTodoListCommand,
undoCommand,
redoCommand,
].contains(shortcut),
),

View File

@ -9,6 +9,7 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/base/selec
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/image_placeholder.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/slash_menu_items.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/sub_page/sub_page_block_component.dart';
import 'package:appflowy/workspace/application/view/view_service.dart';
import 'package:appflowy/workspace/presentation/settings/widgets/emoji_picker/emoji_menu_item.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
@ -552,6 +553,30 @@ SelectionMenuItem fileSlashMenuItem = SelectionMenuItem(
},
);
// Sub-page menu item
SelectionMenuItem subPageSlashMenuItem = SelectionMenuItem.node(
getName: () => LocaleKeys.document_slashMenu_subPage_name.tr(),
nameBuilder: _slashMenuItemNameBuilder,
iconBuilder: (_, isSelected, style) => SelectableSvgWidget(
data: FlowySvgs.insert_document_s,
isSelected: isSelected,
style: style,
),
keywords: [
LocaleKeys.document_slashMenu_subPage_keyword1.tr(),
LocaleKeys.document_slashMenu_subPage_keyword2.tr(),
LocaleKeys.document_slashMenu_subPage_keyword3.tr(),
LocaleKeys.document_slashMenu_subPage_keyword4.tr(),
LocaleKeys.document_slashMenu_subPage_keyword5.tr(),
LocaleKeys.document_slashMenu_subPage_keyword6.tr(),
LocaleKeys.document_slashMenu_subPage_keyword7.tr(),
],
updateSelection: (_, path, __, ___) =>
Selection.collapsed(Position(path: path)),
replace: (_, node) => node.delta?.isEmpty ?? false,
nodeBuilder: (_, __) => subPageNode(),
);
Widget _slashMenuItemNameBuilder(
String name,
SelectionMenuStyle style,

View File

@ -0,0 +1,285 @@
import 'package:flutter/material.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/block_transaction_handler/block_transaction_handler.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/sub_page/sub_page_block_component.dart';
import 'package:appflowy/plugins/trash/application/trash_service.dart';
import 'package:appflowy/workspace/application/view/view_service.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pbenum.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/style_widget/snap_bar.dart';
class SubPageBlockTransactionHandler extends BlockTransactionHandler {
SubPageBlockTransactionHandler() : super(blockType: SubPageBlockKeys.type);
final List<String> _beingCreated = [];
@override
void onRedo(
BuildContext context,
EditorState editorState,
List<Node> before,
List<Node> after,
) {
_handleUndoRedo(context, editorState, before, after);
}
@override
void onUndo(
BuildContext context,
EditorState editorState,
List<Node> before,
List<Node> after,
) {
_handleUndoRedo(context, editorState, before, after);
}
void _handleUndoRedo(
BuildContext context,
EditorState editorState,
List<Node> before,
List<Node> after,
) {
final additions = after.where((e) => !before.contains(e)).toList();
final removals = before.where((e) => !after.contains(e)).toList();
// Removals goes to trash
for (final node in removals) {
_subPageDeleted(context, editorState, node);
}
// Additions are moved to this view
for (final node in additions) {
_subPageAdded(context, editorState, node);
}
}
@override
Future<void> onTransaction(
BuildContext context,
EditorState editorState,
List<Node> added,
List<Node> removed, {
bool isUndoRedo = false,
bool isPaste = false,
bool isDraggingNode = false,
String? parentViewId,
}) async {
if (isDraggingNode) {
return;
}
for (final node in removed) {
if (!context.mounted) return;
await _subPageDeleted(context, editorState, node);
}
for (final node in added) {
if (!context.mounted) return;
await _subPageAdded(
context,
editorState,
node,
isPaste: isPaste,
parentViewId: parentViewId,
);
}
}
Future<void> _subPageDeleted(
BuildContext context,
EditorState editorState,
Node node,
) async {
if (node.type != blockType) {
return;
}
final view = node.attributes[SubPageBlockKeys.viewId];
if (view == null) {
return;
}
// We move the view to Trash
final result = await ViewBackendService.deleteView(viewId: view);
result.fold(
(_) {},
(error) {
Log.error(error);
if (context.mounted) {
showSnapBar(
context,
LocaleKeys.document_plugins_subPage_errors_failedDeletePage.tr(),
);
}
},
);
}
Future<void> _subPageAdded(
BuildContext context,
EditorState editorState,
Node node, {
bool isPaste = false,
String? parentViewId,
}) async {
if (node.type != blockType || _beingCreated.contains(node.id)) {
return;
}
final viewId = node.attributes[SubPageBlockKeys.viewId];
if (viewId == null && parentViewId != null) {
_beingCreated.add(node.id);
// This is a new Node, we need to create the view
final viewOrResult = await ViewBackendService.createView(
layoutType: ViewLayoutPB.Document,
parentViewId: parentViewId,
name: LocaleKeys.menuAppHeader_defaultNewPageName.tr(),
);
await viewOrResult.fold(
(view) async {
final transaction = editorState.transaction
..updateNode(node, {SubPageBlockKeys.viewId: view.id});
await editorState.apply(
transaction,
withUpdateSelection: false,
options: const ApplyOptions(recordUndo: false),
);
editorState.reload();
},
(error) async {
Log.error(error);
showSnapBar(
context,
LocaleKeys.document_plugins_subPage_errors_failedCreatePage.tr(),
);
// Remove the node because it failed
final transaction = editorState.transaction..deleteNode(node);
await editorState.apply(
transaction,
withUpdateSelection: false,
options: const ApplyOptions(recordUndo: false),
);
},
);
_beingCreated.remove(node.id);
} else if (isPaste) {
final wasCut = node.attributes[SubPageBlockKeys.wasCut];
if (wasCut == true && parentViewId != null) {
// Just in case, we try to put back from trash before moving
await TrashService.putback(viewId);
final viewOrResult = await ViewBackendService.moveViewV2(
viewId: viewId,
newParentId: parentViewId,
prevViewId: null,
);
viewOrResult.fold(
(_) {},
(error) {
Log.error(error);
showSnapBar(
context,
LocaleKeys.document_plugins_subPage_errors_failedMovePage.tr(),
);
},
);
} else {
final viewId = node.attributes[SubPageBlockKeys.viewId];
if (viewId == null) {
return;
}
final viewOrResult = await ViewBackendService.getView(viewId);
return viewOrResult.fold(
(view) async {
final duplicatedViewOrResult = await ViewBackendService.duplicate(
view: view,
openAfterDuplicate: false,
includeChildren: true,
syncAfterDuplicate: true,
parentViewId: parentViewId,
);
return duplicatedViewOrResult.fold(
(view) async {
final transaction = editorState.transaction
..updateNode(node, {
SubPageBlockKeys.viewId: view.id,
SubPageBlockKeys.wasCut: false,
SubPageBlockKeys.wasCopied: false,
});
await editorState.apply(
transaction,
withUpdateSelection: false,
options: const ApplyOptions(recordUndo: false),
);
editorState.reload();
},
(error) {
Log.error(error);
if (context.mounted) {
showSnapBar(
context,
LocaleKeys
.document_plugins_subPage_errors_failedDuplicatePage
.tr(),
);
}
},
);
},
(error) async {
Log.error(error);
final transaction = editorState.transaction..deleteNode(node);
await editorState.apply(
transaction,
withUpdateSelection: false,
options: const ApplyOptions(recordUndo: false),
);
editorState.reload();
if (context.mounted) {
showSnapBar(
context,
LocaleKeys
.document_plugins_subPage_errors_failedDuplicateFindView
.tr(),
);
}
},
);
}
} else {
// Try to restore from trash, and move to parent view
await TrashService.putback(viewId);
// Check if View needs to be moved
if (parentViewId != null) {
final view = pageMemorizer[viewId] ??
(await ViewBackendService.getView(viewId)).toNullable();
if (view == null) {
return Log.error('View not found: $viewId');
}
if (view.parentViewId == parentViewId) {
return;
}
await ViewBackendService.moveViewV2(
viewId: viewId,
newParentId: parentViewId,
prevViewId: null,
);
}
}
}
}

View File

@ -0,0 +1,358 @@
import 'package:flutter/material.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_page_block.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/view/view_ext.dart';
import 'package:appflowy/workspace/application/view/view_listener.dart';
import 'package:appflowy/workspace/application/view/view_service.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:collection/collection.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:provider/provider.dart';
Node subPageNode({String? viewId}) {
return Node(
type: SubPageBlockKeys.type,
attributes: {SubPageBlockKeys.viewId: viewId},
);
}
class SubPageBlockKeys {
const SubPageBlockKeys._();
static const String type = 'sub_page';
/// The ID of the View which is being linked to.
///
static const String viewId = "view_id";
/// Signifies whether the block was inserted after a Copy operation.
///
static const String wasCopied = "was_copied";
/// Signifies whether the block was inserted after a Cut operation.
///
static const String wasCut = "was_cut";
}
class SubPageBlockComponentBuilder extends BlockComponentBuilder {
SubPageBlockComponentBuilder({super.configuration});
@override
BlockComponentWidget build(BlockComponentContext blockComponentContext) {
final node = blockComponentContext.node;
return SubPageBlockComponent(
key: node.key,
node: node,
showActions: showActions(node),
configuration: configuration,
actionBuilder: (_, state) => actionBuilder(blockComponentContext, state),
);
}
@override
bool validate(Node node) => node.delta == null && node.children.isEmpty;
}
class SubPageBlockComponent extends BlockComponentStatefulWidget {
const SubPageBlockComponent({
super.key,
required super.node,
super.showActions,
super.actionBuilder,
super.configuration = const BlockComponentConfiguration(),
});
@override
State<SubPageBlockComponent> createState() => SubPageBlockComponentState();
}
class SubPageBlockComponentState extends State<SubPageBlockComponent>
with SelectableMixin, BlockComponentConfigurable {
@override
BlockComponentConfiguration get configuration => widget.configuration;
@override
Node get node => widget.node;
RenderBox? get _renderBox => context.findRenderObject() as RenderBox?;
final subPageKey = GlobalKey();
ViewListener? viewListener;
Future<ViewPB?>? viewFuture;
bool isHovering = false;
bool isHandlingPaste = false;
bool isInTrash = false;
EditorState get editorState => context.read<EditorState>();
@override
void initState() {
super.initState();
final viewId = node.attributes[SubPageBlockKeys.viewId];
if (viewId != null) {
viewFuture = fetchView(viewId);
viewListener = ViewListener(viewId: viewId)
..start(
onViewUpdated: (view) {
pageMemorizer[view.id] = view;
viewFuture = fetchView(viewId);
editorState.reload();
},
);
}
}
@override
void didUpdateWidget(SubPageBlockComponent oldWidget) {
final viewId = node.attributes[SubPageBlockKeys.viewId];
final oldViewId = viewListener?.viewId ??
oldWidget.node.attributes[SubPageBlockKeys.viewId];
if (viewId != null && (viewId != oldViewId || viewListener == null)) {
viewFuture = fetchView(viewId);
viewListener?.stop();
viewListener = ViewListener(viewId: viewId)
..start(
onViewUpdated: (view) {
pageMemorizer[view.id] = view;
viewFuture = fetchView(viewId);
editorState.reload();
},
);
}
super.didUpdateWidget(oldWidget);
}
@override
void dispose() {
viewListener?.stop();
super.dispose();
}
@override
Widget build(BuildContext context) {
return FutureBuilder<ViewPB?>(
initialData: pageMemorizer[node.attributes[SubPageBlockKeys.viewId]],
future: viewFuture,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const SizedBox.shrink();
}
final view = snapshot.data;
if (view == null) {
// Delete this node if the view is not found
WidgetsBinding.instance.addPostFrameCallback((_) {
final transaction = editorState.transaction..deleteNode(node);
editorState.apply(
transaction,
withUpdateSelection: false,
options: const ApplyOptions(recordUndo: false),
);
});
return const SizedBox.shrink();
}
Widget child = Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => isHovering = true),
onExit: (_) => setState(() => isHovering = false),
opaque: false,
child: Container(
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(4),
),
child: BlockSelectionContainer(
node: node,
delegate: this,
listenable: editorState.selectionNotifier,
remoteSelection: editorState.remoteSelections,
blockColor: editorState.editorStyle.selectionColor,
cursorColor: editorState.editorStyle.cursorColor,
selectionColor: editorState.editorStyle.selectionColor,
supportTypes: const [
BlockSelectionType.block,
BlockSelectionType.cursor,
BlockSelectionType.selection,
],
child: GestureDetector(
// TODO(Mathias): Handle mobile tap
onTap: isHandlingPaste
? null
: () => getIt<TabsBloc>().add(
TabsEvent.openPlugin(
plugin: view.plugin(),
view: view,
),
),
child: DecoratedBox(
decoration: BoxDecoration(
color: isHovering
? Theme.of(context).colorScheme.secondary
: Theme.of(context)
.colorScheme
.surfaceContainerHighest,
borderRadius: BorderRadius.circular(4),
),
child: SizedBox(
height: 36,
child: Row(
children: [
const HSpace(10),
view.icon.value.isNotEmpty
? FlowyText.emoji(
view.icon.value,
fontSize: textStyle.fontSize,
lineHeight: textStyle.height,
)
: Opacity(
opacity: 0.6,
child: view.defaultIcon(),
),
const HSpace(10),
FlowyText(
view.name,
fontSize: textStyle.fontSize,
fontWeight: textStyle.fontWeight,
lineHeight: textStyle.height,
),
if (isInTrash) ...[
const HSpace(4),
FlowyText(
LocaleKeys.document_plugins_subPage_inTrashHint.tr(),
fontSize: textStyle.fontSize,
fontWeight: textStyle.fontWeight,
lineHeight: textStyle.height,
color: Theme.of(context).hintColor,
),
] else if (isHandlingPaste) ...[
FlowyText(
LocaleKeys.document_plugins_subPage_handlingPasteHint.tr(),
fontSize: textStyle.fontSize,
fontWeight: textStyle.fontWeight,
lineHeight: textStyle.height,
color: Theme.of(context).hintColor,
),
const HSpace(10),
const CircularProgressIndicator(),
],
],
),
),
),
),
),
),
),
);
if (widget.showActions && widget.actionBuilder != null) {
child = BlockComponentActionWrapper(
node: node,
actionBuilder: widget.actionBuilder!,
child: child,
);
}
return child;
},
);
}
Future<ViewPB?> fetchView(String pageId) async {
final view = await ViewBackendService.getView(pageId).then(
(res) => res.toNullable(),
);
if (view == null) {
// try to fetch from trash
final trashViews = await TrashService().readTrash();
final trash = trashViews.fold(
(l) => l.items.firstWhereOrNull((trash) => trash.id == pageId),
(r) => null,
);
if (trash != null) {
isInTrash = true;
return ViewPB()
..id = trash.id
..name = trash.name;
}
}
return view;
}
@override
Position start() => Position(path: widget.node.path);
@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 getBlockRect({
bool shiftWithBaseOffset = false,
}) {
return getRectsInSelection(Selection.invalid()).first;
}
@override
Rect? getCursorRectInPosition(
Position position, {
bool shiftWithBaseOffset = false,
}) {
final rects = getRectsInSelection(
Selection.collapsed(position),
shiftWithBaseOffset: shiftWithBaseOffset,
);
return rects.firstOrNull;
}
@override
List<Rect> getRectsInSelection(
Selection selection, {
bool shiftWithBaseOffset = false,
}) {
if (_renderBox == null) {
return [];
}
final parentBox = context.findRenderObject();
final renderBox = subPageKey.currentContext?.findRenderObject();
if (parentBox is RenderBox && renderBox is RenderBox) {
return [
renderBox.localToGlobal(Offset.zero, ancestor: parentBox) &
renderBox.size,
];
}
return [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, {bool shiftWithBaseOffset = false}) =>
_renderBox!.localToGlobal(offset);
}

View File

@ -0,0 +1,38 @@
import 'package:flutter/widgets.dart';
import 'package:appflowy/plugins/document/presentation/editor_notification.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
/// Undo
///
/// - support
/// - desktop
/// - web
///
final CommandShortcutEvent customUndoCommand = CommandShortcutEvent(
key: 'undo',
getDescription: () => AppFlowyEditorL10n.current.cmdUndo,
command: 'ctrl+z',
macOSCommand: 'cmd+z',
handler: (editorState) {
EditorNotification.undo().post();
return KeyEventResult.handled;
},
);
/// Redo
///
/// - support
/// - desktop
/// - web
///
final CommandShortcutEvent customRedoCommand = CommandShortcutEvent(
key: 'redo',
getDescription: () => AppFlowyEditorL10n.current.cmdRedo,
command: 'ctrl+y,ctrl+shift+z',
macOSCommand: 'cmd+shift+z',
handler: (editorState) {
EditorNotification.redo().post();
return KeyEventResult.handled;
},
);

View File

@ -42,7 +42,7 @@ class TrashBloc extends Bloc<TrashEvent, TrashState> {
emit(state.copyWith(objects: e.trash));
},
putback: (e) async {
final result = await _service.putback(e.trashId);
final result = await TrashService.putback(e.trashId);
await _handleResult(result, emit);
},
delete: (e) async {

View File

@ -10,7 +10,7 @@ class TrashService {
return FolderEventListTrashItems().send();
}
Future<FlowyResult<void, FlowyError>> putback(String trashId) {
static Future<FlowyResult<void, FlowyError>> putback(String trashId) {
final id = TrashIdPB.create()..id = trashId;
return FolderEventRestoreTrashItem(id).send();

View File

@ -136,7 +136,7 @@ class ViewBackendService {
return FolderEventDeleteView(request).send();
}
static Future<FlowyResult<void, FlowyError>> duplicate({
static Future<FlowyResult<ViewPB, FlowyError>> duplicate({
required ViewPB view,
required bool openAfterDuplicate,
// should include children views

View File

@ -128,7 +128,6 @@ class _SearchFieldState extends State<SearchField> {
),
),
const HSpace(8),
// TODO(Mathias): Remove beta when support database search
FlowyTooltip(
message: LocaleKeys.commandPalette_betaTooltip.tr(),
child: Container(

View File

@ -1,5 +1,7 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:appflowy/shared/flowy_error_page.dart';
import 'package:appflowy/util/int64_extension.dart';
import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart';
@ -21,7 +23,6 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:fixnum/fixnum.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../generated/locale_keys.g.dart';
@ -260,7 +261,7 @@ class _SettingsBillingViewState extends State<SettingsBillingView> {
),
),
).then((didChangePlan) {
if (didChangePlan == true) {
if (didChangePlan == true && context.mounted) {
context
.read<SettingsBillingBloc>()
.add(const SettingsBillingEvent.started());

View File

@ -1,5 +1,7 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/application/document_appearance_cubit.dart';
@ -44,7 +46,6 @@ import 'package:flowy_infra/theme.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flowy_infra_ui/style_widget/hover.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:google_fonts/google_fonts.dart';
@ -625,7 +626,7 @@ class _ThemeDropdown extends StatelessWidget {
),
),
).then((val) {
if (val != null) {
if (val != null && context.mounted) {
showSnackBarMessage(
context,
LocaleKeys.settings_appearance_themeUpload_uploadSuccess

View File

@ -1,5 +1,7 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/util/navigator_context_exntesion.dart';
import 'package:appflowy/workspace/application/export/document_exporter.dart';
@ -16,7 +18,6 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/file_picker/file_picker_service.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:path/path.dart' as p;
@ -126,7 +127,7 @@ class _FileExporterWidgetState extends State<FileExporterWidget> {
);
}
}
} else {
} else if (mounted) {
showSnackBarMessage(
context,
LocaleKeys.settings_files_exportFileFail.tr(),

View File

@ -0,0 +1 @@
<svg viewBox="-0.5 -0.5 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" id="Gallery--Streamline-Solar-Ar.svg" height="16" width="16"><desc>Gallery Streamline Icon: https://streamlinehq.com</desc><path d="M0.6628125 7.5C0.6628125 4.276875 0.6628125 2.665375 1.6640625 1.6640625C2.665375 0.6628125 4.276875 0.6628125 7.5 0.6628125C10.723062500000001 0.6628125 12.334624999999999 0.6628125 13.335875 1.6640625C14.337187499999999 2.665375 14.337187499999999 4.276875 14.337187499999999 7.5C14.337187499999999 10.723062500000001 14.337187499999999 12.334624999999999 13.335875 13.335875C12.3346875 14.337187499999999 10.723062500000001 14.337187499999999 7.5 14.337187499999999C4.276875 14.337187499999999 2.665375 14.337187499999999 1.6640625 13.335875C0.6628125 12.3346875 0.6628125 10.723062500000001 0.6628125 7.5Z" stroke="#000000" stroke-width="1"></path><path stroke="#000000" d="M8.867437500000001 4.765125C8.865187500000001 5.81775 10.0033125 6.478125 10.916062499999999 5.953749999999999C11.3415 5.7093125 11.603375 5.25575 11.6023125 4.765125C11.6045625 3.7124375 10.466437500000001 3.052125 9.5536875 3.5765000000000002C9.12825 3.820875 8.866375 4.2745 8.867437500000001 4.765125" stroke-width="1"></path><path d="M0.6628125 7.8419375L1.860375 6.7940625C2.4834375 6.2489375 3.4225 6.280187499999999 4.007875 6.865562499999999L6.940875 9.7985625C7.410687500000001 10.268437500000001 8.150375 10.3325 8.694062500000001 9.950375000000001L8.897937500000001 9.807125C9.680250000000001 9.2573125 10.73875 9.321 11.449499999999999 9.960687499999999L13.653500000000001 11.94425" stroke="#000000" stroke-linecap="round" stroke-width="1"></path></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@ -0,0 +1 @@
<svg viewBox="-0.5 -0.5 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" id="Document-Medicine--Streamline-Solar-Ar.svg" height="16" width="16"><desc>Document Medicine Streamline Icon: https://streamlinehq.com</desc><path d="M1.3464999999999998 6.1325625C1.3464999999999998 3.5540624999999997 1.3464999999999998 2.264875 2.1475625000000003 1.4638125C2.9485625 0.6628125 4.2378125 0.6628125 6.81625 0.6628125H8.18375C10.7621875 0.6628125 12.051437499999999 0.6628125 12.8524375 1.4638125C13.653500000000001 2.264875 13.653500000000001 3.5540624999999997 13.653500000000001 6.1325625V8.867437500000001C13.653500000000001 11.445875000000001 13.653500000000001 12.7351875 12.8524375 13.536125000000002C12.051437499999999 14.337187499999999 10.7621875 14.337187499999999 8.18375 14.337187499999999H6.81625C4.2378125 14.337187499999999 2.9485625 14.337187499999999 2.1475625000000003 13.5361875C1.3464999999999998 12.7351875 1.3464999999999998 11.445875000000001 1.3464999999999998 8.867437500000001V6.1325625Z" stroke="#000000" stroke-width="1"></path><path d="M7.5 3.3976875V4.765125M7.5 4.765125V6.1325625M7.5 4.765125H6.1325625M7.5 4.765125H8.867437500000001" stroke="#000000" stroke-linecap="round" stroke-width="1"></path><path d="M4.765125 8.867437500000001H10.234875" stroke="#000000" stroke-linecap="round" stroke-width="1"></path><path d="M5.4488125 11.6023125H9.551187500000001" stroke="#000000" stroke-linecap="round" stroke-width="1"></path></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -1568,6 +1568,16 @@
"dateOrReminder": "Date or Reminder",
"photoGallery": "Photo Gallery",
"file": "File"
},
"subPage": {
"name": "Embed sub-page",
"keyword1": "sub page",
"keyword2": "page",
"keyword3": "child page",
"keyword4": "insert page",
"keyword5": "embed page",
"keyword6": "new page",
"keyword7": "create page"
}
},
"selectionMenu": {
@ -1747,6 +1757,17 @@
"linkedAt": "Link added on {}",
"failedToOpenMsg": "Failed to open, file not found"
},
"subPage": {
"inTrashHint": " - (in trash)",
"handlingPasteHint": " - (handling paste)",
"errors": {
"failedDeletePage": "Failed to delete page",
"failedCreatePage": "Failed to create page",
"failedMovePage": "Failed to move page to this document",
"failedDuplicatePage": "Failed to duplicate page",
"failedDuplicateFindView": "Failed to duplicate page - original view not found"
}
},
"cannotMoveToItsChildren": "Cannot move to its children"
},
"outlineBlock": {

View File

@ -268,11 +268,12 @@ pub(crate) async fn move_nested_view_handler(
pub(crate) async fn duplicate_view_handler(
data: AFPluginData<DuplicateViewPayloadPB>,
folder: AFPluginState<Weak<FolderManager>>,
) -> Result<(), FlowyError> {
) -> DataResult<ViewPB, FlowyError> {
let folder = upgrade_folder(folder)?;
let params: DuplicateViewParams = data.into_inner().try_into()?;
folder.duplicate_view(params).await?;
Ok(())
let view_pb = folder.duplicate_view(params).await?;
data_result_ok(view_pb)
}
#[tracing::instrument(level = "debug", skip(folder), err)]

View File

@ -91,7 +91,7 @@ pub enum FolderEvent {
DeleteView = 13,
/// Duplicate the view
#[event(input = "DuplicateViewPayloadPB")]
#[event(input = "DuplicateViewPayloadPB", output = "ViewPB")]
DuplicateView = 14,
/// Close and release the resources that are used by this view.

View File

@ -611,6 +611,19 @@ impl FolderManager {
pub async fn move_view_to_trash(&self, view_id: &str) -> FlowyResult<()> {
if let Some(lock) = self.mutex_folder.load_full() {
let mut folder = lock.write().await;
// Check if the view is already in trash, if not we can move the same
// view to trash multiple times (duplicates)
let trash_info = folder.get_my_trash_info();
if trash_info.into_iter().any(|info| info.id == view_id) {
return Err(FlowyError::new(
ErrorCode::Internal,
format!(
"Can't move the view({}) to trash, it is already in trash",
view_id
),
));
}
if let Some(view) = folder.get_view(view_id) {
Self::unfavorite_view_and_decendants(view.clone(), &mut folder);
folder.add_trash_view_ids(vec![view_id.to_string()]);
@ -791,7 +804,10 @@ impl FolderManager {
///
/// Including the view data (icon, cover, extra) and the child views.
#[tracing::instrument(level = "debug", skip(self), err)]
pub(crate) async fn duplicate_view(&self, params: DuplicateViewParams) -> Result<(), FlowyError> {
pub(crate) async fn duplicate_view(
&self,
params: DuplicateViewParams,
) -> Result<ViewPB, FlowyError> {
let lock = self
.mutex_folder
.load_full()
@ -832,7 +848,7 @@ impl FolderManager {
include_children: bool,
suffix: Option<String>,
sync_after_create: bool,
) -> Result<(), FlowyError> {
) -> Result<ViewPB, FlowyError> {
if view_id == parent_view_id {
return Err(FlowyError::new(
ErrorCode::Internal,
@ -840,6 +856,8 @@ impl FolderManager {
));
}
tracing::warn!("[DEBUG] Duplicating view {}", view_id);
// filter the view ids that in the trash or private section
let filtered_view_ids = match self.mutex_folder.load_full() {
None => Vec::default(),
@ -851,6 +869,7 @@ impl FolderManager {
// only apply the `open_after_duplicated` and the `include_children` to the first view
let mut is_source_view = true;
let mut new_view_id = String::default();
// use a stack to duplicate the view and its children
let mut stack = vec![(view_id.to_string(), parent_view_id.to_string())];
let mut objects = vec![];
@ -924,6 +943,11 @@ impl FolderManager {
.create_view_with_params(duplicate_params, false)
.await?;
if is_source_view {
new_view_id = duplicated_view.id.clone();
tracing::warn!("[DEBUG] Setting new view id to {}", new_view_id);
}
if sync_after_create {
if let Some(encoded_collab) = encoded_collab {
let object_id = duplicated_view.id.clone();
@ -972,7 +996,11 @@ impl FolderManager {
let folder = lock.read().await;
notify_parent_view_did_change(workspace_id, &folder, vec![parent_view_id.to_string()]);
Ok(())
let duplicated_view = self.get_view_pb(&new_view_id).await?;
tracing::warn!("[DEBUG] Duplicated view: {:?}", duplicated_view);
Ok(duplicated_view)
}
#[tracing::instrument(level = "trace", skip(self), err)]