Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fbf928e6e4 | ||
|
|
8f63667282 | ||
|
|
954e844a21 | ||
|
|
13acc3af86 | ||
|
|
079112c9d2 | ||
|
|
0be8dcc000 | ||
|
|
d7d040b0f9 | ||
|
|
f652229718 | ||
|
|
f727dde74b | ||
|
|
92179fe61c | ||
|
|
ecfcf3be4d | ||
|
|
be132d867a | ||
|
|
e6951012f0 | ||
|
|
3214ec075b | ||
|
|
be1d2b4b92 | ||
|
|
a5eb2cdd9a | ||
|
|
9b077969e7 | ||
|
|
7160790596 | ||
|
|
5c3e81e6dc | ||
|
|
846172a709 | ||
|
|
c6511cfb55 | ||
|
|
69b5452af5 | ||
|
|
9291236733 | ||
|
|
3b3ae7fde9 | ||
|
|
d9748d5ef1 | ||
|
|
d01909830d | ||
|
|
03ecc3d6c9 | ||
|
|
1630e11c4d | ||
|
|
99fb6ab743 | ||
|
|
35bc095760 | ||
|
|
a44ad63230 | ||
|
|
ddbaf0d530 | ||
|
|
437deaf986 | ||
|
|
54df6b2863 | ||
|
|
69b98cb323 | ||
|
|
4d172761ce | ||
|
|
8c4324ee9d | ||
|
|
2e295e6891 | ||
|
|
351c891a5a | ||
|
|
bcb1e8e4f5 | ||
|
|
4997ac99cf | ||
|
|
e3bbabd63e | ||
|
|
403c558f9b | ||
|
|
31d2c1d603 | ||
|
|
a7adeeadb1 | ||
|
|
f7d7141a59 | ||
|
|
2371d4691d | ||
|
|
9ec0437673 | ||
|
|
89525b7f7b | ||
|
|
ad227bcf79 | ||
|
|
8b82d532e0 | ||
|
|
f4af47728f | ||
|
|
b3a4a9ba04 | ||
|
|
13028c6cfe | ||
|
|
7d0f6c9deb | ||
|
|
8d2901cc58 | ||
|
|
bbc7b6d172 | ||
|
|
145d1e5fdb | ||
|
|
eae4e42dcc | ||
|
|
6886261692 | ||
|
|
23f2d85e70 | ||
|
|
d1d598940d | ||
|
|
efb98d28ef | ||
|
|
462c822255 | ||
|
|
9e30b1816f | ||
|
|
d79929d1c9 | ||
|
|
0286678286 | ||
|
|
4896d7c1be | ||
|
|
b06a2337a0 | ||
|
|
20bcdd1f90 | ||
|
|
24d57336a9 | ||
|
|
91397c963a | ||
|
|
995b773c74 | ||
|
|
c561abd9f8 | ||
|
|
10dd0fa438 |
No files matched your search
@@ -338,7 +338,7 @@ jobs:
|
||||
- {
|
||||
arch: x86_64,
|
||||
target: x86_64-unknown-linux-gnu,
|
||||
os: ubuntu-20.04,
|
||||
os: ubuntu-22.04,
|
||||
extra-build-args: "",
|
||||
flutter_profile: production-linux-x86_64,
|
||||
}
|
||||
|
||||
@@ -1,4 +1,24 @@
|
||||
# Release Notes
|
||||
## Version 0.8.9 - 16/04/2025
|
||||
### Desktop
|
||||
#### New Features
|
||||
- Supported pasting a link as a mention, providing a more condensed visualization of linked content
|
||||
- Supported converting between link formats (e.g. transforming a mention into a bookmark)
|
||||
- Improved the link editing experience with enhanced UX
|
||||
- Added OTP (One-Time Password) support for sign-in authentication
|
||||
- Added latest AI models: GPT-4.1, GPT-4.1-mini, and Claude 3.7 Sonnet
|
||||
#### Bug Fixes
|
||||
- Fixed an issue where properties were not displaying in the row detail page
|
||||
- Fixed a bug where Undo didn't work in the row detail page
|
||||
- Fixed an issue where blocks didn't grow when the grid got bigger
|
||||
- Fixed several bugs related to AI writers
|
||||
### Mobile
|
||||
#### New Features
|
||||
- Added sign-in with OTP (One-Time Password)
|
||||
#### Bug Fixes
|
||||
- Fixed an issue where the slash menu sometimes failed to display
|
||||
- Updated the mention page block to handle page selection with more context.
|
||||
|
||||
## Version 0.8.8 - 01/04/2025
|
||||
### New Features
|
||||
- Added support for selecting AI models in AI writer
|
||||
|
||||
@@ -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:
|
||||
|
||||
Whitespace-only changes.
Binary file not shown.
File diff suppressed because it is too large.
Load diff
-1
@@ -15,7 +15,6 @@ void main() {
|
||||
cloudType: AuthenticatorType.appflowyCloudSelfHost,
|
||||
);
|
||||
|
||||
await tester.tapContinousAnotherWay();
|
||||
await tester.tapAnonymousSignInButton();
|
||||
await tester.expectToSeeHomePageWithGetStartedPage();
|
||||
|
||||
|
||||
+9
-9
@@ -6,7 +6,7 @@ import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/shared/icon_emoji_picker/icon_picker.dart';
|
||||
import 'package:appflowy/workspace/presentation/command_palette/command_palette.dart';
|
||||
import 'package:appflowy/workspace/presentation/command_palette/widgets/search_field.dart';
|
||||
import 'package:appflowy/workspace/presentation/command_palette/widgets/search_result_tile.dart';
|
||||
import 'package:appflowy/workspace/presentation/command_palette/widgets/search_result_cell.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/view.pbenum.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
@@ -44,12 +44,12 @@ void main() {
|
||||
await tester.pumpAndSettle(const Duration(milliseconds: 200));
|
||||
|
||||
// Expect two search results "ViewOna" and "ViewOne" (Distance 1 to ViewOna)
|
||||
expect(find.byType(SearchResultTile), findsNWidgets(2));
|
||||
expect(find.byType(SearchResultCell), findsNWidgets(2));
|
||||
|
||||
// The score should be higher for "ViewOna" thus it should be shown first
|
||||
final secondDocumentWidget = tester
|
||||
.widget(find.byType(SearchResultTile).first) as SearchResultTile;
|
||||
expect(secondDocumentWidget.result.data, secondDocument);
|
||||
.widget(find.byType(SearchResultCell).first) as SearchResultCell;
|
||||
expect(secondDocumentWidget.item.displayName, secondDocument);
|
||||
|
||||
// Change search to "ViewOne"
|
||||
await tester.enterText(searchFieldFinder, firstDocument);
|
||||
@@ -57,9 +57,9 @@ void main() {
|
||||
|
||||
// The score should be higher for "ViewOne" thus it should be shown first
|
||||
final firstDocumentWidget = tester.widget(
|
||||
find.byType(SearchResultTile).first,
|
||||
) as SearchResultTile;
|
||||
expect(firstDocumentWidget.result.data, firstDocument);
|
||||
find.byType(SearchResultCell).first,
|
||||
) as SearchResultCell;
|
||||
expect(firstDocumentWidget.item.displayName, firstDocument);
|
||||
});
|
||||
|
||||
testWidgets('Displaying icons in search results', (tester) async {
|
||||
@@ -89,11 +89,11 @@ void main() {
|
||||
);
|
||||
await tester.enterText(searchFieldFinder, 'Page-$randomValue');
|
||||
await tester.pumpAndSettle(const Duration(milliseconds: 200));
|
||||
expect(find.byType(SearchResultTile), findsNWidgets(2));
|
||||
expect(find.byType(SearchResultCell), findsNWidgets(2));
|
||||
|
||||
/// check results
|
||||
final svgs = find.descendant(
|
||||
of: find.byType(SearchResultTile),
|
||||
of: find.byType(SearchResultCell),
|
||||
matching: find.byType(FlowySvg),
|
||||
);
|
||||
expect(svgs, findsNWidgets(2));
|
||||
|
||||
+4
-3
@@ -1,5 +1,5 @@
|
||||
import 'package:appflowy/workspace/presentation/command_palette/command_palette.dart';
|
||||
import 'package:appflowy/workspace/presentation/command_palette/widgets/recent_view_tile.dart';
|
||||
import 'package:appflowy/workspace/presentation/command_palette/widgets/search_recent_view_cell.dart';
|
||||
import 'package:appflowy/workspace/presentation/command_palette/widgets/recent_views_list.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
@@ -27,11 +27,12 @@ void main() {
|
||||
expect(find.byType(RecentViewsList), findsOneWidget);
|
||||
|
||||
// Expect three recent history items
|
||||
expect(find.byType(RecentViewTile), findsNWidgets(3));
|
||||
expect(find.byType(SearchRecentViewCell), findsNWidgets(3));
|
||||
|
||||
// Expect the first item to be the last viewed document
|
||||
final firstDocumentWidget =
|
||||
tester.widget(find.byType(RecentViewTile).first) as RecentViewTile;
|
||||
tester.widget(find.byType(SearchRecentViewCell).first)
|
||||
as SearchRecentViewCell;
|
||||
expect(firstDocumentWidget.view.name, secondDocument);
|
||||
});
|
||||
});
|
||||
|
||||
+41
@@ -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,5 +1,10 @@
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/database/grid/presentation/grid_page.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-database2/setting_entities.pbenum.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter_animate/flutter_animate.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
|
||||
@@ -73,5 +78,37 @@ void main() {
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
});
|
||||
|
||||
testWidgets('insert grid in column', (tester) async {
|
||||
await tester.initializeAppFlowy();
|
||||
await tester.tapAnonymousSignInButton();
|
||||
|
||||
/// create page and show slash menu
|
||||
await tester.createNewPageWithNameUnderParent(name: 'test page');
|
||||
await tester.editor.tapLineOfEditorAt(0);
|
||||
await tester.editor.showSlashMenu();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
/// create a column
|
||||
await tester.editor.tapSlashMenuItemWithName(
|
||||
LocaleKeys.document_slashMenu_name_twoColumns.tr(),
|
||||
);
|
||||
final actionList = find.byType(BlockActionList);
|
||||
expect(actionList, findsNWidgets(2));
|
||||
final position = tester.getCenter(actionList.last);
|
||||
|
||||
/// tap the second child of column
|
||||
await tester.tapAt(position.copyWith(dx: position.dx + 50));
|
||||
|
||||
/// create a grid
|
||||
await tester.editor.showSlashMenu();
|
||||
await tester.pumpAndSettle();
|
||||
await tester.editor.tapSlashMenuItemWithName(
|
||||
LocaleKeys.document_slashMenu_name_grid.tr(),
|
||||
);
|
||||
|
||||
final grid = find.byType(GridPageContent);
|
||||
expect(grid, findsOneWidget);
|
||||
});
|
||||
});
|
||||
}
|
||||
+29
-25
@@ -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,20 @@ 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 +372,19 @@ 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:
|
||||
@@ -469,16 +483,6 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets('paste image url without extension', (tester) async {
|
||||
const plainText =
|
||||
'https://images.unsplash.com/photo-1469474968028-56623f02e42e?ixlib=rb-4.0.3&q=85&fm=jpg&crop=entropy&cs=srgb&dl=david-marcu-78A265wPiO4-unsplash.jpg&w=640';
|
||||
await tester.pasteContent(plainText: plainText, (editorState) {
|
||||
final node = editorState.getNodeAtPath([0])!;
|
||||
expect(node.type, ImageBlockKeys.type);
|
||||
expect(node.attributes[ImageBlockKeys.url], isNotEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
const testMarkdownText = '''
|
||||
# I'm h1
|
||||
## I'm h2
|
||||
@@ -521,7 +525,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 +562,6 @@ extension on WidgetTester {
|
||||
);
|
||||
await pumpAndSettle(const Duration(milliseconds: 1000));
|
||||
|
||||
test(editor.getCurrentEditorState());
|
||||
await test(editor.getCurrentEditorState());
|
||||
}
|
||||
}
|
||||
+2
@@ -13,6 +13,8 @@ void main() {
|
||||
await tester.initializeAppFlowy();
|
||||
|
||||
await tester.tapAnonymousSignInButton();
|
||||
final finder = find.text(gettingStarted, findRichText: true);
|
||||
await tester.pumpUntilFound(finder, timeout: const Duration(seconds: 2));
|
||||
|
||||
// create a new document
|
||||
const pageName = 'Test Document';
|
||||
|
||||
+453
@@ -0,0 +1,453 @@
|
||||
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_embed/link_embed_menu.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,
|
||||
LinkEmbedConvertCommand 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, LinkEmbedConvertCommand.toMention);
|
||||
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, LinkEmbedConvertCommand.toURL);
|
||||
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, LinkEmbedConvertCommand.toBookmark);
|
||||
final node = tester.editor.getNodeAtPath([0]);
|
||||
checkBookmark(node, link);
|
||||
});
|
||||
});
|
||||
}
|
||||
+37
@@ -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));
|
||||
});
|
||||
});
|
||||
}
|
||||
+2
@@ -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();
|
||||
}
|
||||
+4
-1
@@ -43,7 +43,10 @@ void main() {
|
||||
});
|
||||
|
||||
group('insert emoji by colon', () {
|
||||
Future<void> createNewDocumentAndShowEmojiList(WidgetTester tester, {String? search}) async {
|
||||
Future<void> createNewDocumentAndShowEmojiList(
|
||||
WidgetTester tester, {
|
||||
String? search,
|
||||
}) async {
|
||||
await tester.initializeAppFlowy();
|
||||
await tester.tapAnonymousSignInButton();
|
||||
await tester.createNewPageWithNameUnderParent();
|
||||
|
||||
@@ -67,12 +67,10 @@ extension CommonOperations on WidgetTester {
|
||||
} else {
|
||||
// cloud version
|
||||
final anonymousButton = find.byType(SignInAnonymousButtonV2);
|
||||
await tapButton(anonymousButton);
|
||||
await tapButton(anonymousButton, warnIfMissed: true);
|
||||
}
|
||||
|
||||
if (Platform.isWindows) {
|
||||
await pumpAndSettle(const Duration(milliseconds: 200));
|
||||
}
|
||||
await pumpAndSettle(const Duration(milliseconds: 200));
|
||||
}
|
||||
|
||||
Future<void> tapContinousAnotherWay() async {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -181,37 +181,37 @@ EXTERNAL SOURCES:
|
||||
:path: ".symlinks/plugins/webview_flutter_wkwebview/darwin"
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
app_links: e7a6750a915a9e161c58d91bc610e8cd1d4d0ad0
|
||||
appflowy_backend: 144c20d8bfb298c4e10fa3fa6701a9f41bf98b88
|
||||
connectivity_plus: bf0076dd84a130856aa636df1c71ccaff908fa1d
|
||||
device_info_plus: 97af1d7e84681a90d0693e63169a5d50e0839a0d
|
||||
app_links: 3da4c36b46cac3bf24eb897f1a6ce80bda109874
|
||||
appflowy_backend: 78f6a053f756e6bc29bcc5a2106cbe77b756e97a
|
||||
connectivity_plus: 481668c94744c30c53b8895afb39159d1e619bdf
|
||||
device_info_plus: 71ffc6ab7634ade6267c7a93088ed7e4f74e5896
|
||||
DKImagePickerController: b512c28220a2b8ac7419f21c491fc8534b7601ac
|
||||
DKPhotoGallery: fdfad5125a9fdda9cc57df834d49df790dbb4179
|
||||
file_picker: 09aa5ec1ab24135ccd7a1621c46c84134bfd6655
|
||||
flowy_infra_ui: 0455e1fa8c51885aa1437848e361e99419f34ebc
|
||||
file_picker: 9b3292d7c8bc68c8a7bf8eb78f730e49c8efc517
|
||||
flowy_infra_ui: 931b73a18b54a392ab6152eebe29a63a30751f53
|
||||
Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
|
||||
fluttertoast: e9a18c7be5413da53898f660530c56f35edfba9c
|
||||
image_picker_ios: c560581cceedb403a6ff17f2f816d7fea1421fc1
|
||||
integration_test: 252f60fa39af5e17c3aa9899d35d908a0721b573
|
||||
irondash_engine_context: 3458bf979b90d616ffb8ae03a150bafe2e860cc9
|
||||
keyboard_height_plugin: 43fa8bba20fd5c4fdeed5076466b8b9d43cc6b86
|
||||
open_filex: 6e26e659846ec990262224a12ef1c528bb4edbe4
|
||||
package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4
|
||||
path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46
|
||||
permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2
|
||||
fluttertoast: 76fea30fcf04176325f6864c87306927bd7d2038
|
||||
image_picker_ios: 7fe1ff8e34c1790d6fff70a32484959f563a928a
|
||||
integration_test: 4a889634ef21a45d28d50d622cf412dc6d9f586e
|
||||
irondash_engine_context: 8e58ca8e0212ee9d1c7dc6a42121849986c88486
|
||||
keyboard_height_plugin: ef70a8181b29f27670e9e2450814ca6b6dc05b05
|
||||
open_filex: 432f3cd11432da3e39f47fcc0df2b1603854eff1
|
||||
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
|
||||
path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564
|
||||
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
|
||||
ReachabilitySwift: 985039c6f7b23a1da463388634119492ff86c825
|
||||
saver_gallery: 76172dc4bf6b40e66d694948ada9ff402304dd87
|
||||
saver_gallery: af2d0c762dafda254e0ad025ef0dabd6506cd490
|
||||
SDWebImage: b9a731e1d6307f44ca703b3976d18c24ca561e84
|
||||
Sentry: 1fe34e9c2cbba1e347623610d26db121dcb569f1
|
||||
sentry_flutter: a39c2a2d67d5e5b9cb0b94a4985c76dd5b3fc737
|
||||
share_plus: 8b6f8b3447e494cca5317c8c3073de39b3600d1f
|
||||
shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
|
||||
sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d
|
||||
super_native_extensions: 4916b3c627a9c7fffdc48a23a9eca0b1ac228fa7
|
||||
sentry_flutter: e24b397f9a61fa5bbefd8279c3b2242ca86faa90
|
||||
share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a
|
||||
shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7
|
||||
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
|
||||
super_native_extensions: b763c02dc3a8fd078389f410bf15149179020cb4
|
||||
SwiftyGif: 6c3eafd0ce693cad58bb63d2b2fb9bacb8552780
|
||||
Toast: 91b396c56ee72a5790816f40d3a94dd357abc196
|
||||
url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe
|
||||
webview_flutter_wkwebview: 0982481e3d9c78fd5c6f62a002fcd24fc791f1e4
|
||||
url_launcher_ios: 694010445543906933d732453a59da0a173ae33d
|
||||
webview_flutter_wkwebview: 44d4dee7d7056d5ad185d25b38404436d56c547c
|
||||
|
||||
PODFILE CHECKSUM: d0d9b4ff572d8695c38eb3f9b490f55cdfc57eca
|
||||
|
||||
|
||||
@@ -45,16 +45,18 @@ Future<bool> afLaunchUri(
|
||||
}
|
||||
|
||||
// try to launch the uri directly
|
||||
bool result;
|
||||
try {
|
||||
result = await launcher.launchUrl(
|
||||
uri,
|
||||
mode: mode,
|
||||
webOnlyWindowName: webOnlyWindowName,
|
||||
);
|
||||
} on PlatformException catch (e) {
|
||||
Log.error('Failed to open uri: $e');
|
||||
return false;
|
||||
bool result = await launcher.canLaunchUrl(uri);
|
||||
if (result) {
|
||||
try {
|
||||
result = await launcher.launchUrl(
|
||||
uri,
|
||||
mode: mode,
|
||||
webOnlyWindowName: webOnlyWindowName,
|
||||
);
|
||||
} on PlatformException catch (e) {
|
||||
Log.error('Failed to open uri: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// if the uri is not a valid url, try to launch it with http scheme
|
||||
@@ -133,7 +135,6 @@ Future<bool> _afLaunchLocalUri(
|
||||
};
|
||||
if (context != null && context.mounted) {
|
||||
showToastNotification(
|
||||
context,
|
||||
message: message,
|
||||
type: result.type == ResultType.done
|
||||
? ToastificationType.success
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
|
||||
+2
-13
@@ -66,7 +66,7 @@ class MobileViewPageMoreBottomSheet extends StatelessWidget {
|
||||
break;
|
||||
case MobileViewBottomSheetBodyAction.delete:
|
||||
context.read<ViewBloc>().add(const ViewEvent.delete());
|
||||
context.pop();
|
||||
Navigator.of(context).pop();
|
||||
break;
|
||||
case MobileViewBottomSheetBodyAction.addToFavorites:
|
||||
_addFavorite(context);
|
||||
@@ -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(),
|
||||
);
|
||||
|
||||
-3
@@ -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(),
|
||||
);
|
||||
},
|
||||
|
||||
+3
-1
@@ -182,7 +182,7 @@ class MobileViewBottomSheetBody extends StatelessWidget {
|
||||
),
|
||||
_divider(),
|
||||
..._buildPublishActions(context),
|
||||
_divider(),
|
||||
|
||||
MobileQuickActionButton(
|
||||
text: LocaleKeys.button_delete.tr(),
|
||||
textColor: Theme.of(context).colorScheme.error,
|
||||
@@ -236,6 +236,7 @@ class MobileViewBottomSheetBody extends StatelessWidget {
|
||||
MobileViewBottomSheetBodyAction.unpublish,
|
||||
),
|
||||
),
|
||||
_divider(),
|
||||
];
|
||||
} else {
|
||||
return [
|
||||
@@ -246,6 +247,7 @@ class MobileViewBottomSheetBody extends StatelessWidget {
|
||||
MobileViewBottomSheetBodyAction.publish,
|
||||
),
|
||||
),
|
||||
_divider(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
-2
@@ -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(),
|
||||
);
|
||||
|
||||
|
||||
+1
-1
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-7
@@ -71,12 +71,6 @@ class _MobileHomeSettingPageState extends State<MobileHomeSettingPage> {
|
||||
}
|
||||
|
||||
Widget _buildSettingsWidget(UserProfilePB userProfile) {
|
||||
// show the third-party sign in buttons if user logged in with local session and auth is enabled.
|
||||
|
||||
final isLocalAuthEnabled =
|
||||
userProfile.authenticator == AuthenticatorPB.Local && isAuthEnabled;
|
||||
'';
|
||||
|
||||
return BlocProvider(
|
||||
create: (context) => UserWorkspaceBloc(userProfile: userProfile)
|
||||
..add(const UserWorkspaceEvent.initial()),
|
||||
@@ -105,7 +99,7 @@ class _MobileHomeSettingPageState extends State<MobileHomeSettingPage> {
|
||||
const AboutSettingGroup(),
|
||||
UserSessionSettingGroup(
|
||||
userProfile: userProfile,
|
||||
showThirdPartyLogin: isLocalAuthEnabled,
|
||||
showThirdPartyLogin: false,
|
||||
),
|
||||
const VSpace(20),
|
||||
],
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
|
||||
|
||||
+2
-2
@@ -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(),
|
||||
);
|
||||
|
||||
-3
@@ -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)',
|
||||
);
|
||||
|
||||
|
||||
-4
@@ -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(),
|
||||
);
|
||||
}
|
||||
|
||||
+48
-31
@@ -1,5 +1,4 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:appflowy/mobile/presentation/selection_menu/mobile_selection_menu_item.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
@@ -71,6 +70,7 @@ class MobileSelectionMenu extends SelectionMenuService {
|
||||
final editorWidth = editorState.renderBox!.size.width;
|
||||
|
||||
_positionNotifier = ValueNotifier(position);
|
||||
final showAtTop = position.top != null;
|
||||
_selectionMenuEntry = OverlayEntry(
|
||||
builder: (context) {
|
||||
return SizedBox(
|
||||
@@ -94,6 +94,7 @@ class MobileSelectionMenu extends SelectionMenuService {
|
||||
child: MobileSelectionMenuWidget(
|
||||
selectionMenuStyle: style,
|
||||
singleColumn: singleColumn,
|
||||
showAtTop: showAtTop,
|
||||
items: selectionMenuItems
|
||||
..forEach((element) {
|
||||
if (element is MobileSelectionMenuItem) {
|
||||
@@ -166,7 +167,8 @@ class MobileSelectionMenu extends SelectionMenuService {
|
||||
if (selectionRects.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
calculateSelectionMenuOffset(selectionRects.first);
|
||||
final screenSize = MediaQuery.of(context).size;
|
||||
calculateSelectionMenuOffset(selectionRects.first, screenSize);
|
||||
final (left, top, right, bottom) = getPosition();
|
||||
return _Position(left, top, right, bottom);
|
||||
}
|
||||
@@ -205,50 +207,65 @@ class MobileSelectionMenu extends SelectionMenuService {
|
||||
return (left, top, right, bottom);
|
||||
}
|
||||
|
||||
void calculateSelectionMenuOffset(Rect rect) {
|
||||
void calculateSelectionMenuOffset(Rect rect, Size screenSize) {
|
||||
// Workaround: We can customize the padding through the [EditorStyle],
|
||||
// but the coordinates of overlay are not properly converted currently.
|
||||
// Just subtract the padding here as a result.
|
||||
const menuHeight = 192.0, menuWidth = 240.0 + 10;
|
||||
const menuOffset = Offset(0, 10);
|
||||
const menuHeight = 192.0, menuWidth = 240.0;
|
||||
final editorOffset =
|
||||
editorState.renderBox?.localToGlobal(Offset.zero) ?? Offset.zero;
|
||||
final editorHeight = editorState.renderBox!.size.height;
|
||||
final screenHeight = screenSize.height;
|
||||
final editorWidth = editorState.renderBox!.size.width;
|
||||
final rectHeight = rect.height;
|
||||
|
||||
// show below default
|
||||
_alignment = Alignment.topLeft;
|
||||
final bottomRight = rect.bottomRight;
|
||||
final topRight = rect.topRight;
|
||||
var offset = bottomRight + menuOffset;
|
||||
final limitX = editorWidth - menuWidth + editorOffset.dx;
|
||||
_alignment = Alignment.bottomRight;
|
||||
final bottomRight = rect.topLeft;
|
||||
final offset = bottomRight;
|
||||
final limitX = editorWidth + editorOffset.dx - menuWidth,
|
||||
limitY = screenHeight -
|
||||
editorHeight +
|
||||
editorOffset.dy -
|
||||
menuHeight -
|
||||
rectHeight;
|
||||
_offset = Offset(
|
||||
min(offset.dx, limitX),
|
||||
offset.dy,
|
||||
editorWidth - offset.dx - menuWidth,
|
||||
screenHeight - offset.dy - menuHeight - rectHeight,
|
||||
);
|
||||
|
||||
// show above
|
||||
if (offset.dy + menuHeight >= editorOffset.dy + editorHeight) {
|
||||
offset = topRight - menuOffset;
|
||||
_alignment = Alignment.bottomLeft;
|
||||
|
||||
_offset = Offset(
|
||||
offset.dx,
|
||||
editorOffset.dy + editorHeight - offset.dy,
|
||||
);
|
||||
/// show above
|
||||
if (offset.dy > menuHeight) {
|
||||
_offset = Offset(
|
||||
_offset.dx,
|
||||
offset.dy - menuHeight,
|
||||
);
|
||||
_alignment = Alignment.topRight;
|
||||
} else {
|
||||
_offset = Offset(
|
||||
_offset.dx,
|
||||
limitY,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// show on left
|
||||
if (_offset.dx - editorOffset.dx > editorWidth / 2) {
|
||||
_alignment = _alignment == Alignment.topLeft
|
||||
? Alignment.topRight
|
||||
: Alignment.bottomRight;
|
||||
|
||||
final x = editorWidth - _offset.dx + editorOffset.dx;
|
||||
_offset = Offset(
|
||||
min(x, limitX),
|
||||
_offset.dy,
|
||||
);
|
||||
if (offset.dx + menuWidth >= editorOffset.dx + editorWidth) {
|
||||
/// show left
|
||||
if (offset.dx > menuWidth) {
|
||||
_alignment = _alignment == Alignment.bottomRight
|
||||
? Alignment.bottomLeft
|
||||
: Alignment.topLeft;
|
||||
_offset = Offset(
|
||||
offset.dx - menuWidth,
|
||||
_offset.dy,
|
||||
);
|
||||
} else {
|
||||
_offset = Offset(
|
||||
limitX,
|
||||
_offset.dy,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+32
-20
@@ -22,6 +22,7 @@ class MobileSelectionMenuWidget extends StatefulWidget {
|
||||
required this.deleteSlashByDefault,
|
||||
required this.singleColumn,
|
||||
required this.startOffset,
|
||||
required this.showAtTop,
|
||||
this.nameBuilder,
|
||||
});
|
||||
|
||||
@@ -38,6 +39,7 @@ class MobileSelectionMenuWidget extends StatefulWidget {
|
||||
|
||||
final bool deleteSlashByDefault;
|
||||
final bool singleColumn;
|
||||
final bool showAtTop;
|
||||
final int startOffset;
|
||||
|
||||
final SelectionMenuItemNameBuilder? nameBuilder;
|
||||
@@ -172,27 +174,37 @@ class _MobileSelectionMenuWidgetState extends State<MobileSelectionMenuWidget> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Focus(
|
||||
focusNode: _focusNode,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: widget.selectionMenuStyle.selectionMenuBackgroundColor,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
blurRadius: 5,
|
||||
spreadRadius: 1,
|
||||
color: Colors.black.withValues(alpha: 0.1),
|
||||
),
|
||||
],
|
||||
borderRadius: BorderRadius.circular(6.0),
|
||||
),
|
||||
child: _showingItems.isEmpty
|
||||
? _buildNoResultsWidget(context)
|
||||
: _buildResultsWidget(
|
||||
context,
|
||||
_showingItems,
|
||||
widget.itemCountFilter,
|
||||
return SizedBox(
|
||||
height: 192,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (widget.showAtTop) Spacer(),
|
||||
Focus(
|
||||
focusNode: _focusNode,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: widget.selectionMenuStyle.selectionMenuBackgroundColor,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
blurRadius: 5,
|
||||
spreadRadius: 1,
|
||||
color: Colors.black.withValues(alpha: 0.1),
|
||||
),
|
||||
],
|
||||
borderRadius: BorderRadius.circular(6.0),
|
||||
),
|
||||
child: _showingItems.isEmpty
|
||||
? _buildNoResultsWidget(context)
|
||||
: _buildResultsWidget(
|
||||
context,
|
||||
_showingItems,
|
||||
widget.itemCountFilter,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (!widget.showAtTop) Spacer(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
+2
-2
@@ -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(
|
||||
|
||||
+2
-4
@@ -1,5 +1,3 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:appflowy/env/cloud_env.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart';
|
||||
@@ -7,10 +5,10 @@ import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy/workspace/application/user/prelude.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../widgets/widgets.dart';
|
||||
|
||||
import 'personal_info.dart';
|
||||
|
||||
class PersonalInfoSettingGroup extends StatelessWidget {
|
||||
@@ -32,7 +30,7 @@ class PersonalInfoSettingGroup extends StatelessWidget {
|
||||
selector: (state) => state.userProfile.name,
|
||||
builder: (context, userName) {
|
||||
return MobileSettingGroup(
|
||||
groupTitle: LocaleKeys.settings_mobile_personalInfo.tr(),
|
||||
groupTitle: LocaleKeys.settings_accountPage_title.tr(),
|
||||
settingItemList: [
|
||||
MobileSettingItem(
|
||||
name: userName,
|
||||
|
||||
@@ -81,7 +81,6 @@ class SupportSettingGroup extends StatelessWidget {
|
||||
);
|
||||
if (context.mounted) {
|
||||
showToastNotification(
|
||||
context,
|
||||
message: LocaleKeys.settings_files_clearCacheSuccess.tr(),
|
||||
);
|
||||
}
|
||||
|
||||
+9
-2
@@ -63,8 +63,15 @@ class UserSessionSettingGroup extends StatelessWidget {
|
||||
);
|
||||
},
|
||||
builder: (context, state) {
|
||||
return const ThirdPartySignInButtons(
|
||||
expanded: true,
|
||||
return Column(
|
||||
children: [
|
||||
const ContinueWithEmailAndPassword(),
|
||||
const VSpace(12.0),
|
||||
const ThirdPartySignInButtons(
|
||||
expanded: true,
|
||||
),
|
||||
const VSpace(16.0),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
+2
-8
@@ -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>()
|
||||
|
||||
@@ -27,7 +27,7 @@ class ChatAIAvatar extends StatelessWidget {
|
||||
child: const CircleAvatar(
|
||||
backgroundColor: Colors.transparent,
|
||||
child: FlowySvg(
|
||||
FlowySvgs.flowy_logo_s,
|
||||
FlowySvgs.app_logo_s,
|
||||
size: Size.square(16),
|
||||
blendMode: null,
|
||||
),
|
||||
|
||||
@@ -46,7 +46,7 @@ class ChatWelcomePage extends StatelessWidget {
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const FlowySvg(
|
||||
FlowySvgs.flowy_logo_xl,
|
||||
FlowySvgs.app_logo_xl,
|
||||
size: Size.square(32),
|
||||
blendMode: null,
|
||||
),
|
||||
|
||||
+1
-2
@@ -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(),
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
+1
-2
@@ -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(
|
||||
|
||||
+5
@@ -241,6 +241,11 @@ class SelectOptionCellEditorBloc
|
||||
} else if (!state.selectedOptions
|
||||
.any((option) => option.id == focusedOptionId)) {
|
||||
_selectOptionService.select(optionIds: [focusedOptionId]);
|
||||
emit(
|
||||
state.copyWith(
|
||||
clearFilter: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+11
-6
@@ -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(
|
||||
|
||||
+14
-17
@@ -73,27 +73,24 @@ class RelatedRowDetailPageBloc
|
||||
});
|
||||
}
|
||||
|
||||
/// initialize bloc through the `database_id` and `row_id`. The process is as
|
||||
/// follows:
|
||||
/// 1. use the `database_id` to get the database meta, which contains the
|
||||
/// `inline_view_id`
|
||||
/// 2. use the `inline_view_id` to instantiate a `DatabaseController`.
|
||||
/// 3. use the `row_id` with the DatabaseController` to create `RowController`
|
||||
void _init(String databaseId, String initialRowId) async {
|
||||
final databaseMeta =
|
||||
await DatabaseEventGetDatabaseMeta(DatabaseIdPB(value: databaseId))
|
||||
.send()
|
||||
.fold<DatabaseMetaPB?>((s) => s, (f) => null);
|
||||
if (databaseMeta == null) {
|
||||
final viewId = await DatabaseEventGetDefaultDatabaseViewId(
|
||||
DatabaseIdPB(value: databaseId),
|
||||
).send().fold(
|
||||
(pb) => pb.value,
|
||||
(error) => null,
|
||||
);
|
||||
|
||||
if (viewId == null) {
|
||||
return;
|
||||
}
|
||||
final inlineView =
|
||||
await ViewBackendService.getView(databaseMeta.inlineViewId)
|
||||
.fold((viewPB) => viewPB, (f) => null);
|
||||
if (inlineView == null) {
|
||||
|
||||
final databaseView = await ViewBackendService.getView(viewId)
|
||||
.fold((viewPB) => viewPB, (f) => null);
|
||||
if (databaseView == null) {
|
||||
return;
|
||||
}
|
||||
final databaseController = DatabaseController(view: inlineView);
|
||||
final databaseController = DatabaseController(view: databaseView);
|
||||
await databaseController.open().fold(
|
||||
(s) => databaseController.setIsLoading(false),
|
||||
(f) => null,
|
||||
@@ -104,7 +101,7 @@ class RelatedRowDetailPageBloc
|
||||
}
|
||||
final rowController = RowController(
|
||||
rowMeta: rowInfo.rowMeta,
|
||||
viewId: inlineView.id,
|
||||
viewId: databaseView.id,
|
||||
rowCache: databaseController.rowCache,
|
||||
);
|
||||
|
||||
|
||||
@@ -386,15 +386,15 @@ class _BoardContentState extends State<_BoardContent> {
|
||||
scrollManager: scrollManager,
|
||||
),
|
||||
),
|
||||
cardBuilder: (context, column, columnItem) =>
|
||||
cardBuilder: (cardContext, column, columnItem) =>
|
||||
MultiBlocProvider(
|
||||
key: ValueKey("board_card_${column.id}_${columnItem.id}"),
|
||||
providers: [
|
||||
BlocProvider<BoardBloc>.value(
|
||||
value: context.read<BoardBloc>(),
|
||||
value: cardContext.read<BoardBloc>(),
|
||||
),
|
||||
BlocProvider.value(
|
||||
value: context.read<BoardActionsCubit>(),
|
||||
value: cardContext.read<BoardActionsCubit>(),
|
||||
),
|
||||
BlocProvider(
|
||||
create: (_) => ViewLockStatusBloc(view: widget.view)
|
||||
@@ -402,7 +402,7 @@ class _BoardContentState extends State<_BoardContent> {
|
||||
),
|
||||
],
|
||||
child: BlocBuilder<ViewLockStatusBloc, ViewLockStatusState>(
|
||||
builder: (context, state) {
|
||||
builder: (lockStatusContext, state) {
|
||||
return IgnorePointer(
|
||||
ignoring: state.isLocked,
|
||||
child: _BoardCard(
|
||||
@@ -412,6 +412,13 @@ class _BoardContentState extends State<_BoardContent> {
|
||||
notifier: widget.focusScope,
|
||||
cellBuilder: cellBuilder,
|
||||
compactMode: compactMode,
|
||||
onOpenCard: (rowMeta) => _openCard(
|
||||
context: context,
|
||||
databaseController: lockStatusContext
|
||||
.read<BoardBloc>()
|
||||
.databaseController,
|
||||
rowMeta: rowMeta,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -581,6 +588,7 @@ class _BoardCard extends StatefulWidget {
|
||||
required this.cellBuilder,
|
||||
required this.notifier,
|
||||
required this.compactMode,
|
||||
required this.onOpenCard,
|
||||
});
|
||||
|
||||
final AppFlowyGroupData afGroupData;
|
||||
@@ -589,6 +597,7 @@ class _BoardCard extends StatefulWidget {
|
||||
final CardCellBuilder cellBuilder;
|
||||
final BoardFocusScope notifier;
|
||||
final bool compactMode;
|
||||
final void Function(RowMetaPB) onOpenCard;
|
||||
|
||||
@override
|
||||
State<_BoardCard> createState() => _BoardCardState();
|
||||
@@ -698,10 +707,8 @@ class _BoardCardState extends State<_BoardCard> {
|
||||
groupingFieldId: widget.groupItem.fieldInfo.id,
|
||||
isEditing: _isEditing,
|
||||
cellBuilder: widget.cellBuilder,
|
||||
onTap: (context) => _openCard(
|
||||
context: context,
|
||||
databaseController: databaseController,
|
||||
rowMeta: context.read<CardBloc>().rowController.rowMeta,
|
||||
onTap: (context) => widget.onOpenCard(
|
||||
context.read<CardBloc>().rowController.rowMeta,
|
||||
),
|
||||
onShiftTap: (_) {
|
||||
Focus.of(context).requestFocus();
|
||||
|
||||
+6
-3
@@ -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) {
|
||||
|
||||
@@ -362,9 +362,13 @@ const kDatabasePluginWidgetBuilderActionBuilder = 'action_builder';
|
||||
const kDatabasePluginWidgetBuilderNode = 'node';
|
||||
|
||||
class DatabasePluginWidgetBuilderSize {
|
||||
const DatabasePluginWidgetBuilderSize({required this.horizontalPadding});
|
||||
const DatabasePluginWidgetBuilderSize({
|
||||
required this.horizontalPadding,
|
||||
this.verticalPadding = 16.0,
|
||||
});
|
||||
|
||||
final double horizontalPadding;
|
||||
final double verticalPadding;
|
||||
}
|
||||
|
||||
class DatabasePluginWidgetBuilder extends PluginWidgetBuilder {
|
||||
|
||||
+1
-1
@@ -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();
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:appflowy/plugins/document/application/document_bloc.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_drop_handler.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_drop_manager.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_page.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/ai/widgets/ai_writer_scroll_wrapper.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/shared_context/shared_context.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/transaction_handler/editor_transaction_service.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_style.dart';
|
||||
@@ -120,29 +121,34 @@ class _RowEditor extends StatelessWidget {
|
||||
return context;
|
||||
},
|
||||
dispose: (_, editorContext) => editorContext.dispose(),
|
||||
child: EditorDropHandler(
|
||||
child: AiWriterScrollWrapper(
|
||||
viewId: view.id,
|
||||
editorState: editorState,
|
||||
isLocalMode: context.read<DocumentBloc>().isLocalMode,
|
||||
dropManagerState: context.read<EditorDropManagerState>(),
|
||||
child: EditorTransactionService(
|
||||
child: EditorDropHandler(
|
||||
viewId: view.id,
|
||||
editorState: editorState,
|
||||
child: Provider(
|
||||
create: (context) =>
|
||||
DatabasePluginWidgetBuilderSize(horizontalPadding: 0),
|
||||
child: AppFlowyEditorPage(
|
||||
shrinkWrap: true,
|
||||
autoFocus: false,
|
||||
editorState: editorState,
|
||||
styleCustomizer: EditorStyleCustomizer(
|
||||
context: context,
|
||||
padding: const EdgeInsets.only(left: 16, right: 54),
|
||||
isLocalMode: context.read<DocumentBloc>().isLocalMode,
|
||||
dropManagerState: context.read<EditorDropManagerState>(),
|
||||
child: EditorTransactionService(
|
||||
viewId: view.id,
|
||||
editorState: editorState,
|
||||
child: Provider(
|
||||
create: (context) => DatabasePluginWidgetBuilderSize(
|
||||
horizontalPadding: 0,
|
||||
),
|
||||
child: AppFlowyEditorPage(
|
||||
shrinkWrap: true,
|
||||
autoFocus: false,
|
||||
editorState: editorState,
|
||||
styleCustomizer: EditorStyleCustomizer(
|
||||
context: context,
|
||||
padding: const EdgeInsets.only(left: 16, right: 54),
|
||||
),
|
||||
showParagraphPlaceholder: (editorState, _) =>
|
||||
editorState.document.isEmpty,
|
||||
placeholderText: (_) =>
|
||||
LocaleKeys.cardDetails_notesPlaceholder.tr(),
|
||||
),
|
||||
showParagraphPlaceholder: (editorState, _) =>
|
||||
editorState.document.isEmpty,
|
||||
placeholderText: (_) =>
|
||||
LocaleKeys.cardDetails_notesPlaceholder.tr(),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
+32
-36
@@ -1,3 +1,4 @@
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/plugins/database/application/row/related_row_detail_bloc.dart';
|
||||
import 'package:appflowy/plugins/database/grid/application/row/row_detail_bloc.dart';
|
||||
import 'package:appflowy/plugins/database/grid/presentation/widgets/common/type_option_separator.dart';
|
||||
@@ -7,8 +8,10 @@ import 'package:appflowy/plugins/database/widgets/row/row_property.dart';
|
||||
import 'package:appflowy/plugins/document/application/document_bloc.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/banner.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_drop_handler.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_notification.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_page.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/ai/widgets/ai_writer_scroll_wrapper.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/shared_context/shared_context.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/transaction_handler/editor_transaction_service.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_style.dart';
|
||||
import 'package:appflowy/shared/flowy_error_page.dart';
|
||||
@@ -18,8 +21,10 @@ import 'package:appflowy/workspace/application/action_navigation/navigation_acti
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../workspace/application/view/view_bloc.dart';
|
||||
|
||||
@@ -48,18 +53,6 @@ class DatabaseDocumentPage extends StatefulWidget {
|
||||
class _DatabaseDocumentPageState extends State<DatabaseDocumentPage> {
|
||||
EditorState? editorState;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
EditorNotification.addListener(_onEditorNotification);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
EditorNotification.removeListener(_onEditorNotification);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MultiBlocProvider(
|
||||
@@ -104,7 +97,11 @@ class _DatabaseDocumentPageState extends State<DatabaseDocumentPage> {
|
||||
return BlocListener<ActionNavigationBloc, ActionNavigationState>(
|
||||
listener: _onNotificationAction,
|
||||
listenWhen: (_, curr) => curr.action != null,
|
||||
child: _buildEditorPage(context, state),
|
||||
child: AiWriterScrollWrapper(
|
||||
viewId: widget.view.id,
|
||||
editorState: editorState,
|
||||
child: _buildEditorPage(context, state),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -121,21 +118,34 @@ class _DatabaseDocumentPageState extends State<DatabaseDocumentPage> {
|
||||
styleCustomizer: EditorStyleCustomizer(
|
||||
context: context,
|
||||
padding: EditorStyleCustomizer.documentPadding,
|
||||
editorState: state.editorState!,
|
||||
),
|
||||
header: _buildDatabaseDataContent(context, state.editorState!),
|
||||
initialSelection: widget.initialSelection,
|
||||
useViewInfoBloc: false,
|
||||
placeholderText: (node) =>
|
||||
node.type == ParagraphBlockKeys.type && !node.isInTable
|
||||
? LocaleKeys.editor_slashPlaceHolder.tr()
|
||||
: '',
|
||||
),
|
||||
);
|
||||
|
||||
return EditorTransactionService(
|
||||
viewId: widget.view.id,
|
||||
editorState: state.editorState!,
|
||||
child: Column(
|
||||
children: [
|
||||
if (state.isDeleted) _buildBanner(context),
|
||||
Expanded(child: appflowyEditorPage),
|
||||
],
|
||||
return Provider(
|
||||
create: (_) {
|
||||
final context = SharedEditorContext();
|
||||
context.isInDatabaseRowPage = true;
|
||||
return context;
|
||||
},
|
||||
dispose: (_, editorContext) => editorContext.dispose(),
|
||||
child: EditorTransactionService(
|
||||
viewId: widget.view.id,
|
||||
editorState: state.editorState!,
|
||||
child: Column(
|
||||
children: [
|
||||
if (state.isDeleted) _buildBanner(context),
|
||||
Expanded(child: appflowyEditorPage),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -208,20 +218,6 @@ class _DatabaseDocumentPageState extends State<DatabaseDocumentPage> {
|
||||
);
|
||||
}
|
||||
|
||||
void _onEditorNotification(EditorNotificationType type) {
|
||||
final editorState = this.editorState;
|
||||
if (editorState == null) {
|
||||
return;
|
||||
}
|
||||
if (type == EditorNotificationType.undo) {
|
||||
undoCommand.execute(editorState);
|
||||
} else if (type == EditorNotificationType.redo) {
|
||||
redoCommand.execute(editorState);
|
||||
} else if (type == EditorNotificationType.exitEditing) {
|
||||
editorState.selection = null;
|
||||
}
|
||||
}
|
||||
|
||||
void _onNotificationAction(
|
||||
BuildContext context,
|
||||
ActionNavigationState state,
|
||||
|
||||
@@ -442,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,
|
||||
);
|
||||
|
||||
+3
-17
@@ -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) {
|
||||
@@ -982,21 +983,6 @@ LinkPreviewBlockComponentBuilder _buildLinkPreviewBlockComponentBuilder(
|
||||
return const EdgeInsets.symmetric(vertical: 10);
|
||||
},
|
||||
),
|
||||
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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -20,9 +20,9 @@ import 'package:appflowy/workspace/application/view/view_lock_status_bloc.dart';
|
||||
import 'package:appflowy/workspace/application/view_info/view_info_bloc.dart';
|
||||
import 'package:appflowy/workspace/presentation/home/af_focus_manager.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart' hide QuoteBlockKeys;
|
||||
import 'package:appflowy_ui/appflowy_ui.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flowy_infra/theme_extension.dart';
|
||||
import 'package:flowy_infra/theme_extension_v2.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
@@ -352,7 +352,6 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage>
|
||||
final isLocked =
|
||||
context.read<ViewLockStatusBloc?>()?.state.isLocked ?? false;
|
||||
|
||||
final themeV2 = AFThemeExtensionV2.of(context);
|
||||
final editor = Directionality(
|
||||
textDirection: textDirection,
|
||||
child: AppFlowyEditor(
|
||||
@@ -431,6 +430,7 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage>
|
||||
),
|
||||
);
|
||||
}
|
||||
final appTheme = AppFlowyTheme.of(context);
|
||||
return Center(
|
||||
child: BlocProvider.value(
|
||||
value: context.read<DocumentBloc>(),
|
||||
@@ -443,15 +443,9 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage>
|
||||
),
|
||||
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,
|
||||
),
|
||||
],
|
||||
borderRadius: BorderRadius.circular(appTheme.borderRadius.l),
|
||||
color: appTheme.surfaceColorScheme.primary,
|
||||
boxShadow: [appTheme.shadow.small],
|
||||
),
|
||||
toolbarBuilder: (_, child, onDismiss, isMetricsChanged) =>
|
||||
BlocProvider.value(
|
||||
|
||||
+6
-7
@@ -5,8 +5,8 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.da
|
||||
import 'package:appflowy/plugins/document/presentation/editor_style.dart';
|
||||
import 'package:appflowy/workspace/presentation/widgets/dialogs.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/theme_extension_v2.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
@@ -119,7 +119,7 @@ class _AiWriterToolbarActionListState extends State<AiWriterToolbarActionList> {
|
||||
}
|
||||
|
||||
Widget buildChild(BuildContext context) {
|
||||
final themeV2 = AFThemeExtensionV2.of(context);
|
||||
final theme = AppFlowyTheme.of(context), iconScheme = theme.iconColorTheme;
|
||||
final child = FlowyIconButton(
|
||||
width: 48,
|
||||
height: 32,
|
||||
@@ -131,13 +131,13 @@ class _AiWriterToolbarActionListState extends State<AiWriterToolbarActionList> {
|
||||
FlowySvg(
|
||||
FlowySvgs.toolbar_ai_writer_m,
|
||||
size: Size.square(20),
|
||||
color: themeV2.icon_primary,
|
||||
color: iconScheme.primary,
|
||||
),
|
||||
HSpace(4),
|
||||
FlowySvg(
|
||||
FlowySvgs.toolbar_arrow_down_m,
|
||||
size: Size(12, 20),
|
||||
color: themeV2.icon_tertiary,
|
||||
color: iconScheme.primary,
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -150,7 +150,6 @@ class _AiWriterToolbarActionListState extends State<AiWriterToolbarActionList> {
|
||||
});
|
||||
} else {
|
||||
showToastNotification(
|
||||
context,
|
||||
message: LocaleKeys.document_plugins_appflowyAIEditDisabled.tr(),
|
||||
);
|
||||
}
|
||||
@@ -181,6 +180,7 @@ class ImproveWritingButton extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = AppFlowyTheme.of(context);
|
||||
final child = FlowyIconButton(
|
||||
width: 36,
|
||||
height: 32,
|
||||
@@ -188,7 +188,7 @@ class ImproveWritingButton extends StatelessWidget {
|
||||
icon: FlowySvg(
|
||||
FlowySvgs.toolbar_ai_improve_writing_m,
|
||||
size: Size.square(20.0),
|
||||
color: AFThemeExtensionV2.of(context).icon_primary,
|
||||
color: theme.iconColorTheme.primary,
|
||||
),
|
||||
onPressed: () {
|
||||
if (_isAIEnabled(editorState)) {
|
||||
@@ -196,7 +196,6 @@ class ImproveWritingButton extends StatelessWidget {
|
||||
_insertAiNode(editorState, AiWriterCommand.improveWriting);
|
||||
} else {
|
||||
showToastNotification(
|
||||
context,
|
||||
message: LocaleKeys.document_plugins_appflowyAIEditDisabled.tr(),
|
||||
);
|
||||
}
|
||||
|
||||
+60
-7
@@ -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 = true;
|
||||
|
||||
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 {
|
||||
|
||||
+29
-2
@@ -34,7 +34,15 @@ extension AiWriterNodeExtension on EditorState {
|
||||
|
||||
// if the selected nodes are not entirely selected, slice the nodes
|
||||
final slicedNodes = <Node>[];
|
||||
final nodes = getNodesInSelection(selection);
|
||||
final List<Node> flattenNodes = getNodesInSelection(selection);
|
||||
final List<Node> nodes = [];
|
||||
|
||||
for (final node in flattenNodes) {
|
||||
if (nodes.any((element) => element.isParentOf(node))) {
|
||||
continue;
|
||||
}
|
||||
nodes.add(node);
|
||||
}
|
||||
|
||||
for (final node in nodes) {
|
||||
final delta = node.delta;
|
||||
@@ -57,11 +65,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',
|
||||
);
|
||||
|
||||
return markdown;
|
||||
// trim the last \n if it exists
|
||||
return markdown.trimRight();
|
||||
}
|
||||
|
||||
List<String> getPlainTextInSelection(Selection? selection) {
|
||||
|
||||
+1
@@ -40,6 +40,7 @@ class _AiWriterScrollWrapperState extends State<AiWriterScrollWrapper> {
|
||||
onRemoveNode: () {
|
||||
aiWriterRegistered = false;
|
||||
widget.editorState.service.keyboardService?.enableShortcuts();
|
||||
widget.editorState.service.keyboardService?.enable();
|
||||
},
|
||||
onAppendToDocument: onAppendToDocument,
|
||||
);
|
||||
|
||||
+6
-1
@@ -1,3 +1,4 @@
|
||||
import 'package:appflowy/plugins/database/tab_bar/tab_bar_view.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
|
||||
import 'package:appflowy/workspace/application/view/view_service.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-error/errors.pbserver.dart';
|
||||
@@ -6,6 +7,7 @@ import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:appflowy_result/appflowy_result.dart';
|
||||
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
class BuiltInPageWidget extends StatefulWidget {
|
||||
const BuiltInPageWidget({
|
||||
@@ -77,6 +79,9 @@ class _BuiltInPageWidgetState extends State<BuiltInPageWidget> {
|
||||
}
|
||||
|
||||
Widget _buildPage(BuildContext context, ViewPB view) {
|
||||
final verticalPadding =
|
||||
context.read<DatabasePluginWidgetBuilderSize?>()?.verticalPadding ??
|
||||
0.0;
|
||||
return Focus(
|
||||
focusNode: focusNode,
|
||||
onFocusChange: (value) {
|
||||
@@ -85,7 +90,7 @@ class _BuiltInPageWidgetState extends State<BuiltInPageWidget> {
|
||||
}
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
padding: EdgeInsets.symmetric(vertical: verticalPadding),
|
||||
child: widget.builder(view),
|
||||
),
|
||||
);
|
||||
|
||||
+6
-7
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+275
-4
@@ -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,242 @@ 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) {
|
||||
if (nodes.isNotEmpty && node.children.isNotEmpty) {
|
||||
// merge the children of the selected node and the first node of the ai response
|
||||
nodes[0] = nodes[0].copyWith(
|
||||
children: [
|
||||
...node.children.map((e) => e.deepCopy()),
|
||||
...nodes[0].children,
|
||||
],
|
||||
);
|
||||
}
|
||||
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 flattenNodes = editorState.getNodesInSelection(selection);
|
||||
final nodes = <Node>[];
|
||||
for (final node in flattenNodes) {
|
||||
if (nodes.any((element) => element.isParentOf(node))) {
|
||||
continue;
|
||||
}
|
||||
nodes.add(node);
|
||||
}
|
||||
|
||||
// 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);
|
||||
|
||||
// if the first markdown node has children, we need to insert the children
|
||||
// and delete the children of the first node that are in the selection.
|
||||
if (firstMarkdownNode.children.isNotEmpty) {
|
||||
transaction.insertNodes(
|
||||
firstNode.path.child(0),
|
||||
firstMarkdownNode.children.map((e) => e.deepCopy()),
|
||||
);
|
||||
}
|
||||
|
||||
final nodesToDelete =
|
||||
firstNode.children.where((e) => e.path.inSelection(selection));
|
||||
transaction.deleteNodes(nodesToDelete);
|
||||
}
|
||||
|
||||
// step 2
|
||||
bool handledLastNode = false;
|
||||
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 &&
|
||||
firstNode?.id != lastNode.id) {
|
||||
handledLastNode = true;
|
||||
|
||||
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);
|
||||
|
||||
if (lastMarkdownNode.children.isNotEmpty) {
|
||||
transaction
|
||||
..insertNodes(
|
||||
lastNode.path.child(0),
|
||||
lastMarkdownNode.children.map((e) => e.deepCopy()),
|
||||
)
|
||||
..deleteNodes(
|
||||
lastNode.children.where((e) => e.path.inSelection(selection)),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// step 3
|
||||
final insertedPath = selection.start.path.nextNPath(1);
|
||||
final insertLength = handledLastNode ? 2 : 1;
|
||||
if (markdownNodes.length > insertLength) {
|
||||
transaction.insertNodes(
|
||||
insertedPath,
|
||||
markdownNodes
|
||||
.skip(1)
|
||||
.take(markdownNodes.length - insertLength)
|
||||
.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 {
|
||||
|
||||
-1
@@ -47,7 +47,6 @@ class _CopyButton extends StatelessWidget {
|
||||
|
||||
if (context.mounted) {
|
||||
showToastNotification(
|
||||
context,
|
||||
message: LocaleKeys.document_codeBlock_codeCopiedSnackbar.tr(),
|
||||
);
|
||||
}
|
||||
|
||||
+6
-1
@@ -1,3 +1,4 @@
|
||||
import 'package:appflowy/plugins/database/tab_bar/tab_bar_view.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/columns/simple_columns_block_constant.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
@@ -115,7 +116,11 @@ class SimpleColumnBlockComponentState extends State<SimpleColumnBlockComponent>
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: node.children.map(
|
||||
(e) {
|
||||
Widget child = IntrinsicHeight(
|
||||
Widget child = Provider(
|
||||
create: (_) => DatabasePluginWidgetBuilderSize(
|
||||
verticalPadding: 0,
|
||||
horizontalPadding: 0,
|
||||
),
|
||||
child: editorState.renderer.build(context, e),
|
||||
);
|
||||
if (SimpleColumnsBlockConstants.enableDebugBorder) {
|
||||
|
||||
+5
-4
@@ -9,10 +9,12 @@ class SimpleColumnBlockWidthResizer extends StatefulWidget {
|
||||
super.key,
|
||||
required this.columnNode,
|
||||
required this.editorState,
|
||||
this.height,
|
||||
});
|
||||
|
||||
final Node columnNode;
|
||||
final EditorState editorState;
|
||||
final double? height;
|
||||
|
||||
@override
|
||||
State<SimpleColumnBlockWidthResizer> createState() =>
|
||||
@@ -53,15 +55,14 @@ class _SimpleColumnBlockWidthResizerState
|
||||
child: ValueListenableBuilder<bool>(
|
||||
valueListenable: isHovering,
|
||||
builder: (context, isHovering, child) {
|
||||
if (isDraggingAppFlowyEditorBlock.value) {
|
||||
return SizedBox.shrink();
|
||||
}
|
||||
final hide = isDraggingAppFlowyEditorBlock.value || !isHovering;
|
||||
return MouseRegion(
|
||||
cursor: SystemMouseCursors.resizeLeftRight,
|
||||
child: Container(
|
||||
width: 2,
|
||||
height: widget.height ?? 20,
|
||||
margin: EdgeInsets.symmetric(horizontal: 2),
|
||||
color: isHovering
|
||||
color: !hide
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Colors.transparent,
|
||||
),
|
||||
|
||||
+28
-10
@@ -92,17 +92,18 @@ class ColumnsBlockComponentState extends State<ColumnsBlockComponent>
|
||||
|
||||
final ScrollController scrollController = ScrollController();
|
||||
|
||||
final ValueNotifier<double?> heightValueNotifier = ValueNotifier(null);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
_updateColumnsBlock();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
scrollController.dispose();
|
||||
|
||||
heightValueNotifier.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -110,15 +111,13 @@ class ColumnsBlockComponentState extends State<ColumnsBlockComponent>
|
||||
Widget build(BuildContext context) {
|
||||
Widget child = Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: _buildChildren(),
|
||||
);
|
||||
|
||||
child = Align(
|
||||
alignment: Alignment.topLeft,
|
||||
child: IntrinsicHeight(
|
||||
child: child,
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
|
||||
child = Padding(
|
||||
@@ -141,7 +140,10 @@ class ColumnsBlockComponentState extends State<ColumnsBlockComponent>
|
||||
|
||||
// the columns block does not support the block actions and selection
|
||||
// because the columns block is a layout wrapper, it does not have a content
|
||||
return child;
|
||||
return NotificationListener<SizeChangedLayoutNotification>(
|
||||
onNotification: (v) => updateHeightValueNotifier(v),
|
||||
child: SizeChangedLayoutNotifier(child: child),
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _buildChildren() {
|
||||
@@ -164,9 +166,15 @@ class ColumnsBlockComponentState extends State<ColumnsBlockComponent>
|
||||
|
||||
if (i != length - 1) {
|
||||
children.add(
|
||||
SimpleColumnBlockWidthResizer(
|
||||
columnNode: childNode,
|
||||
editorState: editorState,
|
||||
ValueListenableBuilder(
|
||||
valueListenable: heightValueNotifier,
|
||||
builder: (context, height, child) {
|
||||
return SimpleColumnBlockWidthResizer(
|
||||
columnNode: childNode,
|
||||
editorState: editorState,
|
||||
height: height,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -194,6 +202,16 @@ class ColumnsBlockComponentState extends State<ColumnsBlockComponent>
|
||||
}
|
||||
}
|
||||
|
||||
bool updateHeightValueNotifier(SizeChangedLayoutNotification notification) {
|
||||
if (!mounted) return true;
|
||||
final height = _renderBox?.size.height;
|
||||
if (heightValueNotifier.value == height) return true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
heightValueNotifier.value = height;
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
@override
|
||||
Position start() => Position(path: widget.node.path);
|
||||
|
||||
|
||||
+6
-1
@@ -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;
|
||||
@@ -184,7 +186,7 @@ Future<bool> _pasteAsLinkPreview(
|
||||
node.delta?.toPlainText().isNotEmpty == true) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isMobile) return false;
|
||||
final bool isImageUrl;
|
||||
try {
|
||||
isImageUrl = await _isImageUrl(text);
|
||||
@@ -193,6 +195,8 @@ Future<bool> _pasteAsLinkPreview(
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!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) {
|
||||
|
||||
+5
-7
@@ -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);
|
||||
|
||||
|
||||
+2
@@ -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());
|
||||
}
|
||||
|
||||
-3
@@ -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(),
|
||||
);
|
||||
}
|
||||
|
||||
+26
@@ -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;
|
||||
}
|
||||
}
|
||||
+7
@@ -1,3 +1,4 @@
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/custom_link_parser.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_block.dart';
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
|
||||
@@ -27,9 +28,15 @@ extension TextDeltaExtension on Delta {
|
||||
if (op.text == MentionBlockKeys.mentionChar) {
|
||||
final mention = attributes?[MentionBlockKeys.mention];
|
||||
final mentionPageId = mention?[MentionBlockKeys.pageId];
|
||||
final mentionType = mention?[MentionBlockKeys.type];
|
||||
if (mentionPageId != null) {
|
||||
text += await getMentionPageName(mentionPageId);
|
||||
continue;
|
||||
} else if (mentionType == MentionType.externalLink.name) {
|
||||
final url = mention?[MentionBlockKeys.url] ?? '';
|
||||
final info = await LinkInfoCache.get(url);
|
||||
text += info?.title ?? url;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+14
-14
@@ -5,6 +5,7 @@ import 'package:appflowy/plugins/shared/share/constants.dart';
|
||||
import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart';
|
||||
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.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';
|
||||
@@ -304,17 +305,16 @@ void showLinkCreateMenu(
|
||||
return (left, top, right, bottom, alignment);
|
||||
}
|
||||
|
||||
ShapeDecoration buildToolbarLinkDecoration(BuildContext context) =>
|
||||
ShapeDecoration(
|
||||
color: Theme.of(context).cardColor,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
shadows: [
|
||||
const BoxShadow(
|
||||
color: LinkStyle.shadowMedium,
|
||||
blurRadius: 24,
|
||||
offset: Offset(0, 4),
|
||||
),
|
||||
],
|
||||
);
|
||||
ShapeDecoration buildToolbarLinkDecoration(
|
||||
BuildContext context, {
|
||||
double radius = 12.0,
|
||||
}) {
|
||||
final theme = AppFlowyTheme.of(context);
|
||||
return ShapeDecoration(
|
||||
color: theme.surfaceColorScheme.primary,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(radius),
|
||||
),
|
||||
shadows: [theme.shadow.small],
|
||||
);
|
||||
}
|
||||
+97
-67
@@ -13,6 +13,7 @@ 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_search_text_field.dart';
|
||||
import 'link_styles.dart';
|
||||
@@ -38,15 +39,14 @@ class LinkEditMenu extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _LinkEditMenuState extends State<LinkEditMenu> {
|
||||
ValueChanged<LinkInfo> get onApply => widget.onApply;
|
||||
|
||||
ValueChanged<LinkInfo> get onRemoveLink => widget.onRemoveLink;
|
||||
|
||||
VoidCallback get onDismiss => widget.onDismiss;
|
||||
|
||||
late TextEditingController linkNameController =
|
||||
TextEditingController(text: linkInfo.name);
|
||||
final textFocusNode = FocusNode();
|
||||
late FocusNode textFocusNode = FocusNode(onKeyEvent: onFocusKeyEvent);
|
||||
late FocusNode menuFocusNode = FocusNode(onKeyEvent: onFocusKeyEvent);
|
||||
late LinkInfo linkInfo = widget.linkInfo;
|
||||
late LinkSearchTextField searchTextField;
|
||||
bool isShowingSearchResult = false;
|
||||
@@ -74,12 +74,14 @@ class _LinkEditMenuState extends State<LinkEditMenu> {
|
||||
if (mounted) setState(() {});
|
||||
},
|
||||
)..searchRecentViews();
|
||||
makeSureHasFocus();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
linkNameController.dispose();
|
||||
textFocusNode.dispose();
|
||||
menuFocusNode.dispose();
|
||||
searchTextField.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
@@ -91,56 +93,59 @@ class _LinkEditMenuState extends State<LinkEditMenu> {
|
||||
final errorHeight = showErrorText ? 20.0 : 0.0;
|
||||
return GestureDetector(
|
||||
onTap: onDismiss,
|
||||
child: Container(
|
||||
width: 400,
|
||||
height: 250 + (showingRecent ? 32 : 0),
|
||||
color: Colors.white.withAlpha(1),
|
||||
child: Stack(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: hideSearchResult,
|
||||
child: Container(
|
||||
width: 400,
|
||||
height: 192 + errorHeight,
|
||||
decoration: buildToolbarLinkDecoration(context),
|
||||
child: Focus(
|
||||
focusNode: menuFocusNode,
|
||||
child: Container(
|
||||
width: 400,
|
||||
height: 250 + (showingRecent ? 32 : 0),
|
||||
color: Colors.white.withAlpha(1),
|
||||
child: Stack(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: hideSearchResult,
|
||||
child: Container(
|
||||
width: 400,
|
||||
height: 192 + errorHeight,
|
||||
decoration: buildToolbarLinkDecoration(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: 16,
|
||||
left: 20,
|
||||
child: FlowyText.semibold(
|
||||
LocaleKeys.document_toolbar_pageOrURL.tr(),
|
||||
color: LinkStyle.textTertiary,
|
||||
fontSize: 12,
|
||||
figmaLineHeight: 16,
|
||||
Positioned(
|
||||
top: 16,
|
||||
left: 20,
|
||||
child: FlowyText.semibold(
|
||||
LocaleKeys.document_toolbar_pageOrURL.tr(),
|
||||
color: LinkStyle.textTertiary,
|
||||
fontSize: 12,
|
||||
figmaLineHeight: 16,
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: 80 + errorHeight,
|
||||
left: 20,
|
||||
child: FlowyText.semibold(
|
||||
LocaleKeys.document_toolbar_linkName.tr(),
|
||||
color: LinkStyle.textTertiary,
|
||||
fontSize: 12,
|
||||
figmaLineHeight: 16,
|
||||
Positioned(
|
||||
top: 80 + errorHeight,
|
||||
left: 20,
|
||||
child: FlowyText.semibold(
|
||||
LocaleKeys.document_toolbar_linkName.tr(),
|
||||
color: LinkStyle.textTertiary,
|
||||
fontSize: 12,
|
||||
figmaLineHeight: 16,
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: 144 + errorHeight,
|
||||
left: 20,
|
||||
child: buildButtons(),
|
||||
),
|
||||
Positioned(
|
||||
top: 100 + errorHeight,
|
||||
left: 20,
|
||||
child: buildNameTextField(),
|
||||
),
|
||||
Positioned(
|
||||
top: 36,
|
||||
left: 20,
|
||||
child: buildLinkField(),
|
||||
),
|
||||
],
|
||||
Positioned(
|
||||
top: 144 + errorHeight,
|
||||
left: 20,
|
||||
child: buildButtons(),
|
||||
),
|
||||
Positioned(
|
||||
top: 100 + errorHeight,
|
||||
left: 20,
|
||||
child: buildNameTextField(),
|
||||
),
|
||||
Positioned(
|
||||
top: 36,
|
||||
left: 20,
|
||||
child: buildLinkField(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -254,23 +259,7 @@ class _LinkEditMenuState extends State<LinkEditMenu> {
|
||||
fontColor: Colors.white,
|
||||
fillColor: LinkStyle.fillThemeThick,
|
||||
fontWeight: FontWeight.w400,
|
||||
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);
|
||||
},
|
||||
onPressed: onApply,
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -287,6 +276,7 @@ class _LinkEditMenuState extends State<LinkEditMenu> {
|
||||
child: TextFormField(
|
||||
autovalidateMode: AutovalidateMode.onUserInteraction,
|
||||
focusNode: textFocusNode,
|
||||
autofocus: true,
|
||||
textAlign: TextAlign.left,
|
||||
controller: linkNameController,
|
||||
style: TextStyle(
|
||||
@@ -395,6 +385,45 @@ class _LinkEditMenuState extends State<LinkEditMenu> {
|
||||
);
|
||||
}
|
||||
|
||||
KeyEventResult onFocusKeyEvent(FocusNode node, KeyEvent key) {
|
||||
if (key is! KeyDownEvent) return KeyEventResult.ignored;
|
||||
if (key.logicalKey == LogicalKeyboardKey.enter) {
|
||||
onApply();
|
||||
return KeyEventResult.handled;
|
||||
} else if (key.logicalKey == LogicalKeyboardKey.escape) {
|
||||
onDismiss();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
Future<void> makeSureHasFocus() async {
|
||||
final focusNode = textFocusNode;
|
||||
if (!mounted || focusNode.hasFocus) return;
|
||||
focusNode.requestFocus();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
makeSureHasFocus();
|
||||
});
|
||||
}
|
||||
|
||||
void onApply() {
|
||||
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);
|
||||
}
|
||||
|
||||
void onConfirm() {
|
||||
searchTextField.onSearchResult(
|
||||
onLink: onLinkSelected,
|
||||
@@ -404,6 +433,7 @@ class _LinkEditMenuState extends State<LinkEditMenu> {
|
||||
searchTextField.unfocus();
|
||||
},
|
||||
);
|
||||
menuFocusNode.requestFocus();
|
||||
}
|
||||
|
||||
Future<void> getPageView() async {
|
||||
|
||||
+152
-9
@@ -6,6 +6,9 @@ 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';
|
||||
@@ -14,6 +17,7 @@ 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';
|
||||
@@ -139,6 +143,7 @@ class _LinkHoverTriggerState extends State<LinkHoverTrigger> {
|
||||
isHoverMenuHovering = false;
|
||||
tryToDismissLinkHoverMenu();
|
||||
},
|
||||
onConvertTo: (type) => convertLinkTo(editorState, selection, type),
|
||||
onOpenLink: openLink,
|
||||
onCopyLink: () => copyLink(context),
|
||||
onEditLink: showLinkEditMenu,
|
||||
@@ -236,15 +241,7 @@ class _LinkHoverTriggerState extends State<LinkHoverTrigger> {
|
||||
|
||||
Future<void> copyLink(BuildContext context) async {
|
||||
final href = widget.attribute.href ?? '';
|
||||
if (href.isEmpty) return;
|
||||
await getIt<ClipboardService>()
|
||||
.setData(ClipboardServiceData(plainText: href));
|
||||
if (context.mounted) {
|
||||
showToastNotification(
|
||||
context,
|
||||
message: LocaleKeys.shareAction_copyLinkSuccess.tr(),
|
||||
);
|
||||
}
|
||||
await context.copyLink(href);
|
||||
hoverMenuController.close();
|
||||
}
|
||||
|
||||
@@ -271,6 +268,26 @@ 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,
|
||||
@@ -308,6 +325,7 @@ class LinkHoverMenu extends StatefulWidget {
|
||||
required this.onOpenLink,
|
||||
required this.onEditLink,
|
||||
required this.onRemoveLink,
|
||||
required this.onConvertTo,
|
||||
});
|
||||
|
||||
final Attributes attribute;
|
||||
@@ -318,6 +336,7 @@ class LinkHoverMenu extends StatefulWidget {
|
||||
final VoidCallback onOpenLink;
|
||||
final VoidCallback onEditLink;
|
||||
final VoidCallback onRemoveLink;
|
||||
final ValueChanged<LinkConvertMenuCommand> onConvertTo;
|
||||
|
||||
@override
|
||||
State<LinkHoverMenu> createState() => _LinkHoverMenuState();
|
||||
@@ -327,6 +346,8 @@ 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() {
|
||||
@@ -334,6 +355,12 @@ class _LinkHoverMenuState extends State<LinkHoverMenu> {
|
||||
if (isPage) getPageView();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
super.dispose();
|
||||
popoverController.close();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
@@ -365,6 +392,7 @@ class _LinkHoverMenuState extends State<LinkHoverMenu> {
|
||||
FlowyIconButton(
|
||||
icon: FlowySvg(FlowySvgs.toolbar_link_m),
|
||||
tooltipText: LocaleKeys.editor_copyLink.tr(),
|
||||
preferBelow: false,
|
||||
width: 36,
|
||||
height: 32,
|
||||
onPressed: widget.onCopyLink,
|
||||
@@ -372,13 +400,16 @@ class _LinkHoverMenuState extends State<LinkHoverMenu> {
|
||||
FlowyIconButton(
|
||||
icon: FlowySvg(FlowySvgs.toolbar_link_edit_m),
|
||||
tooltipText: LocaleKeys.editor_editLink.tr(),
|
||||
preferBelow: false,
|
||||
width: 36,
|
||||
height: 32,
|
||||
onPressed: widget.onEditLink,
|
||||
),
|
||||
buildConvertButton(),
|
||||
FlowyIconButton(
|
||||
icon: FlowySvg(FlowySvgs.toolbar_link_unlink_m),
|
||||
tooltipText: LocaleKeys.editor_removeLink.tr(),
|
||||
preferBelow: false,
|
||||
width: 36,
|
||||
height: 32,
|
||||
onPressed: widget.onRemoveLink,
|
||||
@@ -445,6 +476,73 @@ class _LinkHoverMenuState extends State<LinkHoverMenu> {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -490,3 +588,48 @@ class LinkHoverTriggers {
|
||||
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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
+184
@@ -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;
|
||||
}
|
||||
}
|
||||
-1
@@ -154,7 +154,6 @@ class _ErrorBlockComponentWidgetState extends State<ErrorBlockComponentWidget>
|
||||
|
||||
void _copyBlockContent() {
|
||||
showToastNotification(
|
||||
context,
|
||||
message: LocaleKeys.document_errorBlock_blockContentHasBeenCopied.tr(),
|
||||
);
|
||||
|
||||
|
||||
+2
-6
@@ -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(),
|
||||
);
|
||||
|
||||
+7
-9
@@ -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,
|
||||
|
||||
-2
@@ -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(),
|
||||
|
||||
-2
@@ -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,
|
||||
);
|
||||
|
||||
-1
@@ -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(),
|
||||
);
|
||||
}
|
||||
|
||||
+310
@@ -0,0 +1,310 @@
|
||||
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/callout/callout_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/default_selectable_mixin.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
|
||||
DefaultSelectableMixinState<LinkEmbedBlockComponent> createState() =>
|
||||
LinkEmbedBlockComponentState();
|
||||
}
|
||||
|
||||
class LinkEmbedBlockComponentState
|
||||
extends DefaultSelectableMixinState<LinkEmbedBlockComponent>
|
||||
with BlockComponentConfigurable {
|
||||
@override
|
||||
BlockComponentConfiguration get configuration => widget.configuration;
|
||||
|
||||
@override
|
||||
Node get node => widget.node;
|
||||
|
||||
String get url => widget.node.attributes[LinkPreviewBlockKeys.url] ?? '';
|
||||
|
||||
LinkLoadingStatus status = LinkLoadingStatus.loading;
|
||||
final parser = LinkParser();
|
||||
late LinkInfo linkInfo = LinkInfo(url: url);
|
||||
|
||||
final showActionsNotifier = ValueNotifier<bool>(false);
|
||||
bool isMenuShowing = false, isHovering = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
parser.addLinkInfoListener((v) {
|
||||
final hasNewInfo = !v.isEmpty(), hasOldInfo = !linkInfo.isEmpty();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
if (hasNewInfo) {
|
||||
linkInfo = v;
|
||||
status = LinkLoadingStatus.idle;
|
||||
} else if (!hasOldInfo) {
|
||||
status = LinkLoadingStatus.error;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
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),
|
||||
);
|
||||
final parent = node.parent;
|
||||
EdgeInsets newPadding = padding;
|
||||
if (parent?.type == CalloutBlockKeys.type) {
|
||||
newPadding = padding.copyWith(right: padding.right + 10);
|
||||
}
|
||||
|
||||
result = Padding(padding: newPadding, 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 == LinkLoadingStatus.idle;
|
||||
if (isIdle) {
|
||||
child = buildContent(context);
|
||||
} else {
|
||||
child = buildErrorLoadingWidget(context);
|
||||
}
|
||||
return Container(
|
||||
height: 450,
|
||||
key: widgetKey,
|
||||
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 = LinkLoadingStatus.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,
|
||||
),
|
||||
),
|
||||
),
|
||||
MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () =>
|
||||
afLaunchUrlString(url, addingHttpSchemeWhenFailed: true),
|
||||
child: 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 == LinkLoadingStatus.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_unableToDisplay
|
||||
.tr(),
|
||||
style: TextStyle(
|
||||
color: textSceme.primary,
|
||||
fontSize: 14,
|
||||
height: 20 / 14,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Node get currentNode => node;
|
||||
|
||||
@override
|
||||
EdgeInsets get boxPadding => padding;
|
||||
}
|
||||
+354
@@ -0,0 +1,354 @@
|
||||
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/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(12),
|
||||
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,
|
||||
),
|
||||
radius: BorderRadius.all(Radius.circular(theme.borderRadius.m)),
|
||||
tooltipText: LocaleKeys.editor_copyLink.tr(),
|
||||
preferBelow: false,
|
||||
onPressed: () => copyLink(context),
|
||||
),
|
||||
buildconvertBotton(),
|
||||
buildMoreOptionBotton(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildconvertBotton() {
|
||||
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) => buildConvertMenu(),
|
||||
child: FlowyIconButton(
|
||||
icon: FlowySvg(
|
||||
FlowySvgs.turninto_m,
|
||||
color: iconScheme.tertiary,
|
||||
),
|
||||
radius: BorderRadius.all(Radius.circular(theme.borderRadius.m)),
|
||||
tooltipText: LocaleKeys.editor_convertTo.tr(),
|
||||
preferBelow: false,
|
||||
onPressed: showTurnIntoMenu,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildConvertMenu() {
|
||||
final types = LinkEmbedConvertCommand.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: () {
|
||||
if (command == LinkEmbedConvertCommand.toBookmark) {
|
||||
final transaction = editorState.transaction;
|
||||
transaction.updateNode(node, {
|
||||
LinkPreviewBlockKeys.url: url,
|
||||
LinkEmbedKeys.previewType: '',
|
||||
});
|
||||
editorState.apply(transaction);
|
||||
} else if (command == LinkEmbedConvertCommand.toMention) {
|
||||
convertUrlPreviewNodeToMention(editorState, node);
|
||||
} else if (command == LinkEmbedConvertCommand.toURL) {
|
||||
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,
|
||||
),
|
||||
radius: BorderRadius.all(Radius.circular(theme.borderRadius.m)),
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum LinkEmbedConvertCommand {
|
||||
toMention,
|
||||
toURL,
|
||||
toBookmark;
|
||||
|
||||
String get title {
|
||||
switch (this) {
|
||||
case toMention:
|
||||
return LocaleKeys.document_plugins_linkPreview_linkPreviewMenu_toMetion
|
||||
.tr();
|
||||
case toURL:
|
||||
return LocaleKeys.document_plugins_linkPreview_linkPreviewMenu_toUrl
|
||||
.tr();
|
||||
case toBookmark:
|
||||
return LocaleKeys
|
||||
.document_plugins_linkPreview_linkPreviewMenu_toBookmark
|
||||
.tr();
|
||||
}
|
||||
}
|
||||
}
|
||||
+151
@@ -0,0 +1,151 @@
|
||||
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:flutter/material.dart';
|
||||
import 'link_parsers/default_parser.dart';
|
||||
import 'link_parsers/youtube_parser.dart';
|
||||
|
||||
class LinkParser {
|
||||
final Set<ValueChanged<LinkInfo>> _listeners = <ValueChanged<LinkInfo>>{};
|
||||
static final Map<String, LinkInfoParser> _hostToParsers = {
|
||||
'www.youtube.com': YoutubeParser(),
|
||||
'youtube.com': YoutubeParser(),
|
||||
'youtu.be': YoutubeParser(),
|
||||
};
|
||||
|
||||
Future<void> start(String url, {LinkInfoParser? parser}) async {
|
||||
final uri = Uri.tryParse(LinkInfoParser.formatUrl(url)) ?? Uri.parse(url);
|
||||
final data = await LinkInfoCache.get(uri);
|
||||
if (data != null) {
|
||||
refreshLinkInfo(data);
|
||||
}
|
||||
|
||||
final host = uri.host;
|
||||
final currentParser = parser ?? _hostToParsers[host] ?? DefaultParser();
|
||||
await _getLinkInfo(uri, currentParser);
|
||||
}
|
||||
|
||||
Future<LinkInfo?> _getLinkInfo(Uri uri, LinkInfoParser parser) async {
|
||||
try {
|
||||
final linkInfo = await parser.parse(uri) ?? LinkInfo(url: '$uri');
|
||||
if (!linkInfo.isEmpty()) await LinkInfoCache.set(uri, linkInfo);
|
||||
refreshLinkInfo(linkInfo);
|
||||
return linkInfo;
|
||||
} catch (e, s) {
|
||||
Log.error('get link info error: ', e, s);
|
||||
refreshLinkInfo(LinkInfo(url: '$uri'));
|
||||
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'],
|
||||
url: json['url'] ?? '',
|
||||
title: json['title'],
|
||||
description: json['description'],
|
||||
imageUrl: json['imageUrl'],
|
||||
faviconUrl: json['faviconUrl'],
|
||||
);
|
||||
|
||||
LinkInfo({
|
||||
required this.url,
|
||||
this.siteName,
|
||||
this.title,
|
||||
this.description,
|
||||
this.imageUrl,
|
||||
this.faviconUrl,
|
||||
});
|
||||
|
||||
final String url;
|
||||
final String? siteName;
|
||||
final String? title;
|
||||
final String? description;
|
||||
final String? imageUrl;
|
||||
final String? faviconUrl;
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'url': url,
|
||||
'siteName': siteName,
|
||||
'title': title,
|
||||
'description': description,
|
||||
'imageUrl': imageUrl,
|
||||
'faviconUrl': faviconUrl,
|
||||
};
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'LinkInfo{url: $url, siteName: $siteName, title: $title, description: $description, imageUrl: $imageUrl, faviconUrl: $faviconUrl}';
|
||||
}
|
||||
|
||||
bool isEmpty() {
|
||||
return title == 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 {
|
||||
static const _linkInfoPrefix = 'link_info';
|
||||
|
||||
static Future<LinkInfo?> get(Uri uri) async {
|
||||
final option = await getIt<KeyValueStorage>().getWithFormat<LinkInfo?>(
|
||||
'$_linkInfoPrefix$uri',
|
||||
(value) => LinkInfo.fromJson(jsonDecode(value)),
|
||||
);
|
||||
return option;
|
||||
}
|
||||
|
||||
static Future<void> set(Uri uri, LinkInfo data) async {
|
||||
await getIt<KeyValueStorage>().set(
|
||||
'$_linkInfoPrefix$uri',
|
||||
jsonEncode(data.toJson()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
enum LinkLoadingStatus {
|
||||
loading,
|
||||
idle,
|
||||
error,
|
||||
}
|
||||
+122
-56
@@ -3,8 +3,10 @@ import 'package:appflowy/generated/flowy_svgs.g.dart';
|
||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
||||
import 'package:appflowy/mobile/presentation/widgets/widgets.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/mobile_block_action_buttons.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/callout/callout_block_component.dart';
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/shared.dart';
|
||||
import 'package:appflowy/shared/appflowy_network_image.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';
|
||||
@@ -12,6 +14,9 @@ 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';
|
||||
|
||||
import 'custom_link_parser.dart';
|
||||
|
||||
class CustomLinkPreviewWidget extends StatelessWidget {
|
||||
const CustomLinkPreviewWidget({
|
||||
@@ -21,6 +26,8 @@ class CustomLinkPreviewWidget extends StatelessWidget {
|
||||
this.title,
|
||||
this.description,
|
||||
this.imageUrl,
|
||||
this.isHovering = false,
|
||||
this.status = LinkLoadingStatus.loading,
|
||||
});
|
||||
|
||||
final Node node;
|
||||
@@ -28,9 +35,14 @@ class CustomLinkPreviewWidget extends StatelessWidget {
|
||||
final String? description;
|
||||
final String? imageUrl;
|
||||
final String url;
|
||||
final bool isHovering;
|
||||
final LinkLoadingStatus status;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = AppFlowyTheme.of(context),
|
||||
borderScheme = theme.borderColorScheme,
|
||||
textScheme = theme.textColorScheme;
|
||||
final documentFontSize = context
|
||||
.read<EditorState>()
|
||||
.editorStyle
|
||||
@@ -38,73 +50,67 @@ class CustomLinkPreviewWidget extends StatelessWidget {
|
||||
.text
|
||||
.fontSize ??
|
||||
16.0;
|
||||
final isInDarkCallout = node.parent?.type == CalloutBlockKeys.type &&
|
||||
!Theme.of(context).isLightMode;
|
||||
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 || isInDarkCallout
|
||||
? 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),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
if (title != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
bottom: 4.0,
|
||||
right: 10.0,
|
||||
),
|
||||
child: FlowyText.medium(
|
||||
title!,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
fontSize: fontSize,
|
||||
),
|
||||
padding: const EdgeInsets.fromLTRB(20, 12, 58, 12),
|
||||
child: status != LinkLoadingStatus.idle
|
||||
? buildLoadingOrErrorWidget()
|
||||
: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
if (title != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4.0),
|
||||
child: FlowyText.medium(
|
||||
title!,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
fontSize: fontSize,
|
||||
color: textScheme.primary,
|
||||
figmaLineHeight: 20,
|
||||
),
|
||||
),
|
||||
if (description != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16.0),
|
||||
child: FlowyText(
|
||||
description!,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
fontSize: fontSize - 4,
|
||||
figmaLineHeight: 16,
|
||||
color: textScheme.primary,
|
||||
),
|
||||
),
|
||||
FlowyText(
|
||||
url.toString(),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
color: textScheme.secondary,
|
||||
fontSize: fontSize - 4,
|
||||
figmaLineHeight: 16,
|
||||
),
|
||||
],
|
||||
),
|
||||
if (description != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4.0),
|
||||
child: FlowyText(
|
||||
description!,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
fontSize: fontSize - 4,
|
||||
),
|
||||
),
|
||||
FlowyText(
|
||||
url.toString(),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 2,
|
||||
color: Theme.of(context).hintColor,
|
||||
fontSize: fontSize - 4,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -113,9 +119,12 @@ class CustomLinkPreviewWidget extends StatelessWidget {
|
||||
);
|
||||
|
||||
if (UniversalPlatform.isDesktopOrWeb) {
|
||||
return InkWell(
|
||||
onTap: () => afLaunchUrlString(url),
|
||||
child: child,
|
||||
return MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: GestureDetector(
|
||||
onTap: () => afLaunchUrlString(url),
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -150,4 +159,61 @@ 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?.isNotEmpty ?? false) {
|
||||
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 == LinkLoadingStatus.loading) {
|
||||
return const Center(
|
||||
child: SizedBox(
|
||||
height: 16,
|
||||
width: 16,
|
||||
child: CircularProgressIndicator.adaptive(),
|
||||
),
|
||||
);
|
||||
} else if (status == LinkLoadingStatus.error) {
|
||||
return const Center(
|
||||
child: SizedBox(
|
||||
height: 16,
|
||||
width: 16,
|
||||
child: Icon(
|
||||
Icons.error_outline,
|
||||
color: Colors.red,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return SizedBox.shrink();
|
||||
}
|
||||
}
|
||||
+194
@@ -0,0 +1,194 @@
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/callout/callout_block_component.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_editor/appflowy_editor.dart';
|
||||
import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'custom_link_preview.dart';
|
||||
import 'default_selectable_mixin.dart';
|
||||
import 'link_preview_menu.dart';
|
||||
|
||||
class CustomLinkPreviewBlockComponentBuilder extends BlockComponentBuilder {
|
||||
CustomLinkPreviewBlockComponentBuilder({
|
||||
super.configuration,
|
||||
});
|
||||
|
||||
@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),
|
||||
);
|
||||
}
|
||||
|
||||
@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(),
|
||||
});
|
||||
|
||||
@override
|
||||
DefaultSelectableMixinState<CustomLinkPreviewBlockComponent> createState() =>
|
||||
CustomLinkPreviewBlockComponentState();
|
||||
}
|
||||
|
||||
class CustomLinkPreviewBlockComponentState
|
||||
extends DefaultSelectableMixinState<CustomLinkPreviewBlockComponent>
|
||||
with BlockComponentConfigurable {
|
||||
@override
|
||||
BlockComponentConfiguration get configuration => widget.configuration;
|
||||
|
||||
@override
|
||||
Node get node => widget.node;
|
||||
|
||||
String get url => widget.node.attributes[LinkPreviewBlockKeys.url]!;
|
||||
|
||||
final parser = LinkParser();
|
||||
LinkLoadingStatus status = LinkLoadingStatus.loading;
|
||||
late LinkInfo linkInfo = LinkInfo(url: url);
|
||||
|
||||
final showActionsNotifier = ValueNotifier<bool>(false);
|
||||
bool isMenuShowing = false, isHovering = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
parser.addLinkInfoListener((v) {
|
||||
final hasNewInfo = !v.isEmpty(), hasOldInfo = !linkInfo.isEmpty();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
if (hasNewInfo) {
|
||||
linkInfo = v;
|
||||
status = LinkLoadingStatus.idle;
|
||||
} else if (!hasOldInfo) {
|
||||
status = LinkLoadingStatus.error;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
parser.start(url);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
parser.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@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(
|
||||
valueListenable: showActionsNotifier,
|
||||
builder: (context, showActions, child) {
|
||||
return buildPreview(showActions);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildPreview(bool showActions) {
|
||||
Widget child = CustomLinkPreviewWidget(
|
||||
key: widgetKey,
|
||||
node: node,
|
||||
url: url,
|
||||
isHovering: showActions,
|
||||
title: linkInfo.siteName,
|
||||
description: linkInfo.description,
|
||||
imageUrl: linkInfo.imageUrl,
|
||||
status: status,
|
||||
);
|
||||
|
||||
if (widget.showActions && widget.actionBuilder != null) {
|
||||
child = BlockComponentActionWrapper(
|
||||
node: node,
|
||||
actionBuilder: widget.actionBuilder!,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
child = Stack(
|
||||
children: [
|
||||
child,
|
||||
if (showActions)
|
||||
Positioned(
|
||||
top: 12,
|
||||
right: 12,
|
||||
child: CustomLinkPreviewMenu(
|
||||
onMenuShowed: () {
|
||||
isMenuShowing = true;
|
||||
},
|
||||
onMenuHided: () {
|
||||
isMenuShowing = false;
|
||||
if (!isHovering && mounted) {
|
||||
showActionsNotifier.value = false;
|
||||
}
|
||||
},
|
||||
onReload: () {
|
||||
setState(() {
|
||||
status = LinkLoadingStatus.loading;
|
||||
});
|
||||
Future.delayed(const Duration(milliseconds: 200), () {
|
||||
if (mounted) parser.start(url);
|
||||
});
|
||||
},
|
||||
node: node,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
final parent = node.parent;
|
||||
EdgeInsets newPadding = padding;
|
||||
if (parent?.type == CalloutBlockKeys.type) {
|
||||
newPadding = padding.copyWith(right: padding.right + 10);
|
||||
}
|
||||
child = Padding(padding: newPadding, child: child);
|
||||
|
||||
return child;
|
||||
}
|
||||
|
||||
@override
|
||||
Node get currentNode => node;
|
||||
|
||||
@override
|
||||
EdgeInsets get boxPadding => padding;
|
||||
}
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
abstract class DefaultSelectableMixinState<T extends StatefulWidget>
|
||||
extends State<T> with SelectableMixin {
|
||||
final widgetKey = GlobalKey();
|
||||
RenderBox? get _renderBox =>
|
||||
widgetKey.currentContext?.findRenderObject() as RenderBox?;
|
||||
|
||||
Node get currentNode;
|
||||
|
||||
EdgeInsets get boxPadding => EdgeInsets.zero;
|
||||
|
||||
@override
|
||||
Position start() => Position(path: currentNode.path);
|
||||
|
||||
@override
|
||||
Position end() => Position(path: currentNode.path, offset: 1);
|
||||
|
||||
@override
|
||||
Position getPositionInOffset(Offset start) => end();
|
||||
|
||||
@override
|
||||
bool get shouldCursorBlink => false;
|
||||
|
||||
@override
|
||||
CursorStyle get cursorStyle => CursorStyle.cover;
|
||||
|
||||
@override
|
||||
Rect getBlockRect({
|
||||
bool shiftWithBaseOffset = false,
|
||||
}) {
|
||||
final box = _renderBox;
|
||||
if (box is RenderBox) {
|
||||
return boxPadding.topLeft & box.size;
|
||||
}
|
||||
return Rect.zero;
|
||||
}
|
||||
|
||||
@override
|
||||
Rect? getCursorRectInPosition(
|
||||
Position position, {
|
||||
bool shiftWithBaseOffset = false,
|
||||
}) {
|
||||
final rects = getRectsInSelection(Selection.collapsed(position));
|
||||
return rects.firstOrNull;
|
||||
}
|
||||
|
||||
@override
|
||||
List<Rect> getRectsInSelection(
|
||||
Selection selection, {
|
||||
bool shiftWithBaseOffset = false,
|
||||
}) {
|
||||
if (_renderBox == null) {
|
||||
return [];
|
||||
}
|
||||
final parentBox = context.findRenderObject();
|
||||
final box = widgetKey.currentContext?.findRenderObject();
|
||||
if (parentBox is RenderBox && box is RenderBox) {
|
||||
return [
|
||||
box.localToGlobal(Offset.zero, ancestor: parentBox) & box.size,
|
||||
];
|
||||
}
|
||||
return [Offset.zero & _renderBox!.size];
|
||||
}
|
||||
|
||||
@override
|
||||
Selection getSelectionInRange(Offset start, Offset end) => Selection.single(
|
||||
path: currentNode.path,
|
||||
startOffset: 0,
|
||||
endOffset: 1,
|
||||
);
|
||||
|
||||
@override
|
||||
Offset localToGlobal(Offset offset, {bool shiftWithBaseOffset = false}) =>
|
||||
_renderBox!.localToGlobal(offset);
|
||||
}
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/custom_link_parser.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
// ignore: depend_on_referenced_packages
|
||||
import 'package:html/parser.dart' as html_parser;
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
abstract class LinkInfoParser {
|
||||
Future<LinkInfo?> parse(
|
||||
Uri link, {
|
||||
Duration timeout = const Duration(seconds: 8),
|
||||
Map<String, String>? headers,
|
||||
});
|
||||
|
||||
static String formatUrl(String url) {
|
||||
Uri? uri = Uri.tryParse(url);
|
||||
if (uri == null) return url;
|
||||
if (!uri.hasScheme) uri = Uri.tryParse('http://$url');
|
||||
if (uri == null) return url;
|
||||
final isHome = (uri.hasEmptyPath || uri.path == '/') && !uri.hasQuery;
|
||||
final homeUrl = '${uri.scheme}://${uri.host}/';
|
||||
if (isHome) return homeUrl;
|
||||
return '$uri';
|
||||
}
|
||||
}
|
||||
|
||||
class DefaultParser implements LinkInfoParser {
|
||||
@override
|
||||
Future<LinkInfo?> parse(
|
||||
Uri link, {
|
||||
Duration timeout = const Duration(seconds: 8),
|
||||
Map<String, String>? headers,
|
||||
}) async {
|
||||
try {
|
||||
final isHome = (link.hasEmptyPath || link.path == '/') && !link.hasQuery;
|
||||
final http.Response response =
|
||||
await http.get(link, headers: headers).timeout(timeout);
|
||||
final code = response.statusCode;
|
||||
if (code != 200 && isHome) {
|
||||
throw Exception('Http request error: $code');
|
||||
}
|
||||
// else if (!isHome && code == 403) {
|
||||
// uri = Uri.parse('${uri.scheme}://${uri.host}/');
|
||||
// response = await http.get(uri).timeout(timeout);
|
||||
// }
|
||||
|
||||
final document = html_parser.parse(response.body);
|
||||
|
||||
final siteName = document
|
||||
.querySelector('meta[property="og:site_name"]')
|
||||
?.attributes['content'];
|
||||
|
||||
String? title = document
|
||||
.querySelector('meta[property="og:title"]')
|
||||
?.attributes['content'];
|
||||
title ??= document.querySelector('title')?.text;
|
||||
|
||||
String? description = document
|
||||
.querySelector('meta[property="og:description"]')
|
||||
?.attributes['content'];
|
||||
description ??= document
|
||||
.querySelector('meta[name="description"]')
|
||||
?.attributes['content'];
|
||||
|
||||
String? imageUrl = document
|
||||
.querySelector('meta[property="og:image"]')
|
||||
?.attributes['content'];
|
||||
if (imageUrl != null && !imageUrl.startsWith('http')) {
|
||||
imageUrl = link.resolve(imageUrl).toString();
|
||||
}
|
||||
|
||||
final favicon =
|
||||
'https://www.faviconextractor.com/favicon/${link.host}?larger=true';
|
||||
|
||||
return LinkInfo(
|
||||
url: '$link',
|
||||
siteName: siteName,
|
||||
title: title,
|
||||
description: description,
|
||||
imageUrl: imageUrl,
|
||||
faviconUrl: favicon,
|
||||
);
|
||||
} catch (e) {
|
||||
Log.error('Parse link $link error: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/custom_link_parser.dart';
|
||||
import 'package:appflowy_backend/log.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'default_parser.dart';
|
||||
|
||||
class YoutubeParser implements LinkInfoParser {
|
||||
@override
|
||||
Future<LinkInfo?> parse(
|
||||
Uri link, {
|
||||
Duration timeout = const Duration(seconds: 8),
|
||||
Map<String, String>? headers,
|
||||
}) async {
|
||||
try {
|
||||
final isHome = (link.hasEmptyPath || link.path == '/') && !link.hasQuery;
|
||||
if (isHome) {
|
||||
return DefaultParser().parse(
|
||||
link,
|
||||
timeout: timeout,
|
||||
headers: headers,
|
||||
);
|
||||
}
|
||||
|
||||
final requestLink =
|
||||
'https://www.youtube.com/oembed?url=$link&format=json';
|
||||
final http.Response response = await http
|
||||
.get(Uri.parse(requestLink), headers: headers)
|
||||
.timeout(timeout);
|
||||
final code = response.statusCode;
|
||||
if (code != 200) {
|
||||
throw Exception('Http request error: $code');
|
||||
}
|
||||
|
||||
final youtubeInfo = YoutubeInfo.fromJson(jsonDecode(response.body));
|
||||
|
||||
final favicon =
|
||||
'https://www.google.com/s2/favicons?sz=64&domain=${link.host}';
|
||||
return LinkInfo(
|
||||
url: '$link',
|
||||
title: youtubeInfo.title,
|
||||
siteName: youtubeInfo.authorName,
|
||||
imageUrl: youtubeInfo.thumbnailUrl,
|
||||
faviconUrl: favicon,
|
||||
);
|
||||
} catch (e) {
|
||||
Log.error('Parse link $link error: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class YoutubeInfo {
|
||||
YoutubeInfo({
|
||||
this.title,
|
||||
this.authorName,
|
||||
this.version,
|
||||
this.providerName,
|
||||
this.providerUrl,
|
||||
this.thumbnailUrl,
|
||||
});
|
||||
|
||||
YoutubeInfo.fromJson(Map<String, dynamic> json) {
|
||||
title = json['title'];
|
||||
authorName = json['author_name'];
|
||||
version = json['version'];
|
||||
providerName = json['provider_name'];
|
||||
providerUrl = json['provider_url'];
|
||||
thumbnailUrl = json['thumbnail_url'];
|
||||
}
|
||||
String? title;
|
||||
String? authorName;
|
||||
String? version;
|
||||
String? providerName;
|
||||
String? providerUrl;
|
||||
String? thumbnailUrl;
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'title': title,
|
||||
'author_name': authorName,
|
||||
'version': version,
|
||||
'provider_name': providerName,
|
||||
'provider_url': providerUrl,
|
||||
'thumbnail_url': thumbnailUrl,
|
||||
};
|
||||
}
|
||||
-25
@@ -1,25 +0,0 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:appflowy/core/config/kv.dart';
|
||||
import 'package:appflowy/startup/startup.dart';
|
||||
import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart';
|
||||
|
||||
class LinkPreviewDataCache implements LinkPreviewDataCacheInterface {
|
||||
@override
|
||||
Future<LinkPreviewData?> get(String url) async {
|
||||
final option =
|
||||
await getIt<KeyValueStorage>().getWithFormat<LinkPreviewData?>(
|
||||
url,
|
||||
(value) => LinkPreviewData.fromJson(jsonDecode(value)),
|
||||
);
|
||||
return option;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> set(String url, LinkPreviewData data) async {
|
||||
await getIt<KeyValueStorage>().set(
|
||||
url,
|
||||
jsonEncode(data.toJson()),
|
||||
);
|
||||
}
|
||||
}
|
||||
+180
-83
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+259
@@ -0,0 +1,259 @@
|
||||
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:appflowy_ui/appflowy_ui.dart';
|
||||
import 'package:easy_localization/easy_localization.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) {
|
||||
keepEditorFocusNotifier.decrease();
|
||||
// editorState.service.scrollService?.enable();
|
||||
// editorState.service.keyboardService?.enable();
|
||||
}
|
||||
_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(
|
||||
editorState: editorState,
|
||||
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!);
|
||||
|
||||
keepEditorFocusNotifier.increase();
|
||||
// editorState.service.keyboardService?.disable(showCursor: true);
|
||||
// editorState.service.scrollService?.disable();
|
||||
}
|
||||
}
|
||||
|
||||
class PasteAsMenu extends StatefulWidget {
|
||||
const PasteAsMenu({
|
||||
super.key,
|
||||
required this.onSelect,
|
||||
required this.onDismiss,
|
||||
required this.editorState,
|
||||
});
|
||||
final ValueChanged<PasteMenuType?> onSelect;
|
||||
final VoidCallback onDismiss;
|
||||
final EditorState editorState;
|
||||
|
||||
@override
|
||||
State<PasteAsMenu> createState() => _PasteAsMenuState();
|
||||
}
|
||||
|
||||
class _PasteAsMenuState extends State<PasteAsMenu> {
|
||||
final focusNode = FocusNode(debugLabel: 'paste_as_menu');
|
||||
final ValueNotifier<int> selectedIndexNotifier = ValueNotifier(0);
|
||||
|
||||
EditorState get editorState => widget.editorState;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback(
|
||||
(_) => focusNode.requestFocus(),
|
||||
);
|
||||
editorState.selectionNotifier.addListener(dismiss);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
focusNode.dispose();
|
||||
selectedIndexNotifier.dispose();
|
||||
editorState.selectionNotifier.removeListener(dismiss);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = AppFlowyTheme.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.surfaceColorScheme.primary,
|
||||
boxShadow: [theme.shadow.medium],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
height: 32,
|
||||
padding: EdgeInsets.all(8),
|
||||
child: FlowyText.semibold(
|
||||
color: theme.textColorScheme.primary,
|
||||
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]);
|
||||
return KeyEventResult.handled;
|
||||
} else if (event.logicalKey == LogicalKeyboardKey.escape) {
|
||||
dismiss();
|
||||
} else if (event.logicalKey == LogicalKeyboardKey.backspace) {
|
||||
dismiss();
|
||||
} else if ([LogicalKeyboardKey.arrowUp, LogicalKeyboardKey.arrowLeft]
|
||||
.contains(event.logicalKey)) {
|
||||
if (index == 0) {
|
||||
index = length - 1;
|
||||
} else {
|
||||
index--;
|
||||
}
|
||||
changeIndex(index);
|
||||
return KeyEventResult.handled;
|
||||
} else if ([LogicalKeyboardKey.arrowDown, LogicalKeyboardKey.arrowRight]
|
||||
.contains(event.logicalKey)) {
|
||||
if (index == length - 1) {
|
||||
index = 0;
|
||||
} else {
|
||||
index++;
|
||||
}
|
||||
changeIndex(index);
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+172
-1
@@ -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);
|
||||
}
|
||||
+5
-9
@@ -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,
|
||||
|
||||
Loaded 100 of 492 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user