From 5d45e0ccf769ce37e37fae264774373a641a7ffa Mon Sep 17 00:00:00 2001 From: "Kilu.He" <108015703+qinluhe@users.noreply.github.com> Date: Wed, 13 Aug 2025 10:20:14 +0800 Subject: [PATCH] chore: fix update issues (#31) --- src/application/database-yjs/context.ts | 2 + src/application/database-yjs/selector.ts | 44 +++++-- .../services/js-services/http/http_api.ts | 34 ++++- src/application/services/js-services/index.ts | 13 ++ src/application/services/services.type.ts | 3 + src/application/types.ts | 4 + .../_shared/image-upload/Unsplash.tsx | 5 +- src/components/app/DatabaseView.tsx | 9 +- src/components/app/app.hooks.tsx | 41 +++++++ .../app/landing-pages/ApproveConversion.tsx | 2 +- .../app/landing-pages/ApproveRequestPage.tsx | 2 +- .../app/landing-pages/InviteCode.tsx | 2 +- .../app/landing-pages/RequestAccess.tsx | 26 +++- src/components/database/Database.tsx | 3 - .../board/column/HiddenGroupColumn.tsx | 77 ++++++------ .../components/cell/relation/RelationCell.tsx | 3 +- .../cell/relation/RelationCellMenuContent.tsx | 2 +- .../cell/relation/RelationItems.tsx | 48 +++++--- .../database-row/DatabaseRowSubDocument.tsx | 106 ++++++++++++---- .../drag-and-drop/useDragContext.ts | 8 +- .../database/components/tabs/DatabaseTabs.tsx | 24 +++- src/components/document/Document.tsx | 4 +- src/components/view-meta/TitleEditable.tsx | 116 +++++++++++++----- src/components/view-meta/ViewMetaPreview.tsx | 27 ++-- src/pages/AcceptInvitationPage.tsx | 2 +- 25 files changed, 442 insertions(+), 165 deletions(-) diff --git a/src/application/database-yjs/context.ts b/src/application/database-yjs/context.ts index b64dfad4..99d5689f 100644 --- a/src/application/database-yjs/context.ts +++ b/src/application/database-yjs/context.ts @@ -21,6 +21,7 @@ import { YjsEditorKey, YSharedRoot, } from '@/application/types'; +import EventEmitter from 'events'; export interface DatabaseContextState { readOnly: boolean; @@ -54,6 +55,7 @@ export interface DatabaseContextState { testDatabasePromptConfig?: TestDatabasePromptConfig; requestInstance?: AxiosInstance | null; checkIfRowDocumentExists?: (documentId: string) => Promise; + eventEmitter?: EventEmitter; } export const DatabaseContext = createContext(null); diff --git a/src/application/database-yjs/selector.ts b/src/application/database-yjs/selector.ts index 2fb9b5e0..4f356426 100644 --- a/src/application/database-yjs/selector.ts +++ b/src/application/database-yjs/selector.ts @@ -18,6 +18,7 @@ import { getDateFormat, getTimeFormat, getTypeOptions, + parseRelationTypeOption, parseSelectOptionTypeOptions, SelectOption, TimeFormat, @@ -290,6 +291,30 @@ export function useFieldSelector(fieldId: string) { }; } +export function useDatabaseIdFromField(fieldId: string) { + const database = useDatabase(); + const field = database?.get(YjsDatabaseKey.fields)?.get(fieldId); + const [databaseId, setDatabaseId] = useState(null); + + useEffect(() => { + if (!field) return; + + const observerEvent = () => { + setDatabaseId(parseRelationTypeOption(field)?.database_id); + }; + + observerEvent(); + + field.observe(observerEvent); + + return () => { + field.unobserve(observerEvent); + }; + }, [database, field, fieldId]); + + return databaseId; +} + export function useFiltersSelector() { const database = useDatabase(); const viewId = useDatabaseViewId(); @@ -725,11 +750,14 @@ export function useRowOrdersSelector() { sorts?.observeDeep(throttleChange); filters?.observeDeep(throttleChange); fields?.observeDeep(throttleChange); - Object.values(rows || {}).forEach((rowDoc) => { - const rowSharedRoot = rowDoc.getMap(YjsEditorKey.data_section); - const databaseRow = rowSharedRoot.get(YjsEditorKey.database_row) as YDatabaseRow; + const debouncedConditionsChange = debounce(onConditionsChange, 150); - databaseRow?.get(YjsDatabaseKey.cells)?.observeDeep(throttleChange); + const observerRowsEvent = () => { + debouncedConditionsChange(); + }; + + Object.values(rows || {}).forEach((row) => { + row.getMap(YjsEditorKey.data_section).observeDeep(observerRowsEvent); }); return () => { @@ -737,12 +765,8 @@ export function useRowOrdersSelector() { sorts?.unobserveDeep(throttleChange); filters?.unobserveDeep(throttleChange); fields?.unobserveDeep(throttleChange); - - Object.values(rows || {}).forEach((rowDoc) => { - const rowSharedRoot = rowDoc.getMap(YjsEditorKey.data_section); - const databaseRow = rowSharedRoot.get(YjsEditorKey.database_row) as YDatabaseRow; - - databaseRow?.get(YjsDatabaseKey.cells)?.unobserveDeep(throttleChange); + Object.values(rows || {}).forEach((row) => { + row.getMap(YjsEditorKey.data_section).unobserveDeep(observerRowsEvent); }); }; }, [onConditionsChange, view, fields, filters, sorts, rows]); diff --git a/src/application/services/js-services/http/http_api.ts b/src/application/services/js-services/http/http_api.ts index 3e61f3b5..c8e1bb3d 100644 --- a/src/application/services/js-services/http/http_api.ts +++ b/src/application/services/js-services/http/http_api.ts @@ -50,6 +50,7 @@ import { UploadPublishNamespacePayload, User, View, + ViewIconType, ViewId, ViewInfo, ViewLayout, @@ -1548,6 +1549,37 @@ export async function updatePage(workspaceId: string, viewId: string, data: Upda return Promise.reject(res?.data); } +export async function updatePageIcon(workspaceId: string, viewId: string, icon: { + ty: ViewIconType; + value: string; +}): Promise { + const url = `/api/workspace/${workspaceId}/page-view/${viewId}/update-icon`; + const response = await axiosInstance?.post<{ + code: number; + message: string; + }>(url, { icon }); + + if (response?.data.code === 0) { + return; + } + + return Promise.reject(response?.data); +} + +export async function updatePageName(workspaceId: string, viewId: string, name: string): Promise { + const url = `/api/workspace/${workspaceId}/page-view/${viewId}/update-name`; + const response = await axiosInstance?.post<{ + code: number; + message: string; + }>(url, { name }); + + if (response?.data.code === 0) { + return; + } + + return Promise.reject(response?.data); +} + export async function deleteTrash(workspaceId: string, viewId?: string) { if (viewId) { const url = `/api/workspace/${workspaceId}/trash/${viewId}`; @@ -2155,7 +2187,7 @@ export async function addRecentPages(workspaceId: string, viewIds: string[]) { } export async function checkIfCollabExists(workspaceId: string, objectId: string) { - const url = `/api/workspace/${workspaceId}/collab/${objectId}/row-document-collab-exists`; + const url = `/api/workspace/${workspaceId}/collab/${objectId}/collab-exists`; const response = await axiosInstance?.get<{ code: number; diff --git a/src/application/services/js-services/index.ts b/src/application/services/js-services/index.ts index db9cb3b8..7fe5ebaa 100644 --- a/src/application/services/js-services/index.ts +++ b/src/application/services/js-services/index.ts @@ -54,6 +54,7 @@ import { UploadPublishNamespacePayload, WorkspaceMember, YjsEditorKey, + ViewIconType } from '@/application/types'; import { applyYDoc } from '@/application/ydoc/apply'; @@ -703,4 +704,16 @@ export class AFClientService implements AFService { async addRecentPages(workspaceId: string, viewIds: string[]) { return APIService.addRecentPages(workspaceId, viewIds); } + + async updateAppPageIcon( + workspaceId: string, + viewId: string, + icon: { ty: ViewIconType; value: string } + ): Promise { + return APIService.updatePageIcon(workspaceId, viewId, icon); + } + + async updateAppPageName(workspaceId: string, viewId: string, name: string): Promise { + return APIService.updatePageName(workspaceId, viewId, name); + } } diff --git a/src/application/services/services.type.ts b/src/application/services/services.type.ts index 205457d2..042e6e67 100644 --- a/src/application/services/services.type.ts +++ b/src/application/services/services.type.ts @@ -42,6 +42,7 @@ import { User, UserWorkspaceInfo, View, + ViewIconType, Workspace, WorkspaceMember, YDoc, @@ -132,6 +133,8 @@ export interface AppService { addAppPage: (workspaceId: string, parentViewId: string, payload: CreatePagePayload) => Promise; createFolderView: (workspaceId: string, payload: CreateFolderViewPayload) => Promise; updateAppPage: (workspaceId: string, viewId: string, data: UpdatePagePayload) => Promise; + updateAppPageIcon: (workspaceId: string, viewId: string, icon: { ty: ViewIconType; value: string }) => Promise; + updateAppPageName: (workspaceId: string, viewId: string, name: string) => Promise; deleteTrash: (workspaceId: string, viewId?: string) => Promise; moveToTrash: (workspaceId: string, viewId: string) => Promise; restoreFromTrash: (workspaceId: string, viewId?: string) => Promise; diff --git a/src/application/types.ts b/src/application/types.ts index abc7e4bf..6fc67c1b 100644 --- a/src/application/types.ts +++ b/src/application/types.ts @@ -1040,6 +1040,8 @@ export interface ViewMetaProps { readOnly?: boolean; updatePage?: (viewId: string, data: UpdatePagePayload) => Promise; uploadFile?: (file: File) => Promise; + updatePageIcon?: (viewId: string, icon: { ty: ViewIconType; value: string }) => Promise; + updatePageName?: (viewId: string, name: string) => Promise; onEnter?: (text: string) => void; maxWidth?: number; onFocus?: () => void; @@ -1082,6 +1084,8 @@ export interface ViewComponentProps { config: PromptDatabaseConfiguration; fields: DatabasePromptField[]; }>; + updatePageIcon?: (viewId: string, icon: { ty: ViewIconType; value: string }) => Promise; + updatePageName?: (viewId: string, name: string) => Promise; } export interface CreatePagePayload { diff --git a/src/components/_shared/image-upload/Unsplash.tsx b/src/components/_shared/image-upload/Unsplash.tsx index d8a61604..0016ff49 100644 --- a/src/components/_shared/image-upload/Unsplash.tsx +++ b/src/components/_shared/image-upload/Unsplash.tsx @@ -1,4 +1,3 @@ -import { openUrl } from '@/utils/url'; import { CircularProgress } from '@mui/material'; import TextField from '@mui/material/TextField'; import Typography from '@mui/material/Typography'; @@ -7,6 +6,8 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { createApi } from 'unsplash-js'; +import { openUrl } from '@/utils/url'; + const unsplash = createApi({ accessKey: '1WxD1JpMOUX86lZKKob4Ca0LMZPyO2rUmAgjpWm9Ids', }); @@ -129,7 +130,7 @@ export function Unsplash({ onDone, onEscape }: { onDone?: (value: string) => voi <>
{photos.map((photo) => ( -
+
{ diff --git a/src/components/app/DatabaseView.tsx b/src/components/app/DatabaseView.tsx index c414195a..a62007aa 100644 --- a/src/components/app/DatabaseView.tsx +++ b/src/components/app/DatabaseView.tsx @@ -82,7 +82,14 @@ function DatabaseView(props: ViewComponentProps) { className={'relative flex h-full w-full flex-col'} > {rowId ? null : ( - + )} diff --git a/src/components/app/app.hooks.tsx b/src/components/app/app.hooks.tsx index f4549a4a..eb0a4bed 100644 --- a/src/components/app/app.hooks.tsx +++ b/src/components/app/app.hooks.tsx @@ -37,6 +37,7 @@ import { UpdateSpacePayload, UserWorkspaceInfo, View, + ViewIconType, ViewLayout, YDatabase, YDoc, @@ -80,6 +81,8 @@ export interface AppContextType { addPage?: (parentId: string, payload: CreatePagePayload) => Promise; deletePage?: (viewId: string) => Promise; updatePage?: (viewId: string, payload: UpdatePagePayload) => Promise; + updatePageIcon?: (viewId: string, icon: { ty: ViewIconType; value: string }) => Promise; + updatePageName?: (viewId: string, name: string) => Promise; deleteTrash?: (viewId?: string) => Promise; restorePage?: (viewId?: string) => Promise; movePage?: (viewId: string, parentId: string, prevViewId?: string) => Promise; @@ -744,6 +747,40 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => { [currentWorkspaceId, service, loadOutline] ); + const updatePageIcon = useCallback( + async (viewId: string, icon: { ty: ViewIconType; value: string }) => { + if (!currentWorkspaceId || !service) { + throw new Error('No workspace or service found'); + } + + try { + await service.updateAppPageIcon(currentWorkspaceId, viewId, icon); + + return; + } catch (e) { + return Promise.reject(e); + } + }, + [currentWorkspaceId, service] + ); + + const updatePageName = useCallback( + async (viewId: string, name: string) => { + if (!currentWorkspaceId || !service) { + throw new Error('No workspace or service found'); + } + + try { + await service.updateAppPageName(currentWorkspaceId, viewId, name); + + return; + } catch (e) { + return Promise.reject(e); + } + }, + [currentWorkspaceId, service] + ); + const movePage = useCallback( async (viewId: string, parentId: string, prevViewId?: string) => { if (!currentWorkspaceId || !service) { @@ -1284,6 +1321,8 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => { getMentionUser, awarenessMap, checkIfRowDocumentExists, + updatePageIcon, + updatePageName, }} > @@ -1459,6 +1498,8 @@ export function useAppHandlers() { getMentionUser: context.getMentionUser, awarenessMap: context.awarenessMap, checkIfRowDocumentExists: context.checkIfRowDocumentExists, + updatePageIcon: context.updatePageIcon, + updatePageName: context.updatePageName, }; } diff --git a/src/components/app/landing-pages/ApproveConversion.tsx b/src/components/app/landing-pages/ApproveConversion.tsx index ee72c647..2a0a99d7 100644 --- a/src/components/app/landing-pages/ApproveConversion.tsx +++ b/src/components/app/landing-pages/ApproveConversion.tsx @@ -153,7 +153,7 @@ export function ApproveConversion() { } if (isError) { - return ; + return ; } return ( diff --git a/src/components/app/landing-pages/ApproveRequestPage.tsx b/src/components/app/landing-pages/ApproveRequestPage.tsx index e65e399b..ef582368 100644 --- a/src/components/app/landing-pages/ApproveRequestPage.tsx +++ b/src/components/app/landing-pages/ApproveRequestPage.tsx @@ -135,7 +135,7 @@ function ApproveRequestPage() { ); if (isError) { - return ; + return ; } if (notInvitee) { diff --git a/src/components/app/landing-pages/InviteCode.tsx b/src/components/app/landing-pages/InviteCode.tsx index 7921d72c..be2583e0 100644 --- a/src/components/app/landing-pages/InviteCode.tsx +++ b/src/components/app/landing-pages/InviteCode.tsx @@ -105,7 +105,7 @@ function InviteCode() { } if (isError) { - return ; + return ; } if (hasJoined) { diff --git a/src/components/app/landing-pages/RequestAccess.tsx b/src/components/app/landing-pages/RequestAccess.tsx index 98f91bc0..2f46346c 100644 --- a/src/components/app/landing-pages/RequestAccess.tsx +++ b/src/components/app/landing-pages/RequestAccess.tsx @@ -1,11 +1,12 @@ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; +import { useSearchParams } from 'react-router-dom'; import { toast } from 'sonner'; import { ReactComponent as NoAccessLogo } from '@/assets/icons/no_access.svg'; import { ReactComponent as SuccessLogo } from '@/assets/icons/success_logo.svg'; import { useAppViewId, useCurrentWorkspaceId } from '@/components/app/app.hooks'; -import { useService } from '@/components/main/app.hooks'; +import { useCurrentUser, useService } from '@/components/main/app.hooks'; import { Progress } from '@/components/ui/progress'; import { ErrorPage } from '@/components/_shared/landing-page/ErrorPage'; import LandingPage from '@/components/_shared/landing-page/LandingPage'; @@ -17,10 +18,12 @@ function RequestAccess() { const service = useService(); const currentWorkspaceId = useCurrentWorkspaceId(); const viewId = useAppViewId(); + const [searchParams] = useSearchParams(); + const isGuest = searchParams.get('is_guest') === 'true'; const [hasSend, setHasSend] = useState(false); const [loading, setLoading] = useState(false); const [isError, setIsError] = useState(false); - + const currentUser = useCurrentUser(); const handleSendRequest = async () => { try { if (!service) return; @@ -47,6 +50,21 @@ function RequestAccess() { } }; + useEffect(() => { + if (isGuest && currentUser) { + window.open( + `appflowy-flutter://open-page?workspace_id=${currentWorkspaceId}&view_id=${viewId}&email=${currentUser.email}`, + '_self' + ); + } + }, [isGuest, currentWorkspaceId, viewId, currentUser]); + + const description = isGuest + ? `${t( + 'landingPage.noAccess.description' + )}\n\n Guests invited to this page can access it via the desktop or mobile app.` + : t('landingPage.noAccess.description'); + if (hasSend) { return ( Row[]; }) { - const { - hiddenColumns, - } = useGetBoardHiddenGroup(groupId); + const { hiddenColumns } = useGetBoardHiddenGroup(groupId); const { isCollapsed } = useBoardLayoutSettings(); const reorderColumn = useReorderGroupColumnDispatch(groupId); - const onReorder = ({ - oldData, - newData, - startIndex, - finishIndex, - }: { - oldData: GroupColumn[] - newData: GroupColumn[] - startIndex: number - finishIndex: number - }) => { - const columnId = oldData[startIndex].id; + const onReorder = useCallback( + ({ + oldData, + newData, + startIndex, + finishIndex, + }: { + oldData: GroupColumn[]; + newData: GroupColumn[]; + startIndex: number; + finishIndex: number; + }) => { + const columnId = oldData[startIndex].id; - if (!columnId) { - throw new Error('No columnId provided'); - } + if (!columnId) { + throw new Error('No columnId provided'); + } - const beforeId = newData[finishIndex - 1]?.id; + const beforeId = newData[finishIndex - 1]?.id; - reorderColumn(columnId, beforeId); - }; + reorderColumn(columnId, beforeId); + }, + [reorderColumn] + ); const [container, setContainer] = useState(null); const contextValue = useDragContextValue({ data: hiddenColumns, - enabled: true, + enabled: !isCollapsed, reorderAction: onReorder, container, }); @@ -56,26 +57,20 @@ function HiddenGroupColumn ({ style={{ width: isCollapsed ? '32px' : '240px', }} - className={'flex transform transition-all flex-col overflow-hidden gap-2'} + className={'flex transform flex-col gap-2 overflow-hidden transition-all'} > - {!isCollapsed && -
- {hiddenColumns.map((column) => ( - - ))} -
-
} - - + {!isCollapsed && ( + +
+ {hiddenColumns.map((column) => ( + + ))} +
+
+ )}
); } -export default HiddenGroupColumn; \ No newline at end of file +export default HiddenGroupColumn; diff --git a/src/components/database/components/cell/relation/RelationCell.tsx b/src/components/database/components/cell/relation/RelationCell.tsx index de618a70..f0d78256 100644 --- a/src/components/database/components/cell/relation/RelationCell.tsx +++ b/src/components/database/components/cell/relation/RelationCell.tsx @@ -1,7 +1,8 @@ +import { useMemo } from 'react'; + import { CellProps, RelationCell as RelationCellType } from '@/application/database-yjs/cell.type'; import RelationCellMenu from '@/components/database/components/cell/relation/RelationCellMenu'; import RelationItems from '@/components/database/components/cell/relation/RelationItems'; -import { useMemo } from 'react'; export function RelationCell({ cell, diff --git a/src/components/database/components/cell/relation/RelationCellMenuContent.tsx b/src/components/database/components/cell/relation/RelationCellMenuContent.tsx index b639db8b..24b44df2 100644 --- a/src/components/database/components/cell/relation/RelationCellMenuContent.tsx +++ b/src/components/database/components/cell/relation/RelationCellMenuContent.tsx @@ -111,7 +111,7 @@ function RelationCellMenuContent({ const rowSharedRoot = rowDoc.getMap(YjsEditorKey.data_section); const row = rowSharedRoot?.get(YjsEditorKey.database_row) as YDatabaseRow; - const cell = row.get(YjsDatabaseKey.cells)?.get(primaryFieldId); + const cell = row?.get(YjsDatabaseKey.cells)?.get(primaryFieldId); if (!cell) return ''; const cellValue = parseYDatabaseCellToCell(cell); diff --git a/src/components/database/components/cell/relation/RelationItems.tsx b/src/components/database/components/cell/relation/RelationItems.tsx index e5ac4f61..69a65af5 100644 --- a/src/components/database/components/cell/relation/RelationItems.tsx +++ b/src/components/database/components/cell/relation/RelationItems.tsx @@ -4,12 +4,11 @@ import * as Y from 'yjs'; import { DatabaseContextState, getPrimaryFieldId, - parseRelationTypeOption, useDatabaseContext, - useFieldSelector, + useDatabaseIdFromField, } from '@/application/database-yjs'; import { RelationCell, RelationCellData } from '@/application/database-yjs/cell.type'; -import { View, YDatabase, YDoc, YjsEditorKey } from '@/application/types'; +import { YDoc, YjsEditorKey } from '@/application/types'; import { RelationPrimaryValue } from '@/components/database/components/cell/relation/RelationPrimaryValue'; import { notify } from '@/components/_shared/notify'; import { cn } from '@/lib/utils'; @@ -27,13 +26,12 @@ function RelationItems({ }) { const context = useDatabaseContext(); const viewId = context.iidIndex; - const { field } = useFieldSelector(fieldId); - const relatedDatabaseId = field ? parseRelationTypeOption(field)?.database_id : null; + const relatedDatabaseId = useDatabaseIdFromField(fieldId); const createRowDoc = context.createRowDoc; - const loadViewMeta = context.loadViewMeta; const loadView = context.loadView; const navigateToRow = context.navigateToRow; + const loadDatabaseRelations = context.loadDatabaseRelations; const [noAccess, setNoAccess] = useState(false); const [relations, setRelations] = useState | null>(); @@ -41,25 +39,25 @@ function RelationItems({ const [relatedFieldId, setRelatedFieldId] = useState(); const relatedViewId = relatedDatabaseId ? relations?.[relatedDatabaseId] : null; const [docGuid, setDocGuid] = useState(null); + const [databaseDoc, setDatabaseDoc] = useState(null); const [rowIds, setRowIds] = useState([] as string[]); const navigateToView = context.navigateToView; useEffect(() => { - if (!viewId) return; + const loadRelations = async () => { + const relations = await loadDatabaseRelations?.(); - const update = (meta: View | null) => { - if (!meta) return; - setRelations(meta.database_relations); + setRelations(relations); }; try { - void loadViewMeta?.(viewId, update); + void loadRelations(); } catch (e) { console.error(e); } - }, [loadViewMeta, viewId]); + }, [loadDatabaseRelations]); const handleUpdateRowIds = useCallback(() => { const data = cell?.data; @@ -109,11 +107,8 @@ function RelationItems({ } setDocGuid(viewDoc.guid); - const database = viewDoc.getMap(YjsEditorKey.data_section).get(YjsEditorKey.database) as YDatabase; - const fieldId = getPrimaryFieldId(database); - setNoAccess(!fieldId); - setRelatedFieldId(fieldId); + setDatabaseDoc(viewDoc); } catch (e) { console.error(e); setNoAccess(true); @@ -121,6 +116,27 @@ function RelationItems({ })(); }, [loadView, relatedViewId]); + useEffect(() => { + if (!databaseDoc) return; + const sharedRoot = databaseDoc.getMap(YjsEditorKey.data_section); + + const observerEvent = () => { + const database = sharedRoot.get(YjsEditorKey.database); + + const fieldId = getPrimaryFieldId(database); + + setRelatedFieldId(fieldId); + setNoAccess(!fieldId); + }; + + observerEvent(); + + sharedRoot.observe(observerEvent); + return () => { + sharedRoot.unobserve(observerEvent); + }; + }, [databaseDoc]); + return (
{ const [loading, setLoading] = useState(true); const [doc, setDoc] = useState(null); - const handleCreateDocument = useCallback( - async (documentId: string) => { - if (!createOrphanedView || !documentId) return; - try { - setDoc(null); - await createOrphanedView({ document_id: documentId }); - - const doc = await loadView?.(documentId, true, true); - - if (doc) { - setDoc(doc); - } - // eslint-disable-next-line - } catch (e: any) { - toast.error(e.message); - } - }, - [createOrphanedView, loadView] - ); const document = doc?.getMap(YjsEditorKey.data_section)?.get(YjsEditorKey.document); const handleOpenDocument = useCallback( @@ -119,6 +109,24 @@ export const DatabaseRowSubDocument = memo(({ rowId }: { rowId: string }) => { }, [loadView] ); + const handleCreateDocument = useCallback( + async (documentId: string) => { + if (!createOrphanedView || !documentId) return; + setLoading(true); + try { + setDoc(null); + await createOrphanedView({ document_id: documentId }); + + await handleOpenDocument(documentId); + // eslint-disable-next-line + } catch (e: any) { + toast.error(e.message); + } finally { + setLoading(false); + } + }, + [createOrphanedView, handleOpenDocument] + ); useEffect(() => { if (!documentId) return; @@ -137,6 +145,57 @@ export const DatabaseRowSubDocument = memo(({ rowId }: { rowId: string }) => { return JSON.stringify(properties); }, [properties]); + const editorRef = useRef(null); + + const isDocumentEmpty = useCallback((editor: YjsEditor) => { + const children = editor.children; + + if (children.length === 0) { + return true; + } + + if (children.length === 1) { + const firstChildBlockType = 'type' in children[0] ? (children[0].type as BlockType) : BlockType.Paragraph; + + if (firstChildBlockType !== BlockType.Paragraph) { + return false; + } + + return true; + } + + return false; + }, []); + + const handleEditorConnected = useCallback( + (editor: YjsEditor) => { + editorRef.current = editor; + if (readOnly) return; + + if (!isDocumentEmpty(editor)) { + updateRowMeta(RowMetaKey.IsDocumentEmpty, false); + return; + } + }, + [isDocumentEmpty, updateRowMeta, readOnly] + ); + + const handleWordCountChange = useCallback( + (_: string, { characters }: { characters: number }) => { + if (characters > 0) { + updateRowMeta(RowMetaKey.IsDocumentEmpty, false); + return; + } + + const editor = editorRef.current; + + if (!editor) return; + + updateRowMeta(RowMetaKey.IsDocumentEmpty, isDocumentEmpty(editor)); + }, + [isDocumentEmpty, updateRowMeta] + ); + if (loading) { return ; } @@ -150,9 +209,8 @@ export const DatabaseRowSubDocument = memo(({ rowId }: { rowId: string }) => { doc={doc} readOnly={readOnly} getMoreAIContext={getMoreAIContext} - onWordCountChange={(_, { characters }) => { - updateRowMeta(RowMetaKey.IsDocumentEmpty, characters <= 0); - }} + onEditorConnected={handleEditorConnected} + onWordCountChange={handleWordCountChange} /> ); }); diff --git a/src/components/database/components/drag-and-drop/useDragContext.ts b/src/components/database/components/drag-and-drop/useDragContext.ts index f825aa1d..909ca287 100644 --- a/src/components/database/components/drag-and-drop/useDragContext.ts +++ b/src/components/database/components/drag-and-drop/useDragContext.ts @@ -78,9 +78,9 @@ export function useDragContextValue< stableData.current = data; }, [data]); - const getData = () => { + const getData = useCallback(() => { return stableData.current; - }; + }, []); const reorderItem = useCallback( ({ startIndex, indexOfTarget, closestEdgeOfTarget }: ReorderPayload) => { @@ -163,9 +163,9 @@ export function useDragContextValue< reorderItem, registerItem: registry.register, instanceId, - enabled + enabled, }), - [reorderItem, registry.register, instanceId, enabled] + [getData, reorderItem, registry.register, instanceId, enabled] ); return contextValue; diff --git a/src/components/database/components/tabs/DatabaseTabs.tsx b/src/components/database/components/tabs/DatabaseTabs.tsx index 683e5cc0..4e971e80 100644 --- a/src/components/database/components/tabs/DatabaseTabs.tsx +++ b/src/components/database/components/tabs/DatabaseTabs.tsx @@ -2,6 +2,7 @@ import { forwardRef, useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { toast } from 'sonner'; +import { APP_EVENTS } from '@/application/constants'; import { useDatabase, useDatabaseContext } from '@/application/database-yjs'; import { useAddDatabaseView, useUpdateDatabaseView } from '@/application/database-yjs/dispatch'; import { DatabaseViewLayout, View, ViewLayout, YDatabaseView, YjsDatabaseKey } from '@/application/types'; @@ -17,6 +18,7 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigge import { Progress } from '@/components/ui/progress'; import { TabLabel, Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; +import { findView } from '@/components/_shared/outline/utils'; import { AFScroller } from '@/components/_shared/scroller'; import { ViewIcon } from '@/components/_shared/view-icon'; import PageIcon from '@/components/_shared/view-icon/PageIcon'; @@ -36,7 +38,7 @@ export const DatabaseTabs = forwardRef( const views = useDatabase().get(YjsDatabaseKey.views); const context = useDatabaseContext(); const onAddView = useAddDatabaseView(); - const { loadViewMeta, readOnly, showActions = true } = context; + const { loadViewMeta, readOnly, showActions = true, eventEmitter } = context; const updatePage = useUpdateDatabaseView(); const [meta, setMeta] = useState(null); const scrollLeft = context.paddingStart; @@ -118,6 +120,26 @@ export const DatabaseTabs = forwardRef( } }, [iidIndex, loadViewMeta]); + useEffect(() => { + const handleOutlineLoaded = (outline: View[]) => { + const view = findView(outline, iidIndex); + + if (view) { + setMeta(view); + } + }; + + if (eventEmitter) { + eventEmitter.on(APP_EVENTS.OUTLINE_LOADED, handleOutlineLoaded); + } + + return () => { + if (eventEmitter) { + eventEmitter.off(APP_EVENTS.OUTLINE_LOADED, handleOutlineLoaded); + } + }; + }, [iidIndex, eventEmitter, reloadView]); + const renameView = useMemo(() => { if (renameViewId === iidIndex) return meta; return meta?.children.find((v) => v.view_id === renameViewId); diff --git a/src/components/document/Document.tsx b/src/components/document/Document.tsx index d8e5b69f..bbc83c23 100644 --- a/src/components/document/Document.tsx +++ b/src/components/document/Document.tsx @@ -22,7 +22,7 @@ export type DocumentProps = ViewComponentProps & { export const Document = (props: DocumentProps) => { const [search] = useSearchParams(); - const { doc, readOnly, viewMeta, isTemplateThumb, updatePage, onRendered, onEditorConnected, uploadFile } = props; + const { doc, readOnly, viewMeta, isTemplateThumb, updatePage, onRendered, onEditorConnected, uploadFile, updatePageIcon, updatePageName } = props; const blockId = search.get('blockId') || undefined; const awareness = useAppAwareness(viewMeta.viewId); @@ -141,6 +141,8 @@ export const Document = (props: DocumentProps) => { {...viewMeta} readOnly={readOnly} updatePage={updatePage} + updatePageIcon={updatePageIcon} + updatePageName={updatePageName} onEnter={readOnly ? undefined : handleEnter} maxWidth={952} uploadFile={uploadFile} diff --git a/src/components/view-meta/TitleEditable.tsx b/src/components/view-meta/TitleEditable.tsx index 4de11186..62326f93 100644 --- a/src/components/view-meta/TitleEditable.tsx +++ b/src/components/view-meta/TitleEditable.tsx @@ -1,5 +1,5 @@ import { debounce } from 'lodash-es'; -import { memo, useEffect, useMemo, useRef } from 'react'; +import { memo, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; const isCursorAtEnd = (el: HTMLDivElement) => { @@ -23,6 +23,22 @@ const getCursorOffset = () => { return range.startOffset; }; +const setCursorPosition = (element: HTMLDivElement, position: number) => { + const range = document.createRange(); + const selection = window.getSelection(); + + if (!element.firstChild) return; + + const textNode = element.firstChild; + const maxPosition = textNode.textContent?.length || 0; + const safePosition = Math.min(position, maxPosition); + + range.setStart(textNode, safePosition); + range.collapse(true); + selection?.removeAllRanges(); + selection?.addRange(range); +}; + function TitleEditable({ viewId, name, @@ -40,33 +56,29 @@ function TitleEditable({ const debounceUpdateName = useMemo(() => { return debounce(onUpdateName, 200); }, [onUpdateName]); + const contentRef = useRef(null); - const timestampsRef = useRef<{ - local: number; - remote: number; - }>({ - local: 0, - remote: 0, - }); + const [isEditing, setIsEditing] = useState(false); + const cursorPositionRef = useRef(0); + const initialEditValueRef = useRef(''); + // Only update the content when not editing useEffect(() => { - timestampsRef.current.remote = Date.now(); - if (contentRef.current && timestampsRef.current.local < timestampsRef.current.remote) { + if (!isEditing && contentRef.current && contentRef.current.textContent !== name) { + const cursorPosition = cursorPositionRef.current; + contentRef.current.textContent = name; - } - }, [name]); - useEffect(() => { - if (contentRef.current) { - const activeElement = document.activeElement; - - if (activeElement === contentRef.current) { - return; + // If the element has focus, restore the cursor position + if (document.activeElement === contentRef.current) { + setTimeout(() => { + if (contentRef.current) { + setCursorPosition(contentRef.current, cursorPosition); + } + }, 0); } - - contentRef.current.textContent = name; } - }, [name]); + }, [name, isEditing]); const focusedTextbox = () => { const contentBox = contentRef.current; @@ -78,19 +90,22 @@ function TitleEditable({ textbox?.focus(); }; + // Set focus and cursor position when initializing useEffect(() => { const contentBox = contentRef.current; if (!contentBox) return; - contentBox.focus(); - if (contentBox.textContent !== '') { - const range = document.createRange(); - const sel = window.getSelection(); - range.setStart(contentBox.childNodes[0], contentBox.textContent?.length || 0); - range.collapse(true); - sel?.removeAllRanges(); - sel?.addRange(range); + // Record the initial value (for subsequent editing comparison) + initialEditValueRef.current = contentBox.textContent || ''; + + contentBox.focus(); + + // Move the cursor to the end + if (contentBox.textContent !== '') { + setTimeout(() => { + setCursorPosition(contentBox, contentBox.textContent?.length || 0); + }, 0); } }, []); @@ -110,18 +125,52 @@ function TitleEditable({ aria-readonly={false} autoFocus={true} onFocus={() => { + // Record the initial value when starting to edit + if (contentRef.current) { + initialEditValueRef.current = contentRef.current.textContent || ''; + } + + setIsEditing(true); onFocus?.(); }} + onBlur={() => { + // Immediately save the user's latest input to avoid content loss due to debounce + if (contentRef.current) { + const currentText = contentRef.current.textContent || ''; + const initialValue = initialEditValueRef.current; + + // Cancel debounce, update immediately (but only when the user really modified the content) + debounceUpdateName.cancel(); + if (currentText !== initialValue) { + onUpdateName(currentText); + } + } + + // Delay a bit before setting the editing state to avoid issues with rapid focus switching + setTimeout(() => { + setIsEditing(false); + }, 100); + }} onInput={() => { if (!contentRef.current) return; - timestampsRef.current.local = Date.now(); - debounceUpdateName(contentRef.current.textContent || ''); + + // Save the current cursor position + cursorPositionRef.current = getCursorOffset(); + + // Clean up the automatically inserted
tags by the browser if (contentRef.current.innerHTML === '
') { contentRef.current.innerHTML = ''; } + + // Debounce update remote data + debounceUpdateName(contentRef.current.textContent || ''); }} onKeyDown={(e) => { if (!contentRef.current) return; + + // Save the current cursor position + cursorPositionRef.current = getCursorOffset(); + if (e.key === 'Enter' || e.key === 'Escape') { e.preventDefault(); if (e.key === 'Enter') { @@ -130,7 +179,7 @@ function TitleEditable({ const afterText = contentRef.current.textContent?.slice(offset) || ''; contentRef.current.textContent = beforeText; - timestampsRef.current.remote = Date.now(); + setIsEditing(false); onUpdateName(beforeText); onEnter?.(afterText); @@ -138,7 +187,8 @@ function TitleEditable({ focusedTextbox(); }, 0); } else { - timestampsRef.current.remote = Date.now(); + // Escape key: complete editing and save the current content + setIsEditing(false); onUpdateName(contentRef.current.textContent || ''); } } else if (e.key === 'ArrowDown' || (e.key === 'ArrowRight' && isCursorAtEnd(contentRef.current))) { diff --git a/src/components/view-meta/ViewMetaPreview.tsx b/src/components/view-meta/ViewMetaPreview.tsx index a2892605..41d43f71 100644 --- a/src/components/view-meta/ViewMetaPreview.tsx +++ b/src/components/view-meta/ViewMetaPreview.tsx @@ -23,6 +23,8 @@ export function ViewMetaPreview({ uploadFile, layout, onFocus, + updatePageIcon, + updatePageName, }: ViewMetaProps) { const [cover, setCover] = React.useState(coverProp || null); const [icon, setIcon] = React.useState(iconProp || null); @@ -69,41 +71,30 @@ export function ViewMetaPreview({ const handleUpdateIcon = React.useCallback( async (icon: { ty: ViewIconType; value: string }) => { - if (!updatePage || !viewId) return; + if (!updatePageIcon || !viewId) return; setIcon(icon); try { - await updatePage(viewId, { - icon, - name: name || '', - extra: extra || {}, - }); + await updatePageIcon(viewId, icon); // eslint-disable-next-line } catch (e: any) { notify.error(e.message); } }, - [updatePage, viewId, name, extra] + [updatePageIcon, viewId] ); const handleUpdateName = React.useCallback( async (newName: string) => { - if (!updatePage || !viewId) return; + if (!updatePageName || !viewId) return; try { if (name === newName) return; - await updatePage(viewId, { - icon: icon || { - ty: ViewIconType.Emoji, - value: '', - }, - name: newName, - extra: extra || {}, - }); + await updatePageName(viewId, newName); // eslint-disable-next-line } catch (e: any) { notify.error(e.message); } }, - [name, updatePage, viewId, icon, extra] + [name, updatePageName, viewId] ); const handleUpdateCover = React.useCallback( @@ -253,7 +244,7 @@ export function ViewMetaPreview({ icon, layout: ViewLayout.Document, }} - className={'flex h-[90%] w-[90%] items-center justify-center'} + className={'flex h-[90%] w-[90%] min-w-[36px] items-center justify-center'} />
diff --git a/src/pages/AcceptInvitationPage.tsx b/src/pages/AcceptInvitationPage.tsx index 6ba3211c..f2b5300f 100644 --- a/src/pages/AcceptInvitationPage.tsx +++ b/src/pages/AcceptInvitationPage.tsx @@ -134,7 +134,7 @@ function AcceptInvitationPage() { } if (isError) { - return ; + return ; } if (hasJoined) {