mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-09-22 19:53:48 +08:00
fix(core): fix ui flashing (#7056)
This commit is contained in:
@@ -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
-4
@@ -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={{
|
||||||
|
|||||||
+4
-11
@@ -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({
|
||||||
|
|||||||
+1
-6
@@ -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 ? (
|
||||||
|
|||||||
+12
-5
@@ -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]
|
||||||
);
|
);
|
||||||
|
|||||||
+5
-9
@@ -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();
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
Vendored
BIN
Binary file not shown.
|
After Width: | Height: | Size: 138 B |
Reference in New Issue
Block a user