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

View File

@@ -0,0 +1,94 @@
import { act, fireEvent, renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createRef } from 'react'
import { useImageLightbox } from './useImageLightbox'
const { trackInlineImageLightboxOpenedMock } = vi.hoisted(() => ({
trackInlineImageLightboxOpenedMock: vi.fn(),
}))
vi.mock('../lib/productAnalytics', () => ({
trackInlineImageLightboxOpened: trackInlineImageLightboxOpenedMock,
}))
function createHookTarget() {
const container = document.createElement('div')
document.body.appendChild(container)
const ref = createRef<HTMLDivElement>()
ref.current = container
const view = renderHook(() => useImageLightbox({ containerRef: ref }))
return { container, view }
}
function appendImage(container: HTMLElement, src = 'https://example.com/photo.png') {
const img = document.createElement('img')
img.src = src
img.alt = 'Preview target'
container.appendChild(img)
return img
}
beforeEach(() => {
document.body.replaceChildren()
trackInlineImageLightboxOpenedMock.mockClear()
})
describe('useImageLightbox', () => {
it('opens the image lightbox on image double-click', () => {
const { container, view } = createHookTarget()
const img = appendImage(container)
fireEvent.doubleClick(img)
expect(view.result.current.image).toEqual({
src: 'https://example.com/photo.png',
alt: 'Preview target',
})
expect(trackInlineImageLightboxOpenedMock).toHaveBeenCalledTimes(1)
})
it('opens before BlockNote can stop the double-click from bubbling', () => {
const { container, view } = createHookTarget()
const img = appendImage(container)
img.addEventListener('dblclick', (event) => event.stopPropagation())
fireEvent.doubleClick(img)
expect(view.result.current.image?.src).toBe('https://example.com/photo.png')
expect(trackInlineImageLightboxOpenedMock).toHaveBeenCalledTimes(1)
})
it('leaves single-click image selection alone', () => {
const { container, view } = createHookTarget()
const img = appendImage(container)
fireEvent.click(img)
expect(view.result.current.image).toBeNull()
expect(trackInlineImageLightboxOpenedMock).not.toHaveBeenCalled()
})
it('ignores double-clicks on non-image targets', () => {
const { container, view } = createHookTarget()
const text = document.createElement('span')
container.appendChild(text)
fireEvent.doubleClick(text)
expect(view.result.current.image).toBeNull()
expect(trackInlineImageLightboxOpenedMock).not.toHaveBeenCalled()
})
it('closes the current lightbox image', () => {
const { container, view } = createHookTarget()
const img = appendImage(container)
fireEvent.doubleClick(img)
act(() => {
view.result.current.close()
})
expect(view.result.current.image).toBeNull()
})
})

View File

@@ -0,0 +1,36 @@
import { useCallback, useEffect, useState, type RefObject } from 'react'
import { trackInlineImageLightboxOpened } from '../lib/productAnalytics'
import { getDoubleClickedImageTarget, type ImageLightboxTarget } from '../utils/imageLightboxTarget'
type UseImageLightboxArgs = {
containerRef: RefObject<HTMLDivElement | null>
}
type UseImageLightboxResult = {
image: ImageLightboxTarget | null
close: () => void
}
export function useImageLightbox({ containerRef }: UseImageLightboxArgs): UseImageLightboxResult {
const [image, setImage] = useState<ImageLightboxTarget | null>(null)
const close = useCallback(() => setImage(null), [])
useEffect(() => {
const container = containerRef.current
if (!container) return
const onDoubleClick = (event: MouseEvent) => {
const nextImage = getDoubleClickedImageTarget(event.target)
if (!nextImage) return
event.preventDefault()
setImage(nextImage)
trackInlineImageLightboxOpened()
}
container.addEventListener('dblclick', onDoubleClick, true)
return () => container.removeEventListener('dblclick', onDoubleClick, true)
}, [containerRef])
return { image, close }
}

View File

