mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-12 12:28:42 +00:00
fix(core): fix ui flashing (#7056)
This commit is contained in:
@@ -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
|
||||
/>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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: (
|
||||
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>
|
||||
);
|
||||
|
||||
@@ -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)}
|
||||
|
||||
Reference in New Issue
Block a user