fix(core): fix ui flashing (#7056)

This commit is contained in:
EYHN
2024-05-27 08:05:20 +00:00
parent 306cf2ae6f
commit b356ddbe6e
33 changed files with 545 additions and 404 deletions
@@ -1,4 +1,5 @@
import { DebugLogger } from '@affine/debug'; import { DebugLogger } from '@affine/debug';
import { isEqual } from 'lodash-es';
import { catchError, EMPTY, exhaustMap, mergeMap } from 'rxjs'; import { catchError, EMPTY, exhaustMap, mergeMap } from 'rxjs';
import { Entity } from '../../../framework'; import { Entity } from '../../../framework';
@@ -54,7 +55,10 @@ export class WorkspaceProfile extends Entity<{ metadata: WorkspaceMetadata }> {
providers.find(p => p.flavour === this.props.metadata.flavour) ?? null; providers.find(p => p.flavour === this.props.metadata.flavour) ?? null;
} }
private setCache(info: WorkspaceProfileInfo) { private setProfile(info: WorkspaceProfileInfo) {
if (isEqual(this.profile$.value, info)) {
return;
}
this.cache.setProfileCache(this.props.metadata.id, info); this.cache.setProfileCache(this.props.metadata.id, info);
} }
@@ -69,7 +73,7 @@ export class WorkspaceProfile extends Entity<{ metadata: WorkspaceMetadata }> {
).pipe( ).pipe(
mergeMap(info => { mergeMap(info => {
if (info) { if (info) {
this.setCache({ ...this.profile$.value, ...info }); this.setProfile({ ...this.profile$.value, ...info });
} }
return EMPTY; return EMPTY;
}), }),
@@ -86,11 +90,11 @@ export class WorkspaceProfile extends Entity<{ metadata: WorkspaceMetadata }> {
syncWithWorkspace(workspace: Workspace) { syncWithWorkspace(workspace: Workspace) {
workspace.name$.subscribe(name => { workspace.name$.subscribe(name => {
const old = this.profile$.value; const old = this.profile$.value;
this.setCache({ ...old, name: name ?? old?.name }); this.setProfile({ ...old, name: name ?? old?.name });
}); });
workspace.avatar$.subscribe(avatar => { workspace.avatar$.subscribe(avatar => {
const old = this.profile$.value; const old = this.profile$.value;
this.setCache({ ...old, avatar: avatar ?? old?.avatar }); this.setProfile({ ...old, avatar: avatar ?? old?.avatar });
}); });
} }
} }
@@ -1,3 +1,4 @@
import { WorkspaceAvatar } from '@affine/component/workspace-avatar';
import { UNTITLED_WORKSPACE_NAME } from '@affine/env/constant'; import { UNTITLED_WORKSPACE_NAME } from '@affine/env/constant';
import { WorkspaceFlavour } from '@affine/env/workspace'; import { WorkspaceFlavour } from '@affine/env/workspace';
import { CollaborationIcon, SettingsIcon } from '@blocksuite/icons'; import { CollaborationIcon, SettingsIcon } from '@blocksuite/icons';
@@ -5,7 +6,7 @@ import type { WorkspaceMetadata } from '@toeverything/infra';
import clsx from 'clsx'; import clsx from 'clsx';
import { type MouseEvent, useCallback } from 'react'; import { type MouseEvent, useCallback } from 'react';
import { Avatar, type AvatarProps } from '../../../ui/avatar'; import { type AvatarProps } from '../../../ui/avatar';
import { Button } from '../../../ui/button'; import { Button } from '../../../ui/button';
import { Skeleton } from '../../../ui/skeleton'; import { Skeleton } from '../../../ui/skeleton';
import * as styles from './styles.css'; import * as styles from './styles.css';
@@ -24,7 +25,6 @@ export interface WorkspaceCardProps {
isOwner?: boolean; isOwner?: boolean;
openingId?: string | null; openingId?: string | null;
enableCloudText?: string; enableCloudText?: string;
avatar?: string;
name?: string; name?: string;
} }
@@ -57,7 +57,6 @@ export const WorkspaceCard = ({
isOwner = true, isOwner = true,
enableCloudText = 'Enable Cloud', enableCloudText = 'Enable Cloud',
name, name,
avatar,
}: WorkspaceCardProps) => { }: WorkspaceCardProps) => {
const isLocal = meta.flavour === WorkspaceFlavour.LOCAL; const isLocal = meta.flavour === WorkspaceFlavour.LOCAL;
const displayName = name ?? UNTITLED_WORKSPACE_NAME; const displayName = name ?? UNTITLED_WORKSPACE_NAME;
@@ -78,11 +77,12 @@ export const WorkspaceCard = ({
onClick(meta); onClick(meta);
}, [onClick, meta])} }, [onClick, meta])}
> >
<Avatar <WorkspaceAvatar
key={meta.id}
meta={meta}
imageProps={avatarImageProps} imageProps={avatarImageProps}
fallbackProps={avatarImageProps} fallbackProps={avatarImageProps}
size={28} size={28}
url={avatar}
name={name} name={name}
colorfulFallback colorfulFallback
/> />
@@ -33,9 +33,6 @@ export const root = style({
'&[data-enable-animation="true"]': { '&[data-enable-animation="true"]': {
transition: `margin-left ${animationTimeout} .05s, margin-right ${animationTimeout} .05s, width ${animationTimeout} .05s`, transition: `margin-left ${animationTimeout} .05s, margin-right ${animationTimeout} .05s, width ${animationTimeout} .05s`,
}, },
'&[data-is-floating="false"][data-transparent=true]': {
backgroundColor: 'transparent',
},
'&[data-transition-state="exited"]': { '&[data-transition-state="exited"]': {
// avoid focus on hidden panel // avoid focus on hidden panel
visibility: 'hidden', visibility: 'hidden',
@@ -1,7 +1,14 @@
import { assertExists } from '@blocksuite/global/utils'; import { assertExists } from '@blocksuite/global/utils';
import { assignInlineVars } from '@vanilla-extract/dynamic'; import { assignInlineVars } from '@vanilla-extract/dynamic';
import clsx from 'clsx'; import clsx from 'clsx';
import { forwardRef, useCallback, useEffect, useRef, useState } from 'react'; import {
forwardRef,
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from 'react';
import { useTransition } from 'react-transition-state'; import { useTransition } from 'react-transition-state';
import * as styles from './resize-panel.css'; import * as styles from './resize-panel.css';
@@ -157,7 +164,7 @@ export const ResizePanel = forwardRef<HTMLDivElement, ResizePanelProps>(
const [{ status }, toggle] = useTransition({ const [{ status }, toggle] = useTransition({
timeout: animationTimeout, timeout: animationTimeout,
}); });
useEffect(() => { useLayoutEffect(() => {
toggle(open); toggle(open);
}, [open]); }, [open]);
return ( return (
@@ -0,0 +1,80 @@
import {
useLiveData,
useService,
type WorkspaceMetadata,
WorkspacesService,
} from '@toeverything/infra';
import { useEffect, useLayoutEffect, useState } from 'react';
import { Avatar, type AvatarProps } from '../../ui/avatar';
const cache = new Map<string, { imageBitmap: ImageBitmap; key: string }>();
/**
* workspace avatar component with automatic cache, and avoid flashing
*/
export const WorkspaceAvatar = ({
meta,
...otherProps
}: { meta: WorkspaceMetadata } & AvatarProps) => {
const workspacesService = useService(WorkspacesService);
const profile = workspacesService.getProfile(meta);
useEffect(() => {
profile.revalidate();
}, [meta, profile]);
const avatarKey = useLiveData(profile.profile$.map(v => v?.avatar));
const [downloadedAvatar, setDownloadedAvatar] = useState<
{ imageBitmap: ImageBitmap; key: string } | undefined
>(cache.get(meta.id));
useLayoutEffect(() => {
if (!avatarKey || !meta) {
setDownloadedAvatar(undefined);
return;
}
let canceled = false;
workspacesService
.getWorkspaceBlob(meta, avatarKey)
.then(async blob => {
if (blob && !canceled) {
const image = document.createElement('img');
const objectUrl = URL.createObjectURL(blob);
image.src = objectUrl;
await image.decode();
// limit the size of the image data to reduce memory usage
const hRatio = 128 / image.naturalWidth;
const vRatio = 128 / image.naturalHeight;
const ratio = Math.min(hRatio, vRatio);
const imageBitmap = await createImageBitmap(image, {
resizeWidth: image.naturalWidth * ratio,
resizeHeight: image.naturalHeight * ratio,
});
URL.revokeObjectURL(objectUrl);
setDownloadedAvatar(prev => {
if (prev?.key === avatarKey) {
return prev;
}
return { imageBitmap, key: avatarKey };
});
cache.set(meta.id, {
imageBitmap,
key: avatarKey,
});
}
})
.catch(err => {
console.error('get workspace blob error: ' + err);
});
return () => {
canceled = true;
};
}, [meta, workspacesService, avatarKey]);
return <Avatar image={downloadedAvatar?.imageBitmap} {...otherProps} />;
};
@@ -18,9 +18,6 @@ export interface WorkspaceListProps {
useIsWorkspaceOwner: ( useIsWorkspaceOwner: (
workspaceMetadata: WorkspaceMetadata workspaceMetadata: WorkspaceMetadata
) => boolean | undefined; ) => boolean | undefined;
useWorkspaceAvatar: (
workspaceMetadata: WorkspaceMetadata
) => string | undefined;
useWorkspaceName: ( useWorkspaceName: (
workspaceMetadata: WorkspaceMetadata workspaceMetadata: WorkspaceMetadata
) => string | undefined; ) => string | undefined;
@@ -34,7 +31,6 @@ const SortableWorkspaceItem = ({
item, item,
openingId, openingId,
useIsWorkspaceOwner, useIsWorkspaceOwner,
useWorkspaceAvatar,
useWorkspaceName, useWorkspaceName,
currentWorkspaceId, currentWorkspaceId,
onClick, onClick,
@@ -42,7 +38,6 @@ const SortableWorkspaceItem = ({
onEnableCloudClick, onEnableCloudClick,
}: SortableWorkspaceItemProps) => { }: SortableWorkspaceItemProps) => {
const isOwner = useIsWorkspaceOwner?.(item); const isOwner = useIsWorkspaceOwner?.(item);
const avatar = useWorkspaceAvatar?.(item);
const name = useWorkspaceName?.(item); const name = useWorkspaceName?.(item);
return ( return (
<div className={workspaceItemStyle} data-testid="draggable-item"> <div className={workspaceItemStyle} data-testid="draggable-item">
@@ -55,7 +50,6 @@ const SortableWorkspaceItem = ({
openingId={openingId} openingId={openingId}
isOwner={isOwner} isOwner={isOwner}
name={name} name={name}
avatar={avatar}
/> />
</div> </div>
); );
@@ -17,7 +17,13 @@ import type {
MouseEvent, MouseEvent,
ReactElement, ReactElement,
} from 'react'; } from 'react';
import { forwardRef, useMemo, useState } from 'react'; import {
forwardRef,
useCallback,
useLayoutEffect,
useMemo,
useState,
} from 'react';
import { IconButton } from '../button'; import { IconButton } from '../button';
import type { TooltipProps } from '../tooltip'; import type { TooltipProps } from '../tooltip';
@@ -29,6 +35,7 @@ import { blurVar, sizeVar } from './style.css';
export type AvatarProps = { export type AvatarProps = {
size?: number; size?: number;
url?: string | null; url?: string | null;
image?: ImageBitmap /* use pre-loaded image data can avoid flashing */;
name?: string; name?: string;
className?: string; className?: string;
style?: CSSProperties; style?: CSSProperties;
@@ -39,18 +46,56 @@ export type AvatarProps = {
removeTooltipOptions?: Omit<TooltipProps, 'children'>; removeTooltipOptions?: Omit<TooltipProps, 'children'>;
fallbackProps?: AvatarFallbackProps; fallbackProps?: AvatarFallbackProps;
imageProps?: Omit<AvatarImageProps, 'src'>; imageProps?: Omit<
AvatarImageProps & React.HTMLProps<HTMLCanvasElement>,
'src' | 'ref'
>;
avatarProps?: RadixAvatarProps; avatarProps?: RadixAvatarProps;
hoverWrapperProps?: HTMLAttributes<HTMLDivElement>; hoverWrapperProps?: HTMLAttributes<HTMLDivElement>;
removeButtonProps?: HTMLAttributes<HTMLButtonElement>; removeButtonProps?: HTMLAttributes<HTMLButtonElement>;
} & HTMLAttributes<HTMLSpanElement>; } & HTMLAttributes<HTMLSpanElement>;
function drawImageFit(
img: ImageBitmap,
ctx: CanvasRenderingContext2D,
size: number
) {
const hRatio = size / img.width;
const vRatio = size / img.height;
const ratio = Math.max(hRatio, vRatio);
const centerShift_x = (size - img.width * ratio) / 2;
const centerShift_y = (size - img.height * ratio) / 2;
console.log(ctx.canvas);
ctx.canvas.dataset['drawed'] = 'true';
console.log(
'drawImageFit',
img.width,
img.height,
size,
ratio,
centerShift_x,
centerShift_y
);
ctx.drawImage(
img,
0,
0,
img.width,
img.height,
centerShift_x,
centerShift_y,
img.width * ratio,
img.height * ratio
);
}
export const Avatar = forwardRef<HTMLSpanElement, AvatarProps>( export const Avatar = forwardRef<HTMLSpanElement, AvatarProps>(
( (
{ {
size = 20, size = 20,
style: propsStyles = {}, style: propsStyles = {},
url, url,
image,
name, name,
className, className,
colorfulFallback = false, colorfulFallback = false,
@@ -76,18 +121,35 @@ export const Avatar = forwardRef<HTMLSpanElement, AvatarProps>(
const firstCharOfName = useMemo(() => { const firstCharOfName = useMemo(() => {
return name?.slice(0, 1) || 'A'; return name?.slice(0, 1) || 'A';
}, [name]); }, [name]);
const [imageDom, setImageDom] = useState<HTMLDivElement | null>(null); const [containerDom, setContainerDom] = useState<HTMLDivElement | null>(
null
);
const [removeButtonDom, setRemoveButtonDom] = const [removeButtonDom, setRemoveButtonDom] =
useState<HTMLButtonElement | null>(null); useState<HTMLButtonElement | null>(null);
const [canvas, setCanvas] = useState<HTMLCanvasElement | null>(null);
useLayoutEffect(() => {
if (canvas && image) {
const ctx = canvas?.getContext('2d');
if (ctx) {
drawImageFit(image, ctx, size * window.devicePixelRatio);
}
}
return;
}, [canvas, image, size]);
const canvasRef = useCallback((node: HTMLCanvasElement | null) => {
setCanvas(node);
}, []);
return ( return (
<AvatarRoot className={style.avatarRoot} {...avatarProps} ref={ref}> <AvatarRoot className={style.avatarRoot} {...avatarProps} ref={ref}>
<Tooltip <Tooltip
portalOptions={{ container: imageDom }} portalOptions={{ container: containerDom }}
{...avatarTooltipOptions} {...avatarTooltipOptions}
> >
<div <div
ref={setImageDom} ref={setContainerDom}
className={clsx(style.avatarWrapper, className)} className={clsx(style.avatarWrapper, className)}
style={{ style={{
...assignInlineVars({ ...assignInlineVars({
@@ -98,24 +160,36 @@ export const Avatar = forwardRef<HTMLSpanElement, AvatarProps>(
}} }}
{...props} {...props}
> >
<AvatarImage {image /* canvas mode */ ? (
className={style.avatarImage} <canvas
src={url || ''} className={style.avatarImage}
alt={name} ref={canvasRef}
{...imageProps} width={size * window.devicePixelRatio}
/> height={size * window.devicePixelRatio}
{...imageProps}
/>
) : (
<AvatarImage
className={style.avatarImage}
src={url || ''}
alt={name}
{...imageProps}
/>
)}
<AvatarFallback {!image /* no fallback on canvas mode */ && (
className={clsx(style.avatarFallback, fallbackClassName)} <AvatarFallback
delayMs={url ? 600 : undefined} className={clsx(style.avatarFallback, fallbackClassName)}
{...fallbackProps} delayMs={url ? 600 : undefined}
> {...fallbackProps}
{colorfulFallback ? ( >
<ColorfulFallback char={firstCharOfName} /> {colorfulFallback ? (
) : ( <ColorfulFallback char={firstCharOfName} />
firstCharOfName.toUpperCase() ) : (
)} firstCharOfName.toUpperCase()
</AvatarFallback> )}
</AvatarFallback>
)}
{hoverIcon ? ( {hoverIcon ? (
<div <div
className={clsx(style.hoverWrapper, hoverWrapperClassName)} className={clsx(style.hoverWrapper, hoverWrapperClassName)}
@@ -1,6 +1,12 @@
import type { ReactElement } from 'react';
import { useAppSettingHelper } from '../../hooks/affine/use-app-setting-helper'; import { useAppSettingHelper } from '../../hooks/affine/use-app-setting-helper';
import { AppSidebarFallback } from '../app-sidebar';
import type { WorkspaceRootProps } from '../workspace'; import type { WorkspaceRootProps } from '../workspace';
import { AppContainer as AppContainerWithoutSettings } from '../workspace'; import {
AppContainer as AppContainerWithoutSettings,
MainContainer,
} from '../workspace';
export const AppContainer = (props: WorkspaceRootProps) => { export const AppContainer = (props: WorkspaceRootProps) => {
const { appSettings } = useAppSettingHelper(); const { appSettings } = useAppSettingHelper();
@@ -17,3 +23,12 @@ export const AppContainer = (props: WorkspaceRootProps) => {
/> />
); );
}; };
export const AppFallback = (): ReactElement => {
return (
<AppContainer>
<AppSidebarFallback />
<MainContainer />
</AppContainer>
);
};
@@ -4,7 +4,7 @@ import {
} from '@affine/component/setting-components'; } from '@affine/component/setting-components';
import { Avatar } from '@affine/component/ui/avatar'; import { Avatar } from '@affine/component/ui/avatar';
import { Tooltip } from '@affine/component/ui/tooltip'; import { Tooltip } from '@affine/component/ui/tooltip';
import { useWorkspaceBlobObjectUrl } from '@affine/core/hooks/use-workspace-blob'; import { WorkspaceAvatar } from '@affine/component/workspace-avatar';
import { useWorkspaceInfo } from '@affine/core/hooks/use-workspace-info'; import { useWorkspaceInfo } from '@affine/core/hooks/use-workspace-info';
import { AuthService } from '@affine/core/modules/cloud'; import { AuthService } from '@affine/core/modules/cloud';
import { UserFeatureService } from '@affine/core/modules/cloud/services/user-feature'; import { UserFeatureService } from '@affine/core/modules/cloud/services/user-feature';
@@ -277,7 +277,6 @@ const WorkspaceListItem = ({
UserFeatureService, UserFeatureService,
}); });
const information = useWorkspaceInfo(meta); const information = useWorkspaceInfo(meta);
const avatarUrl = useWorkspaceBlobObjectUrl(meta, information?.avatar);
const name = information?.name ?? UNTITLED_WORKSPACE_NAME; const name = information?.name ?? UNTITLED_WORKSPACE_NAME;
const currentWorkspace = workspaceService.workspace; const currentWorkspace = workspaceService.workspace;
const isCurrent = currentWorkspace.id === meta.id; const isCurrent = currentWorkspace.id === meta.id;
@@ -318,9 +317,10 @@ const WorkspaceListItem = ({
onClick={onClickPreference} onClick={onClickPreference}
data-testid="workspace-list-item" data-testid="workspace-list-item"
> >
<Avatar <WorkspaceAvatar
key={meta.id}
meta={meta}
size={16} size={16}
url={avatarUrl}
name={name} name={name}
colorfulFallback colorfulFallback
style={{ style={{
@@ -1,9 +1,8 @@
import { FlexWrapper, Input, notify, Wrapper } from '@affine/component'; import { FlexWrapper, Input, notify, Wrapper } from '@affine/component';
import { Avatar } from '@affine/component/ui/avatar';
import { Button } from '@affine/component/ui/button'; import { Button } from '@affine/component/ui/button';
import { WorkspaceAvatar } from '@affine/component/workspace-avatar';
import { Upload } from '@affine/core/components/pure/file-upload'; import { Upload } from '@affine/core/components/pure/file-upload';
import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks'; import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
import { useWorkspaceBlobObjectUrl } from '@affine/core/hooks/use-workspace-blob';
import { WorkspacePermissionService } from '@affine/core/modules/permissions'; import { WorkspacePermissionService } from '@affine/core/modules/permissions';
import { validateAndReduceImage } from '@affine/core/utils/reduce-image'; import { validateAndReduceImage } from '@affine/core/utils/reduce-image';
import { UNTITLED_WORKSPACE_NAME } from '@affine/env/constant'; import { UNTITLED_WORKSPACE_NAME } from '@affine/env/constant';
@@ -28,18 +27,13 @@ export const ProfilePanel = () => {
}, [permissionService]); }, [permissionService]);
const workspaceIsReady = useLiveData(workspace?.engine.rootDocState$)?.ready; const workspaceIsReady = useLiveData(workspace?.engine.rootDocState$)?.ready;
const [avatarBlob, setAvatarBlob] = useState<string | null>(null);
const [name, setName] = useState(''); const [name, setName] = useState('');
const avatarUrl = useWorkspaceBlobObjectUrl(workspace?.meta, avatarBlob);
useEffect(() => { useEffect(() => {
if (workspace?.docCollection) { if (workspace?.docCollection) {
setAvatarBlob(workspace.docCollection.meta.avatar ?? null);
setName(workspace.docCollection.meta.name ?? UNTITLED_WORKSPACE_NAME); setName(workspace.docCollection.meta.name ?? UNTITLED_WORKSPACE_NAME);
const dispose = workspace.docCollection.meta.commonFieldsUpdated.on( const dispose = workspace.docCollection.meta.commonFieldsUpdated.on(
() => { () => {
setAvatarBlob(workspace.docCollection.meta.avatar ?? null);
setName(workspace.docCollection.meta.name ?? UNTITLED_WORKSPACE_NAME); setName(workspace.docCollection.meta.name ?? UNTITLED_WORKSPACE_NAME);
} }
); );
@@ -47,7 +41,6 @@ export const ProfilePanel = () => {
dispose.dispose(); dispose.dispose();
}; };
} else { } else {
setAvatarBlob(null);
setName(UNTITLED_WORKSPACE_NAME); setName(UNTITLED_WORKSPACE_NAME);
} }
return; return;
@@ -139,7 +132,7 @@ export const ProfilePanel = () => {
[setWorkspaceAvatar] [setWorkspaceAvatar]
); );
const canAdjustAvatar = workspaceIsReady && avatarUrl && isOwner; const canAdjustAvatar = workspaceIsReady && isOwner;
return ( return (
<div className={style.profileWrapper}> <div className={style.profileWrapper}>
@@ -149,9 +142,9 @@ export const ProfilePanel = () => {
data-testid="upload-avatar" data-testid="upload-avatar"
disabled={!isOwner} disabled={!isOwner}
> >
<Avatar <WorkspaceAvatar
meta={workspace.meta}
size={56} size={56}
url={avatarUrl}
name={name} name={name}
imageProps={avatarImageProps} imageProps={avatarImageProps}
fallbackProps={avatarImageProps} fallbackProps={avatarImageProps}
@@ -4,7 +4,6 @@ export const floatingMaxWidth = 768;
export const navWrapperStyle = style({ export const navWrapperStyle = style({
zIndex: 3, zIndex: 3,
paddingBottom: '8px', paddingBottom: '8px',
backgroundColor: cssVar('backgroundPrimaryColor'),
'@media': { '@media': {
print: { print: {
display: 'none', display: 'none',
@@ -15,6 +14,9 @@ export const navWrapperStyle = style({
'&[data-has-border=true]': { '&[data-has-border=true]': {
borderRight: `1px solid ${cssVar('borderColor')}`, borderRight: `1px solid ${cssVar('borderColor')}`,
}, },
'&[data-is-floating="true"]': {
backgroundColor: cssVar('backgroundPrimaryColor'),
},
}, },
}); });
export const navHeaderButton = style({ export const navHeaderButton = style({
@@ -10,7 +10,7 @@ import {
WorkspacesService, WorkspacesService,
} from '@toeverything/infra'; } from '@toeverything/infra';
import { useSetAtom } from 'jotai'; import { useSetAtom } from 'jotai';
import { Suspense, useCallback, useEffect } from 'react'; import { Suspense, useCallback } from 'react';
import { import {
authAtom, authAtom,
@@ -130,11 +130,6 @@ const UserWithWorkspaceListInner = ({
const workspaceManager = useService(WorkspacesService); const workspaceManager = useService(WorkspacesService);
const workspaces = useLiveData(workspaceManager.list.workspaces$); const workspaces = useLiveData(workspaceManager.list.workspaces$);
// revalidate workspace list when mounted
useEffect(() => {
workspaceManager.list.revalidate();
}, [workspaceManager]);
return ( return (
<div className={styles.workspaceListWrapper}> <div className={styles.workspaceListWrapper}>
{isAuthenticated ? ( {isAuthenticated ? (
@@ -3,7 +3,6 @@ import { Divider } from '@affine/component/ui/divider';
import { WorkspaceList } from '@affine/component/workspace-list'; import { WorkspaceList } from '@affine/component/workspace-list';
import { useEnableCloud } from '@affine/core/hooks/affine/use-enable-cloud'; import { useEnableCloud } from '@affine/core/hooks/affine/use-enable-cloud';
import { import {
useWorkspaceAvatar,
useWorkspaceInfo, useWorkspaceInfo,
useWorkspaceName, useWorkspaceName,
} from '@affine/core/hooks/use-workspace-info'; } from '@affine/core/hooks/use-workspace-info';
@@ -76,7 +75,6 @@ const CloudWorkSpaceList = ({
onSettingClick={onClickWorkspaceSetting} onSettingClick={onClickWorkspaceSetting}
useIsWorkspaceOwner={useIsWorkspaceOwner} useIsWorkspaceOwner={useIsWorkspaceOwner}
useWorkspaceName={useWorkspaceName} useWorkspaceName={useWorkspaceName}
useWorkspaceAvatar={useWorkspaceAvatar}
/> />
</div> </div>
); );
@@ -115,7 +113,6 @@ const LocalWorkspaces = ({
onEnableCloudClick={onClickEnableCloud} onEnableCloudClick={onClickEnableCloud}
useIsWorkspaceOwner={useIsWorkspaceOwner} useIsWorkspaceOwner={useIsWorkspaceOwner}
useWorkspaceName={useWorkspaceName} useWorkspaceName={useWorkspaceName}
useWorkspaceAvatar={useWorkspaceAvatar}
/> />
</div> </div>
); );
@@ -186,8 +183,18 @@ export const AFFiNEWorkspaceList = ({
const onClickWorkspace = useCallback( const onClickWorkspace = useCallback(
(workspaceMetadata: WorkspaceMetadata) => { (workspaceMetadata: WorkspaceMetadata) => {
jumpToSubPath(workspaceMetadata.id, WorkspaceSubPath.ALL); if (document.startViewTransition) {
onEventEnd?.(); document.startViewTransition(() => {
jumpToSubPath(workspaceMetadata.id, WorkspaceSubPath.ALL);
onEventEnd?.();
return new Promise(resolve =>
setTimeout(resolve, 150)
); /* start transition after 150ms */
});
} else {
jumpToSubPath(workspaceMetadata.id, WorkspaceSubPath.ALL);
onEventEnd?.();
}
}, },
[jumpToSubPath, onEventEnd] [jumpToSubPath, onEventEnd]
); );
@@ -1,9 +1,9 @@
import { notify, Tooltip } from '@affine/component'; import { notify, Tooltip } from '@affine/component';
import { Avatar, type AvatarProps } from '@affine/component/ui/avatar'; import { type AvatarProps } from '@affine/component/ui/avatar';
import { Loading } from '@affine/component/ui/loading'; import { Loading } from '@affine/component/ui/loading';
import { WorkspaceAvatar } from '@affine/component/workspace-avatar';
import { openSettingModalAtom } from '@affine/core/atoms'; import { openSettingModalAtom } from '@affine/core/atoms';
import { useDocEngineStatus } from '@affine/core/hooks/affine/use-doc-engine-status'; import { useDocEngineStatus } from '@affine/core/hooks/affine/use-doc-engine-status';
import { useWorkspaceBlobObjectUrl } from '@affine/core/hooks/use-workspace-blob';
import { useWorkspaceInfo } from '@affine/core/hooks/use-workspace-info'; import { useWorkspaceInfo } from '@affine/core/hooks/use-workspace-info';
import { WorkspacePermissionService } from '@affine/core/modules/permissions'; import { WorkspacePermissionService } from '@affine/core/modules/permissions';
import { UNTITLED_WORKSPACE_NAME } from '@affine/env/constant'; import { UNTITLED_WORKSPACE_NAME } from '@affine/env/constant';
@@ -284,11 +284,6 @@ export const WorkspaceCard = forwardRef<
const information = useWorkspaceInfo(currentWorkspace.meta); const information = useWorkspaceInfo(currentWorkspace.meta);
const avatarUrl = useWorkspaceBlobObjectUrl(
currentWorkspace.meta,
information?.avatar
);
const name = information?.name ?? UNTITLED_WORKSPACE_NAME; const name = information?.name ?? UNTITLED_WORKSPACE_NAME;
return ( return (
@@ -301,12 +296,13 @@ export const WorkspaceCard = forwardRef<
ref={ref} ref={ref}
{...props} {...props}
> >
<Avatar <WorkspaceAvatar
key={currentWorkspace.id}
meta={currentWorkspace.meta}
imageProps={avatarImageProps} imageProps={avatarImageProps}
fallbackProps={avatarImageProps} fallbackProps={avatarImageProps}
data-testid="workspace-avatar" data-testid="workspace-avatar"
size={32} size={32}
url={avatarUrl}
name={name} name={name}
colorfulFallback colorfulFallback
/> />
@@ -13,7 +13,7 @@ import { useLiveData, useService } from '@toeverything/infra';
import { useAtomValue } from 'jotai'; import { useAtomValue } from 'jotai';
import { nanoid } from 'nanoid'; import { nanoid } from 'nanoid';
import type { HTMLAttributes, ReactElement } from 'react'; import type { HTMLAttributes, ReactElement } from 'react';
import { forwardRef, useCallback, useEffect } from 'react'; import { forwardRef, memo, useCallback, useEffect } from 'react';
import { useAppSettingHelper } from '../../hooks/affine/use-app-setting-helper'; import { useAppSettingHelper } from '../../hooks/affine/use-app-setting-helper';
import { useTrashModalHelper } from '../../hooks/affine/use-trash-modal-helper'; import { useTrashModalHelper } from '../../hooks/affine/use-trash-modal-helper';
@@ -89,174 +89,177 @@ RouteMenuLinkItem.displayName = 'RouteMenuLinkItem';
* *
* @todo(himself65): rewrite all styled component into @vanilla-extract/css * @todo(himself65): rewrite all styled component into @vanilla-extract/css
*/ */
export const RootAppSidebar = ({ export const RootAppSidebar = memo(
currentWorkspace, ({
openPage, currentWorkspace,
createPage, openPage,
paths, createPage,
onOpenQuickSearchModal, paths,
onOpenSettingModal, onOpenQuickSearchModal,
}: RootAppSidebarProps): ReactElement => { onOpenSettingModal,
const currentWorkspaceId = currentWorkspace.id; }: RootAppSidebarProps): ReactElement => {
const { appSettings } = useAppSettingHelper(); const currentWorkspaceId = currentWorkspace.id;
const docCollection = currentWorkspace.docCollection; const { appSettings } = useAppSettingHelper();
const t = useAFFiNEI18N(); const docCollection = currentWorkspace.docCollection;
const currentPath = useLiveData( const t = useAFFiNEI18N();
useService(WorkbenchService).workbench.location$.map( const currentPath = useLiveData(
location => location.pathname useService(WorkbenchService).workbench.location$.map(
) location => location.pathname
); )
);
const allPageActive = currentPath === '/all'; const allPageActive = currentPath === '/all';
const trashActive = currentPath === '/trash'; const trashActive = currentPath === '/trash';
const onClickNewPage = useAsyncCallback(async () => { const onClickNewPage = useAsyncCallback(async () => {
const page = createPage(); const page = createPage();
page.load(); page.load();
openPage(page.id); openPage(page.id);
mixpanel.track('DocCreated', { mixpanel.track('DocCreated', {
page: allPageActive ? 'all' : trashActive ? 'trash' : 'other', page: allPageActive ? 'all' : trashActive ? 'trash' : 'other',
segment: 'navigation panel', segment: 'navigation panel',
module: 'bottom button', module: 'bottom button',
control: 'new doc button', control: 'new doc button',
category: 'page', category: 'page',
type: 'doc', type: 'doc',
});
}, [allPageActive, createPage, openPage, trashActive]);
const { trashModal, setTrashModal, handleOnConfirm } =
useTrashModalHelper(docCollection);
const deletePageTitles = trashModal.pageTitles;
const trashConfirmOpen = trashModal.open;
const onTrashConfirmOpenChange = useCallback(
(open: boolean) => {
setTrashModal({
...trashModal,
open,
});
},
[trashModal, setTrashModal]
);
const navigateHelper = useNavigateHelper();
// Listen to the "New Page" action from the menu
useEffect(() => {
if (environment.isDesktop) {
return events?.applicationMenu.onNewPageAction(onClickNewPage);
}
return;
}, [onClickNewPage]);
const sidebarOpen = useAtomValue(appSidebarOpenAtom);
useEffect(() => {
if (environment.isDesktop) {
apis?.ui.handleSidebarVisibilityChange(sidebarOpen).catch(err => {
console.error(err);
});
}
}, [sidebarOpen]);
const dropItemId = getDNDId('sidebar-trash', 'container', 'trash');
const trashDroppable = useDroppable({
id: dropItemId,
}); });
}, [allPageActive, createPage, openPage, trashActive]);
const { trashModal, setTrashModal, handleOnConfirm } = const collection = useService(CollectionService);
useTrashModalHelper(docCollection); const { node, open } = useEditCollectionName({
const deletePageTitles = trashModal.pageTitles; title: t['com.affine.editCollection.createCollection'](),
const trashConfirmOpen = trashModal.open; showTips: true,
const onTrashConfirmOpenChange = useCallback( });
(open: boolean) => { const handleCreateCollection = useCallback(() => {
setTrashModal({ open('')
...trashModal, .then(name => {
open, const id = nanoid();
}); collection.addCollection(createEmptyCollection(id, { name }));
}, navigateHelper.jumpToCollection(docCollection.id, id);
[trashModal, setTrashModal] })
); .catch(err => {
console.error(err);
});
}, [docCollection.id, collection, navigateHelper, open]);
const navigateHelper = useNavigateHelper(); return (
// Listen to the "New Page" action from the menu <AppSidebar
useEffect(() => { clientBorder={appSettings.clientBorder}
if (environment.isDesktop) { translucentUI={appSettings.enableBlurBackground}
return events?.applicationMenu.onNewPageAction(onClickNewPage); >
} <MoveToTrash.ConfirmModal
return; open={trashConfirmOpen}
}, [onClickNewPage]); onConfirm={handleOnConfirm}
onOpenChange={onTrashConfirmOpenChange}
const sidebarOpen = useAtomValue(appSidebarOpenAtom); titles={deletePageTitles}
useEffect(() => { />
if (environment.isDesktop) { <SidebarContainer>
apis?.ui.handleSidebarVisibilityChange(sidebarOpen).catch(err => { <div className={workspaceAndUserWrapper}>
console.error(err); <div className={workspaceWrapper}>
}); <WorkspaceSelector />
} </div>
}, [sidebarOpen]); <UserInfo />
const dropItemId = getDNDId('sidebar-trash', 'container', 'trash');
const trashDroppable = useDroppable({
id: dropItemId,
});
const collection = useService(CollectionService);
const { node, open } = useEditCollectionName({
title: t['com.affine.editCollection.createCollection'](),
showTips: true,
});
const handleCreateCollection = useCallback(() => {
open('')
.then(name => {
const id = nanoid();
collection.addCollection(createEmptyCollection(id, { name }));
navigateHelper.jumpToCollection(docCollection.id, id);
})
.catch(err => {
console.error(err);
});
}, [docCollection.id, collection, navigateHelper, open]);
return (
<AppSidebar
clientBorder={appSettings.clientBorder}
translucentUI={appSettings.enableBlurBackground}
>
<MoveToTrash.ConfirmModal
open={trashConfirmOpen}
onConfirm={handleOnConfirm}
onOpenChange={onTrashConfirmOpenChange}
titles={deletePageTitles}
/>
<SidebarContainer>
<div className={workspaceAndUserWrapper}>
<div className={workspaceWrapper}>
<WorkspaceSelector />
</div> </div>
<UserInfo /> <QuickSearchInput
</div> data-testid="slider-bar-quick-search-button"
<QuickSearchInput onClick={onOpenQuickSearchModal}
data-testid="slider-bar-quick-search-button" />
onClick={onOpenQuickSearchModal}
/>
<RouteMenuLinkItem
icon={<FolderIcon />}
active={allPageActive}
path={paths.all(currentWorkspaceId)}
>
<span data-testid="all-pages">
{t['com.affine.workspaceSubPath.all']()}
</span>
</RouteMenuLinkItem>
<AppSidebarJournalButton
docCollection={currentWorkspace.docCollection}
/>
{runtimeConfig.enableNewSettingModal ? (
<MenuItem
data-testid="slider-bar-workspace-setting-button"
icon={<SettingsIcon />}
onClick={onOpenSettingModal}
>
<span data-testid="settings-modal-trigger">
{t['com.affine.settingSidebar.title']()}
</span>
</MenuItem>
) : null}
</SidebarContainer>
<SidebarScrollableContainer>
<FavoriteList docCollection={docCollection} />
<CategoryDivider label={t['com.affine.rootAppSidebar.collections']()}>
<AddCollectionButton node={node} onClick={handleCreateCollection} />
</CategoryDivider>
<CollectionsList
docCollection={docCollection}
onCreate={handleCreateCollection}
/>
<CategoryDivider label={t['com.affine.rootAppSidebar.others']()} />
{/* fixme: remove the following spacer */}
<div style={{ height: '4px' }} />
<div style={{ padding: '0 8px' }}>
<RouteMenuLinkItem <RouteMenuLinkItem
ref={trashDroppable.setNodeRef} icon={<FolderIcon />}
icon={<AnimatedDeleteIcon closed={trashDroppable.isOver} />} active={allPageActive}
active={trashActive || trashDroppable.isOver} path={paths.all(currentWorkspaceId)}
path={paths.trash(currentWorkspaceId)}
> >
<span data-testid="trash-page"> <span data-testid="all-pages">
{t['com.affine.workspaceSubPath.trash']()} {t['com.affine.workspaceSubPath.all']()}
</span> </span>
</RouteMenuLinkItem> </RouteMenuLinkItem>
<ImportPage docCollection={docCollection} /> <AppSidebarJournalButton
</div> docCollection={currentWorkspace.docCollection}
</SidebarScrollableContainer> />
<SidebarContainer> {runtimeConfig.enableNewSettingModal ? (
{environment.isDesktop ? <UpdaterButton /> : <AppDownloadButton />} <MenuItem
<div style={{ height: '4px' }} /> data-testid="slider-bar-workspace-setting-button"
<AddPageButton onClick={onClickNewPage} /> icon={<SettingsIcon />}
</SidebarContainer> onClick={onOpenSettingModal}
</AppSidebar> >
); <span data-testid="settings-modal-trigger">
}; {t['com.affine.settingSidebar.title']()}
</span>
</MenuItem>
) : null}
</SidebarContainer>
<SidebarScrollableContainer>
<FavoriteList docCollection={docCollection} />
<CategoryDivider label={t['com.affine.rootAppSidebar.collections']()}>
<AddCollectionButton node={node} onClick={handleCreateCollection} />
</CategoryDivider>
<CollectionsList
docCollection={docCollection}
onCreate={handleCreateCollection}
/>
<CategoryDivider label={t['com.affine.rootAppSidebar.others']()} />
{/* fixme: remove the following spacer */}
<div style={{ height: '4px' }} />
<div style={{ padding: '0 8px' }}>
<RouteMenuLinkItem
ref={trashDroppable.setNodeRef}
icon={<AnimatedDeleteIcon closed={trashDroppable.isOver} />}
active={trashActive || trashDroppable.isOver}
path={paths.trash(currentWorkspaceId)}
>
<span data-testid="trash-page">
{t['com.affine.workspaceSubPath.trash']()}
</span>
</RouteMenuLinkItem>
<ImportPage docCollection={docCollection} />
</div>
</SidebarScrollableContainer>
<SidebarContainer>
{environment.isDesktop ? <UpdaterButton /> : <AppDownloadButton />}
<div style={{ height: '4px' }} />
<AddPageButton onClick={onClickNewPage} />
</SidebarContainer>
</AppSidebar>
);
}
);
RootAppSidebar.displayName = 'memo(RootAppSidebar)';
@@ -1,6 +1,7 @@
import { Menu } from '@affine/component'; import { Menu } from '@affine/component';
import { useService, WorkspacesService } from '@toeverything/infra';
import { useAtom } from 'jotai'; import { useAtom } from 'jotai';
import { Suspense, useCallback } from 'react'; import { useCallback, useEffect } from 'react';
import { openWorkspaceListModalAtom } from '../../atoms'; import { openWorkspaceListModalAtom } from '../../atoms';
import { mixpanel } from '../../utils'; import { mixpanel } from '../../utils';
@@ -21,16 +22,21 @@ export const WorkspaceSelector = () => {
setOpenUserWorkspaceList(true); setOpenUserWorkspaceList(true);
}, [setOpenUserWorkspaceList]); }, [setOpenUserWorkspaceList]);
const workspaceManager = useService(WorkspacesService);
// revalidate workspace list when open workspace list
useEffect(() => {
if (isUserWorkspaceListOpened) {
workspaceManager.list.revalidate();
}
}, [workspaceManager, isUserWorkspaceListOpened]);
return ( return (
<Menu <Menu
rootOptions={{ rootOptions={{
open: isUserWorkspaceListOpened, open: isUserWorkspaceListOpened,
}} }}
items={ items={<UserWithWorkspaceList onEventEnd={closeUserWorkspaceList} />}
<Suspense>
<UserWithWorkspaceList onEventEnd={closeUserWorkspaceList} />
</Suspense>
}
contentOptions={{ contentOptions={{
// hide trigger // hide trigger
sideOffset: -58, sideOffset: -58,
@@ -9,7 +9,7 @@ import { useAtomValue } from 'jotai';
import type { HTMLAttributes, PropsWithChildren, ReactElement } from 'react'; import type { HTMLAttributes, PropsWithChildren, ReactElement } from 'react';
import { forwardRef } from 'react'; import { forwardRef } from 'react';
import { AppSidebarFallback, appSidebarOpenAtom } from '../app-sidebar'; import { appSidebarOpenAtom } from '../app-sidebar';
import { appStyle, mainContainerStyle, toolStyle } from './index.css'; import { appStyle, mainContainerStyle, toolStyle } from './index.css';
export type WorkspaceRootProps = PropsWithChildren<{ export type WorkspaceRootProps = PropsWithChildren<{
@@ -87,12 +87,3 @@ export const ToolContainer = (props: PropsWithChildren): ReactElement => {
</div> </div>
); );
}; };
export const WorkspaceFallback = (): ReactElement => {
return (
<AppContainer>
<AppSidebarFallback />
<MainContainer />
</AppContainer>
);
};
@@ -1,39 +0,0 @@
import type { WorkspaceMetadata } from '@toeverything/infra';
import { useService, WorkspacesService } from '@toeverything/infra';
import { useEffect, useState } from 'react';
export function useWorkspaceBlobObjectUrl(
meta?: WorkspaceMetadata,
blobKey?: string | null
) {
const workspacesService = useService(WorkspacesService);
const [blob, setBlob] = useState<string | undefined>(undefined);
useEffect(() => {
setBlob(undefined);
if (!blobKey || !meta) {
return;
}
let canceled = false;
let objectUrl: string = '';
workspacesService
.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, workspacesService]);
return blob;
}
@@ -4,24 +4,16 @@ import {
useService, useService,
WorkspacesService, WorkspacesService,
} from '@toeverything/infra'; } from '@toeverything/infra';
import { useEffect, useState } from 'react'; import { useEffect } from 'react';
import { useWorkspaceBlobObjectUrl } from './use-workspace-blob';
export function useWorkspaceInfo(meta: WorkspaceMetadata) { export function useWorkspaceInfo(meta: WorkspaceMetadata) {
const workspacesService = useService(WorkspacesService); const workspacesService = useService(WorkspacesService);
const [profile, setProfile] = useState(() => const profile = workspacesService.getProfile(meta);
workspacesService.getProfile(meta)
);
useEffect(() => { useEffect(() => {
const profile = workspacesService.getProfile(meta);
profile.revalidate(); profile.revalidate();
}, [meta, profile]);
setProfile(profile);
}, [meta, workspacesService]);
return useLiveData(profile.profile$); return useLiveData(profile.profile$);
} }
@@ -31,10 +23,3 @@ export function useWorkspaceName(meta: WorkspaceMetadata) {
return information?.name; return information?.name;
} }
export function useWorkspaceAvatar(meta: WorkspaceMetadata) {
const information = useWorkspaceInfo(meta);
const avatar = useWorkspaceBlobObjectUrl(meta, information?.avatar);
return avatar;
}
@@ -16,7 +16,7 @@ import {
} from '@toeverything/infra'; } from '@toeverything/infra';
import { useAtom, useAtomValue, useSetAtom } from 'jotai'; import { useAtom, useAtomValue, useSetAtom } from 'jotai';
import type { PropsWithChildren, ReactNode } from 'react'; import type { PropsWithChildren, ReactNode } from 'react';
import { lazy, Suspense, useCallback, useEffect, useState } from 'react'; import { lazy, useCallback, useEffect, useMemo, useState } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { Map as YMap } from 'yjs'; import { Map as YMap } from 'yjs';
@@ -24,14 +24,11 @@ import { openQuickSearchModalAtom, openSettingModalAtom } from '../atoms';
import { WorkspaceAIOnboarding } from '../components/affine/ai-onboarding'; import { WorkspaceAIOnboarding } from '../components/affine/ai-onboarding';
import { AppContainer } from '../components/affine/app-container'; import { AppContainer } from '../components/affine/app-container';
import { SyncAwareness } from '../components/affine/awareness'; import { SyncAwareness } from '../components/affine/awareness';
import { import { appSidebarResizingAtom } from '../components/app-sidebar';
AppSidebarFallback,
appSidebarResizingAtom,
} from '../components/app-sidebar';
import { usePageHelper } from '../components/blocksuite/block-suite-page-list/utils'; import { usePageHelper } from '../components/blocksuite/block-suite-page-list/utils';
import type { DraggableTitleCellData } from '../components/page-list'; import type { DraggableTitleCellData } from '../components/page-list';
import { RootAppSidebar } from '../components/root-app-sidebar'; import { RootAppSidebar } from '../components/root-app-sidebar';
import { MainContainer, WorkspaceFallback } from '../components/workspace'; import { MainContainer } from '../components/workspace';
import { WorkspaceUpgrade } from '../components/workspace-upgrade'; import { WorkspaceUpgrade } from '../components/workspace-upgrade';
import { useAppSettingHelper } from '../hooks/affine/use-app-setting-helper'; import { useAppSettingHelper } from '../hooks/affine/use-app-setting-helper';
import { import {
@@ -93,15 +90,11 @@ export const WorkspaceLayout = function WorkspaceLayout({
return ( return (
<SWRConfigProvider> <SWRConfigProvider>
{/* load all workspaces is costly, do not block the whole UI */} {/* load all workspaces is costly, do not block the whole UI */}
<Suspense> <AllWorkspaceModals />
<AllWorkspaceModals /> <CurrentWorkspaceModals />
<CurrentWorkspaceModals /> <WorkspaceLayoutInner>{children}</WorkspaceLayoutInner>
</Suspense> {/* should show after workspace loaded */}
<Suspense fallback={<WorkspaceFallback />}> <WorkspaceAIOnboarding />
<WorkspaceLayoutInner>{children}</WorkspaceLayoutInner>
{/* should show after workspace loaded */}
<WorkspaceAIOnboarding />
</Suspense>
</SWRConfigProvider> </SWRConfigProvider>
); );
}; };
@@ -177,11 +170,18 @@ export const WorkspaceLayoutInner = ({ children }: PropsWithChildren) => {
const resizing = useAtomValue(appSidebarResizingAtom); const resizing = useAtomValue(appSidebarResizingAtom);
const sensors = useSensors( const sensors = useSensors(
useSensor(MouseSensor, { useSensor(
activationConstraint: { MouseSensor,
distance: 10, useMemo(
}, /* useMemo is necessary to avoid re-render */
}) () => ({
activationConstraint: {
distance: 10,
},
}),
[]
)
)
); );
const { handleDragEnd } = useGlobalDNDHelper(); const { handleDragEnd } = useGlobalDNDHelper();
@@ -192,27 +192,23 @@ export const WorkspaceLayoutInner = ({ children }: PropsWithChildren) => {
{/* This DndContext is used for drag page from all-pages list into a folder in sidebar */} {/* This DndContext is used for drag page from all-pages list into a folder in sidebar */}
<DndContext sensors={sensors} onDragEnd={handleDragEnd}> <DndContext sensors={sensors} onDragEnd={handleDragEnd}>
<AppContainer data-current-path={currentPath} resizing={resizing}> <AppContainer data-current-path={currentPath} resizing={resizing}>
<Suspense fallback={<AppSidebarFallback />}> <RootAppSidebar
<RootAppSidebar isPublicWorkspace={false}
isPublicWorkspace={false} onOpenQuickSearchModal={handleOpenQuickSearchModal}
onOpenQuickSearchModal={handleOpenQuickSearchModal} onOpenSettingModal={handleOpenSettingModal}
onOpenSettingModal={handleOpenSettingModal} currentWorkspace={currentWorkspace}
currentWorkspace={currentWorkspace} openPage={useCallback(
openPage={useCallback( (pageId: string) => {
(pageId: string) => { assertExists(currentWorkspace);
assertExists(currentWorkspace); return openPage(currentWorkspace.id, pageId);
return openPage(currentWorkspace.id, pageId); },
}, [currentWorkspace, openPage]
[currentWorkspace, openPage] )}
)} createPage={handleCreatePage}
createPage={handleCreatePage} paths={pathGenerator}
paths={pathGenerator} />
/>
</Suspense>
<MainContainer clientBorder={appSettings.clientBorder}> <MainContainer clientBorder={appSettings.clientBorder}>
<Suspense> {needUpgrade || upgrading ? <WorkspaceUpgrade /> : children}
{needUpgrade || upgrading ? <WorkspaceUpgrade /> : children}
</Suspense>
</MainContainer> </MainContainer>
</AppContainer> </AppContainer>
<GlobalDragOverlay /> <GlobalDragOverlay />
@@ -16,7 +16,14 @@ import type {
PropsWithChildren, PropsWithChildren,
RefObject, RefObject,
} from 'react'; } from 'react';
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import {
memo,
useCallback,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react';
import type { View } from '../../entities/view'; import type { View } from '../../entities/view';
import { WorkbenchService } from '../../services/workbench'; import { WorkbenchService } from '../../services/workbench';
@@ -57,7 +64,7 @@ export const SplitViewPanel = memo(function SplitViewPanel({
const isDragging = dndIsDragging || indicatorPressed; const isDragging = dndIsDragging || indicatorPressed;
const isActive = activeView === view; const isActive = activeView === view;
useEffect(() => { useLayoutEffect(() => {
if (ref.current) { if (ref.current) {
setSlots?.(slots => ({ ...slots, [view.id]: ref })); setSlots?.(slots => ({ ...slots, [view.id]: ref }));
} }
@@ -15,7 +15,7 @@ import {
import { useService } from '@toeverything/infra'; import { useService } from '@toeverything/infra';
import clsx from 'clsx'; import clsx from 'clsx';
import type { HTMLAttributes, RefObject } from 'react'; import type { HTMLAttributes, RefObject } from 'react';
import { useCallback, useRef, useState } from 'react'; import { useCallback, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import type { View } from '../../entities/view'; import type { View } from '../../entities/view';
@@ -52,11 +52,18 @@ export const SplitView = ({
const workbench = useService(WorkbenchService).workbench; const workbench = useService(WorkbenchService).workbench;
const sensors = useSensors( const sensors = useSensors(
useSensor(PointerSensor, { useSensor(
activationConstraint: { PointerSensor,
distance: 0, useMemo(
}, /* avoid re-rendering */
}) () => ({
activationConstraint: {
distance: 0,
},
}),
[]
)
)
); );
const onResizing = useCallback( const onResizing = useCallback(
@@ -1,5 +1,5 @@
import { FrameworkScope, useLiveData } from '@toeverything/infra'; import { FrameworkScope, useLiveData } from '@toeverything/infra';
import { lazy as reactLazy, useEffect, useMemo } from 'react'; import { lazy as reactLazy, useLayoutEffect, useMemo } from 'react';
import { import {
createMemoryRouter, createMemoryRouter,
RouterProvider, RouterProvider,
@@ -34,7 +34,7 @@ export const ViewRoot = ({ view }: { view: View }) => {
const location = useLiveData(view.location$); const location = useLiveData(view.location$);
useEffect(() => { useLayoutEffect(() => {
viewRouter.navigate(location).catch(err => { viewRouter.navigate(location).catch(err => {
console.error('navigate error', err); console.error('navigate error', err);
}); });
@@ -1,5 +1,5 @@
import { useLiveData, useService } from '@toeverything/infra'; import { useLiveData, useService } from '@toeverything/infra';
import { useCallback, useEffect, useRef } from 'react'; import { memo, useCallback, useEffect, useRef } from 'react';
import { useLocation } from 'react-router-dom'; import { useLocation } from 'react-router-dom';
import type { View } from '../entities/view'; import type { View } from '../entities/view';
@@ -14,7 +14,7 @@ const useAdapter = environment.isDesktop
? useBindWorkbenchToDesktopRouter ? useBindWorkbenchToDesktopRouter
: useBindWorkbenchToBrowserRouter; : useBindWorkbenchToBrowserRouter;
export const WorkbenchRoot = () => { export const WorkbenchRoot = memo(() => {
const workbench = useService(WorkbenchService).workbench; const workbench = useService(WorkbenchService).workbench;
// for debugging // for debugging
@@ -50,7 +50,9 @@ export const WorkbenchRoot = () => {
onMove={onMove} onMove={onMove}
/> />
); );
}; });
WorkbenchRoot.displayName = 'memo(WorkbenchRoot)';
const WorkbenchView = ({ view, index }: { view: View; index: number }) => { const WorkbenchView = ({ view, index }: { view: View; index: number }) => {
const workbench = useService(WorkbenchService).workbench; const workbench = useService(WorkbenchService).workbench;
@@ -24,6 +24,7 @@ import {
type WorkspaceProfileInfo, type WorkspaceProfileInfo,
} from '@toeverything/infra'; } from '@toeverything/infra';
import { effect, globalBlockSuiteSchema, Service } from '@toeverything/infra'; import { effect, globalBlockSuiteSchema, Service } from '@toeverything/infra';
import { isEqual } from 'lodash-es';
import { nanoid } from 'nanoid'; import { nanoid } from 'nanoid';
import { EMPTY, map, mergeMap } from 'rxjs'; import { EMPTY, map, mergeMap } from 'rxjs';
import { applyUpdate, encodeStateAsUpdate } from 'yjs'; import { applyUpdate, encodeStateAsUpdate } from 'yjs';
@@ -148,11 +149,16 @@ export class CloudWorkspaceFlavourProviderService
mergeMap(data => { mergeMap(data => {
if (data) { if (data) {
const { accountId, workspaces } = data; const { accountId, workspaces } = data;
const sorted = workspaces.sort((a, b) => {
return a.id.localeCompare(b.id);
});
this.globalState.set( this.globalState.set(
CLOUD_WORKSPACES_CACHE_KEY + accountId, CLOUD_WORKSPACES_CACHE_KEY + accountId,
workspaces sorted
); );
this.workspaces$.next(workspaces); if (!isEqual(this.workspaces$.value, sorted)) {
this.workspaces$.next(sorted);
}
} else { } else {
this.workspaces$.next([]); this.workspaces$.next([]);
} }
@@ -22,13 +22,15 @@ export class CloudBlobStorage implements BlobStorage {
? key ? key
: `/api/workspaces/${this.workspaceId}/blobs/${key}`; : `/api/workspaces/${this.workspaceId}/blobs/${key}`;
return fetch(getBaseUrl() + suffix).then(async res => { return fetch(getBaseUrl() + suffix, { cache: 'default' }).then(
if (!res.ok) { async res => {
// status not in the range 200-299 if (!res.ok) {
return null; // status not in the range 200-299
return null;
}
return bufferToBlob(await res.arrayBuffer());
} }
return bufferToBlob(await res.arrayBuffer()); );
});
} }
async set(key: string, value: Blob) { async set(key: string, value: Blob) {
@@ -9,6 +9,7 @@ import type {
WorkspaceProfileInfo, WorkspaceProfileInfo,
} from '@toeverything/infra'; } from '@toeverything/infra';
import { globalBlockSuiteSchema, LiveData, Service } from '@toeverything/infra'; import { globalBlockSuiteSchema, LiveData, Service } from '@toeverything/infra';
import { isEqual } from 'lodash-es';
import { nanoid } from 'nanoid'; import { nanoid } from 'nanoid';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { applyUpdate, encodeStateAsUpdate } from 'yjs'; import { applyUpdate, encodeStateAsUpdate } from 'yjs';
@@ -96,12 +97,14 @@ export class LocalWorkspaceFlavourProvider
} }
workspaces$ = LiveData.from( workspaces$ = LiveData.from(
new Observable<WorkspaceMetadata[]>(subscriber => { new Observable<WorkspaceMetadata[]>(subscriber => {
let last: WorkspaceMetadata[] | null = null;
const emit = () => { const emit = () => {
subscriber.next( const value = JSON.parse(
JSON.parse( localStorage.getItem(LOCAL_WORKSPACE_LOCAL_STORAGE_KEY) ?? '[]'
localStorage.getItem(LOCAL_WORKSPACE_LOCAL_STORAGE_KEY) ?? '[]' ).map((id: string) => ({ id, flavour: WorkspaceFlavour.LOCAL }));
).map((id: string) => ({ id, flavour: WorkspaceFlavour.LOCAL })) if (isEqual(last, value)) return;
); subscriber.next(value);
last = value;
}; };
emit(); emit();
+2 -2
View File
@@ -19,8 +19,8 @@ import {
buildShowcaseWorkspace, buildShowcaseWorkspace,
createFirstAppData, createFirstAppData,
} from '../bootstrap/first-app-data'; } from '../bootstrap/first-app-data';
import { AppFallback } from '../components/affine/app-container';
import { UserWithWorkspaceList } from '../components/pure/workspace-slider-bar/user-with-workspace-list'; import { UserWithWorkspaceList } from '../components/pure/workspace-slider-bar/user-with-workspace-list';
import { WorkspaceFallback } from '../components/workspace';
import { useNavigateHelper } from '../hooks/use-navigate-helper'; import { useNavigateHelper } from '../hooks/use-navigate-helper';
import { AuthService } from '../modules/cloud'; import { AuthService } from '../modules/cloud';
import { WorkspaceSubPath } from '../shared'; import { WorkspaceSubPath } from '../shared';
@@ -141,7 +141,7 @@ export const Component = () => {
}, [jumpToPage, openPage, workspacesService]); }, [jumpToPage, openPage, workspacesService]);
if (navigating || creating) { if (navigating || creating) {
return <WorkspaceFallback></WorkspaceFallback>; return <AppFallback></AppFallback>;
} }
// TODO: We need a no workspace page // TODO: We need a no workspace page
@@ -1,3 +1,4 @@
import { AppFallback } from '@affine/core/components/affine/app-container';
import { useWorkspace } from '@affine/core/hooks/use-workspace'; import { useWorkspace } from '@affine/core/hooks/use-workspace';
import { ZipTransformer } from '@blocksuite/blocks'; import { ZipTransformer } from '@blocksuite/blocks';
import type { Workspace } from '@toeverything/infra'; import type { Workspace } from '@toeverything/infra';
@@ -9,11 +10,10 @@ import {
WorkspacesService, WorkspacesService,
} from '@toeverything/infra'; } from '@toeverything/infra';
import type { ReactElement } from 'react'; import type { ReactElement } from 'react';
import { Suspense, useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { AffineErrorBoundary } from '../../components/affine/affine-error-boundary'; import { AffineErrorBoundary } from '../../components/affine/affine-error-boundary';
import { WorkspaceFallback } from '../../components/workspace';
import { WorkspaceLayout } from '../../layouts/workspace-layout'; import { WorkspaceLayout } from '../../layouts/workspace-layout';
import { RightSidebarContainer } from '../../modules/right-sidebar'; import { RightSidebarContainer } from '../../modules/right-sidebar';
import { WorkbenchRoot } from '../../modules/workbench'; import { WorkbenchRoot } from '../../modules/workbench';
@@ -121,13 +121,13 @@ export const Component = (): ReactElement => {
return <PageNotFound noPermission />; return <PageNotFound noPermission />;
} }
if (!workspace) { if (!workspace) {
return <WorkspaceFallback key="workspaceLoading" />; return <AppFallback key="workspaceLoading" />;
} }
if (!isRootDocReady) { if (!isRootDocReady) {
return ( return (
<FrameworkScope scope={workspace.scope}> <FrameworkScope scope={workspace.scope}>
<WorkspaceFallback key="workspaceLoading" /> <AppFallback key="workspaceLoading" />
<AllWorkspaceModals /> <AllWorkspaceModals />
</FrameworkScope> </FrameworkScope>
); );
@@ -135,14 +135,12 @@ export const Component = (): ReactElement => {
return ( return (
<FrameworkScope scope={workspace.scope}> <FrameworkScope scope={workspace.scope}>
<Suspense fallback={<WorkspaceFallback key="workspaceFallback" />}> <AffineErrorBoundary height="100vh">
<AffineErrorBoundary height="100vh"> <WorkspaceLayout>
<WorkspaceLayout> <WorkbenchRoot />
<WorkbenchRoot /> <RightSidebarContainer />
<RightSidebarContainer /> </WorkspaceLayout>
</WorkspaceLayout> </AffineErrorBoundary>
</AffineErrorBoundary>
</Suspense>
</FrameworkScope> </FrameworkScope>
); );
}; };
+2 -2
View File
@@ -4,7 +4,7 @@ import '@affine/component/theme/theme.css';
import { NotificationCenter } from '@affine/component'; import { NotificationCenter } from '@affine/component';
import { AffineContext } from '@affine/component/context'; import { AffineContext } from '@affine/component/context';
import { GlobalLoading } from '@affine/component/global-loading'; import { GlobalLoading } from '@affine/component/global-loading';
import { WorkspaceFallback } from '@affine/core/components/workspace'; import { AppFallback } from '@affine/core/components/affine/app-container';
import { configureCommonModules, configureImpls } from '@affine/core/modules'; import { configureCommonModules, configureImpls } from '@affine/core/modules';
import { import {
configureBrowserWorkspaceFlavours, configureBrowserWorkspaceFlavours,
@@ -108,7 +108,7 @@ export function App() {
<GlobalLoading /> <GlobalLoading />
<NotificationCenter /> <NotificationCenter />
<RouterProvider <RouterProvider
fallbackElement={<WorkspaceFallback key="RouterFallback" />} fallbackElement={<AppFallback key="RouterFallback" />}
router={router} router={router}
future={future} future={future}
/> />
+2 -2
View File
@@ -4,7 +4,7 @@ import '@affine/component/theme/theme.css';
import { NotificationCenter } from '@affine/component'; import { NotificationCenter } from '@affine/component';
import { AffineContext } from '@affine/component/context'; import { AffineContext } from '@affine/component/context';
import { GlobalLoading } from '@affine/component/global-loading'; import { GlobalLoading } from '@affine/component/global-loading';
import { WorkspaceFallback } from '@affine/core/components/workspace'; import { AppFallback } from '@affine/core/components/affine/app-container';
import { configureCommonModules, configureImpls } from '@affine/core/modules'; import { configureCommonModules, configureImpls } from '@affine/core/modules';
import { import {
configureBrowserWorkspaceFlavours, configureBrowserWorkspaceFlavours,
@@ -97,7 +97,7 @@ export function App() {
<GlobalLoading /> <GlobalLoading />
<NotificationCenter /> <NotificationCenter />
<RouterProvider <RouterProvider
fallbackElement={<WorkspaceFallback key="RouterFallback" />} fallbackElement={<AppFallback key="RouterFallback" />}
router={router} router={router}
future={future} future={future}
/> />
@@ -26,19 +26,29 @@ test('should create a page with a local first avatar and remove it', async ({
await page.getByTestId('current-workspace-label').click(); await page.getByTestId('current-workspace-label').click();
await page await page
.getByTestId('upload-avatar') .getByTestId('upload-avatar')
.setInputFiles(resolve(rootDir, 'tests', 'fixtures', 'smile.png')); .setInputFiles(resolve(rootDir, 'tests', 'fixtures', 'blue.png'));
await page.mouse.click(0, 0); await page.mouse.click(0, 0);
await page.getByTestId('workspace-name').click(); await page.getByTestId('workspace-name').click();
await page.getByTestId('workspace-card').nth(0).click(); await page.getByTestId('workspace-card').nth(0).click();
await page.waitForTimeout(1000); await page.waitForTimeout(1000);
await page.getByTestId('workspace-name').click(); await page.getByTestId('workspace-name').click();
await page.getByTestId('workspace-card').nth(1).click(); await page.getByTestId('workspace-card').nth(1).click();
const blobUrl = await page const avatarCanvas = await page
.getByTestId('workspace-avatar') .getByTestId('workspace-avatar')
.locator('img') .locator('canvas')
.getAttribute('src'); .first()
// out user uploaded avatar .elementHandle();
expect(blobUrl).toContain('blob:'); const avatarPixelData = await page.evaluate(
({ avatarCanvas }) => {
return Array.from(
(avatarCanvas as HTMLCanvasElement)
.getContext('2d')!
.getImageData(1, 1, 1, 1).data // get pixel data of the avatar
);
},
{ avatarCanvas }
);
expect(avatarPixelData).toEqual([0, 0, 255, 255]); // blue color
// Click remove button to remove workspace avatar // Click remove button to remove workspace avatar
await page.getByTestId('settings-modal-trigger').click(); await page.getByTestId('settings-modal-trigger').click();
@@ -51,7 +61,7 @@ test('should create a page with a local first avatar and remove it', async ({
await page.getByTestId('workspace-card').nth(1).click(); await page.getByTestId('workspace-card').nth(1).click();
const removedAvatarImage = await page const removedAvatarImage = await page
.getByTestId('workspace-avatar') .getByTestId('workspace-avatar')
.locator('img') .locator('canvas')
.count(); .count();
expect(removedAvatarImage).toBe(0); expect(removedAvatarImage).toBe(0);
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 138 B