mirror of
https://github.com/toeverything/AFFiNE.git
synced 2024-12-03 06:03:21 +03:00
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:
parent
03af538989
commit
81462fe000
@ -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]);
|
||||
|
||||
|
@ -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',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
@ -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>
|
||||
|
Loading…
Reference in New Issue
Block a user