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
No known key found for this signature in database
GPG Key ID: 398BFA91AC539CF7
4 changed files with 155 additions and 146 deletions

View File

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

View File

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

View File

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

View File

@ -582,6 +582,9 @@ test('tooltips for all buttons should be visible when hovering', async ({
await title.click();
await page.keyboard.press('Enter');
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.waitForTimeout(500);
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');
await page.waitForTimeout(500);
await locator.getByTestId('previous-image-button').hover();
await locator.getByTestId('previous-image-button').isDisabled();
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 page.waitForTimeout(1000);
@ -609,6 +607,18 @@ test('tooltips for all buttons should be visible when hovering', async ({
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 page.waitForTimeout(1000);
{