diff --git a/frontend/appflowy_web_app/cypress.config.ts b/frontend/appflowy_web_app/cypress.config.ts index 212d1edca7857..3612fcbca5794 100644 --- a/frontend/appflowy_web_app/cypress.config.ts +++ b/frontend/appflowy_web_app/cypress.config.ts @@ -22,10 +22,11 @@ export default defineConfig({ }, supportFile: 'cypress/support/component.ts', }, + chromeWebSecurity: false, retries: { // Configure retry attempts for `cypress run` // Default is 0 - runMode: 10, + runMode: 16, // Configure retry attempts for `cypress open` // Default is 0 openMode: 0, diff --git a/frontend/appflowy_web_app/cypress/support/component.ts b/frontend/appflowy_web_app/cypress/support/component.ts index 84df06273fe3f..f60970e7edf3f 100644 --- a/frontend/appflowy_web_app/cypress/support/component.ts +++ b/frontend/appflowy_web_app/cypress/support/component.ts @@ -17,6 +17,7 @@ import 'cypress-real-events'; // Import commands.js using ES2015 syntax: import '@cypress/code-coverage/support'; +import 'cypress-real-events/support'; import './commands'; import './document'; diff --git a/frontend/appflowy_web_app/cypress/support/document.ts b/frontend/appflowy_web_app/cypress/support/document.ts index 6edbf118728fd..b7dfe0900a95b 100644 --- a/frontend/appflowy_web_app/cypress/support/document.ts +++ b/frontend/appflowy_web_app/cypress/support/document.ts @@ -114,7 +114,7 @@ export class DocumentTest { blockText.applyDelta(child.text); this.textMap.set(blockId, blockText); - + const blockChildren = new Y.Array(); this.childrenMap.set(blockId, blockChildren); @@ -137,7 +137,7 @@ export class DocumentTest { children.push({ type: child.get(YjsEditorKey.block_type), data: JSON.parse(child.get(YjsEditorKey.block_data)), - text: this.textMap.get(childId).toDelta(), + text: this.textMap.get(childId)?.toDelta() || [], children: this.toJSONChildren(childId), }); } diff --git a/frontend/appflowy_web_app/package.json b/frontend/appflowy_web_app/package.json index e2a7ad574a8b3..1cf2c1125105e 100644 --- a/frontend/appflowy_web_app/package.json +++ b/frontend/appflowy_web_app/package.json @@ -29,6 +29,7 @@ "@emoji-mart/react": "^1.1.1", "@emotion/react": "^11.10.6", "@emotion/styled": "^11.10.6", + "@floating-ui/react": "^0.26.27", "@jest/globals": "^29.7.0", "@mui/icons-material": "^5.11.11", "@mui/material": "6.0.0-alpha.2", @@ -44,6 +45,7 @@ "decimal.js": "^10.4.3", "dexie": "^4.0.7", "dexie-react-hooks": "^1.1.7", + "dompurify": "^3.1.7", "emoji-mart": "^5.5.2", "emoji-regex": "^10.2.1", "escape-string-regexp": "^5.0.0", @@ -122,6 +124,7 @@ "@tauri-apps/cli": "^1.5.11", "@testing-library/react": "^16.0.0", "@types/cypress-image-snapshot": "^3.1.9", + "@types/dompurify": "^3.0.5", "@types/google-protobuf": "^3.15.12", "@types/is-hotkey": "^0.1.7", "@types/jest": "^29.5.3", diff --git a/frontend/appflowy_web_app/pnpm-lock.yaml b/frontend/appflowy_web_app/pnpm-lock.yaml index 768b5b6d2649f..5f682564a4f53 100644 --- a/frontend/appflowy_web_app/pnpm-lock.yaml +++ b/frontend/appflowy_web_app/pnpm-lock.yaml @@ -20,6 +20,9 @@ dependencies: '@emotion/styled': specifier: ^11.10.6 version: 11.11.5(@emotion/react@11.11.4)(@types/react@18.2.66)(react@18.2.0) + '@floating-ui/react': + specifier: ^0.26.27 + version: 0.26.27(react-dom@18.2.0)(react@18.2.0) '@jest/globals': specifier: ^29.7.0 version: 29.7.0 @@ -65,6 +68,9 @@ dependencies: dexie-react-hooks: specifier: ^1.1.7 version: 1.1.7(@types/react@18.2.66)(dexie@4.0.7)(react@18.2.0) + dompurify: + specifier: ^3.1.7 + version: 3.1.7 emoji-mart: specifier: ^5.5.2 version: 5.6.0 @@ -295,6 +301,9 @@ devDependencies: '@types/cypress-image-snapshot': specifier: ^3.1.9 version: 3.1.9 + '@types/dompurify': + specifier: ^3.0.5 + version: 3.0.5 '@types/google-protobuf': specifier: ^3.15.12 version: 3.15.12 @@ -2694,10 +2703,38 @@ packages: react-dom: 18.2.0(react@18.2.0) dev: false + /@floating-ui/react-dom@2.1.2(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-06okr5cgPzMNBy+Ycse2A6udMi4bqwW/zgBF/rwjcNqWkyr82Mcg8b0vjX8OJpZFy/FKjJmw6wV7t44kK6kW7A==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + dependencies: + '@floating-ui/dom': 1.6.5 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + + /@floating-ui/react@0.26.27(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-jLP72x0Kr2CgY6eTYi/ra3VA9LOkTo4C+DUTrbFgFOExKy3omYVmwMjNKqxAHdsnyLS96BIDLcO2SlnsNf8KUQ==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + dependencies: + '@floating-ui/react-dom': 2.1.2(react-dom@18.2.0)(react@18.2.0) + '@floating-ui/utils': 0.2.8 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + tabbable: 6.2.0 + dev: false + /@floating-ui/utils@0.2.2: resolution: {integrity: sha512-J4yDIIthosAsRZ5CPYP/jQvUAQtlZTTD/4suA08/FEnlxqW3sKS9iAhgsa9VYLZ6vDHn/ixJgIqRQPotoBjxIw==} dev: false + /@floating-ui/utils@0.2.8: + resolution: {integrity: sha512-kym7SodPp8/wloecOpcmSnWJsK7M0E5Wg8UcFA+uO4B9s5d0ywXOEro/8HM9x0rW+TljRzul/14UYz3TleT3ig==} + dev: false + /@humanwhocodes/config-array@0.11.14: resolution: {integrity: sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==} engines: {node: '>=10.10.0'} @@ -4116,6 +4153,12 @@ packages: resolution: {integrity: sha512-p9eZ2X9B80iKiTW4ukVj8B4K6q9/+xFtQ5MGYA5HWToY9nL4EkhV9+6ftT2VHpVMEZb5Tv00Iel516bVdO+yRw==} dev: true + /@types/dompurify@3.0.5: + resolution: {integrity: sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==} + dependencies: + '@types/trusted-types': 2.0.7 + dev: true + /@types/eslint-scope@3.7.7: resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==} dependencies: @@ -4378,6 +4421,10 @@ packages: resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} dev: true + /@types/trusted-types@2.0.7: + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + dev: true + /@types/unist@3.0.3: resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} dev: false @@ -4709,6 +4756,7 @@ packages: /acorn-import-assertions@1.9.0(acorn@8.11.3): resolution: {integrity: sha512-cmMwop9x+8KFhxvKrKfPYmN6/pKTYYHBqLa0DfvVZcKMJWNyWLnaqND7dx/qn66R7ewM1UX5XMaDVP5wlVTaVA==} + deprecated: package has been renamed to acorn-import-attributes peerDependencies: acorn: ^8 dependencies: @@ -6265,6 +6313,10 @@ packages: domelementtype: 2.3.0 dev: true + /dompurify@3.1.7: + resolution: {integrity: sha512-VaTstWtsneJY8xzy7DekmYWEOZcmzIe3Qb3zPd4STve1OBTa+e+WmS1ITQec1fZYXI3HCsOZZiSMpG6oxoWMWQ==} + dev: false + /domutils@2.8.0: resolution: {integrity: sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==} dependencies: @@ -11441,6 +11493,10 @@ packages: resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} dev: true + /tabbable@6.2.0: + resolution: {integrity: sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==} + dev: false + /tailwindcss@3.2.7(postcss@8.4.21): resolution: {integrity: sha512-B6DLqJzc21x7wntlH/GsZwEXTBttVSl1FtCzC8WP4oBc/NKef7kaax5jeihkkCEWc831/5NDJ9gRNDK6NEioQQ==} engines: {node: '>=12.13.0'} diff --git a/frontend/appflowy_web_app/src/application/database-yjs/context.ts b/frontend/appflowy_web_app/src/application/database-yjs/context.ts index 41ca2374931fe..9c036f5b0ff3b 100644 --- a/frontend/appflowy_web_app/src/application/database-yjs/context.ts +++ b/frontend/appflowy_web_app/src/application/database-yjs/context.ts @@ -17,11 +17,14 @@ export interface DatabaseContextState { viewId: string; rowDocMap: Record | null; isDatabaseRowPage?: boolean; + scrollLeft?: number; + isDocumentBlock?: boolean; navigateToRow?: (rowId: string) => void; loadView?: LoadView; createRowDoc?: CreateRowDoc; loadViewMeta?: LoadViewMeta; navigateToView?: (viewId: string, blockId?: string) => Promise; + onRendered?: (height: number) => void; } export const DatabaseContext = createContext(null); @@ -69,7 +72,7 @@ export const useViewId = () => { export const useReadOnly = () => { const context = useContext(DatabaseContext); - return context?.readOnly; + return context?.readOnly || true; }; export const useDatabaseView = () => { diff --git a/frontend/appflowy_web_app/src/application/services/js-services/http/http_api.ts b/frontend/appflowy_web_app/src/application/services/js-services/http/http_api.ts index f1e8799d19b55..43af091fa305c 100644 --- a/frontend/appflowy_web_app/src/application/services/js-services/http/http_api.ts +++ b/frontend/appflowy_web_app/src/application/services/js-services/http/http_api.ts @@ -14,7 +14,7 @@ import { Subscriptions, SubscriptionPlan, SubscriptionInterval, - RequestAccessInfoStatus, ViewInfo, + RequestAccessInfoStatus, ViewInfo, UpdatePagePayload, CreatePagePayload, CreateSpacePayload, UpdateSpacePayload, } from '@/application/types'; import { GlobalComment, Reaction } from '@/application/comment.type'; import { initGrantService, refreshToken } from '@/application/services/js-services/http/gotrue'; @@ -30,6 +30,7 @@ import { } from '@/application/template.type'; import axios, { AxiosInstance } from 'axios'; import dayjs from 'dayjs'; +import { omit } from 'lodash-es'; export * from './gotrue'; @@ -1007,7 +1008,7 @@ export async function deleteTemplateCreator (creatorId: string) { return Promise.reject(response?.data.message); } -export async function uploadFileToCDN (file: File) { +export async function uploadTemplateAvatar (file: File) { const url = '/api/template-center/avatar'; const formData = new FormData(); @@ -1236,3 +1237,131 @@ export async function uploadImportFile (presignedUrl: string, file: File, onProg }); } +export async function addAppPage (workspaceId: string, parentViewId: string, { + layout, + name, +}: CreatePagePayload) { + const url = `/api/workspace/${workspaceId}/page-view`; + const response = await axiosInstance?.post<{ + code: number; + data: { + view_id: string; + }; + message: string; + }>(url, { + parent_view_id: parentViewId, + layout, + name, + }); + + if (response?.data.code === 0) { + return response?.data.data.view_id; + } + + return Promise.reject(response?.data); +} + +export async function updatePage (workspaceId: string, viewId: string, data: UpdatePagePayload) { + const url = `/api/workspace/${workspaceId}/page-view/${viewId}`; + + const res = await axiosInstance?.patch<{ + code: number; + message: string; + }>(url, data); + + if (res?.data.code === 0) { + return; + } + + return Promise.reject(res?.data); +} + +export async function deleteTrash (workspaceId: string, viewId?: string) { + const url = `/api/workspace/${workspaceId}/page-view/trash/${viewId}`; + const response = await axiosInstance?.delete<{ + code: number; + message: string; + }>(url); + + if (response?.data.code === 0) { + return; + } + + return Promise.reject(response?.data); +} + +export async function moveToTrash (workspaceId: string, viewId: string) { + const url = `/api/workspace/${workspaceId}/page-view/${viewId}/move-to-trash`; + const response = await axiosInstance?.post<{ + code: number; + message: string; + }>(url); + + if (response?.data.code === 0) { + return; + } + + return Promise.reject(response?.data); +} + +export async function movePageTo (workspaceId: string, viewId: string, parentViewId: string) { + const url = `/api/workspace/${workspaceId}/page-view/${viewId}/move`; + const response = await axiosInstance?.post<{ + code: number; + message: string; + }>(url, { + parent_view_id: parentViewId, + }); + + if (response?.data.code === 0) { + return; + } + + return Promise.reject(response?.data); +} + +export async function restorePage (workspaceId: string, viewId?: string) { + const url = viewId ? `/api/workspace/${workspaceId}/page-view/${viewId}/restore-from-trash` : `/api/workspace/${workspaceId}/restore-all-pages-from-trash`; + const response = await axiosInstance?.post<{ + code: number; + message: string; + }>(url); + + if (response?.data.code === 0) { + return; + } + + return Promise.reject(response?.data); +} + +export async function createSpace (workspaceId: string, payload: CreateSpacePayload) { + const url = `/api/workspace/${workspaceId}/space`; + const response = await axiosInstance?.post<{ + code: number; + data: { + view_id: string; + }; + message: string; + }>(url, payload); + + if (response?.data.code === 0) { + return response?.data.data.view_id; + } + + return Promise.reject(response?.data); +} + +export async function updateSpace (workspaceId: string, payload: UpdateSpacePayload) { + const url = `/api/workspace/${workspaceId}/space/${payload.view_id}`; + const data = omit(payload, ['view_id']); + const response = await axiosInstance?.patch<{ + code: number; + message: string; + }>(url, data); + + if (response?.data.code === 0) { + return; + } + + return Promise.reject(response?.data); +} \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/application/services/js-services/index.ts b/frontend/appflowy_web_app/src/application/services/js-services/index.ts index 7f2a91aa14826..d1f996fdd11af 100644 --- a/frontend/appflowy_web_app/src/application/services/js-services/index.ts +++ b/frontend/appflowy_web_app/src/application/services/js-services/index.ts @@ -29,10 +29,11 @@ import { UploadTemplatePayload, } from '@/application/template.type'; import { + CreatePagePayload, CreateSpacePayload, DatabaseRelations, DuplicatePublishView, SubscriptionInterval, SubscriptionPlan, - Types, + Types, UpdatePagePayload, UpdateSpacePayload, YjsEditorKey, } from '@/application/types'; import { applyYDoc } from '@/application/ydoc/apply'; @@ -380,8 +381,8 @@ export class AFClientService implements AFService { return APIService.deleteTemplateCreator(creatorId); } - async uploadFileToCDN (file: File) { - return APIService.uploadFileToCDN(file); + async uploadTemplateAvatar (file: File) { + return APIService.uploadTemplateAvatar(file); } async getPageDoc (workspaceId: string, viewId: string, errorCallback?: (error: { @@ -479,4 +480,36 @@ export class AFClientService implements AFService { await APIService.uploadImportFile(task.presignedUrl, file, onProgress); } + + async createSpace (workspaceId: string, payload: CreateSpacePayload) { + return APIService.createSpace(workspaceId, payload); + } + + async updateSpace (workspaceId: string, payload: UpdateSpacePayload) { + return APIService.updateSpace(workspaceId, payload); + } + + async addAppPage (workspaceId: string, parentViewId: string, payload: CreatePagePayload) { + return APIService.addAppPage(workspaceId, parentViewId, payload); + } + + async updateAppPage (workspaceId: string, viewId: string, data: UpdatePagePayload) { + return APIService.updatePage(workspaceId, viewId, data); + } + + async deleteTrash (workspaceId: string, viewId?: string) { + return APIService.deleteTrash(workspaceId, viewId); + } + + async moveToTrash (workspaceId: string, viewId: string) { + return APIService.moveToTrash(workspaceId, viewId); + } + + async restoreFromTrash (workspaceId: string, viewId?: string) { + return APIService.restorePage(workspaceId, viewId); + } + + async movePage (workspaceId: string, viewId: string, parentId: string) { + return APIService.movePageTo(workspaceId, viewId, parentId); + } } diff --git a/frontend/appflowy_web_app/src/application/services/services.type.ts b/frontend/appflowy_web_app/src/application/services/services.type.ts index b4933bd87ee37..f8b90101793a4 100644 --- a/frontend/appflowy_web_app/src/application/services/services.type.ts +++ b/frontend/appflowy_web_app/src/application/services/services.type.ts @@ -6,7 +6,14 @@ import { UserWorkspaceInfo, View, Workspace, - YDoc, DatabaseRelations, GetRequestAccessInfoResponse, Subscriptions, SubscriptionPlan, SubscriptionInterval, Types, + YDoc, + DatabaseRelations, + GetRequestAccessInfoResponse, + Subscriptions, + SubscriptionPlan, + SubscriptionInterval, + Types, + UpdatePagePayload, CreatePagePayload, CreateSpacePayload, UpdateSpacePayload, } from '@/application/types'; import { GlobalComment, Reaction } from '@/application/comment.type'; import { ViewMeta } from '@/application/db/tables/view_metas'; @@ -55,7 +62,7 @@ export interface AppService { getWorkspaceFolder: (workspaceId: string) => Promise; getCurrentUser: () => Promise; getUserWorkspaceInfo: () => Promise; - uploadFileToCDN: (file: File) => Promise; + uploadTemplateAvatar: (file: File) => Promise; getInvitation: (invitationId: string) => Promise; acceptInvitation: (invitationId: string) => Promise; getRequestAccessInfo: (requestId: string) => Promise; @@ -68,6 +75,14 @@ export interface AppService { workspaceId: string, objectId: string, collabType: Types }) => void; importFile: (file: File, onProgress: (progress: number) => void) => Promise; + createSpace: (workspaceId: string, payload: CreateSpacePayload) => Promise; + updateSpace: (workspaceId: string, payload: UpdateSpacePayload) => Promise; + addAppPage: (workspaceId: string, parentViewId: string, payload: CreatePagePayload) => Promise; + updateAppPage: (workspaceId: string, viewId: string, data: UpdatePagePayload) => Promise; + deleteTrash: (workspaceId: string, viewId?: string) => Promise; + moveToTrash: (workspaceId: string, viewId: string) => Promise; + restoreFromTrash: (workspaceId: string, viewId?: string) => Promise; + movePage: (workspaceId: string, viewId: string, parentId: string) => Promise; } export interface TemplateService { diff --git a/frontend/appflowy_web_app/src/application/services/tauri-services/index.ts b/frontend/appflowy_web_app/src/application/services/tauri-services/index.ts index 740a25eef75cf..fe9fafe6f411f 100644 --- a/frontend/appflowy_web_app/src/application/services/tauri-services/index.ts +++ b/frontend/appflowy_web_app/src/application/services/tauri-services/index.ts @@ -170,7 +170,7 @@ export class AFClientService implements AFService { return Promise.reject('Method not implemented'); } - uploadFileToCDN (_file: File): Promise { + uploadTemplateAvatar (_file: File): Promise { return Promise.resolve(''); } @@ -258,11 +258,43 @@ export class AFClientService implements AFService { return Promise.reject('Method not implemented'); } - registerDocUpdate (): void { + registerDocUpdate () { throw new Error('Method not implemented.'); } importFile (_file: File, _onProgress: (progress: number) => void) { return Promise.reject('Method not implemented'); } + + addAppPage (): Promise { + return Promise.reject('Method not implemented'); + } + + deleteTrash (): Promise { + return Promise.reject('Method not implemented'); + } + + movePage (): Promise { + return Promise.reject('Method not implemented'); + } + + moveToTrash (): Promise { + return Promise.reject('Method not implemented'); + } + + restoreFromTrash (): Promise { + return Promise.reject('Method not implemented'); + } + + updateAppPage (): Promise { + return Promise.reject('Method not implemented'); + } + + createSpace (): Promise { + return Promise.reject(''); + } + + updateSpace (): Promise { + return Promise.reject(undefined); + } } diff --git a/frontend/appflowy_web_app/src/application/slate-yjs/command/const.ts b/frontend/appflowy_web_app/src/application/slate-yjs/command/const.ts index 2c05c7b6cab9a..093aaee179e90 100644 --- a/frontend/appflowy_web_app/src/application/slate-yjs/command/const.ts +++ b/frontend/appflowy_web_app/src/application/slate-yjs/command/const.ts @@ -3,6 +3,8 @@ import { BlockType } from '@/application/types'; /** List block types */ export const ListBlockTypes = [BlockType.TodoListBlock, BlockType.BulletedListBlock, BlockType.NumberedListBlock]; +export const TOGGLE_BLOCK_TYPES = [BlockType.ToggleListBlock, BlockType.QuoteBlock, BlockType.CalloutBlock]; + /** Container block types */ export const CONTAINER_BLOCK_TYPES = [ BlockType.ToggleListBlock, @@ -12,5 +14,10 @@ export const CONTAINER_BLOCK_TYPES = [ BlockType.BulletedListBlock, BlockType.NumberedListBlock, BlockType.Page, + BlockType.CalloutBlock, ]; -export const SOFT_BREAK_TYPES = [BlockType.CalloutBlock, BlockType.CodeBlock]; \ No newline at end of file +export const SOFT_BREAK_TYPES = [BlockType.CodeBlock]; + +export const isEmbedBlockTypes = (type: BlockType) => { + return ![...ListBlockTypes, ...CONTAINER_BLOCK_TYPES, ...SOFT_BREAK_TYPES, BlockType.HeadingBlock].includes(type); +}; \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/application/slate-yjs/command/index.ts b/frontend/appflowy_web_app/src/application/slate-yjs/command/index.ts index a50b60471e177..d151610e2525b 100644 --- a/frontend/appflowy_web_app/src/application/slate-yjs/command/index.ts +++ b/frontend/appflowy_web_app/src/application/slate-yjs/command/index.ts @@ -1,13 +1,23 @@ import { YjsEditor } from '@/application/slate-yjs/plugins/withYjs'; import { EditorMarkFormat } from '@/application/slate-yjs/types'; -import { findIndentPath, findLiftPath } from '@/application/slate-yjs/utils/slateUtils'; +import { + beforePasted, + findSlateEntryByBlockId, +} from '@/application/slate-yjs/utils/slateUtils'; import { + addBlock, dataStringTOJson, - executeOperations, findSlateEntryByBlockId, getAffectedBlocks, + deepCopyBlock, + deleteBlock, + executeOperations, + getAffectedBlocks, getBlock, getBlockEntry, - getSelectionOrThrow, getSelectionTexts, + getBlockIndex, + getParent, getPreviousSiblingBlock, + getSelectionOrThrow, + getSelectionTexts, getSharedRoot, handleCollapsedBreakWithTxn, handleDeleteEntireDocumentWithTxn, @@ -15,21 +25,27 @@ import { handleMergeBlockBackwardWithTxn, handleMergeBlockForwardWithTxn, handleNonParagraphBlockBackspaceAndEnterWithTxn, - handleRangeBreak, indentBlock, liftBlock, preventIndentNode, preventLiftNode, + handleRangeBreak, + indentBlock, + liftBlock, + preventIndentNode, + preventLiftNode, removeRangeWithTxn, turnToBlock, + updateBlockParent, } from '@/application/slate-yjs/utils/yjsOperations'; import { BlockData, - BlockType, + BlockType, Mention, MentionType, TodoListBlockData, ToggleListBlockData, YjsEditorKey, } from '@/application/types'; +import { EditorInlineAttributes } from '@/slate-editor'; import { renderDate } from '@/utils/time'; import isEqual from 'lodash-es/isEqual'; -import { BaseRange, Editor, Element, Node, NodeEntry, Path, Range, Text, Transforms } from 'slate'; +import { BasePoint, BaseRange, Editor, Element, Node, NodeEntry, Path, Range, Text, Transforms } from 'slate'; import { ReactEditor } from 'slate-react'; export const CustomEditor = { @@ -148,12 +164,12 @@ export const CustomEditor = { const block = getBlock(node.blockId as string, sharedRoot); const blockType = block.get(YjsEditorKey.block_type) as BlockType; - if (blockType !== BlockType.Paragraph) { - handleNonParagraphBlockBackspaceAndEnterWithTxn(editor, sharedRoot, block, point); + if (path.length > 1 && handleLiftBlockOnBackspaceAndEnterWithTxn(editor, sharedRoot, block, point)) { return; } - if (path.length > 1 && handleLiftBlockOnBackspaceAndEnterWithTxn(editor, sharedRoot, block, point)) { + if (blockType !== BlockType.Paragraph) { + handleNonParagraphBlockBackspaceAndEnterWithTxn(editor, sharedRoot, block, point); return; } @@ -214,6 +230,9 @@ export const CustomEditor = { const endBlockPath = endNode[1]; const startAtPath = point.path.slice(startBlockPath.length); const startAtOffset = point.offset; + const isAncestor = Path.isAncestor(startBlockPath, endBlockPath); + const endRelativeToStart = endBlockPath.slice(startBlockPath.length); + const endAtPath = endPoint.path.slice(endBlockPath.length); const endAtOffset = endPoint.offset; let newStartBlockPath: Path = []; @@ -221,6 +240,7 @@ export const CustomEditor = { const isSameBlock = node[0].blockId === endNode[0].blockId; + editor.deselect(); if (isSameBlock) { const block = getBlock(node[0].blockId as string, sharedRoot); let newBlockId: string | undefined; @@ -254,9 +274,10 @@ export const CustomEditor = { }); if (newBlockIds.length === 0) return; const newStartBlockEntry = findSlateEntryByBlockId(editor, newBlockIds[0]); + const newEndBlockEntry = findSlateEntryByBlockId(editor, newBlockIds[newBlockIds.length - 1]); newStartBlockPath = newStartBlockEntry[1]; - newEndBlockPath = type === 'tabForward' ? findIndentPath(startBlockPath, endBlockPath, newStartBlockPath) : findLiftPath(startBlockPath, endBlockPath, newStartBlockPath); + newEndBlockPath = isAncestor ? [...newStartBlockPath, ...endRelativeToStart] : newEndBlockEntry[1]; } const newStartPath = [...newStartBlockPath, ...startAtPath]; @@ -279,28 +300,57 @@ export const CustomEditor = { const data = dataStringTOJson(getBlock(blockId, sharedRoot).get(YjsEditorKey.block_data)) as ToggleListBlockData; const { selection } = editor; - if (!selection) return; - - if (Range.isExpanded(selection)) { + if (selection && Range.isExpanded(selection)) { Transforms.collapse(editor, { edge: 'start' }); } - const point = Editor.start(editor, selection); + let selected = false; + + if (selection) { + const point = Editor.start(editor, selection); - const [node] = getBlockEntry(editor, point); + const [node] = getBlockEntry(editor, point); + + selected = node.blockId !== blockId; + } CustomEditor.setBlockData(editor, blockId, { collapsed: !data.collapsed, - }, node.blockId !== blockId); + }, selected); }, - toggleTodoList (editor: YjsEditor, blockId: string) { + toggleTodoList (editor: YjsEditor, blockId: string, shiftKey: boolean) { const sharedRoot = getSharedRoot(editor); - const data = dataStringTOJson(getBlock(blockId, sharedRoot).get(YjsEditorKey.block_data)) as TodoListBlockData; + const block = getBlock(blockId, sharedRoot); + const data = dataStringTOJson(block.get(YjsEditorKey.block_data)) as TodoListBlockData; + const checked = data.checked; + + if (!shiftKey) { + CustomEditor.setBlockData(editor, blockId, { + checked: !checked, + }, false); + return; + } - CustomEditor.setBlockData(editor, blockId, { - checked: !data.checked, - }, false); + const [, path] = findSlateEntryByBlockId(editor, blockId); + const [start, end] = editor.edges(path); + + const toggleBlockNodes = Array.from( + Editor.nodes(editor, { + at: { + anchor: start, + focus: end, + }, + match: (n) => !Editor.isEditor(n) && Element.isElement(n) && n.type === BlockType.TodoListBlock, + }), + ) as unknown as NodeEntry[]; + + toggleBlockNodes.forEach(([node]) => { + + CustomEditor.setBlockData(editor, node.blockId as string, { + checked: !checked, + }, false); + }); }, toggleMark (editor: ReactEditor, { @@ -318,7 +368,7 @@ export const CustomEditor = { addMark (editor: ReactEditor, { key, value, }: { - key: EditorMarkFormat, value: boolean | string + key: EditorMarkFormat, value: boolean | string | Mention }) { editor.addMark(key, value); }, @@ -338,11 +388,14 @@ export const CustomEditor = { return; } + let newBlockId: string | undefined; + operations.push(() => { - turnToBlock(sharedRoot, sourceBlock, type, data); + newBlockId = turnToBlock(sharedRoot, sourceBlock, type, data); }); executeOperations(sharedRoot, operations, 'turnToBlock'); + return newBlockId; }, isBlockActive (editor: YjsEditor, type: BlockType) { @@ -379,6 +432,29 @@ export const CustomEditor = { return marks ? !!marks[key] : false; }, + getAllMarks (editor: ReactEditor) { + const selection = editor.selection; + + if (!selection) return []; + + const isExpanded = Range.isExpanded(selection); + + if (isExpanded) { + const texts = getSelectionTexts(editor); + + return texts.map((node) => { + const { text, ...attributes } = node; + + if (!text) return {}; + return attributes as EditorInlineAttributes; + }); + } + + const marks = Editor.marks(editor) as EditorInlineAttributes; + + return [marks]; + }, + isMarkActive (editor: ReactEditor, key: string) { const selection = editor.selection; @@ -402,4 +478,157 @@ export const CustomEditor = { return marks ? !!marks[key] : false; }, + + addBlock (editor: YjsEditor, blockId: string, direction: 'below' | 'above', type: BlockType, data: BlockData) { + const parent = getParent(blockId, editor.sharedRoot); + const index = getBlockIndex(blockId, editor.sharedRoot); + + if (!parent) return; + + const newBlockId = addBlock(editor, { + ty: type, + data, + }, parent, direction === 'below' ? index + 1 : index); + + if (!newBlockId) { + return; + } + + try { + const [, path] = findSlateEntryByBlockId(editor, newBlockId); + + if (path) { + ReactEditor.focus(editor); + const point = editor.start(path); + + Transforms.select(editor, point); + return newBlockId; + } + } catch (e) { + console.error(e); + } + }, + + addBelowBlock (editor: YjsEditor, blockId: string, type: BlockType, data: BlockData) { + return CustomEditor.addBlock(editor, blockId, 'below', type, data); + }, + + addAboveBlock (editor: YjsEditor, blockId: string, type: BlockType, data: BlockData) { + return CustomEditor.addBlock(editor, blockId, 'above', type, data); + }, + + deleteBlock (editor: YjsEditor, blockId: string) { + const sharedRoot = getSharedRoot(editor); + const parent = getParent(blockId, sharedRoot); + + if (!parent) { + console.warn('Parent block not found'); + return; + } + + try { + const prevBlockId = getPreviousSiblingBlock(sharedRoot, getBlock(blockId, sharedRoot)); + let point: BasePoint | undefined; + + if (!prevBlockId) { + if (parent.get(YjsEditorKey.block_type) !== BlockType.Page) { + const [, path] = findSlateEntryByBlockId(editor, parent.get(YjsEditorKey.block_id)); + + point = editor.start(path); + } + } else { + const [, path] = findSlateEntryByBlockId(editor, prevBlockId); + + point = editor.end(path); + } + + if (point && ReactEditor.hasRange(editor, { + anchor: point, + focus: point, + })) { + Transforms.select(editor, point); + } else { + Transforms.deselect(editor); + } + + } catch (e) { + // do nothing + } + + executeOperations(sharedRoot, [() => { + deleteBlock(sharedRoot, blockId); + }], 'deleteBlock'); + const children = editor.children; + + if (children.length === 0) { + addBlock(editor, { + ty: BlockType.Paragraph, + data: {}, + }, parent, 0); + } + + ReactEditor.focus(editor); + }, + + duplicateBlock (editor: YjsEditor, blockId: string, prevId?: string) { + const sharedRoot = getSharedRoot(editor); + const block = getBlock(blockId, sharedRoot); + + const parent = getParent(blockId, sharedRoot); + const prevIndex = getBlockIndex(prevId || blockId, sharedRoot); + + if (!parent) { + console.warn('Parent block not found'); + return; + } + + let newBlockId: string | null = null; + + executeOperations(sharedRoot, [() => { + newBlockId = deepCopyBlock(sharedRoot, block); + + if (!newBlockId) { + console.warn('Copied block not found'); + return; + } + + const copiedBlock = getBlock(newBlockId, sharedRoot); + + updateBlockParent(sharedRoot, copiedBlock, parent, prevIndex + 1); + }], 'duplicateBlock'); + + return newBlockId; + }, + + pastedText (editor: YjsEditor, text: string) { + if (!beforePasted(editor)) + return; + + const point = editor.selection?.anchor as BasePoint; + + Transforms.insertNodes(editor, { text }, { at: point, select: true, voids: false }); + }, + + highlight (editor: ReactEditor) { + const selection = editor.selection; + + if (!selection) return; + + const [start, end] = Range.edges(selection); + + if (isEqual(start, end)) return; + + const marks = CustomEditor.getAllMarks(editor); + + marks.forEach((mark) => { + if (mark[EditorMarkFormat.BgColor]) { + CustomEditor.removeMark(editor, EditorMarkFormat.BgColor); + } else { + CustomEditor.addMark(editor, { + key: EditorMarkFormat.BgColor, + value: '#ffeb3b', + }); + } + }); + }, }; diff --git a/frontend/appflowy_web_app/src/application/slate-yjs/plugins/withYjs.ts b/frontend/appflowy_web_app/src/application/slate-yjs/plugins/withYjs.ts index e1c391cb8af1b..1df8891ff03b9 100644 --- a/frontend/appflowy_web_app/src/application/slate-yjs/plugins/withYjs.ts +++ b/frontend/appflowy_web_app/src/application/slate-yjs/plugins/withYjs.ts @@ -144,13 +144,7 @@ export function withYjs ( }); if (selection) { if (!ReactEditor.hasRange(editor, selection)) { - try { - Transforms.select(e, Editor.start(editor, [0])); - - } catch (e) { - console.error(e); - editor.deselect(); - } + editor.deselect(); } else { e.select(selection); } diff --git a/frontend/appflowy_web_app/src/application/slate-yjs/utils/applyToSlate.ts b/frontend/appflowy_web_app/src/application/slate-yjs/utils/applyToSlate.ts index 2df8d066f6019..84a3223edc5e3 100644 --- a/frontend/appflowy_web_app/src/application/slate-yjs/utils/applyToSlate.ts +++ b/frontend/appflowy_web_app/src/application/slate-yjs/utils/applyToSlate.ts @@ -1,6 +1,7 @@ import { YjsEditor } from '@/application/slate-yjs'; import { BlockJson } from '@/application/slate-yjs/types'; import { blockToSlateNode, deltaInsertToSlateNode } from '@/application/slate-yjs/utils/convert'; +import { findSlateEntryByBlockId } from '@/application/slate-yjs/utils/slateUtils'; import { dataStringTOJson, getBlock, @@ -45,10 +46,7 @@ function applyUpdateBlockYEvent (editor: YjsEditor, blockId: string, event: YMap const { target } = event; const block = target as YBlock; const newData = dataStringTOJson(block.get(YjsEditorKey.block_data)); - const [entry] = editor.nodes({ - match: (n) => !Editor.isEditor(n) && Element.isElement(n) && n.blockId === blockId, - mode: 'all', - }); + const entry = findSlateEntryByBlockId(editor, blockId); if (!entry) { console.error('Block node not found', blockId); @@ -79,8 +77,10 @@ function applyTextYEvent (editor: YjsEditor, textId: string, event: YTextEvent) const [entry] = editor.nodes({ match: (n) => !Editor.isEditor(n) && Element.isElement(n) && n.textId === textId, mode: 'all', + at: [], }); + console.log('=== Applying text Yjs event ===', entry); if (!entry) { console.error('Text node not found', textId); return []; @@ -137,20 +137,25 @@ function handleNewBlock (editor: YjsEditor, key: string, keyPath: Record !Editor.isEditor(n) && Element.isElement(n) && n.textId !== undefined, }); - const [node] = entry as NodeEntry; + if (!textEntry) { + const [entry] = editor.nodes({ + at: point, + match: (n) => !Editor.isEditor(n) && Element.isElement(n) && n.blockId !== undefined, + }); + + return { + point: Y.createRelativePositionFromTypeIndex(sharedRoot, 0), + entry: entry as NodeEntry, + }; + + } + + const [node] = textEntry as NodeEntry; if (!node) { throw new Error('Node not found'); @@ -58,7 +72,11 @@ export function slatePointToRelativePosition ( const textId = node.textId as string; let ytext = getText(textId, sharedRoot); - if (!ytext) { + if (!ytext && [ + ...CONTAINER_BLOCK_TYPES, + ...SOFT_BREAK_TYPES, + BlockType.HeadingBlock, + ].includes(node.type as BlockType)) { const newYText = new Y.Text(); const textMap = getTextMap(sharedRoot); const ops = (node.children as Text[]).map(slateNodeToDeltaInsert); @@ -68,14 +86,22 @@ export function slatePointToRelativePosition ( ytext = newYText; } - const offset = Math.min(calculateOffsetRelativeToParent(node, point), ytext.length); + if (ytext) { + const offset = Math.min(calculateOffsetRelativeToParent(node, point), ytext.length); - const relPos = Y.createRelativePositionFromTypeIndex(ytext, offset); + const relPos = Y.createRelativePositionFromTypeIndex(ytext, offset); + + return { + point: relPos, + entry: textEntry as NodeEntry, + }; + } return { - point: relPos, - entry: entry as NodeEntry, + point: Y.createRelativePositionFromTypeIndex(sharedRoot, 0), + entry: textEntry as NodeEntry, }; + } export function calculateOffsetRelativeToParent (slateNode: Element, point: BasePoint): number { diff --git a/frontend/appflowy_web_app/src/application/slate-yjs/utils/slateUtils.tsx b/frontend/appflowy_web_app/src/application/slate-yjs/utils/slateUtils.tsx index 3cb983591a472..9d95f65cab0e7 100644 --- a/frontend/appflowy_web_app/src/application/slate-yjs/utils/slateUtils.tsx +++ b/frontend/appflowy_web_app/src/application/slate-yjs/utils/slateUtils.tsx @@ -1,26 +1,142 @@ -import { Path } from 'slate'; +import { Editor, Element, NodeEntry, Path, Range, Transforms, Node, Point, BasePoint } from 'slate'; +import { ReactEditor } from 'slate-react'; -export function findIndentPath (originalStart: Path, originalEnd: Path, newStart: Path): Path { - // Find the common ancestor path - const commonPath = Path.common(originalStart, originalEnd); +export function findSlateEntryByBlockId (editor: Editor, blockId: string) { + const [node] = Editor.nodes(editor, { + match: (n) => !Editor.isEditor(n) && Element.isElement(n) && n.blockId === blockId, + at: [], + mode: 'all', + voids: true, + }); - // Calculate end's path relative to common ancestor - const endRelativePath = originalEnd.slice(commonPath.length); + return node as NodeEntry; +} + +export function beforePasted (editor: ReactEditor) { + const { selection } = editor; + + if (!selection) { + return false; + } + + if (Range.isExpanded(selection)) { + Transforms.collapse(editor, { edge: 'start' }); - // Calculate new common ancestor path by maintaining the same level difference - const startToCommonLevels = originalStart.length - commonPath.length; - const newCommonAncestor = newStart.slice(0, newStart.length - startToCommonLevels); + editor.delete({ + at: selection, + }); + } - // Append the relative path to new common ancestor - return [...newCommonAncestor, ...endRelativePath]; + return true; } -export function findLiftPath (originalStart: Path, originalEnd: Path, newStart: Path): Path { - // Same logic as findIndentPath - const commonPath = Path.common(originalStart, originalEnd); - const endRelativePath = originalEnd.slice(commonPath.length); - const startToCommonLevels = originalStart.length - commonPath.length; - const newCommonAncestor = newStart.slice(0, newStart.length - startToCommonLevels); +export function getSelectedPaths (editor: ReactEditor) { + const { selection } = editor; + + if (!selection) { + return null; + } + + const [start, end] = Range.edges(selection); + const startEntry = editor.above({ + at: start, + match: n => !Editor.isEditor(n) && Element.isElement(n) && n.blockId !== undefined, + }) as unknown as NodeEntry | undefined; + + const endEntry = editor.above({ + at: end, + match: n => !Editor.isEditor(n) && Element.isElement(n) && n.blockId !== undefined, + }) as unknown as NodeEntry | undefined; + + const startBlockId = startEntry ? startEntry[0].blockId : undefined; + const endBlockId = endEntry ? endEntry[0].blockId : undefined; + + const blockEntries = Array.from( + Editor.nodes(editor, { + at: selection, + match: (n, path) => { + // It's a block element if it's not the editor itself, it's an element, and it has a blockId + const isBlockElement = !Editor.isEditor(n) && + Element.isElement(n) && + n.blockId !== undefined; + + if (!isBlockElement) return false; + + // Get the range of the block element + const nodeRange = Editor.range(editor, path); + const [anchor, focus] = editor.edges(nodeRange); + + if (n.blockId === startBlockId || n.blockId === endBlockId) { + return true; + } + + const isIntersecting = (anchor: BasePoint, focus: BasePoint) => { + return Point.compare(anchor, start) >= 0 && + Point.compare(focus, end) <= 0; + }; + + // Check if the block element is fully within the selection + if (isIntersecting(anchor, focus)) { + return true; + } else { + // Check if the block element contains a text node that is within the selection + const textNode = (n.children[0] as Element)?.textId !== undefined ? n.children[0] : null; + + if (textNode) { + const [anchor, focus] = editor.edges([...path, 0]); + + return isIntersecting(anchor, focus); + } + } + + return false; + }, + voids: true, + }), + ); + + return blockEntries.map(([, path]) => path); +} + +export function filterValidNodes (editor: ReactEditor, selectedPaths: Path[]): [ + Element, + Path, +][] { + const sortedPaths = selectedPaths.sort((a, b) => + Path.compare(a, b), + ); + + const validPaths = sortedPaths.filter((path, index) => { + // Check if the current path is a child of any previous paths + const isChildOfPrevious = sortedPaths + .slice(0, index) + .some(prevPath => Path.isDescendant(path, prevPath)); + + return !isChildOfPrevious; + }); + + // Get the nodes from the valid paths + return validPaths.map(path => { + const node = Node.get(editor, path); + + return [node, path] as [Element, Path]; + }); +} + +export function sortNodesByDepth (editor: Editor, selectedPaths: Path[]) { + const pathsWithDepth = selectedPaths.map(path => ({ + path, + depth: path.length, + node: Node.get(editor, path), + })); + + return pathsWithDepth.sort((a, b) => { + // use depth to sort + if (b.depth !== a.depth) { + return b.depth - a.depth; + } - return [...newCommonAncestor, ...endRelativePath]; + // if depth is same, use path comparison + return Path.compare(a.path, b.path); + }); } \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/application/slate-yjs/utils/yjsOperations.ts b/frontend/appflowy_web_app/src/application/slate-yjs/utils/yjsOperations.ts index 3bd7c0876364d..b8037945f36f0 100644 --- a/frontend/appflowy_web_app/src/application/slate-yjs/utils/yjsOperations.ts +++ b/frontend/appflowy_web_app/src/application/slate-yjs/utils/yjsOperations.ts @@ -1,4 +1,10 @@ -import { CONTAINER_BLOCK_TYPES, ListBlockTypes } from '@/application/slate-yjs/command/const'; +import { + CONTAINER_BLOCK_TYPES, + isEmbedBlockTypes, + ListBlockTypes, + TOGGLE_BLOCK_TYPES, +} from '@/application/slate-yjs/command/const'; +import { findSlateEntryByBlockId } from '@/application/slate-yjs/utils/slateUtils'; import { BlockData, BlockType, @@ -12,6 +18,7 @@ import { YSharedRoot, YTextMap, } from '@/application/types'; +import { uniq } from 'lodash-es'; import { nanoid } from 'nanoid'; import Delta, { Op } from 'quill-delta'; @@ -122,6 +129,14 @@ export function generateBlockId () { return nanoid(8); } +export function getBlockIndex (blockId: string, sharedRoot: YSharedRoot) { + const block = getBlock(blockId, sharedRoot); + const parent = getBlock(block.get(YjsEditorKey.block_parent), sharedRoot); + const parentChildren = getChildrenArray(parent.get(YjsEditorKey.block_children), sharedRoot); + + return parentChildren.toArray().findIndex((id) => id === blockId); +} + export function createBlock (sharedRoot: YSharedRoot, { ty, data, @@ -135,8 +150,6 @@ export function createBlock (sharedRoot: YSharedRoot, { block.set(YjsEditorKey.block_id, id); block.set(YjsEditorKey.block_type, ty); block.set(YjsEditorKey.block_children, id); - block.set(YjsEditorKey.block_external_id, id); - block.set(YjsEditorKey.block_external_type, 'text'); block.set(YjsEditorKey.block_data, JSON.stringify(data)); const document = getDocument(sharedRoot); @@ -146,10 +159,16 @@ export function createBlock (sharedRoot: YSharedRoot, { 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.set(id, new Y.Array()); - textMap.set(id, new Y.Text()); + + if (!isEmbedBlockTypes(ty)) { + block.set(YjsEditorKey.block_external_id, id); + block.set(YjsEditorKey.block_external_type, 'text'); + const textMap = meta.get(YjsEditorKey.text_map) as YTextMap; + + textMap.set(id, new Y.Text()); + } return block as YBlock; } @@ -166,6 +185,72 @@ export function updateBlockParent (sharedRoot: YSharedRoot, block: YBlock, paren parentChildren.insert(index, [block.get(YjsEditorKey.block_id)]); } +export function ensureBlockText (editor: YjsEditor) { + const { selection } = editor; + + if (!selection) { + return; + } + + const sharedRoot = editor.sharedRoot; + + const [start, end] = editor.edges(selection); + const startNodeEntry = getBlockEntry(editor, start); + const [startNode] = startNodeEntry; + const endNodeEntry = getBlockEntry(editor, end); + const [endNode] = endNodeEntry; + + const startBlockId = startNode.blockId; + + const endBlockId = endNode.blockId; + const startBlockType = startNode.type as BlockType; + const endBlockType = endNode.type as BlockType; + + if (!startBlockId || !endBlockId) { + return; + } + + const compatibleBlocks: string[] = []; + + if (!isEmbedBlockTypes(startBlockType)) { + compatibleBlocks.push(startBlockId); + } + + if (!isEmbedBlockTypes(endBlockType)) { + compatibleBlocks.push(endBlockId); + } + + uniq(compatibleBlocks).forEach((blockId) => { + const block = getBlock(blockId, sharedRoot); + const textId = block.get(YjsEditorKey.block_external_id); + + if (!textId || !getText(textId, sharedRoot)) { + const data = dataStringTOJson(block.get(YjsEditorKey.block_data)) as BlockData; + + compatibleDataDeltaToYText(sharedRoot, data.delta || [], blockId); + } + }); +} + +export function compatibleDataDeltaToYText (sharedRoot: YSharedRoot, ops: Op[], blockId: string) { + const yText = new Y.Text(); + + executeOperations(sharedRoot, [() => { + + yText.applyDelta(ops); + + const block = getBlock(blockId, sharedRoot); + + block.set(YjsEditorKey.block_external_id, blockId); + block.set(YjsEditorKey.block_external_type, YjsEditorKey.text); + const textMap = getTextMap(sharedRoot); + + textMap.set(blockId, yText); + + }], 'compatibleDataDeltaToYText'); + return yText; +} + export function handleCollapsedBreakWithTxn (editor: YjsEditor, sharedRoot: YSharedRoot, at: BaseRange) { const { startBlock, startOffset } = getBreakInfo(editor, sharedRoot, at); const [blockNode, path] = startBlock; @@ -177,19 +262,29 @@ export function handleCollapsedBreakWithTxn (editor: YjsEditor, sharedRoot: YSha } const blockType = block.get(YjsEditorKey.block_type); - const yText = getText(block.get(YjsEditorKey.block_external_id), sharedRoot); + const textId = block.get(YjsEditorKey.block_external_id); + let yText = getText(textId, sharedRoot); + + if (!yText && !isEmbedBlockTypes(blockType)) { + const data = blockNode.data as BlockData; + const delta = new Delta(data.delta || []); + + yText = compatibleDataDeltaToYText(sharedRoot, delta.ops, blockId); + + } if (yText.length === 0) { const point = Editor.start(editor, at); - if (blockType !== BlockType.Paragraph) { - handleNonParagraphBlockBackspaceAndEnterWithTxn(editor, sharedRoot, block, point); + if (path.length > 1 && handleLiftBlockOnBackspaceAndEnterWithTxn(editor, sharedRoot, block, point)) { return; } - if (path.length > 1 && handleLiftBlockOnBackspaceAndEnterWithTxn(editor, sharedRoot, block, point)) { + if (blockType !== BlockType.Paragraph) { + handleNonParagraphBlockBackspaceAndEnterWithTxn(editor, sharedRoot, block, point); return; } + } const { operations, select } = getSplitBlockOperations(sharedRoot, block, startOffset); @@ -202,7 +297,6 @@ export function handleCollapsedBreakWithTxn (editor: YjsEditor, sharedRoot: YSha Transforms.select(editor, Editor.start(editor, at)); } - console.log('handleCollapsedBreakWithTxn', editor.selection); } export function removeRangeWithTxn (editor: YjsEditor, sharedRoot: YSharedRoot, range: Range) { @@ -273,12 +367,17 @@ export function turnToBlock (sharedRoot: YSharedRoot, sourc ty: type, data, }); + const newBlockId = newBlock.get(YjsEditorKey.block_id); - copyBlockText(sharedRoot, sourceBlock, newBlock); + if (!isEmbedBlockTypes(type)) { + copyBlockText(sharedRoot, sourceBlock, newBlock); + } const parent = getBlock(sourceBlock.get(YjsEditorKey.block_parent), sharedRoot); - if (!parent) return; + if (!parent) { + return newBlockId; + } const parentChildren = getChildrenArray(parent.get(YjsEditorKey.block_children), sharedRoot); const index = parentChildren.toArray().findIndex((id) => id === sourceBlock.get(YjsEditorKey.block_id)); @@ -294,34 +393,54 @@ export function turnToBlock (sharedRoot: YSharedRoot, sourc // delete source block deleteBlock(sharedRoot, sourceBlock.get(YjsEditorKey.block_id)); - // turn to toggle heading - if (type === BlockType.ToggleListBlock && (data as unknown as ToggleListBlockData).level) { - const nextSiblings = getNextSiblings(sharedRoot, newBlock); + extendNextSiblingsToToggleHeading(sharedRoot, newBlock); - if (!nextSiblings || nextSiblings.length === 0) return; - // find the next sibling with the same or higher level - const index = nextSiblings.findIndex((id) => { - const block = getBlock(id, sharedRoot); - const blockData = dataStringTOJson(block.get(YjsEditorKey.block_data)); + return newBlockId; +} - if ('level' in blockData && (blockData as { - level: number - }).level <= ((data as unknown as ToggleListBlockData).level as number)) { - return true; - } +function extendNextSiblingsToToggleHeading (sharedRoot: YSharedRoot, block: YBlock) { + const type = block.get(YjsEditorKey.block_type); + const data = dataStringTOJson(block.get(YjsEditorKey.block_data)) as ToggleListBlockData; - return false; - }); + if (type !== BlockType.ToggleListBlock || !data.level) return; - const nodes = index > -1 ? nextSiblings.slice(0, index) : nextSiblings; + const nextSiblings = getNextSiblings(sharedRoot, block); - // if not found, return. Otherwise, indent the block - nodes.forEach((id) => { - const block = getBlock(id, sharedRoot); + if (!nextSiblings || nextSiblings.length === 0) return; + // find the next sibling with the same or higher level + const index = nextSiblings.findIndex((id) => { + const block = getBlock(id, sharedRoot); + const blockData = dataStringTOJson(block.get(YjsEditorKey.block_data)); - indentBlock(sharedRoot, block); - }); - } + if ('level' in blockData && (blockData as { + level: number + }).level <= ((data as unknown as ToggleListBlockData).level as number)) { + return true; + } + + return false; + }); + + const nodes = index > -1 ? nextSiblings.slice(0, index) : nextSiblings; + + // if not found, return. Otherwise, indent the block + nodes.forEach((id) => { + const block = getBlock(id, sharedRoot); + + indentBlock(sharedRoot, block); + }); +} + +export function getPreviousSiblingBlock (sharedRoot: YSharedRoot, block: YBlock) { + const parent = getBlock(block.get(YjsEditorKey.block_parent), sharedRoot); + + if (!parent) return; + + const parentChildren = getChildrenArray(parent.get(YjsEditorKey.block_children), sharedRoot); + const index = parentChildren.toArray().findIndex((id) => id === block.get(YjsEditorKey.block_id)); + + if (index === 0) return null; + return parentChildren.get(index - 1); } function getNextSiblings (sharedRoot: YSharedRoot, block: YBlock) { @@ -404,15 +523,6 @@ function moveToNextLine (editor: Editor, block: YBlock, at: BaseRange, blockId: Transforms.move(editor, { distance: 1, unit: 'line' }); } -export function findSlateEntryByBlockId (editor: Editor, blockId: string) { - const [node] = Editor.nodes(editor, { - match: (n) => !Editor.isEditor(n) && Element.isElement(n) && n.blockId === blockId, - at: [], - }); - - return node; -} - export function getNextSiblingBlockPath (editor: Editor, blockId: string) { const [blockSlateNode] = editor.nodes({ match: (n) => !Editor.isEditor(n) && Element.isElement(n) && n.blockId === blockId, @@ -624,7 +734,15 @@ export function getBreakInfo (editor: YjsEditor, sharedRoot: YSharedRoot, at: Ba throw new Error('Block not found'); } - const yText = getText(startTextId, sharedRoot); + let yText = getText(startTextId, sharedRoot); + + if (!yText) { + const data = startBlock[0].data as BlockData; + const delta = new Delta(data.delta || []); + + yText = compatibleDataDeltaToYText(sharedRoot, delta.ops, startBlock[0].blockId as string); + } + const startOffset = Math.min(startPos.index, yText.length); return { startBlock, startOffset }; @@ -692,7 +810,7 @@ export function splitBlock (sharedRoot: YSharedRoot, block: YBlock, offset: numb const blockType = block.get(YjsEditorKey.block_type); - if (blockType === BlockType.ToggleListBlock || blockType === BlockType.QuoteBlock) { + if (TOGGLE_BLOCK_TYPES.includes(blockType)) { const data = dataStringTOJson(block.get(YjsEditorKey.block_data)) as ToggleListBlockData; if (!data.collapsed) { @@ -881,7 +999,6 @@ export function handleNonParagraphBlockBackspaceAndEnterWithTxn (editor: YjsEdit const operations: (() => void)[] = []; operations.push(() => { - turnToBlock(sharedRoot, block, BlockType.Paragraph, {}); }); executeOperations(sharedRoot, operations, 'turnToBlock'); @@ -1341,4 +1458,64 @@ export function getSlatePointFromOffset (editor: YjsEditor, range: { offset: num const start = calculatePointFromParentOffset(textNode, path, range.offset); return start; +} + +export function getParent (blockId: string, sharedRoot: YSharedRoot) { + const block = getBlock(blockId, sharedRoot); + + if (!block) { + return; + } + + const parentId = block.get(YjsEditorKey.block_parent); + + return getBlock(parentId, sharedRoot); +} + +export function addBlock (editor: YjsEditor, { + ty, + data, +}: { + ty: BlockType, + data: BlockData, +}, parent: YBlock, index: number): string | undefined { + const sharedRoot = getSharedRoot(editor); + const operations: (() => void)[] = []; + + let newBlockId: string | undefined; + + operations.push(() => { + const newBlock = createBlock(sharedRoot, { + ty, + data, + }); + + newBlockId = newBlock.get(YjsEditorKey.block_id); + + updateBlockParent(sharedRoot, newBlock, parent, index); + + extendNextSiblingsToToggleHeading(sharedRoot, newBlock); + }); + + executeOperations(sharedRoot, operations, 'addBlock'); + + return newBlockId; +} + +export function appendFirstEmptyParagraph (sharedRoot: YSharedRoot, defaultText: string) { + const pageId = getPageId(sharedRoot); + const page = getBlock(pageId, sharedRoot); + + executeOperations(sharedRoot, [() => { + const newBlock = createBlock(sharedRoot, { + ty: BlockType.Paragraph, + data: {}, + }); + + const newBlockText = getText(newBlock.get(YjsEditorKey.block_external_id), sharedRoot); + + newBlockText.insert(0, defaultText); + + updateBlockParent(sharedRoot, newBlock, page, 0); + }], 'appendFirstEmptyParagraph'); } \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/application/types.ts b/frontend/appflowy_web_app/src/application/types.ts index e54483894e0e4..2925f2ffe6682 100644 --- a/frontend/appflowy_web_app/src/application/types.ts +++ b/frontend/appflowy_web_app/src/application/types.ts @@ -1,3 +1,5 @@ +import { TextCount } from '@/utils/word'; +import { Op } from 'quill-delta'; import * as Y from 'yjs'; export type BlockId = string; @@ -53,6 +55,7 @@ export interface BlockData { bgColor?: string; font_color?: string; align?: AlignType; + delta?: Op[]; } export interface HeadingBlockData extends BlockData { @@ -94,10 +97,10 @@ export enum FieldURLType { } export interface FileBlockData extends BlockData { - name: string; - uploaded_at: number; - url: string; - url_type: FieldURLType; + name?: string; + uploaded_at?: number; + url?: string; + url_type?: FieldURLType; } export enum ImageType { @@ -779,6 +782,11 @@ export interface ViewIcon { value: string; } +export enum SpacePermission { + Public = 0, + Private = 1, +} + export interface ViewExtra { is_space: boolean; space_created_at?: number; @@ -884,4 +892,68 @@ export interface Subscription { recurring_interval: SubscriptionInterval; } -export type Subscriptions = Subscription[]; \ No newline at end of file +export type Subscriptions = Subscription[]; + +export interface UpdatePagePayload { + name: string; + icon: { + ty: ViewIconType, + value: string, + }; + extra: Partial; +} + +export interface ViewMetaCover { + type: CoverType; + value: string; +} + +export interface ViewMetaProps { + icon?: ViewMetaIcon; + cover?: ViewMetaCover; + name?: string; + viewId?: string; + layout?: ViewLayout; + visibleViewIds?: string[]; + extra?: ViewExtra | null; + readOnly?: boolean; + updatePage?: (viewId: string, data: UpdatePagePayload) => Promise; + onEnter?: (text: string) => void; + maxWidth?: number; +} + +export interface ViewComponentProps { + doc: YDoc; + readOnly: boolean; + navigateToView?: (viewId: string, blockId?: string) => Promise; + loadViewMeta?: LoadViewMeta; + createRowDoc?: CreateRowDoc; + loadView?: LoadView; + viewMeta: ViewMetaProps; + appendBreadcrumb?: AppendBreadcrumb; + onRendered?: () => void; + updatePage?: (viewId: string, data: UpdatePagePayload) => Promise; + addPage?: (parentId: string, payload: CreatePagePayload) => Promise; + deletePage?: (viewId: string) => Promise; + openPageModal?: (viewId: string) => void; + variant?: UIVariant; + isTemplateThumb?: boolean; + loadViews?: (variant?: UIVariant) => Promise; + onWordCountChange?: (viewId: string, props: TextCount) => void; +} + +export interface CreatePagePayload { + layout: ViewLayout; + name?: string; +} + +export interface CreateSpacePayload { + name?: string; + space_icon?: string; + space_icon_color?: string; + space_permission?: SpacePermission, // 0 for public space, 1 for private space +} + +export interface UpdateSpacePayload extends CreateSpacePayload { + view_id: string; +} \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/assets/add_circle.svg b/frontend/appflowy_web_app/src/assets/add_circle.svg new file mode 100644 index 0000000000000..6916c28e4cb06 --- /dev/null +++ b/frontend/appflowy_web_app/src/assets/add_circle.svg @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/assets/add_cover.svg b/frontend/appflowy_web_app/src/assets/add_cover.svg new file mode 100644 index 0000000000000..3ffaffb1fee1b --- /dev/null +++ b/frontend/appflowy_web_app/src/assets/add_cover.svg @@ -0,0 +1,10 @@ + + + + + + + diff --git a/frontend/appflowy_web_app/src/assets/add_icon.svg b/frontend/appflowy_web_app/src/assets/add_icon.svg new file mode 100644 index 0000000000000..2af11fce6a490 --- /dev/null +++ b/frontend/appflowy_web_app/src/assets/add_icon.svg @@ -0,0 +1,6 @@ + + + + + diff --git a/frontend/appflowy_web_app/src/assets/add_user.svg b/frontend/appflowy_web_app/src/assets/add_user.svg new file mode 100644 index 0000000000000..2924cd76d9243 --- /dev/null +++ b/frontend/appflowy_web_app/src/assets/add_user.svg @@ -0,0 +1,14 @@ + + + + + + + + \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/assets/change_icon.svg b/frontend/appflowy_web_app/src/assets/change_icon.svg new file mode 100644 index 0000000000000..cb04181836c66 --- /dev/null +++ b/frontend/appflowy_web_app/src/assets/change_icon.svg @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/assets/download.svg b/frontend/appflowy_web_app/src/assets/download.svg index 17f4583503032..ee6108cb6c79e 100644 --- a/frontend/appflowy_web_app/src/assets/download.svg +++ b/frontend/appflowy_web_app/src/assets/download.svg @@ -1,15 +1,8 @@ - - - - - - - - - - - + + Download Streamline Icon: https://streamlinehq.com + + + \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/assets/drag_element.svg b/frontend/appflowy_web_app/src/assets/drag_element.svg new file mode 100644 index 0000000000000..5920a08ebfffb --- /dev/null +++ b/frontend/appflowy_web_app/src/assets/drag_element.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/frontend/appflowy_web_app/src/assets/duplicate.svg b/frontend/appflowy_web_app/src/assets/duplicate.svg new file mode 100644 index 0000000000000..da49b531a7608 --- /dev/null +++ b/frontend/appflowy_web_app/src/assets/duplicate.svg @@ -0,0 +1,6 @@ + + + + diff --git a/frontend/appflowy_web_app/src/assets/edit.svg b/frontend/appflowy_web_app/src/assets/edit.svg index a8df944260ee0..dccaac769dd12 100644 --- a/frontend/appflowy_web_app/src/assets/edit.svg +++ b/frontend/appflowy_web_app/src/assets/edit.svg @@ -1,5 +1,9 @@ - - - + + + + \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/assets/filter.svg b/frontend/appflowy_web_app/src/assets/filter.svg new file mode 100644 index 0000000000000..be0d3b83abaea --- /dev/null +++ b/frontend/appflowy_web_app/src/assets/filter.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/assets/format_text.svg b/frontend/appflowy_web_app/src/assets/format_text.svg new file mode 100644 index 0000000000000..e7213cf9eb3af --- /dev/null +++ b/frontend/appflowy_web_app/src/assets/format_text.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/assets/full_view.svg b/frontend/appflowy_web_app/src/assets/full_view.svg index d4fe3090cb62f..8a96824afb1ab 100644 --- a/frontend/appflowy_web_app/src/assets/full_view.svg +++ b/frontend/appflowy_web_app/src/assets/full_view.svg @@ -1,6 +1,6 @@ - - - - + + + + diff --git a/frontend/appflowy_web_app/src/assets/move_down.svg b/frontend/appflowy_web_app/src/assets/move_down.svg new file mode 100644 index 0000000000000..93dd6ff43b4bb --- /dev/null +++ b/frontend/appflowy_web_app/src/assets/move_down.svg @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/assets/move_to.svg b/frontend/appflowy_web_app/src/assets/move_to.svg new file mode 100644 index 0000000000000..b3c38f9757f8c --- /dev/null +++ b/frontend/appflowy_web_app/src/assets/move_to.svg @@ -0,0 +1,6 @@ + + + + + diff --git a/frontend/appflowy_web_app/src/assets/open_in_browser.svg b/frontend/appflowy_web_app/src/assets/open_in_browser.svg new file mode 100644 index 0000000000000..5c49c5aed831e --- /dev/null +++ b/frontend/appflowy_web_app/src/assets/open_in_browser.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + diff --git a/frontend/appflowy_web_app/src/assets/remove_favorite.svg b/frontend/appflowy_web_app/src/assets/remove_favorite.svg new file mode 100644 index 0000000000000..5786e7aaefe6b --- /dev/null +++ b/frontend/appflowy_web_app/src/assets/remove_favorite.svg @@ -0,0 +1,7 @@ + + + + + diff --git a/frontend/appflowy_web_app/src/assets/restore.svg b/frontend/appflowy_web_app/src/assets/restore.svg new file mode 100644 index 0000000000000..e2293cd1bd26d --- /dev/null +++ b/frontend/appflowy_web_app/src/assets/restore.svg @@ -0,0 +1,5 @@ + + + + diff --git a/frontend/appflowy_web_app/src/assets/settings.svg b/frontend/appflowy_web_app/src/assets/settings.svg new file mode 100644 index 0000000000000..c2486296be742 --- /dev/null +++ b/frontend/appflowy_web_app/src/assets/settings.svg @@ -0,0 +1,6 @@ + + + + diff --git a/frontend/appflowy_web_app/src/assets/side_outlined.svg b/frontend/appflowy_web_app/src/assets/side_outlined.svg index 6f1be3f72d2e9..aae08134121c5 100644 --- a/frontend/appflowy_web_app/src/assets/side_outlined.svg +++ b/frontend/appflowy_web_app/src/assets/side_outlined.svg @@ -1,14 +1,6 @@ - - - - - - - - - - + + + + \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/assets/sign-hashtag.svg b/frontend/appflowy_web_app/src/assets/sign-hashtag.svg new file mode 100644 index 0000000000000..35cf27cfdf22b --- /dev/null +++ b/frontend/appflowy_web_app/src/assets/sign-hashtag.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/appflowy_web_app/src/assets/sign_out.svg b/frontend/appflowy_web_app/src/assets/sign_out.svg new file mode 100644 index 0000000000000..60f881b34a2d7 --- /dev/null +++ b/frontend/appflowy_web_app/src/assets/sign_out.svg @@ -0,0 +1,7 @@ + + + + + \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/assets/slash_menu_icon_add_doc.svg b/frontend/appflowy_web_app/src/assets/slash_menu_icon_add_doc.svg new file mode 100644 index 0000000000000..1beb6db7690bc --- /dev/null +++ b/frontend/appflowy_web_app/src/assets/slash_menu_icon_add_doc.svg @@ -0,0 +1,12 @@ + + Document Medicine Streamline Icon: https://streamlinehq.com + + + + + \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/assets/slash_menu_icon_ai_writer.svg b/frontend/appflowy_web_app/src/assets/slash_menu_icon_ai_writer.svg new file mode 100644 index 0000000000000..31cdba2e2ffe1 --- /dev/null +++ b/frontend/appflowy_web_app/src/assets/slash_menu_icon_ai_writer.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + diff --git a/frontend/appflowy_web_app/src/assets/slash_menu_icon_bulleted_list.svg b/frontend/appflowy_web_app/src/assets/slash_menu_icon_bulleted_list.svg new file mode 100644 index 0000000000000..eeb12a2c9b060 --- /dev/null +++ b/frontend/appflowy_web_app/src/assets/slash_menu_icon_bulleted_list.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/frontend/appflowy_web_app/src/assets/slash_menu_icon_calendar-1.svg b/frontend/appflowy_web_app/src/assets/slash_menu_icon_calendar-1.svg new file mode 100644 index 0000000000000..5bc9051ead399 --- /dev/null +++ b/frontend/appflowy_web_app/src/assets/slash_menu_icon_calendar-1.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + diff --git a/frontend/appflowy_web_app/src/assets/slash_menu_icon_calendar.svg b/frontend/appflowy_web_app/src/assets/slash_menu_icon_calendar.svg new file mode 100644 index 0000000000000..2bc9f45c513a5 --- /dev/null +++ b/frontend/appflowy_web_app/src/assets/slash_menu_icon_calendar.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + diff --git a/frontend/appflowy_web_app/src/assets/slash_menu_icon_callout.svg b/frontend/appflowy_web_app/src/assets/slash_menu_icon_callout.svg new file mode 100644 index 0000000000000..c6046ea475cd0 --- /dev/null +++ b/frontend/appflowy_web_app/src/assets/slash_menu_icon_callout.svg @@ -0,0 +1,11 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/src/assets/slash_menu_icon_checkbox.svg b/frontend/appflowy_web_app/src/assets/slash_menu_icon_checkbox.svg new file mode 100644 index 0000000000000..036f1a38836d5 --- /dev/null +++ b/frontend/appflowy_web_app/src/assets/slash_menu_icon_checkbox.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + diff --git a/frontend/appflowy_web_app/src/assets/slash_menu_icon_code.svg b/frontend/appflowy_web_app/src/assets/slash_menu_icon_code.svg new file mode 100644 index 0000000000000..608d18dc90971 --- /dev/null +++ b/frontend/appflowy_web_app/src/assets/slash_menu_icon_code.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + diff --git a/frontend/appflowy_web_app/src/assets/slash_menu_icon_date_or_reminder.svg b/frontend/appflowy_web_app/src/assets/slash_menu_icon_date_or_reminder.svg new file mode 100644 index 0000000000000..f572c9b6f3b16 --- /dev/null +++ b/frontend/appflowy_web_app/src/assets/slash_menu_icon_date_or_reminder.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + diff --git a/frontend/appflowy_web_app/src/assets/slash_menu_icon_divider.svg b/frontend/appflowy_web_app/src/assets/slash_menu_icon_divider.svg new file mode 100644 index 0000000000000..87aa7842831bf --- /dev/null +++ b/frontend/appflowy_web_app/src/assets/slash_menu_icon_divider.svg @@ -0,0 +1,5 @@ + + + diff --git a/frontend/appflowy_web_app/src/assets/slash_menu_icon_doc.svg b/frontend/appflowy_web_app/src/assets/slash_menu_icon_doc.svg new file mode 100644 index 0000000000000..fdb07f20bf74c --- /dev/null +++ b/frontend/appflowy_web_app/src/assets/slash_menu_icon_doc.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + diff --git a/frontend/appflowy_web_app/src/assets/slash_menu_icon_emoji.svg b/frontend/appflowy_web_app/src/assets/slash_menu_icon_emoji.svg new file mode 100644 index 0000000000000..755b5c992da3c --- /dev/null +++ b/frontend/appflowy_web_app/src/assets/slash_menu_icon_emoji.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + diff --git a/frontend/appflowy_web_app/src/assets/slash_menu_icon_file.svg b/frontend/appflowy_web_app/src/assets/slash_menu_icon_file.svg new file mode 100644 index 0000000000000..c839ddc562b63 --- /dev/null +++ b/frontend/appflowy_web_app/src/assets/slash_menu_icon_file.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + diff --git a/frontend/appflowy_web_app/src/assets/slash_menu_icon_grid.svg b/frontend/appflowy_web_app/src/assets/slash_menu_icon_grid.svg new file mode 100644 index 0000000000000..f37834fd41942 --- /dev/null +++ b/frontend/appflowy_web_app/src/assets/slash_menu_icon_grid.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + diff --git a/frontend/appflowy_web_app/src/assets/slash_menu_icon_h1.svg b/frontend/appflowy_web_app/src/assets/slash_menu_icon_h1.svg new file mode 100644 index 0000000000000..a8c031037ee0f --- /dev/null +++ b/frontend/appflowy_web_app/src/assets/slash_menu_icon_h1.svg @@ -0,0 +1,7 @@ + + + + + + diff --git a/frontend/appflowy_web_app/src/assets/slash_menu_icon_h2.svg b/frontend/appflowy_web_app/src/assets/slash_menu_icon_h2.svg new file mode 100644 index 0000000000000..8a9d89e7852f2 --- /dev/null +++ b/frontend/appflowy_web_app/src/assets/slash_menu_icon_h2.svg @@ -0,0 +1,7 @@ + + + + + + diff --git a/frontend/appflowy_web_app/src/assets/slash_menu_icon_h3.svg b/frontend/appflowy_web_app/src/assets/slash_menu_icon_h3.svg new file mode 100644 index 0000000000000..e1282ba45bd34 --- /dev/null +++ b/frontend/appflowy_web_app/src/assets/slash_menu_icon_h3.svg @@ -0,0 +1,9 @@ + + + + + + + diff --git a/frontend/appflowy_web_app/src/assets/slash_menu_icon_image.svg b/frontend/appflowy_web_app/src/assets/slash_menu_icon_image.svg new file mode 100644 index 0000000000000..a3fbd6d6bdaf9 --- /dev/null +++ b/frontend/appflowy_web_app/src/assets/slash_menu_icon_image.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + diff --git a/frontend/appflowy_web_app/src/assets/slash_menu_icon_kanban.svg b/frontend/appflowy_web_app/src/assets/slash_menu_icon_kanban.svg new file mode 100644 index 0000000000000..60a8bf186b371 --- /dev/null +++ b/frontend/appflowy_web_app/src/assets/slash_menu_icon_kanban.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + diff --git a/frontend/appflowy_web_app/src/assets/slash_menu_icon_math_equation.svg b/frontend/appflowy_web_app/src/assets/slash_menu_icon_math_equation.svg new file mode 100644 index 0000000000000..759ddd60006d7 --- /dev/null +++ b/frontend/appflowy_web_app/src/assets/slash_menu_icon_math_equation.svg @@ -0,0 +1,6 @@ + + + + + diff --git a/frontend/appflowy_web_app/src/assets/slash_menu_icon_numbered_list.svg b/frontend/appflowy_web_app/src/assets/slash_menu_icon_numbered_list.svg new file mode 100644 index 0000000000000..0c38fd37b29bf --- /dev/null +++ b/frontend/appflowy_web_app/src/assets/slash_menu_icon_numbered_list.svg @@ -0,0 +1,9 @@ + + + + + + + + diff --git a/frontend/appflowy_web_app/src/assets/slash_menu_icon_outline.svg b/frontend/appflowy_web_app/src/assets/slash_menu_icon_outline.svg new file mode 100644 index 0000000000000..da4388fb12720 --- /dev/null +++ b/frontend/appflowy_web_app/src/assets/slash_menu_icon_outline.svg @@ -0,0 +1,4 @@ + + + diff --git a/frontend/appflowy_web_app/src/assets/slash_menu_icon_photo_gallery.svg b/frontend/appflowy_web_app/src/assets/slash_menu_icon_photo_gallery.svg new file mode 100644 index 0000000000000..21cab41f1f6bc --- /dev/null +++ b/frontend/appflowy_web_app/src/assets/slash_menu_icon_photo_gallery.svg @@ -0,0 +1,11 @@ + + + + + + + diff --git a/frontend/appflowy_web_app/src/assets/slash_menu_icon_quote.svg b/frontend/appflowy_web_app/src/assets/slash_menu_icon_quote.svg new file mode 100644 index 0000000000000..4bb78a2b40747 --- /dev/null +++ b/frontend/appflowy_web_app/src/assets/slash_menu_icon_quote.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/appflowy_web_app/src/assets/slash_menu_icon_simple_table.svg b/frontend/appflowy_web_app/src/assets/slash_menu_icon_simple_table.svg new file mode 100644 index 0000000000000..e7b0de034d24a --- /dev/null +++ b/frontend/appflowy_web_app/src/assets/slash_menu_icon_simple_table.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + diff --git a/frontend/appflowy_web_app/src/assets/slash_menu_icon_text.svg b/frontend/appflowy_web_app/src/assets/slash_menu_icon_text.svg new file mode 100644 index 0000000000000..75ff13a1a6a3f --- /dev/null +++ b/frontend/appflowy_web_app/src/assets/slash_menu_icon_text.svg @@ -0,0 +1,5 @@ + + + + diff --git a/frontend/appflowy_web_app/src/assets/slash_menu_icon_toggle.svg b/frontend/appflowy_web_app/src/assets/slash_menu_icon_toggle.svg new file mode 100644 index 0000000000000..e31ffe408d8c8 --- /dev/null +++ b/frontend/appflowy_web_app/src/assets/slash_menu_icon_toggle.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/src/assets/slash_menu_icon_toggle_heading1.svg b/frontend/appflowy_web_app/src/assets/slash_menu_icon_toggle_heading1.svg new file mode 100644 index 0000000000000..c8a491372e4e8 --- /dev/null +++ b/frontend/appflowy_web_app/src/assets/slash_menu_icon_toggle_heading1.svg @@ -0,0 +1,8 @@ + + + + + + + \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/assets/slash_menu_icon_toggle_heading2.svg b/frontend/appflowy_web_app/src/assets/slash_menu_icon_toggle_heading2.svg new file mode 100644 index 0000000000000..8a9d89e7852f2 --- /dev/null +++ b/frontend/appflowy_web_app/src/assets/slash_menu_icon_toggle_heading2.svg @@ -0,0 +1,7 @@ + + + + + + diff --git a/frontend/appflowy_web_app/src/assets/slash_menu_icon_toggle_heading3.svg b/frontend/appflowy_web_app/src/assets/slash_menu_icon_toggle_heading3.svg new file mode 100644 index 0000000000000..e1282ba45bd34 --- /dev/null +++ b/frontend/appflowy_web_app/src/assets/slash_menu_icon_toggle_heading3.svg @@ -0,0 +1,9 @@ + + + + + + + diff --git a/frontend/appflowy_web_app/src/assets/sort.svg b/frontend/appflowy_web_app/src/assets/sort.svg new file mode 100644 index 0000000000000..afceedca86b87 --- /dev/null +++ b/frontend/appflowy_web_app/src/assets/sort.svg @@ -0,0 +1,6 @@ + + + + diff --git a/frontend/appflowy_web_app/src/assets/space_permission_dropdown.svg b/frontend/appflowy_web_app/src/assets/space_permission_dropdown.svg new file mode 100644 index 0000000000000..33a7ca99ed6f2 --- /dev/null +++ b/frontend/appflowy_web_app/src/assets/space_permission_dropdown.svg @@ -0,0 +1,6 @@ + + + + + diff --git a/frontend/appflowy_web_app/src/assets/space_permission_private.svg b/frontend/appflowy_web_app/src/assets/space_permission_private.svg new file mode 100644 index 0000000000000..417fedec02ca0 --- /dev/null +++ b/frontend/appflowy_web_app/src/assets/space_permission_private.svg @@ -0,0 +1,12 @@ + + + + + + + diff --git a/frontend/appflowy_web_app/src/assets/space_permission_public.svg b/frontend/appflowy_web_app/src/assets/space_permission_public.svg new file mode 100644 index 0000000000000..2ce63ebe4129c --- /dev/null +++ b/frontend/appflowy_web_app/src/assets/space_permission_public.svg @@ -0,0 +1,20 @@ + + + + + + + + + + diff --git a/frontend/appflowy_web_app/src/assets/template.svg b/frontend/appflowy_web_app/src/assets/template.svg index 887b30a756078..07b077c294612 100644 --- a/frontend/appflowy_web_app/src/assets/template.svg +++ b/frontend/appflowy_web_app/src/assets/template.svg @@ -1,10 +1,10 @@ - - - + + + + + \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/components/_shared/breadcrumb/Breadcrumb.tsx b/frontend/appflowy_web_app/src/components/_shared/breadcrumb/Breadcrumb.tsx index 21c74ccfdb3fe..523e29500add2 100644 --- a/frontend/appflowy_web_app/src/components/_shared/breadcrumb/Breadcrumb.tsx +++ b/frontend/appflowy_web_app/src/components/_shared/breadcrumb/Breadcrumb.tsx @@ -55,7 +55,7 @@ export function Breadcrumb ({ crumbs, toView, variant }: BreadcrumbProps) { variant={variant} toView={toView} crumb={crumb} - disableClick={false} + disableClick={index === lastCrumbs.length - 1} /> {index === lastCrumbs.length - 1 ? null : } diff --git a/frontend/appflowy_web_app/src/components/_shared/breadcrumb/BreadcrumbItem.tsx b/frontend/appflowy_web_app/src/components/_shared/breadcrumb/BreadcrumbItem.tsx index fe21caa3bda6e..8597c031e423d 100644 --- a/frontend/appflowy_web_app/src/components/_shared/breadcrumb/BreadcrumbItem.tsx +++ b/frontend/appflowy_web_app/src/components/_shared/breadcrumb/BreadcrumbItem.tsx @@ -3,7 +3,6 @@ import PublishIcon from '@/components/_shared/view-icon/PublishIcon'; import { notify } from '@/components/_shared/notify'; import { ViewIcon } from '@/components/_shared/view-icon'; import SpaceIcon from '@/components/_shared/breadcrumb/SpaceIcon'; -import { renderColor } from '@/utils/color'; import { isFlagEmoji } from '@/utils/emoji'; import { Tooltip } from '@mui/material'; import React, { useMemo } from 'react'; @@ -51,18 +50,12 @@ function BreadcrumbItem ({ crumb, disableClick = false, toView, variant }: { }} > {extra && extra.is_space ? ( - - - + bgColor={extra.space_icon_color} + value={extra.space_icon || ''} + char={extra.space_icon ? undefined : name.slice(0, 1)} + /> ) : ( {icon?.value || { } }; -function SpaceIcon ({ value, char }: { value: string, char?: string }) { +function SpaceIcon ({ value, char, bgColor, className: classNameProp }: { + value: string, + char?: string, + bgColor?: string, + className?: string +}) { const IconComponent = getIconComponent(value); const [iconEncodeContent, setIconEncodeContent] = useState(null); const isDark = useContext(ThemeModeContext)?.isDark || false; @@ -84,19 +90,38 @@ function SpaceIcon ({ value, char }: { value: string, char?: string }) { />; }, [iconEncodeContent, value]); - if (char) { - return ( - + const content = useMemo(() => { + if (char) { + return ( + {char} - ); - } + ); + } - if (!IconComponent) { - return customIcon; - } + if (!IconComponent) { + return customIcon; + } + + return ; + }, [IconComponent, char, customIcon]); + + const className = useMemo(() => { + const classList = ['icon', 'h-[1.2em]', 'w-[1.2em]', 'shrink-0', 'rounded-[4px]']; + + if (classNameProp) { + classList.push(classNameProp); + } + + return classList.join(' '); + }, [classNameProp]); - return ; + return {content}; } export default SpaceIcon; diff --git a/frontend/appflowy_web_app/src/components/_shared/file-dropzone/FileDropzone.tsx b/frontend/appflowy_web_app/src/components/_shared/file-dropzone/FileDropzone.tsx index 5775fcd119b1a..26a6fd292a329 100644 --- a/frontend/appflowy_web_app/src/components/_shared/file-dropzone/FileDropzone.tsx +++ b/frontend/appflowy_web_app/src/components/_shared/file-dropzone/FileDropzone.tsx @@ -9,7 +9,7 @@ interface FileDropzoneProps { accept?: string; multiple?: boolean; disabled?: boolean; - placeholder?: string; + placeholder?: string | React.ReactNode; } function FileDropzone ({ diff --git a/frontend/appflowy_web_app/src/components/_shared/help/Help.tsx b/frontend/appflowy_web_app/src/components/_shared/help/Help.tsx index f0caf724b1876..2ad0c948b66b0 100644 --- a/frontend/appflowy_web_app/src/components/_shared/help/Help.tsx +++ b/frontend/appflowy_web_app/src/components/_shared/help/Help.tsx @@ -1,8 +1,10 @@ import { notify } from '@/components/_shared/notify'; import { Popover } from '@/components/_shared/popover'; +import { ThemeModeContext } from '@/components/main/useAppThemeMode'; import { copyTextToClipboard } from '@/utils/copy'; import { Button, Divider, Portal, Tooltip } from '@mui/material'; import { PopoverProps } from '@mui/material/Popover'; +import { useContext } from 'react'; import * as React from 'react'; import Box from '@mui/material/Box'; import { ReactComponent as SpeedDialIcon } from '@/assets/help.svg'; @@ -11,6 +13,8 @@ import { ReactComponent as WhatsNewIcon } from '@/assets/star.svg'; import { ReactComponent as SupportIcon } from '@/assets/message_support.svg'; import { ReactComponent as DebugIcon } from '@/assets/debug.svg'; import { ReactComponent as FeedbackIcon } from '@/assets/report.svg'; +import { ReactComponent as MoonIcon } from '@/assets/moon.svg'; +import { ReactComponent as SunIcon } from '@/assets/sun.svg'; const popoverProps: Partial = { anchorOrigin: { @@ -27,6 +31,7 @@ export default function Help () { const ref = React.useRef(null); const [open, setOpen] = React.useState(false); const { t } = useTranslation(); + const { isDark, setDark } = useContext(ThemeModeContext) || {}; return ( @@ -38,16 +43,32 @@ export default function Help () {
setOpen(!open)} - className={'w-9 h-9 rounded-full flex items-center justify-center border border-line-border bg-bg-body hover:bg-fill-list-hover cursor-pointer shadow-md'} + className={'py-2'} > - +
+ +
+
- setOpen(false)} + setOpen(false)} >
+ + + + ))} +
+ ); + }, []); + + return ( +
+
+
+ } + value={searchValue} + onChange={(e) => { + setSearchValue(e.target.value); + }} + onKeyUp={(e) => { + if (e.key === 'Escape' && onEscape) { + onEscape(); + } + }} + autoFocus={true} + fullWidth={true} + size={'small'} + autoCorrect={'off'} + autoComplete={'off'} + spellCheck={false} + inputProps={{ + className: 'px-2 py-1.5 text-base', + }} + className={'search-emoji-input'} + placeholder={t('search.label')} + /> +
+ + + + +
+
+
+ +
+
+ + {({ height, width }: { height: number; width: number }) => ( + + {Row} + + )} + +
+
{ + t('emoji.openSourceIconsFrom') + } + + Streamline + +
+
+ + setAnchorEl(null)} + onKeyDown={(e) => { + if (e.key === 'Escape') { + setAnchorEl(null); + } + }} + > +
+ {IconColors.map((color) => ( + + ))} + +
+ +
+
+ ); +} + +export default IconPicker; \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/components/_shared/icon-picker/index.ts b/frontend/appflowy_web_app/src/components/_shared/icon-picker/index.ts new file mode 100644 index 0000000000000..682a28500be42 --- /dev/null +++ b/frontend/appflowy_web_app/src/components/_shared/icon-picker/index.ts @@ -0,0 +1 @@ +export * from './IconPicker'; \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/components/_shared/image-render/ImageRender.tsx b/frontend/appflowy_web_app/src/components/_shared/image-render/ImageRender.tsx index 55321884df1fa..114270c27b0ce 100644 --- a/frontend/appflowy_web_app/src/components/_shared/image-render/ImageRender.tsx +++ b/frontend/appflowy_web_app/src/components/_shared/image-render/ImageRender.tsx @@ -8,12 +8,11 @@ interface ImageRenderProps extends React.HTMLAttributes { alt?: string; } -export function ImageRender({ src, ...props }: ImageRenderProps) { +export function ImageRender ({ src, ...props }: ImageRenderProps) { const { t } = useTranslation(); const [loading, setLoading] = useState(true); const [hasError, setHasError] = useState(false); - console.log('ImageRender', src); return ( <> {hasError ? ( @@ -22,7 +21,11 @@ export function ImageRender({ src, ...props }: ImageRenderProps) {
{t('editor.imageLoadFailed')}
) : loading ? ( - + ) : null} void; + onEscape?: () => void; + placeholder?: string; +}) { + const { t } = useTranslation(); + + const [value, setValue] = useState(defaultLink ?? ''); + const [error, setError] = useState(false); + + const handleChange = useCallback( + (e: React.ChangeEvent) => { + const value = e.target.value; + + setValue(value); + setError(!isURL(value, { require_protocol: true })); + }, + [setValue, setError], + ); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !error && value) { + e.preventDefault(); + e.stopPropagation(); + onDone?.(value); + } + + if (e.key === 'Escape') { + onEscape?.(); + } + }, + [error, onDone, onEscape, value], + ); + + return ( +
+ + +
+ ); +} + +export default EmbedLink; diff --git a/frontend/appflowy_web_app/src/components/_shared/image-upload/Unsplash.tsx b/frontend/appflowy_web_app/src/components/_shared/image-upload/Unsplash.tsx new file mode 100644 index 0000000000000..6cfe69fb702e8 --- /dev/null +++ b/frontend/appflowy_web_app/src/components/_shared/image-upload/Unsplash.tsx @@ -0,0 +1,179 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { createApi } from 'unsplash-js'; +import TextField from '@mui/material/TextField'; +import { useTranslation } from 'react-i18next'; +import Typography from '@mui/material/Typography'; +import debounce from 'lodash-es/debounce'; +import { CircularProgress } from '@mui/material'; +import { open } from '@tauri-apps/api/shell'; + +const unsplash = createApi({ + accessKey: '1WxD1JpMOUX86lZKKob4Ca0LMZPyO2rUmAgjpWm9Ids', +}); + +const SEARCH_DEBOUNCE_TIME = 500; + +export function Unsplash ({ onDone, onEscape }: { onDone?: (value: string) => void; onEscape?: () => void }) { + const { t } = useTranslation(); + + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + const [photos, setPhotos] = useState< + { + thumb: string; + full: string; + alt: string | null; + id: string; + user: { + name: string; + link: string; + }; + }[] + >([]); + const [searchValue, setSearchValue] = useState(''); + + const handleChange = useCallback((e: React.ChangeEvent) => { + const value = e.target.value; + + setSearchValue(value); + }, []); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Escape') { + onEscape?.(); + } + }, + [onEscape], + ); + + const debounceSearchPhotos = useMemo(() => { + return debounce(async (searchValue: string) => { + const request = searchValue + ? unsplash.search.getPhotos({ query: searchValue ?? undefined, perPage: 32 }) + : unsplash.photos.list({ perPage: 32 }); + + setError(''); + setLoading(true); + await request.then((result) => { + if (result.errors) { + setError(result.errors[0]); + } else { + setPhotos( + result.response.results.map((photo) => ({ + id: photo.id, + thumb: photo.urls.thumb, + full: photo.urls.full, + alt: photo.alt_description, + user: { + name: photo.user.name, + link: photo.user.links.html, + }, + })), + ); + } + + setLoading(false); + }); + }, SEARCH_DEBOUNCE_TIME); + }, []); + + useEffect(() => { + void debounceSearchPhotos(searchValue); + return () => { + debounceSearchPhotos.cancel(); + }; + }, [debounceSearchPhotos, searchValue]); + + return ( +
+ + + {loading ? ( +
+ +
{t('editor.loading')}
+
+ ) : error ? ( + + {error} + + ) : ( +
+ {photos.length > 0 ? ( + <> +
+ {photos.map((photo) => ( +
+
+ { + onDone?.(photo.full); + }} + src={photo.thumb} + alt={photo.alt ?? ''} + className={` + absolute top-0 left-0 + w-full h-full + rounded object-cover + cursor-pointer + hover:opacity-80 + transition-opacity + `} + /> +
+
+ by{' '} + { + void open(photo.user.link); + }} + className={'underline hover:text-function-info'} + > + {photo.user.name} + +
+
+ ))} +
+ + {t('findAndReplace.searchMore')} + + + ) : ( + + {t('findAndReplace.noResult')} + + )} +
+ )} +
+ ); +} diff --git a/frontend/appflowy_web_app/src/components/_shared/image-upload/UploadImage.tsx b/frontend/appflowy_web_app/src/components/_shared/image-upload/UploadImage.tsx new file mode 100644 index 0000000000000..22c8111f3878f --- /dev/null +++ b/frontend/appflowy_web_app/src/components/_shared/image-upload/UploadImage.tsx @@ -0,0 +1,38 @@ +import FileDropzone from '@/components/_shared/file-dropzone/FileDropzone'; +import { notify } from '@/components/_shared/notify'; +import React, { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; + +export const MAX_IMAGE_SIZE = 10 * 1024 * 1024; // 10MB +export const ALLOWED_IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'svg', 'webp']; + +export function UploadImage ({ onDone }: { onDone?: (url: string) => void }) { + const { t } = useTranslation(); + const handleFileChange = useCallback(async (files: File[]) => { + const file = files[0]; + + if (!file) return; + + if (file.size > MAX_IMAGE_SIZE) { + notify.error('File size is too large, please upload a file less than 10MB'); + + return; + } + + onDone?.(URL.createObjectURL(file)); + + }, [onDone]); + + return ( +
+ +
+ + ); +} + +export default UploadImage; diff --git a/frontend/appflowy_web_app/src/components/_shared/image-upload/UploadTabs.tsx b/frontend/appflowy_web_app/src/components/_shared/image-upload/UploadTabs.tsx new file mode 100644 index 0000000000000..108a5156102a1 --- /dev/null +++ b/frontend/appflowy_web_app/src/components/_shared/image-upload/UploadTabs.tsx @@ -0,0 +1,162 @@ +import { Popover } from '@/components/_shared/popover'; +import { TabPanel, ViewTab, ViewTabs } from '@/components/_shared/tabs/ViewTabs'; +import React, { SyntheticEvent, useCallback, useEffect, useRef, useState } from 'react'; +import { PopoverProps } from '@mui/material/Popover'; +import SwipeableViews from 'react-swipeable-views'; + +export enum TAB_KEY { + Colors = 'colors', + UPLOAD = 'upload', + EMBED_LINK = 'embed_link', + UNSPLASH = 'unsplash', +} + +export type TabOption = { + key: TAB_KEY; + label: string; + Component: React.ComponentType<{ + onDone?: (value: string) => void; + onEscape?: () => void; + }>; + onDone?: (value: string) => void; +}; + +export function UploadTabs ({ + tabOptions, + popoverProps, + containerStyle, + extra, +}: { + containerStyle?: React.CSSProperties; + tabOptions: TabOption[]; + popoverProps?: PopoverProps; + extra?: React.ReactNode; +}) { + const [tabValue, setTabValue] = useState(() => { + return tabOptions[0].key; + }); + + const handleTabChange = useCallback((_: SyntheticEvent, newValue: string) => { + setTabValue(newValue as TAB_KEY); + }, []); + + const selectedIndex = tabOptions.findIndex((tab) => tab.key === tabValue); + + const onKeyDown = useCallback( + (e: React.KeyboardEvent) => { + e.stopPropagation(); + + if (e.key === 'Escape') { + e.preventDefault(); + e.stopPropagation(); + popoverProps?.onClose?.({}, 'escapeKeyDown'); + } + + if (e.key === 'Tab') { + e.preventDefault(); + e.stopPropagation(); + setTabValue((prev) => { + const currentIndex = tabOptions.findIndex((tab) => tab.key === prev); + let nextIndex = currentIndex + 1; + + if (e.shiftKey) { + nextIndex = currentIndex - 1; + } + + return tabOptions[nextIndex % tabOptions.length]?.key ?? tabOptions[0].key; + }); + } + }, + [popoverProps, tabOptions], + ); + + const ref = useRef(null); + + useEffect(() => { + const el = ref.current; + + if (!el) return; + + const handleResize = () => { + const top = el.getBoundingClientRect().top; + const height = window.innerHeight - top - 20; + + el.style.maxHeight = `${height}px`; + }; + + if (tabValue === 'unsplash') { + handleResize(); + } + + }, [tabValue]); + + return ( + +
+
+ + {tabOptions.map((tab) => { + const { key, label } = tab; + + return ; + })} + + {extra} +
+ +
+ + {tabOptions.map((tab, index) => { + const { key, Component, onDone } = tab; + + return ( + + popoverProps?.onClose?.({}, 'escapeKeyDown')} + /> + + ); + })} + +
+
+
+ ); +} diff --git a/frontend/appflowy_web_app/src/components/_shared/image-upload/index.ts b/frontend/appflowy_web_app/src/components/_shared/image-upload/index.ts new file mode 100644 index 0000000000000..c2b4c5552d24f --- /dev/null +++ b/frontend/appflowy_web_app/src/components/_shared/image-upload/index.ts @@ -0,0 +1,4 @@ +export * from './Unsplash'; +export * from './UploadImage'; +export * from './EmbedLink'; +export * from './UploadTabs'; diff --git a/frontend/appflowy_web_app/src/components/_shared/modal/NormalModal.tsx b/frontend/appflowy_web_app/src/components/_shared/modal/NormalModal.tsx index 2a159d68bb896..de4aa0cc1b735 100644 --- a/frontend/appflowy_web_app/src/components/_shared/modal/NormalModal.tsx +++ b/frontend/appflowy_web_app/src/components/_shared/modal/NormalModal.tsx @@ -44,6 +44,10 @@ export function NormalModal ({ if (e.key === 'Escape' && closable) { onClose?.(); } + + if (e.key === 'Enter' && onOk) { + onOk(); + } }} {...dialogProps} > @@ -68,6 +72,7 @@ export function NormalModal ({ + + ); diff --git a/frontend/appflowy_web_app/src/components/_shared/notify/index.ts b/frontend/appflowy_web_app/src/components/_shared/notify/index.ts index 127d89dbacf9c..d7108d61d664f 100644 --- a/frontend/appflowy_web_app/src/components/_shared/notify/index.ts +++ b/frontend/appflowy_web_app/src/components/_shared/notify/index.ts @@ -1,19 +1,19 @@ import { InfoProps } from '@/components/_shared/notify/InfoSnackbar'; -import { lazy } from 'react'; +import React, { lazy } from 'react'; export const InfoSnackbar = lazy(() => import('./InfoSnackbar')); export const notify = { - success: (message: string) => { + success: (message: string | React.ReactNode) => { window.toast.success(message); }, - error: (message: string) => { + error: (message: string | React.ReactNode) => { window.toast.error(message); }, - default: (message: string) => { + default: (message: string | React.ReactNode) => { window.toast.default(message); }, - warning: (message: string) => { + warning: (message: string | React.ReactNode) => { window.toast.warning(message); }, info: (props: InfoProps) => { diff --git a/frontend/appflowy_web_app/src/components/_shared/outline/Outline.tsx b/frontend/appflowy_web_app/src/components/_shared/outline/Outline.tsx index 157cce78d26ad..29cc0402bc9c6 100644 --- a/frontend/appflowy_web_app/src/components/_shared/outline/Outline.tsx +++ b/frontend/appflowy_web_app/src/components/_shared/outline/Outline.tsx @@ -12,7 +12,7 @@ export function Outline ({ outline, width, selectedViewId, navigateToView, varia }) { return ( -
+
{!outline || outline.length === 0 ?
@@ -49,7 +50,8 @@ export function OutlineDrawer ({ header, variant, open, width, onClose, children
{header ? header :
} > - - + +
@@ -80,7 +86,10 @@ export function OutlineDrawer ({ header, variant, open, width, onClose, children
- + ); diff --git a/frontend/appflowy_web_app/src/components/_shared/outline/OutlineIcon.tsx b/frontend/appflowy_web_app/src/components/_shared/outline/OutlineIcon.tsx index 4b96217ed3d38..07f4d79c095da 100644 --- a/frontend/appflowy_web_app/src/components/_shared/outline/OutlineIcon.tsx +++ b/frontend/appflowy_web_app/src/components/_shared/outline/OutlineIcon.tsx @@ -12,7 +12,8 @@ function OutlineIcon ({ isExpanded, setIsExpanded, level }: { style={{ paddingLeft: 1.125 * level + 'em', }} - onClick={() => { + onClick={(e) => { + e.stopPropagation(); setIsExpanded(false); }} className={'opacity-50 hover:opacity-100'} @@ -28,7 +29,8 @@ function OutlineIcon ({ isExpanded, setIsExpanded, level }: { paddingLeft: 1.125 * level + 'em', }} className={'opacity-50 hover:opacity-100'} - onClick={() => { + onClick={(e) => { + e.stopPropagation(); setIsExpanded(true); }} > diff --git a/frontend/appflowy_web_app/src/components/_shared/outline/OutlineItem.tsx b/frontend/appflowy_web_app/src/components/_shared/outline/OutlineItem.tsx index 3b7c7914c277f..1be2b0c528d4a 100644 --- a/frontend/appflowy_web_app/src/components/_shared/outline/OutlineItem.tsx +++ b/frontend/appflowy_web_app/src/components/_shared/outline/OutlineItem.tsx @@ -1,30 +1,9 @@ import { UIVariant, View } from '@/application/types'; +import { ReactComponent as PrivateIcon } from '@/assets/lock.svg'; import OutlineIcon from '@/components/_shared/outline/OutlineIcon'; import OutlineItemContent from '@/components/_shared/outline/OutlineItemContent'; +import { getOutlineExpands, setOutlineExpands } from '@/components/_shared/outline/utils'; import React, { useCallback, useEffect, useMemo } from 'react'; -import { ReactComponent as PrivateIcon } from '@/assets/lock.svg'; - -function getOutlineExpands () { - const expandView = localStorage.getItem('outline_expanded'); - - try { - return JSON.parse(expandView || '{}'); - } catch (e) { - return {}; - } -} - -function setOutlineExpands (viewId: string, isExpanded: boolean) { - const expands = getOutlineExpands(); - - if (isExpanded) { - expands[viewId] = true; - } else { - delete expands[viewId]; - } - - localStorage.setItem('outline_expanded', JSON.stringify(expands)); -} function OutlineItem ({ view, level = 0, width, navigateToView, selectedViewId, variant }: { view: View; @@ -33,7 +12,6 @@ function OutlineItem ({ view, level = 0, width, navigateToView, selectedViewId, selectedViewId?: string; navigateToView?: (viewId: string) => Promise variant?: UIVariant; - }) { const selected = selectedViewId === view.view_id; const [isExpanded, setIsExpanded] = React.useState(() => { @@ -54,14 +32,17 @@ function OutlineItem ({ view, level = 0, width, navigateToView, selectedViewId, const renderItem = useCallback((item: View) => { return ( -
+
{item.children?.length ? getIcon() : null} @@ -77,7 +58,7 @@ function OutlineItem ({ view, level = 0, width, navigateToView, selectedViewId,
); - }, [getIcon, level, navigateToView, variant, selected, width]); + }, [variant, width, selected, getIcon, navigateToView, level]); const children = useMemo(() => view.children || [], [view.children]); diff --git a/frontend/appflowy_web_app/src/components/_shared/outline/OutlineItemContent.tsx b/frontend/appflowy_web_app/src/components/_shared/outline/OutlineItemContent.tsx index cf1ce29ffb1ad..84c48bbcb5bf3 100644 --- a/frontend/appflowy_web_app/src/components/_shared/outline/OutlineItemContent.tsx +++ b/frontend/appflowy_web_app/src/components/_shared/outline/OutlineItemContent.tsx @@ -2,10 +2,10 @@ import { UIVariant, View, ViewLayout } from '@/application/types'; import SpaceIcon from '@/components/_shared/breadcrumb/SpaceIcon'; import { ViewIcon } from '@/components/_shared/view-icon'; import PublishIcon from '@/components/_shared/view-icon/PublishIcon'; -import { renderColor } from '@/utils/color'; import { isFlagEmoji } from '@/utils/emoji'; import { Tooltip } from '@mui/material'; import React, { memo } from 'react'; +import { useTranslation } from 'react-i18next'; function OutlineItemContent ({ item, @@ -24,6 +24,7 @@ function OutlineItemContent ({ const { icon, layout, name, view_id, extra } = item; const [hovered, setHovered] = React.useState(false); const isSpace = extra?.is_space; + const { t } = useTranslation(); return (
{isSpace && extra ? - - - : + :
@@ -75,7 +69,7 @@ function OutlineItemContent ({ enterDelay={1000} enterNextDelay={1000} > -
{name}
+
{name || t('menuAppHeader.defaultNewPageName')}
{hovered && variant === UIVariant.Publish && { + const onKeyUp = useCallback((e: KeyboardEvent) => { switch (true) { case createHotkey(HOT_KEY_NAME.TOGGLE_SIDEBAR)(e): e.preventDefault(); @@ -30,11 +30,12 @@ export function useOutlinePopover ({ }, [onCloseDrawer, onOpenDrawer, openDrawer]); useEffect(() => { - window.addEventListener('keydown', onKeyDown); + + document.addEventListener('keyup', onKeyUp, true); return () => { - window.removeEventListener('keydown', onKeyDown); + document.removeEventListener('keyup', onKeyUp, true); }; - }, [onKeyDown]); + }, [onKeyUp]); const debounceClosePopover = useMemo(() => { return debounce(() => { diff --git a/frontend/appflowy_web_app/src/components/_shared/outline/utils.ts b/frontend/appflowy_web_app/src/components/_shared/outline/utils.ts index f1f7dcb8a1946..5a67ba5b62b8c 100644 --- a/frontend/appflowy_web_app/src/components/_shared/outline/utils.ts +++ b/frontend/appflowy_web_app/src/components/_shared/outline/utils.ts @@ -58,6 +58,53 @@ export function filterOutViewsByLayout (views: View[], layout: ViewLayout): View return filterOut(views); } +export function filterViewsByCondition (views: View[], condition: (view: View) => boolean): View[] { + const filter = (views: View[]): View[] => { + let result: View[] = []; + + for (const view of views) { + if (condition(view)) { + result.push(view); + } + + if (view.children) { + const filteredChildren = filter(view.children); + + result = result.concat(filteredChildren); + } + } + + return result; + }; + + return filter(views); +} + +export function filterOutByCondition (views: View[], condition: (view: View) => { + remove: boolean; +}): View[] { + const filterOut = (views: View[]): View[] => { + const result: View[] = []; + + for (const view of views) { + const { remove } = condition(view); + + if (remove) { + continue; + } + + const newView = { ...view }; + + newView.children = filterOut(view.children); + result.push(newView); + } + + return result; + }; + + return filterOut(views); +} + export function findAncestors (data: View[], targetId: string, currentPath: View[] = []): View[] | null { for (const item of data) { const newPath = [...currentPath, item]; @@ -94,4 +141,40 @@ export function findView (data: View[], targetId: string): View | null { } return null; +} + +export function flattenViews (views: View[]): View[] { + const result: View[] = []; + + for (const view of views) { + result.push(view); + + if (view.children) { + result.push(...flattenViews(view.children)); + } + } + + return result; +} + +export function getOutlineExpands () { + const expandView = localStorage.getItem('outline_expanded'); + + try { + return JSON.parse(expandView || '{}'); + } catch (e) { + return {}; + } +} + +export function setOutlineExpands (viewId: string, isExpanded: boolean) { + const expands = getOutlineExpands(); + + if (isExpanded) { + expands[viewId] = true; + } else { + delete expands[viewId]; + } + + localStorage.setItem('outline_expanded', JSON.stringify(expands)); } \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/components/_shared/popover/Popover.tsx b/frontend/appflowy_web_app/src/components/_shared/popover/Popover.tsx index 90819ed57b13e..915bac0a36fe8 100644 --- a/frontend/appflowy_web_app/src/components/_shared/popover/Popover.tsx +++ b/frontend/appflowy_web_app/src/components/_shared/popover/Popover.tsx @@ -1,4 +1,5 @@ -import React from 'react'; +import { PopoverOrigin } from '@mui/material/Popover/Popover'; +import React, { useState } from 'react'; import { Popover as PopoverComponent, PopoverProps as PopoverComponentProps } from '@mui/material'; const defaultProps: Partial = { @@ -10,12 +11,157 @@ const defaultProps: Partial = { }, }; -export function Popover({ children, ...props }: PopoverComponentProps) { +interface Position { + top: number; + left: number; +} + +export interface Origins { + anchorOrigin: PopoverOrigin; + transformOrigin: PopoverOrigin; +} + +const DEFAULT_ORIGINS: Origins = { + anchorOrigin: { + vertical: 'bottom', + horizontal: 'left', + }, + transformOrigin: { + vertical: 'top', + horizontal: 'left', + }, +}; + +function calculateOptimalOrigins ( + position: Position, + popoverWidth: number, + popoverHeight: number, + defaultOrigins: Origins = DEFAULT_ORIGINS, + spacing: number = 8, +): Origins { + const windowWidth = window.innerWidth; + const windowHeight = window.innerHeight; + + // Check if there is enough space for the default position + const hasEnoughSpaceForDefault = + position.top + popoverHeight + spacing <= windowHeight && + position.left + popoverWidth + spacing <= windowWidth; + + // If there is enough space for the default position, return it + if (hasEnoughSpaceForDefault) { + return defaultOrigins; + } + + // Otherwise, calculate the optimal position + const spaceAbove = position.top; + const spaceBelow = windowHeight - position.top; + const spaceLeft = position.left; + const spaceRight = windowWidth - position.left; + + // Vertical + let vertical: { + anchor: 'top' | 'center' | 'bottom'; + transform: 'top' | 'center' | 'bottom'; + }; + + if (spaceBelow >= popoverHeight + spacing) { + vertical = { anchor: 'bottom', transform: 'top' }; + } else if (spaceAbove >= popoverHeight + spacing) { + vertical = { anchor: 'top', transform: 'bottom' }; + } else { + vertical = spaceBelow > spaceAbove + ? { anchor: 'center', transform: 'center' } + : { anchor: 'center', transform: 'center' }; + } + + // Horizontal + let horizontal: { + anchor: 'left' | 'center' | 'right'; + transform: 'left' | 'center' | 'right'; + }; + + if (spaceRight >= popoverWidth + spacing) { + horizontal = { anchor: 'left', transform: 'left' }; + } else if (spaceLeft >= popoverWidth + spacing) { + horizontal = { anchor: 'right', transform: 'right' }; + } else { + horizontal = spaceRight > spaceLeft + ? { anchor: 'center', transform: 'center' } + : { anchor: 'center', transform: 'center' }; + } + + return { + anchorOrigin: { + vertical: vertical.anchor, + horizontal: horizontal.anchor, + }, + transformOrigin: { + vertical: vertical.transform, + horizontal: horizontal.transform, + }, + }; +} + +export function Popover ({ + children, + transformOrigin = DEFAULT_ORIGINS.transformOrigin, + anchorOrigin = DEFAULT_ORIGINS.anchorOrigin, + anchorPosition, + anchorEl, + adjustOrigins = false, + ...props +}: PopoverComponentProps & { + adjustOrigins?: boolean +}) { + const [origins, setOrigins] = useState({ + transformOrigin, + anchorOrigin, + }); + + const handleEntered = (element: HTMLElement) => { + const { width, height } = element.getBoundingClientRect(); + let position: Position; + + if (anchorEl && anchorEl instanceof Element) { + const rect = anchorEl.getBoundingClientRect(); + + position = { + top: rect.top, + left: rect.left, + }; + } else if (anchorPosition) { + position = anchorPosition; + } else { + return; + } + + const newOrigins = calculateOptimalOrigins( + position, + width, + height, + { anchorOrigin, transformOrigin }, + anchorPosition ? 20 : 8, + ); + + setOrigins(newOrigins); + }; + return ( - + {children} ); } export default Popover; + diff --git a/frontend/appflowy_web_app/src/components/_shared/skeleton/CalendarSkeleton.tsx b/frontend/appflowy_web_app/src/components/_shared/skeleton/CalendarSkeleton.tsx index 3d8b084304741..8d2bc4d7eb471 100644 --- a/frontend/appflowy_web_app/src/components/_shared/skeleton/CalendarSkeleton.tsx +++ b/frontend/appflowy_web_app/src/components/_shared/skeleton/CalendarSkeleton.tsx @@ -10,7 +10,7 @@ function CalendarSkeleton ({ includeTitle = true, includeTabs = true }: { const weeksInMonth = 4; return ( -
+
{includeTitle && ( <>
@@ -34,7 +34,10 @@ function CalendarSkeleton ({ includeTitle = true, includeTabs = true }: { {/* Weekday Names */}
{[...Array(daysInWeek)].map((_, index) => ( -
+
))}
@@ -42,7 +45,10 @@ function CalendarSkeleton ({ includeTitle = true, includeTabs = true }: {
{[...Array(weeksInMonth * daysInWeek)].map((_, index) => ( -
+
diff --git a/frontend/appflowy_web_app/src/components/_shared/skeleton/DocumentSkeleton.tsx b/frontend/appflowy_web_app/src/components/_shared/skeleton/DocumentSkeleton.tsx index e7d873b2b400c..863e5c723af81 100644 --- a/frontend/appflowy_web_app/src/components/_shared/skeleton/DocumentSkeleton.tsx +++ b/frontend/appflowy_web_app/src/components/_shared/skeleton/DocumentSkeleton.tsx @@ -10,11 +10,11 @@ function DocumentSkeleton () {
-
+
-
+
diff --git a/frontend/appflowy_web_app/src/components/_shared/skeleton/EditorSkeleton.tsx b/frontend/appflowy_web_app/src/components/_shared/skeleton/EditorSkeleton.tsx index b831d7b5d1b7c..4fa334cc4c5de 100644 --- a/frontend/appflowy_web_app/src/components/_shared/skeleton/EditorSkeleton.tsx +++ b/frontend/appflowy_web_app/src/components/_shared/skeleton/EditorSkeleton.tsx @@ -2,7 +2,7 @@ import React from 'react'; function EditorSkeleton () { return ( -
+
diff --git a/frontend/appflowy_web_app/src/components/_shared/skeleton/GridSkeleton.tsx b/frontend/appflowy_web_app/src/components/_shared/skeleton/GridSkeleton.tsx index ae1a2370e4050..fdc80e1e59065 100644 --- a/frontend/appflowy_web_app/src/components/_shared/skeleton/GridSkeleton.tsx +++ b/frontend/appflowy_web_app/src/components/_shared/skeleton/GridSkeleton.tsx @@ -17,7 +17,7 @@ function GridSkeleton ({ includeTitle = true, includeTabs = true }: { includeTit }, []); return ( -
+
{includeTitle && ( <>
@@ -36,7 +36,10 @@ function GridSkeleton ({ includeTitle = true, includeTabs = true }: { includeTit {[...Array(columns)].map((_, index) => ( - +
))} @@ -46,7 +49,10 @@ function GridSkeleton ({ includeTitle = true, includeTabs = true }: { includeTit {[...Array(rows)].map((_, rowIndex) => ( {[...Array(columns)].map((_, colIndex) => ( - +
))} diff --git a/frontend/appflowy_web_app/src/components/_shared/skeleton/KanbanSkeleton.tsx b/frontend/appflowy_web_app/src/components/_shared/skeleton/KanbanSkeleton.tsx index 289bd6391e4cc..225ae50b7d127 100644 --- a/frontend/appflowy_web_app/src/components/_shared/skeleton/KanbanSkeleton.tsx +++ b/frontend/appflowy_web_app/src/components/_shared/skeleton/KanbanSkeleton.tsx @@ -13,7 +13,7 @@ function KanbanSkeleton ({ const cardsPerColumn = Math.max(Math.ceil(window.innerHeight / 300), 3); return ( -
+
{includeTitle && ( <>
@@ -29,13 +29,19 @@ function KanbanSkeleton ({
{[...Array(columns)].map((_, columnIndex) => ( -
+
{/* Column title */}
{/* Cards */} {[...Array(cardsPerColumn)].map((_, cardIndex) => ( -
+
diff --git a/frontend/appflowy_web_app/src/components/_shared/skeleton/RecentListSkeleton.tsx b/frontend/appflowy_web_app/src/components/_shared/skeleton/RecentListSkeleton.tsx index a4a9ec6644373..26ca31a21012a 100644 --- a/frontend/appflowy_web_app/src/components/_shared/skeleton/RecentListSkeleton.tsx +++ b/frontend/appflowy_web_app/src/components/_shared/skeleton/RecentListSkeleton.tsx @@ -2,9 +2,12 @@ import React from 'react'; const RecentListSkeleton = ({ rows = 5 }) => { return ( -
+
{[...Array(rows)].map((_, index) => ( -
+
diff --git a/frontend/appflowy_web_app/src/components/_shared/view-icon/ChangeIconPopover.tsx b/frontend/appflowy_web_app/src/components/_shared/view-icon/ChangeIconPopover.tsx new file mode 100644 index 0000000000000..da146fc844f1e --- /dev/null +++ b/frontend/appflowy_web_app/src/components/_shared/view-icon/ChangeIconPopover.tsx @@ -0,0 +1,119 @@ +import { ViewIconType } from '@/application/types'; +import { EmojiPicker } from '@/components/_shared/emoji-picker'; +import IconPicker from '@/components/_shared/icon-picker/IconPicker'; +import { Popover } from '@/components/_shared/popover'; +import { TabPanel, ViewTab, ViewTabs } from '@/components/_shared/tabs/ViewTabs'; +import { Button } from '@mui/material'; +import { PopoverProps } from '@mui/material/Popover'; +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +function ChangeIconPopover ({ + open, + anchorEl, + onClose, + defaultType, + emojiEnabled = true, + iconEnabled = true, + popoverProps = {}, + onSelectIcon, + removeIcon, + anchorPosition, +}: { + open: boolean, + anchorEl?: HTMLElement | null, + anchorPosition?: PopoverProps['anchorPosition'], + onClose: () => void, + defaultType: 'emoji' | 'icon', + emojiEnabled?: boolean, + iconEnabled?: boolean, + popoverProps?: Partial, + onSelectIcon?: (icon: { ty: ViewIconType, value: string, color?: string }) => void, + removeIcon?: () => void, +}) { + const [value, setValue] = useState(defaultType); + const { t } = useTranslation(); + + return ( + +
+ setValue(newValue)} + value={value} + className={'flex-1 mb-[-2px]'} + > + { + iconEnabled && ( + + ) + } + { + emojiEnabled && ( + + ) + } + + + +
+ + {iconEnabled && + { + onSelectIcon?.({ + ty: ViewIconType.Icon, + ...icon, + }); + onClose(); + }} + /> + } + {emojiEnabled && + { + onSelectIcon?.({ + ty: ViewIconType.Emoji, + value: emoji, + }); + }} + onEscape={onClose} + hideRemove + /> + } +
+ ); +} + +export default ChangeIconPopover; \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/components/app/DatabaseView.tsx b/frontend/appflowy_web_app/src/components/app/DatabaseView.tsx index d32f9c5106c86..4d8ccaab1020d 100644 --- a/frontend/appflowy_web_app/src/components/app/DatabaseView.tsx +++ b/frontend/appflowy_web_app/src/components/app/DatabaseView.tsx @@ -1,11 +1,8 @@ import { - AppendBreadcrumb, - CreateRowDoc, - LoadView, - LoadViewMeta, ViewLayout, + ViewLayout, YDatabase, - YDoc, YjsEditorKey, + ViewComponentProps, } from '@/application/types'; import { findView } from '@/components/_shared/outline/utils'; import ComponentLoading from '@/components/_shared/progress/ComponentLoading'; @@ -15,21 +12,11 @@ import GridSkeleton from '@/components/_shared/skeleton/GridSkeleton'; import KanbanSkeleton from '@/components/_shared/skeleton/KanbanSkeleton'; import { useAppOutline } from '@/components/app/app.hooks'; import { Database } from '@/components/database'; -import DatabaseHeader from '@/components/database/components/header/DatabaseHeader'; -import { ViewMetaProps } from '@/components/view-meta'; import React, { Suspense, useCallback, useMemo } from 'react'; import { useSearchParams } from 'react-router-dom'; +import ViewMetaPreview from 'src/components/view-meta/ViewMetaPreview'; -function DatabaseView ({ viewMeta, ...props }: { - doc: YDoc; - navigateToView?: (viewId: string, blockId?: string) => Promise; - loadViewMeta?: LoadViewMeta; - createRowDoc?: CreateRowDoc; - loadView?: LoadView; - viewMeta: ViewMetaProps; - appendBreadcrumb?: AppendBreadcrumb; - onRendered?: () => void; -}) { +function DatabaseView ({ viewMeta, ...props }: ViewComponentProps) { const [search, setSearch] = useSearchParams(); const outline = useAppOutline(); const iidIndex = viewMeta.viewId; @@ -94,9 +81,13 @@ function DatabaseView ({ viewMeta, ...props }: { style={{ minHeight: 'calc(100vh - 48px)', }} - className={'relative flex h-full w-full flex-col px-6'} + className={'relative flex h-full w-full flex-col'} > - {rowId ? null : } + {rowId ? null : } import('@/components/app/SideBarBottom')); @@ -21,10 +20,6 @@ function SideBar ({ toggleOpenDrawer, onResizeDrawerWidth, }: SideBarProps) { - const outline = useAppOutline(); - - const viewId = useAppViewId(); - const navigateToView = useContext(AppContext)?.toView; return ( toggleOpenDrawer(false)} header={} > -
+
+ - +
); diff --git a/frontend/appflowy_web_app/src/components/app/SideBarBottom.tsx b/frontend/appflowy_web_app/src/components/app/SideBarBottom.tsx index 658ff25302101..102138c4fdad7 100644 --- a/frontend/appflowy_web_app/src/components/app/SideBarBottom.tsx +++ b/frontend/appflowy_web_app/src/components/app/SideBarBottom.tsx @@ -1,30 +1,39 @@ -import { Button, Divider } from '@mui/material'; +import { IconButton, Tooltip } from '@mui/material'; import React from 'react'; import { useTranslation } from 'react-i18next'; -import Trash from 'src/components/app/trash/Trash'; import { ReactComponent as TemplateIcon } from '@/assets/template.svg'; +import { useNavigate } from 'react-router-dom'; +import { ReactComponent as TrashIcon } from '@/assets/trash.svg'; function SideBarBottom () { const { t } = useTranslation(); + const navigate = useNavigate(); return (
- - - + + { + window.open('https://appflowy.io/templates', '_blank'); + }} + > + + + + + + { + navigate('/app/trash'); + }} + > + + +
); } diff --git a/frontend/appflowy_web_app/src/components/app/ViewModal.tsx b/frontend/appflowy_web_app/src/components/app/ViewModal.tsx new file mode 100644 index 0000000000000..7346a7b7f392a --- /dev/null +++ b/frontend/appflowy_web_app/src/components/app/ViewModal.tsx @@ -0,0 +1,222 @@ +import { + ViewComponentProps, + ViewLayout, + YDoc, + ViewMetaProps, +} from '@/application/types'; +import SpaceIcon from '@/components/_shared/breadcrumb/SpaceIcon'; +import { findAncestors, findView } from '@/components/_shared/outline/utils'; +import { useAppHandlers, useAppOutline } from '@/components/app/app.hooks'; +import DatabaseView from '@/components/app/DatabaseView'; +import MoreActions from '@/components/app/header/MoreActions'; +import MovePagePopover from '@/components/app/view-actions/MovePagePopover'; +import { Document } from '@/components/document'; +import RecordNotFound from '@/components/error/RecordNotFound'; +import { Button, Dialog, Divider, IconButton, Tooltip } from '@mui/material'; +import React, { useCallback, useEffect, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ReactComponent as ExpandMoreIcon } from '$icons/16x/full_view.svg'; +import ShareButton from 'src/components/app/share/ShareButton'; +import { ReactComponent as CloseIcon } from '@/assets/close.svg'; +import { ReactComponent as ArrowRightIcon } from '@/assets/arrow_right.svg'; + +function ViewModal ({ + viewId, + open, + onClose, +}: { + viewId: string; + open: boolean; + onClose: () => void; +}) { + const { t } = useTranslation(); + const { + toView, + loadViewMeta, + createRowDoc, + loadView, + updatePage, + addPage, + deletePage, + openPageModal, + loadViews, + setWordCount, + } = useAppHandlers(); + const outline = useAppOutline(); + const [doc, setDoc] = React.useState(undefined); + const [notFound, setNotFound] = React.useState(false); + const loadPageDoc = useCallback(async () => { + + if (!viewId) { + return; + } + + setNotFound(false); + setDoc(undefined); + try { + const doc = await loadView(viewId); + + setDoc(doc); + } catch (e) { + setNotFound(true); + console.error(e); + } + + }, [loadView, viewId]); + + useEffect(() => { + void loadPageDoc(); + }, [loadPageDoc]); + + const view = useMemo(() => { + if (!outline || !viewId) return; + return findView(outline, viewId); + }, [outline, viewId]); + + const viewMeta: ViewMetaProps | null = useMemo(() => { + return view ? { + name: view.name, + icon: view.icon || undefined, + cover: view.extra?.cover || undefined, + layout: view.layout, + visibleViewIds: [], + viewId: view.view_id, + extra: view.extra, + } : null; + }, [view]); + + const [movePopoverAnchorEl, setMovePopoverAnchorEl] = React.useState(null); + + const onMoved = useCallback(() => { + setMovePopoverAnchorEl(null); + }, []); + + const modalTitle = useMemo(() => { + const space = findAncestors(outline || [], viewId)?.find(item => item.extra?.is_space); + + return ( +
+
+ + { + onClose(); + + void toView(viewId); + }} + > + + + + + {space && ( + + )} +
+ +
+ + { + onClose(); + }} + viewId={viewId} + /> + + + + + +
+ +
+ ); + }, [onClose, outline, t, toView, viewId]); + + const layout = view?.layout || ViewLayout.Document; + + const View = useMemo(() => { + switch (layout) { + case ViewLayout.Document: + return Document; + case ViewLayout.Grid: + case ViewLayout.Board: + case ViewLayout.Calendar: + return DatabaseView; + default: + return null; + } + }, [layout]) as React.FC; + + const viewDom = useMemo(() => { + + if (!doc || !viewMeta) return null; + return ; + }, [openPageModal, setWordCount, loadViews, doc, viewMeta, View, toView, loadViewMeta, createRowDoc, loadView, updatePage, addPage, deletePage]); + + return ( + + {modalTitle} + {notFound ? ( + + ) : ( +
+ {viewDom} +
+ )} + setMovePopoverAnchorEl(null)} + onMoved={onMoved} + /> +
+ ); +} + +export default ViewModal; \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/components/app/app.hooks.tsx b/frontend/appflowy_web_app/src/components/app/app.hooks.tsx index 08a40cd65b50b..15efa727de20f 100644 --- a/frontend/appflowy_web_app/src/components/app/app.hooks.tsx +++ b/frontend/appflowy_web_app/src/components/app/app.hooks.tsx @@ -1,22 +1,31 @@ import { invalidToken } from '@/application/session/token'; import { - AppendBreadcrumb, - CreateRowDoc, + AppendBreadcrumb, CreatePagePayload, + CreateRowDoc, CreateSpacePayload, DatabaseRelations, LoadView, - LoadViewMeta, Types, + LoadViewMeta, + Types, + UIVariant, + UpdatePagePayload, UpdateSpacePayload, UserWorkspaceInfo, View, - ViewLayout, YjsDatabaseKey, YjsEditorKey, YSharedRoot, + ViewLayout, + YjsDatabaseKey, + YjsEditorKey, + YSharedRoot, } from '@/application/types'; import { findAncestors, findView, findViewByLayout } from '@/components/_shared/outline/utils'; import RequestAccess from '@/components/app/landing-pages/RequestAccess'; import { AFConfigContext, useService } from '@/components/main/app.hooks'; -import { uniqBy } from 'lodash-es'; -import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; +import { TextCount } from '@/utils/word'; +import { sortBy, uniqBy } from 'lodash-es'; +import React, { createContext, Suspense, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import { validate as uuidValidate } from 'uuid'; +const ViewModal = React.lazy(() => import('@/components/app/ViewModal')); + export interface AppContextType { toView: (viewId: string, blockId?: string, keepSearch?: boolean) => Promise; loadViewMeta: LoadViewMeta; @@ -24,13 +33,15 @@ export interface AppContextType { loadView: LoadView; outline?: View[]; viewId?: string; + wordCount?: Record; + setWordCount?: (viewId: string, count: TextCount) => void; currentWorkspaceId?: string; onChangeWorkspace?: (workspaceId: string) => Promise; userWorkspaceInfo?: UserWorkspaceInfo; breadcrumbs?: View[]; appendBreadcrumb?: AppendBreadcrumb; - loadFavoriteViews?: () => Promise; - loadRecentViews?: () => Promise; + loadFavoriteViews?: () => Promise; + loadRecentViews?: () => Promise; loadTrash?: (workspaceId: string) => Promise; favoriteViews?: View[]; recentViews?: View[]; @@ -39,6 +50,17 @@ export interface AppContextType { onRendered?: () => void; notFound?: boolean; viewHasBeenDeleted?: boolean; + addPage?: (parentId: string, payload: CreatePagePayload) => Promise; + deletePage?: (viewId: string) => Promise; + updatePage?: (viewId: string, payload: UpdatePagePayload) => Promise; + deleteTrash?: (viewId?: string) => Promise; + restorePage?: (viewId?: string) => Promise; + movePage?: (viewId: string, parentId: string) => Promise; + openPageModal?: (viewId: string) => void; + openPageModalViewId?: string; + loadViews?: (variant?: UIVariant) => Promise; + createSpace?: (payload: CreateSpacePayload) => Promise; + updateSpace?: (payload: UpdateSpacePayload) => Promise; } const USER_NO_ACCESS_CODE = [1024, 1012]; @@ -54,6 +76,12 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => { if (id && !uuidValidate(id)) return; return id; }, [params.viewId]); + const [openModalViewId, setOpenModalViewId] = useState(undefined); + const wordCountRef = useRef>({}); + const setWordCount = useCallback((viewId: string, count: TextCount) => { + wordCountRef.current[viewId] = count; + }, []); + const [userWorkspaceInfo, setUserWorkspaceInfo] = useState(undefined); const currentWorkspaceId = useMemo(() => params.workspaceId || userWorkspaceInfo?.selectedWorkspace.id, [params.workspaceId, userWorkspaceInfo?.selectedWorkspace.id]); const [workspaceDatabases, setWorkspaceDatabases] = useState(undefined); @@ -268,7 +296,7 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => { } }, [service]); - const loadOutline = useCallback(async (workspaceId: string) => { + const loadOutline = useCallback(async (workspaceId: string, force = true) => { if (!service) return; try { @@ -279,6 +307,7 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => { } setOutline(res); + if (!force) return; const firstView = findViewByLayout(res, [ViewLayout.Document, ViewLayout.Board, ViewLayout.Grid, ViewLayout.Calendar]); @@ -337,6 +366,7 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => { } setFavoriteViews(res); + return res; } catch (e) { console.error('Favorite views not found'); } @@ -351,7 +381,10 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => { throw new Error('Recent views not found'); } - setRecentViews(uniqBy(res, 'view_id')); + const views = uniqBy(res, 'view_id'); + + setRecentViews(views); + return views; } catch (e) { console.error('Recent views not found'); } @@ -367,7 +400,7 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => { throw new Error('App trash not found'); } - setTrashList(res); + setTrashList(sortBy(uniqBy(res, 'view_id'), 'last_edited_time').reverse()); } catch (e) { return Promise.reject('App trash not found'); } @@ -403,11 +436,161 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => { } await service.openWorkspace(workspaceId); + await loadUserWorkspaceInfo(); localStorage.removeItem('last_view_id'); setOutline(undefined); navigate(`/app/${workspaceId}`); - }, [navigate, service, userWorkspaceInfo]); + }, [navigate, service, userWorkspaceInfo, loadUserWorkspaceInfo]); + + const addPage = useCallback(async (parentViewId: string, payload: CreatePagePayload) => { + if (!currentWorkspaceId || !service) { + throw new Error('No workspace or service found'); + } + + try { + const viewId = await service.addAppPage(currentWorkspaceId, parentViewId, payload); + + void loadOutline(currentWorkspaceId, false); + + return viewId; + } catch (e) { + return Promise.reject(e); + } + }, [currentWorkspaceId, service, loadOutline]); + + const openPageModal = useCallback((viewId: string) => { + setOpenModalViewId(viewId); + }, []); + + const deletePage = useCallback(async (id: string) => { + if (!currentWorkspaceId || !service) { + throw new Error('No workspace or service found'); + } + + try { + await service.moveToTrash(currentWorkspaceId, id); + void loadTrash(currentWorkspaceId); + void loadOutline(currentWorkspaceId, false); + return; + } catch (e) { + return Promise.reject(e); + } + }, [currentWorkspaceId, service, loadTrash, loadOutline]); + + const deleteTrash = useCallback(async (viewId?: string) => { + if (!currentWorkspaceId || !service) { + throw new Error('No workspace or service found'); + } + + try { + await service.deleteTrash(currentWorkspaceId, viewId); + + void loadOutline(currentWorkspaceId, false); + return; + } catch (e) { + return Promise.reject(e); + } + }, [currentWorkspaceId, service, loadOutline]); + + const restorePage = useCallback(async (viewId?: string) => { + if (!currentWorkspaceId || !service) { + throw new Error('No workspace or service found'); + } + + try { + await service.restoreFromTrash(currentWorkspaceId, viewId); + + void loadOutline(currentWorkspaceId, false); + return; + } catch (e) { + return Promise.reject(e); + } + }, [currentWorkspaceId, service, loadOutline]); + + const updatePage = useCallback(async (viewId: string, payload: UpdatePagePayload) => { + if (!currentWorkspaceId || !service) { + throw new Error('No workspace or service found'); + } + + try { + await service.updateAppPage(currentWorkspaceId, viewId, payload); + + void loadOutline(currentWorkspaceId, false); + return; + } catch (e) { + return Promise.reject(e); + } + }, [currentWorkspaceId, service, loadOutline]); + + const movePage = useCallback(async (viewId: string, parentId: string) => { + if (!currentWorkspaceId || !service) { + throw new Error('No workspace or service found'); + } + + try { + await service.movePage(currentWorkspaceId, viewId, parentId); + + void loadOutline(currentWorkspaceId, false); + return; + } catch (e) { + return Promise.reject(e); + } + }, [currentWorkspaceId, service, loadOutline]); + + const loadViews = useCallback(async (varient?: UIVariant) => { + if (!varient) { + return outline || []; + } + + if (varient === UIVariant.Favorite) { + if (favoriteViews && favoriteViews.length > 0) { + return favoriteViews || []; + } else { + return loadFavoriteViews(); + } + } + + if (varient === UIVariant.Recent) { + if (recentViews && recentViews.length > 0) { + return recentViews || []; + } else { + return loadRecentViews(); + } + } + + return []; + }, [favoriteViews, loadFavoriteViews, loadRecentViews, outline, recentViews]); + + const createSpace = useCallback(async (payload: CreateSpacePayload) => { + if (!currentWorkspaceId || !service) { + throw new Error('No workspace or service found'); + } + + try { + const res = await service.createSpace(currentWorkspaceId, payload); + + void loadOutline(currentWorkspaceId, false); + return res; + } catch (e) { + return Promise.reject(e); + } + }, [currentWorkspaceId, service, loadOutline]); + + const updateSpace = useCallback(async (payload: UpdateSpacePayload) => { + if (!currentWorkspaceId || !service) { + throw new Error('No workspace or service found'); + } + + try { + const res = await service.updateSpace(currentWorkspaceId, payload); + + void loadOutline(currentWorkspaceId, false); + return res; + } catch (e) { + return Promise.reject(e); + } + }, [currentWorkspaceId, service, loadOutline]); return { onRendered, notFound: viewNotFound, viewHasBeenDeleted, + addPage, + openPageModal, + openPageModalViewId: openModalViewId, + deletePage, + deleteTrash, + updatePage, + movePage, + restorePage, + loadViews, + wordCount: wordCountRef.current, + setWordCount, + createSpace, + updateSpace, }} > {requestAccessOpened ? : children} + {openModalViewId && { + setOpenModalViewId(undefined); + }} + />} ; }; @@ -491,16 +694,42 @@ export function useAppViewId () { return context.viewId; } -export function useAppView () { - const viewId = useAppViewId(); - const outline = useAppOutline(); - const view = useMemo(() => viewId ? findView(outline || [], viewId) : null, [outline, viewId]); +export function useAppWordCount (viewId?: string | null) { + const context = useContext(AppContext); + + if (!context) { + throw new Error('useAppWordCount must be used within an AppProvider'); + } + + if (!viewId) { + return; + } + + return context.wordCount?.[viewId]; +} + +export function useOpenModalViewId () { + const context = useContext(AppContext); + + if (!context) { + throw new Error('useOpenModalViewId must be used within an AppProvider'); + } + + return context.openPageModalViewId; +} + +export function useAppView (viewId?: string) { + const context = useContext(AppContext); + + if (!context) { + throw new Error('useAppView must be used within an AppProvider'); + } - if (!viewId || !outline) { + if (!viewId) { return; } - return view; + return findView(context.outline || [], viewId); } export function useCurrentWorkspaceId () { @@ -528,6 +757,17 @@ export function useAppHandlers () { appendBreadcrumb: context.appendBreadcrumb, onChangeWorkspace: context.onChangeWorkspace, onRendered: context.onRendered, + addPage: context.addPage, + openPageModal: context.openPageModal, + deletePage: context.deletePage, + deleteTrash: context.deleteTrash, + restorePage: context.restorePage, + updatePage: context.updatePage, + movePage: context.movePage, + loadViews: context.loadViews, + setWordCount: context.setWordCount, + createSpace: context.createSpace, + updateSpace: context.updateSpace, }; } diff --git a/frontend/appflowy_web_app/src/components/app/favorite/Favorite.tsx b/frontend/appflowy_web_app/src/components/app/favorite/Favorite.tsx index 7e6a4f6f84c95..17932a0d29d3a 100644 --- a/frontend/appflowy_web_app/src/components/app/favorite/Favorite.tsx +++ b/frontend/appflowy_web_app/src/components/app/favorite/Favorite.tsx @@ -156,7 +156,6 @@ export function Favorite () { onClose={() => setMoreOpened(false)} >
- {groupByViews}
diff --git a/frontend/appflowy_web_app/src/components/app/header/AppHeader.tsx b/frontend/appflowy_web_app/src/components/app/header/AppHeader.tsx index d18b9487d0e2f..0375bd94a7b39 100644 --- a/frontend/appflowy_web_app/src/components/app/header/AppHeader.tsx +++ b/frontend/appflowy_web_app/src/components/app/header/AppHeader.tsx @@ -7,7 +7,7 @@ import { IconButton } from '@mui/material'; import { ReactComponent as SideOutlined } from '@/assets/side_outlined.svg'; import React, { memo, lazy, Suspense, useContext, useMemo } from 'react'; -import Recent from 'src/components/app/recent/Recent'; +import Recent from '@/components/app/recent/Recent'; import OutlinePopover from '@/components/_shared/outline/OutlinePopover'; @@ -64,6 +64,7 @@ export function AppHeader ({ content={recent} > - + diff --git a/frontend/appflowy_web_app/src/components/app/header/DocumentInfo.tsx b/frontend/appflowy_web_app/src/components/app/header/DocumentInfo.tsx new file mode 100644 index 0000000000000..1d177b6597491 --- /dev/null +++ b/frontend/appflowy_web_app/src/components/app/header/DocumentInfo.tsx @@ -0,0 +1,66 @@ +import { useAppView, useAppWordCount } from '@/components/app/app.hooks'; +import { Divider } from '@mui/material'; +import dayjs from 'dayjs'; +import React, { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; + +function DocumentInfo ({ viewId }: { + viewId: string; +}) { + const view = useAppView(viewId); + const { t } = useTranslation(); + const wordCount = useAppWordCount(viewId); + const formatTime = useCallback((timestamp: string) => { + const now = dayjs(); + const past = dayjs(timestamp); + + const diffSec = now.diff(past, 'second'); + const diffMin = now.diff(past, 'minute'); + const diffHour = now.diff(past, 'hour'); + + if (diffSec < 5) { + return t('globalComment.showSeconds', { + count: 0, + }); + } + + if (diffMin < 1) { + return t('globalComment.showSeconds', { + count: diffSec, + }); + } + + if (diffHour < 1) { + return t('globalComment.showMinutes', { + count: diffMin, + }); + } + + return dayjs(timestamp).format('MMM D, YYYY hh:mm'); + }, [t]); + + if (!view) return null; + + return ( + <> + +
+
+ {t('moreAction.wordCountLabel')}{wordCount?.words} +
+
+ {t('moreAction.charCountLabel')}{wordCount?.characters} +
+ {view.created_at &&
+ {t('moreAction.createdAtLabel')}{formatTime(view.created_at)} +
} + + +
+ + ); +} + +export default DocumentInfo; \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/components/app/header/MoreActions.tsx b/frontend/appflowy_web_app/src/components/app/header/MoreActions.tsx new file mode 100644 index 0000000000000..d4efb1192e727 --- /dev/null +++ b/frontend/appflowy_web_app/src/components/app/header/MoreActions.tsx @@ -0,0 +1,81 @@ +import DocumentInfo from '@/components/app/header/DocumentInfo'; +import MoreActionsContent from './MoreActionsContent'; +import React from 'react'; +import { Popover } from '@/components/_shared/popover'; +import { IconButton } from '@mui/material'; +import { ReactComponent as MoreIcon } from '@/assets/more.svg'; + +function MoreActions ({ + viewId, + onDeleted, +}: { + viewId: string; + onDeleted?: () => void; +}) { + + const [anchorEl, setAnchorEl] = React.useState(null); + const handleClick = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); + }; + + const handleClose = () => { + setAnchorEl(null); + }; + + const open = Boolean(anchorEl); + + return ( + <> + + + + {open && ( + + { + handleClose(); + }} + onDeleted={onDeleted} + viewId={viewId} + movePopoverOrigins={{ + transformOrigin: { + vertical: 'top', + horizontal: 'right', + }, + anchorOrigin: { + vertical: 'top', + horizontal: -20, + }, + }} + /> + {open && } + + )} + + ); +} + +export default MoreActions; \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/components/app/header/MoreActionsContent.tsx b/frontend/appflowy_web_app/src/components/app/header/MoreActionsContent.tsx new file mode 100644 index 0000000000000..b00a36aeada9d --- /dev/null +++ b/frontend/appflowy_web_app/src/components/app/header/MoreActionsContent.tsx @@ -0,0 +1,80 @@ +import { Origins } from '@/components/_shared/popover'; +import DeletePageConfirm from '@/components/app/view-actions/DeletePageConfirm'; +import MovePagePopover from '@/components/app/view-actions/MovePagePopover'; +import { Button } from '@mui/material'; +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ReactComponent as DeleteIcon } from '@/assets/trash.svg'; +import { ReactComponent as DuplicateIcon } from '@/assets/duplicate.svg'; +import { ReactComponent as MoveToIcon } from '@/assets/move_to.svg'; + +function MoreActionsContent ({ itemClicked, viewId, movePopoverOrigins, onDeleted }: { + itemClicked?: () => void; + onDeleted?: () => void; + viewId: string; + movePopoverOrigins: Origins +}) { + const { t } = useTranslation(); + const [movePopoverAnchorEl, setMovePopoverAnchorEl] = useState(null); + const [deleteModalOpen, setDeleteModalOpen] = useState(false); + + return ( +
+ + + + { + setDeleteModalOpen(false); + itemClicked?.(); + }} + viewId={viewId} + onDeleted={() => { + onDeleted?.(); + itemClicked?.(); + }} + /> + { + setMovePopoverAnchorEl(null); + itemClicked?.(); + }} + onMoved={itemClicked} + /> +
+ ); +} + +export default MoreActionsContent; \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/components/app/header/RightMenu.tsx b/frontend/appflowy_web_app/src/components/app/header/RightMenu.tsx index 14e2cb4a21335..fc796f3cfb5c1 100644 --- a/frontend/appflowy_web_app/src/components/app/header/RightMenu.tsx +++ b/frontend/appflowy_web_app/src/components/app/header/RightMenu.tsx @@ -1,30 +1,20 @@ -import { NormalModal } from '@/components/_shared/modal'; -import MoreActions from '@/components/_shared/more-actions/MoreActions'; +import MoreActions from './MoreActions'; +import { useAppViewId } from '@/components/app/app.hooks'; import { openOrDownload } from '@/utils/open_schema'; -import { Button, Divider, Tooltip } from '@mui/material'; +import { Divider, Tooltip } from '@mui/material'; import React from 'react'; import { useTranslation } from 'react-i18next'; import ShareButton from 'src/components/app/share/ShareButton'; import { ReactComponent as Logo } from '@/assets/logo.svg'; -import { ReactComponent as EditOutlined } from '@/assets/edit.svg'; function RightMenu () { const { t } = useTranslation(); - const [comingSoon, setComingSoon] = React.useState(false); + const viewId = useAppViewId(); return (
- - - + {viewId && } + {viewId && } - setComingSoon(false)} - okText={t('button.gotIt')} - title={ -
{'❤️ Coming Soon'}
- } - open={comingSoon} - onClose={() => setComingSoon(false)} - > -
- 🌟 This feature is coming soon. Stay tuned! -
-
); } diff --git a/frontend/appflowy_web_app/src/components/app/outline/Outline.tsx b/frontend/appflowy_web_app/src/components/app/outline/Outline.tsx new file mode 100644 index 0000000000000..2f60e81f3fbab --- /dev/null +++ b/frontend/appflowy_web_app/src/components/app/outline/Outline.tsx @@ -0,0 +1,60 @@ +import { View } from '@/application/types'; +import { getOutlineExpands, setOutlineExpands } from '@/components/_shared/outline/utils'; +import DirectoryStructure from '@/components/_shared/skeleton/DirectoryStructure'; +import { useAppHandlers, useAppOutline } from '@/components/app/app.hooks'; +import SpaceItem from '@/components/app/outline/SpaceItem'; +import React, { useCallback, Suspense } from 'react'; + +const ViewActions = React.lazy(() => import('@/components/app/view-actions/ViewActions')); + +export function Outline ({ + width, +}: { + width: number; +}) { + const outline = useAppOutline(); + const [expandViewIds, setExpandViewIds] = React.useState(Object.keys(getOutlineExpands())); + const toggleExpandView = useCallback((id: string, isExpanded: boolean) => { + + setOutlineExpands(id, isExpanded); + setExpandViewIds((prev) => { + return isExpanded ? [...prev, id] : prev.filter((v) => v !== id); + }); + }, []); + const renderActions = useCallback(({ hovered, view }: { hovered: boolean; view: View }) => { + return ; + }, []); + + const { + toView, + } = useAppHandlers(); + + const onClickView = useCallback((viewId: string) => { + void toView(viewId); + }, [toView]); + + return ( +
+ {!outline || outline.length === 0 ?
+
: + outline.map((view) => )} +
+ ); +} + +export default Outline; \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/components/app/outline/SpaceItem.tsx b/frontend/appflowy_web_app/src/components/app/outline/SpaceItem.tsx new file mode 100644 index 0000000000000..547b59ccc68f0 --- /dev/null +++ b/frontend/appflowy_web_app/src/components/app/outline/SpaceItem.tsx @@ -0,0 +1,106 @@ +import SpaceIcon from '@/components/_shared/breadcrumb/SpaceIcon'; +import ViewItem from '@/components/app/outline/ViewItem'; +import { Tooltip } from '@mui/material'; +import React, { useMemo } from 'react'; +import { View } from '@/application/types'; +import { ReactComponent as PrivateIcon } from '@/assets/lock.svg'; + +function SpaceItem ({ + view, + width, + renderExtra, + expandIds, + toggleExpand, + onClickView, +}: { + view: View; + width: number; + expandIds: string[]; + toggleExpand: (id: string, isExpand: boolean) => void; + renderExtra?: ({ + hovered, + view, + }: { + hovered: boolean; + view: View + }) => React.ReactNode; + onClickView?: (viewId: string) => void; +}) { + const [hovered, setHovered] = React.useState(false); + const isExpanded = expandIds.includes(view.view_id); + const isPrivate = view.is_private; + const renderItem = useMemo(() => { + if (!view) return null; + const extra = view?.extra; + const name = view?.name || ''; + + return ( +
{ + toggleExpand(view.view_id, !isExpanded); + }} + onMouseEnter={() => setHovered(true)} + onMouseLeave={() => setHovered(false)} + className={ + 'flex items-center px-1 truncate cursor-pointer min-h-[34px] w-full gap-0.5 rounded-[8px] py-1.5 text-sm hover:bg-fill-list-hover focus:bg-content-blue-50 focus:outline-none' + } + > + + +
+
{name}
+ + {isPrivate && +
+ +
+ } +
+
+ { + renderExtra && renderExtra({ hovered, view })} +
+ ); + }, [hovered, isExpanded, isPrivate, renderExtra, toggleExpand, view, width]); + + const renderChildren = useMemo(() => { + return
{ + view?.children?.map((child) => ( + + )) + }
; + }, [onClickView, isExpanded, view?.children, width, renderExtra, expandIds, toggleExpand]); + + return ( +
+ {renderItem} + {renderChildren} +
+ ); +} + +export default SpaceItem; \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/components/app/outline/ViewItem.tsx b/frontend/appflowy_web_app/src/components/app/outline/ViewItem.tsx new file mode 100644 index 0000000000000..0e981a4c583d5 --- /dev/null +++ b/frontend/appflowy_web_app/src/components/app/outline/ViewItem.tsx @@ -0,0 +1,179 @@ +import { View, ViewIconType, ViewLayout } from '@/application/types'; +import { notify } from '@/components/_shared/notify'; +import OutlineIcon from '@/components/_shared/outline/OutlineIcon'; +import { Origins } from '@/components/_shared/popover'; +import { ViewIcon } from '@/components/_shared/view-icon'; +import { useAppHandlers, useAppViewId } from '@/components/app/app.hooks'; +import { isFlagEmoji } from '@/utils/emoji'; +import React, { lazy, Suspense, useCallback, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Tooltip } from '@mui/material'; + +const ChangeIconPopover = lazy(() => import('@/components/_shared/view-icon/ChangeIconPopover')); + +const popoverProps: Origins = { + transformOrigin: { + vertical: 'top', + horizontal: 'left', + }, + anchorOrigin: { + vertical: 'top', + horizontal: 'right', + }, +}; + +function ViewItem ({ view, width, level = 0, renderExtra, expandIds, toggleExpand, onClickView }: { + view: View; + width: number; + level?: number; + renderExtra?: ({ + hovered, + view, + }: { + hovered: boolean; + view: View + }) => React.ReactNode; + expandIds: string[]; + toggleExpand: (id: string, isExpand: boolean) => void; + onClickView?: (viewId: string) => void; +}) { + const { t } = useTranslation(); + const selectedViewId = useAppViewId(); + const viewId = view.view_id; + const selected = selectedViewId === viewId; + const { updatePage } = useAppHandlers(); + + const isExpanded = expandIds.includes(viewId); + const [hovered, setHovered] = React.useState(false); + const [iconPopoverAnchorEl, setIconPopoverAnchorEl] = React.useState(null); + const openIconPopover = Boolean(iconPopoverAnchorEl); + + const getIcon = useCallback(() => { + return { + toggleExpand(viewId, status); + }} + />; + }, [isExpanded, level, toggleExpand, viewId]); + + const renderItem = useMemo(() => { + if (!view) return null; + const { layout, icon } = view; + + return ( +
setHovered(true)} + onMouseLeave={() => setHovered(false)} + onClick={() => { + onClickView?.(viewId); + }} + className={ + 'flex items-center my-0.5 overflow-hidden cursor-pointer min-h-[34px] w-full gap-1 rounded-[8px] py-1.5 px-0.5 text-sm hover:bg-fill-list-hover focus:outline-none' + } + > + {view.children?.length ? getIcon() : null} +
{ + e.stopPropagation(); + setIconPopoverAnchorEl(e.currentTarget); + }} + className={`${icon && isFlagEmoji(icon.value) ? 'icon' : ''}`} + > + {icon?.value || } +
+ +
+
{view.name || t('menuAppHeader.defaultNewPageName')}
+
+
+ {renderExtra && renderExtra({ hovered, view })} +
+ ); + }, [view, selected, level, getIcon, t, renderExtra, hovered, onClickView, viewId]); + + const renderChildren = useMemo(() => { + return
{ + view?.children?.map((child) => ( + + )) + }
; + }, [toggleExpand, onClickView, isExpanded, expandIds, level, renderExtra, view?.children, width]); + + const handleChangeIcon = useCallback(async (icon: { ty: ViewIconType, value: string }) => { + + try { + await updatePage?.(view.view_id, { + icon: icon, + name: view.name, + extra: view.extra || {}, + }); + setIconPopoverAnchorEl(null); + + // eslint-disable-next-line + } catch (e: any) { + notify.error(e); + } + }, [updatePage, view.extra, view.name, view.view_id]); + + const handleRemoveIcon = useCallback(() => { + void handleChangeIcon({ ty: 0, value: '' }); + }, [handleChangeIcon]); + + return ( +
+ {renderItem} + {renderChildren} + + { + setIconPopoverAnchorEl(null); + }} + popoverProps={popoverProps} + onSelectIcon={handleChangeIcon} + removeIcon={handleRemoveIcon} + /> + +
+ ); +} + +export default ViewItem; \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/components/app/outline/index.ts b/frontend/appflowy_web_app/src/components/app/outline/index.ts new file mode 100644 index 0000000000000..d2c3b875a575f --- /dev/null +++ b/frontend/appflowy_web_app/src/components/app/outline/index.ts @@ -0,0 +1 @@ +export * from './Outline'; \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/components/app/share/PublishPanel.tsx b/frontend/appflowy_web_app/src/components/app/share/PublishPanel.tsx index 98a039f4f902e..26062de812272 100644 --- a/frontend/appflowy_web_app/src/components/app/share/PublishPanel.tsx +++ b/frontend/appflowy_web_app/src/components/app/share/PublishPanel.tsx @@ -8,22 +8,24 @@ import React, { useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { ReactComponent as PublishIcon } from '@/assets/publish.svg'; -function PublishPanel () { - const view = useAppView(); +function PublishPanel ({ viewId }: { viewId: string }) { + const view = useAppView(viewId); const currentWorkspaceId = useCurrentWorkspaceId(); const { t } = useTranslation(); const { url, - } = useLoadPublishInfo(); + } = useLoadPublishInfo(viewId); const renderPublished = useCallback(() => { return
; @@ -33,18 +35,24 @@ function PublishPanel () { return ; }, [currentWorkspaceId, t, view?.view_id]); return (
- + {t('shareAction.publishToTheWeb')} {t('shareAction.publishToTheWebHint')} {view?.is_published ? renderPublished() : renderUnpublished()}
diff --git a/frontend/appflowy_web_app/src/components/app/share/ShareButton.tsx b/frontend/appflowy_web_app/src/components/app/share/ShareButton.tsx index a2199417ec387..9edabb4e3379a 100644 --- a/frontend/appflowy_web_app/src/components/app/share/ShareButton.tsx +++ b/frontend/appflowy_web_app/src/components/app/share/ShareButton.tsx @@ -4,7 +4,7 @@ import { Button } from '@mui/material'; import React, { useRef } from 'react'; import { useTranslation } from 'react-i18next'; -export function ShareButton () { +export function ShareButton ({ viewId }: { viewId: string }) { const { t } = useTranslation(); const [opened, setOpened] = React.useState(false); @@ -16,11 +16,32 @@ export function ShareButton () { className={'max-sm:hidden'} onClick={() => { setOpened(true); - }} ref={ref} size={'small'} variant={'contained'} color={'primary'} + }} + ref={ref} + size={'small'} + variant={'contained'} + color={'primary'} >{t('shareAction.buttonText')} - {opened && setOpened(false)}> + {opened && setOpened(false)} + sx={{ + '& .MuiPopover-paper': { + margin: '8px 0', + }, + }} + transformOrigin={{ + vertical: 'top', + horizontal: 'center', + }} + anchorOrigin={{ + vertical: 'bottom', + horizontal: 'center', + }} + >
- +
} diff --git a/frontend/appflowy_web_app/src/components/app/share/ShareTabs.tsx b/frontend/appflowy_web_app/src/components/app/share/ShareTabs.tsx index 1a8bf2557641b..81b6737717363 100644 --- a/frontend/appflowy_web_app/src/components/app/share/ShareTabs.tsx +++ b/frontend/appflowy_web_app/src/components/app/share/ShareTabs.tsx @@ -13,9 +13,9 @@ enum TabKey { TEMPLATE = 'template', } -function ShareTabs () { +function ShareTabs ({ viewId }: { viewId: string }) { const { t } = useTranslation(); - const view = useAppView(); + const view = useAppView(viewId); const [value, setValue] = React.useState(TabKey.PUBLISH); const currentUser = useCurrentUser(); @@ -34,7 +34,7 @@ function ShareTabs () { value: TabKey; label: string; icon?: React.JSX.Element; - Panel: React.FC + Panel: React.FC<{ viewId: string }> }[]; }, [currentUser?.email, t, view?.is_published]); @@ -45,10 +45,16 @@ function ShareTabs () { return ( <> - + {options.map((option) => ( @@ -57,9 +63,12 @@ function ShareTabs () {
{options.map((option) => ( - + ))}
diff --git a/frontend/appflowy_web_app/src/components/app/share/TemplatePanel.tsx b/frontend/appflowy_web_app/src/components/app/share/TemplatePanel.tsx index c8c3bdc900430..ee00df1fee34d 100644 --- a/frontend/appflowy_web_app/src/components/app/share/TemplatePanel.tsx +++ b/frontend/appflowy_web_app/src/components/app/share/TemplatePanel.tsx @@ -12,8 +12,8 @@ import { useTranslation } from 'react-i18next'; import { ReactComponent as DeleteIcon } from '@/assets/trash.svg'; import { ReactComponent as EditIcon } from '@/assets/edit.svg'; -function TemplatePanel () { - const view = useAppView(); +function TemplatePanel ({ viewId }: { viewId: string }) { + const view = useAppView(viewId); const service = useService(); const [loading, setLoading] = React.useState(false); const [deleteModalOpen, setDeleteModalOpen] = React.useState(false); @@ -35,7 +35,7 @@ function TemplatePanel () { const { t } = useTranslation(); const { url: publishUrl, - } = useLoadPublishInfo(); + } = useLoadPublishInfo(viewId); const url = useMemo(() => { const origin = import.meta.env.AF_BASE_URL?.includes('test') ? 'https://test.appflowy.io' : 'https://appflowy.io'; @@ -49,11 +49,22 @@ function TemplatePanel () { const renderLoading = useCallback(() => { return <> - +
- + - +
; }, []); @@ -70,15 +81,19 @@ function TemplatePanel () { color={'error'} variant={'contained'} startIcon={} - className={'flex-1'} onClick={() => { - setDeleteModalOpen(true); - }} + className={'flex-1'} + onClick={() => { + setDeleteModalOpen(true); + }} > {t('button.delete')}
; @@ -86,7 +101,7 @@ function TemplatePanel () { return (
- {loading ? renderLoading() : template ? renderTemplateButtons() : } + {loading ? renderLoading() : template ? renderTemplateButtons() : } {deleteModalOpen && view && (); const service = useService(); diff --git a/frontend/appflowy_web_app/src/components/app/view-actions/AddPageActions.tsx b/frontend/appflowy_web_app/src/components/app/view-actions/AddPageActions.tsx new file mode 100644 index 0000000000000..45f122b6dd292 --- /dev/null +++ b/frontend/appflowy_web_app/src/components/app/view-actions/AddPageActions.tsx @@ -0,0 +1,108 @@ +import { View, ViewLayout } from '@/application/types'; +import { notify } from '@/components/_shared/notify'; +import { ViewIcon } from '@/components/_shared/view-icon'; +import { useAppHandlers } from '@/components/app/app.hooks'; +import { Button } from '@mui/material'; +import CircularProgress from '@mui/material/CircularProgress'; +import React, { useCallback, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +function AddPageActions ({ view, onClose }: { + view: View; + onClose: () => void; +}) { + const { t } = useTranslation(); + const { + addPage, + openPageModal, + } = useAppHandlers(); + + const handleAddPage = useCallback(async (layout: ViewLayout, name?: string) => { + if (!addPage || !openPageModal) return; + notify.default( + + + {t('document.creating')} + , + ); + try { + const viewId = await addPage(view.view_id, { layout, name }); + + openPageModal(viewId); + notify.clear(); + // eslint-disable-next-line + } catch (e: any) { + notify.clear(); + notify.error(e.message); + } + }, [addPage, openPageModal, t, view.view_id]); + + const actions: { + label: string; + icon: React.ReactNode; + onClick: (e: React.MouseEvent) => void; + }[] = useMemo(() => [ + { + label: t('document.menuName'), + icon: , + onClick: () => { + void handleAddPage(ViewLayout.Document); + }, + }, + { + label: t('grid.menuName'), + icon: , + onClick: () => { + void handleAddPage(ViewLayout.Grid, 'Table'); + }, + }, + { + label: t('board.menuName'), + icon: , + onClick: () => { + void handleAddPage(ViewLayout.Board, 'Board'); + }, + }, + { + label: t('calendar.menuName'), + icon: , + onClick: () => { + void handleAddPage(ViewLayout.Calendar, 'Calendar'); + }, + }, + ], [handleAddPage, t]); + + return ( +
+ {actions.map(action => ( + + ))} +
+ ); +} + +export default AddPageActions; \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/components/app/view-actions/CreateSpaceModal.tsx b/frontend/appflowy_web_app/src/components/app/view-actions/CreateSpaceModal.tsx new file mode 100644 index 0000000000000..ce24feaa36a1b --- /dev/null +++ b/frontend/appflowy_web_app/src/components/app/view-actions/CreateSpaceModal.tsx @@ -0,0 +1,98 @@ +import { SpacePermission } from '@/application/types'; +import { NormalModal } from '@/components/_shared/modal'; +import { notify } from '@/components/_shared/notify'; +import { useAppHandlers } from '@/components/app/app.hooks'; +import SpaceIconButton from '@/components/app/view-actions/SpaceIconButton'; +import SpacePermissionButton from '@/components/app/view-actions/SpacePermissionButton'; +import { OutlinedInput } from '@mui/material'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +function CreateSpaceModal ({ open, onClose, onCreated }: { + open: boolean; + onClose: () => void; + onCreated?: (spaceId: string) => void; +}) { + const [spaceName, setSpaceName] = React.useState(''); + const [spaceIcon, setSpaceIcon] = React.useState(''); + const [spaceIconColor, setSpaceIconColor] = React.useState(''); + const [spacePermission, setSpacePermission] = React.useState(SpacePermission.Public); + const [loading, setLoading] = React.useState(false); + const { t } = useTranslation(); + const { createSpace } = useAppHandlers(); + const handleOk = async () => { + if (!createSpace) return; + setLoading(true); + try { + const spaceId = await createSpace({ + name: spaceName, + space_icon: spaceIcon, + space_icon_color: spaceIconColor, + space_permission: spacePermission, + }); + + onClose(); + + onCreated && onCreated(spaceId); + // eslint-disable-next-line + } catch (e: any) { + notify.error(e.message); + } finally { + setLoading(false); + } + }; + + return ( + +
+
+
{t('space.createSpaceDescription')}
+ +
+
+
{t('space.spaceName')}
+ setSpaceName(e.target.value)} + size={'small'} + placeholder={t('space.spaceNamePlaceholder')} + /> +
+
+
{t('space.permission')}
+ +
+
+ +
+ ); +} + +export default CreateSpaceModal; \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/components/app/view-actions/DeletePageConfirm.tsx b/frontend/appflowy_web_app/src/components/app/view-actions/DeletePageConfirm.tsx new file mode 100644 index 0000000000000..a0c5ef8f27622 --- /dev/null +++ b/frontend/appflowy_web_app/src/components/app/view-actions/DeletePageConfirm.tsx @@ -0,0 +1,74 @@ +import { NormalModal } from '@/components/_shared/modal'; +import { notify } from '@/components/_shared/notify'; +import { filterViewsByCondition } from '@/components/_shared/outline/utils'; +import { useAppHandlers, useAppView } from '@/components/app/app.hooks'; +import React, { useCallback, useEffect, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +function DeletePageConfirm ({ open, onClose, viewId, onDeleted }: { + open: boolean; + onClose: () => void; + viewId: string; + onDeleted?: () => void; +}) { + const view = useAppView(viewId); + const [loading, setLoading] = React.useState(false); + const { + deletePage, + } = useAppHandlers(); + const { t } = useTranslation(); + + const handleOk = useCallback(async () => { + if (!view) return; + setLoading(true); + try { + await deletePage?.(viewId); + onClose(); + onDeleted?.(); + // eslint-disable-next-line + } catch (e: any) { + notify.error(e.message); + } finally { + setLoading(false); + } + }, [deletePage, onClose, onDeleted, view, viewId]); + + const hasPublished = useMemo(() => { + const publishedView = filterViewsByCondition(view?.children || [], v => v.is_published); + + return view?.is_published || !!publishedView.length; + }, [view]); + + useEffect(() => { + if (!hasPublished && open) { + void handleOk(); + } + }, [handleOk, hasPublished, open]); + + if (!hasPublished) return null; + + return ( + {`${t('button.delete')}: ${view?.name}`}
+ } + onOk={handleOk} + PaperProps={{ + className: 'w-[420px] max-w-[70vw]', + }} + > +
{t('publish.containsPublishedPage')}
+ + + ); +} + +export default DeletePageConfirm; \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/components/app/view-actions/DeleteSpaceConfirm.tsx b/frontend/appflowy_web_app/src/components/app/view-actions/DeleteSpaceConfirm.tsx new file mode 100644 index 0000000000000..f7ee9b8be6a71 --- /dev/null +++ b/frontend/appflowy_web_app/src/components/app/view-actions/DeleteSpaceConfirm.tsx @@ -0,0 +1,57 @@ +import { NormalModal } from '@/components/_shared/modal'; +import { notify } from '@/components/_shared/notify'; +import { useAppHandlers, useAppView } from '@/components/app/app.hooks'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +function DeleteSpaceConfirm ({ open, onClose, viewId }: { + open: boolean; + onClose: () => void; + viewId: string; +}) { + const view = useAppView(viewId); + + const [loading, setLoading] = React.useState(false); + const { + deletePage, + } = useAppHandlers(); + const { t } = useTranslation(); + + const handleOk = async () => { + if (!view) return; + setLoading(true); + try { + await deletePage?.(viewId); + onClose(); + // eslint-disable-next-line + } catch (e: any) { + notify.error(e.message); + } finally { + setLoading(false); + } + }; + + return ( + {`${t('button.delete')}: ${view?.name}`}
+ } + onOk={handleOk} + PaperProps={{ + className: 'w-[420px] max-w-[70vw]', + }} + > +
{t('space.deleteConfirmationDescription')}
+ + + ); +} + +export default DeleteSpaceConfirm; \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/components/app/view-actions/ManageSpace.tsx b/frontend/appflowy_web_app/src/components/app/view-actions/ManageSpace.tsx new file mode 100644 index 0000000000000..fcdcb1932ca04 --- /dev/null +++ b/frontend/appflowy_web_app/src/components/app/view-actions/ManageSpace.tsx @@ -0,0 +1,98 @@ +import { SpacePermission } from '@/application/types'; +import { NormalModal } from '@/components/_shared/modal'; +import { notify } from '@/components/_shared/notify'; +import { useAppHandlers, useAppView } from '@/components/app/app.hooks'; +import SpaceIconButton from '@/components/app/view-actions/SpaceIconButton'; +import SpacePermissionButton from '@/components/app/view-actions/SpacePermissionButton'; +import { OutlinedInput } from '@mui/material'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +function ManageSpace ({ open, onClose, viewId }: { + open: boolean; + onClose: () => void; + viewId: string; +}) { + const view = useAppView(viewId); + const [spaceName, setSpaceName] = React.useState(view?.name || ''); + const [spaceIcon, setSpaceIcon] = React.useState(view?.extra?.space_icon || ''); + const [spaceIconColor, setSpaceIconColor] = React.useState(view?.extra?.space_icon_color || ''); + const [spacePermission, setSpacePermission] = React.useState(view?.is_private ? SpacePermission.Private : SpacePermission.Public); + + const [loading, setLoading] = React.useState(false); + const { t } = useTranslation(); + const { updateSpace } = useAppHandlers(); + + const handleOk = async () => { + if (!updateSpace) return; + setLoading(true); + try { + await updateSpace({ + view_id: viewId, + name: spaceName, + space_icon: spaceIcon, + space_icon_color: spaceIconColor, + space_permission: spacePermission, + }); + onClose(); + // eslint-disable-next-line + } catch (e: any) { + notify.error(e.message); + } finally { + setLoading(false); + } + }; + + if (!view) return null; + return ( + +
+
+
{t('space.spaceName')}
+
+ + setSpaceName(e.target.value)} + size={'small'} + placeholder={t('space.spaceNamePlaceholder')} + /> +
+
+
+
{t('space.permission')}
+ +
+
+ +
+ ); +} + +export default ManageSpace; \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/components/app/view-actions/MorePageActions.tsx b/frontend/appflowy_web_app/src/components/app/view-actions/MorePageActions.tsx new file mode 100644 index 0000000000000..fe436a9d8a451 --- /dev/null +++ b/frontend/appflowy_web_app/src/components/app/view-actions/MorePageActions.tsx @@ -0,0 +1,141 @@ +import { View, ViewIconType } from '@/application/types'; +import { ReactComponent as EditIcon } from '@/assets/edit.svg'; +import { ReactComponent as ChangeIcon } from '@/assets/change_icon.svg'; +import { ReactComponent as OpenInBrowserIcon } from '@/assets/open_in_browser.svg'; +import { notify } from '@/components/_shared/notify'; +import { Origins } from '@/components/_shared/popover'; +import { useAppHandlers, useCurrentWorkspaceId } from '@/components/app/app.hooks'; +import MoreActionsContent from '@/components/app/header/MoreActionsContent'; +import RenameModal from '@/components/app/view-actions/RenameModal'; + +import { Button, Divider } from '@mui/material'; +import React, { lazy, Suspense, useCallback, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +const ChangeIconPopover = lazy(() => import('@/components/_shared/view-icon/ChangeIconPopover')); + +const popoverProps: Origins = { + transformOrigin: { + vertical: 'top', + horizontal: 'left', + }, + anchorOrigin: { + vertical: 'top', + horizontal: 'right', + }, +}; + +function MorePageActions ({ view, onClose }: { + view: View; + onClose?: () => void; +}) { + const currentWorkspaceId = useCurrentWorkspaceId(); + + const [iconPopoverAnchorEl, setIconPopoverAnchorEl] = useState(null); + const openIconPopover = Boolean(iconPopoverAnchorEl); + + const [renameModalOpen, setRenameModalOpen] = useState(false); + + const { + updatePage, + } = useAppHandlers(); + const { t } = useTranslation(); + + const handleChangeIcon = useCallback(async (icon: { ty: ViewIconType, value: string }) => { + try { + await updatePage?.(view.view_id, { + icon: icon, + name: view.name, + extra: view.extra || {}, + }); + setIconPopoverAnchorEl(null); + onClose?.(); + // eslint-disable-next-line + } catch (e: any) { + notify.error(e); + } + }, [onClose, updatePage, view.extra, view.name, view.view_id]); + + const handleRemoveIcon = useCallback(() => { + void handleChangeIcon({ ty: 0, value: '' }); + }, [handleChangeIcon]); + + const actions = useMemo(() => { + return [{ + label: t('button.rename'), + icon: , + onClick: () => { + setRenameModalOpen(true); + }, + }, { + label: t('disclosureAction.changeIcon'), + icon: , + onClick: (e: React.MouseEvent) => { + setIconPopoverAnchorEl(e.currentTarget); + }, + }]; + }, [t]); + + return ( +
+ {actions.map(action => ( + + ))} + + + + + { + onClose?.(); + setIconPopoverAnchorEl(null); + }} + popoverProps={popoverProps} + onSelectIcon={handleChangeIcon} + removeIcon={handleRemoveIcon} + /> + + { + onClose?.(); + setRenameModalOpen(false); + }} + viewId={view.view_id} + /> +
+ ); +} + +export default MorePageActions; \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/components/app/view-actions/MoreSpaceActions.tsx b/frontend/appflowy_web_app/src/components/app/view-actions/MoreSpaceActions.tsx new file mode 100644 index 0000000000000..6044a7e25c00b --- /dev/null +++ b/frontend/appflowy_web_app/src/components/app/view-actions/MoreSpaceActions.tsx @@ -0,0 +1,103 @@ +import { View } from '@/application/types'; +import CreateSpaceModal from '@/components/app/view-actions/CreateSpaceModal'; +import DeleteSpaceConfirm from '@/components/app/view-actions/DeleteSpaceConfirm'; +import ManageSpace from '@/components/app/view-actions/ManageSpace'; +import { Button, Divider } from '@mui/material'; +import React, { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ReactComponent as DeleteIcon } from '@/assets/trash.svg'; +import { ReactComponent as DuplicateIcon } from '@/assets/duplicate.svg'; +import { ReactComponent as SettingsIcon } from '@/assets/settings.svg'; +import { ReactComponent as AddIcon } from '@/assets/add.svg'; + +function MoreSpaceActions ({ + view, + onClose, +}: { + view: View; + onClose: () => void; +}) { + const { t } = useTranslation(); + const [deleteModalOpen, setDeleteModalOpen] = React.useState(false); + const [manageModalOpen, setManageModalOpen] = React.useState(false); + const [createSpaceModalOpen, setCreateSpaceModalOpen] = React.useState(false); + const actions = useMemo(() => { + return [{ + label: t('space.manage'), + icon: , + onClick: () => { + setManageModalOpen(true); + }, + }, { + label: t('space.duplicate'), + icon: , + onClick: () => { + // + }, + }]; + }, [t]); + + return ( +
+ {actions.map(action => ( + + ))} + + + + + { + setManageModalOpen(false); + onClose(); + }} + viewId={view.view_id} + /> + setCreateSpaceModalOpen(false)} + /> + { + setDeleteModalOpen(false); + onClose(); + }} + /> +
+ ); +} + +export default MoreSpaceActions; \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/components/app/view-actions/MovePagePopover.tsx b/frontend/appflowy_web_app/src/components/app/view-actions/MovePagePopover.tsx new file mode 100644 index 0000000000000..f43caee3e1da5 --- /dev/null +++ b/frontend/appflowy_web_app/src/components/app/view-actions/MovePagePopover.tsx @@ -0,0 +1,103 @@ +import { View, ViewLayout } from '@/application/types'; +import { notify } from '@/components/_shared/notify'; +import { filterOutByCondition } from '@/components/_shared/outline/utils'; +import { Popover } from '@/components/_shared/popover'; +import { useAppHandlers, useAppOutline } from '@/components/app/app.hooks'; +import SpaceItem from '@/components/app/outline/SpaceItem'; +import { IconButton, OutlinedInput, Tooltip } from '@mui/material'; +import { PopoverProps } from '@mui/material/Popover'; +import React, { useMemo } from 'react'; +import { ReactComponent as SearchOutlined } from '@/assets/search.svg'; +import { useTranslation } from 'react-i18next'; +import { ReactComponent as MoveIcon } from '@/assets/move_down.svg'; + +function MovePagePopover ({ + viewId, + onMoved, + ...props +}: PopoverProps & { + viewId: string; + onMoved?: () => void; +}) { + const outline = useAppOutline(); + const [search, setSearch] = React.useState(''); + const { + movePage, + } = useAppHandlers(); + + const views = useMemo(() => { + if (!outline) return []; + return filterOutByCondition(outline, (view) => ({ + remove: view.view_id === viewId || view.layout !== ViewLayout.Document || Boolean(search && !view.name.toLowerCase().includes(search.toLowerCase())), + })); + }, [outline, search, viewId]); + const { t } = useTranslation(); + const [expandViewIds, setExpandViewIds] = React.useState([]); + const toggleExpandView = React.useCallback((id: string, isExpanded: boolean) => { + setExpandViewIds((prev) => { + return isExpanded ? [...prev, id] : prev.filter((v) => v !== id); + }); + }, []); + + const renderExtra = React.useCallback(({ hovered, view }: { + hovered: boolean; + view: View + }) => { + if (!hovered) return null; + return { + e.stopPropagation(); + try { + await movePage?.(viewId, view.view_id); + props.onClose?.(e, 'backdropClick'); + onMoved?.(); + // eslint-disable-next-line + } catch (e: any) { + notify.error(e.message); + } + }} + > + + ; + }, [movePage, onMoved, props, t, viewId]); + + return ( + +
+ } + value={search} + onChange={(e) => { + setSearch(e.target.value); + }} + autoFocus={true} + fullWidth={true} + size={'small'} + autoCorrect={'off'} + autoComplete={'off'} + spellCheck={false} + inputProps={{ + className: 'px-2 py-1.5 text-base', + }} + className={'search-emoji-input'} + placeholder={t('search.label')} + /> + {views.map((view) => )} +
+
+ ); +} + +export default MovePagePopover; \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/components/app/view-actions/NewPage.tsx b/frontend/appflowy_web_app/src/components/app/view-actions/NewPage.tsx new file mode 100644 index 0000000000000..f333f9061e7fc --- /dev/null +++ b/frontend/appflowy_web_app/src/components/app/view-actions/NewPage.tsx @@ -0,0 +1,113 @@ +import { ViewLayout } from '@/application/types'; +import { ReactComponent as Add } from '@/assets/add_circle.svg'; +import { NormalModal } from '@/components/_shared/modal'; +import { notify } from '@/components/_shared/notify'; +import { useAppHandlers, useAppOutline } from '@/components/app/app.hooks'; +import CreateSpaceModal from '@/components/app/view-actions/CreateSpaceModal'; +import SpaceList from '@/components/publish/header/duplicate/SpaceList'; +import { Button } from '@mui/material'; +import React, { useCallback, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +function NewPage () { + const { t } = useTranslation(); + const [open, setOpen] = React.useState(false); + const [loading, setLoading] = React.useState(false); + const [selectedSpaceId, setSelectedSpaceId] = React.useState(''); + const outline = useAppOutline(); + const spaceList = useMemo(() => { + if (!outline) return []; + + return outline.map(view => { + return { + id: view.view_id, + extra: JSON.stringify(view.extra), + name: view.name, + isPrivate: view.is_private, + }; + }); + }, [outline]); + + const onClose = React.useCallback(() => { + setOpen(false); + }, []); + + const { + addPage, + openPageModal, + } = useAppHandlers(); + + const [createSpaceOpen, setCreateSpaceOpen] = React.useState(false); + + const handleAddPage = useCallback(async (parentId: string) => { + if (!addPage || !openPageModal) return; + setLoading(true); + try { + const viewId = await addPage(parentId, { + layout: ViewLayout.Document, + }); + + openPageModal(viewId); + onClose(); + // eslint-disable-next-line + } catch (e: any) { + + notify.error(e.message); + } finally { + setLoading(false); + + } + }, [addPage, openPageModal, onClose]); + + return ( +
+ + { + void handleAddPage(selectedSpaceId); + }} + okLoading={loading} + > + + {t('publish.addTo')} + {` ${t('web.or')} `} + +
} + /> + + setCreateSpaceOpen(false)} + onCreated={(spaceId: string) => { + void handleAddPage(spaceId); + }} + /> +
+ ); +} + +export default NewPage; \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/components/app/view-actions/PageActions.tsx b/frontend/appflowy_web_app/src/components/app/view-actions/PageActions.tsx new file mode 100644 index 0000000000000..a9ff01f0f83c8 --- /dev/null +++ b/frontend/appflowy_web_app/src/components/app/view-actions/PageActions.tsx @@ -0,0 +1,57 @@ +import { View, ViewLayout } from '@/application/types'; +import { ReactComponent as AddIcon } from '@/assets/add.svg'; +import { ReactComponent as MoreIcon } from '@/assets/more.svg'; +import { IconButton, Tooltip } from '@mui/material'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +function PageActions ({ + onClickMore, + onClickAdd, + view, +}: { + view: View; + onClickAdd: (e: React.MouseEvent) => void; + onClickMore: (e: React.MouseEvent) => void; +}) { + const { t } = useTranslation(); + + return ( +
e.stopPropagation()} + className={'flex items-center px-2'} + > + + { + e.stopPropagation(); + onClickMore(e); + }} + size={'small'} + > + + + + {view.layout === ViewLayout.Document && + { + e.stopPropagation(); + onClickAdd(e); + }} + size={'small'} + > + + + } + +
+ ); +} + +export default PageActions; \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/components/app/view-actions/RenameModal.tsx b/frontend/appflowy_web_app/src/components/app/view-actions/RenameModal.tsx new file mode 100644 index 0000000000000..c45031ccaf6ed --- /dev/null +++ b/frontend/appflowy_web_app/src/components/app/view-actions/RenameModal.tsx @@ -0,0 +1,88 @@ +import { ViewIconType } from '@/application/types'; +import { NormalModal } from '@/components/_shared/modal'; +import { notify } from '@/components/_shared/notify'; +import { useAppHandlers, useAppView } from '@/components/app/app.hooks'; +import { OutlinedInput } from '@mui/material'; +import React, { useCallback, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; + +function RenameModal ({ open, onClose, viewId }: { + open: boolean; + onClose: () => void; + viewId: string; +}) { + const view = useAppView(viewId); + + const { t } = useTranslation(); + + const [newValue, setNewValue] = React.useState(''); + const [loading, setLoading] = React.useState(false); + + const { updatePage } = useAppHandlers(); + + const handleOk = useCallback(async () => { + if (!view) return; + if (!newValue) { + notify.warning(t('web.error.pageNameIsEmpty')); + return; + } + + if (newValue === view.name) { + return; + } + + setLoading(true); + try { + await updatePage?.(viewId, { + name: newValue, icon: view.icon || { + ty: ViewIconType.Emoji, + value: '', + }, extra: view.extra || {}, + }); + onClose(); + // eslint-disable-next-line + } catch (e: any) { + notify.error(e.message); + } finally { + setLoading(false); + } + }, [newValue, t, updatePage, view, viewId, onClose]); + + useEffect(() => { + if (view) { + setNewValue(view.name); + } + }, [view]); + + return ( + + setNewValue(e.target.value)} + fullWidth + onKeyDown={(e) => { + if (e.key === 'Enter') { + void handleOk(); + } + }} + /> + + ); +} + +export default RenameModal; \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/components/app/view-actions/SpaceActions.tsx b/frontend/appflowy_web_app/src/components/app/view-actions/SpaceActions.tsx new file mode 100644 index 0000000000000..96f2cfea08354 --- /dev/null +++ b/frontend/appflowy_web_app/src/components/app/view-actions/SpaceActions.tsx @@ -0,0 +1,56 @@ +import { View } from '@/application/types'; +import { IconButton, Tooltip } from '@mui/material'; +import React from 'react'; +import { ReactComponent as MoreIcon } from '@/assets/more.svg'; +import { ReactComponent as AddIcon } from '@/assets/add.svg'; +import { useTranslation } from 'react-i18next'; + +function SpaceActions ({ + onClickMore, + onClickAdd, +}: { + view: View; + onClickAdd: (e: React.MouseEvent) => void; + onClickMore: (e: React.MouseEvent) => void; +}) { + + const { t } = useTranslation(); + + return ( +
e.stopPropagation()} + className={'flex items-center px-2'} + > + + { + e.stopPropagation(); + onClickMore(e); + }} + size={'small'} + > + + + + + { + e.stopPropagation(); + onClickAdd(e); + }} + size={'small'} + > + + + +
+ ); +} + +export default SpaceActions; \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/components/app/view-actions/SpaceIconButton.tsx b/frontend/appflowy_web_app/src/components/app/view-actions/SpaceIconButton.tsx new file mode 100644 index 0000000000000..ef5b37bc2e837 --- /dev/null +++ b/frontend/appflowy_web_app/src/components/app/view-actions/SpaceIconButton.tsx @@ -0,0 +1,83 @@ +import SpaceIcon from '@/components/_shared/breadcrumb/SpaceIcon'; +import ChangeIconPopover from '@/components/_shared/view-icon/ChangeIconPopover'; +import { Avatar } from '@mui/material'; +import { PopoverProps } from '@mui/material/Popover'; +import React from 'react'; +import { ReactComponent as EditIcon } from '@/assets/edit.svg'; + +const popoverProps: Partial = { + transformOrigin: { + vertical: 'top', + horizontal: 'center', + }, + anchorOrigin: { + vertical: 'bottom', + horizontal: 'center', + }, +}; + +function SpaceIconButton ({ + spaceIcon, + spaceIconColor, + spaceName, + onSelectSpaceIcon, + onSelectSpaceIconColor, + size, +}: { + spaceIconColor?: string; + spaceIcon?: string; + spaceName: string; + onSelectSpaceIcon: (icon: string) => void; + onSelectSpaceIconColor: (color: string) => void; + size?: number; +}) { + const [spaceIconEditing, setSpaceIconEditing] = React.useState(false); + const [iconAnchorEl, setIconAnchorEl] = React.useState(null); + + return ( + + <> + setSpaceIconEditing(true)} + onMouseLeave={() => setSpaceIconEditing(false)} + onClick={e => { + setSpaceIconEditing(false); + setIconAnchorEl(e.currentTarget); + }} + > + + {spaceIconEditing && +
+
+ +
+
+ } +
+ {Boolean(iconAnchorEl) && { + setIconAnchorEl(null); + + }} + onSelectIcon={({ value, color }) => { + onSelectSpaceIcon(value); + onSelectSpaceIconColor(color || ''); + }} + />} + + ); +} + +export default SpaceIconButton; \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/components/app/view-actions/SpacePermissionButton.tsx b/frontend/appflowy_web_app/src/components/app/view-actions/SpacePermissionButton.tsx new file mode 100644 index 0000000000000..67f3d44692ab5 --- /dev/null +++ b/frontend/appflowy_web_app/src/components/app/view-actions/SpacePermissionButton.tsx @@ -0,0 +1,93 @@ +import { Popover } from '@/components/_shared/popover'; +import { Button, Divider } from '@mui/material'; +import React from 'react'; +import { ReactComponent as LockIcon } from '@/assets/space_permission_private.svg'; +import { ReactComponent as PublicIcon } from '@/assets/space_permission_public.svg'; +import { ReactComponent as DropdownIcon } from '@/assets/space_permission_dropdown.svg'; +import { ReactComponent as SelectedIcon } from '@/assets/selected.svg'; +import { useTranslation } from 'react-i18next'; +import { SpacePermission } from '@/application/types'; + +function SpacePermissionButton ({ + onSelected, + value, +}: { + value: SpacePermission; + onSelected?: (permission: SpacePermission) => void; +}) { + const [anchorEl, setAnchorEl] = React.useState(null); + const { t } = useTranslation(); + + return ( + <> + + setAnchorEl(null)} + > +
+ + + +
+
+ + ); +} + +export default SpacePermissionButton; \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/components/app/view-actions/ViewActions.tsx b/frontend/appflowy_web_app/src/components/app/view-actions/ViewActions.tsx new file mode 100644 index 0000000000000..071272f5e9a96 --- /dev/null +++ b/frontend/appflowy_web_app/src/components/app/view-actions/ViewActions.tsx @@ -0,0 +1,122 @@ +import { View } from '@/application/types'; +import { Popover } from '@/components/_shared/popover'; +import AddPageActions from '@/components/app/view-actions/AddPageActions'; +import MorePageActions from '@/components/app/view-actions/MorePageActions'; +import MoreSpaceActions from '@/components/app/view-actions/MoreSpaceActions'; +import PageActions from '@/components/app/view-actions/PageActions'; +import SpaceActions from '@/components/app/view-actions/SpaceActions'; +import { PopoverProps } from '@mui/material/Popover'; +import React, { useCallback, useMemo } from 'react'; + +const popoverProps: Partial = { + transformOrigin: { + vertical: 'top', + horizontal: 'left', + }, + anchorOrigin: { + vertical: 'bottom', + horizontal: 'left', + }, +}; + +export function ViewActions ({ view, hovered }: { + view: View; + hovered?: boolean; +}) { + const isSpace = view?.extra?.is_space; + const [popoverType, setPopoverType] = React.useState<{ + category: 'space' | 'page'; + type: 'more' | 'add'; + } | null>(null); + const [anchorPosition, setAnchorPosition] = React.useState(undefined); + const open = Boolean(anchorPosition); + const handleClosePopover = () => { + setAnchorPosition(undefined); + }; + + const handleClick = useCallback((e: React.MouseEvent, popoverType: { + category: 'space' | 'page'; + type: 'more' | 'add'; + }) => { + setPopoverType(popoverType); + const rect = (e.target as HTMLElement).getBoundingClientRect(); + + setAnchorPosition({ top: rect.bottom, left: rect.left }); + }, []); + + const renderButton = useMemo(() => { + if (!hovered || !view) return null; + if (isSpace) return { + handleClick(e, { category: 'space', type: 'add' }); + }} + onClickMore={(e) => { + handleClick(e, { category: 'space', type: 'more' }); + }} + view={view} + />; + return { + handleClick(e, { category: 'page', type: 'add' }); + }} + onClickMore={(e) => { + handleClick(e, { category: 'page', type: 'more' }); + }} + view={view} + />; + }, [handleClick, hovered, isSpace, view]); + + const popoverContent = useMemo(() => { + if (!popoverType) return null; + + if (popoverType.type === 'add') { + return { + handleClosePopover(); + }} + view={view} + />; + } + + if (popoverType.category === 'space') { + return { + handleClosePopover(); + }} + view={view} + />; + } else { + return { + handleClosePopover(); + }} + />; + } + }, [popoverType, view]); + + return
e.stopPropagation()}> + {renderButton} + + {popoverContent} + +
; + +} + +export default ViewActions; \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/components/app/view-actions/index.ts b/frontend/appflowy_web_app/src/components/app/view-actions/index.ts new file mode 100644 index 0000000000000..f18cd0edffce0 --- /dev/null +++ b/frontend/appflowy_web_app/src/components/app/view-actions/index.ts @@ -0,0 +1 @@ +export * from './ViewActions'; diff --git a/frontend/appflowy_web_app/src/components/app/workspaces/CurrentWorkspace.tsx b/frontend/appflowy_web_app/src/components/app/workspaces/CurrentWorkspace.tsx index 69077232e21c4..c892b9ff977ea 100644 --- a/frontend/appflowy_web_app/src/components/app/workspaces/CurrentWorkspace.tsx +++ b/frontend/appflowy_web_app/src/components/app/workspaces/CurrentWorkspace.tsx @@ -8,10 +8,12 @@ function CurrentWorkspace ({ userWorkspaceInfo, selectedWorkspace, onChangeWorkspace, + avatarSize = 32, }: { userWorkspaceInfo?: UserWorkspaceInfo; selectedWorkspace?: Workspace; onChangeWorkspace: (selectedId: string) => void; + avatarSize?: number; }) { if (!userWorkspaceInfo || !selectedWorkspace) { @@ -32,8 +34,13 @@ function CurrentWorkspace ({ return
{selectedWorkspace.name}
; diff --git a/frontend/appflowy_web_app/src/components/app/workspaces/WorkspaceList.tsx b/frontend/appflowy_web_app/src/components/app/workspaces/WorkspaceList.tsx index e4b749fa7e262..8d490e1552b4b 100644 --- a/frontend/appflowy_web_app/src/components/app/workspaces/WorkspaceList.tsx +++ b/frontend/appflowy_web_app/src/components/app/workspaces/WorkspaceList.tsx @@ -41,10 +41,9 @@ function WorkspaceList ({ {workspaces.map((workspace) => { return -
- } - open={moreOpen} - onClose={() => setMoreOpen(false)} - > - setMoreOpen(prev => !prev)}> - - - -
- - {open && } +
+ {open && } +
-
- -
+ + +
diff --git a/frontend/appflowy_web_app/src/components/as-template/AsTemplateButton.tsx b/frontend/appflowy_web_app/src/components/as-template/AsTemplateButton.tsx index fe50beea96196..8e62b2b874021 100644 --- a/frontend/appflowy_web_app/src/components/as-template/AsTemplateButton.tsx +++ b/frontend/appflowy_web_app/src/components/as-template/AsTemplateButton.tsx @@ -6,13 +6,13 @@ import React, { useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { ReactComponent as TemplateIcon } from '@/assets/template.svg'; -function AsTemplateButton () { +function AsTemplateButton ({ viewId }: { viewId: string }) { const { t } = useTranslation(); const { url: publishUrl, - } = useLoadPublishInfo(); - const view = useAppView(); + } = useLoadPublishInfo(viewId); + const view = useAppView(viewId); const handleClick = useCallback(() => { diff --git a/frontend/appflowy_web_app/src/components/as-template/creator/UploadAvatar.tsx b/frontend/appflowy_web_app/src/components/as-template/creator/UploadAvatar.tsx index f0f55ce7eadf2..9fcaa6bbf0d68 100644 --- a/frontend/appflowy_web_app/src/components/as-template/creator/UploadAvatar.tsx +++ b/frontend/appflowy_web_app/src/components/as-template/creator/UploadAvatar.tsx @@ -33,7 +33,7 @@ function UploadAvatar ({ setUploadStatus('loading'); try { - const url = await service?.uploadFileToCDN(file); + const url = await service?.uploadTemplateAvatar(file); if (!url) throw new Error('Failed to upload file'); onChange(url); @@ -56,7 +56,8 @@ function UploadAvatar ({ /> {file && (
-
{uploadStatus === 'loading' ? : } - +
{file.name}
{ uploadStatus === 'success' && !hovered && } - {hovered && + {hovered && { setFile(null); diff --git a/frontend/appflowy_web_app/src/components/database/Database.tsx b/frontend/appflowy_web_app/src/components/database/Database.tsx index b5c443a5b8830..3b41399ef3864 100644 --- a/frontend/appflowy_web_app/src/components/database/Database.tsx +++ b/frontend/appflowy_web_app/src/components/database/Database.tsx @@ -3,7 +3,7 @@ import { AppendBreadcrumb, CreateRowDoc, LoadView, - LoadViewMeta, RowId, + LoadViewMeta, RowId, UIVariant, YDatabase, YDoc, YjsDatabaseKey, @@ -18,6 +18,7 @@ import { DatabaseContextProvider } from './DatabaseContext'; export interface Database2Props { doc: YDoc; + readOnly?: boolean; createRowDoc?: CreateRowDoc; loadView?: LoadView; navigateToView?: (viewId: string, blockId?: string) => Promise; @@ -31,8 +32,10 @@ export interface Database2Props { visibleViewIds: string[]; iidIndex: string; hideConditions?: boolean; - variant?: 'publish' | 'app'; - onRendered?: () => void; + variant?: UIVariant; + onRendered?: (height: number) => void; + isDocumentBlock?: boolean; + scrollLeft?: number; } function Database ({ @@ -51,10 +54,12 @@ function Database ({ hideConditions, appendBreadcrumb, onRendered, - variant = 'app', + readOnly = true, + variant = UIVariant.App, + scrollLeft, + isDocumentBlock, }: Database2Props) { const database = doc.getMap(YjsEditorKey.data_section)?.get(YjsEditorKey.database) as YDatabase; - const view = database.get(YjsDatabaseKey.views).get(iidIndex); const rowOrders = view?.get(YjsDatabaseKey.row_orders); @@ -105,13 +110,12 @@ function Database ({ }; }, [handleUpdateRowDocMap, rowOrders]); - useEffect(() => { - onRendered?.(); - }, [onRendered]); if (!rowDocMap || !viewId) { return null; } + console.log('Database', database.toJSON()); + return (
{rowId ? ( { switch (layout) { case DatabaseViewLayout.Grid: - return ; + return ; case DatabaseViewLayout.Board: - return ; + return ; case DatabaseViewLayout.Calendar: - return ; + return ; } }, [layout]); diff --git a/frontend/appflowy_web_app/src/components/database/board/Board.tsx b/frontend/appflowy_web_app/src/components/database/board/Board.tsx index 73c0f953980be..ec525b3c869d6 100644 --- a/frontend/appflowy_web_app/src/components/database/board/Board.tsx +++ b/frontend/appflowy_web_app/src/components/database/board/Board.tsx @@ -3,7 +3,7 @@ import { Group } from '@/components/database/components/board'; import { CircularProgress } from '@mui/material'; import React from 'react'; -export function Board() { +export function Board () { const database = useDatabase(); const groups = useGroupsSelector(); @@ -16,9 +16,14 @@ export function Board() { } return ( -
+
{groups.map((groupId) => ( - + ))}
); diff --git a/frontend/appflowy_web_app/src/components/database/calendar/Calendar.tsx b/frontend/appflowy_web_app/src/components/database/calendar/Calendar.tsx index b439bb8afffc4..067b388b27d62 100644 --- a/frontend/appflowy_web_app/src/components/database/calendar/Calendar.tsx +++ b/frontend/appflowy_web_app/src/components/database/calendar/Calendar.tsx @@ -1,14 +1,33 @@ +import { DatabaseContext } from '@/application/database-yjs'; import { useCalendarSetup } from '@/components/database/calendar/Calendar.hooks'; import { Toolbar, Event } from '@/components/database/components/calendar'; -import React from 'react'; +import React, { useContext, useEffect, useRef } from 'react'; import { Calendar as BigCalendar } from 'react-big-calendar'; import './calendar.scss'; export function Calendar () { const { dayPropGetter, localizer, formats, events, emptyEvents } = useCalendarSetup(); + const scrollLeft = useContext(DatabaseContext)?.scrollLeft; + const isDocumentBlock = useContext(DatabaseContext)?.isDocumentBlock; + const ref = useRef(null); + const onRendered = useContext(DatabaseContext)?.onRendered; + useEffect(() => { + const el = ref.current; + + if (!el) return; + + onRendered?.(el.scrollHeight + 34); + }, [onRendered]); return ( -
+
, diff --git a/frontend/appflowy_web_app/src/components/database/components/board/group/Group.tsx b/frontend/appflowy_web_app/src/components/database/components/board/group/Group.tsx index 77ab998ff3f7e..92e6323059d76 100644 --- a/frontend/appflowy_web_app/src/components/database/components/board/group/Group.tsx +++ b/frontend/appflowy_web_app/src/components/database/components/board/group/Group.tsx @@ -1,6 +1,6 @@ -import { useRowsByGroup } from '@/application/database-yjs'; +import { DatabaseContext, useRowsByGroup } from '@/application/database-yjs'; import { AFScroller } from '@/components/_shared/scroller'; -import React from 'react'; +import React, { useContext } from 'react'; import { useTranslation } from 'react-i18next'; import { Column } from '../column'; @@ -10,8 +10,8 @@ export interface GroupProps { export const Group = ({ groupId }: GroupProps) => { const { columns, groupResult, fieldId, notFound } = useRowsByGroup(groupId); - const { t } = useTranslation(); + const scrollLeft = useContext(DatabaseContext)?.scrollLeft; if (notFound) { return ( @@ -24,11 +24,29 @@ export const Group = ({ groupId }: GroupProps) => { if (columns.length === 0 || !fieldId) return null; return ( - -
- {columns.map((data) => ( - - ))} + +
+
+ {columns.map((data) => ( + + ))} +
); diff --git a/frontend/appflowy_web_app/src/components/database/components/conditions/DatabaseActions.tsx b/frontend/appflowy_web_app/src/components/database/components/conditions/DatabaseActions.tsx index 38c96edf105a9..02f06dbfabdcf 100644 --- a/frontend/appflowy_web_app/src/components/database/components/conditions/DatabaseActions.tsx +++ b/frontend/appflowy_web_app/src/components/database/components/conditions/DatabaseActions.tsx @@ -1,10 +1,12 @@ import { useFiltersSelector, useSortsSelector } from '@/application/database-yjs'; import { useConditionsContext } from '@/components/database/components/conditions/context'; -import { TextButton } from '@/components/database/components/tabs/TextButton'; +import { IconButton, Tooltip } from '@mui/material'; import React from 'react'; import { useTranslation } from 'react-i18next'; +import { ReactComponent as FilterIcon } from '@/assets/filter.svg'; +import { ReactComponent as SortIcon } from '@/assets/sort.svg'; -export function DatabaseActions() { +export function DatabaseActions () { const { t } = useTranslation(); const sorts = useSortsSelector(); @@ -12,25 +14,31 @@ export function DatabaseActions() { const conditionsContext = useConditionsContext(); return ( -
- { - conditionsContext?.toggleExpanded(); - }} - data-testid={'database-actions-filter'} - color={filter.length > 0 ? 'primary' : 'inherit'} - > - {t('grid.settings.filter')} - - { - conditionsContext?.toggleExpanded(); - }} - color={sorts.length > 0 ? 'primary' : 'inherit'} - > - {t('grid.settings.sort')} - +
+ + { + conditionsContext?.toggleExpanded(); + }} + size={'small'} + data-testid={'database-actions-filter'} + color={filter.length > 0 ? 'primary' : undefined} + > + + + + + { + conditionsContext?.toggleExpanded(); + }} + color={sorts.length > 0 ? 'primary' : undefined} + > + + +
); } diff --git a/frontend/appflowy_web_app/src/components/database/components/conditions/DatabaseBlockActions.tsx b/frontend/appflowy_web_app/src/components/database/components/conditions/DatabaseBlockActions.tsx new file mode 100644 index 0000000000000..0f5c2fa6334ed --- /dev/null +++ b/frontend/appflowy_web_app/src/components/database/components/conditions/DatabaseBlockActions.tsx @@ -0,0 +1,33 @@ +import { ReactComponent as ExpandMoreIcon } from '@/assets/full_view.svg'; +import { DatabaseContext } from '@/application/database-yjs'; +import { IconButton, Tooltip } from '@mui/material'; +import React, { useContext } from 'react'; +import { useTranslation } from 'react-i18next'; + +function DatabaseBlockActions () { + const { t } = useTranslation(); + const context = useContext(DatabaseContext); + const navigateToView = context?.navigateToView; + const viewId = context?.viewId; + + return ( +
+ + { + if (!viewId) return; + void navigateToView?.(viewId); + }} + size={'small'} + > + + + +
+ ); +} + +export default DatabaseBlockActions; \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/components/database/components/conditions/DatabaseConditions.tsx b/frontend/appflowy_web_app/src/components/database/components/conditions/DatabaseConditions.tsx index 8b2f243c73396..5dfffd06ca85a 100644 --- a/frontend/appflowy_web_app/src/components/database/components/conditions/DatabaseConditions.tsx +++ b/frontend/appflowy_web_app/src/components/database/components/conditions/DatabaseConditions.tsx @@ -1,7 +1,8 @@ -import { useFiltersSelector, useSortsSelector } from '@/application/database-yjs'; +import { DatabaseContext, useDatabaseView, useFiltersSelector, useSortsSelector } from '@/application/database-yjs'; +import { DatabaseViewLayout, YjsDatabaseKey } from '@/application/types'; import { AFScroller } from '@/components/_shared/scroller'; import { useConditionsContext } from '@/components/database/components/conditions/context'; -import React from 'react'; +import React, { useContext, useMemo } from 'react'; import Filters from 'src/components/database/components/filters/Filters'; import Sorts from 'src/components/database/components/sorts/Sorts'; @@ -10,18 +11,38 @@ export function DatabaseConditions () { const expanded = conditionsContext?.expanded ?? false; const sorts = useSortsSelector(); const filters = useFiltersSelector(); + const view = useDatabaseView(); + const scrollLeft = useContext(DatabaseContext)?.scrollLeft; + const layout = Number(view?.get(YjsDatabaseKey.layout)); + const className = useMemo(() => { + const classList = ['database-conditions min-w-0 max-w-full relative transform overflow-hidden transition-all']; + + if (layout === DatabaseViewLayout.Grid) { + classList.push('max-sm:!pl-6'); + classList.push('pl-24'); + } else { + classList.push('max-sm:!px-6'); + classList.push('px-24'); + } + + return classList.join(' '); + }, [layout]); return (
- + {sorts.length > 0 && filters.length > 0 &&
} diff --git a/frontend/appflowy_web_app/src/components/database/components/database-row/DatabaseRowProperties.tsx b/frontend/appflowy_web_app/src/components/database/components/database-row/DatabaseRowProperties.tsx index f2dc779080bf9..d9fff839e9a7b 100644 --- a/frontend/appflowy_web_app/src/components/database/components/database-row/DatabaseRowProperties.tsx +++ b/frontend/appflowy_web_app/src/components/database/components/database-row/DatabaseRowProperties.tsx @@ -7,9 +7,13 @@ export function DatabaseRowProperties ({ rowId }: { rowId: string }) { const fields = useFieldsSelector().filter((column) => column.fieldId !== primaryFieldId); return ( -
+
{fields.map((field) => { - return ; + return ; })}
); diff --git a/frontend/appflowy_web_app/src/components/database/components/database-row/DatabaseRowSubDocument.tsx b/frontend/appflowy_web_app/src/components/database/components/database-row/DatabaseRowSubDocument.tsx index 2d4ef3a416984..8cadd0900c428 100644 --- a/frontend/appflowy_web_app/src/components/database/components/database-row/DatabaseRowSubDocument.tsx +++ b/frontend/appflowy_web_app/src/components/database/components/database-row/DatabaseRowSubDocument.tsx @@ -1,11 +1,12 @@ import { YDoc } from '@/application/types'; -import { DatabaseContext, useRowMetaSelector } from '@/application/database-yjs'; +import { DatabaseContext, useReadOnly, useRowMetaSelector } from '@/application/database-yjs'; import EditorSkeleton from '@/components/_shared/skeleton/EditorSkeleton'; import { Editor } from '@/components/editor'; import React, { useCallback, useContext, useEffect, useState } from 'react'; export function DatabaseRowSubDocument ({ rowId }: { rowId: string }) { const meta = useRowMetaSelector(rowId); + const readOnly = useReadOnly(); const documentId = meta?.documentId; const loadView = useContext(DatabaseContext)?.loadView; const createRowDoc = useContext(DatabaseContext)?.createRowDoc; @@ -47,7 +48,7 @@ export function DatabaseRowSubDocument ({ rowId }: { rowId: string }) { loadViewMeta={loadViewMeta} navigateToView={navigateToView} createRowDoc={createRowDoc} - readOnly={true} + readOnly={readOnly} loadView={loadView} /> ); diff --git a/frontend/appflowy_web_app/src/components/database/components/database-row/OpenAction.tsx b/frontend/appflowy_web_app/src/components/database/components/database-row/OpenAction.tsx index 94b6977bca73c..b405a85f670a8 100644 --- a/frontend/appflowy_web_app/src/components/database/components/database-row/OpenAction.tsx +++ b/frontend/appflowy_web_app/src/components/database/components/database-row/OpenAction.tsx @@ -1,16 +1,20 @@ -import { ReactComponent as ExpandMoreIcon } from '$icons/16x/full_view.svg'; +import { ReactComponent as ExpandMoreIcon } from '@/assets/full_view.svg'; + import { useTranslation } from 'react-i18next'; import { useNavigateToRow } from '@/application/database-yjs'; import { Tooltip } from '@mui/material'; import React from 'react'; -function OpenAction({ rowId }: { rowId: string }) { +function OpenAction ({ rowId }: { rowId: string }) { const navigateToRow = useNavigateToRow(); const { t } = useTranslation(); return ( - +