Compare commits

...

5 commits
main ... 0.7.7

Author SHA1 Message Date
Lucas
c3bfae866b
chore: bump version 0.7.7 (#6951)
* feat: add same delete design in database (#6620)

* add same delete design in database

* fix: remove padding when widget is null or function is null

* fix(desktop): resize sidebar menu regression (#6897)

* fix: initial ai chat load (#6920)

* fix: unable to open local file using afLaunchUrl function (#6927)

* fix: unable to open local file using afLaunchUrl function

* chore: use the latest api to open the local file

* chore: use the latest api to open the local file

* chore: use the latest api to open the local file

* test: add local paht regex test

* fix(flutter): implement mention date transaction handler (#6933)

* fix: implement mention date transaction handler

* test: add integration tests

* chore: code cleanup

* chore: early return if null delta

* fix(flutter_desktop): clicking on empty space when editing a cell sho… (#6949)

* fix(flutter_desktop): clicking on empty space when editing a cell shouldn't close event card

* test: fix integration tests

* chore: bump version 0.7.7

* fix: hotfix issues for v0.7.7 (#6948)

* fix: include link preview block and file block in exported markdown

* test: include link preview block and file block in exported markdown

* chore: remove unused logs

* chore: update editor version

* fix: "+" menu should be close after pressing space

* test: cancel inline page reference menu by space

* chore: update editor version

* chore: remove unused logs

---------

Co-authored-by: Ahad Patel <69256193+Ahad-patel@users.noreply.github.com>
Co-authored-by: Richard Shiue <71320345+richardshiue@users.noreply.github.com>
2024-12-09 17:50:45 +08:00
Lucas.Xu
d3c7d4b1d2 Revert "fix: hotfix issues for v0.7.7 (#6948)"
This reverts commit 8726df703e.
2024-12-09 16:21:12 +08:00
Lucas
8726df703e fix: hotfix issues for v0.7.7 (#6948)
* fix: include link preview block and file block in exported markdown

* test: include link preview block and file block in exported markdown

* chore: remove unused logs

* chore: update editor version

* fix: "+" menu should be close after pressing space

* test: cancel inline page reference menu by space

* chore: update editor version

* chore: remove unused logs
2024-12-09 16:20:36 +08:00
nathan
3fdd19f7a2 chore: do not pull object when object ids is empty 2024-12-09 15:17:11 +08:00
Lucas.Xu
fb132cb4b8 chore: upgrade rust version to 1.80.1 2024-12-03 21:06:51 +08:00
59 changed files with 1018 additions and 140 deletions

View file

@ -7,7 +7,7 @@ on:
env:
FLUTTER_VERSION: "3.22.0"
RUST_TOOLCHAIN: "1.77.2"
RUST_TOOLCHAIN: "1.80.1"
jobs:
create-release:

View file

@ -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:

View file

@ -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:

View file

@ -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"
args: "--debug"

View file

@ -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"
args: "--debug"

View file

@ -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

View file

@ -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

View file

@ -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"

View file

@ -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);

View file

@ -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);
});
});
}

View file

@ -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<ReminderBloc>().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<ReminderBloc>().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<ReminderBloc>().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<ReminderBloc>().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<ReminderBloc>().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<ReminderBloc>().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<ReminderBloc>().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<ReminderBloc>().state.reminders.isEmpty, isTrue);
});
});
}

View file

