feat(component): startScopedViewTranstion func to support scoped view transition (#8093)

AF-1293
This commit is contained in:
CatsJuice
2024-09-05 06:45:29 +00:00
parent 73dd1d3326
commit 03c2051926
7 changed files with 72 additions and 26 deletions

View File

@@ -12,6 +12,7 @@ import clsx from 'clsx';
import type { CSSProperties, MouseEvent } from 'react'; import type { CSSProperties, MouseEvent } from 'react';
import { forwardRef, useCallback, useEffect, useState } from 'react'; import { forwardRef, useCallback, useEffect, useState } from 'react';
import { startScopedViewTransition } from '../../utils';
import type { IconButtonProps } from '../button'; import type { IconButtonProps } from '../button';
import { IconButton } from '../button'; import { IconButton } from '../button';
import * as styles from './styles.css'; import * as styles from './styles.css';
@@ -86,21 +87,19 @@ class ModalTransitionContainer extends HTMLElement {
} }
this.animationFrame = requestAnimationFrame(() => { this.animationFrame = requestAnimationFrame(() => {
if (typeof document.startViewTransition === 'function') {
const nodes = this.pendingTransitionNodes; const nodes = this.pendingTransitionNodes;
nodes.forEach(child => { nodes.forEach(child => {
if (child instanceof HTMLElement) { if (child instanceof HTMLElement) {
child.classList.add('vt-active'); child.classList.add('vt-active');
} }
}); });
document.startViewTransition(() => { startScopedViewTransition(styles.modalVTScope, () => {
nodes.forEach(child => { nodes.forEach(child => {
// eslint-disable-next-line unicorn/prefer-dom-node-remove // eslint-disable-next-line unicorn/prefer-dom-node-remove
super.removeChild(child); super.removeChild(child);
}); });
}); });
this.pendingTransitionNodes = []; this.pendingTransitionNodes = [];
}
}); });
} }
} }

View File

