mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2025-12-27 23:24:38 +00:00
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:
parent
3b48ca0f4b
commit
0d69b895aa
@ -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';
|
||||
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -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));
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
),
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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),
|
||||
),
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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;
|
||||
},
|
||||
);
|
||||
@ -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 {
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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());
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(),
|
||||
|
||||
1
frontend/resources/flowy_icons/16x/image_rounded.svg
Normal file
1
frontend/resources/flowy_icons/16x/image_rounded.svg
Normal 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 |
1
frontend/resources/flowy_icons/16x/insert_document.svg
Normal file
1
frontend/resources/flowy_icons/16x/insert_document.svg
Normal 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 |
@ -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": {
|
||||
|
||||
@ -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)]
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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)]
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user