refactor(infra): directory structure (#4615)

This commit is contained in:
Joooye_34
2023-10-18 23:30:08 +08:00
committed by GitHub
parent 814d552be8
commit bed9310519
1150 changed files with 539 additions and 584 deletions

View File

@@ -0,0 +1,23 @@
/**
* @vitest-environment happy-dom
*/
import { renderHook } from '@testing-library/react';
import { describe, expect, test, vi } from 'vitest';
import { useSystemOnline } from '../use-system-online';
describe('useSystemOnline', () => {
test('should be online', () => {
const systemOnlineHook = renderHook(() => useSystemOnline());
expect(systemOnlineHook.result.current).toBe(true);
});
test('should be offline', () => {
const systemOnlineHook = renderHook(() => useSystemOnline());
vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(false);
expect(systemOnlineHook.result.current).toBe(true);
window.dispatchEvent(new Event('offline'));
systemOnlineHook.rerender();
expect(systemOnlineHook.result.current).toBe(false);
});
});

View File

@@ -0,0 +1,6 @@
# AFFiNE Hooks
> This directory will be moved to `@affine/worksapce/affine/hooks` in the future.
Only put hooks in this directory if they are specific to AFFiNE, for example
if they are using the AFFiNE API, or if the `AffineWorkspace` is required.

View File

@@ -0,0 +1,157 @@
import {
useBlockSuitePageMeta,
usePageMetaHelper,
} from '@toeverything/hooks/use-block-suite-page-meta';
import { useAtomValue, useSetAtom } from 'jotai';
import { useCallback } from 'react';
import { setPageModeAtom } from '../../atoms';
import { currentModeAtom } from '../../atoms/mode';
import type { BlockSuiteWorkspace } from '../../shared';
import { useReferenceLinkHelper } from './use-reference-link-helper';
export function useBlockSuiteMetaHelper(
blockSuiteWorkspace: BlockSuiteWorkspace
) {
const { setPageMeta, getPageMeta, setPageReadonly } =
usePageMetaHelper(blockSuiteWorkspace);
const { addReferenceLink } = useReferenceLinkHelper(blockSuiteWorkspace);
const metas = useBlockSuitePageMeta(blockSuiteWorkspace);
const setPageMode = useSetAtom(setPageModeAtom);
const currentMode = useAtomValue(currentModeAtom);
const switchToPageMode = useCallback(
(pageId: string) => {
setPageMode(pageId, 'page');
},
[setPageMode]
);
const switchToEdgelessMode = useCallback(
(pageId: string) => {
setPageMode(pageId, 'edgeless');
},
[setPageMode]
);
const togglePageMode = useCallback(
(pageId: string) => {
setPageMode(pageId, currentMode === 'edgeless' ? 'page' : 'edgeless');
},
[currentMode, setPageMode]
);
const addToFavorite = useCallback(
(pageId: string) => {
setPageMeta(pageId, {
favorite: true,
});
},
[setPageMeta]
);
const removeFromFavorite = useCallback(
(pageId: string) => {
setPageMeta(pageId, {
favorite: false,
});
},
[setPageMeta]
);
const toggleFavorite = useCallback(
(pageId: string) => {
const { favorite } = getPageMeta(pageId) ?? {};
setPageMeta(pageId, {
favorite: !favorite,
});
},
[getPageMeta, setPageMeta]
);
// TODO-Doma
// "Remove" may cause ambiguity here. Consider renaming as "moveToTrash".
const removeToTrash = useCallback(
(pageId: string, isRoot = true) => {
const parentMeta = metas.find(m => m.subpageIds?.includes(pageId));
const { subpageIds = [] } = getPageMeta(pageId) ?? {};
subpageIds.forEach(id => {
removeToTrash(id, false);
});
setPageMeta(pageId, {
trash: true,
trashDate: +new Date(),
trashRelate: isRoot ? parentMeta?.id : undefined,
});
setPageReadonly(pageId, true);
},
[getPageMeta, metas, setPageMeta, setPageReadonly]
);
const restoreFromTrash = useCallback(
(pageId: string) => {
const { subpageIds = [], trashRelate } = getPageMeta(pageId) ?? {};
if (trashRelate) {
addReferenceLink(trashRelate, pageId);
}
setPageMeta(pageId, {
trash: false,
trashDate: undefined,
trashRelate: undefined,
});
setPageReadonly(pageId, false);
subpageIds.forEach(id => {
restoreFromTrash(id);
});
},
[addReferenceLink, getPageMeta, setPageMeta, setPageReadonly]
);
const permanentlyDeletePage = useCallback(
(pageId: string) => {
blockSuiteWorkspace.removePage(pageId);
},
[blockSuiteWorkspace]
);
/**
* see {@link useBlockSuiteWorkspacePageIsPublic}
*/
const publicPage = useCallback(
(pageId: string) => {
setPageMeta(pageId, {
isPublic: true,
});
},
[setPageMeta]
);
/**
* see {@link useBlockSuiteWorkspacePageIsPublic}
*/
const cancelPublicPage = useCallback(
(pageId: string) => {
setPageMeta(pageId, {
isPublic: false,
});
},
[setPageMeta]
);
return {
switchToPageMode,
switchToEdgelessMode,
togglePageMode,
publicPage,
cancelPublicPage,
addToFavorite,
removeFromFavorite,
toggleFavorite,
removeToTrash,
restoreFromTrash,
permanentlyDeletePage,
};
}

View File

@@ -0,0 +1,10 @@
// eslint-disable-next-line @typescript-eslint/no-restricted-imports
import { useSession } from 'next-auth/react';
export function useCurrentLoginStatus():
| 'authenticated'
| 'unauthenticated'
| 'loading' {
const session = useSession();
return session.status;
}

View File

@@ -0,0 +1,45 @@
import type { DefaultSession } from 'next-auth';
// eslint-disable-next-line @typescript-eslint/no-restricted-imports
import { useSession } from 'next-auth/react';
export type CheckedUser = {
id: string;
name: string;
email: string;
image: string;
hasPassword: boolean;
update: ReturnType<typeof useSession>['update'];
};
// FIXME: Should this namespace be here?
declare module 'next-auth' {
interface Session {
user: {
id: string;
hasPassword: boolean;
} & DefaultSession['user'];
}
}
/**
* This hook checks if the user is logged in.
* If not, it will throw an error.
*/
export function useCurrentUser(): CheckedUser {
const { data: session, status, update } = useSession();
// If you are seeing this error, it means that you are not logged in.
// This should be prohibited in the development environment, please re-write your component logic.
if (status === 'unauthenticated') {
throw new Error('session.status should be authenticated');
}
const user = session?.user;
return {
id: user?.id ?? 'REPLACE_ME_DEFAULT_ID',
name: user?.name ?? 'REPLACE_ME_DEFAULT_NAME',
email: user?.email ?? 'REPLACE_ME_DEFAULT_EMAIL',
image: user?.image ?? 'REPLACE_ME_DEFAULT_URL',
hasPassword: user?.hasPassword ?? false,
update,
};
}

View File

@@ -0,0 +1,79 @@
import { pushNotificationAtom } from '@affine/component/notification-center';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import type { PageBlockModel } from '@blocksuite/blocks';
import { ContentParser } from '@blocksuite/blocks/content-parser';
import type { Page } from '@blocksuite/store';
import { useSetAtom } from 'jotai';
import { useCallback } from 'react';
type ExportType = 'pdf' | 'html' | 'png' | 'markdown';
const typeToContentParserMethodMap = {
pdf: 'exportPdf',
html: 'exportHtml',
png: 'exportPng',
markdown: 'exportMarkdown',
} satisfies Record<ExportType, keyof ContentParser>;
const contentParserWeakMap = new WeakMap<Page, ContentParser>();
const getContentParser = (page: Page) => {
if (!contentParserWeakMap.has(page)) {
contentParserWeakMap.set(
page,
new ContentParser(page, {
imageProxyEndpoint: !environment.isDesktop
? runtimeConfig.imageProxyUrl
: undefined,
})
);
}
return contentParserWeakMap.get(page) as ContentParser;
};
interface ExportHandlerOptions {
page: Page;
type: ExportType;
}
async function exportHandler({ page, type }: ExportHandlerOptions) {
if (type === 'pdf' && environment.isDesktop && page.meta.mode === 'page') {
window.apis?.export.savePDFFileAs(
(page.root as PageBlockModel).title.toString()
);
} else {
const contentParser = getContentParser(page);
const method = typeToContentParserMethodMap[type];
await contentParser[method]();
}
}
export const useExportPage = (page: Page) => {
const pushNotification = useSetAtom(pushNotificationAtom);
const t = useAFFiNEI18N();
const onClickHandler = useCallback(
async (type: ExportType) => {
try {
await exportHandler({
page,
type,
});
pushNotification({
title: t['com.affine.export.success.title'](),
message: t['com.affine.export.success.message'](),
type: 'success',
});
} catch (err) {
console.error(err);
pushNotification({
title: t['com.affine.export.error.title'](),
message: t['com.affine.export.error.message'](),
type: 'error',
});
}
},
[page, pushNotification, t]
);
return onClickHandler;
};

View File

@@ -0,0 +1,30 @@
import type { Permission } from '@affine/graphql';
import { inviteByEmailMutation } from '@affine/graphql';
import { useMutation } from '@affine/workspace/affine/gql';
import { useCallback } from 'react';
import { useMutateCloud } from './use-mutate-cloud';
export function useInviteMember(workspaceId: string) {
const { trigger, isMutating } = useMutation({
mutation: inviteByEmailMutation,
});
const mutate = useMutateCloud();
return {
invite: useCallback(
async (email: string, permission: Permission, sendInviteMail = false) => {
const res = await trigger({
workspaceId,
email,
permission,
sendInviteMail,
});
await mutate();
// return is successful
return res?.invite;
},
[mutate, trigger, workspaceId]
),
isMutating,
};
}

View File

@@ -0,0 +1,57 @@
import {
getWorkspaceSharedPagesQuery,
revokePageMutation,
sharePageMutation,
} from '@affine/graphql';
import { useMutation, useQuery } from '@affine/workspace/affine/gql';
import { useCallback, useMemo } from 'react';
export function useIsSharedPage(
workspaceId: string,
pageId: string
): [isSharedPage: boolean, setSharedPage: (enable: boolean) => void] {
const { data, mutate } = useQuery({
query: getWorkspaceSharedPagesQuery,
variables: {
workspaceId,
},
});
const { trigger: enableSharePage } = useMutation({
mutation: sharePageMutation,
});
const { trigger: disableSharePage } = useMutation({
mutation: revokePageMutation,
});
return [
useMemo(
() => data.workspace.sharedPages.some(id => id === pageId),
[data.workspace.sharedPages, pageId]
),
useCallback(
(enable: boolean) => {
// todo: push notification
if (enable) {
enableSharePage({
workspaceId,
pageId,
})
.then(() => {
return mutate();
})
.catch(console.error);
} else {
disableSharePage({
workspaceId,
pageId,
})
.then(() => {
return mutate();
})
.catch(console.error);
}
mutate().catch(console.error);
},
[disableSharePage, enableSharePage, mutate, pageId, workspaceId]
),
];
}

View File

@@ -0,0 +1,13 @@
import { getIsOwnerQuery } from '@affine/graphql';
import { useQuery } from '@affine/workspace/affine/gql';
export function useIsWorkspaceOwner(workspaceId: string) {
const { data } = useQuery({
query: getIsOwnerQuery,
variables: {
workspaceId,
},
});
return data.isOwner;
}

View File

@@ -0,0 +1,34 @@
import { LOCALES, useI18N } from '@affine/i18n';
import { useCallback, useMemo } from 'react';
export function useLanguageHelper() {
const i18n = useI18N();
const currentLanguage = useMemo(
() => LOCALES.find(item => item.tag === i18n.language),
[i18n.language]
);
const languagesList = useMemo(
() =>
LOCALES.map(item => ({
tag: item.tag,
originalName: item.originalName,
name: item.name,
})),
[]
);
const onSelect = useCallback(
(event: string) => {
i18n.changeLanguage(event);
},
[i18n]
);
return useMemo(
() => ({
currentLanguage,
languagesList,
onSelect,
}),
[currentLanguage, languagesList, onSelect]
);
}

View File

@@ -0,0 +1,26 @@
import { leaveWorkspaceMutation } from '@affine/graphql';
import { useMutation } from '@affine/workspace/affine/gql';
import { useCallback } from 'react';
import { useAppHelper } from '../use-workspaces';
export function useLeaveWorkspace() {
const { deleteWorkspaceMeta } = useAppHelper();
const { trigger: leaveWorkspace } = useMutation({
mutation: leaveWorkspaceMutation,
});
return useCallback(
async (workspaceId: string, workspaceName: string) => {
deleteWorkspaceMeta(workspaceId);
await leaveWorkspace({
workspaceId,
workspaceName,
sendLeaveMail: true,
});
},
[deleteWorkspaceMeta, leaveWorkspace]
);
}

View File

@@ -0,0 +1,13 @@
import { getMemberCountByWorkspaceIdQuery } from '@affine/graphql';
import { useQuery } from '@affine/workspace/affine/gql';
export function useMemberCount(workspaceId: string) {
const { data } = useQuery({
query: getMemberCountByWorkspaceIdQuery,
variables: {
workspaceId,
},
});
return data.workspace.memberCount;
}

View File

@@ -0,0 +1,25 @@
import {
type GetMembersByWorkspaceIdQuery,
getMembersByWorkspaceIdQuery,
} from '@affine/graphql';
import { useQuery } from '@affine/workspace/affine/gql';
export type Member = Omit<
GetMembersByWorkspaceIdQuery['workspace']['members'][number],
'__typename'
>;
export function useMembers(
workspaceId: string,
skip: number,
take: number = 8
) {
const { data } = useQuery({
query: getMembersByWorkspaceIdQuery,
variables: {
workspaceId,
skip,
take,
},
});
return data.workspace.members;
}

View File

@@ -0,0 +1,14 @@
import { useCallback } from 'react';
import { useSWRConfig } from 'swr';
export function useMutateCloud() {
const { mutate } = useSWRConfig();
return useCallback(async () => {
return mutate(key => {
if (Array.isArray(key)) {
return key[0] === 'cloud';
}
return false;
});
}, [mutate]);
}

View File

@@ -0,0 +1,35 @@
import { useCallback } from 'react';
import type { BlockSuiteWorkspace } from '../../shared';
export function useReferenceLinkHelper(
blockSuiteWorkspace: BlockSuiteWorkspace
) {
const addReferenceLink = useCallback(
(pageId: string, referenceId: string) => {
const page = blockSuiteWorkspace?.getPage(pageId);
if (!page) {
return;
}
const text = page.Text.fromDelta([
{
insert: ' ',
attributes: {
reference: {
type: 'Subpage',
pageId: referenceId,
},
},
},
]);
const [frame] = page.getBlockByFlavour('affine:note');
frame && page.addBlock('affine:paragraph', { text }, frame.id);
},
[blockSuiteWorkspace]
);
return {
addReferenceLink,
};
}

View File

@@ -0,0 +1,208 @@
import { toast } from '@affine/component';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { assertExists } from '@blocksuite/global/utils';
import { EdgelessIcon, PageIcon } from '@blocksuite/icons';
import type { Workspace } from '@blocksuite/store';
import { usePageMetaHelper } from '@toeverything/hooks/use-block-suite-page-meta';
import {
PreconditionStrategy,
registerAffineCommand,
} from '@toeverything/infra/command';
import { useCallback, useEffect } from 'react';
import { useBlockSuiteMetaHelper } from './use-block-suite-meta-helper';
import { useExportPage } from './use-export-page';
import { useTrashModalHelper } from './use-trash-modal-helper';
export function useRegisterBlocksuiteEditorCommands(
blockSuiteWorkspace: Workspace,
pageId: string,
mode: 'page' | 'edgeless'
) {
const t = useAFFiNEI18N();
const { getPageMeta } = usePageMetaHelper(blockSuiteWorkspace);
const currentPage = blockSuiteWorkspace.getPage(pageId);
assertExists(currentPage);
const pageMeta = getPageMeta(pageId);
assertExists(pageMeta);
const favorite = pageMeta.favorite ?? false;
const trash = pageMeta.trash ?? false;
const { togglePageMode, toggleFavorite, restoreFromTrash } =
useBlockSuiteMetaHelper(blockSuiteWorkspace);
const exportHandler = useExportPage(currentPage);
const { setTrashModal } = useTrashModalHelper(blockSuiteWorkspace);
const onClickDelete = useCallback(() => {
setTrashModal({
open: true,
pageId: pageId,
pageTitle: pageMeta.title,
});
}, [pageId, pageMeta.title, setTrashModal]);
useEffect(() => {
const unsubs: Array<() => void> = [];
const preconditionStrategy = () =>
PreconditionStrategy.InPaperOrEdgeless && !trash;
//TODO: add back when edgeless presentation is ready
// this is pretty hack and easy to break. need a better way to communicate with blocksuite editor
// unsubs.push(
// registerAffineCommand({
// id: 'editor:edgeless-presentation-start',
// preconditionStrategy: () => PreconditionStrategy.InEdgeless && !trash,
// category: 'editor:edgeless',
// icon: <EdgelessIcon />,
// label: t['com.affine.cmdk.affine.editor.edgeless.presentation-start'](),
// run() {
// document
// .querySelector<HTMLElement>('edgeless-toolbar')
// ?.shadowRoot?.querySelector<HTMLElement>(
// '.edgeless-toolbar-left-part > edgeless-tool-icon-button:last-child'
// )
// ?.click();
// },
// })
// );
unsubs.push(
registerAffineCommand({
id: `editor:${mode}-${favorite ? 'remove-from' : 'add-to'}-favourites`,
preconditionStrategy,
category: `editor:${mode}`,
icon: mode === 'page' ? <PageIcon /> : <EdgelessIcon />,
label: favorite
? t['com.affine.favoritePageOperation.remove']()
: t['com.affine.favoritePageOperation.add'](),
run() {
toggleFavorite(pageId);
toast(
favorite
? t['com.affine.cmdk.affine.editor.remove-from-favourites']()
: t['com.affine.cmdk.affine.editor.add-to-favourites']()
);
},
})
);
unsubs.push(
registerAffineCommand({
id: `editor:${mode}-convert-to-${
mode === 'page' ? 'edgeless' : 'page'
}`,
preconditionStrategy,
category: `editor:${mode}`,
icon: mode === 'page' ? <PageIcon /> : <EdgelessIcon />,
label: `${t['Convert to ']()}${
mode === 'page'
? t['com.affine.pageMode.edgeless']()
: t['com.affine.pageMode.page']()
}`,
run() {
togglePageMode(pageId);
toast(
mode === 'page'
? t['com.affine.toastMessage.edgelessMode']()
: t['com.affine.toastMessage.pageMode']()
);
},
})
);
unsubs.push(
registerAffineCommand({
id: `editor:${mode}-export-to-pdf`,
preconditionStrategy,
category: `editor:${mode}`,
icon: mode === 'page' ? <PageIcon /> : <EdgelessIcon />,
label: t['Export to PDF'](),
run() {
exportHandler('pdf');
},
})
);
unsubs.push(
registerAffineCommand({
id: `editor:${mode}-export-to-html`,
preconditionStrategy,
category: `editor:${mode}`,
icon: mode === 'page' ? <PageIcon /> : <EdgelessIcon />,
label: t['Export to HTML'](),
run() {
exportHandler('html');
},
})
);
unsubs.push(
registerAffineCommand({
id: `editor:${mode}-export-to-png`,
preconditionStrategy,
category: `editor:${mode}`,
icon: mode === 'page' ? <PageIcon /> : <EdgelessIcon />,
label: t['Export to PNG'](),
run() {
exportHandler('png');
},
})
);
unsubs.push(
registerAffineCommand({
id: `editor:${mode}-export-to-markdown`,
preconditionStrategy,
category: `editor:${mode}`,
icon: mode === 'page' ? <PageIcon /> : <EdgelessIcon />,
label: t['Export to Markdown'](),
run() {
exportHandler('markdown');
},
})
);
unsubs.push(
registerAffineCommand({
id: `editor:${mode}-move-to-trash`,
preconditionStrategy,
category: `editor:${mode}`,
icon: mode === 'page' ? <PageIcon /> : <EdgelessIcon />,
label: t['com.affine.moveToTrash.title'](),
run() {
onClickDelete();
},
})
);
unsubs.push(
registerAffineCommand({
id: `editor:${mode}-restore-from-trash`,
preconditionStrategy: () =>
PreconditionStrategy.InPaperOrEdgeless && trash,
category: `editor:${mode}`,
icon: mode === 'page' ? <PageIcon /> : <EdgelessIcon />,
label: t['com.affine.cmdk.affine.editor.restore-from-trash'](),
run() {
restoreFromTrash(pageId);
},
})
);
return () => {
unsubs.forEach(unsub => unsub());
};
}, [
favorite,
mode,
onClickDelete,
exportHandler,
pageId,
pageMeta.title,
restoreFromTrash,
t,
toggleFavorite,
togglePageMode,
trash,
]);
}

View File

@@ -0,0 +1,24 @@
import { revokeMemberPermissionMutation } from '@affine/graphql';
import { useMutation } from '@affine/workspace/affine/gql';
import { useCallback } from 'react';
import { useMutateCloud } from './use-mutate-cloud';
export function useRevokeMemberPermission(workspaceId: string) {
const mutate = useMutateCloud();
const { trigger } = useMutation({
mutation: revokeMemberPermissionMutation,
});
return useCallback(
async (userId: string) => {
const res = await trigger({
workspaceId,
userId,
});
await mutate();
return res;
},
[mutate, trigger, workspaceId]
);
}

View File

@@ -0,0 +1,15 @@
'use client';
import { useMemo } from 'react';
export function useShareLink(workspaceId: string): string {
return useMemo(() => {
if (environment.isServer) {
throw new Error('useShareLink is not available on server side');
}
if (environment.isDesktop) {
return '???';
} else {
return origin + '/share/' + workspaceId;
}
}, [workspaceId]);
}

View File

@@ -0,0 +1,325 @@
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { useCallback, useMemo } from 'react';
type KeyboardShortcutsI18NKeys =
| 'cancel'
| 'quickSearch'
| 'newPage'
| 'appendDailyNote'
| 'expandOrCollapseSidebar'
| 'goBack'
| 'goForward'
| 'selectAll'
| 'undo'
| 'redo'
| 'zoomIn'
| 'zoomOut'
| 'zoomTo100'
| 'zoomToFit'
| 'select'
| 'text'
| 'shape'
| 'image'
| 'straightConnector'
| 'elbowedConnector'
| 'curveConnector'
| 'pen'
| 'hand'
| 'note'
| 'group'
| 'unGroup'
| 'switch'
| 'bold'
| 'italic'
| 'underline'
| 'strikethrough'
| 'inlineCode'
| 'codeBlock'
| 'link'
| 'bodyText'
| 'increaseIndent'
| 'reduceIndent'
| 'groupDatabase'
| 'moveUp'
| 'moveDown'
| 'divider';
// TODO(550): remove this hook after 'useAFFiNEI18N' support scoped i18n
const useKeyboardShortcutsI18N = () => {
const t = useAFFiNEI18N();
return useCallback(
(key: KeyboardShortcutsI18NKeys) =>
t[`com.affine.keyboardShortcuts.${key}`](),
[t]
);
};
// TODO(550): remove this hook after 'useAFFiNEI18N' support scoped i18n
const useHeadingKeyboardShortcutsI18N = () => {
const t = useAFFiNEI18N();
return useCallback(
(number: string) => t['com.affine.keyboardShortcuts.heading']({ number }),
[t]
);
};
interface ShortcutMap {
[x: string]: string[];
}
export interface ShortcutsInfo {
title: string;
shortcuts: ShortcutMap;
}
export const useWinGeneralKeyboardShortcuts = (): ShortcutMap => {
const t = useKeyboardShortcutsI18N();
return useMemo(
() => ({
[t('cancel')]: ['ESC'],
[t('quickSearch')]: ['Ctrl', 'K'],
[t('newPage')]: ['Ctrl', 'N'],
// not implement yet
// [t('appendDailyNote')]: 'Ctrl + Alt + A',
[t('expandOrCollapseSidebar')]: ['Ctrl', '/'],
[t('goBack')]: ['Ctrl + ['],
[t('goForward')]: ['Ctrl + ]'],
}),
[t]
);
};
export const useMacGeneralKeyboardShortcuts = (): ShortcutMap => {
const t = useKeyboardShortcutsI18N();
return useMemo(
() => ({
[t('cancel')]: ['ESC'],
[t('quickSearch')]: ['⌘', 'K'],
[t('newPage')]: ['⌘', 'N'],
// not implement yet
// [t('appendDailyNote')]: '⌘ + ⌥ + A',
[t('expandOrCollapseSidebar')]: ['⌘', '/'],
[t('goBack')]: ['⌘ + ['],
[t('goForward')]: ['⌘ + ]'],
}),
[t]
);
};
export const useMacEdgelessKeyboardShortcuts = (): ShortcutMap => {
const t = useKeyboardShortcutsI18N();
return useMemo(
() => ({
[t('selectAll')]: ['⌘', 'A'],
[t('undo')]: ['⌘', 'Z'],
[t('redo')]: ['⌘', '⇧', 'Z'],
[t('zoomIn')]: ['⌘', '+'],
[t('zoomOut')]: ['⌘', '-'],
[t('zoomTo100')]: ['⌘', '0'],
[t('zoomToFit')]: ['⌘', '1'],
[t('select')]: ['V'],
[t('text')]: ['T'],
[t('shape')]: ['S'],
[t('image')]: ['I'],
[t('straightConnector')]: ['L'],
[t('elbowedConnector')]: ['X'],
// not implement yet
// [t('curveConnector')]: 'C',
[t('pen')]: ['P'],
[t('hand')]: ['H'],
[t('note')]: ['N'],
// not implement yet
// [t('group')]: '⌘ + G',
// [t('unGroup')]: '⌘ + ⇧ + G',
}),
[t]
);
};
export const useWinEdgelessKeyboardShortcuts = (): ShortcutMap => {
const t = useKeyboardShortcutsI18N();
return useMemo(
() => ({
[t('selectAll')]: ['Ctrl', 'A'],
[t('undo')]: ['Ctrl', 'Z'],
[t('redo')]: ['Ctrl', 'Y/Ctrl', 'Shift', 'Z'],
[t('zoomIn')]: ['Ctrl', '+'],
[t('zoomOut')]: ['Ctrl', '-'],
[t('zoomTo100')]: ['Ctrl', '0'],
[t('zoomToFit')]: ['Ctrl', '1'],
[t('select')]: ['V'],
[t('text')]: ['T'],
[t('shape')]: ['S'],
[t('image')]: ['I'],
[t('straightConnector')]: ['L'],
[t('elbowedConnector')]: ['X'],
// not implement yet
// [t('curveConnector')]: 'C',
[t('pen')]: ['P'],
[t('hand')]: ['H'],
[t('note')]: ['N'],
[t('switch')]: ['Alt ', ''],
// not implement yet
// [t('group')]: 'Ctrl + G',
// [t('unGroup')]: 'Ctrl + Shift + G',
}),
[t]
);
};
export const useMacPageKeyboardShortcuts = (): ShortcutMap => {
const t = useKeyboardShortcutsI18N();
const tH = useHeadingKeyboardShortcutsI18N();
return useMemo(
() => ({
[t('undo')]: ['⌘', 'Z'],
[t('redo')]: ['⌘', '⇧', 'Z'],
[t('bold')]: ['⌘', 'B'],
[t('italic')]: ['⌘', 'I'],
[t('underline')]: ['⌘', 'U'],
[t('strikethrough')]: ['⌘', '⇧', 'S'],
[t('inlineCode')]: ['⌘', 'E'],
[t('codeBlock')]: ['⌘', '⌥', 'C'],
[t('link')]: ['⌘', 'K'],
[t('quickSearch')]: ['⌘', 'K'],
[t('bodyText')]: ['⌘', '⌥', '0'],
[tH('1')]: ['⌘', '⌥', '1'],
[tH('2')]: ['⌘', '⌥', '2'],
[tH('3')]: ['⌘', '⌥', '3'],
[tH('4')]: ['⌘', '⌥', '4'],
[tH('5')]: ['⌘', '⌥', '5'],
[tH('6')]: ['⌘', '⌥', '6'],
[t('increaseIndent')]: ['Tab'],
[t('reduceIndent')]: ['⇧', 'Tab'],
[t('groupDatabase')]: ['⌘', 'G'],
[t('switch')]: ['⌥', 'S'],
// not implement yet
// [t('moveUp')]: '⌘ + ⌥ + ↑',
// [t('moveDown')]: '⌘ + ⌥ + ↓',
}),
[t, tH]
);
};
export const useMacMarkdownShortcuts = (): ShortcutMap => {
const t = useKeyboardShortcutsI18N();
const tH = useHeadingKeyboardShortcutsI18N();
return useMemo(
() => ({
[t('bold')]: ['**Text**'],
[t('italic')]: ['*Text*'],
[t('underline')]: ['~Text~'],
[t('strikethrough')]: ['~~Text~~'],
[t('divider')]: ['***'],
[t('inlineCode')]: ['`Text` '],
[t('codeBlock')]: ['``` Space'],
[tH('1')]: ['# Text'],
[tH('2')]: ['## Text'],
[tH('3')]: ['### Text'],
[tH('4')]: ['#### Text'],
[tH('5')]: ['##### Text'],
[tH('6')]: ['###### Text'],
}),
[t, tH]
);
};
export const useWinPageKeyboardShortcuts = (): ShortcutMap => {
const t = useKeyboardShortcutsI18N();
const tH = useHeadingKeyboardShortcutsI18N();
return useMemo(
() => ({
[t('undo')]: ['Ctrl', 'Z'],
[t('redo')]: ['Ctrl', 'Y'],
[t('bold')]: ['Ctrl', 'B'],
[t('italic')]: ['Ctrl', 'I'],
[t('underline')]: ['Ctrl', 'U'],
[t('strikethrough')]: ['Ctrl', 'Shift', 'S'],
[t('inlineCode')]: [' Ctrl', 'E'],
[t('codeBlock')]: ['Ctrl', 'Alt', 'C'],
[t('link')]: ['Ctr', 'K'],
[t('quickSearch')]: ['Ctrl', 'K'],
[t('bodyText')]: ['Ctrl', 'Shift', '0'],
[tH('1')]: ['Ctrl', 'Shift', '1'],
[tH('2')]: ['Ctrl', 'Shift', '2'],
[tH('3')]: ['Ctrl', 'Shift', '3'],
[tH('4')]: ['Ctrl', 'Shift', '4'],
[tH('5')]: ['Ctrl', 'Shift', '5'],
[tH('6')]: ['Ctrl', 'Shift', '6'],
[t('increaseIndent')]: ['Tab'],
[t('reduceIndent')]: ['Shift+Tab'],
[t('groupDatabase')]: ['Ctrl + G'],
['Switch']: ['Alt + S'],
// not implement yet
// [t('moveUp')]: 'Ctrl + Alt + ↑',
// [t('moveDown')]: 'Ctrl + Alt + ↓',
}),
[t, tH]
);
};
export const useWinMarkdownShortcuts = (): ShortcutMap => {
const t = useKeyboardShortcutsI18N();
const tH = useHeadingKeyboardShortcutsI18N();
return useMemo(
() => ({
[t('bold')]: ['**Text** '],
[t('italic')]: ['*Text* '],
[t('underline')]: ['~Text~ '],
[t('strikethrough')]: ['~~Text~~ '],
[t('divider')]: ['***'],
[t('inlineCode')]: ['`Text` '],
[t('codeBlock')]: ['``` Text'],
[tH('1')]: ['# Text'],
[tH('2')]: ['## Text'],
[tH('3')]: ['### Text'],
[tH('4')]: ['#### Text'],
[tH('5')]: ['##### Text'],
[tH('6')]: ['###### Text'],
}),
[t, tH]
);
};
export const useMarkdownShortcuts = (): ShortcutsInfo => {
const t = useAFFiNEI18N();
const macMarkdownShortcuts = useMacMarkdownShortcuts();
const winMarkdownShortcuts = useWinMarkdownShortcuts();
const isMac = environment.isBrowser && environment.isMacOs;
return {
title: t['com.affine.shortcutsTitle.markdownSyntax'](),
shortcuts: isMac ? macMarkdownShortcuts : winMarkdownShortcuts,
};
};
export const usePageShortcuts = (): ShortcutsInfo => {
const t = useAFFiNEI18N();
const macPageShortcuts = useMacPageKeyboardShortcuts();
const winPageShortcuts = useWinPageKeyboardShortcuts();
const isMac = environment.isBrowser && environment.isMacOs;
return {
title: t['com.affine.shortcutsTitle.page'](),
shortcuts: isMac ? macPageShortcuts : winPageShortcuts,
};
};
export const useEdgelessShortcuts = (): ShortcutsInfo => {
const t = useAFFiNEI18N();
const macEdgelessShortcuts = useMacEdgelessKeyboardShortcuts();
const winEdgelessShortcuts = useWinEdgelessKeyboardShortcuts();
const isMac = environment.isBrowser && environment.isMacOs;
return {
title: t['com.affine.shortcutsTitle.edgeless'](),
shortcuts: isMac ? macEdgelessShortcuts : winEdgelessShortcuts,
};
};
export const useGeneralShortcuts = (): ShortcutsInfo => {
const t = useAFFiNEI18N();
const macGeneralShortcuts = useMacGeneralKeyboardShortcuts();
const winGeneralShortcuts = useWinGeneralKeyboardShortcuts();
const isMac = environment.isBrowser && environment.isMacOs;
return {
title: t['com.affine.shortcutsTitle.general'](),
shortcuts: isMac ? macGeneralShortcuts : winGeneralShortcuts,
};
};

View File

@@ -0,0 +1,22 @@
import { setWorkspacePublicByIdMutation } from '@affine/graphql';
import { useMutation } from '@affine/workspace/affine/gql';
import { useCallback } from 'react';
import { useMutateCloud } from './use-mutate-cloud';
export function useToggleCloudPublic(workspaceId: string) {
const mutate = useMutateCloud();
const { trigger } = useMutation({
mutation: setWorkspacePublicByIdMutation,
});
return useCallback(
async (isPublic: boolean) => {
await trigger({
id: workspaceId,
public: isPublic,
});
await mutate();
},
[mutate, trigger, workspaceId]
);
}

View File

@@ -0,0 +1,27 @@
import { toast } from '@affine/component';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import type { Workspace } from '@blocksuite/store';
import { useAtom } from 'jotai';
import { useCallback } from 'react';
import { trashModalAtom } from '../../atoms/trash-modal';
import { useBlockSuiteMetaHelper } from './use-block-suite-meta-helper';
export function useTrashModalHelper(blocksuiteWorkspace: Workspace) {
const t = useAFFiNEI18N();
const [trashModal, setTrashModal] = useAtom(trashModalAtom);
const { pageId } = trashModal;
const { removeToTrash } = useBlockSuiteMetaHelper(blocksuiteWorkspace);
const handleOnConfirm = useCallback(() => {
removeToTrash(pageId);
toast(t['com.affine.toastMessage.movedTrash']());
setTrashModal({ ...trashModal, open: false });
}, [pageId, removeToTrash, setTrashModal, t, trashModal]);
return {
trashModal,
setTrashModal,
handleOnConfirm,
};
}

View File

@@ -0,0 +1,54 @@
import { assertExists } from '@blocksuite/global/utils';
import {
currentPageIdAtom,
currentWorkspaceIdAtom,
} from '@toeverything/infra/atom';
import { useAtom, useSetAtom } from 'jotai';
import { useCallback, useEffect } from 'react';
import type { AllWorkspace } from '../../shared';
import { useWorkspace, useWorkspaceEffect } from '../use-workspace';
declare global {
/**
* @internal debug only
*/
// eslint-disable-next-line no-var
var currentWorkspace: AllWorkspace | undefined;
interface WindowEventMap {
'affine:workspace:change': CustomEvent<{ id: string }>;
}
}
export function useCurrentWorkspace(): [
AllWorkspace,
(id: string | null) => void,
] {
const [id, setId] = useAtom(currentWorkspaceIdAtom);
assertExists(id);
const currentWorkspace = useWorkspace(id);
// when you call current workspace, effect is always called
useWorkspaceEffect(currentWorkspace.id);
useEffect(() => {
globalThis.currentWorkspace = currentWorkspace;
globalThis.dispatchEvent(
new CustomEvent('affine:workspace:change', {
detail: { id: currentWorkspace.id },
})
);
}, [currentWorkspace]);
const setPageId = useSetAtom(currentPageIdAtom);
return [
currentWorkspace,
useCallback(
(id: string | null) => {
if (environment.isBrowser && id) {
localStorage.setItem('last_workspace_id', id);
}
setPageId(null);
setId(id);
},
[setId, setPageId]
),
];
}

View File

@@ -0,0 +1,90 @@
import { pushNotificationAtom } from '@affine/component/notification-center';
import type { WorkspaceRegistry } from '@affine/env/workspace';
import type { WorkspaceFlavour } from '@affine/env/workspace';
import { WorkspaceSubPath } from '@affine/env/workspace';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import {
rootWorkspacesMetadataAtom,
workspaceAdaptersAtom,
} from '@affine/workspace/atom';
import { currentPageIdAtom } from '@toeverything/infra/atom';
import { WorkspaceVersion } from '@toeverything/infra/blocksuite';
import { useAtomValue, useSetAtom } from 'jotai';
import { useCallback } from 'react';
import { openSettingModalAtom } from '../../atoms';
import { useNavigateHelper } from '../use-navigate-helper';
export function useOnTransformWorkspace() {
const t = useAFFiNEI18N();
const setSettingModal = useSetAtom(openSettingModalAtom);
const WorkspaceAdapters = useAtomValue(workspaceAdaptersAtom);
const setMetadata = useSetAtom(rootWorkspacesMetadataAtom);
const { openPage } = useNavigateHelper();
const currentPageId = useAtomValue(currentPageIdAtom);
const pushNotification = useSetAtom(pushNotificationAtom);
return useCallback(
async <From extends WorkspaceFlavour, To extends WorkspaceFlavour>(
from: From,
to: To,
workspace: WorkspaceRegistry[From]
): Promise<void> => {
// create first, then delete, in case of failure
const newId = await WorkspaceAdapters[to].CRUD.create(
workspace.blockSuiteWorkspace
);
await WorkspaceAdapters[from].CRUD.delete(workspace.blockSuiteWorkspace);
setMetadata(workspaces => {
const idx = workspaces.findIndex(ws => ws.id === workspace.id);
workspaces.splice(idx, 1, {
id: newId,
flavour: to,
version: WorkspaceVersion.SubDoc,
});
return [...workspaces];
}, newId);
// fixme(himself65): setting modal could still open and open the non-exist workspace
setSettingModal(settings => ({
...settings,
open: false,
}));
window.dispatchEvent(
new CustomEvent('affine-workspace:transform', {
detail: {
from,
to,
oldId: workspace.id,
newId: newId,
},
})
);
openPage(newId, currentPageId ?? WorkspaceSubPath.ALL);
pushNotification({
title: t['Successfully enabled AFFiNE Cloud'](),
type: 'success',
});
},
[
WorkspaceAdapters,
setMetadata,
setSettingModal,
openPage,
currentPageId,
pushNotification,
t,
]
);
}
declare global {
// global Events
interface WindowEventMap {
'affine-workspace:transform': CustomEvent<{
from: WorkspaceFlavour;
to: WorkspaceFlavour;
oldId: string;
newId: string;
}>;
}
}

View File

@@ -0,0 +1,91 @@
import { pushNotificationAtom } from '@affine/component/notification-center';
import type {
AffineSocketIOProvider,
LocalIndexedDBBackgroundProvider,
SQLiteProvider,
} from '@affine/env/workspace';
import { assertExists } from '@blocksuite/global/utils';
import type { Workspace } from '@blocksuite/store';
import { useSetAtom } from 'jotai';
import { startTransition, useCallback, useMemo, useState } from 'react';
import { type Status, syncDataSource } from 'y-provider';
export function useDatasourceSync(workspace: Workspace) {
const [status, setStatus] = useState<Status>({
type: 'idle',
});
const pushNotification = useSetAtom(pushNotificationAtom);
const providers = workspace.providers;
const remoteProvider: AffineSocketIOProvider | undefined = useMemo(() => {
return providers.find(
(provider): provider is AffineSocketIOProvider =>
provider.flavour === 'affine-socket-io'
);
}, [providers]);
const localProvider = useMemo(() => {
const sqliteProvider = providers.find(
(provider): provider is SQLiteProvider => provider.flavour === 'sqlite'
);
const indexedDbProvider = providers.find(
(provider): provider is LocalIndexedDBBackgroundProvider =>
provider.flavour === 'local-indexeddb-background'
);
const provider = sqliteProvider || indexedDbProvider;
assertExists(provider, 'no local provider');
return provider;
}, [providers]);
return [
status,
useCallback(() => {
if (!remoteProvider) {
return;
}
startTransition(() => {
setStatus({
type: 'syncing',
});
});
syncDataSource(
() => [
workspace.doc.guid,
...[...workspace.doc.subdocs].map(doc => doc.guid),
],
remoteProvider.datasource,
localProvider.datasource
)
.then(async () => {
// by default, the syncing status will show for 2.4s
setTimeout(() => {
startTransition(() => {
setStatus({
type: 'synced',
});
pushNotification({
title: 'Synced successfully',
type: 'success',
});
});
}, 2400);
})
.catch(error => {
startTransition(() => {
setStatus({
type: 'error',
error,
});
pushNotification({
title: 'Unable to Sync',
message: 'Server error, please try again later.',
type: 'error',
});
});
});
}, [
remoteProvider,
localProvider.datasource,
workspace.doc.guid,
workspace.doc.subdocs,
pushNotification,
]),
] as const;
}

View File

@@ -0,0 +1,29 @@
import type { GetPageInfoById } from '@affine/env/page-info';
import type { Workspace } from '@blocksuite/store';
import { useBlockSuitePageMeta } from '@toeverything/hooks/use-block-suite-page-meta';
import { useAtomValue } from 'jotai';
import { useCallback, useMemo } from 'react';
import { pageSettingsAtom } from '../atoms';
export const useGetPageInfoById = (workspace: Workspace): GetPageInfoById => {
const pageMetas = useBlockSuitePageMeta(workspace);
const pageMap = useMemo(
() => Object.fromEntries(pageMetas.map(page => [page.id, page])),
[pageMetas]
);
const pageSettings = useAtomValue(pageSettingsAtom);
return useCallback(
(id: string) => {
const page = pageMap[id];
if (!page) {
return;
}
return {
...page,
isEdgeless: pageSettings[id]?.mode === 'edgeless',
};
},
[pageMap, pageSettings]
);
};

View File

@@ -0,0 +1,131 @@
import type { WorkspaceSubPath } from '@affine/env/workspace';
import { useCallback, useMemo } from 'react';
import {
type NavigateOptions,
useLocation,
// eslint-disable-next-line @typescript-eslint/no-restricted-imports
useNavigate,
} from 'react-router-dom';
export enum RouteLogic {
REPLACE = 'replace',
PUSH = 'push',
}
export function useNavigateHelper() {
const location = useLocation();
const navigate = useNavigate();
const jumpToPage = useCallback(
(
workspaceId: string,
pageId: string,
logic: RouteLogic = RouteLogic.PUSH
) => {
return navigate(`/workspace/${workspaceId}/${pageId}`, {
replace: logic === RouteLogic.REPLACE,
});
},
[navigate]
);
const jumpToPublicWorkspacePage = useCallback(
(
workspaceId: string,
pageId: string,
logic: RouteLogic = RouteLogic.PUSH
) => {
return navigate(`/public-workspace/${workspaceId}/${pageId}`, {
replace: logic === RouteLogic.REPLACE,
});
},
[navigate]
);
const jumpToSubPath = useCallback(
(
workspaceId: string,
subPath: WorkspaceSubPath,
logic: RouteLogic = RouteLogic.PUSH
) => {
return navigate(`/workspace/${workspaceId}/${subPath}`, {
replace: logic === RouteLogic.REPLACE,
});
},
[navigate]
);
const isPublicWorkspace = useMemo(() => {
return location.pathname.indexOf('/public-workspace') === 0;
}, [location.pathname]);
const openPage = useCallback(
(workspaceId: string, pageId: string) => {
if (isPublicWorkspace) {
return jumpToPublicWorkspacePage(workspaceId, pageId);
} else {
return jumpToPage(workspaceId, pageId);
}
},
[jumpToPage, jumpToPublicWorkspacePage, isPublicWorkspace]
);
const jumpToIndex = useCallback(
(logic: RouteLogic = RouteLogic.PUSH) => {
return navigate('/', {
replace: logic === RouteLogic.REPLACE,
});
},
[navigate]
);
const jumpTo404 = useCallback(
(logic: RouteLogic = RouteLogic.PUSH) => {
return navigate('/404', {
replace: logic === RouteLogic.REPLACE,
});
},
[navigate]
);
const jumpToExpired = useCallback(
(logic: RouteLogic = RouteLogic.PUSH) => {
return navigate('/expired', {
replace: logic === RouteLogic.REPLACE,
});
},
[navigate]
);
const jumpToSignIn = useCallback(
(
logic: RouteLogic = RouteLogic.PUSH,
otherOptions?: Omit<NavigateOptions, 'replace'>
) => {
return navigate('/signIn', {
replace: logic === RouteLogic.REPLACE,
...otherOptions,
});
},
[navigate]
);
return useMemo(
() => ({
jumpToPage,
jumpToPublicWorkspacePage,
jumpToSubPath,
jumpToIndex,
jumpTo404,
openPage,
jumpToExpired,
jumpToSignIn,
}),
[
jumpTo404,
jumpToExpired,
jumpToIndex,
jumpToPage,
jumpToPublicWorkspacePage,
jumpToSignIn,
jumpToSubPath,
openPage,
]
);
}

View File

@@ -0,0 +1,84 @@
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { useAtom, useStore } from 'jotai';
import { useTheme } from 'next-themes';
import { useEffect } from 'react';
import { allPageModeSelectAtom } from '../atoms';
import {
registerAffineCreationCommands,
registerAffineHelpCommands,
registerAffineLayoutCommands,
registerAffineNavigationCommands,
registerAffineSettingsCommands,
registerAffineUpdatesCommands,
} from '../commands';
import { usePageHelper } from '../components/blocksuite/block-suite-page-list/utils';
import { useLanguageHelper } from './affine/use-language-helper';
import { useCurrentWorkspace } from './current/use-current-workspace';
import { useNavigateHelper } from './use-navigate-helper';
export function useRegisterWorkspaceCommands() {
const store = useStore();
const t = useAFFiNEI18N();
const theme = useTheme();
const [currentWorkspace] = useCurrentWorkspace();
const languageHelper = useLanguageHelper();
const pageHelper = usePageHelper(currentWorkspace.blockSuiteWorkspace);
const navigationHelper = useNavigateHelper();
const [pageListMode, setPageListMode] = useAtom(allPageModeSelectAtom);
useEffect(() => {
const unsubs: Array<() => void> = [];
unsubs.push(
registerAffineUpdatesCommands({
store,
t,
})
);
unsubs.push(
registerAffineNavigationCommands({
store,
t,
workspace: currentWorkspace.blockSuiteWorkspace,
navigationHelper,
pageListMode,
setPageListMode,
})
);
unsubs.push(
registerAffineSettingsCommands({
store,
t,
theme,
languageHelper,
})
);
unsubs.push(registerAffineLayoutCommands({ store, t }));
unsubs.push(
registerAffineCreationCommands({
store,
pageHelper: pageHelper,
t,
})
);
unsubs.push(
registerAffineHelpCommands({
store,
t,
})
);
return () => {
unsubs.forEach(unsub => unsub());
};
}, [
store,
pageHelper,
t,
theme,
currentWorkspace.blockSuiteWorkspace,
navigationHelper,
pageListMode,
setPageListMode,
languageHelper,
]);
}

View File

@@ -0,0 +1,49 @@
import {
PreconditionStrategy,
registerAffineCommand,
} from '@toeverything/infra/command';
import { useEffect } from 'react';
export function useRegisterBlocksuiteEditorCommands(
back: () => unknown,
forward: () => unknown
) {
useEffect(() => {
const unsubs: Array<() => void> = [];
unsubs.push(
registerAffineCommand({
id: 'affine:shortcut-history-go-back',
category: 'affine:general',
preconditionStrategy: PreconditionStrategy.Never,
icon: 'none',
label: 'go back',
keyBinding: {
binding: '$mod+[',
},
run() {
back();
},
})
);
unsubs.push(
registerAffineCommand({
id: 'affine:shortcut-history-go-forward',
category: 'affine:general',
preconditionStrategy: PreconditionStrategy.Never,
icon: 'none',
label: 'go forward',
keyBinding: {
binding: '$mod+]',
},
run() {
forward();
},
})
);
return () => {
unsubs.forEach(unsub => unsub());
};
}, [back, forward]);
}

View File

@@ -0,0 +1,21 @@
import { useCallback, useSyncExternalStore } from 'react';
const getOnLineStatus = () =>
typeof navigator !== 'undefined' && typeof navigator.onLine === 'boolean'
? navigator.onLine
: true;
export function useSystemOnline(): boolean {
return useSyncExternalStore(
useCallback(onStoreChange => {
window.addEventListener('online', onStoreChange);
window.addEventListener('offline', onStoreChange);
return () => {
window.removeEventListener('online', onStoreChange);
window.removeEventListener('offline', onStoreChange);
};
}, []),
useCallback(() => getOnLineStatus(), []),
useCallback(() => true, [])
);
}

View File

@@ -0,0 +1,58 @@
import { DebugLogger } from '@affine/debug';
import type { BlobManager } from '@blocksuite/store';
import { useEffect, useMemo, useRef, useState } from 'react';
import type { BlockSuiteWorkspace } from '../shared';
const logger = new DebugLogger('useWorkspaceBlob');
export function useWorkspaceBlob(
blockSuiteWorkspace: BlockSuiteWorkspace
): BlobManager {
return useMemo(() => blockSuiteWorkspace.blobs, [blockSuiteWorkspace.blobs]);
}
export function useWorkspaceBlobImage(
key: string | null,
blockSuiteWorkspace: BlockSuiteWorkspace
) {
const blobManager = useWorkspaceBlob(blockSuiteWorkspace);
const [blob, setBlob] = useState<Blob | null>(null);
useEffect(() => {
const controller = new AbortController();
if (key === null) {
setBlob(null);
return;
}
blobManager
?.get(key)
.then(blob => {
if (controller.signal.aborted) {
return;
}
if (blob) {
setBlob(blob);
}
})
.catch(err => {
logger.error('Failed to get blob', err);
});
return () => {
controller.abort();
};
}, [blobManager, key]);
const [url, setUrl] = useState<string | null>(null);
const ref = useRef<string | null>(null);
useEffect(() => {
if (ref.current) {
URL.revokeObjectURL(ref.current);
}
if (blob) {
const url = URL.createObjectURL(blob);
setUrl(url);
ref.current = url;
}
}, [blob]);
return url;
}

View File

@@ -0,0 +1,42 @@
import type { AffineOfficialWorkspace } from '@affine/env/workspace';
import { rootWorkspacesMetadataAtom } from '@affine/workspace/atom';
import { assertExists } from '@blocksuite/global/utils';
import type { Workspace } from '@blocksuite/store';
import { getBlockSuiteWorkspaceAtom } from '@toeverything/infra/__internal__/workspace';
import type { Atom } from 'jotai';
import { atom, useAtomValue } from 'jotai';
const workspaceWeakMap = new WeakMap<
Workspace,
Atom<Promise<AffineOfficialWorkspace>>
>();
// workspace effect is the side effect like connect to the server/indexeddb,
// this will save the workspace updates permanently.
export function useWorkspaceEffect(workspaceId: string): void {
const [, effectAtom] = getBlockSuiteWorkspaceAtom(workspaceId);
useAtomValue(effectAtom);
}
// todo(himself65): remove this hook
export function useWorkspace(workspaceId: string): AffineOfficialWorkspace {
const [workspaceAtom] = getBlockSuiteWorkspaceAtom(workspaceId);
const workspace = useAtomValue(workspaceAtom);
if (!workspaceWeakMap.has(workspace)) {
const baseAtom = atom(async get => {
const metadata = await get(rootWorkspacesMetadataAtom);
const flavour = metadata.find(({ id }) => id === workspaceId)?.flavour;
assertExists(flavour, 'workspace flavour not found');
return {
id: workspaceId,
flavour,
blockSuiteWorkspace: workspace,
};
});
workspaceWeakMap.set(workspace, baseAtom);
}
return useAtomValue(
workspaceWeakMap.get(workspace) as Atom<Promise<AffineOfficialWorkspace>>
);
}

View File

@@ -0,0 +1,126 @@
import { DebugLogger } from '@affine/debug';
import { WorkspaceFlavour } from '@affine/env/workspace';
import { rootWorkspacesMetadataAtom } from '@affine/workspace/atom';
import { saveWorkspaceToLocalStorage } from '@affine/workspace/local/crud';
import {
getOrCreateWorkspace,
globalBlockSuiteSchema,
} from '@affine/workspace/manager';
import { getWorkspace } from '@toeverything/infra/__internal__/workspace';
import { getCurrentStore } from '@toeverything/infra/atom';
import {
buildShowcaseWorkspace,
WorkspaceVersion,
} from '@toeverything/infra/blocksuite';
import { useAtomValue, useSetAtom } from 'jotai';
import { nanoid } from 'nanoid';
import { useCallback } from 'react';
import { LocalAdapter } from '../adapters/local';
import { WorkspaceAdapters } from '../adapters/workspace';
import { setPageModeAtom } from '../atoms';
const logger = new DebugLogger('use-workspaces');
/**
* This hook has the permission to all workspaces. Be careful when using it.
*/
export function useAppHelper() {
const jotaiWorkspaces = useAtomValue(rootWorkspacesMetadataAtom);
const set = useSetAtom(rootWorkspacesMetadataAtom);
return {
addLocalWorkspace: useCallback(
async (workspaceId: string): Promise<string> => {
getOrCreateWorkspace(workspaceId, WorkspaceFlavour.LOCAL);
saveWorkspaceToLocalStorage(workspaceId);
set(workspaces => [
...workspaces,
{
id: workspaceId,
flavour: WorkspaceFlavour.LOCAL,
version: WorkspaceVersion.DatabaseV3,
},
]);
logger.debug('imported local workspace', workspaceId);
return workspaceId;
},
[set]
),
addCloudWorkspace: useCallback(
(workspaceId: string) => {
getOrCreateWorkspace(workspaceId, WorkspaceFlavour.AFFINE_CLOUD);
set(workspaces => [
...workspaces,
{
id: workspaceId,
flavour: WorkspaceFlavour.AFFINE_CLOUD,
version: WorkspaceVersion.DatabaseV3,
},
]);
logger.debug('imported cloud workspace', workspaceId);
},
[set]
),
createLocalWorkspace: useCallback(
async (name: string): Promise<string> => {
const blockSuiteWorkspace = getOrCreateWorkspace(
nanoid(),
WorkspaceFlavour.LOCAL
);
blockSuiteWorkspace.meta.setName(name);
const id = await LocalAdapter.CRUD.create(blockSuiteWorkspace);
{
// this is hack, because CRUD doesn't return the workspace
const blockSuiteWorkspace = getOrCreateWorkspace(
id,
WorkspaceFlavour.LOCAL
);
await buildShowcaseWorkspace(blockSuiteWorkspace, {
schema: globalBlockSuiteSchema,
store: getCurrentStore(),
atoms: {
pageMode: setPageModeAtom,
},
});
}
set(workspaces => [
...workspaces,
{
id,
flavour: WorkspaceFlavour.LOCAL,
version: WorkspaceVersion.DatabaseV3,
},
]);
logger.debug('created local workspace', id);
return id;
},
[set]
),
deleteWorkspace: useCallback(
async (workspaceId: string) => {
const targetJotaiWorkspace = jotaiWorkspaces.find(
ws => ws.id === workspaceId
);
if (!targetJotaiWorkspace) {
throw new Error('page cannot be found');
}
const targetWorkspace = getWorkspace(targetJotaiWorkspace.id);
// delete workspace from plugin
await WorkspaceAdapters[targetJotaiWorkspace.flavour].CRUD.delete(
targetWorkspace
);
// delete workspace from jotai storage
set(workspaces => workspaces.filter(ws => ws.id !== workspaceId));
},
[jotaiWorkspaces, set]
),
deleteWorkspaceMeta: useCallback(
(workspaceId: string) => {
set(workspaces => workspaces.filter(ws => ws.id !== workspaceId));
},
[set]
),
};
}