feat(component): keyboard navigation for image-viewer (#2334)

Co-authored-by: Himself65 <himself65@outlook.com>
This commit is contained in:
Aditya Sharma 2023-05-23 15:05:11 +05:30 committed by GitHub
parent 259d7988d9
commit 48c109e149
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 216 additions and 28 deletions

View File

@ -35,6 +35,20 @@ export const imagePreviewModalCloseButtonStyle = style({
transition: 'background 0.2s ease-in-out',
});
export const imagePreviewModalGoStyle = style({
height: '50%',
color: 'var(--affine-white)',
position: 'absolute',
fontSize: '60px',
lineHeight: '60px',
fontWeight: 'bold',
display: 'flex',
alignItems: 'center',
opacity: '0.2',
padding: '0 15px',
cursor: 'pointer',
});
export const imagePreviewModalContainerStyle = style({
position: 'absolute',
top: '20%',

View File

@ -6,12 +6,14 @@ import { assertExists } from '@blocksuite/global/utils';
import type { Workspace } from '@blocksuite/store';
import { useAtom } from 'jotai';
import type { ReactElement } from 'react';
import { Suspense, useCallback } from 'react';
import { useEffect, useRef, useState } from 'react';
import useSWR from 'swr';
import {
imagePreviewModalCloseButtonStyle,
imagePreviewModalContainerStyle,
imagePreviewModalGoStyle,
imagePreviewModalImageStyle,
imagePreviewModalStyle,
} from './index.css';
@ -28,6 +30,7 @@ const ImagePreviewModalImpl = (
onClose: () => void;
}
): ReactElement | null => {
const [blockId, setBlockId] = useAtom(previewBlockIdAtom);
const [caption, setCaption] = useState(() => {
const page = props.workspace.getPage(props.pageId);
assertExists(page);
@ -96,14 +99,67 @@ const ImagePreviewModalImpl = (
/>
</svg>
</button>
<span
className={imagePreviewModalGoStyle}
style={{
left: 0,
}}
onClick={() => {
assertExists(blockId);
const workspace = props.workspace;
const page = workspace.getPage(props.pageId);
assertExists(page);
const block = page.getBlockById(blockId);
assertExists(block);
const prevBlock = page
.getPreviousSiblings(block)
.findLast(
(block): block is EmbedBlockModel =>
block.flavour === 'affine:embed'
);
if (prevBlock) {
setBlockId(prevBlock.id);
}
}}
>
</span>
<div className={imagePreviewModalContainerStyle}>
<img
data-blob-id={props.blockId}
alt={caption}
className={imagePreviewModalImageStyle}
ref={imageRef}
src={url}
/>
</div>
<span
className={imagePreviewModalGoStyle}
style={{
right: 0,
}}
onClick={() => {
assertExists(blockId);
const workspace = props.workspace;
const page = workspace.getPage(props.pageId);
assertExists(page);
const block = page.getBlockById(blockId);
assertExists(block);
const nextBlock = page
.getNextSiblings(block)
.find(
(block): block is EmbedBlockModel =>
block.flavour === 'affine:embed'
);
if (nextBlock) {
setBlockId(nextBlock.id);
}
}}
>
</span>
</div>
);
};
@ -112,15 +168,74 @@ export const ImagePreviewModal = (
props: ImagePreviewModalProps
): ReactElement | null => {
const [blockId, setBlockId] = useAtom(previewBlockIdAtom);
const handleKeyUp = useCallback(
(event: KeyboardEvent) => {
if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
setBlockId(null);
return;
}
if (!blockId) {
return;
}
const workspace = props.workspace;
const page = workspace.getPage(props.pageId);
assertExists(page);
const block = page.getBlockById(blockId);
assertExists(block);
if (event.key === 'ArrowLeft') {
const prevBlock = page
.getPreviousSiblings(block)
.findLast(
(block): block is EmbedBlockModel =>
block.flavour === 'affine:embed'
);
if (prevBlock) {
setBlockId(prevBlock.id);
}
} else if (event.key === 'ArrowRight') {
const nextBlock = page
.getNextSiblings(block)
.find(
(block): block is EmbedBlockModel =>
block.flavour === 'affine:embed'
);
if (nextBlock) {
setBlockId(nextBlock.id);
}
} else {
return;
}
event.preventDefault();
event.stopPropagation();
},
[blockId, setBlockId, props.workspace, props.pageId]
);
useEffect(() => {
document.addEventListener('keyup', handleKeyUp);
return () => {
document.removeEventListener('keyup', handleKeyUp);
};
}, [handleKeyUp]);
if (!blockId) {
return null;
}
return (
<ImagePreviewModalImpl
{...props}
blockId={blockId}
onClose={() => setBlockId(null)}
/>
<Suspense fallback={<div className={imagePreviewModalStyle} />}>
<ImagePreviewModalImpl
{...props}
blockId={blockId}
onClose={() => setBlockId(null)}
/>
</Suspense>
);
};

BIN
tests/fixtures/affine-preview.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

View File

@ -1,3 +1,4 @@
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { openHomePage } from '../libs/load-page';
@ -7,6 +8,38 @@ import {
waitMarkdownImported,
} from '../libs/page-logic';
async function importImage(page: Page, url: string) {
await page.evaluate(
([url]) => {
const clipData = {
'text/html': `<img src=${url} />`,
};
const e = new ClipboardEvent('paste', {
clipboardData: new DataTransfer(),
});
Object.defineProperty(e, 'target', {
writable: false,
value: document.body,
});
Object.entries(clipData).forEach(([key, value]) => {
e.clipboardData?.setData(key, value);
});
document.body.dispatchEvent(e);
},
[url]
);
await page.waitForTimeout(500);
}
async function closeImagePreviewModal(page: Page) {
await page
.getByTestId('image-preview-modal')
.locator('button')
.first()
.click();
await page.waitForTimeout(500);
}
test('image preview should be shown', async ({ page }) => {
await openHomePage(page);
await waitMarkdownImported(page);
@ -14,31 +47,57 @@ test('image preview should be shown', async ({ page }) => {
const title = await getBlockSuiteEditorTitle(page);
await title.click();
await page.keyboard.press('Enter');
await page.evaluate(() => {
const clipData = {
'text/html': `<img src="http://localhost:8081/large-image.png" />`,
};
const e = new ClipboardEvent('paste', {
clipboardData: new DataTransfer(),
});
Object.defineProperty(e, 'target', {
writable: false,
value: document.body,
});
Object.entries(clipData).forEach(([key, value]) => {
e.clipboardData?.setData(key, value);
});
document.body.dispatchEvent(e);
});
await page.waitForTimeout(500);
await importImage(page, 'http://localhost:8081/large-image.png');
await page.locator('img').first().dblclick();
const locator = page.getByTestId('image-preview-modal');
expect(locator.isVisible()).toBeTruthy();
await page
.getByTestId('image-preview-modal')
.locator('button')
.first()
.click();
await page.waitForTimeout(500);
await closeImagePreviewModal(page);
expect(await locator.isVisible()).toBeFalsy();
});
test('image go left and right', async ({ page }) => {
await openHomePage(page);
await waitMarkdownImported(page);
await newPage(page);
let blobId: string;
{
const title = await getBlockSuiteEditorTitle(page);
await title.click();
await page.keyboard.press('Enter');
await importImage(page, 'http://localhost:8081/large-image.png');
await page.locator('img').first().dblclick();
await page.waitForTimeout(500);
blobId = (await page
.locator('img')
.nth(1)
.getAttribute('data-blob-id')) as string;
expect(blobId).toBeTruthy();
await closeImagePreviewModal(page);
}
{
const title = await getBlockSuiteEditorTitle(page);
await title.click();
await page.keyboard.press('Enter');
await importImage(page, 'http://localhost:8081/affine-preview.png');
}
const locator = page.getByTestId('image-preview-modal');
expect(locator.isVisible()).toBeTruthy();
await page.locator('img').first().dblclick();
await page.waitForTimeout(5000);
{
const newBlobId = (await page
.locator('img[data-blob-id]')
.first()
.getAttribute('data-blob-id')) as string;
expect(newBlobId).not.toBe(blobId);
}
await page.keyboard.press('ArrowRight');
await page.waitForTimeout(5000);
{
const newBlobId = (await page
.locator('img[data-blob-id]')
.first()
.getAttribute('data-blob-id')) as string;
expect(newBlobId).toBe(blobId);
}
});