@ -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<bool> 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<bool> afLaunchUri(
Uri uri, {
BuildContext? context,
OnFailureCallback? onFailure,
@ -18,6 +26,18 @@ Future<bool> 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<bool> 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<bool> afLaunchUrl(
return result;
}
/// Launch the url string
///
/// See [afLaunchUri] for more details.
Future<bool> afLaunchUrlString(
String url, {
bool addingHttpSchemeWhenFailed = false,
BuildContext? context,
OnFailureCallback? onFailure,
}) async {
final Uri uri;
try {
@ -67,12 +92,56 @@ Future<bool> 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<bool> _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,

View file

@ -189,7 +189,7 @@ class MobileViewPageMoreBottomSheet extends StatelessWidget {
final url = context.read<ShareBloc>().state.url;
if (url.isNotEmpty) {
unawaited(
afLaunchUrl(
afLaunchUri(
Uri.parse(url),
mode: LaunchMode.externalApplication,
),

View file

@ -59,6 +59,16 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
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<void> close() async {
await answerStream?.dispose();
@ -76,7 +86,11 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
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()),
);

View file

@ -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<MediaFilePB> files,
) {
if (file.fileType != MediaFileTypePB.Image) {
afLaunchUrlString(file.url);
afLaunchUrlString(file.url, context: context);
return;
}

View file

@ -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),

View file

@ -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<EditorDropManagerState>().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<RowDetailBloc>()),
@ -479,7 +480,7 @@ class _FilePreviewRenderState extends State<_FilePreviewRender> {
? null
: () {
if (file.uploadType == FileUploadTypePB.LocalFile) {
OpenFilex.open(file.url);
afLaunchUrlString(file.url);
return;
}

View file

@ -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(

View file

@ -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,

View file

@ -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),
);
}
}

View file

@ -117,6 +117,22 @@ Future<bool> 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<bool> 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,
);
}
}

View file

@ -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<FileBlockComponent>
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() {

View file

@ -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<void> 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(

View file

@ -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<void> onTransaction(
BuildContext context,
String viewId,
EditorState editorState,
List<MentionBlockData> added,
List<MentionBlockData> removed, {

View file

@ -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<void> onTransaction(
BuildContext context,
String viewId,
EditorState editorState,
List<MentionBlockData> added,
List<MentionBlockData> 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 = <MentionBlockData>[];
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<ClipboardState>().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<ClipboardState>().endHandlingPaste(_pasteIdentifier);
}
}
}
void _handleDeletion(
BuildContext context,
MentionBlockData data,
) {
final reminderId = data.$2[MentionBlockKeys.reminderId];
if (reminderId case String _ when reminderId.isNotEmpty) {
getIt<ReminderBloc>().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<ReminderBloc>().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<ReminderBloc>().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<String, dynamic> 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;
}

View file

@ -8,7 +8,6 @@ import 'package:provider/provider.dart';
enum MentionType {
page,
reminder,
date,
childPage;

View file

@ -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';

View file

@ -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';
}
}

View file

@ -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';
}
}

View file

@ -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';

View file

@ -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';

View file

@ -21,6 +21,7 @@ class SubPageTransactionHandler extends BlockTransactionHandler {
@override
Future<void> onTransaction(
BuildContext context,
String viewId,
EditorState editorState,
List<Node> added,
List<Node> removed, {

View file

@ -25,6 +25,7 @@ abstract class EditorTransactionHandler<T> {
Future<void> onTransaction(
BuildContext context,
String viewId,
EditorState editorState,
List<T> added,
List<T> removed, {

View file

@ -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 = <EditorTransactionHandler>[
if (FeatureFlag.inlineSubPageMention.isOn) ...[
SubPageTransactionHandler(),
ChildPageTransactionHandler(),
],
DateTransactionHandler(),
];
/// Handles delegating transactions to appropriate handlers.
@ -148,10 +151,16 @@ class _EditorTransactionServiceState extends State<EditorTransactionService> {
handler.type: handler.livesInDelta ? <MentionBlockData>[] : <Node>[],
};
// based on the type of the transaction handler
final uniqueTransactionHandlers = <String, EditorTransactionHandler>{};
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<EditorTransactionService> {
}
} 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<EditorTransactionService> {
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<EditorTransactionService> {
removed[handler.type]!.addAll(mentionBlockDatas);
}
fetchedMentions = true;
}
}
}
@ -226,6 +238,7 @@ class _EditorTransactionServiceState extends State<EditorTransactionService> {
handler.onTransaction(
context,
widget.viewId,
widget.editorState,
additions,
removals,

View file

@ -12,12 +12,6 @@ typedef MentionBlockData = (Node, Map<String, dynamic>, int);
abstract class MentionTransactionHandler
extends EditorTransactionHandler<MentionBlockData> {
const MentionTransactionHandler({
required this.subType,
})
const MentionTransactionHandler()
: super(type: MentionBlockKeys.mention, livesInDelta: true);
final String subType;
MentionType get mentionType => MentionType.fromString(subType);
}

View file

@ -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<InlineActionsResult> 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,
),
),
),

View file

@ -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<InlineActionsHandler> createState() => _InlineActionsHandlerState();
@ -288,12 +290,17 @@ class _InlineActionsHandlerState extends State<InlineActionsHandler> {
/// 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;
}

View file

@ -197,8 +197,23 @@ class ShareBloc extends Bloc<ShareEvent, ShareState> {
(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;

View file

@ -20,6 +20,8 @@ String customDocumentToMarkdown(Document document) {
const ToggleListNodeParser(),
const CustomImageNodeParser(),
const SimpleTableNodeParser(),
const LinkPreviewNodeParser(),
const FileBlockNodeParser(),
],
);
}

View file

@ -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);

View file

@ -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',

View file

@ -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<void> 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) {

View file

@ -86,7 +86,7 @@ class HomeSettingBloc extends Bloc<HomeSettingEvent, HomeSettingState> {
},
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));
}

View file

@ -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<PluginStateEvent, PluginStateState> {
@ -91,7 +92,7 @@ class PluginStateBloc extends Bloc<PluginStateEvent, PluginStateState> {
final result = await AIEventGetModelStorageDirectory().send();
result.fold(
(data) {
afLaunchUrl(Uri.file(data.filePath));
afLaunchUri(Uri.file(data.filePath));
},
(err) => Log.error(err.toString()),
);

View file

@ -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<SidebarPlanEvent, SidebarPlanState> {
@ -113,7 +114,8 @@ class SidebarPlanBloc extends Bloc<SidebarPlanEvent, SidebarPlanState> {
} 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<SidebarPlanEvent, SidebarPlanState> {
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;
}

View file

@ -329,14 +329,15 @@ class SpaceBloc extends Bloc<SpaceEvent, SpaceState> {
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(

View file

@ -14,10 +14,11 @@ class HomeLayout {
HomeLayout(BuildContext context) {
final homeSetting = context.read<HomeSettingBloc>().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

View file

@ -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)),
),
],
);

View file

@ -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 {

View file

@ -220,12 +220,12 @@ class InteractiveImageToolbar extends StatelessWidget {
Future<void> _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(

View file

@ -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 {

View file

@ -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<Widget> 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(

View file

@ -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"

View file

@ -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:

View file

@ -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',
);
});
});
}

View file

@ -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);
}
});
});
}

View file

@ -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"
}
}
}

View file

@ -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)
}