feat(core): adjust center peek animation (#7393)

This commit is contained in:
CatsJuice 2024-07-15 06:36:24 +00:00
parent 7082f7ea7a
commit 242c41b440
No known key found for this signature in database
GPG Key ID: 1C1E76924FAFDDE4
7 changed files with 275 additions and 203 deletions

View File

@ -133,9 +133,17 @@ function resolvePeekInfoFromPeekTarget(
return;
}
export type PeekViewAnimation = 'fade' | 'zoom' | 'none';
export class PeekViewEntity extends Entity {
private readonly _active$ = new LiveData<ActivePeekView | null>(null);
private readonly _show$ = new LiveData<boolean>(false);
private readonly _show$ = new LiveData<{
animation: PeekViewAnimation;
value: boolean;
}>({
animation: 'zoom',
value: false,
});
constructor(private readonly workbenchService: WorkbenchService) {
super();
@ -143,7 +151,7 @@ export class PeekViewEntity extends Entity {
active$ = this._active$.distinctUntilChanged();
show$ = this._show$
.map(show => show && this._active$.value !== null)
.map(show => (this._active$.value !== null ? show : null))
.distinctUntilChanged();
// return true if the peek view will be handled
@ -159,17 +167,23 @@ export class PeekViewEntity extends Entity {
const active = this._active$.value;
// if there is an active peek view and it is a doc peek view, we will navigate it first
if (active?.info.type === 'doc' && this.show$.value) {
if (active?.info.type === 'doc' && this.show$.value?.value) {
// TODO(@pengx17): scroll to the viewing position?
this.workbenchService.workbench.openDoc(active.info.docId);
}
this._active$.next({ target, info: resolvedInfo });
this._show$.next(true);
this._show$.next({
value: true,
animation: resolvedInfo.type === 'doc' ? 'zoom' : 'fade',
});
return firstValueFrom(race(this._active$, this.show$).pipe(map(() => {})));
};
close = () => {
this._show$.next(false);
close = (animation?: PeekViewAnimation) => {
this._show$.next({
value: false,
animation: animation ?? this._show$.value.animation,
});
};
}

View File

@ -3,7 +3,7 @@ import { OnEvent, Service } from '@toeverything/infra';
import { WorkbenchLocationChanged } from '../../workbench/services/workbench';
import { PeekViewEntity } from '../entities/peek-view';
@OnEvent(WorkbenchLocationChanged, e => e.peekView.close)
@OnEvent(WorkbenchLocationChanged, e => () => e.peekView.close())
export class PeekViewService extends Service {
public readonly peekView = this.framework.createEntity(PeekViewEntity);
}

View File

@ -451,7 +451,7 @@ export const ImagePreviewPeekView = (
): ReactElement | null => {
const [blockId, setBlockId] = useState<string | null>(props.blockId);
const peekView = useService(PeekViewService).peekView;
const onClose = peekView.close;
const onClose = useCallback(() => peekView.close(), [peekView]);
const buttonRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {

View File

@ -1,106 +1,17 @@
import { cssVar } from '@toeverything/theme';
import {
createVar,
generateIdentifier,
globalStyle,
keyframes,
style,
} from '@vanilla-extract/css';
export const animationTimeout = createVar();
export const transformOrigin = createVar();
export const animationType = createVar();
const zoomIn = keyframes({
from: {
transform: 'scale(0.10)',
},
to: {
transform: 'scale(1)',
},
});
const zoomOut = keyframes({
to: {
opacity: 0,
transform: 'scale(0.10)',
},
from: {
opacity: 1,
transform: 'scale(1)',
},
});
const fadeIn = keyframes({
from: {
opacity: 0,
},
to: {
opacity: 1,
},
});
const fadeOut = keyframes({
from: {
opacity: 1,
},
to: {
opacity: 0,
},
});
// every item must have its own unique view-transition-name
const vtContentZoom = generateIdentifier('content-zoom');
const vtContentFade = generateIdentifier('content-fade');
const vtOverlayFade = generateIdentifier('content-fade');
globalStyle(`::view-transition-group(${vtOverlayFade})`, {
animationDuration: animationTimeout,
});
globalStyle(`::view-transition-new(${vtOverlayFade})`, {
animationName: fadeIn,
});
globalStyle(`::view-transition-old(${vtOverlayFade})`, {
animationName: fadeOut,
});
globalStyle(
`::view-transition-group(${vtContentZoom}),
::view-transition-group(${vtContentFade})`,
{
animationDuration: animationTimeout,
animationFillMode: 'forwards',
animationTimingFunction: 'cubic-bezier(0.42, 0, 0.58, 1)',
}
);
globalStyle(`::view-transition-new(${vtContentZoom})`, {
animationName: zoomIn,
// origin has to be set in ::view-transition-new/old
transformOrigin: transformOrigin,
});
globalStyle(`::view-transition-old(${vtContentZoom})`, {
animationName: zoomOut,
transformOrigin: transformOrigin,
});
globalStyle(`::view-transition-new(${vtContentFade})`, {
animationName: fadeIn,
});
globalStyle(`::view-transition-old(${vtContentFade})`, {
animationName: fadeOut,
});
import { style } from '@vanilla-extract/css';
export const modalOverlay = style({
position: 'fixed',
inset: 0,
zIndex: cssVar('zIndexModal'),
backgroundColor: cssVar('black30'),
viewTransitionName: vtOverlayFade,
pointerEvents: 'auto',
selectors: {
'&[data-anime-state="animating"]': {
opacity: 0,
},
},
});
export const modalContentWrapper = style({
@ -113,32 +24,36 @@ export const modalContentWrapper = style({
});
export const modalContentContainer = style({
display: 'flex',
alignItems: 'flex-start',
width: '100%',
height: '100%',
position: 'relative',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderRadius: 12,
selectors: {
'[data-padding="true"] &': {
width: '90%',
height: '90%',
maxWidth: 1248,
},
'&[data-anime-state="animating"]': {
opacity: 0,
},
},
});
export const modalContentContainerWithZoom = style({
viewTransitionName: vtContentZoom,
});
export const modalContentContainerWithFade = style({
viewTransitionName: vtContentFade,
});
export const containerPadding = style({
width: '90%',
height: '90%',
maxWidth: 1248,
export const dialog = style({
backgroundColor: cssVar('backgroundOverlayPanelColor'),
boxShadow: cssVar('shadow3'),
});
export const modalContent = style({
flex: 1,
borderRadius: 'inherit',
width: '100%',
height: '100%',
backgroundColor: cssVar('backgroundOverlayPanelColor'),
backdropFilter: 'drop-shadow(0px 0px 2px rgba(0, 0, 0, 0.08))',
borderRadius: '8px',
flexShrink: 0,
overflow: 'hidden',
minHeight: 300,
// :focus-visible will set outline
outline: 'none',
@ -151,7 +66,9 @@ export const modalContent = style({
});
export const modalControls = style({
flexShrink: 0,
position: 'absolute',
left: '100%',
top: 0,
zIndex: -1,
minWidth: '48px',
padding: '8px 0 0 16px',

View File

@ -1,19 +1,20 @@
import * as Dialog from '@radix-ui/react-dialog';
import anime, { type AnimeInstance, type AnimeParams } from 'animejs';
import clsx from 'clsx';
import {
createContext,
forwardRef,
type PropsWithChildren,
useCallback,
useContext,
useEffect,
useRef,
useState,
} from 'react';
import { flushSync } from 'react-dom';
import type { PeekViewAnimation } from '../entities/peek-view';
import * as styles from './modal-container.css';
const animationTimeout = 200;
const contentOptions: Dialog.DialogContentProps = {
['data-testid' as string]: 'peek-view-modal',
onPointerDownOutside: e => {
@ -31,11 +32,6 @@ const contentOptions: Dialog.DialogContentProps = {
// this is because radix-ui register the escape key event on the document using capture, which is not possible to prevent in blocksuite
e.preventDefault();
},
style: {
padding: 0,
backgroundColor: 'transparent',
overflow: 'hidden',
},
};
// a dummy context to let elements know they are inside a peek view
@ -50,31 +46,6 @@ export const useInsidePeekView = () => {
return !!context;
};
/**
* Convert var(--xxx) to --xxx
* @param fullName
* @returns
*/
function toCssVarName(fullName: string) {
return fullName.slice(4, -1);
}
function getElementScreenPositionCenter(target: HTMLElement) {
const rect = target.getBoundingClientRect();
if (rect.top === 0 || rect.left === 0) {
return null;
}
const scrollTop = window.scrollY || document.documentElement.scrollTop;
const scrollLeft = window.scrollX || document.documentElement.scrollLeft;
return {
x: rect.x + scrollLeft + rect.width / 2,
y: rect.y + scrollTop + rect.height / 2,
};
}
export type PeekViewModalContainerProps = PropsWithChildren<{
onOpenChange: (open: boolean) => void;
open: boolean;
@ -83,8 +54,10 @@ export type PeekViewModalContainerProps = PropsWithChildren<{
onAnimationStart?: () => void;
onAnimateEnd?: () => void;
padding?: boolean;
animation?: 'fade' | 'zoom';
animation?: PeekViewAnimation;
testId?: string;
/** Whether to apply shadow & bg */
dialogFrame?: boolean;
}>;
const PeekViewModalOverlay = 'div';
@ -103,22 +76,193 @@ export const PeekViewModalContainer = forwardRef<
onAnimateEnd,
animation = 'zoom',
padding = true,
testId,
dialogFrame = true,
},
ref
) {
const [vtOpen, setVtOpen] = useState(open);
useEffect(() => {
if (document.startViewTransition) {
document.startViewTransition(() => {
flushSync(() => {
setVtOpen(open);
const [animeState, setAnimeState] = useState<'idle' | 'ready' | 'animating'>(
'idle'
);
const contentClipRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const overlayRef = useRef<HTMLDivElement>(null);
const controlsRef = useRef<HTMLDivElement>(null);
const prevAnimeMap = useRef<Record<string, AnimeInstance | undefined>>({});
const animateControls = useCallback((animateIn = false) => {
const controls = controlsRef.current;
if (!controls) return;
anime({
targets: controls,
opacity: animateIn ? [0, 1] : [1, 0],
translateX: animateIn ? [-32, 0] : [0, -32],
easing: 'easeOutQuad',
duration: 230,
});
}, []);
const zoomAnimate = useCallback(
async (
zoomIn?: boolean,
paramsMap?: {
overlay?: AnimeParams;
content?: AnimeParams;
contentWrapper?: AnimeParams;
}
) => {
return new Promise<void>(resolve => {
const contentClip = contentClipRef.current;
const content = contentRef.current;
const overlay = overlayRef.current;
if (!contentClip || !content || !target || !overlay) {
resolve();
return;
}
const targets = contentClip;
const lockSizeEl = content;
const from = zoomIn ? target : contentClip;
const to = zoomIn ? contentClip : target;
const fromRect = from.getBoundingClientRect();
const toRect = to.getBoundingClientRect();
targets.style.position = 'fixed';
targets.style.overflow = 'hidden';
targets.style.opacity = '1';
lockSizeEl.style.width = zoomIn
? `${toRect.width}px`
: `${fromRect.width}px`;
lockSizeEl.style.height = zoomIn
? `${toRect.height}px`
: `${fromRect.height}px`;
lockSizeEl.style.overflow = 'hidden';
overlay.style.pointerEvents = 'none';
prevAnimeMap.current.overlay?.pause();
prevAnimeMap.current.content?.pause();
prevAnimeMap.current.contentWrapper?.pause();
const overlayAnime = anime({
targets: overlay,
opacity: zoomIn ? [0, 1] : [1, 0],
easing: 'easeOutQuad',
duration: 230,
...paramsMap?.overlay,
});
const contentAnime =
paramsMap?.content &&
anime({
targets: content,
...paramsMap.content,
});
const contentWrapperAnime = anime({
targets,
left: [fromRect.left, toRect.left],
top: [fromRect.top, toRect.top],
width: [fromRect.width, toRect.width],
height: [fromRect.height, toRect.height],
easing: 'easeOutQuad',
duration: 230,
...paramsMap?.contentWrapper,
complete: (ins: AnimeInstance) => {
paramsMap?.contentWrapper?.complete?.(ins);
setAnimeState('idle');
overlay.style.pointerEvents = '';
if (zoomIn) {
Object.assign(targets.style, {
position: '',
left: '',
top: '',
width: '',
height: '',
overflow: '',
});
Object.assign(lockSizeEl.style, {
width: '100%',
height: '100%',
overflow: '',
});
}
resolve();
},
});
prevAnimeMap.current = {
overlay: overlayAnime,
content: contentAnime,
contentWrapper: contentWrapperAnime,
};
});
},
[target]
);
const animateZoomIn = useCallback(() => {
setAnimeState('animating');
setVtOpen(true);
setTimeout(() => {
zoomAnimate(true, {
contentWrapper: {
opacity: [0.5, 1],
easing: 'cubicBezier(.46,.36,0,1)',
duration: 400,
},
content: {
opacity: [0, 1],
duration: 100,
},
})
.then(() => animateControls(true))
.catch(console.error);
}, 0);
}, [animateControls, zoomAnimate]);
const animateZoomOut = useCallback(() => {
setAnimeState('animating');
animateControls(false);
zoomAnimate(false, {
contentWrapper: {
easing: 'cubicBezier(.57,.25,.76,.44)',
opacity: [1, 0.5],
duration: 180,
},
content: {
opacity: [1, 0],
duration: 180,
easing: 'easeOutQuad',
},
})
.then(() => setVtOpen(false))
.catch(console.error);
}, [animateControls, zoomAnimate]);
const animateFade = useCallback((animateIn: boolean) => {
setAnimeState('animating');
return new Promise<void>(resolve => {
if (animateIn) setVtOpen(true);
setTimeout(() => {
const overlay = overlayRef.current;
const contentClip = contentClipRef.current;
if (!overlay || !contentClip) {
resolve();
return;
}
anime({
targets: [overlay, contentClip],
opacity: animateIn ? [0, 1] : [1, 0],
easing: 'easeOutQuad',
duration: 230,
complete: () => {
if (!animateIn) setVtOpen(false);
setAnimeState('idle');
resolve();
},
});
});
} else {
setVtOpen(open);
}
}, [open]);
});
}, []);
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
@ -133,57 +277,53 @@ export const PeekViewModalContainer = forwardRef<
}, [onOpenChange]);
useEffect(() => {
const bondingBox = target ? getElementScreenPositionCenter(target) : null;
const offsetLeft =
(window.innerWidth - Math.min(window.innerWidth * 0.9, 1200)) / 2;
const modalHeight = window.innerHeight * 0.05;
const transformOrigin = bondingBox
? `${bondingBox.x - offsetLeft}px ${bondingBox.y - modalHeight}px`
: null;
if (animation === 'zoom') {
open ? animateZoomIn() : animateZoomOut();
} else if (animation === 'fade') {
animateFade(open).catch(console.error);
} else if (animation === 'none') {
setVtOpen(open);
}
}, [animateZoomOut, animation, open, target, animateZoomIn, animateFade]);
document.documentElement.style.setProperty(
toCssVarName(styles.transformOrigin),
transformOrigin
);
document.documentElement.style.setProperty(
toCssVarName(styles.animationTimeout),
animationTimeout + 'ms'
);
}, [open, target]);
return (
<PeekViewContext.Provider value={emptyContext}>
<Dialog.Root modal open={vtOpen} onOpenChange={onOpenChange}>
<Dialog.Portal>
<PeekViewModalOverlay
ref={overlayRef}
className={styles.modalOverlay}
onAnimationStart={onAnimationStart}
onAnimationEnd={onAnimateEnd}
data-anime-state={animeState}
/>
<div
ref={ref}
data-testid={testId}
data-padding={padding}
data-peek-view-wrapper
className={styles.modalContentWrapper}
className={clsx(styles.modalContentWrapper)}
>
<div
className={clsx(
styles.modalContentContainer,
padding && styles.containerPadding,
animation === 'fade'
? styles.modalContentContainerWithFade
: styles.modalContentContainerWithZoom
)}
data-testid="peek-view-modal-animation-container"
data-anime-state={animeState}
ref={contentClipRef}
className={styles.modalContentContainer}
>
<Dialog.Content
{...contentOptions}
className={styles.modalContent}
className={clsx({
[styles.modalContent]: true,
[styles.dialog]: dialogFrame,
})}
>
{children}
<Dialog.Title style={{ display: 'none' }} />
<div style={{ height: '100%' }} ref={contentRef}>
{children}
</div>
</Dialog.Content>
{controls ? (
<div className={styles.modalControls}>{controls}</div>
<div ref={controlsRef} className={styles.modalControls}>
{controls}
</div>
) : null}
</div>
</div>

View File

@ -78,7 +78,7 @@ export const DefaultPeekViewControls = ({
icon: <CloseIcon />,
nameKey: 'close',
name: t['com.affine.peek-view-controls.close'](),
onClick: peekView.close,
onClick: () => peekView.close(),
},
].filter((opt): opt is ControlButtonProps => Boolean(opt));
}, [peekView, t]);
@ -109,7 +109,7 @@ export const DocPeekViewControls = ({
icon: <CloseIcon />,
nameKey: 'close',
name: t['com.affine.peek-view-controls.close'](),
onClick: peekView.close,
onClick: () => peekView.close(),
},
{
icon: <ExpandFullIcon />,
@ -123,7 +123,7 @@ export const DocPeekViewControls = ({
if (mode) {
doc?.setMode(mode);
}
peekView.close();
peekView.close('none');
},
},
environment.isDesktop && {
@ -132,7 +132,7 @@ export const DocPeekViewControls = ({
name: t['com.affine.peek-view-controls.open-doc-in-split-view'](),
onClick: () => {
workbench.openDoc(docId, { at: 'beside' });
peekView.close();
peekView.close('none');
},
},
!environment.isDesktop && {
@ -144,7 +144,7 @@ export const DocPeekViewControls = ({
`/workspace/${workspace.id}/${docId}#${blockId ?? ''}`,
'_blank'
);
peekView.close();
peekView.close('none');
},
},
].filter((opt): opt is ControlButtonProps => Boolean(opt));

View File

@ -73,7 +73,7 @@ const getRendererProps = (
? activePeekView.target
: undefined,
padding: activePeekView.info.type === 'doc',
animation: activePeekView.info.type === 'image' ? 'fade' : 'zoom',
dialogFrame: activePeekView.info.type !== 'image',
};
};
@ -104,7 +104,8 @@ export const PeekViewManagerModal = () => {
return (
<PeekViewModalContainer
{...renderProps}
open={show && !!renderProps}
open={!!show?.value && !!renderProps}
animation={show?.animation || 'none'}
onOpenChange={open => {
if (!open) {
peekViewEntity.close();