diff --git a/packages/frontend/core/src/modules/peek-view/entities/peek-view.ts b/packages/frontend/core/src/modules/peek-view/entities/peek-view.ts index 36581f15e..960c74888 100644 --- a/packages/frontend/core/src/modules/peek-view/entities/peek-view.ts +++ b/packages/frontend/core/src/modules/peek-view/entities/peek-view.ts @@ -133,9 +133,17 @@ function resolvePeekInfoFromPeekTarget( return; } +export type PeekViewAnimation = 'fade' | 'zoom' | 'none'; + export class PeekViewEntity extends Entity { private readonly _active$ = new LiveData(null); - private readonly _show$ = new LiveData(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, + }); }; } diff --git a/packages/frontend/core/src/modules/peek-view/services/peek-view.ts b/packages/frontend/core/src/modules/peek-view/services/peek-view.ts index c4abfe232..025f8464b 100644 --- a/packages/frontend/core/src/modules/peek-view/services/peek-view.ts +++ b/packages/frontend/core/src/modules/peek-view/services/peek-view.ts @@ -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); } diff --git a/packages/frontend/core/src/modules/peek-view/view/image-preview/index.tsx b/packages/frontend/core/src/modules/peek-view/view/image-preview/index.tsx index c8b8da12e..d97f32328 100644 --- a/packages/frontend/core/src/modules/peek-view/view/image-preview/index.tsx +++ b/packages/frontend/core/src/modules/peek-view/view/image-preview/index.tsx @@ -451,7 +451,7 @@ export const ImagePreviewPeekView = ( ): ReactElement | null => { const [blockId, setBlockId] = useState(props.blockId); const peekView = useService(PeekViewService).peekView; - const onClose = peekView.close; + const onClose = useCallback(() => peekView.close(), [peekView]); const buttonRef = useRef(null); useEffect(() => { diff --git a/packages/frontend/core/src/modules/peek-view/view/modal-container.css.ts b/packages/frontend/core/src/modules/peek-view/view/modal-container.css.ts index a2d144954..ffbbffb3d 100644 --- a/packages/frontend/core/src/modules/peek-view/view/modal-container.css.ts +++ b/packages/frontend/core/src/modules/peek-view/view/modal-container.css.ts @@ -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', diff --git a/packages/frontend/core/src/modules/peek-view/view/modal-container.tsx b/packages/frontend/core/src/modules/peek-view/view/modal-container.tsx index 347c13f2d..97702610b 100644 --- a/packages/frontend/core/src/modules/peek-view/view/modal-container.tsx +++ b/packages/frontend/core/src/modules/peek-view/view/modal-container.tsx @@ -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(null); + const contentRef = useRef(null); + const overlayRef = useRef(null); + const controlsRef = useRef(null); + const prevAnimeMap = useRef>({}); + + 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(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(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 (
- {children} + +
+ {children} +
{controls ? ( -
{controls}
+
+ {controls} +
) : null}
diff --git a/packages/frontend/core/src/modules/peek-view/view/peek-view-controls.tsx b/packages/frontend/core/src/modules/peek-view/view/peek-view-controls.tsx index 4eb641a3a..da62eb2fe 100644 --- a/packages/frontend/core/src/modules/peek-view/view/peek-view-controls.tsx +++ b/packages/frontend/core/src/modules/peek-view/view/peek-view-controls.tsx @@ -78,7 +78,7 @@ export const DefaultPeekViewControls = ({ icon: , 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: , nameKey: 'close', name: t['com.affine.peek-view-controls.close'](), - onClick: peekView.close, + onClick: () => peekView.close(), }, { icon: , @@ -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)); diff --git a/packages/frontend/core/src/modules/peek-view/view/peek-view-manager.tsx b/packages/frontend/core/src/modules/peek-view/view/peek-view-manager.tsx index 23ac2d35f..1030e3161 100644 --- a/packages/frontend/core/src/modules/peek-view/view/peek-view-manager.tsx +++ b/packages/frontend/core/src/modules/peek-view/view/peek-view-manager.tsx @@ -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 ( { if (!open) { peekViewEntity.close();