From b298bb49beac9b2ceefc97fef3f2bcb47e4c040a Mon Sep 17 00:00:00 2001 From: "Kilu.He" <108015703+qinluhe@users.noreply.github.com> Date: Mon, 31 Mar 2025 10:21:14 +0800 Subject: [PATCH] feat: support ai writer (#69) * feat: support ai writer * feat: support duplicate page * feat: support upgrade AI Max --- cypress.config.ts | 5 - package.json | 4 +- pnpm-lock.yaml | 143 ++++++++++-- src/@types/translations/en.json | 36 ++- .../database-yjs/__tests__/selector.test.tsx | 1 + src/application/database-yjs/context.ts | 5 +- .../services/js-services/http/http_api.ts | 16 +- src/application/services/js-services/index.ts | 4 + src/application/services/services.type.ts | 13 +- src/application/slate-yjs/command/index.ts | 17 +- .../slate-yjs/utils/applyToSlate.ts | 32 +-- src/application/slate-yjs/utils/applyToYjs.ts | 38 ++-- src/application/slate-yjs/utils/convert.ts | 8 +- src/application/slate-yjs/utils/editor.ts | 1 - src/application/slate-yjs/utils/yjs.ts | 11 +- src/application/types.ts | 5 + src/application/ydoc/apply/index.ts | 4 +- src/assets/add_user.svg | 16 +- src/assets/ai.svg | 7 + src/assets/continue_writing.svg | 10 + src/assets/debug.svg | 37 ++- src/assets/help_documentation.svg | 8 + src/assets/import.svg | 12 +- src/assets/improve-writing.svg | 6 + src/assets/message_support.svg | 16 +- src/assets/moon.svg | 13 +- src/assets/report.svg | 24 +- src/assets/sign_out.svg | 13 +- src/assets/star.svg | 13 +- src/assets/sun.svg | 16 +- src/assets/upgrade_ai_max.svg | 13 ++ .../_shared/breadcrumb/BreadcrumbItem.tsx | 15 +- src/components/_shared/help/Help.tsx | 15 +- .../_shared/notify/InfoSnackbar.tsx | 18 +- src/components/ai-chat/DrawerContent.tsx | 12 +- src/components/ai-chat/utils.ts | 58 ++++- src/components/app/Main.tsx | 2 +- src/components/app/MainLayout.tsx | 2 +- src/components/app/ViewModal.tsx | 16 +- .../app/header/MoreActionsContent.tsx | 52 +++-- .../app/view-actions/MoreSpaceActions.tsx | 59 +++-- .../view-actions/SpacePermissionButton.tsx | 4 +- src/components/app/workspaces/Workspaces.tsx | 54 ++++- src/components/billing/UpgradeAIMax.tsx | 211 ++++++++++++++++++ src/components/billing/UpgradePlan.tsx | 90 +++++--- src/components/database/Database.tsx | 19 +- .../database/__tests__/DatabaseRow.cy.tsx | 3 +- .../__tests__/withTestingDatabase.tsx | 5 +- .../database-row/DatabaseRowSubDocument.tsx | 14 +- .../components/header/DatabaseRowHeader.tsx | 16 +- src/components/document/Document.tsx | 3 +- src/components/editor/Editable.tsx | 37 +-- src/components/editor/Editor.cy.tsx | 3 +- src/components/editor/EditorContext.tsx | 9 +- src/components/editor/EditorOverlay.tsx | 204 ++++++++++++++++- src/components/editor/__tests__/mount.tsx | 10 +- .../blocks/database/DatabaseBlock.tsx | 2 + .../editor/components/blocks/image/Img.tsx | 6 +- .../blocks/link-preview/LinkPreview.tsx | 21 +- .../editor/components/blocks/text/Text.tsx | 11 +- .../components/element/BlockNotFound.tsx | 31 +++ .../editor/components/element/Element.tsx | 5 +- .../panels/slash-panel/SlashPanel.tsx | 93 ++++++-- .../block-controls/HoverControls.hooks.ts | 25 ++- .../SelectionToolbar.hooks.ts | 62 +++-- .../selection-toolbar/ToolbarActions.tsx | 92 ++++---- .../selection-toolbar/actions/AIAssistant.tsx | 111 +++++++++ src/components/editor/editor.scss | 20 +- src/components/publish/CollabView.tsx | 15 +- src/components/publish/DatabaseView.tsx | 9 +- src/components/view-meta/ViewMetaPreview.tsx | 4 +- src/pages/AcceptInvitationPage.tsx | 40 ++-- src/pages/AppPage.tsx | 23 +- src/styles/app.scss | 1 + src/styles/variables/dark.variables.css | 4 + src/styles/variables/light.variables.css | 3 + src/utils/image.ts | 4 +- tailwind/box-shadow.cjs | 2 +- tailwind/colors.cjs | 9 +- 79 files changed, 1575 insertions(+), 496 deletions(-) create mode 100644 src/assets/ai.svg create mode 100644 src/assets/continue_writing.svg create mode 100644 src/assets/help_documentation.svg create mode 100644 src/assets/improve-writing.svg create mode 100644 src/assets/upgrade_ai_max.svg create mode 100644 src/components/billing/UpgradeAIMax.tsx create mode 100644 src/components/editor/components/element/BlockNotFound.tsx create mode 100644 src/components/editor/components/toolbar/selection-toolbar/actions/AIAssistant.tsx diff --git a/cypress.config.ts b/cypress.config.ts index f06cce32..38bf5303 100644 --- a/cypress.config.ts +++ b/cypress.config.ts @@ -1,7 +1,4 @@ import { defineConfig } from 'cypress'; -import registerCodeCoverageTasks from '@cypress/code-coverage/task'; - -import { addMatchImageSnapshotPlugin } from 'cypress-image-snapshot/plugin'; export default defineConfig({ env: { @@ -16,8 +13,6 @@ export default defineConfig({ bundler: 'vite', }, setupNodeEvents(on, config) { - registerCodeCoverageTasks(on, config); - addMatchImageSnapshotPlugin(on, config); return config; }, supportFile: 'cypress/support/component.ts', diff --git a/package.json b/package.json index 03e96b55..97d107f6 100644 --- a/package.json +++ b/package.json @@ -18,8 +18,8 @@ "coverage": "pnpm run test:unit && pnpm run test:components" }, "dependencies": { - "@appflowyinc/ai-chat": "0.0.15", - "@appflowyinc/editor": "^0.1.6", + "@appflowyinc/ai-chat": "0.1.24", + "@appflowyinc/editor": "^0.1.10", "@atlaskit/primitives": "^5.5.3", "@emoji-mart/data": "^1.1.2", "@emoji-mart/react": "^1.1.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b06f45cf..3c65f0d5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2,11 +2,11 @@ lockfileVersion: '6.0' dependencies: '@appflowyinc/ai-chat': - specifier: 0.0.15 - version: 0.0.15(@appflowyinc/editor@0.1.6)(@types/react-dom@18.2.22)(@types/react@18.2.66)(axios@1.7.2)(dompurify@3.1.7)(i18next-resources-to-backend@1.2.1)(i18next@22.5.1)(react-dom@18.2.0)(react-i18next@14.1.2)(react@18.2.0) + specifier: 0.1.24 + version: 0.1.24(@appflowyinc/editor@0.1.10)(@types/react-dom@18.2.22)(@types/react@18.2.66)(axios@1.7.2)(dompurify@3.1.7)(i18next-resources-to-backend@1.2.1)(i18next@22.5.1)(react-dom@18.2.0)(react-i18next@14.1.2)(react@18.2.0) '@appflowyinc/editor': - specifier: ^0.1.6 - version: 0.1.6(@types/react-dom@18.2.22)(@types/react@18.2.66)(i18next-resources-to-backend@1.2.1)(i18next@22.5.1)(react-dom@18.2.0)(react-i18next@14.1.2)(react@18.2.0)(slate-history@0.100.0)(slate-react@0.101.6)(slate@0.101.5) + specifier: ^0.1.10 + version: 0.1.10(@types/react-dom@18.2.22)(@types/react@18.2.66)(i18next-resources-to-backend@1.2.1)(i18next@22.5.1)(react-dom@18.2.0)(react-i18next@14.1.2)(react@18.2.0)(slate-history@0.100.0)(slate-react@0.101.6)(slate@0.101.5) '@atlaskit/primitives': specifier: ^5.5.3 version: 5.7.0(@types/react@18.2.66)(react@18.2.0) @@ -535,10 +535,10 @@ packages: resolution: {integrity: sha512-+562v9k4aI80m1+VuMHehNJWLOFjBnXn3tdOitzD0il5b7smkSBal4+a3oKiQTbrwMmN/TBUMDvbdoWDehgOww==} dev: false - /@appflowyinc/ai-chat@0.0.15(@appflowyinc/editor@0.1.6)(@types/react-dom@18.2.22)(@types/react@18.2.66)(axios@1.7.2)(dompurify@3.1.7)(i18next-resources-to-backend@1.2.1)(i18next@22.5.1)(react-dom@18.2.0)(react-i18next@14.1.2)(react@18.2.0): - resolution: {integrity: sha512-vpF73487ARBFrxO7BywRsCWDEKSTyhT5SgPyZetBRsyVwLju6iYtFTyf7N7FdLsl1mWZnJUBcvi7YWdWEqno6A==} + /@appflowyinc/ai-chat@0.1.24(@appflowyinc/editor@0.1.10)(@types/react-dom@18.2.22)(@types/react@18.2.66)(axios@1.7.2)(dompurify@3.1.7)(i18next-resources-to-backend@1.2.1)(i18next@22.5.1)(react-dom@18.2.0)(react-i18next@14.1.2)(react@18.2.0): + resolution: {integrity: sha512-A8NtOFMlI0jzJ+vC0GfLdRoD8WMVGUV3ypLNBm+8fnG5r8Z8aSe0Zu9Y3BRowyV+IvT3vZFbH/K1jUiIUxgFLw==} peerDependencies: - '@appflowyinc/editor': ^0.1.6 + '@appflowyinc/editor': ^0.1.7 axios: ^1.7.9 dompurify: ^3.2.4 i18next: ^22.4.10 @@ -547,11 +547,13 @@ packages: react-dom: ^18.2.0 react-i18next: ^14.1.0 dependencies: - '@appflowyinc/editor': 0.1.6(@types/react-dom@18.2.22)(@types/react@18.2.66)(i18next-resources-to-backend@1.2.1)(i18next@22.5.1)(react-dom@18.2.0)(react-i18next@14.1.2)(react@18.2.0)(slate-history@0.100.0)(slate-react@0.101.6)(slate@0.101.5) + '@appflowyinc/editor': 0.1.10(@types/react-dom@18.2.22)(@types/react@18.2.66)(i18next-resources-to-backend@1.2.1)(i18next@22.5.1)(react-dom@18.2.0)(react-i18next@14.1.2)(react@18.2.0)(slate-history@0.100.0)(slate-react@0.101.6)(slate@0.101.5) '@jest/globals': 29.7.0 '@radix-ui/react-avatar': 1.1.3(@types/react-dom@18.2.22)(@types/react@18.2.66)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-dialog': 1.1.6(@types/react-dom@18.2.22)(@types/react@18.2.66)(react-dom@18.2.0)(react@18.2.0) '@radix-ui/react-label': 2.1.1(@types/react-dom@18.2.22)(@types/react@18.2.66)(react-dom@18.2.0)(react@18.2.0) '@radix-ui/react-popover': 1.1.4(@types/react-dom@18.2.22)(@types/react@18.2.66)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-radio-group': 1.2.3(@types/react-dom@18.2.22)(@types/react@18.2.66)(react-dom@18.2.0)(react@18.2.0) '@radix-ui/react-select': 2.1.4(@types/react-dom@18.2.22)(@types/react@18.2.66)(react-dom@18.2.0)(react@18.2.0) '@radix-ui/react-separator': 1.1.1(@types/react-dom@18.2.22)(@types/react@18.2.66)(react-dom@18.2.0)(react@18.2.0) '@radix-ui/react-slot': 1.1.2(@types/react@18.2.66)(react@18.2.0) @@ -573,6 +575,7 @@ packages: react-i18next: 14.1.2(i18next@22.5.1)(react-dom@18.2.0)(react@18.2.0) react-infinite-scroll-component: 6.1.0(react@18.2.0) sass: 1.83.0 + smooth-scroll-into-view-if-needed: 2.0.2 tailwind-merge: 2.5.5 tailwindcss: 3.4.17 tailwindcss-animate: 1.0.7(tailwindcss@3.4.17) @@ -586,8 +589,8 @@ packages: - ts-node dev: false - /@appflowyinc/editor@0.1.6(@types/react-dom@18.2.22)(@types/react@18.2.66)(i18next-resources-to-backend@1.2.1)(i18next@22.5.1)(react-dom@18.2.0)(react-i18next@14.1.2)(react@18.2.0)(slate-history@0.100.0)(slate-react@0.101.6)(slate@0.101.5): - resolution: {integrity: sha512-IR+SfRM1E5QWTl4Q83GZ+XXSEYH/27nxj5Dy5Zfv697D7qirHENggU4lJBUXqRBXHSoKWB5wDJunF/t9HY+E9w==} + /@appflowyinc/editor@0.1.10(@types/react-dom@18.2.22)(@types/react@18.2.66)(i18next-resources-to-backend@1.2.1)(i18next@22.5.1)(react-dom@18.2.0)(react-i18next@14.1.2)(react@18.2.0)(slate-history@0.100.0)(slate-react@0.101.6)(slate@0.101.5): + resolution: {integrity: sha512-2/IcZ1FTIEhv9DzVdCu+9WvOnjSLENU3j08Uu2Pep52EsoYQpEHpdeKaYKqBpwESFGwI0vc2sOXFyKOel8UgTg==} peerDependencies: i18next: ^22.4.10 i18next-resources-to-backend: ^1.2.1 @@ -4056,6 +4059,39 @@ packages: react: 18.2.0 dev: false + /@radix-ui/react-dialog@1.1.6(@types/react-dom@18.2.22)(@types/react@18.2.66)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-/IVhJV5AceX620DUJ4uYVMymzsipdKBzo3edo+omeskCKGm9FRHM0ebIdbPnlQVJqyuHbuBltQUOG2mOTq2IYw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@radix-ui/primitive': 1.1.1 + '@radix-ui/react-compose-refs': 1.1.1(@types/react@18.2.66)(react@18.2.0) + '@radix-ui/react-context': 1.1.1(@types/react@18.2.66)(react@18.2.0) + '@radix-ui/react-dismissable-layer': 1.1.5(@types/react-dom@18.2.22)(@types/react@18.2.66)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-focus-guards': 1.1.1(@types/react@18.2.66)(react@18.2.0) + '@radix-ui/react-focus-scope': 1.1.2(@types/react-dom@18.2.22)(@types/react@18.2.66)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-id': 1.1.0(@types/react@18.2.66)(react@18.2.0) + '@radix-ui/react-portal': 1.1.4(@types/react-dom@18.2.22)(@types/react@18.2.66)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-presence': 1.1.2(@types/react-dom@18.2.22)(@types/react@18.2.66)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-primitive': 2.0.2(@types/react-dom@18.2.22)(@types/react@18.2.66)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-slot': 1.1.2(@types/react@18.2.66)(react@18.2.0) + '@radix-ui/react-use-controllable-state': 1.1.0(@types/react@18.2.66)(react@18.2.0) + '@types/react': 18.2.66 + '@types/react-dom': 18.2.22 + aria-hidden: 1.2.4 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + react-remove-scroll: 2.6.3(@types/react@18.2.66)(react@18.2.0) + dev: false + /@radix-ui/react-direction@1.1.0(@types/react@18.2.66)(react@18.2.0): resolution: {integrity: sha512-BUuBvgThEiAXh2DWu93XsT+a3aWrGqolGlqqw5VU1kG7p/ZH2cuDlM1sRLNnY3QcBS69UIz2mcKhMxDsdewhjg==} peerDependencies: @@ -4152,6 +4188,28 @@ packages: react-dom: 18.2.0(react@18.2.0) dev: false + /@radix-ui/react-focus-scope@1.1.2(@types/react-dom@18.2.22)(@types/react@18.2.66)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-zxwE80FCU7lcXUGWkdt6XpTTCKPitG1XKOwViTxHVKIJhZl9MvIl2dVHeZENCWD9+EdWv05wlaEkRXUykU27RA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@radix-ui/react-compose-refs': 1.1.1(@types/react@18.2.66)(react@18.2.0) + '@radix-ui/react-primitive': 2.0.2(@types/react-dom@18.2.22)(@types/react@18.2.66)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-use-callback-ref': 1.1.0(@types/react@18.2.66)(react@18.2.0) + '@types/react': 18.2.66 + '@types/react-dom': 18.2.22 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + /@radix-ui/react-id@1.1.0(@types/react@18.2.66)(react@18.2.0): resolution: {integrity: sha512-EJUrI8yYh7WOjNOqpoJaf1jlFIH2LvtgAl+YcFqNCa+4hj64ZXmPkAKOFs/ukjz3byN6bdb/AVUqHkI8/uWWMA==} peerDependencies: @@ -4217,7 +4275,7 @@ packages: aria-hidden: 1.2.4 react: 18.2.0 react-dom: 18.2.0(react@18.2.0) - react-remove-scroll: 2.6.2(@types/react@18.2.66)(react@18.2.0) + react-remove-scroll: 2.6.3(@types/react@18.2.66)(react@18.2.0) dev: false /@radix-ui/react-popper@1.2.1(@types/react-dom@18.2.22)(@types/react@18.2.66)(react-dom@18.2.0)(react@18.2.0): @@ -4352,6 +4410,63 @@ packages: react-dom: 18.2.0(react@18.2.0) dev: false + /@radix-ui/react-radio-group@1.2.3(@types/react-dom@18.2.22)(@types/react@18.2.66)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-xtCsqt8Rp09FK50ItqEqTJ7Sxanz8EM8dnkVIhJrc/wkMMomSmXHvYbhv3E7Zx4oXh98aaLt9W679SUYXg4IDA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@radix-ui/primitive': 1.1.1 + '@radix-ui/react-compose-refs': 1.1.1(@types/react@18.2.66)(react@18.2.0) + '@radix-ui/react-context': 1.1.1(@types/react@18.2.66)(react@18.2.0) + '@radix-ui/react-direction': 1.1.0(@types/react@18.2.66)(react@18.2.0) + '@radix-ui/react-presence': 1.1.2(@types/react-dom@18.2.22)(@types/react@18.2.66)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-primitive': 2.0.2(@types/react-dom@18.2.22)(@types/react@18.2.66)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-roving-focus': 1.1.2(@types/react-dom@18.2.22)(@types/react@18.2.66)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-use-controllable-state': 1.1.0(@types/react@18.2.66)(react@18.2.0) + '@radix-ui/react-use-previous': 1.1.0(@types/react@18.2.66)(react@18.2.0) + '@radix-ui/react-use-size': 1.1.0(@types/react@18.2.66)(react@18.2.0) + '@types/react': 18.2.66 + '@types/react-dom': 18.2.22 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + + /@radix-ui/react-roving-focus@1.1.2(@types/react-dom@18.2.22)(@types/react@18.2.66)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-zgMQWkNO169GtGqRvYrzb0Zf8NhMHS2DuEB/TiEmVnpr5OqPU3i8lfbxaAmC2J/KYuIQxyoQQ6DxepyXp61/xw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@radix-ui/primitive': 1.1.1 + '@radix-ui/react-collection': 1.1.2(@types/react-dom@18.2.22)(@types/react@18.2.66)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-compose-refs': 1.1.1(@types/react@18.2.66)(react@18.2.0) + '@radix-ui/react-context': 1.1.1(@types/react@18.2.66)(react@18.2.0) + '@radix-ui/react-direction': 1.1.0(@types/react@18.2.66)(react@18.2.0) + '@radix-ui/react-id': 1.1.0(@types/react@18.2.66)(react@18.2.0) + '@radix-ui/react-primitive': 2.0.2(@types/react-dom@18.2.22)(@types/react@18.2.66)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-use-callback-ref': 1.1.0(@types/react@18.2.66)(react@18.2.0) + '@radix-ui/react-use-controllable-state': 1.1.0(@types/react@18.2.66)(react@18.2.0) + '@types/react': 18.2.66 + '@types/react-dom': 18.2.22 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + /@radix-ui/react-select@2.1.4(@types/react-dom@18.2.22)(@types/react@18.2.66)(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-pOkb2u8KgO47j/h7AylCj7dJsm69BXcjkrvTqMptFqsE2i0p8lHkfgneXKjAgPzBMivnoMyt8o4KiV4wYzDdyQ==} peerDependencies: @@ -4389,7 +4504,7 @@ packages: aria-hidden: 1.2.4 react: 18.2.0 react-dom: 18.2.0(react@18.2.0) - react-remove-scroll: 2.6.2(@types/react@18.2.66)(react@18.2.0) + react-remove-scroll: 2.6.3(@types/react@18.2.66)(react@18.2.0) dev: false /@radix-ui/react-separator@1.1.1(@types/react-dom@18.2.22)(@types/react@18.2.66)(react-dom@18.2.0)(react@18.2.0): @@ -12742,8 +12857,8 @@ packages: tslib: 2.6.2 dev: false - /react-remove-scroll@2.6.2(@types/react@18.2.66)(react@18.2.0): - resolution: {integrity: sha512-KmONPx5fnlXYJQqC62Q+lwIeAk64ws/cUw6omIumRzMRPqgnYqhSSti99nbj0Ry13bv7dF+BKn7NB+OqkdZGTw==} + /react-remove-scroll@2.6.3(@types/react@18.2.66)(react@18.2.0): + resolution: {integrity: sha512-pnAi91oOk8g8ABQKGF5/M9qxmmOPxaAnopyTHYfqYEwJhyFrbbBtHuSgtKEoH0jpcxx5o3hXqH1mNd9/Oi+8iQ==} engines: {node: '>=10'} peerDependencies: '@types/react': '*' diff --git a/src/@types/translations/en.json b/src/@types/translations/en.json index d95aec0f..607699f5 100644 --- a/src/@types/translations/en.json +++ b/src/@types/translations/en.json @@ -264,7 +264,8 @@ "questionBubble": { "shortcuts": "Shortcuts", "whatsNew": "What's new?", - "help": "Help & Support", + "documentation": "Help & documentation", + "help": "Get support", "markdown": "Markdown", "debug": { "name": "Debug Info", @@ -1687,7 +1688,9 @@ "aiWriter": "AI Writer", "dateOrReminder": "Date or Reminder", "photoGallery": "Photo Gallery", - "file": "File" + "file": "File", + "continueWriting": "Continue Writing", + "askAIAnything": "Ask AI Anything" }, "subPage": { "name": "Document", @@ -2446,7 +2449,9 @@ "toggleListShortForm": "Toggle", "quoteListShortForm": "Quote", "mathEquationShortForm": "Formula", - "codeBlockShortForm": "Code" + "codeBlockShortForm": "Code", + "improveWriting": "Improve writing", + "askAI": "Ask AI Anything" }, "favorite": { "noFavorite": "No favorite page", @@ -2978,6 +2983,21 @@ "priceIn": "Price in ", "free": "Free", "pro": "Pro", + "getAIMax": "Get AI Max", + "upgradeAIMax": "Upgrade to AI Max", + "unlock": "Unlock", + "AIMax": { + "label": "AI Max", + "removeTitle": "Remove AI Max", + "removeDescription": "Are you sure you want to remove AI Max? You will lose access to the features and benefits of AI Max immediately.", + "description": "Access the most advanced AI models including GPT-4o, GPT-o3-mini, DeepSeek R1, and Claude 3.5 Sonnet", + "pricing": "per member per month\nbilled annually", + "points": { + "first": "Unlimited AI responses", + "second": "Unlimited file uploads", + "third": "50 AI images per month" + } + }, "freeDescription": "For individuals up to 2 members to organize everything", "proDescription": "For small teams to manage projects and team knowledge", "proDuration": { @@ -2995,15 +3015,17 @@ "three": "5 GB storage", "four": "Intelligent search", "five": "10 AI responses", - "six": "Mobile app", - "seven": "Real-time collaboration" + "six": "2 AI images", + "seven": "Mobile app", + "eight": "Real-time collaboration" }, "proPoints": { "first": "Unlimited storage", "second": "Up to 10 workspace members", "three": "Unlimited AI responses", - "four": "Unlimited file uploads", - "five": "Custom namespace" + "four": "10 AI images per month", + "five": "Unlimited file uploads", + "six": "Custom namespace" }, "cancelPlan": { "title": "Sorry to see you go", diff --git a/src/application/database-yjs/__tests__/selector.test.tsx b/src/application/database-yjs/__tests__/selector.test.tsx index 1cdfd3db..aaa38c69 100644 --- a/src/application/database-yjs/__tests__/selector.test.tsx +++ b/src/application/database-yjs/__tests__/selector.test.tsx @@ -35,6 +35,7 @@ const wrapperCreator = databaseDoc={doc} rowDocMap={rowDocMap} readOnly={true} + workspaceId={''} > {children} diff --git a/src/application/database-yjs/context.ts b/src/application/database-yjs/context.ts index 0125e0f5..56e3fb31 100644 --- a/src/application/database-yjs/context.ts +++ b/src/application/database-yjs/context.ts @@ -26,6 +26,7 @@ export interface DatabaseContextState { navigateToView?: (viewId: string, blockId?: string) => Promise; onRendered?: (height: number) => void; showActions?: boolean; + workspaceId: string; } export const DatabaseContext = createContext(null); @@ -33,7 +34,7 @@ export const DatabaseContext = createContext(null); export const useDatabaseContext = () => { const context = useContext(DatabaseContext); - if (!context) { + if(!context) { throw new Error('DatabaseContext is not provided'); } @@ -89,7 +90,7 @@ export const useDatabaseView = () => { return viewId ? database?.get(YjsDatabaseKey.views)?.get(viewId) : undefined; }; -export function useDatabaseFields () { +export function useDatabaseFields() { const database = useDatabase(); return database.get(YjsDatabaseKey.fields); diff --git a/src/application/services/js-services/http/http_api.ts b/src/application/services/js-services/http/http_api.ts index dac732e1..c0d374db 100644 --- a/src/application/services/js-services/http/http_api.ts +++ b/src/application/services/js-services/http/http_api.ts @@ -1855,7 +1855,7 @@ export async function getChatMessages(workspaceId: string, chatId: string, limit data?: RepeatedChatMessage; message: string; }>(url, { - params: { limit: limit}, + params: { limit: limit }, }); const data = response?.data; @@ -1866,3 +1866,17 @@ export async function getChatMessages(workspaceId: string, chatId: string, limit return Promise.reject(data); } + +export async function duplicatePage(workspaceId: string, viewId: string) { + const url = `/api/workspace/${workspaceId}/page-view/${viewId}/duplicate`; + const response = await axiosInstance?.post<{ + code: number; + message: string; + }>(url, {}); + + if(response?.data.code === 0) { + return; + } + + return Promise.reject(response?.data); +} \ No newline at end of file diff --git a/src/application/services/js-services/index.ts b/src/application/services/js-services/index.ts index adb004e0..74b02fc0 100644 --- a/src/application/services/js-services/index.ts +++ b/src/application/services/js-services/index.ts @@ -581,6 +581,10 @@ export class AFClientService implements AFService { return APIService.updatePage(workspaceId, viewId, data); } + async duplicateAppPage(workspaceId: string, viewId: string) { + return APIService.duplicatePage(workspaceId, viewId); + } + async deleteTrash(workspaceId: string, viewId?: string) { return APIService.deleteTrash(workspaceId, viewId); } diff --git a/src/application/services/services.type.ts b/src/application/services/services.type.ts index 51ca97bb..3c607b3b 100644 --- a/src/application/services/services.type.ts +++ b/src/application/services/services.type.ts @@ -38,7 +38,14 @@ import { import { AxiosInstance } from 'axios'; import { RepeatedChatMessage } from '@appflowyinc/ai-chat/dist/types'; -export type AFService = PublishService & AppService & WorkspaceService & TemplateService & QuickNoteService & AIChatService & { +export type AFService = + PublishService + & AppService + & WorkspaceService + & TemplateService + & QuickNoteService + & AIChatService + & { getClientId: () => string; getAxiosInstance: () => AxiosInstance | null; }; @@ -110,7 +117,7 @@ export interface AppService { restoreFromTrash: (workspaceId: string, viewId?: string) => Promise; movePage: (workspaceId: string, viewId: string, parentId: string, prevViewId?: string) => Promise; uploadFile: (workspaceId: string, viewId: string, file: File, onProgress?: (progress: number) => void) => Promise; - + duplicateAppPage: (workspaceId: string, viewId: string) => Promise; } export interface QuickNoteService { @@ -179,7 +186,7 @@ export interface PublishService { } -export interface AIChatService{ +export interface AIChatService { getChatMessages: ( workspaceId: string, chatId: string, diff --git a/src/application/slate-yjs/command/index.ts b/src/application/slate-yjs/command/index.ts index 8c5af1c3..f923c94b 100644 --- a/src/application/slate-yjs/command/index.ts +++ b/src/application/slate-yjs/command/index.ts @@ -45,6 +45,21 @@ import { } from '@/application/slate-yjs/utils/yjs'; export const CustomEditor = { + getEditorContent(editor: YjsEditor) { + const allNodes = editor.children ?? []; + + return allNodes.map((node) => { + return CustomEditor.getBlockTextContent(node); + }).join('\n'); + }, + + getSelectionContent(editor: YjsEditor, range?: Range) { + const at = range || editor.selection; + + if(!at) return ''; + + return editor.string(at); + }, // Get the text content of a block node, including the text content of its children and formula nodes getBlockTextContent(node: Node, depth: number = Infinity): string { if(Text.isText(node)) { @@ -78,7 +93,7 @@ export const CustomEditor = { }, setBlockData(editor: YjsEditor, blockId: string, updateData: T, select?: boolean) { - + const block = getBlock(blockId, editor.sharedRoot); const oldData = dataStringTOJson(block.get(YjsEditorKey.block_data)); const newData = { diff --git a/src/application/slate-yjs/utils/applyToSlate.ts b/src/application/slate-yjs/utils/applyToSlate.ts index 2a877e55..bfae06ac 100644 --- a/src/application/slate-yjs/utils/applyToSlate.ts +++ b/src/application/slate-yjs/utils/applyToSlate.ts @@ -17,17 +17,17 @@ export function translateYEvents(editor: YjsEditor, events: Array) { events.forEach((event) => { console.log(event.path); - if (isEqual(event.path, ['document', 'blocks'])) { + if(isEqual(event.path, ['document', 'blocks'])) { applyBlocksYEvent(editor, event as BlockMapEvent); } - if (isEqual((event.path), ['document', 'blocks', event.path[2]])) { + if(isEqual((event.path), ['document', 'blocks', event.path[2]])) { const blockId = event.path[2] as string; applyUpdateBlockYEvent(editor, blockId, event as YMapEvent); } - if (isEqual(event.path, ['document', 'meta', 'text_map', event.path[3]])) { + if(isEqual(event.path, ['document', 'meta', 'text_map', event.path[3]])) { const textId = event.path[3] as string; applyTextYEvent(editor, textId, event as YTextEvent); @@ -42,7 +42,7 @@ function applyUpdateBlockYEvent(editor: YjsEditor, blockId: string, event: YMapE const newData = dataStringTOJson(block.get(YjsEditorKey.block_data)); const entry = findSlateEntryByBlockId(editor, blockId); - if (!entry) { + if(!entry) { console.error('Block node not found', blockId); return []; } @@ -75,7 +75,7 @@ function applyTextYEvent(editor: YjsEditor, textId: string, event: YTextEvent) { }); console.log('=== Applying text Yjs event ===', entry); - if (!entry) { + if(!entry) { console.error('Text node not found', textId); return []; } @@ -107,14 +107,14 @@ function applyBlocksYEvent(editor: YjsEditor, event: BlockMapEvent) { keysChanged.forEach((key: string) => { const value = keys.get(key); - if (!value) return; + if(!value) return; - if (value.action === 'add') { + if(value.action === 'add') { handleNewBlock(editor, key, keyPath); - } else if (value.action === 'delete') { + } else if(value.action === 'delete') { handleDeleteNode(editor, key); - } else if (value.action === 'update') { + } else if(value.action === 'update') { console.log('=== Applying block update Yjs event ===', key); } }); @@ -127,7 +127,7 @@ function handleNewBlock(editor: YjsEditor, key: string, keyPath: Record !Editor.isEditor(n) && Element.isElement(n) && n.blockId === parentId, mode: 'all', at: [], }); - if (!parentEntry) { - if (keyPath[parentId]) { + if(!parentEntry) { + if(keyPath[parentId]) { path = [...keyPath[parentId], index + 1]; } else { console.error('Parent block not found', parentId); @@ -198,7 +198,7 @@ function handleDeleteNode(editor: YjsEditor, key: string) { match: (n) => !Editor.isEditor(n) && Element.isElement(n) && n.blockId === key, }); - if (!entry) { + if(!entry) { console.error('Block not found'); return []; } diff --git a/src/application/slate-yjs/utils/applyToYjs.ts b/src/application/slate-yjs/utils/applyToYjs.ts index ad456b49..74a4bab2 100644 --- a/src/application/slate-yjs/utils/applyToYjs.ts +++ b/src/application/slate-yjs/utils/applyToYjs.ts @@ -17,10 +17,10 @@ import { getBlock, getText } from '@/application/slate-yjs/utils/yjs'; // transform slate op to yjs op and apply it to ydoc export function applyToYjs(ydoc: Y.Doc, editor: Editor, op: Operation, slateContent: Descendant[]) { - if (op.type === 'set_selection') return; + if(op.type === 'set_selection') return; console.log('applySlateOp', op, slateContent); - switch (op.type) { + switch(op.type) { case 'insert_text': return applyInsertText(ydoc, editor, op, slateContent); case 'remove_text': @@ -38,11 +38,11 @@ function getAttributesAtOffset(ytext: Y.Text, offset: number): object | null { const delta = ytext.toDelta(); let currentOffset = 0; - for (const op of delta) { - if ('insert' in op) { + for(const op of delta) { + if('insert' in op) { const length = op.insert.length; - if (currentOffset <= offset && offset < currentOffset + length) { + if(currentOffset <= offset && offset < currentOffset + length) { return op.attributes || null; } @@ -63,25 +63,25 @@ function insertText(ydoc: Y.Doc, editor: Editor, { path, offset, text, attribute const sharedRoot = ydoc.getMap(YjsEditorKey.data_section) as YSharedRoot; const yText = getText(textId, sharedRoot); - if (!yText) return; + if(!yText) return; const point = { path, offset }; const relativeOffset = Math.min(calculateOffsetRelativeToParent(node, point), yText.toJSON().length); const beforeAttributes = getAttributesAtOffset(yText, relativeOffset - 1); - if (beforeAttributes && ('formula' in beforeAttributes || 'mention' in beforeAttributes)) { + if(beforeAttributes && ('formula' in beforeAttributes || 'mention' in beforeAttributes)) { const newAttributes = { ...attributes, }; - if ('formula' in beforeAttributes) { + if('formula' in beforeAttributes) { Object.assign({ formula: null, }); } - if ('mention' in beforeAttributes) { + if('mention' in beforeAttributes) { Object.assign({ mention: null, }); @@ -105,7 +105,7 @@ function applyInsertText(ydoc: Y.Doc, editor: Editor, op: InsertTextOperation, s function applyInsertNode(ydoc: Y.Doc, editor: Editor, op: InsertNodeOperation, slateContent: Descendant[]) { const { path, node } = op; - if (!Text.isText(node)) return; + if(!Text.isText(node)) return; const { text, ...attributes } = node; const offset = 0; @@ -121,7 +121,7 @@ function applyRemoveText(ydoc: Y.Doc, editor: Editor, op: RemoveTextOperation, s const textId = node.textId; - if (!textId) { + if(!textId) { console.error('textId not found', node); return; } @@ -129,8 +129,8 @@ function applyRemoveText(ydoc: Y.Doc, editor: Editor, op: RemoveTextOperation, s const sharedRoot = ydoc.getMap(YjsEditorKey.data_section) as YSharedRoot; const yText = getText(textId, sharedRoot); - if (!yText) { - console.error('yText not found', textId, sharedRoot.toJSON()); + if(!yText) { + console.error('yText not found', textId); return; } @@ -152,11 +152,11 @@ function applySetNode(ydoc: Y.Doc, editor: Editor, op: SetNodeOperation, slateCo const sharedRoot = ydoc.getMap(YjsEditorKey.data_section) as YSharedRoot; console.log('applySetNode isLeaf', isLeaf, op); - if (isLeaf) { + if(isLeaf) { const node = getNodeAtPath(slateContent, path.slice(0, -1)) as Element; const textId = node.textId; - if (!textId) return; + if(!textId) return; const yText = getText(textId, sharedRoot); const start = { @@ -179,7 +179,7 @@ function applySetNode(ydoc: Y.Doc, editor: Editor, op: SetNodeOperation, slateCo }; Object.entries(properties).forEach(([key, val]) => { - if (val && !(key in newProperties)) { + if(val && !(key in newProperties)) { formats[key] = null; } }); @@ -188,18 +188,18 @@ function applySetNode(ydoc: Y.Doc, editor: Editor, op: SetNodeOperation, slateCo return; } - if (isData) { + if(isData) { const node = getNodeAtPath(slateContent, path) as Element; const blockId = node.blockId as string; - if (!blockId) { + if(!blockId) { console.error('blockId is not found in node', node, newProperties); return; } const block = getBlock(blockId, sharedRoot); - if ( + if( 'data' in newProperties ) { block.set(YjsEditorKey.block_data, JSON.stringify(newProperties.data)); diff --git a/src/application/slate-yjs/utils/convert.ts b/src/application/slate-yjs/utils/convert.ts index f53a9428..6e72e6e0 100644 --- a/src/application/slate-yjs/utils/convert.ts +++ b/src/application/slate-yjs/utils/convert.ts @@ -26,7 +26,13 @@ export function traverseBlock(id: string, sharedRoot: YSharedRoot): Element | un if(!block) { console.error('Block not found', id); - return; + return { + blockId: id, + type: 'block_not_found', + data: {}, + relationId: id, + children: [{ type: 'text', textId: id, children: [{ text: '' }] }], + }; } const childrenId = block.children as string; diff --git a/src/application/slate-yjs/utils/editor.ts b/src/application/slate-yjs/utils/editor.ts index 497f32aa..045c3f31 100644 --- a/src/application/slate-yjs/utils/editor.ts +++ b/src/application/slate-yjs/utils/editor.ts @@ -990,4 +990,3 @@ export function addBlock(editor: YjsEditor, { return newBlockId; } - diff --git a/src/application/slate-yjs/utils/yjs.ts b/src/application/slate-yjs/utils/yjs.ts index d9949599..edc5296e 100644 --- a/src/application/slate-yjs/utils/yjs.ts +++ b/src/application/slate-yjs/utils/yjs.ts @@ -236,15 +236,10 @@ export function deleteBlock(sharedRoot: YSharedRoot, blockId: string) { deleteBlock(sharedRoot, id); }); - blocks.delete(blockId); - const meta = document.get(YjsEditorKey.meta) as YMeta; const childrenMap = meta.get(YjsEditorKey.children_map) as YChildrenMap; const textMap = meta.get(YjsEditorKey.text_map) as YTextMap; - childrenMap.delete(blockId); - textMap.delete(blockId); - const parent = getBlock(parentId, sharedRoot); if(!parent) return; @@ -256,8 +251,14 @@ export function deleteBlock(sharedRoot: YSharedRoot, blockId: string) { if(index !== -1) { parentChildren.delete(index, 1); + } else { + console.info('Block not found in parent\'s children'); } + blocks.delete(blockId); + childrenMap.delete(blockId); + textMap.delete(blockId); + // delete parent if it's empty column block if(parentType === BlockType.ColumnBlock && afterDeletedLength === 0) { deleteBlock(sharedRoot, parentId); diff --git a/src/application/types.ts b/src/application/types.ts index 61a1eeba..038b7dfc 100644 --- a/src/application/types.ts +++ b/src/application/types.ts @@ -1,3 +1,4 @@ +import { AxiosInstance } from 'axios'; import * as Y from 'yjs'; export type BlockId = string; @@ -956,6 +957,7 @@ export enum SubscriptionPlan { Free = 'free', Pro = 'pro', Team = 'team', + AIMax = 'ai_max' } export enum SubscriptionInterval { @@ -991,6 +993,7 @@ export interface ViewMetaProps { cover?: ViewMetaCover; name?: string; viewId?: string; + workspaceId?: string; layout?: ViewLayout; visibleViewIds?: string[]; extra?: ViewExtra | null; @@ -1008,6 +1011,7 @@ export interface TextCount { export interface ViewComponentProps { doc: YDoc; + workspaceId: string; readOnly: boolean; navigateToView?: (viewId: string, blockId?: string) => Promise; loadViewMeta?: LoadViewMeta; @@ -1025,6 +1029,7 @@ export interface ViewComponentProps { loadViews?: (variant?: UIVariant) => Promise; onWordCountChange?: (viewId: string, props: TextCount) => void; uploadFile?: (file: File) => Promise; + requestInstance?: AxiosInstance | null; } export interface CreatePagePayload { diff --git a/src/application/ydoc/apply/index.ts b/src/application/ydoc/apply/index.ts index fb492590..f0b476db 100644 --- a/src/application/ydoc/apply/index.ts +++ b/src/application/ydoc/apply/index.ts @@ -13,11 +13,11 @@ export function applyYDoc(doc: Y.Doc, state: Uint8Array) { () => { try { Y.applyUpdate(doc, state, CollabOrigin.Remote); - } catch (e) { + } catch(e) { console.error('Error applying', doc, e); throw e; } }, - CollabOrigin.Remote + CollabOrigin.Remote, ); } diff --git a/src/assets/add_user.svg b/src/assets/add_user.svg index 2924cd76..f28c6a1f 100644 --- a/src/assets/add_user.svg +++ b/src/assets/add_user.svg @@ -1,14 +1,8 @@ - - + - - - - - - \ No newline at end of file + diff --git a/src/assets/ai.svg b/src/assets/ai.svg new file mode 100644 index 00000000..321f3775 --- /dev/null +++ b/src/assets/ai.svg @@ -0,0 +1,7 @@ + + + + diff --git a/src/assets/continue_writing.svg b/src/assets/continue_writing.svg new file mode 100644 index 00000000..47eaebfd --- /dev/null +++ b/src/assets/continue_writing.svg @@ -0,0 +1,10 @@ + + + + + + + + diff --git a/src/assets/debug.svg b/src/assets/debug.svg index 5d3c0145..f66ef5e8 100644 --- a/src/assets/debug.svg +++ b/src/assets/debug.svg @@ -1,22 +1,15 @@ - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/assets/help_documentation.svg b/src/assets/help_documentation.svg new file mode 100644 index 00000000..b602496e --- /dev/null +++ b/src/assets/help_documentation.svg @@ -0,0 +1,8 @@ + + + + + + diff --git a/src/assets/import.svg b/src/assets/import.svg index f6ad75bb..4e18120d 100644 --- a/src/assets/import.svg +++ b/src/assets/import.svg @@ -1,6 +1,8 @@ - - - - + + + + + diff --git a/src/assets/improve-writing.svg b/src/assets/improve-writing.svg new file mode 100644 index 00000000..cd771691 --- /dev/null +++ b/src/assets/improve-writing.svg @@ -0,0 +1,6 @@ + + + + diff --git a/src/assets/message_support.svg b/src/assets/message_support.svg index 15a2871e..f28a3b87 100644 --- a/src/assets/message_support.svg +++ b/src/assets/message_support.svg @@ -1,14 +1,12 @@ - - - - - + + + + - - + + diff --git a/src/assets/moon.svg b/src/assets/moon.svg index 304a7ec9..c9c24b65 100644 --- a/src/assets/moon.svg +++ b/src/assets/moon.svg @@ -1,9 +1,4 @@ - - - - - - - + + + \ No newline at end of file diff --git a/src/assets/report.svg b/src/assets/report.svg index 6a5bd10f..2dc159e2 100644 --- a/src/assets/report.svg +++ b/src/assets/report.svg @@ -1,18 +1,6 @@ - - - - - - - - - - - - - + + + + + \ No newline at end of file diff --git a/src/assets/sign_out.svg b/src/assets/sign_out.svg index 60f881b3..327c437c 100644 --- a/src/assets/sign_out.svg +++ b/src/assets/sign_out.svg @@ -1,7 +1,8 @@ - - - - + - \ No newline at end of file + + + diff --git a/src/assets/star.svg b/src/assets/star.svg index 532abf0e..92a629c5 100644 --- a/src/assets/star.svg +++ b/src/assets/star.svg @@ -1,11 +1,4 @@ - - - - - - - - - + + diff --git a/src/assets/sun.svg b/src/assets/sun.svg index fe4e2e89..38dd6db4 100644 --- a/src/assets/sun.svg +++ b/src/assets/sun.svg @@ -1,14 +1,4 @@ - - - - - - - - - + + diff --git a/src/assets/upgrade_ai_max.svg b/src/assets/upgrade_ai_max.svg new file mode 100644 index 00000000..57dd6b8e --- /dev/null +++ b/src/assets/upgrade_ai_max.svg @@ -0,0 +1,13 @@ + + + + + + + + diff --git a/src/components/_shared/breadcrumb/BreadcrumbItem.tsx b/src/components/_shared/breadcrumb/BreadcrumbItem.tsx index 0ec55031..c6d3917e 100644 --- a/src/components/_shared/breadcrumb/BreadcrumbItem.tsx +++ b/src/components/_shared/breadcrumb/BreadcrumbItem.tsx @@ -20,8 +20,8 @@ function BreadcrumbItem({ crumb, disableClick = false, toView, variant }: { const className = useMemo(() => { const classList = ['flex', 'items-center', 'gap-1.5', 'text-sm', 'overflow-hidden', 'max-sm:text-base']; - if (!disableClick && !extra?.is_space) { - if ((is_published && variant === 'publish') || variant === 'app') { + if(!disableClick && !extra?.is_space) { + if((is_published && variant === 'publish') || variant === 'app') { classList.push('cursor-pointer hover:text-text-title hover:underline'); } else { classList.push('flex-1'); @@ -35,12 +35,12 @@ function BreadcrumbItem({ crumb, disableClick = false, toView, variant }: {
{ - if (disableClick || extra?.is_space || (!is_published && variant === 'publish')) return; + onClick={async() => { + if(disableClick || extra?.is_space || (!is_published && variant === 'publish')) return; try { await toView?.(view_id); // eslint-disable-next-line @typescript-eslint/no-explicit-any - } catch (e: any) { + } catch(e: any) { notify.error(e.message); } }} @@ -53,7 +53,10 @@ function BreadcrumbItem({ crumb, disableClick = false, toView, variant }: { char={extra.space_icon ? undefined : name.slice(0, 1)} /> ) : ( - + )} = { anchorOrigin: { @@ -27,7 +28,7 @@ const popoverProps: Partial = { }, }; -export default function Help () { +export default function Help() { const ref = React.useRef(null); const [open, setOpen] = React.useState(false); const { t } = useTranslation(); @@ -79,6 +80,16 @@ export default function Help () { variant={'text'} >{t('questionBubble.whatsNew')} + ))} - + - + diff --git a/src/components/app/view-actions/SpacePermissionButton.tsx b/src/components/app/view-actions/SpacePermissionButton.tsx index 67f3d446..8ca16200 100644 --- a/src/components/app/view-actions/SpacePermissionButton.tsx +++ b/src/components/app/view-actions/SpacePermissionButton.tsx @@ -8,7 +8,7 @@ import { ReactComponent as SelectedIcon } from '@/assets/selected.svg'; import { useTranslation } from 'react-i18next'; import { SpacePermission } from '@/application/types'; -function SpacePermissionButton ({ +function SpacePermissionButton({ onSelected, value, }: { @@ -30,7 +30,7 @@ function SpacePermissionButton ({ onClick={e => setAnchorEl(e.currentTarget)} >
-
{ +
{ SpacePermission.Private === value ? t('space.privatePermission') : t('space.publicPermission') }
{ diff --git a/src/components/app/workspaces/Workspaces.tsx b/src/components/app/workspaces/Workspaces.tsx index 5c35358a..4e7e29e3 100644 --- a/src/components/app/workspaces/Workspaces.tsx +++ b/src/components/app/workspaces/Workspaces.tsx @@ -17,16 +17,19 @@ import { ReactComponent as TipIcon } from '@/assets/warning.svg'; import { useTranslation } from 'react-i18next'; import { ReactComponent as SignOutIcon } from '@/assets/sign_out.svg'; import { ReactComponent as UpgradeIcon } from '@/assets/icon_upgrade.svg'; +import { ReactComponent as UpgradeAIMaxIcon } from '@/assets/upgrade_ai_max.svg'; import { useNavigate, useSearchParams } from 'react-router-dom'; import InviteMember from '@/components/app/workspaces/InviteMember'; import UpgradePlan from '@/components/billing/UpgradePlan'; +import UpgradeAIMax from '@/components/billing/UpgradeAIMax'; -export function Workspaces () { +export function Workspaces() { const { t } = useTranslation(); const userWorkspaceInfo = useUserWorkspaceInfo(); const currentWorkspaceId = useCurrentWorkspaceId(); const currentUser = useCurrentUser(); const [openUpgradePlan, setOpenUpgradePlan] = React.useState(false); + const [openUpgradeAIMax, setOpenUpgradeAIMax] = React.useState(false); const [open, setOpen] = React.useState(false); const [hoveredHeader, setHoveredHeader] = React.useState(false); const ref = React.useRef(null); @@ -48,12 +51,12 @@ export function Workspaces () { setCurrentWorkspace(userWorkspaceInfo?.workspaces.find((workspace) => workspace.id === currentWorkspaceId)); }, [currentWorkspaceId, userWorkspaceInfo]); - const handleChange = useCallback(async (selectedId: string) => { + const handleChange = useCallback(async(selectedId: string) => { setChangeLoading(selectedId); try { await handleSelectedWorkspace?.(selectedId); setOpen(false); - } catch (e) { + } catch(e) { notify.error('Failed to change workspace'); } @@ -108,7 +111,7 @@ export function Workspaces () { changeLoading={changeLoading || undefined} onUpdateCurrentWorkspace={(name) => { setCurrentWorkspace(prev => { - if (!prev) return prev; + if(!prev) return prev; return { ...prev, name, @@ -151,6 +154,7 @@ export function Workspaces () { + + }
{isOwner && - { - setOpenUpgradePlan(true); + <> + { + setOpenUpgradePlan(true); + } } - } - open={openUpgradePlan} - onClose={() => setOpenUpgradePlan(false)} - />} + open={openUpgradePlan} + onClose={() => setOpenUpgradePlan(false)} + /> + { + setOpenUpgradeAIMax(true); + } + } + open={openUpgradeAIMax} + onClose={() => setOpenUpgradeAIMax(false)} + /> + + + } + ; } diff --git a/src/components/billing/UpgradeAIMax.tsx b/src/components/billing/UpgradeAIMax.tsx new file mode 100644 index 00000000..e4f831ff --- /dev/null +++ b/src/components/billing/UpgradeAIMax.tsx @@ -0,0 +1,211 @@ +import { SubscriptionInterval, SubscriptionPlan } from '@/application/types'; +import { NormalModal } from '@/components/_shared/modal'; +import { notify } from '@/components/_shared/notify'; +import { useAppHandlers, useCurrentWorkspaceId } from '@/components/app/app.hooks'; +import { useService } from '@/components/main/app.hooks'; +import { Button, CircularProgress } from '@mui/material'; +import React, { useCallback, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useSearchParams } from 'react-router-dom'; + +function UpgradeAIMax({ open, onClose, onOpen }: { + open: boolean; + onClose: () => void; + onOpen: () => void; +}) { + const { t } = useTranslation(); + const [isActive, setIsActive] = React.useState(false); + const service = useService(); + const currentWorkspaceId = useCurrentWorkspaceId(); + const [cancelLoading, setCancelLoading] = React.useState(false); + const [cancelOpen, setCancelOpen] = React.useState(false); + const { getSubscriptions } = useAppHandlers(); + + const [search, setSearch] = useSearchParams(); + const action = search.get('action'); + + useEffect(() => { + if(!open && action === 'upgrade_ai_max') { + onOpen(); + } + + if(open) { + setSearch(prev => { + prev.set('action', 'upgrade_ai_max'); + return prev; + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [action, open, setSearch]); + + const loadSubscription = useCallback(async() => { + try { + const subscriptions = await getSubscriptions?.(); + + if(!subscriptions || subscriptions.length === 0) { + setIsActive(false); + return; + } + + const subscription = subscriptions.find(item => item.plan === SubscriptionPlan.AIMax); + + setIsActive(!!subscription); + + } catch(e) { + console.error(e); + } + }, [getSubscriptions]); + + const handleClose = useCallback(() => { + onClose(); + setSearch(prev => { + prev.delete('action'); + return prev; + }); + }, [onClose, setSearch]); + + const handleUpgrade = useCallback(async() => { + if(!service || !currentWorkspaceId) return; + const plan = SubscriptionPlan.AIMax; + + try { + const link = await service.getSubscriptionLink(currentWorkspaceId, plan, SubscriptionInterval.Year); + + window.open(link, '_current'); + // eslint-disable-next-line + } catch(e: any) { + notify.error(e.message); + } + }, [currentWorkspaceId, service]); + + const handleCancel = useCallback(async() => { + if(!service || !currentWorkspaceId) return; + setCancelLoading(true); + const plan = SubscriptionPlan.AIMax; + + try { + await service.cancelSubscription(currentWorkspaceId, plan, ''); + notify.success(t('subscribe.cancelPlan.success')); + setCancelOpen(false); + handleClose(); + // eslint-disable-next-line + } catch(e: any) { + notify.error(e.message); + } finally { + setCancelLoading(false); + } + + }, [currentWorkspaceId, service, t, handleClose]); + + useEffect(() => { + if(open) { + void loadSubscription(); + } + }, [open, loadSubscription]); + + return ( + +
+ +
+
+ {t('subscribe.AIMax.description')} +
+
+
+
$8
+
+ {t('subscribe.AIMax.pricing')} +
+
+ {!isActive ? +
+ +
: + + } +
+
+
+
+
+
{t('subscribe.AIMax.points.first')}
+
+
+
+
+
+
+ {t('subscribe.AIMax.points.second')} +
+
+ +
+
+
+
+
{ + t('subscribe.AIMax.points.third') + }
+
+
+
+ {t('subscribe.AIMax.removeTitle')}
+ } + classes={{ paper: 'w-[420px]' }} + + open={cancelOpen} + onOk={handleCancel} + danger + onClose={() => { + setCancelOpen(false); + }} + okLoading={cancelLoading} + onCancel={() => { + setCancelOpen(false); + }} + okText={t('button.confirm')} + > +
+ {t('subscribe.AIMax.removeDescription')} + +
+ + + ); +} + +export default UpgradeAIMax; \ No newline at end of file diff --git a/src/components/billing/UpgradePlan.tsx b/src/components/billing/UpgradePlan.tsx index 39c9f7bd..4e6b1be5 100644 --- a/src/components/billing/UpgradePlan.tsx +++ b/src/components/billing/UpgradePlan.tsx @@ -26,11 +26,11 @@ function UpgradePlan({ open, onClose, onOpen }: { const action = search.get('action'); useEffect(() => { - if (!open && action === 'change_plan') { + if(!open && action === 'change_plan') { onOpen(); } - if (open) { + if(open) { setSearch(prev => { prev.set('action', 'change_plan'); return prev; @@ -39,11 +39,11 @@ function UpgradePlan({ open, onClose, onOpen }: { // eslint-disable-next-line react-hooks/exhaustive-deps }, [action, open, setSearch]); - const loadSubscription = useCallback(async () => { + const loadSubscription = useCallback(async() => { try { const subscriptions = await getSubscriptions?.(); - if (!subscriptions || subscriptions.length === 0) { + if(!subscriptions || subscriptions.length === 0) { setActiveSubscription({ plan: SubscriptionPlan.Free, currency: '', @@ -56,7 +56,7 @@ function UpgradePlan({ open, onClose, onOpen }: { const subscription = subscriptions[0]; setActiveSubscription(subscription); - } catch (e) { + } catch(e) { console.error(e); } }, [getSubscriptions]); @@ -70,8 +70,8 @@ function UpgradePlan({ open, onClose, onOpen }: { }, [onClose, setSearch]); const [interval, setInterval] = React.useState(SubscriptionInterval.Year); - const handleUpgrade = useCallback(async () => { - if (!service || !currentWorkspaceId) return; + const handleUpgrade = useCallback(async() => { + if(!service || !currentWorkspaceId) return; const plan = SubscriptionPlan.Pro; try { @@ -79,13 +79,13 @@ function UpgradePlan({ open, onClose, onOpen }: { window.open(link, '_current'); // eslint-disable-next-line - } catch (e: any) { + } catch(e: any) { notify.error(e.message); } }, [currentWorkspaceId, service, interval]); useEffect(() => { - if (open) { + if(open) { void loadSubscription(); } }, [open, loadSubscription]); @@ -105,6 +105,7 @@ function UpgradePlan({ open, onClose, onOpen }: { t('subscribe.freePoints.five'), t('subscribe.freePoints.six'), t('subscribe.freePoints.seven'), + t('subscribe.freePoints.eight'), ], }, { key: SubscriptionPlan.Pro, @@ -118,6 +119,7 @@ function UpgradePlan({ open, onClose, onOpen }: { t('subscribe.proPoints.three'), t('subscribe.proPoints.four'), t('subscribe.proPoints.five'), + t('subscribe.proPoints.six'), ], }]; }, [t, interval]); @@ -142,13 +144,23 @@ function UpgradePlan({ open, onClose, onOpen }: { >
- { - setInterval(v); - }}> - - + { + setInterval(v); + }} + > + +
{t('subscribe.priceIn')} @@ -158,18 +170,24 @@ function UpgradePlan({ open, onClose, onOpen }: {
{plans.map((plan) => { - return
+ return
{activeSubscription?.plan === plan.key &&
+ className={'absolute bg-billing-primary text-content-on-fill right-0 top-0 rounded-[14px] text-xs rounded-br-none rounded-tl-none p-2'} + > {t('subscribe.currentPlan')}
}
{plan.name}
{plan.description}
{plan.price} + className={'text-lg'} + >{plan.price}
{plan.duration}
@@ -179,23 +197,31 @@ function UpgradePlan({ open, onClose, onOpen }: { } {t('subscribe.everythingInFree')}
: activeSubscription?.plan !== plan.key && - }
{plan.points.map((point, index) => { - return
+ return
-
+
{point}
; @@ -205,9 +231,13 @@ function UpgradePlan({ open, onClose, onOpen }: { })}
- { - setCancelOpen(false); - }}/> + { + setCancelOpen(false); + }} + /> ); } diff --git a/src/components/database/Database.tsx b/src/components/database/Database.tsx index 4ff2c78b..2e503cdf 100644 --- a/src/components/database/Database.tsx +++ b/src/components/database/Database.tsx @@ -17,6 +17,7 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { DatabaseContextProvider } from './DatabaseContext'; export interface Database2Props { + workspaceId: string; doc: YDoc; readOnly?: boolean; createRowDoc?: CreateRowDoc; @@ -38,7 +39,8 @@ export interface Database2Props { showActions?: boolean; } -function Database ({ +function Database({ + workspaceId, doc, createRowDoc, navigateToView, @@ -65,19 +67,19 @@ function Database ({ const rowOrders = view?.get(YjsDatabaseKey.row_orders); const [rowIds, setRowIds] = useState([]); const [rowDocMap, setRowDocMap] = useState | null>(null); - const dbRows = useLiveQuery(async () => { + const dbRows = useLiveQuery(async() => { const rows = await db.rows.bulkGet(rowIds.map(id => `${doc.guid}_rows_${id}`)); return rows; }, [rowIds, variant]); - const updateRowMap = useCallback(async () => { + const updateRowMap = useCallback(async() => { const newRowMap: Record = {}; - if (!dbRows || !createRowDoc) return; + if(!dbRows || !createRowDoc) return; - for (const row of dbRows) { - if (!row) { + for(const row of dbRows) { + if(!row) { continue; } @@ -100,7 +102,7 @@ function Database ({ console.log('Database.tsx: rowDocMap', rowDocMap); }, [rowDocMap, database]); - const handleUpdateRowDocMap = useCallback(async () => { + const handleUpdateRowDocMap = useCallback(async() => { setRowIds(rowOrders?.toJSON().map(({ id }: { id: string }) => id) || []); }, [rowOrders]); @@ -113,13 +115,14 @@ function Database ({ }; }, [handleUpdateRowDocMap, rowOrders]); - if (!rowDocMap || !viewId) { + if(!rowDocMap || !viewId) { return null; } return (
', () => { }); }); -function TestDatabaseRow ({ +function TestDatabaseRow({ rowId, databaseDoc, rows, @@ -73,6 +73,7 @@ function TestDatabaseRow ({ }) { return ( (null); - const handleOpenDocument = useCallback(async () => { - if (!loadView || !documentId) return; + const handleOpenDocument = useCallback(async() => { + if(!loadView || !documentId) return; try { setDoc(null); const doc = await loadView(documentId, true); setDoc(doc); - } catch (e) { + } catch(e) { console.error(e); // haven't created by client, ignore error and show empty } @@ -35,15 +36,16 @@ export function DatabaseRowSubDocument ({ rowId }: { rowId: string }) { void handleOpenDocument().then(() => setLoading(false)); }, [handleOpenDocument]); - if (loading) { + if(loading) { return ( ); } - if (!doc || !documentId) return null; + if(!doc || !documentId || !workspaceId) return null; return ( (null); @@ -16,9 +16,9 @@ function DatabaseRowHeader ({ rowId, appendBreadcrumb }: { rowId: string; append const cover = meta?.cover; const renderCoverImage = useCallback((cover: RowMeta['cover']) => { - if (!cover) return null; + if(!cover) return null; - if (cover.cover_type === RowCoverType.GradientCover || cover.cover_type === RowCoverType.ColorCover) { + if(cover.cover_type === RowCoverType.GradientCover || cover.cover_type === RowCoverType.ColorCover) { return
@@ -81,11 +81,11 @@ function DatabaseRowHeader ({ rowId, appendBreadcrumb }: { rowId: string; append useEffect(() => { const el = ref.current; - if (!el) return; + if(!el) return; - const container = document.querySelector('.appflowy-scroll-container') || getScrollParent(el); + const container = el.closest('.appflowy-scroll-container') || getScrollParent(el); - if (!container) return; + if(!container) return; const handleResize = () => { setOffsetLeft(container.getBoundingClientRect().left - el.getBoundingClientRect().left); diff --git a/src/components/document/Document.tsx b/src/components/document/Document.tsx index 158c33dd..e2b5244f 100644 --- a/src/components/document/Document.tsx +++ b/src/components/document/Document.tsx @@ -64,9 +64,10 @@ export const Document = (props: DocumentProps) => { el.style.minHeight = `${scrollElement?.clientHeight - 64}px`; }, [onRendered]); - if(!document || !viewMeta.viewId) return null; + if(!document || !viewMeta.viewId || !viewMeta.workspaceId) return null; return ( +
import('@/components/editor/EditorOverlay')); const EditorEditable = () => { - const { readOnly, decorateState, onWordCountChange, viewId } = useEditorContext(); + const { readOnly, decorateState, onWordCountChange, viewId, workspaceId } = useEditorContext(); const editor = useSlate(); const codeDecorate = useDecorate(editor); @@ -32,12 +32,12 @@ const EditorEditable = () => { class_name: string; })[] = []; - if (!decorateState) return []; + if(!decorateState) return []; Object.values(decorateState).forEach((state) => { const intersection = Range.intersection(state.range, Editor.range(editor, path)); - if (intersection) { + if(intersection) { highlightRanges.push({ ...intersection, class_name: state.class_name, @@ -69,8 +69,8 @@ const EditorEditable = () => { const onCompositionStart = useCallback(() => { const { selection } = editor; - if (!selection) return; - if (Range.isExpanded(selection)) { + if(!selection) return; + if(Range.isExpanded(selection)) { editor.delete(); } }, [editor]); @@ -84,7 +84,7 @@ const EditorEditable = () => { }, [onWordCountChange, viewId, editor]); useEffect(() => { - if (readOnly) return; + if(readOnly) return; const { onChange } = editor; editor.onChange = () => { @@ -104,13 +104,13 @@ const EditorEditable = () => { const currentTarget = e.currentTarget as HTMLElement; const bottomArea = currentTarget.getBoundingClientRect().bottom - 56 * 4; - if (e.clientY > bottomArea && e.clientY < (bottomArea + 56)) { + if(e.clientY > bottomArea && e.clientY < (bottomArea + 56)) { const lastBlock = editor.children[editor.children.length - 1] as SlateElement; const isEmptyLine = CustomEditor.getBlockTextContent(lastBlock) === ''; const type = lastBlock.type; - if (!lastBlock) return; - if (isEmptyLine && type === BlockType.Paragraph) { + if(!lastBlock) return; + if(isEmptyLine && type === BlockType.Paragraph) { editor.select(editor.end([editor.children.length - 1])); return; } @@ -123,7 +123,7 @@ const EditorEditable = () => { const handleMouseDown = useCallback((e: React.MouseEvent) => { const detail = e.detail; - if (detail >= 3) { + if(detail >= 3) { e.stopPropagation(); e.preventDefault(); } @@ -141,11 +141,13 @@ const EditorEditable = () => { return ( - + { @@ -169,7 +171,10 @@ const EditorEditable = () => { /> {!readOnly && - + ', () => { }); }); -function renderEditor (doc: YDoc) { +function renderEditor(doc: YDoc) { const AppWrapper = withAppWrapper(() => { return (
diff --git a/src/components/editor/EditorContext.tsx b/src/components/editor/EditorContext.tsx index 8776c1fd..1d06f899 100644 --- a/src/components/editor/EditorContext.tsx +++ b/src/components/editor/EditorContext.tsx @@ -5,6 +5,7 @@ import { LoadView, LoadViewMeta, UIVariant, View, CreatePagePayload, TextCount, } from '@/application/types'; +import { AxiosInstance } from 'axios'; import { createContext, useCallback, useContext, useState } from 'react'; import { BaseRange, Range } from 'slate'; @@ -26,6 +27,7 @@ export interface Decorate { } export interface EditorContextState { + workspaceId: string; viewId: string; readOnly: boolean; layoutStyle?: EditorLayoutStyle; @@ -43,7 +45,6 @@ export interface EditorContextState { decorateState?: Record; addDecorate?: (range: BaseRange, class_name: string, type: string) => void; removeDecorate?: (type: string) => void; - selectedBlockIds?: string[]; setSelectedBlockIds?: React.Dispatch>; addPage?: (parentId: string, payload: CreatePagePayload) => Promise; @@ -52,6 +53,7 @@ export interface EditorContextState { loadViews?: (variant?: UIVariant) => Promise; onWordCountChange?: (viewId: string, props: TextCount) => void; uploadFile?: (file: File) => Promise; + requestInstance?: AxiosInstance | null; } export const EditorContext = createContext({ @@ -59,6 +61,7 @@ export const EditorContext = createContext({ layoutStyle: defaultLayoutStyle, codeGrammars: {}, viewId: '', + workspaceId: '', }); export const EditorContextProvider = ({ children, ...props }: EditorContextState & { children: React.ReactNode }) => { @@ -69,7 +72,7 @@ export const EditorContextProvider = ({ children, ...props }: EditorContextState setDecorateState((prev) => { const oldValue = prev[type]; - if (oldValue && Range.equals(oldValue.range, range) && oldValue.class_name === class_name) { + if(oldValue && Range.equals(oldValue.range, range) && oldValue.class_name === class_name) { return prev; } @@ -85,7 +88,7 @@ export const EditorContextProvider = ({ children, ...props }: EditorContextState const removeDecorate = useCallback((type: string) => { setDecorateState((prev) => { - if (prev[type] === undefined) { + if(prev[type] === undefined) { return prev; } diff --git a/src/components/editor/EditorOverlay.tsx b/src/components/editor/EditorOverlay.tsx index 81a65df3..85ae5ea8 100644 --- a/src/components/editor/EditorOverlay.tsx +++ b/src/components/editor/EditorOverlay.tsx @@ -1,18 +1,208 @@ -import React from 'react'; +import { YjsEditor } from '@/application/slate-yjs'; +import { CustomEditor } from '@/application/slate-yjs/command'; +import { isEmbedBlockTypes } from '@/application/slate-yjs/command/const'; +import { findSlateEntryByBlockId, getBlockEntry } from '@/application/slate-yjs/utils/editor'; +import '@appflowyinc/ai-chat/style'; +import { getBlock, getText } from '@/application/slate-yjs/utils/yjs'; +import { BlockType, YjsEditorKey } from '@/application/types'; +import { notify } from '@/components/_shared/notify'; +import { insertDataAfterBlock } from '@/components/ai-chat/utils'; +import { useEditorContext } from '@/components/editor/EditorContext'; +import { getScrollParent } from '@/components/global-comment/utils'; +import { AIAssistantProvider, ContextPlaceholder, WriterRequest } from '@appflowyinc/ai-chat'; +import { EditorData } from '@appflowyinc/editor'; +import { Portal } from '@mui/material'; +import React, { useCallback, useEffect, useMemo } from 'react'; import { ErrorBoundary } from 'react-error-boundary'; -import Toolbars from './components/toolbar'; -import Panels from './components/panels'; +import { Element, NodeEntry, Range, Text, Transforms } from 'slate'; +import { ReactEditor, useSlate } from 'slate-react'; import BlockPopover from './components/block-popover'; +import Panels from './components/panels'; +import Toolbars from './components/toolbar'; -function EditorOverlay () { +function EditorOverlay({ + viewId, + workspaceId, +}: { + viewId: string; + workspaceId: string; +}) { + const { requestInstance } = useEditorContext(); + const editor = useSlate() as YjsEditor; + const selection = editor.selection; + const isRange = selection ? Range.isExpanded(selection) : false; + const start = useMemo(() => selection ? editor.start(selection) : null, [editor, selection]); + const end = useMemo(() => selection ? editor.end(selection) : null, [editor, selection]); + const startBlock = useMemo(() => { + if(!start) return null; + try { + return getBlockEntry(editor, start); + } catch(e) { + return null; + } + }, [editor, start]); + + const endBlock = useMemo(() => { + if(!end) return null; + try { + return getBlockEntry(editor, end); + + } catch(e) { + return null; + } + }, [editor, end]); + + const writerRequest = useMemo(() => { + return new WriterRequest(workspaceId, viewId, requestInstance || undefined); + }, [workspaceId, viewId, requestInstance]); + + const handleInsertBelow = useCallback((data: EditorData) => { + if(!endBlock) return; + try { + const [node] = endBlock as NodeEntry; + + if(!node) return; + + const blockId = insertDataAfterBlock(editor.sharedRoot, data, node.blockId as string); + + try { + ReactEditor.focus(editor); + const [, path] = findSlateEntryByBlockId(editor, blockId); + + editor.select(editor.end(path)); + } catch(e) { + // + } + } catch(e) { + console.error(e); + } + + }, [editor, endBlock]); + + const handleReplaceSelection = useCallback((data: EditorData) => { + try { + if(data.length === 1 && !isEmbedBlockTypes(data[0].type as unknown as BlockType)) { + ReactEditor.focus(editor); + if(Range.isExpanded(editor.selection as Range)) { + CustomEditor.deleteBlockForward(editor); + } + + const texts = data[0].delta?.map(op => { + return { + text: op.insert, + ...op.attributes, + }; + }) || []; + + Transforms.insertNodes(editor, texts as Text[], { + select: true, + voids: false, + }); + + return; + } else { + ReactEditor.focus(editor); + if(Range.isExpanded(editor.selection as Range)) { + CustomEditor.deleteBlockForward(editor); + } + + if(!startBlock) return; + + const [node] = startBlock as NodeEntry; + + if(!node) return; + + const blockId = insertDataAfterBlock(editor.sharedRoot, data, node.blockId as string); + const startYBlock = getBlock(node.blockId as string, editor.sharedRoot); + const startYText = getText(startYBlock.get(YjsEditorKey.block_external_id), editor.sharedRoot); + + if(startYText && startYText.length === 0) { + CustomEditor.deleteBlock(editor, node.blockId as string); + } + + ReactEditor.focus(editor); + const [, path] = findSlateEntryByBlockId(editor, blockId); + + editor.select(editor.end(path)); + + } + // eslint-disable-next-line + } catch(e: any) { + notify.error(e.message); + } + + }, [editor, startBlock]); + const { + removeDecorate, + } = useEditorContext(); + + const handleExit = useCallback(() => { + removeDecorate?.('ai-writer'); + if(!ReactEditor.isFocused(editor)) { + ReactEditor.focus(editor); + } + }, [removeDecorate, editor]); + + const [container, setContainer] = React.useState(null); + const [scrollerContainer, setScrollerContainer] = React.useState(null); + const [absoluteHeight, setAbsoluteHeight] = React.useState(0); + + useEffect(() => { + if(endBlock) { + const [node] = endBlock; + + try { + const dom = ReactEditor.toDOMNode(editor, node); + + const firstChild = dom.firstChild as HTMLElement; + + if(firstChild && firstChild.innerText.trim() === '') { + setAbsoluteHeight(firstChild.offsetHeight); + } else { + setAbsoluteHeight(0); + } + + setContainer(dom as HTMLDivElement); + + const container = dom.closest('.appflowy-scroll-container') || getScrollParent(dom); + + setScrollerContainer(container as HTMLDivElement); + } catch(e) { + console.error(e); + } + } + }, [editor, endBlock]); return ( null}> - - - + + + + + + {absoluteHeight ?
: + } +
+ +
+ ); } diff --git a/src/components/editor/__tests__/mount.tsx b/src/components/editor/__tests__/mount.tsx index 243b5722..910ff820 100644 --- a/src/components/editor/__tests__/mount.tsx +++ b/src/components/editor/__tests__/mount.tsx @@ -13,7 +13,7 @@ export function mountEditor(props: EditorProps) { ); }); - cy.mount(); + cy.mount(); } export const moveToEnd = () => { @@ -35,7 +35,7 @@ export const moveToLineStart = (lineIndex: number) => { cy.get(selector).as('targetBlock'); - if (lineIndex === 0) { + if(lineIndex === 0) { cy.get('@targetBlock').invoke('on', 'click', (e: MouseEvent) => { e.stopPropagation(); }).type('{movetostart}').wait(50); @@ -54,7 +54,7 @@ export const moveCursor = (lineIndex: number, charIndex: number) => { const batchSize = 1; const batches = Math.ceil(charIndex / batchSize); - for (let i = 0; i < batches; i++) { + for(let i = 0; i < batches; i++) { const remainingMoves = Math.min(batchSize, charIndex - i * batchSize); cy.get('@targetBlock').invoke('on', 'click', (e: MouseEvent) => { @@ -79,7 +79,7 @@ export const initialEditorTest = () => { const initializeEditor = (data: FromBlockJSON[]) => { documentTest = new DocumentTest(); documentTest.fromJSON(data); - mountEditor({ readOnly: false, doc: documentTest.doc, viewId: 'test' }); + mountEditor({ readOnly: false, doc: documentTest.doc, viewId: 'test', workspaceId: 'test' }); cy.get('[role="textbox"]').should('exist'); }; @@ -104,7 +104,7 @@ export const initialEditorTest = () => { }; export const getModKey = () => { - if (Cypress.platform === 'darwin') { + if(Cypress.platform === 'darwin') { return 'Meta'; } else { return 'Control'; diff --git a/src/components/editor/components/blocks/database/DatabaseBlock.tsx b/src/components/editor/components/blocks/database/DatabaseBlock.tsx index 77f8d499..740a6f20 100644 --- a/src/components/editor/components/blocks/database/DatabaseBlock.tsx +++ b/src/components/editor/components/blocks/database/DatabaseBlock.tsx @@ -15,6 +15,7 @@ export const DatabaseBlock = memo( const { t } = useTranslation(); const viewId = node.data.view_id; const context = useEditorContext(); + const workspaceId = context.workspaceId; const navigateToView = context?.navigateToView; const loadView = context?.loadView; const createRowDoc = context?.createRowDoc; @@ -175,6 +176,7 @@ export const DatabaseBlock = memo( {selectedViewId && doc ? ( <> Promise = async() => { try { - console.log(`Checking ${pollingInterval}ms`); const result = await checkImage(url); // Success case if(result.ok) { setImgError(null); setLoading(false); + setLocalUrl(result.validatedUrl || ''); setTimeout(() => { if(onLoad) { onLoad(); @@ -56,6 +57,7 @@ function Img({ onLoad, imgRef, url, width }: { if(attempts >= maxAttempts || elapsedTime >= timeoutDuration) { setLoading(false); // Stop loading after max attempts or timeout + setImgError({ ok: false, status: 404, statusText: 'Image Not Found' }); return false; } @@ -91,7 +93,7 @@ function Img({ onLoad, imgRef, url, width }: { <> {''} { setLoading(false); diff --git a/src/components/editor/components/blocks/link-preview/LinkPreview.tsx b/src/components/editor/components/blocks/link-preview/LinkPreview.tsx index 559f4d80..0dcb54a7 100644 --- a/src/components/editor/components/blocks/link-preview/LinkPreview.tsx +++ b/src/components/editor/components/blocks/link-preview/LinkPreview.tsx @@ -7,7 +7,7 @@ import emptyImageSrc from '@/assets/images/empty.png'; export const LinkPreview = memo( forwardRef>(({ node, children, ...attributes }, ref) => { const [data, setData] = useState<{ - image: { url: string }; + image?: { url: string }; title: string; description: string; } | null>(null); @@ -15,15 +15,15 @@ export const LinkPreview = memo( const url = node.data.url; useEffect(() => { - if (!url) return; + if(!url) return; setData(null); - void (async () => { + void (async() => { try { setNotFound(false); const response = await axios.get(`https://api.microlink.io/?url=${url}`); - if (response.data.statusCode !== 200) { + if(response.data.statusCode !== 200) { setNotFound(true); return; } @@ -31,7 +31,7 @@ export const LinkPreview = memo( const data = response.data.data; setData(data); - } catch (_) { + } catch(_) { setNotFound(true); } })(); @@ -57,8 +57,13 @@ export const LinkPreview = memo( {notFound ? (
- {'Empty + className={'text-text-title min-w-[80px] w-[120px] flex items-center justify-center mr-2 h-[80px] border rounded'} + > + {'Empty
@@ -73,7 +78,7 @@ export const LinkPreview = memo( ) : ( <> {data?.title} diff --git a/src/components/editor/components/blocks/text/Text.tsx b/src/components/editor/components/blocks/text/Text.tsx index fb83490a..fd2fa632 100644 --- a/src/components/editor/components/blocks/text/Text.tsx +++ b/src/components/editor/components/blocks/text/Text.tsx @@ -1,4 +1,5 @@ import Placeholder from '@/components/editor/components/blocks/text/Placeholder'; +import { ErrorBoundary } from 'react-error-boundary'; import { useSlateStatic } from 'slate-react'; import { useStartIcon } from './StartIcon.hooks'; import { EditorElementProps, TextNode } from '@/components/editor/editor.type'; @@ -12,14 +13,16 @@ export const Text = forwardRef>( const className = useMemo(() => { const classList = ['text-element', 'relative', 'flex', 'w-full', 'whitespace-pre-wrap', 'break-word']; - if (classNameProp) classList.push(classNameProp); - if (hasStartIcon) classList.push('has-start-icon'); + if(classNameProp) classList.push(classNameProp); + if(hasStartIcon) classList.push('has-start-icon'); return classList.join(' '); }, [classNameProp, hasStartIcon]); const placeholder = useMemo(() => { - if (!isEmpty) return null; - return ; + if(!isEmpty) return null; + return + + ; }, [isEmpty, node]); const content = useMemo(() => { diff --git a/src/components/editor/components/element/BlockNotFound.tsx b/src/components/editor/components/element/BlockNotFound.tsx new file mode 100644 index 00000000..faaa62e2 --- /dev/null +++ b/src/components/editor/components/element/BlockNotFound.tsx @@ -0,0 +1,31 @@ +import { EditorElementProps } from '@/components/editor/editor.type'; +import React, { forwardRef } from 'react'; +import { Alert } from '@mui/material'; + +export const BlockNotFound = forwardRef(({ node }, ref) => { + if(import.meta.env.DEV) { + return ( +
+ +
{`Block not found, id is ${node.blockId}`}
+
+ {'It might be deleted or moved to another place but the children map is still referencing it.'} +
+
+
+ ); + } + + return
; +}); diff --git a/src/components/editor/components/element/Element.tsx b/src/components/editor/components/element/Element.tsx index e804a0f2..d441bf31 100644 --- a/src/components/editor/components/element/Element.tsx +++ b/src/components/editor/components/element/Element.tsx @@ -23,6 +23,7 @@ import SimpleTableRow from '@/components/editor/components/blocks/simple-table/S import { TableBlock, TableCellBlock } from '@/components/editor/components/blocks/table'; import { Text } from '@/components/editor/components/blocks/text'; import { VideoBlock } from '@/components/editor/components/blocks/video'; +import { BlockNotFound } from '@/components/editor/components/element/BlockNotFound'; import { UnSupportedBlock } from '@/components/editor/components/element/UnSupportedBlock'; import { EditorElementProps, TextNode } from '@/components/editor/editor.type'; import { useEditorContext } from '@/components/editor/EditorContext'; @@ -156,6 +157,8 @@ export const Element = ({ return Columns; case BlockType.ColumnBlock: return Column; + case 'block_not_found': + return BlockNotFound; default: return UnSupportedBlock; } @@ -181,7 +184,7 @@ export const Element = ({ const className = useMemo(() => { const data = (node.data as BlockData) || {}; const align = data.align; - const classList = ['block-element relative flex rounded-[4px]']; + const classList = ['block-element flex-col relative flex rounded-[4px]']; if(selected) { classList.push('selected'); diff --git a/src/components/editor/components/panels/slash-panel/SlashPanel.tsx b/src/components/editor/components/panels/slash-panel/SlashPanel.tsx index adf0f1f9..e38f687e 100644 --- a/src/components/editor/components/panels/slash-panel/SlashPanel.tsx +++ b/src/components/editor/components/panels/slash-panel/SlashPanel.tsx @@ -41,7 +41,8 @@ import { ReactComponent as ToggleHeading2Icon } from '@/assets/toggle_heading2.s import { ReactComponent as ToggleHeading3Icon } from '@/assets/toggle_heading3.svg'; import { ReactComponent as MathIcon } from '@/assets/slash_menu_icon_math_equation.svg'; import { ReactComponent as VideoIcon } from '@/assets/video.svg'; - +import { ReactComponent as AskAIIcon } from '@/assets/ai.svg'; +import { ReactComponent as ContinueWritingIcon } from '@/assets/continue_writing.svg'; import { notify } from '@/components/_shared/notify'; import { calculateOptimalOrigins, Popover } from '@/components/_shared/popover'; import { usePopoverContext } from '@/components/editor/components/block-popover/BlockPopoverContext'; @@ -49,9 +50,11 @@ import { usePanelContext } from '@/components/editor/components/panels/Panels.ho import { PanelType } from '@/components/editor/components/panels/PanelsContext'; import { getRangeRect } from '@/components/editor/components/toolbar/selection-toolbar/utils'; import { useEditorContext } from '@/components/editor/EditorContext'; +import { getCharacters } from '@/utils/word'; +import { useAIWriter } from '@appflowyinc/ai-chat'; import { Button } from '@mui/material'; import { PopoverOrigin } from '@mui/material/Popover/Popover'; -import React, { useCallback, useEffect, useMemo, useRef } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { ReactEditor, useSlateStatic } from 'slate-react'; @@ -67,9 +70,18 @@ export function SlashPanel({ searchText, removeContent, } = usePanelContext(); + const { + addPage, + openPageModal, + viewId, + loadViewMeta, + } = useEditorContext(); + const [viewName, setViewName] = useState(''); + + const editor = useSlateStatic() as YjsEditor; + const { t } = useTranslation(); const optionsRef = useRef(null); - const editor = useSlateStatic() as YjsEditor; const [selectedOption, setSelectedOption] = React.useState(null); const [transformOrigin, setTransformOrigin] = React.useState(undefined); const selectedOptionRef = React.useRef(null); @@ -80,6 +92,38 @@ export function SlashPanel({ return isPanelOpen(PanelType.Slash); }, [isPanelOpen]); + useEffect(() => { + if(viewId && open) { + void loadViewMeta?.(viewId).then((view) => { + setViewName(view.name); + }); + } + }, [viewId, loadViewMeta, open]); + + const getBeforeContent = useCallback(() => { + const { selection } = editor; + + if(!selection) return ''; + + const start = { + path: [0], + offset: 0, + }; + + const end = editor.end(selection); + + return viewName + '\n' + CustomEditor.getSelectionContent(editor, { + anchor: start, + focus: end, + }); + }, [editor, viewName]); + + const chars = useMemo(() => { + if(!open) return 0; + + return getCharacters(getBeforeContent()); + }, [open, getBeforeContent]); + const handleSelectOption = useCallback((option: string) => { setSelectedOption(option); removeContent(); @@ -121,14 +165,13 @@ export function SlashPanel({ }, [editor, openPopover]); - const { - addPage, - openPageModal, - viewId, - } = useEditorContext(); - const { openPanel } = usePanelContext(); + const { + askAIAnything, + continueWriting, + } = useAIWriter(); + const options: { label: string; key: string; @@ -137,12 +180,29 @@ export function SlashPanel({ onClick?: () => void; }[] = useMemo(() => { return [ - // { - // label: t('document.slashMenu.name.aiWriter'), - // key: 'aiWriter', - // icon: , - // keywords: ['ai', 'writer'], - // }, + { + label: t('document.slashMenu.name.askAIAnything'), + key: 'askAIAnything', + icon: , + keywords: ['ai', 'writer', 'ask', 'anything', 'askAIAnything', 'askai'], + onClick: () => { + const content = getBeforeContent(); + + askAIAnything(content); + }, + }, + { + label: t('document.slashMenu.name.continueWriting'), + key: 'continueWriting', + disabled: chars < 2, + icon: , + keywords: ['ai', 'writing', 'continue'], + onClick: () => { + const content = getBeforeContent(); + + void continueWriting(content); + }, + }, { label: t('document.slashMenu.name.text'), key: 'text', @@ -465,12 +525,13 @@ export function SlashPanel({ turnInto(BlockType.FileBlock, {}); }, }].filter((option) => { + if(option.disabled) return false; if(!searchText) return true; return option.keywords.some((keyword: string) => { return keyword.toLowerCase().includes(searchText.toLowerCase()); }); }); - }, [t, turnInto, openPanel, viewId, addPage, openPageModal, setEmojiPosition, searchText]); + }, [t, chars, getBeforeContent, askAIAnything, continueWriting, turnInto, openPanel, viewId, addPage, openPageModal, setEmojiPosition, searchText]); const resultLength = options.length; diff --git a/src/components/editor/components/toolbar/block-controls/HoverControls.hooks.ts b/src/components/editor/components/toolbar/block-controls/HoverControls.hooks.ts index 090bc7a1..44e94755 100644 --- a/src/components/editor/components/toolbar/block-controls/HoverControls.hooks.ts +++ b/src/components/editor/components/toolbar/block-controls/HoverControls.hooks.ts @@ -67,19 +67,24 @@ export function useHoverControls({ disabled }: { disabled: boolean; }) { console.warn('No range and node found'); return; } else if(range) { - const match = editor.above({ - match: (n) => { - return !Editor.isEditor(n) && Element.isElement(n) && n.blockId !== undefined; - }, - at: range, - }); + try { + const match = editor.above({ + match: (n) => { + return !Editor.isEditor(n) && Element.isElement(n) && n.blockId !== undefined; + }, + at: range, + }); - if(!match) { - close(); - return; + if(!match) { + close(); + return; + } + + node = match[0]; + } catch { + // do nothing } - node = match[0]; } if(!node) { diff --git a/src/components/editor/components/toolbar/selection-toolbar/SelectionToolbar.hooks.ts b/src/components/editor/components/toolbar/selection-toolbar/SelectionToolbar.hooks.ts index b77ae633..1074b0b7 100644 --- a/src/components/editor/components/toolbar/selection-toolbar/SelectionToolbar.hooks.ts +++ b/src/components/editor/components/toolbar/selection-toolbar/SelectionToolbar.hooks.ts @@ -3,8 +3,9 @@ import { EditorMarkFormat } from '@/application/slate-yjs/types'; import { getSelectionPosition } from '@/components/editor/components/toolbar/selection-toolbar/utils'; import { Decorate, useEditorContext } from '@/components/editor/EditorContext'; import { createHotkey, HOT_KEY_NAME } from '@/utils/hotkeys'; +import { useAIWriter } from '@appflowyinc/ai-chat'; import { debounce } from 'lodash-es'; -import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'; +import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; import { Range } from 'slate'; import { ReactEditor, useFocused, useSlate, useSlateStatic } from 'slate-react'; @@ -20,24 +21,44 @@ export function useVisible() { const isExpanded = selection ? Range.isExpanded(selection) : false; const selectedText = useMemo(() => { - if (!selection) return 0; + if(!selection) return 0; return CustomEditor.getTextNodes(editor).length; }, [editor, selection]); + const [visible, setVisible] = useState(false); - const visible = useMemo(() => { - if (forceShow) return true; - if (!focus) return false; + const { + assistantType, + } = useAIWriter(); - if (document.getSelection()?.isCollapsed) return false; - - const show = Boolean(selectedText && isExpanded && !isDragging); - - return show; - }, [forceShow, focus, selectedText, isExpanded, isDragging]); + const assistantTypeRef = useRef(assistantType); useEffect(() => { - if (!visible) { + assistantTypeRef.current = assistantType; + }, [assistantType]); + + useEffect(() => { + const handleSelectionChange = () => { + setVisible(() => { + if(forceShow) return true; + if(!focus) return false; + + if(document.getSelection()?.isCollapsed || assistantTypeRef.current !== undefined) return false; + + return Boolean(selectedText && isExpanded && !isDragging); + }); + }; + + handleSelectionChange(); + document.addEventListener('selectionchange', handleSelectionChange); + + return () => { + document.removeEventListener('selectionchange', handleSelectionChange); + }; + }, [focus, forceShow, isDragging, isExpanded, selectedText]); + + useEffect(() => { + if(!visible) { removeDecorate?.('selection-toolbar'); return; } @@ -47,9 +68,10 @@ export function useVisible() { useEffect(() => { const handleMouseDown = () => { + const { selection } = editor; - if (selection && Range.isExpanded(selection)) { + if(selection && Range.isExpanded(selection)) { window.getSelection()?.removeAllRanges(); } @@ -75,7 +97,7 @@ export function useVisible() { }, [editor, removeDecorate]); const handleForceShow = useCallback((show: boolean) => { - if (show && editor.selection) { + if(show && editor.selection) { setForceShow(true); addDecorate?.(editor.selection, 'bg-content-blue-100', 'selection-toolbar'); } else { @@ -88,12 +110,12 @@ export function useVisible() { }, [decorateState]); useEffect(() => { - if (!visible) return; + if(!visible) return; const handleKeyDown = (event: KeyboardEvent) => { - switch (true) { + switch(true) { case createHotkey(HOT_KEY_NAME.ESCAPE)(event): { - if (!editor.selection) break; + if(!editor.selection) break; event.preventDefault(); event.stopPropagation(); const start = editor.start(editor.selection); @@ -196,7 +218,7 @@ export function useToolbarPosition() { const left = position.left + slateEditorDom.offsetLeft; // If toolbar is out of editor, move it to the left edge of the editor - if (left <= 0) { + if(left <= 0) { toolbarEl.style.left = '0px'; return; } @@ -205,7 +227,7 @@ export function useToolbarPosition() { const rightBound = slateEditorDom.offsetWidth + slateEditorDom.offsetLeft; // If toolbar is out of editor, move the right edge to the right edge of the editor - if (right > rightBound) { + if(right > rightBound) { toolbarEl.style.left = `${rightBound - toolbarEl.offsetWidth}px`; return; } @@ -216,7 +238,7 @@ export function useToolbarPosition() { const showToolbar = useCallback((toolbarEl: HTMLDivElement) => { const position = getSelectionPosition(editor); - if (position) { + if(position) { toolbarEl.style.opacity = '1'; toolbarEl.style.pointerEvents = 'auto'; setPosition(toolbarEl, position); diff --git a/src/components/editor/components/toolbar/selection-toolbar/ToolbarActions.tsx b/src/components/editor/components/toolbar/selection-toolbar/ToolbarActions.tsx index a829bcc2..8a8b53cd 100644 --- a/src/components/editor/components/toolbar/selection-toolbar/ToolbarActions.tsx +++ b/src/components/editor/components/toolbar/selection-toolbar/ToolbarActions.tsx @@ -1,6 +1,7 @@ import { YjsEditor } from '@/application/slate-yjs'; import { getBlockEntry } from '@/application/slate-yjs/utils/editor'; import { BlockType } from '@/application/types'; +import AIAssistant from '@/components/editor/components/toolbar/selection-toolbar/actions/AIAssistant'; import Align from '@/components/editor/components/toolbar/selection-toolbar/actions/Align'; import Bold from '@/components/editor/components/toolbar/selection-toolbar/actions/Bold'; import BulletedList from '@/components/editor/components/toolbar/selection-toolbar/actions/BulletedList'; @@ -34,29 +35,29 @@ function ToolbarActions() { const end = useMemo(() => selection ? editor.end(selection) : null, [editor, selection]); const startBlock = useMemo(() => { - if (!start) return null; + if(!start) return null; try { return getBlockEntry(editor, start); - } catch (e) { + } catch(e) { return null; } }, [editor, start]); const endBlock = useMemo(() => { - if (!end) return null; + if(!end) return null; try { return getBlockEntry(editor, end); - } catch (e) { + } catch(e) { return null; } }, [editor, end]); const isAcrossBlock = useMemo(() => { - if (startBlock && endBlock && Path.equals(startBlock[1], endBlock[1])) return false; + if(startBlock && endBlock && Path.equals(startBlock[1], endBlock[1])) return false; return startBlock?.[0].blockId !== endBlock?.[0].blockId; }, [endBlock, startBlock]); const isCodeBlock = useMemo(() => { - if (!start || !end) return false; + if(!start || !end) return false; const range = { anchor: start, focus: end }; const [codeBlock] = editor.nodes({ @@ -67,57 +68,50 @@ function ToolbarActions() { return !!codeBlock; }, [editor, end, start]); - const groupTwo = <> - - - - - ; - - const groupOne = <> - - - - ; - - const groupThree = <> - - - - - - - ; - - const groupFour = <>; - return (
+ {!isCodeBlock && } { - !isAcrossBlock && !isCodeBlock && groupOne + !isAcrossBlock && !isCodeBlock && <> + + + + } - {groupTwo} - {!isCodeBlock && } - {!isCodeBlock && !isAcrossBlock && } + <> + + + + + + {!isCodeBlock && } + {!isCodeBlock && !isAcrossBlock && } { - !isAcrossBlock && !isCodeBlock && groupThree + !isAcrossBlock && !isCodeBlock && <> + + + + + + + } - {!isCodeBlock && groupFour} - + {!isCodeBlock && } +
); } diff --git a/src/components/editor/components/toolbar/selection-toolbar/actions/AIAssistant.tsx b/src/components/editor/components/toolbar/selection-toolbar/actions/AIAssistant.tsx new file mode 100644 index 00000000..06098b9a --- /dev/null +++ b/src/components/editor/components/toolbar/selection-toolbar/actions/AIAssistant.tsx @@ -0,0 +1,111 @@ +import { YjsEditor } from '@/application/slate-yjs'; +import { CustomEditor } from '@/application/slate-yjs/command'; +import ActionButton from '@/components/editor/components/toolbar/selection-toolbar/actions/ActionButton'; +import { + useSelectionToolbarContext, +} from '@/components/editor/components/toolbar/selection-toolbar/SelectionToolbar.hooks'; +import { useEditorContext } from '@/components/editor/EditorContext'; +import { useAIWriter, AIWriterMenu, AIAssistantType } from '@appflowyinc/ai-chat'; +import React, { useCallback, useEffect } from 'react'; +import { ReactComponent as AskAIIcon } from '@/assets/ai.svg'; +import { ReactComponent as ImproveWritingIcon } from '@/assets/improve-writing.svg'; +import { useTranslation } from 'react-i18next'; +import { ReactEditor, useSlate } from 'slate-react'; +import { ReactComponent as RightIcon } from '@/assets/arrow_right.svg'; + +function AIAssistant() { + const { t } = useTranslation(); + const editor = useSlate() as YjsEditor; + + const [open, setOpen] = React.useState(false); + const { + addDecorate, + } = useEditorContext(); + const { + visible: toolbarVisible, + } = useSelectionToolbarContext(); + const { + improveWriting, + } = useAIWriter(); + const [content, setContent] = React.useState(''); + + const addReplaceStyle = useCallback(() => { + const range = editor.selection; + + if(!range) return; + + addDecorate?.(range, 'line-through text-text-caption', 'ai-writer'); + }, [addDecorate, editor.selection]); + + const addHighLightStyle = useCallback(() => { + const range = editor.selection; + + if(!range) return; + + addDecorate?.(range, 'bg-content-blue-100', 'ai-writer'); + }, [addDecorate, editor.selection]); + const onClickImproveWriting = useCallback(() => { + addReplaceStyle(); + const content = CustomEditor.getSelectionContent(editor); + + void improveWriting(content); + }, [addReplaceStyle, editor, improveWriting]); + + const isFilterOut = useCallback((type: AIAssistantType) => { + return type === AIAssistantType.ContinueWriting; + }, []); + + const onItemClicked = useCallback((type: AIAssistantType) => { + if([AIAssistantType.ImproveWriting, AIAssistantType.FixSpelling, AIAssistantType.MakeLonger, AIAssistantType.MakeShorter].includes(type)) { + addReplaceStyle(); + } else { + addHighLightStyle(); + } + + ReactEditor.blur(editor); + }, [addHighLightStyle, addReplaceStyle, editor]); + + useEffect(() => { + if(!toolbarVisible) { + setOpen(false); + } + }, [toolbarVisible]); + + return ( + <> + + + + + { + e.stopPropagation(); + setContent(CustomEditor.getSelectionContent(editor)); + setOpen(prev => !prev); + }} + className={'!text-ai-primary !hover:text-billing-primary'} + tooltip={t('editor.askAI')} + > +
+ + + +
+ +
+
+ + + ); +} + +export default AIAssistant; \ No newline at end of file diff --git a/src/components/editor/editor.scss b/src/components/editor/editor.scss index 5223a7e5..b6ef116c 100644 --- a/src/components/editor/editor.scss +++ b/src/components/editor/editor.scss @@ -1,9 +1,5 @@ @use "src/styles/mixin.scss"; -.block-element:not([data-block-type="table/cell"]) { - @apply my-[4px]; -} - .block-element { &:has(.embed-block) { @@ -11,7 +7,7 @@ } .embed-block { - @apply z-[1] hover:bg-content-blue-50 flex relative w-full gap-4 overflow-hidden rounded-[8px] border border-line-divider bg-fill-list-active; + @apply z-[1] my-1 hover:bg-content-blue-50 flex relative w-full gap-4 overflow-hidden rounded-[8px] border border-line-divider bg-fill-list-active; } .math-equation-block, .gallery-block { @@ -506,3 +502,17 @@ span[data-slate-placeholder="true"]:not(.inline-block-content) { } } } + +#appflowy-ai-writer { + @apply bg-bg-body; + #appflowy-editor > div { + padding: 0 !important; + @apply underline text-writer-placeholder; + + div[data-block-type="image"] { + @apply border-[4px] border-content-blue-200 p-0; + } + + } + +} \ No newline at end of file diff --git a/src/components/publish/CollabView.tsx b/src/components/publish/CollabView.tsx index 07420aa8..180d556b 100644 --- a/src/components/publish/CollabView.tsx +++ b/src/components/publish/CollabView.tsx @@ -19,11 +19,11 @@ export interface CollabViewProps { doc?: YDoc; } -function CollabView ({ doc }: CollabViewProps) { +function CollabView({ doc }: CollabViewProps) { const visibleViewIds = usePublishContext()?.viewMeta?.visible_view_ids; const { viewId, layout, icon, cover, layoutClassName, style, name } = useViewMeta(); const View = useMemo(() => { - switch (layout) { + switch(layout) { case ViewLayout.Document: return Document; case ViewLayout.Grid: @@ -47,11 +47,11 @@ function CollabView ({ doc }: CollabViewProps) { const className = useMemo(() => { const classList = ['relative w-full flex-1']; - if (isTemplateThumb && layout !== ViewLayout.Document) { + if(isTemplateThumb && layout !== ViewLayout.Document) { classList.push('flex justify-center h-full'); } - if (layoutClassName) { + if(layoutClassName) { classList.push(layoutClassName); } @@ -59,7 +59,7 @@ function CollabView ({ doc }: CollabViewProps) { }, [isTemplateThumb, layout, layoutClassName]); const skeleton = useMemo(() => { - switch (layout) { + switch(layout) { case ViewLayout.Grid: return ; case ViewLayout.Board: @@ -73,9 +73,9 @@ function CollabView ({ doc }: CollabViewProps) { } }, [layout]); - if (!View) return null; + if(!View) return null; - if (!doc) { + if(!doc) { return skeleton; } @@ -93,6 +93,7 @@ function CollabView ({ doc }: CollabViewProps) { className={className} > void; } -function DatabaseView ({ viewMeta, ...props }: DatabaseProps) { +function DatabaseView({ viewMeta, ...props }: DatabaseProps) { const [search, setSearch] = useSearchParams(); const visibleViewIds = useMemo(() => viewMeta.visibleViewIds || [], [viewMeta]); @@ -66,11 +67,11 @@ function DatabaseView ({ viewMeta, ...props }: DatabaseProps) { const database = doc?.getMap(YjsEditorKey.data_section)?.get(YjsEditorKey.database) as YDatabase; const skeleton = useMemo(() => { - if (rowId) { + if(rowId) { return ; } - switch (viewMeta.layout) { + switch(viewMeta.layout) { case ViewLayout.Grid: return ; case ViewLayout.Board: @@ -82,7 +83,7 @@ function DatabaseView ({ viewMeta, ...props }: DatabaseProps) { } }, [rowId, viewMeta.layout]); - if (!viewId || !database) return null; + if(!viewId || !database) return null; return (
: null diff --git a/src/pages/AcceptInvitationPage.tsx b/src/pages/AcceptInvitationPage.tsx index f33e488a..055e5704 100644 --- a/src/pages/AcceptInvitationPage.tsx +++ b/src/pages/AcceptInvitationPage.tsx @@ -22,34 +22,34 @@ function AcceptInvitationPage() { const { t } = useTranslation(); useEffect(() => { - if (!isAuthenticated) { + if(!isAuthenticated) { navigate('/login?redirectTo=' + encodeURIComponent(window.location.href)); } }, [isAuthenticated, navigate]); - const loadInvitation = useCallback(async (invitationId: string) => { - if (!service) return; + const loadInvitation = useCallback(async(invitationId: string) => { + if(!service) return; try { const res = await service.getInvitation(invitationId); - if (res.status === 'Accepted') { + if(res.status === 'Accepted') { notify.warning(t('invitation.alreadyAccepted')); } setInvitation(res); // eslint-disable-next-line - } catch (e: any) { + } catch(e: any) { setModalOpened(true); } }, [service, t]); useEffect(() => { - if (!invitationId) return; + if(!invitationId) return; void loadInvitation(invitationId); }, [loadInvitation, invitationId]); const workspaceIconProps = useMemo(() => { - if (!invitation) return {}; + if(!invitation) return {}; return getAvatar({ icon: invitation.workspace_icon, @@ -61,7 +61,7 @@ function AcceptInvitationPage() { }, []); const inviterIconProps = useMemo(() => { - if (!invitation) return {}; + if(!invitation) return {}; return getAvatar({ icon: invitation.inviter_icon, @@ -79,7 +79,7 @@ function AcceptInvitationPage() { }} className={'flex w-full cursor-pointer max-md:justify-center max-md:h-32 h-20 items-center justify-between sticky'} > - +
AppFlowy
- +
-
{t('invitation.invitedBy')}
-
{invitation?.inviter_name}
+
{t('invitation.invitedBy')}
+
{invitation?.inviter_name}
{t('invitation.membersCount', { count: invitation?.member_count || 0, })}
@@ -118,7 +118,7 @@ function AcceptInvitationPage() {
- + {currentUser?.email}
@@ -127,9 +127,9 @@ function AcceptInvitationPage() { color={'primary'} size={'large'} className={'max-w-full w-[400px] rounded-[16px] text-[24px] py-5 px-10'} - onClick={async () => { - if (!invitationId) return; - if (invitation?.status === 'Accepted') { + onClick={async() => { + if(!invitationId) return; + if(invitation?.status === 'Accepted') { notify.warning(t('invitation.alreadyAccepted')); return; } @@ -149,7 +149,7 @@ function AcceptInvitationPage() { }, }); - } catch (e) { + } catch(e) { notify.error('Failed to join workspace'); } }} @@ -157,7 +157,11 @@ function AcceptInvitationPage() { {t('invitation.joinWorkspace')}
- {isAuthenticated && } + {isAuthenticated && }
); } diff --git a/src/pages/AppPage.tsx b/src/pages/AppPage.tsx index e53e28f8..def93136 100644 --- a/src/pages/AppPage.tsx +++ b/src/pages/AppPage.tsx @@ -3,10 +3,17 @@ import { ReactComponent as TipIcon } from '@/assets/warning.svg'; import Help from '@/components/_shared/help/Help'; import { notify } from '@/components/_shared/notify'; import { findView } from '@/components/_shared/outline/utils'; -import { AppContext, useAppHandlers, useAppOutline, useAppViewId } from '@/components/app/app.hooks'; +import { + AppContext, + useAppHandlers, + useAppOutline, + useAppViewId, + useCurrentWorkspaceId, +} from '@/components/app/app.hooks'; import DatabaseView from '@/components/app/DatabaseView'; import { Document } from '@/components/document'; import RecordNotFound from '@/components/error/RecordNotFound'; +import { useService } from '@/components/main/app.hooks'; import { getPlatform } from '@/utils/platform'; import { desktopDownloadLink, openAppFlowySchema } from '@/utils/url'; import { Button, Checkbox, FormControlLabel } from '@mui/material'; @@ -19,7 +26,7 @@ function AppPage() { const viewId = useAppViewId(); const outline = useAppOutline(); const ref = React.useRef(null); - + const workspaceId = useCurrentWorkspaceId(); const { toView, loadViewMeta, @@ -88,8 +95,9 @@ function AppPage() { visibleViewIds: [], viewId: view.view_id, extra: view.extra, + workspaceId, } : null; - }, [view]); + }, [view, workspaceId]); const handleUploadFile = useCallback((file: File) => { if(view && uploadFile) { @@ -99,6 +107,9 @@ function AppPage() { return Promise.reject(); }, [uploadFile, view]); + const service = useService(); + const requestInstance = service?.getAxiosInstance(); + const viewDom = useMemo(() => { const isMobile = getPlatform().isMobile; @@ -111,8 +122,10 @@ function AppPage() { const View = layout === ViewLayout.Document ? Document : DatabaseView; - return doc && viewMeta && View ? ( + return doc && viewMeta && workspaceId && View ? ( ) : null; - }, [doc, layout, viewId, viewMeta, toView, loadViewMeta, createRowDoc, appendBreadcrumb, loadView, onRendered, updatePage, addPage, deletePage, openPageModal, loadViews, setWordCount, handleUploadFile]); + }, [requestInstance, workspaceId, doc, layout, viewId, viewMeta, toView, loadViewMeta, createRowDoc, appendBreadcrumb, loadView, onRendered, updatePage, addPage, deletePage, openPageModal, loadViews, setWordCount, handleUploadFile]); useEffect(() => { if(!viewId) return; diff --git a/src/styles/app.scss b/src/styles/app.scss index 1cb12d98..febf9a14 100644 --- a/src/styles/app.scss +++ b/src/styles/app.scss @@ -3,6 +3,7 @@ * { -webkit-tap-highlight-color: transparent; -webkit-touch-callout: none; + scroll-behavior: smooth; } .sketch-picker { diff --git a/src/styles/variables/dark.variables.css b/src/styles/variables/dark.variables.css index e0f63582..e7d47a73 100644 --- a/src/styles/variables/dark.variables.css +++ b/src/styles/variables/dark.variables.css @@ -27,6 +27,7 @@ --content-blue-300: #52d1f4; --content-blue-600: #009fd1; --content-blue-100: #005174; + --content-blue-200: #49CFF480; --content-blue-50: #024562; --content-blue-700: #0079a5; --content-blue-800: #00597a; @@ -82,4 +83,7 @@ --note-header: #232b38; --billing-primary: #601DAA; --billing-primary-hover: #7A2EBF; + --ai-primary: #D08EED; + --writer-placeholder: #49CFF4; + } \ No newline at end of file diff --git a/src/styles/variables/light.variables.css b/src/styles/variables/light.variables.css index 519f0da6..3014b098 100644 --- a/src/styles/variables/light.variables.css +++ b/src/styles/variables/light.variables.css @@ -32,6 +32,7 @@ --content-blue-800: #00597a; --content-blue-900: #003d4d; --content-blue-100: #e0f8ff; + --content-blue-200: #00729680; --content-blue-50: #f2fcff; --content-on-fill-hover: #00bcf0; --content-on-fill: #ffffff; @@ -84,4 +85,6 @@ --note-header: #EDEFF3; --billing-primary: #8427e0; --billing-primary-hover: #9f3ae6; + --ai-primary: #D08EED; + --writer-placeholder: #007296; } \ No newline at end of file diff --git a/src/utils/image.ts b/src/utils/image.ts index aa3f7e58..6920cd51 100644 --- a/src/utils/image.ts +++ b/src/utils/image.ts @@ -22,13 +22,13 @@ export const checkImage = async(url: string) => { clearTimeout(timeoutId); // Add cache-busting parameter to prevent browser caching // which can sometimes hide image loading issues - const cacheBuster = `?cb=${Date.now()}`; + // const cacheBuster = `?cb=${Date.now()}`; resolve({ ok: true, status: 200, statusText: 'OK', - validatedUrl: url + cacheBuster, + validatedUrl: url, }); }; diff --git a/tailwind/box-shadow.cjs b/tailwind/box-shadow.cjs index 426954cd..754da707 100644 --- a/tailwind/box-shadow.cjs +++ b/tailwind/box-shadow.cjs @@ -1,7 +1,7 @@ /** * Do not edit directly -* Generated on Tue, 07 Jan 2025 10:54:45 GMT +* Generated on Thu, 27 Mar 2025 11:44:33 GMT * Generated from $pnpm css:variables */ diff --git a/tailwind/colors.cjs b/tailwind/colors.cjs index ea16f6b3..279121e4 100644 --- a/tailwind/colors.cjs +++ b/tailwind/colors.cjs @@ -1,7 +1,7 @@ /** * Do not edit directly -* Generated on Tue, 07 Jan 2025 10:54:45 GMT +* Generated on Thu, 27 Mar 2025 11:44:33 GMT * Generated from $pnpm css:variables */ @@ -57,6 +57,7 @@ module.exports = { "blue-800": "var(--content-blue-800)", "blue-900": "var(--content-blue-900)", "blue-100": "var(--content-blue-100)", + "blue-200": "var(--content-blue-200)", "blue-50": "var(--content-blue-50)", "on-fill-hover": "var(--content-on-fill-hover)", "on-fill": "var(--content-on-fill)", @@ -103,5 +104,11 @@ module.exports = { "billing": { "primary": "var(--billing-primary)", "primary-hover": "var(--billing-primary-hover)" + }, + "ai": { + "primary": "var(--ai-primary)" + }, + "writer": { + "placeholder": "var(--writer-placeholder)" } };