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 (
+
+
+
+
+
+
+ );
+};
+
+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();
+});