From f51da066a80225651e9341bbe21a21f3c154921c Mon Sep 17 00:00:00 2001 From: pengx17 Date: Tue, 25 Jun 2024 07:34:18 +0000 Subject: [PATCH] refactor: image preview component issues in center peek (#7313) fix AF-948 - remove jotai - every editor got its own image block modal - image block uses center peek modal for animation and style --- .../src/components/image-preview/index.css.ts | 49 +- .../components/image-preview/index.jotai.ts | 24 - .../src/components/image-preview/index.tsx | 708 +++++++++--------- .../peek-view/view/doc-peek-view.css.ts | 10 + .../modules/peek-view/view/doc-peek-view.tsx | 13 +- .../peek-view/view/modal-container.css.ts | 42 +- .../peek-view/view/modal-container.tsx | 36 +- .../workspace/detail-page/detail-page.tsx | 8 +- tests/affine-local/e2e/image-preview.spec.ts | 9 +- 9 files changed, 440 insertions(+), 459 deletions(-) delete mode 100644 packages/frontend/core/src/components/image-preview/index.jotai.ts diff --git a/packages/frontend/core/src/components/image-preview/index.css.ts b/packages/frontend/core/src/components/image-preview/index.css.ts index 172ab57529..dc3473cece 100644 --- a/packages/frontend/core/src/components/image-preview/index.css.ts +++ b/packages/frontend/core/src/components/image-preview/index.css.ts @@ -1,47 +1,17 @@ -import { baseTheme, cssVar } from '@toeverything/theme'; -import { keyframes, style } from '@vanilla-extract/css'; -const fadeInAnimation = keyframes({ - from: { - opacity: 0, - }, - to: { - opacity: 1, - }, -}); -const fadeOutAnimation = keyframes({ - from: { - opacity: 1, - }, - to: { - opacity: 0, - }, -}); -export const imagePreviewBackgroundStyle = style({ - position: 'fixed', - top: 0, - left: 0, - width: '100%', - height: '100%', - zIndex: baseTheme.zIndexModal, - background: 'rgba(0, 0, 0, 0.75)', -}); +import { cssVar } from '@toeverything/theme'; +import { style } from '@vanilla-extract/css'; + export const imagePreviewModalStyle = style({ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', }); -export const loaded = style({ - opacity: 0, - animationName: fadeInAnimation, - animationDuration: '0.25s', - animationFillMode: 'forwards', -}); -export const unloaded = style({ - opacity: 1, - animationName: fadeOutAnimation, - animationDuration: '0.25s', - animationFillMode: 'forwards', +export const imagePreviewTrap = style({ + position: 'absolute', + inset: 0, + width: '100%', + height: '100%', }); export const imagePreviewModalCloseButtonStyle = style({ position: 'absolute', @@ -96,6 +66,7 @@ export const imagePreviewModalCenterStyle = style({ display: 'flex', flexDirection: 'column', alignItems: 'center', + userSelect: 'none', }); export const imagePreviewModalCaptionStyle = style({ color: cssVar('white'), @@ -155,7 +126,7 @@ export const imageBottomContainerStyle = style({ alignItems: 'center', position: 'fixed', bottom: '28px', - zIndex: baseTheme.zIndexModal + 1, + zIndex: 2, }); export const captionStyle = style({ maxWidth: '686px', diff --git a/packages/frontend/core/src/components/image-preview/index.jotai.ts b/packages/frontend/core/src/components/image-preview/index.jotai.ts deleted file mode 100644 index cb69bdac19..0000000000 --- a/packages/frontend/core/src/components/image-preview/index.jotai.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { ImageBlockModel } from '@blocksuite/blocks'; -import { atom } from 'jotai'; - -export const previewBlockIdAtom = atom(null); -export const previewblocksAtom = atom([]); -export const hasAnimationPlayedAtom = atom(true); - -previewBlockIdAtom.onMount = set => { - const callback = (event: MouseEvent) => { - const target = event.target as HTMLElement | null; - if (target?.tagName === 'IMG') { - const imageBlock = target.closest('affine-image'); - if (imageBlock) { - const blockId = imageBlock.dataset.blockId; - if (!blockId) return; - set(blockId); - } - } - }; - window.addEventListener('dblclick', callback); - return () => { - window.removeEventListener('dblclick', callback); - }; -}; diff --git a/packages/frontend/core/src/components/image-preview/index.tsx b/packages/frontend/core/src/components/image-preview/index.tsx index 48ea91697e..e9ba3a733b 100644 --- a/packages/frontend/core/src/components/image-preview/index.tsx +++ b/packages/frontend/core/src/components/image-preview/index.tsx @@ -1,11 +1,14 @@ import { toast } from '@affine/component'; import { Button, IconButton } from '@affine/component/ui/button'; import { Tooltip } from '@affine/component/ui/tooltip'; +import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks'; +import { PeekViewModalContainer } from '@affine/core/modules/peek-view/view/modal-container'; import type { ImageBlockModel } from '@blocksuite/blocks'; import { assertExists } from '@blocksuite/global/utils'; import { ArrowLeftSmallIcon, ArrowRightSmallIcon, + CloseIcon, CopyIcon, DeleteIcon, DownloadIcon, @@ -16,54 +19,158 @@ import { import type { BlockModel, DocCollection } from '@blocksuite/store'; import clsx from 'clsx'; import { useErrorBoundary } from 'foxact/use-error-boundary'; -import { useAtom } from 'jotai'; import type { PropsWithChildren, ReactElement } from 'react'; -import { Suspense, useCallback, useEffect, useRef, useState } from 'react'; -import ReactDOM from 'react-dom'; +import { + Suspense, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react'; import type { FallbackProps } from 'react-error-boundary'; import { ErrorBoundary } from 'react-error-boundary'; import useSWR from 'swr'; import { useZoomControls } from './hooks/use-zoom'; -import { - captionStyle, - cursorStyle, - dividerStyle, - imageBottomContainerStyle, - imagePreviewActionBarStyle, - imagePreviewBackgroundStyle, - imagePreviewModalCaptionStyle, - imagePreviewModalCenterStyle, - imagePreviewModalCloseButtonStyle, - imagePreviewModalContainerStyle, - imagePreviewModalStyle, - loaded, - scaleIndicatorButtonStyle, - unloaded, -} from './index.css'; -import { - hasAnimationPlayedAtom, - previewBlockIdAtom, - previewblocksAtom, -} from './index.jotai'; +import * as styles from './index.css'; const filterImageBlock = (block: BlockModel): block is ImageBlockModel => { return block.flavour === 'affine:image'; }; +function resolveMimeType(buffer: Uint8Array): string { + if ( + buffer[0] === 0x47 && + buffer[1] === 0x49 && + buffer[2] === 0x46 && + buffer[3] === 0x38 + ) { + return 'image/gif'; + } else if ( + buffer[0] === 0x89 && + buffer[1] === 0x50 && + buffer[2] === 0x4e && + buffer[3] === 0x47 + ) { + return 'image/png'; + } else if ( + buffer[0] === 0xff && + buffer[1] === 0xd8 && + buffer[2] === 0xff && + buffer[3] === 0xe0 + ) { + return 'image/jpeg'; + } else { + // unknown, fallback to png + console.error('unknown image type'); + return 'image/png'; + } +} + +async function imageUrlToBlob(url: string): Promise { + const buffer = await fetch(url).then(response => { + return response.arrayBuffer(); + }); + + if (!buffer) { + console.warn('Could not get blob'); + return; + } + try { + const type = resolveMimeType(new Uint8Array(buffer)); + const blob = new Blob([buffer], { type }); + return blob; + } catch (error) { + console.error('Error converting image to blob', error); + } + return; +} + +async function copyImageToClipboard(url: string) { + const blob = await imageUrlToBlob(url); + if (!blob) { + return; + } + try { + await navigator.clipboard.write([new ClipboardItem({ [blob.type]: blob })]); + console.log('Image copied to clipboard'); + toast('Copied to clipboard.'); + } catch (error) { + console.error('Error copying image to clipboard', error); + } +} + +async function saveBufferToFile(url: string, filename: string) { + // given input url may not have correct mime type + const blob = await imageUrlToBlob(url); + if (!blob) { + return; + } + + const blobUrl = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = blobUrl; + a.download = filename; + document.body.append(a); + a.click(); + a.remove(); + URL.revokeObjectURL(blobUrl); +} + export type ImagePreviewModalProps = { docCollection: DocCollection; pageId: string; + host: HTMLElement; +}; + +const ButtonWithTooltip = ({ + icon, + tooltip, + disabled, + ...props +}: PropsWithChildren<{ + disabled?: boolean; + icon?: ReactElement; + tooltip: string; + onClick: () => void; + className?: string; +}>) => { + const element = icon ? ( + + ) : ( + - - - } - type="plain" - onClick={() => zoomIn()} - /> - -
- - } - type="plain" - onClick={() => { - assertExists(blockId); - downloadHandler(blockId).catch(err => { - console.error('Could not download image', err); - }); - }} - /> - - - } - type="plain" - onClick={() => { - if (!imageRef.current) { - return; - } - const canvas = document.createElement('canvas'); - canvas.width = imageRef.current.naturalWidth; - canvas.height = imageRef.current.naturalHeight; - const context = canvas.getContext('2d'); - if (!context) { - console.warn('Could not get canvas context'); - return; - } - context.drawImage(imageRef.current, 0, 0); - canvas.toBlob(blob => { - if (!blob) { - console.warn('Could not get blob'); - return; - } - const dataUrl = URL.createObjectURL(blob); - navigator.clipboard - .write([new ClipboardItem({ 'image/png': blob })]) - .then(() => { - console.log('Image copied to clipboard'); - URL.revokeObjectURL(dataUrl); - }) - .catch(error => { - console.error('Error copying image to clipboard', error); - URL.revokeObjectURL(dataUrl); - }); - }, 'image/png'); - toast('Copied to clipboard.'); - }} - /> - -
- - } - type="plain" - disabled={blocks.length === 0} - onClick={() => deleteHandler(cursor)} - /> - + } + disabled={props.animating || cursor + 1 === blocks.length} + onClick={() => goto(cursor + 1)} + /> +
+ } + disabled={props.animating} + onClick={() => resetZoom()} + /> + } + disabled={props.animating} + onClick={zoomOut} + /> + + {`${(currentScale * 100).toFixed(0)}%`} + + + } + disabled={props.animating} + onClick={zoomIn} + /> +
+ } + disabled={props.animating} + onClick={downloadHandler} + /> + } + disabled={props.animating} + onClick={copyHandler} + /> +
+ } + disabled={props.animating || blocks.length === 0} + onClick={() => deleteHandler(cursor)} + /> @@ -472,110 +486,64 @@ export const ImagePreviewErrorBoundary = ( export const ImagePreviewModal = ( props: ImagePreviewModalProps ): ReactElement | null => { - const [blockId, setBlockId] = useAtom(previewBlockIdAtom); - const [isOpen, setIsOpen] = useAtom(hasAnimationPlayedAtom); + const [show, setShow] = useState(false); + const [blockId, setBlockId] = useState(null); + const isOpen = show && !!blockId; - const handleKeyUp = useCallback( - (event: KeyboardEvent) => { - if (event.key === 'Escape') { - event.preventDefault(); - event.stopPropagation(); - if (isOpen) { - setIsOpen(false); + // todo: refactor this to use peek view service + useLayoutEffect(() => { + const handleDblClick = (event: MouseEvent) => { + const target = event.target as HTMLElement | null; + if (target?.tagName === 'IMG') { + const imageBlock = target.closest('affine-image'); + if (imageBlock) { + const blockId = imageBlock.dataset.blockId; + if (!blockId) return; + setBlockId(blockId); + setShow(true); } - return; } - - if (!blockId) { - return; - } - - const workspace = props.docCollection; - - const page = workspace.getDoc(props.pageId); - assertExists(page); - const block = page.getBlockById(blockId); - assertExists(block); - - if (event.key === 'ArrowLeft') { - const prevBlock = page - .getPrevs(block) - .findLast( - (block): block is ImageBlockModel => - block.flavour === 'affine:image' - ); - if (prevBlock) { - setBlockId(prevBlock.id); - } - } else if (event.key === 'ArrowRight') { - const nextBlock = page - .getNexts(block) - .find( - (block): block is ImageBlockModel => - block.flavour === 'affine:image' - ); - if (nextBlock) { - setBlockId(nextBlock.id); - } - } else { - return; - } - event.preventDefault(); - event.stopPropagation(); - }, - [blockId, setBlockId, props.docCollection, props.pageId, isOpen, setIsOpen] - ); - - useEffect(() => { - document.addEventListener('keyup', handleKeyUp); - return () => { - document.removeEventListener('keyup', handleKeyUp); }; - }, [handleKeyUp]); + props.host.addEventListener('dblclick', handleDblClick); + return () => { + props.host.removeEventListener('dblclick', handleDblClick); + }; + }, [props.host]); - if (!blockId) { - return null; - } + const [animating, setAnimating] = useState(false); - return ReactDOM.createPortal( - -
+ return ( + setAnimating(true)} + onAnimateEnd={() => setAnimating(false)} + testId="image-preview-modal" + > + - setBlockId(null)} - /> - - -
-
, - document.body + ) : null} + + + + ); }; diff --git a/packages/frontend/core/src/modules/peek-view/view/doc-peek-view.css.ts b/packages/frontend/core/src/modules/peek-view/view/doc-peek-view.css.ts index e6903aadee..7fb09c3ea2 100644 --- a/packages/frontend/core/src/modules/peek-view/view/doc-peek-view.css.ts +++ b/packages/frontend/core/src/modules/peek-view/view/doc-peek-view.css.ts @@ -1,3 +1,4 @@ +import { cssVar } from '@toeverything/theme'; import { style } from '@vanilla-extract/css'; export const editor = style({ @@ -7,3 +8,12 @@ export const editor = style({ }, minHeight: '100%', }); + +export const affineDocViewport = style({ + display: 'flex', + flexDirection: 'column', + userSelect: 'none', + containerName: 'viewport', + containerType: 'inline-size', + background: cssVar('backgroundPrimaryColor'), +}); diff --git a/packages/frontend/core/src/modules/peek-view/view/doc-peek-view.tsx b/packages/frontend/core/src/modules/peek-view/view/doc-peek-view.tsx index ba789edd9f..20bc02c1dc 100644 --- a/packages/frontend/core/src/modules/peek-view/view/doc-peek-view.tsx +++ b/packages/frontend/core/src/modules/peek-view/view/doc-peek-view.tsx @@ -3,6 +3,7 @@ import { PageDetailSkeleton } from '@affine/component/page-detail-skeleton'; import { AIProvider } from '@affine/core/blocksuite/presets/ai'; import { AffineErrorBoundary } from '@affine/core/components/affine/affine-error-boundary'; import { BlockSuiteEditor } from '@affine/core/components/blocksuite/block-suite-editor'; +import { ImagePreviewModal } from '@affine/core/components/image-preview'; import { useNavigateHelper } from '@affine/core/hooks/use-navigate-helper'; import { PageNotFound } from '@affine/core/pages/404'; import { Bound, type EdgelessRootService } from '@blocksuite/blocks'; @@ -10,6 +11,7 @@ import { DisposableGroup } from '@blocksuite/global/utils'; import type { AffineEditorContainer } from '@blocksuite/presets'; import type { DocMode } from '@toeverything/infra'; import { DocsService, FrameworkScope, useService } from '@toeverything/infra'; +import clsx from 'clsx'; import { useEffect, useState } from 'react'; import { WorkbenchService } from '../../workbench'; @@ -145,7 +147,9 @@ export function DocPeekPreview({ return ( - + + {editor?.host ? ( + + ) : null} diff --git a/packages/frontend/core/src/modules/peek-view/view/modal-container.css.ts b/packages/frontend/core/src/modules/peek-view/view/modal-container.css.ts index 627c8831aa..d97ca50ea2 100644 --- a/packages/frontend/core/src/modules/peek-view/view/modal-container.css.ts +++ b/packages/frontend/core/src/modules/peek-view/view/modal-container.css.ts @@ -3,8 +3,9 @@ import { createVar, keyframes, style } from '@vanilla-extract/css'; export const animationTimeout = createVar(); export const transformOrigin = createVar(); +export const animationType = createVar(); -const contentShow = keyframes({ +const zoomIn = keyframes({ from: { transform: 'scale(0.10)', }, @@ -12,7 +13,7 @@ const contentShow = keyframes({ transform: 'scale(1)', }, }); -const contentHide = keyframes({ +const zoomOut = keyframes({ to: { opacity: 0, transform: 'scale(0.10)', @@ -93,32 +94,57 @@ export const modalContentWrapper = style({ export const modalContentContainer = style({ display: 'flex', alignItems: 'flex-start', - width: '90%', - height: '90%', - maxWidth: 1248, + width: '100%', + height: '100%', willChange: 'transform, opacity', transformOrigin: transformOrigin, selectors: { '&[data-state=entered], &[data-state=entering]': { animationFillMode: 'forwards', - animationName: contentShow, animationDuration: animationTimeout, animationTimingFunction: 'cubic-bezier(0.42, 0, 0.58, 1)', }, '&[data-state=exited], &[data-state=exiting]': { animationFillMode: 'forwards', - animationName: contentHide, animationDuration: animationTimeout, animationTimingFunction: 'cubic-bezier(0.42, 0, 0.58, 1)', }, }, }); +export const modalContentContainerWithZoom = style({ + selectors: { + '&[data-state=entered], &[data-state=entering]': { + animationName: zoomIn, + }, + '&[data-state=exited], &[data-state=exiting]': { + animationName: zoomOut, + }, + }, +}); + +export const modalContentContainerWithFade = style({ + selectors: { + '&[data-state=entered], &[data-state=entering]': { + animationName: fadeIn, + }, + '&[data-state=exited], &[data-state=exiting]': { + animationName: fadeOut, + }, + }, +}); + +export const containerPadding = style({ + width: '90%', + height: '90%', + maxWidth: 1248, +}); + export const modalContent = style({ flex: 1, height: '100%', backgroundColor: cssVar('backgroundOverlayPanelColor'), - boxShadow: '0px 0px 0px 2.23px rgba(0, 0, 0, 0.08)', + backdropFilter: 'drop-shadow(0px 0px 2px rgba(0, 0, 0, 0.08))', borderRadius: '8px', minHeight: 300, // :focus-visible will set outline diff --git a/packages/frontend/core/src/modules/peek-view/view/modal-container.tsx b/packages/frontend/core/src/modules/peek-view/view/modal-container.tsx index 740bc6d8f2..385db277b2 100644 --- a/packages/frontend/core/src/modules/peek-view/view/modal-container.tsx +++ b/packages/frontend/core/src/modules/peek-view/view/modal-container.tsx @@ -1,6 +1,6 @@ import * as Dialog from '@radix-ui/react-dialog'; -import { cssVar } from '@toeverything/theme'; import { assignInlineVars } from '@vanilla-extract/dynamic'; +import clsx from 'clsx'; import { createContext, type PropsWithChildren, @@ -24,7 +24,7 @@ const contentOptions: Dialog.DialogContentProps = { }, style: { padding: 0, - backgroundColor: cssVar('backgroundPrimaryColor'), + backgroundColor: 'transparent', overflow: 'hidden', }, }; @@ -64,14 +64,22 @@ export const PeekViewModalContainer = ({ controls, children, hideOnEntering, + onAnimationStart, onAnimateEnd, + animation = 'zoom', + padding = true, + testId, }: PropsWithChildren<{ open: boolean; hideOnEntering?: boolean; target?: HTMLElement; onOpenChange: (open: boolean) => void; - controls: React.ReactNode; + controls?: React.ReactNode; + onAnimationStart?: () => void; onAnimateEnd?: () => void; + padding?: boolean; + animation?: 'fade' | 'zoom'; + testId?: string; }>) => { const [{ status }, toggle] = useTransition({ timeout: animationTimeout, @@ -100,11 +108,11 @@ export const PeekViewModalContainer = ({ [styles.transformOrigin]: transformOrigin, [styles.animationTimeout]: `${animationTimeout}ms`, })} - onAnimationEnd={() => { - onAnimateEnd?.(); - }} + onAnimationStart={onAnimationStart} + onAnimationEnd={onAnimateEnd} />
@@ -123,9 +137,11 @@ export const PeekViewModalContainer = ({ > {hideOnEntering && status === 'entering' ? null : children} -
- {controls} -
+ {controls ? ( +
+ {controls} +
+ ) : null}
diff --git a/packages/frontend/core/src/pages/workspace/detail-page/detail-page.tsx b/packages/frontend/core/src/pages/workspace/detail-page/detail-page.tsx index 67b2df07de..931bf2e550 100644 --- a/packages/frontend/core/src/pages/workspace/detail-page/detail-page.tsx +++ b/packages/frontend/core/src/pages/workspace/detail-page/detail-page.tsx @@ -287,7 +287,13 @@ const DetailPageImpl = memo(function DetailPageImpl() { } /> - + {editor?.host ? ( + + ) : null} diff --git a/tests/affine-local/e2e/image-preview.spec.ts b/tests/affine-local/e2e/image-preview.spec.ts index 3f67e2de84..ac479a9cec 100644 --- a/tests/affine-local/e2e/image-preview.spec.ts +++ b/tests/affine-local/e2e/image-preview.spec.ts @@ -399,12 +399,9 @@ test('image able to copy to clipboard', async ({ page }) => { const locator = page.getByTestId('image-preview-modal'); await expect(locator).toBeVisible(); await locator.getByTestId('copy-to-clipboard-button').click(); - await new Promise(resolve => { - page.on('console', message => { - expect(message.text()).toBe('Image copied to clipboard'); - resolve(); - }); - }); + await expect( + page.locator('[data-testid=affine-toast]:has-text("Copied to clipboard.")') + ).toBeVisible(); }); test('image able to download', async ({ page }) => {