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:
@@ -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,31 +19,37 @@ 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}]`
|
||||||
) {
|
) {
|
||||||
const rootService =
|
try {
|
||||||
editor.host.std.spec.getService<EdgelessRootService>('affine:page');
|
const rootService =
|
||||||
rootService.viewport.onResize();
|
editor.host.std.spec.getService<EdgelessRootService>('affine:page');
|
||||||
|
rootService.viewport.onResize();
|
||||||
|
|
||||||
if (xywh) {
|
if (xywh) {
|
||||||
const viewport = {
|
const viewport = {
|
||||||
xywh: xywh,
|
xywh: xywh,
|
||||||
padding: [60, 20, 20, 20] as [number, number, number, number],
|
padding: [60, 20, 20, 20] as [number, number, number, number],
|
||||||
};
|
};
|
||||||
rootService.viewport.setViewportByBound(
|
rootService.viewport.setViewportByBound(
|
||||||
Bound.deserialize(viewport.xywh),
|
Bound.deserialize(viewport.xywh),
|
||||||
viewport.padding,
|
viewport.padding,
|
||||||
false
|
false
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
const data = rootService.getFitToScreenData();
|
const data = rootService.getFitToScreenData();
|
||||||
rootService.viewport.setViewport(
|
rootService.viewport.setViewport(
|
||||||
data.zoom,
|
data.zoom,
|
||||||
[data.centerX, data.centerY],
|
[data.centerX, data.centerY],
|
||||||
false
|
false
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
logger.warn('failed to fitViewPort', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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"]')
|
fitViewport(editor, xywh);
|
||||||
?.addEventListener(
|
}
|
||||||
'animationend',
|
})
|
||||||
() => {
|
.catch(console.error);
|
||||||
fitViewport(editor, xywh);
|
|
||||||
},
|
|
||||||
{
|
|
||||||
once: true,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}, [editor, resolvedMode, xywh]);
|
}, [editor, resolvedMode, xywh]);
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user