refactor(core): image block use peek view workflow (#7329)

depends on https://github.com/toeverything/blocksuite/pull/7424
This commit is contained in:
pengx17
2024-06-26 07:49:25 +00:00
parent 092c639b0a
commit 7baa260e97
20 changed files with 340 additions and 305 deletions
@@ -3,6 +3,7 @@ import {
AffineReference,
type EmbedLinkedDocModel,
type EmbedSyncedDocModel,
type ImageBlockModel,
type SurfaceRefBlockComponent,
type SurfaceRefBlockModel,
} from '@blocksuite/blocks';
@@ -12,6 +13,7 @@ import type { TemplateResult } from 'lit';
import { firstValueFrom, map, race } from 'rxjs';
import { resolveLinkToDoc } from '../../navigation';
import type { WorkbenchService } from '../../workbench';
export type PeekViewTarget =
| HTMLElement
@@ -20,17 +22,28 @@ export type PeekViewTarget =
| HTMLAnchorElement
| { docId: string; blockId?: string };
export type DocPeekViewInfo = {
export interface DocPeekViewInfo {
type: 'doc';
docId: string;
blockId?: string;
mode?: DocMode;
xywh?: `[${number},${number},${number},${number}]`;
}
export type ImagePeekViewInfo = {
type: 'image';
docId: string;
blockId: string;
};
export type CustomTemplatePeekViewInfo = {
type: 'template';
template: TemplateResult;
};
export type ActivePeekView = {
target: PeekViewTarget;
info?: DocPeekViewInfo;
template?: TemplateResult;
info: DocPeekViewInfo | ImagePeekViewInfo | CustomTemplatePeekViewInfo;
};
const EMBED_DOC_FLAVOURS = [
@@ -44,6 +57,12 @@ const isEmbedDocModel = (
return EMBED_DOC_FLAVOURS.includes(blockModel.flavour);
};
const isImageBlockModel = (
blockModel: BlockModel
): blockModel is ImageBlockModel => {
return blockModel.flavour === 'affine:image';
};
const isSurfaceRefModel = (
blockModel: BlockModel
): blockModel is SurfaceRefBlockModel => {
@@ -51,12 +70,20 @@ const isSurfaceRefModel = (
};
function resolvePeekInfoFromPeekTarget(
peekTarget?: PeekViewTarget
): DocPeekViewInfo | undefined {
if (!peekTarget) return;
peekTarget: PeekViewTarget,
template?: TemplateResult
): ActivePeekView['info'] | undefined {
if (template) {
return {
type: 'template',
template,
};
}
if (peekTarget instanceof AffineReference) {
if (peekTarget.refMeta) {
return {
type: 'doc',
docId: peekTarget.refMeta.id,
};
}
@@ -64,6 +91,7 @@ function resolvePeekInfoFromPeekTarget(
const blockModel = peekTarget.model;
if (isEmbedDocModel(blockModel)) {
return {
type: 'doc',
docId: blockModel.pageId,
};
} else if (isSurfaceRefModel(blockModel)) {
@@ -73,22 +101,31 @@ function resolvePeekInfoFromPeekTarget(
const docId =
'doc' in refModel ? refModel.doc.id : refModel.surface.doc.id;
return {
type: 'doc',
docId,
mode: 'edgeless',
xywh: refModel.xywh,
};
}
} else if (isImageBlockModel(blockModel)) {
return {
type: 'image',
docId: blockModel.doc.id,
blockId: blockModel.id,
};
}
} else if (peekTarget instanceof HTMLAnchorElement) {
const maybeDoc = resolveLinkToDoc(peekTarget.href);
if (maybeDoc) {
return {
type: 'doc',
docId: maybeDoc.docId,
blockId: maybeDoc.blockId,
};
}
} else if ('docId' in peekTarget) {
return {
type: 'doc',
docId: peekTarget.docId,
blockId: peekTarget.blockId,
};
@@ -100,6 +137,10 @@ export class PeekViewEntity extends Entity {
private readonly _active$ = new LiveData<ActivePeekView | null>(null);
private readonly _show$ = new LiveData<boolean>(false);
constructor(private readonly workbenchService: WorkbenchService) {
super();
}
active$ = this._active$.distinctUntilChanged();
show$ = this._show$
.map(show => show && this._active$.value !== null)
@@ -110,11 +151,20 @@ export class PeekViewEntity extends Entity {
target: ActivePeekView['target'],
template?: TemplateResult
) => {
const resolvedInfo = resolvePeekInfoFromPeekTarget(target);
if (!resolvedInfo && !template) {
const resolvedInfo = resolvePeekInfoFromPeekTarget(target, template);
if (!resolvedInfo) {
return;
}
this._active$.next({ target, info: resolvedInfo, template });
const active = this._active$.value;
// if there is an active peek view and it is a doc peek view, we will navigate it first
if (active?.info.type === 'doc' && this.show$.value) {
// TODO(@pengx17): scroll to the viewing position?
this.workbenchService.workbench.openPage(active.info.docId);
}
this._active$.next({ target, info: resolvedInfo });
this._show$.next(true);
return firstValueFrom(race(this._active$, this.show$).pipe(map(() => {})));
};
@@ -1,10 +1,14 @@
import type { Framework } from '@toeverything/infra';
import { type Framework, WorkspaceScope } from '@toeverything/infra';
import { WorkbenchService } from '../workbench';
import { PeekViewEntity } from './entities/peek-view';
import { PeekViewService } from './services/peek-view';
export function configurePeekViewModule(framework: Framework) {
framework.service(PeekViewService).entity(PeekViewEntity);
framework
.scope(WorkspaceScope)
.service(PeekViewService)
.entity(PeekViewEntity, [WorkbenchService]);
}
export { PeekViewEntity, PeekViewService };
@@ -3,7 +3,6 @@ import { PageDetailSkeleton } from '@affine/component/page-detail-skeleton';
import { AIProvider } from '@affine/core/blocksuite/presets/ai';
import { AffineErrorBoundary } from '@affine/core/components/affine/affine-error-boundary';
import { BlockSuiteEditor } from '@affine/core/components/blocksuite/block-suite-editor';
import { ImagePreviewModal } from '@affine/core/components/image-preview';
import { useNavigateHelper } from '@affine/core/hooks/use-navigate-helper';
import { PageNotFound } from '@affine/core/pages/404';
import { Bound, type EdgelessRootService } from '@blocksuite/blocks';
@@ -14,10 +13,10 @@ import { DocsService, FrameworkScope, useService } from '@toeverything/infra';
import clsx from 'clsx';
import { useEffect, useState } from 'react';
import { WorkbenchService } from '../../workbench';
import { PeekViewService } from '../services/peek-view';
import { WorkbenchService } from '../../../workbench';
import { PeekViewService } from '../../services/peek-view';
import { useDoc } from '../utils';
import * as styles from './doc-peek-view.css';
import { useDoc } from './utils';
function fitViewport(
editor: AffineEditorContainer,
@@ -160,13 +159,6 @@ export function DocPeekPreview({
defaultSelectedBlockId={blockId}
page={doc.blockSuiteDoc}
/>
{editor?.host ? (
<ImagePreviewModal
pageId={doc.id}
docCollection={doc.blockSuiteDoc.collection}
host={editor.host}
/>
) : null}
</FrameworkScope>
</Scrollable.Viewport>
<Scrollable.Scrollbar />
@@ -0,0 +1 @@
export { DocPeekPreview } from './doc-peek-view';
@@ -0,0 +1,228 @@
import type { MouseEvent as ReactMouseEvent, RefObject } from 'react';
import { useCallback, useEffect, useState } from 'react';
interface UseZoomControlsProps {
zoomRef: RefObject<HTMLDivElement>;
imageRef: RefObject<HTMLImageElement>;
}
export const useZoomControls = ({
zoomRef,
imageRef,
}: UseZoomControlsProps) => {
const [currentScale, setCurrentScale] = useState<number>(1);
const [isZoomedBigger, setIsZoomedBigger] = useState<boolean>(false);
const [isDragging, setIsDragging] = useState<boolean>(false);
const [mouseX, setMouseX] = useState<number>(0);
const [mouseY, setMouseY] = useState<number>(0);
const [dragBeforeX, setDragBeforeX] = useState<number>(0);
const [dragBeforeY, setDragBeforeY] = useState<number>(0);
const [imagePos, setImagePos] = useState<{ x: number; y: number }>({
x: 0,
y: 0,
});
const handleDragStart = useCallback(
(event: ReactMouseEvent) => {
event?.preventDefault();
setIsDragging(true);
const image = imageRef.current;
if (image && isZoomedBigger) {
image.style.cursor = 'grab';
const rect = image.getBoundingClientRect();
setDragBeforeX(rect.left);
setDragBeforeY(rect.top);
setMouseX(event.clientX);
setMouseY(event.clientY);
}
},
[imageRef, isZoomedBigger]
);
const handleDrag = useCallback(
(event: ReactMouseEvent) => {
event?.preventDefault();
const image = imageRef.current;
if (isDragging && image && isZoomedBigger) {
image.style.cursor = 'grabbing';
const currentX = imagePos.x;
const currentY = imagePos.y;
const newPosX = currentX + event.clientX - mouseX;
const newPosY = currentY + event.clientY - mouseY;
image.style.transform = `translate(${newPosX}px, ${newPosY}px)`;
}
},
[
imagePos.x,
imagePos.y,
imageRef,
isDragging,
isZoomedBigger,
mouseX,
mouseY,
]
);
const dragEndImpl = useCallback(() => {
setIsDragging(false);
const image = imageRef.current;
if (image && isZoomedBigger && isDragging) {
image.style.cursor = 'pointer';
const rect = image.getBoundingClientRect();
const newPos = { x: rect.left, y: rect.top };
const currentX = imagePos.x;
const currentY = imagePos.y;
const newPosX = currentX + newPos.x - dragBeforeX;
const newPosY = currentY + newPos.y - dragBeforeY;
setImagePos({ x: newPosX, y: newPosY });
}
}, [
dragBeforeX,
dragBeforeY,
imagePos.x,
imagePos.y,
imageRef,
isDragging,
isZoomedBigger,
]);
const handleDragEnd = useCallback(
(event: ReactMouseEvent) => {
event.preventDefault();
dragEndImpl();
},
[dragEndImpl]
);
const handleMouseUp = useCallback(
(evt: MouseEvent) => {
evt.preventDefault();
if (isDragging) {
dragEndImpl();
}
},
[isDragging, dragEndImpl]
);
const checkZoomSize = useCallback(() => {
const { current: zoomArea } = zoomRef;
if (zoomArea) {
const image = zoomArea.querySelector('img');
if (image) {
const zoomedWidth = image.naturalWidth * currentScale;
const zoomedHeight = image.naturalHeight * currentScale;
const containerWidth = window.innerWidth;
const containerHeight = window.innerHeight;
setIsZoomedBigger(
zoomedWidth > containerWidth || zoomedHeight > containerHeight
);
}
}
}, [currentScale, zoomRef]);
const zoomIn = useCallback(() => {
const image = imageRef.current;
if (image && currentScale < 2) {
const newScale = currentScale + 0.1;
setCurrentScale(newScale);
image.style.width = `${image.naturalWidth * newScale}px`;
image.style.height = `${image.naturalHeight * newScale}px`;
}
}, [imageRef, currentScale]);
const zoomOut = useCallback(() => {
const image = imageRef.current;
if (image && currentScale > 0.2) {
const newScale = currentScale - 0.1;
setCurrentScale(newScale);
image.style.width = `${image.naturalWidth * newScale}px`;
image.style.height = `${image.naturalHeight * newScale}px`;
const zoomedWidth = image.naturalWidth * newScale;
const zoomedHeight = image.naturalHeight * newScale;
const containerWidth = window.innerWidth;
const containerHeight = window.innerHeight;
if (zoomedWidth > containerWidth || zoomedHeight > containerHeight) {
image.style.transform = `translate(0px, 0px)`;
setImagePos({ x: 0, y: 0 });
}
}
}, [imageRef, currentScale]);
const resetZoom = useCallback(() => {
const image = imageRef.current;
if (image) {
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const margin = 0.2;
const availableWidth = viewportWidth * (1 - margin);
const availableHeight = viewportHeight * (1 - margin);
const widthRatio = availableWidth / image.naturalWidth;
const heightRatio = availableHeight / image.naturalHeight;
const newScale = Math.min(widthRatio, heightRatio);
setCurrentScale(newScale);
image.style.width = `${image.naturalWidth * newScale}px`;
image.style.height = `${image.naturalHeight * newScale}px`;
image.style.transform = 'translate(0px, 0px)';
setImagePos({ x: 0, y: 0 });
checkZoomSize();
}
}, [imageRef, checkZoomSize]);
const resetScale = useCallback(() => {
const image = imageRef.current;
if (image) {
setCurrentScale(1);
image.style.width = `${image.naturalWidth}px`;
image.style.height = `${image.naturalHeight}px`;
image.style.transform = 'translate(0px, 0px)';
setImagePos({ x: 0, y: 0 });
}
}, [imageRef]);
useEffect(() => {
const handleScroll = (event: WheelEvent) => {
event.preventDefault();
const { deltaY } = event;
if (deltaY > 0) {
zoomOut();
} else if (deltaY < 0 && currentScale < 2) {
zoomIn();
}
};
const handleResize = (event: UIEvent) => {
event.preventDefault();
checkZoomSize();
};
checkZoomSize();
window.addEventListener('wheel', handleScroll, { passive: false });
window.addEventListener('resize', handleResize);
window.addEventListener('mouseup', handleMouseUp);
return () => {
window.removeEventListener('wheel', handleScroll);
window.removeEventListener('resize', handleResize);
window.removeEventListener('mouseup', handleMouseUp);
};
}, [zoomIn, zoomOut, checkZoomSize, handleMouseUp, currentScale]);
return {
zoomIn,
zoomOut,
resetZoom,
resetScale,
isZoomedBigger,
currentScale,
handleDragStart,
handleDrag,
handleDragEnd,
};
};
@@ -0,0 +1,141 @@
import { cssVar } from '@toeverything/theme';
import { style } from '@vanilla-extract/css';
export const imagePreviewModalStyle = style({
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
});
export const imagePreviewTrap = style({
position: 'absolute',
inset: 0,
width: '100%',
height: '100%',
});
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: cssVar('white'),
border: 'none',
padding: '0.5rem',
cursor: 'pointer',
color: cssVar('iconColor'),
transition: 'background 0.2s ease-in-out',
zIndex: 1,
marginTop: '38px',
marginRight: '38px',
});
export const imagePreviewModalGoStyle = style({
color: cssVar('white'),
position: 'absolute',
fontSize: '60px',
lineHeight: '60px',
fontWeight: 'bold',
opacity: '0.2',
padding: '0 15px',
cursor: 'pointer',
});
export const imageNavigationControlStyle = style({
display: 'flex',
height: '100%',
zIndex: 2,
justifyContent: 'space-between',
alignItems: 'center',
});
export const imagePreviewModalContainerStyle = style({
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
zIndex: 1,
'@media': {
'screen and (max-width: 768px)': {
alignItems: 'center',
},
},
});
export const imagePreviewModalCenterStyle = style({
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
userSelect: 'none',
});
export const imagePreviewModalCaptionStyle = style({
color: cssVar('white'),
marginTop: '24px',
'@media': {
'screen and (max-width: 768px)': {
textAlign: 'center',
},
},
});
export const imagePreviewActionBarStyle = style({
height: '36px',
maxWidth: 'max-content',
padding: '0 6px',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
gap: '8px',
borderRadius: '4px',
border: `0.5px solid ${cssVar('borderColor')}`,
backgroundColor: cssVar('white'),
boxShadow: '0px 6px 16px 0px rgba(0, 0, 0, 0.14)',
boxSizing: 'content-box',
color: cssVar('iconColor'),
userSelect: 'none',
});
export const cursorStyle = style({
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '24px',
minWidth: '34px',
padding: '1px 2px',
fontSize: '14px',
});
export const dividerStyle = style({
width: '0.5px',
height: '100%',
background: cssVar('borderColor'),
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
});
export const scaleIndicatorButtonStyle = style({
height: '24px',
padding: '1px 2px',
minWidth: '50px',
fontSize: '14px',
color: `${cssVar('iconColor')} !important`,
':hover': {
backgroundColor: cssVar('hoverColor'),
},
});
export const imageBottomContainerStyle = style({
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
position: 'fixed',
bottom: '28px',
zIndex: 2,
});
export const captionStyle = style({
maxWidth: '686px',
color: cssVar('white'),
background: 'rgba(0,0,0,0.75)',
padding: '10px',
marginBottom: '21px',
});
export const suspenseFallbackStyle = style({
opacity: 0,
transition: 'opacity 2s ease-in-out',
});
@@ -0,0 +1,508 @@
import { toast } from '@affine/component';
import { Button, IconButton } from '@affine/component/ui/button';
import { Tooltip } from '@affine/component/ui/tooltip';
import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
import type { ImageBlockModel } from '@blocksuite/blocks';
import { assertExists } from '@blocksuite/global/utils';
import {
ArrowLeftSmallIcon,
ArrowRightSmallIcon,
CloseIcon,
CopyIcon,
DeleteIcon,
DownloadIcon,
MinusIcon,
PlusIcon,
ViewBarIcon,
} from '@blocksuite/icons/rc';
import type { BlockModel } from '@blocksuite/store';
import { useService } from '@toeverything/infra';
import clsx from 'clsx';
import { useErrorBoundary } from 'foxact/use-error-boundary';
import type { PropsWithChildren, ReactElement } from 'react';
import {
Suspense,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import type { FallbackProps } from 'react-error-boundary';
import { ErrorBoundary } from 'react-error-boundary';
import useSWR from 'swr';
import { PeekViewService } from '../../services/peek-view';
import { useDoc } from '../utils';
import { useZoomControls } from './hooks/use-zoom';
import * as styles from './index.css';
const filterImageBlock = (block: BlockModel): block is ImageBlockModel => {
return block.flavour === 'affine:image';
};
function resolveMimeType(buffer: Uint8Array): string {
if (
buffer[0] === 0x47 &&
buffer[1] === 0x49 &&
buffer[2] === 0x46 &&
buffer[3] === 0x38
) {
return 'image/gif';
} else if (
buffer[0] === 0x89 &&
buffer[1] === 0x50 &&
buffer[2] === 0x4e &&
buffer[3] === 0x47
) {
return 'image/png';
} else if (
buffer[0] === 0xff &&
buffer[1] === 0xd8 &&
buffer[2] === 0xff &&
buffer[3] === 0xe0
) {
return 'image/jpeg';
} else {
// unknown, fallback to png
console.error('unknown image type');
return 'image/png';
}
}
async function imageUrlToBlob(url: string): Promise<Blob | undefined> {
const buffer = await fetch(url).then(response => {
return response.arrayBuffer();
});
if (!buffer) {
console.warn('Could not get blob');
return;
}
try {
const type = resolveMimeType(new Uint8Array(buffer));
const blob = new Blob([buffer], { type });
return blob;
} catch (error) {
console.error('Error converting image to blob', error);
}
return;
}
async function copyImageToClipboard(url: string) {
const blob = await imageUrlToBlob(url);
if (!blob) {
return;
}
try {
await navigator.clipboard.write([new ClipboardItem({ [blob.type]: blob })]);
console.log('Image copied to clipboard');
toast('Copied to clipboard.');
} catch (error) {
console.error('Error copying image to clipboard', error);
}
}
async function saveBufferToFile(url: string, filename: string) {
// given input url may not have correct mime type
const blob = await imageUrlToBlob(url);
if (!blob) {
return;
}
const blobUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = blobUrl;
a.download = filename;
document.body.append(a);
a.click();
a.remove();
URL.revokeObjectURL(blobUrl);
}
export type ImagePreviewModalProps = {
docId: string;
blockId: string;
};
const ButtonWithTooltip = ({
icon,
tooltip,
disabled,
...props
}: PropsWithChildren<{
disabled?: boolean;
icon?: ReactElement;
tooltip: string;
onClick: () => void;
className?: string;
}>) => {
const element = icon ? (
<IconButton icon={icon} type="plain" disabled={disabled} {...props} />
) : (
<Button disabled={disabled} type="plain" {...props} />
);
if (disabled) {
return element;
} else {
return <Tooltip content={tooltip}>{element}</Tooltip>;
}
};
const ImagePreviewModalImpl = ({
docId,
blockId,
onBlockIdChange,
onClose,
}: ImagePreviewModalProps & {
onBlockIdChange: (blockId: string) => void;
onClose: () => void;
}): ReactElement | null => {
const { doc, workspace } = useDoc(docId);
const blocksuiteDoc = doc?.blockSuiteDoc;
const docCollection = workspace.docCollection;
const blockModel = useMemo(() => {
const block = blocksuiteDoc?.getBlock(blockId);
if (!block) {
return null;
}
return block.model as ImageBlockModel;
}, [blockId, blocksuiteDoc]);
const caption = useMemo(() => {
return blockModel?.caption ?? '';
}, [blockModel?.caption]);
const [blocks, setBlocks] = useState<ImageBlockModel[]>([]);
const [cursor, setCursor] = useState(0);
const zoomRef = useRef<HTMLDivElement | null>(null);
const imageRef = useRef<HTMLImageElement | null>(null);
const {
isZoomedBigger,
handleDrag,
handleDragStart,
handleDragEnd,
resetZoom,
zoomIn,
zoomOut,
resetScale,
currentScale,
} = useZoomControls({ zoomRef, imageRef });
const goto = useCallback(
(index: number) => {
const page = docCollection.getDoc(docId);
assertExists(page);
const block = blocks[index];
if (!block) return;
setCursor(index);
onBlockIdChange(block.id);
resetZoom();
},
[docCollection, docId, blocks, onBlockIdChange, resetZoom]
);
const deleteHandler = useCallback(
(index: number) => {
if (!blocksuiteDoc) {
return;
}
let block = blocks[index];
if (!block) return;
const newBlocks = blocks.toSpliced(index, 1);
setBlocks(newBlocks);
blocksuiteDoc.deleteBlock(block);
// next
block = newBlocks[index];
// prev
if (!block) {
index -= 1;
block = newBlocks[index];
if (!block) {
onClose();
return;
}
setCursor(index);
}
onBlockIdChange(block.id);
resetZoom();
},
[blocksuiteDoc, blocks, onBlockIdChange, resetZoom, onClose]
);
const downloadHandler = useAsyncCallback(async () => {
const url = imageRef.current?.src;
if (url) {
await saveBufferToFile(url, caption || blockModel?.id || 'image');
}
}, [caption, blockModel?.id]);
const copyHandler = useAsyncCallback(async () => {
const url = imageRef.current?.src;
if (url) {
await copyImageToClipboard(url);
}
}, []);
useEffect(() => {
if (!blockModel || !blocksuiteDoc) {
return;
}
const prevs = blocksuiteDoc.getPrevs(blockModel).filter(filterImageBlock);
const nexts = blocksuiteDoc.getNexts(blockModel).filter(filterImageBlock);
const blocks = [...prevs, blockModel, ...nexts];
setBlocks(blocks);
setCursor(blocks.length ? prevs.length : 0);
}, [setBlocks, blockModel, blocksuiteDoc]);
const { data, error } = useSWR(['workspace', 'image', docId, blockId], {
fetcher: ([_, __, pageId, blockId]) => {
const page = docCollection.getDoc(pageId);
assertExists(page);
const block = page.getBlock(blockId);
if (!block) {
return null;
}
const blockModel = block.model as ImageBlockModel;
return docCollection.blobSync.get(blockModel.sourceId as string);
},
suspense: true,
});
useEffect(() => {
const handleKeyUp = (event: KeyboardEvent) => {
if (!blocksuiteDoc || !blockModel) {
return;
}
if (event.key === 'ArrowLeft') {
const prevBlock = blocksuiteDoc
.getPrevs(blockModel)
.findLast(
(block): block is ImageBlockModel =>
block.flavour === 'affine:image'
);
if (prevBlock) {
onBlockIdChange(prevBlock.id);
}
} else if (event.key === 'ArrowRight') {
const nextBlock = blocksuiteDoc
.getNexts(blockModel)
.find(
(block): block is ImageBlockModel =>
block.flavour === 'affine:image'
);
if (nextBlock) {
onBlockIdChange(nextBlock.id);
}
} else {
return;
}
event.preventDefault();
event.stopPropagation();
};
document.addEventListener('keyup', handleKeyUp);
return () => {
document.removeEventListener('keyup', handleKeyUp);
};
}, [blockModel, blocksuiteDoc, onBlockIdChange]);
useErrorBoundary(error);
const [prevData, setPrevData] = useState<string | null>(() => data);
const [url, setUrl] = useState<string | null>(null);
if (data === null) {
return null;
} else 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={styles.imagePreviewModalStyle}
>
<div className={styles.imagePreviewTrap} onClick={onClose} />
<div className={styles.imagePreviewModalContainerStyle}>
<div
className={clsx('zoom-area', { 'zoomed-bigger': isZoomedBigger })}
ref={zoomRef}
>
<div className={styles.imagePreviewModalCenterStyle}>
<img
data-blob-id={blockId}
data-testid="image-content"
src={url}
alt={caption}
ref={imageRef}
draggable={isZoomedBigger}
onMouseDown={handleDragStart}
onMouseMove={handleDrag}
onMouseUp={handleDragEnd}
onLoad={resetZoom}
/>
{isZoomedBigger ? null : (
<p
data-testid="image-caption-zoomedout"
className={styles.imagePreviewModalCaptionStyle}
>
{caption}
</p>
)}
</div>
</div>
</div>
<div className={styles.imageBottomContainerStyle}>
{isZoomedBigger && caption !== '' ? (
<p
data-testid={'image-caption-zoomedin'}
className={styles.captionStyle}
>
{caption}
</p>
) : null}
<div className={styles.imagePreviewActionBarStyle}>
<ButtonWithTooltip
data-testid="previous-image-button"
tooltip="Previous"
icon={<ArrowLeftSmallIcon />}
disabled={cursor < 1}
onClick={() => goto(cursor - 1)}
/>
<div className={styles.cursorStyle}>
{`${blocks.length ? cursor + 1 : 0}/${blocks.length}`}
</div>
<ButtonWithTooltip
data-testid="next-image-button"
tooltip="Next"
icon={<ArrowRightSmallIcon />}
disabled={cursor + 1 === blocks.length}
onClick={() => goto(cursor + 1)}
/>
<div className={styles.dividerStyle}></div>
<ButtonWithTooltip
data-testid="fit-to-screen-button"
tooltip="Fit to screen"
icon={<ViewBarIcon />}
onClick={() => resetZoom()}
/>
<ButtonWithTooltip
data-testid="zoom-out-button"
tooltip="Zoom out"
icon={<MinusIcon />}
onClick={zoomOut}
/>
<ButtonWithTooltip
data-testid="reset-scale-button"
tooltip="Reset scale"
onClick={resetScale}
>
{`${(currentScale * 100).toFixed(0)}%`}
</ButtonWithTooltip>
<ButtonWithTooltip
data-testid="zoom-in-button"
tooltip="Zoom in"
icon={<PlusIcon />}
onClick={zoomIn}
/>
<div className={styles.dividerStyle}></div>
<ButtonWithTooltip
data-testid="download-button"
tooltip="Download"
icon={<DownloadIcon />}
onClick={downloadHandler}
/>
<ButtonWithTooltip
data-testid="copy-to-clipboard-button"
tooltip="Copy to clipboard"
icon={<CopyIcon />}
onClick={copyHandler}
/>
<div className={styles.dividerStyle}></div>
<ButtonWithTooltip
data-testid="delete-button"
tooltip="Delete"
icon={<DeleteIcon />}
disabled={blocks.length === 0}
onClick={() => deleteHandler(cursor)}
/>
</div>
</div>
</div>
);
};
const ErrorLogger = (props: FallbackProps) => {
console.error('image preview modal error', props.error);
return null;
};
export const ImagePreviewErrorBoundary = (
props: PropsWithChildren
): ReactElement => {
return (
<ErrorBoundary fallbackRender={ErrorLogger}>{props.children}</ErrorBoundary>
);
};
export const ImagePreviewPeekView = (
props: ImagePreviewModalProps
): ReactElement | null => {
const [blockId, setBlockId] = useState<string | null>(props.blockId);
const peekView = useService(PeekViewService).peekView;
const onClose = peekView.close;
const buttonRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
setBlockId(props.blockId);
}, [props.blockId]);
return (
<ImagePreviewErrorBoundary>
<Suspense>
{blockId ? (
<ImagePreviewModalImpl
{...props}
onClose={onClose}
blockId={blockId}
onBlockIdChange={setBlockId}
/>
) : null}
<button
ref={buttonRef}
data-testid="image-preview-close-button"
onClick={onClose}
className={styles.imagePreviewModalCloseButtonStyle}
>
<CloseIcon />
</button>
</Suspense>
</ImagePreviewErrorBoundary>
);
};
@@ -150,6 +150,11 @@ export const modalContent = style({
// :focus-visible will set outline
outline: 'none',
position: 'relative',
selectors: {
'&[data-no-interaction=true]': {
pointerEvents: 'none',
},
},
});
export const modalControls = style({
@@ -3,6 +3,7 @@ import { assignInlineVars } from '@vanilla-extract/dynamic';
import clsx from 'clsx';
import {
createContext,
forwardRef,
type PropsWithChildren,
useContext,
useEffect,
@@ -57,30 +58,38 @@ function getElementScreenPositionCenter(target: HTMLElement) {
};
}
export const PeekViewModalContainer = ({
onOpenChange,
open,
target,
controls,
children,
hideOnEntering,
onAnimationStart,
onAnimateEnd,
animation = 'zoom',
padding = true,
testId,
}: PropsWithChildren<{
open: boolean;
hideOnEntering?: boolean;
target?: HTMLElement;
export type PeekViewModalContainerProps = PropsWithChildren<{
onOpenChange: (open: boolean) => void;
open: boolean;
target?: HTMLElement;
controls?: React.ReactNode;
hideOnEntering?: boolean;
onAnimationStart?: () => void;
onAnimateEnd?: () => void;
padding?: boolean;
animation?: 'fade' | 'zoom';
testId?: string;
}>) => {
}>;
export const PeekViewModalContainer = forwardRef<
HTMLDivElement,
PeekViewModalContainerProps
>(function PeekViewModalContainer(
{
onOpenChange,
open,
target,
controls,
children,
hideOnEntering,
onAnimationStart,
onAnimateEnd,
animation = 'zoom',
padding = true,
testId,
},
ref
) {
const [{ status }, toggle] = useTransition({
timeout: animationTimeout,
});
@@ -112,6 +121,7 @@ export const PeekViewModalContainer = ({
onAnimationEnd={onAnimateEnd}
/>
<div
ref={ref}
data-testid={testId}
data-peek-view-wrapper
className={styles.modalContentWrapper}
@@ -133,6 +143,7 @@ export const PeekViewModalContainer = ({
>
<Dialog.Content
{...contentOptions}
data-no-interaction={status !== 'entered'}
className={styles.modalContent}
>
{hideOnEntering && status === 'entering' ? null : children}
@@ -148,4 +159,4 @@ export const PeekViewModalContainer = ({
</Dialog.Root>
</PeekViewContext.Provider>
);
};
});
@@ -5,34 +5,41 @@ import { useEffect, useMemo } from 'react';
import type { ActivePeekView } from '../entities/peek-view';
import { PeekViewService } from '../services/peek-view';
import { DocPeekPreview } from './doc-peek-view';
import { PeekViewModalContainer } from './modal-container';
import { DocPeekPreview } from './doc-preview';
import { ImagePreviewPeekView } from './image-preview';
import {
PeekViewModalContainer,
type PeekViewModalContainerProps,
} from './modal-container';
import {
DefaultPeekViewControls,
DocPeekViewControls,
} from './peek-view-controls';
function renderPeekView({ info, template }: ActivePeekView) {
if (template) {
return toReactNode(template);
function renderPeekView({ info }: ActivePeekView) {
if (info.type === 'template') {
return toReactNode(info.template);
}
if (info.type === 'doc') {
return (
<DocPeekPreview
mode={info.mode}
xywh={info.xywh}
docId={info.docId}
blockId={info.blockId}
/>
);
}
if (!info) {
return null;
if (info.type === 'image') {
return <ImagePreviewPeekView docId={info.docId} blockId={info.blockId} />;
}
return (
<DocPeekPreview
mode={info.mode}
xywh={info.xywh}
docId={info.docId}
blockId={info.blockId}
/>
);
return null; // unreachable
}
const renderControls = ({ info }: ActivePeekView) => {
if (info && 'docId' in info) {
if (info.type === 'doc') {
return (
<DocPeekViewControls
mode={info.mode}
@@ -42,20 +49,44 @@ const renderControls = ({ info }: ActivePeekView) => {
);
}
if (info.type === 'image') {
return null; // image controls are rendered in the image preview
}
return <DefaultPeekViewControls />;
};
const getRendererProps = (
activePeekView?: ActivePeekView
): Partial<PeekViewModalContainerProps> | undefined => {
if (!activePeekView) {
return;
}
const preview = renderPeekView(activePeekView);
const controls = renderControls(activePeekView);
return {
children: preview,
controls,
target:
activePeekView?.target instanceof HTMLElement
? activePeekView.target
: undefined,
padding: activePeekView.info.type === 'doc',
animation: activePeekView.info.type === 'image' ? 'fade' : 'zoom',
};
};
export const PeekViewManagerModal = () => {
const peekViewEntity = useService(PeekViewService).peekView;
const activePeekView = useLiveData(peekViewEntity.active$);
const show = useLiveData(peekViewEntity.show$);
const preview = useMemo(() => {
return activePeekView ? renderPeekView(activePeekView) : null;
}, [activePeekView]);
const controls = useMemo(() => {
return activePeekView ? renderControls(activePeekView) : null;
const renderProps = useMemo(() => {
if (!activePeekView) {
return;
}
return getRendererProps(activePeekView);
}, [activePeekView]);
useEffect(() => {
@@ -72,20 +103,15 @@ export const PeekViewManagerModal = () => {
return (
<PeekViewModalContainer
open={show && !!preview}
target={
activePeekView?.target instanceof HTMLElement
? activePeekView.target
: undefined
}
controls={controls}
{...renderProps}
open={show && !!renderProps}
onOpenChange={open => {
if (!open) {
peekViewEntity.close();
}
}}
>
{preview}
{renderProps?.children}
</PeekViewModalContainer>
);
};