feat(component): support image preview by double click (#2198)

This commit is contained in:
Himself65 2023-05-09 14:09:39 +08:00 committed by GitHub
parent 242e074ae6
commit c41718e80d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 346 additions and 3 deletions

View File

@ -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,

View File

@ -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() {
</Suspense>
{env.isDesktop && (
<Suspense>
<OnboardingModalAtom
<OnboardingModal
open={openOnboardingModal}
onClose={onCloseOnboardingModal}
/>

View File

@ -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",

View File

@ -48,6 +48,7 @@ const Template: StoryFn<EditorProps> = (props: Partial<EditorProps>) => {
style={{
height: '100vh',
width: '100vw',
overflow: 'auto',
}}
>
<BlockSuiteEditor onInit={initPage} page={page} mode="page" {...props} />

View File

@ -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(
<Suspense fallback={<BlockSuiteFallback />}>
<BlockSuiteEditorImpl {...props} />
</Suspense>
{config.enableImagePreviewModal && props.page && (
<Suspense fallback={null}>
{createPortal(
<ImagePreviewModal
workspace={props.page.workspace}
pageId={props.page.id}
/>,
document.body
)}
</Suspense>
)}
</ErrorBoundary>
);
});

View File

@ -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)',
});

View File

@ -0,0 +1,16 @@
import type { EmbedBlockDoubleClickData } from '@blocksuite/blocks';
import { atom } from 'jotai';
export const previewBlockIdAtom = atom<string | null>(null);
previewBlockIdAtom.onMount = set => {
if (typeof window !== 'undefined') {
const callback = (event: CustomEvent<EmbedBlockDoubleClickData>) => {
set(event.detail.blockId);
};
window.addEventListener('affine.embed-block-db-click', callback);
return () => {
window.removeEventListener('affine.embed-block-db-click', callback);
};
}
};

View File

@ -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 (
<>
<div
style={{
height: '100vh',
width: '100vw',
overflow: 'auto',
}}
>
<BlockSuiteEditor mode="page" page={page} onInit={initPage} />
</div>
<div
style={{
position: 'absolute',
right: 12,
bottom: 12,
}}
id="toolWrapper"
/>
</>
);
};

View File

@ -0,0 +1,126 @@
/// <reference types="react/experimental" />
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<string | null>(() => data);
const [url, setUrl] = useState<string | null>(null);
const imageRef = useRef<HTMLImageElement>(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 (
<div data-testid="image-preview-modal" className={imagePreviewModalStyle}>
<button
onClick={() => {
props.onClose();
}}
className={imagePreviewModalCloseButtonStyle}
>
<svg
width="10"
height="10"
viewBox="0 0 10 10"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M0.286086 0.285964C0.530163 0.0418858 0.925891 0.0418858 1.16997 0.285964L5.00013 4.11613L8.83029 0.285964C9.07437 0.0418858 9.4701 0.0418858 9.71418 0.285964C9.95825 0.530041 9.95825 0.925769 9.71418 1.16985L5.88401 5.00001L9.71418 8.83017C9.95825 9.07425 9.95825 9.46998 9.71418 9.71405C9.4701 9.95813 9.07437 9.95813 8.83029 9.71405L5.00013 5.88389L1.16997 9.71405C0.925891 9.95813 0.530163 9.95813 0.286086 9.71405C0.0420079 9.46998 0.0420079 9.07425 0.286086 8.83017L4.11625 5.00001L0.286086 1.16985C0.0420079 0.925769 0.0420079 0.530041 0.286086 0.285964Z"
fill="#77757D"
/>
</svg>
</button>
<div className={imagePreviewModalContainerStyle}>
<img
alt={caption}
className={imagePreviewModalImageStyle}
ref={imageRef}
src={url}
/>
</div>
</div>
);
};
export const ImagePreviewModal = (
props: ImagePreviewModalProps
): ReactElement | null => {
const [blockId, setBlockId] = useAtom(previewBlockIdAtom);
if (!blockId) {
return null;
}
return (
<ImagePreviewModalImpl
{...props}
blockId={blockId}
onClose={() => setBlockId(null)}
/>
);
};

View File

@ -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(),

View File

@ -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',

BIN
tests/fixtures/large-image.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

View File

@ -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': `<img src="http://localhost:8081/large-image.png" />`,
};
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();
});