diff --git a/apps/web/preset.config.mjs b/apps/web/preset.config.mjs index d8f57d2250..d0061c95f3 100644 --- a/apps/web/preset.config.mjs +++ b/apps/web/preset.config.mjs @@ -18,6 +18,9 @@ export const blockSuiteFeatureFlags = { * @type {import('@affine/env').BuildFlags} */ export const buildFlags = { + enableImagePreviewModal: process.env.ENABLE_IMAGE_PREVIEW_MODAL + ? process.env.ENABLE_IMAGE_PREVIEW_MODAL === 'true' + : true, enableTestProperties: process.env.ENABLE_TEST_PROPERTIES ? process.env.ENABLE_TEST_PROPERTIES === 'true' : true, diff --git a/apps/web/src/providers/modal-provider.tsx b/apps/web/src/providers/modal-provider.tsx index 778c630446..3d32e14779 100644 --- a/apps/web/src/providers/modal-provider.tsx +++ b/apps/web/src/providers/modal-provider.tsx @@ -26,6 +26,7 @@ const WorkspaceListModal = lazy(() => default: module.WorkspaceListModal, })) ); + const CreateWorkspaceModal = lazy(() => import('../components/pure/create-workspace-modal').then(module => ({ default: module.CreateWorkspaceModal, @@ -39,7 +40,8 @@ const TmpDisableAffineCloudModal = lazy(() => }) ) ); -const OnboardingModalAtom = lazy(() => + +const OnboardingModal = lazy(() => import('../components/pure/onboarding-modal').then(module => ({ default: module.OnboardingModal, })) @@ -85,7 +87,7 @@ export function Modals() { {env.isDesktop && ( - diff --git a/package.json b/package.json index 3ef9939fbc..03c3f9503d 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "export": "yarn workspace @affine/web export", "start": "yarn workspace @affine/web start", "start:storybook": "yarn exec serve packages/component/storybook-static -l 6006", + "serve:test-static": "yarn exec serve tests/fixtures --cors -p 8081", "start:e2e": "yar dlx run-p start start:storybook", "lint": "eslint . --ext .js,mjs,.ts,.tsx --cache", "lint:fix": "yarn lint --fix", diff --git a/packages/component/src/components/block-suite-editor/index.stories.tsx b/packages/component/src/components/block-suite-editor/index.stories.tsx index 2c82d80650..9b21928910 100644 --- a/packages/component/src/components/block-suite-editor/index.stories.tsx +++ b/packages/component/src/components/block-suite-editor/index.stories.tsx @@ -48,6 +48,7 @@ const Template: StoryFn = (props: Partial) => { style={{ height: '100vh', width: '100vw', + overflow: 'auto', }} > diff --git a/packages/component/src/components/block-suite-editor/index.tsx b/packages/component/src/components/block-suite-editor/index.tsx index 39b8607d74..eef7dea7e0 100644 --- a/packages/component/src/components/block-suite-editor/index.tsx +++ b/packages/component/src/components/block-suite-editor/index.tsx @@ -1,3 +1,4 @@ +import { config } from '@affine/env'; import { editorContainerModuleAtom } from '@affine/jotai'; import type { BlockHub } from '@blocksuite/blocks'; import type { EditorContainer } from '@blocksuite/editor'; @@ -6,7 +7,8 @@ import type { Page } from '@blocksuite/store'; import { Skeleton } from '@mui/material'; import { useAtomValue } from 'jotai'; import type { CSSProperties, ReactElement } from 'react'; -import { memo, Suspense, useCallback, useEffect, useRef } from 'react'; +import { lazy, memo, Suspense, useCallback, useEffect, useRef } from 'react'; +import { createPortal } from 'react-dom'; import type { FallbackProps } from 'react-error-boundary'; import { ErrorBoundary } from 'react-error-boundary'; @@ -31,6 +33,12 @@ declare global { var currentEditor: EditorContainer | undefined; } +const ImagePreviewModal = lazy(() => + import('../image-preview-modal').then(module => ({ + default: module.ImagePreviewModal, + })) +); + const BlockSuiteEditorImpl = (props: EditorProps): ReactElement => { const JotaiEditorContainer = useAtomValue( editorContainerModuleAtom @@ -152,6 +160,17 @@ export const BlockSuiteEditor = memo(function BlockSuiteEditor( }> + {config.enableImagePreviewModal && props.page && ( + + {createPortal( + , + document.body + )} + + )} ); }); diff --git a/packages/component/src/components/image-preview-modal/index.css.ts b/packages/component/src/components/image-preview-modal/index.css.ts new file mode 100644 index 0000000000..da79fc3be2 --- /dev/null +++ b/packages/component/src/components/image-preview-modal/index.css.ts @@ -0,0 +1,55 @@ +import { baseTheme } from '@toeverything/theme'; +import { style } from '@vanilla-extract/css'; + +export const imagePreviewModalStyle = style({ + position: 'fixed', + top: 0, + left: 0, + width: '100%', + height: '100%', + zIndex: baseTheme.zIndexModal, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + background: 'var(--affine-background-modal-color)', +}); + +export const imagePreviewModalCloseButtonStyle = style({ + position: 'absolute', + display: 'flex', + flexDirection: 'column', + justifyContent: 'center', + alignItems: 'center', + + height: '36px', + width: '36px', + borderRadius: '10px', + + top: '0.5rem', + right: '0.5rem', + background: 'var(--affine-white)', + border: 'none', + padding: '0.5rem', + cursor: 'pointer', + color: 'var(--affine-icon-color)', + transition: 'background 0.2s ease-in-out', +}); + +export const imagePreviewModalContainerStyle = style({ + position: 'absolute', + top: '20%', +}); + +export const imagePreviewModalImageStyle = style({ + background: 'transparent', + maxWidth: '686px', + objectFit: 'contain', + objectPosition: 'center', + borderRadius: '4px', +}); + +export const imagePreviewModalActionsStyle = style({ + position: 'absolute', + bottom: '28px', + background: 'var(--affine-white)', +}); diff --git a/packages/component/src/components/image-preview-modal/index.jotai.ts b/packages/component/src/components/image-preview-modal/index.jotai.ts new file mode 100644 index 0000000000..f19968f02d --- /dev/null +++ b/packages/component/src/components/image-preview-modal/index.jotai.ts @@ -0,0 +1,16 @@ +import type { EmbedBlockDoubleClickData } from '@blocksuite/blocks'; +import { atom } from 'jotai'; + +export const previewBlockIdAtom = atom(null); + +previewBlockIdAtom.onMount = set => { + if (typeof window !== 'undefined') { + const callback = (event: CustomEvent) => { + set(event.detail.blockId); + }; + window.addEventListener('affine.embed-block-db-click', callback); + return () => { + window.removeEventListener('affine.embed-block-db-click', callback); + }; + } +}; diff --git a/packages/component/src/components/image-preview-modal/index.stories.tsx b/packages/component/src/components/image-preview-modal/index.stories.tsx new file mode 100644 index 0000000000..bcf817a91e --- /dev/null +++ b/packages/component/src/components/image-preview-modal/index.stories.tsx @@ -0,0 +1,65 @@ +import { initPage } from '@affine/env/blocksuite'; +import { WorkspaceFlavour } from '@affine/workspace/type'; +import { createEmptyBlockSuiteWorkspace } from '@affine/workspace/utils'; +import type { Meta } from '@storybook/react'; + +import { BlockSuiteEditor } from '../block-suite-editor'; +import { ImagePreviewModal } from '.'; + +export default { + title: 'Component/ImagePreviewModal', + component: ImagePreviewModal, +} satisfies Meta; + +const workspace = createEmptyBlockSuiteWorkspace( + 'test', + WorkspaceFlavour.LOCAL +); +const page = workspace.createPage('page0'); +initPage(page); +fetch(new URL('@affine-test/fixtures/large-image.png', import.meta.url)) + .then(res => res.arrayBuffer()) + .then(async buffer => { + const id = await workspace.blobs.set( + new Blob([buffer], { type: 'image/png' }) + ); + const frameId = page.getBlockByFlavour('affine:frame')[0].id; + page.addBlock( + 'affine:paragraph', + { + text: new page.Text('Please double click the image to preview it.'), + }, + frameId + ); + page.addBlock( + 'affine:embed', + { + sourceId: id, + }, + frameId + ); + }); + +export const Default = () => { + return ( + <> +
+ +
+
+ + ); +}; diff --git a/packages/component/src/components/image-preview-modal/index.tsx b/packages/component/src/components/image-preview-modal/index.tsx new file mode 100644 index 0000000000..97dc34ffa2 --- /dev/null +++ b/packages/component/src/components/image-preview-modal/index.tsx @@ -0,0 +1,126 @@ +/// +import '@blocksuite/blocks'; + +import type { EmbedBlockModel } from '@blocksuite/blocks'; +import { assertExists } from '@blocksuite/global/utils'; +import type { Workspace } from '@blocksuite/store'; +import { useAtom } from 'jotai'; +import type { ReactElement } from 'react'; +import { useEffect, useRef, useState } from 'react'; +import useSWR from 'swr'; + +import { + imagePreviewModalCloseButtonStyle, + imagePreviewModalContainerStyle, + imagePreviewModalImageStyle, + imagePreviewModalStyle, +} from './index.css'; +import { previewBlockIdAtom } from './index.jotai'; + +export type ImagePreviewModalProps = { + workspace: Workspace; + pageId: string; +}; + +const ImagePreviewModalImpl = ( + props: ImagePreviewModalProps & { + blockId: string; + onClose: () => void; + } +): ReactElement | null => { + const [caption, setCaption] = useState(() => { + const page = props.workspace.getPage(props.pageId); + assertExists(page); + const block = page.getBlockById(props.blockId) as EmbedBlockModel | null; + assertExists(block); + return block.caption; + }); + useEffect(() => { + const page = props.workspace.getPage(props.pageId); + assertExists(page); + const block = page.getBlockById(props.blockId) as EmbedBlockModel | null; + assertExists(block); + const disposable = block.propsUpdated.on(() => { + setCaption(block.caption); + }); + return () => { + disposable.dispose(); + }; + }, [props.blockId, props.pageId, props.workspace]); + const { data } = useSWR(['workspace', 'embed', props.pageId, props.blockId], { + fetcher: ([_, __, pageId, blockId]) => { + const page = props.workspace.getPage(pageId); + assertExists(page); + const block = page.getBlockById(blockId) as EmbedBlockModel | null; + assertExists(block); + return props.workspace.blobs.get(block.sourceId); + }, + suspense: true, + }); + const [prevData, setPrevData] = useState(() => data); + const [url, setUrl] = useState(null); + const imageRef = useRef(null); + if (prevData !== data) { + if (url) { + URL.revokeObjectURL(url); + } + setUrl(URL.createObjectURL(data)); + + setPrevData(data); + } else if (!url) { + setUrl(URL.createObjectURL(data)); + } + if (!url) { + return null; + } + return ( +
+ +
+ {caption} +
+
+ ); +}; + +export const ImagePreviewModal = ( + props: ImagePreviewModalProps +): ReactElement | null => { + const [blockId, setBlockId] = useAtom(previewBlockIdAtom); + if (!blockId) { + return null; + } + + return ( + setBlockId(null)} + /> + ); +}; diff --git a/packages/env/src/config.ts b/packages/env/src/config.ts index 79f093bd7b..f49db2c41a 100644 --- a/packages/env/src/config.ts +++ b/packages/env/src/config.ts @@ -6,6 +6,7 @@ import { z } from 'zod'; import { getUaHelper } from './ua-helper'; export const buildFlagsSchema = z.object({ + enableImagePreviewModal: z.boolean(), enableTestProperties: z.boolean(), enableBroadCastChannelProvider: z.boolean(), enableDebugPage: z.boolean(), diff --git a/playwright.config.ts b/playwright.config.ts index dabd9a8951..de73acd6c2 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -42,6 +42,16 @@ const config: PlaywrightTestConfig = { reporter: process.env.CI ? 'github' : 'list', webServer: [ + { + command: 'yarn serve:test-static', + port: 8081, + timeout: 120 * 1000, + reuseExistingServer: !process.env.CI, + env: { + COVERAGE: process.env.COVERAGE || 'false', + ENABLE_DEBUG_PAGE: '1', + }, + }, { // Intentionally not building the storybook, reminds you to run it by yourself. command: 'yarn run start:storybook', diff --git a/tests/fixtures/large-image.png b/tests/fixtures/large-image.png new file mode 100644 index 0000000000..bc27c7c78b Binary files /dev/null and b/tests/fixtures/large-image.png differ diff --git a/tests/parallels/image-preview.spec.ts b/tests/parallels/image-preview.spec.ts new file mode 100644 index 0000000000..b25efdf0ca --- /dev/null +++ b/tests/parallels/image-preview.spec.ts @@ -0,0 +1,44 @@ +import { expect, test } from '@playwright/test'; + +import { openHomePage } from '../libs/load-page'; +import { + getBlockSuiteEditorTitle, + newPage, + waitMarkdownImported, +} from '../libs/page-logic'; + +test('image preview should be shown', async ({ page }) => { + await openHomePage(page); + await waitMarkdownImported(page); + await newPage(page); + const title = await getBlockSuiteEditorTitle(page); + await title.click(); + await page.keyboard.press('Enter'); + await page.evaluate(() => { + const clipData = { + 'text/html': ``, + }; + const e = new ClipboardEvent('paste', { + clipboardData: new DataTransfer(), + }); + Object.defineProperty(e, 'target', { + writable: false, + value: document.body, + }); + Object.entries(clipData).forEach(([key, value]) => { + e.clipboardData?.setData(key, value); + }); + document.body.dispatchEvent(e); + }); + await page.waitForTimeout(500); + await page.locator('img').first().dblclick(); + const locator = page.getByTestId('image-preview-modal'); + expect(locator.isVisible()).toBeTruthy(); + await page + .getByTestId('image-preview-modal') + .locator('button') + .first() + .click(); + await page.waitForTimeout(500); + expect(await locator.isVisible()).toBeFalsy(); +});