feat: open inline images in lightbox
This commit is contained in:
44
src/components/ImageLightbox.test.tsx
Normal file
44
src/components/ImageLightbox.test.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { ImageLightbox } from './ImageLightbox'
|
||||
|
||||
describe('ImageLightbox', () => {
|
||||
it('renders the selected image in a dialog', () => {
|
||||
render(
|
||||
<ImageLightbox
|
||||
image={{ src: 'https://example.com/photo.png', alt: 'A lake' }}
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('image-lightbox')).toBeInTheDocument()
|
||||
expect(screen.getByRole('img', { name: 'A lake' })).toHaveAttribute('src', 'https://example.com/photo.png')
|
||||
expect(screen.getByText('Image preview')).toHaveClass('sr-only')
|
||||
})
|
||||
|
||||
it('falls back to localized alt text when the image has no alt', () => {
|
||||
render(
|
||||
<ImageLightbox
|
||||
image={{ src: 'https://example.com/photo.png', alt: '' }}
|
||||
locale="zh-CN"
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('img', { name: '图像预览' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onClose when the dialog closes', () => {
|
||||
const onClose = vi.fn()
|
||||
render(
|
||||
<ImageLightbox
|
||||
image={{ src: 'https://example.com/photo.png', alt: 'A lake' }}
|
||||
onClose={onClose}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.keyDown(screen.getByTestId('image-lightbox'), { key: 'Escape' })
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
34
src/components/ImageLightbox.tsx
Normal file
34
src/components/ImageLightbox.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Dialog, DialogContent, DialogTitle } from './ui/dialog'
|
||||
import { translate, type AppLocale } from '../lib/i18n'
|
||||
import type { ImageLightboxTarget } from '../utils/imageLightboxTarget'
|
||||
|
||||
type ImageLightboxProps = {
|
||||
image: ImageLightboxTarget | null
|
||||
locale?: AppLocale
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function ImageLightbox({ image, locale = 'en', onClose }: ImageLightboxProps) {
|
||||
const title = translate(locale, 'editor.imageLightbox.title')
|
||||
const open = image !== null
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(nextOpen) => { if (!nextOpen) onClose() }}>
|
||||
<DialogContent
|
||||
aria-describedby={undefined}
|
||||
data-testid="image-lightbox"
|
||||
className="flex max-h-[90vh] max-w-[90vw] items-center justify-center border-none bg-transparent p-0 shadow-none sm:max-w-[90vw]"
|
||||
>
|
||||
<DialogTitle className="sr-only">{title}</DialogTitle>
|
||||
{image && (
|
||||
<img
|
||||
data-testid="image-lightbox-image"
|
||||
src={image.src}
|
||||
alt={image.alt || title}
|
||||
className="max-h-[90vh] max-w-[90vw] rounded-md object-contain shadow-2xl"
|
||||
/>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -20,6 +20,8 @@ import { ExternalLink } from 'lucide-react'
|
||||
import { useDocumentThemeMode } from '../hooks/useDocumentThemeMode'
|
||||
import { useEditorTheme } from '../hooks/useTheme'
|
||||
import { useImageDrop } from '../hooks/useImageDrop'
|
||||
import { useImageLightbox } from '../hooks/useImageLightbox'
|
||||
import type { AppLocale } from '../lib/i18n'
|
||||
import { buildTypeEntryMap } from '../utils/typeColors'
|
||||
import { preFilterWikilinks, deduplicateByPath, MIN_QUERY_LENGTH } from '../utils/wikilinkSuggestions'
|
||||
import { filterPersonMentions, PERSON_MENTION_MIN_QUERY } from '../utils/personMentionSuggestions'
|
||||
@@ -39,6 +41,7 @@ import {
|
||||
import { TolariaSideMenu } from './tolariaBlockNoteSideMenu'
|
||||
import { useEditorLinkActivation } from './useEditorLinkActivation'
|
||||
import { findNearestTextCursorBlock } from './blockNoteCursorTarget'
|
||||
import { ImageLightbox } from './ImageLightbox'
|
||||
|
||||
const TEST_TABLE_MARKDOWN = `| Head 1 | Head 2 | Head 3 |
|
||||
| --- | --- | --- |
|
||||
@@ -546,13 +549,14 @@ function useInsertImageCallback(editor: ReturnType<typeof useCreateBlockNote>) {
|
||||
}
|
||||
|
||||
/** Single BlockNote editor view — content is swapped via replaceBlocks */
|
||||
export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange, vaultPath, editable = true }: {
|
||||
export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange, vaultPath, editable = true, locale = 'en' }: {
|
||||
editor: ReturnType<typeof useCreateBlockNote>
|
||||
entries: VaultEntry[]
|
||||
onNavigateWikilink: (target: string) => void
|
||||
onChange?: () => void
|
||||
vaultPath?: string
|
||||
editable?: boolean
|
||||
locale?: AppLocale
|
||||
}) {
|
||||
const { cssVars } = useEditorTheme()
|
||||
const themeMode = useDocumentThemeMode()
|
||||
@@ -561,6 +565,7 @@ export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange
|
||||
const handleEditorChange = useCompositionAwareEditorChange({ containerRef, onChange })
|
||||
const onImageUrl = useInsertImageCallback(editor)
|
||||
const { isDragOver } = useImageDrop({ containerRef, onImageUrl, vaultPath })
|
||||
const lightbox = useImageLightbox({ containerRef })
|
||||
useBlockNoteSideMenuHoverGuard(containerRef)
|
||||
useEditorLinkActivation(containerRef, onNavigateWikilink)
|
||||
|
||||
@@ -650,6 +655,7 @@ export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange
|
||||
onItemClick={(item: WikilinkSuggestionItem) => runEditorAction(item.onItemClick)}
|
||||
/>
|
||||
</SharedContextBlockNoteView>
|
||||
<ImageLightbox image={lightbox.image} locale={locale} onClose={lightbox.close} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -345,6 +345,7 @@ function EditorCanvas({
|
||||
onEditorChange,
|
||||
isDeletedPreview,
|
||||
vaultPath,
|
||||
locale,
|
||||
}: Pick<
|
||||
EditorContentModel,
|
||||
| 'showEditor'
|
||||
@@ -355,6 +356,7 @@ function EditorCanvas({
|
||||
| 'onEditorChange'
|
||||
| 'isDeletedPreview'
|
||||
| 'vaultPath'
|
||||
| 'locale'
|
||||
>) {
|
||||
if (!showEditor) return null
|
||||
|
||||
@@ -368,6 +370,7 @@ function EditorCanvas({
|
||||
onChange={onEditorChange}
|
||||
vaultPath={vaultPath}
|
||||
editable={!isDeletedPreview}
|
||||
locale={locale}
|
||||
/>
|
||||
</div>
|
||||
</EditorFindScope>
|
||||
@@ -498,6 +501,7 @@ export function EditorContentLayout(model: EditorContentModel) {
|
||||
onNavigateWikilink={onNavigateWikilink}
|
||||
onEditorChange={onEditorChange}
|
||||
isDeletedPreview={isDeletedPreview}
|
||||
locale={locale}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user