chore: fix update issues (#31)

This commit is contained in:
Kilu.He
2025-08-13 10:20:14 +08:00
committed by GitHub
parent b487e4eb1a
commit 5d45e0ccf7
25 changed files with 442 additions and 165 deletions

View File

@@ -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<void>;
eventEmitter?: EventEmitter;
}
export const DatabaseContext = createContext<DatabaseContextState | null>(null);

View File

@@ -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<string | null>(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]);

View File

@@ -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<void> {
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<void> {
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;

View File

@@ -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<void> {
return APIService.updatePageIcon(workspaceId, viewId, icon);
}
async updateAppPageName(workspaceId: string, viewId: string, name: string): Promise<void> {
return APIService.updatePageName(workspaceId, viewId, name);
}
}

View File

@@ -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<string>;
createFolderView: (workspaceId: string, payload: CreateFolderViewPayload) => Promise<string>;
updateAppPage: (workspaceId: string, viewId: string, data: UpdatePagePayload) => Promise<void>;
updateAppPageIcon: (workspaceId: string, viewId: string, icon: { ty: ViewIconType; value: string }) => Promise<void>;
updateAppPageName: (workspaceId: string, viewId: string, name: string) => Promise<void>;
deleteTrash: (workspaceId: string, viewId?: string) => Promise<void>;
moveToTrash: (workspaceId: string, viewId: string) => Promise<void>;
restoreFromTrash: (workspaceId: string, viewId?: string) => Promise<void>;

View File

@@ -1040,6 +1040,8 @@ export interface ViewMetaProps {
readOnly?: boolean;
updatePage?: (viewId: string, data: UpdatePagePayload) => Promise<void>;
uploadFile?: (file: File) => Promise<string>;
updatePageIcon?: (viewId: string, icon: { ty: ViewIconType; value: string }) => Promise<void>;
updatePageName?: (viewId: string, name: string) => Promise<void>;
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<void>;
updatePageName?: (viewId: string, name: string) => Promise<void>;
}
export interface CreatePagePayload {

View File

@@ -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
<>
<div className={`grid w-full grid-cols-4 gap-4 max-sm:grid-cols-3`}>
{photos.map((photo) => (
<div key={photo.id} className={'flex flex-col gap-2'}>
<div key={photo.id + photo.full} className={'flex flex-col gap-2'}>
<div className={'relative pt-[56.25%]'}>
<img
onClick={() => {

View File

@@ -82,7 +82,14 @@ function DatabaseView(props: ViewComponentProps) {
className={'relative flex h-full w-full flex-col'}
>
{rowId ? null : (
<ViewMetaPreview {...viewMeta} readOnly={props.readOnly} updatePage={props.updatePage} uploadFile={uploadFile} />
<ViewMetaPreview
{...viewMeta}
readOnly={props.readOnly}
updatePage={props.updatePage}
updatePageIcon={props.updatePageIcon}
updatePageName={props.updatePageName}
uploadFile={uploadFile}
/>
)}
<Suspense fallback={skeleton}>

View File

@@ -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<string>;
deletePage?: (viewId: string) => Promise<void>;
updatePage?: (viewId: string, payload: UpdatePagePayload) => Promise<void>;
updatePageIcon?: (viewId: string, icon: { ty: ViewIconType; value: string }) => Promise<void>;
updatePageName?: (viewId: string, name: string) => Promise<void>;
deleteTrash?: (viewId?: string) => Promise<void>;
restorePage?: (viewId?: string) => Promise<void>;
movePage?: (viewId: string, parentId: string, prevViewId?: string) => Promise<void>;
@@ -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,
}}
>
<AIChatProvider>
@@ -1459,6 +1498,8 @@ export function useAppHandlers() {
getMentionUser: context.getMentionUser,
awarenessMap: context.awarenessMap,
checkIfRowDocumentExists: context.checkIfRowDocumentExists,
updatePageIcon: context.updatePageIcon,
updatePageName: context.updatePageName,
};
}

View File

@@ -153,7 +153,7 @@ export function ApproveConversion() {
}
if (isError) {
return <ErrorPage onRetry={loadConversion} />;
return <ErrorPage onRetry={handleApprove} />;
}
return (

View File

@@ -135,7 +135,7 @@ function ApproveRequestPage() {
);
if (isError) {
return <ErrorPage onRetry={loadRequestInfo} />;
return <ErrorPage onRetry={handleApprove} />;
}
if (notInvitee) {

View File

@@ -105,7 +105,7 @@ function InviteCode() {
}
if (isError) {
return <ErrorPage onRetry={loadWorkspaceInfo} />;
return <ErrorPage onRetry={handleJoin} />;
}
if (hasJoined) {

View File

@@ -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 (
<LandingPage
@@ -69,7 +87,7 @@ function RequestAccess() {
<LandingPage
Logo={NoAccessLogo}
title={t('landingPage.noAccess.title')}
description={t('landingPage.noAccess.description')}
description={description}
primaryAction={{
onClick: handleSendRequest,
loading,

View File

@@ -152,9 +152,6 @@ function Database(props: Database2Props) {
setOpenModalViewId(viewId);
setOpenModalRowDatabaseDoc(viewDoc);
const database = viewDoc.getMap(YjsEditorKey.data_section)?.get(YjsEditorKey.database) as YDatabase;
console.log('======', database.toJSON());
const rowDoc = await createRowDoc?.(`${viewDoc.guid}_rows_${rowId}`);

View File

@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useCallback, useState } from 'react';
import { GroupColumn, Row, useBoardLayoutSettings, useGetBoardHiddenGroup } from '@/application/database-yjs';
import { useReorderGroupColumnDispatch } from '@/application/database-yjs/dispatch';
@@ -6,7 +6,7 @@ import HiddenColumnItem from '@/components/database/components/board/column/Hidd
import HiddenGroupColumnHeader from '@/components/database/components/board/column/HiddenGroupColumnHeader';
import { DragContext, useDragContextValue } from '@/components/database/components/drag-and-drop/useDragContext';
function HiddenGroupColumn ({
function HiddenGroupColumn({
groupId,
fieldId,
getRows,
@@ -15,37 +15,38 @@ function HiddenGroupColumn ({
fieldId: string;
getRows: (id: string) => 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<HTMLDivElement | null>(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'}
>
<HiddenGroupColumnHeader />
{!isCollapsed && <DragContext.Provider value={contextValue}>
<div className={'flex flex-col justify-start'}>
{hiddenColumns.map((column) => (
<HiddenColumnItem
key={column.id}
fieldId={fieldId}
id={column.id}
getRows={getRows}
groupId={groupId}
/>
))}
</div>
</DragContext.Provider>}
{!isCollapsed && (
<DragContext.Provider value={contextValue}>
<div className={'flex flex-col justify-start'}>
{hiddenColumns.map((column) => (
<HiddenColumnItem key={column.id} fieldId={fieldId} id={column.id} getRows={getRows} groupId={groupId} />
))}
</div>
</DragContext.Provider>
)}
</div>
);
}
export default HiddenGroupColumn;
export default HiddenGroupColumn;

View File

@@ -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,

View File

@@ -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);

View File

@@ -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<Record<string, string> | null>();
@@ -41,25 +39,25 @@ function RelationItems({
const [relatedFieldId, setRelatedFieldId] = useState<string | undefined>();
const relatedViewId = relatedDatabaseId ? relations?.[relatedDatabaseId] : null;
const [docGuid, setDocGuid] = useState<string | null>(null);
const [databaseDoc, setDatabaseDoc] = useState<YDoc | null>(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 (
<div
style={style}

View File

@@ -1,4 +1,4 @@
import { memo, useCallback, useEffect, useMemo, useState } from 'react';
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { toast } from 'sonner';
import {
@@ -13,7 +13,16 @@ import {
} from '@/application/database-yjs';
import { getCellDataText } from '@/application/database-yjs/cell.parse';
import { useUpdateRowMetaDispatch } from '@/application/database-yjs/dispatch';
import { YDatabaseCell, YDatabaseField, YDatabaseRow, YDoc, YjsDatabaseKey, YjsEditorKey } from '@/application/types';
import { YjsEditor } from '@/application/slate-yjs';
import {
BlockType,
YDatabaseCell,
YDatabaseField,
YDatabaseRow,
YDoc,
YjsDatabaseKey,
YjsEditorKey,
} from '@/application/types';
import { Editor } from '@/components/editor';
import { EditorSkeleton } from '@/components/_shared/skeleton/EditorSkeleton';
@@ -79,25 +88,6 @@ export const DatabaseRowSubDocument = memo(({ rowId }: { rowId: string }) => {
const [loading, setLoading] = useState(true);
const [doc, setDoc] = useState<YDoc | null>(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<YjsEditor | null>(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 <EditorSkeleton />;
}
@@ -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}
/>
);
});

View File

@@ -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;

View File

@@ -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<HTMLDivElement, DatabaseTabBarProps>(
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<View | null>(null);
const scrollLeft = context.paddingStart;
@@ -118,6 +120,26 @@ export const DatabaseTabs = forwardRef<HTMLDivElement, DatabaseTabBarProps>(
}
}, [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);

View File

@@ -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}

View File

@@ -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<HTMLDivElement>(null);
const timestampsRef = useRef<{
local: number;
remote: number;
}>({
local: 0,
remote: 0,
});
const [isEditing, setIsEditing] = useState(false);
const cursorPositionRef = useRef<number>(0);
const initialEditValueRef = useRef<string>('');
// 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 <br> tags by the browser
if (contentRef.current.innerHTML === '<br>') {
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))) {

View File

@@ -23,6 +23,8 @@ export function ViewMetaPreview({
uploadFile,
layout,
onFocus,
updatePageIcon,
updatePageName,
}: ViewMetaProps) {
const [cover, setCover] = React.useState<ViewMetaCover | null>(coverProp || null);
const [icon, setIcon] = React.useState<ViewMetaIcon | null>(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'}
/>
</div>
</CustomIconPopover>

View File

@@ -134,7 +134,7 @@ function AcceptInvitationPage() {
}
if (isError) {
return <ErrorPage onRetry={loadInvitation} />;
return <ErrorPage onRetry={handleJoinWorkspace} />;
}
if (hasJoined) {