@@ -366,6 +366,7 @@
"editor.toolbar.revealFile": "Im Finder anzeigen",
"editor.toolbar.copyFilePath": "Dateipfad kopieren",
"editor.toolbar.openProperties": "Eigenschaften-Panel öffnen",
"editor.imageLightbox.title": "Bildvorschau",
"editor.filename.rename": "Dateinamen umbenennen",
"editor.filename.renameToTitle": "Datei entsprechend dem Titel umbenennen",
"editor.filename.trigger": "Dateiname {filename}. Zum Umbenennen die Eingabetaste drücken",

View File

@@ -366,6 +366,7 @@
"editor.toolbar.revealFile": "Reveal in Finder",
"editor.toolbar.copyFilePath": "Copy file path",
"editor.toolbar.openProperties": "Open the properties panel",
"editor.imageLightbox.title": "Image preview",
"editor.filename.rename": "Rename filename",
"editor.filename.renameToTitle": "Rename the file to match the title",
"editor.filename.trigger": "Filename {filename}. Press Enter to rename",

View File

@@ -366,6 +366,7 @@
"editor.toolbar.revealFile": "Mostrar en el Finder",
"editor.toolbar.copyFilePath": "Copiar la ruta del archivo",
"editor.toolbar.openProperties": "Abrir el panel de propiedades",
"editor.imageLightbox.title": "Vista previa de la imagen",
"editor.filename.rename": "Cambiar el nombre del archivo",
"editor.filename.renameToTitle": "Cambiar el nombre del archivo para que coincida con el título",
"editor.filename.trigger": "Nombre de archivo {filename}. Presione Entrar para cambiar el nombre.",

View File

@@ -366,6 +366,7 @@
"editor.toolbar.revealFile": "Mostrar en el Finder",
"editor.toolbar.copyFilePath": "Copiar la ruta del archivo",
"editor.toolbar.openProperties": "Abrir el panel de propiedades",
"editor.imageLightbox.title": "Vista previa de la imagen",
"editor.filename.rename": "Cambiar el nombre del archivo",
"editor.filename.renameToTitle": "Cambiar el nombre del archivo para que coincida con el título",
"editor.filename.trigger": "Nombre de archivo {filename}. Pulsa Intro para cambiar el nombre",

View File

@@ -366,6 +366,7 @@
"editor.toolbar.revealFile": "Afficher dans le Finder",
"editor.toolbar.copyFilePath": "Copier le chemin du fichier",
"editor.toolbar.openProperties": "Ouvrir le panneau des propriétés",
"editor.imageLightbox.title": "Aperçu de l'image",
"editor.filename.rename": "Renommer le fichier",
"editor.filename.renameToTitle": "Renommer le fichier pour qu'il corresponde au titre",
"editor.filename.trigger": "Nom de fichier {filename}. Appuyez sur Entrée pour le renommer",

View File

@@ -366,6 +366,7 @@
"editor.toolbar.revealFile": "Mostra nel Finder",
"editor.toolbar.copyFilePath": "Copia il percorso del file",
"editor.toolbar.openProperties": "Apri il pannello delle proprietà",
"editor.imageLightbox.title": "Anteprima immagine",
"editor.filename.rename": "Rinomina nome file",
"editor.filename.renameToTitle": "Rinomina il file in modo che corrisponda al titolo",
"editor.filename.trigger": "Nome file {filename}. Premi Invio per rinominarlo",

View File

@@ -366,6 +366,7 @@
"editor.toolbar.revealFile": "Finderで表示",
"editor.toolbar.copyFilePath": "ファイルパスをコピー",
"editor.toolbar.openProperties": "プロパティパネルを開く",
"editor.imageLightbox.title": "画像プレビュー",
"editor.filename.rename": "ファイル名を変更",
"editor.filename.renameToTitle": "タイトルと一致するようにファイル名を変更する",
"editor.filename.trigger": "ファイル名:{filename}。名前を変更するにはEnterキーを押してください。",

View File

@@ -366,6 +366,7 @@
"editor.toolbar.revealFile": "Finder에서 표시",
"editor.toolbar.copyFilePath": "파일 경로 복사",
"editor.toolbar.openProperties": "속성 패널 열기",
"editor.imageLightbox.title": "이미지 미리 보기",
"editor.filename.rename": "파일 이름 변경",
"editor.filename.renameToTitle": "파일 이름을 제목과 일치하도록 변경",
"editor.filename.trigger": "파일 이름 {filename}. 이름을 바꾸려면 Enter 키를 누르세요.",

