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
No known key found for this signature in database
GPG Key ID: 23F23D9E8B3971ED
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 { useNavigateHelper } from '@affine/core/hooks/use-navigate-helper';
import { PageNotFound } from '@affine/core/pages/404';
import { DebugLogger } from '@affine/debug';
import { Bound, type EdgelessRootService } from '@blocksuite/blocks';
import { DisposableGroup } from '@blocksuite/global/utils';
import type { AffineEditorContainer } from '@blocksuite/presets';
@ -18,31 +19,37 @@ import { PeekViewService } from '../../services/peek-view';
import { useDoc } from '../utils';
import * as styles from './doc-peek-view.css';
const logger = new DebugLogger('doc-peek-view');
function fitViewport(
editor: AffineEditorContainer,
xywh?: `[${number},${number},${number},${number}]`
) {
const rootService =
editor.host.std.spec.getService<EdgelessRootService>('affine:page');
rootService.viewport.onResize();
try {
const rootService =
editor.host.std.spec.getService<EdgelessRootService>('affine:page');
rootService.viewport.onResize();
if (xywh) {
const viewport = {
xywh: xywh,
padding: [60, 20, 20, 20] as [number, number, number, number],
};
rootService.viewport.setViewportByBound(
Bound.deserialize(viewport.xywh),
viewport.padding,
false
);
} else {
const data = rootService.getFitToScreenData();
rootService.viewport.setViewport(
data.zoom,
[data.centerX, data.centerY],
false
);
if (xywh) {
const viewport = {
xywh: xywh,
padding: [60, 20, 20, 20] as [number, number, number, number],
};
rootService.viewport.setViewportByBound(
Bound.deserialize(viewport.xywh),
viewport.padding,
false
);
} else {
const data = rootService.getFitToScreenData();
rootService.viewport.setViewport(
data.zoom,
[data.centerX, data.centerY],
false
);
}
} catch (e) {
logger.warn('failed to fitViewPort', e);
}
}
@ -71,21 +78,13 @@ export function DocPeekPreview({
const [resolvedMode, setResolvedMode] = useState<DocMode | undefined>(mode);
useEffect(() => {
requestAnimationFrame(() => {
if (editor && editor.host && resolvedMode === 'edgeless') {
editor.host
.closest('[data-testid="peek-view-modal-animation-container"]')
?.addEventListener(
'animationend',
() => {
fitViewport(editor, xywh);
},
{
once: true,
}
);
}
});
editor?.updateComplete
.then(() => {
if (resolvedMode === 'edgeless') {
fitViewport(editor, xywh);
}
})
.catch(console.error);
return;
}, [editor, resolvedMode, xywh]);

View File

@ -1,5 +1,11 @@
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 transformOrigin = createVar();
@ -42,26 +48,50 @@ const fadeOut = keyframes({
},
});
const slideRight = keyframes({
from: {
transform: 'translateX(-200%)',
opacity: 0,
},
to: {
transform: 'translateX(0)',
opacity: 1,
},
// 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,
});
const slideLeft = keyframes({
from: {
transform: 'translateX(0)',
opacity: 1,
},
to: {
transform: 'translateX(-200%)',
opacity: 0,
},
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,
});
export const modalOverlay = style({
@ -69,17 +99,7 @@ export const modalOverlay = style({
inset: 0,
zIndex: cssVar('zIndexModal'),
backgroundColor: cssVar('black30'),
opacity: 0,
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',
},
},
viewTransitionName: vtOverlayFade,
});
export const modalContentWrapper = style({
@ -96,42 +116,14 @@ export const modalContentContainer = style({
alignItems: 'flex-start',
width: '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({
selectors: {
'&[data-state=entered], &[data-state=entering]': {
animationName: zoomIn,
},
'&[data-state=exited], &[data-state=exiting]': {
animationName: zoomOut,
},
},
viewTransitionName: vtContentZoom,
});
export const modalContentContainerWithFade = style({
selectors: {
'&[data-state=entered], &[data-state=entering]': {
animationName: fadeIn,
},
'&[data-state=exited], &[data-state=exiting]': {
animationName: fadeOut,
},
},
viewTransitionName: vtContentFade,
});
export const containerPadding = style({
@ -162,20 +154,5 @@ export const modalControls = style({
zIndex: -1,
minWidth: '48px',
padding: '8px 0 0 16px',
opacity: 0,
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 { assignInlineVars } from '@vanilla-extract/dynamic';
import clsx from 'clsx';
import {
createContext,
@ -9,7 +8,7 @@ import {
useEffect,
useState,
} from 'react';
import useTransition from 'react-transition-state';
import { flushSync } from 'react-dom';
import * as styles from './modal-container.css';
@ -42,6 +41,15 @@ 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();
@ -63,7 +71,6 @@ export type PeekViewModalContainerProps = PropsWithChildren<{
open: boolean;
target?: HTMLElement;
controls?: React.ReactNode;
hideOnEntering?: boolean;
onAnimationStart?: () => void;
onAnimateEnd?: () => void;
padding?: boolean;
@ -81,7 +88,6 @@ export const PeekViewModalContainer = forwardRef<
target,
controls,
children,
hideOnEntering,
onAnimationStart,
onAnimateEnd,
animation = 'zoom',
@ -90,33 +96,40 @@ export const PeekViewModalContainer = forwardRef<
},
ref
) {
const [{ status }, toggle] = useTransition({
timeout: animationTimeout,
});
const [transformOrigin, setTransformOrigin] = useState<string | null>(null);
const [vtOpen, setVtOpen] = useState(open);
useEffect(() => {
document.startViewTransition(() => {
flushSync(() => {
setVtOpen(open);
});
});
}, [open]);
useEffect(() => {
toggle(open);
const bondingBox = target ? getElementScreenPositionCenter(target) : null;
const offsetLeft =
(window.innerWidth - Math.min(window.innerWidth * 0.9, 1200)) / 2;
const modalHeight = window.innerHeight * 0.05;
setTransformOrigin(
bondingBox
? `${bondingBox.x - offsetLeft}px ${bondingBox.y - modalHeight}px`
: null
const transformOrigin = bondingBox
? `${bondingBox.x - offsetLeft}px ${bondingBox.y - modalHeight}px`
: null;
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={status !== 'exited'} onOpenChange={onOpenChange}>
<Dialog.Root modal open={vtOpen} onOpenChange={onOpenChange}>
<Dialog.Portal>
<Dialog.Overlay
className={styles.modalOverlay}
data-state={status}
style={assignInlineVars({
[styles.transformOrigin]: transformOrigin,
[styles.animationTimeout]: `${animationTimeout}ms`,
})}
onAnimationStart={onAnimationStart}
onAnimationEnd={onAnimateEnd}
/>
@ -125,10 +138,6 @@ export const PeekViewModalContainer = forwardRef<
data-testid={testId}
data-peek-view-wrapper
className={styles.modalContentWrapper}
style={assignInlineVars({
[styles.transformOrigin]: transformOrigin,
[styles.animationTimeout]: `${animationTimeout}ms`,
})}
>
<div
className={clsx(
@ -139,19 +148,15 @@ export const PeekViewModalContainer = forwardRef<
: styles.modalContentContainerWithZoom
)}
data-testid="peek-view-modal-animation-container"
data-state={status}
>
<Dialog.Content
{...contentOptions}
data-no-interaction={status !== 'entered'}
className={styles.modalContent}
>
{hideOnEntering && status === 'entering' ? null : children}
{children}
</Dialog.Content>
{controls ? (
<div data-state={status} className={styles.modalControls}>
{controls}
</div>
<div className={styles.modalControls}>{controls}</div>
) : null}
</div>
</div>