refactor!: next generation AFFiNE code structure (#1176)

This commit is contained in:
Himself65
2023-03-01 01:40:01 -06:00
committed by GitHub
parent 2dcccc772c
commit e0481d29ad
270 changed files with 8308 additions and 6829 deletions

View File

@@ -0,0 +1,17 @@
// Vitest Snapshot v1
exports[`usePageMetas > basic 1`] = `
<DocumentFragment>
<div>
<div>
page0
</div>
<div>
page1
</div>
<div>
page2
</div>
</div>
</DocumentFragment>
`;

View File

@@ -0,0 +1,180 @@
/**
* @vitest-environment happy-dom
*/
import 'fake-indexeddb/auto';
import assert from 'node:assert';
import { __unstableSchemas, builtInSchemas } from '@blocksuite/blocks/models';
import { assertExists } from '@blocksuite/store';
import { render, renderHook } from '@testing-library/react';
import { useRouter } from 'next/router';
import routerMock from 'next-router-mock';
import { createDynamicRouteParser } from 'next-router-mock/dynamic-routes';
import { beforeAll, beforeEach, describe, expect, test } from 'vitest';
import { BlockSuiteWorkspace, RemWorkspaceFlavour } from '../../shared';
import { useCurrentWorkspace } from '../current/use-current-workspace';
import { useLastOpenedWorkspace } from '../use-last-opened-workspace';
import { usePageMeta, usePageMetaHelper } from '../use-page-meta';
import { useSyncRouterWithCurrentWorkspaceAndPage } from '../use-sync-router-with-current-workspace-and-page';
import {
useWorkspaces,
useWorkspacesHelper,
vitestRefreshWorkspaces,
} from '../use-workspaces';
let blockSuiteWorkspace: BlockSuiteWorkspace;
beforeAll(() => {
routerMock.useParser(
createDynamicRouteParser(['/workspace/[workspaceId]/[pageId]'])
);
});
beforeEach(async () => {
vitestRefreshWorkspaces();
dataCenter.isLoaded = true;
return new Promise<void>(resolve => {
blockSuiteWorkspace = new BlockSuiteWorkspace({
room: 'test',
})
.register(builtInSchemas)
.register(__unstableSchemas);
blockSuiteWorkspace.signals.pageAdded.on(pageId => {
setTimeout(() => {
const page = blockSuiteWorkspace.getPage(pageId);
expect(page).not.toBeNull();
assertExists(page);
const pageBlockId = page.addBlockByFlavour('affine:page', {
title: '',
});
const frameId = page.addBlockByFlavour('affine:frame', {}, pageBlockId);
page.addBlockByFlavour('affine:paragraph', {}, frameId);
if (pageId === 'page2') {
resolve();
}
});
});
blockSuiteWorkspace.createPage('page0');
blockSuiteWorkspace.createPage('page1');
blockSuiteWorkspace.createPage('page2');
});
});
describe('usePageMetas', async () => {
test('basic', async () => {
const Component = () => {
const pageMetas = usePageMeta(blockSuiteWorkspace);
return (
<div>
{pageMetas.map(meta => (
<div key={meta.id}>{meta.id}</div>
))}
</div>
);
};
const result = render(<Component />);
await result.findByText('page0');
await result.findByText('page1');
await result.findByText('page2');
expect(result.asFragment()).toMatchSnapshot();
});
test('mutation', () => {
const { result, rerender } = renderHook(() =>
usePageMeta(blockSuiteWorkspace)
);
expect(result.current.length).toBe(3);
expect(result.current[0].mode).not.exist;
const { result: result2 } = renderHook(() =>
usePageMetaHelper(blockSuiteWorkspace)
);
result2.current.setPageMeta('page0', {
mode: 'edgeless',
});
rerender();
expect(result.current[0].mode).exist;
expect(result.current[0].mode).toBe('edgeless');
result2.current.setPageMeta('page0', {
mode: 'page',
});
rerender();
expect(result.current[0].mode).toBe('page');
});
});
describe('useWorkspaces', () => {
test('basic', () => {
const { result } = renderHook(() => useWorkspaces());
expect(result.current).toEqual([]);
});
test('mutation', () => {
const { result } = renderHook(() => useWorkspacesHelper());
result.current.createRemLocalWorkspace('test');
const { result: result2 } = renderHook(() => useWorkspaces());
expect(result2.current.length).toEqual(1);
const firstWorkspace = result2.current[0];
expect(firstWorkspace.flavour).toBe('local');
assert(firstWorkspace.flavour === RemWorkspaceFlavour.LOCAL);
expect(firstWorkspace.blockSuiteWorkspace.meta.name).toBe('test');
});
});
describe('useSyncRouterWithCurrentWorkspaceAndPage', () => {
test('from "/"', async () => {
const mutationHook = renderHook(() => useWorkspacesHelper());
const id = mutationHook.result.current.createRemLocalWorkspace('test0');
mutationHook.result.current.createWorkspacePage(id, 'page0');
const routerHook = renderHook(() => useRouter());
await routerHook.result.current.push('/');
routerHook.rerender();
expect(routerHook.result.current.asPath).toBe('/');
renderHook(
({ router }) => useSyncRouterWithCurrentWorkspaceAndPage(router),
{
initialProps: {
router: routerHook.result.current,
},
}
);
expect(routerHook.result.current.asPath).toBe(`/workspace/${id}/page0`);
});
test('from incorrect "/workspace/[workspaceId]/[pageId]"', async () => {
const mutationHook = renderHook(() => useWorkspacesHelper());
const id = mutationHook.result.current.createRemLocalWorkspace('test0');
mutationHook.result.current.createWorkspacePage(id, 'page0');
const routerHook = renderHook(() => useRouter());
await routerHook.result.current.push(`/workspace/${id}/not_exist`);
routerHook.rerender();
expect(routerHook.result.current.asPath).toBe(`/workspace/${id}/not_exist`);
renderHook(
({ router }) => useSyncRouterWithCurrentWorkspaceAndPage(router),
{
initialProps: {
router: routerHook.result.current,
},
}
);
expect(routerHook.result.current.asPath).toBe(`/workspace/${id}/page0`);
});
});
describe('useLastOpenedWorkspace', () => {
test('basic', async () => {
const workspaceHelperHook = renderHook(() => useWorkspacesHelper());
workspaceHelperHook.result.current.createRemLocalWorkspace('test');
const workspacesHook = renderHook(() => useWorkspaces());
const currentWorkspaceHook = renderHook(() => useCurrentWorkspace());
currentWorkspaceHook.result.current[1](workspacesHook.result.current[0].id);
const lastOpenedWorkspace = renderHook(() => useLastOpenedWorkspace());
expect(lastOpenedWorkspace.result.current[0]).toBe(null);
const lastOpenedWorkspace2 = renderHook(() => useLastOpenedWorkspace());
expect(lastOpenedWorkspace2.result.current[0]).toBe(
workspacesHook.result.current[0].id
);
});
});

View File

@@ -0,0 +1,53 @@
/**
* @vitest-environment happy-dom
*/
import 'fake-indexeddb/auto';
import { __unstableSchemas, builtInSchemas } from '@blocksuite/blocks/models';
import { Page } from '@blocksuite/store';
import { renderHook } from '@testing-library/react';
import { beforeEach, describe, expect, test, vi } from 'vitest';
import { BlockSuiteWorkspace } from '../../shared';
import { useBlockSuiteWorkspaceHelper } from '../use-blocksuite-workspace-helper';
import { usePageMeta } from '../use-page-meta';
import { vitestRefreshWorkspaces } from '../use-workspaces';
let blockSuiteWorkspace: BlockSuiteWorkspace;
beforeEach(() => {
vitestRefreshWorkspaces();
blockSuiteWorkspace = new BlockSuiteWorkspace({
room: 'test',
})
.register(builtInSchemas)
.register(__unstableSchemas);
blockSuiteWorkspace.signals.pageAdded.on(pageId => {
const page = blockSuiteWorkspace.getPage(pageId) as Page;
const pageBlockId = page.addBlockByFlavour('affine:page', { title: '' });
const frameId = page.addBlockByFlavour('affine:frame', {}, pageBlockId);
page.addBlockByFlavour('affine:paragraph', {}, frameId);
});
blockSuiteWorkspace.createPage('page0');
blockSuiteWorkspace.createPage('page1');
blockSuiteWorkspace.createPage('page2');
});
describe('useBlockSuiteWorkspaceHelper', () => {
test('should create page', () => {
expect(blockSuiteWorkspace.meta.pageMetas.length).toBe(3);
const helperHook = renderHook(() =>
useBlockSuiteWorkspaceHelper(blockSuiteWorkspace)
);
const pageMetaHook = renderHook(() => usePageMeta(blockSuiteWorkspace));
expect(pageMetaHook.result.current.length).toBe(3);
expect(blockSuiteWorkspace.meta.pageMetas.length).toBe(3);
const callback = vi.fn(id => {
expect(id).toBe('page4');
});
helperHook.result.current.createPage('page4').then(callback);
expect(blockSuiteWorkspace.meta.pageMetas.length).toBe(4);
pageMetaHook.rerender();
expect(pageMetaHook.result.current.length).toBe(4);
});
});

View File

@@ -0,0 +1,25 @@
/**
* @vitest-environment happy-dom
*/
import { renderHook } from '@testing-library/react';
import { beforeEach, describe, expect, test } from 'vitest';
import { defaultRecord, useFeatureFlag } from '../use-feature-flag';
beforeEach(() => {
globalThis.featureFlag.record = defaultRecord;
globalThis.featureFlag.callback.clear();
});
describe('useFeatureFlag', () => {
test('basic', () => {
const flagHook = renderHook(() =>
useFeatureFlag('enableIndexedDBProvider')
);
expect(flagHook.result.current).toBe(defaultRecord.enableIndexedDBProvider);
globalThis.featureFlag.record.enableIndexedDBProvider = false;
globalThis.featureFlag.callback.forEach(cb => cb());
flagHook.rerender();
expect(flagHook.result.current).toBe(false);
});
});

View File

@@ -0,0 +1,101 @@
/**
* @vitest-environment happy-dom
*/
import { renderHook } from '@testing-library/react';
import { useRouter } from 'next/router';
import routerMock from 'next-router-mock';
import { createDynamicRouteParser } from 'next-router-mock/dynamic-routes';
import { beforeAll, describe, expect, test } from 'vitest';
import { WorkspaceSubPath } from '../../shared';
import { RouteLogic, useRouterHelper } from '../use-router-helper';
beforeAll(() => {
routerMock.useParser(
createDynamicRouteParser([
'/workspace/[workspaceId]/[pageId]',
'/workspace/[workspaceId]/all',
'/workspace/[workspaceId]/favorite',
'/workspace/[workspaceId]/trash',
'/workspace/[workspaceId]/setting',
])
);
});
describe('useRouterHelper', () => {
test('should return a expected router helper', async () => {
const routerHook = renderHook(() => useRouter());
const helperHook = renderHook(router => useRouterHelper(router), {
initialProps: routerHook.result.current,
});
const hook = helperHook.result.current;
expect(hook).toBeTypeOf('object');
Object.values(hook).forEach(value => {
expect(value).toBeTypeOf('function');
});
});
test('should jump to the expected sub path', async () => {
const routerHook = renderHook(() => useRouter());
// set current path to '/'
await routerHook.result.current.replace('/');
const helperHook = renderHook(router => useRouterHelper(router), {
initialProps: routerHook.result.current,
});
const hook = helperHook.result.current;
await hook.jumpToSubPath('workspace0', WorkspaceSubPath.ALL);
routerHook.rerender();
expect(routerHook.result.current.pathname).toBe(
'/workspace/[workspaceId]/all'
);
expect(routerHook.result.current.asPath).toBe('/workspace/workspace0/all');
// `router.back` is not working in `next-router-mock`
// routerHook.result.current.back()
// routerHook.rerender()
// expect(routerHook.result.current.pathname).toBe('/')
await hook.jumpToSubPath(
'workspace1',
WorkspaceSubPath.FAVORITE,
RouteLogic.REPLACE
);
routerHook.rerender();
expect(routerHook.result.current.pathname).toBe(
'/workspace/[workspaceId]/favorite'
);
expect(routerHook.result.current.asPath).toBe(
'/workspace/workspace1/favorite'
);
});
test('should jump to the expected page', async () => {
const routerHook = renderHook(() => useRouter());
// set current path to '/'
await routerHook.result.current.replace('/');
const helperHook = renderHook(router => useRouterHelper(router), {
initialProps: routerHook.result.current,
});
const hook = helperHook.result.current;
await hook.jumpToPage('workspace0', 'page0');
routerHook.rerender();
expect(routerHook.result.current.pathname).toBe(
'/workspace/[workspaceId]/[pageId]'
);
expect(routerHook.result.current.asPath).toBe(
'/workspace/workspace0/page0'
);
// `router.back` is not working in `next-router-mock`
// routerHook.result.current.back()
// routerHook.rerender()
// expect(routerHook.result.current.pathname).toBe('/')
await hook.jumpToPage('workspace1', 'page1', RouteLogic.REPLACE);
routerHook.rerender();
expect(routerHook.result.current.pathname).toBe(
'/workspace/[workspaceId]/[pageId]'
);
expect(routerHook.result.current.asPath).toBe(
'/workspace/workspace1/page1'
);
});
});

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,8 @@
import { PermissionType } from '@affine/datacenter';
import { AffineOfficialWorkspace } from '../../shared';
export function useIsWorkspaceOwner(workspace: AffineOfficialWorkspace) {
if (workspace.flavour === 'local') return true;
return workspace.permission === PermissionType.Owner;
}

View File

@@ -0,0 +1,43 @@
import { Member } from '@affine/datacenter';
import { useCallback } from 'react';
import useSWR from 'swr';
import { QueryKey } from '../../plugins/affine/fetcher';
import { apis } from '../../shared/apis';
export function useMembers(workspaceId: string) {
const { data, mutate } = useSWR<Member[]>(
[QueryKey.getMembers, workspaceId],
{
fallbackData: [],
}
);
const inviteMember = useCallback(
async (email: string) => {
await apis.inviteMember({
id: workspaceId,
email,
});
return mutate();
},
[mutate, workspaceId]
);
const removeMember = useCallback(
async (permissionId: number) => {
// fixme: what about the workspaceId?
await apis.removeMember({
permissionId,
});
return mutate();
},
[mutate]
);
return {
members: data ?? [],
inviteMember,
removeMember,
};
}

View File

@@ -0,0 +1,21 @@
import { useCallback } from 'react';
import { mutate } from 'swr';
import { QueryKey } from '../../plugins/affine/fetcher';
import { AffineRemoteWorkspace } from '../../shared';
import { apis } from '../../shared/apis';
import { refreshDataCenter } from '../use-workspaces';
export function useToggleWorkspacePublish(workspace: AffineRemoteWorkspace) {
return useCallback(
async (isPublish: boolean) => {
await apis.updateWorkspace({
id: workspace.id,
public: isPublish,
});
await mutate(QueryKey.getWorkspaces);
await refreshDataCenter();
},
[workspace]
);
}

View File

@@ -0,0 +1,24 @@
import useSWR from 'swr';
import { QueryKey } from '../../plugins/affine/fetcher';
export interface QueryEmailMember {
id: string;
name: string;
email: string;
avatar_url: string;
create_at: string;
}
export function useUsersByEmail(
workspaceId: string,
email: string
): QueryEmailMember[] | null {
const { data } = useSWR<QueryEmailMember[] | null>(
[QueryKey.getUserByEmail, workspaceId, email],
{
fallbackData: null,
}
);
return data ?? null;
}

View File

@@ -0,0 +1,10 @@
import { useAtom } from 'jotai';
import { currentPageIdAtom } from '../../atoms';
export function useCurrentPageId(): [
string | null,
(newId: string | null) => void
] {
return useAtom(currentPageIdAtom);
}

View File

@@ -0,0 +1,11 @@
import { AccessTokenMessage } from '@affine/datacenter';
import useSWR from 'swr';
import { QueryKey } from '../../plugins/affine/fetcher';
export function useCurrentUser(): AccessTokenMessage | null {
const { data } = useSWR<AccessTokenMessage | null>(QueryKey.getUser, {
fallbackData: null,
});
return data ?? null;
}

View File

@@ -0,0 +1,24 @@
import { useAtom } from 'jotai';
import { useCallback } from 'react';
import { currentPageIdAtom, currentWorkspaceIdAtom } from '../../atoms';
import { RemWorkspace } from '../../shared';
import { useWorkspace } from '../use-workspace';
export function useCurrentWorkspace(): [
RemWorkspace | null,
(id: string | null) => void
] {
const [id, setId] = useAtom(currentWorkspaceIdAtom);
const [, setPageId] = useAtom(currentPageIdAtom);
return [
useWorkspace(id),
useCallback(
(id: string | null) => {
setPageId(null);
setId(id);
},
[setId, setPageId]
),
];
}

View File

@@ -0,0 +1,29 @@
import { assertExists } from '@blocksuite/store';
import { useMemo } from 'react';
import { BlockSuiteWorkspace } from '../shared';
export function useBlockSuiteWorkspaceHelper(
blockSuiteWorkspace: BlockSuiteWorkspace | null
) {
return useMemo(
() => ({
createPage: (pageId: string, title?: string): Promise<string> => {
return new Promise(resolve => {
assertExists(blockSuiteWorkspace);
const dispose = blockSuiteWorkspace.signals.pageAdded.on(id => {
if (id === pageId) {
dispose.dispose();
// Fixme: https://github.com/toeverything/blocksuite/issues/1350
setTimeout(() => {
resolve(pageId);
}, 0);
}
});
blockSuiteWorkspace.createPage(pageId);
});
},
}),
[blockSuiteWorkspace]
);
}

View File

@@ -0,0 +1,26 @@
import { assertExists } from '@blocksuite/store';
import { useEffect, useState } from 'react';
import { BlockSuiteWorkspace } from '../shared';
export function useBlockSuiteWorkspacePageTitle(
blockSuiteWorkspace: BlockSuiteWorkspace,
pageId: string
) {
const page = blockSuiteWorkspace.getPage(pageId);
const [title, setTitle] = useState(() => page?.meta.title || 'AFFiNE');
useEffect(() => {
const page = blockSuiteWorkspace.getPage(pageId);
setTitle(page?.meta.title || 'AFFiNE');
const dispose = blockSuiteWorkspace.meta.pagesUpdated.on(() => {
const page = blockSuiteWorkspace.getPage(pageId);
assertExists(page);
setTitle(page?.meta.title || 'AFFiNE');
});
return () => {
dispose.dispose();
};
}, [blockSuiteWorkspace, pageId]);
return title;
}

View File

@@ -1,26 +0,0 @@
import { useCallback } from 'react';
import { PageMeta } from '@/providers/app-state-provider';
import { useGlobalState } from '@/store/app';
export type ChangePageMeta = (
pageId: string,
pageMeta: Partial<PageMeta>
) => void;
export const useChangePageMeta = () => {
const currentWorkspace = useGlobalState(
useCallback(store => store.currentDataCenterWorkspace, [])
);
return useCallback<ChangePageMeta>(
(pageId, pageMeta) => {
currentWorkspace?.blocksuiteWorkspace?.setPageMeta(pageId, {
...pageMeta,
});
},
[currentWorkspace]
);
};
export default ChangePageMeta;

View File

@@ -1,42 +0,0 @@
import { useCallback, useEffect, useState } from 'react';
import { PageMeta } from '@/providers/app-state-provider';
import { useGlobalState } from '@/store/app';
export const useCurrentPageMeta = (): PageMeta | null => {
const currentPage = useGlobalState(store => store.currentPage);
const currentBlockSuiteWorkspace = useGlobalState(
store => store.currentWorkspace
);
const pageMetaHandler = useCallback((): PageMeta | null => {
if (!currentPage || !currentBlockSuiteWorkspace) {
return null;
}
return (
(currentBlockSuiteWorkspace.meta.pageMetas.find(
p => p.id === currentPage.id
) as PageMeta) ?? null
);
}, [currentPage, currentBlockSuiteWorkspace]);
const [currentPageMeta, setCurrentPageMeta] = useState<PageMeta | null>(
pageMetaHandler
);
useEffect(() => {
setCurrentPageMeta(pageMetaHandler);
const dispose = currentBlockSuiteWorkspace?.meta.pagesUpdated.on(() => {
setCurrentPageMeta(pageMetaHandler);
}).dispose;
return () => {
dispose?.();
};
}, [currentPage, currentBlockSuiteWorkspace, pageMetaHandler]);
return currentPageMeta;
};
export default useCurrentPageMeta;

View File

@@ -1,73 +0,0 @@
import { assertEquals } from '@blocksuite/global/utils';
import { useRouter } from 'next/router';
import { useCallback, useEffect, useState } from 'react';
import { useGlobalState } from '@/store/app';
// todo: refactor with suspense mode
// It is a fully effective hook
// Cause it not just ensure workspace loaded, but also have router change.
export const useEnsureWorkspace = () => {
const dataCenter = useGlobalState(useCallback(store => store.dataCenter, []));
const currentWorkspace = useGlobalState(
useCallback(store => store.currentDataCenterWorkspace, [])
);
const loadWorkspace = useGlobalState(
useCallback(store => store.loadWorkspace, [])
);
const router = useRouter();
const [currentWorkspaceId, setCurrentWorkspaceId] = useState<string | null>(
typeof router.query.workspaceId === 'string'
? router.query.workspaceId
: null
);
// const defaultOutLineWorkspaceId = '99ce7eb7';
// console.log(defaultOutLineWorkspaceId);
useEffect(() => {
const abortController = new AbortController();
const workspaceId =
(router.query.workspaceId as string) || dataCenter.workspaces[0]?.id;
// If router.query.workspaceId is not in workspace list, jump to 404 page
// If workspaceList is empty, we need to create a default workspace but not jump to 404
if (
workspaceId &&
dataCenter.workspaces.length &&
dataCenter.workspaces.findIndex(
meta => meta.id.toString() === workspaceId
) === -1
) {
router.push('/404');
return;
}
// If user is not login and input a custom workspaceId, jump to 404 page
// if (
// !user &&
// router.query.workspaceId &&
// router.query.workspaceId !== defaultOutLineWorkspaceId
// ) {
// router.push('/404');
// return;
// }
loadWorkspace(workspaceId, abortController.signal).then(unit => {
if (!abortController.signal.aborted && unit) {
setCurrentWorkspaceId(unit.id);
assertEquals(unit.id, workspaceId);
}
});
return () => {
abortController.abort();
};
}, [dataCenter, loadWorkspace, router]);
return {
workspaceLoaded: currentWorkspace?.id === currentWorkspaceId,
activeWorkspaceId: currentWorkspace?.id ?? router.query.workspaceId,
};
};
export default useEnsureWorkspace;

View File

@@ -0,0 +1,46 @@
import { useCallback, useSyncExternalStore } from 'react';
interface FeatureFlag {
enableIndexedDBProvider: boolean;
}
export const defaultRecord: FeatureFlag = {
enableIndexedDBProvider: true,
} as const;
const featureFlag = {
record: {
enableIndexedDBProvider: true,
} as FeatureFlag,
callback: new Set<() => void>(),
};
declare global {
// eslint-disable-next-line no-var
var featureFlag: {
record: FeatureFlag;
callback: Set<() => void>;
};
}
if (!globalThis.featureFlag) {
globalThis.featureFlag = featureFlag;
}
export const getFeatureFlag = <Key extends keyof FeatureFlag>(key: Key) =>
featureFlag.record[key];
export function useFeatureFlag<Key extends keyof FeatureFlag>(
key: Key
): FeatureFlag[Key] {
return useSyncExternalStore(
useCallback(onStoreChange => {
featureFlag.callback.add(onStoreChange);
return () => {
featureFlag.callback.delete(onStoreChange);
};
}, []),
useCallback(() => featureFlag.record[key], [key]),
useCallback(() => defaultRecord[key], [key])
);
}

View File

@@ -1,37 +0,0 @@
import { Page } from '@blocksuite/store';
import { useEffect, useRef } from 'react';
import { useGlobalState } from '@/store/app';
export type EventCallBack<T> = (callback: (props: T) => void) => void;
export type UseHistoryUpdated = (page?: Page) => EventCallBack<Page>;
export const useHistoryUpdate: UseHistoryUpdated = () => {
const currentPage = useGlobalState(store => store.currentPage);
const callbackQueue = useRef<((page: Page) => void)[]>([]);
useEffect(() => {
if (!currentPage) {
return;
}
setTimeout(() => {
currentPage.signals.historyUpdated.on(() => {
callbackQueue.current.forEach(callback => {
callback(currentPage);
});
});
}, 300);
return () => {
callbackQueue.current = [];
currentPage.signals.historyUpdated.dispose();
};
}, [currentPage]);
return callback => {
callbackQueue.current.push(callback);
};
};
export default useHistoryUpdate;

View File

@@ -0,0 +1,41 @@
import { useCallback, useEffect, useState } from 'react';
import { useCurrentPageId } from './current/use-current-page-id';
import { useCurrentWorkspace } from './current/use-current-workspace';
const kLastOpenedWorkspaceKey = 'affine-last-opened-workspace';
const kLastOpenedPageKey = 'affine-last-opened-page';
export function useLastOpenedWorkspace(): [
string | null,
string | null,
() => void
] {
const [currentWorkspace] = useCurrentWorkspace();
const [currentPageId] = useCurrentPageId();
const [lastWorkspaceId, setLastWorkspaceId] = useState<string | null>(null);
const [lastPageId, setLastPageId] = useState<string | null>(null);
useEffect(() => {
const lastWorkspaceId = localStorage.getItem(kLastOpenedWorkspaceKey);
if (lastWorkspaceId) {
setLastWorkspaceId(lastWorkspaceId);
}
const lastPageId = localStorage.getItem(kLastOpenedPageKey);
if (lastPageId) {
setLastPageId(lastPageId);
}
}, []);
useEffect(() => {
if (currentWorkspace) {
localStorage.setItem(kLastOpenedWorkspaceKey, currentWorkspace.id);
}
if (currentPageId) {
localStorage.setItem(kLastOpenedPageKey, currentPageId);
}
}, [currentPageId, currentWorkspace]);
const refresh = useCallback(() => {
localStorage.removeItem(kLastOpenedWorkspaceKey);
localStorage.removeItem(kLastOpenedPageKey);
}, []);
return [lastWorkspaceId, lastPageId, refresh];
}

View File

@@ -1,35 +0,0 @@
import { getDataCenter, WorkspaceUnit } from '@affine/datacenter';
import { useRouter } from 'next/router';
import { useEffect, useState } from 'react';
export function useLoadPublicWorkspace(workspaceId: string) {
const router = useRouter();
const [workspace, setWorkspace] = useState<WorkspaceUnit | null>();
const [status, setStatus] = useState<'loading' | 'error' | 'success'>(
'loading'
);
useEffect(() => {
setStatus('loading');
const init = async () => {
const dataCenter = await getDataCenter();
dataCenter
.loadPublicWorkspace(workspaceId)
.then(data => {
setWorkspace(data);
setStatus('success');
})
.catch(() => {
// if (!cancel) {
// router.push('/404');
// }
setStatus('error');
});
};
init();
}, [router, workspaceId]);
return { status, workspace };
}

View File

@@ -0,0 +1,13 @@
import { useEffect } from 'react';
import { RemWorkspace, RemWorkspaceFlavour } from '../shared';
export function useLoadWorkspace(workspace: RemWorkspace | null | undefined) {
useEffect(() => {
if (workspace?.flavour === RemWorkspaceFlavour.AFFINE) {
if (!workspace.firstBinarySynced) {
workspace.syncBinary();
}
}
}, [workspace]);
}

View File

@@ -1,33 +0,0 @@
import { Dispatch, SetStateAction, useEffect, useState } from 'react';
export type UseLocalStorage = <S>(
key: string,
initialState: S | (() => S),
// If initialState is different from the initial local data, set it
initialValue?: S
) => [S, Dispatch<SetStateAction<S>>];
export const useLocalStorage: UseLocalStorage = (
key,
defaultValue,
initialValue
) => {
const [item, setItem] = useState(defaultValue);
useEffect(() => {
const saved = localStorage.getItem(key);
if (saved) {
setItem(JSON.parse(saved));
} else if (initialValue) {
setItem(initialValue);
}
}, [initialValue, key, setItem]);
useEffect(() => {
localStorage.setItem(key, JSON.stringify(item));
}, [item, key]);
return [item, setItem];
};
export default useLocalStorage;

View File

@@ -1,55 +0,0 @@
import { Member } from '@affine/datacenter';
import { useCallback, useEffect, useState } from 'react';
import { useGlobalState } from '@/store/app';
export const useMembers = () => {
const dataCenter = useGlobalState(store => store.dataCenter);
const currentWorkspace = useGlobalState(
useCallback(store => store.currentDataCenterWorkspace, [])
);
const [members, setMembers] = useState<Member[]>([]);
const [loaded, setLoaded] = useState(false);
const refreshMembers = useCallback(async () => {
if (!currentWorkspace || !dataCenter) return;
const members = await dataCenter.getMembers(currentWorkspace.id);
setMembers(members ?? []);
}, [dataCenter, currentWorkspace]);
useEffect(() => {
const init = async () => {
await refreshMembers();
setLoaded(true);
};
init();
}, [refreshMembers]);
const inviteMember = async (email: string) => {
currentWorkspace &&
dataCenter &&
(await dataCenter.inviteMember(currentWorkspace.id, email));
};
const removeMember = async (permissionId: number) => {
if (!currentWorkspace || !dataCenter) {
return;
}
setLoaded(false);
await dataCenter.removeMember(currentWorkspace.id, permissionId);
await refreshMembers();
setLoaded(true);
};
const getUserByEmail = async (email: string) => {
if (!currentWorkspace) return null;
return dataCenter?.getUserByEmail(currentWorkspace.id, email);
};
return {
members,
removeMember,
inviteMember,
getUserByEmail,
loaded,
};
};
export default useMembers;

View File

@@ -1,152 +0,0 @@
import { WorkspaceUnit } from '@affine/datacenter';
import { EditorContainer } from '@blocksuite/editor';
import { uuidv4, Workspace } from '@blocksuite/store';
// eslint-disable-next-line @typescript-eslint/no-restricted-imports
import type { QueryContent } from '@blocksuite/store/dist/workspace/search';
import { useRouter } from 'next/router';
import { useCallback } from 'react';
import { useChangePageMeta } from '@/hooks/use-change-page-meta';
import { PageMeta } from '@/providers/app-state-provider';
import { useGlobalState } from '@/store/app';
export type EditorHandlers = {
createPage: (params?: {
pageId?: string;
title?: string;
}) => Promise<string | null>;
openPage: (
pageId: string,
query?: { [key: string]: string },
newTab?: boolean
) => Promise<boolean>;
getPageMeta: (pageId: string) => PageMeta | null;
toggleDeletePage: (pageId: string) => Promise<boolean>;
toggleFavoritePage: (pageId: string) => Promise<boolean>;
permanentlyDeletePage: (pageId: string) => void;
search: (
query: QueryContent,
workspace?: Workspace
) => Map<string, string | undefined>;
// changeEditorMode: (pageId: string) => void;
changePageMode: (
pageId: string,
mode: EditorContainer['mode']
) => Promise<EditorContainer['mode']>;
};
const getPageMeta = (workspace: WorkspaceUnit | null, pageId: string) => {
return workspace?.blocksuiteWorkspace?.meta.pageMetas.find(
p => p.id === pageId
);
};
export const usePageHelper = (): EditorHandlers => {
const router = useRouter();
const changePageMeta = useChangePageMeta();
const editor = useGlobalState(store => store.editor);
const currentWorkspace = useGlobalState(
useCallback(store => store.currentDataCenterWorkspace, [])
);
return {
createPage: ({
pageId = uuidv4().replaceAll('-', ''),
title = '',
} = {}) => {
return new Promise(resolve => {
if (!currentWorkspace) {
return resolve(null);
}
currentWorkspace.blocksuiteWorkspace?.createPage(pageId);
currentWorkspace.blocksuiteWorkspace?.signals.pageAdded.once(
addedPageId => {
currentWorkspace.blocksuiteWorkspace?.setPageMeta(addedPageId, {
title,
mode: 'page',
});
resolve(addedPageId);
}
);
});
},
toggleFavoritePage: async pageId => {
const pageMeta = getPageMeta(currentWorkspace, pageId);
if (!pageMeta) {
return Promise.reject('No page');
}
const favorite = !pageMeta.favorite;
changePageMeta(pageMeta.id, {
favorite,
});
return favorite;
},
toggleDeletePage: async pageId => {
const pageMeta = getPageMeta(currentWorkspace, pageId);
if (!pageMeta) {
return Promise.reject('No page');
}
const trash = !pageMeta.trash;
changePageMeta(pageMeta.id, {
trash,
trashDate: +new Date(),
});
return trash;
},
search: (query: QueryContent, workspace?: Workspace) => {
if (workspace) {
return workspace.search(query);
}
if (currentWorkspace) {
if (currentWorkspace.blocksuiteWorkspace) {
return currentWorkspace.blocksuiteWorkspace.search(query);
}
}
return new Map();
},
changePageMode: async (pageId, mode) => {
const pageMeta = getPageMeta(currentWorkspace, pageId);
if (!pageMeta) {
return Promise.reject('No page');
}
editor?.setAttribute('mode', mode as string);
changePageMeta(pageMeta.id, {
mode,
});
return mode;
},
permanentlyDeletePage: pageId => {
// TODO: workspace.meta.removePage or workspace.removePage?
currentWorkspace!.blocksuiteWorkspace?.meta.removePage(pageId);
},
openPage: (pageId, query = {}, newTab = false) => {
pageId = pageId.replace('space:', '');
if (newTab) {
window.open(`/workspace/${currentWorkspace?.id}/${pageId}`, '_blank');
return Promise.resolve(true);
}
return router.push({
pathname: `/workspace/${currentWorkspace?.id}/${pageId}`,
query,
});
},
getPageMeta: pageId => {
if (!currentWorkspace) {
return null;
}
return (
(currentWorkspace.blocksuiteWorkspace?.meta.pageMetas.find(
page => page.id === pageId
) as PageMeta) || null
);
},
};
};
export default usePageHelper;

View File

@@ -0,0 +1,49 @@
import { PageMeta } from '@blocksuite/store';
import { useEffect, useMemo, useState } from 'react';
import { BlockSuiteWorkspace } from '../shared';
declare module '@blocksuite/store' {
interface PageMeta {
mode?: 'page' | 'edgeless';
favorite?: boolean;
trash?: boolean;
}
}
export function usePageMeta(
blockSuiteWorkspace: BlockSuiteWorkspace | null
): PageMeta[] {
const [pageMeta, setPageMeta] = useState<PageMeta[]>(
() => blockSuiteWorkspace?.meta.pageMetas ?? []
);
const [prev, setPrev] = useState(() => blockSuiteWorkspace);
if (prev !== blockSuiteWorkspace) {
setPrev(blockSuiteWorkspace);
if (blockSuiteWorkspace) {
setPageMeta(blockSuiteWorkspace.meta.pageMetas);
}
}
useEffect(() => {
if (blockSuiteWorkspace) {
const dispose = blockSuiteWorkspace.meta.pagesUpdated.on(() => {
setPageMeta(blockSuiteWorkspace.meta.pageMetas);
});
return () => {
dispose.dispose();
};
}
}, [blockSuiteWorkspace]);
return pageMeta;
}
export function usePageMetaHelper(blockSuiteWorkspace: BlockSuiteWorkspace) {
return useMemo(
() => ({
setPageMeta: (pageId: string, pageMeta: Partial<PageMeta>) => {
blockSuiteWorkspace.meta.setPageMeta(pageId, pageMeta);
},
}),
[blockSuiteWorkspace]
);
}

View File

@@ -1,39 +0,0 @@
import { EditorContainer } from '@blocksuite/editor';
import { useEffect, useRef } from 'react';
import { useGlobalState } from '@/store/app';
export type EventCallBack<T> = (callback: (props: T) => void) => void;
export type UsePropsUpdated = (
editor?: EditorContainer
) => EventCallBack<EditorContainer>;
export const usePropsUpdated: UsePropsUpdated = () => {
const editor = useGlobalState(store => store.editor);
const callbackQueue = useRef<((editor: EditorContainer) => void)[]>([]);
useEffect(() => {
if (!editor) {
return;
}
setTimeout(() => {
editor.pageBlockModel?.propsUpdated.on(() => {
callbackQueue.current.forEach(callback => {
callback(editor);
});
});
}, 300);
return () => {
callbackQueue.current = [];
editor?.pageBlockModel?.propsUpdated?.dispose();
};
}, [editor]);
return callback => {
callbackQueue.current.push(callback);
};
};
export default usePropsUpdated;

View File

@@ -0,0 +1,42 @@
import { NextRouter } from 'next/router';
import { useMemo } from 'react';
import { WorkspaceSubPath } from '../shared';
export const enum RouteLogic {
REPLACE = 'replace',
PUSH = 'push',
}
export function useRouterHelper(router: NextRouter) {
return useMemo(
() => ({
jumpToPage: (
workspaceId: string,
pageId: string,
logic: RouteLogic = RouteLogic.PUSH
) => {
return router[logic]({
pathname: `/workspace/[workspaceId]/[pageId]`,
query: {
workspaceId,
pageId,
},
});
},
jumpToSubPath: (
workspaceId: string,
subPath: WorkspaceSubPath,
logic: RouteLogic = RouteLogic.PUSH
): Promise<boolean> => {
return router[logic]({
pathname: `/workspace/[workspaceId]/${subPath}`,
query: {
workspaceId,
},
});
},
}),
[router]
);
}

View File

@@ -0,0 +1,26 @@
import { NextRouter } from 'next/router';
import { useMemo } from 'react';
import { WorkspaceSubPathName } from '../shared';
export function useRouterTitle(router: NextRouter) {
return useMemo(() => {
if (!router.isReady) {
return 'Loading...';
} else {
if (
!router.query.pageId &&
router.pathname.startsWith('/workspace/[workspaceId]/')
) {
const subPath = router.pathname.split('/').at(-1);
if (subPath && subPath in WorkspaceSubPathName) {
return (
WorkspaceSubPathName[subPath as keyof typeof WorkspaceSubPathName] +
' - AFFiNE'
);
}
}
return 'AFFiNE';
}
}, [router]);
}

View File

@@ -0,0 +1,166 @@
import { NextRouter } from 'next/router';
import { useEffect } from 'react';
import { RemWorkspace, RemWorkspaceFlavour } from '../shared';
import { useCurrentPageId } from './current/use-current-page-id';
import { useCurrentWorkspace } from './current/use-current-workspace';
import { useWorkspaces, useWorkspacesIsLoaded } from './use-workspaces';
export function findSuitablePageId(
workspace: RemWorkspace,
targetId: string
): string | null {
switch (workspace.flavour) {
case RemWorkspaceFlavour.AFFINE: {
if (workspace.firstBinarySynced) {
return (
workspace.blockSuiteWorkspace.meta.pageMetas.find(
page => page.id === targetId
)?.id ??
workspace.blockSuiteWorkspace.meta.pageMetas.at(0)?.id ??
null
);
} else {
return null;
}
break;
}
case RemWorkspaceFlavour.LOCAL: {
return (
workspace.blockSuiteWorkspace.meta.pageMetas.find(
page => page.id === targetId
)?.id ??
workspace.blockSuiteWorkspace.meta.pageMetas.at(0)?.id ??
null
);
}
}
}
export function useSyncRouterWithCurrentWorkspaceAndPage(router: NextRouter) {
const [currentWorkspace, setCurrentWorkspaceId] = useCurrentWorkspace();
const [currentPageId, setCurrentPageId] = useCurrentPageId();
const workspaces = useWorkspaces();
const isLoaded = useWorkspacesIsLoaded();
useEffect(() => {
const listener: Parameters<typeof router.events.on>[1] = (url: string) => {
if (url.startsWith('/')) {
const path = url.split('/');
if (path.length === 4 && path[1] === 'workspace') {
if (
path[3] === 'all' ||
path[3] === 'setting' ||
path[3] === 'trash' ||
path[3] === 'favorite'
) {
return;
}
setCurrentWorkspaceId(path[2]);
if (currentWorkspace && 'blockSuiteWorkspace' in currentWorkspace) {
if (currentWorkspace.blockSuiteWorkspace.getPage(path[3])) {
setCurrentPageId(path[3]);
}
}
}
}
};
router.events.on('routeChangeStart', listener);
return () => {
router.events.off('routeChangeStart', listener);
};
}, [currentWorkspace, router, setCurrentPageId, setCurrentWorkspaceId]);
useEffect(() => {
if (!router.isReady || !isLoaded) {
return;
}
if (
router.pathname === '/workspace/[workspaceId]/[pageId]' ||
router.pathname === '/'
) {
const targetPageId = router.query.pageId;
const targetWorkspaceId = router.query.workspaceId;
if (currentWorkspace && currentPageId) {
if (
currentWorkspace.id === targetWorkspaceId &&
currentPageId === targetPageId
) {
return;
}
}
if (
typeof targetPageId !== 'string' ||
typeof targetWorkspaceId !== 'string'
) {
if (router.asPath === '/') {
const first = workspaces.at(0);
if (first && 'blockSuiteWorkspace' in first) {
const targetWorkspaceId = first.id;
const targetPageId =
first.blockSuiteWorkspace.meta.pageMetas.at(0)?.id;
if (targetPageId) {
setCurrentWorkspaceId(targetWorkspaceId);
setCurrentPageId(targetPageId);
router.push(`/workspace/${targetWorkspaceId}/${targetPageId}`);
}
}
}
return;
}
if (!currentWorkspace) {
const targetWorkspace = workspaces.find(
workspace => workspace.id === targetPageId
);
if (targetWorkspace) {
setCurrentWorkspaceId(targetWorkspace.id);
router.push({
query: {
...router.query,
workspaceId: targetWorkspace.id,
},
});
return;
} else {
const first = workspaces.at(0);
if (first) {
setCurrentWorkspaceId(first.id);
router.push({
query: {
...router.query,
workspaceId: first.id,
},
});
return;
}
}
} else {
if (!currentPageId && currentWorkspace) {
if ('blockSuiteWorkspace' in currentWorkspace) {
const targetId = findSuitablePageId(currentWorkspace, targetPageId);
if (targetId) {
setCurrentPageId(targetId);
router.push({
query: {
...router.query,
workspaceId: currentWorkspace.id,
pageId: targetId,
},
});
return;
}
}
}
}
}
}, [
currentPageId,
currentWorkspace,
router.query.workspaceId,
router.query.pageId,
setCurrentPageId,
setCurrentWorkspaceId,
workspaces,
router,
isLoaded,
]);
}

View File

@@ -0,0 +1,55 @@
import { NextRouter } from 'next/router';
import { useEffect } from 'react';
import { useCurrentWorkspace } from './current/use-current-workspace';
import { useLoadWorkspace } from './use-load-workspace';
import { useWorkspaces } from './use-workspaces';
export function useSyncRouterWithCurrentWorkspace(router: NextRouter) {
const [currentWorkspace, setCurrentWorkspaceId] = useCurrentWorkspace();
useLoadWorkspace(currentWorkspace);
const workspaces = useWorkspaces();
useEffect(() => {
const listener: Parameters<typeof router.events.on>[1] = (url: string) => {
if (url.startsWith('/')) {
const path = url.split('/');
if (path.length === 4 && path[1] === 'workspace') {
setCurrentWorkspaceId(path[2]);
}
}
};
router.events.on('routeChangeStart', listener);
return () => {
router.events.off('routeChangeStart', listener);
};
}, [currentWorkspace, router, setCurrentWorkspaceId]);
useEffect(() => {
if (!router.isReady) {
return;
}
const workspaceId = router.query.workspaceId;
if (typeof workspaceId !== 'string') {
return;
}
if (!currentWorkspace) {
const targetWorkspace = workspaces.find(
workspace => workspace.id === workspaceId
);
if (targetWorkspace) {
setCurrentWorkspaceId(targetWorkspace.id);
} else {
const targetWorkspace = workspaces.at(0);
if (targetWorkspace) {
setCurrentWorkspaceId(targetWorkspace.id);
router.push({
pathname: '/workspace/[workspaceId]/all',
query: {
workspaceId: targetWorkspace.id,
},
});
}
}
}
}, [currentWorkspace, router, setCurrentWorkspaceId, workspaces]);
}

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,35 @@
import { Theme } from '@affine/component';
import { useCallback, useSyncExternalStore } from 'react';
const themeRef = {
current: 'light',
media: null,
} as {
current: Theme;
media: MediaQueryList | null;
};
if (typeof window !== 'undefined') {
themeRef.media = window.matchMedia('(prefers-color-scheme: light)');
}
export function useSystemTheme() {
return useSyncExternalStore<Theme>(
useCallback(onStoreChange => {
if (themeRef.media) {
const media = themeRef.media;
media.addEventListener('change', onStoreChange);
return () => {
media.addEventListener('change', onStoreChange);
};
}
return () => {};
}, []),
useCallback(
() =>
themeRef.media ? (themeRef.media.matches ? 'light' : 'dark') : 'light',
[]
),
useCallback(() => 'light', [])
);
}

View File

@@ -0,0 +1,30 @@
import { BlobStorage } from '@blocksuite/store';
import { useEffect, useState } from 'react';
import { BlockSuiteWorkspace } from '../shared';
export function useWorkspaceBlob(
blockSuiteWorkspace: BlockSuiteWorkspace
): BlobStorage | null {
const [blobStorage, setBlobStorage] = useState<BlobStorage | null>(null);
useEffect(() => {
blockSuiteWorkspace.blobs.then(blobStorage => {
setBlobStorage(blobStorage);
});
}, [blockSuiteWorkspace]);
return blobStorage;
}
export function useWorkspaceBlobImage(
key: string,
blockSuiteWorkspace: BlockSuiteWorkspace
) {
const blobStorage = useWorkspaceBlob(blockSuiteWorkspace);
const [imageURL, setImageURL] = useState<string | null>(null);
useEffect(() => {
blobStorage?.get(key).then(blob => {
setImageURL(blob);
});
}, [blobStorage, key]);
return imageURL;
}

View File

@@ -1,60 +0,0 @@
import { WorkspaceUnit } from '@affine/datacenter';
import { useCallback } from 'react';
import { useGlobalState } from '@/store/app';
export const useWorkspaceHelper = () => {
const dataCenter = useGlobalState(store => store.dataCenter);
const currentWorkspace = useGlobalState(
useCallback(store => store.currentDataCenterWorkspace, [])
);
const loadWorkspace = useGlobalState(store => store.loadWorkspace);
const createWorkspace = async (name: string) => {
const workspaceInfo = await dataCenter.createWorkspace({
name: name,
});
if (workspaceInfo && workspaceInfo.id) {
return loadWorkspace(workspaceInfo.id);
}
return null;
};
// const updateWorkspace = async (workspace: Workspace) => {};
const publishWorkspace = async (workspaceId: string, publish: boolean) => {
await dataCenter.setWorkspacePublish(workspaceId, publish);
};
const updateWorkspace = async (
{ name, avatarBlob }: { name?: string; avatarBlob?: Blob },
workspace: WorkspaceUnit
) => {
if (name) {
await dataCenter.updateWorkspaceMeta({ name }, workspace);
}
if (avatarBlob) {
const blobId = await dataCenter.setBlob(workspace, avatarBlob);
await dataCenter.updateWorkspaceMeta({ avatar: blobId }, workspace);
}
};
const deleteWorkSpace = async () => {
currentWorkspace && (await dataCenter.deleteWorkspace(currentWorkspace.id));
};
const leaveWorkSpace = async () => {
currentWorkspace && (await dataCenter.leaveWorkspace(currentWorkspace.id));
};
const acceptInvite = async (inviteCode: string) => {
return dataCenter.acceptInvitation(inviteCode);
};
return {
createWorkspace,
publishWorkspace,
updateWorkspace,
deleteWorkSpace,
leaveWorkSpace,
acceptInvite,
};
};

View File

@@ -0,0 +1,12 @@
import { useMemo } from 'react';
import { RemWorkspace } from '../shared';
import { useWorkspaces } from './use-workspaces';
export function useWorkspace(workspaceId: string | null): RemWorkspace | null {
const workspaces = useWorkspaces();
return useMemo(
() => workspaces.find(ws => ws.id === workspaceId) ?? null,
[workspaces, workspaceId]
);
}

View File

@@ -0,0 +1,195 @@
import { Workspace } from '@affine/datacenter';
import { uuidv4 } from '@blocksuite/store';
import { useCallback, useMemo, useSyncExternalStore } from 'react';
import useSWR from 'swr';
import { IndexeddbPersistence } from 'y-indexeddb';
import { lockMutex } from '../atoms';
import { createLocalProviders } from '../blocksuite';
import { WorkspacePlugins } from '../plugins';
import { QueryKey } from '../plugins/affine/fetcher';
import { kStoreKey } from '../plugins/local';
import { LocalWorkspace, RemWorkspace, RemWorkspaceFlavour } from '../shared';
import { config } from '../shared/env';
import { createEmptyBlockSuiteWorkspace } from '../utils';
// fixme(himself65): refactor with jotai atom using async
export const dataCenter = {
workspaces: [] as RemWorkspace[],
isLoaded: false,
callbacks: new Set<() => void>(),
};
export function vitestRefreshWorkspaces() {
dataCenter.workspaces = [];
dataCenter.callbacks.clear();
}
declare global {
// eslint-disable-next-line no-var
var dataCenter: {
workspaces: RemWorkspace[];
isLoaded: boolean;
callbacks: Set<() => void>;
};
}
globalThis.dataCenter = dataCenter;
function createRemLocalWorkspace(name: string) {
const id = uuidv4();
const blockSuiteWorkspace = createEmptyBlockSuiteWorkspace(id);
blockSuiteWorkspace.meta.setName(name);
const workspace: LocalWorkspace = {
flavour: RemWorkspaceFlavour.LOCAL,
blockSuiteWorkspace: blockSuiteWorkspace,
providers: [...createLocalProviders(blockSuiteWorkspace)],
syncBinary: async () => {
if (!config.enableIndexedDBProvider) {
return {
...workspace,
};
}
const persistence = new IndexeddbPersistence(
blockSuiteWorkspace.room as string,
blockSuiteWorkspace.doc
);
return persistence.whenSynced.then(() => {
persistence.destroy();
return {
...workspace,
};
});
},
id,
};
if (config.enableIndexedDBProvider) {
let ids: string[];
try {
ids = JSON.parse(localStorage.getItem(kStoreKey) ?? '[]');
if (!Array.isArray(ids)) {
localStorage.setItem(kStoreKey, '[]');
ids = [];
}
} catch (e) {
localStorage.setItem(kStoreKey, '[]');
ids = [];
}
ids.push(id);
localStorage.setItem(kStoreKey, JSON.stringify(ids));
}
dataCenter.workspaces = [...dataCenter.workspaces, workspace];
dataCenter.callbacks.forEach(cb => cb());
return id;
}
const emptyWorkspaces: RemWorkspace[] = [];
export async function refreshDataCenter(signal?: AbortSignal) {
dataCenter.isLoaded = false;
dataCenter.callbacks.forEach(cb => cb());
// fixme(himself65): `prefetchWorkspace` is not used
// use `config.enablePlugin = ['affine', 'local']` instead
// if (!config.prefetchWorkspace) {
// console.info('prefetchNecessaryData: skip prefetching');
// return;
// }
const plugins = Object.values(WorkspacePlugins).sort(
(a, b) => a.loadPriority - b.loadPriority
);
// prefetch data in order
for (const plugin of plugins) {
console.info('prefetchNecessaryData: plugin', plugin.flavour);
try {
if (signal?.aborted) {
break;
}
const oldData = dataCenter.workspaces;
await plugin.prefetchData(dataCenter, signal);
const newData = dataCenter.workspaces;
if (!Object.is(oldData, newData)) {
console.info('prefetchNecessaryData: data changed');
}
} catch (e) {
console.error('error prefetch data', plugin.flavour, e);
}
}
dataCenter.isLoaded = true;
dataCenter.callbacks.forEach(cb => cb());
}
export function useWorkspaces(): RemWorkspace[] {
return useSyncExternalStore(
useCallback(onStoreChange => {
dataCenter.callbacks.add(onStoreChange);
return () => {
dataCenter.callbacks.delete(onStoreChange);
};
}, []),
useCallback(() => dataCenter.workspaces, []),
useCallback(() => emptyWorkspaces, [])
);
}
export function useWorkspacesIsLoaded(): boolean {
return useSyncExternalStore(
useCallback(onStoreChange => {
dataCenter.callbacks.add(onStoreChange);
return () => {
dataCenter.callbacks.delete(onStoreChange);
};
}, []),
useCallback(() => dataCenter.isLoaded, []),
useCallback(() => true, [])
);
}
export function useSyncWorkspaces() {
return useSWR<Workspace[]>(QueryKey.getWorkspaces, {
fallbackData: [],
revalidateOnReconnect: true,
revalidateOnFocus: false,
revalidateOnMount: true,
revalidateIfStale: false,
});
}
async function deleteWorkspace(workspaceId: string) {
return lockMutex(async () => {
console.warn('deleting workspace');
const idx = dataCenter.workspaces.findIndex(
workspace => workspace.id === workspaceId
);
if (idx === -1) {
throw new Error('workspace not found');
}
try {
const [workspace] = dataCenter.workspaces.splice(idx, 1);
// @ts-expect-error
await WorkspacePlugins[workspace.flavour].deleteWorkspace(workspace);
dataCenter.callbacks.forEach(cb => cb());
} catch (e) {
console.error('error deleting workspace', e);
}
});
}
export function useWorkspacesHelper() {
return useMemo(
() => ({
createWorkspacePage: (workspaceId: string, pageId: string) => {
const workspace = dataCenter.workspaces.find(
ws => ws.id === workspaceId
) as LocalWorkspace;
if (workspace && 'blockSuiteWorkspace' in workspace) {
workspace.blockSuiteWorkspace.createPage(pageId);
} else {
throw new Error('cannot create page. blockSuiteWorkspace not found');
}
},
createRemLocalWorkspace,
deleteWorkspace,
}),
[]
);
}