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 (