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 | 296 ++++++++++-------- .../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, 256 insertions(+), 198 deletions(-) diff --git a/.github/renovate.json b/.github/renovate.json index cef310a6e243a..a0e49e06b4f1b 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 04322483fc807..ce1c53588d164 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 aad7e9101c31e..a6229ae58ff43 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 6d29f6e7971a2..5ba0a206dee0d 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 68c439f9bc260..f47a519a1c010 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] - ), +/** + * This component is a hack to support `startViewTransition` in the modal. + */ +class ModalTransitionContainer extends HTMLElement { + pendingTransitionNodes: Node[] = []; + animationFrame: number | null = null; + + /** + * This method will be called when the modal is removed from the DOM + * https://github.com/facebook/react/blob/e4b4aac2a01b53f8151ca85148873096368a7de2/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js#L833 + */ + override removeChild(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 = []; + } }); - useEffect(() => { - toggle(customOpen); - }, [customOpen]); + } +} + +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 20ec2df75f4a9..ee4016e164dce 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 4bbabab33dc4b..b126f4131935a 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 65ec931750411..f8942808988e3 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 (