diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1f2dde57e5..315f3ff310 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,7 +7,7 @@ on: env: FLUTTER_VERSION: "3.22.0" - RUST_TOOLCHAIN: "1.77.2" + RUST_TOOLCHAIN: "1.80.1" jobs: create-release: diff --git a/.github/workflows/rust_ci.yaml b/.github/workflows/rust_ci.yaml index 069e535976..6b2417f3db 100644 --- a/.github/workflows/rust_ci.yaml +++ b/.github/workflows/rust_ci.yaml @@ -19,7 +19,7 @@ on: env: CARGO_TERM_COLOR: always CLOUD_VERSION: 0.7.6-amd64 - RUST_TOOLCHAIN: "1.77.2" + RUST_TOOLCHAIN: "1.80.1" jobs: self-hosted-job: diff --git a/.github/workflows/rust_coverage.yml b/.github/workflows/rust_coverage.yml index 12e728698f..4e2dd3c8a4 100644 --- a/.github/workflows/rust_coverage.yml +++ b/.github/workflows/rust_coverage.yml @@ -11,7 +11,7 @@ on: env: CARGO_TERM_COLOR: always FLUTTER_VERSION: "3.22.0" - RUST_TOOLCHAIN: "1.77.2" + RUST_TOOLCHAIN: "1.80.1" jobs: tests: diff --git a/.github/workflows/tauri2_ci.yaml b/.github/workflows/tauri2_ci.yaml index 6bbb7928ee..1191b2a354 100644 --- a/.github/workflows/tauri2_ci.yaml +++ b/.github/workflows/tauri2_ci.yaml @@ -11,7 +11,7 @@ on: env: NODE_VERSION: "18.16.0" PNPM_VERSION: "8.5.0" - RUST_TOOLCHAIN: "1.77.2" + RUST_TOOLCHAIN: "1.80.1" CARGO_MAKE_VERSION: "0.36.6" CI: true @@ -121,4 +121,4 @@ jobs: with: tauriScript: pnpm tauri projectPath: frontend/appflowy_web_app - args: "--debug" \ No newline at end of file + args: "--debug" diff --git a/.github/workflows/tauri_ci.yaml b/.github/workflows/tauri_ci.yaml index 70ad621451..1cc817fb7c 100644 --- a/.github/workflows/tauri_ci.yaml +++ b/.github/workflows/tauri_ci.yaml @@ -7,7 +7,7 @@ on: env: NODE_VERSION: "18.16.0" PNPM_VERSION: "8.5.0" - RUST_TOOLCHAIN: "1.77.2" + RUST_TOOLCHAIN: "1.80.1" concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} @@ -19,7 +19,7 @@ jobs: strategy: fail-fast: false matrix: - platform: [ ubuntu-20.04 ] + platform: [ubuntu-20.04] runs-on: ${{ matrix.platform }} @@ -108,4 +108,4 @@ jobs: with: tauriScript: pnpm tauri projectPath: frontend/appflowy_tauri - args: "--debug" \ No newline at end of file + args: "--debug" diff --git a/.github/workflows/tauri_release.yml b/.github/workflows/tauri_release.yml index 7de80b017e..4595ec84ea 100644 --- a/.github/workflows/tauri_release.yml +++ b/.github/workflows/tauri_release.yml @@ -4,20 +4,19 @@ on: workflow_dispatch: inputs: branch: - description: 'The branch to release' + description: "The branch to release" required: true - default: 'main' + default: "main" version: - description: 'The version to release' + description: "The version to release" required: true - default: '0.0.0' + default: "0.0.0" env: NODE_VERSION: "18.16.0" PNPM_VERSION: "8.5.0" - RUST_TOOLCHAIN: "1.77.2" + RUST_TOOLCHAIN: "1.80.1" jobs: - publish-tauri: permissions: contents: write diff --git a/CHANGELOG.md b/CHANGELOG.md index 51df2a5742..de5c792d8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,8 @@ # Release Notes +## Version 0.7.6 - 03/12/2024 +### Bug Fixes + + ## Version 0.7.6 - 03/12/2024 ### New Features - Revamped the simple table UI diff --git a/frontend/Makefile.toml b/frontend/Makefile.toml index f86cb1befe..deb314db41 100644 --- a/frontend/Makefile.toml +++ b/frontend/Makefile.toml @@ -26,7 +26,7 @@ CARGO_MAKE_EXTEND_WORKSPACE_MAKEFILE = true CARGO_MAKE_CRATE_FS_NAME = "dart_ffi" CARGO_MAKE_CRATE_NAME = "dart-ffi" LIB_NAME = "dart_ffi" -APPFLOWY_VERSION = "0.7.6" +APPFLOWY_VERSION = "0.7.7" FLUTTER_DESKTOP_FEATURES = "dart" PRODUCT_NAME = "AppFlowy" MACOSX_DEPLOYMENT_TARGET = "11.0" diff --git a/frontend/appflowy_flutter/integration_test/desktop/database/database_calendar_test.dart b/frontend/appflowy_flutter/integration_test/desktop/database/database_calendar_test.dart index eb1d67ffcd..d6df648bb3 100644 --- a/frontend/appflowy_flutter/integration_test/desktop/database/database_calendar_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/database/database_calendar_test.dart @@ -301,6 +301,7 @@ void main() { await tester.createOption(name: "qwer"); await tester.selectOption(name: "asdf"); await tester.dismissCellEditor(); + await tester.dismissCellEditor(); await tester.tapDatabaseFilterButton(); await tester.tapCreateFilterByFieldType(FieldType.MultiSelect, "Tags"); @@ -332,6 +333,7 @@ void main() { await tester.tapButton(finderForFieldType(FieldType.MultiSelect)); await tester.selectOption(name: "asdf"); await tester.dismissCellEditor(); + await tester.dismissCellEditor(); tester.assertNumberOfEventsInCalendar(0); diff --git a/frontend/appflowy_flutter/integration_test/desktop/document/document_inline_sub_page_test.dart b/frontend/appflowy_flutter/integration_test/desktop/document/document_inline_sub_page_test.dart index ef9ef73b3c..30e115774a 100644 --- a/frontend/appflowy_flutter/integration_test/desktop/document/document_inline_sub_page_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/document/document_inline_sub_page_test.dart @@ -2,12 +2,12 @@ import 'dart:io'; 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/inline_actions/inline_actions_menu.dart'; import 'package:appflowy/workspace/presentation/home/menu/view/view_action_type.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/services.dart'; - -import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; @@ -330,6 +330,23 @@ void main() { expect(find.text("$_createdPageName (copy)"), findsNWidgets(2)); expect(find.text("$_createdPageName (copy) (copy)"), findsOneWidget); }); + + testWidgets('Cancel inline page reference menu by space', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + await tester.createOpenRenameDocumentUnderParent(name: _firstDocName); + + await tester.editor.tapLineOfEditorAt(0); + await tester.editor.showPlusMenu(); + + // Cancel by space + await tester.simulateKeyEvent( + LogicalKeyboardKey.space, + ); + await tester.pumpAndSettle(); + + expect(find.byType(InlineActionsMenu), findsNothing); + }); }); } diff --git a/frontend/appflowy_flutter/integration_test/desktop/document/document_with_date_reminder_test.dart b/frontend/appflowy_flutter/integration_test/desktop/document/document_with_date_reminder_test.dart index c7fea448f7..ccfdbae76e 100644 --- a/frontend/appflowy_flutter/integration_test/desktop/document/document_with_date_reminder_test.dart +++ b/frontend/appflowy_flutter/integration_test/desktop/document/document_with_date_reminder_test.dart @@ -1,14 +1,21 @@ +import 'dart:io'; + import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_date_block.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/user/application/reminder/reminder_bloc.dart'; import 'package:appflowy/workspace/application/settings/date_time/date_format_ext.dart'; import 'package:appflowy/workspace/presentation/widgets/date_picker/desktop_date_picker.dart'; import 'package:appflowy/workspace/presentation/widgets/toggle/toggle.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; +import 'package:table_calendar/table_calendar.dart'; import '../../shared/util.dart'; @@ -18,7 +25,7 @@ void main() { TestWidgetsFlutterBinding.ensureInitialized(); }); - group('date or reminder block in document', () { + group('date or reminder block in document:', () { testWidgets("insert date with time block", (tester) async { await tester.initializeAppFlowy(); await tester.tapAnonymousSignInButton(); @@ -121,5 +128,339 @@ void main() { expect(find.text('@$formattedDate'), findsOneWidget); expect(find.byFlowySvg(FlowySvgs.reminder_clock_s), findsOneWidget); }); + + testWidgets("copy, cut and paste a date mention", (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + // create a new document + await tester.createNewPageWithNameUnderParent( + name: 'copy, cut and paste a date mention', + ); + + // tap the first line of the document + await tester.editor.tapLineOfEditorAt(0); + await tester.editor.showSlashMenu(); + await tester.editor.tapSlashMenuItemWithName( + LocaleKeys.document_slashMenu_name_dateOrReminder.tr(), + ); + + final dateTimeSettings = DateTimeSettingsPB( + dateFormat: UserDateFormatPB.Friendly, + timeFormat: UserTimeFormatPB.TwentyFourHour, + ); + final DateTime currentDateTime = DateTime.now(); + final String formattedDate = + dateTimeSettings.dateFormat.formatDate(currentDateTime, false); + + // get current date in editor + expect(find.byType(MentionDateBlock), findsOneWidget); + expect(find.text('@$formattedDate'), findsOneWidget); + + // update selection and copy + await tester.editor.updateSelection( + Selection( + start: Position(path: [0]), + end: Position(path: [0], offset: 1), + ), + ); + await tester.simulateKeyEvent( + LogicalKeyboardKey.keyC, + isControlPressed: Platform.isLinux || Platform.isWindows, + isMetaPressed: Platform.isMacOS, + ); + await tester.pumpAndSettle(); + + // update selection and paste + await tester.editor.updateSelection( + Selection.collapsed(Position(path: [0], offset: 1)), + ); + await tester.simulateKeyEvent( + LogicalKeyboardKey.keyV, + isControlPressed: Platform.isLinux || Platform.isWindows, + isMetaPressed: Platform.isMacOS, + ); + await tester.pumpAndSettle(); + + expect(find.byType(MentionDateBlock), findsNWidgets(2)); + expect(find.text('@$formattedDate'), findsNWidgets(2)); + + // update selection and cut + await tester.editor.updateSelection( + Selection( + start: Position(path: [0], offset: 1), + end: Position(path: [0], offset: 2), + ), + ); + await tester.simulateKeyEvent( + LogicalKeyboardKey.keyX, + isControlPressed: Platform.isLinux || Platform.isWindows, + isMetaPressed: Platform.isMacOS, + ); + await tester.pumpAndSettle(); + + expect(find.byType(MentionDateBlock), findsOneWidget); + expect(find.text('@$formattedDate'), findsOneWidget); + + // update selection and paste + await tester.editor.updateSelection( + Selection.collapsed(Position(path: [0], offset: 1)), + ); + await tester.simulateKeyEvent( + LogicalKeyboardKey.keyV, + isControlPressed: Platform.isLinux || Platform.isWindows, + isMetaPressed: Platform.isMacOS, + ); + await tester.pumpAndSettle(); + + expect(find.byType(MentionDateBlock), findsNWidgets(2)); + expect(find.text('@$formattedDate'), findsNWidgets(2)); + }); + + testWidgets("copy, cut and paste a reminder mention", (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + // create a new document + await tester.createNewPageWithNameUnderParent( + name: 'copy, cut and paste a reminder mention', + ); + + // tap the first line of the document + await tester.editor.tapLineOfEditorAt(0); + await tester.editor.showSlashMenu(); + await tester.editor.tapSlashMenuItemWithName( + LocaleKeys.document_slashMenu_name_dateOrReminder.tr(), + ); + + // trigger popup + await tester.tapButton(find.byType(MentionDateBlock)); + await tester.pumpAndSettle(); + + // set date to be fifteenth of the next month + await tester.tap( + find.descendant( + of: find.byType(DesktopAppFlowyDatePicker), + matching: find.byFlowySvg(FlowySvgs.arrow_right_s), + ), + ); + await tester.pumpAndSettle(); + await tester.tap( + find.descendant( + of: find.byType(TableCalendar), + matching: find.text(15.toString()), + ), + ); + await tester.pumpAndSettle(); + await tester.simulateKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + + // add a reminder + await tester.tap(find.byType(MentionDateBlock)); + await tester.pumpAndSettle(); + await tester.tap(find.text(LocaleKeys.datePicker_reminderLabel.tr())); + await tester.pumpAndSettle(); + await tester.tap( + find.textContaining( + LocaleKeys.datePicker_reminderOptions_oneDayBefore.tr(), + ), + ); + await tester.pumpAndSettle(); + await tester.simulateKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + + // verify + final dateTimeSettings = DateTimeSettingsPB( + dateFormat: UserDateFormatPB.Friendly, + timeFormat: UserTimeFormatPB.TwentyFourHour, + ); + final now = DateTime.now(); + final fifteenthOfNextMonth = DateTime(now.year, now.month + 1, 15); + final formattedDate = + dateTimeSettings.dateFormat.formatDate(fifteenthOfNextMonth, false); + + expect(find.byType(MentionDateBlock), findsOneWidget); + expect(find.text('@$formattedDate'), findsOneWidget); + expect(find.byFlowySvg(FlowySvgs.reminder_clock_s), findsOneWidget); + expect(getIt().state.reminders.map((e) => e.id).length, 1); + + // update selection and copy + await tester.editor.updateSelection( + Selection( + start: Position(path: [0]), + end: Position(path: [0], offset: 1), + ), + ); + await tester.simulateKeyEvent( + LogicalKeyboardKey.keyC, + isControlPressed: Platform.isLinux || Platform.isWindows, + isMetaPressed: Platform.isMacOS, + ); + await tester.pumpAndSettle(); + + // update selection and paste + await tester.editor.updateSelection( + Selection.collapsed(Position(path: [0], offset: 1)), + ); + await tester.simulateKeyEvent( + LogicalKeyboardKey.keyV, + isControlPressed: Platform.isLinux || Platform.isWindows, + isMetaPressed: Platform.isMacOS, + ); + await tester.pumpAndSettle(); + + expect(find.byType(MentionDateBlock), findsNWidgets(2)); + expect(find.text('@$formattedDate'), findsNWidgets(2)); + expect(find.byFlowySvg(FlowySvgs.reminder_clock_s), findsNWidgets(2)); + expect( + getIt().state.reminders.map((e) => e.id).toSet().length, + 2, + ); + + // update selection and cut + await tester.editor.updateSelection( + Selection( + start: Position(path: [0], offset: 1), + end: Position(path: [0], offset: 2), + ), + ); + await tester.simulateKeyEvent( + LogicalKeyboardKey.keyX, + isControlPressed: Platform.isLinux || Platform.isWindows, + isMetaPressed: Platform.isMacOS, + ); + await tester.pumpAndSettle(); + + expect(find.byType(MentionDateBlock), findsOneWidget); + expect(find.text('@$formattedDate'), findsOneWidget); + expect(find.byFlowySvg(FlowySvgs.reminder_clock_s), findsOneWidget); + expect(getIt().state.reminders.map((e) => e.id).length, 1); + + // update selection and paste + await tester.editor.updateSelection( + Selection.collapsed(Position(path: [0], offset: 1)), + ); + await tester.simulateKeyEvent( + LogicalKeyboardKey.keyV, + isControlPressed: Platform.isLinux || Platform.isWindows, + isMetaPressed: Platform.isMacOS, + ); + await tester.pumpAndSettle(); + + expect(find.byType(MentionDateBlock), findsNWidgets(2)); + expect(find.text('@$formattedDate'), findsNWidgets(2)); + expect(find.byType(MentionDateBlock), findsNWidgets(2)); + expect(find.text('@$formattedDate'), findsNWidgets(2)); + expect(find.byFlowySvg(FlowySvgs.reminder_clock_s), findsNWidgets(2)); + expect( + getIt().state.reminders.map((e) => e.id).toSet().length, + 2, + ); + }); + + testWidgets("delete, undo and redo a reminder mention", (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + // create a new document + await tester.createNewPageWithNameUnderParent( + name: 'delete, undo and redo a reminder mention', + ); + + // tap the first line of the document + await tester.editor.tapLineOfEditorAt(0); + await tester.editor.showSlashMenu(); + await tester.editor.tapSlashMenuItemWithName( + LocaleKeys.document_slashMenu_name_dateOrReminder.tr(), + ); + + // trigger popup + await tester.tapButton(find.byType(MentionDateBlock)); + await tester.pumpAndSettle(); + + // set date to be fifteenth of the next month + await tester.tap( + find.descendant( + of: find.byType(DesktopAppFlowyDatePicker), + matching: find.byFlowySvg(FlowySvgs.arrow_right_s), + ), + ); + await tester.pumpAndSettle(); + await tester.tap( + find.descendant( + of: find.byType(TableCalendar), + matching: find.text(15.toString()), + ), + ); + await tester.pumpAndSettle(); + await tester.simulateKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + + // add a reminder + await tester.tap(find.byType(MentionDateBlock)); + await tester.pumpAndSettle(); + await tester.tap(find.text(LocaleKeys.datePicker_reminderLabel.tr())); + await tester.pumpAndSettle(); + await tester.tap( + find.textContaining( + LocaleKeys.datePicker_reminderOptions_oneDayBefore.tr(), + ), + ); + await tester.pumpAndSettle(); + await tester.simulateKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + + // verify + final dateTimeSettings = DateTimeSettingsPB( + dateFormat: UserDateFormatPB.Friendly, + timeFormat: UserTimeFormatPB.TwentyFourHour, + ); + final now = DateTime.now(); + final fifteenthOfNextMonth = DateTime(now.year, now.month + 1, 15); + final formattedDate = + dateTimeSettings.dateFormat.formatDate(fifteenthOfNextMonth, false); + + expect(find.byType(MentionDateBlock), findsOneWidget); + expect(find.text('@$formattedDate'), findsOneWidget); + expect(find.byFlowySvg(FlowySvgs.reminder_clock_s), findsOneWidget); + expect(getIt().state.reminders.map((e) => e.id).length, 1); + + // update selection and backspace to delete the mention + await tester.editor.updateSelection( + Selection.collapsed(Position(path: [0], offset: 1)), + ); + await tester.simulateKeyEvent(LogicalKeyboardKey.backspace); + await tester.pumpAndSettle(); + + expect(find.byType(MentionDateBlock), findsNothing); + expect(find.text('@$formattedDate'), findsNothing); + expect(find.byFlowySvg(FlowySvgs.reminder_clock_s), findsNothing); + expect(getIt().state.reminders.isEmpty, isTrue); + + // undo + await tester.simulateKeyEvent( + LogicalKeyboardKey.keyZ, + isControlPressed: Platform.isWindows || Platform.isLinux, + isMetaPressed: Platform.isMacOS, + ); + + expect(find.byType(MentionDateBlock), findsOneWidget); + expect(find.text('@$formattedDate'), findsOneWidget); + expect(find.byFlowySvg(FlowySvgs.reminder_clock_s), findsOneWidget); + expect(getIt().state.reminders.map((e) => e.id).length, 1); + + // redo + await tester.simulateKeyEvent( + LogicalKeyboardKey.keyZ, + isControlPressed: Platform.isWindows || Platform.isLinux, + isMetaPressed: Platform.isMacOS, + isShiftPressed: true, + ); + + expect(find.byType(MentionDateBlock), findsNothing); + expect(find.text('@$formattedDate'), findsNothing); + expect(find.byFlowySvg(FlowySvgs.reminder_clock_s), findsNothing); + expect(getIt().state.reminders.isEmpty, isTrue); + }); }); } diff --git a/frontend/appflowy_flutter/lib/core/helpers/url_launcher.dart b/frontend/appflowy_flutter/lib/core/helpers/url_launcher.dart index 94d2074c6b..2b0bc7b345 100644 --- a/frontend/appflowy_flutter/lib/core/helpers/url_launcher.dart +++ b/frontend/appflowy_flutter/lib/core/helpers/url_launcher.dart @@ -1,16 +1,24 @@ -import 'package:flutter/services.dart'; -import 'package:flutter/widgets.dart'; +import 'dart:io'; import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/shared/patterns/common_patterns.dart'; import 'package:appflowy/workspace/presentation/home/toast.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy_backend/log.dart'; import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:open_filex/open_filex.dart'; import 'package:string_validator/string_validator.dart'; import 'package:url_launcher/url_launcher.dart' as launcher; typedef OnFailureCallback = void Function(Uri uri); -Future afLaunchUrl( +/// Launch the uri +/// +/// If the uri is a local file path, it will be opened with the OpenFilex. +/// Otherwise, it will be launched with the url_launcher. +Future afLaunchUri( Uri uri, { BuildContext? context, OnFailureCallback? onFailure, @@ -18,6 +26,18 @@ Future afLaunchUrl( String? webOnlyWindowName, bool addingHttpSchemeWhenFailed = false, }) async { + final url = uri.toString(); + final decodedUrl = Uri.decodeComponent(url); + + // check if the uri is the local file path + if (localPathRegex.hasMatch(decodedUrl)) { + return _afLaunchLocalUri( + uri, + context: context, + onFailure: onFailure, + ); + } + // try to launch the uri directly bool result; try { @@ -32,7 +52,7 @@ Future afLaunchUrl( } // if the uri is not a valid url, try to launch it with http scheme - final url = uri.toString(); + if (addingHttpSchemeWhenFailed && !result && !isURL(url, {'require_protocol': true})) { @@ -54,9 +74,14 @@ Future afLaunchUrl( return result; } +/// Launch the url string +/// +/// See [afLaunchUri] for more details. Future afLaunchUrlString( String url, { bool addingHttpSchemeWhenFailed = false, + BuildContext? context, + OnFailureCallback? onFailure, }) async { final Uri uri; try { @@ -67,12 +92,56 @@ Future afLaunchUrlString( } // try to launch the uri directly - return afLaunchUrl( + return afLaunchUri( uri, addingHttpSchemeWhenFailed: addingHttpSchemeWhenFailed, + context: context, + onFailure: onFailure, ); } +/// Launch the local uri +/// +/// See [afLaunchUri] for more details. +Future _afLaunchLocalUri( + Uri uri, { + BuildContext? context, + OnFailureCallback? onFailure, +}) async { + final decodedUrl = Uri.decodeComponent(uri.toString()); + // open the file with the OpenfileX + var result = await OpenFilex.open(decodedUrl); + if (result.type != ResultType.done) { + // For the file cant be opened, fallback to open the folder + final parentFolder = Directory(decodedUrl).parent.path; + result = await OpenFilex.open(parentFolder); + } + // show the toast if the file is not found + final message = switch (result.type) { + ResultType.done => LocaleKeys.openFileMessage_success.tr(), + ResultType.fileNotFound => LocaleKeys.openFileMessage_fileNotFound.tr(), + ResultType.noAppToOpen => LocaleKeys.openFileMessage_noAppToOpenFile.tr(), + ResultType.permissionDenied => + LocaleKeys.openFileMessage_permissionDenied.tr(), + ResultType.error => LocaleKeys.failedToOpenUrl.tr(), + }; + if (context != null && context.mounted) { + showToastNotification( + context, + message: message, + type: result.type == ResultType.done + ? ToastificationType.success + : ToastificationType.error, + ); + } + final openFileSuccess = result.type == ResultType.done; + if (!openFileSuccess && onFailure != null) { + onFailure(uri); + Log.error('Failed to open file: $result.message'); + } + return openFileSuccess; +} + void _errorHandler( Uri uri, { BuildContext? context, diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/base/view_page/more_bottom_sheet.dart b/frontend/appflowy_flutter/lib/mobile/presentation/base/view_page/more_bottom_sheet.dart index eba8b09025..25541ed7d4 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/base/view_page/more_bottom_sheet.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/base/view_page/more_bottom_sheet.dart @@ -189,7 +189,7 @@ class MobileViewPageMoreBottomSheet extends StatelessWidget { final url = context.read().state.url; if (url.isNotEmpty) { unawaited( - afLaunchUrl( + afLaunchUri( Uri.parse(url), mode: LaunchMode.externalApplication, ), diff --git a/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_bloc.dart b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_bloc.dart index 5c0cdabe67..09d7ed2417 100644 --- a/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/ai_chat/application/chat_bloc.dart @@ -59,6 +59,16 @@ class ChatBloc extends Bloc { AnswerStream? answerStream; int numSendMessage = 0; + /// a counter used to determine whether the initial loading state should be + /// set to finish. It should hit two before we emit: one for the local fetch + /// and another for the server fetch. + /// + /// This is to work around a bug where if an ai chat that is not yet on the + /// user local storage is opened but has messages in the server, it will + /// remain stuck on the welcome screen until the user switches to another page + /// then come back. + int initialFetchCounter = 0; + @override Future close() async { await answerStream?.dispose(); @@ -76,7 +86,11 @@ class ChatBloc extends Bloc { await chatController.insert(message, index: 0); } - if (state.loadingState.isLoading) { + if (initialFetchCounter < 2) { + initialFetchCounter++; + } + + if (state.loadingState.isLoading && initialFetchCounter >= 2) { emit( state.copyWith(loadingState: const ChatLoadingState.finish()), ); diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_media_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_media_cell.dart index bcf136bcf1..f66d106ed0 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_media_cell.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_media_cell.dart @@ -1,24 +1,23 @@ import 'package:appflowy/core/helpers/url_launcher.dart'; -import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet_media_upload.dart'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/image/common.dart'; -import 'package:appflowy/workspace/presentation/widgets/image_viewer/image_provider.dart'; -import 'package:appflowy/workspace/presentation/widgets/image_viewer/interactive_image_viewer.dart'; -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/presentation/bottom_sheet/bottom_sheet.dart'; +import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet_media_upload.dart'; import 'package:appflowy/plugins/database/application/cell/bloc/media_cell_bloc.dart'; import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/media.dart'; import 'package:appflowy/plugins/database/widgets/cell_editor/media_cell_editor.dart'; import 'package:appflowy/plugins/database/widgets/cell_editor/mobile_media_cell_editor.dart'; import 'package:appflowy/plugins/database/widgets/media_file_type_ext.dart'; import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/common.dart'; import 'package:appflowy/shared/af_image.dart'; +import 'package:appflowy/workspace/presentation/widgets/image_viewer/image_provider.dart'; +import 'package:appflowy/workspace/presentation/widgets/image_viewer/interactive_image_viewer.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/media_entities.pb.dart'; import 'package:easy_localization/easy_localization.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:universal_platform/universal_platform.dart'; @@ -159,7 +158,7 @@ class GridMediaCellSkin extends IEditableMediaCellSkin { List files, ) { if (file.fileType != MediaFileTypePB.Image) { - afLaunchUrlString(file.url); + afLaunchUrlString(file.url, context: context); return; } diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_date_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_date_cell.dart index 9d0c0f0324..f10f9a9279 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_date_cell.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_date_cell.dart @@ -31,6 +31,7 @@ class DesktopRowDetailDateCellSkin extends IEditableDateCellSkin { direction: PopoverDirection.bottomWithLeftAligned, constraints: BoxConstraints.loose(const Size(260, 620)), margin: EdgeInsets.zero, + asBarrier: true, child: Container( alignment: AlignmentDirectional.centerStart, padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_media_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_media_cell.dart index 772149fa59..34d8a18ad8 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_media_cell.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_media_cell.dart @@ -27,7 +27,6 @@ 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:open_filex/open_filex.dart'; import 'package:reorderables/reorderables.dart'; const _dropFileKey = 'files_media'; @@ -253,6 +252,7 @@ class _AddFileButtonState extends State<_AddFileButton> { direction: widget.direction, constraints: const BoxConstraints(maxWidth: _menuWidth), margin: EdgeInsets.zero, + asBarrier: true, onClose: () => context.read().remove(_dropFileKey), popupBuilder: (_) { @@ -459,6 +459,7 @@ class _FilePreviewRenderState extends State<_FilePreviewRender> { offset: const Offset(0, 5), triggerActions: PopoverTriggerFlags.none, onClose: () => setState(() => isSelected = false), + asBarrier: true, popupBuilder: (popoverContext) => MultiBlocProvider( providers: [ BlocProvider.value(value: context.read()), @@ -479,7 +480,7 @@ class _FilePreviewRenderState extends State<_FilePreviewRender> { ? null : () { if (file.uploadType == FileUploadTypePB.LocalFile) { - OpenFilex.open(file.url); + afLaunchUrlString(file.url); return; } diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_relation_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_relation_cell.dart index 996d04267c..b3096d49e4 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_relation_cell.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_relation_cell.dart @@ -24,6 +24,7 @@ class DesktopRowDetailRelationCellSkin extends IEditableRelationCellSkin { direction: PopoverDirection.bottomWithLeftAligned, constraints: const BoxConstraints(maxWidth: 400, maxHeight: 400), margin: EdgeInsets.zero, + asBarrier: true, onClose: () => cellContainerNotifier.isFocus = false, popupBuilder: (context) { return BlocProvider.value( diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_select_option_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_select_option_cell.dart index 6eab6438dc..3d41a824ce 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_select_option_cell.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_select_option_cell.dart @@ -25,6 +25,7 @@ class DesktopRowDetailSelectOptionCellSkin controller: popoverController, constraints: const BoxConstraints.tightFor(width: 300), margin: EdgeInsets.zero, + asBarrier: true, triggerActions: PopoverTriggerFlags.none, direction: PopoverDirection.bottomWithLeftAligned, onClose: () => cellContainerNotifier.isFocus = false, diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/field/field_editor.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/field/field_editor.dart index d91f207797..4a9fedcd94 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/field/field_editor.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/field/field_editor.dart @@ -199,22 +199,30 @@ class FieldActionCell extends StatelessWidget { (action == FieldAction.duplicate || action == FieldAction.delete)) { enable = false; } - - return FlowyButton( + return FlowyIconTextButton( resetHoverOnRebuild: false, disable: !enable, - text: FlowyText( - action.title(fieldInfo), - lineHeight: 1.0, - color: enable ? null : Theme.of(context).disabledColor, - ), onHover: (_) => popoverMutex?.close(), onTap: () => action.run(context, viewId, fieldInfo), - leftIcon: action.leading( - fieldInfo, - enable ? null : Theme.of(context).disabledColor, + // show the error color when delete is hovered + textBuilder: (onHover) => FlowyText( + action.title(fieldInfo), + lineHeight: 1.0, + color: enable + ? action == FieldAction.delete && onHover + ? Theme.of(context).colorScheme.error + : null + : Theme.of(context).disabledColor, ), - rightIcon: action.trailing(context, fieldInfo), + leftIconBuilder: (onHover) => action.leading( + fieldInfo, + enable + ? action == FieldAction.delete && onHover + ? Theme.of(context).colorScheme.error + : null + : Theme.of(context).disabledColor, + ), + rightIconBuilder: (_) => action.trailing(context, fieldInfo), ); } } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/page_reference_commands.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/page_reference_commands.dart index e42bb3a2bf..079a062837 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/page_reference_commands.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/base/page_reference_commands.dart @@ -117,6 +117,22 @@ Future inlinePageReferenceCommandHandler( initialResults: initialResults, style: style, startCharAmount: previousChar != null ? 2 : 1, + cancelBySpaceHandler: () { + if (character == _plusChar) { + final currentSelection = editorState.selection; + if (currentSelection == null) { + return false; + } + // check if the space is after the character + if (currentSelection.isCollapsed && + currentSelection.start.offset == + selection.start.offset + character.length) { + _cancelInlinePageReferenceMenu(editorState); + return true; + } + } + return false; + }, ); selectionMenuService?.show(); @@ -124,3 +140,17 @@ Future inlinePageReferenceCommandHandler( return true; } + +void _cancelInlinePageReferenceMenu(EditorState editorState) { + selectionMenuService?.dismiss(); + selectionMenuService = null; + + // re-focus the selection + final selection = editorState.selection; + if (selection != null) { + editorState.updateSelectionWithReason( + selection, + reason: SelectionUpdateReason.uiEvent, + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_block_component.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_block_component.dart index e4dd1d4e4a..f1ffbe9d11 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_block_component.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_block_component.dart @@ -10,7 +10,6 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_p import 'package:appflowy/plugins/document/presentation/editor_plugins/file/file_util.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/workspace/presentation/home/toast.dart'; -import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:cross_file/cross_file.dart'; import 'package:desktop_drop/desktop_drop.dart'; @@ -19,7 +18,6 @@ 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:go_router/go_router.dart'; -import 'package:open_filex/open_filex.dart'; import 'package:provider/provider.dart'; import 'package:string_validator/string_validator.dart'; import 'package:universal_platform/universal_platform.dart'; @@ -323,22 +321,7 @@ class FileBlockComponentState extends State FileUrlType urlType, String url, ) async { - if ([FileUrlType.cloud, FileUrlType.network].contains(urlType)) { - await afLaunchUrlString(url); - } else { - final result = await OpenFilex.open(url); - if (result.type == ResultType.done) { - return; - } - - if (context.mounted) { - showToastNotification( - context, - message: LocaleKeys.document_plugins_file_failedToOpenMsg.tr(), - type: ToastificationType.error, - ); - } - } + await afLaunchUrlString(url, context: context); } void _openMenu() { diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_util.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_util.dart index f09aa1ca5e..3ab93b4c95 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_util.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_util.dart @@ -1,8 +1,6 @@ import 'dart:convert'; import 'dart:io'; -import 'package:flutter/material.dart'; - import 'package:appflowy/core/helpers/url_launcher.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/application/document_service.dart'; @@ -22,6 +20,7 @@ import 'package:cross_file/cross_file.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/file_picker/file_picker_impl.dart'; import 'package:flowy_infra/uuid.dart'; +import 'package:flutter/material.dart'; import 'package:http/http.dart' as http; import 'package:path/path.dart' as p; import 'package:universal_platform/universal_platform.dart'; @@ -102,7 +101,7 @@ Future downloadMediaFile( FileUploadTypePB.LocalFile, ].contains(file.uploadType)) { /// When the file is a network file or a local file, we can directly open the file. - await afLaunchUrl(Uri.parse(file.url)); + await afLaunchUrlString(file.url); } else { if (userProfile == null) { return showToastNotification( diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/child_page_transaction_handler.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/child_page_transaction_handler.dart index 491bf10fa5..ef32ad1098 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/child_page_transaction_handler.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/child_page_transaction_handler.dart @@ -16,11 +16,12 @@ import '../transaction_handler/mention_transaction_handler.dart'; const _pasteIdentifier = 'child_page_transaction'; class ChildPageTransactionHandler extends MentionTransactionHandler { - ChildPageTransactionHandler() : super(subType: MentionType.childPage.name); + ChildPageTransactionHandler(); @override Future onTransaction( BuildContext context, + String viewId, EditorState editorState, List added, List removed, { diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/date_transaction_handler.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/date_transaction_handler.dart new file mode 100644 index 0000000000..972ed229dd --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/date_transaction_handler.dart @@ -0,0 +1,263 @@ +import 'package:appflowy/shared/clipboard_state.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/user/application/reminder/reminder_bloc.dart'; +import 'package:appflowy/user/application/reminder/reminder_extension.dart'; +import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/reminder_selector.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:collection/collection.dart'; +import 'package:fixnum/fixnum.dart'; +import 'package:flutter/material.dart'; +import 'package:nanoid/nanoid.dart'; +import 'package:provider/provider.dart'; + +import '../plugins.dart'; +import '../transaction_handler/mention_transaction_handler.dart'; + +const _pasteIdentifier = 'date_transaction'; + +class DateTransactionHandler extends MentionTransactionHandler { + DateTransactionHandler(); + + @override + Future onTransaction( + BuildContext context, + String viewId, + EditorState editorState, + List added, + List removed, { + bool isCut = false, + bool isUndoRedo = false, + bool isPaste = false, + bool isDraggingNode = false, + bool isTurnInto = false, + String? parentViewId, + }) async { + if (isDraggingNode || isTurnInto) { + return; + } + + // Remove the mentions that were both added and removed in the same transaction. + // These were just moved around. + final moved = []; + for (final mention in added) { + if (removed.any((r) => r.$2 == mention.$2)) { + moved.add(mention); + } + } + + for (final mention in removed) { + if (!context.mounted || moved.any((m) => m.$2 == mention.$2)) { + return; + } + + if (mention.$2[MentionBlockKeys.type] != MentionType.date.name) { + continue; + } + + _handleDeletion(context, mention); + } + + if (isPaste || isUndoRedo) { + if (context.mounted) { + context.read().startHandlingPaste(_pasteIdentifier); + } + + for (final mention in added) { + if (!context.mounted || moved.any((m) => m.$2 == mention.$2)) { + return; + } + + if (mention.$2[MentionBlockKeys.type] != MentionType.date.name) { + continue; + } + + _handleAddition( + context, + viewId, + editorState, + mention, + isPaste, + isCut, + ); + } + + if (context.mounted) { + context.read().endHandlingPaste(_pasteIdentifier); + } + } + } + + void _handleDeletion( + BuildContext context, + MentionBlockData data, + ) { + final reminderId = data.$2[MentionBlockKeys.reminderId]; + + if (reminderId case String _ when reminderId.isNotEmpty) { + getIt().add(ReminderEvent.remove(reminderId: reminderId)); + } + } + + void _handleAddition( + BuildContext context, + String viewId, + EditorState editorState, + MentionBlockData data, + bool isPaste, + bool isCut, + ) { + final dateData = _MentionDateBlockData.fromData(data.$2); + if (dateData.dateString.isEmpty) { + Log.error("mention date block doesn't have a valid date string"); + return; + } + + if (isPaste && !isCut) { + _handlePasteFromCopy( + context, + viewId, + editorState, + data.$1, + data.$3, + dateData, + ); + } else { + _handlePasteFromCut(viewId, data.$1, dateData); + } + } + + void _handlePasteFromCut( + String viewId, + Node node, + _MentionDateBlockData data, + ) { + final dateTime = DateTime.tryParse(data.dateString); + + if (data.reminderId == null || dateTime == null) { + return; + } + + getIt().add( + ReminderEvent.addById( + reminderId: data.reminderId!, + objectId: viewId, + scheduledAt: Int64( + data.reminderOption + .getNotificationDateTime(dateTime) + .millisecondsSinceEpoch ~/ + 1000, + ), + meta: { + ReminderMetaKeys.includeTime: data.includeTime.toString(), + ReminderMetaKeys.blockId: node.id, + }, + ), + ); + } + + void _handlePasteFromCopy( + BuildContext context, + String viewId, + EditorState editorState, + Node node, + int index, + _MentionDateBlockData data, + ) async { + final dateTime = DateTime.tryParse(data.dateString); + + if (node.delta == null) { + return; + } + + if (data.reminderId == null || dateTime == null) { + return; + } + + final reminderId = nanoid(); + getIt().add( + ReminderEvent.addById( + reminderId: reminderId, + objectId: viewId, + scheduledAt: Int64( + data.reminderOption + .getNotificationDateTime(dateTime) + .millisecondsSinceEpoch ~/ + 1000, + ), + meta: { + ReminderMetaKeys.includeTime: data.includeTime.toString(), + ReminderMetaKeys.blockId: node.id, + }, + ), + ); + + final newMentionAttributes = { + MentionBlockKeys.mention: { + MentionBlockKeys.type: MentionType.date.name, + MentionBlockKeys.date: dateTime.toIso8601String(), + MentionBlockKeys.reminderId: reminderId, + MentionBlockKeys.includeTime: data.includeTime, + MentionBlockKeys.reminderOption: data.reminderOption.name, + }, + }; + + // The index is the index of the delta, to get the index of the mention character + // in all the text, we need to calculate it based on the deltas before the current delta. + int mentionIndex = 0; + for (final (i, delta) in node.delta!.indexed) { + if (i >= index) { + break; + } + + mentionIndex += delta.length; + } + + // Required to prevent editing the same spot at the same time + await Future.delayed(const Duration(milliseconds: 100)); + + final transaction = editorState.transaction + ..formatText( + node, + mentionIndex, + MentionBlockKeys.mentionChar.length, + newMentionAttributes, + ); + + await editorState.apply( + transaction, + options: const ApplyOptions(recordUndo: false), + ); + } +} + +/// A helper class to parse and store the mention date block data +class _MentionDateBlockData { + _MentionDateBlockData.fromData(Map data) { + dateString = switch (data[MentionBlockKeys.date]) { + final String string when DateTime.tryParse(string) != null => string, + _ => "", + }; + includeTime = switch (data[MentionBlockKeys.includeTime]) { + final bool flag => flag, + _ => false, + }; + reminderOption = switch (data[MentionBlockKeys.reminderOption]) { + final String name => + ReminderOption.values.firstWhereOrNull((o) => o.name == name) ?? + ReminderOption.none, + _ => ReminderOption.none, + }; + reminderId = switch (data[MentionBlockKeys.reminderId]) { + final String id + when id.isNotEmpty && reminderOption != ReminderOption.none => + id, + _ => null, + }; + } + + late final String dateString; + late final bool includeTime; + late final String? reminderId; + late final ReminderOption reminderOption; +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_block.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_block.dart index cf2f0b8e11..a5bc340f71 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_block.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/mention/mention_block.dart @@ -8,7 +8,6 @@ import 'package:provider/provider.dart'; enum MentionType { page, - reminder, date, childPage; diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/parsers/document_markdown_parsers.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/parsers/document_markdown_parsers.dart index 0b694f396e..3f2895d57e 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/parsers/document_markdown_parsers.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/parsers/document_markdown_parsers.dart @@ -1,4 +1,7 @@ export 'callout_node_parser.dart'; export 'custom_image_node_parser.dart'; +export 'file_block_node_parser.dart'; +export 'link_preview_node_parser.dart'; export 'math_equation_node_parser.dart'; +export 'simple_table_node_parser.dart'; export 'toggle_list_node_parser.dart'; diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/parsers/file_block_node_parser.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/parsers/file_block_node_parser.dart new file mode 100644 index 0000000000..e57ededcec --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/parsers/file_block_node_parser.dart @@ -0,0 +1,19 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; + +class FileBlockNodeParser extends NodeParser { + const FileBlockNodeParser(); + + @override + String get id => FileBlockKeys.type; + + @override + String transform(Node node, DocumentMarkdownEncoder? encoder) { + final name = node.attributes[FileBlockKeys.name]; + final url = node.attributes[FileBlockKeys.url]; + if (name == null || url == null) { + return ''; + } + return '[$name]($url)\n'; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/parsers/link_preview_node_parser.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/parsers/link_preview_node_parser.dart new file mode 100644 index 0000000000..c7ce69d221 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/parsers/link_preview_node_parser.dart @@ -0,0 +1,18 @@ +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; + +class LinkPreviewNodeParser extends NodeParser { + const LinkPreviewNodeParser(); + + @override + String get id => LinkPreviewBlockKeys.type; + + @override + String transform(Node node, DocumentMarkdownEncoder? encoder) { + final href = node.attributes[LinkPreviewBlockKeys.url]; + if (href == null) { + return ''; + } + return '[$href]($href)\n'; + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/parsers/markdown_parsers.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/parsers/markdown_parsers.dart index 568baeaac0..4ad7734643 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/parsers/markdown_parsers.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/parsers/markdown_parsers.dart @@ -1,6 +1,2 @@ -export 'callout_node_parser.dart'; -export 'custom_image_node_parser.dart'; export 'markdown_code_parser.dart'; -export 'math_equation_node_parser.dart'; -export 'toggle_list_node_parser.dart'; -export 'simple_table_parser.dart'; +export 'markdown_simple_table_parser.dart'; diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/parsers/simple_table_parser.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/parsers/simple_table_node_parser.dart similarity index 100% rename from frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/parsers/simple_table_parser.dart rename to frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/parsers/simple_table_node_parser.dart diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/plugins.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/plugins.dart index da28f7f720..4d1fd95558 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/plugins.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/plugins.dart @@ -57,6 +57,7 @@ export 'openai/widgets/ai_writer_block_component.dart'; export 'openai/widgets/ask_ai_block_component.dart'; export 'openai/widgets/ask_ai_toolbar_item.dart'; export 'outline/outline_block_component.dart'; +export 'parsers/document_markdown_parsers.dart'; export 'parsers/markdown_parsers.dart'; export 'parsers/markdown_simple_table_parser.dart'; export 'quote/quote_block_shortcuts.dart'; diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/sub_page/sub_page_transaction_handler.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/sub_page/sub_page_transaction_handler.dart index b7d784d534..c5c7398bdb 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/sub_page/sub_page_transaction_handler.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/sub_page/sub_page_transaction_handler.dart @@ -21,6 +21,7 @@ class SubPageTransactionHandler extends BlockTransactionHandler { @override Future onTransaction( BuildContext context, + String viewId, EditorState editorState, List added, List removed, { diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/transaction_handler/editor_transaction_handler.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/transaction_handler/editor_transaction_handler.dart index 334f649341..243532e8ce 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/transaction_handler/editor_transaction_handler.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/transaction_handler/editor_transaction_handler.dart @@ -25,6 +25,7 @@ abstract class EditorTransactionHandler { Future onTransaction( BuildContext context, + String viewId, EditorState editorState, List added, List removed, { diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/transaction_handler/editor_transaction_service.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/transaction_handler/editor_transaction_service.dart index 29cc7435ff..b56066ae8b 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/transaction_handler/editor_transaction_service.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/transaction_handler/editor_transaction_service.dart @@ -2,20 +2,23 @@ import 'dart:async'; import 'package:appflowy/plugins/document/presentation/editor_notification.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/child_page_transaction_handler.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/date_transaction_handler.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/sub_page/sub_page_transaction_handler.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/transaction_handler/editor_transaction_handler.dart'; -import 'package:appflowy/plugins/document/presentation/editor_plugins/transaction_handler/mention_transaction_handler.dart'; import 'package:appflowy/shared/clipboard_state.dart'; import 'package:appflowy/shared/feature_flags.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'mention_transaction_handler.dart'; + final _transactionHandlers = [ if (FeatureFlag.inlineSubPageMention.isOn) ...[ SubPageTransactionHandler(), ChildPageTransactionHandler(), ], + DateTransactionHandler(), ]; /// Handles delegating transactions to appropriate handlers. @@ -148,10 +151,16 @@ class _EditorTransactionServiceState extends State { handler.type: handler.livesInDelta ? [] : [], }; + // based on the type of the transaction handler + final uniqueTransactionHandlers = {}; + for (final handler in _transactionHandlers) { + uniqueTransactionHandlers.putIfAbsent(handler.type, () => handler); + } + for (final op in transaction.operations) { if (op is InsertOperation) { for (final n in op.nodes) { - for (final handler in _transactionHandlers) { + for (final handler in uniqueTransactionHandlers.values) { if (handler.livesInDelta) { added[handler.type]! .addAll(extractMentionsForType(n, handler.type)); @@ -163,7 +172,7 @@ class _EditorTransactionServiceState extends State { } } else if (op is DeleteOperation) { for (final n in op.nodes) { - for (final handler in _transactionHandlers) { + for (final handler in uniqueTransactionHandlers.values) { if (handler.livesInDelta) { removed[handler.type]!.addAll( extractMentionsForType(n, handler.type, false), @@ -191,8 +200,9 @@ class _EditorTransactionServiceState extends State { final (add, del) = diffDeltas(deltaBefore, deltaAfter); + bool fetchedMentions = false; for (final handler in _transactionHandlers) { - if (!handler.livesInDelta) { + if (!handler.livesInDelta || fetchedMentions) { continue; } @@ -212,6 +222,8 @@ class _EditorTransactionServiceState extends State { removed[handler.type]!.addAll(mentionBlockDatas); } + + fetchedMentions = true; } } } @@ -226,6 +238,7 @@ class _EditorTransactionServiceState extends State { handler.onTransaction( context, + widget.viewId, widget.editorState, additions, removals, diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/transaction_handler/mention_transaction_handler.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/transaction_handler/mention_transaction_handler.dart index 632561589e..d08ce05510 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/transaction_handler/mention_transaction_handler.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/transaction_handler/mention_transaction_handler.dart @@ -12,12 +12,6 @@ typedef MentionBlockData = (Node, Map, int); abstract class MentionTransactionHandler extends EditorTransactionHandler { - const MentionTransactionHandler({ - required this.subType, - }) + const MentionTransactionHandler() : super(type: MentionBlockKeys.mention, livesInDelta: true); - - final String subType; - - MentionType get mentionType => MentionType.fromString(subType); } diff --git a/frontend/appflowy_flutter/lib/plugins/inline_actions/inline_actions_menu.dart b/frontend/appflowy_flutter/lib/plugins/inline_actions/inline_actions_menu.dart index e31d7c2ef0..dadc4ebf6f 100644 --- a/frontend/appflowy_flutter/lib/plugins/inline_actions/inline_actions_menu.dart +++ b/frontend/appflowy_flutter/lib/plugins/inline_actions/inline_actions_menu.dart @@ -19,12 +19,14 @@ class InlineActionsMenu extends InlineActionsMenuService { required this.initialResults, required this.style, this.startCharAmount = 1, + this.cancelBySpaceHandler, }); final BuildContext context; final EditorState editorState; final InlineActionsService service; final List initialResults; + final bool Function()? cancelBySpaceHandler; @override final InlineActionsMenuStyle style; @@ -137,6 +139,7 @@ class InlineActionsMenu extends InlineActionsMenuService { onSelectionUpdate: _onSelectionUpdate, style: style, startCharAmount: startCharAmount, + cancelBySpaceHandler: cancelBySpaceHandler, ), ), ), diff --git a/frontend/appflowy_flutter/lib/plugins/inline_actions/widgets/inline_actions_handler.dart b/frontend/appflowy_flutter/lib/plugins/inline_actions/widgets/inline_actions_handler.dart index edf34a3dc1..be9a6c2f5f 100644 --- a/frontend/appflowy_flutter/lib/plugins/inline_actions/widgets/inline_actions_handler.dart +++ b/frontend/appflowy_flutter/lib/plugins/inline_actions/widgets/inline_actions_handler.dart @@ -62,6 +62,7 @@ class InlineActionsHandler extends StatefulWidget { required this.onSelectionUpdate, required this.style, this.startCharAmount = 1, + this.cancelBySpaceHandler, }); final InlineActionsService service; @@ -72,6 +73,7 @@ class InlineActionsHandler extends StatefulWidget { final VoidCallback onSelectionUpdate; final InlineActionsMenuStyle style; final int startCharAmount; + final bool Function()? cancelBySpaceHandler; @override State createState() => _InlineActionsHandlerState(); @@ -288,12 +290,17 @@ class _InlineActionsHandlerState extends State { /// that the selection change occurred from the handler. widget.onSelectionUpdate(); + if (event.logicalKey == LogicalKeyboardKey.space) { + final cancelBySpaceHandler = widget.cancelBySpaceHandler; + if (cancelBySpaceHandler != null && cancelBySpaceHandler()) { + return KeyEventResult.handled; + } + } + // Interpolation to avoid having a getter for private variable _insertCharacter(event.character!); return KeyEventResult.handled; - } - - if (moveKeys.contains(event.logicalKey)) { + } else if (moveKeys.contains(event.logicalKey)) { _moveSelection(event.logicalKey); return KeyEventResult.handled; } diff --git a/frontend/appflowy_flutter/lib/plugins/shared/share/share_bloc.dart b/frontend/appflowy_flutter/lib/plugins/shared/share/share_bloc.dart index c533f67ed8..c42bbda5a0 100644 --- a/frontend/appflowy_flutter/lib/plugins/shared/share/share_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/shared/share/share_bloc.dart @@ -197,8 +197,23 @@ class ShareBloc extends Bloc { (p) => false, ); - Log.info( - 'get publish info: $publishInfo for view: ${view.name}(${view.id})', + // skip the "Record not found" error, it's because the view is not published yet + publishInfo.fold( + (s) { + Log.info( + 'get publish info success: $publishInfo for view: ${view.name}(${view.id})', + ); + }, + (f) { + if (![ + ErrorCode.RecordNotFound, + ErrorCode.LocalVersionNotSupport, + ].contains(f.code)) { + Log.info( + 'get publish info failed: $f for view: ${view.name}(${view.id})', + ); + } + }, ); String workspaceId = state.workspaceId; diff --git a/frontend/appflowy_flutter/lib/shared/markdown_to_document.dart b/frontend/appflowy_flutter/lib/shared/markdown_to_document.dart index 328c66eca9..b3e2034602 100644 --- a/frontend/appflowy_flutter/lib/shared/markdown_to_document.dart +++ b/frontend/appflowy_flutter/lib/shared/markdown_to_document.dart @@ -20,6 +20,8 @@ String customDocumentToMarkdown(Document document) { const ToggleListNodeParser(), const CustomImageNodeParser(), const SimpleTableNodeParser(), + const LinkPreviewNodeParser(), + const FileBlockNodeParser(), ], ); } diff --git a/frontend/appflowy_flutter/lib/shared/patterns/common_patterns.dart b/frontend/appflowy_flutter/lib/shared/patterns/common_patterns.dart index 20444f7205..ebd310a747 100644 --- a/frontend/appflowy_flutter/lib/shared/patterns/common_patterns.dart +++ b/frontend/appflowy_flutter/lib/shared/patterns/common_patterns.dart @@ -42,3 +42,6 @@ final appflowySharePageLinkRegex = RegExp(appflowySharePageLinkPattern); const _numberedListPattern = r'^(\d+)\.'; final numberedListRegex = RegExp(_numberedListPattern); + +const _localPathPattern = r'^(file:\/\/|\/|\\|[a-zA-Z]:[/\\]|\.{1,2}[/\\])'; +final localPathRegex = RegExp(_localPathPattern, caseSensitive: false); diff --git a/frontend/appflowy_flutter/lib/user/application/auth/af_cloud_auth_service.dart b/frontend/appflowy_flutter/lib/user/application/auth/af_cloud_auth_service.dart index ae7fe37982..149bddc951 100644 --- a/frontend/appflowy_flutter/lib/user/application/auth/af_cloud_auth_service.dart +++ b/frontend/appflowy_flutter/lib/user/application/auth/af_cloud_auth_service.dart @@ -56,7 +56,7 @@ class AppFlowyCloudAuthService implements AuthService { (data) async { // Open the webview with oauth url final uri = Uri.parse(data.oauthUrl); - final isSuccess = await afLaunchUrl( + final isSuccess = await afLaunchUri( uri, mode: LaunchMode.externalApplication, webOnlyWindowName: '_self', diff --git a/frontend/appflowy_flutter/lib/util/share_log_files.dart b/frontend/appflowy_flutter/lib/util/share_log_files.dart index 21955e6c05..d7e7b6ce87 100644 --- a/frontend/appflowy_flutter/lib/util/share_log_files.dart +++ b/frontend/appflowy_flutter/lib/util/share_log_files.dart @@ -1,11 +1,11 @@ import 'dart:io'; +import 'package:appflowy/core/helpers/url_launcher.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:archive/archive_io.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; -import 'package:open_filex/open_filex.dart'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; import 'package:share_plus/share_plus.dart'; @@ -67,7 +67,7 @@ Future shareLogFiles(BuildContext? context) async { await zipFile.delete(); } else { // open the directory - await OpenFilex.open(zipFile.path); + await afLaunchUri(zipFile.uri); } } catch (e) { if (context != null && context.mounted) { diff --git a/frontend/appflowy_flutter/lib/workspace/application/home/home_setting_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/home/home_setting_bloc.dart index 05418f3315..657f2592d7 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/home/home_setting_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/home/home_setting_bloc.dart @@ -86,7 +86,7 @@ class HomeSettingBloc extends Bloc { }, editPanelResized: (_EditPanelResized e) { final newPosition = - (e.offset + state.resizeStart).clamp(-50, 200).toDouble(); + (state.resizeStart + e.offset).clamp(0, 200).toDouble(); if (state.resizeOffset != newPosition) { emit(state.copyWith(resizeOffset: newPosition)); } diff --git a/frontend/appflowy_flutter/lib/workspace/application/settings/ai/plugin_state_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/settings/ai/plugin_state_bloc.dart index 41070e2fe4..4f24309bde 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/settings/ai/plugin_state_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/settings/ai/plugin_state_bloc.dart @@ -8,6 +8,7 @@ import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart'; import 'package:bloc/bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:url_launcher/url_launcher.dart' show launchUrl; + part 'plugin_state_bloc.freezed.dart'; class PluginStateBloc extends Bloc { @@ -91,7 +92,7 @@ class PluginStateBloc extends Bloc { final result = await AIEventGetModelStorageDirectory().send(); result.fold( (data) { - afLaunchUrl(Uri.file(data.filePath)); + afLaunchUri(Uri.file(data.filePath)); }, (err) => Log.error(err.toString()), ); diff --git a/frontend/appflowy_flutter/lib/workspace/application/sidebar/billing/sidebar_plan_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/sidebar/billing/sidebar_plan_bloc.dart index 7af2a547dd..1737494530 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/sidebar/billing/sidebar_plan_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/sidebar/billing/sidebar_plan_bloc.dart @@ -12,6 +12,7 @@ import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/workspace.pb.dart'; import 'package:bloc/bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; + part 'sidebar_plan_bloc.freezed.dart'; class SidebarPlanBloc extends Bloc { @@ -113,7 +114,8 @@ class SidebarPlanBloc extends Bloc { } else if (error.code == ErrorCode.SingleUploadLimitExceeded) { emit( state.copyWith( - tierIndicator: const SidebarToastTierIndicator.singleFileLimitHit(), + tierIndicator: + const SidebarToastTierIndicator.singleFileLimitHit(), ), ); } else { @@ -184,13 +186,10 @@ class SidebarPlanBloc extends Bloc { if (state.workspaceId != null) { final payload = UserWorkspaceIdPB(workspaceId: state.workspaceId!); UserEventGetWorkspaceUsage(payload).send().then((result) { - result.fold( + result.onSuccess( (usage) { add(SidebarPlanEvent.updateWorkspaceUsage(usage)); }, - (error) { - Log.error("Failed to get workspace usage, error: $error"); - }, ); }); } @@ -231,7 +230,8 @@ class SidebarPlanState with _$SidebarPlanState { @freezed class SidebarToastTierIndicator with _$SidebarToastTierIndicator { const factory SidebarToastTierIndicator.storageLimitHit() = _StorageLimitHit; - const factory SidebarToastTierIndicator.singleFileLimitHit() = _SingleFileLimitHit; + const factory SidebarToastTierIndicator.singleFileLimitHit() = + _SingleFileLimitHit; const factory SidebarToastTierIndicator.aiMaxiLimitHit() = _aiMaxLimitHit; const factory SidebarToastTierIndicator.loading() = _Loading; } diff --git a/frontend/appflowy_flutter/lib/workspace/application/sidebar/space/space_bloc.dart b/frontend/appflowy_flutter/lib/workspace/application/sidebar/space/space_bloc.dart index 46d4943ddf..e1a825d631 100644 --- a/frontend/appflowy_flutter/lib/workspace/application/sidebar/space/space_bloc.dart +++ b/frontend/appflowy_flutter/lib/workspace/application/sidebar/space/space_bloc.dart @@ -329,14 +329,15 @@ class SpaceBloc extends Bloc { final (spaces, _, _) = await _getSpaces(); final currentSpace = await _getLastOpenedSpace(spaces); + Log.info( + 'receive space update, current space: ${currentSpace?.name}(${currentSpace?.id})', + ); + for (var i = 0; i < spaces.length; i++) { Log.info( - 'did receive space update[$i]: ${spaces[i].name}(${spaces[i].id})', + 'receive space update[$i]: ${spaces[i].name}(${spaces[i].id})', ); } - Log.info( - 'did receive space update, current space: ${currentSpace?.name}(${currentSpace?.id})', - ); emit( state.copyWith( diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/home/home_layout.dart b/frontend/appflowy_flutter/lib/workspace/presentation/home/home_layout.dart index fb108b702e..98139f1db7 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/home/home_layout.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/home/home_layout.dart @@ -14,10 +14,11 @@ class HomeLayout { HomeLayout(BuildContext context) { final homeSetting = context.read().state; showEditPanel = homeSetting.panelContext != null; - menuWidth = Sizes.sideBarWidth; - menuWidth += homeSetting.resizeOffset; - menuWidth = max(menuWidth, HomeSizes.minimumSidebarWidth); + menuWidth = max( + HomeSizes.minimumSidebarWidth + homeSetting.resizeOffset, + HomeSizes.minimumSidebarWidth, + ); final screenWidthPx = context.widthPx; context diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_manage_data_view.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_manage_data_view.dart index 3499580bbe..7c096b4b2f 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_manage_data_view.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/pages/settings_manage_data_view.dart @@ -1,8 +1,5 @@ import 'dart:async'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; - import 'package:appflowy/core/helpers/url_launcher.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; @@ -28,6 +25,8 @@ 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:flowy_infra_ui/style_widget/hover.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:fluttertoast/fluttertoast.dart'; @@ -450,7 +449,7 @@ class _DataPathActions extends StatelessWidget { label: LocaleKeys.settings_manageDataPage_dataStorage_actions_open.tr(), icon: const FlowySvg(FlowySvgs.folder_m, size: Size.square(20)), - onPressed: () => afLaunchUrl(Uri.file(currentPath)), + onPressed: () => afLaunchUri(Uri.file(currentPath)), ), ], ); diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/theme_upload/theme_upload_learn_more_button.dart b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/theme_upload/theme_upload_learn_more_button.dart index d57d2d2a00..9e25dece0e 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/theme_upload/theme_upload_learn_more_button.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/settings/widgets/theme_upload/theme_upload_learn_more_button.dart @@ -1,14 +1,13 @@ -import 'package:flutter/material.dart'; - import 'package:appflowy/core/helpers/url_launcher.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/workspace/presentation/settings/widgets/theme_upload/theme_upload_view.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/widget/buttons/secondary_button.dart'; import 'package:flowy_infra_ui/widget/error_page.dart'; -import 'package:flowy_infra/theme_extension.dart'; +import 'package:flutter/material.dart'; class ThemeUploadLearnMoreButton extends StatelessWidget { const ThemeUploadLearnMoreButton({super.key}); @@ -32,7 +31,7 @@ class ThemeUploadLearnMoreButton extends StatelessWidget { ), onPressed: () async { final uri = Uri.parse(learnMoreURL); - await afLaunchUrl( + await afLaunchUri( uri, context: context, onFailure: (_) async { diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/image_viewer/interactive_image_toolbar.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/image_viewer/interactive_image_toolbar.dart index 223910ceac..c1d9823bd1 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/image_viewer/interactive_image_toolbar.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/image_viewer/interactive_image_toolbar.dart @@ -220,12 +220,12 @@ class InteractiveImageToolbar extends StatelessWidget { Future _locateOrDownloadImage(BuildContext context) async { if (currentImage.isLocal) { /// If the image type is local, we simply open the image - await afLaunchUrl(Uri.file(currentImage.url)); + await afLaunchUri(Uri.file(currentImage.url)); } else if (currentImage.isNotInternal) { // In case of eg. Unsplash images (images without extension type in URL), // we don't know their mimetype. In the future we can write a parser // using the Mime package and read the image to get the proper extension. - await afLaunchUrl(Uri.parse(currentImage.url)); + await afLaunchUri(Uri.parse(currentImage.url)); } else { if (userProfile == null) { return showSnapBar( diff --git a/frontend/appflowy_flutter/packages/flowy_infra/lib/size.dart b/frontend/appflowy_flutter/packages/flowy_infra/lib/size.dart index b9c6d565f8..f58dad95b5 100644 --- a/frontend/appflowy_flutter/packages/flowy_infra/lib/size.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra/lib/size.dart @@ -57,8 +57,6 @@ class Sizes { static double get hit => 40 * hitScale; static double get iconMed => 20; - - static double get sideBarWidth => 250 * hitScale; } class Corners { diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/button.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/button.dart index e1b9308e32..0e5b656a35 100644 --- a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/button.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/button.dart @@ -13,8 +13,8 @@ class FlowyIconTextButton extends StatelessWidget { final VoidCallback? onSecondaryTap; final void Function(bool)? onHover; final EdgeInsets? margin; - final Widget Function(bool onHover)? leftIconBuilder; - final Widget Function(bool onHover)? rightIconBuilder; + final Widget? Function(bool onHover)? leftIconBuilder; + final Widget? Function(bool onHover)? rightIconBuilder; final Color? hoverColor; final bool isSelected; final BorderRadius? radius; @@ -29,6 +29,7 @@ class FlowyIconTextButton extends StatelessWidget { final double iconPadding; final bool expand; final Color? borderColor; + final bool resetHoverOnRebuild; const FlowyIconTextButton({ super.key, @@ -53,6 +54,7 @@ class FlowyIconTextButton extends StatelessWidget { this.iconPadding = 6, this.expand = false, this.borderColor, + this.resetHoverOnRebuild = true, }); @override @@ -64,6 +66,7 @@ class FlowyIconTextButton extends StatelessWidget { onTap: disable ? null : onTap, onSecondaryTap: disable ? null : onSecondaryTap, child: FlowyHover( + resetHoverOnRebuild: resetHoverOnRebuild, cursor: disable ? SystemMouseCursors.forbidden : SystemMouseCursors.click, style: HoverStyle( @@ -81,11 +84,12 @@ class FlowyIconTextButton extends StatelessWidget { Widget _render(BuildContext context, bool onHover) { final List children = []; - if (leftIconBuilder != null) { + final Widget? leftIcon = leftIconBuilder?.call(onHover); + if (leftIcon != null) { children.add( SizedBox.fromSize( size: leftIconSize, - child: leftIconBuilder!(onHover), + child: leftIcon, ), ); children.add(HSpace(iconPadding)); @@ -97,10 +101,11 @@ class FlowyIconTextButton extends StatelessWidget { children.add(textBuilder(onHover)); } - if (rightIconBuilder != null) { + final Widget? rightIcon = rightIconBuilder?.call(onHover); + if (rightIcon != null) { children.add(HSpace(iconPadding)); // No need to define the size of rightIcon. Just use its intrinsic width - children.add(rightIconBuilder!(onHover)); + children.add(rightIcon); } Widget child = Row( diff --git a/frontend/appflowy_flutter/pubspec.lock b/frontend/appflowy_flutter/pubspec.lock index 71bc08bf67..c8dc56a973 100644 --- a/frontend/appflowy_flutter/pubspec.lock +++ b/frontend/appflowy_flutter/pubspec.lock @@ -61,8 +61,8 @@ packages: dependency: "direct main" description: path: "." - ref: ac3b090 - resolved-ref: ac3b0906ea2e7f2e7e3c2c7852e9ec3529e07512 + ref: "157ded3" + resolved-ref: "157ded3cd321b9a54d011c0cc27e270ded35d3aa" url: "https://github.com/AppFlowy-IO/appflowy-editor.git" source: git version: "4.0.0" diff --git a/frontend/appflowy_flutter/pubspec.yaml b/frontend/appflowy_flutter/pubspec.yaml index 29a3d57cf7..949d264bf2 100644 --- a/frontend/appflowy_flutter/pubspec.yaml +++ b/frontend/appflowy_flutter/pubspec.yaml @@ -4,7 +4,7 @@ description: Bring projects, wikis, and teams together with AI. AppFlowy is an your data. The best open source alternative to Notion. publish_to: "none" -version: 0.7.6 +version: 0.7.7 environment: flutter: ">=3.22.0" @@ -173,7 +173,7 @@ dependency_overrides: appflowy_editor: git: url: https://github.com/AppFlowy-IO/appflowy-editor.git - ref: "ac3b090" + ref: "157ded3" appflowy_editor_plugins: git: diff --git a/frontend/appflowy_flutter/test/unit_test/markdown/markdown_parser_test.dart b/frontend/appflowy_flutter/test/unit_test/markdown/markdown_parser_test.dart new file mode 100644 index 0000000000..70775612e2 --- /dev/null +++ b/frontend/appflowy_flutter/test/unit_test/markdown/markdown_parser_test.dart @@ -0,0 +1,37 @@ +import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; +import 'package:appflowy/shared/markdown_to_document.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('export markdown to document', () { + test('file block', () async { + final document = Document.blank() + ..insert( + [0], + [ + fileNode( + name: 'file.txt', + url: 'https://file.com', + ), + ], + ); + final markdown = customDocumentToMarkdown(document); + expect(markdown, '[file.txt](https://file.com)\n'); + }); + + test('link preview', () { + final document = Document.blank() + ..insert( + [0], + [linkPreviewNode(url: 'https://www.link_preview.com')], + ); + final markdown = customDocumentToMarkdown(document); + expect( + markdown, + '[https://www.link_preview.com](https://www.link_preview.com)\n', + ); + }); + }); +} diff --git a/frontend/appflowy_flutter/test/unit_test/url_launcher/url_launcher_test.dart b/frontend/appflowy_flutter/test/unit_test/url_launcher/url_launcher_test.dart new file mode 100644 index 0000000000..feac569127 --- /dev/null +++ b/frontend/appflowy_flutter/test/unit_test/url_launcher/url_launcher_test.dart @@ -0,0 +1,19 @@ +import 'package:appflowy/shared/patterns/common_patterns.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('url launcher unit test', () { + test('launch local uri', () async { + const localUris = [ + 'file://path/to/file.txt', + '/path/to/file.txt', + 'C:\\path\\to\\file.txt', + '../path/to/file.txt', + ]; + for (final uri in localUris) { + final result = localPathRegex.hasMatch(uri); + expect(result, true); + } + }); + }); +} diff --git a/frontend/resources/translations/en.json b/frontend/resources/translations/en.json index dc93847dd6..8b1d97dba5 100644 --- a/frontend/resources/translations/en.json +++ b/frontend/resources/translations/en.json @@ -2896,5 +2896,12 @@ "favoriteDisabledHint": "Cannot favorite this view", "pinTab": "Pin", "unpinTab": "Unpin" + }, + "openFileMessage": { + "success": "File opened successfully", + "fileNotFound": "File not found", + "noAppToOpenFile": "No app to open this file", + "permissionDenied": "No permission to open this file", + "unknownError": "File open failed" } -} \ No newline at end of file +} diff --git a/frontend/rust-lib/flowy-database2/src/manager.rs b/frontend/rust-lib/flowy-database2/src/manager.rs index 8246da8a97..cf14295e4c 100644 --- a/frontend/rust-lib/flowy-database2/src/manager.rs +++ b/frontend/rust-lib/flowy-database2/src/manager.rs @@ -903,18 +903,21 @@ impl DatabaseCollabService for WorkspaceDatabaseCollabServiceImpl { encoded_collab_by_id.insert(k, v); } - // 2. Fetch remaining collabs from remote - let remote_collabs = self - .batch_get_encode_collab(object_ids, collab_type) - .await?; + if !object_ids.is_empty() { + // 2. Fetch remaining collabs from remote + let remote_collabs = self + .batch_get_encode_collab(object_ids, collab_type) + .await?; - trace!( - "[Database]: load {} database row from remote", - remote_collabs.len() - ); - for (k, v) in remote_collabs { - encoded_collab_by_id.insert(k, v); + trace!( + "[Database]: load {} database row from remote", + remote_collabs.len() + ); + for (k, v) in remote_collabs { + encoded_collab_by_id.insert(k, v); + } } + Ok(encoded_collab_by_id) }