refactor: support suspense mode in workspaces (#1304)

This commit is contained in:
Himself65
2023-03-04 20:11:15 -06:00
committed by GitHub
parent dd6bee68cb
commit 9a199eb9a1
27 changed files with 713 additions and 652 deletions

View File

@@ -1,7 +1,11 @@
import { atom } from 'jotai';
import { createStore } from 'jotai';
import { assertExists } from '@blocksuite/store';
import { atom, createStore } from 'jotai';
import { atomWithStorage } from 'jotai/utils';
import { unstable_batchedUpdates } from 'react-dom';
import { WorkspacePlugins } from '../plugins';
import { RemWorkspace, RemWorkspaceFlavour } from '../shared';
// workspace necessary atoms
export const currentWorkspaceIdAtom = atom<string | null>(null);
export const currentPageIdAtom = atom<string | null>(null);
@@ -27,3 +31,32 @@ export const openCreateWorkspaceModalAtom = atom(false);
export const openQuickSearchModalAtom = atom(false);
export const jotaiStore = createStore();
type JotaiWorkspace = {
id: string;
flavour: RemWorkspaceFlavour;
};
export const jotaiWorkspacesAtom = atomWithStorage<JotaiWorkspace[]>(
'jotai-workspaces',
[]
);
export const workspacesAtom = atom<Promise<RemWorkspace[]>>(async get => {
const flavours: string[] = Object.values(WorkspacePlugins).map(
plugin => plugin.flavour
);
const jotaiWorkspaces = get(jotaiWorkspacesAtom).filter(workspace =>
flavours.includes(workspace.flavour)
);
const workspaces = await Promise.all(
jotaiWorkspaces.map(workspace => {
const plugin =
WorkspacePlugins[workspace.flavour as keyof typeof WorkspacePlugins];
assertExists(plugin);
const { CRUD } = plugin;
return CRUD.get(workspace.id);
})
);
return workspaces.filter(workspace => workspace !== null) as RemWorkspace[];
});

View File

@@ -1,10 +1,6 @@
import { atom } from 'jotai/index';
import {
BlockSuiteWorkspace,
LocalWorkspace,
RemWorkspaceFlavour,
} from '../../shared';
import { BlockSuiteWorkspace } from '../../shared';
import { apis } from '../../shared/apis';
import { createEmptyBlockSuiteWorkspace } from '../../utils';
@@ -35,14 +31,15 @@ export const publicBlockSuiteAtom = atom<Promise<BlockSuiteWorkspace>>(
blockSuiteWorkspace.awarenessStore.setFlag('enable_drag_handle', false);
return new Promise(resolve => {
setTimeout(() => {
const workspace: LocalWorkspace = {
id: workspaceId,
blockSuiteWorkspace,
flavour: RemWorkspaceFlavour.LOCAL,
providers: [],
};
dataCenter.workspaces.push(workspace);
dataCenter.callbacks.forEach(cb => cb());
// const workspace: LocalWorkspace = {
// id: workspaceId,
// blockSuiteWorkspace,
// flavour: RemWorkspaceFlavour.LOCAL,
// providers: [],
// };
// fixme: quick search won't work, ASAP
// dataCenter.workspaces.push(workspace);
// dataCenter.callbacks.forEach(cb => cb());
resolve(blockSuiteWorkspace);
}, 0);
});

View File

@@ -63,6 +63,7 @@ const createIndexedDBProvider = (
cleanup: () => {
assertExists(indexeddbProvider);
indexeddbProvider.clearData();
indexeddbProvider = null;
},
connect: () => {
providerLogger.info(

View File

@@ -5,22 +5,36 @@ import 'fake-indexeddb/auto';
import { assertExists } from '@blocksuite/store';
import { render, renderHook } from '@testing-library/react';
import { createStore, getDefaultStore, Provider } from 'jotai';
import { useRouter } from 'next/router';
import { useCallback, useState } from 'react';
import { describe, expect, test, vi } from 'vitest';
import React, { useCallback, useState } from 'react';
import { beforeEach, describe, expect, test, vi } from 'vitest';
import createFetchMock from 'vitest-fetch-mock';
import { workspacesAtom } from '../../atoms';
import { useCurrentPageId } from '../../hooks/current/use-current-page-id';
import { useCurrentWorkspace } from '../../hooks/current/use-current-workspace';
import {
currentWorkspaceAtom,
useCurrentWorkspace,
} from '../../hooks/current/use-current-workspace';
import { useBlockSuiteWorkspaceHelper } from '../../hooks/use-blocksuite-workspace-helper';
import { useWorkspacesHelper } from '../../hooks/use-workspaces';
import { ThemeProvider } from '../../providers/ThemeProvider';
import { pathGenerator } from '../../shared';
import { pathGenerator, RemWorkspaceFlavour } from '../../shared';
import { WorkSpaceSliderBar } from '../pure/workspace-slider-bar';
const fetchMocker = createFetchMock(vi);
// fetchMocker.enableMocks();
let store = getDefaultStore();
beforeEach(async () => {
store = createStore();
await store.get(workspacesAtom);
});
const ProviderWrapper: React.FC<React.PropsWithChildren> = ({ children }) => {
return <Provider store={store}>{children}</Provider>;
};
describe('WorkSpaceSliderBar', () => {
test('basic', async () => {
@@ -28,10 +42,17 @@ describe('WorkSpaceSliderBar', () => {
const onOpenWorkspaceListModalFn = vi.fn();
const onOpenQuickSearchModalFn = vi.fn();
const mutationHook = renderHook(() => useWorkspacesHelper());
const id = mutationHook.result.current.createRemLocalWorkspace('test0');
const mutationHook = renderHook(() => useWorkspacesHelper(), {
wrapper: ProviderWrapper,
});
const id = await mutationHook.result.current.createLocalWorkspace('test0');
await store.get(workspacesAtom);
mutationHook.rerender();
mutationHook.result.current.createWorkspacePage(id, 'test1');
const currentWorkspaceHook = renderHook(() => useCurrentWorkspace());
await store.get(currentWorkspaceAtom);
const currentWorkspaceHook = renderHook(() => useCurrentWorkspace(), {
wrapper: ProviderWrapper,
});
let i = 0;
const Component = () => {
const [show, setShow] = useState(false);
@@ -63,11 +84,17 @@ describe('WorkSpaceSliderBar', () => {
const App = () => {
return (
<ThemeProvider>
<Component />
<ProviderWrapper>
<Component />
</ProviderWrapper>
</ThemeProvider>
);
};
currentWorkspaceHook.result.current[1](id);
const currentWorkspace = await store.get(currentWorkspaceAtom);
expect(currentWorkspace).toBeDefined();
expect(currentWorkspace?.flavour).toBe(RemWorkspaceFlavour.LOCAL);
expect(currentWorkspace?.id).toBe(id);
const app = render(<App />);
const card = await app.findByTestId('current-workspace');
expect(onOpenWorkspaceListModalFn).toBeCalledTimes(0);

View File

@@ -30,7 +30,6 @@ const isMac = () => {
export type QuickSearchModalProps = {
blockSuiteWorkspace: BlockSuiteWorkspace;
enableShortCut: boolean;
open: boolean;
setOpen: (value: boolean) => void;
router: NextRouter;
@@ -40,7 +39,6 @@ export const QuickSearchModal: React.FC<QuickSearchModalProps> = ({
open,
setOpen,
router,
enableShortCut,
blockSuiteWorkspace,
}) => {
const [loading, startTransition] = useTransition();
@@ -65,9 +63,6 @@ export const QuickSearchModal: React.FC<QuickSearchModalProps> = ({
}, [setOpen, setQuery]);
// Add ‘⌘+K shortcut keys as switches
useEffect(() => {
if (!enableShortCut) {
return;
}
const keydown = (e: KeyboardEvent) => {
if ((e.key === 'k' && e.metaKey) || (e.key === 'k' && e.ctrlKey)) {
const selection = window.getSelection();
@@ -86,7 +81,7 @@ export const QuickSearchModal: React.FC<QuickSearchModalProps> = ({
document.addEventListener('keydown', keydown, { capture: true });
return () =>
document.removeEventListener('keydown', keydown, { capture: true });
}, [enableShortCut, open, router, setOpen, setQuery]);
}, [open, router, setOpen, setQuery]);
return (
<Modal

View File

@@ -217,6 +217,7 @@ export const WorkSpaceSliderBar: React.FC<WorkSpaceSliderBarProps> = ({
currentPath ===
(currentWorkspaceId && paths.setting(currentWorkspaceId))
}
data-testid="slider-bar-workspace-setting-button"
>
<StyledLink
href={{

View File

@@ -8,22 +8,26 @@ 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 { createStore, Provider } from 'jotai';
import { useRouter } from 'next/router';
import routerMock from 'next-router-mock';
import { createDynamicRouteParser } from 'next-router-mock/dynamic-routes';
import React from 'react';
import { beforeAll, beforeEach, describe, expect, test } from 'vitest';
import { workspacesAtom } from '../../atoms';
import { BlockSuiteWorkspace, RemWorkspaceFlavour } from '../../shared';
import { useCurrentWorkspace } from '../current/use-current-workspace';
import { useBlockSuiteWorkspaceName } from '../use-blocksuite-workspace-name';
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';
currentWorkspaceAtom,
useCurrentWorkspace,
} from '../current/use-current-workspace';
import { useBlockSuiteWorkspaceName } from '../use-blocksuite-workspace-name';
import { usePageMeta, usePageMetaHelper } from '../use-page-meta';
import {
REDIRECT_TIMEOUT,
useSyncRouterWithCurrentWorkspaceAndPage,
} from '../use-sync-router-with-current-workspace-and-page';
import { useWorkspaces, useWorkspacesHelper } from '../use-workspaces';
let blockSuiteWorkspace: BlockSuiteWorkspace;
beforeAll(() => {
@@ -32,9 +36,26 @@ beforeAll(() => {
);
});
beforeEach(() => {
localStorage.clear();
});
async function getJotaiContext() {
const store = createStore();
const ProviderWrapper: React.FC<React.PropsWithChildren> =
function ProviderWrapper({ children }) {
return <Provider store={store}>{children}</Provider>;
};
const workspaces = await store.get(workspacesAtom);
expect(workspaces.length).toBe(0);
return {
store,
ProviderWrapper,
initialWorkspaces: workspaces,
} as const;
}
beforeEach(async () => {
vitestRefreshWorkspaces();
dataCenter.isLoaded = true;
return new Promise<void>(resolve => {
blockSuiteWorkspace = new BlockSuiteWorkspace({
room: 'test',
@@ -104,16 +125,50 @@ describe('usePageMetas', async () => {
});
});
describe('useWorkspacesHelper', () => {
test('basic', async () => {
const { ProviderWrapper, store } = await getJotaiContext();
const workspaceHelperHook = renderHook(() => useWorkspacesHelper(), {
wrapper: ProviderWrapper,
});
const id = await workspaceHelperHook.result.current.createLocalWorkspace(
'test'
);
const workspaces = await store.get(workspacesAtom);
expect(workspaces.length).toBe(1);
expect(workspaces[0].id).toBe(id);
const workspacesHook = renderHook(() => useWorkspaces(), {
wrapper: ProviderWrapper,
});
await store.get(currentWorkspaceAtom);
const currentWorkspaceHook = renderHook(() => useCurrentWorkspace(), {
wrapper: ProviderWrapper,
});
currentWorkspaceHook.result.current[1](workspacesHook.result.current[0].id);
});
});
describe('useWorkspaces', () => {
test('basic', () => {
const { result } = renderHook(() => useWorkspaces());
test('basic', async () => {
const { ProviderWrapper } = await getJotaiContext();
const { result } = renderHook(() => useWorkspaces(), {
wrapper: ProviderWrapper,
});
expect(result.current).toEqual([]);
});
test('mutation', () => {
const { result } = renderHook(() => useWorkspacesHelper());
result.current.createRemLocalWorkspace('test');
const { result: result2 } = renderHook(() => useWorkspaces());
test('mutation', async () => {
const { ProviderWrapper, store } = await getJotaiContext();
const { result } = renderHook(() => useWorkspacesHelper(), {
wrapper: ProviderWrapper,
});
await result.current.createLocalWorkspace('test');
const workspaces = await store.get(workspacesAtom);
console.log(workspaces);
expect(workspaces.length).toEqual(1);
const { result: result2 } = renderHook(() => useWorkspaces(), {
wrapper: ProviderWrapper,
});
expect(result2.current.length).toEqual(1);
const firstWorkspace = result2.current[0];
expect(firstWorkspace.flavour).toBe('local');
@@ -124,8 +179,13 @@ describe('useWorkspaces', () => {
describe('useSyncRouterWithCurrentWorkspaceAndPage', () => {
test('from "/"', async () => {
const mutationHook = renderHook(() => useWorkspacesHelper());
const id = mutationHook.result.current.createRemLocalWorkspace('test0');
const { ProviderWrapper, store } = await getJotaiContext();
const mutationHook = renderHook(() => useWorkspacesHelper(), {
wrapper: ProviderWrapper,
});
const id = await mutationHook.result.current.createLocalWorkspace('test0');
await store.get(currentWorkspaceAtom);
mutationHook.rerender();
mutationHook.result.current.createWorkspacePage(id, 'page0');
const routerHook = renderHook(() => useRouter());
await routerHook.result.current.push('/');
@@ -134,6 +194,7 @@ describe('useSyncRouterWithCurrentWorkspaceAndPage', () => {
renderHook(
({ router }) => useSyncRouterWithCurrentWorkspaceAndPage(router),
{
wrapper: ProviderWrapper,
initialProps: {
router: routerHook.result.current,
},
@@ -144,8 +205,14 @@ describe('useSyncRouterWithCurrentWorkspaceAndPage', () => {
});
test('from incorrect "/workspace/[workspaceId]/[pageId]"', async () => {
const mutationHook = renderHook(() => useWorkspacesHelper());
const id = mutationHook.result.current.createRemLocalWorkspace('test0');
const { ProviderWrapper, store } = await getJotaiContext();
const mutationHook = renderHook(() => useWorkspacesHelper(), {
wrapper: ProviderWrapper,
});
const id = await mutationHook.result.current.createLocalWorkspace('test0');
const workspaces = await store.get(workspacesAtom);
expect(workspaces.length).toEqual(1);
mutationHook.rerender();
mutationHook.result.current.createWorkspacePage(id, 'page0');
const routerHook = renderHook(() => useRouter());
await routerHook.result.current.push(`/workspace/${id}/not_exist`);
@@ -154,29 +221,16 @@ describe('useSyncRouterWithCurrentWorkspaceAndPage', () => {
renderHook(
({ router }) => useSyncRouterWithCurrentWorkspaceAndPage(router),
{
wrapper: ProviderWrapper,
initialProps: {
router: routerHook.result.current,
},
}
);
expect(routerHook.result.current.asPath).toBe(`/workspace/${id}/page0`);
});
});
await new Promise(resolve => setTimeout(resolve, REDIRECT_TIMEOUT));
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
);
expect(routerHook.result.current.asPath).toBe(`/workspace/${id}/page0`);
});
});

View File

@@ -11,12 +11,10 @@ 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',
})

View File

@@ -1,10 +1,10 @@
import { useCallback } from 'react';
import { mutate } from 'swr';
import { jotaiStore, jotaiWorkspacesAtom } from '../../atoms';
import { QueryKey } from '../../plugins/affine/fetcher';
import { AffineWorkspace } from '../../shared';
import { apis } from '../../shared/apis';
import { refreshDataCenter } from '../use-workspaces';
export function useToggleWorkspacePublish(workspace: AffineWorkspace) {
return useCallback(
@@ -14,7 +14,10 @@ export function useToggleWorkspacePublish(workspace: AffineWorkspace) {
public: isPublish,
});
await mutate(QueryKey.getWorkspaces);
await refreshDataCenter();
// force update
jotaiStore.set(jotaiWorkspacesAtom, [
...jotaiStore.get(jotaiWorkspacesAtom),
]);
},
[workspace]
);

View File

@@ -1,7 +1,40 @@
import { useAtom } from 'jotai';
import { Page } from '@blocksuite/store';
import { atom, useAtom, useAtomValue } from 'jotai';
import { currentPageIdAtom } from '../../atoms';
import { currentWorkspaceAtom } from './use-current-workspace';
export const currentPageAtom = atom<Promise<Page | null>>(async get => {
const id = get(currentPageIdAtom);
const workspace = await get(currentWorkspaceAtom);
if (!workspace || !id) {
return Promise.resolve(null);
}
const page = workspace.blockSuiteWorkspace.getPage(id);
if (page) {
return page;
} else {
return new Promise(resolve => {
const dispose = workspace.blockSuiteWorkspace.slots.pageAdded.on(
pageId => {
if (pageId === id) {
resolve(page);
dispose.dispose();
}
}
);
});
}
});
export function useCurrentPage(): Page | null {
return useAtomValue(currentPageAtom);
}
/**
* @deprecated
*/
export function useCurrentPageId(): [
string | null,
(newId: string | null) => void

View File

@@ -1,18 +1,30 @@
import { useAtom } from 'jotai';
import { atom, useAtom, useAtomValue } from 'jotai';
import { useCallback } from 'react';
import { currentPageIdAtom, currentWorkspaceIdAtom } from '../../atoms';
import {
currentPageIdAtom,
currentWorkspaceIdAtom,
workspacesAtom,
} from '../../atoms';
import { RemWorkspace } from '../../shared';
import { useWorkspace } from '../use-workspace';
export const currentWorkspaceAtom = atom<Promise<RemWorkspace | null>>(
async get => {
const id = get(currentWorkspaceIdAtom);
const workspaces = await get(workspacesAtom);
return workspaces.find(workspace => workspace.id === id) ?? null;
}
);
export function useCurrentWorkspace(): [
RemWorkspace | null,
(id: string | null) => void
] {
const [id, setId] = useAtom(currentWorkspaceIdAtom);
const currentWorkspace = useAtomValue(currentWorkspaceAtom);
const [, setId] = useAtom(currentWorkspaceIdAtom);
const [, setPageId] = useAtom(currentPageIdAtom);
return [
useWorkspace(id),
currentWorkspace,
useCallback(
(id: string | null) => {
setPageId(null);

View File

@@ -0,0 +1,53 @@
import { DEFAULT_WORKSPACE_NAME } from '@affine/env';
import { assertEquals, assertExists, nanoid } from '@blocksuite/store';
import { useAtom } from 'jotai/index';
import { useEffect } from 'react';
import { jotaiWorkspacesAtom } from '../atoms';
import { LocalPlugin } from '../plugins/local';
import { RemWorkspaceFlavour } from '../shared';
import { createEmptyBlockSuiteWorkspace } from '../utils';
export function useCreateFirstWorkspace() {
const [jotaiWorkspaces, set] = useAtom(jotaiWorkspacesAtom);
useEffect(() => {
const controller = new AbortController();
/**
* Create a first workspace, only just once for a browser
*/
async function createFirst() {
const blockSuiteWorkspace = createEmptyBlockSuiteWorkspace(
nanoid(),
(_: string) => undefined
);
blockSuiteWorkspace.meta.setName(DEFAULT_WORKSPACE_NAME);
const id = await LocalPlugin.CRUD.create(blockSuiteWorkspace);
const workspace = await LocalPlugin.CRUD.get(id);
assertExists(workspace);
assertEquals(workspace.id, id);
const newPageId = nanoid();
workspace.blockSuiteWorkspace.slots.pageAdded.once(pageId => {
assertEquals(pageId, newPageId);
set(workspaces => [
...workspaces,
{
id: workspace.id,
flavour: RemWorkspaceFlavour.LOCAL,
},
]);
});
workspace.blockSuiteWorkspace.createPage(newPageId);
}
if (
jotaiWorkspaces.length === 0 &&
localStorage.getItem('first') !== 'true'
) {
localStorage.setItem('first', 'true');
createFirst();
}
return () => {
controller.abort();
};
}, [jotaiWorkspaces.length, set]);
}

View File

@@ -1,41 +0,0 @@
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,10 +1,11 @@
import { NextRouter } from 'next/router';
import { useEffect } from 'react';
import { currentPageIdAtom, jotaiStore } from '../atoms';
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';
import { useWorkspaces } from './use-workspaces';
export function findSuitablePageId(
workspace: RemWorkspace,
@@ -32,11 +33,11 @@ export function findSuitablePageId(
}
}
export const REDIRECT_TIMEOUT = 1000;
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('/')) {
@@ -66,7 +67,7 @@ export function useSyncRouterWithCurrentWorkspaceAndPage(router: NextRouter) {
};
}, [currentWorkspace, router, setCurrentPageId, setCurrentWorkspaceId]);
useEffect(() => {
if (!router.isReady || !isLoaded) {
if (!router.isReady) {
return;
}
if (
@@ -130,19 +131,55 @@ export function useSyncRouterWithCurrentWorkspaceAndPage(router: NextRouter) {
}
} 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;
}
const targetId = findSuitablePageId(currentWorkspace, targetPageId);
if (targetId) {
setCurrentPageId(targetId);
router.push({
query: {
...router.query,
workspaceId: currentWorkspace.id,
pageId: targetId,
},
});
return;
} else {
const dispose =
currentWorkspace.blockSuiteWorkspace.slots.pageAdded.on(
pageId => {
if (pageId === targetPageId) {
dispose.dispose();
setCurrentPageId(pageId);
router.push({
query: {
...router.query,
workspaceId: currentWorkspace.id,
pageId: targetId,
},
});
}
}
);
const clearId = setTimeout(() => {
if (jotaiStore.get(currentPageIdAtom) === null) {
const id =
currentWorkspace.blockSuiteWorkspace.meta.pageMetas.at(0)?.id;
if (id) {
router.push({
query: {
...router.query,
workspaceId: currentWorkspace.id,
pageId: id,
},
});
setCurrentPageId(id);
}
}
dispose.dispose();
}, REDIRECT_TIMEOUT);
return () => {
clearTimeout(clearId);
dispose.dispose();
};
}
}
}
@@ -156,6 +193,5 @@ export function useSyncRouterWithCurrentWorkspaceAndPage(router: NextRouter) {
setCurrentWorkspaceId,
workspaces,
router,
isLoaded,
]);
}

View File

@@ -1,172 +1,25 @@
import { Workspace } from '@affine/datacenter';
import { config, getEnvironment } from '@affine/env';
import { nanoid } from '@blocksuite/store';
import { useCallback, useMemo, useSyncExternalStore } from 'react';
import useSWR from 'swr';
import { useAtomValue, useSetAtom } from 'jotai';
import { useCallback } from 'react';
import { lockMutex } from '../atoms';
import { createLocalProviders } from '../blocksuite';
import { jotaiWorkspacesAtom, workspacesAtom } from '../atoms';
import { WorkspacePlugins } from '../plugins';
import { QueryKey } from '../plugins/affine/fetcher';
import { kStoreKey } from '../plugins/local';
import { LocalPlugin } from '../plugins/local';
import { LocalWorkspace, RemWorkspace, RemWorkspaceFlavour } from '../shared';
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 = nanoid();
const blockSuiteWorkspace = createEmptyBlockSuiteWorkspace(
id,
(_: string) => undefined
);
blockSuiteWorkspace.meta.setName(name);
const workspace: LocalWorkspace = {
flavour: RemWorkspaceFlavour.LOCAL,
blockSuiteWorkspace: blockSuiteWorkspace,
providers: [...createLocalProviders(blockSuiteWorkspace)],
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());
if (getEnvironment().isServer) {
return;
}
// 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);
}
});
return useAtomValue(workspacesAtom);
}
export function useWorkspacesHelper() {
return useMemo(
() => ({
createWorkspacePage: (workspaceId: string, pageId: string) => {
const workspace = dataCenter.workspaces.find(
const workspaces = useWorkspaces();
const jotaiWorkspaces = useAtomValue(jotaiWorkspacesAtom);
const set = useSetAtom(jotaiWorkspacesAtom);
return {
createWorkspacePage: useCallback(
(workspaceId: string, pageId: string) => {
const workspace = workspaces.find(
ws => ws.id === workspaceId
) as LocalWorkspace;
if (workspace && 'blockSuiteWorkspace' in workspace) {
@@ -175,9 +28,46 @@ export function useWorkspacesHelper() {
throw new Error('cannot create page. blockSuiteWorkspace not found');
}
},
createRemLocalWorkspace,
deleteWorkspace,
}),
[]
);
[workspaces]
),
createLocalWorkspace: useCallback(
async (name: string): Promise<string> => {
const blockSuiteWorkspace = createEmptyBlockSuiteWorkspace(
nanoid(),
_ => undefined
);
blockSuiteWorkspace.meta.setName(name);
const id = await LocalPlugin.CRUD.create(blockSuiteWorkspace);
set(workspaces => [
...workspaces,
{
id,
flavour: RemWorkspaceFlavour.LOCAL,
},
]);
return id;
},
[set]
),
deleteWorkspace: useCallback(
async (workspaceId: string) => {
const targetJotaiWorkspace = jotaiWorkspaces.find(
ws => ws.id === workspaceId
);
const targetWorkspace = workspaces.find(ws => ws.id === workspaceId);
if (!targetJotaiWorkspace || !targetWorkspace) {
throw new Error('page cannot be found');
}
// delete workspace from plugin
await WorkspacePlugins[targetWorkspace.flavour].CRUD.delete(
// fixme: type casting
targetWorkspace as any
);
// delete workspace from jotai storage
set(workspaces => workspaces.filter(ws => ws.id !== workspaceId));
},
[jotaiWorkspaces, set, workspaces]
),
};
}

View File

@@ -1,57 +1,100 @@
import { DebugLogger } from '@affine/debug';
import { setUpLanguage, useTranslation } from '@affine/i18n';
import { assertExists, nanoid } from '@blocksuite/store';
import { useAtom, useAtomValue } from 'jotai';
import { NoSsr } from '@mui/material';
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
import { atomWithStorage } from 'jotai/utils';
import { useRouter } from 'next/router';
import React, { useCallback, useEffect } from 'react';
import React, { Suspense, useCallback, useEffect } from 'react';
import { Helmet } from 'react-helmet-async';
import {
currentWorkspaceIdAtom,
jotaiWorkspacesAtom,
openQuickSearchModalAtom,
openWorkspacesModalAtom,
workspaceLockAtom,
} from '../atoms';
import { HelpIsland } from '../components/pure/help-island';
import { PageLoading } from '../components/pure/loading';
import QuickSearchModal from '../components/pure/quick-search-modal';
import WorkSpaceSliderBar from '../components/pure/workspace-slider-bar';
import { useCurrentPageId } from '../hooks/current/use-current-page-id';
import { useCurrentWorkspace } from '../hooks/current/use-current-workspace';
import { useBlockSuiteWorkspaceHelper } from '../hooks/use-blocksuite-workspace-helper';
import { useCreateFirstWorkspace } from '../hooks/use-create-first-workspace';
import { useRouterTitle } from '../hooks/use-router-title';
import {
refreshDataCenter,
useSyncWorkspaces,
useWorkspaces,
} from '../hooks/use-workspaces';
import { useWorkspaces } from '../hooks/use-workspaces';
import { WorkspacePlugins } from '../plugins';
import { ModalProvider } from '../providers/ModalProvider';
import { pathGenerator, publicPathGenerator } from '../shared';
import { StyledPage, StyledToolWrapper, StyledWrapper } from './styles';
const sideBarOpenAtom = atomWithStorage('sideBarOpen', true);
refreshDataCenter();
const logger = new DebugLogger('workspace-layout');
export const WorkspaceLayout: React.FC<React.PropsWithChildren> =
function WorkspacesSuspense({ children }) {
const { i18n } = useTranslation();
useEffect(() => {
document.documentElement.lang = i18n.language;
// todo(himself65): this is a hack, we should use a better way to set the language
setUpLanguage(i18n);
}, [i18n]);
useCreateFirstWorkspace();
const set = useSetAtom(jotaiWorkspacesAtom);
useEffect(() => {
logger.info('mount');
const controller = new AbortController();
const lists = Object.values(WorkspacePlugins)
.sort((a, b) => a.loadPriority - b.loadPriority)
.map(({ CRUD }) => CRUD.list);
async function fetch() {
const items = [];
for (const list of lists) {
try {
const item = await list();
items.push(...item.map(x => ({ id: x.id, flavour: x.flavour })));
} catch (e) {
logger.error('list data error:', e);
}
}
if (controller.signal.aborted) {
return;
}
set([...items]);
logger.info('mount first data:', items);
}
fetch();
return () => {
controller.abort();
logger.info('unmount');
};
}, [set]);
const currentWorkspaceId = useAtomValue(currentWorkspaceIdAtom);
return (
<NoSsr>
{/* fixme(himself65): don't re-render whole modals */}
<ModalProvider key={currentWorkspaceId} />
<Suspense fallback={<PageLoading />}>
<WorkspaceLayoutInner>{children}</WorkspaceLayoutInner>
</Suspense>
</NoSsr>
);
};
export const WorkspaceLayout: React.FC<React.PropsWithChildren> = ({
export const WorkspaceLayoutInner: React.FC<React.PropsWithChildren> = ({
children,
}) => {
const { i18n } = useTranslation();
useEffect(() => {
document.documentElement.lang = i18n.language;
// todo(himself65): this is a hack, we should use a better way to set the language
setUpLanguage(i18n);
}, [i18n]);
useEffect(() => {
const controller = new AbortController();
refreshDataCenter(controller.signal);
return () => {
controller.abort();
};
}, []);
const [show, setShow] = useAtom(sideBarOpenAtom);
useSyncWorkspaces();
const [currentWorkspace] = useCurrentWorkspace();
const [currentPageId] = useCurrentPageId();
const workspaces = useWorkspaces();
useEffect(() => {
console.log(workspaces);
}, [workspaces]);
useEffect(() => {
const providers = workspaces.flatMap(workspace =>
workspace.providers.filter(provider => provider.background)
@@ -91,7 +134,6 @@ export const WorkspaceLayout: React.FC<React.PropsWithChildren> = ({
const isPublicWorkspace =
router.pathname.split('/')[1] === 'public-workspace';
const title = useRouterTitle(router);
const [, setOpenQuickSearchModalAtom] = useAtom(openQuickSearchModalAtom);
const handleOpenPage = useCallback(
(pageId: string) => {
assertExists(currentWorkspace);
@@ -113,6 +155,10 @@ export const WorkspaceLayout: React.FC<React.PropsWithChildren> = ({
const handleOpenWorkspaceListModal = useCallback(() => {
setOpenWorkspacesModal(true);
}, [setOpenWorkspacesModal]);
const [openQuickSearchModal, setOpenQuickSearchModalAtom] = useAtom(
openQuickSearchModalAtom
);
const handleOpenQuickSearchModal = useCallback(() => {
setOpenQuickSearchModalAtom(true);
}, [setOpenQuickSearchModalAtom]);
@@ -153,6 +199,14 @@ export const WorkspaceLayout: React.FC<React.PropsWithChildren> = ({
</StyledToolWrapper>
</StyledWrapper>
</StyledPage>
{currentWorkspace?.blockSuiteWorkspace && (
<QuickSearchModal
blockSuiteWorkspace={currentWorkspace?.blockSuiteWorkspace}
open={openQuickSearchModal}
setOpen={setOpenQuickSearchModalAtom}
router={router}
/>
)}
</>
);
};

View File

@@ -18,7 +18,6 @@ import { AffineErrorBoundary } from '../components/affine/affine-error-eoundary'
import { ProviderComposer } from '../components/provider-composer';
import { PageLoading } from '../components/pure/loading';
import { AffineSWRConfigProvider } from '../providers/AffineSWRConfigProvider';
import { ModalProvider } from '../providers/ModalProvider';
import { ThemeProvider } from '../providers/ThemeProvider';
import { NextPageWithLayout } from '../shared';
import createEmotionCache from '../utils/create-emotion-cache';
@@ -81,7 +80,6 @@ const App = function App({
<AffineSWRConfigProvider key="AffineSWRConfigProvider" />,
<Provider key="JotaiProvider" store={jotaiStore} />,
<ThemeProvider key="ThemeProvider" />,
<ModalProvider key="ModalProvider" />,
],
[]
)}

View File

@@ -1,50 +1,18 @@
import { useAtom } from 'jotai';
import { NextPage } from 'next';
import { useRouter } from 'next/router';
import React, { useEffect } from 'react';
import React, { Suspense, useEffect } from 'react';
import { currentWorkspaceIdAtom } from '../atoms';
import { PageLoading } from '../components/pure/loading';
import { refreshDataCenter, useWorkspaces } from '../hooks/use-workspaces';
import { useCreateFirstWorkspace } from '../hooks/use-create-first-workspace';
import { useWorkspaces } from '../hooks/use-workspaces';
const IndexPage: NextPage = () => {
const IndexPageInner = () => {
const router = useRouter();
useEffect(() => {
const controller = new AbortController();
refreshDataCenter(controller.signal);
return () => {
controller.abort();
};
}, []);
const [workspaceId] = useAtom(currentWorkspaceIdAtom);
const workspaces = useWorkspaces();
useEffect(() => {
if (!router.isReady) {
return;
}
const targetWorkspace = workspaces.find(w => w.id === workspaceId);
if (workspaceId && targetWorkspace) {
const pageId =
targetWorkspace.blockSuiteWorkspace.meta.pageMetas.at(0)?.id;
if (pageId) {
router.replace({
pathname: '/workspace/[workspaceId]/[pageId]',
query: {
workspaceId,
pageId,
},
});
return;
} else {
router.replace({
pathname: '/workspace/[workspaceId]/all',
query: {
workspaceId,
},
});
return;
}
}
const firstWorkspace = workspaces.at(0);
if (firstWorkspace) {
const pageId =
@@ -59,17 +27,44 @@ const IndexPage: NextPage = () => {
});
return;
} else {
router.replace({
pathname: '/workspace/[workspaceId]/all',
query: {
workspaceId: firstWorkspace.id,
},
});
return;
const clearId = setTimeout(() => {
dispose.dispose();
router.replace({
pathname: '/workspace/[workspaceId]/all',
query: {
workspaceId: firstWorkspace.id,
},
});
}, 1000);
const dispose = firstWorkspace.blockSuiteWorkspace.slots.pageAdded.once(
pageId => {
clearTimeout(clearId);
router.replace({
pathname: '/workspace/[workspaceId]/[pageId]',
query: {
workspaceId: firstWorkspace.id,
pageId,
},
});
}
);
return () => {
clearTimeout(clearId);
dispose.dispose();
};
}
}
}, [router, workspaceId, workspaces]);
}, [router, workspaces]);
return <PageLoading />;
};
const IndexPage: NextPage = () => {
useCreateFirstWorkspace();
return (
<Suspense fallback={<PageLoading />}>
<IndexPageInner />
</Suspense>
);
};
export default IndexPage;

View File

@@ -1,5 +1,5 @@
import { useRouter } from 'next/router';
import React, { useEffect, useState } from 'react';
import React, { useEffect } from 'react';
import { Unreachable } from '../../../components/affine/affine-error-eoundary';
import { PageLoading } from '../../../components/pure/loading';
@@ -27,20 +27,6 @@ function enableFullFlags(blockSuiteWorkspace: BlockSuiteWorkspace) {
const WorkspaceDetail: React.FC = () => {
const [pageId] = useCurrentPageId();
const [currentWorkspace] = useCurrentWorkspace();
const [, rerender] = useState(false);
// fixme(himself65): this is a hack
useEffect(() => {
const dispose = currentWorkspace?.blockSuiteWorkspace.slots.pageAdded.on(
id => {
if (pageId === id) {
rerender(prev => !prev);
}
}
);
return () => {
dispose?.dispose();
};
}, [currentWorkspace?.blockSuiteWorkspace.slots.pageAdded, pageId]);
useEffect(() => {
if (currentWorkspace) {
enableFullFlags(currentWorkspace.blockSuiteWorkspace);
@@ -52,9 +38,6 @@ const WorkspaceDetail: React.FC = () => {
if (!pageId) {
return <PageLoading />;
}
if (!currentWorkspace.blockSuiteWorkspace.getPage(pageId)) {
return <PageLoading />;
}
if (currentWorkspace.flavour === RemWorkspaceFlavour.AFFINE) {
const PageDetail = WorkspacePlugins[currentWorkspace.flavour].PageDetail;
return (

View File

@@ -1,4 +1,4 @@
import { assertEquals } from '@blocksuite/store';
import { createJSONStorage } from 'jotai/utils';
import React from 'react';
import { preload } from 'swr';
import { z } from 'zod';
@@ -19,6 +19,7 @@ import { createEmptyBlockSuiteWorkspace } from '../../utils';
import { WorkspacePlugin } from '..';
import { fetcher, QueryKey } from './fetcher';
const storage = createJSONStorage(() => localStorage);
const kAffineLocal = 'affine-local-storage-v2';
const schema = z.object({
id: z.string(),
@@ -31,33 +32,48 @@ const schema = z.object({
export const AffinePlugin: WorkspacePlugin<RemWorkspaceFlavour.AFFINE> = {
flavour: RemWorkspaceFlavour.AFFINE,
loadPriority: LoadPriority.HIGH,
createWorkspace: async (blockSuiteWorkspace: BlockSuiteWorkspace) => {
const binary = BlockSuiteWorkspace.Y.encodeStateAsUpdate(
blockSuiteWorkspace.doc
);
const { id } = await apis.createWorkspace(new Blob([binary.buffer]));
return id;
},
deleteWorkspace: async workspace => {
await apis.deleteWorkspace({
id: workspace.id,
});
workspace.providers.forEach(p => p.cleanup());
},
prefetchData: async dataCenter => {
if (localStorage.getItem(kAffineLocal)) {
const localData = JSON.parse(localStorage.getItem(kAffineLocal) || '[]');
if (Array.isArray(localData)) {
const workspacesDump = localData
.map((item: any) => {
const result = schema.safeParse(item);
if (result.success) {
return result.data;
}
return null;
})
.filter(Boolean) as z.infer<typeof schema>[];
const workspaces = workspacesDump.map(workspace => {
CRUD: {
create: async blockSuiteWorkspace => {
const binary = BlockSuiteWorkspace.Y.encodeStateAsUpdate(
blockSuiteWorkspace.doc
);
const { id } = await apis.createWorkspace(new Blob([binary.buffer]));
return id;
},
delete: async workspace => {
await apis.deleteWorkspace({
id: workspace.id,
});
},
get: async workspaceId => {
const workspaces: AffineWorkspace[] = await preload(
QueryKey.getWorkspaces,
fetcher
);
const workspace = workspaces.find(
workspace => workspace.id === workspaceId
);
const dump = workspaces.map(workspace => {
return {
id: workspace.id,
type: workspace.type,
public: workspace.public,
permission: workspace.permission,
create_at: workspace.create_at,
} satisfies z.infer<typeof schema>;
});
storage.setItem(kAffineLocal, dump);
if (!workspace) {
return null;
}
return workspace;
},
list: async () => {
// fixme: refactor auth check
if (!apis.auth.isLogin) return [];
return await apis.getWorkspaces().then(workspaces => {
return workspaces.map(workspace => {
const blockSuiteWorkspace = createEmptyBlockSuiteWorkspace(
workspace.id,
(k: string) =>
@@ -66,75 +82,14 @@ export const AffinePlugin: WorkspacePlugin<RemWorkspaceFlavour.AFFINE> = {
);
const affineWorkspace: AffineWorkspace = {
...workspace,
flavour: RemWorkspaceFlavour.AFFINE,
blockSuiteWorkspace,
providers: [...createAffineProviders(blockSuiteWorkspace)],
flavour: RemWorkspaceFlavour.AFFINE,
};
return affineWorkspace;
});
// fixme: refactor to a function
workspaces.forEach(workspace => {
const exist = dataCenter.workspaces.findIndex(
ws => ws.id === workspace.id
);
if (exist !== -1) {
dataCenter.workspaces.splice(exist, 1, workspace);
dataCenter.workspaces = [...dataCenter.workspaces];
} else {
dataCenter.workspaces = [...dataCenter.workspaces, workspace];
}
});
dataCenter.callbacks.forEach(cb => cb());
} else {
localStorage.removeItem(kAffineLocal);
}
}
const promise: Promise<AffineWorkspace[]> = preload(
QueryKey.getWorkspaces,
fetcher
);
return promise
.then(async workspaces => {
const promises = workspaces.map(workspace => {
assertEquals(workspace.flavour, RemWorkspaceFlavour.AFFINE);
return workspace;
});
return Promise.all(promises)
.then(workspaces => {
workspaces.forEach(workspace => {
if (workspace === null) {
return;
}
const exist = dataCenter.workspaces.findIndex(
ws => ws.id === workspace.id
);
if (exist !== -1) {
dataCenter.workspaces.splice(exist, 1, workspace);
dataCenter.workspaces = [...dataCenter.workspaces];
} else {
dataCenter.workspaces = [...dataCenter.workspaces, workspace];
}
});
return workspaces;
})
.then(ws => {
const workspaces = ws.filter(Boolean) as AffineWorkspace[];
const dump = workspaces.map(workspace => {
return {
id: workspace.id,
type: workspace.type,
public: workspace.public,
permission: workspace.permission,
create_at: workspace.create_at,
} satisfies z.infer<typeof schema>;
});
localStorage.setItem(kAffineLocal, JSON.stringify(dump));
});
})
.catch(error => {
console.error(error);
});
},
},
PageDetail: ({ currentWorkspace, currentPageId }) => {
const page = currentWorkspace.blockSuiteWorkspace.getPage(currentPageId);

View File

@@ -1,12 +1,10 @@
import { assertExists } from '@blocksuite/store';
import React from 'react';
import { refreshDataCenter } from '../hooks/use-workspaces';
import { jotaiStore, jotaiWorkspacesAtom } from '../atoms';
import {
BlockSuiteWorkspace,
FlavourToWorkspace,
LoadPriority,
RemWorkspace,
RemWorkspaceFlavour,
SettingPanel,
} from '../shared';
@@ -45,19 +43,14 @@ export interface WorkspacePlugin<Flavour extends RemWorkspaceFlavour> {
// Plugin will be loaded according to the priority
loadPriority: LoadPriority;
// Fetch necessary data for the first render
prefetchData: (
dataCenter: {
workspaces: RemWorkspace[];
callbacks: Set<() => void>;
},
signal?: AbortSignal
) => Promise<void>;
createWorkspace: (
blockSuiteWorkspace: BlockSuiteWorkspace
) => Promise<string>;
deleteWorkspace: (workspace: FlavourToWorkspace[Flavour]) => Promise<void>;
CRUD: {
create: (blockSuiteWorkspace: BlockSuiteWorkspace) => Promise<string>;
delete: (workspace: FlavourToWorkspace[Flavour]) => Promise<void>;
get: (workspaceId: string) => Promise<FlavourToWorkspace[Flavour] | null>;
// not supported yet
// update: (workspace: FlavourToWorkspace[Flavour]) => Promise<void>;
list: () => Promise<FlavourToWorkspace[Flavour][]>;
};
//#region UI
PageDetail: React.FC<PageDetailProps<Flavour>>;
@@ -73,18 +66,26 @@ export const WorkspacePlugins = {
[Key in RemWorkspaceFlavour]: WorkspacePlugin<Key>;
};
/**
* Transform workspace from one flavour to another
*
* The logic here is to delete the old workspace and create a new one.
*/
export async function transformWorkspace<
From extends RemWorkspaceFlavour,
To extends RemWorkspaceFlavour
>(from: From, to: To, workspace: FlavourToWorkspace[From]): Promise<string> {
// fixme: type cast
await WorkspacePlugins[from].deleteWorkspace(workspace as any);
const newId = await WorkspacePlugins[to].createWorkspace(
await WorkspacePlugins[from].CRUD.delete(workspace as any);
const newId = await WorkspacePlugins[to].CRUD.create(
workspace.blockSuiteWorkspace
);
// refresh the data center
dataCenter.workspaces = [];
await refreshDataCenter();
assertExists(dataCenter.workspaces.some(w => w.id === newId));
const workspaces = jotaiStore.get(jotaiWorkspacesAtom);
const idx = workspaces.findIndex(ws => ws.id === workspace.id);
workspaces.splice(idx, 1, {
id: newId,
flavour: to,
});
jotaiStore.set(jotaiWorkspacesAtom, [...workspaces]);
return newId;
}

View File

@@ -1,8 +1,9 @@
import { DebugLogger } from '@affine/debug';
import { config, DEFAULT_WORKSPACE_NAME } from '@affine/env';
import { assertEquals, nanoid } from '@blocksuite/store';
import { DEFAULT_WORKSPACE_NAME } from '@affine/env';
import { nanoid } from '@blocksuite/store';
import { createJSONStorage } from 'jotai/utils';
import React from 'react';
import { IndexeddbPersistence } from 'y-indexeddb';
import { z } from 'zod';
import { createLocalProviders } from '../../blocksuite';
import { PageNotFoundError } from '../../components/affine/affine-error-eoundary';
@@ -18,139 +19,90 @@ import {
import { createEmptyBlockSuiteWorkspace } from '../../utils';
import { WorkspacePlugin } from '..';
const logger = new DebugLogger('local-plugin');
const getStorage = () => createJSONStorage(() => localStorage);
export const kStoreKey = 'affine-local-workspace';
// fixme(himself65): this is a hacking that first workspace will disappear somehow
const hashMap = new Map<string, BlockSuiteWorkspace>();
const schema = z.array(z.string());
export const LocalPlugin: WorkspacePlugin<RemWorkspaceFlavour.LOCAL> = {
flavour: RemWorkspaceFlavour.LOCAL,
loadPriority: LoadPriority.LOW,
createWorkspace: async blockSuiteWorkspace => {
let ids: string[] = [];
try {
ids = JSON.parse(localStorage.getItem(kStoreKey) ?? '[]');
if (!Array.isArray(ids)) {
localStorage.setItem(kStoreKey, '[]');
ids = [];
CRUD: {
get: async workspaceId => {
const storage = getStorage();
!Array.isArray(storage.getItem(kStoreKey)) &&
storage.setItem(kStoreKey, []);
const data = storage.getItem(kStoreKey) as z.infer<typeof schema>;
const id = data.find(id => id === workspaceId);
if (!id) {
return null;
}
} catch (e) {
localStorage.setItem(kStoreKey, '[]');
ids = [];
}
const id = nanoid();
const persistence = new IndexeddbPersistence(id, blockSuiteWorkspace.doc);
await persistence.whenSynced.then(() => {
persistence.destroy();
});
ids.push(id);
localStorage.setItem(kStoreKey, JSON.stringify(ids));
return id;
},
deleteWorkspace: async workspace => {
const id = workspace.id;
let ids: string[];
try {
ids = JSON.parse(localStorage.getItem(kStoreKey) ?? '[]');
if (!Array.isArray(ids)) {
localStorage.setItem(kStoreKey, '[]');
ids = [];
}
} catch (e) {
localStorage.setItem(kStoreKey, '[]');
ids = [];
}
const idx = ids.findIndex(x => x === id);
if (idx === -1) {
throw new Error('cannot find local workspace from localStorage');
}
workspace.providers.forEach(p => p.cleanup());
ids.splice(idx, 1);
assertEquals(
ids.every(id => typeof id === 'string'),
true
);
localStorage.setItem(kStoreKey, JSON.stringify(ids));
},
prefetchData: async (dataCenter, signal) => {
if (typeof window === 'undefined') {
// SSR mode, no local data
return;
}
if (signal?.aborted) {
return;
}
let ids: string[];
try {
ids = JSON.parse(localStorage.getItem(kStoreKey) ?? '[]');
if (!Array.isArray(ids)) {
localStorage.setItem(kStoreKey, '[]');
ids = [];
}
} catch (e) {
localStorage.setItem(kStoreKey, '[]');
ids = [];
}
if (config.enableIndexedDBProvider) {
const workspaces = await Promise.all(
ids.map(id => {
const blockSuiteWorkspace = hashMap.has(id)
? (hashMap.get(id) as BlockSuiteWorkspace)
: createEmptyBlockSuiteWorkspace(id, (_: string) => undefined);
hashMap.set(id, blockSuiteWorkspace);
const workspace: LocalWorkspace = {
id,
flavour: RemWorkspaceFlavour.LOCAL,
blockSuiteWorkspace: blockSuiteWorkspace,
providers: [...createLocalProviders(blockSuiteWorkspace)],
};
return workspace;
})
);
workspaces.forEach(workspace => {
if (workspace) {
const exist = dataCenter.workspaces.findIndex(
w => w.id === workspace.id
);
if (exist === -1) {
dataCenter.workspaces = [...dataCenter.workspaces, workspace];
} else {
dataCenter.workspaces[exist] = workspace;
dataCenter.workspaces = [...dataCenter.workspaces];
}
}
});
}
if (dataCenter.workspaces.length === 0) {
if (signal?.aborted) {
return;
}
logger.info('no local workspace found, create a new one');
const workspaceId = nanoid();
const blockSuiteWorkspace = createEmptyBlockSuiteWorkspace(
workspaceId,
id,
(_: string) => undefined
);
hashMap.set(workspaceId, blockSuiteWorkspace);
blockSuiteWorkspace.meta.setName(DEFAULT_WORKSPACE_NAME);
localStorage.setItem(kStoreKey, JSON.stringify([workspaceId]));
blockSuiteWorkspace.createPage(nanoid());
const workspace: LocalWorkspace = {
id: workspaceId,
id,
flavour: RemWorkspaceFlavour.LOCAL,
blockSuiteWorkspace: blockSuiteWorkspace,
providers: [...createLocalProviders(blockSuiteWorkspace)],
};
const persistence = new IndexeddbPersistence(
blockSuiteWorkspace.room as string,
blockSuiteWorkspace.doc
return workspace;
},
create: async ({ doc }) => {
const storage = getStorage();
!Array.isArray(storage.getItem(kStoreKey)) &&
storage.setItem(kStoreKey, []);
const data = storage.getItem(kStoreKey) as z.infer<typeof schema>;
const binary = BlockSuiteWorkspace.Y.encodeStateAsUpdateV2(doc);
const id = nanoid();
const blockSuiteWorkspace = createEmptyBlockSuiteWorkspace(
id,
(_: string) => undefined
);
BlockSuiteWorkspace.Y.applyUpdateV2(blockSuiteWorkspace.doc, binary);
const persistence = new IndexeddbPersistence(id, blockSuiteWorkspace.doc);
await persistence.whenSynced.then(() => {
persistence.destroy();
});
dataCenter.workspaces = [workspace];
}
storage.setItem(kStoreKey, [...data, id]);
console.log('create', id, storage.getItem(kStoreKey));
return id;
},
delete: async workspace => {
const storage = getStorage();
!Array.isArray(storage.getItem(kStoreKey)) &&
storage.setItem(kStoreKey, []);
const data = storage.getItem(kStoreKey) as z.infer<typeof schema>;
const idx = data.findIndex(id => id === workspace.id);
if (idx === -1) {
throw new Error('workspace not found');
}
data.splice(idx, 1);
storage.setItem(kStoreKey, [...data]);
},
list: async () => {
const storage = getStorage();
!Array.isArray(storage.getItem(kStoreKey)) &&
storage.setItem(kStoreKey, []);
const data = (
await Promise.all(
(storage.getItem(kStoreKey) as z.infer<typeof schema>).map(id =>
LocalPlugin.CRUD.get(id)
)
)
).filter(item => item !== null) as LocalWorkspace[];
if (data.length === 0) {
const blockSuiteWorkspace = createEmptyBlockSuiteWorkspace(
nanoid(),
(_: string) => undefined
);
blockSuiteWorkspace.meta.setName(DEFAULT_WORKSPACE_NAME);
await LocalPlugin.CRUD.create(blockSuiteWorkspace);
return LocalPlugin.CRUD.list();
}
return data;
},
},
PageDetail: ({ currentWorkspace, currentPageId }) => {
const page = currentWorkspace.blockSuiteWorkspace.getPage(currentPageId);

View File

@@ -1,17 +1,15 @@
import { useAtom } from 'jotai';
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
import { useRouter } from 'next/router';
import React, { useCallback } from 'react';
import {
currentWorkspaceIdAtom,
openCreateWorkspaceModalAtom,
openQuickSearchModalAtom,
openWorkspacesModalAtom,
} from '../atoms';
import { CreateWorkspaceModal } from '../components/pure/create-workspace-modal';
import QuickSearchModal from '../components/pure/quick-search-modal';
import { WorkspaceListModal } from '../components/pure/workspace-list-modal';
import { useCurrentUser } from '../hooks/current/use-current-user';
import { useCurrentWorkspace } from '../hooks/current/use-current-workspace';
import { useWorkspaces, useWorkspacesHelper } from '../hooks/use-workspaces';
import { apis } from '../shared/apis';
@@ -22,28 +20,27 @@ export function Modals() {
const [openCreateWorkspaceModal, setOpenCreateWorkspaceModal] = useAtom(
openCreateWorkspaceModalAtom
);
const [openQuickSearchModal, setOpenQuickSearchModalAtom] = useAtom(
openQuickSearchModalAtom
);
const router = useRouter();
const user = useCurrentUser();
const workspaces = useWorkspaces();
const [currentWorkspace, setCurrentWorkspace] = useCurrentWorkspace();
const { createRemLocalWorkspace } = useWorkspacesHelper();
const currentWorkspaceId = useAtomValue(currentWorkspaceIdAtom);
const setCurrentWorkspace = useSetAtom(currentWorkspaceIdAtom);
const { createLocalWorkspace } = useWorkspacesHelper();
const disableShortCut = router.pathname.startsWith('/404');
return (
<>
<WorkspaceListModal
user={user}
workspaces={workspaces}
currentWorkspaceId={currentWorkspace?.id ?? null}
currentWorkspaceId={currentWorkspaceId}
open={openWorkspacesModal}
onClose={useCallback(() => {
setOpenWorkspacesModal(false);
}, [setOpenWorkspacesModal])}
onClickWorkspace={useCallback(
workspace => {
setOpenWorkspacesModal(false);
setCurrentWorkspace(workspace.id);
router.push({
pathname: `/workspace/[workspaceId]/all`,
@@ -51,7 +48,6 @@ export function Modals() {
workspaceId: workspace.id,
},
});
setOpenWorkspacesModal(false);
},
[router, setCurrentWorkspace, setOpenWorkspacesModal]
)}
@@ -74,11 +70,11 @@ export function Modals() {
setOpenCreateWorkspaceModal(false);
}, [setOpenCreateWorkspaceModal])}
onCreate={useCallback(
name => {
const id = createRemLocalWorkspace(name);
async name => {
const id = await createLocalWorkspace(name);
setOpenCreateWorkspaceModal(false);
setOpenWorkspacesModal(false);
router.push({
return router.push({
pathname: '/workspace/[workspaceId]/all',
query: {
workspaceId: id,
@@ -86,22 +82,13 @@ export function Modals() {
});
},
[
createRemLocalWorkspace,
createLocalWorkspace,
router,
setOpenCreateWorkspaceModal,
setOpenWorkspacesModal,
]
)}
/>
{currentWorkspace?.blockSuiteWorkspace && (
<QuickSearchModal
enableShortCut={!disableShortCut}
blockSuiteWorkspace={currentWorkspace?.blockSuiteWorkspace}
open={openQuickSearchModal}
setOpen={setOpenQuickSearchModalAtom}
router={router}
/>
)}
</>
);
}

View File

@@ -24,15 +24,21 @@ export function stringToColour(str: string) {
return colour;
}
const hashMap = new Map<string, BlockSuiteWorkspace>();
export const createEmptyBlockSuiteWorkspace = (
room: string,
blobOptionsGetter?: BlobOptionsGetter
) => {
return new BlockSuiteWorkspace({
): BlockSuiteWorkspace => {
if (hashMap.has(room)) {
return hashMap.get(room) as BlockSuiteWorkspace;
}
const workspace = new BlockSuiteWorkspace({
room,
isSSR: typeof window === 'undefined',
blobOptionsGetter,
})
.register(builtInSchemas)
.register(__unstableSchemas);
hashMap.set(room, workspace);
return workspace;
};