Compare commits

...
Author SHA1 Message Date
Nathan 2d5946e75c chore: clippy 2025-04-14 10:31:05 +08:00
Nathan f28a299786 chore: exit anon mode 2025-04-14 10:16:20 +08:00
Nathan 4b0d61b534 chore: do not show logout button on mobile when current user is anon 2025-04-13 14:05:46 +08:00
Nathan cf8fb19976 chore: test 2025-04-13 13:19:10 +08:00
Nathan 1525a363f2 chore: remove unused code 2025-04-13 13:09:05 +08:00
Nathan 5a99da9a45 chore: anon login 2025-04-13 12:48:27 +08:00
Nathan 495226fa2c Merge branch 'main' into anon_user_mode 2025-04-13 12:15:24 +08:00
Lucas 2e295e6891 fix: unable to accept the response from 'mark longer' (#7725)
* fix: unable to accpet 'make it longer'

* fix: markdown text robot test
2025-04-11 14:25:19 +08:00
Richard Shiue 351c891a5a fix: adjust ghost icon hover color (#7723) 2025-04-11 10:21:02 +08:00
Richard Shiue bcb1e8e4f5 chore: adjust replace, insert below, discard selection behavior (#7719) 2025-04-11 09:57:41 +08:00
MornandLucas 4997ac99cf feat: revamp link preivew (#7692)
* feat: revamp link preivew

* feat: add convert to menu for link hover menu

* feat: add mention link

* feat: support convert preview to mention

* feat: add embed link preview

* fix: some test erros

* fix: test errors

* fix: some UI issues

* chore: add test for url

* chore: add test for mention

* chore: add test for bookmark

* chore: add test for embed

* chore: remove unuse import

* fix: some UI issues

* fix: remove text span overlay on mobile

* fix: code lint error

---------

Co-authored-by: Lucas <lucas.xu@appflowy.io>
2025-04-10 14:45:13 +08:00
Lucas e3bbabd63e feat: sign in with passcode (#7685)
* feat: sign in with password or passcode

* feat: add loading dialog

* chore: update translations

* feat: support otp on mobile
2025-04-10 14:06:08 +08:00
Morn 403c558f9b chore: update translation (#7675) 2025-04-10 13:45:22 +08:00
Nathan.fooo 31d2c1d603 Merge pull request #7718 from AppFlowy-IO/remove_path_to_lai
chore: remove local ai related path to lai repo
2025-04-09 21:56:40 +08:00
Nathan a7adeeadb1 chore: remove local ai related path to lai repo 2025-04-09 20:47:03 +08:00
Nathan.fooo f7d7141a59 Merge pull request #7717 from AppFlowy-IO/fix_local_ai_setting
chore: fix local ai setting
2025-04-09 20:43:36 +08:00
Nathan 2371d4691d chore: fix local ai setting 2025-04-09 20:07:06 +08:00
Nathan 9ec0437673 chore: remove enable sync log toggle 2025-04-09 14:15:48 +08:00
Richard Shiue 89525b7f7b chore: bump version (#7716) 2025-04-09 14:11:33 +08:00
Richard Shiue ad227bcf79 chore: toast message when theme folder doesn't have permission (#7715) 2025-04-09 13:45:07 +08:00
Lucas 8b82d532e0 fix: replace the selected text with ai response in the same line (#7708)
* fix: replace the selected text with ai response in the same line

* fix: replace the selected text with ai response in the multiple lines

* fix: integrate the replace function in the ai writer cubit

* fix: unit test and integration test
2025-04-09 13:21:19 +08:00
Morn f4af47728f fix: overwriting a selected text removes more than expected (#7714) 2025-04-09 12:57:58 +08:00
Richard Shiue b3a4a9ba04 fix: use normal underline for AI suggested text style (#7712) 2025-04-09 10:25:29 +08:00
Lucas 13028c6cfe fix: icon and emoji doesn't align in mention page menu (#7673) 2025-04-09 09:39:35 +08:00
Richard Shiue 7d0f6c9deb fix(mobile): edit mention page (#7698)
* fix(mobile): edit mention page

* chore: type check

* chore: replace usages

* chore: use MentionType instead
2025-04-09 09:38:42 +08:00
FakhriAzzouz 8d2901cc58 chore: arabic translations update (#7701)
* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦
2025-04-09 09:38:00 +08:00
Aniket Patil bbc7b6d172 feat(i18n): add initial Marathi (mr-IN) translations (#7667)
* feat(i18n): Add initial Marathi (mr-IN) translations

* fix(i18n): Force add mr-IN.json in assets folder for localization

* fix(i18n): Sync mr-IN.json from assets to resources

* fix(i18n): Final sync of full Marathi translation in assets

* feat(i18n): Force add Marathi translations to ignored directory

* feat(i18n): Complete Marathi translations
2025-04-09 09:37:21 +08:00
Lucas 145d1e5fdb feat: support white label on windows (#7706) 2025-04-09 09:36:53 +08:00
Nathan 7ffd459875 chore: anon mode 2025-04-08 21:22:16 +08:00
Nathan.fooo eae4e42dcc Merge pull request #7709 from AppFlowy-IO/refactor_path_name
chore: rename data path
2025-04-08 21:19:07 +08:00
Nathan 6886261692 chore: ensure path 2025-04-08 20:49:15 +08:00
Nathan 23f2d85e70 chore: clippy 2025-04-08 20:23:25 +08:00
Nathan d1d598940d chore: clippy 2025-04-08 16:08:28 +08:00
Nathan efb98d28ef chore: rename data path 2025-04-08 15:53:34 +08:00
Richard Shiue 462c822255 fix: translations (#7694) 2025-04-08 13:08:10 +08:00
Richard Shiue 9e30b1816f fix(flutter_desktop): grid bug (#7697)
* fix: field width doesn't persist

* test: add test

* test: fix test

* test: grid width integration test
2025-04-08 11:46:06 +08:00
Nathan.fooo d79929d1c9 Merge pull request #7700 from AppFlowy-IO/sync_log_enable_default
chore: enable sync log by default
2025-04-08 10:31:01 +08:00
Nathan 0286678286 chore: clippy 2025-04-08 10:30:48 +08:00
Nathan 4896d7c1be chore: enable sync log by default 2025-04-07 22:04:32 +08:00
Nathan.fooo b06a2337a0 Merge pull request #7699 from AppFlowy-IO/use_uuid
Chore: Use UUID instead of using String
2025-04-07 21:54:48 +08:00
Nathan 20bcdd1f90 chore: fmt 2025-04-07 21:28:48 +08:00
Nathan 24d57336a9 chore: bump collab id 2025-04-07 21:15:30 +08:00
Nathan 91397c963a chore: clippy 2025-04-07 19:34:26 +08:00
Nathan 995b773c74 chore: replace str with uuid 2025-04-07 19:24:58 +08:00
Richard Shiue c561abd9f8 fix: clear text field upon selection (#7695) 2025-04-07 14:52:44 +08:00
Lucas 10dd0fa438 feat: implement new color tokens design (#7684)
* fix: missing AFThemeExtensionV2 on mobile

* feat: add appflowy_ui package
2025-04-04 14:41:13 +08:00
Lucas d74b0bf6e1 fix: only log document event when enableDocumentInternalLog is enabled (#7676) 2025-04-02 16:09:52 +08:00
Morn 913924d8d3 fix: emoji menu should only be triggered when “:” has a followed letter (#7672) 2025-04-02 14:15:14 +08:00
Nathan.fooo 7bc9ce4391 Merge pull request #7674 from AppFlowy-IO/custom_compare_func
fix: after applying different ai model, the selection menu doesn't show the new model
2025-04-02 13:59:04 +08:00
Nathan ed5334a7d6 chore: clippy 2025-04-02 13:38:29 +08:00
Nathan 120f22c6fc chore: notify model change after applying local ai model 2025-04-02 13:08:45 +08:00
FakhriAzzouz f8a17dac00 chore: update translations with Fink 🐦 (#7669) 2025-04-02 09:50:50 +08:00
Lucas 7f41feb959 chore: update changelog (#7671) 2025-04-01 21:41:20 +08:00
Nathan.fooo da7f584da7 Merge pull request #7670 from richardshiue/fix/hotfix
fix: translation hotfix
2025-04-01 21:34:06 +08:00
Richard Shiue 031a88f4c4 fix: translation hotfix 2025-04-01 21:26:24 +08:00
Lucas e2ff12415c chore: update translation (#7666) 2025-04-01 17:35:22 +08:00
MornandLucas.Xu edd59cf462 fix: link menu issues (#7661)
* fix: duplicated link menu issue

* fix: support toolbar animation

* chore: update appflowy_editor

* fix: update pubspect.lock

* fix: testing error

---------

Co-authored-by: Lucas.Xu <lucas.xu@appflowy.io>
2025-04-01 14:17:04 +08:00
Morn b52f681e3c fix: launch review issues for non-search-bar emoji picker (#7654)
* fix: some launch review issues for emoji picker

* fix: revamp emoji picker which is created by character

* fix: add padding for non-searchbar emoji picker
2025-04-01 09:33:54 +08:00
Lucas 5d33d723e9 fix: the checklist item will disappear when reordering (#7659) 2025-04-01 09:25:49 +08:00
Morn 5e1a8b1ec7 fix: toolbar link launch review issues (#7639)
* fix: some toolbar link launch review issues

* fix: support check link format for link menus

* fix: toolbar and link hover menu will not display together

* fix: filter link search result with current document id

* fix: remove error text while link menu is not focus

* fix: some issues

* fix: test errors

* fix: add tooltip for link menu
2025-03-31 15:16:56 +08:00
Richard Shiue 3c99105b23 fix: show cursor at end of selection when generating (#7655) 2025-03-31 15:06:50 +08:00
Richard Shiue 0b298ea426 chore: use emoji instead of Local for copy (#7656) 2025-03-31 15:06:39 +08:00
Lucas 7ed36f8736 feat: support i18n in delete my account operation (#7635) 2025-03-31 13:07:42 +08:00
Richard Shiue 34d2b7f24e chore: improve local ai settings page (#7651)
* chore: improve local ai settings page

* chore: move local to top in select list

* chore: improve copy of missing model

* chore: remove green background and add progress indicator

* chore: change text color
2025-03-31 12:05:11 +08:00
Richard Shiue a2303d35e8 chore: hide formats when using specific ai writer commands (#7648)
* chore: hide formats for specific ai writer features

* chore: use black color for selected model name
2025-03-31 10:24:46 +08:00
456 changed files with 22877 additions and 4739 deletions

No files matched your search

+11
View File
@@ -1,4 +1,15 @@
# Release Notes
## Version 0.8.8 - 01/04/2025
### New Features
- Added support for selecting AI models in AI writer
- Revamped link menu in toolbar
- Added support for using ":" to add emojis in documents
- Passed the history of past AI prompts and responses to AI writer
### Bug Fixes
- Improved AI writer scrolling user experience
- Fixed issue where checklist items would disappear during reordering
- Fixed numbered lists generated by AI to maintain the same index as the input
## Version 0.8.7 - 18/03/2025
### New Features
- Made local AI free and integrated with Ollama
+124 -124
View File
@@ -1,125 +1,125 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
// This task only builds the Dart code of AppFlowy.
// It supports both the desktop and mobile version.
"name": "AF: Build Dart Only",
"request": "launch",
"program": "./lib/main.dart",
"type": "dart",
"env": {
"RUST_LOG": "debug",
},
// uncomment the following line to testing performance.
// "flutterMode": "profile",
"cwd": "${workspaceRoot}/appflowy_flutter"
},
{
// This task builds the Rust and Dart code of AppFlowy.
"name": "AF-desktop: Build All",
"request": "launch",
"program": "./lib/main.dart",
"type": "dart",
"preLaunchTask": "AF: Build Appflowy Core",
"env": {
"RUST_LOG": "trace",
"RUST_BACKTRACE": "1"
},
"cwd": "${workspaceRoot}/appflowy_flutter"
},
{
// This task builds will:
// - call the clean task,
// - rebuild all the generated Files (including freeze and language files)
// - rebuild the the Rust and Dart code of AppFlowy.
"name": "AF-desktop: Clean + Rebuild All",
"request": "launch",
"program": "./lib/main.dart",
"type": "dart",
"preLaunchTask": "AF: Clean + Rebuild All",
"env": {
"RUST_LOG": "trace"
},
"cwd": "${workspaceRoot}/appflowy_flutter"
},
{
"name": "AF-iOS: Build All",
"request": "launch",
"program": "./lib/main.dart",
"type": "dart",
"preLaunchTask": "AF: Build Appflowy Core For iOS",
"env": {
"RUST_LOG": "trace"
},
"cwd": "${workspaceRoot}/appflowy_flutter"
},
{
"name": "AF-iOS: Clean + Rebuild All",
"request": "launch",
"program": "./lib/main.dart",
"type": "dart",
"preLaunchTask": "AF: Clean + Rebuild All (iOS)",
"env": {
"RUST_LOG": "trace"
},
"cwd": "${workspaceRoot}/appflowy_flutter"
},
{
"name": "AF-iOS-Simulator: Build All",
"request": "launch",
"program": "./lib/main.dart",
"type": "dart",
"preLaunchTask": "AF: Build Appflowy Core For iOS Simulator",
"env": {
"RUST_LOG": "trace"
},
"cwd": "${workspaceRoot}/appflowy_flutter"
},
{
"name": "AF-iOS-Simulator: Clean + Rebuild All",
"request": "launch",
"program": "./lib/main.dart",
"type": "dart",
"preLaunchTask": "AF: Clean + Rebuild All (iOS Simulator)",
"env": {
"RUST_LOG": "trace"
},
"cwd": "${workspaceRoot}/appflowy_flutter"
},
{
"name": "AF-Android: Build All",
"request": "launch",
"program": "./lib/main.dart",
"type": "dart",
"preLaunchTask": "AF: Build Appflowy Core For Android",
"env": {
"RUST_LOG": "trace"
},
"cwd": "${workspaceRoot}/appflowy_flutter"
},
{
"name": "AF-Android: Clean + Rebuild All",
"request": "launch",
"program": "./lib/main.dart",
"type": "dart",
"preLaunchTask": "AF: Clean + Rebuild All (Android)",
"env": {
"RUST_LOG": "trace"
},
"cwd": "${workspaceRoot}/appflowy_flutter"
},
{
"name": "AF-desktop: Debug Rust",
"type": "lldb",
"request": "attach",
"pid": "${command:pickMyProcess}"
// To launch the application directly, use the following configuration:
// "request": "launch",
// "program": "[YOUR_APPLICATION_PATH]",
},
]
}
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
// This task only builds the Dart code of AppFlowy.
// It supports both the desktop and mobile version.
"name": "AF: Build Dart Only",
"request": "launch",
"program": "./lib/main.dart",
"type": "dart",
"env": {
"RUST_LOG": "trace",
},
// uncomment the following line to testing performance.
// "flutterMode": "profile",
"cwd": "${workspaceRoot}/appflowy_flutter"
},
{
// This task builds the Rust and Dart code of AppFlowy.
"name": "AF-desktop: Build All",
"request": "launch",
"program": "./lib/main.dart",
"type": "dart",
"preLaunchTask": "AF: Build Appflowy Core",
"env": {
"RUST_LOG": "trace",
"RUST_BACKTRACE": "1"
},
"cwd": "${workspaceRoot}/appflowy_flutter"
},
{
// This task builds will:
// - call the clean task,
// - rebuild all the generated Files (including freeze and language files)
// - rebuild the the Rust and Dart code of AppFlowy.
"name": "AF-desktop: Clean + Rebuild All",
"request": "launch",
"program": "./lib/main.dart",
"type": "dart",
"preLaunchTask": "AF: Clean + Rebuild All",
"env": {
"RUST_LOG": "trace"
},
"cwd": "${workspaceRoot}/appflowy_flutter"
},
{
"name": "AF-iOS: Build All",
"request": "launch",
"program": "./lib/main.dart",
"type": "dart",
"preLaunchTask": "AF: Build Appflowy Core For iOS",
"env": {
"RUST_LOG": "trace"
},
"cwd": "${workspaceRoot}/appflowy_flutter"
},
{
"name": "AF-iOS: Clean + Rebuild All",
"request": "launch",
"program": "./lib/main.dart",
"type": "dart",
"preLaunchTask": "AF: Clean + Rebuild All (iOS)",
"env": {
"RUST_LOG": "trace"
},
"cwd": "${workspaceRoot}/appflowy_flutter"
},
{
"name": "AF-iOS-Simulator: Build All",
"request": "launch",
"program": "./lib/main.dart",
"type": "dart",
"preLaunchTask": "AF: Build Appflowy Core For iOS Simulator",
"env": {
"RUST_LOG": "trace"
},
"cwd": "${workspaceRoot}/appflowy_flutter"
},
{
"name": "AF-iOS-Simulator: Clean + Rebuild All",
"request": "launch",
"program": "./lib/main.dart",
"type": "dart",
"preLaunchTask": "AF: Clean + Rebuild All (iOS Simulator)",
"env": {
"RUST_LOG": "trace"
},
"cwd": "${workspaceRoot}/appflowy_flutter"
},
{
"name": "AF-Android: Build All",
"request": "launch",
"program": "./lib/main.dart",
"type": "dart",
"preLaunchTask": "AF: Build Appflowy Core For Android",
"env": {
"RUST_LOG": "trace"
},
"cwd": "${workspaceRoot}/appflowy_flutter"
},
{
"name": "AF-Android: Clean + Rebuild All",
"request": "launch",
"program": "./lib/main.dart",
"type": "dart",
"preLaunchTask": "AF: Clean + Rebuild All (Android)",
"env": {
"RUST_LOG": "trace"
},
"cwd": "${workspaceRoot}/appflowy_flutter"
},
{
"name": "AF-desktop: Debug Rust",
"type": "lldb",
"request": "attach",
"pid": "${command:pickMyProcess}"
// To launch the application directly, use the following configuration:
// "request": "launch",
// "program": "[YOUR_APPLICATION_PATH]",
},
]
}
+1 -1
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.8.8"
APPFLOWY_VERSION = "0.8.9"
FLUTTER_DESKTOP_FEATURES = "dart"
PRODUCT_NAME = "AppFlowy"
MACOSX_DEPLOYMENT_TARGET = "11.0"
@@ -4,6 +4,7 @@ analyzer:
exclude:
- "**/*.g.dart"
- "**/*.freezed.dart"
- "packages/**/*.dart"
linter:
rules:
File diff suppressed because it is too large. Load diff
@@ -15,7 +15,6 @@ void main() {
cloudType: AuthenticatorType.appflowyCloudSelfHost,
);
await tester.tapContinousAnotherWay();
await tester.tapAnonymousSignInButton();
await tester.expectToSeeHomePageWithGetStartedPage();
@@ -15,6 +15,7 @@ void main() {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
// create a database and add a linked database view
await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid);
await tester.tapCreateLinkedDatabaseViewButton(DatabaseLayoutPB.Grid);
@@ -29,6 +30,11 @@ void main() {
await tester.tapHidePropertyButton();
tester.noFieldWithName('New field 1');
// create another field, New field 1 to be hidden still
await tester.tapNewPropertyButton();
await tester.dismissFieldEditor();
tester.noFieldWithName('New field 1');
// go back to inline database view, expect field to be shown
await tester.tapTabBarLinkedViewByViewName('Untitled');
tester.findFieldWithName('New field 1');
@@ -60,5 +66,40 @@ void main() {
await tester.tapDatabaseSortButton();
await tester.tapCreateSortByFieldType(FieldType.RichText, "New field 1");
});
testWidgets('field cell width', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
// create a database and add a linked database view
await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid);
await tester.tapCreateLinkedDatabaseViewButton(DatabaseLayoutPB.Grid);
// create a field
await tester.scrollToRight(find.byType(GridPage));
await tester.tapNewPropertyButton();
await tester.renameField('New field 1');
await tester.dismissFieldEditor();
// check the width of the field
expect(tester.getFieldWidth('New field 1'), 150);
// change the width of the field
await tester.changeFieldWidth('New field 1', 200);
expect(tester.getFieldWidth('New field 1'), 205);
// create another field, New field 1 to be same width
await tester.tapNewPropertyButton();
await tester.dismissFieldEditor();
expect(tester.getFieldWidth('New field 1'), 205);
// go back to inline database view, expect New field 1 to be 150px
await tester.tapTabBarLinkedViewByViewName('Untitled');
expect(tester.getFieldWidth('New field 1'), 150);
// go back to linked database view, expect New field 1 to be 205px
await tester.tapTabBarLinkedViewByViewName('Grid');
expect(tester.getFieldWidth('New field 1'), 205);
});
});
}
@@ -1,10 +1,12 @@
import 'dart:async';
import 'dart:io';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/block_menu/block_menu_button.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/custom_image_block_component/custom_image_block_component.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/custom_link_preview.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/link_preview_menu.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/paste_as/paste_as_menu.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart';
@@ -320,8 +322,14 @@ void main() {
(tester) async {
const url = 'https://appflowy.io';
await tester.pasteContent(plainText: url, (editorState) async {
final pasteAsMenu = find.byType(PasteAsMenu);
expect(pasteAsMenu, findsOneWidget);
final bookmarkButton = find.text(
LocaleKeys.document_plugins_linkPreview_typeSelection_bookmark.tr(),
);
await tester.tapButton(bookmarkButton);
// the second one is the paragraph node
expect(editorState.document.root.children.length, 2);
expect(editorState.document.root.children.length, 1);
final node = editorState.getNodeAtPath([0])!;
expect(node.type, LinkPreviewBlockKeys.type);
expect(node.attributes[LinkPreviewBlockKeys.url], url);
@@ -333,19 +341,19 @@ void main() {
await tester.hoverOnWidget(
find.byType(CustomLinkPreviewWidget),
onHover: () async {
final convertToLinkButton = find.byWidgetPredicate((widget) {
return widget is MenuBlockButton &&
widget.tooltip ==
LocaleKeys.document_plugins_urlPreview_convertToLink.tr();
});
/// show menu
final menu = find.byType(CustomLinkPreviewMenu);
expect(menu, findsOneWidget);
await tester.tapButton(menu);
final convertToLinkButton = find.text(
LocaleKeys.document_plugins_linkPreview_linkPreviewMenu_toUrl.tr(),
);
expect(convertToLinkButton, findsOneWidget);
await tester.tap(convertToLinkButton);
await tester.pumpAndSettle();
await tester.tapButton(convertToLinkButton);
},
);
await tester.pumpAndSettle();
final editorState = tester.editor.getCurrentEditorState();
final textNode = editorState.getNodeAtPath([0])!;
expect(textNode.type, ParagraphBlockKeys.type);
@@ -363,14 +371,20 @@ void main() {
(tester) async {
const url = 'https://appflowy.io';
await tester.pasteContent(plainText: url, (editorState) async {
final pasteAsMenu = find.byType(PasteAsMenu);
expect(pasteAsMenu, findsOneWidget);
final bookmarkButton = find.text(
LocaleKeys.document_plugins_linkPreview_typeSelection_bookmark.tr(),
);
await tester.tapButton(bookmarkButton);
// the second one is the paragraph node
expect(editorState.document.root.children.length, 2);
expect(editorState.document.root.children.length, 1);
final node = editorState.getNodeAtPath([0])!;
expect(node.type, LinkPreviewBlockKeys.type);
expect(node.attributes[LinkPreviewBlockKeys.url], url);
});
await tester.editor.tapLineOfEditorAt(0);
await tester.simulateKeyEvent(
LogicalKeyboardKey.keyZ,
isControlPressed:
@@ -521,7 +535,7 @@ void main() {
extension on WidgetTester {
Future<void> pasteContent(
void Function(EditorState editorState) test, {
FutureOr<void> Function(EditorState editorState) test, {
Future<void> Function(EditorState editorState)? beforeTest,
String? plainText,
String? html,
@@ -558,6 +572,6 @@ extension on WidgetTester {
);
await pumpAndSettle(const Duration(milliseconds: 1000));
test(editor.getCurrentEditorState());
await test(editor.getCurrentEditorState());
}
}
@@ -0,0 +1,452 @@
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/copy_and_paste/clipboard_service.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_hover_menu.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/link_embed/link_embed_block_component.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/custom_link_preview_block_component.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/link_preview_menu.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/paste_as/paste_as_menu.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_block.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_link_block.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_link_error_preview.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_link_preview.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import '../../shared/util.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
const avaliableLink = 'https://appflowy.io/',
unavailableLink = 'www.thereIsNoting.com';
Future<void> preparePage(WidgetTester tester, {String? pageName}) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
await tester.createNewPageWithNameUnderParent(name: pageName);
await tester.editor.tapLineOfEditorAt(0);
}
Future<void> pasteLink(WidgetTester tester, String link) async {
await getIt<ClipboardService>()
.setData(ClipboardServiceData(plainText: link));
/// paste the link
await tester.simulateKeyEvent(
LogicalKeyboardKey.keyV,
isControlPressed: Platform.isLinux || Platform.isWindows,
isMetaPressed: Platform.isMacOS,
);
await tester.pumpAndSettle(Duration(seconds: 1));
}
Future<void> pasteAs(
WidgetTester tester,
String link,
PasteMenuType type, {
Duration waitTime = const Duration(milliseconds: 500),
}) async {
await pasteLink(tester, link);
final convertToMentionButton = find.text(type.title);
await tester.tapButton(convertToMentionButton);
await tester.pumpAndSettle(waitTime);
}
void checkUrl(Node node, String link) {
expect(node.type, ParagraphBlockKeys.type);
expect(node.delta!.toJson(), [
{
'insert': link,
'attributes': {'href': link},
}
]);
}
void checkMention(Node node, String link) {
final delta = node.delta!;
final insert = (delta.first as TextInsert).text;
final attributes = delta.first.attributes;
expect(insert, MentionBlockKeys.mentionChar);
final mention =
attributes?[MentionBlockKeys.mention] as Map<String, dynamic>;
expect(mention[MentionBlockKeys.type], MentionType.externalLink.name);
expect(mention[MentionBlockKeys.url], avaliableLink);
}
void checkBookmark(Node node, String link) {
expect(node.type, LinkPreviewBlockKeys.type);
expect(node.attributes[LinkPreviewBlockKeys.url], link);
}
void checkEmbed(Node node, String link) {
expect(node.type, LinkPreviewBlockKeys.type);
expect(node.attributes[LinkEmbedKeys.previewType], LinkEmbedKeys.embed);
expect(node.attributes[LinkPreviewBlockKeys.url], link);
}
group('Paste as URL', () {
Future<void> pasteAndTurnInto(
WidgetTester tester,
String link,
String title,
) async {
await pasteLink(tester, link);
final convertToLinkButton = find
.text(LocaleKeys.document_plugins_linkPreview_typeSelection_URL.tr());
await tester.tapButton(convertToLinkButton);
/// hover link and turn into mention
await tester.hoverOnWidget(
find.byType(LinkHoverTrigger),
onHover: () async {
final turnintoButton = find.byFlowySvg(FlowySvgs.turninto_m);
await tester.tapButton(turnintoButton);
final convertToButton = find.text(title);
await tester.tapButton(convertToButton);
await tester.pumpAndSettle(Duration(seconds: 1));
},
);
}
testWidgets('paste a link', (tester) async {
final link = avaliableLink;
await preparePage(tester);
await pasteLink(tester, link);
final convertToLinkButton = find
.text(LocaleKeys.document_plugins_linkPreview_typeSelection_URL.tr());
await tester.tapButton(convertToLinkButton);
final node = tester.editor.getNodeAtPath([0]);
checkUrl(node, link);
});
testWidgets('paste a link and turn into mention', (tester) async {
final link = avaliableLink;
await preparePage(tester);
await pasteAndTurnInto(
tester,
link,
LinkConvertMenuCommand.toMention.title,
);
/// check metion values
final node = tester.editor.getNodeAtPath([0]);
checkMention(node, link);
});
testWidgets('paste a link and turn into bookmark', (tester) async {
final link = avaliableLink;
await preparePage(tester);
await pasteAndTurnInto(
tester,
link,
LinkConvertMenuCommand.toBookmark.title,
);
/// check metion values
final node = tester.editor.getNodeAtPath([0]);
checkBookmark(node, link);
});
testWidgets('paste a link and turn into embed', (tester) async {
final link = avaliableLink;
await preparePage(tester);
await pasteAndTurnInto(
tester,
link,
LinkConvertMenuCommand.toEmbed.title,
);
/// check metion values
final node = tester.editor.getNodeAtPath([0]);
checkEmbed(node, link);
});
});
group('Paste as Mention', () {
Future<void> pasteAsMention(WidgetTester tester, String link) =>
pasteAs(tester, link, PasteMenuType.mention);
String getMentionLink(Node node) {
final insert = node.delta?.first as TextInsert?;
final mention = insert?.attributes?[MentionBlockKeys.mention]
as Map<String, dynamic>?;
return mention?[MentionBlockKeys.url] ?? '';
}
Future<void> hoverMentionAndClick(
WidgetTester tester,
String command,
) async {
final mentionLink = find.byType(MentionLinkBlock);
expect(mentionLink, findsOneWidget);
await tester.hoverOnWidget(
mentionLink,
onHover: () async {
final errorPreview = find.byType(MentionLinkErrorPreview);
expect(errorPreview, findsOneWidget);
final convertButton = find.byFlowySvg(FlowySvgs.turninto_m);
await tester.tapButton(convertButton);
final menuButton = find.text(command);
await tester.tapButton(menuButton);
},
);
}
testWidgets('paste a link as mention', (tester) async {
final link = avaliableLink;
await preparePage(tester);
await pasteAsMention(tester, link);
final node = tester.editor.getNodeAtPath([0]);
checkMention(node, link);
});
testWidgets('paste as mention and copy link', (tester) async {
final link = avaliableLink;
await preparePage(tester);
await pasteAsMention(tester, link);
final mentionLink = find.byType(MentionLinkBlock);
expect(mentionLink, findsOneWidget);
await tester.hoverOnWidget(
mentionLink,
onHover: () async {
final preview = find.byType(MentionLinkPreview);
if (!preview.hasFound) {
final copyButton = find.byFlowySvg(FlowySvgs.toolbar_link_m);
await tester.tapButton(copyButton);
} else {
final moreOptionButton = find.byFlowySvg(FlowySvgs.toolbar_more_m);
await tester.tapButton(moreOptionButton);
final copyButton =
find.text(MentionLinktMenuCommand.copyLink.title);
await tester.tapButton(copyButton);
}
},
);
final clipboardContent = await getIt<ClipboardService>().getData();
expect(clipboardContent.plainText, link);
});
testWidgets('paste as error mention and turninto url', (tester) async {
String link = unavailableLink;
await preparePage(tester);
await pasteAsMention(tester, link);
Node node = tester.editor.getNodeAtPath([0]);
link = getMentionLink(node);
await hoverMentionAndClick(
tester,
MentionLinktErrorMenuCommand.toURL.title,
);
node = tester.editor.getNodeAtPath([0]);
checkUrl(node, link);
});
testWidgets('paste as error mention and turninto embed', (tester) async {
String link = unavailableLink;
await preparePage(tester);
await pasteAsMention(tester, link);
Node node = tester.editor.getNodeAtPath([0]);
link = getMentionLink(node);
await hoverMentionAndClick(
tester,
MentionLinktErrorMenuCommand.toEmbed.title,
);
node = tester.editor.getNodeAtPath([0]);
checkEmbed(node, link);
});
testWidgets('paste as error mention and turninto bookmark', (tester) async {
String link = unavailableLink;
await preparePage(tester);
await pasteAsMention(tester, link);
Node node = tester.editor.getNodeAtPath([0]);
link = getMentionLink(node);
await hoverMentionAndClick(
tester,
MentionLinktErrorMenuCommand.toBookmark.title,
);
node = tester.editor.getNodeAtPath([0]);
checkBookmark(node, link);
});
testWidgets('paste as error mention and remove link', (tester) async {
String link = unavailableLink;
await preparePage(tester);
await pasteAsMention(tester, link);
Node node = tester.editor.getNodeAtPath([0]);
link = getMentionLink(node);
await hoverMentionAndClick(
tester,
MentionLinktErrorMenuCommand.removeLink.title,
);
node = tester.editor.getNodeAtPath([0]);
expect(node.type, ParagraphBlockKeys.type);
expect(node.delta!.toJson(), [
{'insert': link},
]);
});
});
group('Paste as Bookmark', () {
Future<void> pasteAsBookmark(WidgetTester tester, String link) =>
pasteAs(tester, link, PasteMenuType.bookmark);
Future<void> hoverAndClick(
WidgetTester tester,
LinkPreviewMenuCommand command,
) async {
final bookmark = find.byType(CustomLinkPreviewBlockComponent);
expect(bookmark, findsOneWidget);
await tester.hoverOnWidget(
bookmark,
onHover: () async {
final menuButton = find.byFlowySvg(FlowySvgs.toolbar_more_m);
await tester.tapButton(menuButton);
final commandButton = find.text(command.title);
await tester.tapButton(commandButton);
},
);
}
testWidgets('paste a link as bookmark', (tester) async {
final link = avaliableLink;
await preparePage(tester);
await pasteAsBookmark(tester, link);
final node = tester.editor.getNodeAtPath([0]);
checkBookmark(node, link);
});
testWidgets('paste a link as bookmark and convert to mention',
(tester) async {
final link = avaliableLink;
await preparePage(tester);
await pasteAsBookmark(tester, link);
await hoverAndClick(tester, LinkPreviewMenuCommand.convertToMention);
final node = tester.editor.getNodeAtPath([0]);
checkMention(node, link);
});
testWidgets('paste a link as bookmark and convert to url', (tester) async {
final link = avaliableLink;
await preparePage(tester);
await pasteAsBookmark(tester, link);
await hoverAndClick(tester, LinkPreviewMenuCommand.convertToUrl);
final node = tester.editor.getNodeAtPath([0]);
checkUrl(node, link);
});
testWidgets('paste a link as bookmark and convert to embed',
(tester) async {
final link = avaliableLink;
await preparePage(tester);
await pasteAsBookmark(tester, link);
await hoverAndClick(tester, LinkPreviewMenuCommand.convertToEmbed);
final node = tester.editor.getNodeAtPath([0]);
checkEmbed(node, link);
});
testWidgets('paste a link as bookmark and copy link', (tester) async {
final link = avaliableLink;
await preparePage(tester);
await pasteAsBookmark(tester, link);
await hoverAndClick(tester, LinkPreviewMenuCommand.copyLink);
final clipboardContent = await getIt<ClipboardService>().getData();
expect(clipboardContent.plainText, link);
});
testWidgets('paste a link as bookmark and replace link', (tester) async {
final link = avaliableLink;
await preparePage(tester);
await pasteAsBookmark(tester, link);
await hoverAndClick(tester, LinkPreviewMenuCommand.replace);
await tester.simulateKeyEvent(
LogicalKeyboardKey.keyA,
isControlPressed: Platform.isLinux || Platform.isWindows,
isMetaPressed: Platform.isMacOS,
);
await tester.simulateKeyEvent(LogicalKeyboardKey.delete);
await tester.enterText(find.byType(TextFormField), unavailableLink);
await tester.tapButton(find.text(LocaleKeys.button_replace.tr()));
final node = tester.editor.getNodeAtPath([0]);
checkBookmark(node, unavailableLink);
});
testWidgets('paste a link as bookmark and remove link', (tester) async {
final link = avaliableLink;
await preparePage(tester);
await pasteAsBookmark(tester, link);
await hoverAndClick(tester, LinkPreviewMenuCommand.removeLink);
final node = tester.editor.getNodeAtPath([0]);
expect(node.type, ParagraphBlockKeys.type);
expect(node.delta!.toJson(), [
{'insert': link},
]);
});
});
group('Paste as Embed', () {
Future<void> pasteAsEmbed(WidgetTester tester, String link) =>
pasteAs(tester, link, PasteMenuType.embed);
Future<void> hoverAndConvert(
WidgetTester tester,
PasteMenuType command,
) async {
final embed = find.byType(LinkEmbedBlockComponent);
expect(embed, findsOneWidget);
await tester.hoverOnWidget(
embed,
onHover: () async {
final menuButton = find.byFlowySvg(FlowySvgs.turninto_m);
await tester.tapButton(menuButton);
final commandButton = find.text(command.title);
await tester.tapButton(commandButton);
},
);
}
testWidgets('paste a link as embed', (tester) async {
final link = avaliableLink;
await preparePage(tester);
await pasteAsEmbed(tester, link);
final node = tester.editor.getNodeAtPath([0]);
checkEmbed(node, link);
});
testWidgets('paste a link as bookmark and convert to mention',
(tester) async {
final link = avaliableLink;
await preparePage(tester);
await pasteAsEmbed(tester, link);
await hoverAndConvert(tester, PasteMenuType.mention);
final node = tester.editor.getNodeAtPath([0]);
checkMention(node, link);
});
testWidgets('paste a link as bookmark and convert to url', (tester) async {
final link = avaliableLink;
await preparePage(tester);
await pasteAsEmbed(tester, link);
await hoverAndConvert(tester, PasteMenuType.url);
final node = tester.editor.getNodeAtPath([0]);
checkUrl(node, link);
});
testWidgets('paste a link as bookmark and convert to bookmark',
(tester) async {
final link = avaliableLink;
await preparePage(tester);
await pasteAsEmbed(tester, link);
await hoverAndConvert(tester, PasteMenuType.bookmark);
final node = tester.editor.getNodeAtPath([0]);
checkBookmark(node, link);
});
});
}
@@ -1,5 +1,6 @@
import 'package:appflowy_editor/appflowy_editor.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';
@@ -47,5 +48,41 @@ void main() {
expect(editorState.selection!.start.offset, 0);
});
testWidgets('select and delete text', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
/// create a new document
await tester.createNewPageWithNameUnderParent();
/// input text
final editor = tester.editor;
final editorState = editor.getCurrentEditorState();
const inputText = 'Test for text selection and deletion';
final texts = inputText.split(' ');
await editor.tapLineOfEditorAt(0);
await tester.ime.insertText(inputText);
/// selecte and delete
int index = 0;
while (texts.isNotEmpty) {
final text = texts.removeAt(0);
await tester.editor.updateSelection(
Selection(
start: Position(path: [0], offset: index),
end: Position(path: [0], offset: index + text.length),
),
);
await tester.simulateKeyEvent(LogicalKeyboardKey.delete);
index++;
}
/// excpete the text value is correct
final node = editorState.getNodeAtPath([0])!;
final nodeText = node.delta?.toPlainText() ?? '';
expect(nodeText, ' ' * (index - 1));
});
});
}
@@ -13,6 +13,7 @@ import 'document_with_multi_image_block_test.dart'
as document_with_multi_image_block_test;
import 'document_with_simple_table_test.dart'
as document_with_simple_table_test;
import 'document_link_preview_test.dart' as document_link_preview_test;
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
@@ -28,4 +29,5 @@ void main() {
document_find_menu_test.main();
document_toolbar_test.main();
document_with_simple_table_test.main();
document_link_preview_test.main();
}
@@ -184,6 +184,45 @@ void main() {
3,
);
});
testWidgets('toolbar will not rebuild after click item', (tester) async {
const text = 'Test rebuilding';
await prepareForToolbar(tester, text);
Finder toolbar = find.byType(DesktopFloatingToolbar);
Element toolbarElement = toolbar.evaluate().first;
final elementHashcode = toolbarElement.hashCode;
final boldButton = find.byFlowySvg(FlowySvgs.toolbar_bold_m),
underlineButton = find.byFlowySvg(FlowySvgs.toolbar_underline_m),
italicButton = find.byFlowySvg(FlowySvgs.toolbar_inline_italic_m);
/// tap format buttons
await tester.tapButton(boldButton);
await tester.tapButton(underlineButton);
await tester.tapButton(italicButton);
toolbar = find.byType(DesktopFloatingToolbar);
toolbarElement = toolbar.evaluate().first;
/// check if the toolbar is not rebuilt
expect(elementHashcode, toolbarElement.hashCode);
final editorState = tester.editor.getCurrentEditorState();
/// check text formats
expect(
editorState
.getDeltaAttributeValueInSelection(AppFlowyRichTextKeys.bold),
true,
);
expect(
editorState
.getDeltaAttributeValueInSelection(AppFlowyRichTextKeys.italic),
true,
);
expect(
editorState
.getDeltaAttributeValueInSelection(AppFlowyRichTextKeys.underline),
true,
);
});
});
group('document toolbar: link', () {
@@ -228,6 +267,7 @@ void main() {
expect(toolbar, findsNothing);
/// show toolbar again
await tester.editor.tapLineOfEditorAt(0);
await selectText(tester, text);
await tester.tapButton(linkButton);
@@ -43,12 +43,15 @@ void main() {
});
group('insert emoji by colon', () {
Future<void> createNewDocumentAndShowEmojiList(WidgetTester tester) async {
Future<void> createNewDocumentAndShowEmojiList(
WidgetTester tester, {
String? search,
}) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
await tester.createNewPageWithNameUnderParent();
await tester.editor.tapLineOfEditorAt(0);
await tester.ime.insertText(':');
await tester.ime.insertText(':${search ?? 'a'}');
await tester.pumpAndSettle(Duration(seconds: 1));
}
@@ -106,11 +109,10 @@ void main() {
});
testWidgets('insert with searching', (tester) async {
await createNewDocumentAndShowEmojiList(tester);
await createNewDocumentAndShowEmojiList(tester, search: 's');
/// search for `smiling eyes`, IME is not working, use keyboard input
final searchText = [
LogicalKeyboardKey.keyS,
LogicalKeyboardKey.keyM,
LogicalKeyboardKey.keyI,
LogicalKeyboardKey.keyL,
@@ -138,16 +140,10 @@ void main() {
});
testWidgets('start searching with sapce', (tester) async {
await createNewDocumentAndShowEmojiList(tester);
await createNewDocumentAndShowEmojiList(tester, search: ' ');
/// emoji list is showing
final emojiHandler = find.byType(EmojiHandler);
expect(emojiHandler, findsOneWidget);
/// input space
await tester.simulateKeyEvent(LogicalKeyboardKey.space);
/// emoji list is dismissed
expect(emojiHandler, findsNothing);
});
});
@@ -54,8 +54,6 @@ extension AppFlowyTestBase on WidgetTester {
final rustEnvs = <String, String>{};
if (cloudType != null) {
switch (cloudType) {
case AuthenticatorType.local:
break;
case AuthenticatorType.appflowyCloudSelfHost:
rustEnvs["GOTRUE_ADMIN_EMAIL"] = "admin@example.com";
rustEnvs["GOTRUE_ADMIN_PASSWORD"] = "password";
@@ -71,9 +69,6 @@ extension AppFlowyTestBase on WidgetTester {
() async {
if (cloudType != null) {
switch (cloudType) {
case AuthenticatorType.local:
await useLocalServer();
break;
case AuthenticatorType.appflowyCloudSelfHost:
await useTestSelfHostedAppFlowyCloud();
getIt.unregister<AuthService>();
@@ -942,6 +942,31 @@ extension AppFlowyDatabaseTest on WidgetTester {
await pumpAndSettle(const Duration(milliseconds: 200));
}
Future<void> changeFieldWidth(String fieldName, double width) async {
final field = find.byWidgetPredicate(
(widget) => widget is GridFieldCell && widget.fieldInfo.name == fieldName,
);
await hoverOnWidget(
field,
onHover: () async {
final dragHandle = find.descendant(
of: field,
matching: find.byType(DragToExpandLine),
);
await drag(dragHandle, Offset(width - getSize(field).width, 0));
await pumpAndSettle(const Duration(milliseconds: 200));
},
);
}
double getFieldWidth(String fieldName) {
final field = find.byWidgetPredicate(
(widget) => widget is GridFieldCell && widget.fieldInfo.name == fieldName,
);
return getSize(field).width;
}
Future<void> findDateEditor(dynamic matcher) async {
final finder = find.byType(DateCellEditor);
expect(finder, matcher);
@@ -307,9 +307,11 @@ class EditorOperations {
Future<void> openTurnIntoMenu(Path path) async {
await hoverAndClickOptionMenuButton(path);
await tester.tapButton(
find.findTextInFlowyText(
LocaleKeys.document_plugins_optionAction_turnInto.tr(),
),
find
.findTextInFlowyText(
LocaleKeys.document_plugins_optionAction_turnInto.tr(),
)
.first,
);
await tester.pumpUntilFound(find.byType(TurnIntoOptionMenu));
}
@@ -23,6 +23,7 @@ class DesktopPromptInput extends StatefulWidget {
required this.selectedSourcesNotifier,
required this.onUpdateSelectedSources,
this.hideDecoration = false,
this.hideFormats = false,
this.extraBottomActionButton,
});
@@ -34,6 +35,7 @@ class DesktopPromptInput extends StatefulWidget {
final ValueNotifier<List<String>> selectedSourcesNotifier;
final void Function(List<String>) onUpdateSelectedSources;
final bool hideDecoration;
final bool hideFormats;
final Widget? extraBottomActionButton;
@override
@@ -139,11 +141,11 @@ class _DesktopPromptInputState extends State<DesktopPromptInput> {
children: [
ConstrainedBox(
constraints: getTextFieldConstraints(
state.showPredefinedFormats,
state.showPredefinedFormats && !widget.hideFormats,
),
child: inputTextField(),
),
if (state.showPredefinedFormats)
if (state.showPredefinedFormats && !widget.hideFormats)
Positioned.fill(
bottom: null,
child: TextFieldTapRegion(
@@ -168,8 +170,9 @@ class _DesktopPromptInputState extends State<DesktopPromptInput> {
top: null,
child: TextFieldTapRegion(
child: _PromptBottomActions(
showPredefinedFormats:
showPredefinedFormatBar:
state.showPredefinedFormats,
showPredefinedFormatButton: !widget.hideFormats,
onTogglePredefinedFormatSection: () =>
context.read<AIPromptInputBloc>().add(
AIPromptInputEvent
@@ -571,7 +574,8 @@ class PromptInputTextField extends StatelessWidget {
class _PromptBottomActions extends StatelessWidget {
const _PromptBottomActions({
required this.sendButtonState,
required this.showPredefinedFormats,
required this.showPredefinedFormatBar,
required this.showPredefinedFormatButton,
required this.onTogglePredefinedFormatSection,
required this.onStartMention,
required this.onSendPressed,
@@ -581,7 +585,8 @@ class _PromptBottomActions extends StatelessWidget {
this.extraBottomActionButton,
});
final bool showPredefinedFormats;
final bool showPredefinedFormatBar;
final bool showPredefinedFormatButton;
final void Function() onTogglePredefinedFormatSection;
final void Function() onStartMention;
final SendButtonState sendButtonState;
@@ -600,10 +605,12 @@ class _PromptBottomActions extends StatelessWidget {
builder: (context, state) {
return Row(
children: [
_predefinedFormatButton(),
const HSpace(
DesktopAIChatSizes.inputActionBarButtonSpacing,
),
if (showPredefinedFormatButton) ...[
_predefinedFormatButton(),
const HSpace(
DesktopAIChatSizes.inputActionBarButtonSpacing,
),
],
SelectModelMenu(
aiModelStateNotifier:
context.read<AIPromptInputBloc>().aiModelStateNotifier,
@@ -641,7 +648,7 @@ class _PromptBottomActions extends StatelessWidget {
Widget _predefinedFormatButton() {
return PromptInputDesktopToggleFormatButton(
showFormatBar: showPredefinedFormats,
showFormatBar: showPredefinedFormatBar,
onTap: onTogglePredefinedFormatSection,
);
}
@@ -173,7 +173,6 @@ class _ModelItem extends StatelessWidget {
model.i18n,
figmaLineHeight: 20,
overflow: TextOverflow.ellipsis,
color: isSelected ? Theme.of(context).colorScheme.primary : null,
),
if (model.desc.isNotEmpty)
FlowyText(
@@ -133,7 +133,6 @@ Future<bool> _afLaunchLocalUri(
};
if (context != null && context.mounted) {
showToastNotification(
context,
message: message,
type: result.type == ResultType.done
? ToastificationType.success
+2
View File
@@ -16,6 +16,7 @@ class AppFlowyConfiguration {
required this.platform,
required this.authenticator_type,
required this.appflowy_cloud_config,
required this.is_anon,
required this.envs,
});
@@ -28,6 +29,7 @@ class AppFlowyConfiguration {
final String origin_app_path;
final String device_id;
final String platform;
final bool is_anon;
final int authenticator_type;
final AppFlowyCloudConfiguration appflowy_cloud_config;
final Map<String, String> envs;
+1 -21
View File
@@ -23,9 +23,6 @@ import 'package:appflowy_backend/log.dart';
///
Future<void> _setAuthenticatorType(AuthenticatorType ty) async {
switch (ty) {
case AuthenticatorType.local:
await getIt<KeyValueStorage>().set(KVKeys.kCloudType, 0.toString());
break;
case AuthenticatorType.appflowyCloud:
await getIt<KeyValueStorage>().set(KVKeys.kCloudType, 2.toString());
break;
@@ -63,8 +60,6 @@ Future<AuthenticatorType> getAuthenticatorType() async {
}
switch (value ?? "0") {
case "0":
return AuthenticatorType.local;
case "2":
return AuthenticatorType.appflowyCloud;
case "3":
@@ -100,24 +95,17 @@ bool get isAuthEnabled {
return false;
}
bool get isLocalAuthEnabled {
return currentCloudType().isLocal;
}
/// Determines if AppFlowy Cloud is enabled.
bool get isAppFlowyCloudEnabled {
return currentCloudType().isAppFlowyCloudEnabled;
}
enum AuthenticatorType {
local,
appflowyCloud,
appflowyCloudSelfHost,
// The 'appflowyCloudDevelop' type is used for develop purposes only.
appflowyCloudDevelop;
bool get isLocal => this == AuthenticatorType.local;
bool get isAppFlowyCloudEnabled =>
this == AuthenticatorType.appflowyCloudSelfHost ||
this == AuthenticatorType.appflowyCloudDevelop ||
@@ -125,8 +113,6 @@ enum AuthenticatorType {
int get value {
switch (this) {
case AuthenticatorType.local:
return 0;
case AuthenticatorType.appflowyCloud:
return 2;
case AuthenticatorType.appflowyCloudSelfHost:
@@ -138,8 +124,6 @@ enum AuthenticatorType {
static AuthenticatorType fromValue(int value) {
switch (value) {
case 0:
return AuthenticatorType.local;
case 2:
return AuthenticatorType.appflowyCloud;
case 3:
@@ -147,7 +131,7 @@ enum AuthenticatorType {
case 4:
return AuthenticatorType.appflowyCloudDevelop;
default:
return AuthenticatorType.local;
return AuthenticatorType.appflowyCloud;
}
}
}
@@ -180,10 +164,6 @@ Future<void> useAppFlowyBetaCloudWithURL(
await _setAppFlowyCloudUrl(url);
}
Future<void> useLocalServer() async {
await _setAuthenticatorType(AuthenticatorType.local);
}
// Use getIt<AppFlowyCloudSharedEnv>() to get the shared environment.
class AppFlowyCloudSharedEnv {
AppFlowyCloudSharedEnv({
@@ -16,6 +16,8 @@ const double _kMinimumWidth = 112.0;
const double _kDefaultHorizontalPadding = 12.0;
typedef CompareFunction<T> = bool Function(T? left, T? right);
// Navigation shortcuts to move the selected menu items up or down.
final Map<ShortcutActivator, Intent> _kMenuTraversalShortcuts =
<ShortcutActivator, Intent>{
@@ -86,6 +88,7 @@ class AFDropdownMenu<T> extends StatefulWidget {
this.requestFocusOnTap,
this.expandedInsets,
this.searchCallback,
this.selectOptionCompare,
required this.dropdownMenuEntries,
});
@@ -267,6 +270,11 @@ class AFDropdownMenu<T> extends StatefulWidget {
/// which contains the contents of the text input field.
final SearchCallback<T>? searchCallback;
/// Defines the compare function for the menu items.
///
/// Defaults to null. If this is null, the menu items will be sorted by the label.
final CompareFunction<T>? selectOptionCompare;
@override
State<AFDropdownMenu<T>> createState() => _AFDropdownMenuState<T>();
}
@@ -301,7 +309,16 @@ class _AFDropdownMenuState<T> extends State<AFDropdownMenu<T>> {
filteredEntries.any((DropdownMenuEntry<T> entry) => entry.enabled);
final int index = filteredEntries.indexWhere(
(DropdownMenuEntry<T> entry) => entry.value == widget.initialSelection,
(DropdownMenuEntry<T> entry) {
if (widget.selectOptionCompare != null) {
return widget.selectOptionCompare!(
entry.value,
widget.initialSelection,
);
} else {
return entry.value == widget.initialSelection;
}
},
);
if (index != -1) {
_textEditingController.value = TextEditingValue(
@@ -336,7 +336,6 @@ class _MobileViewPageState extends State<MobileViewPage> {
listener: (context, state) {
if (state.isLocked) {
showToastNotification(
context,
message: LocaleKeys.lockPage_pageLockedToast.tr(),
);
@@ -366,7 +365,6 @@ class _MobileViewPageState extends State<MobileViewPage> {
listener: (context, state) {
if (state.isLocked) {
showToastNotification(
context,
message: LocaleKeys.lockPage_pageLockedToast.tr(),
);
}
@@ -161,7 +161,6 @@ class MobileViewPageMoreBottomSheet extends StatelessWidget {
context.pop();
showToastNotification(
context,
message: LocaleKeys.button_duplicateSuccessfully.tr(),
);
}
@@ -170,7 +169,6 @@ class MobileViewPageMoreBottomSheet extends StatelessWidget {
_toggleFavorite(context);
showToastNotification(
context,
message: LocaleKeys.button_favoriteSuccessfully.tr(),
);
}
@@ -179,7 +177,6 @@ class MobileViewPageMoreBottomSheet extends StatelessWidget {
_toggleFavorite(context);
showToastNotification(
context,
message: LocaleKeys.button_unfavoriteSuccessfully.tr(),
);
}
@@ -202,8 +199,7 @@ class MobileViewPageMoreBottomSheet extends StatelessWidget {
),
);
showToastNotification(
context,
message: LocaleKeys.grid_url_copy.tr(),
message: LocaleKeys.message_copy_success.tr(),
);
}
}
@@ -234,12 +230,10 @@ class MobileViewPageMoreBottomSheet extends StatelessWidget {
),
);
showToastNotification(
context,
message: LocaleKeys.shareAction_copyLinkSuccess.tr(),
);
} else {
showToastNotification(
context,
message: LocaleKeys.shareAction_copyLinkToBlockFailed.tr(),
type: ToastificationType.error,
);
@@ -323,11 +317,9 @@ class MobileViewPageMoreBottomSheet extends StatelessWidget {
if (state.publishResult != null) {
state.publishResult!.fold(
(value) => showToastNotification(
context,
message: LocaleKeys.publish_publishSuccessfully.tr(),
),
(error) => showToastNotification(
context,
message: '${LocaleKeys.publish_publishFailed.tr()}: ${error.code}',
type: ToastificationType.error,
),
@@ -335,11 +327,9 @@ class MobileViewPageMoreBottomSheet extends StatelessWidget {
} else if (state.unpublishResult != null) {
state.unpublishResult!.fold(
(value) => showToastNotification(
context,
message: LocaleKeys.publish_unpublishSuccessfully.tr(),
),
(error) => showToastNotification(
context,
message: LocaleKeys.publish_unpublishFailed.tr(),
description: error.msg,
type: ToastificationType.error,
@@ -349,7 +339,6 @@ class MobileViewPageMoreBottomSheet extends StatelessWidget {
state.updatePathNameResult!.onSuccess(
(value) {
showToastNotification(
context,
message:
LocaleKeys.settings_sites_success_updatePathNameSuccess.tr(),
);
@@ -65,7 +65,6 @@ class _MobileViewItemBottomSheetState extends State<MobileViewItemBottomSheet> {
Navigator.pop(context);
context.read<ViewBloc>().add(const ViewEvent.duplicate());
showToastNotification(
context,
message: LocaleKeys.button_duplicateSuccessfully.tr(),
);
break;
@@ -84,7 +83,6 @@ class _MobileViewItemBottomSheetState extends State<MobileViewItemBottomSheet> {
.read<FavoriteBloc>()
.add(FavoriteEvent.toggle(widget.view));
showToastNotification(
context,
message: !widget.view.isFavorite
? LocaleKeys.button_favoriteSuccessfully.tr()
: LocaleKeys.button_unfavoriteSuccessfully.tr(),
@@ -146,7 +144,6 @@ class _MobileViewItemBottomSheetState extends State<MobileViewItemBottomSheet> {
Navigator.pop(context);
showToastNotification(
context,
message: LocaleKeys.sideBar_removeSuccess.tr(),
);
},
@@ -45,7 +45,6 @@ enum MobilePaneActionType {
size: 24.0,
onPressed: (context) {
showToastNotification(
context,
message: LocaleKeys.button_unfavoriteSuccessfully.tr(),
);
@@ -61,7 +60,6 @@ enum MobilePaneActionType {
size: 24.0,
onPressed: (context) {
showToastNotification(
context,
message: LocaleKeys.button_favoriteSuccessfully.tr(),
);
@@ -103,7 +103,7 @@ class _OpenRowPageButtonState extends State<OpenRowPageButton> {
Log.info('Open row page(${widget.documentId})');
if (view == null) {
showToastNotification(context, message: 'Failed to open row page');
showToastNotification(message: 'Failed to open row page');
// reload the view again
unawaited(_preloadView(context));
Log.error('Failed to open row page(${widget.documentId})');
@@ -329,7 +329,7 @@ class _HomePageState extends State<_HomePage> {
}
if (message != null) {
showToastNotification(context, message: message, type: toastType);
showToastNotification(message: message, type: toastType);
}
}
}
@@ -16,6 +16,7 @@ enum _MobileSettingsPopupMenuItem {
members,
trash,
help,
helpAndDocumentation,
}
class HomePageSettingsPopupMenu extends StatelessWidget {
@@ -62,10 +63,16 @@ class HomePageSettingsPopupMenu extends StatelessWidget {
text: LocaleKeys.settings_popupMenuItem_trash.tr(),
),
const PopupMenuDivider(height: 0.5),
_buildItem(
value: _MobileSettingsPopupMenuItem.helpAndDocumentation,
svg: FlowySvgs.help_and_documentation_s,
text: LocaleKeys.settings_popupMenuItem_helpAndDocumentation.tr(),
),
const PopupMenuDivider(height: 0.5),
_buildItem(
value: _MobileSettingsPopupMenuItem.help,
svg: FlowySvgs.message_support_s,
text: LocaleKeys.settings_popupMenuItem_helpAndSupport.tr(),
text: LocaleKeys.settings_popupMenuItem_getSupport.tr(),
),
],
onSelected: (_MobileSettingsPopupMenuItem value) {
@@ -82,6 +89,9 @@ class HomePageSettingsPopupMenu extends StatelessWidget {
case _MobileSettingsPopupMenuItem.help:
_openHelpPage(context);
break;
case _MobileSettingsPopupMenuItem.helpAndDocumentation:
_openHelpAndDocumentationPage(context);
break;
}
},
child: const Padding(
@@ -123,6 +133,10 @@ class HomePageSettingsPopupMenu extends StatelessWidget {
void _openSettingsPage(BuildContext context) {
context.push(MobileHomeSettingPage.routeName);
}
void _openHelpAndDocumentationPage(BuildContext context) {
afLaunchUrlString('https://appflowy.com/guide');
}
}
class _PopupButton extends StatelessWidget {
@@ -339,7 +339,6 @@ class _SpaceMenuItemTrailingState extends State<SpaceMenuItemTrailing> {
context.read<SpaceBloc>().add(const SpaceEvent.duplicate());
showToastNotification(
context,
message: LocaleKeys.space_success_duplicateSpace.tr(),
);
@@ -374,7 +373,6 @@ class _SpaceMenuItemTrailingState extends State<SpaceMenuItemTrailing> {
.add(SpaceEvent.rename(space: widget.space, name: name));
showToastNotification(
context,
message: LocaleKeys.space_success_renameSpace.tr(),
);
},
@@ -424,7 +422,6 @@ class _SpaceMenuItemTrailingState extends State<SpaceMenuItemTrailing> {
);
showToastNotification(
context,
message: LocaleKeys.space_success_updateSpace.tr(),
);
@@ -457,7 +454,6 @@ class _SpaceMenuItemTrailingState extends State<SpaceMenuItemTrailing> {
context.read<SpaceBloc>().add(SpaceEvent.delete(widget.space));
showToastNotification(
context,
message: LocaleKeys.space_success_deleteSpace.tr(),
);
@@ -102,7 +102,7 @@ class MobileInlineActionsWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
final hasIcon = item.icon != null;
final hasIcon = item.iconBuilder != null;
return Container(
height: 36,
decoration: BoxDecoration(
@@ -119,7 +119,7 @@ class MobileInlineActionsWidget extends StatelessWidget {
child: Row(
children: [
if (hasIcon) ...[
item.icon!.call(isSelected),
item.iconBuilder!.call(isSelected),
SizedBox(width: 12),
],
Flexible(
@@ -332,7 +332,6 @@ class _NotificationNavigationBar extends StatelessWidget {
}
showToastNotification(
context,
message: LocaleKeys
.settings_notifications_markAsReadNotifications_allSuccess
.tr(),
@@ -350,7 +349,6 @@ class _NotificationNavigationBar extends StatelessWidget {
}
showToastNotification(
context,
message: LocaleKeys.settings_notifications_archiveNotifications_allSuccess
.tr(),
);
@@ -108,7 +108,6 @@ class NotificationSettingsPopupMenu extends StatelessWidget {
void _onMarkAllAsRead(BuildContext context) {
showToastNotification(
context,
message: LocaleKeys
.settings_notifications_markAsReadNotifications_allSuccess
.tr(),
@@ -119,7 +118,6 @@ class NotificationSettingsPopupMenu extends StatelessWidget {
void _onArchiveAll(BuildContext context) {
showToastNotification(
context,
message: LocaleKeys.settings_notifications_archiveNotifications_allSuccess
.tr(),
);
@@ -133,7 +131,6 @@ class NotificationSettingsPopupMenu extends StatelessWidget {
}
showToastNotification(
context,
message: 'Unarchive all success (Debug Mode)',
);
@@ -31,7 +31,6 @@ enum NotificationPaneActionType {
size: 24.0,
onPressed: (context) {
showToastNotification(
context,
message: LocaleKeys
.settings_notifications_markAsReadNotifications_success
.tr(),
@@ -55,7 +54,6 @@ enum NotificationPaneActionType {
size: 24.0,
onPressed: (context) {
showToastNotification(
context,
message: 'Unarchive notification success',
);
@@ -168,7 +166,6 @@ class _NotificationMoreActions extends StatelessWidget {
Navigator.of(context).pop();
showToastNotification(
context,
message: LocaleKeys.settings_notifications_markAsReadNotifications_success
.tr(),
);
@@ -191,7 +188,6 @@ class _NotificationMoreActions extends StatelessWidget {
void _onArchive(BuildContext context) {
showToastNotification(
context,
message: LocaleKeys.settings_notifications_archiveNotifications_success
.tr()
.tr(),
@@ -74,7 +74,6 @@ class _NotificationTabState extends State<NotificationTab>
if (context.mounted) {
showToastNotification(
context,
message: LocaleKeys.settings_notifications_refreshSuccess.tr(),
);
}
@@ -25,14 +25,14 @@ class AboutSettingGroup extends StatelessWidget {
trailing: const Icon(
Icons.chevron_right,
),
onTap: () => afLaunchUrlString('https://appflowy.io/privacy'),
onTap: () => afLaunchUrlString('https://appflowy.com/privacy'),
),
MobileSettingItem(
name: LocaleKeys.settings_mobile_termsAndConditions.tr(),
trailing: const Icon(
Icons.chevron_right,
),
onTap: () => afLaunchUrlString('https://appflowy.io/terms'),
onTap: () => afLaunchUrlString('https://appflowy.com/terms'),
),
if (kDebugMode)
MobileSettingItem(
@@ -81,7 +81,6 @@ class SupportSettingGroup extends StatelessWidget {
);
if (context.mounted) {
showToastNotification(
context,
message: LocaleKeys.settings_files_clearCacheSuccess.tr(),
);
}
@@ -1,9 +1,7 @@
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/widgets/show_flowy_mobile_confirm_dialog.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/user/application/auth/auth_service.dart';
import 'package:appflowy/user/application/sign_in_bloc.dart';
import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/widgets.dart';
import 'package:appflowy/workspace/presentation/settings/pages/account/account_deletion.dart';
@@ -32,12 +30,6 @@ class UserSessionSettingGroup extends StatelessWidget {
if (showThirdPartyLogin) _buildThirdPartySignInButtons(context),
const VSpace(8.0),
// logout button
MobileLogoutButton(
text: LocaleKeys.settings_menu_logout.tr(),
onPressed: () async => _showLogoutDialog(),
),
// delete account button
// only show the delete account button in cloud mode
if (userProfile.authenticator == AuthenticatorPB.AppFlowyCloud) ...[
@@ -79,31 +71,6 @@ class UserSessionSettingGroup extends StatelessWidget {
builder: (_) => const _DeleteAccountBottomSheet(),
);
}
Future<void> _showLogoutDialog() async {
return showFlowyCupertinoConfirmDialog(
title: LocaleKeys.settings_menu_logoutPrompt.tr(),
leftButton: FlowyText(
LocaleKeys.button_cancel.tr(),
fontSize: 17.0,
figmaLineHeight: 24.0,
fontWeight: FontWeight.w500,
color: const Color(0xFF007AFF),
),
rightButton: FlowyText(
LocaleKeys.button_logout.tr(),
fontSize: 17.0,
figmaLineHeight: 24.0,
fontWeight: FontWeight.w400,
color: const Color(0xFFFE0220),
),
onRightButtonPressed: (context) async {
Navigator.of(context).pop();
await getIt<AuthService>().signOut();
await runAppFlowy();
},
);
}
}
class _DeleteAccountBottomSheet extends StatefulWidget {
@@ -201,7 +201,6 @@ class _InviteMemberPageState extends State<_InviteMemberPage> {
result.fold(
(s) {
showToastNotification(
context,
message:
LocaleKeys.settings_appearance_members_addMemberSuccess.tr(),
bottomPadding: keyboardHeight,
@@ -218,7 +217,6 @@ class _InviteMemberPageState extends State<_InviteMemberPage> {
exceededLimit = f.code == ErrorCode.WorkspaceMemberLimitExceeded;
});
showToastNotification(
context,
type: ToastificationType.error,
bottomPadding: keyboardHeight,
message: message,
@@ -229,7 +227,6 @@ class _InviteMemberPageState extends State<_InviteMemberPage> {
result.fold(
(s) {
showToastNotification(
context,
message:
LocaleKeys.settings_appearance_members_inviteMemberSuccess.tr(),
bottomPadding: keyboardHeight,
@@ -247,7 +244,6 @@ class _InviteMemberPageState extends State<_InviteMemberPage> {
exceededLimit = f.code == ErrorCode.WorkspaceMemberLimitExceeded;
});
showToastNotification(
context,
type: ToastificationType.error,
message: message,
bottomPadding: keyboardHeight,
@@ -258,7 +254,6 @@ class _InviteMemberPageState extends State<_InviteMemberPage> {
result.fold(
(s) {
showToastNotification(
context,
message: LocaleKeys
.settings_appearance_members_removeFromWorkspaceSuccess
.tr(),
@@ -267,7 +262,6 @@ class _InviteMemberPageState extends State<_InviteMemberPage> {
},
(f) {
showToastNotification(
context,
type: ToastificationType.error,
message: LocaleKeys
.settings_appearance_members_removeFromWorkspaceFailed
@@ -282,11 +276,11 @@ class _InviteMemberPageState extends State<_InviteMemberPage> {
void _inviteMember(BuildContext context) {
final email = emailController.text;
if (!isEmail(email)) {
return showToastNotification(
context,
showToastNotification(
type: ToastificationType.error,
message: LocaleKeys.settings_appearance_members_emailInvalidError.tr(),
);
return;
}
context
.read<WorkspaceMemberBloc>()
@@ -184,8 +184,7 @@ class CopyButton extends StatelessWidget {
);
if (context.mounted) {
showToastNotification(
context,
message: LocaleKeys.grid_url_copiedNotification.tr(),
message: LocaleKeys.message_copy_success.tr(),
);
}
},
@@ -376,8 +376,7 @@ class ChatAIMessagePopup extends StatelessWidget {
}
if (context.mounted) {
showToastNotification(
context,
message: LocaleKeys.grid_url_copiedNotification.tr(),
message: LocaleKeys.message_copy_success.tr(),
);
}
},
@@ -14,7 +14,6 @@ import 'package:universal_platform/universal_platform.dart';
void openPageFromMessage(BuildContext context, ViewPB? view) {
if (view == null) {
showToastNotification(
context,
message: LocaleKeys.chat_openPagePreviewFailedToast.tr(),
type: ToastificationType.error,
);
@@ -36,7 +35,6 @@ void showSaveMessageSuccessToast(BuildContext context, ViewPB? view) {
return;
}
showToastNotification(
context,
richMessage: TextSpan(
children: [
TextSpan(
@@ -241,6 +241,11 @@ class SelectOptionCellEditorBloc
} else if (!state.selectedOptions
.any((option) => option.id == focusedOptionId)) {
_selectOptionService.select(optionIds: [focusedOptionId]);
emit(
state.copyWith(
clearFilter: true,
),
);
}
}
@@ -411,23 +411,28 @@ class FieldController {
/// Listen for field setting changes in the backend.
void _listenOnFieldSettingsChanged() {
FieldInfo? updateFieldSettings(FieldSettingsPB updatedFieldSettings) {
final List<FieldInfo> newFields = fieldInfos;
var updatedField = newFields.firstOrNull;
final newFields = [...fieldInfos];
if (updatedField == null) {
if (newFields.isEmpty) {
return null;
}
final index = newFields
.indexWhere((field) => field.id == updatedFieldSettings.fieldId);
if (index != -1) {
newFields[index] =
newFields[index].copyWith(fieldSettings: updatedFieldSettings);
updatedField = newFields[index];
_fieldNotifier.fieldInfos = newFields;
_fieldSettings
..removeWhere(
(field) => field.fieldId == updatedFieldSettings.fieldId,
)
..add(updatedFieldSettings);
return newFields[index];
}
_fieldNotifier.fieldInfos = newFields;
return updatedField;
return null;
}
_fieldSettingsListener.start(
@@ -108,7 +108,7 @@ class _GridFieldCellState extends State<GridFieldCell> {
top: 0,
bottom: 0,
right: 0,
child: _DragToExpandLine(),
child: DragToExpandLine(),
);
return _GridHeaderCellContainer(
@@ -158,8 +158,11 @@ class _GridHeaderCellContainer extends StatelessWidget {
}
}
class _DragToExpandLine extends StatelessWidget {
const _DragToExpandLine();
@visibleForTesting
class DragToExpandLine extends StatelessWidget {
const DragToExpandLine({
super.key,
});
@override
Widget build(BuildContext context) {
@@ -1,5 +1,3 @@
import 'dart:io';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/database/application/cell/bloc/checklist_cell_bloc.dart';
@@ -16,6 +14,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:provider/provider.dart';
import 'package:universal_platform/universal_platform.dart';
import '../editable_cell_skeleton/checklist.dart';
@@ -201,19 +200,16 @@ class _ChecklistItems extends StatelessWidget {
physics: const NeverScrollableScrollPhysics(),
proxyDecorator: (child, index, _) => Material(
color: Colors.transparent,
child: Stack(
children: [
BlocProvider.value(
child: MouseRegion(
cursor: UniversalPlatform.isWindows
? SystemMouseCursors.click
: SystemMouseCursors.grabbing,
child: IgnorePointer(
child: BlocProvider.value(
value: context.read<ChecklistCellBloc>(),
child: child,
),
MouseRegion(
cursor: Platform.isWindows
? SystemMouseCursors.click
: SystemMouseCursors.grabbing,
child: const SizedBox.expand(),
),
],
),
),
),
buildDefaultDragHandles: false,
@@ -203,7 +203,7 @@ class MobileURLEditor extends StatelessWidget {
ClipboardData(text: textEditingController.text),
);
Fluttertoast.showToast(
msg: LocaleKeys.grid_url_copiedNotification.tr(),
msg: LocaleKeys.message_copy_success.tr(),
gravity: ToastGravity.BOTTOM,
);
context.pop();
@@ -14,6 +14,7 @@ import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:universal_platform/universal_platform.dart';
import '../../application/cell/bloc/checklist_cell_bloc.dart';
import 'checklist_cell_textfield.dart';
@@ -125,19 +126,16 @@ class ChecklistItemList extends StatelessWidget {
shrinkWrap: true,
proxyDecorator: (child, index, _) => Material(
color: Colors.transparent,
child: Stack(
children: [
BlocProvider.value(
child: MouseRegion(
cursor: UniversalPlatform.isWindows
? SystemMouseCursors.click
: SystemMouseCursors.grabbing,
child: IgnorePointer(
child: BlocProvider.value(
value: context.read<ChecklistCellBloc>(),
child: child,
),
MouseRegion(
cursor: Platform.isWindows
? SystemMouseCursors.click
: SystemMouseCursors.grabbing,
child: const SizedBox.expand(),
),
],
),
),
),
buildDefaultDragHandles: false,
@@ -272,7 +272,9 @@ class DocumentBloc extends Bloc<DocumentEvent, DocumentState> {
}
if (options.inMemoryUpdate) {
Log.trace('skip transaction for in-memory update');
if (enableDocumentInternalLog) {
Log.trace('skip transaction for in-memory update');
}
return;
}
@@ -440,7 +442,6 @@ class DocumentBloc extends Bloc<DocumentEvent, DocumentState> {
final context = AppGlobals.rootNavKey.currentContext;
if (context != null && context.mounted) {
showToastNotification(
context,
message: 'document integrity check failed',
type: ToastificationType.error,
);
@@ -16,6 +16,7 @@ import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:universal_platform/universal_platform.dart';
import 'editor_plugins/link_preview/custom_link_preview_block_component.dart';
import 'editor_plugins/page_block/custom_page_block_component.dart';
/// A global configuration for the editor.
@@ -969,11 +970,11 @@ OutlineBlockComponentBuilder _buildOutlineBlockComponentBuilder(
);
}
LinkPreviewBlockComponentBuilder _buildLinkPreviewBlockComponentBuilder(
CustomLinkPreviewBlockComponentBuilder _buildLinkPreviewBlockComponentBuilder(
BuildContext context,
BlockComponentConfiguration configuration,
) {
return LinkPreviewBlockComponentBuilder(
return CustomLinkPreviewBlockComponentBuilder(
configuration: configuration.copyWith(
padding: (node) {
if (UniversalPlatform.isMobile) {
@@ -983,20 +984,6 @@ LinkPreviewBlockComponentBuilder _buildLinkPreviewBlockComponentBuilder(
},
),
cache: LinkPreviewDataCache(),
showMenu: true,
menuBuilder: (context, node, state) => Positioned(
top: 10,
right: 0,
child: LinkPreviewMenu(node: node, state: state),
),
builder: (_, node, url, title, description, imageUrl) =>
CustomLinkPreviewWidget(
node: node,
url: url,
title: title,
description: description,
imageUrl: imageUrl,
),
);
}
@@ -432,44 +432,50 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage>
);
}
return Center(
child: FloatingToolbar(
floatingToolbarHeight: 40,
padding: EdgeInsets.symmetric(horizontal: 6),
style: FloatingToolbarStyle(
backgroundColor: Theme.of(context).cardColor,
toolbarActiveColor: Color(0xffe0f8fd),
),
items: toolbarItems,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: Theme.of(context).cardColor,
boxShadow: [
BoxShadow(
offset: Offset(0, 4),
blurRadius: 24,
color: themeV2.shadow_medium,
child: BlocProvider.value(
value: context.read<DocumentBloc>(),
child: FloatingToolbar(
floatingToolbarHeight: 40,
padding: EdgeInsets.symmetric(horizontal: 6),
style: FloatingToolbarStyle(
backgroundColor: Theme.of(context).cardColor,
toolbarActiveColor: Color(0xffe0f8fd),
),
items: toolbarItems,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: Theme.of(context).cardColor,
boxShadow: [
BoxShadow(
offset: Offset(0, 4),
blurRadius: 24,
color: themeV2.shadow_medium,
),
],
),
toolbarBuilder: (_, child, onDismiss, isMetricsChanged) =>
BlocProvider.value(
value: context.read<DocumentBloc>(),
child: DesktopFloatingToolbar(
editorState: editorState,
onDismiss: onDismiss,
enableAnimation: !isMetricsChanged,
child: child,
),
],
),
toolbarBuilder: (context, child, onDismiss, isMetricsChanged) =>
DesktopFloatingToolbar(
),
placeHolderBuilder: (_) => customPlaceholderItem,
editorState: editorState,
onDismiss: onDismiss,
enableAnimation: !isMetricsChanged,
child: child,
editorScrollController: editorScrollController,
textDirection: textDirection,
tooltipBuilder: (context, id, message, child) =>
widget.styleCustomizer.buildToolbarItemTooltip(
context,
id,
message,
child,
),
child: editor,
),
placeHolderBuilder: (_) => customPlaceholderItem,
editorState: editorState,
editorScrollController: editorScrollController,
textDirection: textDirection,
tooltipBuilder: (context, id, message, child) =>
widget.styleCustomizer.buildToolbarItemTooltip(
context,
id,
message,
child,
),
child: editor,
),
);
}
@@ -490,6 +490,12 @@ class MainContentArea extends StatelessWidget {
return DesktopPromptInput(
isStreaming: false,
hideDecoration: true,
hideFormats: [
AiWriterCommand.fixSpellingAndGrammar,
AiWriterCommand.improveWriting,
AiWriterCommand.makeLonger,
AiWriterCommand.makeShorter,
].contains(state.command),
textController: textController,
onSubmitted: (message, format, _) {
cubit.runCommand(state.command, message, format);
@@ -150,7 +150,6 @@ class _AiWriterToolbarActionListState extends State<AiWriterToolbarActionList> {
});
} else {
showToastNotification(
context,
message: LocaleKeys.document_plugins_appflowyAIEditDisabled.tr(),
);
}
@@ -196,7 +195,6 @@ class ImproveWritingButton extends StatelessWidget {
_insertAiNode(editorState, AiWriterCommand.improveWriting);
} else {
showToastNotification(
context,
message: LocaleKeys.document_plugins_appflowyAIEditDisabled.tr(),
);
}
@@ -3,6 +3,7 @@ import 'dart:async';
import 'package:appflowy/ai/ai.dart';
import 'package:appflowy/workspace/application/view/view_service.dart';
import 'package:appflowy_backend/dispatch/dispatch.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-ai/protobuf.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_result/appflowy_result.dart';
@@ -15,6 +16,11 @@ import 'ai_writer_block_operations.dart';
import 'ai_writer_entities.dart';
import 'ai_writer_node_extension.dart';
/// Enable the debug log for the AiWriterCubit.
///
/// This is useful for debugging the AI writer cubit.
const _aiWriterCubitDebugLog = false;
class AiWriterCubit extends Cubit<AiWriterState> {
AiWriterCubit({
required this.documentId,
@@ -51,8 +57,13 @@ class AiWriterCubit extends Cubit<AiWriterState> {
bool withDiscard = true,
bool withUnformat = true,
}) async {
if (aiWriterNode == null) {
return;
}
if (withDiscard) {
await _textRobot.discard();
await _textRobot.discard(
afterSelection: aiWriterNode!.aiWriterSelection,
);
}
_textRobot.clear();
_textRobot.reset();
@@ -95,6 +106,10 @@ class AiWriterCubit extends Cubit<AiWriterState> {
final command = node.aiWriterCommand;
final (run, prompt) = await _addSelectionTextToRecords(command);
_aiWriterCubitLog(
'command: $command, run: $run, prompt: $prompt',
);
if (!run) {
await exit();
return;
@@ -211,20 +226,32 @@ class AiWriterCubit extends Cubit<AiWriterState> {
return;
}
// Accept
//
// If the user clicks accept, we need to replace the selection with the AI's response
if (action case SuggestionAction.accept) {
await _textRobot.persist();
// trim the markdown text to avoid extra new lines
final trimmedMarkdownText = _textRobot.markdownText.trim();
_aiWriterCubitLog(
'trigger accept action, markdown text: $trimmedMarkdownText',
);
await formatSelection(
editorState,
selection,
ApplySuggestionFormatType.clear,
);
final nodes = editorState.getNodesInSelection(selection);
final transaction = editorState.transaction..deleteNodes(nodes);
await editorState.apply(
transaction,
withUpdateSelection: false,
await _textRobot.deleteAINodes();
await _textRobot.replace(
selection: selection,
markdownText: trimmedMarkdownText,
);
await exit(withDiscard: false, withUnformat: false);
return;
}
@@ -276,17 +303,24 @@ class AiWriterCubit extends Cubit<AiWriterState> {
AiWriterCommand command,
) async {
final node = aiWriterNode;
// check the node is registered
if (node == null) {
return (false, '');
}
// check the selection is valid
final selection = node.aiWriterSelection?.normalized;
if (selection == null) {
return (false, '');
}
// if the command is continue writing, we don't need to get the selection text
if (command == AiWriterCommand.continueWriting) {
return (true, '');
}
// if the selection is collapsed, we don't need to get the selection text
if (selection.isCollapsed) {
return (true, '');
}
@@ -297,6 +331,7 @@ class AiWriterCubit extends Cubit<AiWriterState> {
records.add(
AiWriterRecord.user(content: selectionText, format: null),
);
return (true, '');
} else {
return (true, selectionText);
@@ -540,6 +575,10 @@ class AiWriterCubit extends Cubit<AiWriterState> {
attributes: ApplySuggestionFormatType.replace.attributes,
);
onAppendToDocument?.call();
_aiWriterCubitLog(
'received message: $text',
);
},
processAssistMessage: (text) async {
if (state case final GeneratingAiWriterState generatingState) {
@@ -551,6 +590,10 @@ class AiWriterCubit extends Cubit<AiWriterState> {
),
);
}
_aiWriterCubitLog(
'received assist message: $text',
);
},
onEnd: () async {
if (state case final GeneratingAiWriterState generatingState) {
@@ -567,6 +610,10 @@ class AiWriterCubit extends Cubit<AiWriterState> {
records.add(
AiWriterRecord.ai(content: _textRobot.markdownText),
);
_aiWriterCubitLog(
'returned response: ${_textRobot.markdownText}',
);
}
},
onError: (error) async {
@@ -658,6 +705,12 @@ class AiWriterCubit extends Cubit<AiWriterState> {
);
}
}
void _aiWriterCubitLog(String message) {
if (_aiWriterCubitDebugLog) {
Log.debug('[AiWriterCubit] $message');
}
}
}
mixin RegisteredAiWriter {
@@ -57,11 +57,30 @@ extension AiWriterNodeExtension on EditorState {
slicedNodes.add(copiedNode);
}
for (final (i, node) in slicedNodes.indexed) {
final childNodesShouldBeDeleted = <Node>[];
for (final child in node.children) {
if (!child.path.inSelection(selection)) {
childNodesShouldBeDeleted.add(child);
}
}
for (final child in childNodesShouldBeDeleted) {
slicedNodes[i] = node.copyWith(
children: node.children.where((e) => e.id != child.id).toList(),
type: selection.startIndex != 0 ? ParagraphBlockKeys.type : node.type,
);
}
}
// use \n\n as line break to improve the ai response
// using \n will cause the ai response treat the text as a single line
final markdown = await customDocumentToMarkdown(
Document.blank()..insert([0], slicedNodes),
lineBreak: '\n\n',
);
return markdown;
// trim the last \n if it exists
return markdown.trimRight();
}
List<String> getPlainTextInSelection(Selection? selection) {
@@ -40,6 +40,7 @@ class _AiWriterScrollWrapperState extends State<AiWriterScrollWrapper> {
onRemoveNode: () {
aiWriterRegistered = false;
widget.editorState.service.keyboardService?.enableShortcuts();
widget.editorState.service.keyboardService?.enable();
},
onAppendToDocument: onAppendToDocument,
);
@@ -208,11 +209,26 @@ class _AiWriterScrollWrapperState extends State<AiWriterScrollWrapper> {
throttler.call(() {
if (aiWriterCubit.aiWriterNode != null) {
final path = aiWriterCubit.aiWriterNode!.path;
if (path.isNotEmpty) {
widget.editorState.updateSelectionWithReason(
Selection.collapsed(Position(path: path)),
);
if (path.isEmpty) {
return;
}
if (path.previous.isNotEmpty) {
final node = widget.editorState.getNodeAtPath(path.previous);
if (node != null && node.delta != null && node.delta!.isNotEmpty) {
widget.editorState.updateSelectionWithReason(
Selection.collapsed(
Position(path: path, offset: node.delta!.length),
),
);
return;
}
}
widget.editorState.updateSelectionWithReason(
Selection.collapsed(Position(path: path)),
);
}
});
}
@@ -70,13 +70,12 @@ extension InsertDatabase on EditorState {
node,
selection.end.offset,
0,
r'$',
attributes: {
MentionBlockKeys.mention: {
MentionBlockKeys.type: MentionType.page.name,
MentionBlockKeys.pageId: view.id,
},
},
MentionBlockKeys.mentionChar,
attributes: MentionBlockKeys.buildMentionPageAttributes(
mentionType: MentionType.page,
pageId: view.id,
blockId: null,
),
);
}
@@ -110,12 +110,15 @@ class MarkdownTextRobot {
}
/// Persist the text into the document
Future<void> persist({String? markdownText}) async {
Future<void> persist({
String? markdownText,
}) async {
if (markdownText != null) {
_markdownText = markdownText;
}
await _lock.synchronized(() async {
await _refresh(inMemoryUpdate: false);
await _refresh(inMemoryUpdate: false, updateSelection: true);
});
if (_enableDebug) {
@@ -124,8 +127,38 @@ class MarkdownTextRobot {
}
}
/// Replace the selected content with the AI's response
Future<void> replace({
required Selection selection,
required String markdownText,
}) async {
if (selection.isSingle) {
await _replaceInSameLine(
selection: selection,
markdownText: markdownText,
);
} else {
await _replaceInMultiLines(
selection: selection,
markdownText: markdownText,
);
}
}
/// Delete the temporary inserted AI nodes
Future<void> deleteAINodes() async {
final nodes = getInsertedNodes();
final transaction = editorState.transaction..deleteNodes(nodes);
await editorState.apply(
transaction,
options: const ApplyOptions(recordUndo: false),
);
}
/// Discard the inserted content
Future<void> discard() async {
Future<void> discard({
Selection? afterSelection,
}) async {
final start = _insertPosition;
if (start == null) {
return;
@@ -134,6 +167,8 @@ class MarkdownTextRobot {
return;
}
afterSelection ??= Selection.collapsed(start);
// fallback to the calculated position if the selection is null.
final end = Position(
path: start.path.nextNPath(_insertedNodes.length - 1),
@@ -143,7 +178,7 @@ class MarkdownTextRobot {
);
final transaction = editorState.transaction
..deleteNodes(deletedNodes)
..afterSelection = Selection.collapsed(start);
..afterSelection = afterSelection;
await editorState.apply(
transaction,
@@ -282,6 +317,194 @@ class MarkdownTextRobot {
children: children,
);
}
/// If the selected content is in the same line,
/// keep the selected node and replace the delta.
Future<void> _replaceInSameLine({
required Selection selection,
required String markdownText,
}) async {
if (markdownText.isEmpty) {
assert(false, 'Expected non-empty markdown text');
Log.error('Expected non-empty markdown text');
return;
}
selection = selection.normalized;
// If the selection is not a single node, do nothing.
if (!selection.isSingle) {
assert(false, 'Expected single node selection');
Log.error('Expected single node selection');
return;
}
final startIndex = selection.startIndex;
final endIndex = selection.endIndex;
final length = endIndex - startIndex;
// Get the selected node.
final node = editorState.getNodeAtPath(selection.start.path);
final delta = node?.delta;
if (node == null || delta == null) {
assert(false, 'Expected non-null node and delta');
Log.error('Expected non-null node and delta');
return;
}
// Convert the markdown text to delta.
// Question: Why we need to convert the markdown to document first?
// Answer: Because the markdown text may contain the list item,
// if we convert the markdown to delta directly, the list item will be
// treated as a normal text node, and the delta will be incorrect.
// For example, the markdown text is:
// ```
// 1. item1
// ```
// if we convert the markdown to delta directly, the delta will be:
// ```
// [
// {
// "insert": "1. item1"
// }
// ]
// ```
// if we convert the markdown to document first, the document will be:
// ```
// [
// {
// "type": "numbered_list",
// "children": [
// {
// "insert": "item1"
// }
// ]
// }
// ]
final document = customMarkdownToDocument(markdownText);
final nodes = document.root.children;
final decoder = DeltaMarkdownDecoder();
final markdownDelta =
nodes.firstOrNull?.delta ?? decoder.convert(markdownText);
if (markdownDelta.isEmpty) {
assert(false, 'Expected non-empty markdown delta');
Log.error('Expected non-empty markdown delta');
return;
}
// Replace the delta of the selected node.
final transaction = editorState.transaction;
// it means the user selected the entire sentence, we just replace the node
if (startIndex == 0 && length == node.delta?.length) {
transaction
..insertNodes(node.path.next, nodes)
..deleteNode(node);
} else {
// it means the user selected a part of the sentence, we need to delete the
// selected part and insert the new delta.
transaction
..deleteText(node, startIndex, length)
..insertTextDelta(node, startIndex, markdownDelta);
// Add the remaining nodes to the document.
final remainingNodes = nodes.skip(1);
if (remainingNodes.isNotEmpty) {
transaction.insertNodes(
node.path.next,
remainingNodes,
);
}
}
await editorState.apply(transaction);
}
/// If the selected content is in multiple lines
Future<void> _replaceInMultiLines({
required Selection selection,
required String markdownText,
}) async {
selection = selection.normalized;
// If the selection is a single node, do nothing.
if (selection.isSingle) {
assert(false, 'Expected multi-line selection');
Log.error('Expected multi-line selection');
return;
}
final markdownNodes = customMarkdownToDocument(
markdownText,
tableWidth: 250.0,
).root.children;
// Get the selected nodes.
final nodes = editorState.getNodesInSelection(selection);
// Note: Don't change its order, otherwise the delta will be incorrect.
// step 1. merge the first selected node and the first node from the ai response
// step 2. merge the last selected node and the last node from the ai response
// step 3. insert the middle nodes from the ai response
// step 4. delete the middle nodes
final transaction = editorState.transaction;
// step 1
final firstNode = nodes.firstOrNull;
final delta = firstNode?.delta;
final firstMarkdownNode = markdownNodes.firstOrNull;
final firstMarkdownDelta = firstMarkdownNode?.delta;
if (firstNode != null &&
delta != null &&
firstMarkdownNode != null &&
firstMarkdownDelta != null) {
final startIndex = selection.startIndex;
final length = delta.length - startIndex;
transaction
..deleteText(firstNode, startIndex, length)
..insertTextDelta(firstNode, startIndex, firstMarkdownDelta);
}
// step 2
final lastNode = nodes.lastOrNull;
final lastDelta = lastNode?.delta;
final lastMarkdownNode = markdownNodes.lastOrNull;
final lastMarkdownDelta = lastMarkdownNode?.delta;
if (lastNode != null &&
lastDelta != null &&
lastMarkdownNode != null &&
lastMarkdownDelta != null) {
final endIndex = selection.endIndex;
transaction.deleteText(lastNode, 0, endIndex);
// if the last node is same as the first node, it means we have replaced the
// selected text in the first node.
if (lastMarkdownNode.id != firstMarkdownNode?.id) {
transaction.insertTextDelta(lastNode, 0, lastMarkdownDelta);
}
}
// step 3
final insertedPath = selection.start.path.nextNPath(1);
if (markdownNodes.length > 2) {
transaction.insertNodes(
insertedPath,
markdownNodes.skip(1).take(markdownNodes.length - 2).toList(),
);
}
// step 4
final length = nodes.length - 2;
if (length > 0) {
final middleNodes = nodes.skip(1).take(length).toList();
transaction.deleteNodes(middleNodes);
}
await editorState.apply(transaction);
}
}
class AINodeExternalValues extends NodeExternalValues {
@@ -47,7 +47,6 @@ class _CopyButton extends StatelessWidget {
if (context.mounted) {
showToastNotification(
context,
message: LocaleKeys.document_codeBlock_codeCopiedSnackbar.tr(),
);
}
@@ -16,6 +16,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:http/http.dart' as http;
import 'package:string_validator/string_validator.dart';
import 'package:universal_platform/universal_platform.dart';
/// - support
/// - desktop
@@ -162,6 +163,7 @@ Future<bool> _pasteAsLinkPreview(
EditorState editorState,
String? text,
) async {
final isMobile = UniversalPlatform.isMobile;
// the url should contain a protocol
if (text == null || !isURL(text, {'require_protocol': true})) {
return false;
@@ -193,6 +195,8 @@ Future<bool> _pasteAsLinkPreview(
return false;
}
if (!isMobile && !isImageUrl) return false;
// insert the text with link format
final textTransaction = editorState.transaction
..insertText(
@@ -250,6 +254,7 @@ Future<void> doPlainPaste(EditorState editorState) async {
}
Future<bool> _isImageUrl(String text) async {
if (isNotImageUrl(text)) return false;
final response = await http.head(Uri.parse(text));
if (response.statusCode == 200) {
@@ -43,13 +43,11 @@ extension PasteFromBlockLink on EditorState {
node,
selection.startIndex,
MentionBlockKeys.mentionChar,
attributes: {
MentionBlockKeys.mention: {
MentionBlockKeys.type: MentionType.page.name,
MentionBlockKeys.blockId: blockId,
MentionBlockKeys.pageId: pageId,
},
},
attributes: MentionBlockKeys.buildMentionPageAttributes(
mentionType: MentionType.page,
pageId: pageId,
blockId: blockId,
),
);
await apply(transaction);
@@ -1,3 +1,4 @@
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_plain_text.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
import 'package:appflowy/shared/markdown_to_document.dart';
import 'package:appflowy/shared/patterns/common_patterns.dart';
@@ -13,6 +14,7 @@ extension PasteFromHtml on EditorState {
}
if (nodes.length == 1) {
await pasteSingleLineNode(nodes.first);
checkToShowPasteAsMenu(nodes.first);
} else {
await pasteMultiLineNodes(nodes.toList());
}
@@ -73,7 +73,6 @@ extension PasteFromImage on EditorState {
Log.info('unsupported format: $format');
if (UniversalPlatform.isMobile) {
showToastNotification(
context,
message: LocaleKeys.document_imageBlock_error_invalidImageFormat.tr(),
);
}
@@ -112,7 +111,6 @@ extension PasteFromImage on EditorState {
if (errorMessage != null && context.mounted) {
showToastNotification(
context,
message: errorMessage,
);
return false;
@@ -131,7 +129,6 @@ extension PasteFromImage on EditorState {
Log.error('cannot copy image file', e);
if (context.mounted) {
showToastNotification(
context,
message: LocaleKeys.document_imageBlock_error_invalidImage.tr(),
);
}
@@ -1,6 +1,8 @@
import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/paste_as/paste_as_menu.dart';
import 'package:appflowy/shared/markdown_to_document.dart';
import 'package:appflowy/shared/patterns/common_patterns.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:universal_platform/universal_platform.dart';
extension PasteFromPlainText on EditorState {
Future<void> pastePlainText(String plainText) async {
@@ -41,6 +43,7 @@ extension PasteFromPlainText on EditorState {
}
if (nodes.length == 1) {
await pasteSingleLineNode(nodes.first);
checkToShowPasteAsMenu(nodes.first);
} else {
await pasteMultiLineNodes(nodes.toList());
}
@@ -65,6 +68,29 @@ extension PasteFromPlainText on EditorState {
AppFlowyRichTextKeys.href: plainText,
});
await apply(transaction);
checkToShowPasteAsMenu(node);
return true;
}
void checkToShowPasteAsMenu(Node node) {
if (selection == null || !selection!.isCollapsed) return;
if (UniversalPlatform.isMobile) return;
final href = _getLinkFromNode(node);
if (href != null) {
final context = document.root.context;
if (context != null && context.mounted) {
PasteAsMenuService(context: context, editorState: this).show(href);
}
}
}
String? _getLinkFromNode(Node node) {
final delta = node.delta;
if (delta == null) return null;
final inserts = delta.whereType<TextInsert>();
if (inserts.isEmpty || inserts.length > 1) return null;
final link = inserts.first.attributes?.href;
if (link != null) return inserts.first.text;
return null;
}
}
@@ -1,10 +1,9 @@
import 'package:appflowy/plugins/document/presentation/editor_plugins/base/toolbar_extension.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'toolbar_animation.dart';
import 'toolbar_cubit.dart';
class DesktopFloatingToolbar extends StatefulWidget {
const DesktopFloatingToolbar({
@@ -28,6 +27,7 @@ class _DesktopFloatingToolbarState extends State<DesktopFloatingToolbar> {
EditorState get editorState => widget.editorState;
_Position? position;
final toolbarController = getIt<FloatingToolbarController>();
@override
void initState() {
@@ -39,24 +39,32 @@ class _DesktopFloatingToolbarState extends State<DesktopFloatingToolbar> {
final selectionRect = editorState.selectionRects();
if (selectionRect.isEmpty) return;
position = calculateSelectionMenuOffset(selectionRect.first);
toolbarController._addCallback(dismiss);
}
@override
void dispose() {
toolbarController._removeCallback(dismiss);
super.dispose();
}
@override
Widget build(BuildContext context) {
if (position == null) return Container();
return BlocProvider<ToolbarCubit>(
create: (_) => ToolbarCubit(widget.onDismiss),
child: Positioned(
left: position!.left,
top: position!.top,
right: position!.right,
child: widget.enableAnimation
? ToolbarAnimationWidget(child: widget.child)
: widget.child,
),
return Positioned(
left: position!.left,
top: position!.top,
right: position!.right,
child: widget.enableAnimation
? ToolbarAnimationWidget(child: widget.child)
: widget.child,
);
}
void dismiss() {
widget.onDismiss.call();
}
_Position calculateSelectionMenuOffset(
Rect rect,
) {
@@ -92,3 +100,33 @@ class _Position {
final double? top;
final double? right;
}
class FloatingToolbarController {
final Set<VoidCallback> _dismissCallbacks = {};
final Set<VoidCallback> _displayListeners = {};
void _addCallback(VoidCallback callback) {
_dismissCallbacks.add(callback);
for (final listener in Set.of(_displayListeners)) {
listener.call();
}
}
void _removeCallback(VoidCallback callback) =>
_dismissCallbacks.remove(callback);
bool get isToolbarShowing => _dismissCallbacks.isNotEmpty;
void addDisplayListener(VoidCallback listener) =>
_displayListeners.add(listener);
void removeDisplayListener(VoidCallback listener) =>
_displayListeners.remove(listener);
void hideToolbar() {
if (_dismissCallbacks.isEmpty) return;
for (final callback in _dismissCallbacks) {
callback.call();
}
}
}
@@ -19,11 +19,15 @@ class LinkCreateMenu extends StatefulWidget {
required this.onSubmitted,
required this.onDismiss,
required this.alignment,
required this.currentViewId,
required this.initialText,
});
final EditorState editorState;
final void Function(String link, bool isPage) onSubmitted;
final VoidCallback onDismiss;
final String currentViewId;
final String initialText;
final LinkMenuAlignment alignment;
@override
@@ -32,6 +36,8 @@ class LinkCreateMenu extends StatefulWidget {
class _LinkCreateMenuState extends State<LinkCreateMenu> {
late LinkSearchTextField searchTextField = LinkSearchTextField(
currentViewId: widget.currentViewId,
initialSearchText: widget.initialText,
onEnter: () {
searchTextField.onSearchResult(
onLink: () => onSubmittedLink(),
@@ -48,17 +54,28 @@ class _LinkCreateMenuState extends State<LinkCreateMenu> {
},
);
bool get isButtonEnable => searchText.isNotEmpty;
bool get isTextfieldEnable => searchTextField.isTextfieldEnable;
String get searchText => searchTextField.searchText;
bool get showAtTop => widget.alignment.isTop;
bool showErrorText = false;
@override
void initState() {
super.initState();
searchTextField.requestFocus();
searchTextField.searchRecentViews();
final focusNode = searchTextField.focusNode;
bool hasFocus = focusNode.hasFocus;
focusNode.addListener(() {
if (hasFocus != focusNode.hasFocus && mounted) {
setState(() {
hasFocus = focusNode.hasFocus;
});
}
});
}
@override
@@ -98,32 +115,45 @@ class _LinkCreateMenuState extends State<LinkCreateMenu> {
Widget buildSearchContainer() {
return Container(
width: 320,
height: 48,
decoration: buildToolbarLinkDecoration(context),
padding: EdgeInsets.all(8),
child: ValueListenableBuilder(
valueListenable: searchTextField.textEditingController,
builder: (context, _, __) {
return Row(
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: searchTextField.buildTextField()),
HSpace(8),
FlowyTextButton(
LocaleKeys.document_toolbar_insert.tr(),
mainAxisAlignment: MainAxisAlignment.center,
padding: EdgeInsets.zero,
constraints: BoxConstraints(maxWidth: 72, minHeight: 32),
fontSize: 14,
fontColor:
isButtonEnable ? Colors.white : LinkStyle.textTertiary,
fillColor: isButtonEnable
? LinkStyle.fillThemeThick
: LinkStyle.borderColor,
hoverColor: LinkStyle.fillThemeThick,
lineHeight: 20 / 14,
fontWeight: FontWeight.w600,
onPressed: isButtonEnable ? () => onSubmittedLink() : null,
Row(
children: [
Expanded(
child: searchTextField.buildTextField(context: context),
),
HSpace(8),
FlowyTextButton(
LocaleKeys.document_toolbar_insert.tr(),
mainAxisAlignment: MainAxisAlignment.center,
padding: EdgeInsets.zero,
constraints: BoxConstraints(maxWidth: 72, minHeight: 32),
fontSize: 14,
fontColor: Colors.white,
fillColor: LinkStyle.fillThemeThick,
hoverColor: LinkStyle.fillThemeThick.withAlpha(200),
lineHeight: 20 / 14,
fontWeight: FontWeight.w600,
onPressed: onSubmittedLink,
),
],
),
if (showErrorText)
Padding(
padding: const EdgeInsets.only(top: 4),
child: FlowyText.regular(
LocaleKeys.document_plugins_file_networkUrlInvalid.tr(),
color: LinkStyle.textStatusError,
fontSize: 12,
figmaLineHeight: 16,
),
),
],
);
},
@@ -131,7 +161,15 @@ class _LinkCreateMenuState extends State<LinkCreateMenu> {
);
}
void onSubmittedLink() => widget.onSubmitted(searchText, false);
void onSubmittedLink() {
if (!isTextfieldEnable) {
setState(() {
showErrorText = true;
});
return;
}
widget.onSubmitted(searchText, false);
}
void onSubmittedPageLink(ViewPB view) async {
final workspaceId = context
@@ -152,13 +190,16 @@ void showLinkCreateMenu(
BuildContext context,
EditorState editorState,
Selection selection,
String currentViewId,
) {
if (!context.mounted) return;
final (left, top, right, bottom, alignment) = _getPosition(editorState);
final node = editorState.getNodeAtPath(selection.end.path);
if (node == null) {
return;
}
final selectedText = editorState.getTextInSelection(selection).join();
OverlayEntry? overlay;
@@ -178,12 +219,18 @@ void showLinkCreateMenu(
builder: (context) {
return LinkCreateMenu(
alignment: alignment,
initialText: selectedText,
currentViewId: currentViewId,
editorState: editorState,
onSubmitted: (link, isPage) async {
await editorState.formatDelta(selection, {
BuiltInAttributeKey.href: link,
kIsPageLink: isPage,
});
await editorState.updateSelectionWithReason(
null,
reason: SelectionUpdateReason.uiEvent,
);
dismissOverlay();
},
onDismiss: dismissOverlay,
@@ -257,11 +304,14 @@ void showLinkCreateMenu(
return (left, top, right, bottom, alignment);
}
ShapeDecoration buildToolbarLinkDecoration(BuildContext context) =>
ShapeDecoration buildToolbarLinkDecoration(
BuildContext context, {
double radius = 12.0,
}) =>
ShapeDecoration(
color: Theme.of(context).cardColor,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
borderRadius: BorderRadius.circular(radius),
),
shadows: [
const BoxShadow(
@@ -3,6 +3,7 @@ import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/toolbar_item/custom_link_toolbar_item.dart';
import 'package:appflowy/plugins/shared/share/constants.dart';
import 'package:appflowy/user/application/user_service.dart';
import 'package:appflowy/util/theme_extension.dart';
import 'package:appflowy/workspace/application/view/view_service.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
@@ -10,7 +11,8 @@ import 'package:appflowy_result/appflowy_result.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
// ignore: implementation_imports
import 'package:appflowy_editor/src/editor/util/link_util.dart';
import 'link_create_menu.dart';
import 'link_search_text_field.dart';
import 'link_styles.dart';
@@ -22,12 +24,14 @@ class LinkEditMenu extends StatefulWidget {
required this.onDismiss,
required this.onApply,
required this.onRemoveLink,
required this.currentViewId,
});
final LinkInfo linkInfo;
final ValueChanged<LinkInfo> onApply;
final VoidCallback onRemoveLink;
final ValueChanged<LinkInfo> onRemoveLink;
final VoidCallback onDismiss;
final String currentViewId;
@override
State<LinkEditMenu> createState() => _LinkEditMenuState();
@@ -36,7 +40,7 @@ class LinkEditMenu extends StatefulWidget {
class _LinkEditMenuState extends State<LinkEditMenu> {
ValueChanged<LinkInfo> get onApply => widget.onApply;
VoidCallback get onRemoveLink => widget.onRemoveLink;
ValueChanged<LinkInfo> get onRemoveLink => widget.onRemoveLink;
VoidCallback get onDismiss => widget.onDismiss;
@@ -47,9 +51,7 @@ class _LinkEditMenuState extends State<LinkEditMenu> {
late LinkSearchTextField searchTextField;
bool isShowingSearchResult = false;
ViewPB? currentView;
bool get enableApply =>
linkInfo.link.isNotEmpty && linkNameController.text.isNotEmpty;
bool showErrorText = false;
@override
void initState() {
@@ -58,18 +60,9 @@ class _LinkEditMenuState extends State<LinkEditMenu> {
if (isPageLink) getPageView();
searchTextField = LinkSearchTextField(
initialSearchText: isPageLink ? '' : linkInfo.link,
onEnter: () {
searchTextField.onSearchResult(
onLink: onLinkSelected,
onRecentViews: () =>
onPageSelected(searchTextField.currentRecentView),
onSearchViews: () =>
onPageSelected(searchTextField.currentSearchedView),
onEmpty: () {
searchTextField.unfocus();
},
);
},
initialViewId: linkInfo.viewId,
currentViewId: widget.currentViewId,
onEnter: onConfirm,
onEscape: () {
if (isShowingSearchResult) {
hideSearchResult();
@@ -95,6 +88,7 @@ class _LinkEditMenuState extends State<LinkEditMenu> {
Widget build(BuildContext context) {
final showingRecent =
searchTextField.showingRecent && isShowingSearchResult;
final errorHeight = showErrorText ? 20.0 : 0.0;
return GestureDetector(
onTap: onDismiss,
child: Container(
@@ -107,9 +101,8 @@ class _LinkEditMenuState extends State<LinkEditMenu> {
onTap: hideSearchResult,
child: Container(
width: 400,
height: 192,
height: 192 + errorHeight,
decoration: buildToolbarLinkDecoration(context),
padding: EdgeInsets.fromLTRB(20, 16, 20, 16),
),
),
Positioned(
@@ -123,7 +116,7 @@ class _LinkEditMenuState extends State<LinkEditMenu> {
),
),
Positioned(
top: 80,
top: 80 + errorHeight,
left: 20,
child: FlowyText.semibold(
LocaleKeys.document_toolbar_linkName.tr(),
@@ -133,12 +126,12 @@ class _LinkEditMenuState extends State<LinkEditMenu> {
),
),
Positioned(
top: 152,
top: 144 + errorHeight,
left: 20,
child: buildButtons(),
),
Positioned(
top: 108,
top: 100 + errorHeight,
left: 20,
child: buildNameTextField(),
),
@@ -155,29 +148,53 @@ class _LinkEditMenuState extends State<LinkEditMenu> {
Widget buildLinkField() {
final showPageView = linkInfo.isPage && !isShowingSearchResult;
if (showPageView) return buildPageView();
if (!isShowingSearchResult) return buildLinkView();
return SizedBox(
width: 360,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 360,
height: 32,
child: searchTextField.buildTextField(
autofocus: true,
Widget child;
if (showPageView) {
child = buildPageView();
} else if (!isShowingSearchResult) {
child = buildLinkView();
} else {
return SizedBox(
width: 360,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 360,
height: 32,
child: searchTextField.buildTextField(
autofocus: true,
context: context,
),
),
VSpace(6),
searchTextField.buildResultContainer(
context: context,
width: 360,
onPageLinkSelected: onPageSelected,
onLinkSelected: onLinkSelected,
),
],
),
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
child,
if (showErrorText)
Padding(
padding: const EdgeInsets.only(top: 4),
child: FlowyText.regular(
LocaleKeys.document_plugins_file_networkUrlInvalid.tr(),
color: LinkStyle.textStatusError,
fontSize: 12,
figmaLineHeight: 16,
),
),
VSpace(6),
searchTextField.buildResultContainer(
context: context,
width: 360,
onPageLinkSelected: onPageSelected,
onLinkSelected: onLinkSelected,
),
],
),
],
);
}
@@ -197,15 +214,15 @@ class _LinkEditMenuState extends State<LinkEditMenu> {
preferBelow: false,
decoration: BoxDecoration(
borderRadius: BorderRadius.all(Radius.circular(8)),
border: Border.all(color: LinkStyle.borderColor),
border: Border.all(color: LinkStyle.borderColor(context)),
),
onPressed: onRemoveLink,
onPressed: () => onRemoveLink.call(linkInfo),
),
Spacer(),
DecoratedBox(
decoration: BoxDecoration(
borderRadius: BorderRadius.all(Radius.circular(8)),
border: Border.all(color: LinkStyle.borderColor),
border: Border.all(color: LinkStyle.borderColor(context)),
),
child: FlowyTextButton(
LocaleKeys.button_cancel.tr(),
@@ -214,7 +231,9 @@ class _LinkEditMenuState extends State<LinkEditMenu> {
constraints: BoxConstraints(maxWidth: 78, minHeight: 32),
fontSize: 14,
lineHeight: 20 / 14,
fontColor: LinkStyle.textPrimary,
fontColor: Theme.of(context).isLightMode
? LinkStyle.textPrimary
: Theme.of(context).iconTheme.color,
fillColor: Colors.transparent,
fontWeight: FontWeight.w400,
onPressed: onDismiss,
@@ -232,14 +251,26 @@ class _LinkEditMenuState extends State<LinkEditMenu> {
fontSize: 14,
lineHeight: 20 / 14,
hoverColor: LinkStyle.fillThemeThick.withAlpha(200),
fontColor:
enableApply ? Colors.white : LinkStyle.textTertiary,
fillColor: enableApply
? LinkStyle.fillThemeThick
: LinkStyle.borderColor,
fontColor: Colors.white,
fillColor: LinkStyle.fillThemeThick,
fontWeight: FontWeight.w400,
onPressed:
enableApply ? () => widget.onApply.call(linkInfo) : null,
onPressed: () {
if (isShowingSearchResult) {
onConfirm();
return;
}
if (linkInfo.link.isEmpty) {
widget.onRemoveLink(linkInfo);
return;
}
if (linkInfo.link.isEmpty || !isUri(linkInfo.link)) {
setState(() {
showErrorText = true;
});
return;
}
widget.onApply.call(linkInfo);
},
);
},
),
@@ -272,6 +303,7 @@ class _LinkEditMenuState extends State<LinkEditMenu> {
},
decoration: LinkStyle.buildLinkTextFieldInputDecoration(
LocaleKeys.document_toolbar_linkNameHint.tr(),
context,
),
),
);
@@ -288,24 +320,34 @@ class _LinkEditMenuState extends State<LinkEditMenu> {
),
);
} else {
final viewName = view.name;
final displayName = viewName.isEmpty
? LocaleKeys.document_title_placeholder.tr()
: viewName;
child = GestureDetector(
onTap: showSearchResult,
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: Container(
height: 32,
padding: EdgeInsets.fromLTRB(8, 6, 8, 6),
child: Row(
children: [
searchTextField.buildIcon(view),
HSpace(8),
Flexible(
child: FlowyText.regular(
view.name,
overflow: TextOverflow.ellipsis,
child: FlowyTooltip(
preferBelow: false,
message: displayName,
child: Container(
height: 32,
padding: EdgeInsets.fromLTRB(8, 0, 8, 0),
child: Row(
children: [
searchTextField.buildIcon(view),
HSpace(4),
Flexible(
child: FlowyText.regular(
displayName,
overflow: TextOverflow.ellipsis,
figmaLineHeight: 20,
fontSize: 14,
),
),
),
],
],
),
),
),
),
@@ -324,24 +366,28 @@ class _LinkEditMenuState extends State<LinkEditMenu> {
width: 360,
height: 32,
decoration: buildDecoration(),
child: GestureDetector(
onTap: showSearchResult,
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: Padding(
padding: EdgeInsets.fromLTRB(8, 6, 8, 6),
child: Row(
children: [
FlowySvg(FlowySvgs.toolbar_link_earth_m),
HSpace(8),
Flexible(
child: FlowyText.regular(
linkInfo.link,
overflow: TextOverflow.ellipsis,
figmaLineHeight: 20,
child: FlowyTooltip(
preferBelow: false,
message: linkInfo.link,
child: GestureDetector(
onTap: showSearchResult,
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: Padding(
padding: EdgeInsets.fromLTRB(8, 6, 8, 6),
child: Row(
children: [
FlowySvg(FlowySvgs.toolbar_link_earth_m),
HSpace(8),
Flexible(
child: FlowyText.regular(
linkInfo.link,
overflow: TextOverflow.ellipsis,
figmaLineHeight: 20,
),
),
),
],
],
),
),
),
),
@@ -349,12 +395,21 @@ class _LinkEditMenuState extends State<LinkEditMenu> {
);
}
void onConfirm() {
searchTextField.onSearchResult(
onLink: onLinkSelected,
onRecentViews: () => onPageSelected(searchTextField.currentRecentView),
onSearchViews: () => onPageSelected(searchTextField.currentSearchedView),
onEmpty: () {
searchTextField.unfocus();
},
);
}
Future<void> getPageView() async {
if (!linkInfo.isPage) return;
final link = linkInfo.link;
final viewId = link.split('/').lastOrNull ?? '';
final (view, isInTrash, isDeleted) =
await ViewBackendService.getMentionPageStatus(viewId);
await ViewBackendService.getMentionPageStatus(linkInfo.viewId);
if (mounted) {
setState(() {
currentView = view;
@@ -413,7 +468,7 @@ class _LinkEditMenuState extends State<LinkEditMenu> {
BoxDecoration buildDecoration() => BoxDecoration(
borderRadius: BorderRadius.circular(8),
border: Border.all(color: LinkStyle.borderColor),
border: Border.all(color: LinkStyle.borderColor(context)),
);
}
@@ -426,4 +481,6 @@ class LinkInfo {
Attributes toAttribute() =>
{AppFlowyRichTextKeys.href: link, kIsPageLink: isPage};
String get viewId => isPage ? link.split('/').lastOrNull ?? '' : '';
}
@@ -3,20 +3,29 @@ import 'dart:math';
import 'package:appflowy/core/helpers/url_launcher.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/application/document_bloc.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/desktop_floating_toolbar.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/link_embed/link_embed_block_component.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/shared.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_block.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/toolbar_item/custom_link_toolbar_item.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/util/theme_extension.dart';
import 'package:appflowy/workspace/application/view/view_service.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'link_create_menu.dart';
import 'link_edit_menu.dart';
import 'link_styles.dart';
class LinkHoverTrigger extends StatefulWidget {
const LinkHoverTrigger({
@@ -45,6 +54,7 @@ class LinkHoverTrigger extends StatefulWidget {
class _LinkHoverTriggerState extends State<LinkHoverTrigger> {
final hoverMenuController = PopoverController();
final editMenuController = PopoverController();
final toolbarController = getIt<FloatingToolbarController>();
bool isHoverMenuShowing = false;
bool isHoverMenuHovering = false;
bool isHoverTriggerHovering = false;
@@ -57,12 +67,13 @@ class _LinkHoverTriggerState extends State<LinkHoverTrigger> {
Attributes get attribute => widget.attribute;
HoverTriggerKey get triggerKey => HoverTriggerKey(widget.node.id, selection);
late HoverTriggerKey triggerKey = HoverTriggerKey(widget.node.id, selection);
@override
void initState() {
super.initState();
getIt<LinkHoverTriggers>()._add(triggerKey, showLinkHoverMenu);
toolbarController.addDisplayListener(onToolbarShow);
}
@override
@@ -70,6 +81,7 @@ class _LinkHoverTriggerState extends State<LinkHoverTrigger> {
hoverMenuController.close();
editMenuController.close();
getIt<LinkHoverTriggers>()._remove(triggerKey, showLinkHoverMenu);
toolbarController.removeDisplayListener(onToolbarShow);
super.dispose();
}
@@ -131,10 +143,11 @@ class _LinkHoverTriggerState extends State<LinkHoverTrigger> {
isHoverMenuHovering = false;
tryToDismissLinkHoverMenu();
},
onConvertTo: (type) => convertLinkTo(editorState, selection, type),
onOpenLink: openLink,
onCopyLink: copyLink,
onCopyLink: () => copyLink(context),
onEditLink: showLinkEditMenu,
onRemoveLink: () => removeLink(editorState, selection, true),
onRemoveLink: () => removeLink(editorState, selection),
),
child: child,
);
@@ -144,6 +157,7 @@ class _LinkHoverTriggerState extends State<LinkHoverTrigger> {
final href = attribute.href ?? '',
isPage = attribute.isPage,
title = editorState.getTextInSelection(selection).join();
final currentViewId = context.read<DocumentBloc?>()?.documentId ?? '';
return AppFlowyPopover(
controller: editMenuController,
direction: PopoverDirection.bottomWithLeftAligned,
@@ -159,6 +173,7 @@ class _LinkHoverTriggerState extends State<LinkHoverTrigger> {
minHeight: 282,
),
popupBuilder: (context) => LinkEditMenu(
currentViewId: currentViewId,
linkInfo: LinkInfo(name: title, link: href, isPage: isPage),
onDismiss: () => editMenuController.close(),
onApply: (info) async {
@@ -173,14 +188,19 @@ class _LinkHoverTriggerState extends State<LinkHoverTrigger> {
editMenuController.close();
await editorState.apply(transaction);
},
onRemoveLink: () => removeLink(editorState, selection, true),
onRemoveLink: (linkinfo) =>
onRemoveAndReplaceLink(editorState, selection, linkinfo.name),
),
child: child,
);
}
void onToolbarShow() => hoverMenuController.close();
void showLinkHoverMenu() {
if (isHoverMenuShowing) return;
if (isHoverMenuShowing || toolbarController.isToolbarShowing || !mounted) {
return;
}
keepEditorFocusNotifier.increase();
hoverMenuController.show();
}
@@ -206,7 +226,7 @@ class _LinkHoverTriggerState extends State<LinkHoverTrigger> {
if (isPage) {
final viewId = href.split('/').lastOrNull ?? '';
if (viewId.isEmpty) {
await afLaunchUrlString(href);
await afLaunchUrlString(href, addingHttpSchemeWhenFailed: true);
} else {
final (view, isInTrash, isDeleted) =
await ViewBackendService.getMentionPageStatus(viewId);
@@ -215,24 +235,20 @@ class _LinkHoverTriggerState extends State<LinkHoverTrigger> {
}
}
} else {
await afLaunchUrlString(href);
await afLaunchUrlString(href, addingHttpSchemeWhenFailed: true);
}
}
Future<void> copyLink() async {
Future<void> copyLink(BuildContext context) async {
final href = widget.attribute.href ?? '';
if (href.isEmpty) return;
await getIt<ClipboardService>()
.setData(ClipboardServiceData(plainText: href));
await context.copyLink(href);
hoverMenuController.close();
}
void removeLink(
EditorState editorState,
Selection selection,
bool isHref,
) {
if (!isHref) return;
final node = editorState.getNodeAtPath(selection.end.path);
if (node == null) {
return;
@@ -251,9 +267,54 @@ class _LinkHoverTriggerState extends State<LinkHoverTrigger> {
);
editorState.apply(transaction);
}
Future<void> convertLinkTo(
EditorState editorState,
Selection selection,
LinkConvertMenuCommand type,
) async {
final url = widget.attribute.href ?? '';
if (type == LinkConvertMenuCommand.toBookmark) {
await convertUrlToLinkPreview(editorState, selection, url);
} else if (type == LinkConvertMenuCommand.toMention) {
await convertUrlToMention(editorState, selection);
} else if (type == LinkConvertMenuCommand.toEmbed) {
await convertUrlToLinkPreview(
editorState,
selection,
url,
previewType: LinkEmbedKeys.embed,
);
}
}
void onRemoveAndReplaceLink(
EditorState editorState,
Selection selection,
String text,
) {
final node = editorState.getNodeAtPath(selection.end.path);
if (node == null) {
return;
}
final index = selection.normalized.startIndex;
final length = selection.length;
final transaction = editorState.transaction
..replaceText(
node,
index,
length,
text,
attributes: {
BuiltInAttributeKey.href: null,
kIsPageLink: null,
},
);
editorState.apply(transaction);
}
}
class LinkHoverMenu extends StatelessWidget {
class LinkHoverMenu extends StatefulWidget {
const LinkHoverMenu({
super.key,
required this.attribute,
@@ -264,6 +325,7 @@ class LinkHoverMenu extends StatelessWidget {
required this.onOpenLink,
required this.onEditLink,
required this.onRemoveLink,
required this.onConvertTo,
});
final Attributes attribute;
@@ -274,18 +336,41 @@ class LinkHoverMenu extends StatelessWidget {
final VoidCallback onOpenLink;
final VoidCallback onEditLink;
final VoidCallback onRemoveLink;
final ValueChanged<LinkConvertMenuCommand> onConvertTo;
@override
State<LinkHoverMenu> createState() => _LinkHoverMenuState();
}
class _LinkHoverMenuState extends State<LinkHoverMenu> {
ViewPB? currentView;
late bool isPage = widget.attribute.isPage;
late String href = widget.attribute.href ?? '';
final popoverController = PopoverController();
bool isConvertButtonSelected = false;
@override
void initState() {
super.initState();
if (isPage) getPageView();
}
@override
void dispose() {
super.dispose();
popoverController.close();
}
@override
Widget build(BuildContext context) {
final href = attribute.href ?? '';
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MouseRegion(
onEnter: onEnter,
onExit: onExit,
onEnter: widget.onEnter,
onExit: widget.onExit,
child: SizedBox(
width: max(320, triggerSize.width),
width: max(320, widget.triggerSize.width),
height: 48,
child: Align(
alignment: Alignment.centerLeft,
@@ -293,43 +378,41 @@ class LinkHoverMenu extends StatelessWidget {
width: 320,
height: 48,
decoration: buildToolbarLinkDecoration(context),
padding: EdgeInsets.all(8),
padding: EdgeInsets.fromLTRB(12, 8, 8, 8),
child: Row(
children: [
Expanded(
child: FlowyText.regular(
href,
fontSize: 14,
figmaLineHeight: 20,
overflow: TextOverflow.ellipsis,
),
),
Expanded(child: buildLinkWidget()),
Container(
height: 20,
width: 1,
color: LinkStyle.borderColor,
color: Color(0xffE8ECF3)
.withAlpha(Theme.of(context).isLightMode ? 255 : 40),
margin: EdgeInsets.symmetric(horizontal: 6),
),
FlowyIconButton(
icon: FlowySvg(FlowySvgs.toolbar_link_m),
tooltipText: LocaleKeys.editor_copyLink.tr(),
preferBelow: false,
width: 36,
height: 32,
onPressed: onCopyLink,
onPressed: widget.onCopyLink,
),
FlowyIconButton(
icon: FlowySvg(FlowySvgs.toolbar_link_edit_m),
tooltipText: LocaleKeys.editor_editLink.tr(),
preferBelow: false,
width: 36,
height: 32,
onPressed: onEditLink,
onPressed: widget.onEditLink,
),
buildConvertButton(),
FlowyIconButton(
icon: FlowySvg(FlowySvgs.toolbar_link_unlink_m),
tooltipText: LocaleKeys.editor_removeLink.tr(),
preferBelow: false,
width: 36,
height: 32,
onPressed: onRemoveLink,
onPressed: widget.onRemoveLink,
),
],
),
@@ -339,13 +422,13 @@ class LinkHoverMenu extends StatelessWidget {
),
MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: onEnter,
onExit: onExit,
onEnter: widget.onEnter,
onExit: widget.onExit,
child: GestureDetector(
onTap: onOpenLink,
onTap: widget.onOpenLink,
child: Container(
width: triggerSize.width,
height: triggerSize.height,
width: widget.triggerSize.width,
height: widget.triggerSize.height,
color: Colors.black.withAlpha(1),
),
),
@@ -353,6 +436,113 @@ class LinkHoverMenu extends StatelessWidget {
],
);
}
Future<void> getPageView() async {
final viewId = href.split('/').lastOrNull ?? '';
final (view, isInTrash, isDeleted) =
await ViewBackendService.getMentionPageStatus(viewId);
if (mounted) {
setState(() {
currentView = view;
});
}
}
Widget buildLinkWidget() {
final view = currentView;
if (isPage && view == null) {
return SizedBox.square(
dimension: 20,
child: CircularProgressIndicator(),
);
}
String text = '';
if (isPage && view != null) {
text = view.name;
if (text.isEmpty) {
text = LocaleKeys.document_title_placeholder.tr();
}
} else {
text = href;
}
return FlowyTooltip(
message: text,
preferBelow: false,
child: FlowyText.regular(
text,
overflow: TextOverflow.ellipsis,
figmaLineHeight: 20,
fontSize: 14,
),
);
}
Widget buildConvertButton() {
return AppFlowyPopover(
offset: Offset(44, 10.0),
direction: PopoverDirection.bottomWithRightAligned,
margin: EdgeInsets.zero,
controller: popoverController,
onOpen: () => keepEditorFocusNotifier.increase(),
onClose: () => keepEditorFocusNotifier.decrease(),
popupBuilder: (context) => buildConvertMenu(),
child: FlowyIconButton(
icon: FlowySvg(FlowySvgs.turninto_m),
isSelected: isConvertButtonSelected,
tooltipText: LocaleKeys.editor_convertTo.tr(),
preferBelow: false,
width: 36,
height: 32,
onPressed: () {
setState(() {
isConvertButtonSelected = true;
});
showConvertMenu();
},
),
);
}
Widget buildConvertMenu() {
return MouseRegion(
onEnter: widget.onEnter,
onExit: widget.onExit,
child: Padding(
padding: const EdgeInsets.all(8.0),
child: SeparatedColumn(
mainAxisSize: MainAxisSize.min,
separatorBuilder: () => const VSpace(0.0),
children:
List.generate(LinkConvertMenuCommand.values.length, (index) {
final command = LinkConvertMenuCommand.values[index];
return SizedBox(
height: 36,
child: FlowyButton(
text: FlowyText(
command.title,
fontWeight: FontWeight.w400,
figmaLineHeight: 20,
),
onTap: () {
widget.onConvertTo(command);
closeConvertMenu();
},
),
);
}),
),
),
);
}
void showConvertMenu() {
keepEditorFocusNotifier.increase();
popoverController.show();
}
void closeConvertMenu() {
popoverController.close();
}
}
class HoverTriggerKey {
@@ -367,7 +557,11 @@ class HoverTriggerKey {
other is HoverTriggerKey &&
runtimeType == other.runtimeType &&
nodeId == other.nodeId &&
selection == other.selection;
isSelectionSame(other.selection);
bool isSelectionSame(Selection other) =>
(selection.start == other.start && selection.end == other.end) ||
(selection.start == other.end && selection.end == other.start);
@override
int get hashCode => nodeId.hashCode ^ selection.hashCode;
@@ -390,8 +584,52 @@ class LinkHoverTriggers {
void call(HoverTriggerKey key) {
final callbacks = _map[key] ?? {};
for (final callback in callbacks) {
callback.call();
if (callbacks.isEmpty) return;
callbacks.first.call();
}
}
enum LinkConvertMenuCommand {
toMention,
toBookmark,
toEmbed;
String get title {
switch (this) {
case toMention:
return LocaleKeys.document_plugins_linkPreview_linkPreviewMenu_toMetion
.tr();
case toBookmark:
return LocaleKeys
.document_plugins_linkPreview_linkPreviewMenu_toBookmark
.tr();
case toEmbed:
return LocaleKeys.document_plugins_linkPreview_linkPreviewMenu_toEmbed
.tr();
}
}
String get type {
switch (this) {
case toMention:
return MentionBlockKeys.type;
case toBookmark:
return LinkPreviewBlockKeys.type;
case toEmbed:
return LinkPreviewBlockKeys.type;
}
}
}
extension LinkExtension on BuildContext {
Future<void> copyLink(String link) async {
if (link.isEmpty) return;
await getIt<ClipboardService>()
.setData(ClipboardServiceData(plainText: link));
if (mounted) {
showToastNotification(
message: LocaleKeys.shareAction_copyLinkSuccess.tr(),
);
}
}
}
@@ -0,0 +1,184 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/menu/menu_extension.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
// ignore: implementation_imports
import 'package:appflowy_editor/src/editor/util/link_util.dart';
import 'package:flutter/services.dart';
import 'link_create_menu.dart';
import 'link_styles.dart';
void showReplaceMenu({
required BuildContext context,
required EditorState editorState,
required Node node,
String? url,
required LTRB ltrb,
required ValueChanged<String> onReplace,
}) {
OverlayEntry? overlay;
void dismissOverlay() {
keepEditorFocusNotifier.decrease();
overlay?.remove();
overlay = null;
}
keepEditorFocusNotifier.increase();
overlay = FullScreenOverlayEntry(
top: ltrb.top,
bottom: ltrb.bottom,
left: ltrb.left,
right: ltrb.right,
dismissCallback: () => keepEditorFocusNotifier.decrease(),
builder: (context) {
return LinkReplaceMenu(
link: url ?? '',
onSubmitted: (link) async {
onReplace.call(link);
dismissOverlay();
},
onDismiss: dismissOverlay,
);
},
).build();
Overlay.of(context, rootOverlay: true).insert(overlay!);
}
class LinkReplaceMenu extends StatefulWidget {
const LinkReplaceMenu({
super.key,
required this.onSubmitted,
required this.link,
required this.onDismiss,
});
final ValueChanged<String> onSubmitted;
final VoidCallback onDismiss;
final String link;
@override
State<LinkReplaceMenu> createState() => _LinkReplaceMenuState();
}
class _LinkReplaceMenuState extends State<LinkReplaceMenu> {
bool showErrorText = false;
late FocusNode focusNode = FocusNode(onKeyEvent: onKeyEvent);
late TextEditingController textEditingController =
TextEditingController(text: widget.link);
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
focusNode.requestFocus();
});
}
@override
void dispose() {
focusNode.dispose();
textEditingController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Container(
width: 330,
padding: EdgeInsets.all(8),
decoration: buildToolbarLinkDecoration(context),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: buildLinkField()),
HSpace(8),
buildReplaceButton(),
],
),
);
}
Widget buildLinkField() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
height: 32,
child: TextFormField(
autovalidateMode: AutovalidateMode.onUserInteraction,
autofocus: true,
focusNode: focusNode,
textAlign: TextAlign.left,
controller: textEditingController,
style: TextStyle(
fontSize: 14,
height: 20 / 14,
fontWeight: FontWeight.w400,
),
decoration: LinkStyle.buildLinkTextFieldInputDecoration(
LocaleKeys.document_plugins_linkPreview_linkPreviewMenu_pasteHint
.tr(),
context,
showErrorBorder: showErrorText,
),
),
),
if (showErrorText)
Padding(
padding: const EdgeInsets.only(top: 4),
child: FlowyText.regular(
LocaleKeys.document_plugins_file_networkUrlInvalid.tr(),
color: LinkStyle.textStatusError,
fontSize: 12,
figmaLineHeight: 16,
),
),
],
);
}
Widget buildReplaceButton() {
return FlowyTextButton(
LocaleKeys.button_replace.tr(),
padding: EdgeInsets.zero,
mainAxisAlignment: MainAxisAlignment.center,
constraints: BoxConstraints(maxWidth: 78, minHeight: 32),
fontSize: 14,
lineHeight: 20 / 14,
hoverColor: LinkStyle.fillThemeThick.withAlpha(200),
fontColor: Colors.white,
fillColor: LinkStyle.fillThemeThick,
fontWeight: FontWeight.w400,
onPressed: onSubmit,
);
}
void onSubmit() {
final link = textEditingController.text.trim();
if (link.isEmpty || !isUri(link)) {
setState(() {
showErrorText = true;
});
return;
}
widget.onSubmitted.call(link);
}
KeyEventResult onKeyEvent(FocusNode node, KeyEvent key) {
if (key is! KeyDownEvent) return KeyEventResult.ignored;
if (key.logicalKey == LogicalKeyboardKey.escape) {
widget.onDismiss.call();
return KeyEventResult.handled;
} else if (key.logicalKey == LogicalKeyboardKey.enter) {
onSubmit();
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
}
@@ -12,6 +12,8 @@ import 'package:appflowy/workspace/application/view/view_service.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
// ignore: implementation_imports
import 'package:appflowy_editor/src/flutter/scrollable_positioned_list/scrollable_positioned_list.dart';
// ignore: implementation_imports
import 'package:appflowy_editor/src/editor/util/link_util.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
@@ -25,12 +27,16 @@ class LinkSearchTextField {
this.onEscape,
this.onEnter,
this.onDataRefresh,
this.initialViewId = '',
required this.currentViewId,
String? initialSearchText,
}) : textEditingController = TextEditingController(
text: initialSearchText ?? '',
text: isUri(initialSearchText ?? '') ? initialSearchText : '',
);
final TextEditingController textEditingController;
final String initialViewId;
final String currentViewId;
final ItemScrollController searchController = ItemScrollController();
late FocusNode focusNode = FocusNode(onKeyEvent: onKeyEvent);
final List<ViewPB> searchedViews = [];
@@ -43,7 +49,7 @@ class LinkSearchTextField {
String get searchText => textEditingController.text;
bool get isButtonEnable => searchText.isNotEmpty;
bool get isTextfieldEnable => searchText.isNotEmpty && isUri(searchText);
bool get showingRecent => searchText.isEmpty && recentViews.isNotEmpty;
@@ -58,7 +64,11 @@ class LinkSearchTextField {
recentViews.clear();
}
Widget buildTextField({bool autofocus = false}) {
Widget buildTextField({
bool autofocus = false,
bool showError = false,
required BuildContext context,
}) {
return TextFormField(
autovalidateMode: AutovalidateMode.onUserInteraction,
autofocus: autofocus,
@@ -81,6 +91,8 @@ class LinkSearchTextField {
},
decoration: LinkStyle.buildLinkTextFieldInputDecoration(
LocaleKeys.document_toolbar_linkInputHint.tr(),
context,
showErrorBorder: showError,
),
);
}
@@ -177,29 +189,41 @@ class LinkSearchTextField {
bool isSelected,
ValueChanged<ViewPB>? onSubmittedPageLink,
) {
final viewName = view.name;
final displayName = viewName.isEmpty
? LocaleKeys.document_title_placeholder.tr()
: viewName;
final isCurrent = initialViewId == view.id;
return SizedBox(
height: 32,
child: FlowyButton(
isSelected: isSelected,
leftIcon: buildIcon(view),
leftIcon: buildIcon(view, padding: EdgeInsets.zero),
text: FlowyText.regular(
view.name,
displayName,
overflow: TextOverflow.ellipsis,
fontSize: 14,
figmaLineHeight: 20,
),
rightIcon: isCurrent ? FlowySvg(FlowySvgs.toolbar_check_m) : null,
onTap: () => onSubmittedPageLink?.call(view),
),
);
}
Widget buildIcon(ViewPB view) {
Widget buildIcon(
ViewPB view, {
EdgeInsetsGeometry padding = const EdgeInsets.only(top: 4),
}) {
if (view.icon.value.isEmpty) return view.defaultIcon(size: Size(20, 20));
final iconData = view.icon.toEmojiIconData();
return RawEmojiIconWidget(
emoji: iconData,
emojiSize: iconData.type == FlowyIconType.emoji ? 16 : 20,
lineHeight: 1,
return Padding(
padding: padding,
child: RawEmojiIconWidget(
emoji: iconData,
emojiSize: iconData.type == FlowyIconType.emoji ? 16 : 20,
lineHeight: 1,
),
);
}
@@ -288,6 +312,7 @@ class LinkSearchTextField {
final views = sectionViews
.unique((e) => e.item.id)
.map((e) => e.item)
.where((e) => e.id != currentViewId)
.take(5)
.toList();
recentViews.clear();
@@ -303,13 +328,14 @@ class LinkSearchTextField {
?.items
.where(
(view) =>
view.name.toLowerCase().contains(search.toLowerCase()) ||
(view.name.isEmpty && search.isEmpty) ||
(view.name.isEmpty &&
LocaleKeys.menuAppHeader_defaultNewPageName
.tr()
.toLowerCase()
.contains(search.toLowerCase())),
(view.id != currentViewId) &&
(view.name.toLowerCase().contains(search.toLowerCase()) ||
(view.name.isEmpty && search.isEmpty) ||
(view.name.isEmpty &&
LocaleKeys.menuAppHeader_defaultNewPageName
.tr()
.toLowerCase()
.contains(search.toLowerCase()))),
)
.take(10)
.toList();
@@ -1,19 +1,31 @@
import 'package:appflowy/util/theme_extension.dart';
import 'package:flutter/material.dart';
class LinkStyle {
static const borderColor = Color(0xFFE8ECF3);
static const textTertiary = Color(0xFF99A1A8);
static const textStatusError = Color(0xffE71D32);
static const fillThemeThick = Color(0xFF00B5FF);
static const shadowMedium = Color(0x1F22251F);
static const textPrimary = Color(0xFF1F2329);
static InputDecoration buildLinkTextFieldInputDecoration(String hintText) {
const border = OutlineInputBorder(
static Color borderColor(BuildContext context) =>
Theme.of(context).isLightMode ? Color(0xFFE8ECF3) : Color(0x64BDBDBD);
static InputDecoration buildLinkTextFieldInputDecoration(
String hintText,
BuildContext context, {
bool showErrorBorder = false,
}) {
final border = OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(8.0)),
borderSide: BorderSide(color: LinkStyle.borderColor),
borderSide: BorderSide(color: borderColor(context)),
);
final enableBorder = border.copyWith(
borderSide: BorderSide(color: LinkStyle.fillThemeThick),
borderSide: BorderSide(
color: showErrorBorder
? LinkStyle.textStatusError
: LinkStyle.fillThemeThick,
),
);
const hintStyle = TextStyle(
fontSize: 14,
@@ -1,17 +0,0 @@
import 'dart:ui';
import 'package:bloc/bloc.dart';
class ToolbarCubit extends Cubit<ToolbarState> {
ToolbarCubit(this.onDismissCallback) : super(ToolbarState._());
final VoidCallback onDismissCallback;
void dismiss() {
onDismissCallback.call();
}
}
class ToolbarState {
const ToolbarState._();
}
@@ -154,7 +154,6 @@ class _ErrorBlockComponentWidgetState extends State<ErrorBlockComponentWidget>
void _copyBlockContent() {
showToastNotification(
context,
message: LocaleKeys.document_errorBlock_blockContentHasBeenCopied.tr(),
);
@@ -104,10 +104,10 @@ Future<void> downloadMediaFile(
await afLaunchUrlString(file.url);
} else {
if (userProfile == null) {
return showToastNotification(
context,
showToastNotification(
message: LocaleKeys.grid_media_downloadFailedToken.tr(),
);
return;
}
final uri = Uri.parse(file.url);
@@ -128,14 +128,12 @@ Future<void> downloadMediaFile(
if (result != null && context.mounted) {
showToastNotification(
context,
type: ToastificationType.error,
message: LocaleKeys.grid_media_downloadSuccess.tr(),
);
}
} else if (context.mounted) {
showToastNotification(
context,
type: ToastificationType.error,
message: LocaleKeys.document_plugins_image_imageDownloadFailed.tr(),
);
@@ -159,13 +157,11 @@ Future<void> downloadMediaFile(
if (context.mounted) {
showToastNotification(
context,
message: LocaleKeys.grid_media_downloadSuccess.tr(),
);
}
} else if (context.mounted) {
showToastNotification(
context,
type: ToastificationType.error,
message: LocaleKeys.document_plugins_image_imageDownloadFailed.tr(),
);
@@ -8,6 +8,7 @@ import 'package:appflowy/shared/icon_emoji_picker/icon_picker.dart';
import 'package:appflowy/user/application/user_service.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
import 'package:flowy_svg/flowy_svg.dart';
import 'package:flutter/material.dart';
import 'package:string_validator/string_validator.dart';
@@ -113,24 +114,21 @@ class _RawEmojiIconWidgetState extends State<RawEmojiIconWidget> {
try {
switch (widget.emoji.type) {
case FlowyIconType.emoji:
return EmojiText(
emoji: widget.emoji.emoji,
return FlowyText.emoji(
widget.emoji.emoji,
fontSize: widget.emojiSize,
textAlign: TextAlign.justify,
lineHeight: widget.lineHeight,
);
case FlowyIconType.icon:
IconsData iconData =
IconsData.fromJson(jsonDecode(widget.emoji.emoji));
IconsData iconData = IconsData.fromJson(
jsonDecode(widget.emoji.emoji),
);
if (!widget.enableColor) {
iconData = iconData.noColor();
}
/// Under the same width conditions, icons on macOS seem to appear
/// larger than emojis, so 0.9 is used here to slightly reduce the
/// size of the icons
final iconSize =
Platform.isMacOS ? widget.emojiSize * 0.9 : widget.emojiSize;
final iconSize = widget.emojiSize;
return IconWidget(
iconsData: iconData,
size: iconSize,
@@ -378,7 +378,6 @@ class CustomImageBlockComponentState extends State<CustomImageBlockComponent>
onTap: () async {
context.pop();
showToastNotification(
context,
message: LocaleKeys.document_plugins_image_copiedToPasteBoard.tr(),
);
await getIt<ClipboardService>().setPlainText(url);
@@ -431,7 +430,6 @@ class CustomImageBlockComponentState extends State<CustomImageBlockComponent>
);
if (mounted) {
showToastNotification(
context,
message: result.isSuccess
? LocaleKeys.document_imageBlock_successToAddImageToGallery.tr()
: LocaleKeys.document_imageBlock_failedToAddImageToGallery.tr(),
@@ -117,14 +117,12 @@ class _ImageMenuState extends State<ImageMenu> {
if (mounted) {
showToastNotification(
context,
message: LocaleKeys.message_copy_success.tr(),
);
}
} catch (e) {
if (mounted) {
showToastNotification(
context,
message: LocaleKeys.message_copy_fail.tr(),
type: ToastificationType.error,
);
@@ -218,7 +218,6 @@ class _MultiImageMenuState extends State<MultiImageMenu> {
ClipboardData(text: images[widget.indexNotifier.value].url),
);
showToastNotification(
context,
message: LocaleKeys.document_plugins_image_copiedToPasteBoard.tr(),
);
}
@@ -0,0 +1,286 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/custom_link_parser.dart';
import 'package:appflowy/shared/appflowy_network_image.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'link_embed_menu.dart';
class LinkEmbedKeys {
const LinkEmbedKeys._();
static const String previewType = 'preview_type';
static const String embed = 'embed';
static const String align = 'align';
}
Node linkEmbedNode({required String url}) => Node(
type: LinkPreviewBlockKeys.type,
attributes: {
LinkPreviewBlockKeys.url: url,
LinkEmbedKeys.previewType: LinkEmbedKeys.embed,
},
);
class LinkEmbedBlockComponent extends BlockComponentStatefulWidget {
const LinkEmbedBlockComponent({
super.key,
super.showActions,
super.actionBuilder,
super.configuration = const BlockComponentConfiguration(),
required super.node,
});
@override
State<LinkEmbedBlockComponent> createState() =>
LinkEmbedBlockComponentState();
}
class LinkEmbedBlockComponentState extends State<LinkEmbedBlockComponent>
with BlockComponentConfigurable {
@override
BlockComponentConfiguration get configuration => widget.configuration;
@override
Node get node => widget.node;
String get url => widget.node.attributes[LinkPreviewBlockKeys.url]!;
EmbedLoadingStatus status = EmbedLoadingStatus.loading;
final parser = LinkParser();
LinkInfo linkInfo = LinkInfo();
final showActionsNotifier = ValueNotifier<bool>(false);
bool isMenuShowing = false, isHovering = false;
@override
void initState() {
super.initState();
parser.addLinkInfoListener((v) {
if (mounted) {
setState(() {
if (v.isEmpty() && linkInfo.isEmpty()) {
status = EmbedLoadingStatus.error;
} else {
linkInfo = v;
status = EmbedLoadingStatus.idle;
}
});
}
});
parser.start(url);
}
@override
void dispose() {
parser.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
Widget result = MouseRegion(
onEnter: (_) {
isHovering = true;
showActionsNotifier.value = true;
},
onExit: (_) {
isHovering = false;
Future.delayed(const Duration(milliseconds: 200), () {
if (isMenuShowing || isHovering) return;
if (mounted) showActionsNotifier.value = false;
});
},
child: buildChild(context),
);
result = Padding(padding: padding, child: result);
if (widget.showActions && widget.actionBuilder != null) {
result = BlockComponentActionWrapper(
node: node,
actionBuilder: widget.actionBuilder!,
child: result,
);
}
return result;
}
Widget buildChild(BuildContext context) {
final theme = AppFlowyTheme.of(context),
fillSceme = theme.fillColorScheme,
borderScheme = theme.borderColorScheme;
Widget child;
final isIdle = status == EmbedLoadingStatus.idle;
if (isIdle) {
child = buildContent(context);
} else {
child = buildErrorLoadingWidget(context);
}
return Container(
height: 450,
decoration: BoxDecoration(
color: isIdle ? Theme.of(context).cardColor : fillSceme.tertiaryHover,
borderRadius: BorderRadius.all(Radius.circular(16)),
border: Border.all(color: borderScheme.greyTertiary),
),
child: Stack(
children: [
child,
buildMenu(context),
],
),
);
}
Widget buildMenu(BuildContext context) {
return Positioned(
top: 12,
right: 12,
child: ValueListenableBuilder<bool>(
valueListenable: showActionsNotifier,
builder: (context, showActions, child) {
if (!showActions) return SizedBox.shrink();
return LinkEmbedMenu(
editorState: context.read<EditorState>(),
node: node,
onReload: () {
setState(() {
status = EmbedLoadingStatus.loading;
});
Future.delayed(const Duration(milliseconds: 200), () {
if (mounted) parser.start(url);
});
},
onMenuShowed: () {
isMenuShowing = true;
},
onMenuHided: () {
isMenuShowing = false;
if (!isHovering && mounted) {
showActionsNotifier.value = false;
}
},
);
},
),
);
}
Widget buildContent(BuildContext context) {
final theme = AppFlowyTheme.of(context), textScheme = theme.textColorScheme;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: ClipRRect(
borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
child: FlowyNetworkImage(
url: linkInfo.imageUrl ?? '',
width: MediaQuery.of(context).size.width,
),
),
),
Container(
height: 64,
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 20),
child: Row(
children: [
SizedBox.square(
dimension: 40,
child: Center(
child: linkInfo.buildIconWidget(size: Size.square(32)),
),
),
HSpace(12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
FlowyText(
linkInfo.siteName ?? '',
color: textScheme.primary,
fontSize: 14,
figmaLineHeight: 20,
fontWeight: FontWeight.w600,
overflow: TextOverflow.ellipsis,
),
VSpace(4),
FlowyText.regular(
url,
color: textScheme.secondary,
fontSize: 12,
figmaLineHeight: 16,
overflow: TextOverflow.ellipsis,
),
],
),
),
],
),
),
],
);
}
Widget buildErrorLoadingWidget(BuildContext context) {
final theme = AppFlowyTheme.of(context), textSceme = theme.textColorScheme;
final isLoading = status == EmbedLoadingStatus.loading;
return isLoading
? Center(
child: SizedBox.square(
dimension: 64,
child: CircularProgressIndicator.adaptive(),
),
)
: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
SvgPicture.asset(
FlowySvgs.embed_error_xl.path,
),
VSpace(4),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: RichText(
maxLines: 1,
overflow: TextOverflow.ellipsis,
text: TextSpan(
children: [
TextSpan(
text: '$url ',
style: TextStyle(
color: textSceme.primary,
fontSize: 14,
height: 20 / 14,
fontWeight: FontWeight.w700,
),
),
TextSpan(
text: LocaleKeys
.document_plugins_linkPreview_linkPreviewMenu_refuseConnect
.tr(),
style: TextStyle(
color: textSceme.primary,
fontSize: 14,
height: 20 / 14,
fontWeight: FontWeight.w400,
),
),
],
),
),
),
],
),
);
}
}
enum EmbedLoadingStatus { loading, idle, error }
@@ -0,0 +1,332 @@
import 'package:appflowy/core/helpers/url_launcher.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_hover_menu.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_replace_menu.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/paste_as/paste_as_menu.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/shared.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/menu/menu_extension.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/widgets.dart';
import 'link_embed_block_component.dart';
class LinkEmbedMenu extends StatefulWidget {
const LinkEmbedMenu({
super.key,
required this.node,
required this.editorState,
required this.onMenuShowed,
required this.onMenuHided,
required this.onReload,
});
final Node node;
final EditorState editorState;
final VoidCallback onMenuShowed;
final VoidCallback onMenuHided;
final VoidCallback onReload;
@override
State<LinkEmbedMenu> createState() => _LinkEmbedMenuState();
}
class _LinkEmbedMenuState extends State<LinkEmbedMenu> {
final turnintoController = PopoverController();
final moreOptionController = PopoverController();
int turnintoMenuNum = 0, moreOptionNum = 0, alignMenuNum = 0;
final moreOptionButtonKey = GlobalKey();
bool get isTurnIntoShowing => turnintoMenuNum > 0;
bool get isMoreOptionShowing => moreOptionNum > 0;
bool get isAlignMenuShowing => alignMenuNum > 0;
Node get node => widget.node;
EditorState get editorState => widget.editorState;
String get url => node.attributes[LinkPreviewBlockKeys.url] ?? '';
@override
void dispose() {
super.dispose();
turnintoController.close();
moreOptionController.close();
widget.onMenuHided.call();
}
@override
Widget build(BuildContext context) {
return buildChild();
}
Widget buildChild() {
final theme = AppFlowyTheme.of(context),
iconScheme = theme.iconColorTheme,
fillScheme = theme.fillColorScheme;
return Container(
padding: EdgeInsets.all(4),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
color: fillScheme.primaryAlpha80,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
// FlowyIconButton(
// icon: FlowySvg(
// FlowySvgs.embed_fullscreen_m,
// color: iconScheme.tertiary,
// ),
// tooltipText: LocaleKeys.document_imageBlock_openFullScreen.tr(),
// preferBelow: false,
// onPressed: () {},
// ),
FlowyIconButton(
icon: FlowySvg(
FlowySvgs.toolbar_link_m,
color: iconScheme.tertiary,
),
tooltipText: LocaleKeys.editor_copyLink.tr(),
preferBelow: false,
onPressed: () => copyLink(context),
),
buildTurnIntoBotton(),
buildMoreOptionBotton(),
],
),
);
}
Widget buildTurnIntoBotton() {
final theme = AppFlowyTheme.of(context), iconScheme = theme.iconColorTheme;
return AppFlowyPopover(
offset: Offset(0, 6),
direction: PopoverDirection.bottomWithRightAligned,
margin: EdgeInsets.zero,
controller: turnintoController,
onOpen: () {
keepEditorFocusNotifier.increase();
turnintoMenuNum++;
},
onClose: () {
keepEditorFocusNotifier.decrease();
turnintoMenuNum--;
checkToHideMenu();
},
popupBuilder: (context) => buildTurnIntoMenu(),
child: FlowyIconButton(
icon: FlowySvg(
FlowySvgs.turninto_m,
color: iconScheme.tertiary,
),
tooltipText: LocaleKeys.document_toolbar_turnInto.tr(),
preferBelow: false,
onPressed: showTurnIntoMenu,
),
);
}
Widget buildTurnIntoMenu() {
final types =
PasteMenuType.values.where((e) => e != PasteMenuType.embed).toList();
return Padding(
padding: const EdgeInsets.all(8.0),
child: SeparatedColumn(
mainAxisSize: MainAxisSize.min,
separatorBuilder: () => const VSpace(0.0),
children: List.generate(types.length, (index) {
final command = types[index];
return SizedBox(
height: 36,
child: FlowyButton(
text: FlowyText(
command.title,
fontWeight: FontWeight.w400,
figmaLineHeight: 20,
),
onTap: () {
if (command == PasteMenuType.bookmark) {
final transaction = editorState.transaction;
transaction.updateNode(node, {
LinkPreviewBlockKeys.url: url,
LinkEmbedKeys.previewType: '',
});
editorState.apply(transaction);
} else if (command == PasteMenuType.mention) {
convertUrlPreviewNodeToMention(editorState, node);
} else if (command == PasteMenuType.url) {
convertUrlPreviewNodeToLink(editorState, node);
}
},
),
);
}),
),
);
}
Widget buildMoreOptionBotton() {
final theme = AppFlowyTheme.of(context), iconScheme = theme.iconColorTheme;
return AppFlowyPopover(
offset: Offset(0, 6),
direction: PopoverDirection.bottomWithRightAligned,
margin: EdgeInsets.zero,
controller: moreOptionController,
onOpen: () {
keepEditorFocusNotifier.increase();
moreOptionNum++;
},
onClose: () {
keepEditorFocusNotifier.decrease();
moreOptionNum--;
checkToHideMenu();
},
popupBuilder: (context) => buildMoreOptionMenu(),
child: FlowyIconButton(
key: moreOptionButtonKey,
icon: FlowySvg(
FlowySvgs.toolbar_more_m,
color: iconScheme.tertiary,
),
tooltipText: LocaleKeys.document_toolbar_moreOptions.tr(),
preferBelow: false,
onPressed: showMoreOptionMenu,
),
);
}
Widget buildMoreOptionMenu() {
final types = LinkEmbedMenuCommand.values;
return Padding(
padding: const EdgeInsets.all(8.0),
child: SeparatedColumn(
mainAxisSize: MainAxisSize.min,
separatorBuilder: () => const VSpace(0.0),
children: List.generate(types.length, (index) {
final command = types[index];
return SizedBox(
height: 36,
child: FlowyButton(
text: FlowyText(
command.title,
fontWeight: FontWeight.w400,
figmaLineHeight: 20,
),
onTap: () => onEmbedMenuCommand(command),
),
);
}),
),
);
}
void showTurnIntoMenu() {
keepEditorFocusNotifier.increase();
turnintoController.show();
checkToShowMenu();
turnintoMenuNum++;
if (isMoreOptionShowing) closeMoreOptionMenu();
}
void closeTurnIntoMenu() {
turnintoController.close();
checkToHideMenu();
}
void showMoreOptionMenu() {
keepEditorFocusNotifier.increase();
moreOptionController.show();
checkToShowMenu();
moreOptionNum++;
if (isTurnIntoShowing) closeTurnIntoMenu();
}
void closeMoreOptionMenu() {
moreOptionController.close();
checkToHideMenu();
}
void checkToHideMenu() {
Future.delayed(Duration(milliseconds: 200), () {
if (!mounted) return;
if (!isAlignMenuShowing && !isMoreOptionShowing && !isTurnIntoShowing) {
widget.onMenuHided.call();
}
});
}
void checkToShowMenu() {
if (!isAlignMenuShowing && !isMoreOptionShowing && !isTurnIntoShowing) {
widget.onMenuShowed.call();
}
}
Future<void> copyLink(BuildContext context) async {
await context.copyLink(url);
widget.onMenuHided.call();
}
void onEmbedMenuCommand(LinkEmbedMenuCommand command) {
switch (command) {
case LinkEmbedMenuCommand.openLink:
afLaunchUrlString(url, addingHttpSchemeWhenFailed: true);
break;
case LinkEmbedMenuCommand.replace:
final box = moreOptionButtonKey.currentContext?.findRenderObject()
as RenderBox?;
if (box == null) return;
final p = box.localToGlobal(Offset.zero);
showReplaceMenu(
context: context,
editorState: editorState,
node: node,
url: url,
ltrb: LTRB(left: p.dx - 330, top: p.dy),
onReplace: (url) async {
await convertLinkBlockToOtherLinkBlock(
editorState,
node,
node.type,
url: url,
);
},
);
break;
case LinkEmbedMenuCommand.reload:
widget.onReload.call();
break;
case LinkEmbedMenuCommand.removeLink:
removeUrlPreviewLink(editorState, node);
break;
}
closeMoreOptionMenu();
}
}
enum LinkEmbedMenuCommand {
openLink,
replace,
reload,
removeLink;
String get title {
switch (this) {
case openLink:
return LocaleKeys.editor_openLink.tr();
case replace:
return LocaleKeys.document_plugins_linkPreview_linkPreviewMenu_replace
.tr();
case reload:
return LocaleKeys.document_plugins_linkPreview_linkPreviewMenu_reload
.tr();
case removeLink:
return LocaleKeys
.document_plugins_linkPreview_linkPreviewMenu_removeLink
.tr();
}
}
}
@@ -0,0 +1,135 @@
import 'dart:convert';
import 'package:appflowy/core/config/kv.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/shared/appflowy_network_image.dart';
import 'package:appflowy/shared/appflowy_network_svg.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy_backend/log.dart';
import 'package:favicon/favicon.dart';
import 'package:flutter/material.dart';
// ignore: depend_on_referenced_packages
import 'package:flutter_link_previewer/flutter_link_previewer.dart' hide Size;
class LinkParser {
static final LinkInfoCache _cache = LinkInfoCache();
final Set<ValueChanged<LinkInfo>> _listeners = <ValueChanged<LinkInfo>>{};
Future<void> start(String url) async {
final data = await _cache.get(url);
if (data != null) {
refreshLinkInfo(data);
}
await _getLinkInfo(url);
}
Future<LinkInfo?> _getLinkInfo(String url) async {
try {
final previewData = await getPreviewData(url);
final favicon = await FaviconFinder.getBest(url);
final linkInfo = LinkInfo(
siteName: previewData.title,
description: previewData.description,
imageUrl: previewData.image?.url,
faviconUrl: favicon?.url,
);
if (!linkInfo.isEmpty()) await _cache.set(url, linkInfo);
refreshLinkInfo(linkInfo);
return linkInfo;
} catch (e, s) {
Log.error('get link info error: ', e, s);
refreshLinkInfo(LinkInfo());
return null;
}
}
void refreshLinkInfo(LinkInfo info) {
for (final listener in _listeners) {
listener(info);
}
}
void addLinkInfoListener(ValueChanged<LinkInfo> listener) {
_listeners.add(listener);
}
void dispose() {
_listeners.clear();
}
}
class LinkInfo {
factory LinkInfo.fromJson(Map<String, dynamic> json) => LinkInfo(
siteName: json['siteName'],
description: json['description'],
imageUrl: json['imageUrl'],
faviconUrl: json['faviconUrl'],
);
LinkInfo({
this.siteName,
this.description,
this.imageUrl,
this.faviconUrl,
});
final String? siteName;
final String? description;
final String? imageUrl;
final String? faviconUrl;
Map<String, dynamic> toJson() => {
'siteName': siteName,
'description': description,
'imageUrl': imageUrl,
'faviconUrl': faviconUrl,
};
bool isEmpty() {
return siteName == null ||
description == null ||
imageUrl == null ||
faviconUrl == null;
}
Widget buildIconWidget({Size size = const Size.square(20.0)}) {
final iconUrl = faviconUrl;
if (iconUrl == null) {
return FlowySvg(FlowySvgs.toolbar_link_earth_m, size: size);
}
if (iconUrl.endsWith('.svg')) {
return FlowyNetworkSvg(
iconUrl,
height: size.height,
width: size.width,
errorWidget: const FlowySvg(FlowySvgs.toolbar_link_earth_m),
);
}
return FlowyNetworkImage(
url: iconUrl,
fit: BoxFit.contain,
height: size.height,
width: size.width,
errorWidgetBuilder: (context, error, stackTrace) =>
const FlowySvg(FlowySvgs.toolbar_link_earth_m),
);
}
}
class LinkInfoCache {
final _linkInfoPrefix = 'link_info';
Future<LinkInfo?> get(String url) async {
final option = await getIt<KeyValueStorage>().getWithFormat<LinkInfo?>(
_linkInfoPrefix + url,
(value) => LinkInfo.fromJson(jsonDecode(value)),
);
return option;
}
Future<void> set(String url, LinkInfo data) async {
await getIt<KeyValueStorage>().set(
_linkInfoPrefix + url,
jsonEncode(data.toJson()),
);
}
}
@@ -12,6 +12,7 @@ import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';
import 'package:universal_platform/universal_platform.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
class CustomLinkPreviewWidget extends StatelessWidget {
const CustomLinkPreviewWidget({
@@ -21,6 +22,8 @@ class CustomLinkPreviewWidget extends StatelessWidget {
this.title,
this.description,
this.imageUrl,
this.isHovering = false,
this.status = LinkPreviewStatus.loading,
});
final Node node;
@@ -28,9 +31,14 @@ class CustomLinkPreviewWidget extends StatelessWidget {
final String? description;
final String? imageUrl;
final String url;
final bool isHovering;
final LinkPreviewStatus status;
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context),
borderScheme = theme.borderColorScheme,
textScheme = theme.textColorScheme;
final documentFontSize = context
.read<EditorState>()
.editorStyle
@@ -39,69 +47,60 @@ class CustomLinkPreviewWidget extends StatelessWidget {
.fontSize ??
16.0;
final (fontSize, width) = UniversalPlatform.isDesktopOrWeb
? (documentFontSize, 180.0)
? (documentFontSize, 160.0)
: (documentFontSize - 2, 120.0);
final Widget child = Container(
clipBehavior: Clip.hardEdge,
decoration: BoxDecoration(
border: Border.all(
color: Theme.of(context).colorScheme.onSurface,
),
borderRadius: BorderRadius.circular(
6.0,
color: isHovering
? borderScheme.greyTertiaryHover
: borderScheme.greyTertiary,
),
borderRadius: BorderRadius.circular(16.0),
),
child: IntrinsicHeight(
child: SizedBox(
height: 96,
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (imageUrl != null)
ClipRRect(
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(6.0),
bottomLeft: Radius.circular(6.0),
),
child: FlowyNetworkImage(
url: imageUrl!,
width: width,
),
),
buildImage(context),
Expanded(
child: Padding(
padding: const EdgeInsets.all(8.0),
padding: const EdgeInsets.fromLTRB(20, 12, 60, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
buildLoadingOrErrorWidget(),
if (title != null)
Padding(
padding: const EdgeInsets.only(
bottom: 4.0,
right: 10.0,
),
padding: const EdgeInsets.only(bottom: 4.0),
child: FlowyText.medium(
title!,
maxLines: 2,
overflow: TextOverflow.ellipsis,
fontSize: fontSize,
color: textScheme.primary,
figmaLineHeight: 20,
),
),
if (description != null)
Padding(
padding: const EdgeInsets.only(bottom: 4.0),
padding: const EdgeInsets.only(bottom: 16.0),
child: FlowyText(
description!,
maxLines: 2,
overflow: TextOverflow.ellipsis,
fontSize: fontSize - 4,
figmaLineHeight: 16,
color: textScheme.primary,
),
),
FlowyText(
url.toString(),
overflow: TextOverflow.ellipsis,
maxLines: 2,
color: Theme.of(context).hintColor,
color: textScheme.secondary,
fontSize: fontSize - 4,
figmaLineHeight: 16,
),
],
),
@@ -150,4 +149,67 @@ class CustomLinkPreviewWidget extends StatelessWidget {
),
];
}
Widget buildImage(BuildContext context) {
final theme = AppFlowyTheme.of(context),
fillScheme = theme.fillColorScheme,
iconScheme = theme.iconColorTheme;
final width = UniversalPlatform.isDesktopOrWeb ? 160.0 : 120.0;
Widget child;
if (imageUrl != null) {
child = FlowyNetworkImage(
url: imageUrl!,
width: width,
);
} else {
child = Center(
child: FlowySvg(
FlowySvgs.toolbar_link_earth_m,
color: iconScheme.secondary,
size: Size.square(30),
),
);
}
return ClipRRect(
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(16.0),
bottomLeft: Radius.circular(16.0),
),
child: Container(
width: width,
color: fillScheme.quaternary,
child: child,
),
);
}
Widget buildLoadingOrErrorWidget() {
if (status == LinkPreviewStatus.loading) {
return Expanded(
child: const Center(
child: SizedBox(
height: 16,
width: 16,
child: CircularProgressIndicator.adaptive(),
),
),
);
} else if (status == LinkPreviewStatus.error) {
return Expanded(
child: const Center(
child: SizedBox(
height: 16,
width: 16,
child: Icon(
Icons.error_outline,
color: Colors.red,
),
),
),
);
}
return SizedBox.shrink();
}
}
enum LinkPreviewStatus { loading, error, idle }
@@ -0,0 +1,204 @@
import 'package:appflowy/plugins/document/presentation/editor_plugins/link_embed/link_embed_block_component.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart';
import 'package:flutter/material.dart';
import 'custom_link_preview.dart';
import 'link_preview_menu.dart';
class CustomLinkPreviewBlockComponentBuilder extends BlockComponentBuilder {
CustomLinkPreviewBlockComponentBuilder({
super.configuration,
this.cache,
});
final LinkPreviewDataCacheInterface? cache;
@override
BlockComponentWidget build(BlockComponentContext blockComponentContext) {
final node = blockComponentContext.node;
final isEmbed =
node.attributes[LinkEmbedKeys.previewType] == LinkEmbedKeys.embed;
if (isEmbed) {
return LinkEmbedBlockComponent(
key: node.key,
node: node,
configuration: configuration,
showActions: showActions(node),
actionBuilder: (_, state) =>
actionBuilder(blockComponentContext, state),
);
}
return CustomLinkPreviewBlockComponent(
key: node.key,
node: node,
configuration: configuration,
showActions: showActions(node),
actionBuilder: (_, state) => actionBuilder(blockComponentContext, state),
cache: cache,
);
}
@override
BlockComponentValidate get validate =>
(node) => node.attributes[LinkPreviewBlockKeys.url]!.isNotEmpty;
}
class CustomLinkPreviewBlockComponent extends BlockComponentStatefulWidget {
const CustomLinkPreviewBlockComponent({
super.key,
required super.node,
super.showActions,
super.actionBuilder,
super.configuration = const BlockComponentConfiguration(),
this.cache,
});
final LinkPreviewDataCacheInterface? cache;
@override
State<CustomLinkPreviewBlockComponent> createState() =>
CustomLinkPreviewBlockComponentState();
}
class CustomLinkPreviewBlockComponentState
extends State<CustomLinkPreviewBlockComponent>
with BlockComponentConfigurable {
@override
BlockComponentConfiguration get configuration => widget.configuration;
@override
Node get node => widget.node;
String get url => widget.node.attributes[LinkPreviewBlockKeys.url]!;
late final LinkPreviewParser parser;
late Future<void> future;
final showActionsNotifier = ValueNotifier<bool>(false);
bool isMenuShowing = false, isHovering = false;
@override
void initState() {
super.initState();
parser = LinkPreviewParser(url: url, cache: widget.cache);
future = parser.start();
}
@override
void didUpdateWidget(CustomLinkPreviewBlockComponent oldWidget) {
super.didUpdateWidget(oldWidget);
final url = widget.node.attributes[LinkPreviewBlockKeys.url]!;
final oldUrl = oldWidget.node.attributes[LinkPreviewBlockKeys.url]!;
if (url != oldUrl) {
parser = LinkPreviewParser(url: url, cache: widget.cache);
setState(() {
future = parser.start();
});
}
}
@override
Widget build(BuildContext context) {
return MouseRegion(
onEnter: (_) {
isHovering = true;
showActionsNotifier.value = true;
},
onExit: (_) {
isHovering = false;
Future.delayed(const Duration(milliseconds: 200), () {
if (isMenuShowing || isHovering) return;
if (mounted) showActionsNotifier.value = false;
});
},
hitTestBehavior: HitTestBehavior.opaque,
opaque: false,
child: ValueListenableBuilder<bool>(
valueListenable: showActionsNotifier,
builder: (context, showActions, child) {
return FutureBuilder(
future: future,
builder: (context, snapshot) {
Widget child;
if (snapshot.connectionState != ConnectionState.done) {
child = CustomLinkPreviewWidget(
node: node,
url: url,
isHovering: showActions,
);
} else {
final title = parser.getContent(LinkPreviewRegex.title);
final description =
parser.getContent(LinkPreviewRegex.description);
final image = parser.getContent(LinkPreviewRegex.image);
if (title == null && description == null && image == null) {
child = CustomLinkPreviewWidget(
node: node,
url: url,
isHovering: showActions,
status: LinkPreviewStatus.error,
);
} else {
child = CustomLinkPreviewWidget(
node: node,
url: url,
title: title,
description: description,
imageUrl: image,
isHovering: showActions,
status: LinkPreviewStatus.idle,
);
}
}
child = Padding(padding: padding, child: child);
if (widget.showActions && widget.actionBuilder != null) {
child = BlockComponentActionWrapper(
node: node,
actionBuilder: widget.actionBuilder!,
child: child,
);
}
child = Stack(
children: [
child,
if (showActions)
Positioned(
top: 16,
right: 16,
child: CustomLinkPreviewMenu(
onMenuShowed: () {
isMenuShowing = true;
},
onMenuHided: () {
isMenuShowing = false;
if (!isHovering && mounted) {
showActionsNotifier.value = false;
}
},
onReload: () {
if (mounted) {
setState(() {
future = parser.start();
});
}
},
node: node,
),
),
],
);
return child;
},
);
},
),
);
}
}
@@ -1,110 +1,207 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/block_menu/block_menu_button.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_hover_menu.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_replace_menu.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/link_embed/link_embed_block_component.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/shared.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/menu/menu_extension.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../image/custom_image_block_component/custom_image_block_component.dart';
class LinkPreviewMenu extends StatefulWidget {
const LinkPreviewMenu({
class CustomLinkPreviewMenu extends StatefulWidget {
const CustomLinkPreviewMenu({
super.key,
required this.onMenuShowed,
required this.onMenuHided,
required this.onReload,
required this.node,
required this.state,
});
final VoidCallback onMenuShowed;
final VoidCallback onMenuHided;
final VoidCallback onReload;
final Node node;
final LinkPreviewBlockComponentState state;
@override
State<LinkPreviewMenu> createState() => _LinkPreviewMenuState();
State<CustomLinkPreviewMenu> createState() => _CustomLinkPreviewMenuState();
}
class _LinkPreviewMenuState extends State<LinkPreviewMenu> {
class _CustomLinkPreviewMenuState extends State<CustomLinkPreviewMenu> {
final popoverController = PopoverController();
final buttonKey = GlobalKey();
bool closed = false;
bool selected = false;
@override
void dispose() {
super.dispose();
popoverController.close();
widget.onMenuHided.call();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
height: 32,
decoration: BoxDecoration(
color: theme.cardColor,
boxShadow: [
BoxShadow(
blurRadius: 5,
spreadRadius: 1,
color: Colors.black.withValues(alpha: 0.1),
),
],
borderRadius: BorderRadius.circular(4.0),
),
child: Row(
children: [
const HSpace(4),
MenuBlockButton(
tooltip: LocaleKeys.document_plugins_urlPreview_convertToLink.tr(),
iconData: FlowySvgs.m_toolbar_link_m,
onTap: () async => convertUrlPreviewNodeToLink(
context.read<EditorState>(),
widget.node,
),
),
const HSpace(4),
MenuBlockButton(
tooltip: LocaleKeys.editor_copyLink.tr(),
iconData: FlowySvgs.copy_s,
onTap: copyImageLink,
),
const _Divider(),
MenuBlockButton(
tooltip: LocaleKeys.button_delete.tr(),
iconData: FlowySvgs.trash_s,
onTap: deleteLinkPreviewNode,
),
const HSpace(4),
],
return AppFlowyPopover(
offset: Offset(0, 0.0),
direction: PopoverDirection.bottomWithRightAligned,
margin: EdgeInsets.zero,
controller: popoverController,
onOpen: () => keepEditorFocusNotifier.increase(),
onClose: () {
keepEditorFocusNotifier.decrease();
if (!closed) {
closed = true;
return;
} else {
closed = false;
widget.onMenuHided.call();
}
setState(() {
selected = false;
});
},
popupBuilder: (context) => buildMenu(),
child: FlowyIconButton(
key: buttonKey,
isSelected: selected,
icon: FlowySvg(FlowySvgs.toolbar_more_m),
onPressed: showPopover,
),
);
}
void copyImageLink() {
final url = widget.node.attributes[CustomImageBlockKeys.url];
if (url != null) {
Clipboard.setData(ClipboardData(text: url));
showToastNotification(
context,
message: LocaleKeys.document_plugins_urlPreview_copiedToPasteBoard.tr(),
);
Widget buildMenu() {
return MouseRegion(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: SeparatedColumn(
mainAxisSize: MainAxisSize.min,
separatorBuilder: () => const VSpace(0.0),
children:
List.generate(LinkPreviewMenuCommand.values.length, (index) {
final command = LinkPreviewMenuCommand.values[index];
return SizedBox(
height: 36,
child: FlowyButton(
text: FlowyText(
command.title,
fontWeight: FontWeight.w400,
figmaLineHeight: 20,
),
onTap: () => onTap(command),
),
);
}),
),
),
);
}
Future<void> onTap(LinkPreviewMenuCommand command) async {
final editorState = context.read<EditorState>();
final node = widget.node;
final url = node.attributes[LinkPreviewBlockKeys.url];
switch (command) {
case LinkPreviewMenuCommand.convertToMention:
await convertUrlPreviewNodeToMention(editorState, node);
break;
case LinkPreviewMenuCommand.convertToUrl:
await convertUrlPreviewNodeToLink(editorState, node);
break;
case LinkPreviewMenuCommand.convertToEmbed:
final transaction = editorState.transaction;
transaction.updateNode(node, {
LinkPreviewBlockKeys.url: url,
LinkEmbedKeys.previewType: LinkEmbedKeys.embed,
});
await editorState.apply(transaction);
break;
case LinkPreviewMenuCommand.copyLink:
if (url != null) {
await context.copyLink(url);
}
break;
case LinkPreviewMenuCommand.replace:
final box = buttonKey.currentContext?.findRenderObject() as RenderBox?;
if (box == null) return;
final p = box.localToGlobal(Offset.zero);
showReplaceMenu(
context: context,
editorState: editorState,
node: node,
url: url,
ltrb: LTRB(left: p.dx - 330, top: p.dy),
onReplace: (url) async {
await convertLinkBlockToOtherLinkBlock(
editorState,
node,
node.type,
url: url,
);
},
);
break;
case LinkPreviewMenuCommand.reload:
widget.onReload.call();
break;
case LinkPreviewMenuCommand.removeLink:
await removeUrlPreviewLink(editorState, node);
break;
}
closePopover();
}
void showPopover() {
widget.onMenuShowed.call();
keepEditorFocusNotifier.increase();
popoverController.show();
setState(() {
selected = true;
});
}
void closePopover() {
popoverController.close();
widget.onMenuHided.call();
}
}
enum LinkPreviewMenuCommand {
convertToMention,
convertToUrl,
convertToEmbed,
copyLink,
replace,
reload,
removeLink;
String get title {
switch (this) {
case convertToMention:
return LocaleKeys.document_plugins_linkPreview_linkPreviewMenu_toMetion
.tr();
case LinkPreviewMenuCommand.convertToUrl:
return LocaleKeys.document_plugins_linkPreview_linkPreviewMenu_toUrl
.tr();
case LinkPreviewMenuCommand.convertToEmbed:
return LocaleKeys.document_plugins_linkPreview_linkPreviewMenu_toEmbed
.tr();
case LinkPreviewMenuCommand.copyLink:
return LocaleKeys.document_plugins_linkPreview_linkPreviewMenu_copyLink
.tr();
case LinkPreviewMenuCommand.replace:
return LocaleKeys.document_plugins_linkPreview_linkPreviewMenu_replace
.tr();
case LinkPreviewMenuCommand.reload:
return LocaleKeys.document_plugins_linkPreview_linkPreviewMenu_reload
.tr();
case LinkPreviewMenuCommand.removeLink:
return LocaleKeys
.document_plugins_linkPreview_linkPreviewMenu_removeLink
.tr();
}
}
Future<void> deleteLinkPreviewNode() async {
final node = widget.node;
final editorState = context.read<EditorState>();
final transaction = editorState.transaction;
transaction.deleteNode(node);
transaction.afterSelection = null;
await editorState.apply(transaction);
}
}
class _Divider extends StatelessWidget {
const _Divider();
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(8),
child: Container(
width: 1,
color: Colors.grey,
),
);
}
}
@@ -0,0 +1,253 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/link_embed/link_embed_block_component.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/shared.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/menu/menu_extension.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/theme_extension_v2.dart';
import 'package:flowy_infra_ui/style_widget/button.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
const _menuHeighgt = 188.0, _menuWidth = 288.0;
class PasteAsMenuService {
PasteAsMenuService({
required this.context,
required this.editorState,
});
final BuildContext context;
final EditorState editorState;
OverlayEntry? _menuEntry;
void show(String href) {
WidgetsBinding.instance.addPostFrameCallback((_) => _show(href));
}
void dismiss() {
if (_menuEntry != null) {
editorState.service.keyboardService?.enable();
editorState.service.scrollService?.enable();
keepEditorFocusNotifier.decrease();
}
_menuEntry?.remove();
_menuEntry = null;
}
void _show(String href) {
final Size editorSize = editorState.renderBox?.size ?? Size.zero;
if (editorSize == Size.zero) return;
final menuPosition = editorState.calculateMenuOffset(
menuWidth: _menuWidth,
menuHeight: _menuHeighgt,
);
if (menuPosition == null) return;
final ltrb = menuPosition.ltrb;
_menuEntry = OverlayEntry(
builder: (context) => SizedBox(
height: editorSize.height,
width: editorSize.width,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: dismiss,
child: Stack(
children: [
ltrb.buildPositioned(
child: PasteAsMenu(
onSelect: (t) {
final selection = editorState.selection;
if (selection == null) return;
final end = selection.end;
final urlSelection = Selection(
start: end.copyWith(offset: end.offset - href.length),
end: end,
);
if (t == PasteMenuType.bookmark) {
convertUrlToLinkPreview(editorState, urlSelection, href);
} else if (t == PasteMenuType.mention) {
convertUrlToMention(editorState, urlSelection);
} else if (t == PasteMenuType.embed) {
convertUrlToLinkPreview(
editorState,
urlSelection,
href,
previewType: LinkEmbedKeys.embed,
);
}
dismiss();
},
onDismiss: dismiss,
),
),
],
),
),
),
);
Overlay.of(context).insert(_menuEntry!);
editorState.service.keyboardService?.disable(showCursor: true);
editorState.service.scrollService?.disable();
}
}
class PasteAsMenu extends StatefulWidget {
const PasteAsMenu({
super.key,
required this.onSelect,
required this.onDismiss,
});
final ValueChanged<PasteMenuType?> onSelect;
final VoidCallback onDismiss;
@override
State<PasteAsMenu> createState() => _PasteAsMenuState();
}
class _PasteAsMenuState extends State<PasteAsMenu> {
final focusNode = FocusNode(debugLabel: 'paste_as_menu');
final ValueNotifier<int> selectedIndexNotifier = ValueNotifier(0);
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback(
(_) => focusNode.requestFocus(),
);
}
@override
void dispose() {
focusNode.dispose();
selectedIndexNotifier.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final themeV2 = AFThemeExtensionV2.of(context);
return Focus(
focusNode: focusNode,
onKeyEvent: onKeyEvent,
child: Container(
width: _menuWidth,
height: _menuHeighgt,
padding: EdgeInsets.all(6),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: Theme.of(context).cardColor,
boxShadow: [
BoxShadow(
offset: Offset(0, 4),
blurRadius: 16,
color: themeV2.shadow_medium,
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
height: 32,
padding: EdgeInsets.all(8),
child: FlowyText.semibold(
color: themeV2.text_tertiary,
LocaleKeys.document_plugins_linkPreview_typeSelection_pasteAs
.tr(),
),
),
...List.generate(
PasteMenuType.values.length,
(i) => buildItem(PasteMenuType.values[i], i),
),
],
),
),
);
}
Widget buildItem(PasteMenuType type, int i) {
return ValueListenableBuilder(
valueListenable: selectedIndexNotifier,
builder: (context, value, child) {
final isSelected = i == value;
return SizedBox(
height: 36,
child: FlowyButton(
isSelected: isSelected,
text: FlowyText(
type.title,
),
onTap: () => onSelect(type),
),
);
},
);
}
void changeIndex(int index) => selectedIndexNotifier.value = index;
KeyEventResult onKeyEvent(focus, KeyEvent event) {
if (event is! KeyDownEvent && event is! KeyRepeatEvent) {
return KeyEventResult.ignored;
}
int index = selectedIndexNotifier.value,
length = PasteMenuType.values.length;
if (event.logicalKey == LogicalKeyboardKey.enter) {
onSelect(PasteMenuType.values[index]);
} else if (event.logicalKey == LogicalKeyboardKey.escape) {
dismiss();
} else if ([LogicalKeyboardKey.arrowUp, LogicalKeyboardKey.arrowLeft]
.contains(event.logicalKey)) {
if (index == 0) {
index = length - 1;
} else {
index--;
}
changeIndex(index);
} else if ([LogicalKeyboardKey.arrowDown, LogicalKeyboardKey.arrowRight]
.contains(event.logicalKey)) {
if (index == length - 1) {
index = 0;
} else {
index++;
}
changeIndex(index);
}
return KeyEventResult.handled;
}
void onSelect(PasteMenuType type) => widget.onSelect.call(type);
void dismiss() => widget.onDismiss.call();
}
enum PasteMenuType {
mention,
url,
bookmark,
embed,
}
extension PasteMenuTypeExtension on PasteMenuType {
String get title {
switch (this) {
case PasteMenuType.mention:
return LocaleKeys.document_plugins_linkPreview_typeSelection_mention
.tr();
case PasteMenuType.url:
return LocaleKeys.document_plugins_linkPreview_typeSelection_URL.tr();
case PasteMenuType.bookmark:
return LocaleKeys.document_plugins_linkPreview_typeSelection_bookmark
.tr();
case PasteMenuType.embed:
return LocaleKeys.document_plugins_linkPreview_typeSelection_embed.tr();
}
}
}
@@ -1,3 +1,5 @@
import 'package:appflowy/plugins/document/presentation/editor_plugins/link_embed/link_embed_block_component.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart';
@@ -9,7 +11,7 @@ Future<void> convertUrlPreviewNodeToLink(
return;
}
final url = node.attributes[ImageBlockKeys.url];
final url = node.attributes[LinkPreviewBlockKeys.url];
final delta = Delta()
..insert(
url,
@@ -29,3 +31,172 @@ Future<void> convertUrlPreviewNodeToLink(
);
return editorState.apply(transaction);
}
Future<void> convertUrlPreviewNodeToMention(
EditorState editorState,
Node node,
) async {
if (node.type != LinkPreviewBlockKeys.type) {
return;
}
final url = node.attributes[LinkPreviewBlockKeys.url];
final delta = Delta()
..insert(
MentionBlockKeys.mentionChar,
attributes: {
MentionBlockKeys.mention: {
MentionBlockKeys.type: MentionType.externalLink.name,
MentionBlockKeys.url: url,
},
},
);
final transaction = editorState.transaction;
transaction
..insertNode(node.path, paragraphNode(delta: delta))
..deleteNode(node);
transaction.afterSelection = Selection.collapsed(
Position(
path: node.path,
offset: url.length,
),
);
return editorState.apply(transaction);
}
Future<void> removeUrlPreviewLink(
EditorState editorState,
Node node,
) async {
if (node.type != LinkPreviewBlockKeys.type) {
return;
}
final url = node.attributes[LinkPreviewBlockKeys.url];
final delta = Delta()..insert(url);
final transaction = editorState.transaction;
transaction
..insertNode(node.path, paragraphNode(delta: delta))
..deleteNode(node);
transaction.afterSelection = Selection.collapsed(
Position(
path: node.path,
offset: url.length,
),
);
return editorState.apply(transaction);
}
Future<void> convertUrlToLinkPreview(
EditorState editorState,
Selection selection,
String url, {
String? previewType,
}) async {
final node = editorState.getNodeAtPath(selection.end.path);
if (node == null) {
return;
}
final delta = node.delta;
if (delta == null) return;
final List<TextInsert> beforeOperations = [], afterOperations = [];
int index = 0;
for (final insert in delta.whereType<TextInsert>()) {
if (index < selection.startIndex) {
beforeOperations.add(insert);
} else if (index >= selection.endIndex) {
afterOperations.add(insert);
}
index += insert.length;
}
final transaction = editorState.transaction;
transaction
..deleteNode(node)
..insertNodes(node.path.next, [
if (beforeOperations.isNotEmpty)
paragraphNode(delta: Delta(operations: beforeOperations)),
if (previewType == LinkEmbedKeys.embed)
linkEmbedNode(url: url)
else
linkPreviewNode(url: url),
if (afterOperations.isNotEmpty)
paragraphNode(delta: Delta(operations: afterOperations)),
]);
await editorState.apply(transaction);
}
Future<void> convertUrlToMention(
EditorState editorState,
Selection selection,
) async {
final node = editorState.getNodeAtPath(selection.end.path);
if (node == null) {
return;
}
final delta = node.delta;
if (delta == null) return;
String url = '';
int index = 0;
for (final insert in delta.whereType<TextInsert>()) {
if (index >= selection.startIndex && index < selection.endIndex) {
final href = insert.attributes?.href ?? '';
if (href.isNotEmpty) {
url = href;
break;
}
}
index += insert.length;
}
final transaction = editorState.transaction;
transaction.replaceText(
node,
selection.startIndex,
selection.length,
MentionBlockKeys.mentionChar,
attributes: {
MentionBlockKeys.mention: {
MentionBlockKeys.type: MentionType.externalLink.name,
MentionBlockKeys.url: url,
},
},
);
await editorState.apply(transaction);
}
Future<void> convertLinkBlockToOtherLinkBlock(
EditorState editorState,
Node node,
String toType, {
String? url,
}) async {
final nodeType = node.type;
if (nodeType != LinkPreviewBlockKeys.type ||
(nodeType == toType && url == null)) {
return;
}
final insertedNode = <Node>[];
final afterUrl = url ?? node.attributes[LinkPreviewBlockKeys.url] ?? '';
final previewType = node.attributes[LinkEmbedKeys.previewType];
Node afterNode = node.copyWith(
type: toType,
attributes: {
LinkPreviewBlockKeys.url: afterUrl,
LinkEmbedKeys.previewType: previewType,
blockComponentBackgroundColor:
node.attributes[blockComponentBackgroundColor],
blockComponentTextDirection: node.attributes[blockComponentTextDirection],
blockComponentDelta: (node.delta ?? Delta()).toJson(),
},
);
afterNode = afterNode.copyWith(children: []);
insertedNode.add(afterNode);
insertedNode.addAll(node.children.map((e) => e.deepCopy()));
final transaction = editorState.transaction;
transaction.insertNodes(
node.path,
insertedNode,
);
transaction.deleteNodes([node]);
await editorState.apply(transaction);
}
@@ -100,7 +100,6 @@ class ChildPageTransactionHandler extends MentionTransactionHandler {
Log.error(error);
if (context.mounted) {
showToastNotification(
context,
message: LocaleKeys.document_plugins_subPage_errors_failedDeletePage
.tr(),
);
@@ -179,13 +178,6 @@ class ChildPageTransactionHandler extends MentionTransactionHandler {
await duplicatedViewOrFailure.fold(
(newView) async {
final newMentionAttributes = {
MentionBlockKeys.mention: {
MentionBlockKeys.type: MentionType.childPage.name,
MentionBlockKeys.pageId: newView.id,
},
};
// 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;
@@ -202,7 +194,11 @@ class ChildPageTransactionHandler extends MentionTransactionHandler {
node,
mentionIndex,
MentionBlockKeys.mentionChar.length,
newMentionAttributes,
MentionBlockKeys.buildMentionPageAttributes(
mentionType: MentionType.childPage,
pageId: newView.id,
blockId: null,
),
);
await editorState.apply(
transaction,
@@ -192,15 +192,12 @@ class DateTransactionHandler extends MentionTransactionHandler {
),
);
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,
},
};
final newMentionAttributes = MentionBlockKeys.buildMentionDateAttributes(
date: dateTime.toIso8601String(),
reminderId: reminderId,
reminderOption: data.reminderOption.name,
includeTime: data.includeTime,
);
// 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.
@@ -6,14 +6,18 @@ import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'mention_link_block.dart';
enum MentionType {
page,
date,
externalLink,
childPage;
static MentionType fromString(String value) => switch (value) {
'page' => page,
'date' => date,
'externalLink' => externalLink,
'childPage' => childPage,
// Backwards compatibility
'reminder' => date,
@@ -27,12 +31,12 @@ Node dateMentionNode() {
operations: [
TextInsert(
MentionBlockKeys.mentionChar,
attributes: {
MentionBlockKeys.mention: {
MentionBlockKeys.type: MentionType.date.name,
MentionBlockKeys.date: DateTime.now().toIso8601String(),
},
},
attributes: MentionBlockKeys.buildMentionDateAttributes(
date: DateTime.now().toIso8601String(),
reminderId: null,
reminderOption: null,
includeTime: false,
),
),
],
),
@@ -42,18 +46,52 @@ Node dateMentionNode() {
class MentionBlockKeys {
const MentionBlockKeys._();
static const reminderId = 'reminder_id'; // ReminderID
static const mention = 'mention';
static const type = 'type'; // MentionType, String
static const pageId = 'page_id';
static const blockId = 'block_id';
static const url = 'url';
// Related to Reminder and Date blocks
static const date = 'date'; // Start Date
static const includeTime = 'include_time';
static const reminderId = 'reminder_id'; // ReminderID
static const reminderOption = 'reminder_option';
static const mentionChar = '\$';
static Map<String, dynamic> buildMentionPageAttributes({
required MentionType mentionType,
required String pageId,
required String? blockId,
}) {
return {
MentionBlockKeys.mention: {
MentionBlockKeys.type: mentionType.name,
MentionBlockKeys.pageId: pageId,
if (blockId != null) MentionBlockKeys.blockId: blockId,
},
};
}
static Map<String, dynamic> buildMentionDateAttributes({
required String date,
required String? reminderId,
required String? reminderOption,
required bool includeTime,
}) {
return {
MentionBlockKeys.mention: {
MentionBlockKeys.type: MentionType.date.name,
MentionBlockKeys.date: date,
MentionBlockKeys.includeTime: includeTime,
if (reminderId != null) MentionBlockKeys.reminderId: reminderId,
if (reminderOption != null)
MentionBlockKeys.reminderOption: reminderOption,
},
};
}
}
class MentionBlock extends StatelessWidget {
@@ -124,6 +162,17 @@ class MentionBlock extends StatelessWidget {
reminderOption: reminderOption ?? ReminderOption.none,
includeTime: mention[MentionBlockKeys.includeTime] ?? false,
);
case MentionType.externalLink:
final String? url = mention[MentionBlockKeys.url] as String?;
if (url == null) {
return const SizedBox.shrink();
}
return MentionLinkBlock(
url: url,
editorState: editorState,
node: node,
index: index,
);
}
}
}
@@ -201,16 +201,17 @@ class _MentionDateBlockState extends State<MentionDateBlock> {
(reminderOption == ReminderOption.none ? null : widget.reminderId);
final transaction = widget.editorState.transaction
..formatText(widget.node, widget.index, 1, {
MentionBlockKeys.mention: {
MentionBlockKeys.type: MentionType.date.name,
MentionBlockKeys.date: date.toIso8601String(),
MentionBlockKeys.reminderId: rId,
MentionBlockKeys.includeTime: includeTime,
MentionBlockKeys.reminderOption:
reminderOption?.name ?? widget.reminderOption.name,
},
});
..formatText(
widget.node,
widget.index,
1,
MentionBlockKeys.buildMentionDateAttributes(
date: date.toIso8601String(),
reminderId: rId,
includeTime: includeTime,
reminderOption: reminderOption?.name ?? widget.reminderOption.name,
),
);
widget.editorState.apply(transaction, withUpdateSelection: false);
@@ -0,0 +1,337 @@
import 'dart:async';
import 'dart:math';
import 'package:appflowy/core/helpers/url_launcher.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_hover_menu.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/link_embed/link_embed_block_component.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/custom_link_parser.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/paste_as/paste_as_menu.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/shared.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flowy_infra_ui/style_widget/hover.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'mention_link_error_preview.dart';
import 'mention_link_preview.dart';
class MentionLinkBlock extends StatefulWidget {
const MentionLinkBlock({
super.key,
required this.url,
required this.editorState,
required this.node,
required this.index,
this.delayToShow = const Duration(milliseconds: 50),
this.delayToHide = const Duration(milliseconds: 300),
});
final String url;
final Duration delayToShow;
final Duration delayToHide;
final EditorState editorState;
final Node node;
final int index;
@override
State<MentionLinkBlock> createState() => _MentionLinkBlockState();
}
class _MentionLinkBlockState extends State<MentionLinkBlock> {
final parser = LinkParser();
_LoadingStatus status = _LoadingStatus.loading;
final previewController = PopoverController();
LinkInfo linkInfo = LinkInfo();
bool isHovering = false;
int previewFocusNum = 0;
bool isPreviewHovering = false;
bool showAtBottom = false;
final key = GlobalKey();
bool get isPreviewShowing => previewFocusNum > 0;
String get url => widget.url;
EditorState get editorState => widget.editorState;
Node get node => widget.node;
int get index => widget.index;
bool get readyForPreview =>
status == _LoadingStatus.idle && !linkInfo.isEmpty();
@override
void initState() {
super.initState();
parser.addLinkInfoListener((v) {
if (mounted) {
setState(() {
if (v.isEmpty() && linkInfo.isEmpty()) {
status = _LoadingStatus.error;
} else {
linkInfo = v;
status = _LoadingStatus.idle;
}
});
}
});
parser.start(url);
}
@override
void dispose() {
super.dispose();
parser.dispose();
previewController.close();
}
@override
Widget build(BuildContext context) {
return AppFlowyPopover(
key: ValueKey(showAtBottom),
controller: previewController,
direction: showAtBottom
? PopoverDirection.bottomWithLeftAligned
: PopoverDirection.topWithLeftAligned,
offset: Offset(0, showAtBottom ? -20 : 20),
onOpen: () {
keepEditorFocusNotifier.increase();
previewFocusNum++;
},
onClose: () {
keepEditorFocusNotifier.decrease();
previewFocusNum--;
},
decorationColor: Colors.transparent,
popoverDecoration: BoxDecoration(),
margin: EdgeInsets.zero,
constraints: getConstraints(),
borderRadius: BorderRadius.circular(16),
popupBuilder: (context) => readyForPreview
? MentionLinkPreview(
linkInfo: linkInfo,
showAtBottom: showAtBottom,
triggerSize: getSizeFromKey(),
onEnter: (e) {
isPreviewHovering = true;
},
onExit: (e) {
isPreviewHovering = false;
tryToDismissPreview();
},
onCopyLink: () => copyLink(context),
onConvertTo: (s) => convertTo(s),
onRemoveLink: removeLink,
onOpenLink: openLink,
)
: MentionLinkErrorPreview(
url: url,
triggerSize: getSizeFromKey(),
onEnter: (e) {
isPreviewHovering = true;
},
onExit: (e) {
isPreviewHovering = false;
tryToDismissPreview();
},
onCopyLink: () => copyLink(context),
onConvertTo: (s) => convertTo(s),
onRemoveLink: removeLink,
onOpenLink: openLink,
),
child: buildIconWithTitle(context),
);
}
Widget buildIconWithTitle(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: onEnter,
onExit: onExit,
child: GestureDetector(
onTap: () async {
await afLaunchUrlString(url, addingHttpSchemeWhenFailed: true);
},
child: FlowyHoverContainer(
style:
HoverStyle(hoverColor: Theme.of(context).colorScheme.secondary),
applyStyle: isHovering,
child: Row(
mainAxisSize: MainAxisSize.min,
key: key,
children: [
HSpace(2),
buildIcon(),
HSpace(4),
Flexible(
child: FlowyText(
linkInfo.siteName ?? url,
color: theme.textColorScheme.primary,
fontSize: 14,
figmaLineHeight: 20,
overflow: TextOverflow.ellipsis,
),
),
HSpace(2),
],
),
),
),
);
}
Widget buildIcon() {
const defaultWidget = FlowySvg(FlowySvgs.toolbar_link_earth_m);
Widget icon = defaultWidget;
if (status == _LoadingStatus.loading) {
icon = Padding(
padding: const EdgeInsets.all(2.0),
child: const CircularProgressIndicator(strokeWidth: 1),
);
} else {
icon = linkInfo.buildIconWidget();
}
return SizedBox(
height: 20,
width: 20,
child: icon,
);
}
RenderBox? get box => key.currentContext?.findRenderObject() as RenderBox?;
Size getSizeFromKey() => box?.size ?? Size.zero;
Future<void> copyLink(BuildContext context) async {
await context.copyLink(url);
previewController.close();
}
Future<void> openLink() async {
await afLaunchUrlString(url, addingHttpSchemeWhenFailed: true);
}
Future<void> removeLink() async {
final transaction = editorState.transaction
..replaceText(widget.node, widget.index, 1, url, attributes: {});
await editorState.apply(transaction);
}
Future<void> convertTo(PasteMenuType type) async {
if (type == PasteMenuType.url) {
await toUrl();
} else if (type == PasteMenuType.bookmark) {
await toLinkPreview();
} else if (type == PasteMenuType.embed) {
await toLinkPreview(previewType: LinkEmbedKeys.embed);
}
}
Future<void> toUrl() async {
final transaction = editorState.transaction
..replaceText(
widget.node,
widget.index,
1,
url,
attributes: {
AppFlowyRichTextKeys.href: url,
},
);
await editorState.apply(transaction);
}
Future<void> toLinkPreview({String? previewType}) async {
final selection = Selection(
start: Position(path: node.path, offset: index),
end: Position(path: node.path, offset: index + 1),
);
await convertUrlToLinkPreview(
editorState,
selection,
url,
previewType: previewType,
);
}
void changeHovering(bool hovering) {
if (isHovering == hovering) return;
if (mounted) {
setState(() {
isHovering = hovering;
});
}
}
void changeShowAtBottom(bool bottom) {
if (showAtBottom == bottom) return;
if (mounted) {
setState(() {
showAtBottom = bottom;
});
}
}
void tryToDismissPreview() {
Future.delayed(widget.delayToHide, () {
if (isHovering || isPreviewHovering) {
return;
}
previewController.close();
});
}
void onEnter(PointerEnterEvent e) {
changeHovering(true);
final location = box?.localToGlobal(Offset.zero) ?? Offset.zero;
if (readyForPreview) {
if (location.dy < 300) {
changeShowAtBottom(true);
} else {
changeShowAtBottom(false);
}
}
Future.delayed(widget.delayToShow, () {
if (isHovering && !isPreviewShowing && status != _LoadingStatus.loading) {
showPreview();
}
});
}
void onExit(PointerExitEvent e) {
changeHovering(false);
tryToDismissPreview();
}
void showPreview() {
if (!mounted) return;
keepEditorFocusNotifier.increase();
previewController.show();
previewFocusNum++;
}
BoxConstraints getConstraints() {
final size = getSizeFromKey();
if (!readyForPreview) {
return BoxConstraints(
maxWidth: max(320, size.width),
maxHeight: 48 + size.height,
);
}
return BoxConstraints(
maxWidth: max(300, size.width),
maxHeight: 300,
);
}
}
enum _LoadingStatus {
loading,
idle,
error,
}
@@ -0,0 +1,232 @@
import 'dart:math';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_create_menu.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/paste_as/paste_as_menu.dart';
import 'package:appflowy/util/theme_extension.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
class MentionLinkErrorPreview extends StatefulWidget {
const MentionLinkErrorPreview({
super.key,
required this.url,
required this.onEnter,
required this.onExit,
required this.onCopyLink,
required this.onRemoveLink,
required this.onConvertTo,
required this.onOpenLink,
required this.triggerSize,
});
final String url;
final PointerEnterEventListener onEnter;
final PointerExitEventListener onExit;
final VoidCallback onCopyLink;
final VoidCallback onRemoveLink;
final VoidCallback onOpenLink;
final ValueChanged<PasteMenuType> onConvertTo;
final Size triggerSize;
@override
State<MentionLinkErrorPreview> createState() =>
_MentionLinkErrorPreviewState();
}
class _MentionLinkErrorPreviewState extends State<MentionLinkErrorPreview> {
final menuController = PopoverController();
bool isConvertButtonSelected = false;
@override
void dispose() {
super.dispose();
menuController.close();
}
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MouseRegion(
onEnter: widget.onEnter,
onExit: widget.onExit,
child: SizedBox(
width: max(320, widget.triggerSize.width),
height: 48,
child: Align(
alignment: Alignment.centerLeft,
child: Container(
width: 320,
height: 48,
decoration: buildToolbarLinkDecoration(context),
padding: EdgeInsets.fromLTRB(12, 8, 8, 8),
child: Row(
children: [
Expanded(child: buildLinkWidget()),
Container(
height: 20,
width: 1,
color: Color(0xffE8ECF3)
.withAlpha(Theme.of(context).isLightMode ? 255 : 40),
margin: EdgeInsets.symmetric(horizontal: 6),
),
FlowyIconButton(
icon: FlowySvg(FlowySvgs.toolbar_link_m),
tooltipText: LocaleKeys.editor_copyLink.tr(),
preferBelow: false,
width: 36,
height: 32,
onPressed: widget.onCopyLink,
),
buildConvertButton(),
],
),
),
),
),
),
MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: widget.onEnter,
onExit: widget.onExit,
child: GestureDetector(
onTap: widget.onOpenLink,
child: Container(
width: widget.triggerSize.width,
height: widget.triggerSize.height,
color: Colors.black.withAlpha(1),
),
),
),
],
);
}
Widget buildLinkWidget() {
final url = widget.url;
return FlowyTooltip(
message: url,
preferBelow: false,
child: FlowyText.regular(
url,
overflow: TextOverflow.ellipsis,
figmaLineHeight: 20,
fontSize: 14,
),
);
}
Widget buildConvertButton() {
return AppFlowyPopover(
offset: Offset(8, 10),
direction: PopoverDirection.bottomWithRightAligned,
margin: EdgeInsets.zero,
controller: menuController,
onOpen: () => keepEditorFocusNotifier.increase(),
onClose: () => keepEditorFocusNotifier.decrease(),
popupBuilder: (context) => buildConvertMenu(),
child: FlowyIconButton(
icon: FlowySvg(FlowySvgs.turninto_m),
isSelected: isConvertButtonSelected,
tooltipText: LocaleKeys.editor_convertTo.tr(),
preferBelow: false,
width: 36,
height: 32,
onPressed: () {
setState(() {
isConvertButtonSelected = true;
});
showPopover();
},
),
);
}
Widget buildConvertMenu() {
return MouseRegion(
onEnter: widget.onEnter,
onExit: widget.onExit,
child: Padding(
padding: const EdgeInsets.all(8.0),
child: SeparatedColumn(
mainAxisSize: MainAxisSize.min,
separatorBuilder: () => const VSpace(0.0),
children: List.generate(MentionLinktErrorMenuCommand.values.length,
(index) {
final command = MentionLinktErrorMenuCommand.values[index];
return SizedBox(
height: 36,
child: FlowyButton(
text: FlowyText(
command.title,
fontWeight: FontWeight.w400,
figmaLineHeight: 20,
),
onTap: () => onTap(command),
),
);
}),
),
),
);
}
void showPopover() {
keepEditorFocusNotifier.increase();
menuController.show();
}
void closePopover() {
menuController.close();
}
void onTap(MentionLinktErrorMenuCommand command) {
switch (command) {
case MentionLinktErrorMenuCommand.toURL:
widget.onConvertTo(PasteMenuType.url);
break;
case MentionLinktErrorMenuCommand.toBookmark:
widget.onConvertTo(PasteMenuType.bookmark);
break;
case MentionLinktErrorMenuCommand.toEmbed:
widget.onConvertTo(PasteMenuType.embed);
break;
case MentionLinktErrorMenuCommand.removeLink:
widget.onRemoveLink();
break;
}
closePopover();
}
}
enum MentionLinktErrorMenuCommand {
toURL,
toBookmark,
toEmbed,
removeLink;
String get title {
switch (this) {
case toURL:
return LocaleKeys.document_plugins_linkPreview_linkPreviewMenu_toUrl
.tr();
case toBookmark:
return LocaleKeys
.document_plugins_linkPreview_linkPreviewMenu_toBookmark
.tr();
case toEmbed:
return LocaleKeys.document_plugins_linkPreview_linkPreviewMenu_toEmbed
.tr();
case removeLink:
return LocaleKeys
.document_plugins_linkPreview_linkPreviewMenu_removeLink
.tr();
}
}
}
@@ -0,0 +1,271 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_create_menu.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/custom_link_parser.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/paste_as/paste_as_menu.dart';
import 'package:appflowy/plugins/document/presentation/editor_style.dart';
import 'package:appflowy/shared/appflowy_network_image.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
class MentionLinkPreview extends StatefulWidget {
const MentionLinkPreview({
super.key,
required this.linkInfo,
required this.onEnter,
required this.onExit,
required this.onCopyLink,
required this.onRemoveLink,
required this.onConvertTo,
required this.onOpenLink,
required this.triggerSize,
required this.showAtBottom,
});
final LinkInfo linkInfo;
final PointerEnterEventListener onEnter;
final PointerExitEventListener onExit;
final VoidCallback onCopyLink;
final VoidCallback onRemoveLink;
final VoidCallback onOpenLink;
final ValueChanged<PasteMenuType> onConvertTo;
final Size triggerSize;
final bool showAtBottom;
@override
State<MentionLinkPreview> createState() => _MentionLinkPreviewState();
}
class _MentionLinkPreviewState extends State<MentionLinkPreview> {
final menuController = PopoverController();
bool isSelected = false;
LinkInfo get linkInfo => widget.linkInfo;
@override
void dispose() {
super.dispose();
menuController.close();
}
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context),
textColorScheme = theme.textColorScheme;
final card = MouseRegion(
onEnter: widget.onEnter,
onExit: widget.onExit,
child: Container(
decoration: buildToolbarLinkDecoration(context, radius: 16),
width: 280,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClipRRect(
borderRadius:
const BorderRadius.vertical(top: Radius.circular(16)),
child: FlowyNetworkImage(
url: linkInfo.imageUrl ?? '',
width: 280,
height: 120,
),
),
VSpace(12),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: FlowyText.semibold(
linkInfo.siteName ?? '',
fontSize: 14,
figmaLineHeight: 20,
color: textColorScheme.primary,
overflow: TextOverflow.ellipsis,
),
),
VSpace(4),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: FlowyText(
linkInfo.description ?? '',
fontSize: 12,
figmaLineHeight: 16,
color: textColorScheme.secondary,
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
),
VSpace(36),
Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
height: 28,
child: Row(
children: [
linkInfo.buildIconWidget(size: Size.square(16)),
HSpace(6),
Expanded(
child: FlowyText(
linkInfo.description ?? '',
fontSize: 12,
figmaLineHeight: 16,
color: textColorScheme.primary,
overflow: TextOverflow.ellipsis,
fontWeight: FontWeight.w700,
),
),
buildMoreOptionButton(),
],
),
),
VSpace(12),
],
),
),
);
final clickPlaceHolder = MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: widget.onEnter,
onExit: widget.onExit,
child: GestureDetector(
child: Container(
height: 20,
width: widget.triggerSize.width,
color: Colors.white.withAlpha(1),
),
onTap: () {
widget.onOpenLink.call();
closePopover();
},
),
);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: widget.showAtBottom
? [clickPlaceHolder, card]
: [card, clickPlaceHolder],
);
}
Widget buildMoreOptionButton() {
return AppFlowyPopover(
controller: menuController,
direction: PopoverDirection.topWithLeftAligned,
onOpen: () => keepEditorFocusNotifier.increase(),
onClose: () => keepEditorFocusNotifier.decrease(),
margin: EdgeInsets.zero,
borderRadius: BorderRadius.circular(12),
popupBuilder: (context) => buildConvertMenu(),
child: FlowyIconButton(
width: 28,
height: 28,
isSelected: isSelected,
hoverColor: EditorStyleCustomizer.toolbarHoverColor(context),
icon: FlowySvg(
FlowySvgs.toolbar_more_m,
size: Size.square(20),
),
onPressed: () {
setState(() {
isSelected = true;
});
showPopover();
},
),
);
}
Widget buildConvertMenu() {
return MouseRegion(
onEnter: widget.onEnter,
onExit: widget.onExit,
child: Padding(
padding: const EdgeInsets.all(8.0),
child: SeparatedColumn(
mainAxisSize: MainAxisSize.min,
separatorBuilder: () => const VSpace(0.0),
children:
List.generate(MentionLinktMenuCommand.values.length, (index) {
final command = MentionLinktMenuCommand.values[index];
return SizedBox(
height: 36,
child: FlowyButton(
text: FlowyText(
command.title,
fontWeight: FontWeight.w400,
figmaLineHeight: 20,
),
onTap: () => onTap(command),
),
);
}),
),
),
);
}
void showPopover() {
keepEditorFocusNotifier.increase();
menuController.show();
}
void closePopover() {
menuController.close();
}
void onTap(MentionLinktMenuCommand command) {
switch (command) {
case MentionLinktMenuCommand.toURL:
widget.onConvertTo(PasteMenuType.url);
break;
case MentionLinktMenuCommand.toBookmark:
widget.onConvertTo(PasteMenuType.bookmark);
break;
case MentionLinktMenuCommand.toEmbed:
widget.onConvertTo(PasteMenuType.embed);
break;
case MentionLinktMenuCommand.copyLink:
widget.onCopyLink();
break;
case MentionLinktMenuCommand.removeLink:
widget.onRemoveLink();
break;
}
closePopover();
}
}
enum MentionLinktMenuCommand {
toURL,
toBookmark,
toEmbed,
copyLink,
removeLink;
String get title {
switch (this) {
case toURL:
return LocaleKeys.document_plugins_linkPreview_linkPreviewMenu_toUrl
.tr();
case toBookmark:
return LocaleKeys
.document_plugins_linkPreview_linkPreviewMenu_toBookmark
.tr();
case toEmbed:
return LocaleKeys.document_plugins_linkPreview_linkPreviewMenu_toEmbed
.tr();
case copyLink:
return LocaleKeys.document_plugins_linkPreview_linkPreviewMenu_copyLink
.tr();
case removeLink:
return LocaleKeys
.document_plugins_linkPreview_linkPreviewMenu_removeLink
.tr();
}
}
}
@@ -50,12 +50,11 @@ Node pageMentionNode(String viewId) {
operations: [
TextInsert(
MentionBlockKeys.mentionChar,
attributes: {
MentionBlockKeys.mention: {
MentionBlockKeys.type: MentionType.page.name,
MentionBlockKeys.pageId: viewId,
},
},
attributes: MentionBlockKeys.buildMentionPageAttributes(
mentionType: MentionType.page,
pageId: viewId,
blockId: null,
),
),
],
),
@@ -284,12 +283,11 @@ class _MentionSubPageBlockState extends State<MentionSubPageBlock> {
widget.node,
widget.index,
MentionBlockKeys.mentionChar.length,
{
MentionBlockKeys.mention: {
MentionBlockKeys.type: MentionType.page.name,
MentionBlockKeys.pageId: widget.pageId,
},
},
MentionBlockKeys.buildMentionPageAttributes(
mentionType: MentionType.page,
pageId: widget.pageId,
blockId: null,
),
);
widget.editorState.apply(
@@ -383,25 +381,24 @@ Future<void> _handleDoubleTap(
}
final currentViewId = context.read<DocumentBloc>().documentId;
final newViewId = await showPageSelectorSheet(
final newView = await showPageSelectorSheet(
context,
currentViewId: currentViewId,
selectedViewId: viewId,
);
if (newViewId != null) {
if (newView != null) {
// Update this nodes pageId
final transaction = editorState.transaction
..formatText(
node,
index,
1,
{
MentionBlockKeys.mention: {
MentionBlockKeys.type: MentionType.page.name,
MentionBlockKeys.pageId: newViewId,
},
},
MentionBlockKeys.buildMentionPageAttributes(
mentionType: MentionType.page,
pageId: newView.id,
blockId: null,
),
);
await editorState.apply(transaction, withUpdateSelection: false);
@@ -0,0 +1,116 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
extension MenuExtension on EditorState {
MenuPosition? calculateMenuOffset({
Rect? rect,
required double menuWidth,
required double menuHeight,
Offset menuOffset = const Offset(0, 10),
}) {
final selectionService = service.selectionService;
final selectionRects = selectionService.selectionRects;
late Rect startRect;
if (rect != null) {
startRect = rect;
} else {
if (selectionRects.isEmpty) return null;
startRect = selectionRects.first;
}
final editorOffset = renderBox?.localToGlobal(Offset.zero) ?? Offset.zero;
final editorHeight = renderBox!.size.height;
final editorWidth = renderBox!.size.width;
// show below default
Alignment alignment = Alignment.topLeft;
final bottomRight = startRect.bottomRight;
final topRight = startRect.topRight;
var startOffset = bottomRight + menuOffset;
Offset offset = Offset(
startOffset.dx,
startOffset.dy,
);
// show above
if (startOffset.dy + menuHeight >= editorOffset.dy + editorHeight) {
startOffset = topRight - menuOffset;
alignment = Alignment.bottomLeft;
offset = Offset(
startOffset.dx,
editorHeight + editorOffset.dy - startOffset.dy,
);
}
// show on right
if (offset.dx + menuWidth < editorOffset.dx + editorWidth) {
offset = Offset(
offset.dx,
offset.dy,
);
} else if (startOffset.dx - editorOffset.dx > menuWidth) {
// show on left
alignment = alignment == Alignment.topLeft
? Alignment.topRight
: Alignment.bottomRight;
offset = Offset(
editorWidth - offset.dx + editorOffset.dx,
offset.dy,
);
}
return MenuPosition(align: alignment, offset: offset);
}
}
class MenuPosition {
MenuPosition({
required this.align,
required this.offset,
});
final Alignment align;
final Offset offset;
LTRB get ltrb {
double? left, top, right, bottom;
switch (align) {
case Alignment.topLeft:
left = offset.dx;
top = offset.dy;
break;
case Alignment.bottomLeft:
left = offset.dx;
bottom = offset.dy;
break;
case Alignment.topRight:
right = offset.dx;
top = offset.dy;
break;
case Alignment.bottomRight:
right = offset.dx;
bottom = offset.dy;
break;
}
return LTRB(left: left, top: top, right: right, bottom: bottom);
}
}
class LTRB {
LTRB({this.left, this.top, this.right, this.bottom});
final double? left;
final double? top;
final double? right;
final double? bottom;
Positioned buildPositioned({required Widget child}) => Positioned(
left: left,
top: top,
right: right,
bottom: bottom,
child: child,
);
}
@@ -46,12 +46,12 @@ extension on EditorState {
selection.start.offset,
0,
MentionBlockKeys.mentionChar,
attributes: {
MentionBlockKeys.mention: {
MentionBlockKeys.type: MentionType.date.name,
MentionBlockKeys.date: DateTime.now().toIso8601String(),
},
},
attributes: MentionBlockKeys.buildMentionDateAttributes(
date: DateTime.now().toIso8601String(),
reminderId: null,
reminderOption: null,
includeTime: false,
),
);
await apply(transaction);
@@ -2,6 +2,7 @@ import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_page.dart';
import 'package:appflowy/plugins/document/presentation/editor_style.dart';
import 'package:appflowy/util/theme_extension.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
// ignore: implementation_imports
import 'package:appflowy_editor/src/editor/toolbar/desktop/items/utils/tooltip_util.dart';
@@ -68,7 +69,7 @@ class _FormatToolbarItem extends ToolbarItem {
final hoverColor = isHighlight
? highlightColor
: EditorStyleCustomizer.toolbarHoverColor(context);
final isDark = Theme.of(context).brightness == Brightness.dark;
final isDark = !Theme.of(context).isLightMode;
final child = FlowyIconButton(
width: 36,
@@ -3,6 +3,7 @@ import 'package:appflowy/plugins/document/presentation/editor_page.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/color_picker.dart';
import 'package:appflowy/plugins/document/presentation/editor_style.dart';
import 'package:appflowy_editor/appflowy_editor.dart' hide ColorPicker;
import 'package:flowy_infra/theme_extension_v2.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
@@ -57,6 +58,9 @@ class _HighlightColorPickerWidgetState
@override
Widget build(BuildContext context) {
if (editorState.selection == null) {
return const SizedBox.shrink();
}
final selectionRectList = editorState.selectionRects();
final top =
selectionRectList.isEmpty ? 0.0 : selectionRectList.first.height;
@@ -78,7 +82,7 @@ class _HighlightColorPickerWidgetState
}
Widget buildChild(BuildContext context) {
final iconColor = Theme.of(context).iconTheme.color;
final iconColor = AFThemeExtensionV2.of(context).icon_primary;
final child = FlowyIconButton(
width: 36,
@@ -1,15 +1,17 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/plugins/document/application/document_bloc.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/base/toolbar_extension.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/desktop_floating_toolbar.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_create_menu.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_hover_menu.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/toolbar_cubit.dart';
import 'package:appflowy/plugins/document/presentation/editor_style.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/util/theme_extension.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flowy_infra/theme_extension_v2.dart';
import 'package:flowy_infra_ui/style_widget/icon_button.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'toolbar_id_enum.dart';
const kIsPageLink = 'is_page_link';
@@ -28,10 +30,10 @@ final customLinkItem = ToolbarItem(
);
});
final isDark = !Theme.of(context).isLightMode;
final hoverColor = isHref
? highlightColor
: EditorStyleCustomizer.toolbarHoverColor(context);
final toolbarCubit = context.read<ToolbarCubit?>();
final child = FlowyIconButton(
width: 36,
@@ -41,16 +43,20 @@ final customLinkItem = ToolbarItem(
icon: FlowySvg(
FlowySvgs.toolbar_link_m,
size: Size.square(20.0),
color: Theme.of(context).iconTheme.color,
color: (isDark && isHref)
? Color(0xFF282E3A)
: AFThemeExtensionV2.of(context).icon_primary,
),
onPressed: () {
toolbarCubit?.dismiss();
if (isHref) {
getIt<LinkHoverTriggers>().call(
HoverTriggerKey(nodes.first.id, selection),
);
getIt<FloatingToolbarController>().hideToolbar();
if (!isHref) {
final viewId = context.read<DocumentBloc?>()?.documentId ?? '';
showLinkCreateMenu(context, editorState, selection, viewId);
} else {
showLinkCreateMenu(context, editorState, selection);
WidgetsBinding.instance.addPostFrameCallback((_) {
getIt<LinkHoverTriggers>()
.call(HoverTriggerKey(nodes.first.id, selection));
});
}
},
);
@@ -5,6 +5,7 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_to
import 'package:appflowy/plugins/document/presentation/editor_style.dart';
import 'package:appflowy_editor/appflowy_editor.dart' hide ColorPicker;
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/theme_extension_v2.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'toolbar_id_enum.dart';
@@ -77,7 +78,7 @@ class _TextColorPickerWidgetState extends State<TextColorPickerWidget> {
}
Widget buildChild(BuildContext context) {
final iconColor = Theme.of(context).iconTheme.color;
final iconColor = AFThemeExtensionV2.of(context).icon_primary;
final child = FlowyIconButton(
width: 36,
height: 32,
Loaded 100 of 456 files, more files were not shown because too many files have changed in this diff. Show more