From 03c20519264540851c684639edaaa622af19a2d6 Mon Sep 17 00:00:00 2001 From: CatsJuice Date: Thu, 5 Sep 2024 06:45:29 +0000 Subject: [PATCH] feat(component): startScopedViewTranstion func to support scoped view transition (#8093) AF-1293 --- .../frontend/component/src/ui/modal/modal.tsx | 25 ++++++------- .../component/src/ui/modal/styles.css.ts | 8 +++- .../frontend/component/src/utils/index.ts | 1 + .../component/src/utils/observe-resize.ts | 5 ++- .../component/src/utils/view-transition.ts | 37 +++++++++++++++++++ .../src/components/search-input/style.css.ts | 12 +++++- .../mobile/src/views/home-header/index.tsx | 10 ++--- 7 files changed, 72 insertions(+), 26 deletions(-) create mode 100644 packages/frontend/component/src/utils/view-transition.ts diff --git a/packages/frontend/component/src/ui/modal/modal.tsx b/packages/frontend/component/src/ui/modal/modal.tsx index f8e589e24d..05f2073412 100644 --- a/packages/frontend/component/src/ui/modal/modal.tsx +++ b/packages/frontend/component/src/ui/modal/modal.tsx @@ -12,6 +12,7 @@ import clsx from 'clsx'; import type { CSSProperties, MouseEvent } from 'react'; import { forwardRef, useCallback, useEffect, useState } from 'react'; +import { startScopedViewTransition } from '../../utils'; import type { IconButtonProps } from '../button'; import { IconButton } from '../button'; import * as styles from './styles.css'; @@ -86,21 +87,19 @@ class ModalTransitionContainer extends HTMLElement { } 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 => { - if (child instanceof HTMLElement) { - child.classList.add('vt-active'); - } + // eslint-disable-next-line unicorn/prefer-dom-node-remove + super.removeChild(child); }); - document.startViewTransition(() => { - nodes.forEach(child => { - // eslint-disable-next-line unicorn/prefer-dom-node-remove - super.removeChild(child); - }); - }); - this.pendingTransitionNodes = []; - } + }); + this.pendingTransitionNodes = []; }); } } diff --git a/packages/frontend/component/src/ui/modal/styles.css.ts b/packages/frontend/component/src/ui/modal/styles.css.ts index 15a4a867c1..78171882eb 100644 --- a/packages/frontend/component/src/ui/modal/styles.css.ts +++ b/packages/frontend/component/src/ui/modal/styles.css.ts @@ -7,10 +7,14 @@ import { keyframes, style, } from '@vanilla-extract/css'; + +import { vtScopeSelector } from '../../utils/view-transition'; export const widthVar = createVar('widthVar'); export const heightVar = createVar('heightVar'); export const minHeightVar = createVar('minHeightVar'); +export const modalVTScope = generateIdentifier('modal'); + const overlayShow = keyframes({ from: { opacity: 0, @@ -94,14 +98,14 @@ export const modalContentWrapper = style({ animation: `${contentShowFadeScaleTop} 150ms cubic-bezier(0.42, 0, 0.58, 1)`, animationFillMode: 'forwards', }, - '&.anim-fadeScaleTop.vt-active': { + [`${vtScopeSelector(modalVTScope)} &.anim-fadeScaleTop.vt-active`]: { viewTransitionName: modalContentViewTransitionNameFadeScaleTop, }, '&.anim-slideBottom': { animation: `${contentShowSlideBottom} 0.23s ease`, animationFillMode: 'forwards', }, - '&.anim-slideBottom.vt-active': { + [`${vtScopeSelector(modalVTScope)} &.anim-slideBottom.vt-active`]: { viewTransitionName: modalContentViewTransitionNameSlideBottom, }, }, diff --git a/packages/frontend/component/src/utils/index.ts b/packages/frontend/component/src/utils/index.ts index 358271ede7..5a46d59e6b 100644 --- a/packages/frontend/component/src/utils/index.ts +++ b/packages/frontend/component/src/utils/index.ts @@ -1 +1,2 @@ export * from './observe-resize'; +export { startScopedViewTransition } from './view-transition'; diff --git a/packages/frontend/component/src/utils/observe-resize.ts b/packages/frontend/component/src/utils/observe-resize.ts index 30da21d904..87a56faf2b 100644 --- a/packages/frontend/component/src/utils/observe-resize.ts +++ b/packages/frontend/component/src/utils/observe-resize.ts @@ -9,8 +9,9 @@ let _resizeObserver: ResizeObserver | null = null; const elementsMap = new WeakMap>(); // for debugging -(window as any)._resizeObserverElementsMap = elementsMap; - +if (typeof window !== 'undefined') { + (window as any)._resizeObserverElementsMap = elementsMap; +} /** * @internal get or initialize the ResizeObserver instance */ diff --git a/packages/frontend/component/src/utils/view-transition.ts b/packages/frontend/component/src/utils/view-transition.ts new file mode 100644 index 0000000000..c2836041ae --- /dev/null +++ b/packages/frontend/component/src/utils/view-transition.ts @@ -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, + 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((_, 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}]`; +} diff --git a/packages/frontend/mobile/src/components/search-input/style.css.ts b/packages/frontend/mobile/src/components/search-input/style.css.ts index 26f737441c..3fce6dc680 100644 --- a/packages/frontend/mobile/src/components/search-input/style.css.ts +++ b/packages/frontend/mobile/src/components/search-input/style.css.ts @@ -1,10 +1,18 @@ 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({ position: 'relative', backgroundColor: cssVarV2('layer/background/primary'), - viewTransitionName: 'mobile-search-input', + + selectors: { + [`[data-${searchVTScope}] &`]: { + viewTransitionName: searchVTName, + }, + }, }); export const prefixIcon = style({ diff --git a/packages/frontend/mobile/src/views/home-header/index.tsx b/packages/frontend/mobile/src/views/home-header/index.tsx index 7d14f1cd9f..b9e6861c51 100644 --- a/packages/frontend/mobile/src/views/home-header/index.tsx +++ b/packages/frontend/mobile/src/views/home-header/index.tsx @@ -1,4 +1,4 @@ -import { IconButton } from '@affine/component'; +import { IconButton, startScopedViewTransition } from '@affine/component'; import { openSettingModalAtom } from '@affine/core/atoms'; import { WorkbenchService } from '@affine/core/modules/workbench'; import { useI18n } from '@affine/i18n'; @@ -9,6 +9,7 @@ import { useSetAtom } from 'jotai'; import { useCallback, useState } from 'react'; import { SearchInput, WorkspaceSelector } from '../../components'; +import { searchVTScope } from '../../components/search-input/style.css'; import { useGlobalEvent } from '../../hooks/use-global-events'; import * as styles from './styles.css'; @@ -33,13 +34,8 @@ export const HomeHeader = () => { ); const navSearch = useCallback(() => { - if (!document.startViewTransition) { - return workbench.open('/search'); - } - - document.startViewTransition(() => { + startScopedViewTransition(searchVTScope, () => { workbench.open('/search'); - return new Promise(resolve => setTimeout(resolve, 150)); }); }, [workbench]);