feat(component): startScopedViewTranstion func to support scoped view transition (#8093)
AF-1293
This commit is contained in:
@@ -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 => {
|
||||||
|
if (child instanceof HTMLElement) {
|
||||||
|
child.classList.add('vt-active');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
startScopedViewTransition(styles.modalVTScope, () => {
|
||||||
nodes.forEach(child => {
|
nodes.forEach(child => {
|
||||||
if (child instanceof HTMLElement) {
|
// eslint-disable-next-line unicorn/prefer-dom-node-remove
|
||||||
child.classList.add('vt-active');
|
super.removeChild(child);
|
||||||
}
|
|
||||||
});
|
});
|
||||||
document.startViewTransition(() => {
|
});
|
||||||
nodes.forEach(child => {
|
this.pendingTransitionNodes = [];
|
||||||
// eslint-disable-next-line unicorn/prefer-dom-node-remove
|
|
||||||
super.removeChild(child);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
this.pendingTransitionNodes = [];
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
export * from './observe-resize';
|
export * from './observe-resize';
|
||||||
|
export { startScopedViewTransition } from './view-transition';
|
||||||
|
|||||||
@@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
37
packages/frontend/component/src/utils/view-transition.ts
Normal file
37
packages/frontend/component/src/utils/view-transition.ts
Normal 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}]`;
|
||||||
|
}
|
||||||
@@ -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({
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user