refactor(core): image preview toolbar UI (#7207)

Closes: [AFF-1257](https://linear.app/affine-design/issue/AFF-1257/image-preview-toolbar)

* refactor logic
* update UI style

<img width="617" alt="Screenshot 2024-06-13 at 07 21 52" src="https://github.com/toeverything/AFFiNE/assets/27926/1edc6476-0103-4214-8ef2-41b37d95287b">
<img width="760" alt="Screenshot 2024-06-13 at 07 21 33" src="https://github.com/toeverything/AFFiNE/assets/27926/83d27ab2-143f-4bdd-a932-396289c598ec">
This commit is contained in:
fundon
2024-06-14 02:02:46 +00:00
parent 33762423bb
commit 729631ea72
4 changed files with 155 additions and 146 deletions

View File

@@ -107,32 +107,44 @@ export const imagePreviewModalCaptionStyle = style({
}, },
}); });
export const imagePreviewActionBarStyle = style({ export const imagePreviewActionBarStyle = style({
display: 'flex', height: '36px',
justifyContent: 'center',
alignItems: 'center',
backgroundColor: cssVar('white'),
borderRadius: '8px',
boxShadow: '2px 2px 4px rgba(0, 0, 0, 0.3)',
maxWidth: 'max-content', maxWidth: 'max-content',
minHeight: '44px', padding: '0 6px',
maxHeight: '44px', 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 groupStyle = style({ export const cursorStyle = style({
padding: '10px 0', display: 'flex',
boxSizing: 'border-box', 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', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
borderLeft: '1px solid #E3E2E4',
});
export const buttonStyle = style({
margin: '10px 6px',
}); });
export const scaleIndicatorButtonStyle = style({ export const scaleIndicatorButtonStyle = style({
minHeight: '100%', height: '24px',
maxWidth: 'max-content', padding: '1px 2px',
fontSize: '12px', minWidth: '50px',
padding: '5px 5px', fontSize: '14px',
color: `${cssVar('iconColor')} !important`,
':hover': { ':hover': {
backgroundColor: cssVar('hoverColor'), backgroundColor: cssVar('hoverColor'),
}, },

View File

@@ -1,6 +1,8 @@
import type { ImageBlockModel } from '@blocksuite/blocks';
import { atom } from 'jotai'; import { atom } from 'jotai';
export const previewBlockIdAtom = atom<string | null>(null); export const previewBlockIdAtom = atom<string | null>(null);
export const previewblocksAtom = atom<ImageBlockModel[]>([]);
export const hasAnimationPlayedAtom = atom<boolean | null>(true); export const hasAnimationPlayedAtom = atom<boolean | null>(true);
previewBlockIdAtom.onMount = set => { previewBlockIdAtom.onMount = set => {

View File

@@ -13,7 +13,7 @@ import {
PlusIcon, PlusIcon,
ViewBarIcon, ViewBarIcon,
} from '@blocksuite/icons'; } from '@blocksuite/icons';
import type { DocCollection } from '@blocksuite/store'; import type { BlockModel, DocCollection } from '@blocksuite/store';
import clsx from 'clsx'; import clsx from 'clsx';
import { useErrorBoundary } from 'foxact/use-error-boundary'; import { useErrorBoundary } from 'foxact/use-error-boundary';
import { useAtom } from 'jotai'; import { useAtom } from 'jotai';
@@ -26,9 +26,9 @@ import useSWR from 'swr';
import { useZoomControls } from './hooks/use-zoom'; import { useZoomControls } from './hooks/use-zoom';
import { import {
buttonStyle,
captionStyle, captionStyle,
groupStyle, cursorStyle,
dividerStyle,
imageBottomContainerStyle, imageBottomContainerStyle,
imagePreviewActionBarStyle, imagePreviewActionBarStyle,
imagePreviewBackgroundStyle, imagePreviewBackgroundStyle,
@@ -41,7 +41,15 @@ import {
scaleIndicatorButtonStyle, scaleIndicatorButtonStyle,
unloaded, unloaded,
} from './index.css'; } from './index.css';
import { hasAnimationPlayedAtom, previewBlockIdAtom } from './index.jotai'; import {
hasAnimationPlayedAtom,
previewBlockIdAtom,
previewblocksAtom,
} from './index.jotai';
const filterImageBlock = (block: BlockModel): block is ImageBlockModel => {
return block.flavour === 'affine:image';
};
export type ImagePreviewModalProps = { export type ImagePreviewModalProps = {
docCollection: DocCollection; docCollection: DocCollection;
@@ -54,7 +62,9 @@ const ImagePreviewModalImpl = (
onClose: () => void; onClose: () => void;
} }
): ReactElement | null => { ): ReactElement | null => {
const [blocks, setBlocks] = useAtom(previewblocksAtom);
const [blockId, setBlockId] = useAtom(previewBlockIdAtom); const [blockId, setBlockId] = useAtom(previewBlockIdAtom);
const [cursor, setCursor] = useState(0);
const zoomRef = useRef<HTMLDivElement | null>(null); const zoomRef = useRef<HTMLDivElement | null>(null);
const imageRef = useRef<HTMLImageElement | null>(null); const imageRef = useRef<HTMLImageElement | null>(null);
const { const {
@@ -88,98 +98,72 @@ const ImagePreviewModalImpl = (
return; return;
}, [isOpen, props, setIsOpen]); }, [isOpen, props, setIsOpen]);
const nextImageHandler = useCallback( const goto = useCallback(
(blockId: string | null) => { (index: number) => {
assertExists(blockId);
const workspace = props.docCollection;
if (!hasPlayedAnimation) { if (!hasPlayedAnimation) {
setHasPlayedAnimation(true); setHasPlayedAnimation(true);
} }
const page = workspace.getDoc(props.pageId);
assertExists(page);
const block = page.getBlockById(blockId);
assertExists(block);
const nextBlock = page
.getNexts(block)
.find(
(block): block is ImageBlockModel => block.flavour === 'affine:image'
);
if (nextBlock) {
setBlockId(nextBlock.id);
}
},
[props.pageId, props.docCollection, setBlockId, hasPlayedAnimation]
);
const previousImageHandler = useCallback(
(blockId: string | null) => {
assertExists(blockId);
const workspace = props.docCollection; const workspace = props.docCollection;
const page = workspace.getDoc(props.pageId); const page = workspace.getDoc(props.pageId);
assertExists(page); assertExists(page);
const block = page.getBlockById(blockId);
assertExists(block); const block = blocks[index];
const prevBlock = page
.getPrevs(block) if (!block) return;
.findLast(
(block): block is ImageBlockModel => block.flavour === 'affine:image' setCursor(index);
); setBlockId(block.id);
if (prevBlock) {
setBlockId(prevBlock.id);
}
resetZoom(); resetZoom();
}, },
[props.pageId, props.docCollection, setBlockId, resetZoom] [
props.pageId,
props.docCollection,
blocks,
setBlockId,
hasPlayedAnimation,
resetZoom,
]
); );
const deleteHandler = useCallback( const deleteHandler = useCallback(
(blockId: string) => { (index: number) => {
const { pageId, docCollection: workspace, onClose } = props; const { pageId, docCollection: workspace, onClose } = props;
const page = workspace.getDoc(pageId); const page = workspace.getDoc(pageId);
assertExists(page); assertExists(page);
const block = page.getBlockById(blockId);
assertExists(block); let block = blocks[index];
if (
page if (!block) return;
.getPrevs(block)
.some( blocks.splice(index, 1);
(block): block is ImageBlockModel => setBlocks([...blocks]);
block.flavour === 'affine:image'
)
) {
const prevBlock = page
.getPrevs(block)
.findLast(
(block): block is ImageBlockModel =>
block.flavour === 'affine:image'
);
if (prevBlock) {
setBlockId(prevBlock.id);
}
} else if (
page
.getNexts(block)
.some(
(block): block is ImageBlockModel =>
block.flavour === 'affine:image'
)
) {
const nextBlock = page
.getNexts(block)
.find(
(block): block is ImageBlockModel =>
block.flavour === 'affine:image'
);
if (nextBlock) {
setBlockId(nextBlock.id);
}
} else {
onClose();
}
page.deleteBlock(block); page.deleteBlock(block);
// next
block = blocks[index];
// prev
if (!block) {
index -= 1;
block = blocks[index];
if (!block) {
onClose();
return;
}
setCursor(index);
}
setBlockId(block.id);
resetZoom();
}, },
[props, setBlockId] [props, blocks, setBlockId, setBlocks, setCursor, resetZoom]
); );
const downloadHandler = useCallback( const downloadHandler = useCallback(
@@ -188,7 +172,7 @@ const ImagePreviewModalImpl = (
const page = workspace.getDoc(props.pageId); const page = workspace.getDoc(props.pageId);
assertExists(page); assertExists(page);
if (typeof blockId === 'string') { if (typeof blockId === 'string') {
const block = page.getBlockById(blockId) as ImageBlockModel; const block = page.getBlockById<ImageBlockModel>(blockId);
assertExists(block); assertExists(block);
const store = block.page.blobSync; const store = block.page.blobSync;
const url = store?.get(block.sourceId as string); const url = store?.get(block.sourceId as string);
@@ -238,27 +222,39 @@ const ImagePreviewModalImpl = (
}, },
[props.pageId, props.docCollection] [props.pageId, props.docCollection]
); );
const [caption, setCaption] = useState(() => { const [caption, setCaption] = useState(() => {
const page = props.docCollection.getDoc(props.pageId); const page = props.docCollection.getDoc(props.pageId);
assertExists(page); assertExists(page);
const block = page.getBlockById(props.blockId) as ImageBlockModel; const block = page.getBlockById<ImageBlockModel>(props.blockId);
assertExists(block); assertExists(block);
return block?.caption; return block?.caption;
}); });
useEffect(() => { useEffect(() => {
const page = props.docCollection.getDoc(props.pageId); const page = props.docCollection.getDoc(props.pageId);
assertExists(page); assertExists(page);
const block = page.getBlockById(props.blockId) as ImageBlockModel;
const block = page.getBlockById<ImageBlockModel>(props.blockId);
assertExists(block); assertExists(block);
const prevs = page.getPrevs(block).filter(filterImageBlock);
const nexts = page.getNexts(block).filter(filterImageBlock);
const blocks = [...prevs, block, ...nexts];
setBlocks(blocks);
setCursor(blocks.length ? prevs.length : 0);
setCaption(block?.caption); setCaption(block?.caption);
}, [props.blockId, props.pageId, props.docCollection]); }, [props.blockId, props.pageId, props.docCollection, setBlocks]);
const { data, error } = useSWR( const { data, error } = useSWR(
['workspace', 'image', props.pageId, props.blockId], ['workspace', 'image', props.pageId, props.blockId],
{ {
fetcher: ([_, __, pageId, blockId]) => { fetcher: ([_, __, pageId, blockId]) => {
const page = props.docCollection.getDoc(pageId); const page = props.docCollection.getDoc(pageId);
assertExists(page); assertExists(page);
const block = page.getBlockById(blockId) as ImageBlockModel; const block = page.getBlockById<ImageBlockModel>(blockId);
assertExists(block); assertExists(block);
return props.docCollection.blobSync.get(block?.sourceId as string); return props.docCollection.blobSync.get(block?.sourceId as string);
}, },
@@ -335,39 +331,33 @@ const ImagePreviewModalImpl = (
</p> </p>
) : null} ) : null}
<div className={imagePreviewActionBarStyle}> <div className={imagePreviewActionBarStyle}>
<div>
<Tooltip content={'Previous'}> <Tooltip content={'Previous'}>
<IconButton <IconButton
data-testid="previous-image-button" data-testid="previous-image-button"
icon={<ArrowLeftSmallIcon />} icon={<ArrowLeftSmallIcon />}
type="plain" type="plain"
className={buttonStyle} disabled={cursor < 1}
onClick={() => { onClick={() => goto(cursor - 1)}
assertExists(blockId);
previousImageHandler(blockId);
}}
/> />
</Tooltip> </Tooltip>
<div className={cursorStyle}>
{`${blocks.length ? cursor + 1 : 0}/${blocks.length}`}
</div>
<Tooltip content={'Next'}> <Tooltip content={'Next'}>
<IconButton <IconButton
data-testid="next-image-button" data-testid="next-image-button"
icon={<ArrowRightSmallIcon />} icon={<ArrowRightSmallIcon />}
className={buttonStyle}
type="plain" type="plain"
onClick={() => { disabled={cursor + 1 === blocks.length}
assertExists(blockId); onClick={() => goto(cursor + 1)}
nextImageHandler(blockId);
}}
/> />
</Tooltip> </Tooltip>
</div> <div className={dividerStyle}></div>
<div className={groupStyle}></div> <Tooltip content={'Fit to screen'}>
<Tooltip content={'Fit to Screen'}>
<IconButton <IconButton
data-testid="fit-to-screen-button" data-testid="fit-to-screen-button"
icon={<ViewBarIcon />} icon={<ViewBarIcon />}
type="plain" type="plain"
className={buttonStyle}
onClick={() => resetZoom()} onClick={() => resetZoom()}
/> />
</Tooltip> </Tooltip>
@@ -375,16 +365,14 @@ const ImagePreviewModalImpl = (
<IconButton <IconButton
data-testid="zoom-out-button" data-testid="zoom-out-button"
icon={<MinusIcon />} icon={<MinusIcon />}
className={buttonStyle}
type="plain" type="plain"
onClick={zoomOut} onClick={zoomOut}
/> />
</Tooltip> </Tooltip>
<Tooltip content={'Reset Scale'}> <Tooltip content={'Reset scale'}>
<Button <Button
data-testid="reset-scale-button" data-testid="reset-scale-button"
type="plain" type="plain"
size={'large'}
className={scaleIndicatorButtonStyle} className={scaleIndicatorButtonStyle}
onClick={resetScale} onClick={resetScale}
> >
@@ -395,18 +383,16 @@ const ImagePreviewModalImpl = (
<IconButton <IconButton
data-testid="zoom-in-button" data-testid="zoom-in-button"
icon={<PlusIcon />} icon={<PlusIcon />}
className={buttonStyle}
type="plain" type="plain"
onClick={() => zoomIn()} onClick={() => zoomIn()}
/> />
</Tooltip> </Tooltip>
<div className={groupStyle}></div> <div className={dividerStyle}></div>
<Tooltip content={'Download'}> <Tooltip content={'Download'}>
<IconButton <IconButton
data-testid="download-button" data-testid="download-button"
icon={<DownloadIcon />} icon={<DownloadIcon />}
type="plain" type="plain"
className={buttonStyle}
onClick={() => { onClick={() => {
assertExists(blockId); assertExists(blockId);
downloadHandler(blockId).catch(err => { downloadHandler(blockId).catch(err => {
@@ -420,7 +406,6 @@ const ImagePreviewModalImpl = (
data-testid="copy-to-clipboard-button" data-testid="copy-to-clipboard-button"
icon={<CopyIcon />} icon={<CopyIcon />}
type="plain" type="plain"
className={buttonStyle}
onClick={() => { onClick={() => {
if (!imageRef.current) { if (!imageRef.current) {
return; return;
@@ -455,14 +440,14 @@ const ImagePreviewModalImpl = (
}} }}
/> />
</Tooltip> </Tooltip>
<div className={groupStyle}></div> <div className={dividerStyle}></div>
<Tooltip content={'Delete'}> <Tooltip content={'Delete'}>
<IconButton <IconButton
data-testid="delete-button" data-testid="delete-button"
icon={<DeleteIcon />} icon={<DeleteIcon />}
type="plain" type="plain"
className={buttonStyle} disabled={blocks.length === 0}
onClick={() => blockId && deleteHandler(blockId)} onClick={() => deleteHandler(cursor)}
/> />
</Tooltip> </Tooltip>
</div> </div>

View File

@@ -582,6 +582,9 @@ test('tooltips for all buttons should be visible when hovering', async ({
await title.click(); await title.click();
await page.keyboard.press('Enter'); await page.keyboard.press('Enter');
await importImage(page, 'http://localhost:8081/large-image.png'); await importImage(page, 'http://localhost:8081/large-image.png');
await page.locator('affine-page-image').first().click();
await page.keyboard.press('Enter');
await importImage(page, 'http://localhost:8081/large-image.png');
await page.locator('affine-page-image').first().dblclick(); await page.locator('affine-page-image').first().dblclick();
await page.waitForTimeout(500); await page.waitForTimeout(500);
blobId = (await page blobId = (await page
@@ -593,13 +596,8 @@ test('tooltips for all buttons should be visible when hovering', async ({
} }
const locator = page.getByTestId('image-preview-modal'); const locator = page.getByTestId('image-preview-modal');
await page.waitForTimeout(500); await page.waitForTimeout(500);
await locator.getByTestId('previous-image-button').hover(); await locator.getByTestId('previous-image-button').isDisabled();
await page.waitForTimeout(1000); await page.waitForTimeout(1000);
{
const element = page.getByRole('tooltip');
const previousImageTooltip = await element.getByText('Previous').count();
expect(previousImageTooltip).toBe(1);
}
await locator.getByTestId('next-image-button').hover(); await locator.getByTestId('next-image-button').hover();
await page.waitForTimeout(1000); await page.waitForTimeout(1000);
@@ -609,6 +607,18 @@ test('tooltips for all buttons should be visible when hovering', async ({
expect(nextImageTooltip).toBe(1); expect(nextImageTooltip).toBe(1);
} }
await locator.getByTestId('next-image-button').click();
await locator.getByTestId('next-image-button').isDisabled();
await page.waitForTimeout(1000);
await locator.getByTestId('previous-image-button').hover();
await page.waitForTimeout(1000);
{
const element = page.getByRole('tooltip');
const previousImageTooltip = await element.getByText('Previous').count();
expect(previousImageTooltip).toBe(1);
}
await locator.getByTestId('fit-to-screen-button').hover(); await locator.getByTestId('fit-to-screen-button').hover();
await page.waitForTimeout(1000); await page.waitForTimeout(1000);
{ {