refactor(component): make component pure (#5427)

This commit is contained in:
EYHN
2023-12-28 09:57:26 +00:00
parent e11e8277ca
commit 9d51f9596f
19 changed files with 307 additions and 273 deletions

View File

@@ -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",

View File

@@ -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}
/>
);
}

View File

@@ -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"

View File

@@ -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!');
});
});

View File

@@ -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;

View File

@@ -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;
}
}

View File

@@ -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;
}

View File

@@ -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>
);