Compare commits

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

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

* feat: add convert to menu for link hover menu

* feat: add mention link

* feat: support convert preview to mention

* feat: add embed link preview

* fix: some test erros

* fix: test errors

* fix: some UI issues

* chore: add test for url

* chore: add test for mention

* chore: add test for bookmark

* chore: add test for embed

* chore: remove unuse import

* fix: some UI issues

* fix: remove text span overlay on mobile

* fix: code lint error

---------

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

* feat: add loading dialog

* chore: update translations

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

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

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

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

* chore: type check

* chore: replace usages

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

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

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

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

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

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

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

* test: add test

* test: fix test

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

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

* fix: support toolbar animation

* chore: update appflowy_editor

* fix: update pubspect.lock

* fix: testing error

---------

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

* fix: revamp emoji picker which is created by character

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

* fix: support check link format for link menus

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

* fix: filter link search result with current document id

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

* fix: some issues

* fix: test errors

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

* chore: move local to top in select list

* chore: improve copy of missing model

* chore: remove green background and add progress indicator

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

* chore: use black color for selected model name
2025-03-31 10:24:46 +08:00
Nathan.fooo c87d9ab74f Merge pull request #7652 from AppFlowy-IO/show_not_ready
chore: display message when using ai writer with local ai, but local ai  is disabled
2025-03-30 22:16:05 +08:00
Nathan e3a0806eee chore: clippy 2025-03-30 20:53:27 +08:00
Nathan b63c4dfe21 chore: show local ai disable 2025-03-30 20:12:20 +08:00
Nathan 2277d7d234 chore: show local ai disable 2025-03-30 15:15:59 +08:00
Nathan f76ce2be14 chore: show not ready state when using ai writer with local ai 2025-03-30 12:06:08 +08:00
Nathan.fooo 3c74208ab9 Merge pull request #7650 from AppFlowy-IO/display_plugin_version
chore: show plugin version
2025-03-30 12:05:25 +08:00
Nathan 5ae3f42313 chore: fix windows build 2025-03-30 11:28:29 +08:00
Nathan af5c4bfe76 chore: show plugin version 2025-03-30 10:48:26 +08:00
Nathan.fooo 9c25769d8e Merge pull request #7649 from AppFlowy-IO/revert-7643-chore/improve-local-ai-setting-page
Revert "chore: improve local ai settings page"
2025-03-30 09:54:26 +08:00
Nathan.fooo 12a4bf67f6 Revert "feat: improve local ai settings page UI (#7643)"
This reverts commit 3a879b0186.
2025-03-30 09:54:08 +08:00
Nathan 34a858e948 chore: show plugin version 2025-03-30 09:02:06 +08:00
Richard Shiue 3a879b0186 feat: improve local ai settings page UI (#7643)
* chore: improve local ai settings page

* chore: improve local ai settings page

* chore: simplify state and improve ui
2025-03-29 21:56:05 +08:00
Nathan.fooo dbe72b32b2 Merge pull request #7646 from AppFlowy-IO/show_lai_resource
chore: bump lai
2025-03-29 19:58:08 +08:00
Nathan 917aa60c98 chore: bump lai 2025-03-29 15:32:45 +08:00
Nathan.fooo 84e0f5e6ff Merge pull request #7595 from AppFlowy-IO/mcp_protocol
chore: implement MCP client
2025-03-29 09:10:05 +08:00
Nathan 7b89d76cea chore: clippy 2025-03-28 23:31:00 +08:00
Nathan fbf031b06d chore: bump local ai 2025-03-28 23:00:27 +08:00
Nathan 671e855b0e Merge branch 'main' into mcp_protocol 2025-03-28 16:27:19 +08:00
Nathan 8aa32ca3fa chore: disable mcp 2025-03-28 16:27:16 +08:00
Richard Shiue 07c767c4fa fix: ai writer ux issues (#7640)
* chore: fix local ai font weight

* chore: adjust auto response format copy

* fix: escape shortcut

* chore: more action button icon size
2025-03-28 11:23:29 +08:00
Nathan.fooo 4ce0782a05 Merge pull request #7638 from AppFlowy-IO/ai_model_desc
Ai model desc
2025-03-27 22:07:00 +08:00
Nathan 7456c65799 chore: clippy 2025-03-27 20:54:48 +08:00
Nathan 8cf31b8afc chore: bump client api 2025-03-27 20:51:29 +08:00
Nathan ff70595a15 chore: display ai model desc and fix flashing when select model 2025-03-27 20:03:07 +08:00
Nathan f6f19a0a07 chore: display ai model desc on setting page 2025-03-27 19:17:52 +08:00
Richard Shiue b83b964678 chore: add model description UI 2025-03-27 18:01:45 +08:00
Nathan f574b6b9c2 chore: update default name 2025-03-27 17:10:35 +08:00
Nathan 7ee29dcbc5 chore: return model desc 2025-03-27 17:09:01 +08:00
Nathan d348361889 chore: return desc of ai model 2025-03-27 16:31:18 +08:00
Richard Shiue 07a78b4ad7 chore: adjust default model name (#7634) 2025-03-27 14:26:45 +08:00
Morn a26ebbccc1 fix: toolbar launch review issues (#7631)
* fix: keep the turn into menu within six-dot same as toolbar

* fix: change some icon color within toolbar

* fix: improve toolbar

* chore: update editor dependency

* fix: update editor dependency
2025-03-27 14:19:51 +08:00
Nathan ccb020e885 chore: bump lai 2025-03-27 14:19:27 +08:00
Lucas 76cb23e233 feat: add bloc observer (#7633)
* chore: bump version 0.8.8

* feat: add bloc observer

* chore: update comment

* chore: update comment
2025-03-27 14:17:47 +08:00
Morn 4686e13390 feat: add animation for floating toolbar (#7623) 2025-03-27 13:28:35 +08:00
Nathan.fooo 584f762e11 Merge pull request #7617 from richardshiue/chore/improve-model-selection-ui
feat: regenerate message with different model
2025-03-27 12:54:26 +08:00
Richard Shiue 8528811992 chore: remove ai model class 2025-03-27 12:02:58 +08:00
Richard Shiue 9147f64b65 chore: adjust suggestion action button position (#7632) 2025-03-27 11:10:23 +08:00
Richard Shiue f7f2e71ee1 chore: adjust popover shadows (#7630) 2025-03-27 10:05:26 +08:00
Richard Shiue f9e1dcca6c chore: code nit 2025-03-27 00:15:48 +08:00
Richard Shiue e11388491f chore: emit ready state faster 2025-03-27 00:15:48 +08:00
Richard Shiue eb0cff36c9 chore: improve ai model selection ui 2025-03-27 00:15:48 +08:00
Nathan ac8141ab15 Merge branch 'main' into mcp_protocol 2025-03-26 16:18:46 +08:00
Richard Shiue b3b13e550d chore: adjust popover shadows (#7626) 2025-03-26 14:29:11 +08:00
Nathan.fooo ba1767e312 Merge pull request #7628 from AppFlowy-IO/local_ai_global_config
chore: implement local ai config
2025-03-26 14:29:02 +08:00
Nathan 10048dadec Merge branch 'main' into local_ai_global_config 2025-03-26 14:21:12 +08:00
Nathan 815bb11cde chore: implement local ai config 2025-03-26 14:19:57 +08:00
Lucas 7372f5583c chore: bump version 0.8.8 (#7627) 2025-03-26 13:56:24 +08:00
LucasandRichard Shiue 9115e208ac fix: numbered list generated by ai should keep the same index as the input (#7622)
Co-authored-by: Richard Shiue <71320345+richardshiue@users.noreply.github.com>
2025-03-26 13:31:32 +08:00
Morn 24bb1b58a0 feat: support use ":" keyword to create emojis (#7582)
* feat: add ability to use : keyword to create emojis(#2797)

* fix: emoji position error

* chore: add integration test

* chore: dismiss emoji picker while starting searching with space
2025-03-26 13:24:16 +08:00
Ametero cfca70ae14 chore: update translations with Fink 🐦 (#7584) 2025-03-26 13:23:25 +08:00
Morn 1db6da7024 fix: undo not working for toggle heading (#7598) 2025-03-26 13:22:47 +08:00
Richard Shiue c212c568e9 chore: bump editor ref (#7621) 2025-03-26 11:00:41 +08:00
Richard Shiue 1d437fb81e chore: adjust ai writer popover content area (#7618) 2025-03-25 23:44:00 +08:00
Richard Shiue 5a0478ad56 fix(mobile): settings page crashes (#7616) 2025-03-25 23:00:28 +08:00
Richard Shiue 039c191d1f fix: ai text insertion (#7615)
* fix: dont scroll to ai writer node if path not found

* chore: rename text robot clear method and add reset

* fix: insert position is off if using ai writer multiple times

* chore: reorganize code

* fix: undo not working after accept
2025-03-25 22:41:35 +08:00
Richard Shiue eb4b015de8 chore: bump editor plugin ref (#7610) 2025-03-25 20:37:15 +08:00
Khor Shu Heng 5269bfbf8e Merge pull request #7609 from ixicut/fix-ukrainian-translation-7603
fix: correct translation inconsistencies
2025-03-25 18:05:30 +08:00
ixicut ed44c20281 fix: correct translation inconsistencies 2025-03-25 11:19:11 +02:00
Nathan.fooo 878323a299 Merge pull request #7606 from AppFlowy-IO/fix_exceptions
chore: fix incorrect local ai state
2025-03-25 13:59:49 +08:00
Nathan 0dc2363962 chore: fix incorrect local ai state 2025-03-25 13:05:18 +08:00
Richard Shiue 72d660f1ac fix: number cell update flashes with old content (#7605) 2025-03-25 11:07:46 +08:00
Nathan.fooo 46532a861f Merge pull request #7602 from AppFlowy-IO/remove_default_model
chore: remove default model name
2025-03-24 22:37:23 +08:00
Nathan 4c39908748 chore: separate model 2025-03-24 22:21:44 +08:00
Nathan 35081fd311 chore: remove default model name 2025-03-24 21:59:42 +08:00
Richard Shiue 7463e4e3eb fix: pass response format in ai writer (#7599) 2025-03-24 16:50:16 +08:00
Nathan 66ce786726 chore: add client 2025-03-24 16:38:37 +08:00
Richard Shiue 682a50da53 chore: replace ai response every time (#7597) 2025-03-24 14:15:20 +08:00
Nathan d372abd5a1 Merge branch 'main' into mcp_protocol 2025-03-24 13:56:56 +08:00
Richard Shiue dfb5a6629f chore: pass ai writer context (#7596)
* chore: pass ai writer context

* chore: maintain selection after starting ai writer

* chore: improve ui of additional comments

* chore: revert podfile.lock changes

* chore: code readability

* chore: revert podfile.lock changes

* fix: accept shouldn't try to unformat
2025-03-24 13:27:31 +08:00
Nathan.fooo bb72f7e70a Merge pull request #7590 from AppFlowy-IO/support_switch_model
chore: support switch ai model in chat/ AI writer
2025-03-24 12:40:43 +08:00
Nathan 37085042f8 chore: clippy 2025-03-24 12:40:29 +08:00
Nathan 2cbcb320fe chore: add timeout 2025-03-24 12:11:12 +08:00
Nathan 949556e2fa chore: remove tauri feature 2025-03-24 12:03:42 +08:00
Nathan 08d1d3602e chore: implement MCP client 2025-03-24 10:44:20 +08:00
Morn 910c45e457 chore: change some mobile slash menu icons (#7579) 2025-03-24 09:55:58 +08:00
Morn 6f031d0c7e feat: revamp toolbar link (#7578)
* feat: revamp toolbar link

* fix: some review issues

* chore: add integration test for toolbar link
2025-03-24 09:55:23 +08:00
Morn 44c9d572c8 fix: using date picker with @ menu with some errors (#7580) 2025-03-24 09:54:45 +08:00
Nathan 05949d2f87 chore: support switch ai model in chat or ai writer 2025-03-23 21:53:05 +08:00
Nathan.fooo ad695e43b9 Merge pull request #7575 from AppFlowy-IO/completion_stream_v2
chore: completion stream v2
2025-03-21 11:47:28 +08:00
Nathan 87015f7133 chore: upgrade lai commit 2025-03-21 11:47:05 +08:00
Richard Shiue deb019aa4a chore: don't allow double register two ai nodes 2025-03-21 11:14:49 +08:00
Richard Shiue c79d014305 chore: revert podfile changes and update error 2025-03-21 10:49:41 +08:00
Richard Shiue 182101023b chore: remove actions in explanation when not explain 2025-03-21 10:37:10 +08:00
Richard Shiue f1b2f51a06 chore: fix test 2025-03-21 10:20:47 +08:00
Nathan a5c0ad5998 chore: fix test 2025-03-21 10:07:27 +08:00
Nathan 6fd250d4d1 chore: update client api 2025-03-21 10:04:51 +08:00
Nathan db2270c8d8 chore: update local ai commit 2025-03-20 15:13:25 +08:00
Richard Shiue 10f19069c6 chore: implement ui 2025-03-20 13:30:42 +08:00
Richard Shiue 13a7ea07a8 chore: merge remote-tracking branch 'upstream/main' into this one 2025-03-20 13:29:03 +08:00
Richard Shiue aacd795ae0 chore: ai writer more actions (#7576)
* feat: add more button to ai writer input box

* chore: adjust button padding

* chore: adjust icon color
2025-03-20 12:33:51 +08:00
Richard Shiue 566e7b2f40 feat: allow user scroll during generation (#7559)
* chore: add keep alive to ai writer block component

* chore: allow user scrolling during ai writer generation

* chore: pull ai writer cubit upwards

* test: fix unit tests

* chore: clear selection
2025-03-20 11:50:25 +08:00
Nathan f413b9e070 chore: callback 2025-03-20 11:44:02 +08:00
Nathan 6e4206a8e2 chore: completion stream v2 2025-03-20 11:41:49 +08:00
548 changed files with 30254 additions and 6762 deletions

No files matched your search

+11
View File
@@ -1,4 +1,15 @@
# Release Notes
## Version 0.8.8 - 01/04/2025
### New Features
- Added support for selecting AI models in AI writer
- Revamped link menu in toolbar
- Added support for using ":" to add emojis in documents
- Passed the history of past AI prompts and responses to AI writer
### Bug Fixes
- Improved AI writer scrolling user experience
- Fixed issue where checklist items would disappear during reordering
- Fixed numbered lists generated by AI to maintain the same index as the input
## Version 0.8.7 - 18/03/2025
### New Features
- Made local AI free and integrated with Ollama
+124 -124
View File
@@ -1,125 +1,125 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
// This task only builds the Dart code of AppFlowy.
// It supports both the desktop and mobile version.
"name": "AF: Build Dart Only",
"request": "launch",
"program": "./lib/main.dart",
"type": "dart",
"env": {
"RUST_LOG": "debug",
},
// uncomment the following line to testing performance.
// "flutterMode": "profile",
"cwd": "${workspaceRoot}/appflowy_flutter"
},
{
// This task builds the Rust and Dart code of AppFlowy.
"name": "AF-desktop: Build All",
"request": "launch",
"program": "./lib/main.dart",
"type": "dart",
"preLaunchTask": "AF: Build Appflowy Core",
"env": {
"RUST_LOG": "trace",
"RUST_BACKTRACE": "1"
},
"cwd": "${workspaceRoot}/appflowy_flutter"
},
{
// This task builds will:
// - call the clean task,
// - rebuild all the generated Files (including freeze and language files)
// - rebuild the the Rust and Dart code of AppFlowy.
"name": "AF-desktop: Clean + Rebuild All",
"request": "launch",
"program": "./lib/main.dart",
"type": "dart",
"preLaunchTask": "AF: Clean + Rebuild All",
"env": {
"RUST_LOG": "trace"
},
"cwd": "${workspaceRoot}/appflowy_flutter"
},
{
"name": "AF-iOS: Build All",
"request": "launch",
"program": "./lib/main.dart",
"type": "dart",
"preLaunchTask": "AF: Build Appflowy Core For iOS",
"env": {
"RUST_LOG": "trace"
},
"cwd": "${workspaceRoot}/appflowy_flutter"
},
{
"name": "AF-iOS: Clean + Rebuild All",
"request": "launch",
"program": "./lib/main.dart",
"type": "dart",
"preLaunchTask": "AF: Clean + Rebuild All (iOS)",
"env": {
"RUST_LOG": "trace"
},
"cwd": "${workspaceRoot}/appflowy_flutter"
},
{
"name": "AF-iOS-Simulator: Build All",
"request": "launch",
"program": "./lib/main.dart",
"type": "dart",
"preLaunchTask": "AF: Build Appflowy Core For iOS Simulator",
"env": {
"RUST_LOG": "trace"
},
"cwd": "${workspaceRoot}/appflowy_flutter"
},
{
"name": "AF-iOS-Simulator: Clean + Rebuild All",
"request": "launch",
"program": "./lib/main.dart",
"type": "dart",
"preLaunchTask": "AF: Clean + Rebuild All (iOS Simulator)",
"env": {
"RUST_LOG": "trace"
},
"cwd": "${workspaceRoot}/appflowy_flutter"
},
{
"name": "AF-Android: Build All",
"request": "launch",
"program": "./lib/main.dart",
"type": "dart",
"preLaunchTask": "AF: Build Appflowy Core For Android",
"env": {
"RUST_LOG": "trace"
},
"cwd": "${workspaceRoot}/appflowy_flutter"
},
{
"name": "AF-Android: Clean + Rebuild All",
"request": "launch",
"program": "./lib/main.dart",
"type": "dart",
"preLaunchTask": "AF: Clean + Rebuild All (Android)",
"env": {
"RUST_LOG": "trace"
},
"cwd": "${workspaceRoot}/appflowy_flutter"
},
{
"name": "AF-desktop: Debug Rust",
"type": "lldb",
"request": "attach",
"pid": "${command:pickMyProcess}"
// To launch the application directly, use the following configuration:
// "request": "launch",
// "program": "[YOUR_APPLICATION_PATH]",
},
]
}
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
// This task only builds the Dart code of AppFlowy.
// It supports both the desktop and mobile version.
"name": "AF: Build Dart Only",
"request": "launch",
"program": "./lib/main.dart",
"type": "dart",
"env": {
"RUST_LOG": "trace",
},
// uncomment the following line to testing performance.
// "flutterMode": "profile",
"cwd": "${workspaceRoot}/appflowy_flutter"
},
{
// This task builds the Rust and Dart code of AppFlowy.
"name": "AF-desktop: Build All",
"request": "launch",
"program": "./lib/main.dart",
"type": "dart",
"preLaunchTask": "AF: Build Appflowy Core",
"env": {
"RUST_LOG": "trace",
"RUST_BACKTRACE": "1"
},
"cwd": "${workspaceRoot}/appflowy_flutter"
},
{
// This task builds will:
// - call the clean task,
// - rebuild all the generated Files (including freeze and language files)
// - rebuild the the Rust and Dart code of AppFlowy.
"name": "AF-desktop: Clean + Rebuild All",
"request": "launch",
"program": "./lib/main.dart",
"type": "dart",
"preLaunchTask": "AF: Clean + Rebuild All",
"env": {
"RUST_LOG": "trace"
},
"cwd": "${workspaceRoot}/appflowy_flutter"
},
{
"name": "AF-iOS: Build All",
"request": "launch",
"program": "./lib/main.dart",
"type": "dart",
"preLaunchTask": "AF: Build Appflowy Core For iOS",
"env": {
"RUST_LOG": "trace"
},
"cwd": "${workspaceRoot}/appflowy_flutter"
},
{
"name": "AF-iOS: Clean + Rebuild All",
"request": "launch",
"program": "./lib/main.dart",
"type": "dart",
"preLaunchTask": "AF: Clean + Rebuild All (iOS)",
"env": {
"RUST_LOG": "trace"
},
"cwd": "${workspaceRoot}/appflowy_flutter"
},
{
"name": "AF-iOS-Simulator: Build All",
"request": "launch",
"program": "./lib/main.dart",
"type": "dart",
"preLaunchTask": "AF: Build Appflowy Core For iOS Simulator",
"env": {
"RUST_LOG": "trace"
},
"cwd": "${workspaceRoot}/appflowy_flutter"
},
{
"name": "AF-iOS-Simulator: Clean + Rebuild All",
"request": "launch",
"program": "./lib/main.dart",
"type": "dart",
"preLaunchTask": "AF: Clean + Rebuild All (iOS Simulator)",
"env": {
"RUST_LOG": "trace"
},
"cwd": "${workspaceRoot}/appflowy_flutter"
},
{
"name": "AF-Android: Build All",
"request": "launch",
"program": "./lib/main.dart",
"type": "dart",
"preLaunchTask": "AF: Build Appflowy Core For Android",
"env": {
"RUST_LOG": "trace"
},
"cwd": "${workspaceRoot}/appflowy_flutter"
},
{
"name": "AF-Android: Clean + Rebuild All",
"request": "launch",
"program": "./lib/main.dart",
"type": "dart",
"preLaunchTask": "AF: Clean + Rebuild All (Android)",
"env": {
"RUST_LOG": "trace"
},
"cwd": "${workspaceRoot}/appflowy_flutter"
},
{
"name": "AF-desktop: Debug Rust",
"type": "lldb",
"request": "attach",
"pid": "${command:pickMyProcess}"
// To launch the application directly, use the following configuration:
// "request": "launch",
// "program": "[YOUR_APPLICATION_PATH]",
},
]
}
+1 -1
View File
@@ -26,7 +26,7 @@ CARGO_MAKE_EXTEND_WORKSPACE_MAKEFILE = true
CARGO_MAKE_CRATE_FS_NAME = "dart_ffi"
CARGO_MAKE_CRATE_NAME = "dart-ffi"
LIB_NAME = "dart_ffi"
APPFLOWY_VERSION = "0.8.7"
APPFLOWY_VERSION = "0.8.9"
FLUTTER_DESKTOP_FEATURES = "dart"
PRODUCT_NAME = "AppFlowy"
MACOSX_DEPLOYMENT_TARGET = "11.0"
@@ -4,6 +4,7 @@ analyzer:
exclude:
- "**/*.g.dart"
- "**/*.freezed.dart"
- "packages/**/*.dart"
linter:
rules:
File diff suppressed because it is too large. Load diff
@@ -15,7 +15,6 @@ void main() {
cloudType: AuthenticatorType.appflowyCloudSelfHost,
);
await tester.tapContinousAnotherWay();
await tester.tapAnonymousSignInButton();
await tester.expectToSeeHomePageWithGetStartedPage();
@@ -15,6 +15,7 @@ void main() {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
// create a database and add a linked database view
await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid);
await tester.tapCreateLinkedDatabaseViewButton(DatabaseLayoutPB.Grid);
@@ -29,6 +30,11 @@ void main() {
await tester.tapHidePropertyButton();
tester.noFieldWithName('New field 1');
// create another field, New field 1 to be hidden still
await tester.tapNewPropertyButton();
await tester.dismissFieldEditor();
tester.noFieldWithName('New field 1');
// go back to inline database view, expect field to be shown
await tester.tapTabBarLinkedViewByViewName('Untitled');
tester.findFieldWithName('New field 1');
@@ -60,5 +66,40 @@ void main() {
await tester.tapDatabaseSortButton();
await tester.tapCreateSortByFieldType(FieldType.RichText, "New field 1");
});
testWidgets('field cell width', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
// create a database and add a linked database view
await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid);
await tester.tapCreateLinkedDatabaseViewButton(DatabaseLayoutPB.Grid);
// create a field
await tester.scrollToRight(find.byType(GridPage));
await tester.tapNewPropertyButton();
await tester.renameField('New field 1');
await tester.dismissFieldEditor();
// check the width of the field
expect(tester.getFieldWidth('New field 1'), 150);
// change the width of the field
await tester.changeFieldWidth('New field 1', 200);
expect(tester.getFieldWidth('New field 1'), 205);
// create another field, New field 1 to be same width
await tester.tapNewPropertyButton();
await tester.dismissFieldEditor();
expect(tester.getFieldWidth('New field 1'), 205);
// go back to inline database view, expect New field 1 to be 150px
await tester.tapTabBarLinkedViewByViewName('Untitled');
expect(tester.getFieldWidth('New field 1'), 150);
// go back to linked database view, expect New field 1 to be 205px
await tester.tapTabBarLinkedViewByViewName('Grid');
expect(tester.getFieldWidth('New field 1'), 205);
});
});
}
@@ -1,10 +1,12 @@
import 'dart:async';
import 'dart:io';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/block_menu/block_menu_button.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/custom_image_block_component/custom_image_block_component.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/custom_link_preview.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/link_preview_menu.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/paste_as/paste_as_menu.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart';
@@ -320,8 +322,14 @@ void main() {
(tester) async {
const url = 'https://appflowy.io';
await tester.pasteContent(plainText: url, (editorState) async {
final pasteAsMenu = find.byType(PasteAsMenu);
expect(pasteAsMenu, findsOneWidget);
final bookmarkButton = find.text(
LocaleKeys.document_plugins_linkPreview_typeSelection_bookmark.tr(),
);
await tester.tapButton(bookmarkButton);
// the second one is the paragraph node
expect(editorState.document.root.children.length, 2);
expect(editorState.document.root.children.length, 1);
final node = editorState.getNodeAtPath([0])!;
expect(node.type, LinkPreviewBlockKeys.type);
expect(node.attributes[LinkPreviewBlockKeys.url], url);
@@ -333,19 +341,19 @@ void main() {
await tester.hoverOnWidget(
find.byType(CustomLinkPreviewWidget),
onHover: () async {
final convertToLinkButton = find.byWidgetPredicate((widget) {
return widget is MenuBlockButton &&
widget.tooltip ==
LocaleKeys.document_plugins_urlPreview_convertToLink.tr();
});
/// show menu
final menu = find.byType(CustomLinkPreviewMenu);
expect(menu, findsOneWidget);
await tester.tapButton(menu);
final convertToLinkButton = find.text(
LocaleKeys.document_plugins_linkPreview_linkPreviewMenu_toUrl.tr(),
);
expect(convertToLinkButton, findsOneWidget);
await tester.tap(convertToLinkButton);
await tester.pumpAndSettle();
await tester.tapButton(convertToLinkButton);
},
);
await tester.pumpAndSettle();
final editorState = tester.editor.getCurrentEditorState();
final textNode = editorState.getNodeAtPath([0])!;
expect(textNode.type, ParagraphBlockKeys.type);
@@ -363,14 +371,20 @@ void main() {
(tester) async {
const url = 'https://appflowy.io';
await tester.pasteContent(plainText: url, (editorState) async {
final pasteAsMenu = find.byType(PasteAsMenu);
expect(pasteAsMenu, findsOneWidget);
final bookmarkButton = find.text(
LocaleKeys.document_plugins_linkPreview_typeSelection_bookmark.tr(),
);
await tester.tapButton(bookmarkButton);
// the second one is the paragraph node
expect(editorState.document.root.children.length, 2);
expect(editorState.document.root.children.length, 1);
final node = editorState.getNodeAtPath([0])!;
expect(node.type, LinkPreviewBlockKeys.type);
expect(node.attributes[LinkPreviewBlockKeys.url], url);
});
await tester.editor.tapLineOfEditorAt(0);
await tester.simulateKeyEvent(
LogicalKeyboardKey.keyZ,
isControlPressed:
@@ -521,7 +535,7 @@ void main() {
extension on WidgetTester {
Future<void> pasteContent(
void Function(EditorState editorState) test, {
FutureOr<void> Function(EditorState editorState) test, {
Future<void> Function(EditorState editorState)? beforeTest,
String? plainText,
String? html,
@@ -558,6 +572,6 @@ extension on WidgetTester {
);
await pumpAndSettle(const Duration(milliseconds: 1000));
test(editor.getCurrentEditorState());
await test(editor.getCurrentEditorState());
}
}
@@ -0,0 +1,452 @@
import 'dart:io';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_hover_menu.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/link_embed/link_embed_block_component.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/custom_link_preview_block_component.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/link_preview_menu.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/paste_as/paste_as_menu.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_block.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_link_block.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_link_error_preview.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_link_preview.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import '../../shared/util.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
const avaliableLink = 'https://appflowy.io/',
unavailableLink = 'www.thereIsNoting.com';
Future<void> preparePage(WidgetTester tester, {String? pageName}) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
await tester.createNewPageWithNameUnderParent(name: pageName);
await tester.editor.tapLineOfEditorAt(0);
}
Future<void> pasteLink(WidgetTester tester, String link) async {
await getIt<ClipboardService>()
.setData(ClipboardServiceData(plainText: link));
/// paste the link
await tester.simulateKeyEvent(
LogicalKeyboardKey.keyV,
isControlPressed: Platform.isLinux || Platform.isWindows,
isMetaPressed: Platform.isMacOS,
);
await tester.pumpAndSettle(Duration(seconds: 1));
}
Future<void> pasteAs(
WidgetTester tester,
String link,
PasteMenuType type, {
Duration waitTime = const Duration(milliseconds: 500),
}) async {
await pasteLink(tester, link);
final convertToMentionButton = find.text(type.title);
await tester.tapButton(convertToMentionButton);
await tester.pumpAndSettle(waitTime);
}
void checkUrl(Node node, String link) {
expect(node.type, ParagraphBlockKeys.type);
expect(node.delta!.toJson(), [
{
'insert': link,
'attributes': {'href': link},
}
]);
}
void checkMention(Node node, String link) {
final delta = node.delta!;
final insert = (delta.first as TextInsert).text;
final attributes = delta.first.attributes;
expect(insert, MentionBlockKeys.mentionChar);
final mention =
attributes?[MentionBlockKeys.mention] as Map<String, dynamic>;
expect(mention[MentionBlockKeys.type], MentionType.externalLink.name);
expect(mention[MentionBlockKeys.url], avaliableLink);
}
void checkBookmark(Node node, String link) {
expect(node.type, LinkPreviewBlockKeys.type);
expect(node.attributes[LinkPreviewBlockKeys.url], link);
}
void checkEmbed(Node node, String link) {
expect(node.type, LinkPreviewBlockKeys.type);
expect(node.attributes[LinkEmbedKeys.previewType], LinkEmbedKeys.embed);
expect(node.attributes[LinkPreviewBlockKeys.url], link);
}
group('Paste as URL', () {
Future<void> pasteAndTurnInto(
WidgetTester tester,
String link,
String title,
) async {
await pasteLink(tester, link);
final convertToLinkButton = find
.text(LocaleKeys.document_plugins_linkPreview_typeSelection_URL.tr());
await tester.tapButton(convertToLinkButton);
/// hover link and turn into mention
await tester.hoverOnWidget(
find.byType(LinkHoverTrigger),
onHover: () async {
final turnintoButton = find.byFlowySvg(FlowySvgs.turninto_m);
await tester.tapButton(turnintoButton);
final convertToButton = find.text(title);
await tester.tapButton(convertToButton);
await tester.pumpAndSettle(Duration(seconds: 1));
},
);
}
testWidgets('paste a link', (tester) async {
final link = avaliableLink;
await preparePage(tester);
await pasteLink(tester, link);
final convertToLinkButton = find
.text(LocaleKeys.document_plugins_linkPreview_typeSelection_URL.tr());
await tester.tapButton(convertToLinkButton);
final node = tester.editor.getNodeAtPath([0]);
checkUrl(node, link);
});
testWidgets('paste a link and turn into mention', (tester) async {
final link = avaliableLink;
await preparePage(tester);
await pasteAndTurnInto(
tester,
link,
LinkConvertMenuCommand.toMention.title,
);
/// check metion values
final node = tester.editor.getNodeAtPath([0]);
checkMention(node, link);
});
testWidgets('paste a link and turn into bookmark', (tester) async {
final link = avaliableLink;
await preparePage(tester);
await pasteAndTurnInto(
tester,
link,
LinkConvertMenuCommand.toBookmark.title,
);
/// check metion values
final node = tester.editor.getNodeAtPath([0]);
checkBookmark(node, link);
});
testWidgets('paste a link and turn into embed', (tester) async {
final link = avaliableLink;
await preparePage(tester);
await pasteAndTurnInto(
tester,
link,
LinkConvertMenuCommand.toEmbed.title,
);
/// check metion values
final node = tester.editor.getNodeAtPath([0]);
checkEmbed(node, link);
});
});
group('Paste as Mention', () {
Future<void> pasteAsMention(WidgetTester tester, String link) =>
pasteAs(tester, link, PasteMenuType.mention);
String getMentionLink(Node node) {
final insert = node.delta?.first as TextInsert?;
final mention = insert?.attributes?[MentionBlockKeys.mention]
as Map<String, dynamic>?;
return mention?[MentionBlockKeys.url] ?? '';
}
Future<void> hoverMentionAndClick(
WidgetTester tester,
String command,
) async {
final mentionLink = find.byType(MentionLinkBlock);
expect(mentionLink, findsOneWidget);
await tester.hoverOnWidget(
mentionLink,
onHover: () async {
final errorPreview = find.byType(MentionLinkErrorPreview);
expect(errorPreview, findsOneWidget);
final convertButton = find.byFlowySvg(FlowySvgs.turninto_m);
await tester.tapButton(convertButton);
final menuButton = find.text(command);
await tester.tapButton(menuButton);
},
);
}
testWidgets('paste a link as mention', (tester) async {
final link = avaliableLink;
await preparePage(tester);
await pasteAsMention(tester, link);
final node = tester.editor.getNodeAtPath([0]);
checkMention(node, link);
});
testWidgets('paste as mention and copy link', (tester) async {
final link = avaliableLink;
await preparePage(tester);
await pasteAsMention(tester, link);
final mentionLink = find.byType(MentionLinkBlock);
expect(mentionLink, findsOneWidget);
await tester.hoverOnWidget(
mentionLink,
onHover: () async {
final preview = find.byType(MentionLinkPreview);
if (!preview.hasFound) {
final copyButton = find.byFlowySvg(FlowySvgs.toolbar_link_m);
await tester.tapButton(copyButton);
} else {
final moreOptionButton = find.byFlowySvg(FlowySvgs.toolbar_more_m);
await tester.tapButton(moreOptionButton);
final copyButton =
find.text(MentionLinktMenuCommand.copyLink.title);
await tester.tapButton(copyButton);
}
},
);
final clipboardContent = await getIt<ClipboardService>().getData();
expect(clipboardContent.plainText, link);
});
testWidgets('paste as error mention and turninto url', (tester) async {
String link = unavailableLink;
await preparePage(tester);
await pasteAsMention(tester, link);
Node node = tester.editor.getNodeAtPath([0]);
link = getMentionLink(node);
await hoverMentionAndClick(
tester,
MentionLinktErrorMenuCommand.toURL.title,
);
node = tester.editor.getNodeAtPath([0]);
checkUrl(node, link);
});
testWidgets('paste as error mention and turninto embed', (tester) async {
String link = unavailableLink;
await preparePage(tester);
await pasteAsMention(tester, link);
Node node = tester.editor.getNodeAtPath([0]);
link = getMentionLink(node);
await hoverMentionAndClick(
tester,
MentionLinktErrorMenuCommand.toEmbed.title,
);
node = tester.editor.getNodeAtPath([0]);
checkEmbed(node, link);
});
testWidgets('paste as error mention and turninto bookmark', (tester) async {
String link = unavailableLink;
await preparePage(tester);
await pasteAsMention(tester, link);
Node node = tester.editor.getNodeAtPath([0]);
link = getMentionLink(node);
await hoverMentionAndClick(
tester,
MentionLinktErrorMenuCommand.toBookmark.title,
);
node = tester.editor.getNodeAtPath([0]);
checkBookmark(node, link);
});
testWidgets('paste as error mention and remove link', (tester) async {
String link = unavailableLink;
await preparePage(tester);
await pasteAsMention(tester, link);
Node node = tester.editor.getNodeAtPath([0]);
link = getMentionLink(node);
await hoverMentionAndClick(
tester,
MentionLinktErrorMenuCommand.removeLink.title,
);
node = tester.editor.getNodeAtPath([0]);
expect(node.type, ParagraphBlockKeys.type);
expect(node.delta!.toJson(), [
{'insert': link},
]);
});
});
group('Paste as Bookmark', () {
Future<void> pasteAsBookmark(WidgetTester tester, String link) =>
pasteAs(tester, link, PasteMenuType.bookmark);
Future<void> hoverAndClick(
WidgetTester tester,
LinkPreviewMenuCommand command,
) async {
final bookmark = find.byType(CustomLinkPreviewBlockComponent);
expect(bookmark, findsOneWidget);
await tester.hoverOnWidget(
bookmark,
onHover: () async {
final menuButton = find.byFlowySvg(FlowySvgs.toolbar_more_m);
await tester.tapButton(menuButton);
final commandButton = find.text(command.title);
await tester.tapButton(commandButton);
},
);
}
testWidgets('paste a link as bookmark', (tester) async {
final link = avaliableLink;
await preparePage(tester);
await pasteAsBookmark(tester, link);
final node = tester.editor.getNodeAtPath([0]);
checkBookmark(node, link);
});
testWidgets('paste a link as bookmark and convert to mention',
(tester) async {
final link = avaliableLink;
await preparePage(tester);
await pasteAsBookmark(tester, link);
await hoverAndClick(tester, LinkPreviewMenuCommand.convertToMention);
final node = tester.editor.getNodeAtPath([0]);
checkMention(node, link);
});
testWidgets('paste a link as bookmark and convert to url', (tester) async {
final link = avaliableLink;
await preparePage(tester);
await pasteAsBookmark(tester, link);
await hoverAndClick(tester, LinkPreviewMenuCommand.convertToUrl);
final node = tester.editor.getNodeAtPath([0]);
checkUrl(node, link);
});
testWidgets('paste a link as bookmark and convert to embed',
(tester) async {
final link = avaliableLink;
await preparePage(tester);
await pasteAsBookmark(tester, link);
await hoverAndClick(tester, LinkPreviewMenuCommand.convertToEmbed);
final node = tester.editor.getNodeAtPath([0]);
checkEmbed(node, link);
});
testWidgets('paste a link as bookmark and copy link', (tester) async {
final link = avaliableLink;
await preparePage(tester);
await pasteAsBookmark(tester, link);
await hoverAndClick(tester, LinkPreviewMenuCommand.copyLink);
final clipboardContent = await getIt<ClipboardService>().getData();
expect(clipboardContent.plainText, link);
});
testWidgets('paste a link as bookmark and replace link', (tester) async {
final link = avaliableLink;
await preparePage(tester);
await pasteAsBookmark(tester, link);
await hoverAndClick(tester, LinkPreviewMenuCommand.replace);
await tester.simulateKeyEvent(
LogicalKeyboardKey.keyA,
isControlPressed: Platform.isLinux || Platform.isWindows,
isMetaPressed: Platform.isMacOS,
);
await tester.simulateKeyEvent(LogicalKeyboardKey.delete);
await tester.enterText(find.byType(TextFormField), unavailableLink);
await tester.tapButton(find.text(LocaleKeys.button_replace.tr()));
final node = tester.editor.getNodeAtPath([0]);
checkBookmark(node, unavailableLink);
});
testWidgets('paste a link as bookmark and remove link', (tester) async {
final link = avaliableLink;
await preparePage(tester);
await pasteAsBookmark(tester, link);
await hoverAndClick(tester, LinkPreviewMenuCommand.removeLink);
final node = tester.editor.getNodeAtPath([0]);
expect(node.type, ParagraphBlockKeys.type);
expect(node.delta!.toJson(), [
{'insert': link},
]);
});
});
group('Paste as Embed', () {
Future<void> pasteAsEmbed(WidgetTester tester, String link) =>
pasteAs(tester, link, PasteMenuType.embed);
Future<void> hoverAndConvert(
WidgetTester tester,
PasteMenuType command,
) async {
final embed = find.byType(LinkEmbedBlockComponent);
expect(embed, findsOneWidget);
await tester.hoverOnWidget(
embed,
onHover: () async {
final menuButton = find.byFlowySvg(FlowySvgs.turninto_m);
await tester.tapButton(menuButton);
final commandButton = find.text(command.title);
await tester.tapButton(commandButton);
},
);
}
testWidgets('paste a link as embed', (tester) async {
final link = avaliableLink;
await preparePage(tester);
await pasteAsEmbed(tester, link);
final node = tester.editor.getNodeAtPath([0]);
checkEmbed(node, link);
});
testWidgets('paste a link as bookmark and convert to mention',
(tester) async {
final link = avaliableLink;
await preparePage(tester);
await pasteAsEmbed(tester, link);
await hoverAndConvert(tester, PasteMenuType.mention);
final node = tester.editor.getNodeAtPath([0]);
checkMention(node, link);
});
testWidgets('paste a link as bookmark and convert to url', (tester) async {
final link = avaliableLink;
await preparePage(tester);
await pasteAsEmbed(tester, link);
await hoverAndConvert(tester, PasteMenuType.url);
final node = tester.editor.getNodeAtPath([0]);
checkUrl(node, link);
});
testWidgets('paste a link as bookmark and convert to bookmark',
(tester) async {
final link = avaliableLink;
await preparePage(tester);
await pasteAsEmbed(tester, link);
await hoverAndConvert(tester, PasteMenuType.bookmark);
final node = tester.editor.getNodeAtPath([0]);
checkBookmark(node, link);
});
});
}
@@ -1,5 +1,6 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
@@ -47,5 +48,41 @@ void main() {
expect(editorState.selection!.start.offset, 0);
});
testWidgets('select and delete text', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
/// create a new document
await tester.createNewPageWithNameUnderParent();
/// input text
final editor = tester.editor;
final editorState = editor.getCurrentEditorState();
const inputText = 'Test for text selection and deletion';
final texts = inputText.split(' ');
await editor.tapLineOfEditorAt(0);
await tester.ime.insertText(inputText);
/// selecte and delete
int index = 0;
while (texts.isNotEmpty) {
final text = texts.removeAt(0);
await tester.editor.updateSelection(
Selection(
start: Position(path: [0], offset: index),
end: Position(path: [0], offset: index + text.length),
),
);
await tester.simulateKeyEvent(LogicalKeyboardKey.delete);
index++;
}
/// excpete the text value is correct
final node = editorState.getNodeAtPath([0])!;
final nodeText = node.delta?.toPlainText() ?? '';
expect(nodeText, ' ' * (index - 1));
});
});
}
@@ -13,6 +13,7 @@ import 'document_with_multi_image_block_test.dart'
as document_with_multi_image_block_test;
import 'document_with_simple_table_test.dart'
as document_with_simple_table_test;
import 'document_link_preview_test.dart' as document_link_preview_test;
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
@@ -28,4 +29,5 @@ void main() {
document_find_menu_test.main();
document_toolbar_test.main();
document_with_simple_table_test.main();
document_link_preview_test.main();
}
@@ -1,10 +1,19 @@
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/desktop_floating_toolbar.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_create_menu.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_edit_menu.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_hover_menu.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/toolbar_item/custom_link_toolbar_item.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/toolbar_item/more_option_toolbar_item.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/toolbar_item/text_suggestions_toolbar_item.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
@@ -175,5 +184,187 @@ void main() {
3,
);
});
testWidgets('toolbar will not rebuild after click item', (tester) async {
const text = 'Test rebuilding';
await prepareForToolbar(tester, text);
Finder toolbar = find.byType(DesktopFloatingToolbar);
Element toolbarElement = toolbar.evaluate().first;
final elementHashcode = toolbarElement.hashCode;
final boldButton = find.byFlowySvg(FlowySvgs.toolbar_bold_m),
underlineButton = find.byFlowySvg(FlowySvgs.toolbar_underline_m),
italicButton = find.byFlowySvg(FlowySvgs.toolbar_inline_italic_m);
/// tap format buttons
await tester.tapButton(boldButton);
await tester.tapButton(underlineButton);
await tester.tapButton(italicButton);
toolbar = find.byType(DesktopFloatingToolbar);
toolbarElement = toolbar.evaluate().first;
/// check if the toolbar is not rebuilt
expect(elementHashcode, toolbarElement.hashCode);
final editorState = tester.editor.getCurrentEditorState();
/// check text formats
expect(
editorState
.getDeltaAttributeValueInSelection(AppFlowyRichTextKeys.bold),
true,
);
expect(
editorState
.getDeltaAttributeValueInSelection(AppFlowyRichTextKeys.italic),
true,
);
expect(
editorState
.getDeltaAttributeValueInSelection(AppFlowyRichTextKeys.underline),
true,
);
});
});
group('document toolbar: link', () {
String? getLinkFromNode(Node node) {
for (final insert in node.delta!) {
final link = insert.attributes?.href;
if (link != null) return link;
}
return null;
}
bool isPageLink(Node node) {
for (final insert in node.delta!) {
final isPage = insert.attributes?.isPage;
if (isPage == true) return true;
}
return false;
}
String getNodeText(Node node) {
for (final insert in node.delta!) {
if (insert is TextInsert) return insert.text;
}
return '';
}
testWidgets('insert link and remove link', (tester) async {
const text = 'insert link', link = 'https://test.appflowy.cloud';
await prepareForToolbar(tester, text);
final toolbar = find.byType(DesktopFloatingToolbar);
expect(toolbar, findsOneWidget);
/// tap link button to show CreateLinkMenu
final linkButton = find.byFlowySvg(FlowySvgs.toolbar_link_m);
await tester.tapButton(linkButton);
final createLinkMenu = find.byType(LinkCreateMenu);
expect(createLinkMenu, findsOneWidget);
/// test esc to close
await tester.simulateKeyEvent(LogicalKeyboardKey.escape);
expect(toolbar, findsNothing);
/// show toolbar again
await tester.editor.tapLineOfEditorAt(0);
await selectText(tester, text);
await tester.tapButton(linkButton);
/// insert link
final textField = find.descendant(
of: createLinkMenu,
matching: find.byType(TextFormField),
);
await tester.enterText(textField, link);
await tester.pumpAndSettle();
await tester.simulateKeyEvent(LogicalKeyboardKey.enter);
Node node = tester.editor.getNodeAtPath([0]);
expect(getLinkFromNode(node), link);
await tester.simulateKeyEvent(LogicalKeyboardKey.escape);
/// hover link
await tester.hoverOnWidget(find.byType(LinkHoverTrigger));
final hoverMenu = find.byType(LinkHoverMenu);
expect(hoverMenu, findsOneWidget);
/// copy link
final copyButton = find.descendant(
of: hoverMenu,
matching: find.byFlowySvg(FlowySvgs.toolbar_link_m),
);
await tester.tapButton(copyButton);
final clipboardContent = await getIt<ClipboardService>().getData();
final plainText = clipboardContent.plainText;
expect(plainText, link);
/// remove link
await tester.hoverOnWidget(find.byType(LinkHoverTrigger));
await tester.tapButton(find.byFlowySvg(FlowySvgs.toolbar_link_unlink_m));
node = tester.editor.getNodeAtPath([0]);
expect(getLinkFromNode(node), null);
});
testWidgets('insert link and edit link', (tester) async {
const text = 'edit link',
link = 'https://test.appflowy.cloud',
afterText = '$text after';
await prepareForToolbar(tester, text);
/// tap link button to show CreateLinkMenu
final linkButton = find.byFlowySvg(FlowySvgs.toolbar_link_m);
await tester.tapButton(linkButton);
/// search for page and select it
final textField = find.descendant(
of: find.byType(LinkCreateMenu),
matching: find.byType(TextFormField),
);
await tester.enterText(textField, gettingStarted);
await tester.pumpAndSettle();
await tester.simulateKeyEvent(LogicalKeyboardKey.enter);
await tester.simulateKeyEvent(LogicalKeyboardKey.escape);
Node node = tester.editor.getNodeAtPath([0]);
expect(isPageLink(node), true);
expect(getLinkFromNode(node) == link, false);
/// hover link
await tester.hoverOnWidget(find.byType(LinkHoverTrigger));
/// click edit button to show LinkEditMenu
final editButton = find.byFlowySvg(FlowySvgs.toolbar_link_edit_m);
await tester.tapButton(editButton);
final linkEditMenu = find.byType(LinkEditMenu);
expect(linkEditMenu, findsOneWidget);
/// change the link text
final titleField = find.descendant(
of: linkEditMenu,
matching: find.byType(TextFormField),
);
await tester.enterText(titleField, afterText);
await tester.pumpAndSettle();
await tester.tapButton(
find.descendant(of: linkEditMenu, matching: find.text(gettingStarted)),
);
final linkField = find.ancestor(
of: find.text(LocaleKeys.document_toolbar_linkInputHint.tr()),
matching: find.byType(TextFormField),
);
await tester.enterText(linkField, link);
await tester.pumpAndSettle();
await tester.simulateKeyEvent(LogicalKeyboardKey.enter);
/// apply the change
final applyButton =
find.text(LocaleKeys.settings_appearance_documentSettings_apply.tr());
await tester.tapButton(applyButton);
node = tester.editor.getNodeAtPath([0]);
expect(isPageLink(node), false);
expect(getLinkFromNode(node), link);
expect(getNodeText(node), afterText);
});
});
}
@@ -1,8 +1,10 @@
import 'dart:io';
import 'package:appflowy/plugins/emoji/emoji_handler.dart';
import 'package:appflowy/workspace/presentation/settings/widgets/emoji_picker/emoji_picker.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor/src/editor/editor_component/service/editor.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
@@ -39,4 +41,110 @@ void main() {
expect(find.byType(EmojiSelectionMenu), findsOneWidget);
});
});
group('insert emoji by colon', () {
Future<void> createNewDocumentAndShowEmojiList(
WidgetTester tester, {
String? search,
}) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
await tester.createNewPageWithNameUnderParent();
await tester.editor.tapLineOfEditorAt(0);
await tester.ime.insertText(':${search ?? 'a'}');
await tester.pumpAndSettle(Duration(seconds: 1));
}
testWidgets('insert with click', (tester) async {
await createNewDocumentAndShowEmojiList(tester);
/// emoji list is showing
final emojiHandler = find.byType(EmojiHandler);
expect(emojiHandler, findsOneWidget);
final emojiButtons =
find.descendant(of: emojiHandler, matching: find.byType(FlowyButton));
final firstTextFinder = find.descendant(
of: emojiButtons.first,
matching: find.byType(FlowyText),
);
final emojiText =
(firstTextFinder.evaluate().first.widget as FlowyText).text;
/// click first emoji item
await tester.tapButton(emojiButtons.first);
final firstNode =
tester.editor.getCurrentEditorState().getNodeAtPath([0])!;
/// except the emoji is in document
expect(emojiText.contains(firstNode.delta!.toPlainText()), true);
});
testWidgets('insert with arrow and enter', (tester) async {
await createNewDocumentAndShowEmojiList(tester);
/// emoji list is showing
final emojiHandler = find.byType(EmojiHandler);
expect(emojiHandler, findsOneWidget);
final emojiButtons =
find.descendant(of: emojiHandler, matching: find.byType(FlowyButton));
/// tap arrow down and arrow up
await tester.simulateKeyEvent(LogicalKeyboardKey.arrowUp);
await tester.simulateKeyEvent(LogicalKeyboardKey.arrowDown);
final firstTextFinder = find.descendant(
of: emojiButtons.first,
matching: find.byType(FlowyText),
);
final emojiText =
(firstTextFinder.evaluate().first.widget as FlowyText).text;
/// tap enter
await tester.simulateKeyEvent(LogicalKeyboardKey.enter);
final firstNode =
tester.editor.getCurrentEditorState().getNodeAtPath([0])!;
/// except the emoji is in document
expect(emojiText.contains(firstNode.delta!.toPlainText()), true);
});
testWidgets('insert with searching', (tester) async {
await createNewDocumentAndShowEmojiList(tester, search: 's');
/// search for `smiling eyes`, IME is not working, use keyboard input
final searchText = [
LogicalKeyboardKey.keyM,
LogicalKeyboardKey.keyI,
LogicalKeyboardKey.keyL,
LogicalKeyboardKey.keyI,
LogicalKeyboardKey.keyN,
LogicalKeyboardKey.keyG,
LogicalKeyboardKey.space,
LogicalKeyboardKey.keyE,
LogicalKeyboardKey.keyY,
LogicalKeyboardKey.keyE,
LogicalKeyboardKey.keyS,
];
for (final key in searchText) {
await tester.simulateKeyEvent(key);
}
/// tap enter
await tester.simulateKeyEvent(LogicalKeyboardKey.enter);
final firstNode =
tester.editor.getCurrentEditorState().getNodeAtPath([0])!;
/// except the emoji is in document
expect(firstNode.delta!.toPlainText().contains('😄'), true);
});
testWidgets('start searching with sapce', (tester) async {
await createNewDocumentAndShowEmojiList(tester, search: ' ');
/// emoji list is showing
final emojiHandler = find.byType(EmojiHandler);
expect(emojiHandler, findsNothing);
});
});
}
@@ -13,7 +13,6 @@ void main() {
hotkeys_test.main();
emoji_shortcut_test.main();
hotkeys_test.main();
emoji_shortcut_test.main();
share_markdown_test.main();
import_files_test.main();
zoom_in_out_test.main();
@@ -54,8 +54,6 @@ extension AppFlowyTestBase on WidgetTester {
final rustEnvs = <String, String>{};
if (cloudType != null) {
switch (cloudType) {
case AuthenticatorType.local:
break;
case AuthenticatorType.appflowyCloudSelfHost:
rustEnvs["GOTRUE_ADMIN_EMAIL"] = "admin@example.com";
rustEnvs["GOTRUE_ADMIN_PASSWORD"] = "password";
@@ -71,9 +69,6 @@ extension AppFlowyTestBase on WidgetTester {
() async {
if (cloudType != null) {
switch (cloudType) {
case AuthenticatorType.local:
await useLocalServer();
break;
case AuthenticatorType.appflowyCloudSelfHost:
await useTestSelfHostedAppFlowyCloud();
getIt.unregister<AuthService>();
@@ -942,6 +942,31 @@ extension AppFlowyDatabaseTest on WidgetTester {
await pumpAndSettle(const Duration(milliseconds: 200));
}
Future<void> changeFieldWidth(String fieldName, double width) async {
final field = find.byWidgetPredicate(
(widget) => widget is GridFieldCell && widget.fieldInfo.name == fieldName,
);
await hoverOnWidget(
field,
onHover: () async {
final dragHandle = find.descendant(
of: field,
matching: find.byType(DragToExpandLine),
);
await drag(dragHandle, Offset(width - getSize(field).width, 0));
await pumpAndSettle(const Duration(milliseconds: 200));
},
);
}
double getFieldWidth(String fieldName) {
final field = find.byWidgetPredicate(
(widget) => widget is GridFieldCell && widget.fieldInfo.name == fieldName,
);
return getSize(field).width;
}
Future<void> findDateEditor(dynamic matcher) async {
final finder = find.byType(DateCellEditor);
expect(finder, matcher);
@@ -307,9 +307,11 @@ class EditorOperations {
Future<void> openTurnIntoMenu(Path path) async {
await hoverAndClickOptionMenuButton(path);
await tester.tapButton(
find.findTextInFlowyText(
LocaleKeys.document_plugins_optionAction_turnInto.tr(),
),
find
.findTextInFlowyText(
LocaleKeys.document_plugins_optionAction_turnInto.tr(),
)
.first,
);
await tester.pumpUntilFound(find.byType(TurnIntoOptionMenu));
}
+3
View File
@@ -2,6 +2,8 @@ export 'service/ai_entities.dart';
export 'service/ai_prompt_input_bloc.dart';
export 'service/appflowy_ai_service.dart';
export 'service/error.dart';
export 'service/ai_model_state_notifier.dart';
export 'service/select_model_bloc.dart';
export 'widgets/loading_indicator.dart';
export 'widgets/prompt_input/action_buttons.dart';
export 'widgets/prompt_input/desktop_prompt_text_field.dart';
@@ -13,4 +15,5 @@ export 'widgets/prompt_input/mentioned_page_text_span.dart';
export 'widgets/prompt_input/predefined_format_buttons.dart';
export 'widgets/prompt_input/select_sources_bottom_sheet.dart';
export 'widgets/prompt_input/select_sources_menu.dart';
export 'widgets/prompt_input/select_model_menu.dart';
export 'widgets/prompt_input/send_button.dart';
@@ -4,6 +4,28 @@ import 'package:appflowy_backend/protobuf/flowy-ai/protobuf.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:equatable/equatable.dart';
class AIStreamEventPrefix {
static const data = 'data:';
static const error = 'error:';
static const metadata = 'metadata:';
static const start = 'start:';
static const finish = 'finish:';
static const comment = 'comment:';
static const aiResponseLimit = 'AI_RESPONSE_LIMIT';
static const aiImageResponseLimit = 'AI_IMAGE_RESPONSE_LIMIT';
static const aiMaxRequired = 'AI_MAX_REQUIRED:';
static const localAINotReady = 'LOCAL_AI_NOT_READY';
static const localAIDisabled = 'LOCAL_AI_DISABLED';
}
enum AiType {
cloud,
local;
bool get isCloud => this == cloud;
bool get isLocal => this == local;
}
class PredefinedFormat extends Equatable {
const PredefinedFormat({
required this.imageFormat,
@@ -0,0 +1,181 @@
import 'package:appflowy/ai/ai.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/ai_chat/application/ai_model_switch_listener.dart';
import 'package:appflowy/workspace/application/settings/ai/local_llm_listener.dart';
import 'package:appflowy_backend/dispatch/dispatch.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart';
import 'package:appflowy_result/appflowy_result.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:protobuf/protobuf.dart';
import 'package:universal_platform/universal_platform.dart';
typedef OnModelStateChangedCallback = void Function(AiType, bool, String);
typedef OnAvailableModelsChangedCallback = void Function(
List<AIModelPB>,
AIModelPB?,
);
class AIModelStateNotifier {
AIModelStateNotifier({required this.objectId})
: _localAIListener =
UniversalPlatform.isDesktop ? LocalAIStateListener() : null,
_aiModelSwitchListener = AIModelSwitchListener(objectId: objectId) {
_startListening();
_init();
}
final String objectId;
final LocalAIStateListener? _localAIListener;
final AIModelSwitchListener _aiModelSwitchListener;
LocalAIPB? _localAIState;
AvailableModelsPB? _availableModels;
// callbacks
final List<OnModelStateChangedCallback> _stateChangedCallbacks = [];
final List<OnAvailableModelsChangedCallback>
_availableModelsChangedCallbacks = [];
void _startListening() {
if (UniversalPlatform.isDesktop) {
_localAIListener?.start(
stateCallback: (state) async {
_localAIState = state;
_notifyStateChanged();
if (state.state == RunningStatePB.Running ||
state.state == RunningStatePB.Stopped) {
await _loadAvailableModels();
_notifyAvailableModelsChanged();
}
},
);
}
_aiModelSwitchListener.start(
onUpdateSelectedModel: (model) async {
final updatedModels = _availableModels?.deepCopy()
?..selectedModel = model;
_availableModels = updatedModels;
_notifyAvailableModelsChanged();
if (model.isLocal && UniversalPlatform.isDesktop) {
await _loadLocalAiState();
}
_notifyStateChanged();
},
);
}
void _init() async {
await Future.wait([_loadLocalAiState(), _loadAvailableModels()]);
_notifyStateChanged();
_notifyAvailableModelsChanged();
}
void addListener({
OnModelStateChangedCallback? onStateChanged,
OnAvailableModelsChangedCallback? onAvailableModelsChanged,
}) {
if (onStateChanged != null) {
_stateChangedCallbacks.add(onStateChanged);
}
if (onAvailableModelsChanged != null) {
_availableModelsChangedCallbacks.add(onAvailableModelsChanged);
}
}
void removeListener({
OnModelStateChangedCallback? onStateChanged,
OnAvailableModelsChangedCallback? onAvailableModelsChanged,
}) {
if (onStateChanged != null) {
_stateChangedCallbacks.remove(onStateChanged);
}
if (onAvailableModelsChanged != null) {
_availableModelsChangedCallbacks.remove(onAvailableModelsChanged);
}
}
Future<void> dispose() async {
_stateChangedCallbacks.clear();
_availableModelsChangedCallbacks.clear();
await _localAIListener?.stop();
await _aiModelSwitchListener.stop();
}
(AiType, String, bool) getState() {
if (UniversalPlatform.isMobile) {
return (AiType.cloud, LocaleKeys.chat_inputMessageHint.tr(), true);
}
final availableModels = _availableModels;
final localAiState = _localAIState;
if (availableModels == null) {
return (AiType.cloud, LocaleKeys.chat_inputMessageHint.tr(), true);
}
if (localAiState == null) {
Log.warn("Cannot get local AI state");
return (AiType.cloud, LocaleKeys.chat_inputMessageHint.tr(), true);
}
if (!availableModels.selectedModel.isLocal) {
return (AiType.cloud, LocaleKeys.chat_inputMessageHint.tr(), true);
}
final editable = localAiState.state == RunningStatePB.Running;
final hintText = editable
? LocaleKeys.chat_inputLocalAIMessageHint.tr()
: LocaleKeys.settings_aiPage_keys_localAIInitializing.tr();
return (AiType.local, hintText, editable);
}
(List<AIModelPB>, AIModelPB?) getAvailableModels() {
final availableModels = _availableModels;
if (availableModels == null) {
return ([], null);
}
return (availableModels.models, availableModels.selectedModel);
}
void _notifyAvailableModelsChanged() {
final (models, selectedModel) = getAvailableModels();
for (final callback in _availableModelsChangedCallbacks) {
callback(models, selectedModel);
}
}
void _notifyStateChanged() {
final (type, hintText, isEditable) = getState();
for (final callback in _stateChangedCallbacks) {
callback(type, isEditable, hintText);
}
}
Future<void> _loadAvailableModels() {
final payload = AvailableModelsQueryPB(source: objectId);
return AIEventGetAvailableModels(payload).send().fold(
(models) => _availableModels = models,
(err) => Log.error("Failed to get available models: $err"),
);
}
Future<void> _loadLocalAiState() {
return AIEventGetLocalAIState().send().fold(
(localAIState) => _localAIState = localAIState,
(error) => Log.error("Failed to get local AI state: $error"),
);
}
}
extension AiModelExtension on AIModelPB {
bool get isDefault {
return name == "Auto";
}
String get i18n {
return isDefault ? LocaleKeys.chat_switchModel_autoModel.tr() : name;
}
}
@@ -1,14 +1,8 @@
import 'dart:async';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/ai/service/ai_model_state_notifier.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart';
import 'package:appflowy/workspace/application/settings/ai/local_llm_listener.dart';
import 'package:appflowy_backend/dispatch/dispatch.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
import 'package:appflowy_result/appflowy_result.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
@@ -18,19 +12,20 @@ part 'ai_prompt_input_bloc.freezed.dart';
class AIPromptInputBloc extends Bloc<AIPromptInputEvent, AIPromptInputState> {
AIPromptInputBloc({
required String objectId,
required PredefinedFormat? predefinedFormat,
}) : _listener = LocalAIStateListener(),
}) : aiModelStateNotifier = AIModelStateNotifier(objectId: objectId),
super(AIPromptInputState.initial(predefinedFormat)) {
_dispatch();
_startListening();
_init();
}
final LocalAIStateListener _listener;
final AIModelStateNotifier aiModelStateNotifier;
@override
Future<void> close() async {
await _listener.stop();
await aiModelStateNotifier.dispose();
return super.close();
}
@@ -38,29 +33,10 @@ class AIPromptInputBloc extends Bloc<AIPromptInputEvent, AIPromptInputState> {
on<AIPromptInputEvent>(
(event, emit) {
event.when(
updateAIState: (localAIState) {
final aiType = localAIState.enabled ? AiType.local : AiType.cloud;
// final supportChatWithFile =
// aiType.isLocal && localAIState.state == RunningStatePB.Running;
// If local ai is enabled, user can only send messages when the AI is running
final editable = localAIState.enabled
? localAIState.state == RunningStatePB.Running
: true;
var hintText = aiType.isLocal
? LocaleKeys.chat_inputLocalAIMessageHint.tr()
: LocaleKeys.chat_inputMessageHint.tr();
if (editable == false && aiType.isLocal) {
hintText =
LocaleKeys.settings_aiPage_keys_localAIInitializing.tr();
}
updateAIState: (aiType, editable, hintText) {
emit(
state.copyWith(
aiType: aiType,
supportChatWithFile: false,
localAIState: localAIState,
editable: editable,
hintText: hintText,
),
@@ -128,24 +104,16 @@ class AIPromptInputBloc extends Bloc<AIPromptInputEvent, AIPromptInputState> {
}
void _startListening() {
_listener.start(
stateCallback: (pluginState) {
if (!isClosed) {
add(AIPromptInputEvent.updateAIState(pluginState));
}
aiModelStateNotifier.addListener(
onStateChanged: (aiType, editable, hintText) {
add(AIPromptInputEvent.updateAIState(aiType, editable, hintText));
},
);
}
void _init() {
AIEventGetLocalAIState().send().fold(
(localAIState) {
if (!isClosed) {
add(AIPromptInputEvent.updateAIState(localAIState));
}
},
Log.error,
);
final (aiType, hintText, isEditable) = aiModelStateNotifier.getState();
add(AIPromptInputEvent.updateAIState(aiType, isEditable, hintText));
}
Map<String, dynamic> consumeMetadata() {
@@ -164,8 +132,12 @@ class AIPromptInputBloc extends Bloc<AIPromptInputEvent, AIPromptInputState> {
@freezed
class AIPromptInputEvent with _$AIPromptInputEvent {
const factory AIPromptInputEvent.updateAIState(LocalAIPB localAIState) =
_UpdateAIState;
const factory AIPromptInputEvent.updateAIState(
AiType aiType,
bool editable,
String hintText,
) = _UpdateAIState;
const factory AIPromptInputEvent.toggleShowPredefinedFormat() =
_ToggleShowPredefinedFormat;
const factory AIPromptInputEvent.updatePredefinedFormat(
@@ -188,7 +160,6 @@ class AIPromptInputState with _$AIPromptInputState {
required bool supportChatWithFile,
required bool showPredefinedFormats,
required PredefinedFormat? predefinedFormat,
required LocalAIPB? localAIState,
required List<ChatFile> attachedFiles,
required List<ViewPB> mentionedPages,
required bool editable,
@@ -201,18 +172,9 @@ class AIPromptInputState with _$AIPromptInputState {
supportChatWithFile: false,
showPredefinedFormats: format != null,
predefinedFormat: format,
localAIState: null,
attachedFiles: [],
mentionedPages: [],
editable: true,
hintText: '',
);
}
enum AiType {
cloud,
local;
bool get isCloud => this == cloud;
bool get isLocal => this == local;
}
@@ -15,6 +15,11 @@ import 'package:fixnum/fixnum.dart' as fixnum;
import 'ai_entities.dart';
import 'error.dart';
enum LocalAIStreamingState {
notReady,
disabled,
}
abstract class AIRepository {
Future<void> streamCompletion({
String? objectId,
@@ -24,9 +29,12 @@ abstract class AIRepository {
List<AiWriterRecord> history = const [],
required CompletionTypePB completionType,
required Future<void> Function() onStart,
required Future<void> Function(String text) onProcess,
required Future<void> Function(String text) processMessage,
required Future<void> Function(String text) processAssistMessage,
required Future<void> Function() onEnd,
required void Function(AIError error) onError,
required void Function(LocalAIStreamingState state)
onLocalAIStreamingStateChange,
});
}
@@ -40,15 +48,20 @@ class AppFlowyAIService implements AIRepository {
List<AiWriterRecord> history = const [],
required CompletionTypePB completionType,
required Future<void> Function() onStart,
required Future<void> Function(String text) onProcess,
required Future<void> Function(String text) processMessage,
required Future<void> Function(String text) processAssistMessage,
required Future<void> Function() onEnd,
required void Function(AIError error) onError,
required void Function(LocalAIStreamingState state)
onLocalAIStreamingStateChange,
}) async {
final stream = AppFlowyCompletionStream(
onStart: onStart,
onProcess: onProcess,
processMessage: processMessage,
processAssistMessage: processAssistMessage,
processError: onError,
onLocalAIStreamingStateChange: onLocalAIStreamingStateChange,
onEnd: onEnd,
onError: onError,
);
final records = history.map((record) => record.toPB()).toList();
@@ -79,23 +92,30 @@ class AppFlowyAIService implements AIRepository {
abstract class CompletionStream {
CompletionStream({
required this.onStart,
required this.onProcess,
required this.processMessage,
required this.processAssistMessage,
required this.processError,
required this.onLocalAIStreamingStateChange,
required this.onEnd,
required this.onError,
});
final Future<void> Function() onStart;
final Future<void> Function(String text) onProcess;
final Future<void> Function(String text) processMessage;
final Future<void> Function(String text) processAssistMessage;
final void Function(AIError error) processError;
final void Function(LocalAIStreamingState state)
onLocalAIStreamingStateChange;
final Future<void> Function() onEnd;
final void Function(AIError error) onError;
}
class AppFlowyCompletionStream extends CompletionStream {
AppFlowyCompletionStream({
required super.onStart,
required super.onProcess,
required super.processMessage,
required super.processAssistMessage,
required super.processError,
required super.onEnd,
required super.onError,
required super.onLocalAIStreamingStateChange,
}) {
_startListening();
}
@@ -109,51 +129,7 @@ class AppFlowyCompletionStream extends CompletionStream {
_port.handler = _controller.add;
_subscription = _controller.stream.listen(
(event) async {
if (event == "AI_RESPONSE_LIMIT") {
onError(
AIError(
message: LocaleKeys.ai_textLimitReachedDescription.tr(),
code: AIErrorCode.aiResponseLimitExceeded,
),
);
}
if (event == "AI_IMAGE_RESPONSE_LIMIT") {
onError(
AIError(
message: LocaleKeys.ai_imageLimitReachedDescription.tr(),
code: AIErrorCode.aiImageResponseLimitExceeded,
),
);
}
if (event.startsWith("AI_MAX_REQUIRED:")) {
final msg = event.substring(16);
onError(
AIError(
message: msg,
code: AIErrorCode.other,
),
);
}
if (event.startsWith("start:")) {
await onStart();
}
if (event.startsWith("data:")) {
await onProcess(event.substring(5));
}
if (event.startsWith("finish:")) {
await onEnd();
}
if (event.startsWith("error:")) {
onError(
AIError(message: event.substring(6), code: AIErrorCode.other),
);
}
await _handleEvent(event);
},
);
}
@@ -163,4 +139,66 @@ class AppFlowyCompletionStream extends CompletionStream {
await _subscription.cancel();
_port.close();
}
Future<void> _handleEvent(String event) async {
// Check simple matches first
if (event == AIStreamEventPrefix.aiResponseLimit) {
processError(
AIError(
message: LocaleKeys.ai_textLimitReachedDescription.tr(),
code: AIErrorCode.aiResponseLimitExceeded,
),
);
return;
}
if (event == AIStreamEventPrefix.aiImageResponseLimit) {
processError(
AIError(
message: LocaleKeys.ai_imageLimitReachedDescription.tr(),
code: AIErrorCode.aiImageResponseLimitExceeded,
),
);
return;
}
// Otherwise, parse out prefix:content
if (event.startsWith(AIStreamEventPrefix.aiMaxRequired)) {
processError(
AIError(
message: event.substring(AIStreamEventPrefix.aiMaxRequired.length),
code: AIErrorCode.other,
),
);
} else if (event.startsWith(AIStreamEventPrefix.start)) {
await onStart();
} else if (event.startsWith(AIStreamEventPrefix.data)) {
await processMessage(
event.substring(AIStreamEventPrefix.data.length),
);
} else if (event.startsWith(AIStreamEventPrefix.comment)) {
await processAssistMessage(
event.substring(AIStreamEventPrefix.comment.length),
);
} else if (event.startsWith(AIStreamEventPrefix.finish)) {
await onEnd();
} else if (event.startsWith(AIStreamEventPrefix.localAIDisabled)) {
onLocalAIStreamingStateChange(
LocalAIStreamingState.disabled,
);
} else if (event.startsWith(AIStreamEventPrefix.localAINotReady)) {
onLocalAIStreamingStateChange(
LocalAIStreamingState.notReady,
);
} else if (event.startsWith(AIStreamEventPrefix.error)) {
processError(
AIError(
message: event.substring(AIStreamEventPrefix.error.length),
code: AIErrorCode.other,
),
);
} else {
Log.debug('Unknown AI event: $event');
}
}
}
@@ -0,0 +1,92 @@
import 'dart:async';
import 'package:appflowy/ai/service/ai_model_state_notifier.dart';
import 'package:appflowy_backend/dispatch/dispatch.dart';
import 'package:appflowy_backend/protobuf/flowy-ai/entities.pbserver.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'select_model_bloc.freezed.dart';
class SelectModelBloc extends Bloc<SelectModelEvent, SelectModelState> {
SelectModelBloc({
required AIModelStateNotifier aiModelStateNotifier,
}) : _aiModelStateNotifier = aiModelStateNotifier,
super(SelectModelState.initial(aiModelStateNotifier)) {
on<SelectModelEvent>(
(event, emit) {
event.when(
selectModel: (model) {
AIEventUpdateSelectedModel(
UpdateSelectedModelPB(
source: _aiModelStateNotifier.objectId,
selectedModel: model,
),
).send();
emit(state.copyWith(selectedModel: model));
},
didLoadModels: (models, selectedModel) {
emit(
SelectModelState(
models: models,
selectedModel: selectedModel,
),
);
},
);
},
);
_aiModelStateNotifier.addListener(
onAvailableModelsChanged: _onAvailableModelsChanged,
);
}
final AIModelStateNotifier _aiModelStateNotifier;
@override
Future<void> close() async {
_aiModelStateNotifier.removeListener(
onAvailableModelsChanged: _onAvailableModelsChanged,
);
await super.close();
}
void _onAvailableModelsChanged(
List<AIModelPB> models,
AIModelPB? selectedModel,
) {
if (!isClosed) {
add(SelectModelEvent.didLoadModels(models, selectedModel));
}
}
}
@freezed
class SelectModelEvent with _$SelectModelEvent {
const factory SelectModelEvent.selectModel(
AIModelPB model,
) = _SelectModel;
const factory SelectModelEvent.didLoadModels(
List<AIModelPB> models,
AIModelPB? selectedModel,
) = _DidLoadModels;
}
@freezed
class SelectModelState with _$SelectModelState {
const factory SelectModelState({
required List<AIModelPB> models,
required AIModelPB? selectedModel,
}) = _SelectModelState;
factory SelectModelState.initial(AIModelStateNotifier notifier) {
final (models, selectedModel) = notifier.getAvailableModels();
return SelectModelState(
models: models,
selectedModel: selectedModel,
);
}
}
@@ -17,20 +17,26 @@ class DesktopPromptInput extends StatefulWidget {
const DesktopPromptInput({
super.key,
required this.isStreaming,
required this.textController,
required this.onStopStreaming,
required this.onSubmitted,
required this.selectedSourcesNotifier,
required this.onUpdateSelectedSources,
this.hideDecoration = false,
this.hideFormats = false,
this.extraBottomActionButton,
});
final bool isStreaming;
final TextEditingController textController;
final void Function() onStopStreaming;
final void Function(String, PredefinedFormat?, Map<String, dynamic>)
onSubmitted;
final ValueNotifier<List<String>> selectedSourcesNotifier;
final void Function(List<String>) onUpdateSelectedSources;
final bool hideDecoration;
final bool hideFormats;
final Widget? extraBottomActionButton;
@override
State<DesktopPromptInput> createState() => _DesktopPromptInputState();
@@ -42,7 +48,6 @@ class _DesktopPromptInputState extends State<DesktopPromptInput> {
final overlayController = OverlayPortalController();
final inputControlCubit = ChatInputControlCubit();
final focusNode = FocusNode();
final textController = TextEditingController();
late SendButtonState sendButtonState;
bool isComposing = false;
@@ -51,17 +56,19 @@ class _DesktopPromptInputState extends State<DesktopPromptInput> {
void initState() {
super.initState();
textController.addListener(handleTextControllerChanged);
focusNode.addListener(
() {
if (!widget.hideDecoration) {
setState(() {}); // refresh border color
}
if (!focusNode.hasFocus) {
cancelMentionPage(); // hide menu when lost focus
}
},
);
widget.textController.addListener(handleTextControllerChanged);
focusNode
..addListener(
() {
if (!widget.hideDecoration) {
setState(() {}); // refresh border color
}
if (!focusNode.hasFocus) {
cancelMentionPage(); // hide menu when lost focus
}
},
)
..onKeyEvent = handleKeyEvent;
updateSendButtonState();
@@ -79,7 +86,7 @@ class _DesktopPromptInputState extends State<DesktopPromptInput> {
@override
void dispose() {
focusNode.dispose();
textController.dispose();
widget.textController.removeListener(handleTextControllerChanged);
inputControlCubit.close();
super.dispose();
}
@@ -104,7 +111,7 @@ class _DesktopPromptInputState extends State<DesktopPromptInput> {
overlayChildBuilder: (context) {
return PromptInputMentionPageMenu(
anchor: PromptInputAnchor(textFieldKey, layerLink),
textController: textController,
textController: widget.textController,
onPageSelected: handlePageSelected,
);
},
@@ -134,11 +141,11 @@ class _DesktopPromptInputState extends State<DesktopPromptInput> {
children: [
ConstrainedBox(
constraints: getTextFieldConstraints(
state.showPredefinedFormats,
state.showPredefinedFormats && !widget.hideFormats,
),
child: inputTextField(),
),
if (state.showPredefinedFormats)
if (state.showPredefinedFormats && !widget.hideFormats)
Positioned.fill(
bottom: null,
child: TextFieldTapRegion(
@@ -163,8 +170,9 @@ class _DesktopPromptInputState extends State<DesktopPromptInput> {
top: null,
child: TextFieldTapRegion(
child: _PromptBottomActions(
showPredefinedFormats:
showPredefinedFormatBar:
state.showPredefinedFormats,
showPredefinedFormatButton: !widget.hideFormats,
onTogglePredefinedFormatSection: () =>
context.read<AIPromptInputBloc>().add(
AIPromptInputEvent
@@ -178,6 +186,8 @@ class _DesktopPromptInputState extends State<DesktopPromptInput> {
widget.selectedSourcesNotifier,
onUpdateSelectedSources:
widget.onUpdateSelectedSources,
extraBottomActionButton:
widget.extraBottomActionButton,
),
),
),
@@ -216,12 +226,12 @@ class _DesktopPromptInputState extends State<DesktopPromptInput> {
if (!focusNode.hasFocus) {
focusNode.requestFocus();
}
textController.text += '@';
widget.textController.text += '@';
WidgetsBinding.instance.addPostFrameCallback((_) {
if (context.mounted) {
context
.read<ChatInputControlCubit>()
.startSearching(textController.value);
.startSearching(widget.textController.value);
overlayController.show();
}
});
@@ -237,7 +247,7 @@ class _DesktopPromptInputState extends State<DesktopPromptInput> {
void updateSendButtonState() {
if (widget.isStreaming) {
sendButtonState = SendButtonState.streaming;
} else if (textController.text.trim().isEmpty) {
} else if (widget.textController.text.trim().isEmpty) {
sendButtonState = SendButtonState.disabled;
} else {
sendButtonState = SendButtonState.enabled;
@@ -249,9 +259,9 @@ class _DesktopPromptInputState extends State<DesktopPromptInput> {
return;
}
final trimmedText = inputControlCubit.formatIntputText(
textController.text.trim(),
widget.textController.text.trim(),
);
textController.clear();
widget.textController.clear();
if (trimmedText.isEmpty) {
return;
}
@@ -274,7 +284,7 @@ class _DesktopPromptInputState extends State<DesktopPromptInput> {
setState(() {
// update whether send button is clickable
updateSendButtonState();
isComposing = !textController.value.composing.isCollapsed;
isComposing = !widget.textController.value.composing.isCollapsed;
});
if (isComposing) {
@@ -292,6 +302,7 @@ class _DesktopPromptInputState extends State<DesktopPromptInput> {
}
// handle cases where mention a page is cancelled
final textController = widget.textController;
final textSelection = textController.value.selection;
final isSelectingMultipleCharacters = !textSelection.isCollapsed;
final isCaretBeforeStartOfRange =
@@ -338,22 +349,27 @@ class _DesktopPromptInputState extends State<DesktopPromptInput> {
}
KeyEventResult handleKeyEvent(FocusNode node, KeyEvent event) {
if (event.character == '@') {
WidgetsBinding.instance.addPostFrameCallback((_) {
inputControlCubit.startSearching(textController.value);
overlayController.show();
});
// if (event.character == '@') {
// WidgetsBinding.instance.addPostFrameCallback((_) {
// inputControlCubit.startSearching(widget.textController.value);
// overlayController.show();
// });
// }
if (event is KeyDownEvent &&
event.logicalKey == LogicalKeyboardKey.escape) {
node.unfocus();
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
void handlePageSelected(ViewPB view) {
final newText = textController.text.replaceRange(
final newText = widget.textController.text.replaceRange(
inputControlCubit.filterStartPosition,
inputControlCubit.filterEndPosition,
view.id,
);
textController.value = TextEditingValue(
widget.textController.value = TextEditingValue(
text: newText,
selection: TextSelection.collapsed(
offset: inputControlCubit.filterStartPosition + view.id.length,
@@ -378,7 +394,7 @@ class _DesktopPromptInputState extends State<DesktopPromptInput> {
key: textFieldKey,
editable: state.editable,
cubit: inputControlCubit,
textController: textController,
textController: widget.textController,
textFieldFocusNode: focusNode,
contentPadding:
calculateContentPadding(state.showPredefinedFormats),
@@ -558,16 +574,19 @@ class PromptInputTextField extends StatelessWidget {
class _PromptBottomActions extends StatelessWidget {
const _PromptBottomActions({
required this.sendButtonState,
required this.showPredefinedFormats,
required this.showPredefinedFormatBar,
required this.showPredefinedFormatButton,
required this.onTogglePredefinedFormatSection,
required this.onStartMention,
required this.onSendPressed,
required this.onStopStreaming,
required this.selectedSourcesNotifier,
required this.onUpdateSelectedSources,
this.extraBottomActionButton,
});
final bool showPredefinedFormats;
final bool showPredefinedFormatBar;
final bool showPredefinedFormatButton;
final void Function() onTogglePredefinedFormatSection;
final void Function() onStartMention;
final SendButtonState sendButtonState;
@@ -575,6 +594,7 @@ class _PromptBottomActions extends StatelessWidget {
final void Function() onStopStreaming;
final ValueNotifier<List<String>> selectedSourcesNotifier;
final void Function(List<String>) onUpdateSelectedSources;
final Widget? extraBottomActionButton;
@override
Widget build(BuildContext context) {
@@ -583,18 +603,27 @@ class _PromptBottomActions extends StatelessWidget {
margin: DesktopAIChatSizes.inputActionBarMargin,
child: BlocBuilder<AIPromptInputBloc, AIPromptInputState>(
builder: (context, state) {
if (state.localAIState == null) {
return Align(
alignment: AlignmentDirectional.centerEnd,
child: _sendButton(),
);
}
return Row(
children: [
_predefinedFormatButton(),
if (showPredefinedFormatButton) ...[
_predefinedFormatButton(),
const HSpace(
DesktopAIChatSizes.inputActionBarButtonSpacing,
),
],
SelectModelMenu(
aiModelStateNotifier:
context.read<AIPromptInputBloc>().aiModelStateNotifier,
),
const Spacer(),
if (state.aiType.isCloud) ...[
_selectSourcesButton(context),
_selectSourcesButton(),
const HSpace(
DesktopAIChatSizes.inputActionBarButtonSpacing,
),
],
if (extraBottomActionButton != null) ...[
extraBottomActionButton!,
const HSpace(
DesktopAIChatSizes.inputActionBarButtonSpacing,
),
@@ -619,12 +648,12 @@ class _PromptBottomActions extends StatelessWidget {
Widget _predefinedFormatButton() {
return PromptInputDesktopToggleFormatButton(
showFormatBar: showPredefinedFormats,
showFormatBar: showPredefinedFormatBar,
onTap: onTogglePredefinedFormatSection,
);
}
Widget _selectSourcesButton(BuildContext context) {
Widget _selectSourcesButton() {
return PromptInputDesktopSelectSourcesButton(
onUpdateSelectedSources: onUpdateSelectedSources,
selectedSourcesNotifier: selectedSourcesNotifier,
@@ -104,6 +104,7 @@ class ChangeFormatBar extends StatelessWidget {
},
child: FlowyTooltip(
message: format.i18n,
preferBelow: false,
child: SizedBox.square(
dimension: _buttonSize,
child: FlowyHover(
@@ -150,6 +151,7 @@ class ChangeFormatBar extends StatelessWidget {
},
child: FlowyTooltip(
message: format.i18n,
preferBelow: false,
child: SizedBox.square(
dimension: _buttonSize,
child: FlowyHover(
@@ -0,0 +1,258 @@
import 'package:appflowy/ai/ai.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy_backend/protobuf/flowy-ai/protobuf.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flowy_infra_ui/style_widget/hover.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class SelectModelMenu extends StatefulWidget {
const SelectModelMenu({
super.key,
required this.aiModelStateNotifier,
});
final AIModelStateNotifier aiModelStateNotifier;
@override
State<SelectModelMenu> createState() => _SelectModelMenuState();
}
class _SelectModelMenuState extends State<SelectModelMenu> {
final popoverController = PopoverController();
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => SelectModelBloc(
aiModelStateNotifier: widget.aiModelStateNotifier,
),
child: BlocBuilder<SelectModelBloc, SelectModelState>(
builder: (context, state) {
if (state.selectedModel == null) {
return const SizedBox.shrink();
}
return AppFlowyPopover(
offset: Offset(-12.0, 0.0),
constraints: BoxConstraints(maxWidth: 250, maxHeight: 600),
direction: PopoverDirection.topWithLeftAligned,
margin: EdgeInsets.zero,
controller: popoverController,
popupBuilder: (popoverContext) {
return SelectModelPopoverContent(
models: state.models,
selectedModel: state.selectedModel,
onSelectModel: (model) {
if (model != state.selectedModel) {
context
.read<SelectModelBloc>()
.add(SelectModelEvent.selectModel(model));
}
popoverController.close();
},
);
},
child: _CurrentModelButton(
model: state.selectedModel!,
onTap: () => popoverController.show(),
),
);
},
),
);
}
}
class SelectModelPopoverContent extends StatelessWidget {
const SelectModelPopoverContent({
super.key,
required this.models,
required this.selectedModel,
this.onSelectModel,
});
final List<AIModelPB> models;
final AIModelPB? selectedModel;
final void Function(AIModelPB)? onSelectModel;
@override
Widget build(BuildContext context) {
if (models.isEmpty) {
return const SizedBox.shrink();
}
// separate models into local and cloud models
final localModels = models.where((model) => model.isLocal).toList();
final cloudModels = models.where((model) => !model.isLocal).toList();
return Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (localModels.isNotEmpty) ...[
_ModelSectionHeader(
title: LocaleKeys.chat_switchModel_localModel.tr(),
),
const VSpace(4.0),
],
...localModels.map(
(model) => _ModelItem(
model: model,
isSelected: model == selectedModel,
onTap: () => onSelectModel?.call(model),
),
),
if (cloudModels.isNotEmpty && localModels.isNotEmpty) ...[
const VSpace(8.0),
_ModelSectionHeader(
title: LocaleKeys.chat_switchModel_cloudModel.tr(),
),
const VSpace(4.0),
],
...cloudModels.map(
(model) => _ModelItem(
model: model,
isSelected: model == selectedModel,
onTap: () => onSelectModel?.call(model),
),
),
],
),
);
}
}
class _ModelSectionHeader extends StatelessWidget {
const _ModelSectionHeader({
required this.title,
});
final String title;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(top: 4, bottom: 2),
child: FlowyText(
title,
fontSize: 12,
figmaLineHeight: 16,
color: Theme.of(context).hintColor,
fontWeight: FontWeight.w500,
),
);
}
}
class _ModelItem extends StatelessWidget {
const _ModelItem({
required this.model,
required this.isSelected,
required this.onTap,
});
final AIModelPB model;
final bool isSelected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return ConstrainedBox(
constraints: const BoxConstraints(minHeight: 32),
child: FlowyButton(
onTap: onTap,
margin: EdgeInsets.symmetric(horizontal: 8.0, vertical: 6.0),
text: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
FlowyText(
model.i18n,
figmaLineHeight: 20,
overflow: TextOverflow.ellipsis,
),
if (model.desc.isNotEmpty)
FlowyText(
model.desc,
fontSize: 12,
figmaLineHeight: 16,
color: Theme.of(context).hintColor,
overflow: TextOverflow.ellipsis,
),
],
),
rightIcon: isSelected
? FlowySvg(
FlowySvgs.check_s,
size: const Size.square(20),
color: Theme.of(context).colorScheme.primary,
)
: null,
),
);
}
}
class _CurrentModelButton extends StatelessWidget {
const _CurrentModelButton({
required this.model,
required this.onTap,
});
final AIModelPB model;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return FlowyTooltip(
message: LocaleKeys.chat_switchModel_label.tr(),
child: GestureDetector(
onTap: onTap,
behavior: HitTestBehavior.opaque,
child: SizedBox(
height: DesktopAIPromptSizes.actionBarButtonSize,
child: FlowyHover(
style: const HoverStyle(
borderRadius: BorderRadius.all(Radius.circular(8)),
),
child: Padding(
padding: const EdgeInsetsDirectional.all(4.0),
child: Row(
children: [
Padding(
// TODO: remove this after change icon to 20px
padding: EdgeInsets.all(2),
child: FlowySvg(
FlowySvgs.ai_sparks_s,
color: Theme.of(context).hintColor,
size: Size.square(16),
),
),
if (!model.isDefault)
Padding(
padding: EdgeInsetsDirectional.only(end: 2.0),
child: FlowyText(
model.i18n,
fontSize: 12,
figmaLineHeight: 16,
color: Theme.of(context).hintColor,
overflow: TextOverflow.ellipsis,
),
),
FlowySvg(
FlowySvgs.ai_source_drop_down_s,
color: Theme.of(context).hintColor,
size: const Size.square(8),
),
],
),
),
),
),
),
);
}
}
@@ -145,7 +145,7 @@ class _IndicatorButton extends StatelessWidget {
children: [
FlowySvg(
FlowySvgs.ai_page_s,
color: Theme.of(context).iconTheme.color,
color: Theme.of(context).hintColor,
),
const HSpace(2.0),
ValueListenableBuilder(
@@ -170,7 +170,7 @@ class _IndicatorButton extends StatelessWidget {
FlowySvg(
FlowySvgs.ai_source_drop_down_s,
color: Theme.of(context).hintColor,
size: const Size.square(10),
size: const Size.square(8),
),
],
),
@@ -133,7 +133,6 @@ Future<bool> _afLaunchLocalUri(
};
if (context != null && context.mounted) {
showToastNotification(
context,
message: message,
type: result.type == ResultType.done
? ToastificationType.success
+2
View File
@@ -16,6 +16,7 @@ class AppFlowyConfiguration {
required this.platform,
required this.authenticator_type,
required this.appflowy_cloud_config,
required this.is_anon,
required this.envs,
});
@@ -28,6 +29,7 @@ class AppFlowyConfiguration {
final String origin_app_path;
final String device_id;
final String platform;
final bool is_anon;
final int authenticator_type;
final AppFlowyCloudConfiguration appflowy_cloud_config;
final Map<String, String> envs;
+1 -21
View File
@@ -23,9 +23,6 @@ import 'package:appflowy_backend/log.dart';
///
Future<void> _setAuthenticatorType(AuthenticatorType ty) async {
switch (ty) {
case AuthenticatorType.local:
await getIt<KeyValueStorage>().set(KVKeys.kCloudType, 0.toString());
break;
case AuthenticatorType.appflowyCloud:
await getIt<KeyValueStorage>().set(KVKeys.kCloudType, 2.toString());
break;
@@ -63,8 +60,6 @@ Future<AuthenticatorType> getAuthenticatorType() async {
}
switch (value ?? "0") {
case "0":
return AuthenticatorType.local;
case "2":
return AuthenticatorType.appflowyCloud;
case "3":
@@ -100,24 +95,17 @@ bool get isAuthEnabled {
return false;
}
bool get isLocalAuthEnabled {
return currentCloudType().isLocal;
}
/// Determines if AppFlowy Cloud is enabled.
bool get isAppFlowyCloudEnabled {
return currentCloudType().isAppFlowyCloudEnabled;
}
enum AuthenticatorType {
local,
appflowyCloud,
appflowyCloudSelfHost,
// The 'appflowyCloudDevelop' type is used for develop purposes only.
appflowyCloudDevelop;
bool get isLocal => this == AuthenticatorType.local;
bool get isAppFlowyCloudEnabled =>
this == AuthenticatorType.appflowyCloudSelfHost ||
this == AuthenticatorType.appflowyCloudDevelop ||
@@ -125,8 +113,6 @@ enum AuthenticatorType {
int get value {
switch (this) {
case AuthenticatorType.local:
return 0;
case AuthenticatorType.appflowyCloud:
return 2;
case AuthenticatorType.appflowyCloudSelfHost:
@@ -138,8 +124,6 @@ enum AuthenticatorType {
static AuthenticatorType fromValue(int value) {
switch (value) {
case 0:
return AuthenticatorType.local;
case 2:
return AuthenticatorType.appflowyCloud;
case 3:
@@ -147,7 +131,7 @@ enum AuthenticatorType {
case 4:
return AuthenticatorType.appflowyCloudDevelop;
default:
return AuthenticatorType.local;
return AuthenticatorType.appflowyCloud;
}
}
}
@@ -180,10 +164,6 @@ Future<void> useAppFlowyBetaCloudWithURL(
await _setAppFlowyCloudUrl(url);
}
Future<void> useLocalServer() async {
await _setAuthenticatorType(AuthenticatorType.local);
}
// Use getIt<AppFlowyCloudSharedEnv>() to get the shared environment.
class AppFlowyCloudSharedEnv {
AppFlowyCloudSharedEnv({
@@ -16,6 +16,8 @@ const double _kMinimumWidth = 112.0;
const double _kDefaultHorizontalPadding = 12.0;
typedef CompareFunction<T> = bool Function(T? left, T? right);
// Navigation shortcuts to move the selected menu items up or down.
final Map<ShortcutActivator, Intent> _kMenuTraversalShortcuts =
<ShortcutActivator, Intent>{
@@ -86,6 +88,7 @@ class AFDropdownMenu<T> extends StatefulWidget {
this.requestFocusOnTap,
this.expandedInsets,
this.searchCallback,
this.selectOptionCompare,
required this.dropdownMenuEntries,
});
@@ -267,6 +270,11 @@ class AFDropdownMenu<T> extends StatefulWidget {
/// which contains the contents of the text input field.
final SearchCallback<T>? searchCallback;
/// Defines the compare function for the menu items.
///
/// Defaults to null. If this is null, the menu items will be sorted by the label.
final CompareFunction<T>? selectOptionCompare;
@override
State<AFDropdownMenu<T>> createState() => _AFDropdownMenuState<T>();
}
@@ -301,7 +309,16 @@ class _AFDropdownMenuState<T> extends State<AFDropdownMenu<T>> {
filteredEntries.any((DropdownMenuEntry<T> entry) => entry.enabled);
final int index = filteredEntries.indexWhere(
(DropdownMenuEntry<T> entry) => entry.value == widget.initialSelection,
(DropdownMenuEntry<T> entry) {
if (widget.selectOptionCompare != null) {
return widget.selectOptionCompare!(
entry.value,
widget.initialSelection,
);
} else {
return entry.value == widget.initialSelection;
}
},
);
if (index != -1) {
_textEditingController.value = TextEditingValue(
@@ -336,7 +336,6 @@ class _MobileViewPageState extends State<MobileViewPage> {
listener: (context, state) {
if (state.isLocked) {
showToastNotification(
context,
message: LocaleKeys.lockPage_pageLockedToast.tr(),
);
@@ -366,7 +365,6 @@ class _MobileViewPageState extends State<MobileViewPage> {
listener: (context, state) {
if (state.isLocked) {
showToastNotification(
context,
message: LocaleKeys.lockPage_pageLockedToast.tr(),
);
}
@@ -161,7 +161,6 @@ class MobileViewPageMoreBottomSheet extends StatelessWidget {
context.pop();
showToastNotification(
context,
message: LocaleKeys.button_duplicateSuccessfully.tr(),
);
}
@@ -170,7 +169,6 @@ class MobileViewPageMoreBottomSheet extends StatelessWidget {
_toggleFavorite(context);
showToastNotification(
context,
message: LocaleKeys.button_favoriteSuccessfully.tr(),
);
}
@@ -179,7 +177,6 @@ class MobileViewPageMoreBottomSheet extends StatelessWidget {
_toggleFavorite(context);
showToastNotification(
context,
message: LocaleKeys.button_unfavoriteSuccessfully.tr(),
);
}
@@ -202,8 +199,7 @@ class MobileViewPageMoreBottomSheet extends StatelessWidget {
),
);
showToastNotification(
context,
message: LocaleKeys.grid_url_copy.tr(),
message: LocaleKeys.message_copy_success.tr(),
);
}
}
@@ -234,12 +230,10 @@ class MobileViewPageMoreBottomSheet extends StatelessWidget {
),
);
showToastNotification(
context,
message: LocaleKeys.shareAction_copyLinkSuccess.tr(),
);
} else {
showToastNotification(
context,
message: LocaleKeys.shareAction_copyLinkToBlockFailed.tr(),
type: ToastificationType.error,
);
@@ -323,11 +317,9 @@ class MobileViewPageMoreBottomSheet extends StatelessWidget {
if (state.publishResult != null) {
state.publishResult!.fold(
(value) => showToastNotification(
context,
message: LocaleKeys.publish_publishSuccessfully.tr(),
),
(error) => showToastNotification(
context,
message: '${LocaleKeys.publish_publishFailed.tr()}: ${error.code}',
type: ToastificationType.error,
),
@@ -335,11 +327,9 @@ class MobileViewPageMoreBottomSheet extends StatelessWidget {
} else if (state.unpublishResult != null) {
state.unpublishResult!.fold(
(value) => showToastNotification(
context,
message: LocaleKeys.publish_unpublishSuccessfully.tr(),
),
(error) => showToastNotification(
context,
message: LocaleKeys.publish_unpublishFailed.tr(),
description: error.msg,
type: ToastificationType.error,
@@ -349,7 +339,6 @@ class MobileViewPageMoreBottomSheet extends StatelessWidget {
state.updatePathNameResult!.onSuccess(
(value) {
showToastNotification(
context,
message:
LocaleKeys.settings_sites_success_updatePathNameSuccess.tr(),
);
@@ -65,7 +65,6 @@ class _MobileViewItemBottomSheetState extends State<MobileViewItemBottomSheet> {
Navigator.pop(context);
context.read<ViewBloc>().add(const ViewEvent.duplicate());
showToastNotification(
context,
message: LocaleKeys.button_duplicateSuccessfully.tr(),
);
break;
@@ -84,7 +83,6 @@ class _MobileViewItemBottomSheetState extends State<MobileViewItemBottomSheet> {
.read<FavoriteBloc>()
.add(FavoriteEvent.toggle(widget.view));
showToastNotification(
context,
message: !widget.view.isFavorite
? LocaleKeys.button_favoriteSuccessfully.tr()
: LocaleKeys.button_unfavoriteSuccessfully.tr(),
@@ -146,7 +144,6 @@ class _MobileViewItemBottomSheetState extends State<MobileViewItemBottomSheet> {
Navigator.pop(context);
showToastNotification(
context,
message: LocaleKeys.sideBar_removeSuccess.tr(),
);
},
@@ -45,7 +45,6 @@ enum MobilePaneActionType {
size: 24.0,
onPressed: (context) {
showToastNotification(
context,
message: LocaleKeys.button_unfavoriteSuccessfully.tr(),
);
@@ -61,7 +60,6 @@ enum MobilePaneActionType {
size: 24.0,
onPressed: (context) {
showToastNotification(
context,
message: LocaleKeys.button_favoriteSuccessfully.tr(),
);
@@ -103,7 +103,7 @@ class _OpenRowPageButtonState extends State<OpenRowPageButton> {
Log.info('Open row page(${widget.documentId})');
if (view == null) {
showToastNotification(context, message: 'Failed to open row page');
showToastNotification(message: 'Failed to open row page');
// reload the view again
unawaited(_preloadView(context));
Log.error('Failed to open row page(${widget.documentId})');
@@ -329,7 +329,7 @@ class _HomePageState extends State<_HomePage> {
}
if (message != null) {
showToastNotification(context, message: message, type: toastType);
showToastNotification(message: message, type: toastType);
}
}
}
@@ -100,7 +100,6 @@ class _MobileHomeSettingPageState extends State<MobileHomeSettingPage> {
key: ValueKey(currentWorkspaceId),
userProfile: userProfile,
workspaceId: currentWorkspaceId,
currentWorkspaceMemberRole: state.currentWorkspace?.role,
),
const SupportSettingGroup(),
const AboutSettingGroup(),
@@ -16,6 +16,7 @@ enum _MobileSettingsPopupMenuItem {
members,
trash,
help,
helpAndDocumentation,
}
class HomePageSettingsPopupMenu extends StatelessWidget {
@@ -62,10 +63,16 @@ class HomePageSettingsPopupMenu extends StatelessWidget {
text: LocaleKeys.settings_popupMenuItem_trash.tr(),
),
const PopupMenuDivider(height: 0.5),
_buildItem(
value: _MobileSettingsPopupMenuItem.helpAndDocumentation,
svg: FlowySvgs.help_and_documentation_s,
text: LocaleKeys.settings_popupMenuItem_helpAndDocumentation.tr(),
),
const PopupMenuDivider(height: 0.5),
_buildItem(
value: _MobileSettingsPopupMenuItem.help,
svg: FlowySvgs.message_support_s,
text: LocaleKeys.settings_popupMenuItem_helpAndSupport.tr(),
text: LocaleKeys.settings_popupMenuItem_getSupport.tr(),
),
],
onSelected: (_MobileSettingsPopupMenuItem value) {
@@ -82,6 +89,9 @@ class HomePageSettingsPopupMenu extends StatelessWidget {
case _MobileSettingsPopupMenuItem.help:
_openHelpPage(context);
break;
case _MobileSettingsPopupMenuItem.helpAndDocumentation:
_openHelpAndDocumentationPage(context);
break;
}
},
child: const Padding(
@@ -123,6 +133,10 @@ class HomePageSettingsPopupMenu extends StatelessWidget {
void _openSettingsPage(BuildContext context) {
context.push(MobileHomeSettingPage.routeName);
}
void _openHelpAndDocumentationPage(BuildContext context) {
afLaunchUrlString('https://appflowy.com/guide');
}
}
class _PopupButton extends StatelessWidget {
@@ -339,7 +339,6 @@ class _SpaceMenuItemTrailingState extends State<SpaceMenuItemTrailing> {
context.read<SpaceBloc>().add(const SpaceEvent.duplicate());
showToastNotification(
context,
message: LocaleKeys.space_success_duplicateSpace.tr(),
);
@@ -374,7 +373,6 @@ class _SpaceMenuItemTrailingState extends State<SpaceMenuItemTrailing> {
.add(SpaceEvent.rename(space: widget.space, name: name));
showToastNotification(
context,
message: LocaleKeys.space_success_renameSpace.tr(),
);
},
@@ -424,7 +422,6 @@ class _SpaceMenuItemTrailingState extends State<SpaceMenuItemTrailing> {
);
showToastNotification(
context,
message: LocaleKeys.space_success_updateSpace.tr(),
);
@@ -457,7 +454,6 @@ class _SpaceMenuItemTrailingState extends State<SpaceMenuItemTrailing> {
context.read<SpaceBloc>().add(SpaceEvent.delete(widget.space));
showToastNotification(
context,
message: LocaleKeys.space_success_deleteSpace.tr(),
);
@@ -102,7 +102,7 @@ class MobileInlineActionsWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
final hasIcon = item.icon != null;
final hasIcon = item.iconBuilder != null;
return Container(
height: 36,
decoration: BoxDecoration(
@@ -119,7 +119,7 @@ class MobileInlineActionsWidget extends StatelessWidget {
child: Row(
children: [
if (hasIcon) ...[
item.icon!.call(isSelected),
item.iconBuilder!.call(isSelected),
SizedBox(width: 12),
],
Flexible(
@@ -332,7 +332,6 @@ class _NotificationNavigationBar extends StatelessWidget {
}
showToastNotification(
context,
message: LocaleKeys
.settings_notifications_markAsReadNotifications_allSuccess
.tr(),
@@ -350,7 +349,6 @@ class _NotificationNavigationBar extends StatelessWidget {
}
showToastNotification(
context,
message: LocaleKeys.settings_notifications_archiveNotifications_allSuccess
.tr(),
);
@@ -108,7 +108,6 @@ class NotificationSettingsPopupMenu extends StatelessWidget {
void _onMarkAllAsRead(BuildContext context) {
showToastNotification(
context,
message: LocaleKeys
.settings_notifications_markAsReadNotifications_allSuccess
.tr(),
@@ -119,7 +118,6 @@ class NotificationSettingsPopupMenu extends StatelessWidget {
void _onArchiveAll(BuildContext context) {
showToastNotification(
context,
message: LocaleKeys.settings_notifications_archiveNotifications_allSuccess
.tr(),
);
@@ -133,7 +131,6 @@ class NotificationSettingsPopupMenu extends StatelessWidget {
}
showToastNotification(
context,
message: 'Unarchive all success (Debug Mode)',
);
@@ -31,7 +31,6 @@ enum NotificationPaneActionType {
size: 24.0,
onPressed: (context) {
showToastNotification(
context,
message: LocaleKeys
.settings_notifications_markAsReadNotifications_success
.tr(),
@@ -55,7 +54,6 @@ enum NotificationPaneActionType {
size: 24.0,
onPressed: (context) {
showToastNotification(
context,
message: 'Unarchive notification success',
);
@@ -168,7 +166,6 @@ class _NotificationMoreActions extends StatelessWidget {
Navigator.of(context).pop();
showToastNotification(
context,
message: LocaleKeys.settings_notifications_markAsReadNotifications_success
.tr(),
);
@@ -191,7 +188,6 @@ class _NotificationMoreActions extends StatelessWidget {
void _onArchive(BuildContext context) {
showToastNotification(
context,
message: LocaleKeys.settings_notifications_archiveNotifications_success
.tr()
.tr(),
@@ -74,7 +74,6 @@ class _NotificationTabState extends State<NotificationTab>
if (context.mounted) {
showToastNotification(
context,
message: LocaleKeys.settings_notifications_refreshSuccess.tr(),
);
}
@@ -25,14 +25,14 @@ class AboutSettingGroup extends StatelessWidget {
trailing: const Icon(
Icons.chevron_right,
),
onTap: () => afLaunchUrlString('https://appflowy.io/privacy'),
onTap: () => afLaunchUrlString('https://appflowy.com/privacy'),
),
MobileSettingItem(
name: LocaleKeys.settings_mobile_termsAndConditions.tr(),
trailing: const Icon(
Icons.chevron_right,
),
onTap: () => afLaunchUrlString('https://appflowy.io/terms'),
onTap: () => afLaunchUrlString('https://appflowy.com/terms'),
),
if (kDebugMode)
MobileSettingItem(
@@ -5,8 +5,6 @@ import 'package:appflowy/mobile/presentation/setting/widgets/mobile_setting_item
import 'package:appflowy/mobile/presentation/widgets/flowy_option_tile.dart';
import 'package:appflowy/workspace/application/settings/ai/settings_ai_bloc.dart';
import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-user/workspace.pbenum.dart';
import 'package:collection/collection.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
import 'package:flutter/material.dart';
@@ -18,12 +16,10 @@ class AiSettingsGroup extends StatelessWidget {
super.key,
required this.userProfile,
required this.workspaceId,
this.currentWorkspaceMemberRole,
});
final UserProfilePB userProfile;
final String workspaceId;
final AFRolePB? currentWorkspaceMemberRole;
@override
Widget build(BuildContext context) {
@@ -32,7 +28,6 @@ class AiSettingsGroup extends StatelessWidget {
create: (context) => SettingsAIBloc(
userProfile,
workspaceId,
currentWorkspaceMemberRole,
)..add(const SettingsAIEvent.started()),
child: BlocBuilder<SettingsAIBloc, SettingsAIState>(
builder: (context, state) {
@@ -48,7 +43,7 @@ class AiSettingsGroup extends StatelessWidget {
children: [
Flexible(
child: FlowyText(
state.selectedAIModel,
state.availableModels?.selectedModel.name ?? "",
color: theme.colorScheme.onSurface,
overflow: TextOverflow.ellipsis,
),
@@ -84,16 +79,19 @@ class AiSettingsGroup extends StatelessWidget {
title: LocaleKeys.settings_aiPage_keys_llmModelType.tr(),
builder: (_) {
return Column(
children: availableModels
.mapIndexed(
(index, model) => FlowyOptionTile.checkbox(
text: model,
showTopBorder: index == 0,
isSelected: state.selectedAIModel == model,
children: (availableModels?.models ?? [])
.asMap()
.entries
.map(
(entry) => FlowyOptionTile.checkbox(
text: entry.value.name,
showTopBorder: entry.key == 0,
isSelected:
availableModels?.selectedModel.name == entry.value.name,
onTap: () {
context
.read<SettingsAIBloc>()
.add(SettingsAIEvent.selectModel(model));
.add(SettingsAIEvent.selectModel(entry.value));
context.pop();
},
),
@@ -81,7 +81,6 @@ class SupportSettingGroup extends StatelessWidget {
);
if (context.mounted) {
showToastNotification(
context,
message: LocaleKeys.settings_files_clearCacheSuccess.tr(),
);
}
@@ -1,9 +1,7 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart';
import 'package:appflowy/mobile/presentation/widgets/show_flowy_mobile_confirm_dialog.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/user/application/auth/auth_service.dart';
import 'package:appflowy/user/application/sign_in_bloc.dart';
import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/widgets.dart';
import 'package:appflowy/workspace/presentation/settings/pages/account/account_deletion.dart';
@@ -32,12 +30,6 @@ class UserSessionSettingGroup extends StatelessWidget {
if (showThirdPartyLogin) _buildThirdPartySignInButtons(context),
const VSpace(8.0),
// logout button
MobileLogoutButton(
text: LocaleKeys.settings_menu_logout.tr(),
onPressed: () async => _showLogoutDialog(),
),
// delete account button
// only show the delete account button in cloud mode
if (userProfile.authenticator == AuthenticatorPB.AppFlowyCloud) ...[
@@ -79,31 +71,6 @@ class UserSessionSettingGroup extends StatelessWidget {
builder: (_) => const _DeleteAccountBottomSheet(),
);
}
Future<void> _showLogoutDialog() async {
return showFlowyCupertinoConfirmDialog(
title: LocaleKeys.settings_menu_logoutPrompt.tr(),
leftButton: FlowyText(
LocaleKeys.button_cancel.tr(),
fontSize: 17.0,
figmaLineHeight: 24.0,
fontWeight: FontWeight.w500,
color: const Color(0xFF007AFF),
),
rightButton: FlowyText(
LocaleKeys.button_logout.tr(),
fontSize: 17.0,
figmaLineHeight: 24.0,
fontWeight: FontWeight.w400,
color: const Color(0xFFFE0220),
),
onRightButtonPressed: (context) async {
Navigator.of(context).pop();
await getIt<AuthService>().signOut();
await runAppFlowy();
},
);
}
}
class _DeleteAccountBottomSheet extends StatefulWidget {
@@ -201,7 +201,6 @@ class _InviteMemberPageState extends State<_InviteMemberPage> {
result.fold(
(s) {
showToastNotification(
context,
message:
LocaleKeys.settings_appearance_members_addMemberSuccess.tr(),
bottomPadding: keyboardHeight,
@@ -218,7 +217,6 @@ class _InviteMemberPageState extends State<_InviteMemberPage> {
exceededLimit = f.code == ErrorCode.WorkspaceMemberLimitExceeded;
});
showToastNotification(
context,
type: ToastificationType.error,
bottomPadding: keyboardHeight,
message: message,
@@ -229,7 +227,6 @@ class _InviteMemberPageState extends State<_InviteMemberPage> {
result.fold(
(s) {
showToastNotification(
context,
message:
LocaleKeys.settings_appearance_members_inviteMemberSuccess.tr(),
bottomPadding: keyboardHeight,
@@ -247,7 +244,6 @@ class _InviteMemberPageState extends State<_InviteMemberPage> {
exceededLimit = f.code == ErrorCode.WorkspaceMemberLimitExceeded;
});
showToastNotification(
context,
type: ToastificationType.error,
message: message,
bottomPadding: keyboardHeight,
@@ -258,7 +254,6 @@ class _InviteMemberPageState extends State<_InviteMemberPage> {
result.fold(
(s) {
showToastNotification(
context,
message: LocaleKeys
.settings_appearance_members_removeFromWorkspaceSuccess
.tr(),
@@ -267,7 +262,6 @@ class _InviteMemberPageState extends State<_InviteMemberPage> {
},
(f) {
showToastNotification(
context,
type: ToastificationType.error,
message: LocaleKeys
.settings_appearance_members_removeFromWorkspaceFailed
@@ -282,11 +276,11 @@ class _InviteMemberPageState extends State<_InviteMemberPage> {
void _inviteMember(BuildContext context) {
final email = emailController.text;
if (!isEmail(email)) {
return showToastNotification(
context,
showToastNotification(
type: ToastificationType.error,
message: LocaleKeys.settings_appearance_members_emailInvalidError.tr(),
);
return;
}
context
.read<WorkspaceMemberBloc>()
@@ -0,0 +1,53 @@
import 'dart:async';
import 'dart:typed_data';
import 'package:appflowy/plugins/ai_chat/application/chat_notification.dart';
import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-ai/notification.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-notification/subject.pb.dart';
import 'package:appflowy_backend/rust_stream.dart';
import 'package:appflowy_result/appflowy_result.dart';
typedef OnUpdateSelectedModel = void Function(AIModelPB model);
class AIModelSwitchListener {
AIModelSwitchListener({required this.objectId}) {
_parser = ChatNotificationParser(id: objectId, callback: _callback);
_subscription = RustStreamReceiver.listen(
(observable) => _parser?.parse(observable),
);
}
final String objectId;
StreamSubscription<SubscribeObject>? _subscription;
ChatNotificationParser? _parser;
void start({
OnUpdateSelectedModel? onUpdateSelectedModel,
}) {
this.onUpdateSelectedModel = onUpdateSelectedModel;
}
OnUpdateSelectedModel? onUpdateSelectedModel;
void _callback(
ChatNotification ty,
FlowyResult<Uint8List, FlowyError> result,
) {
result.map((r) {
switch (ty) {
case ChatNotification.DidUpdateSelectedModel:
onUpdateSelectedModel?.call(AIModelPB.fromBuffer(r));
break;
default:
break;
}
});
}
Future<void> stop() async {
await _subscription?.cancel();
_subscription = null;
}
}
@@ -239,9 +239,9 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
),
);
},
regenerateAnswer: (id, format) {
regenerateAnswer: (id, format, model) {
_clearRelatedQuestions();
_regenerateAnswer(id, format);
_regenerateAnswer(id, format, model);
lastSentMessage = null;
isFetchingRelatedQuestions = false;
@@ -483,6 +483,7 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
void _regenerateAnswer(
String answerMessageIdString,
PredefinedFormat? format,
AIModelPB? model,
) async {
final id = temporaryMessageIDMap.entries
.firstWhereOrNull((e) => e.value == answerMessageIdString)
@@ -505,6 +506,9 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
if (format != null) {
payload.format = format.toPB();
}
if (model != null) {
payload.model = model;
}
await AIEventRegenerateResponse(payload).send().fold(
(success) {
@@ -637,6 +641,7 @@ class ChatEvent with _$ChatEvent {
const factory ChatEvent.regenerateAnswer(
String id,
PredefinedFormat? format,
AIModelPB? model,
) = _RegenerateAnswer;
// streaming answer
@@ -2,19 +2,9 @@ import 'dart:async';
import 'dart:ffi';
import 'dart:isolate';
import 'package:appflowy/ai/service/ai_entities.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_message_service.dart';
/// Constants for event prefixes.
class AnswerEventPrefix {
static const data = 'data:';
static const error = 'error:';
static const metadata = 'metadata:';
static const aiResponseLimit = 'AI_RESPONSE_LIMIT';
static const aiImageResponseLimit = 'AI_IMAGE_RESPONSE_LIMIT';
static const aiMaxRequired = 'AI_MAX_REQUIRED:';
static const localAINotReady = 'LOCAL_AI_NOT_READY';
}
/// A stream that receives answer events from an isolate or external process.
/// It caches events that might occur before a listener is attached.
class AnswerStream {
@@ -68,31 +58,31 @@ class AnswerStream {
/// Handles incoming events from the underlying stream.
void _handleEvent(String event) {
if (event.startsWith(AnswerEventPrefix.data)) {
if (event.startsWith(AIStreamEventPrefix.data)) {
_hasStarted = true;
final newText = event.substring(AnswerEventPrefix.data.length);
final newText = event.substring(AIStreamEventPrefix.data.length);
_text += newText;
_onData?.call(_text);
} else if (event.startsWith(AnswerEventPrefix.error)) {
_error = event.substring(AnswerEventPrefix.error.length);
} else if (event.startsWith(AIStreamEventPrefix.error)) {
_error = event.substring(AIStreamEventPrefix.error.length);
_onError?.call(_error!);
} else if (event.startsWith(AnswerEventPrefix.metadata)) {
final s = event.substring(AnswerEventPrefix.metadata.length);
} else if (event.startsWith(AIStreamEventPrefix.metadata)) {
final s = event.substring(AIStreamEventPrefix.metadata.length);
_onMetadata?.call(parseMetadata(s));
} else if (event == AnswerEventPrefix.aiResponseLimit) {
} else if (event == AIStreamEventPrefix.aiResponseLimit) {
_aiLimitReached = true;
_onAIResponseLimit?.call();
} else if (event == AnswerEventPrefix.aiImageResponseLimit) {
} else if (event == AIStreamEventPrefix.aiImageResponseLimit) {
_aiImageLimitReached = true;
_onAIImageResponseLimit?.call();
} else if (event.startsWith(AnswerEventPrefix.aiMaxRequired)) {
final msg = event.substring(AnswerEventPrefix.aiMaxRequired.length);
} else if (event.startsWith(AIStreamEventPrefix.aiMaxRequired)) {
final msg = event.substring(AIStreamEventPrefix.aiMaxRequired.length);
if (_onAIMaxRequired != null) {
_onAIMaxRequired!(msg);
} else {
_pendingAIMaxRequiredEvents.add(msg);
}
} else if (event.startsWith(AnswerEventPrefix.localAINotReady)) {
} else if (event.startsWith(AIStreamEventPrefix.localAINotReady)) {
if (_onLocalAIInitializing != null) {
_onLocalAIInitializing!();
} else {
@@ -73,6 +73,7 @@ class AIChatPage extends StatelessWidget {
/// [AIPromptInputBloc] is used to handle the user prompt
BlocProvider(
create: (_) => AIPromptInputBloc(
objectId: view.id,
predefinedFormat: PredefinedFormat(
imageFormat: ImageFormat.text,
textFormat: TextFormat.bulletList,
@@ -264,10 +265,13 @@ class _ChatContentPage extends StatelessWidget {
_onSelectMetadata(context, metadata),
onRegenerate: () => context
.read<ChatBloc>()
.add(ChatEvent.regenerateAnswer(message.id, null)),
.add(ChatEvent.regenerateAnswer(message.id, null, null)),
onChangeFormat: (format) => context
.read<ChatBloc>()
.add(ChatEvent.regenerateAnswer(message.id, format)),
.add(ChatEvent.regenerateAnswer(message.id, format, null)),
onChangeModel: (model) => context
.read<ChatBloc>()
.add(ChatEvent.regenerateAnswer(message.id, null, model)),
onStopStream: () => context.read<ChatBloc>().add(
const ChatEvent.stopStream(),
),
@@ -384,13 +388,26 @@ class _ChatContentPage extends StatelessWidget {
}
}
class _Input extends StatelessWidget {
class _Input extends StatefulWidget {
const _Input({
required this.view,
});
final ViewPB view;
@override
State<_Input> createState() => _InputState();
}
class _InputState extends State<_Input> {
final textController = TextEditingController();
@override
void dispose() {
textController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return BlocSelector<ChatSelectMessageBloc, ChatSelectMessageState, bool>(
@@ -420,6 +437,7 @@ class _Input extends StatelessWidget {
return UniversalPlatform.isDesktop
? DesktopPromptInput(
isStreaming: !canSendMessage,
textController: textController,
onStopStreaming: () {
chatBloc.add(const ChatEvent.stopStream());
},
@@ -41,7 +41,7 @@ class _MobileChatInputState extends State<MobileChatInput> {
void initState() {
super.initState();
textController.addListener(handleTextControllerChange);
textController.addListener(handleTextControllerChanged);
// focusNode.onKeyEvent = handleKeyEvent;
WidgetsBinding.instance.addPostFrameCallback((_) {
@@ -197,7 +197,7 @@ class _MobileChatInputState extends State<MobileChatInput> {
);
}
void handleTextControllerChange() {
void handleTextControllerChanged() {
if (textController.value.isComposingRangeValid) {
return;
}
@@ -0,0 +1,145 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/base/app_bar/app_bar_actions.dart';
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart';
import 'package:appflowy/mobile/presentation/widgets/widgets.dart';
import 'package:appflowy_backend/protobuf/flowy-ai/protobuf.dart';
import 'package:collection/collection.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
Future<AIModelPB?> showChangeModelBottomSheet(
BuildContext context,
List<AIModelPB> models,
) {
return showMobileBottomSheet<AIModelPB?>(
context,
showDragHandle: true,
builder: (context) => _ChangeModelBottomSheetContent(models: models),
);
}
class _ChangeModelBottomSheetContent extends StatefulWidget {
const _ChangeModelBottomSheetContent({
required this.models,
});
final List<AIModelPB> models;
@override
State<_ChangeModelBottomSheetContent> createState() =>
_ChangeModelBottomSheetContentState();
}
class _ChangeModelBottomSheetContentState
extends State<_ChangeModelBottomSheetContent> {
AIModelPB? model;
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
_Header(
onCancel: () => Navigator.of(context).pop(),
onDone: () => Navigator.of(context).pop(model),
),
const VSpace(4.0),
_Body(
models: widget.models,
selectedModel: model,
onSelectModel: (format) {
setState(() => model = format);
},
),
const VSpace(16.0),
],
);
}
}
class _Header extends StatelessWidget {
const _Header({
required this.onCancel,
required this.onDone,
});
final VoidCallback onCancel;
final VoidCallback onDone;
@override
Widget build(BuildContext context) {
return SizedBox(
height: 44.0,
child: Stack(
children: [
Align(
alignment: Alignment.centerLeft,
child: AppBarBackButton(
padding: const EdgeInsets.symmetric(
vertical: 12,
horizontal: 16,
),
onTap: onCancel,
),
),
Align(
child: Container(
constraints: const BoxConstraints(maxWidth: 250),
child: FlowyText(
LocaleKeys.chat_switchModel_label.tr(),
fontSize: 17.0,
fontWeight: FontWeight.w500,
overflow: TextOverflow.ellipsis,
),
),
),
Align(
alignment: Alignment.centerRight,
child: AppBarDoneButton(
onTap: onDone,
),
),
],
),
);
}
}
class _Body extends StatelessWidget {
const _Body({
required this.models,
required this.selectedModel,
required this.onSelectModel,
});
final List<AIModelPB> models;
final AIModelPB? selectedModel;
final void Function(AIModelPB) onSelectModel;
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: models
.mapIndexed(
(index, model) => _buildModelButton(model, index == 0),
)
.toList(),
);
}
Widget _buildModelButton(
AIModelPB model, [
bool isFirst = false,
]) {
return FlowyOptionTile.checkbox(
text: model.name,
isSelected: model == selectedModel,
showTopBorder: isFirst,
onTap: () {
onSelectModel(model);
},
);
}
}
@@ -21,6 +21,7 @@ import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/shared_widget.dart';
import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy_backend/protobuf/flowy-ai/protobuf.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_result/appflowy_result.dart';
@@ -41,6 +42,7 @@ class AIMessageActionBar extends StatefulWidget {
required this.showDecoration,
this.onRegenerate,
this.onChangeFormat,
this.onChangeModel,
this.onOverrideVisibility,
});
@@ -48,6 +50,7 @@ class AIMessageActionBar extends StatefulWidget {
final bool showDecoration;
final void Function()? onRegenerate;
final void Function(PredefinedFormat)? onChangeFormat;
final void Function(AIModelPB)? onChangeModel;
final void Function(bool)? onOverrideVisibility;
@override
@@ -126,6 +129,12 @@ class _AIMessageActionBarState extends State<AIMessageActionBar> {
popoverMutex: popoverMutex,
onOverrideVisibility: widget.onOverrideVisibility,
),
ChangeModelButton(
isInHoverBar: widget.showDecoration,
onRegenerate: widget.onChangeModel,
popoverMutex: popoverMutex,
onOverrideVisibility: widget.onOverrideVisibility,
),
SaveToPageButton(
textMessage: widget.message as TextMessage,
isInHoverBar: widget.showDecoration,
@@ -175,8 +184,7 @@ class CopyButton extends StatelessWidget {
);
if (context.mounted) {
showToastNotification(
context,
message: LocaleKeys.grid_url_copiedNotification.tr(),
message: LocaleKeys.message_copy_success.tr(),
);
}
},
@@ -405,6 +413,85 @@ class _ChangeFormatPopoverContentState
}
}
class ChangeModelButton extends StatefulWidget {
const ChangeModelButton({
super.key,
required this.isInHoverBar,
this.popoverMutex,
this.onRegenerate,
this.onOverrideVisibility,
});
final bool isInHoverBar;
final PopoverMutex? popoverMutex;
final void Function(AIModelPB)? onRegenerate;
final void Function(bool)? onOverrideVisibility;
@override
State<ChangeModelButton> createState() => _ChangeModelButtonState();
}
class _ChangeModelButtonState extends State<ChangeModelButton> {
final popoverController = PopoverController();
@override
Widget build(BuildContext context) {
return AppFlowyPopover(
controller: popoverController,
mutex: widget.popoverMutex,
triggerActions: PopoverTriggerFlags.none,
margin: EdgeInsets.zero,
offset: Offset(8, 0),
direction: PopoverDirection.rightWithBottomAligned,
constraints: BoxConstraints(maxWidth: 250, maxHeight: 600),
onClose: () => widget.onOverrideVisibility?.call(false),
child: buildButton(context),
popupBuilder: (_) {
final bloc = context.read<AIPromptInputBloc>();
final (models, _) = bloc.aiModelStateNotifier.getAvailableModels();
return SelectModelPopoverContent(
models: models,
selectedModel: null,
onSelectModel: widget.onRegenerate,
);
},
);
}
Widget buildButton(BuildContext context) {
return FlowyTooltip(
message: LocaleKeys.chat_switchModel_label.tr(),
child: FlowyIconButton(
width: 32.0,
height: DesktopAIChatSizes.messageActionBarIconSize,
hoverColor: AFThemeExtension.of(context).lightGreyHover,
radius: widget.isInHoverBar
? DesktopAIChatSizes.messageHoverActionBarIconRadius
: DesktopAIChatSizes.messageActionBarIconRadius,
icon: Row(
mainAxisSize: MainAxisSize.min,
children: [
FlowySvg(
FlowySvgs.ai_sparks_s,
color: Theme.of(context).hintColor,
size: const Size.square(16),
),
FlowySvg(
FlowySvgs.ai_source_drop_down_s,
color: Theme.of(context).hintColor,
size: const Size.square(8),
),
],
),
onPressed: () {
widget.onOverrideVisibility?.call(true);
popoverController.show();
},
),
);
}
}
class SaveToPageButton extends StatefulWidget {
const SaveToPageButton({
super.key,
@@ -12,6 +12,7 @@ import 'package:appflowy/shared/markdown_to_document.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy_backend/protobuf/flowy-ai/protobuf.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
@@ -23,6 +24,7 @@ import 'package:universal_platform/universal_platform.dart';
import '../chat_avatar.dart';
import '../layout_define.dart';
import 'ai_change_model_bottom_sheet.dart';
import 'ai_message_action_bar.dart';
import 'ai_change_format_bottom_sheet.dart';
import 'message_util.dart';
@@ -41,6 +43,7 @@ class ChatAIMessageBubble extends StatelessWidget {
this.isSelectingMessages = false,
this.onRegenerate,
this.onChangeFormat,
this.onChangeModel,
});
final Message message;
@@ -50,6 +53,7 @@ class ChatAIMessageBubble extends StatelessWidget {
final bool isSelectingMessages;
final void Function()? onRegenerate;
final void Function(PredefinedFormat)? onChangeFormat;
final void Function(AIModelPB)? onChangeModel;
@override
Widget build(BuildContext context) {
@@ -73,6 +77,7 @@ class ChatAIMessageBubble extends StatelessWidget {
message: message,
onRegenerate: onRegenerate,
onChangeFormat: onChangeFormat,
onChangeModel: onChangeModel,
child: child,
);
}
@@ -82,6 +87,7 @@ class ChatAIMessageBubble extends StatelessWidget {
message: message,
onRegenerate: onRegenerate,
onChangeFormat: onChangeFormat,
onChangeModel: onChangeModel,
child: child,
);
}
@@ -91,6 +97,7 @@ class ChatAIMessageBubble extends StatelessWidget {
message: message,
onRegenerate: onRegenerate,
onChangeFormat: onChangeFormat,
onChangeModel: onChangeModel,
child: child,
);
}
@@ -103,12 +110,14 @@ class ChatAIBottomInlineActions extends StatelessWidget {
required this.message,
this.onRegenerate,
this.onChangeFormat,
this.onChangeModel,
});
final Widget child;
final Message message;
final void Function()? onRegenerate;
final void Function(PredefinedFormat)? onChangeFormat;
final void Function(AIModelPB)? onChangeModel;
@override
Widget build(BuildContext context) {
@@ -127,6 +136,7 @@ class ChatAIBottomInlineActions extends StatelessWidget {
showDecoration: false,
onRegenerate: onRegenerate,
onChangeFormat: onChangeFormat,
onChangeModel: onChangeModel,
),
),
const VSpace(32.0),
@@ -142,12 +152,14 @@ class ChatAIMessageHover extends StatefulWidget {
required this.message,
this.onRegenerate,
this.onChangeFormat,
this.onChangeModel,
});
final Widget child;
final Message message;
final void Function()? onRegenerate;
final void Function(PredefinedFormat)? onChangeFormat;
final void Function(AIModelPB)? onChangeModel;
@override
State<ChatAIMessageHover> createState() => _ChatAIMessageHoverState();
@@ -229,6 +241,7 @@ class _ChatAIMessageHoverState extends State<ChatAIMessageHover> {
showDecoration: true,
onRegenerate: widget.onRegenerate,
onChangeFormat: widget.onChangeFormat,
onChangeModel: widget.onChangeModel,
onOverrideVisibility: (visibility) {
overrideVisibility = visibility;
},
@@ -302,12 +315,14 @@ class ChatAIMessagePopup extends StatelessWidget {
required this.message,
this.onRegenerate,
this.onChangeFormat,
this.onChangeModel,
});
final Widget child;
final Message message;
final void Function()? onRegenerate;
final void Function(PredefinedFormat)? onChangeFormat;
final void Function(AIModelPB)? onChangeModel;
@override
Widget build(BuildContext context) {
@@ -328,6 +343,8 @@ class ChatAIMessagePopup extends StatelessWidget {
_divider(),
_changeFormatButton(context),
_divider(),
_changeModelButton(context),
_divider(),
_saveToPageButton(context),
],
);
@@ -359,8 +376,7 @@ class ChatAIMessagePopup extends StatelessWidget {
}
if (context.mounted) {
showToastNotification(
context,
message: LocaleKeys.grid_url_copiedNotification.tr(),
message: LocaleKeys.message_copy_success.tr(),
);
}
},
@@ -399,6 +415,25 @@ class ChatAIMessagePopup extends StatelessWidget {
);
}
Widget _changeModelButton(BuildContext context) {
return MobileQuickActionButton(
onTap: () async {
final bloc = context.read<AIPromptInputBloc>();
final (models, _) = bloc.aiModelStateNotifier.getAvailableModels();
final result = await showChangeModelBottomSheet(context, models);
if (result != null) {
onChangeModel?.call(result);
if (context.mounted) {
Navigator.of(context).pop();
}
}
},
icon: FlowySvgs.ai_sparks_s,
iconSize: const Size.square(20),
text: LocaleKeys.chat_switchModel_label.tr(),
);
}
Widget _saveToPageButton(BuildContext context) {
return MobileQuickActionButton(
onTap: () async {
@@ -4,6 +4,7 @@ import 'package:appflowy/plugins/ai_chat/application/chat_ai_message_bloc.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_bloc.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_message_stream.dart';
import 'package:appflowy_backend/protobuf/flowy-ai/protobuf.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:fixnum/fixnum.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
@@ -36,6 +37,7 @@ class ChatAIMessageWidget extends StatelessWidget {
this.onSelectedMetadata,
this.onRegenerate,
this.onChangeFormat,
this.onChangeModel,
this.isLastMessage = false,
this.isStreaming = false,
this.isSelectingMessages = false,
@@ -53,6 +55,7 @@ class ChatAIMessageWidget extends StatelessWidget {
final void Function()? onRegenerate;
final void Function() onStopStream;
final void Function(PredefinedFormat)? onChangeFormat;
final void Function(AIModelPB)? onChangeModel;
final bool isStreaming;
final bool isLastMessage;
final bool isSelectingMessages;
@@ -110,6 +113,7 @@ class ChatAIMessageWidget extends StatelessWidget {
isSelectingMessages: isSelectingMessages,
onRegenerate: onRegenerate,
onChangeFormat: onChangeFormat,
onChangeModel: onChangeModel,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -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(
@@ -47,15 +47,6 @@ class NumberCellBloc extends Bloc<NumberCellEvent, NumberCellState> {
if (state.content != text) {
emit(state.copyWith(content: text));
await cellController.saveCellData(text);
// If the input content is "abc" that can't parsered as number then the data stored in the backend will be an empty string.
// So for every cell data that will be formatted in the backend.
// It needs to get the formatted data after saving.
add(
NumberCellEvent.didReceiveCellUpdate(
cellController.getCellData(),
),
);
}
},
);
@@ -241,6 +241,11 @@ class SelectOptionCellEditorBloc
} else if (!state.selectedOptions
.any((option) => option.id == focusedOptionId)) {
_selectOptionService.select(optionIds: [focusedOptionId]);
emit(
state.copyWith(
clearFilter: true,
),
);
}
}
@@ -411,23 +411,28 @@ class FieldController {
/// Listen for field setting changes in the backend.
void _listenOnFieldSettingsChanged() {
FieldInfo? updateFieldSettings(FieldSettingsPB updatedFieldSettings) {
final List<FieldInfo> newFields = fieldInfos;
var updatedField = newFields.firstOrNull;
final newFields = [...fieldInfos];
if (updatedField == null) {
if (newFields.isEmpty) {
return null;
}
final index = newFields
.indexWhere((field) => field.id == updatedFieldSettings.fieldId);
if (index != -1) {
newFields[index] =
newFields[index].copyWith(fieldSettings: updatedFieldSettings);
updatedField = newFields[index];
_fieldNotifier.fieldInfos = newFields;
_fieldSettings
..removeWhere(
(field) => field.fieldId == updatedFieldSettings.fieldId,
)
..add(updatedFieldSettings);
return newFields[index];
}
_fieldNotifier.fieldInfos = newFields;
return updatedField;
return null;
}
_fieldSettingsListener.start(
@@ -108,7 +108,7 @@ class _GridFieldCellState extends State<GridFieldCell> {
top: 0,
bottom: 0,
right: 0,
child: _DragToExpandLine(),
child: DragToExpandLine(),
);
return _GridHeaderCellContainer(
@@ -158,8 +158,11 @@ class _GridHeaderCellContainer extends StatelessWidget {
}
}
class _DragToExpandLine extends StatelessWidget {
const _DragToExpandLine();
@visibleForTesting
class DragToExpandLine extends StatelessWidget {
const DragToExpandLine({
super.key,
});
@override
Widget build(BuildContext context) {
@@ -1,5 +1,3 @@
import 'dart:io';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/database/application/cell/bloc/checklist_cell_bloc.dart';
@@ -16,6 +14,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:provider/provider.dart';
import 'package:universal_platform/universal_platform.dart';
import '../editable_cell_skeleton/checklist.dart';
@@ -201,19 +200,16 @@ class _ChecklistItems extends StatelessWidget {
physics: const NeverScrollableScrollPhysics(),
proxyDecorator: (child, index, _) => Material(
color: Colors.transparent,
child: Stack(
children: [
BlocProvider.value(
child: MouseRegion(
cursor: UniversalPlatform.isWindows
? SystemMouseCursors.click
: SystemMouseCursors.grabbing,
child: IgnorePointer(
child: BlocProvider.value(
value: context.read<ChecklistCellBloc>(),
child: child,
),
MouseRegion(
cursor: Platform.isWindows
? SystemMouseCursors.click
: SystemMouseCursors.grabbing,
child: const SizedBox.expand(),
),
],
),
),
),
buildDefaultDragHandles: false,
@@ -203,7 +203,7 @@ class MobileURLEditor extends StatelessWidget {
ClipboardData(text: textEditingController.text),
);
Fluttertoast.showToast(
msg: LocaleKeys.grid_url_copiedNotification.tr(),
msg: LocaleKeys.message_copy_success.tr(),
gravity: ToastGravity.BOTTOM,
);
context.pop();
@@ -14,6 +14,7 @@ import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:universal_platform/universal_platform.dart';
import '../../application/cell/bloc/checklist_cell_bloc.dart';
import 'checklist_cell_textfield.dart';
@@ -125,19 +126,16 @@ class ChecklistItemList extends StatelessWidget {
shrinkWrap: true,
proxyDecorator: (child, index, _) => Material(
color: Colors.transparent,
child: Stack(
children: [
BlocProvider.value(
child: MouseRegion(
cursor: UniversalPlatform.isWindows
? SystemMouseCursors.click
: SystemMouseCursors.grabbing,
child: IgnorePointer(
child: BlocProvider.value(
value: context.read<ChecklistCellBloc>(),
child: child,
),
MouseRegion(
cursor: Platform.isWindows
? SystemMouseCursors.click
: SystemMouseCursors.grabbing,
child: const SizedBox.expand(),
),
],
),
),
),
buildDefaultDragHandles: false,
@@ -272,7 +272,9 @@ class DocumentBloc extends Bloc<DocumentEvent, DocumentState> {
}
if (options.inMemoryUpdate) {
Log.trace('skip transaction for in-memory update');
if (enableDocumentInternalLog) {
Log.trace('skip transaction for in-memory update');
}
return;
}
@@ -440,7 +442,6 @@ class DocumentBloc extends Bloc<DocumentEvent, DocumentState> {
final context = AppGlobals.rootNavKey.currentContext;
if (context != null && context.mounted) {
showToastNotification(
context,
message: 'document integrity check failed',
type: ToastificationType.error,
);
@@ -5,6 +5,7 @@ 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_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/cover/document_immersive_cover.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/shared_context/shared_context.dart';
@@ -55,8 +56,6 @@ class _DocumentPageState extends State<DocumentPage>
Selection? initialSelection;
late final documentBloc = DocumentBloc(documentId: widget.view.id)
..add(const DocumentEvent.initial());
late final viewBloc = ViewBloc(view: widget.view)
..add(const ViewEvent.initial());
@override
void initState() {
@@ -68,7 +67,6 @@ class _DocumentPageState extends State<DocumentPage>
void dispose() {
WidgetsBinding.instance.removeObserver(this);
documentBloc.close();
viewBloc.close();
super.dispose();
}
@@ -93,7 +91,11 @@ class _DocumentPageState extends State<DocumentPage>
value: ViewLockStatusBloc(view: widget.view)
..add(ViewLockStatusEvent.initial()),
),
BlocProvider.value(value: viewBloc),
BlocProvider(
create: (context) =>
ViewBloc(view: widget.view)..add(const ViewEvent.initial()),
lazy: false,
),
],
child: BlocConsumer<ViewLockStatusBloc, ViewLockStatusState>(
listenWhen: (prev, curr) => curr.isLocked != prev.isLocked,
@@ -126,14 +128,20 @@ class _DocumentPageState extends State<DocumentPage>
return const SizedBox.shrink();
}
return BlocListener<ViewLockStatusBloc, ViewLockStatusState>(
listener: (context, state) {
editorState.editable = !state.isLocked;
},
child:
BlocListener<ActionNavigationBloc, ActionNavigationState>(
listenWhen: (_, curr) => curr.action != null,
listener: onNotificationAction,
return MultiBlocListener(
listeners: [
BlocListener<ViewLockStatusBloc, ViewLockStatusState>(
listener: (context, state) =>
editorState.editable = !state.isLocked,
),
BlocListener<ActionNavigationBloc, ActionNavigationState>(
listenWhen: (_, curr) => curr.action != null,
listener: onNotificationAction,
),
],
child: AiWriterScrollWrapper(
viewId: widget.view.id,
editorState: editorState,
child: buildEditorPage(context, state),
),
);
@@ -16,6 +16,7 @@ import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:universal_platform/universal_platform.dart';
import 'editor_plugins/link_preview/custom_link_preview_block_component.dart';
import 'editor_plugins/page_block/custom_page_block_component.dart';
/// A global configuration for the editor.
@@ -969,11 +970,11 @@ OutlineBlockComponentBuilder _buildOutlineBlockComponentBuilder(
);
}
LinkPreviewBlockComponentBuilder _buildLinkPreviewBlockComponentBuilder(
CustomLinkPreviewBlockComponentBuilder _buildLinkPreviewBlockComponentBuilder(
BuildContext context,
BlockComponentConfiguration configuration,
) {
return LinkPreviewBlockComponentBuilder(
return CustomLinkPreviewBlockComponentBuilder(
configuration: configuration.copyWith(
padding: (node) {
if (UniversalPlatform.isMobile) {
@@ -983,20 +984,6 @@ LinkPreviewBlockComponentBuilder _buildLinkPreviewBlockComponentBuilder(
},
),
cache: LinkPreviewDataCache(),
showMenu: true,
menuBuilder: (context, node, state) => Positioned(
top: 10,
right: 0,
child: LinkPreviewMenu(node: node, state: state),
),
builder: (_, node, url, title, description, imageUrl) =>
CustomLinkPreviewWidget(
node: node,
url: url,
title: title,
description: description,
imageUrl: imageUrl,
),
);
}
@@ -22,6 +22,7 @@ import 'package:appflowy/workspace/presentation/home/af_focus_manager.dart';
import 'package:appflowy_editor/appflowy_editor.dart' hide QuoteBlockKeys;
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';
@@ -350,6 +351,8 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage>
final isViewDeleted = context.read<DocumentBloc>().state.isDeleted;
final isLocked =
context.read<ViewLockStatusBloc?>()?.state.isLocked ?? false;
final themeV2 = AFThemeExtensionV2.of(context);
final editor = Directionality(
textDirection: textDirection,
child: AppFlowyEditor(
@@ -393,7 +396,7 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage>
},
child: SizedBox(
width: double.infinity,
height: UniversalPlatform.isDesktopOrWeb ? 300 : 400,
height: UniversalPlatform.isDesktopOrWeb ? 600 : 400,
),
),
dropTargetStyle: AppFlowyDropTargetStyle(
@@ -428,37 +431,51 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage>
),
);
}
return Center(
child: FloatingToolbar(
floatingToolbarHeight: 40,
padding: EdgeInsets.symmetric(horizontal: 6),
style: FloatingToolbarStyle(
backgroundColor: Theme.of(context).cardColor,
toolbarActiveColor: Color(0xffe0f8fd),
toolbarElevation: 10,
),
items: toolbarItems,
decoration: ShapeDecoration(
color: Theme.of(context).cardColor,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)),
),
toolbarBuilder: (context, child) => DesktopFloatingToolbar(
child: BlocProvider.value(
value: context.read<DocumentBloc>(),
child: FloatingToolbar(
floatingToolbarHeight: 40,
padding: EdgeInsets.symmetric(horizontal: 6),
style: FloatingToolbarStyle(
backgroundColor: Theme.of(context).cardColor,
toolbarActiveColor: Color(0xffe0f8fd),
),
items: toolbarItems,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: Theme.of(context).cardColor,
boxShadow: [
BoxShadow(
offset: Offset(0, 4),
blurRadius: 24,
color: themeV2.shadow_medium,
),
],
),
toolbarBuilder: (_, child, onDismiss, isMetricsChanged) =>
BlocProvider.value(
value: context.read<DocumentBloc>(),
child: DesktopFloatingToolbar(
editorState: editorState,
onDismiss: onDismiss,
enableAnimation: !isMetricsChanged,
child: child,
),
),
placeHolderBuilder: (_) => customPlaceholderItem,
editorState: editorState,
child: child,
editorScrollController: editorScrollController,
textDirection: textDirection,
tooltipBuilder: (context, id, message, child) =>
widget.styleCustomizer.buildToolbarItemTooltip(
context,
id,
message,
child,
),
child: editor,
),
placeHolderBuilder: (_) => customPlaceholderItem,
editorState: editorState,
editorScrollController: editorScrollController,
textDirection: textDirection,
tooltipBuilder: (context, id, message, child) =>
widget.styleCustomizer.buildToolbarItemTooltip(
context,
id,
message,
child,
),
child: editor,
),
);
}
@@ -469,7 +469,7 @@ class BlockActionOptionCubit extends Cubit<BlockActionOptionState> {
blockComponentDelta: newDelta.toJson(),
},
children: [
...node.children,
...node.children.map((e) => e.deepCopy()),
...insertedNodes.map((e) => e.deepCopy()),
],
);
@@ -2,8 +2,7 @@ import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/block_action_option_cubit.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/workspace/presentation/home/menu/menu_shared_state.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/toolbar_item/text_suggestions_toolbar_item.dart';
import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart';
import 'package:appflowy_editor/appflowy_editor.dart'
hide QuoteBlockKeys, quoteNode;
@@ -149,214 +148,134 @@ class TurnIntoOptionMenu extends StatelessWidget {
@override
Widget build(BuildContext context) {
if (hasNonSupportedTypes) {
return buildItem(
pateItem,
textSuggestionItem,
context.read<BlockActionOptionCubit>().editorState,
);
}
return _buildTurnIntoOptions(context, node);
}
Widget _buildTurnIntoOptions(BuildContext context, Node node) {
final editorState = context.read<BlockActionOptionCubit>().editorState;
SuggestionItem currentSuggestionItem = textSuggestionItem;
final List<SuggestionItem> suggestionItems = suggestions.sublist(0, 4);
final List<SuggestionItem> turnIntoItems =
suggestions.sublist(4, suggestions.length);
final textColor = Color(0xff99A1A8);
void refreshSuggestions() {
final selection = editorState.selection;
if (selection == null || !selection.isSingle) return;
final node = editorState.getNodeAtPath(selection.start.path);
if (node == null || node.delta == null) return;
final nodeType = node.type;
SuggestionType? suggestionType;
if (nodeType == HeadingBlockKeys.type) {
final level = node.attributes[HeadingBlockKeys.level] ?? 1;
if (level == 1) {
suggestionType = SuggestionType.h1;
} else if (level == 2) {
suggestionType = SuggestionType.h2;
} else if (level == 3) {
suggestionType = SuggestionType.h3;
}
} else if (nodeType == ToggleListBlockKeys.type) {
final level = node.attributes[ToggleListBlockKeys.level];
if (level == null) {
suggestionType = SuggestionType.toggle;
} else if (level == 1) {
suggestionType = SuggestionType.toggleH1;
} else if (level == 2) {
suggestionType = SuggestionType.toggleH2;
} else if (level == 3) {
suggestionType = SuggestionType.toggleH3;
}
} else {
suggestionType = nodeType2SuggestionType[nodeType];
}
if (suggestionType == null) return;
suggestionItems.clear();
turnIntoItems.clear();
for (final item in suggestions) {
if (item.type.group == suggestionType.group &&
item.type != suggestionType) {
suggestionItems.add(item);
} else {
turnIntoItems.add(item);
}
}
currentSuggestionItem =
suggestions.where((item) => item.type == suggestionType).first;
}
refreshSuggestions();
return Column(
mainAxisSize: MainAxisSize.min,
children: _buildTurnIntoOptions(context, node),
crossAxisAlignment: CrossAxisAlignment.start,
children: [
buildSubTitle(
LocaleKeys.document_toolbar_suggestions.tr(),
textColor,
),
...List.generate(suggestionItems.length, (index) {
return buildItem(
suggestionItems[index],
currentSuggestionItem,
editorState,
);
}),
buildSubTitle(LocaleKeys.document_toolbar_turnInto.tr(), textColor),
...List.generate(turnIntoItems.length, (index) {
return buildItem(
turnIntoItems[index],
currentSuggestionItem,
editorState,
);
}),
],
);
}
List<Widget> _buildTurnIntoOptions(BuildContext context, Node node) {
final children = <Widget>[];
if (hasNonSupportedTypes) {
return children
..add(
_TurnInfoButton(
type: SubPageBlockKeys.type,
node: node,
),
);
}
for (final type in EditorOptionActionType.turnInto.supportTypes) {
if (type == ToggleListBlockKeys.type) {
// toggle list block and toggle heading block are the same type,
// but they have different attributes.
// toggle list block
children.add(
_TurnInfoButton(
type: type,
node: node,
),
);
// toggle heading block
for (final i in [1, 2, 3]) {
children.add(
_TurnInfoButton(
type: type,
node: node,
level: i,
),
);
}
} else if (type != HeadingBlockKeys.type) {
children.add(
_TurnInfoButton(
type: type,
node: node,
),
);
} else {
for (final i in [1, 2, 3]) {
children.add(
_TurnInfoButton(
type: type,
node: node,
level: i,
),
);
}
}
}
return children;
}
}
class _TurnInfoButton extends StatelessWidget {
const _TurnInfoButton({
required this.type,
required this.node,
this.level,
});
final String type;
final Node node;
final int? level;
@override
Widget build(BuildContext context) {
final name = _buildLocalization(type, level: level);
final leftIcon = _buildLeftIcon(type, level: level);
final rightIcon = _buildRightIcon(type, node, level: level);
return HoverButton(
name: name,
leftIcon: FlowySvg(leftIcon),
rightIcon: rightIcon,
itemHeight: ActionListSizes.itemHeight,
onTap: () => BlockActionOptionCubit.turnIntoBlock(
type,
node,
context.read<BlockActionOptionCubit>().editorState,
level: level,
currentViewId: getIt<MenuSharedState>().latestOpenView?.id,
Widget buildSubTitle(String text, Color color) {
return Container(
height: 32,
margin: EdgeInsets.symmetric(horizontal: 8),
child: Align(
alignment: Alignment.centerLeft,
child: FlowyText.semibold(
text,
color: color,
figmaLineHeight: 16,
),
),
);
}
Widget? _buildRightIcon(String type, Node node, {int? level}) {
if (type != node.type) {
return null;
}
if (node.type == HeadingBlockKeys.type) {
final nodeLevel = node.attributes[HeadingBlockKeys.level] ?? 1;
if (level != nodeLevel) {
return null;
}
}
if (node.type == ToggleListBlockKeys.type) {
final nodeLevel = node.attributes[ToggleListBlockKeys.level];
if (level != nodeLevel) {
return null;
}
}
return const FlowySvg(
FlowySvgs.workspace_selected_s,
blendMode: null,
Widget buildItem(
SuggestionItem item,
SuggestionItem currentSuggestionItem,
EditorState state,
) {
final isSelected = item.type == currentSuggestionItem.type;
return SizedBox(
height: 36,
child: FlowyButton(
leftIconSize: const Size.square(20),
leftIcon: FlowySvg(item.svg),
iconPadding: 12,
text: FlowyText(
item.title,
fontWeight: FontWeight.w400,
figmaLineHeight: 20,
),
rightIcon: isSelected ? FlowySvg(FlowySvgs.toolbar_check_m) : null,
onTap: () => item.onTap.call(state, false),
),
);
}
FlowySvgData _buildLeftIcon(String type, {int? level}) {
if (type == ParagraphBlockKeys.type) {
return FlowySvgs.type_text_m;
} else if (type == HeadingBlockKeys.type) {
switch (level) {
case 1:
return FlowySvgs.type_h1_m;
case 2:
return FlowySvgs.type_h2_m;
case 3:
return FlowySvgs.type_h3_m;
default:
return FlowySvgs.type_text_m;
}
} else if (type == QuoteBlockKeys.type) {
return FlowySvgs.type_quote_m;
} else if (type == BulletedListBlockKeys.type) {
return FlowySvgs.type_bulleted_list_m;
} else if (type == NumberedListBlockKeys.type) {
return FlowySvgs.type_numbered_list_m;
} else if (type == TodoListBlockKeys.type) {
return FlowySvgs.type_todo_m;
} else if (type == CalloutBlockKeys.type) {
return FlowySvgs.type_callout_m;
} else if (type == SubPageBlockKeys.type) {
return FlowySvgs.icon_document_s;
} else if (type == ToggleListBlockKeys.type) {
switch (level) {
case 1:
return FlowySvgs.type_toggle_h1_m;
case 2:
return FlowySvgs.type_toggle_h2_m;
case 3:
return FlowySvgs.type_toggle_h3_m;
default:
return FlowySvgs.type_toggle_list_m;
}
}
throw UnimplementedError('Unsupported block type: $type');
}
String _buildLocalization(
String type, {
int? level,
}) {
switch (type) {
case ParagraphBlockKeys.type:
return LocaleKeys.document_slashMenu_name_text.tr();
case HeadingBlockKeys.type:
switch (level) {
case 1:
return LocaleKeys.document_slashMenu_name_heading1.tr();
case 2:
return LocaleKeys.document_slashMenu_name_heading2.tr();
case 3:
return LocaleKeys.document_slashMenu_name_heading3.tr();
default:
return LocaleKeys.document_slashMenu_name_text.tr();
}
case QuoteBlockKeys.type:
return LocaleKeys.document_slashMenu_name_quote.tr();
case BulletedListBlockKeys.type:
return LocaleKeys.editor_bulletedListShortForm.tr();
case NumberedListBlockKeys.type:
return LocaleKeys.editor_numberedListShortForm.tr();
case TodoListBlockKeys.type:
return LocaleKeys.editor_checkbox.tr();
case CalloutBlockKeys.type:
return LocaleKeys.document_slashMenu_name_callout.tr();
case SubPageBlockKeys.type:
return LocaleKeys.editor_page.tr();
case ToggleListBlockKeys.type:
switch (level) {
case 1:
return LocaleKeys.editor_toggleHeading1ShortForm.tr();
case 2:
return LocaleKeys.editor_toggleHeading2ShortForm.tr();
case 3:
return LocaleKeys.editor_toggleHeading3ShortForm.tr();
default:
return LocaleKeys.editor_toggleListShortForm.tr();
}
}
throw UnimplementedError('Unsupported block type: $type');
}
}
@@ -2,14 +2,11 @@ import 'package:appflowy/ai/ai.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/ai_chat/presentation/message/ai_markdown_text.dart';
import 'package:appflowy/plugins/document/application/prelude.dart';
import 'package:appflowy/plugins/document/application/document_bloc.dart';
import 'package:appflowy/util/theme_extension.dart';
import 'package:appflowy/workspace/application/view/view_bloc.dart';
import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/shared_widget.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/colorscheme/default_colorscheme.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
@@ -18,8 +15,8 @@ import 'package:universal_platform/universal_platform.dart';
import 'operations/ai_writer_cubit.dart';
import 'operations/ai_writer_entities.dart';
import 'operations/ai_writer_node_extension.dart';
import 'suggestion_action_bar.dart';
import 'widgets/ai_writer_gesture_detector.dart';
import 'widgets/ai_writer_suggestion_actions.dart';
import 'widgets/ai_writer_prompt_input_more_button.dart';
class AiWriterBlockKeys {
const AiWriterBlockKeys._();
@@ -98,18 +95,12 @@ class AiWriterBlockComponent extends BlockComponentStatefulWidget {
}
class _AIWriterBlockComponentState extends State<AiWriterBlockComponent> {
final key = GlobalKey();
final textController = TextEditingController();
final overlayController = OverlayPortalController();
final layerLink = LayerLink();
final focusNode = FocusNode();
late final editorState = context.read<EditorState>();
late final aiWriterCubit = AiWriterCubit(
documentId: context.read<DocumentBloc>().documentId,
editorState: editorState,
getAiWriterNode: () => widget.node,
initialCommand: widget.node.aiWriterCommand,
);
@override
void initState() {
@@ -117,16 +108,14 @@ class _AIWriterBlockComponentState extends State<AiWriterBlockComponent> {
WidgetsBinding.instance.addPostFrameCallback((_) {
overlayController.show();
if (!widget.node.isAiWriterInitialized) {
aiWriterCubit.init();
}
context.read<AiWriterCubit>().register(widget.node);
});
}
@override
void dispose() {
textController.dispose();
aiWriterCubit.close();
focusNode.dispose();
super.dispose();
}
@@ -136,85 +125,49 @@ class _AIWriterBlockComponentState extends State<AiWriterBlockComponent> {
return const SizedBox.shrink();
}
return MultiBlocProvider(
providers: [
BlocProvider.value(
value: aiWriterCubit,
),
BlocProvider(
create: (_) => AIPromptInputBloc(
predefinedFormat: null,
),
),
],
final documentId = context.read<DocumentBloc?>()?.documentId;
return BlocProvider(
create: (_) => AIPromptInputBloc(
predefinedFormat: null,
objectId: documentId ?? editorState.document.root.id,
),
child: LayoutBuilder(
builder: (context, constraints) {
return BlocListener<AiWriterCubit, AiWriterState>(
listener: (context, state) {
if (state is FailedContinueWritingAiWriterState) {
showConfirmDialog(
context: context,
title: LocaleKeys.ai_continueWritingEmptyDocumentTitle.tr(),
description: LocaleKeys
.ai_continueWritingEmptyDocumentDescription
.tr(),
onConfirm: state.onConfirm,
);
} else if (state is DiscardResponseAiWriterState) {
showConfirmDialog(
context: context,
title: LocaleKeys.button_discard.tr(),
description: LocaleKeys.document_plugins_discardResponse.tr(),
confirmLabel: LocaleKeys.button_discard.tr(),
style: ConfirmPopupStyle.cancelAndOk,
onConfirm: state.onDiscard,
onCancel: () {},
);
}
},
child: OverlayPortal(
controller: overlayController,
overlayChildBuilder: (context) {
return Stack(
children: [
BlocBuilder<AiWriterCubit, AiWriterState>(
builder: (context, state) {
return AiWriterGestureDetector(
behavior: state is GeneratingAiWriterState
? HitTestBehavior.opaque
: HitTestBehavior.translucent,
onPointerEvent: onTapOutside,
);
},
return OverlayPortal(
controller: overlayController,
overlayChildBuilder: (context) {
return Center(
child: CompositedTransformFollower(
link: layerLink,
showWhenUnlinked: false,
child: Container(
padding: const EdgeInsets.only(
left: 40.0,
bottom: 16.0,
),
CompositedTransformFollower(
link: layerLink,
showWhenUnlinked: false,
child: Container(
padding: const EdgeInsets.only(
left: 40.0,
bottom: 16.0,
),
width: constraints.maxWidth,
child: OverlayContent(
editorState: editorState,
node: widget.node,
),
width: constraints.maxWidth,
child: Focus(
focusNode: focusNode,
child: OverlayContent(
editorState: editorState,
node: widget.node,
textController: textController,
),
),
],
);
},
child: CompositedTransformTarget(
link: layerLink,
child: BlocBuilder<AiWriterCubit, AiWriterState>(
builder: (context, state) {
return SizedBox(
key: key,
width: double.infinity,
);
},
),
),
);
},
child: CompositedTransformTarget(
link: layerLink,
child: BlocBuilder<AiWriterCubit, AiWriterState>(
builder: (context, state) {
return SizedBox(
width: double.infinity,
height: 1.0,
);
},
),
),
);
@@ -222,80 +175,84 @@ class _AIWriterBlockComponentState extends State<AiWriterBlockComponent> {
),
);
}
void onTapOutside() {
if (aiWriterCubit.hasUnusedResponse()) {
showConfirmDialog(
context: context,
title: LocaleKeys.button_discard.tr(),
description: LocaleKeys.document_plugins_discardResponse.tr(),
confirmLabel: LocaleKeys.button_discard.tr(),
style: ConfirmPopupStyle.cancelAndOk,
onConfirm: () => aiWriterCubit
..stopStream()
..exit(),
onCancel: () {},
);
} else {
aiWriterCubit
..stopStream()
..exit();
}
}
}
class OverlayContent extends StatelessWidget {
class OverlayContent extends StatefulWidget {
const OverlayContent({
super.key,
required this.editorState,
required this.node,
required this.textController,
});
final EditorState editorState;
final Node node;
final TextEditingController textController;
@override
State<OverlayContent> createState() => _OverlayContentState();
}
class _OverlayContentState extends State<OverlayContent> {
final showCommandsToggle = ValueNotifier(false);
@override
void dispose() {
showCommandsToggle.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return BlocBuilder<AiWriterCubit, AiWriterState>(
builder: (context, state) {
final selection = node.aiWriterSelection;
final showSuggestionPopup =
state is ReadyAiWriterState && !state.isFirstRun;
final showActionPopup = state is ReadyAiWriterState && state.isFirstRun;
if (state is IdleAiWriterState ||
state is DocumentContentEmptyAiWriterState) {
return const SizedBox.shrink();
}
final command = (state as RegisteredAiWriter).command;
final selection = widget.node.aiWriterSelection;
final hasSelection = selection != null && !selection.isCollapsed;
final markdownText = switch (state) {
final ReadyAiWriterState ready => ready.markdownText,
final GeneratingAiWriterState generating => generating.markdownText,
_ => '',
};
final hasSelection = selection != null && !selection.isCollapsed;
final isLightMode = Theme.of(context).isLightMode;
final darkBorderColor =
isLightMode ? Color(0x1F1F2329) : Color(0xFF505469);
final lightBorderColor =
Theme.of(context).brightness == Brightness.light
? ColorSchemeConstants.lightBorderColor
: ColorSchemeConstants.darkBorderColor;
final showSuggestedActions =
state is ReadyAiWriterState && !state.isFirstRun;
final isInitialReadyState =
state is ReadyAiWriterState && state.isFirstRun;
final showSuggestedActionsPopup =
showSuggestedActions && markdownText.isEmpty ||
(markdownText.isNotEmpty && command != AiWriterCommand.explain);
final showSuggestedActionsWithin = showSuggestedActions &&
markdownText.isNotEmpty &&
command == AiWriterCommand.explain;
final borderColor = Theme.of(context).isLightMode
? Color(0x1F1F2329)
: Color(0xFF505469);
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (showSuggestionPopup &&
state.command != AiWriterCommand.explain) ...[
if (showSuggestedActionsPopup) ...[
Container(
padding: EdgeInsets.all(4.0),
decoration: _getModalDecoration(
context,
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.all(Radius.circular(8.0)),
borderColor: darkBorderColor,
borderColor: borderColor,
),
child: SuggestionActionBar(
actions: _getSuggestedActions(
currentCommand: state.command,
hasSelection: hasSelection,
),
currentCommand: command,
hasSelection: hasSelection,
onTap: (action) {
_onSelectSuggestionAction(context, action);
},
@@ -303,93 +260,74 @@ class OverlayContent extends StatelessWidget {
),
const VSpace(4.0 + 1.0),
],
DecoratedBox(
Container(
decoration: _getModalDecoration(
context,
color: null,
borderColor: darkBorderColor,
borderColor: borderColor,
borderRadius: BorderRadius.all(Radius.circular(12.0)),
),
constraints: BoxConstraints(maxHeight: 400),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (markdownText.isNotEmpty) ...[
DecoratedBox(
decoration: _getHelperChildDecoration(context),
child: Container(
constraints: BoxConstraints(maxHeight: 140),
width: double.infinity,
padding: EdgeInsets.symmetric(horizontal: 8.0),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: SingleChildScrollView(
physics: ClampingScrollPhysics(),
padding: EdgeInsets.only(top: 8.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
height: 24.0,
padding:
EdgeInsets.symmetric(horizontal: 6.0),
alignment:
AlignmentDirectional.centerStart,
child: FlowyText(
state.command.i18n,
fontSize: 12,
fontWeight: FontWeight.w600,
color: Color(0xFF666D76),
),
),
const VSpace(4.0),
Padding(
padding:
EdgeInsets.symmetric(horizontal: 6.0),
child: AIMarkdownText(
markdown: markdownText,
),
),
],
),
),
),
if (showSuggestionPopup) ...[
const VSpace(4.0),
SuggestionActionBar(
actions: _getSuggestedActions(
currentCommand: state.command,
hasSelection: hasSelection,
),
onTap: (action) {
_onSelectSuggestionAction(context, action);
},
),
],
const VSpace(8.0),
],
Flexible(
child: DecoratedBox(
decoration: _secondaryContentDecoration(context),
child: SecondaryContentArea(
markdownText: markdownText,
onSelectSuggestionAction: (action) {
_onSelectSuggestionAction(context, action);
},
command: command,
showSuggestionActions: showSuggestedActionsWithin,
hasSelection: hasSelection,
),
),
),
Divider(
height: 1.0,
),
Divider(height: 1.0),
],
DecoratedBox(
decoration: markdownText.isNotEmpty
? _getInputChildDecoration(context)
? _mainContentDecoration(context)
: _getSingleChildDeocoration(context),
child: MainContentArea(),
child: MainContentArea(
textController: widget.textController,
isDocumentEmpty: _isDocumentEmpty(),
isInitialReadyState: isInitialReadyState,
showCommandsToggle: showCommandsToggle,
),
),
],
),
),
..._bottomActions(
context,
showActionPopup,
hasSelection,
lightBorderColor,
ValueListenableBuilder(
valueListenable: showCommandsToggle,
builder: (context, value, child) {
if (!value || !isInitialReadyState) {
return const SizedBox.shrink();
}
return Align(
alignment: AlignmentDirectional.centerEnd,
child: MoreAiWriterCommands(
hasSelection: hasSelection,
editorState: widget.editorState,
onSelectCommand: (command) {
final state = context.read<AIPromptInputBloc>().state;
final showPredefinedFormats = state.showPredefinedFormats;
final predefinedFormat = state.predefinedFormat;
final text = widget.textController.text;
context.read<AiWriterCubit>().runCommand(
command,
text,
showPredefinedFormats ? predefinedFormat : null,
);
},
),
);
},
),
],
);
@@ -397,39 +335,6 @@ class OverlayContent extends StatelessWidget {
);
}
Widget _bottomButton(AiWriterCommand command) {
return Builder(
builder: (context) {
return SizedBox(
height: 30.0,
child: FlowyButton(
leftIcon: FlowySvg(
command.icon,
size: const Size.square(16),
color: Theme.of(context).iconTheme.color,
),
margin: const EdgeInsets.all(6.0),
text: FlowyText(
command.i18n,
figmaLineHeight: 20,
),
onTap: () {
final aiInputBloc = context.read<AIPromptInputBloc>();
final showPredefinedFormats =
aiInputBloc.state.showPredefinedFormats;
final predefinedFormat = aiInputBloc.state.predefinedFormat;
context.read<AiWriterCubit>().runCommand(
command,
showPredefinedFormats ? predefinedFormat : null,
);
},
),
);
},
);
}
BoxDecoration _getModalDecoration(
BuildContext context, {
required Color? color,
@@ -443,13 +348,9 @@ class OverlayContent extends StatelessWidget {
strokeAlign: BorderSide.strokeAlignOutside,
),
borderRadius: borderRadius,
boxShadow: const [
BoxShadow(
offset: Offset(0, 4),
blurRadius: 20,
color: Color(0x1A1F2329),
),
],
boxShadow: Theme.of(context).isLightMode
? ShadowConstants.lightSmall
: ShadowConstants.darkSmall,
);
}
@@ -460,133 +361,20 @@ class OverlayContent extends StatelessWidget {
);
}
BoxDecoration _getHelperChildDecoration(BuildContext context) {
BoxDecoration _secondaryContentDecoration(BuildContext context) {
return BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.vertical(top: Radius.circular(12.0)),
);
}
BoxDecoration _getInputChildDecoration(BuildContext context) {
BoxDecoration _mainContentDecoration(BuildContext context) {
return BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.vertical(bottom: Radius.circular(12.0)),
);
}
List<Widget> _bottomActions(
BuildContext context,
bool showActionPopup,
bool hasSelection,
Color borderColor,
) {
if (!showActionPopup) {
return [];
}
if (editorState.isEmptyForContinueWriting()) {
final documentContext = editorState.document.root.context;
if (documentContext == null) {
return [];
}
final view = documentContext.read<ViewBloc>().state.view;
if (view.name.isEmpty) {
return [];
}
}
return [
// add one here to take into account the border of the main message box.
// It is configured to be on the outside to hide some graphical
// artifacts.
const VSpace(4.0 + 1.0),
Container(
padding: EdgeInsets.all(8.0),
constraints: BoxConstraints(minWidth: 240.0),
decoration: _getModalDecoration(
context,
color: Theme.of(context).colorScheme.surface,
borderColor: borderColor,
borderRadius: BorderRadius.all(Radius.circular(8.0)),
),
child: IntrinsicWidth(
child: SeparatedColumn(
separatorBuilder: () => const VSpace(4.0),
crossAxisAlignment: CrossAxisAlignment.start,
children: _getCommands(
hasSelection: hasSelection,
),
),
),
),
];
}
List<Widget> _getCommands({required bool hasSelection}) {
if (hasSelection) {
return [
_bottomButton(AiWriterCommand.improveWriting),
_bottomButton(AiWriterCommand.fixSpellingAndGrammar),
_bottomButton(AiWriterCommand.explain),
const Divider(height: 1.0, thickness: 1.0),
_bottomButton(AiWriterCommand.makeLonger),
_bottomButton(AiWriterCommand.makeShorter),
];
} else {
return [
_bottomButton(AiWriterCommand.continueWriting),
];
}
}
List<SuggestionAction> _getSuggestedActions({
required AiWriterCommand currentCommand,
required bool hasSelection,
}) {
if (hasSelection) {
return switch (currentCommand) {
AiWriterCommand.userQuestion || AiWriterCommand.continueWriting => [
SuggestionAction.keep,
SuggestionAction.discard,
SuggestionAction.rewrite,
],
AiWriterCommand.explain => [
SuggestionAction.insertBelow,
SuggestionAction.tryAgain,
SuggestionAction.close,
],
AiWriterCommand.fixSpellingAndGrammar ||
AiWriterCommand.improveWriting ||
AiWriterCommand.makeShorter ||
AiWriterCommand.makeLonger =>
[
SuggestionAction.accept,
SuggestionAction.discard,
SuggestionAction.insertBelow,
SuggestionAction.rewrite,
],
};
} else {
return switch (currentCommand) {
AiWriterCommand.userQuestion || AiWriterCommand.continueWriting => [
SuggestionAction.keep,
SuggestionAction.discard,
SuggestionAction.rewrite,
],
AiWriterCommand.explain => [
SuggestionAction.insertBelow,
SuggestionAction.tryAgain,
SuggestionAction.close,
],
_ => [
SuggestionAction.keep,
SuggestionAction.discard,
SuggestionAction.rewrite,
],
};
}
}
void _onSelectSuggestionAction(
BuildContext context,
SuggestionAction action,
@@ -598,10 +386,99 @@ class OverlayContent extends StatelessWidget {
predefinedFormat,
);
}
bool _isDocumentEmpty() {
if (widget.editorState.isEmptyForContinueWriting()) {
final documentContext = widget.editorState.document.root.context;
if (documentContext == null) {
return true;
}
final view = documentContext.read<ViewBloc>().state.view;
if (view.name.isEmpty) {
return true;
}
}
return false;
}
}
class SecondaryContentArea extends StatelessWidget {
const SecondaryContentArea({
super.key,
required this.command,
required this.markdownText,
required this.showSuggestionActions,
required this.hasSelection,
required this.onSelectSuggestionAction,
});
final AiWriterCommand command;
final String markdownText;
final bool showSuggestionActions;
final bool hasSelection;
final void Function(SuggestionAction) onSelectSuggestionAction;
@override
Widget build(BuildContext context) {
return SizedBox(
width: double.infinity,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const VSpace(8.0),
Container(
height: 24.0,
padding: EdgeInsets.symmetric(horizontal: 14.0),
alignment: AlignmentDirectional.centerStart,
child: FlowyText(
command.i18n,
fontSize: 12,
fontWeight: FontWeight.w600,
color: Color(0xFF666D76),
),
),
const VSpace(4.0),
Flexible(
child: SingleChildScrollView(
physics: ClampingScrollPhysics(),
padding: EdgeInsets.symmetric(horizontal: 14.0),
child: AIMarkdownText(
markdown: markdownText,
),
),
),
if (showSuggestionActions) ...[
const VSpace(4.0),
Padding(
padding: EdgeInsets.symmetric(horizontal: 8.0),
child: SuggestionActionBar(
currentCommand: command,
hasSelection: hasSelection,
onTap: onSelectSuggestionAction,
),
),
],
const VSpace(8.0),
],
),
);
}
}
class MainContentArea extends StatelessWidget {
const MainContentArea({super.key});
const MainContentArea({
super.key,
required this.textController,
required this.isInitialReadyState,
required this.isDocumentEmpty,
required this.showCommandsToggle,
});
final TextEditingController textController;
final bool isInitialReadyState;
final bool isDocumentEmpty;
final ValueNotifier<bool> showCommandsToggle;
@override
Widget build(BuildContext context) {
@@ -613,7 +490,16 @@ class MainContentArea extends StatelessWidget {
return DesktopPromptInput(
isStreaming: false,
hideDecoration: true,
onSubmitted: (message, format, _) => cubit.submit(message, format),
hideFormats: [
AiWriterCommand.fixSpellingAndGrammar,
AiWriterCommand.improveWriting,
AiWriterCommand.makeLonger,
AiWriterCommand.makeShorter,
].contains(state.command),
textController: textController,
onSubmitted: (message, format, _) {
cubit.runCommand(state.command, message, format);
},
onStopStreaming: () => cubit.stopStream(),
selectedSourcesNotifier: cubit.selectedSourcesNotifier,
onUpdateSelectedSources: (sources) {
@@ -621,6 +507,18 @@ class MainContentArea extends StatelessWidget {
...sources,
];
},
extraBottomActionButton: isInitialReadyState
? ValueListenableBuilder(
valueListenable: showCommandsToggle,
builder: (context, value, _) {
return AiWriterPromptMoreButton(
isEnabled: !isDocumentEmpty,
isSelected: value,
onTap: () => showCommandsToggle.value = !value,
);
},
)
: null,
);
}
if (state is GeneratingAiWriterState) {
@@ -676,6 +574,26 @@ class MainContentArea extends StatelessWidget {
),
);
}
if (state is LocalAIStreamingAiWriterState) {
final text = switch (state.state) {
LocalAIStreamingState.notReady =>
LocaleKeys.settings_aiPage_keys_localAINotReadyRetryLater.tr(),
LocalAIStreamingState.disabled =>
LocaleKeys.settings_aiPage_keys_localAIDisabled.tr(),
};
return Padding(
padding: EdgeInsets.all(8.0),
child: Row(
children: [
const HSpace(8.0),
Opacity(
opacity: 0.5,
child: FlowyText(text),
),
],
),
);
}
return const SizedBox.shrink();
},
);
@@ -6,6 +6,7 @@ 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: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';
@@ -118,7 +119,7 @@ class _AiWriterToolbarActionListState extends State<AiWriterToolbarActionList> {
}
Widget buildChild(BuildContext context) {
final iconColor = Theme.of(context).iconTheme.color;
final themeV2 = AFThemeExtensionV2.of(context);
final child = FlowyIconButton(
width: 48,
height: 32,
@@ -130,13 +131,13 @@ class _AiWriterToolbarActionListState extends State<AiWriterToolbarActionList> {
FlowySvg(
FlowySvgs.toolbar_ai_writer_m,
size: Size.square(20),
color: iconColor,
color: themeV2.icon_primary,
),
HSpace(4),
FlowySvg(
FlowySvgs.toolbar_arrow_down_m,
size: Size(12, 20),
color: iconColor,
color: themeV2.icon_tertiary,
),
],
),
@@ -149,7 +150,6 @@ class _AiWriterToolbarActionListState extends State<AiWriterToolbarActionList> {
});
} else {
showToastNotification(
context,
message: LocaleKeys.document_plugins_appflowyAIEditDisabled.tr(),
);
}
@@ -187,7 +187,7 @@ class ImproveWritingButton extends StatelessWidget {
icon: FlowySvg(
FlowySvgs.toolbar_ai_improve_writing_m,
size: Size.square(20.0),
color: Theme.of(context).iconTheme.color,
color: AFThemeExtensionV2.of(context).icon_primary,
),
onPressed: () {
if (_isAIEnabled(editorState)) {
@@ -195,7 +195,6 @@ class ImproveWritingButton extends StatelessWidget {
_insertAiNode(editorState, AiWriterCommand.improveWriting);
} else {
showToastNotification(
context,
message: LocaleKeys.document_plugins_appflowyAIEditDisabled.tr(),
);
}
@@ -1,10 +1,45 @@
import 'dart:async';
import 'package:appflowy/shared/markdown_to_document.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import '../ai_writer_block_component.dart';
import 'ai_writer_entities.dart';
import 'ai_writer_node_extension.dart';
Future<void> removeAiWriterNode(EditorState editorState, Node node) async {
Future<void> setAiWriterNodeIsInitialized(
EditorState editorState,
Node node,
) async {
final transaction = editorState.transaction
..updateNode(node, {
AiWriterBlockKeys.isInitialized: true,
});
await editorState.apply(
transaction,
options: const ApplyOptions(
recordUndo: false,
inMemoryUpdate: true,
),
withUpdateSelection: false,
);
final selection = node.aiWriterSelection;
if (selection != null && !selection.isCollapsed) {
unawaited(
editorState.updateSelectionWithReason(
selection,
extraInfo: {selectionExtraInfoDisableToolbar: true},
),
);
}
}
Future<void> removeAiWriterNode(
EditorState editorState,
Node node,
) async {
final transaction = editorState.transaction..deleteNode(node);
await editorState.apply(
transaction,
@@ -13,16 +48,16 @@ Future<void> removeAiWriterNode(EditorState editorState, Node node) async {
);
}
void formatSelection(
Future<void> formatSelection(
EditorState editorState,
Selection selection,
Transaction transaction,
ApplySuggestionFormatType formatType,
) {
) async {
final nodes = editorState.getNodesInSelection(selection).toList();
if (nodes.isEmpty) {
return;
}
final transaction = editorState.transaction;
if (nodes.length == 1) {
final node = nodes.removeAt(0);
@@ -68,29 +103,43 @@ void formatSelection(
}
transaction.compose();
await editorState.apply(
transaction,
options: ApplyOptions(
inMemoryUpdate: true,
recordUndo: false,
),
withUpdateSelection: false,
);
}
Position ensurePreviousNodeIsEmptyParagraph(
Future<Position> ensurePreviousNodeIsEmptyParagraph(
EditorState editorState,
Node aiWriterNode,
Transaction transaction,
) {
) async {
final previous = aiWriterNode.previous;
final needsEmptyParagraphNode = previous == null ||
previous.type != ParagraphBlockKeys.type ||
(previous.delta?.toPlainText().isNotEmpty ?? false);
final Position position;
final transaction = editorState.transaction;
if (needsEmptyParagraphNode) {
position = Position(path: aiWriterNode.path);
transaction.insertNode(aiWriterNode.path, paragraphNode());
} else {
position = Position(path: previous.path);
}
transaction.afterSelection = Selection.collapsed(position);
transaction.updateNode(aiWriterNode, {
AiWriterBlockKeys.isInitialized: true,
});
await editorState.apply(
transaction,
options: ApplyOptions(
inMemoryUpdate: true,
recordUndo: false,
),
);
return position;
}
@@ -1,3 +1,4 @@
import 'package:appflowy/ai/ai.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy_backend/protobuf/flowy-ai/protobuf.dart';
@@ -129,24 +130,22 @@ enum AiRole {
}
class AiWriterRecord extends Equatable {
const AiWriterRecord({
required this.role,
required this.content,
});
const AiWriterRecord.user({
required this.content,
required this.format,
}) : role = AiRole.user;
const AiWriterRecord.ai({
required this.content,
}) : role = AiRole.ai;
}) : role = AiRole.ai,
format = null;
final AiRole role;
final String content;
final PredefinedFormat? format;
@override
List<Object> get props => [role, content];
List<Object?> get props => [role, content, format];
CompletionRecordPB toPB() {
return CompletionRecordPB(
@@ -57,11 +57,30 @@ extension AiWriterNodeExtension on EditorState {
slicedNodes.add(copiedNode);
}
for (final (i, node) in slicedNodes.indexed) {
final childNodesShouldBeDeleted = <Node>[];
for (final child in node.children) {
if (!child.path.inSelection(selection)) {
childNodesShouldBeDeleted.add(child);
}
}
for (final child in childNodesShouldBeDeleted) {
slicedNodes[i] = node.copyWith(
children: node.children.where((e) => e.id != child.id).toList(),
type: selection.startIndex != 0 ? ParagraphBlockKeys.type : node.type,
);
}
}
// use \n\n as line break to improve the ai response
// using \n will cause the ai response treat the text as a single line
final markdown = await customDocumentToMarkdown(
Document.blank()..insert([0], slicedNodes),
lineBreak: '\n\n',
);
return markdown;
// trim the last \n if it exists
return markdown.trimRight();
}
List<String> getPlainTextInSelection(Selection? selection) {
@@ -104,7 +123,7 @@ extension AiWriterNodeExtension on EditorState {
start: Position(path: [0]),
end: selection?.normalized.end ??
this.selection?.normalized.end ??
Position(path: [0]),
Position(path: getLastSelectable()?.$1.path ?? [0]),
);
// if the selected nodes are not entirely selected, slice the nodes
@@ -1,63 +0,0 @@
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'operations/ai_writer_entities.dart';
class SuggestionActionBar extends StatelessWidget {
const SuggestionActionBar({
super.key,
required this.actions,
required this.onTap,
});
final List<SuggestionAction> actions;
final void Function(SuggestionAction) onTap;
@override
Widget build(BuildContext context) {
return SeparatedRow(
mainAxisSize: MainAxisSize.min,
separatorBuilder: () => const HSpace(4.0),
children: actions
.map(
(action) => SuggestionActionButton(
action: action,
onTap: () => onTap(action),
),
)
.toList(),
);
}
}
class SuggestionActionButton extends StatelessWidget {
const SuggestionActionButton({
super.key,
required this.action,
required this.onTap,
});
final SuggestionAction action;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return SizedBox(
height: 28,
child: FlowyButton(
text: FlowyText(
action.i18n,
figmaLineHeight: 20,
),
leftIcon: action.buildIcon(context),
iconPadding: 4.0,
margin: const EdgeInsets.symmetric(
horizontal: 6.0,
vertical: 4.0,
),
onTap: onTap,
useIntrinsicWidth: true,
),
);
}
}
@@ -21,7 +21,10 @@ class AiWriterGestureDetector extends StatelessWidget {
TapGestureRecognizer:
GestureRecognizerFactoryWithHandlers<TapGestureRecognizer>(
() => TapGestureRecognizer(),
(instance) => instance..onTapDown = (_) => onPointerEvent(),
(instance) => instance
..onTapDown = ((_) => onPointerEvent())
..onSecondaryTapDown = ((_) => onPointerEvent())
..onTertiaryTapDown = ((_) => onPointerEvent()),
),
ImmediateMultiDragGestureRecognizer:
GestureRecognizerFactoryWithHandlers<
@@ -0,0 +1,151 @@
import 'package:appflowy/ai/ai.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.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/colorscheme/default_colorscheme.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flowy_infra_ui/style_widget/hover.dart';
import 'package:flutter/material.dart';
import '../operations/ai_writer_entities.dart';
class AiWriterPromptMoreButton extends StatelessWidget {
const AiWriterPromptMoreButton({
super.key,
required this.isEnabled,
required this.isSelected,
required this.onTap,
});
final bool isEnabled;
final bool isSelected;
final void Function() onTap;
@override
Widget build(BuildContext context) {
return IgnorePointer(
ignoring: !isEnabled,
child: GestureDetector(
onTap: onTap,
behavior: HitTestBehavior.opaque,
child: SizedBox(
height: DesktopAIPromptSizes.actionBarButtonSize,
child: FlowyHover(
style: const HoverStyle(
borderRadius: BorderRadius.all(Radius.circular(8)),
),
isSelected: () => isSelected,
child: Padding(
padding: const EdgeInsetsDirectional.fromSTEB(6, 6, 4, 6),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
FlowyText(
LocaleKeys.ai_more.tr(),
fontSize: 12,
figmaLineHeight: 16,
color: isEnabled
? Theme.of(context).hintColor
: Theme.of(context).disabledColor,
),
const HSpace(2.0),
FlowySvg(
FlowySvgs.ai_source_drop_down_s,
color: Theme.of(context).hintColor,
size: const Size.square(8),
),
],
),
),
),
),
),
);
}
}
class MoreAiWriterCommands extends StatelessWidget {
const MoreAiWriterCommands({
super.key,
required this.hasSelection,
required this.editorState,
required this.onSelectCommand,
});
final EditorState editorState;
final bool hasSelection;
final void Function(AiWriterCommand) onSelectCommand;
@override
Widget build(BuildContext context) {
return Container(
// add one here to take into account the border of the main message box.
// It is configured to be on the outside to hide some graphical
// artifacts.
margin: EdgeInsets.only(top: 4.0 + 1.0),
padding: EdgeInsets.all(8.0),
constraints: BoxConstraints(minWidth: 240.0),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
border: Border.all(
color: Theme.of(context).brightness == Brightness.light
? ColorSchemeConstants.lightBorderColor
: ColorSchemeConstants.darkBorderColor,
strokeAlign: BorderSide.strokeAlignOutside,
),
borderRadius: BorderRadius.all(Radius.circular(8.0)),
boxShadow: Theme.of(context).isLightMode
? ShadowConstants.lightSmall
: ShadowConstants.darkSmall,
),
child: IntrinsicWidth(
child: Column(
spacing: 4.0,
crossAxisAlignment: CrossAxisAlignment.start,
children: _getCommands(
hasSelection: hasSelection,
),
),
),
);
}
List<Widget> _getCommands({required bool hasSelection}) {
if (hasSelection) {
return [
_bottomButton(AiWriterCommand.improveWriting),
_bottomButton(AiWriterCommand.fixSpellingAndGrammar),
_bottomButton(AiWriterCommand.explain),
const Divider(height: 1.0, thickness: 1.0),
_bottomButton(AiWriterCommand.makeLonger),
_bottomButton(AiWriterCommand.makeShorter),
];
} else {
return [
_bottomButton(AiWriterCommand.continueWriting),
];
}
}
Widget _bottomButton(AiWriterCommand command) {
return Builder(
builder: (context) {
return FlowyButton(
leftIcon: FlowySvg(
command.icon,
color: Theme.of(context).iconTheme.color,
),
leftIconSize: const Size.square(20),
margin: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 6.0),
text: FlowyText(
command.i18n,
figmaLineHeight: 20,
),
onTap: () => onSelectCommand(command),
);
},
);
}
}
@@ -0,0 +1,242 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/util/throttle.dart';
import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/shared_widget.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../operations/ai_writer_cubit.dart';
import 'ai_writer_gesture_detector.dart';
class AiWriterScrollWrapper extends StatefulWidget {
const AiWriterScrollWrapper({
super.key,
required this.viewId,
required this.editorState,
required this.child,
});
final String viewId;
final EditorState editorState;
final Widget child;
@override
State<AiWriterScrollWrapper> createState() => _AiWriterScrollWrapperState();
}
class _AiWriterScrollWrapperState extends State<AiWriterScrollWrapper> {
final overlayController = OverlayPortalController();
late final throttler = Throttler();
late final aiWriterCubit = AiWriterCubit(
documentId: widget.viewId,
editorState: widget.editorState,
onCreateNode: () {
aiWriterRegistered = true;
widget.editorState.service.keyboardService?.disableShortcuts();
},
onRemoveNode: () {
aiWriterRegistered = false;
widget.editorState.service.keyboardService?.enableShortcuts();
widget.editorState.service.keyboardService?.enable();
},
onAppendToDocument: onAppendToDocument,
);
bool userHasScrolled = false;
bool aiWriterRegistered = false;
bool dialogShown = false;
@override
void initState() {
super.initState();
overlayController.show();
}
@override
void dispose() {
aiWriterCubit.close();
super.dispose();
}
@override
Widget build(BuildContext context) {
return BlocProvider.value(
value: aiWriterCubit,
child: NotificationListener<ScrollNotification>(
onNotification: handleScrollNotification,
child: Focus(
autofocus: true,
onKeyEvent: handleKeyEvent,
child: MultiBlocListener(
listeners: [
BlocListener<AiWriterCubit, AiWriterState>(
listener: (context, state) {
if (state is DocumentContentEmptyAiWriterState) {
showConfirmDialog(
context: context,
title:
LocaleKeys.ai_continueWritingEmptyDocumentTitle.tr(),
description: LocaleKeys
.ai_continueWritingEmptyDocumentDescription
.tr(),
onConfirm: state.onConfirm,
);
}
},
),
BlocListener<AiWriterCubit, AiWriterState>(
listenWhen: (previous, current) =>
previous is GeneratingAiWriterState &&
current is ReadyAiWriterState,
listener: (context, state) {
widget.editorState.updateSelectionWithReason(null);
},
),
],
child: OverlayPortal(
controller: overlayController,
overlayChildBuilder: (context) {
return BlocBuilder<AiWriterCubit, AiWriterState>(
builder: (context, state) {
return AiWriterGestureDetector(
behavior: state is RegisteredAiWriter
? HitTestBehavior.translucent
: HitTestBehavior.deferToChild,
onPointerEvent: () => onTapOutside(context),
);
},
);
},
child: widget.child,
),
),
),
),
);
}
bool handleScrollNotification(ScrollNotification notification) {
if (!aiWriterRegistered) {
return false;
}
if (notification is UserScrollNotification) {
debounceResetUserHasScrolled();
userHasScrolled = true;
throttler.cancel();
}
return false;
}
void debounceResetUserHasScrolled() {
Debounce.debounce(
'user_has_scrolled',
const Duration(seconds: 3),
() => userHasScrolled = false,
);
}
void onTapOutside(BuildContext context) {
final aiWriterCubit = context.read<AiWriterCubit>();
if (aiWriterCubit.hasUnusedResponse()) {
showConfirmDialog(
context: context,
title: LocaleKeys.button_discard.tr(),
description: LocaleKeys.document_plugins_discardResponse.tr(),
confirmLabel: LocaleKeys.button_discard.tr(),
style: ConfirmPopupStyle.cancelAndOk,
onConfirm: stopAndExit,
onCancel: () {},
);
} else {
stopAndExit();
}
}
KeyEventResult handleKeyEvent(FocusNode node, KeyEvent event) {
if (!aiWriterRegistered) {
return KeyEventResult.ignored;
}
if (dialogShown) {
return KeyEventResult.handled;
}
if (event is! KeyDownEvent) {
return KeyEventResult.ignored;
}
switch (event.logicalKey) {
case LogicalKeyboardKey.escape:
if (aiWriterCubit.state case GeneratingAiWriterState _) {
aiWriterCubit.stopStream();
} else if (aiWriterCubit.hasUnusedResponse()) {
dialogShown = true;
showConfirmDialog(
context: context,
title: LocaleKeys.button_discard.tr(),
description: LocaleKeys.document_plugins_discardResponse.tr(),
confirmLabel: LocaleKeys.button_discard.tr(),
style: ConfirmPopupStyle.cancelAndOk,
onConfirm: stopAndExit,
onCancel: () {},
).then((_) => dialogShown = false);
} else {
stopAndExit();
}
return KeyEventResult.handled;
case LogicalKeyboardKey.keyC
when HardwareKeyboard.instance.isControlPressed:
if (aiWriterCubit.state case GeneratingAiWriterState _) {
aiWriterCubit.stopStream();
}
return KeyEventResult.handled;
default:
break;
}
return KeyEventResult.ignored;
}
void onAppendToDocument() {
if (!aiWriterRegistered || userHasScrolled) {
return;
}
throttler.call(() {
if (aiWriterCubit.aiWriterNode != null) {
final path = aiWriterCubit.aiWriterNode!.path;
if (path.isEmpty) {
return;
}
if (path.previous.isNotEmpty) {
final node = widget.editorState.getNodeAtPath(path.previous);
if (node != null && node.delta != null && node.delta!.isNotEmpty) {
widget.editorState.updateSelectionWithReason(
Selection.collapsed(
Position(path: path, offset: node.delta!.length),
),
);
return;
}
}
widget.editorState.updateSelectionWithReason(
Selection.collapsed(Position(path: path)),
);
}
});
}
void stopAndExit() {
Future(() async {
await aiWriterCubit.stopStream();
await aiWriterCubit.exit();
});
}
}
@@ -0,0 +1,110 @@
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import '../operations/ai_writer_entities.dart';
class SuggestionActionBar extends StatelessWidget {
const SuggestionActionBar({
super.key,
required this.currentCommand,
required this.hasSelection,
required this.onTap,
});
final AiWriterCommand currentCommand;
final bool hasSelection;
final void Function(SuggestionAction) onTap;
@override
Widget build(BuildContext context) {
return SeparatedRow(
mainAxisSize: MainAxisSize.min,
separatorBuilder: () => const HSpace(4.0),
children: _getSuggestedActions()
.map(
(action) => SuggestionActionButton(
action: action,
onTap: () => onTap(action),
),
)
.toList(),
);
}
List<SuggestionAction> _getSuggestedActions() {
if (hasSelection) {
return switch (currentCommand) {
AiWriterCommand.userQuestion || AiWriterCommand.continueWriting => [
SuggestionAction.keep,
SuggestionAction.discard,
SuggestionAction.rewrite,
],
AiWriterCommand.explain => [
SuggestionAction.insertBelow,
SuggestionAction.tryAgain,
SuggestionAction.close,
],
AiWriterCommand.fixSpellingAndGrammar ||
AiWriterCommand.improveWriting ||
AiWriterCommand.makeShorter ||
AiWriterCommand.makeLonger =>
[
SuggestionAction.accept,
SuggestionAction.discard,
SuggestionAction.insertBelow,
SuggestionAction.rewrite,
],
};
} else {
return switch (currentCommand) {
AiWriterCommand.userQuestion || AiWriterCommand.continueWriting => [
SuggestionAction.keep,
SuggestionAction.discard,
SuggestionAction.rewrite,
],
AiWriterCommand.explain => [
SuggestionAction.insertBelow,
SuggestionAction.tryAgain,
SuggestionAction.close,
],
_ => [
SuggestionAction.keep,
SuggestionAction.discard,
SuggestionAction.rewrite,
],
};
}
}
}
class SuggestionActionButton extends StatelessWidget {
const SuggestionActionButton({
super.key,
required this.action,
required this.onTap,
});
final SuggestionAction action;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return SizedBox(
height: 28,
child: FlowyButton(
text: FlowyText(
action.i18n,
figmaLineHeight: 20,
),
leftIcon: action.buildIcon(context),
iconPadding: 4.0,
margin: const EdgeInsets.symmetric(
horizontal: 6.0,
vertical: 4.0,
),
onTap: onTap,
useIntrinsicWidth: true,
),
);
}
}
@@ -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,
),
);
}
@@ -1,8 +1,10 @@
import 'dart:convert';
import 'package:appflowy/plugins/document/presentation/editor_plugins/numbered_list/numbered_list_icon.dart';
import 'package:appflowy/shared/markdown_to_document.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:collection/collection.dart';
import 'package:synchronized/synchronized.dart';
const _enableDebug = false;
@@ -28,6 +30,9 @@ class MarkdownTextRobot {
/// Only for debug via [_enableDebug].
final List<String> _debugMarkdownTexts = [];
/// Selection before the refresh.
Selection? _previousSelection;
bool get hasAnyResult => _markdownText.isNotEmpty;
String get markdownText => _markdownText;
@@ -56,9 +61,11 @@ class MarkdownTextRobot {
}
void start({
Selection? previousSelection,
Position? position,
}) {
_insertPosition ??= position ?? editorState.selection?.start;
_insertPosition = position ?? editorState.selection?.start;
_previousSelection = previousSelection ?? editorState.selection;
if (_enableDebug) {
Log.info(
@@ -70,6 +77,7 @@ class MarkdownTextRobot {
/// The text will be inserted into the document but only in memory
Future<void> appendMarkdownText(
String text, {
bool updateSelection = true,
Map<String, dynamic>? attributes,
}) async {
_markdownText += text;
@@ -77,6 +85,7 @@ class MarkdownTextRobot {
await _lock.synchronized(() async {
await _refresh(
inMemoryUpdate: true,
updateSelection: updateSelection,
attributes: attributes,
);
});
@@ -95,19 +104,21 @@ class MarkdownTextRobot {
await _lock.synchronized(() async {
await _refresh(
inMemoryUpdate: true,
updateSelection: false,
attributes: attributes,
);
});
}
/// 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) {
@@ -116,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;
@@ -126,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),
@@ -135,11 +178,11 @@ class MarkdownTextRobot {
);
final transaction = editorState.transaction
..deleteNodes(deletedNodes)
..afterSelection = Selection.collapsed(start);
..afterSelection = afterSelection;
await editorState.apply(
transaction,
options: const ApplyOptions(recordUndo: false),
options: const ApplyOptions(recordUndo: false, inMemoryUpdate: true),
);
if (_enableDebug) {
@@ -147,15 +190,18 @@ class MarkdownTextRobot {
}
}
void reset() {
void clear() {
_markdownText = '';
_insertedNodes = [];
}
void reset() {
_insertPosition = null;
}
Future<void> _refresh({
required bool inMemoryUpdate,
bool updateSelection = true,
bool updateSelection = false,
Map<String, dynamic>? attributes,
}) async {
final position = _insertPosition;
@@ -171,11 +217,40 @@ class MarkdownTextRobot {
tableWidth: 250.0,
).root.children;
// check if the first selected node before the refresh is a numbered list node
final previousSelection = _previousSelection;
final previousSelectedNode = previousSelection == null
? null
: editorState.getNodeAtPath(previousSelection.start.path);
final firstNodeIsNumberedList = previousSelectedNode != null &&
previousSelectedNode.type == NumberedListBlockKeys.type;
final newNodes = attributes == null
? documentNodes
: documentNodes
.map((node) => _styleDelta(node: node, attributes: attributes))
.toList();
: documentNodes.mapIndexed((index, node) {
final n = _styleDelta(node: node, attributes: attributes);
n.externalValues = AINodeExternalValues(
isAINode: true,
);
if (index == 0 && n.type == NumberedListBlockKeys.type) {
if (firstNodeIsNumberedList) {
final builder = NumberedListIndexBuilder(
editorState: editorState,
node: previousSelectedNode,
);
final firstIndex = builder.indexInSameLevel;
n.updateAttributes({
NumberedListBlockKeys.number: firstIndex,
});
}
n.externalValues = AINodeExternalValues(
isAINode: true,
isFirstNumberedListNode: true,
);
}
return n;
}).toList();
if (newNodes.isEmpty) {
return;
@@ -203,10 +278,6 @@ class MarkdownTextRobot {
offset: lastDelta.length,
),
);
if (!updateSelection) {
insertTransaction.afterSelection = null;
}
}
await editorState.apply(
@@ -215,6 +286,7 @@ class MarkdownTextRobot {
inMemoryUpdate: inMemoryUpdate,
recordUndo: !inMemoryUpdate,
),
withUpdateSelection: updateSelection,
);
_insertedNodes = newNodes;
@@ -245,4 +317,202 @@ class MarkdownTextRobot {
children: children,
);
}
/// If the selected content is in the same line,
/// keep the selected node and replace the delta.
Future<void> _replaceInSameLine({
required Selection selection,
required String markdownText,
}) async {
if (markdownText.isEmpty) {
assert(false, 'Expected non-empty markdown text');
Log.error('Expected non-empty markdown text');
return;
}
selection = selection.normalized;
// If the selection is not a single node, do nothing.
if (!selection.isSingle) {
assert(false, 'Expected single node selection');
Log.error('Expected single node selection');
return;
}
final startIndex = selection.startIndex;
final endIndex = selection.endIndex;
final length = endIndex - startIndex;
// Get the selected node.
final node = editorState.getNodeAtPath(selection.start.path);
final delta = node?.delta;
if (node == null || delta == null) {
assert(false, 'Expected non-null node and delta');
Log.error('Expected non-null node and delta');
return;
}
// Convert the markdown text to delta.
// Question: Why we need to convert the markdown to document first?
// Answer: Because the markdown text may contain the list item,
// if we convert the markdown to delta directly, the list item will be
// treated as a normal text node, and the delta will be incorrect.
// For example, the markdown text is:
// ```
// 1. item1
// ```
// if we convert the markdown to delta directly, the delta will be:
// ```
// [
// {
// "insert": "1. item1"
// }
// ]
// ```
// if we convert the markdown to document first, the document will be:
// ```
// [
// {
// "type": "numbered_list",
// "children": [
// {
// "insert": "item1"
// }
// ]
// }
// ]
final document = customMarkdownToDocument(markdownText);
final nodes = document.root.children;
final decoder = DeltaMarkdownDecoder();
final markdownDelta =
nodes.firstOrNull?.delta ?? decoder.convert(markdownText);
if (markdownDelta.isEmpty) {
assert(false, 'Expected non-empty markdown delta');
Log.error('Expected non-empty markdown delta');
return;
}
// Replace the delta of the selected node.
final transaction = editorState.transaction;
// it means the user selected the entire sentence, we just replace the node
if (startIndex == 0 && length == node.delta?.length) {
transaction
..insertNodes(node.path.next, nodes)
..deleteNode(node);
} else {
// it means the user selected a part of the sentence, we need to delete the
// selected part and insert the new delta.
transaction
..deleteText(node, startIndex, length)
..insertTextDelta(node, startIndex, markdownDelta);
// Add the remaining nodes to the document.
final remainingNodes = nodes.skip(1);
if (remainingNodes.isNotEmpty) {
transaction.insertNodes(
node.path.next,
remainingNodes,
);
}
}
await editorState.apply(transaction);
}
/// If the selected content is in multiple lines
Future<void> _replaceInMultiLines({
required Selection selection,
required String markdownText,
}) async {
selection = selection.normalized;
// If the selection is a single node, do nothing.
if (selection.isSingle) {
assert(false, 'Expected multi-line selection');
Log.error('Expected multi-line selection');
return;
}
final markdownNodes = customMarkdownToDocument(
markdownText,
tableWidth: 250.0,
).root.children;
// Get the selected nodes.
final nodes = editorState.getNodesInSelection(selection);
// Note: Don't change its order, otherwise the delta will be incorrect.
// step 1. merge the first selected node and the first node from the ai response
// step 2. merge the last selected node and the last node from the ai response
// step 3. insert the middle nodes from the ai response
// step 4. delete the middle nodes
final transaction = editorState.transaction;
// step 1
final firstNode = nodes.firstOrNull;
final delta = firstNode?.delta;
final firstMarkdownNode = markdownNodes.firstOrNull;
final firstMarkdownDelta = firstMarkdownNode?.delta;
if (firstNode != null &&
delta != null &&
firstMarkdownNode != null &&
firstMarkdownDelta != null) {
final startIndex = selection.startIndex;
final length = delta.length - startIndex;
transaction
..deleteText(firstNode, startIndex, length)
..insertTextDelta(firstNode, startIndex, firstMarkdownDelta);
}
// step 2
final lastNode = nodes.lastOrNull;
final lastDelta = lastNode?.delta;
final lastMarkdownNode = markdownNodes.lastOrNull;
final lastMarkdownDelta = lastMarkdownNode?.delta;
if (lastNode != null &&
lastDelta != null &&
lastMarkdownNode != null &&
lastMarkdownDelta != null) {
final endIndex = selection.endIndex;
transaction.deleteText(lastNode, 0, endIndex);
// if the last node is same as the first node, it means we have replaced the
// selected text in the first node.
if (lastMarkdownNode.id != firstMarkdownNode?.id) {
transaction.insertTextDelta(lastNode, 0, lastMarkdownDelta);
}
}
// step 3
final insertedPath = selection.start.path.nextNPath(1);
if (markdownNodes.length > 2) {
transaction.insertNodes(
insertedPath,
markdownNodes.skip(1).take(markdownNodes.length - 2).toList(),
);
}
// step 4
final length = nodes.length - 2;
if (length > 0) {
final middleNodes = nodes.skip(1).take(length).toList();
transaction.deleteNodes(middleNodes);
}
await editorState.apply(transaction);
}
}
class AINodeExternalValues extends NodeExternalValues {
const AINodeExternalValues({
this.isAINode = false,
this.isFirstNumberedListNode = false,
});
final bool isAINode;
final bool isFirstNumberedListNode;
}
@@ -1,3 +1,5 @@
import 'dart:ui';
import 'package:appflowy/plugins/document/presentation/editor_plugins/callout/callout_block_component.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/toggle/toggle_block_component.dart';
@@ -35,9 +37,7 @@ bool onlyShowInSingleTextTypeSelectionAndExcludeTable(
notShowInTable(editorState);
}
bool enableSuggestions(
EditorState editorState,
) {
bool enableSuggestions(EditorState editorState) {
final selection = editorState.selection;
if (selection == null || !selection.isSingle) {
return false;
@@ -46,10 +46,18 @@ bool enableSuggestions(
if (node == null) {
return false;
}
if (isNarrowWindow(editorState)) return false;
return (node.delta != null && suggestionsItemTypes.contains(node.type)) &&
notShowInTable(editorState);
}
bool isNarrowWindow(EditorState editorState) {
final editorSize = editorState.renderBox?.size ?? Size.zero;
if (editorSize.width < 650) return true;
return false;
}
final Set<String> suggestionsItemTypes = {
...toolbarItemWhiteList,
ToggleListBlockKeys.type,
@@ -3,7 +3,6 @@ import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/application/document_bloc.dart';
import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart';
import 'package:appflowy/shared/icon_emoji_picker/tab.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:easy_localization/easy_localization.dart'
show StringTranslateExtension;
@@ -176,12 +175,7 @@ class _CalloutBlockComponentWidgetState
EmojiIconData result = EmojiIconData.emoji('📌');
try {
result = EmojiIconData(FlowyIconType.values.byName(type), icon);
} catch (e) {
Log.info(
'get emoji error with icon:[$icon], type:[$type] within calloutBlockComponentWidget',
e,
);
}
} catch (_) {}
return result;
}
@@ -47,7 +47,6 @@ class _CopyButton extends StatelessWidget {
if (context.mounted) {
showToastNotification(
context,
message: LocaleKeys.document_codeBlock_codeCopiedSnackbar.tr(),
);
}
@@ -16,6 +16,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:http/http.dart' as http;
import 'package:string_validator/string_validator.dart';
import 'package:universal_platform/universal_platform.dart';
/// - support
/// - desktop
@@ -162,6 +163,7 @@ Future<bool> _pasteAsLinkPreview(
EditorState editorState,
String? text,
) async {
final isMobile = UniversalPlatform.isMobile;
// the url should contain a protocol
if (text == null || !isURL(text, {'require_protocol': true})) {
return false;
@@ -193,6 +195,8 @@ Future<bool> _pasteAsLinkPreview(
return false;
}
if (!isMobile && !isImageUrl) return false;
// insert the text with link format
final textTransaction = editorState.transaction
..insertText(
@@ -250,6 +254,7 @@ Future<void> doPlainPaste(EditorState editorState) async {
}
Future<bool> _isImageUrl(String text) async {
if (isNotImageUrl(text)) return false;
final response = await http.head(Uri.parse(text));
if (response.statusCode == 200) {
@@ -43,13 +43,11 @@ extension PasteFromBlockLink on EditorState {
node,
selection.startIndex,
MentionBlockKeys.mentionChar,
attributes: {
MentionBlockKeys.mention: {
MentionBlockKeys.type: MentionType.page.name,
MentionBlockKeys.blockId: blockId,
MentionBlockKeys.pageId: pageId,
},
},
attributes: MentionBlockKeys.buildMentionPageAttributes(
mentionType: MentionType.page,
pageId: pageId,
blockId: blockId,
),
);
await apply(transaction);
@@ -1,3 +1,4 @@
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_plain_text.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
import 'package:appflowy/shared/markdown_to_document.dart';
import 'package:appflowy/shared/patterns/common_patterns.dart';
@@ -13,6 +14,7 @@ extension PasteFromHtml on EditorState {
}
if (nodes.length == 1) {
await pasteSingleLineNode(nodes.first);
checkToShowPasteAsMenu(nodes.first);
} else {
await pasteMultiLineNodes(nodes.toList());
}
@@ -73,7 +73,6 @@ extension PasteFromImage on EditorState {
Log.info('unsupported format: $format');
if (UniversalPlatform.isMobile) {
showToastNotification(
context,
message: LocaleKeys.document_imageBlock_error_invalidImageFormat.tr(),
);
}
@@ -112,7 +111,6 @@ extension PasteFromImage on EditorState {
if (errorMessage != null && context.mounted) {
showToastNotification(
context,
message: errorMessage,
);
return false;
@@ -131,7 +129,6 @@ extension PasteFromImage on EditorState {
Log.error('cannot copy image file', e);
if (context.mounted) {
showToastNotification(
context,
message: LocaleKeys.document_imageBlock_error_invalidImage.tr(),
);
}
@@ -1,6 +1,8 @@
import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/paste_as/paste_as_menu.dart';
import 'package:appflowy/shared/markdown_to_document.dart';
import 'package:appflowy/shared/patterns/common_patterns.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:universal_platform/universal_platform.dart';
extension PasteFromPlainText on EditorState {
Future<void> pastePlainText(String plainText) async {
@@ -41,6 +43,7 @@ extension PasteFromPlainText on EditorState {
}
if (nodes.length == 1) {
await pasteSingleLineNode(nodes.first);
checkToShowPasteAsMenu(nodes.first);
} else {
await pasteMultiLineNodes(nodes.toList());
}
@@ -65,6 +68,29 @@ extension PasteFromPlainText on EditorState {
AppFlowyRichTextKeys.href: plainText,
});
await apply(transaction);
checkToShowPasteAsMenu(node);
return true;
}
void checkToShowPasteAsMenu(Node node) {
if (selection == null || !selection!.isCollapsed) return;
if (UniversalPlatform.isMobile) return;
final href = _getLinkFromNode(node);
if (href != null) {
final context = document.root.context;
if (context != null && context.mounted) {
PasteAsMenuService(context: context, editorState: this).show(href);
}
}
}
String? _getLinkFromNode(Node node) {
final delta = node.delta;
if (delta == null) return null;
final inserts = delta.whereType<TextInsert>();
if (inserts.isEmpty || inserts.length > 1) return null;
final link = inserts.first.attributes?.href;
if (link != null) return inserts.first.text;
return null;
}
}
@@ -1,15 +1,23 @@
import 'package:appflowy/plugins/document/presentation/editor_plugins/base/toolbar_extension.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
import 'toolbar_animation.dart';
class DesktopFloatingToolbar extends StatefulWidget {
const DesktopFloatingToolbar({
super.key,
required this.editorState,
required this.child,
required this.onDismiss,
this.enableAnimation = true,
});
final EditorState editorState;
final Widget child;
final VoidCallback onDismiss;
final bool enableAnimation;
@override
State<DesktopFloatingToolbar> createState() => _DesktopFloatingToolbarState();
@@ -19,6 +27,7 @@ class _DesktopFloatingToolbarState extends State<DesktopFloatingToolbar> {
EditorState get editorState => widget.editorState;
_Position? position;
final toolbarController = getIt<FloatingToolbarController>();
@override
void initState() {
@@ -30,6 +39,13 @@ class _DesktopFloatingToolbarState extends State<DesktopFloatingToolbar> {
final selectionRect = editorState.selectionRects();
if (selectionRect.isEmpty) return;
position = calculateSelectionMenuOffset(selectionRect.first);
toolbarController._addCallback(dismiss);
}
@override
void dispose() {
toolbarController._removeCallback(dismiss);
super.dispose();
}
@override
@@ -39,19 +55,26 @@ class _DesktopFloatingToolbarState extends State<DesktopFloatingToolbar> {
left: position!.left,
top: position!.top,
right: position!.right,
child: widget.child,
child: widget.enableAnimation
? ToolbarAnimationWidget(child: widget.child)
: widget.child,
);
}
void dismiss() {
widget.onDismiss.call();
}
_Position calculateSelectionMenuOffset(
Rect rect,
) {
const toolbarHeight = 40, topLimit = toolbarHeight + 8;
final bool isLongMenu = onlyShowInSingleSelectionAndTextType(editorState);
final menuWidth = isLongMenu ? 650.0 : 420.0;
final editorOffset =
editorState.renderBox?.localToGlobal(Offset.zero) ?? Offset.zero;
final editorSize = editorState.renderBox?.size ?? Size.zero;
final menuWidth =
isLongMenu ? (isNarrowWindow(editorState) ? 490.0 : 660.0) : 420.0;
final editorRect = editorOffset & editorSize;
final left = rect.left, leftStart = 50;
final top =
@@ -77,3 +100,33 @@ class _Position {
final double? top;
final double? right;
}
class FloatingToolbarController {
final Set<VoidCallback> _dismissCallbacks = {};
final Set<VoidCallback> _displayListeners = {};
void _addCallback(VoidCallback callback) {
_dismissCallbacks.add(callback);
for (final listener in Set.of(_displayListeners)) {
listener.call();
}
}
void _removeCallback(VoidCallback callback) =>
_dismissCallbacks.remove(callback);
bool get isToolbarShowing => _dismissCallbacks.isNotEmpty;
void addDisplayListener(VoidCallback listener) =>
_displayListeners.add(listener);
void removeDisplayListener(VoidCallback listener) =>
_displayListeners.remove(listener);
void hideToolbar() {
if (_dismissCallbacks.isEmpty) return;
for (final callback in _dismissCallbacks) {
callback.call();
}
}
}
@@ -0,0 +1,323 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/link/link_styles.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/toolbar_item/custom_link_toolbar_item.dart';
import 'package:appflowy/plugins/shared/share/constants.dart';
import 'package:appflowy/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: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_search_text_field.dart';
class LinkCreateMenu extends StatefulWidget {
const LinkCreateMenu({
super.key,
required this.editorState,
required this.onSubmitted,
required this.onDismiss,
required this.alignment,
required this.currentViewId,
required this.initialText,
});
final EditorState editorState;
final void Function(String link, bool isPage) onSubmitted;
final VoidCallback onDismiss;
final String currentViewId;
final String initialText;
final LinkMenuAlignment alignment;
@override
State<LinkCreateMenu> createState() => _LinkCreateMenuState();
}
class _LinkCreateMenuState extends State<LinkCreateMenu> {
late LinkSearchTextField searchTextField = LinkSearchTextField(
currentViewId: widget.currentViewId,
initialSearchText: widget.initialText,
onEnter: () {
searchTextField.onSearchResult(
onLink: () => onSubmittedLink(),
onRecentViews: () =>
onSubmittedPageLink(searchTextField.currentRecentView),
onSearchViews: () =>
onSubmittedPageLink(searchTextField.currentSearchedView),
onEmpty: () {},
);
},
onEscape: widget.onDismiss,
onDataRefresh: () {
if (mounted) setState(() {});
},
);
bool get isTextfieldEnable => searchTextField.isTextfieldEnable;
String get searchText => searchTextField.searchText;
bool get showAtTop => widget.alignment.isTop;
bool showErrorText = false;
@override
void initState() {
super.initState();
searchTextField.requestFocus();
searchTextField.searchRecentViews();
final focusNode = searchTextField.focusNode;
bool hasFocus = focusNode.hasFocus;
focusNode.addListener(() {
if (hasFocus != focusNode.hasFocus && mounted) {
setState(() {
hasFocus = focusNode.hasFocus;
});
}
});
}
@override
void dispose() {
searchTextField.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return SizedBox(
width: 320,
child: Column(
children: showAtTop
? [
searchTextField.buildResultContainer(
margin: EdgeInsets.only(bottom: 2),
context: context,
onLinkSelected: onSubmittedLink,
onPageLinkSelected: onSubmittedPageLink,
),
buildSearchContainer(),
]
: [
buildSearchContainer(),
searchTextField.buildResultContainer(
margin: EdgeInsets.only(top: 2),
context: context,
onLinkSelected: onSubmittedLink,
onPageLinkSelected: onSubmittedPageLink,
),
],
),
);
}
Widget buildSearchContainer() {
return Container(
width: 320,
decoration: buildToolbarLinkDecoration(context),
padding: EdgeInsets.all(8),
child: ValueListenableBuilder(
valueListenable: searchTextField.textEditingController,
builder: (context, _, __) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: searchTextField.buildTextField(context: context),
),
HSpace(8),
FlowyTextButton(
LocaleKeys.document_toolbar_insert.tr(),
mainAxisAlignment: MainAxisAlignment.center,
padding: EdgeInsets.zero,
constraints: BoxConstraints(maxWidth: 72, minHeight: 32),
fontSize: 14,
fontColor: Colors.white,
fillColor: LinkStyle.fillThemeThick,
hoverColor: LinkStyle.fillThemeThick.withAlpha(200),
lineHeight: 20 / 14,
fontWeight: FontWeight.w600,
onPressed: onSubmittedLink,
),
],
),
if (showErrorText)
Padding(
padding: const EdgeInsets.only(top: 4),
child: FlowyText.regular(
LocaleKeys.document_plugins_file_networkUrlInvalid.tr(),
color: LinkStyle.textStatusError,
fontSize: 12,
figmaLineHeight: 16,
),
),
],
);
},
),
);
}
void onSubmittedLink() {
if (!isTextfieldEnable) {
setState(() {
showErrorText = true;
});
return;
}
widget.onSubmitted(searchText, false);
}
void onSubmittedPageLink(ViewPB view) async {
final workspaceId = context
.read<UserWorkspaceBloc?>()
?.state
.currentWorkspace
?.workspaceId ??
'';
final link = ShareConstants.buildShareUrl(
workspaceId: workspaceId,
viewId: view.id,
);
widget.onSubmitted(link, true);
}
}
void showLinkCreateMenu(
BuildContext context,
EditorState editorState,
Selection selection,
String currentViewId,
) {
if (!context.mounted) return;
final (left, top, right, bottom, alignment) = _getPosition(editorState);
final node = editorState.getNodeAtPath(selection.end.path);
if (node == null) {
return;
}
final selectedText = editorState.getTextInSelection(selection).join();
OverlayEntry? overlay;
void dismissOverlay() {
keepEditorFocusNotifier.decrease();
overlay?.remove();
overlay = null;
}
keepEditorFocusNotifier.increase();
overlay = FullScreenOverlayEntry(
top: top,
bottom: bottom,
left: left,
right: right,
dismissCallback: () => keepEditorFocusNotifier.decrease(),
builder: (context) {
return LinkCreateMenu(
alignment: alignment,
initialText: selectedText,
currentViewId: currentViewId,
editorState: editorState,
onSubmitted: (link, isPage) async {
await editorState.formatDelta(selection, {
BuiltInAttributeKey.href: link,
kIsPageLink: isPage,
});
await editorState.updateSelectionWithReason(
null,
reason: SelectionUpdateReason.uiEvent,
);
dismissOverlay();
},
onDismiss: dismissOverlay,
);
},
).build();
Overlay.of(context, rootOverlay: true).insert(overlay!);
}
// get a proper position for link menu
(
double? left,
double? top,
double? right,
double? bottom,
LinkMenuAlignment alignment,
) _getPosition(
EditorState editorState,
) {
final rect = editorState.selectionRects().first;
const menuHeight = 222.0, menuWidth = 320.0;
double? left, right, top, bottom;
LinkMenuAlignment alignment = LinkMenuAlignment.topLeft;
final editorOffset = editorState.renderBox!.localToGlobal(Offset.zero),
editorSize = editorState.renderBox!.size;
final editorBottom = editorSize.height + editorOffset.dy,
editorRight = editorSize.width + editorOffset.dx;
final overflowBottom = rect.bottom + menuHeight > editorBottom,
overflowTop = rect.top - menuHeight < 0,
overflowLeft = rect.left - menuWidth < 0,
overflowRight = rect.right + menuWidth > editorRight;
if (overflowTop && !overflowBottom) {
/// show at bottom
top = rect.bottom;
} else if (overflowBottom && !overflowTop) {
/// show at top
bottom = editorBottom - rect.top;
} else if (!overflowTop && !overflowBottom) {
/// show at bottom
top = rect.bottom;
} else {
top = 0;
}
if (overflowLeft && !overflowRight) {
/// show at right
left = rect.left;
} else if (overflowRight && !overflowLeft) {
/// show at left
right = editorRight - rect.right;
} else if (!overflowLeft && !overflowRight) {
/// show at right
left = rect.left;
} else {
left = 0;
}
if (left != null && top != null) {
alignment = LinkMenuAlignment.bottomRight;
} else if (left != null && bottom != null) {
alignment = LinkMenuAlignment.topRight;
} else if (right != null && top != null) {
alignment = LinkMenuAlignment.bottomLeft;
} else if (right != null && bottom != null) {
alignment = LinkMenuAlignment.topLeft;
}
return (left, top, right, bottom, alignment);
}
ShapeDecoration buildToolbarLinkDecoration(
BuildContext context, {
double radius = 12.0,
}) =>
ShapeDecoration(
color: Theme.of(context).cardColor,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(radius),
),
shadows: [
const BoxShadow(
color: LinkStyle.shadowMedium,
blurRadius: 24,
offset: Offset(0, 4),
),
],
);
@@ -0,0 +1,486 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/toolbar_item/custom_link_toolbar_item.dart';
import 'package:appflowy/plugins/shared/share/constants.dart';
import 'package:appflowy/user/application/user_service.dart';
import 'package:appflowy/util/theme_extension.dart';
import 'package:appflowy/workspace/application/view/view_service.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_result/appflowy_result.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
// ignore: implementation_imports
import 'package:appflowy_editor/src/editor/util/link_util.dart';
import 'link_create_menu.dart';
import 'link_search_text_field.dart';
import 'link_styles.dart';
class LinkEditMenu extends StatefulWidget {
const LinkEditMenu({
super.key,
required this.linkInfo,
required this.onDismiss,
required this.onApply,
required this.onRemoveLink,
required this.currentViewId,
});
final LinkInfo linkInfo;
final ValueChanged<LinkInfo> onApply;
final ValueChanged<LinkInfo> onRemoveLink;
final VoidCallback onDismiss;
final String currentViewId;
@override
State<LinkEditMenu> createState() => _LinkEditMenuState();
}
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 LinkInfo linkInfo = widget.linkInfo;
late LinkSearchTextField searchTextField;
bool isShowingSearchResult = false;
ViewPB? currentView;
bool showErrorText = false;
@override
void initState() {
super.initState();
final isPageLink = linkInfo.isPage;
if (isPageLink) getPageView();
searchTextField = LinkSearchTextField(
initialSearchText: isPageLink ? '' : linkInfo.link,
initialViewId: linkInfo.viewId,
currentViewId: widget.currentViewId,
onEnter: onConfirm,
onEscape: () {
if (isShowingSearchResult) {
hideSearchResult();
} else {
onDismiss();
}
},
onDataRefresh: () {
if (mounted) setState(() {});
},
)..searchRecentViews();
}
@override
void dispose() {
linkNameController.dispose();
textFocusNode.dispose();
searchTextField.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final showingRecent =
searchTextField.showingRecent && isShowingSearchResult;
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),
),
),
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: 144 + errorHeight,
left: 20,
child: buildButtons(),
),
Positioned(
top: 100 + errorHeight,
left: 20,
child: buildNameTextField(),
),
Positioned(
top: 36,
left: 20,
child: buildLinkField(),
),
],
),
),
);
}
Widget buildLinkField() {
final showPageView = linkInfo.isPage && !isShowingSearchResult;
Widget child;
if (showPageView) {
child = buildPageView();
} else if (!isShowingSearchResult) {
child = buildLinkView();
} else {
return SizedBox(
width: 360,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 360,
height: 32,
child: searchTextField.buildTextField(
autofocus: true,
context: context,
),
),
VSpace(6),
searchTextField.buildResultContainer(
context: context,
width: 360,
onPageLinkSelected: onPageSelected,
onLinkSelected: onLinkSelected,
),
],
),
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
child,
if (showErrorText)
Padding(
padding: const EdgeInsets.only(top: 4),
child: FlowyText.regular(
LocaleKeys.document_plugins_file_networkUrlInvalid.tr(),
color: LinkStyle.textStatusError,
fontSize: 12,
figmaLineHeight: 16,
),
),
],
);
}
Widget buildButtons() {
return GestureDetector(
onTap: hideSearchResult,
child: SizedBox(
width: 360,
height: 32,
child: Row(
children: [
FlowyIconButton(
icon: FlowySvg(FlowySvgs.toolbar_link_unlink_m),
width: 32,
height: 32,
tooltipText: LocaleKeys.editor_removeLink.tr(),
preferBelow: false,
decoration: BoxDecoration(
borderRadius: BorderRadius.all(Radius.circular(8)),
border: Border.all(color: LinkStyle.borderColor(context)),
),
onPressed: () => onRemoveLink.call(linkInfo),
),
Spacer(),
DecoratedBox(
decoration: BoxDecoration(
borderRadius: BorderRadius.all(Radius.circular(8)),
border: Border.all(color: LinkStyle.borderColor(context)),
),
child: FlowyTextButton(
LocaleKeys.button_cancel.tr(),
padding: EdgeInsets.zero,
mainAxisAlignment: MainAxisAlignment.center,
constraints: BoxConstraints(maxWidth: 78, minHeight: 32),
fontSize: 14,
lineHeight: 20 / 14,
fontColor: Theme.of(context).isLightMode
? LinkStyle.textPrimary
: Theme.of(context).iconTheme.color,
fillColor: Colors.transparent,
fontWeight: FontWeight.w400,
onPressed: onDismiss,
),
),
HSpace(12),
ValueListenableBuilder(
valueListenable: linkNameController,
builder: (context, _, __) {
return FlowyTextButton(
LocaleKeys.settings_appearance_documentSettings_apply.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: () {
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);
},
);
},
),
],
),
),
);
}
Widget buildNameTextField() {
return SizedBox(
width: 360,
height: 32,
child: TextFormField(
autovalidateMode: AutovalidateMode.onUserInteraction,
focusNode: textFocusNode,
textAlign: TextAlign.left,
controller: linkNameController,
style: TextStyle(
fontSize: 14,
height: 20 / 14,
fontWeight: FontWeight.w400,
),
onChanged: (text) {
linkInfo = LinkInfo(
name: text,
link: linkInfo.link,
isPage: linkInfo.isPage,
);
},
decoration: LinkStyle.buildLinkTextFieldInputDecoration(
LocaleKeys.document_toolbar_linkNameHint.tr(),
context,
),
),
);
}
Widget buildPageView() {
late Widget child;
final view = currentView;
if (view == null) {
child = Center(
child: SizedBox.fromSize(
size: Size(10, 10),
child: CircularProgressIndicator(),
),
);
} else {
final viewName = view.name;
final displayName = viewName.isEmpty
? LocaleKeys.document_title_placeholder.tr()
: viewName;
child = GestureDetector(
onTap: showSearchResult,
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: FlowyTooltip(
preferBelow: false,
message: displayName,
child: Container(
height: 32,
padding: EdgeInsets.fromLTRB(8, 0, 8, 0),
child: Row(
children: [
searchTextField.buildIcon(view),
HSpace(4),
Flexible(
child: FlowyText.regular(
displayName,
overflow: TextOverflow.ellipsis,
figmaLineHeight: 20,
fontSize: 14,
),
),
],
),
),
),
),
);
}
return Container(
width: 360,
height: 32,
decoration: buildDecoration(),
child: child,
);
}
Widget buildLinkView() {
return Container(
width: 360,
height: 32,
decoration: buildDecoration(),
child: FlowyTooltip(
preferBelow: false,
message: linkInfo.link,
child: GestureDetector(
onTap: showSearchResult,
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: Padding(
padding: EdgeInsets.fromLTRB(8, 6, 8, 6),
child: Row(
children: [
FlowySvg(FlowySvgs.toolbar_link_earth_m),
HSpace(8),
Flexible(
child: FlowyText.regular(
linkInfo.link,
overflow: TextOverflow.ellipsis,
figmaLineHeight: 20,
),
),
],
),
),
),
),
),
);
}
void onConfirm() {
searchTextField.onSearchResult(
onLink: onLinkSelected,
onRecentViews: () => onPageSelected(searchTextField.currentRecentView),
onSearchViews: () => onPageSelected(searchTextField.currentSearchedView),
onEmpty: () {
searchTextField.unfocus();
},
);
}
Future<void> getPageView() async {
if (!linkInfo.isPage) return;
final (view, isInTrash, isDeleted) =
await ViewBackendService.getMentionPageStatus(linkInfo.viewId);
if (mounted) {
setState(() {
currentView = view;
});
}
}
void showSearchResult() {
setState(() {
if (linkInfo.isPage) searchTextField.updateText('');
isShowingSearchResult = true;
searchTextField.requestFocus();
});
}
void hideSearchResult() {
setState(() {
isShowingSearchResult = false;
searchTextField.unfocus();
textFocusNode.unfocus();
});
}
void onLinkSelected() {
if (mounted) {
linkInfo = LinkInfo(
name: linkInfo.name,
link: searchTextField.searchText,
);
hideSearchResult();
}
}
Future<void> onPageSelected(ViewPB view) async {
currentView = view;
final link = ShareConstants.buildShareUrl(
workspaceId: await UserBackendService.getCurrentWorkspace().fold(
(s) => s.id,
(f) => '',
),
viewId: view.id,
);
linkInfo = LinkInfo(
name: linkInfo.name,
link: link,
isPage: true,
);
searchTextField.updateText(linkInfo.link);
if (mounted) {
setState(() {
isShowingSearchResult = false;
searchTextField.unfocus();
});
}
}
BoxDecoration buildDecoration() => BoxDecoration(
borderRadius: BorderRadius.circular(8),
border: Border.all(color: LinkStyle.borderColor(context)),
);
}
class LinkInfo {
LinkInfo({this.isPage = false, required this.name, required this.link});
final bool isPage;
final String name;
final String link;
Attributes toAttribute() =>
{AppFlowyRichTextKeys.href: link, kIsPageLink: isPage};
String get viewId => isPage ? link.split('/').lastOrNull ?? '' : '';
}
@@ -0,0 +1,635 @@
import 'dart:math';
import 'package:appflowy/core/helpers/url_launcher.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/application/document_bloc.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/desktop_toolbar/desktop_floating_toolbar.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/link_embed/link_embed_block_component.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/link_preview/shared.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_block.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/toolbar_item/custom_link_toolbar_item.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/util/theme_extension.dart';
import 'package:appflowy/workspace/application/view/view_service.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'link_create_menu.dart';
import 'link_edit_menu.dart';
class LinkHoverTrigger extends StatefulWidget {
const LinkHoverTrigger({
super.key,
required this.editorState,
required this.selection,
required this.node,
required this.attribute,
required this.size,
this.delayToShow = const Duration(milliseconds: 50),
this.delayToHide = const Duration(milliseconds: 300),
});
final EditorState editorState;
final Selection selection;
final Node node;
final Attributes attribute;
final Size size;
final Duration delayToShow;
final Duration delayToHide;
@override
State<LinkHoverTrigger> createState() => _LinkHoverTriggerState();
}
class _LinkHoverTriggerState extends State<LinkHoverTrigger> {
final hoverMenuController = PopoverController();
final editMenuController = PopoverController();
final toolbarController = getIt<FloatingToolbarController>();
bool isHoverMenuShowing = false;
bool isHoverMenuHovering = false;
bool isHoverTriggerHovering = false;
Size get size => widget.size;
EditorState get editorState => widget.editorState;
Selection get selection => widget.selection;
Attributes get attribute => widget.attribute;
late HoverTriggerKey triggerKey = HoverTriggerKey(widget.node.id, selection);
@override
void initState() {
super.initState();
getIt<LinkHoverTriggers>()._add(triggerKey, showLinkHoverMenu);
toolbarController.addDisplayListener(onToolbarShow);
}
@override
void dispose() {
hoverMenuController.close();
editMenuController.close();
getIt<LinkHoverTriggers>()._remove(triggerKey, showLinkHoverMenu);
toolbarController.removeDisplayListener(onToolbarShow);
super.dispose();
}
@override
Widget build(BuildContext context) {
return MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (v) {
isHoverTriggerHovering = true;
Future.delayed(widget.delayToShow, () {
if (isHoverTriggerHovering && !isHoverMenuShowing) {
showLinkHoverMenu();
}
});
},
onExit: (v) {
isHoverTriggerHovering = false;
tryToDismissLinkHoverMenu();
},
child: buildHoverPopover(
buildEditPopover(
Container(
color: Colors.black.withAlpha(1),
width: size.width,
height: size.height,
),
),
),
);
}
Widget buildHoverPopover(Widget child) {
return AppFlowyPopover(
controller: hoverMenuController,
direction: PopoverDirection.topWithLeftAligned,
offset: Offset(0, size.height),
onOpen: () {
keepEditorFocusNotifier.increase();
isHoverMenuShowing = true;
},
onClose: () {
keepEditorFocusNotifier.decrease();
isHoverMenuShowing = false;
},
margin: EdgeInsets.zero,
constraints: BoxConstraints(
maxWidth: max(320, size.width),
maxHeight: 48 + size.height,
),
decorationColor: Colors.transparent,
popoverDecoration: BoxDecoration(),
popupBuilder: (context) => LinkHoverMenu(
attribute: widget.attribute,
triggerSize: size,
onEnter: (_) {
isHoverMenuHovering = true;
},
onExit: (_) {
isHoverMenuHovering = false;
tryToDismissLinkHoverMenu();
},
onConvertTo: (type) => convertLinkTo(editorState, selection, type),
onOpenLink: openLink,
onCopyLink: () => copyLink(context),
onEditLink: showLinkEditMenu,
onRemoveLink: () => removeLink(editorState, selection),
),
child: child,
);
}
Widget buildEditPopover(Widget child) {
final href = attribute.href ?? '',
isPage = attribute.isPage,
title = editorState.getTextInSelection(selection).join();
final currentViewId = context.read<DocumentBloc?>()?.documentId ?? '';
return AppFlowyPopover(
controller: editMenuController,
direction: PopoverDirection.bottomWithLeftAligned,
offset: Offset(0, 0),
onOpen: () => keepEditorFocusNotifier.increase(),
onClose: () => keepEditorFocusNotifier.decrease(),
margin: EdgeInsets.zero,
asBarrier: true,
decorationColor: Colors.transparent,
popoverDecoration: BoxDecoration(),
constraints: BoxConstraints(
maxWidth: 400,
minHeight: 282,
),
popupBuilder: (context) => LinkEditMenu(
currentViewId: currentViewId,
linkInfo: LinkInfo(name: title, link: href, isPage: isPage),
onDismiss: () => editMenuController.close(),
onApply: (info) async {
final transaction = editorState.transaction;
transaction.replaceText(
widget.node,
selection.startIndex,
selection.length,
info.name,
attributes: info.toAttribute(),
);
editMenuController.close();
await editorState.apply(transaction);
},
onRemoveLink: (linkinfo) =>
onRemoveAndReplaceLink(editorState, selection, linkinfo.name),
),
child: child,
);
}
void onToolbarShow() => hoverMenuController.close();
void showLinkHoverMenu() {
if (isHoverMenuShowing || toolbarController.isToolbarShowing || !mounted) {
return;
}
keepEditorFocusNotifier.increase();
hoverMenuController.show();
}
void showLinkEditMenu() {
keepEditorFocusNotifier.increase();
hoverMenuController.close();
editMenuController.show();
}
void tryToDismissLinkHoverMenu() {
Future.delayed(widget.delayToHide, () {
if (isHoverMenuHovering || isHoverTriggerHovering) {
return;
}
hoverMenuController.close();
});
}
Future<void> openLink() async {
final href = widget.attribute.href ?? '', isPage = widget.attribute.isPage;
if (isPage) {
final viewId = href.split('/').lastOrNull ?? '';
if (viewId.isEmpty) {
await afLaunchUrlString(href, addingHttpSchemeWhenFailed: true);
} else {
final (view, isInTrash, isDeleted) =
await ViewBackendService.getMentionPageStatus(viewId);
if (view != null) {
await handleMentionBlockTap(context, widget.editorState, view);
}
}
} else {
await afLaunchUrlString(href, addingHttpSchemeWhenFailed: true);
}
}
Future<void> copyLink(BuildContext context) async {
final href = widget.attribute.href ?? '';
await context.copyLink(href);
hoverMenuController.close();
}
void removeLink(
EditorState editorState,
Selection selection,
) {
final node = editorState.getNodeAtPath(selection.end.path);
if (node == null) {
return;
}
final index = selection.normalized.startIndex;
final length = selection.length;
final transaction = editorState.transaction
..formatText(
node,
index,
length,
{
BuiltInAttributeKey.href: null,
kIsPageLink: null,
},
);
editorState.apply(transaction);
}
Future<void> convertLinkTo(
EditorState editorState,
Selection selection,
LinkConvertMenuCommand type,
) async {
final url = widget.attribute.href ?? '';
if (type == LinkConvertMenuCommand.toBookmark) {
await convertUrlToLinkPreview(editorState, selection, url);
} else if (type == LinkConvertMenuCommand.toMention) {
await convertUrlToMention(editorState, selection);
} else if (type == LinkConvertMenuCommand.toEmbed) {
await convertUrlToLinkPreview(
editorState,
selection,
url,
previewType: LinkEmbedKeys.embed,
);
}
}
void onRemoveAndReplaceLink(
EditorState editorState,
Selection selection,
String text,
) {
final node = editorState.getNodeAtPath(selection.end.path);
if (node == null) {
return;
}
final index = selection.normalized.startIndex;
final length = selection.length;
final transaction = editorState.transaction
..replaceText(
node,
index,
length,
text,
attributes: {
BuiltInAttributeKey.href: null,
kIsPageLink: null,
},
);
editorState.apply(transaction);
}
}
class LinkHoverMenu extends StatefulWidget {
const LinkHoverMenu({
super.key,
required this.attribute,
required this.onEnter,
required this.onExit,
required this.triggerSize,
required this.onCopyLink,
required this.onOpenLink,
required this.onEditLink,
required this.onRemoveLink,
required this.onConvertTo,
});
final Attributes attribute;
final PointerEnterEventListener onEnter;
final PointerExitEventListener onExit;
final Size triggerSize;
final VoidCallback onCopyLink;
final VoidCallback onOpenLink;
final VoidCallback onEditLink;
final VoidCallback onRemoveLink;
final ValueChanged<LinkConvertMenuCommand> onConvertTo;
@override
State<LinkHoverMenu> createState() => _LinkHoverMenuState();
}
class _LinkHoverMenuState extends State<LinkHoverMenu> {
ViewPB? currentView;
late bool isPage = widget.attribute.isPage;
late String href = widget.attribute.href ?? '';
final popoverController = PopoverController();
bool isConvertButtonSelected = false;
@override
void initState() {
super.initState();
if (isPage) getPageView();
}
@override
void dispose() {
super.dispose();
popoverController.close();
}
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MouseRegion(
onEnter: widget.onEnter,
onExit: widget.onExit,
child: SizedBox(
width: max(320, widget.triggerSize.width),
height: 48,
child: Align(
alignment: Alignment.centerLeft,
child: Container(
width: 320,
height: 48,
decoration: buildToolbarLinkDecoration(context),
padding: EdgeInsets.fromLTRB(12, 8, 8, 8),
child: Row(
children: [
Expanded(child: buildLinkWidget()),
Container(
height: 20,
width: 1,
color: Color(0xffE8ECF3)
.withAlpha(Theme.of(context).isLightMode ? 255 : 40),
margin: EdgeInsets.symmetric(horizontal: 6),
),
FlowyIconButton(
icon: FlowySvg(FlowySvgs.toolbar_link_m),
tooltipText: LocaleKeys.editor_copyLink.tr(),
preferBelow: false,
width: 36,
height: 32,
onPressed: widget.onCopyLink,
),
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,
),
],
),
),
),
),
),
MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: widget.onEnter,
onExit: widget.onExit,
child: GestureDetector(
onTap: widget.onOpenLink,
child: Container(
width: widget.triggerSize.width,
height: widget.triggerSize.height,
color: Colors.black.withAlpha(1),
),
),
),
],
);
}
Future<void> getPageView() async {
final viewId = href.split('/').lastOrNull ?? '';
final (view, isInTrash, isDeleted) =
await ViewBackendService.getMentionPageStatus(viewId);
if (mounted) {
setState(() {
currentView = view;
});
}
}
Widget buildLinkWidget() {
final view = currentView;
if (isPage && view == null) {
return SizedBox.square(
dimension: 20,
child: CircularProgressIndicator(),
);
}
String text = '';
if (isPage && view != null) {
text = view.name;
if (text.isEmpty) {
text = LocaleKeys.document_title_placeholder.tr();
}
} else {
text = href;
}
return FlowyTooltip(
message: text,
preferBelow: false,
child: FlowyText.regular(
text,
overflow: TextOverflow.ellipsis,
figmaLineHeight: 20,
fontSize: 14,
),
);
}
Widget buildConvertButton() {
return AppFlowyPopover(
offset: Offset(44, 10.0),
direction: PopoverDirection.bottomWithRightAligned,
margin: EdgeInsets.zero,
controller: popoverController,
onOpen: () => keepEditorFocusNotifier.increase(),
onClose: () => keepEditorFocusNotifier.decrease(),
popupBuilder: (context) => buildConvertMenu(),
child: FlowyIconButton(
icon: FlowySvg(FlowySvgs.turninto_m),
isSelected: isConvertButtonSelected,
tooltipText: LocaleKeys.editor_convertTo.tr(),
preferBelow: false,
width: 36,
height: 32,
onPressed: () {
setState(() {
isConvertButtonSelected = true;
});
showConvertMenu();
},
),
);
}
Widget buildConvertMenu() {
return MouseRegion(
onEnter: widget.onEnter,
onExit: widget.onExit,
child: Padding(
padding: const EdgeInsets.all(8.0),
child: SeparatedColumn(
mainAxisSize: MainAxisSize.min,
separatorBuilder: () => const VSpace(0.0),
children:
List.generate(LinkConvertMenuCommand.values.length, (index) {
final command = LinkConvertMenuCommand.values[index];
return SizedBox(
height: 36,
child: FlowyButton(
text: FlowyText(
command.title,
fontWeight: FontWeight.w400,
figmaLineHeight: 20,
),
onTap: () {
widget.onConvertTo(command);
closeConvertMenu();
},
),
);
}),
),
),
);
}
void showConvertMenu() {
keepEditorFocusNotifier.increase();
popoverController.show();
}
void closeConvertMenu() {
popoverController.close();
}
}
class HoverTriggerKey {
HoverTriggerKey(this.nodeId, this.selection);
final String nodeId;
final Selection selection;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is HoverTriggerKey &&
runtimeType == other.runtimeType &&
nodeId == other.nodeId &&
isSelectionSame(other.selection);
bool isSelectionSame(Selection other) =>
(selection.start == other.start && selection.end == other.end) ||
(selection.start == other.end && selection.end == other.start);
@override
int get hashCode => nodeId.hashCode ^ selection.hashCode;
}
class LinkHoverTriggers {
final Map<HoverTriggerKey, Set<VoidCallback>> _map = {};
void _add(HoverTriggerKey key, VoidCallback callback) {
final callbacks = _map[key] ?? {};
callbacks.add(callback);
_map[key] = callbacks;
}
void _remove(HoverTriggerKey key, VoidCallback callback) {
final callbacks = _map[key] ?? {};
callbacks.remove(callback);
_map[key] = callbacks;
}
void call(HoverTriggerKey key) {
final callbacks = _map[key] ?? {};
if (callbacks.isEmpty) return;
callbacks.first.call();
}
}
enum LinkConvertMenuCommand {
toMention,
toBookmark,
toEmbed;
String get title {
switch (this) {
case toMention:
return LocaleKeys.document_plugins_linkPreview_linkPreviewMenu_toMetion
.tr();
case toBookmark:
return LocaleKeys
.document_plugins_linkPreview_linkPreviewMenu_toBookmark
.tr();
case toEmbed:
return LocaleKeys.document_plugins_linkPreview_linkPreviewMenu_toEmbed
.tr();
}
}
String get type {
switch (this) {
case toMention:
return MentionBlockKeys.type;
case toBookmark:
return LinkPreviewBlockKeys.type;
case toEmbed:
return LinkPreviewBlockKeys.type;
}
}
}
extension LinkExtension on BuildContext {
Future<void> copyLink(String link) async {
if (link.isEmpty) return;
await getIt<ClipboardService>()
.setData(ClipboardServiceData(plainText: link));
if (mounted) {
showToastNotification(
message: LocaleKeys.shareAction_copyLinkSuccess.tr(),
);
}
}
}
@@ -0,0 +1,184 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/menu/menu_extension.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
// ignore: implementation_imports
import 'package:appflowy_editor/src/editor/util/link_util.dart';
import 'package:flutter/services.dart';
import 'link_create_menu.dart';
import 'link_styles.dart';
void showReplaceMenu({
required BuildContext context,
required EditorState editorState,
required Node node,
String? url,
required LTRB ltrb,
required ValueChanged<String> onReplace,
}) {
OverlayEntry? overlay;
void dismissOverlay() {
keepEditorFocusNotifier.decrease();
overlay?.remove();
overlay = null;
}
keepEditorFocusNotifier.increase();
overlay = FullScreenOverlayEntry(
top: ltrb.top,
bottom: ltrb.bottom,
left: ltrb.left,
right: ltrb.right,
dismissCallback: () => keepEditorFocusNotifier.decrease(),
builder: (context) {
return LinkReplaceMenu(
link: url ?? '',
onSubmitted: (link) async {
onReplace.call(link);
dismissOverlay();
},
onDismiss: dismissOverlay,
);
},
).build();
Overlay.of(context, rootOverlay: true).insert(overlay!);
}
class LinkReplaceMenu extends StatefulWidget {
const LinkReplaceMenu({
super.key,
required this.onSubmitted,
required this.link,
required this.onDismiss,
});
final ValueChanged<String> onSubmitted;
final VoidCallback onDismiss;
final String link;
@override
State<LinkReplaceMenu> createState() => _LinkReplaceMenuState();
}
class _LinkReplaceMenuState extends State<LinkReplaceMenu> {
bool showErrorText = false;
late FocusNode focusNode = FocusNode(onKeyEvent: onKeyEvent);
late TextEditingController textEditingController =
TextEditingController(text: widget.link);
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
focusNode.requestFocus();
});
}
@override
void dispose() {
focusNode.dispose();
textEditingController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Container(
width: 330,
padding: EdgeInsets.all(8),
decoration: buildToolbarLinkDecoration(context),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: buildLinkField()),
HSpace(8),
buildReplaceButton(),
],
),
);
}
Widget buildLinkField() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
height: 32,
child: TextFormField(
autovalidateMode: AutovalidateMode.onUserInteraction,
autofocus: true,
focusNode: focusNode,
textAlign: TextAlign.left,
controller: textEditingController,
style: TextStyle(
fontSize: 14,
height: 20 / 14,
fontWeight: FontWeight.w400,
),
decoration: LinkStyle.buildLinkTextFieldInputDecoration(
LocaleKeys.document_plugins_linkPreview_linkPreviewMenu_pasteHint
.tr(),
context,
showErrorBorder: showErrorText,
),
),
),
if (showErrorText)
Padding(
padding: const EdgeInsets.only(top: 4),
child: FlowyText.regular(
LocaleKeys.document_plugins_file_networkUrlInvalid.tr(),
color: LinkStyle.textStatusError,
fontSize: 12,
figmaLineHeight: 16,
),
),
],
);
}
Widget buildReplaceButton() {
return FlowyTextButton(
LocaleKeys.button_replace.tr(),
padding: EdgeInsets.zero,
mainAxisAlignment: MainAxisAlignment.center,
constraints: BoxConstraints(maxWidth: 78, minHeight: 32),
fontSize: 14,
lineHeight: 20 / 14,
hoverColor: LinkStyle.fillThemeThick.withAlpha(200),
fontColor: Colors.white,
fillColor: LinkStyle.fillThemeThick,
fontWeight: FontWeight.w400,
onPressed: onSubmit,
);
}
void onSubmit() {
final link = textEditingController.text.trim();
if (link.isEmpty || !isUri(link)) {
setState(() {
showErrorText = true;
});
return;
}
widget.onSubmitted.call(link);
}
KeyEventResult onKeyEvent(FocusNode node, KeyEvent key) {
if (key is! KeyDownEvent) return KeyEventResult.ignored;
if (key.logicalKey == LogicalKeyboardKey.escape) {
widget.onDismiss.call();
return KeyEventResult.handled;
} else if (key.logicalKey == LogicalKeyboardKey.enter) {
onSubmit();
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
}
@@ -0,0 +1,352 @@
import 'dart:math';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart';
import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart';
import 'package:appflowy/shared/list_extension.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/workspace/application/recent/cached_recent_service.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy/workspace/application/view/view_service.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
// ignore: implementation_imports
import 'package:appflowy_editor/src/flutter/scrollable_positioned_list/scrollable_positioned_list.dart';
// ignore: implementation_imports
import 'package:appflowy_editor/src/editor/util/link_util.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'link_create_menu.dart';
import 'link_styles.dart';
class LinkSearchTextField {
LinkSearchTextField({
this.onEscape,
this.onEnter,
this.onDataRefresh,
this.initialViewId = '',
required this.currentViewId,
String? initialSearchText,
}) : textEditingController = TextEditingController(
text: isUri(initialSearchText ?? '') ? initialSearchText : '',
);
final TextEditingController textEditingController;
final String initialViewId;
final String currentViewId;
final ItemScrollController searchController = ItemScrollController();
late FocusNode focusNode = FocusNode(onKeyEvent: onKeyEvent);
final List<ViewPB> searchedViews = [];
final List<ViewPB> recentViews = [];
int selectedIndex = 0;
final VoidCallback? onEscape;
final VoidCallback? onEnter;
final VoidCallback? onDataRefresh;
String get searchText => textEditingController.text;
bool get isTextfieldEnable => searchText.isNotEmpty && isUri(searchText);
bool get showingRecent => searchText.isEmpty && recentViews.isNotEmpty;
ViewPB get currentSearchedView => searchedViews[selectedIndex];
ViewPB get currentRecentView => recentViews[selectedIndex];
void dispose() {
textEditingController.dispose();
focusNode.dispose();
searchedViews.clear();
recentViews.clear();
}
Widget buildTextField({
bool autofocus = false,
bool showError = false,
required BuildContext context,
}) {
return TextFormField(
autovalidateMode: AutovalidateMode.onUserInteraction,
autofocus: autofocus,
focusNode: focusNode,
textAlign: TextAlign.left,
controller: textEditingController,
style: TextStyle(
fontSize: 14,
height: 20 / 14,
fontWeight: FontWeight.w400,
),
onChanged: (text) {
if (text.isEmpty) {
searchedViews.clear();
selectedIndex = 0;
onDataRefresh?.call();
} else {
searchViews(text);
}
},
decoration: LinkStyle.buildLinkTextFieldInputDecoration(
LocaleKeys.document_toolbar_linkInputHint.tr(),
context,
showErrorBorder: showError,
),
);
}
Widget buildResultContainer({
EdgeInsetsGeometry? margin,
required BuildContext context,
VoidCallback? onLinkSelected,
ValueChanged<ViewPB>? onPageLinkSelected,
double width = 320.0,
}) {
return onSearchResult<Widget>(
onEmpty: () => SizedBox.shrink(),
onLink: () => Container(
height: 48,
width: width,
padding: EdgeInsets.all(8),
margin: margin,
decoration: buildToolbarLinkDecoration(context),
child: FlowyButton(
leftIcon: FlowySvg(FlowySvgs.toolbar_link_earth_m),
isSelected: true,
text: FlowyText.regular(
searchText,
overflow: TextOverflow.ellipsis,
fontSize: 14,
figmaLineHeight: 20,
),
onTap: onLinkSelected,
),
),
onRecentViews: () => Container(
width: width,
height: recentViews.length.clamp(1, 5) * 32.0 + 48,
margin: margin,
padding: EdgeInsets.all(8),
decoration: buildToolbarLinkDecoration(context),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
height: 32,
padding: EdgeInsets.all(8),
child: FlowyText.semibold(
LocaleKeys.inlineActions_recentPages.tr(),
color: LinkStyle.textTertiary,
fontSize: 12,
figmaLineHeight: 16,
),
),
Flexible(
child: ListView.builder(
itemBuilder: (context, index) {
final currentView = recentViews[index];
return buildPageItem(
currentView,
index == selectedIndex,
onPageLinkSelected,
);
},
itemCount: recentViews.length,
),
),
],
),
),
onSearchViews: () => Container(
width: width,
height: searchedViews.length.clamp(1, 5) * 32.0 + 16,
margin: margin,
decoration: buildToolbarLinkDecoration(context),
child: ScrollablePositionedList.builder(
padding: EdgeInsets.all(8),
physics: const ClampingScrollPhysics(),
shrinkWrap: true,
itemCount: searchedViews.length,
itemScrollController: searchController,
initialScrollIndex: max(0, selectedIndex),
itemBuilder: (context, index) {
final currentView = searchedViews[index];
return buildPageItem(
currentView,
index == selectedIndex,
onPageLinkSelected,
);
},
),
),
);
}
Widget buildPageItem(
ViewPB view,
bool isSelected,
ValueChanged<ViewPB>? onSubmittedPageLink,
) {
final viewName = view.name;
final displayName = viewName.isEmpty
? LocaleKeys.document_title_placeholder.tr()
: viewName;
final isCurrent = initialViewId == view.id;
return SizedBox(
height: 32,
child: FlowyButton(
isSelected: isSelected,
leftIcon: buildIcon(view, padding: EdgeInsets.zero),
text: FlowyText.regular(
displayName,
overflow: TextOverflow.ellipsis,
fontSize: 14,
figmaLineHeight: 20,
),
rightIcon: isCurrent ? FlowySvg(FlowySvgs.toolbar_check_m) : null,
onTap: () => onSubmittedPageLink?.call(view),
),
);
}
Widget buildIcon(
ViewPB view, {
EdgeInsetsGeometry padding = const EdgeInsets.only(top: 4),
}) {
if (view.icon.value.isEmpty) return view.defaultIcon(size: Size(20, 20));
final iconData = view.icon.toEmojiIconData();
return Padding(
padding: padding,
child: RawEmojiIconWidget(
emoji: iconData,
emojiSize: iconData.type == FlowyIconType.emoji ? 16 : 20,
lineHeight: 1,
),
);
}
void requestFocus() => focusNode.requestFocus();
void unfocus() => focusNode.unfocus();
void updateText(String text) => textEditingController.text = text;
T onSearchResult<T>({
required ValueGetter<T> onLink,
required ValueGetter<T> onRecentViews,
required ValueGetter<T> onSearchViews,
required ValueGetter<T> onEmpty,
}) {
if (searchedViews.isEmpty && recentViews.isEmpty && searchText.isEmpty) {
return onEmpty.call();
}
if (searchedViews.isEmpty && searchText.isNotEmpty) {
return onLink.call();
}
if (searchedViews.isEmpty) return onRecentViews.call();
return onSearchViews.call();
}
KeyEventResult onKeyEvent(FocusNode node, KeyEvent key) {
if (key is! KeyDownEvent) return KeyEventResult.ignored;
int index = selectedIndex;
if (key.logicalKey == LogicalKeyboardKey.escape) {
onEscape?.call();
return KeyEventResult.handled;
} else if (key.logicalKey == LogicalKeyboardKey.arrowUp) {
index = onSearchResult(
onLink: () => 0,
onRecentViews: () {
int result = index - 1;
if (result < 0) result = recentViews.length - 1;
return result;
},
onSearchViews: () {
int result = index - 1;
if (result < 0) result = searchedViews.length - 1;
searchController.scrollTo(
index: result,
alignment: 0.5,
duration: const Duration(milliseconds: 300),
);
return result;
},
onEmpty: () => 0,
);
refreshIndex(index);
return KeyEventResult.handled;
} else if (key.logicalKey == LogicalKeyboardKey.arrowDown) {
index = onSearchResult(
onLink: () => 0,
onRecentViews: () {
int result = index + 1;
if (result >= recentViews.length) result = 0;
return result;
},
onSearchViews: () {
int result = index + 1;
if (result >= searchedViews.length) result = 0;
searchController.scrollTo(
index: result,
alignment: 0.5,
duration: const Duration(milliseconds: 300),
);
return result;
},
onEmpty: () => 0,
);
refreshIndex(index);
return KeyEventResult.handled;
} else if (key.logicalKey == LogicalKeyboardKey.enter) {
onEnter?.call();
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
Future<void> searchRecentViews() async {
final recentService = getIt<CachedRecentService>();
final sectionViews = await recentService.recentViews();
final views = sectionViews
.unique((e) => e.item.id)
.map((e) => e.item)
.where((e) => e.id != currentViewId)
.take(5)
.toList();
recentViews.clear();
recentViews.addAll(views);
selectedIndex = 0;
onDataRefresh?.call();
}
Future<void> searchViews(String search) async {
final viewResult = await ViewBackendService.getAllViews();
final allViews = viewResult
.toNullable()
?.items
.where(
(view) =>
(view.id != currentViewId) &&
(view.name.toLowerCase().contains(search.toLowerCase()) ||
(view.name.isEmpty && search.isEmpty) ||
(view.name.isEmpty &&
LocaleKeys.menuAppHeader_defaultNewPageName
.tr()
.toLowerCase()
.contains(search.toLowerCase()))),
)
.take(10)
.toList();
searchedViews.clear();
searchedViews.addAll(allViews ?? []);
selectedIndex = 0;
onDataRefresh?.call();
}
void refreshIndex(int index) {
selectedIndex = index;
onDataRefresh?.call();
}
}
Loaded 100 of 548 files, more files were not shown because too many files have changed in this diff. Show more