feat: open inline images in lightbox

This commit is contained in:
lucaronin
2026-04-30 05:49:40 +02:00
parent 2c06c8dcc2
commit 65ff44eb50
25 changed files with 373 additions and 2 deletions

View 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)
})
})

View 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>
)
}

View File

@@ -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>
)
}

View File

@@ -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}
/>
</>
)}