@@ -7,10 +7,14 @@ import {
keyframes, keyframes,
style, style,
} from '@vanilla-extract/css'; } from '@vanilla-extract/css';
import { vtScopeSelector } from '../../utils/view-transition';
export const widthVar = createVar('widthVar'); export const widthVar = createVar('widthVar');
export const heightVar = createVar('heightVar'); export const heightVar = createVar('heightVar');
export const minHeightVar = createVar('minHeightVar'); export const minHeightVar = createVar('minHeightVar');
export const modalVTScope = generateIdentifier('modal');
const overlayShow = keyframes({ const overlayShow = keyframes({
from: { from: {
opacity: 0, opacity: 0,
@@ -94,14 +98,14 @@ export const modalContentWrapper = style({
animation: `${contentShowFadeScaleTop} 150ms cubic-bezier(0.42, 0, 0.58, 1)`, animation: `${contentShowFadeScaleTop} 150ms cubic-bezier(0.42, 0, 0.58, 1)`,
animationFillMode: 'forwards', animationFillMode: 'forwards',
}, },
'&.anim-fadeScaleTop.vt-active': { [`${vtScopeSelector(modalVTScope)} &.anim-fadeScaleTop.vt-active`]: {
viewTransitionName: modalContentViewTransitionNameFadeScaleTop, viewTransitionName: modalContentViewTransitionNameFadeScaleTop,
}, },
'&.anim-slideBottom': { '&.anim-slideBottom': {
animation: `${contentShowSlideBottom} 0.23s ease`, animation: `${contentShowSlideBottom} 0.23s ease`,
animationFillMode: 'forwards', animationFillMode: 'forwards',
}, },
'&.anim-slideBottom.vt-active': { [`${vtScopeSelector(modalVTScope)} &.anim-slideBottom.vt-active`]: {
viewTransitionName: modalContentViewTransitionNameSlideBottom, viewTransitionName: modalContentViewTransitionNameSlideBottom,
}, },
}, },

View File

@@ -1 +1,2 @@
export * from './observe-resize'; export * from './observe-resize';
export { startScopedViewTransition } from './view-transition';

View File

@@ -9,8 +9,9 @@ let _resizeObserver: ResizeObserver | null = null;
const elementsMap = new WeakMap<Element, Array<ObserveResize>>(); const elementsMap = new WeakMap<Element, Array<ObserveResize>>();
// for debugging // for debugging
(window as any)._resizeObserverElementsMap = elementsMap; if (typeof window !== 'undefined') {
(window as any)._resizeObserverElementsMap = elementsMap;
}
/** /**
* @internal get or initialize the ResizeObserver instance * @internal get or initialize the ResizeObserver instance
*/ */

View File

@@ -0,0 +1,37 @@
const setScope = (scope: string) =>
document.body.setAttribute(`data-${scope}`, '');
const rmScope = (scope: string) =>
document.body.removeAttribute(`data-${scope}`);
/**
* A wrapper around `document.startViewTransition` that adds a scope attribute to the body element.
*/
export function startScopedViewTransition(
scope: string | string[],
cb: () => Promise<void> | void,
options?: { timeout?: number }
) {
if (typeof document === 'undefined') return;
if (typeof document.startViewTransition === 'function') {
const scopes = Array.isArray(scope) ? scope : [scope];
const timeout = options?.timeout ?? 2000;
scopes.forEach(setScope);
const vt = document.startViewTransition(cb);
const timeoutPromise = new Promise<void>((_, reject) => {
setTimeout(() => reject(new Error('View transition timeout')), timeout);
});
Promise.race([vt.finished, timeoutPromise])
.catch(err => console.error(`View transition[${scope}] failed: ${err}`))
.finally(() => scopes.forEach(rmScope));
} else {
cb()?.catch(console.error);
}
}
export function vtScopeSelector(scope: string) {
return `[data-${scope}]`;
}

View File

@@ -1,10 +1,18 @@
import { cssVarV2 } from '@toeverything/theme/v2'; import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css'; import { generateIdentifier, style } from '@vanilla-extract/css';
export const searchVTName = generateIdentifier('mobile-search-input');
export const searchVTScope = generateIdentifier('mobile-search');
export const wrapper = style({ export const wrapper = style({
position: 'relative', position: 'relative',
backgroundColor: cssVarV2('layer/background/primary'), backgroundColor: cssVarV2('layer/background/primary'),
viewTransitionName: 'mobile-search-input',
selectors: {
[`[data-${searchVTScope}] &`]: {
viewTransitionName: searchVTName,
},
},
}); });
export const prefixIcon = style({ export const prefixIcon = style({

View File

@@ -1,4 +1,4 @@
import { IconButton } from '@affine/component'; import { IconButton, startScopedViewTransition } from '@affine/component';
import { openSettingModalAtom } from '@affine/core/atoms'; import { openSettingModalAtom } from '@affine/core/atoms';
import { WorkbenchService } from '@affine/core/modules/workbench'; import { WorkbenchService } from '@affine/core/modules/workbench';
import { useI18n } from '@affine/i18n'; import { useI18n } from '@affine/i18n';
@@ -9,6 +9,7 @@ import { useSetAtom } from 'jotai';
import { useCallback, useState } from 'react'; import { useCallback, useState } from 'react';
import { SearchInput, WorkspaceSelector } from '../../components'; import { SearchInput, WorkspaceSelector } from '../../components';
import { searchVTScope } from '../../components/search-input/style.css';
import { useGlobalEvent } from '../../hooks/use-global-events'; import { useGlobalEvent } from '../../hooks/use-global-events';
import * as styles from './styles.css'; import * as styles from './styles.css';
@@ -33,13 +34,8 @@ export const HomeHeader = () => {
); );
const navSearch = useCallback(() => { const navSearch = useCallback(() => {
if (!document.startViewTransition) { startScopedViewTransition(searchVTScope, () => {
return workbench.open('/search');
}
document.startViewTransition(() => {
workbench.open('/search'); workbench.open('/search');
return new Promise(resolve => setTimeout(resolve, 150));
}); });
}, [workbench]); }, [workbench]);