mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-14 21:27:20 +00:00
refactor(component): make component pure (#5427)
This commit is contained in:
@@ -42,8 +42,6 @@
|
||||
"@radix-ui/react-toast": "^1.1.5",
|
||||
"@radix-ui/react-toolbar": "^1.0.4",
|
||||
"@radix-ui/react-tooltip": "^1.0.7",
|
||||
"@toeverything/hooks": "workspace:*",
|
||||
"@toeverything/infra": "workspace:*",
|
||||
"@toeverything/theme": "^0.7.24",
|
||||
"@vanilla-extract/dynamic": "^2.0.3",
|
||||
"bytes": "^3.1.2",
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
import { Unreachable } from '@affine/env/constant';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { CloseIcon, NewIcon, ResetIcon } from '@blocksuite/icons';
|
||||
import { useAppUpdater } from '@toeverything/hooks/use-app-updater';
|
||||
import clsx from 'clsx';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
|
||||
import { Tooltip } from '../../../ui/tooltip';
|
||||
import * as styles from './index.css';
|
||||
|
||||
export interface AddPageButtonPureProps {
|
||||
onClickUpdate: () => void;
|
||||
onDismissCurrentChangelog: () => void;
|
||||
currentChangelogUnread: boolean;
|
||||
export interface AddPageButtonProps {
|
||||
onQuitAndInstall: () => void;
|
||||
onDownloadUpdate: () => void;
|
||||
onDismissChangelog: () => void;
|
||||
onOpenChangelog: () => void;
|
||||
changelogUnread: boolean;
|
||||
updateReady: boolean;
|
||||
updateAvailable: {
|
||||
version: string;
|
||||
@@ -33,8 +34,8 @@ interface ButtonContentProps {
|
||||
autoDownload: boolean;
|
||||
downloadProgress: number | null;
|
||||
appQuitting: boolean;
|
||||
currentChangelogUnread: boolean;
|
||||
onDismissCurrentChangelog: () => void;
|
||||
changelogUnread: boolean;
|
||||
onDismissChangelog: () => void;
|
||||
}
|
||||
|
||||
function DownloadUpdate({ updateAvailable }: ButtonContentProps) {
|
||||
@@ -114,14 +115,14 @@ function OpenDownloadPage({ updateAvailable }: ButtonContentProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function WhatsNew({ onDismissCurrentChangelog }: ButtonContentProps) {
|
||||
function WhatsNew({ onDismissChangelog }: ButtonContentProps) {
|
||||
const t = useAFFiNEI18N();
|
||||
const onClickClose: React.MouseEventHandler = useCallback(
|
||||
e => {
|
||||
onDismissCurrentChangelog();
|
||||
onDismissChangelog();
|
||||
e.stopPropagation();
|
||||
},
|
||||
[onDismissCurrentChangelog]
|
||||
[onDismissChangelog]
|
||||
);
|
||||
return (
|
||||
<>
|
||||
@@ -149,42 +150,76 @@ const getButtonContentRenderer = (props: ButtonContentProps) => {
|
||||
}
|
||||
} else if (props.updateAvailable && !props.updateAvailable?.allowAutoUpdate) {
|
||||
return OpenDownloadPage;
|
||||
} else if (props.currentChangelogUnread) {
|
||||
} else if (props.changelogUnread) {
|
||||
return WhatsNew;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export function AppUpdaterButtonPure({
|
||||
export function AppUpdaterButton({
|
||||
updateReady,
|
||||
onClickUpdate,
|
||||
onDismissCurrentChangelog,
|
||||
currentChangelogUnread,
|
||||
changelogUnread,
|
||||
onDismissChangelog,
|
||||
onDownloadUpdate,
|
||||
onQuitAndInstall,
|
||||
onOpenChangelog,
|
||||
updateAvailable,
|
||||
autoDownload,
|
||||
downloadProgress,
|
||||
appQuitting,
|
||||
className,
|
||||
style,
|
||||
}: AddPageButtonPureProps) {
|
||||
}: AddPageButtonProps) {
|
||||
const handleClick = useCallback(() => {
|
||||
if (updateReady) {
|
||||
onQuitAndInstall();
|
||||
} else if (updateAvailable) {
|
||||
if (updateAvailable.allowAutoUpdate) {
|
||||
if (autoDownload) {
|
||||
// wait for download to finish
|
||||
} else {
|
||||
onDownloadUpdate();
|
||||
}
|
||||
} else {
|
||||
window.open(
|
||||
`https://github.com/toeverything/AFFiNE/releases/tag/v${updateAvailable.version}`,
|
||||
'_blank'
|
||||
);
|
||||
}
|
||||
} else if (changelogUnread) {
|
||||
window.open(runtimeConfig.changelogUrl, '_blank');
|
||||
onOpenChangelog();
|
||||
} else {
|
||||
throw new Unreachable();
|
||||
}
|
||||
}, [
|
||||
updateReady,
|
||||
updateAvailable,
|
||||
changelogUnread,
|
||||
onQuitAndInstall,
|
||||
autoDownload,
|
||||
onDownloadUpdate,
|
||||
onOpenChangelog,
|
||||
]);
|
||||
|
||||
const contentProps = useMemo(
|
||||
() => ({
|
||||
updateReady,
|
||||
updateAvailable,
|
||||
currentChangelogUnread,
|
||||
changelogUnread,
|
||||
autoDownload,
|
||||
downloadProgress,
|
||||
appQuitting,
|
||||
onDismissCurrentChangelog,
|
||||
onDismissChangelog,
|
||||
}),
|
||||
[
|
||||
updateReady,
|
||||
updateAvailable,
|
||||
currentChangelogUnread,
|
||||
changelogUnread,
|
||||
autoDownload,
|
||||
downloadProgress,
|
||||
appQuitting,
|
||||
onDismissCurrentChangelog,
|
||||
onDismissChangelog,
|
||||
]
|
||||
);
|
||||
|
||||
@@ -222,6 +257,10 @@ export function AppUpdaterButtonPure({
|
||||
updateReady,
|
||||
]);
|
||||
|
||||
if (!updateAvailable && !changelogUnread) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return wrapWithTooltip(
|
||||
<button
|
||||
style={style}
|
||||
@@ -229,7 +268,7 @@ export function AppUpdaterButtonPure({
|
||||
data-has-update={!!updateAvailable}
|
||||
data-updating={appQuitting}
|
||||
data-disabled={disabled}
|
||||
onClick={onClickUpdate}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{ContentComponent ? <ContentComponent {...contentProps} /> : null}
|
||||
<div className={styles.particles} aria-hidden="true"></div>
|
||||
@@ -238,77 +277,3 @@ export function AppUpdaterButtonPure({
|
||||
updateAvailable?.version
|
||||
);
|
||||
}
|
||||
|
||||
// Although it is called an input, it is actually a button.
|
||||
export function AppUpdaterButton({
|
||||
className,
|
||||
style,
|
||||
}: {
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}) {
|
||||
const {
|
||||
quitAndInstall,
|
||||
appQuitting,
|
||||
autoDownload,
|
||||
downloadUpdate,
|
||||
readChangelog,
|
||||
changelogUnread,
|
||||
updateReady,
|
||||
updateAvailable,
|
||||
downloadProgress,
|
||||
currentVersion,
|
||||
} = useAppUpdater();
|
||||
|
||||
const handleClickUpdate = useCallback(() => {
|
||||
if (updateReady) {
|
||||
quitAndInstall();
|
||||
} else if (updateAvailable) {
|
||||
if (updateAvailable.allowAutoUpdate) {
|
||||
if (autoDownload) {
|
||||
// wait for download to finish
|
||||
} else {
|
||||
downloadUpdate();
|
||||
}
|
||||
} else {
|
||||
window.open(
|
||||
`https://github.com/toeverything/AFFiNE/releases/tag/v${currentVersion}`,
|
||||
'_blank'
|
||||
);
|
||||
}
|
||||
} else if (changelogUnread) {
|
||||
window.open(runtimeConfig.changelogUrl, '_blank');
|
||||
readChangelog();
|
||||
} else {
|
||||
throw new Unreachable();
|
||||
}
|
||||
}, [
|
||||
updateReady,
|
||||
updateAvailable,
|
||||
changelogUnread,
|
||||
quitAndInstall,
|
||||
autoDownload,
|
||||
downloadUpdate,
|
||||
currentVersion,
|
||||
readChangelog,
|
||||
]);
|
||||
|
||||
if (!updateAvailable && !changelogUnread) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<AppUpdaterButtonPure
|
||||
appQuitting={appQuitting}
|
||||
autoDownload={autoDownload}
|
||||
updateReady={!!updateReady}
|
||||
onClickUpdate={handleClickUpdate}
|
||||
onDismissCurrentChangelog={readChangelog}
|
||||
currentChangelogUnread={changelogUnread}
|
||||
updateAvailable={updateAvailable}
|
||||
downloadProgress={downloadProgress}
|
||||
className={className}
|
||||
style={style}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,8 +3,6 @@ import { WorkspaceFlavour } from '@affine/env/workspace';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import type { WorkspaceMetadata } from '@affine/workspace';
|
||||
import { CollaborationIcon, SettingsIcon } from '@blocksuite/icons';
|
||||
import { useWorkspaceBlobObjectUrl } from '@toeverything/hooks/use-workspace-blob';
|
||||
import { useWorkspaceInfo } from '@toeverything/hooks/use-workspace-info';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { Avatar } from '../../../ui/avatar';
|
||||
@@ -72,6 +70,8 @@ export interface WorkspaceCardProps {
|
||||
onClick: (metadata: WorkspaceMetadata) => void;
|
||||
onSettingClick: (metadata: WorkspaceMetadata) => void;
|
||||
isOwner?: boolean;
|
||||
avatar?: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export const WorkspaceCardSkeleton = () => {
|
||||
@@ -96,11 +96,10 @@ export const WorkspaceCard = ({
|
||||
currentWorkspaceId,
|
||||
meta,
|
||||
isOwner = true,
|
||||
name,
|
||||
avatar,
|
||||
}: WorkspaceCardProps) => {
|
||||
const information = useWorkspaceInfo(meta);
|
||||
const avatarUrl = useWorkspaceBlobObjectUrl(meta, information?.avatar);
|
||||
|
||||
const name = information?.name ?? UNTITLED_WORKSPACE_NAME;
|
||||
const displayName = name ?? UNTITLED_WORKSPACE_NAME;
|
||||
return (
|
||||
<StyledCard
|
||||
data-testid="workspace-card"
|
||||
@@ -109,12 +108,10 @@ export const WorkspaceCard = ({
|
||||
}, [onClick, meta])}
|
||||
active={meta.id === currentWorkspaceId}
|
||||
>
|
||||
<Avatar size={28} url={avatarUrl} name={name} colorfulFallback />
|
||||
<Avatar size={28} url={avatar} name={name} colorfulFallback />
|
||||
<StyledWorkspaceInfo>
|
||||
<StyledWorkspaceTitleArea style={{ display: 'flex' }}>
|
||||
<StyledWorkspaceTitle>
|
||||
{information?.name ?? UNTITLED_WORKSPACE_NAME}
|
||||
</StyledWorkspaceTitle>
|
||||
<StyledWorkspaceTitle>{displayName}</StyledWorkspaceTitle>
|
||||
|
||||
<StyledSettingLink
|
||||
size="small"
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* @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 { useAtomValue } from 'jotai';
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
import { beforeEach } from 'vitest';
|
||||
|
||||
import { useBlockSuitePagePreview } from '../use-block-suite-page-preview';
|
||||
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' }));
|
||||
});
|
||||
|
||||
describe('useBlockSuitePagePreview', () => {
|
||||
test('basic', async () => {
|
||||
const page = blockSuiteWorkspace.getPage('page0') as Page;
|
||||
const id = page.addBlock(
|
||||
'affine:paragraph',
|
||||
{
|
||||
text: new page.Text('Hello, world!'),
|
||||
},
|
||||
page.getBlockByFlavour('affine:note')[0].id
|
||||
);
|
||||
const hook = renderHook(() => useAtomValue(useBlockSuitePagePreview(page)));
|
||||
expect(hook.result.current).toBe('Hello, world!');
|
||||
page.transact(() => {
|
||||
page.getBlockById(id)!.text!.insert('Test', 0);
|
||||
});
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
hook.rerender();
|
||||
expect(hook.result.current).toBe('TestHello, world!');
|
||||
|
||||
// Insert before
|
||||
page.addBlock(
|
||||
'affine:paragraph',
|
||||
{
|
||||
text: new page.Text('First block!'),
|
||||
},
|
||||
page.getBlockByFlavour('affine:note')[0].id,
|
||||
0
|
||||
);
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
hook.rerender();
|
||||
expect(hook.result.current).toBe('First block! TestHello, world!');
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,10 @@
|
||||
import type { Workspace } from '@blocksuite/store';
|
||||
import { useBlockSuitePagePreview } from '@toeverything/hooks/use-block-suite-page-preview';
|
||||
import { useBlockSuiteWorkspacePage } from '@toeverything/hooks/use-block-suite-workspace-page';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { Suspense } from 'react';
|
||||
|
||||
import { useBlockSuitePagePreview } from './use-block-suite-page-preview';
|
||||
import { useBlockSuiteWorkspacePage } from './use-block-suite-workspace-page';
|
||||
|
||||
interface PagePreviewInnerProps {
|
||||
workspace: Workspace;
|
||||
pageId: string;
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { Page } from '@blocksuite/store';
|
||||
import type { Atom } from 'jotai';
|
||||
import { atom } from 'jotai';
|
||||
|
||||
const MAX_PREVIEW_LENGTH = 150;
|
||||
const MAX_SEARCH_BLOCK_COUNT = 30;
|
||||
|
||||
const weakMap = new WeakMap<Page, Atom<string>>();
|
||||
|
||||
export const getPagePreviewText = (page: Page) => {
|
||||
const pageRoot = page.root;
|
||||
if (!pageRoot) {
|
||||
return '';
|
||||
}
|
||||
const preview: string[] = [];
|
||||
// DFS
|
||||
const queue = [pageRoot];
|
||||
let previewLenNeeded = MAX_PREVIEW_LENGTH;
|
||||
let count = MAX_SEARCH_BLOCK_COUNT;
|
||||
while (queue.length && previewLenNeeded > 0 && count-- > 0) {
|
||||
const block = queue.shift();
|
||||
if (!block) {
|
||||
console.error('Unexpected empty block');
|
||||
break;
|
||||
}
|
||||
if (block.children) {
|
||||
queue.unshift(...block.children);
|
||||
}
|
||||
if (block.role !== 'content') {
|
||||
continue;
|
||||
}
|
||||
if (block.text) {
|
||||
const text = block.text.toString();
|
||||
if (!text.length) {
|
||||
continue;
|
||||
}
|
||||
previewLenNeeded -= text.length;
|
||||
preview.push(text);
|
||||
} else {
|
||||
// image/attachment/bookmark
|
||||
const type = block.flavour.split('affine:')[1] ?? null;
|
||||
previewLenNeeded -= type.length + 2;
|
||||
type && preview.push(`[${type}]`);
|
||||
}
|
||||
}
|
||||
return preview.join(' ');
|
||||
};
|
||||
|
||||
const emptyAtom = atom<string>('');
|
||||
|
||||
export function useBlockSuitePagePreview(page: Page | null): Atom<string> {
|
||||
if (page === null) {
|
||||
return emptyAtom;
|
||||
} else if (weakMap.has(page)) {
|
||||
return weakMap.get(page) as Atom<string>;
|
||||
} else {
|
||||
const baseAtom = atom<string>('');
|
||||
baseAtom.onMount = set => {
|
||||
const disposables = [
|
||||
page.slots.ready.on(() => {
|
||||
set(getPagePreviewText(page));
|
||||
}),
|
||||
page.slots.blockUpdated.on(() => {
|
||||
set(getPagePreviewText(page));
|
||||
}),
|
||||
];
|
||||
set(getPagePreviewText(page));
|
||||
return () => {
|
||||
disposables.forEach(disposable => disposable.dispose());
|
||||
};
|
||||
};
|
||||
weakMap.set(page, baseAtom);
|
||||
return baseAtom;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -24,21 +24,28 @@ export interface WorkspaceListProps {
|
||||
disabled?: boolean;
|
||||
currentWorkspaceId?: string | null;
|
||||
items: WorkspaceMetadata[];
|
||||
onClick: (workspaceMetadata: WorkspaceMetadata) => void;
|
||||
onSettingClick: (workspaceMetadata: WorkspaceMetadata) => void;
|
||||
onClick: (workspace: WorkspaceMetadata) => void;
|
||||
onSettingClick: (workspace: WorkspaceMetadata) => void;
|
||||
onDragEnd: (event: DragEndEvent) => void;
|
||||
useIsWorkspaceOwner?: (workspaceMetadata: WorkspaceMetadata) => boolean;
|
||||
useIsWorkspaceOwner: (workspaceMetadata: WorkspaceMetadata) => boolean;
|
||||
useWorkspaceAvatar: (
|
||||
workspaceMetadata: WorkspaceMetadata
|
||||
) => string | undefined;
|
||||
useWorkspaceName: (
|
||||
workspaceMetadata: WorkspaceMetadata
|
||||
) => string | undefined;
|
||||
}
|
||||
|
||||
interface SortableWorkspaceItemProps extends Omit<WorkspaceListProps, 'items'> {
|
||||
item: WorkspaceMetadata;
|
||||
useIsWorkspaceOwner?: (workspaceMetadata: WorkspaceMetadata) => boolean;
|
||||
}
|
||||
|
||||
const SortableWorkspaceItem = ({
|
||||
disabled,
|
||||
item,
|
||||
useIsWorkspaceOwner,
|
||||
useWorkspaceAvatar,
|
||||
useWorkspaceName,
|
||||
currentWorkspaceId,
|
||||
onClick,
|
||||
onSettingClick,
|
||||
@@ -59,6 +66,8 @@ const SortableWorkspaceItem = ({
|
||||
[disabled, transform, transition]
|
||||
);
|
||||
const isOwner = useIsWorkspaceOwner?.(item);
|
||||
const avatar = useWorkspaceAvatar?.(item);
|
||||
const name = useWorkspaceName?.(item);
|
||||
return (
|
||||
<div
|
||||
className={workspaceItemStyle}
|
||||
@@ -74,6 +83,8 @@ const SortableWorkspaceItem = ({
|
||||
onClick={onClick}
|
||||
onSettingClick={onSettingClick}
|
||||
isOwner={isOwner}
|
||||
name={name}
|
||||
avatar={avatar}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user