mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-12 04:18:54 +00:00
feat(component): support image preview by double click (#2198)
This commit is contained in:
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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)',
|
||||
});
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -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
packages/component/src/components/image-preview-modal/index.tsx
Normal file
126
packages/component/src/components/image-preview-modal/index.tsx
Normal 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)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user