refactor(core): view transition api for peek-view modal animation (#7350)

Remaining issue:
modal controls' position is not relatively moved to the parent container during transitioning.
This commit is contained in:
pengx17
2024-06-27 08:38:16 +00:00
parent 03af538989
commit 81462fe000
3 changed files with 120 additions and 139 deletions

View File

@@ -5,6 +5,7 @@ import { AffineErrorBoundary } from '@affine/core/components/affine/affine-error
import { BlockSuiteEditor } from '@affine/core/components/blocksuite/block-suite-editor'; import { BlockSuiteEditor } from '@affine/core/components/blocksuite/block-suite-editor';
import { useNavigateHelper } from '@affine/core/hooks/use-navigate-helper'; import { useNavigateHelper } from '@affine/core/hooks/use-navigate-helper';
import { PageNotFound } from '@affine/core/pages/404'; import { PageNotFound } from '@affine/core/pages/404';
import { DebugLogger } from '@affine/debug';
import { Bound, type EdgelessRootService } from '@blocksuite/blocks'; import { Bound, type EdgelessRootService } from '@blocksuite/blocks';
import { DisposableGroup } from '@blocksuite/global/utils'; import { DisposableGroup } from '@blocksuite/global/utils';
import type { AffineEditorContainer } from '@blocksuite/presets'; import type { AffineEditorContainer } from '@blocksuite/presets';
@@ -18,10 +19,13 @@ import { PeekViewService } from '../../services/peek-view';
import { useDoc } from '../utils'; import { useDoc } from '../utils';
import * as styles from './doc-peek-view.css'; import * as styles from './doc-peek-view.css';
const logger = new DebugLogger('doc-peek-view');
function fitViewport( function fitViewport(
editor: AffineEditorContainer, editor: AffineEditorContainer,
xywh?: `[${number},${number},${number},${number}]` xywh?: `[${number},${number},${number},${number}]`
) { ) {
try {
const rootService = const rootService =
editor.host.std.spec.getService<EdgelessRootService>('affine:page'); editor.host.std.spec.getService<EdgelessRootService>('affine:page');
rootService.viewport.onResize(); rootService.viewport.onResize();
@@ -44,6 +48,9 @@ function fitViewport(
false false
); );
} }
} catch (e) {
logger.warn('failed to fitViewPort', e);
}
} }
export function DocPeekPreview({ export function DocPeekPreview({
@@ -71,21 +78,13 @@ export function DocPeekPreview({
const [resolvedMode, setResolvedMode] = useState<DocMode | undefined>(mode); const [resolvedMode, setResolvedMode] = useState<DocMode | undefined>(mode);
useEffect(() => { useEffect(() => {
requestAnimationFrame(() => { editor?.updateComplete
if (editor && editor.host && resolvedMode === 'edgeless') { .then(() => {
editor.host if (resolvedMode === 'edgeless') {
.closest('[data-testid="peek-view-modal-animation-container"]')
?.addEventListener(
'animationend',
() => {
fitViewport(editor, xywh); fitViewport(editor, xywh);
},
{
once: true,
} }
); })
} .catch(console.error);
});
return; return;
}, [editor, resolvedMode, xywh]); }, [editor, resolvedMode, xywh]);

View File

@@ -1,5 +1,11 @@
import { cssVar } from '@toeverything/theme'; import { cssVar } from '@toeverything/theme';
import { createVar, keyframes, style } from '@vanilla-extract/css'; import {
createVar,
generateIdentifier,
globalStyle,
keyframes,
style,
} from '@vanilla-extract/css';
export const animationTimeout = createVar(); export const animationTimeout = createVar();
export const transformOrigin = createVar(); export const transformOrigin = createVar();
@@ -42,26 +48,50 @@ const fadeOut = keyframes({
}, },
}); });
const slideRight = keyframes({ // every item must have its own unique view-transition-name
from: { const vtContentZoom = generateIdentifier('content-zoom');
transform: 'translateX(-200%)', const vtContentFade = generateIdentifier('content-fade');
opacity: 0, const vtOverlayFade = generateIdentifier('content-fade');
},
to: { globalStyle(`::view-transition-group(${vtOverlayFade})`, {
transform: 'translateX(0)', animationDuration: animationTimeout,
opacity: 1,
},
}); });
const slideLeft = keyframes({ globalStyle(`::view-transition-new(${vtOverlayFade})`, {
from: { animationName: fadeIn,
transform: 'translateX(0)', });
opacity: 1,
}, globalStyle(`::view-transition-old(${vtOverlayFade})`, {
to: { animationName: fadeOut,
transform: 'translateX(-200%)', });
opacity: 0,
}, 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,
}); });
export const modalOverlay = style({ export const modalOverlay = style({
@@ -69,17 +99,7 @@ export const modalOverlay = style({
inset: 0, inset: 0,
zIndex: cssVar('zIndexModal'), zIndex: cssVar('zIndexModal'),
backgroundColor: cssVar('black30'), backgroundColor: cssVar('black30'),
opacity: 0, viewTransitionName: vtOverlayFade,
selectors: {
'&[data-state=entered], &[data-state=entering]': {
animation: `${fadeIn} ${animationTimeout} ease-in-out`,
animationFillMode: 'forwards',
},
'&[data-state=exited], &[data-state=exiting]': {
animation: `${fadeOut} ${animationTimeout} ease-in-out`,
animationFillMode: 'backwards',
},
},
}); });
export const modalContentWrapper = style({ export const modalContentWrapper = style({
@@ -96,42 +116,14 @@ export const modalContentContainer = style({
alignItems: 'flex-start', alignItems: 'flex-start',
width: '100%', width: '100%',
height: '100%', height: '100%',
willChange: 'transform, opacity',
transformOrigin: transformOrigin,
selectors: {
'&[data-state=entered], &[data-state=entering]': {
animationFillMode: 'forwards',
animationDuration: animationTimeout,
animationTimingFunction: 'cubic-bezier(0.42, 0, 0.58, 1)',
},
'&[data-state=exited], &[data-state=exiting]': {
animationFillMode: 'forwards',
animationDuration: animationTimeout,
animationTimingFunction: 'cubic-bezier(0.42, 0, 0.58, 1)',
},
},
}); });
export const modalContentContainerWithZoom = style({ export const modalContentContainerWithZoom = style({
selectors: { viewTransitionName: vtContentZoom,
'&[data-state=entered], &[data-state=entering]': {
animationName: zoomIn,
},
'&[data-state=exited], &[data-state=exiting]': {
animationName: zoomOut,
},
},
}); });
export const modalContentContainerWithFade = style({ export const modalContentContainerWithFade = style({
selectors: { viewTransitionName: vtContentFade,
'&[data-state=entered], &[data-state=entering]': {
animationName: fadeIn,
},
'&[data-state=exited], &[data-state=exiting]': {
animationName: fadeOut,
},
},
}); });
export const containerPadding = style({ export const containerPadding = style({
@@ -162,20 +154,5 @@ export const modalControls = style({
zIndex: -1, zIndex: -1,
minWidth: '48px', minWidth: '48px',
padding: '8px 0 0 16px', padding: '8px 0 0 16px',
opacity: 0,
pointerEvents: 'auto', pointerEvents: 'auto',
selectors: {
'&[data-state=entered], &[data-state=entering]': {
animationName: slideRight,
animationDuration: animationTimeout,
animationFillMode: 'forwards',
animationTimingFunction: 'ease-in-out',
},
'&[data-state=exited], &[data-state=exiting]': {
animationName: slideLeft,
animationDuration: animationTimeout,
animationFillMode: 'forwards',
animationTimingFunction: 'ease-in-out',
},
},
}); });

View File

@@ -1,5 +1,4 @@
import * as Dialog from '@radix-ui/react-dialog'; import * as Dialog from '@radix-ui/react-dialog';
import { assignInlineVars } from '@vanilla-extract/dynamic';
import clsx from 'clsx'; import clsx from 'clsx';
import { import {
createContext, createContext,
@@ -9,7 +8,7 @@ import {
useEffect, useEffect,
useState, useState,
} from 'react'; } from 'react';
import useTransition from 'react-transition-state'; import { flushSync } from 'react-dom';
import * as styles from './modal-container.css'; import * as styles from './modal-container.css';
@@ -42,6 +41,15 @@ export const useInsidePeekView = () => {
return !!context; return !!context;
}; };
/**
* Convert var(--xxx) to --xxx
* @param fullName
* @returns
*/
function toCssVarName(fullName: string) {
return fullName.slice(4, -1);
}
function getElementScreenPositionCenter(target: HTMLElement) { function getElementScreenPositionCenter(target: HTMLElement) {
const rect = target.getBoundingClientRect(); const rect = target.getBoundingClientRect();
@@ -63,7 +71,6 @@ export type PeekViewModalContainerProps = PropsWithChildren<{
open: boolean; open: boolean;
target?: HTMLElement; target?: HTMLElement;
controls?: React.ReactNode; controls?: React.ReactNode;
hideOnEntering?: boolean;
onAnimationStart?: () => void; onAnimationStart?: () => void;
onAnimateEnd?: () => void; onAnimateEnd?: () => void;
padding?: boolean; padding?: boolean;
@@ -81,7 +88,6 @@ export const PeekViewModalContainer = forwardRef<
target, target,
controls, controls,
children, children,
hideOnEntering,
onAnimationStart, onAnimationStart,
onAnimateEnd, onAnimateEnd,
animation = 'zoom', animation = 'zoom',
@@ -90,33 +96,40 @@ export const PeekViewModalContainer = forwardRef<
}, },
ref ref
) { ) {
const [{ status }, toggle] = useTransition({ const [vtOpen, setVtOpen] = useState(open);
timeout: animationTimeout, useEffect(() => {
}); document.startViewTransition(() => {
const [transformOrigin, setTransformOrigin] = useState<string | null>(null); flushSync(() => {
setVtOpen(open);
});
});
}, [open]);
useEffect(() => { useEffect(() => {
toggle(open);
const bondingBox = target ? getElementScreenPositionCenter(target) : null; const bondingBox = target ? getElementScreenPositionCenter(target) : null;
const offsetLeft = const offsetLeft =
(window.innerWidth - Math.min(window.innerWidth * 0.9, 1200)) / 2; (window.innerWidth - Math.min(window.innerWidth * 0.9, 1200)) / 2;
const modalHeight = window.innerHeight * 0.05; const modalHeight = window.innerHeight * 0.05;
setTransformOrigin( const transformOrigin = bondingBox
bondingBox
? `${bondingBox.x - offsetLeft}px ${bondingBox.y - modalHeight}px` ? `${bondingBox.x - offsetLeft}px ${bondingBox.y - modalHeight}px`
: null : null;
document.documentElement.style.setProperty(
toCssVarName(styles.transformOrigin),
transformOrigin
);
document.documentElement.style.setProperty(
toCssVarName(styles.animationTimeout),
animationTimeout + 'ms'
); );
}, [open, target]); }, [open, target]);
return ( return (
<PeekViewContext.Provider value={emptyContext}> <PeekViewContext.Provider value={emptyContext}>
<Dialog.Root modal open={status !== 'exited'} onOpenChange={onOpenChange}> <Dialog.Root modal open={vtOpen} onOpenChange={onOpenChange}>
<Dialog.Portal> <Dialog.Portal>
<Dialog.Overlay <Dialog.Overlay
className={styles.modalOverlay} className={styles.modalOverlay}
data-state={status}
style={assignInlineVars({
[styles.transformOrigin]: transformOrigin,
[styles.animationTimeout]: `${animationTimeout}ms`,
})}
onAnimationStart={onAnimationStart} onAnimationStart={onAnimationStart}
onAnimationEnd={onAnimateEnd} onAnimationEnd={onAnimateEnd}
/> />
@@ -125,10 +138,6 @@ export const PeekViewModalContainer = forwardRef<
data-testid={testId} data-testid={testId}
data-peek-view-wrapper data-peek-view-wrapper
className={styles.modalContentWrapper} className={styles.modalContentWrapper}
style={assignInlineVars({
[styles.transformOrigin]: transformOrigin,
[styles.animationTimeout]: `${animationTimeout}ms`,
})}
> >
<div <div
className={clsx( className={clsx(
@@ -139,19 +148,15 @@ export const PeekViewModalContainer = forwardRef<
: styles.modalContentContainerWithZoom : styles.modalContentContainerWithZoom
)} )}
data-testid="peek-view-modal-animation-container" data-testid="peek-view-modal-animation-container"
data-state={status}
> >
<Dialog.Content <Dialog.Content
{...contentOptions} {...contentOptions}
data-no-interaction={status !== 'entered'}
className={styles.modalContent} className={styles.modalContent}
> >
{hideOnEntering && status === 'entering' ? null : children} {children}
</Dialog.Content> </Dialog.Content>
{controls ? ( {controls ? (
<div data-state={status} className={styles.modalControls}> <div className={styles.modalControls}>{controls}</div>
{controls}
</div>
) : null} ) : null}
</div> </div>
</div> </div>