View File

@@ -366,6 +366,7 @@
"editor.toolbar.revealFile": "Exibir no Finder",
"editor.toolbar.copyFilePath": "Copiar caminho do arquivo",
"editor.toolbar.openProperties": "Abrir o painel de propriedades",
"editor.imageLightbox.title": "Pré-visualização da imagem",
"editor.filename.rename": "Renomear nome do arquivo",
"editor.filename.renameToTitle": "Renomear o arquivo para corresponder ao título",
"editor.filename.trigger": "Nome do arquivo {filename}. Pressione Enter para renomear",

View File

@@ -366,6 +366,7 @@
"editor.toolbar.revealFile": "Mostrar no Finder",
"editor.toolbar.copyFilePath": "Copiar caminho do ficheiro",
"editor.toolbar.openProperties": "Abrir o painel de propriedades",
"editor.imageLightbox.title": "Pré-visualização da imagem",
"editor.filename.rename": "Mudar o nome do ficheiro",
"editor.filename.renameToTitle": "Mudar o nome do ficheiro para corresponder ao título",
"editor.filename.trigger": "Nome do ficheiro {filename}. Prima Enter para mudar o nome",

View File

@@ -366,6 +366,7 @@
"editor.toolbar.revealFile": "Показать в Finder",
"editor.toolbar.copyFilePath": "Копировать путь к файлу",
"editor.toolbar.openProperties": "Открыть панель свойств",
"editor.imageLightbox.title": "Предварительный просмотр изображения",
"editor.filename.rename": "Переименовать файл",
"editor.filename.renameToTitle": "Переименовать файл в соответствии с заголовком",
"editor.filename.trigger": "Имя файла: {filename}. Нажмите Enter, чтобы переименовать.",

View File

@@ -366,6 +366,7 @@
"editor.toolbar.revealFile": "在访达中显示",
"editor.toolbar.copyFilePath": "复制文件路径",
"editor.toolbar.openProperties": "打开属性面板",
"editor.imageLightbox.title": "图像预览",
"editor.filename.rename": "重命名文件名",
"editor.filename.renameToTitle": "将文件重命名为匹配标题",
"editor.filename.trigger": "文件名 {filename}。按 Enter 重命名",

View File

@@ -366,6 +366,7 @@
"editor.toolbar.revealFile": "在訪達中顯示",
"editor.toolbar.copyFilePath": "複製檔案路徑",
"editor.toolbar.openProperties": "開啟屬性面板",
"editor.imageLightbox.title": "圖片預覽",
"editor.filename.rename": "重新命名檔名",
"editor.filename.renameToTitle": "將檔案重新命名為匹配標題",
"editor.filename.trigger": "檔名 {filename}。按 Enter 重新命名",

View File

@@ -57,6 +57,10 @@ export function trackNavigationHistoryButtonClicked(direction: NavigationHistory
trackEvent('navigation_history_button_clicked', { direction })
}
export function trackInlineImageLightboxOpened(): void {
trackEvent('inline_image_lightbox_opened')
}
export function trackAiAgentMessageBlocked(agent: AiAgentId, reason: AgentBlockedReason): void {
trackEvent('ai_agent_message_blocked', { agent, reason })
}

View File

