From 6bc53373078f73c46909497aedc963fcf07b8063 Mon Sep 17 00:00:00 2001 From: EYHN Date: Fri, 26 Jul 2024 08:39:34 +0000 Subject: [PATCH] refactor(core): adjust modal animation (#7606) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit
🎥 Video uploaded on Graphite:
When a modal is closed, sometimes its components are completely unmounted from the component tree, making it difficult to animate. This pr defining a custom element as the container of ReactDOM.portal, rewriting the `removeChild` function, and use `startViewTransition` when ReactDOM calls it to implement the animation. # Save Input Some inputs use blur event to save data, but when they are unmounted, blur event will not be triggered at all. This pr changes blur event to native addEventListener, which will be called after the DOM element is unmounted, so as to save data in time. --- .github/renovate.json | 3 +- packages/frontend/component/package.json | 2 +- .../component/src/ui/editable/inline-edit.tsx | 2 +- .../frontend/component/src/ui/input/input.tsx | 25 +- .../frontend/component/src/ui/menu/menu.tsx | 24 +- .../frontend/component/src/ui/modal/modal.tsx | 298 ++++++++++-------- .../component/src/ui/modal/styles.css.ts | 47 +-- .../affine/page-properties/menu-items.tsx | 4 +- .../property-row-value-renderer.tsx | 18 +- .../view/edit-collection/edit-collection.tsx | 22 +- packages/frontend/core/src/pages/index.tsx | 1 + yarn.lock | 10 +- 12 files changed, 257 insertions(+), 199 deletions(-) diff --git a/.github/renovate.json b/.github/renovate.json index cef310a6e2..a0e49e06b4 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -27,7 +27,8 @@ "matchPackagePatterns": ["^@blocksuite"], "excludePackageNames": ["@blocksuite/icons"], "rangeStrategy": "replace", - "followTag": "canary" + "followTag": "canary", + "enabled": false }, { "groupName": "all non-major dependencies", diff --git a/packages/frontend/component/package.json b/packages/frontend/component/package.json index 04322483fc..ce1c53588d 100644 --- a/packages/frontend/component/package.json +++ b/packages/frontend/component/package.json @@ -105,7 +105,7 @@ "@vanilla-extract/css": "^1.14.2", "fake-indexeddb": "^6.0.0", "storybook": "^7.6.17", - "storybook-dark-mode": "4.0.2", + "storybook-dark-mode": "4.0.1", "typescript": "^5.4.5", "vite": "^5.2.8", "vitest": "1.6.0" diff --git a/packages/frontend/component/src/ui/editable/inline-edit.tsx b/packages/frontend/component/src/ui/editable/inline-edit.tsx index aad7e9101c..a6229ae58f 100644 --- a/packages/frontend/component/src/ui/editable/inline-edit.tsx +++ b/packages/frontend/component/src/ui/editable/inline-edit.tsx @@ -214,12 +214,12 @@ export const InlineEdit = ({ className={styles.inlineEditInput} value={editingValue} placeholder={placeholder} - onBlur={onBlur} onEnter={onEnter} onKeyDown={onKeyDown} onChange={inputHandler} style={inputWrapperInheritsStyles} inputStyle={inputInheritsStyles} + onBlur={onBlur} {...inputAttrs} /> } diff --git a/packages/frontend/component/src/ui/input/input.tsx b/packages/frontend/component/src/ui/input/input.tsx index 6d29f6e797..5ba0a206de 100644 --- a/packages/frontend/component/src/ui/input/input.tsx +++ b/packages/frontend/component/src/ui/input/input.tsx @@ -2,21 +2,26 @@ import clsx from 'clsx'; import type { ChangeEvent, CSSProperties, - FocusEventHandler, ForwardedRef, InputHTMLAttributes, KeyboardEvent, KeyboardEventHandler, ReactNode, } from 'react'; -import { forwardRef, useCallback, useLayoutEffect, useRef } from 'react'; +import { + forwardRef, + useCallback, + useEffect, + useLayoutEffect, + useRef, +} from 'react'; import { input, inputWrapper } from './style.css'; export type InputProps = { disabled?: boolean; onChange?: (value: string) => void; - onBlur?: FocusEventHandler; + onBlur?: (ev: FocusEvent & { currentTarget: HTMLInputElement }) => void; onKeyDown?: KeyboardEventHandler; autoSelect?: boolean; noBorder?: boolean; @@ -27,7 +32,7 @@ export type InputProps = { type?: HTMLInputElement['type']; inputStyle?: CSSProperties; onEnter?: () => void; -} & Omit, 'onChange' | 'size'>; +} & Omit, 'onChange' | 'size' | 'onBlur'>; export const Input = forwardRef(function Input( { @@ -43,6 +48,7 @@ export const Input = forwardRef(function Input( endFix, onEnter, onKeyDown, + onBlur, autoFocus, autoSelect, ...otherProps @@ -59,6 +65,17 @@ export const Input = forwardRef(function Input( } }, [autoFocus, autoSelect, upstreamRef]); + // use native blur event to get event after unmount + // don't use useLayoutEffect here, because the cleanup function will be called before unmount + useEffect(() => { + if (!onBlur) return; + inputRef.current?.addEventListener('blur', onBlur as any); + return () => { + // eslint-disable-next-line react-hooks/exhaustive-deps + inputRef.current?.removeEventListener('blur', onBlur as any); + }; + }, [onBlur]); + return (
; rootOptions?: Omit; contentOptions?: Omit; + noPortal?: boolean; } export const Menu = ({ @@ -23,6 +23,7 @@ export const Menu = ({ items, portalOptions, rootOptions, + noPortal, contentOptions: { className = '', style: contentStyle = {}, @@ -33,12 +34,9 @@ export const Menu = ({ {children} - + {noPortal ? ( clsx(styles.menuContent, className), - [className] - )} + className={clsx(styles.menuContent, className)} sideOffset={5} align="start" style={{ zIndex: 'var(--affine-z-index-popover)', ...contentStyle }} @@ -46,7 +44,19 @@ export const Menu = ({ > {items} - + ) : ( + + + {items} + + + )} ); }; diff --git a/packages/frontend/component/src/ui/modal/modal.tsx b/packages/frontend/component/src/ui/modal/modal.tsx index 68c439f9bc..f47a519a1c 100644 --- a/packages/frontend/component/src/ui/modal/modal.tsx +++ b/packages/frontend/component/src/ui/modal/modal.tsx @@ -10,8 +10,7 @@ import * as VisuallyHidden from '@radix-ui/react-visually-hidden'; import { assignInlineVars } from '@vanilla-extract/dynamic'; import clsx from 'clsx'; import type { CSSProperties } from 'react'; -import { forwardRef, useCallback, useEffect } from 'react'; -import { type TransitionState, useTransition } from 'react-transition-state'; +import { forwardRef, useCallback } from 'react'; import type { IconButtonProps } from '../button'; import { IconButton } from '../button'; @@ -29,8 +28,6 @@ export interface ModalProps extends DialogProps { * @default false */ persistent?: boolean; - // animation for modal open/close - animationTimeout?: number; portalOptions?: DialogPortalProps; contentOptions?: DialogContentProps; overlayOptions?: DialogOverlayProps; @@ -48,141 +45,174 @@ const getVar = (style: number | string = '', defaultValue = '') => { : defaultValue; }; -export const Modal = forwardRef( - ( - { - width, - height, - minHeight = 194, - title, - description, - withoutCloseButton = false, - modal, - persistent, - animationTimeout = 120, - portalOptions, - open: customOpen, - onOpenChange: customOnOpenChange, - contentOptions: { - style: contentStyle, - className: contentClassName, - onPointerDownOutside, - onEscapeKeyDown, - ...otherContentOptions - } = {}, - overlayOptions: { - className: overlayClassName, - style: overlayStyle, - ...otherOverlayOptions - } = {}, - closeButtonOptions = {}, - children, - ...props - }, - ref - ) => { - const [{ status }, toggle] = useTransition({ - timeout: animationTimeout, - onStateChange: useCallback( - ({ current }: { current: TransitionState }) => { - // add more status if needed - if (current.status === 'exited') customOnOpenChange?.(false); - if (current.status === 'entered') customOnOpenChange?.(true); - }, - [customOnOpenChange] - ), - }); - useEffect(() => { - toggle(customOpen); - }, [customOpen]); +/** + * This component is a hack to support `startViewTransition` in the modal. + */ +class ModalTransitionContainer extends HTMLElement { + pendingTransitionNodes: Node[] = []; + animationFrame: number | null = null; - return ( - - - (child: T): T { + if (typeof document.startViewTransition === 'function') { + this.pendingTransitionNodes.push(child); + this.requestTransition(); + return child; + } else { + // eslint-disable-next-line unicorn/prefer-dom-node-remove + return super.removeChild(child); + } + } + + /** + * We collect all the nodes that are removed in the single frame and then trigger the transition. + */ + private requestTransition() { + if (this.animationFrame) { + cancelAnimationFrame(this.animationFrame); + } + + this.animationFrame = requestAnimationFrame(() => { + if (typeof document.startViewTransition === 'function') { + const nodes = this.pendingTransitionNodes; + document.startViewTransition(() => { + nodes.forEach(child => { + // eslint-disable-next-line unicorn/prefer-dom-node-remove + super.removeChild(child); + }); + }); + this.pendingTransitionNodes = []; + } + }); + } +} + +let container: ModalTransitionContainer | null = null; +function prepareContainer() { + if (!container) { + customElements.define( + 'modal-transition-container', + ModalTransitionContainer + ); + container = new ModalTransitionContainer(); + document.body.append(container); + } + return container; +} + +export const Modal = forwardRef((props, ref) => { + const { + modal, + portalOptions, + open, + onOpenChange, + width, + height, + minHeight = 194, + title, + description, + withoutCloseButton = false, + persistent, + contentOptions: { + style: contentStyle, + className: contentClassName, + onPointerDownOutside, + onEscapeKeyDown, + ...otherContentOptions + } = {}, + overlayOptions: { + className: overlayClassName, + style: overlayStyle, + ...otherOverlayOptions + } = {}, + closeButtonOptions = {}, + children, + ...otherProps + } = props; + + return ( + + + +
+ { + onPointerDownOutside?.(e); + persistent && e.preventDefault(); + }, + [onPointerDownOutside, persistent] + )} + onEscapeKeyDown={useCallback( + (e: KeyboardEvent) => { + onEscapeKeyDown?.(e); + persistent && e.preventDefault(); + }, + [onEscapeKeyDown, persistent] + )} + className={clsx(styles.modalContent, contentClassName)} style={{ ...assignInlineVars({ - [styles.animationTimeout]: `${animationTimeout}ms`, + [styles.widthVar]: getVar(width, '50vw'), + [styles.heightVar]: getVar(height, 'unset'), + [styles.minHeightVar]: getVar(minHeight, '26px'), }), - ...overlayStyle, + ...contentStyle, }} - {...otherOverlayOptions} - /> -
- { - onPointerDownOutside?.(e); - persistent && e.preventDefault(); - }, - [onPointerDownOutside, persistent] - )} - onEscapeKeyDown={useCallback( - (e: KeyboardEvent) => { - onEscapeKeyDown?.(e); - persistent && e.preventDefault(); - }, - [onEscapeKeyDown, persistent] - )} - className={clsx(styles.modalContent, contentClassName)} - data-state={status} - style={{ - ...assignInlineVars({ - [styles.widthVar]: getVar(width, '50vw'), - [styles.heightVar]: getVar(height, 'unset'), - [styles.minHeightVar]: getVar(minHeight, '26px'), - [styles.animationTimeout]: `${animationTimeout}ms`, - }), - ...contentStyle, - }} - {...(description ? {} : { 'aria-describedby': undefined })} - {...otherContentOptions} - ref={ref} - > - {withoutCloseButton ? null : ( - - - - - - )} - {title ? ( - - {title} - - ) : ( - // Refer: https://www.radix-ui.com/primitives/docs/components/dialog#title - // If you want to hide the title, wrap it inside our Visually Hidden utility like this . - - - - )} - {description ? ( - - {description} - - ) : null} + {...(description ? {} : { 'aria-describedby': undefined })} + {...otherContentOptions} + ref={ref} + > + {withoutCloseButton ? null : ( + + + + + + )} + {title ? ( + + {title} + + ) : ( + // Refer: https://www.radix-ui.com/primitives/docs/components/dialog#title + // If you want to hide the title, wrap it inside our Visually Hidden utility like this . + + + + )} + {description ? ( + + {description} + + ) : null} - {children} - -
- - - ); - } -); + {children} +
+
+
+
+ ); +}); Modal.displayName = 'Modal'; diff --git a/packages/frontend/component/src/ui/modal/styles.css.ts b/packages/frontend/component/src/ui/modal/styles.css.ts index 20ec2df75f..ee4016e164 100644 --- a/packages/frontend/component/src/ui/modal/styles.css.ts +++ b/packages/frontend/component/src/ui/modal/styles.css.ts @@ -1,9 +1,14 @@ import { cssVar } from '@toeverything/theme'; -import { createVar, globalStyle, keyframes, style } from '@vanilla-extract/css'; +import { + createVar, + generateIdentifier, + globalStyle, + keyframes, + style, +} from '@vanilla-extract/css'; export const widthVar = createVar('widthVar'); export const heightVar = createVar('heightVar'); export const minHeightVar = createVar('minHeightVar'); -export const animationTimeout = createVar(); const overlayShow = keyframes({ from: { @@ -13,15 +18,6 @@ const overlayShow = keyframes({ opacity: 1, }, }); -const overlayHide = keyframes({ - to: { - opacity: 0, - }, - from: { - opacity: 1, - }, -}); - const contentShow = keyframes({ from: { opacity: 0, @@ -32,7 +28,7 @@ const contentShow = keyframes({ transform: 'translateY(0) scale(1)', }, }); -const contentHide = keyframes({ +export const contentHide = keyframes({ to: { opacity: 0, transform: 'translateY(-2%) scale(0.96)', @@ -48,15 +44,9 @@ export const modalOverlay = style({ inset: 0, backgroundColor: cssVar('backgroundModalColor'), zIndex: cssVar('zIndexModal'), - selectors: { - '&[data-state=entered], &[data-state=entering]': { - animation: `${overlayShow} ${animationTimeout} forwards`, - }, - '&[data-state=exited], &[data-state=exiting]': { - animation: `${overlayHide} ${animationTimeout} forwards`, - }, - }, + animation: `${overlayShow} 150ms forwards`, }); +const modalContentViewTransitionName = generateIdentifier('modal-content'); export const modalContentWrapper = style({ position: 'fixed', inset: 0, @@ -64,6 +54,13 @@ export const modalContentWrapper = style({ alignItems: 'center', justifyContent: 'center', zIndex: cssVar('zIndexModal'), + animation: `${contentShow} 150ms cubic-bezier(0.42, 0, 0.58, 1)`, + animationFillMode: 'forwards', + viewTransitionName: modalContentViewTransitionName, +}); +globalStyle(`::view-transition-old(${modalContentViewTransitionName})`, { + animation: `${contentHide} 150ms cubic-bezier(0.42, 0, 0.58, 1)`, + animationFillMode: 'forwards', }); export const modalContent = style({ @@ -87,16 +84,6 @@ export const modalContent = style({ maxHeight: 'calc(100vh - 32px)', // :focus-visible will set outline outline: 'none', - selectors: { - '&[data-state=entered], &[data-state=entering]': { - animation: `${contentShow} ${animationTimeout} cubic-bezier(0.42, 0, 0.58, 1)`, - animationFillMode: 'forwards', - }, - '&[data-state=exited], &[data-state=exiting]': { - animation: `${contentHide} ${animationTimeout} cubic-bezier(0.42, 0, 0.58, 1)`, - animationFillMode: 'forwards', - }, - }, }); export const closeButton = style({ position: 'absolute', diff --git a/packages/frontend/core/src/components/affine/page-properties/menu-items.tsx b/packages/frontend/core/src/components/affine/page-properties/menu-items.tsx index 4bbabab33d..b126f41319 100644 --- a/packages/frontend/core/src/components/affine/page-properties/menu-items.tsx +++ b/packages/frontend/core/src/components/affine/page-properties/menu-items.tsx @@ -96,8 +96,8 @@ export const EditPropertyNameMenuItem = ({ [onBlur] ); const handleBlur = useCallback( - (e: React.FocusEvent) => { - onBlur(e.target.value); + (e: FocusEvent & { currentTarget: HTMLInputElement }) => { + onBlur(e.currentTarget.value); }, [onBlur] ); diff --git a/packages/frontend/core/src/components/affine/page-properties/property-row-value-renderer.tsx b/packages/frontend/core/src/components/affine/page-properties/property-row-value-renderer.tsx index 65ec931750..f894280898 100644 --- a/packages/frontend/core/src/components/affine/page-properties/property-row-value-renderer.tsx +++ b/packages/frontend/core/src/components/affine/page-properties/property-row-value-renderer.tsx @@ -8,7 +8,7 @@ import { i18nTime, useI18n } from '@affine/i18n'; import { DocService, useService } from '@toeverything/infra'; import { noop } from 'lodash-es'; import type { ChangeEventHandler } from 'react'; -import { useCallback, useContext, useEffect, useState } from 'react'; +import { useCallback, useContext, useEffect, useRef, useState } from 'react'; import { managerContext } from './common'; import * as styles from './styles.css'; @@ -87,14 +87,24 @@ export const TextValue = ({ property }: PropertyRowValueProps) => { const handleClick = useCallback((e: React.MouseEvent) => { e.stopPropagation(); }, []); + const ref = useRef(null); const handleBlur = useCallback( - (e: React.ChangeEvent) => { + (e: FocusEvent) => { manager.updateCustomProperty(property.id, { - value: e.target.value.trim(), + value: (e.currentTarget as HTMLTextAreaElement).value.trim(), }); }, [manager, property.id] ); + // use native blur event to get event after unmount + // don't use useLayoutEffect here, cause the cleanup function will be called before unmount + useEffect(() => { + ref.current?.addEventListener('blur', handleBlur); + return () => { + // eslint-disable-next-line react-hooks/exhaustive-deps + ref.current?.removeEventListener('blur', handleBlur); + }; + }, [handleBlur]); const handleOnChange: ChangeEventHandler = useCallback( e => { setValue(e.target.value); @@ -109,11 +119,11 @@ export const TextValue = ({ property }: PropertyRowValueProps) => { return (