mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-14 05:14:54 +00:00
refactor(core): move hooks to core (#5458)
* move @toeverything/hooks -> @affine/core/hooks * delete @toeverything/hooks hooks are all business-related logic and are deeply coupled with other parts. Move them into the core and then reconstruct them by feature.
This commit is contained in:
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* @vitest-environment happy-dom
|
||||
*/
|
||||
import 'fake-indexeddb/auto';
|
||||
|
||||
import { __unstableSchemas, AffineSchemas } from '@blocksuite/blocks/models';
|
||||
import { Schema, Workspace } from '@blocksuite/store';
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { initEmptyPage } from '@toeverything/infra/blocksuite';
|
||||
import { beforeEach, describe, expect, test } from 'vitest';
|
||||
|
||||
import { useBlockSuitePageMeta } from '../use-block-suite-page-meta';
|
||||
import { useBlockSuiteWorkspaceHelper } from '../use-block-suite-workspace-helper';
|
||||
|
||||
let blockSuiteWorkspace: Workspace;
|
||||
|
||||
const schema = new Schema();
|
||||
schema.register(AffineSchemas).register(__unstableSchemas);
|
||||
|
||||
beforeEach(async () => {
|
||||
blockSuiteWorkspace = new Workspace({
|
||||
id: 'test',
|
||||
schema,
|
||||
});
|
||||
await initEmptyPage(blockSuiteWorkspace.createPage({ id: 'page0' }));
|
||||
await initEmptyPage(blockSuiteWorkspace.createPage({ id: 'page1' }));
|
||||
await initEmptyPage(blockSuiteWorkspace.createPage({ id: 'page2' }));
|
||||
});
|
||||
|
||||
describe('useBlockSuiteWorkspaceHelper', () => {
|
||||
test('should create page', () => {
|
||||
expect(blockSuiteWorkspace.meta.pageMetas.length).toBe(3);
|
||||
const helperHook = renderHook(() =>
|
||||
useBlockSuiteWorkspaceHelper(blockSuiteWorkspace)
|
||||
);
|
||||
const pageMetaHook = renderHook(() =>
|
||||
useBlockSuitePageMeta(blockSuiteWorkspace)
|
||||
);
|
||||
expect(pageMetaHook.result.current.length).toBe(3);
|
||||
expect(blockSuiteWorkspace.meta.pageMetas.length).toBe(3);
|
||||
const page = helperHook.result.current.createPage('page4');
|
||||
expect(page.id).toBe('page4');
|
||||
expect(blockSuiteWorkspace.meta.pageMetas.length).toBe(4);
|
||||
pageMetaHook.rerender();
|
||||
expect(pageMetaHook.result.current.length).toBe(4);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* @vitest-environment happy-dom
|
||||
*/
|
||||
import 'fake-indexeddb/auto';
|
||||
|
||||
import { __unstableSchemas, AffineSchemas } from '@blocksuite/blocks/models';
|
||||
import { assertExists } from '@blocksuite/global/utils';
|
||||
import type { Page } from '@blocksuite/store';
|
||||
import { Schema, Workspace as BlockSuiteWorkspace } from '@blocksuite/store';
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
import { beforeEach } from 'vitest';
|
||||
|
||||
import { useBlockSuiteWorkspacePageTitle } from '../use-block-suite-workspace-page-title';
|
||||
|
||||
let blockSuiteWorkspace: BlockSuiteWorkspace;
|
||||
|
||||
const schema = new Schema();
|
||||
schema.register(AffineSchemas).register(__unstableSchemas);
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.useFakeTimers({ toFake: ['requestIdleCallback'] });
|
||||
blockSuiteWorkspace = new BlockSuiteWorkspace({ id: 'test', schema });
|
||||
const initPage = async (page: Page) => {
|
||||
await page.waitForLoaded();
|
||||
expect(page).not.toBeNull();
|
||||
assertExists(page);
|
||||
const pageBlockId = page.addBlock('affine:page', {
|
||||
title: new page.Text(''),
|
||||
});
|
||||
const frameId = page.addBlock('affine:note', {}, pageBlockId);
|
||||
page.addBlock('affine:paragraph', {}, frameId);
|
||||
};
|
||||
await initPage(blockSuiteWorkspace.createPage({ id: 'page0' }));
|
||||
await initPage(blockSuiteWorkspace.createPage({ id: 'page1' }));
|
||||
await initPage(blockSuiteWorkspace.createPage({ id: 'page2' }));
|
||||
});
|
||||
|
||||
describe('useBlockSuiteWorkspacePageTitle', () => {
|
||||
test('basic', async () => {
|
||||
const pageTitleHook = renderHook(() =>
|
||||
useBlockSuiteWorkspacePageTitle(blockSuiteWorkspace, 'page0')
|
||||
);
|
||||
expect(pageTitleHook.result.current).toBe('Untitled');
|
||||
blockSuiteWorkspace.setPageMeta('page0', { title: '1' });
|
||||
pageTitleHook.rerender();
|
||||
expect(pageTitleHook.result.current).toBe('1');
|
||||
});
|
||||
});
|
||||
29
packages/frontend/core/src/hooks/affine-async-hooks.ts
Normal file
29
packages/frontend/core/src/hooks/affine-async-hooks.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import React from 'react';
|
||||
|
||||
export type AsyncErrorHandler = (error: Error) => void;
|
||||
|
||||
/**
|
||||
* App should provide a global error handler for async callback in the root.
|
||||
*/
|
||||
export const AsyncCallbackContext = React.createContext<AsyncErrorHandler>(
|
||||
e => {
|
||||
console.error(e);
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Translate async function to sync function and handle error automatically.
|
||||
* Only accept void function, return data here is meaningless.
|
||||
*/
|
||||
export function useAsyncCallback<T extends any[]>(
|
||||
callback: (...args: T) => Promise<void>,
|
||||
deps: any[]
|
||||
): (...args: T) => void {
|
||||
const handleAsyncError = React.useContext(AsyncCallbackContext);
|
||||
return React.useCallback(
|
||||
(...args: any) => {
|
||||
callback(...args).catch(e => handleAsyncError(e));
|
||||
},
|
||||
[...deps] // eslint-disable-line react-hooks/exhaustive-deps
|
||||
);
|
||||
}
|
||||
@@ -3,10 +3,10 @@ import {
|
||||
type AllPageListConfig,
|
||||
FavoriteTag,
|
||||
} from '@affine/component/page-list';
|
||||
import { useBlockSuitePageMeta } from '@affine/core/hooks/use-block-suite-page-meta';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { waitForCurrentWorkspaceAtom } from '@affine/workspace/atom';
|
||||
import type { PageMeta } from '@blocksuite/store';
|
||||
import { useBlockSuitePageMeta } from '@toeverything/hooks/use-block-suite-page-meta';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useAsyncCallback } from '@toeverything/hooks/affine-async-hooks';
|
||||
import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
|
||||
import {
|
||||
useBlockSuitePageMeta,
|
||||
usePageMetaHelper,
|
||||
} from '@toeverything/hooks/use-block-suite-page-meta';
|
||||
import { useBlockSuiteWorkspaceHelper } from '@toeverything/hooks/use-block-suite-workspace-helper';
|
||||
} from '@affine/core/hooks/use-block-suite-page-meta';
|
||||
import { useBlockSuiteWorkspaceHelper } from '@affine/core/hooks/use-block-suite-workspace-helper';
|
||||
import { useAtomValue, useSetAtom } from 'jotai';
|
||||
import { useCallback } from 'react';
|
||||
import { applyUpdate, encodeStateAsUpdate } from 'yjs';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
|
||||
import { LOCALES, useI18N } from '@affine/i18n';
|
||||
import { useAsyncCallback } from '@toeverything/hooks/affine-async-hooks';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
export function useLanguageHelper() {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { toast } from '@affine/component';
|
||||
import { usePageMetaHelper } from '@affine/core/hooks/use-block-suite-page-meta';
|
||||
import { WorkspaceFlavour } from '@affine/env/workspace';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { waitForCurrentWorkspaceAtom } from '@affine/workspace/atom';
|
||||
import { assertExists } from '@blocksuite/global/utils';
|
||||
import { EdgelessIcon, HistoryIcon, PageIcon } from '@blocksuite/icons';
|
||||
import { usePageMetaHelper } from '@toeverything/hooks/use-block-suite-page-meta';
|
||||
import {
|
||||
PreconditionStrategy,
|
||||
registerAffineCommand,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { toast } from '@affine/component';
|
||||
import type { DraggableTitleCellData } from '@affine/component/page-list';
|
||||
import { usePageMetaHelper } from '@affine/core/hooks/use-block-suite-page-meta';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { waitForCurrentWorkspaceAtom } from '@affine/workspace/atom';
|
||||
import type { DragEndEvent, UniqueIdentifier } from '@dnd-kit/core';
|
||||
import { usePageMetaHelper } from '@toeverything/hooks/use-block-suite-page-meta';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useBlockSuiteWorkspacePage } from '@affine/core/hooks/use-block-suite-workspace-page';
|
||||
import { waitForCurrentWorkspaceAtom } from '@affine/workspace/atom';
|
||||
import { useBlockSuiteWorkspacePage } from '@toeverything/hooks/use-block-suite-workspace-page';
|
||||
import { useAtomValue } from 'jotai';
|
||||
|
||||
import { currentPageIdAtom } from '../../atoms/mode';
|
||||
|
||||
198
packages/frontend/core/src/hooks/use-app-updater.ts
Normal file
198
packages/frontend/core/src/hooks/use-app-updater.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
import { apis, events, type UpdateMeta } from '@affine/electron-api';
|
||||
import { isBrowser } from '@affine/env/constant';
|
||||
import { appSettingAtom } from '@toeverything/infra/atom';
|
||||
import { atom, useAtom, useAtomValue } from 'jotai';
|
||||
import { atomWithObservable, atomWithStorage } from 'jotai/utils';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
import { useAsyncCallback } from './affine-async-hooks';
|
||||
|
||||
function rpcToObservable<
|
||||
T,
|
||||
H extends () => Promise<T>,
|
||||
E extends (callback: (t: T) => void) => () => void,
|
||||
>(
|
||||
initialValue: T | null,
|
||||
{
|
||||
event,
|
||||
handler,
|
||||
onSubscribe,
|
||||
}: {
|
||||
event?: E;
|
||||
handler?: H;
|
||||
onSubscribe?: () => void;
|
||||
}
|
||||
): Observable<T | null> {
|
||||
return new Observable<T | null>(subscriber => {
|
||||
subscriber.next(initialValue);
|
||||
onSubscribe?.();
|
||||
if (!isBrowser || !environment.isDesktop || !event) {
|
||||
subscriber.complete();
|
||||
return;
|
||||
}
|
||||
handler?.()
|
||||
.then(t => {
|
||||
subscriber.next(t);
|
||||
})
|
||||
.catch(err => {
|
||||
subscriber.error(err);
|
||||
});
|
||||
return event(t => {
|
||||
subscriber.next(t);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// download complete, ready to install
|
||||
export const updateReadyAtom = atomWithObservable(() => {
|
||||
return rpcToObservable(null as UpdateMeta | null, {
|
||||
event: events?.updater.onUpdateReady,
|
||||
});
|
||||
});
|
||||
|
||||
// update available, but not downloaded yet
|
||||
export const updateAvailableAtom = atomWithObservable(() => {
|
||||
return rpcToObservable(null as UpdateMeta | null, {
|
||||
event: events?.updater.onUpdateAvailable,
|
||||
});
|
||||
});
|
||||
|
||||
// downloading new update
|
||||
export const downloadProgressAtom = atomWithObservable(() => {
|
||||
return rpcToObservable(null as number | null, {
|
||||
event: events?.updater.onDownloadProgress,
|
||||
});
|
||||
});
|
||||
|
||||
export const changelogCheckedAtom = atomWithStorage<Record<string, boolean>>(
|
||||
'affine:client-changelog-checked',
|
||||
{}
|
||||
);
|
||||
|
||||
export const checkingForUpdatesAtom = atom(false);
|
||||
|
||||
export const currentVersionAtom = atom(async () => {
|
||||
if (!isBrowser) {
|
||||
return null;
|
||||
}
|
||||
const currentVersion = await apis?.updater.currentVersion();
|
||||
return currentVersion;
|
||||
});
|
||||
|
||||
const currentChangelogUnreadAtom = atom(
|
||||
async get => {
|
||||
if (!isBrowser) {
|
||||
return false;
|
||||
}
|
||||
const mapping = get(changelogCheckedAtom);
|
||||
const currentVersion = await get(currentVersionAtom);
|
||||
if (currentVersion) {
|
||||
return !mapping[currentVersion];
|
||||
}
|
||||
return false;
|
||||
},
|
||||
async (get, set, v: boolean) => {
|
||||
const currentVersion = await get(currentVersionAtom);
|
||||
if (currentVersion) {
|
||||
set(changelogCheckedAtom, mapping => {
|
||||
return {
|
||||
...mapping,
|
||||
[currentVersion]: v,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const useAppUpdater = () => {
|
||||
const [appQuitting, setAppQuitting] = useState(false);
|
||||
const updateReady = useAtomValue(updateReadyAtom);
|
||||
const [setting, setSetting] = useAtom(appSettingAtom);
|
||||
const downloadProgress = useAtomValue(downloadProgressAtom);
|
||||
const [changelogUnread, setChangelogUnread] = useAtom(
|
||||
currentChangelogUnreadAtom
|
||||
);
|
||||
|
||||
const [checkingForUpdates, setCheckingForUpdates] = useAtom(
|
||||
checkingForUpdatesAtom
|
||||
);
|
||||
|
||||
const quitAndInstall = useCallback(() => {
|
||||
if (updateReady) {
|
||||
setAppQuitting(true);
|
||||
apis?.updater.quitAndInstall().catch(err => {
|
||||
// TODO: add error toast here
|
||||
console.error(err);
|
||||
});
|
||||
}
|
||||
}, [updateReady]);
|
||||
|
||||
const checkForUpdates = useCallback(async () => {
|
||||
if (checkingForUpdates) {
|
||||
return;
|
||||
}
|
||||
setCheckingForUpdates(true);
|
||||
try {
|
||||
const updateInfo = await apis?.updater.checkForUpdates();
|
||||
return updateInfo?.version ?? false;
|
||||
} catch (err) {
|
||||
console.error('Error checking for updates:', err);
|
||||
return null;
|
||||
} finally {
|
||||
setCheckingForUpdates(false);
|
||||
}
|
||||
}, [checkingForUpdates, setCheckingForUpdates]);
|
||||
|
||||
const downloadUpdate = useCallback(() => {
|
||||
apis?.updater.downloadUpdate().catch(err => {
|
||||
console.error('Error downloading update:', err);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const toggleAutoDownload = useCallback(
|
||||
(enable: boolean) => {
|
||||
setSetting({
|
||||
autoDownloadUpdate: enable,
|
||||
});
|
||||
},
|
||||
[setSetting]
|
||||
);
|
||||
|
||||
const toggleAutoCheck = useCallback(
|
||||
(enable: boolean) => {
|
||||
setSetting({
|
||||
autoCheckUpdate: enable,
|
||||
});
|
||||
},
|
||||
[setSetting]
|
||||
);
|
||||
|
||||
const openChangelog = useAsyncCallback(async () => {
|
||||
window.open(runtimeConfig.changelogUrl, '_blank');
|
||||
await setChangelogUnread(true);
|
||||
}, [setChangelogUnread]);
|
||||
|
||||
const dismissChangelog = useAsyncCallback(async () => {
|
||||
await setChangelogUnread(true);
|
||||
}, [setChangelogUnread]);
|
||||
|
||||
return {
|
||||
quitAndInstall,
|
||||
checkForUpdates,
|
||||
downloadUpdate,
|
||||
toggleAutoDownload,
|
||||
toggleAutoCheck,
|
||||
appQuitting,
|
||||
checkingForUpdates,
|
||||
autoCheck: setting.autoCheckUpdate,
|
||||
autoDownload: setting.autoDownloadUpdate,
|
||||
changelogUnread,
|
||||
openChangelog,
|
||||
dismissChangelog,
|
||||
updateReady,
|
||||
updateAvailable: useAtomValue(updateAvailableAtom),
|
||||
downloadProgress,
|
||||
currentVersion: useAtomValue(currentVersionAtom),
|
||||
};
|
||||
};
|
||||
15
packages/frontend/core/src/hooks/use-block-suite-editor.ts
Normal file
15
packages/frontend/core/src/hooks/use-block-suite-editor.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { AffineEditorContainer } from '@blocksuite/presets';
|
||||
import { atom, type SetStateAction, useAtom } from 'jotai';
|
||||
|
||||
const activeEditorContainerAtom = atom<AffineEditorContainer | null>(null);
|
||||
|
||||
export function useActiveBlocksuiteEditor(): [
|
||||
AffineEditorContainer | null,
|
||||
React.Dispatch<SetStateAction<AffineEditorContainer | null>>,
|
||||
] {
|
||||
const [editorContainer, setEditorContainer] = useAtom(
|
||||
activeEditorContainerAtom
|
||||
);
|
||||
|
||||
return [editorContainer, setEditorContainer];
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { PageBlockModel } from '@blocksuite/blocks';
|
||||
import { assertExists } from '@blocksuite/global/utils';
|
||||
import type { PageMeta, Workspace } from '@blocksuite/store';
|
||||
import type { Atom } from 'jotai';
|
||||
import { atom, useAtomValue } from 'jotai';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
const weakMap = new WeakMap<Workspace, Atom<PageMeta[]>>();
|
||||
|
||||
export function useBlockSuitePageMeta(
|
||||
blockSuiteWorkspace: Workspace
|
||||
): PageMeta[] {
|
||||
if (!weakMap.has(blockSuiteWorkspace)) {
|
||||
const baseAtom = atom<PageMeta[]>(blockSuiteWorkspace.meta.pageMetas);
|
||||
weakMap.set(blockSuiteWorkspace, baseAtom);
|
||||
baseAtom.onMount = set => {
|
||||
set(blockSuiteWorkspace.meta.pageMetas);
|
||||
const dispose = blockSuiteWorkspace.meta.pageMetasUpdated.on(() => {
|
||||
set(blockSuiteWorkspace.meta.pageMetas);
|
||||
});
|
||||
return () => {
|
||||
dispose.dispose();
|
||||
};
|
||||
};
|
||||
}
|
||||
return useAtomValue(weakMap.get(blockSuiteWorkspace) as Atom<PageMeta[]>);
|
||||
}
|
||||
|
||||
export function usePageMetaHelper(blockSuiteWorkspace: Workspace) {
|
||||
return useMemo(
|
||||
() => ({
|
||||
setPageTitle: (pageId: string, newTitle: string) => {
|
||||
const page = blockSuiteWorkspace.getPage(pageId);
|
||||
assertExists(page);
|
||||
const pageBlock = page
|
||||
.getBlockByFlavour('affine:page')
|
||||
.at(0) as PageBlockModel;
|
||||
assertExists(pageBlock);
|
||||
page.transact(() => {
|
||||
pageBlock.title.delete(0, pageBlock.title.length);
|
||||
pageBlock.title.insert(newTitle, 0);
|
||||
});
|
||||
blockSuiteWorkspace.meta.setPageMeta(pageId, { title: newTitle });
|
||||
},
|
||||
setPageReadonly: (pageId: string, readonly: boolean) => {
|
||||
const page = blockSuiteWorkspace.getPage(pageId);
|
||||
assertExists(page);
|
||||
page.awarenessStore.setReadonly(page, readonly);
|
||||
},
|
||||
setPageMeta: (pageId: string, pageMeta: Partial<PageMeta>) => {
|
||||
blockSuiteWorkspace.meta.setPageMeta(pageId, pageMeta);
|
||||
},
|
||||
getPageMeta: (pageId: string) => {
|
||||
return blockSuiteWorkspace.meta.getPageMeta(pageId);
|
||||
},
|
||||
}),
|
||||
[blockSuiteWorkspace]
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { Page, Workspace } from '@blocksuite/store';
|
||||
import { type Atom, atom, useAtomValue } from 'jotai';
|
||||
|
||||
import { useBlockSuiteWorkspacePage } from './use-block-suite-workspace-page';
|
||||
|
||||
const weakMap = new WeakMap<Page, Atom<string[]>>();
|
||||
function getPageReferences(page: Page): string[] {
|
||||
return Object.values(
|
||||
page.workspace.indexer.backlink.linkIndexMap[page.id] ?? {}
|
||||
).flatMap(linkNodes => linkNodes.map(linkNode => linkNode.pageId));
|
||||
}
|
||||
|
||||
const getPageReferencesAtom = (page: Page | null) => {
|
||||
if (!page) {
|
||||
return atom([]);
|
||||
}
|
||||
|
||||
if (!weakMap.has(page)) {
|
||||
const baseAtom = atom<string[]>([]);
|
||||
baseAtom.onMount = set => {
|
||||
const disposables = [
|
||||
page.slots.ready.on(() => {
|
||||
set(getPageReferences(page));
|
||||
}),
|
||||
page.workspace.indexer.backlink.slots.indexUpdated.on(() => {
|
||||
set(getPageReferences(page));
|
||||
}),
|
||||
];
|
||||
set(getPageReferences(page));
|
||||
return () => {
|
||||
disposables.forEach(disposable => disposable.dispose());
|
||||
};
|
||||
};
|
||||
weakMap.set(page, baseAtom);
|
||||
}
|
||||
return weakMap.get(page) as Atom<string[]>;
|
||||
};
|
||||
|
||||
export function useBlockSuitePageReferences(
|
||||
blockSuiteWorkspace: Workspace,
|
||||
pageId: string
|
||||
): string[] {
|
||||
const page = useBlockSuiteWorkspacePage(blockSuiteWorkspace, pageId);
|
||||
return useAtomValue(getPageReferencesAtom(page));
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { Page, Workspace } from '@blocksuite/store';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
export function useBlockSuiteWorkspaceHelper(blockSuiteWorkspace: Workspace) {
|
||||
return useMemo(
|
||||
() => ({
|
||||
createPage: (pageId?: string): Page => {
|
||||
return blockSuiteWorkspace.createPage({ id: pageId });
|
||||
},
|
||||
}),
|
||||
[blockSuiteWorkspace]
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { assertExists } from '@blocksuite/global/utils';
|
||||
import type { Workspace } from '@blocksuite/store';
|
||||
import type { Atom } from 'jotai';
|
||||
import { atom, useAtomValue } from 'jotai';
|
||||
|
||||
const weakMap = new WeakMap<Workspace, Map<string, Atom<string>>>();
|
||||
|
||||
function getAtom(w: Workspace, pageId: string): Atom<string> {
|
||||
if (!weakMap.has(w)) {
|
||||
weakMap.set(w, new Map());
|
||||
}
|
||||
const map = weakMap.get(w);
|
||||
assertExists(map);
|
||||
if (!map.has(pageId)) {
|
||||
const baseAtom = atom<string>(w.getPage(pageId)?.meta.title || 'Untitled');
|
||||
baseAtom.onMount = set => {
|
||||
const disposable = w.meta.pageMetasUpdated.on(() => {
|
||||
const page = w.getPage(pageId);
|
||||
set(page?.meta.title || 'Untitled');
|
||||
});
|
||||
return () => {
|
||||
disposable.dispose();
|
||||
};
|
||||
};
|
||||
map.set(pageId, baseAtom);
|
||||
return baseAtom;
|
||||
} else {
|
||||
return map.get(pageId) as Atom<string>;
|
||||
}
|
||||
}
|
||||
|
||||
export function useBlockSuiteWorkspacePageTitle(
|
||||
blockSuiteWorkspace: Workspace,
|
||||
pageId: string
|
||||
) {
|
||||
const titleAtom = getAtom(blockSuiteWorkspace, pageId);
|
||||
assertExists(titleAtom);
|
||||
return useAtomValue(titleAtom);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { DebugLogger } from '@affine/debug';
|
||||
import { DisposableGroup } from '@blocksuite/global/utils';
|
||||
import type { Page, Workspace } from '@blocksuite/store';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
const logger = new DebugLogger('use-block-suite-workspace-page');
|
||||
|
||||
export function useBlockSuiteWorkspacePage(
|
||||
blockSuiteWorkspace: Workspace,
|
||||
pageId: string | null
|
||||
): Page | null {
|
||||
const [page, setPage] = useState(
|
||||
pageId ? blockSuiteWorkspace.getPage(pageId) : null
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const group = new DisposableGroup();
|
||||
group.add(
|
||||
blockSuiteWorkspace.slots.pageAdded.on(id => {
|
||||
if (pageId === id) {
|
||||
setPage(blockSuiteWorkspace.getPage(id));
|
||||
}
|
||||
})
|
||||
);
|
||||
group.add(
|
||||
blockSuiteWorkspace.slots.pageRemoved.on(id => {
|
||||
if (pageId === id) {
|
||||
setPage(null);
|
||||
}
|
||||
})
|
||||
);
|
||||
return () => {
|
||||
group.dispose();
|
||||
};
|
||||
}, [blockSuiteWorkspace, pageId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (page && !page.loaded) {
|
||||
page.load().catch(err => {
|
||||
logger.error('Failed to load page', err);
|
||||
});
|
||||
}
|
||||
}, [page]);
|
||||
|
||||
return page;
|
||||
}
|
||||
15
packages/frontend/core/src/hooks/use-data-source-status.ts
Normal file
15
packages/frontend/core/src/hooks/use-data-source-status.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { useCallback, useSyncExternalStore } from 'react';
|
||||
import type { DataSourceAdapter, Status } from 'y-provider';
|
||||
|
||||
type UIStatus =
|
||||
| Status
|
||||
| {
|
||||
type: 'unknown';
|
||||
};
|
||||
|
||||
export function useDataSourceStatus(provider: DataSourceAdapter): UIStatus {
|
||||
return useSyncExternalStore(
|
||||
provider.subscribeStatusChange,
|
||||
useCallback(() => provider.status, [provider])
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useBlockSuitePageMeta } from '@affine/core/hooks/use-block-suite-page-meta';
|
||||
import type { GetPageInfoById } from '@affine/env/page-info';
|
||||
import type { Workspace } from '@blocksuite/store';
|
||||
import { useBlockSuitePageMeta } from '@toeverything/hooks/use-block-suite-page-meta';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
|
||||
|
||||
73
packages/frontend/core/src/hooks/use-is-tiny-screen.ts
Normal file
73
packages/frontend/core/src/hooks/use-is-tiny-screen.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import 'foxact/use-debounced-state';
|
||||
|
||||
import { debounce } from 'lodash-es';
|
||||
import { type RefObject, useEffect, useState } from 'react';
|
||||
|
||||
export function useIsTinyScreen({
|
||||
container,
|
||||
leftStatic,
|
||||
leftSlot,
|
||||
centerDom,
|
||||
rightSlot,
|
||||
}: {
|
||||
container: HTMLElement | null;
|
||||
leftStatic: RefObject<HTMLElement>;
|
||||
leftSlot: RefObject<HTMLElement>[];
|
||||
centerDom: RefObject<HTMLElement>;
|
||||
rightSlot: RefObject<HTMLElement>[];
|
||||
}) {
|
||||
const [isTinyScreen, setIsTinyScreen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
const handleResize = debounce(() => {
|
||||
if (!centerDom.current) {
|
||||
return;
|
||||
}
|
||||
const leftStaticWidth = leftStatic.current?.clientWidth || 0;
|
||||
const leftSlotWidth = leftSlot.reduce((accWidth, dom) => {
|
||||
return accWidth + (dom.current?.clientWidth || 0);
|
||||
}, 0);
|
||||
|
||||
const rightSlotWidth = rightSlot.reduce((accWidth, dom) => {
|
||||
return accWidth + (dom.current?.clientWidth || 0);
|
||||
}, 0);
|
||||
|
||||
if (!leftSlotWidth && !rightSlotWidth) {
|
||||
if (isTinyScreen) {
|
||||
setIsTinyScreen(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const centerRect = centerDom.current.getBoundingClientRect();
|
||||
|
||||
if (
|
||||
leftStaticWidth + leftSlotWidth + containerRect.left >=
|
||||
centerRect.left ||
|
||||
containerRect.right - centerRect.right <= rightSlotWidth
|
||||
) {
|
||||
setIsTinyScreen(true);
|
||||
} else {
|
||||
setIsTinyScreen(false);
|
||||
}
|
||||
}, 100);
|
||||
|
||||
handleResize();
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
handleResize();
|
||||
});
|
||||
|
||||
resizeObserver.observe(container);
|
||||
|
||||
return () => {
|
||||
resizeObserver.unobserve(container);
|
||||
};
|
||||
}, [centerDom, isTinyScreen, leftSlot, leftStatic, container, rightSlot]);
|
||||
|
||||
return isTinyScreen;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
|
||||
import { type SubscriptionQuery, subscriptionQuery } from '@affine/graphql';
|
||||
import { useAsyncCallback } from '@toeverything/hooks/affine-async-hooks';
|
||||
|
||||
import { useSelfHosted } from './affine/use-server-config';
|
||||
import { useQuery } from './use-query';
|
||||
|
||||
40
packages/frontend/core/src/hooks/use-workspace-blob.ts
Normal file
40
packages/frontend/core/src/hooks/use-workspace-blob.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { workspaceManagerAtom } from '@affine/workspace/atom';
|
||||
import type { WorkspaceMetadata } from '@affine/workspace/metadata';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export function useWorkspaceBlobObjectUrl(
|
||||
meta?: WorkspaceMetadata,
|
||||
blobKey?: string | null
|
||||
) {
|
||||
const workspaceManager = useAtomValue(workspaceManagerAtom);
|
||||
|
||||
const [blob, setBlob] = useState<string | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
setBlob(undefined);
|
||||
if (!blobKey || !meta) {
|
||||
return;
|
||||
}
|
||||
let canceled = false;
|
||||
let objectUrl: string = '';
|
||||
workspaceManager
|
||||
.getWorkspaceBlob(meta, blobKey)
|
||||
.then(blob => {
|
||||
if (blob && !canceled) {
|
||||
objectUrl = URL.createObjectURL(blob);
|
||||
setBlob(objectUrl);
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('get workspace blob error: ' + err);
|
||||
});
|
||||
|
||||
return () => {
|
||||
canceled = true;
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
};
|
||||
}, [meta, blobKey, workspaceManager]);
|
||||
|
||||
return blob;
|
||||
}
|
||||
38
packages/frontend/core/src/hooks/use-workspace-info.ts
Normal file
38
packages/frontend/core/src/hooks/use-workspace-info.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { WorkspaceMetadata } from '@affine/workspace';
|
||||
import { workspaceManagerAtom } from '@affine/workspace/atom';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { useWorkspaceBlobObjectUrl } from './use-workspace-blob';
|
||||
|
||||
export function useWorkspaceInfo(meta: WorkspaceMetadata) {
|
||||
const workspaceManager = useAtomValue(workspaceManagerAtom);
|
||||
|
||||
const [information, setInformation] = useState(
|
||||
() => workspaceManager.list.getInformation(meta).info
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const information = workspaceManager.list.getInformation(meta);
|
||||
|
||||
setInformation(information.info);
|
||||
return information.onUpdated.on(info => {
|
||||
setInformation(info);
|
||||
}).dispose;
|
||||
}, [meta, workspaceManager]);
|
||||
|
||||
return information;
|
||||
}
|
||||
|
||||
export function useWorkspaceName(meta: WorkspaceMetadata) {
|
||||
const information = useWorkspaceInfo(meta);
|
||||
|
||||
return information?.name;
|
||||
}
|
||||
|
||||
export function useWorkspaceAvatar(meta: WorkspaceMetadata) {
|
||||
const information = useWorkspaceInfo(meta);
|
||||
const avatar = useWorkspaceBlobObjectUrl(meta, information?.avatar);
|
||||
|
||||
return avatar;
|
||||
}
|
||||
34
packages/frontend/core/src/hooks/use-workspace-status.ts
Normal file
34
packages/frontend/core/src/hooks/use-workspace-status.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { Workspace, WorkspaceStatus } from '@affine/workspace';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export function useWorkspaceStatus<
|
||||
Selector extends ((status: WorkspaceStatus) => any) | undefined | null,
|
||||
Status = Selector extends (status: WorkspaceStatus) => any
|
||||
? ReturnType<Selector>
|
||||
: WorkspaceStatus,
|
||||
>(workspace?: Workspace | null, selector?: Selector): Status | null {
|
||||
// avoid re-render when selector is changed
|
||||
const [cachedSelector] = useState(() => selector);
|
||||
|
||||
const [status, setStatus] = useState<Status | null>(() => {
|
||||
if (!workspace) {
|
||||
return null;
|
||||
}
|
||||
return cachedSelector ? cachedSelector(workspace.status) : workspace.status;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!workspace) {
|
||||
setStatus(null);
|
||||
return;
|
||||
}
|
||||
setStatus(
|
||||
cachedSelector ? cachedSelector(workspace.status) : workspace.status
|
||||
);
|
||||
return workspace.onStatusChange.on(status =>
|
||||
setStatus(cachedSelector ? cachedSelector(status) : status)
|
||||
).dispose;
|
||||
}, [cachedSelector, workspace]);
|
||||
|
||||
return status;
|
||||
}
|
||||
28
packages/frontend/core/src/hooks/use-workspace.ts
Normal file
28
packages/frontend/core/src/hooks/use-workspace.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { Workspace } from '@affine/workspace';
|
||||
import { workspaceManagerAtom } from '@affine/workspace/atom';
|
||||
import type { WorkspaceMetadata } from '@affine/workspace/metadata';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
/**
|
||||
* definitely be careful when using this hook, open workspace is a heavy operation
|
||||
*/
|
||||
export function useWorkspace(meta?: WorkspaceMetadata | null) {
|
||||
const workspaceManager = useAtomValue(workspaceManagerAtom);
|
||||
|
||||
const [workspace, setWorkspace] = useState<Workspace | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!meta) {
|
||||
setWorkspace(null); // set to null if meta is null or undefined
|
||||
return;
|
||||
}
|
||||
const ref = workspaceManager.use(meta);
|
||||
setWorkspace(ref.workspace);
|
||||
return () => {
|
||||
ref.release();
|
||||
};
|
||||
}, [meta, workspaceManager]);
|
||||
|
||||
return workspace;
|
||||
}
|
||||
Reference in New Issue
Block a user