@@ -0,0 +1,88 @@
import { describe, expect, it } from 'vitest'
import { getDoubleClickedImageTarget } from './imageLightboxTarget'
describe('getDoubleClickedImageTarget', () => {
it('returns the src and alt when the target is an image element with a src', () => {
const img = document.createElement('img')
img.src = 'https://example.com/cat.png'
img.alt = 'Sleeping cat'
expect(getDoubleClickedImageTarget(img)).toEqual({
src: 'https://example.com/cat.png',
alt: 'Sleeping cat',
})
})
it('returns an empty alt when the image has no alt text', () => {
const img = document.createElement('img')
img.src = 'https://example.com/cat.png'
expect(getDoubleClickedImageTarget(img)).toEqual({
src: 'https://example.com/cat.png',
alt: '',
})
})
it('returns null when the target is an image element without a src', () => {
const img = document.createElement('img')
expect(getDoubleClickedImageTarget(img)).toBeNull()
})
it('returns null when the target is not an image element', () => {
const div = document.createElement('div')
expect(getDoubleClickedImageTarget(div)).toBeNull()
})
it('returns the nested BlockNote image when the wrapper is double-clicked', () => {
const wrapper = document.createElement('div')
wrapper.className = 'bn-visual-media-wrapper'
const img = document.createElement('img')
img.src = 'https://example.com/wrapped.png'
img.alt = 'Wrapped image'
wrapper.appendChild(img)
expect(getDoubleClickedImageTarget(wrapper)).toEqual({
src: 'https://example.com/wrapped.png',
alt: 'Wrapped image',
})
})
it('ignores caption and resize controls inside image blocks', () => {
const wrapper = document.createElement('div')
wrapper.className = 'bn-visual-media-wrapper'
const img = document.createElement('img')
img.src = 'https://example.com/wrapped.png'
const caption = document.createElement('figcaption')
caption.className = 'bn-file-caption'
const resizeHandle = document.createElement('div')
resizeHandle.className = 'bn-resize-handle'
wrapper.append(img, caption, resizeHandle)
expect(getDoubleClickedImageTarget(caption)).toBeNull()
expect(getDoubleClickedImageTarget(resizeHandle)).toBeNull()
})
it('returns null when the target is null', () => {
expect(getDoubleClickedImageTarget(null)).toBeNull()
})
it('ignores tracking pixel images smaller than the visibility threshold', () => {
const img = document.createElement('img')
img.src = 'https://example.com/pixel.gif'
Object.defineProperty(img, 'naturalWidth', { value: 1, configurable: true })
Object.defineProperty(img, 'naturalHeight', { value: 1, configurable: true })
expect(getDoubleClickedImageTarget(img)).toBeNull()
})
it('allows unloaded images whose natural dimensions are still unknown', () => {
const img = document.createElement('img')
img.src = 'https://example.com/loading.png'
Object.defineProperty(img, 'naturalWidth', { value: 0, configurable: true })
Object.defineProperty(img, 'naturalHeight', { value: 0, configurable: true })
expect(getDoubleClickedImageTarget(img)?.src).toBe('https://example.com/loading.png')
})
})

View File

@@ -0,0 +1,45 @@
export type ImageLightboxTarget = {
src: string
alt: string
}
const MIN_VIEWABLE_DIMENSION = 16
const IMAGE_WRAPPER_SELECTOR = '.bn-visual-media-wrapper'
const IMAGE_INTERACTION_IGNORE_SELECTOR = [
'.bn-file-caption',
'.bn-resize-handle',
'.bn-add-file-button',
'.bn-file-name-with-icon',
'button',
'[role="button"]',
].join(', ')
export function getDoubleClickedImageTarget(target: EventTarget | null): ImageLightboxTarget | null {
const image = resolveImageElement(target)
if (!image?.src) return null
if (isTooSmallToView(image)) return null
return {
src: image.src,
alt: image.getAttribute('alt')?.trim() ?? '',
}
}
function resolveImageElement(target: EventTarget | null): HTMLImageElement | null {
if (target instanceof HTMLImageElement) return target
if (!(target instanceof HTMLElement)) return null
if (target.closest(IMAGE_INTERACTION_IGNORE_SELECTOR)) return null
const wrapper = target.closest(IMAGE_WRAPPER_SELECTOR)
if (!(wrapper instanceof HTMLElement)) return null
const image = wrapper.querySelector('img')
return image instanceof HTMLImageElement ? image : null
}
function isTooSmallToView(image: HTMLImageElement): boolean {
const width = image.naturalWidth
const height = image.naturalHeight
if (width === 0 && height === 0) return false
return width < MIN_VIEWABLE_DIMENSION && height < MIN_VIEWABLE_DIMENSION
}