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}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
94
src/hooks/useImageLightbox.test.tsx
Normal file
94
src/hooks/useImageLightbox.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
36
src/hooks/useImageLightbox.ts
Normal file
36
src/hooks/useImageLightbox.ts
Normal 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 }
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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キーを押してください。",
|
||||
|
||||
@@ -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 키를 누르세요.",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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, чтобы переименовать.",
|
||||
|
||||
@@ -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 重命名",
|
||||
|
||||
@@ -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 重新命名",
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
88
src/utils/imageLightboxTarget.test.ts
Normal file
88
src/utils/imageLightboxTarget.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
45
src/utils/imageLightboxTarget.ts
Normal file
45
src/utils/imageLightboxTarget.ts
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user