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

View File

@@ -1,3 +1,4 @@
import { WorkspaceAvatar } from '@affine/component/workspace-avatar';
import { UNTITLED_WORKSPACE_NAME } from '@affine/env/constant';
import { WorkspaceFlavour } from '@affine/env/workspace';
import { CollaborationIcon, SettingsIcon } from '@blocksuite/icons';
@@ -5,7 +6,7 @@ import type { WorkspaceMetadata } from '@toeverything/infra';
import clsx from 'clsx';
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 { Skeleton } from '../../../ui/skeleton';
import * as styles from './styles.css';
@@ -24,7 +25,6 @@ export interface WorkspaceCardProps {
isOwner?: boolean;
openingId?: string | null;
enableCloudText?: string;
avatar?: string;
name?: string;
}
@@ -57,7 +57,6 @@ export const WorkspaceCard = ({
isOwner = true,
enableCloudText = 'Enable Cloud',
name,
avatar,
}: WorkspaceCardProps) => {
const isLocal = meta.flavour === WorkspaceFlavour.LOCAL;
const displayName = name ?? UNTITLED_WORKSPACE_NAME;
@@ -78,11 +77,12 @@ export const WorkspaceCard = ({
onClick(meta);
}, [onClick, meta])}
>
<Avatar
<WorkspaceAvatar
key={meta.id}
meta={meta}
imageProps={avatarImageProps}
fallbackProps={avatarImageProps}
size={28}
url={avatar}
name={name}
colorfulFallback
/>

View File

@@ -33,9 +33,6 @@ export const root = style({
'&[data-enable-animation="true"]': {
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"]': {
// avoid focus on hidden panel
visibility: 'hidden',

View File

@@ -1,7 +1,14 @@
import { assertExists } from '@blocksuite/global/utils';
import { assignInlineVars } from '@vanilla-extract/dynamic';
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 * as styles from './resize-panel.css';
@@ -157,7 +164,7 @@ export const ResizePanel = forwardRef<HTMLDivElement, ResizePanelProps>(
const [{ status }, toggle] = useTransition({
timeout: animationTimeout,
});
useEffect(() => {
useLayoutEffect(() => {
toggle(open);
}, [open]);
return (

View File

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

View File

@@ -18,9 +18,6 @@ export interface WorkspaceListProps {
useIsWorkspaceOwner: (
workspaceMetadata: WorkspaceMetadata
) => boolean | undefined;
useWorkspaceAvatar: (
workspaceMetadata: WorkspaceMetadata
) => string | undefined;
useWorkspaceName: (
workspaceMetadata: WorkspaceMetadata
) => string | undefined;
@@ -34,7 +31,6 @@ const SortableWorkspaceItem = ({
item,
openingId,
useIsWorkspaceOwner,
useWorkspaceAvatar,
useWorkspaceName,
currentWorkspaceId,
onClick,
@@ -42,7 +38,6 @@ const SortableWorkspaceItem = ({
onEnableCloudClick,
}: SortableWorkspaceItemProps) => {
const isOwner = useIsWorkspaceOwner?.(item);
const avatar = useWorkspaceAvatar?.(item);
const name = useWorkspaceName?.(item);
return (
<div className={workspaceItemStyle} data-testid="draggable-item">
@@ -55,7 +50,6 @@ const SortableWorkspaceItem = ({
openingId={openingId}
isOwner={isOwner}
name={name}
avatar={avatar}
/>
</div>
);

View File

@@ -17,7 +17,13 @@ import type {
MouseEvent,
ReactElement,
} from 'react';
import { forwardRef, useMemo, useState } from 'react';
import {
forwardRef,
useCallback,
useLayoutEffect,
useMemo,
useState,
} from 'react';
import { IconButton } from '../button';
import type { TooltipProps } from '../tooltip';
@@ -29,6 +35,7 @@ import { blurVar, sizeVar } from './style.css';
export type AvatarProps = {
size?: number;
url?: string | null;
image?: ImageBitmap /* use pre-loaded image data can avoid flashing */;
name?: string;
className?: string;
style?: CSSProperties;
@@ -39,18 +46,56 @@ export type AvatarProps = {
removeTooltipOptions?: Omit<TooltipProps, 'children'>;
fallbackProps?: AvatarFallbackProps;
imageProps?: Omit<AvatarImageProps, 'src'>;
imageProps?: Omit<
AvatarImageProps & React.HTMLProps<HTMLCanvasElement>,
'src' | 'ref'
>;
avatarProps?: RadixAvatarProps;
hoverWrapperProps?: HTMLAttributes<HTMLDivElement>;
removeButtonProps?: HTMLAttributes<HTMLButtonElement>;
} & 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>(
(
{
size = 20,
style: propsStyles = {},
url,
image,
name,
className,
colorfulFallback = false,
@@ -76,18 +121,35 @@ export const Avatar = forwardRef<HTMLSpanElement, AvatarProps>(
const firstCharOfName = useMemo(() => {
return name?.slice(0, 1) || 'A';
}, [name]);
const [imageDom, setImageDom] = useState<HTMLDivElement | null>(null);
const [containerDom, setContainerDom] = useState<HTMLDivElement | null>(
null
);
const [removeButtonDom, setRemoveButtonDom] =
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 (
<AvatarRoot className={style.avatarRoot} {...avatarProps} ref={ref}>
<Tooltip
portalOptions={{ container: imageDom }}
portalOptions={{ container: containerDom }}
{...avatarTooltipOptions}
>
<div
ref={setImageDom}
ref={setContainerDom}
className={clsx(style.avatarWrapper, className)}
style={{
...assignInlineVars({
@@ -98,24 +160,36 @@ export const Avatar = forwardRef<HTMLSpanElement, AvatarProps>(
}}
{...props}
>
<AvatarImage
className={style.avatarImage}
src={url || ''}
alt={name}
{...imageProps}
/>
{image /* canvas mode */ ? (
<canvas
className={style.avatarImage}
ref={canvasRef}
width={size * window.devicePixelRatio}
height={size * window.devicePixelRatio}
{...imageProps}
/>
) : (
<AvatarImage
className={style.avatarImage}
src={url || ''}
alt={name}
{...imageProps}
/>
)}
<AvatarFallback
className={clsx(style.avatarFallback, fallbackClassName)}
delayMs={url ? 600 : undefined}
{...fallbackProps}
>
{colorfulFallback ? (
<ColorfulFallback char={firstCharOfName} />
) : (
firstCharOfName.toUpperCase()
)}
</AvatarFallback>
{!image /* no fallback on canvas mode */ && (
<AvatarFallback
className={clsx(style.avatarFallback, fallbackClassName)}
delayMs={url ? 600 : undefined}
{...fallbackProps}
>
{colorfulFallback ? (
<ColorfulFallback char={firstCharOfName} />
) : (
firstCharOfName.toUpperCase()
)}
</AvatarFallback>
)}
{hoverIcon ? (
<div
className={clsx(style.hoverWrapper, hoverWrapperClassName)}