Skip to content
New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

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

Merged
merged 13 commits into from
May 9, 2023
3 changes: 3 additions & 0 deletions apps/web/preset.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
6 changes: 4 additions & 2 deletions apps/web/src/providers/modal-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ const WorkspaceListModal = lazy(() =>
default: module.WorkspaceListModal,
}))
);

const CreateWorkspaceModal = lazy(() =>
import('../components/pure/create-workspace-modal').then(module => ({
default: module.CreateWorkspaceModal,
Expand All @@ -39,7 +40,8 @@ const TmpDisableAffineCloudModal = lazy(() =>
})
)
);
const OnboardingModalAtom = lazy(() =>

const OnboardingModal = lazy(() =>
import('../components/pure/onboarding-modal').then(module => ({
default: module.OnboardingModal,
}))
Expand Down Expand Up @@ -85,7 +87,7 @@ export function Modals() {
</Suspense>
{env.isDesktop && (
<Suspense>
<OnboardingModalAtom
<OnboardingModal
open={openOnboardingModal}
onClose={onCloseOnboardingModal}
/>
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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} />
Expand Down
21 changes: 20 additions & 1 deletion packages/component/src/components/block-suite-editor/index.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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';

Expand All @@ -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
Expand Down Expand Up @@ -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>
);
});
Expand Down
55 changes: 55 additions & 0 deletions packages/component/src/components/image-preview-modal/index.css.ts
Original file line number Diff line number Diff line change
@@ -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)',
});
Original file line number Diff line number Diff line change
@@ -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);
};
}
};
Original file line number Diff line number Diff line change
@@ -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"
/>
</>
);
};
126 changes: 126 additions & 0 deletions packages/component/src/components/image-preview-modal/index.tsx
Original file line number Diff line number Diff line change
@@ -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)}
/>
);
};
1 change: 1 addition & 0 deletions packages/env/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
10 changes: 10 additions & 0 deletions playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Binary file added tests/fixtures/large-image.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading