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

@@ -553,6 +553,7 @@ Defined in `src/components/tolariaEditorFormatting.tsx` and `src/components/tola
- `SingleEditorView` disables BlockNote's default formatting toolbar, `/` menu, and side menu, then mounts Tolaria-owned controllers so the visible formatting surface matches Tolaria's markdown round-trip guarantees.
- The formatting toolbar only exposes inline controls that persist through `blocksToMarkdownLossy()` in Tolaria's save pipeline: bold, italic, strike, nesting, and link creation. Controls that BlockNote can render temporarily but Tolaria cannot faithfully persist, such as underline, color, alignment, and the block-type dropdown, are hidden instead of appearing to work and later disappearing.
- Tolaria's formatting-toolbar controller also keeps file/image actions mounted across the tiny hover gap between an image block and the floating toolbar, and while the toolbar itself is hovered, so image controls remain usable instead of collapsing mid-interaction.
- `useImageLightbox` listens for `dblclick` on the rich-editor container and opens `ImageLightbox` only when the event target resolves to a viewable BlockNote image. The target resolver handles media wrappers, ignores image captions/resize controls, missing sources, and tiny tracking-style images, preserving BlockNote's ordinary single-click image selection path.
- The `/` slash menu remains the supported path for markdown-safe block transformations such as headings, quotes, and list blocks. Tolaria filters out BlockNote's toggle-heading and toggle-list variants because those do not map cleanly to the markdown note model.
- The block-handle side menu keeps only actions that survive Tolaria's markdown round-trip. Delete and table-header toggles remain available; BlockNote's `Colors` submenu is removed because block colors are not part of Tolaria's supported markdown surface.
- `useNoteWikilinkDrop()` is the shared editor-drop abstraction for dragging note rows into either editor mode. It reads the existing note-retargeting drag payload, resolves the vault-relative stem, and inserts a canonical `[[wikilink]]` without hijacking unrelated plain-text drags.
@@ -783,6 +784,7 @@ Managed by `useSettings` hook and `SettingsPanel` component. `theme_mode` is ins
### Product Events
- **File previews** — `file_preview_opened`, `file_preview_action`, and `file_preview_failed` report only preview/action categories such as `image`, `pdf`, `unsupported`, `open_external`, `copy_path`, and `reveal`.
- **Inline image lightbox** — `inline_image_lightbox_opened` records that a rich-editor inline image was opened from double-click, without sending note paths, image URLs, alt text, or file names.
- **AI agent sessions** — `ai_agent_message_sent`, `ai_agent_message_blocked`, `ai_agent_response_completed`, `ai_agent_response_failed`, and `ai_agent_permission_mode_changed` use only agent ids, permission modes, counts, and coarse status categories.
- **All Notes visibility** — `all_notes_visibility_changed` records only the toggled category and enabled state.

View File

@@ -205,7 +205,7 @@ flowchart TD
- **Sidebar** (150-400px, resizable): Top-level filters (All Notes, Changes, Pulse), saved Views, collapsible type-based section groups, and a dedicated folder tree. The title bar exposes Back/Forward buttons wired to the same note navigation history as the command palette, menu accelerators, and mouse navigation buttons. The folder tree starts with a vault-root row labeled from the opened vault path, shows root-level files when selected, and nests user-created folders plus default vault folders such as `attachments/` and `views/` underneath it; only the dedicated `type/` directory stays hidden because note types already have their own sidebar section. Saved Views persist a top-level YAML `order` field in each view file and use the same ordered-list mental model as Types: pointer users can drag the existing view row, while keyboard users can use command-palette move actions. The folder tree supports inline folder creation and rename, exposes a right-click menu for rename/delete plus filesystem reveal/copy-path actions on mutable folders, and auto-expands ancestor folders when the current selection or rename target is nested. Type sections and folder rows also act as note drop targets: dropping a note on a type updates its `type:` frontmatter, while dropping it on a folder runs the same crash-safe move path as the command palette flow. Each type can have a custom icon, color, sort, and visibility set via its `type: Type` document; new type documents created by Tolaria are written at the vault root.
- **Note List / Pulse View** (200-500px, resizable): When a section group, filter, or saved view is selected, shows filtered notes with snippets, modified dates, status indicators, and per-context note-list controls. When `selection.kind === 'entity'`, the same pane enters **Neighborhood** mode: the source note is pinned at the top as a normal active row, outgoing relationship groups render first, inverse/backlink groups follow, empty groups stay visible with `0`, and duplicates across groups are allowed when multiple relationships are true. Plain click / `Enter` open the focused note without replacing the current Neighborhood, while Cmd/Ctrl-click and Cmd/Ctrl-`Enter` pivot the pane into the clicked note's Neighborhood. Folder-backed lists also show non-Markdown files: previewable image and PDF binaries get file indicators and open in the editor pane, while unsupported binaries remain muted instead of auto-launching an external app. Saved views reuse the same sort and visible-column controls as the built-in lists, and those changes persist back into the view `.yml` definition (`sort`, `listPropertiesDisplay`). When Pulse filter is active, shows `PulseView` — a chronological git activity feed grouped by day.
- **Editor** (flex, fills remaining space): Single note open at a time (no tabs — see ADR-0003). Breadcrumb bar with word count and rich-editor width toggle, BlockNote rich text editor with wikilink support, Markdown-compatible inline/display math rendering, first-class Mermaid diagram blocks, markdown-safe formatting controls, and schema-backed fenced code block highlighting via `@blocknote/code-block`. Can toggle to diff view (modified files), raw CodeMirror view, or a wide rich-editor reading surface with preserved side margins; raw CodeMirror remains full-width and unaffected by note width mode. Binary image and PDF files render through `FilePreview` as ordinary vault files using Tauri asset URLs; external-open actions call `open_vault_file_external` so the target is validated against the active vault before the native default app opens it. Unsupported/broken binaries show explicit fallback states and keyboard focus returns to the note list on `Escape`. Decomposed into `Editor` (orchestrator), `EditorContent`, `FilePreview`, `EditorRightPanel`, `SingleEditorView`, with hooks `useDiffMode`, `useEditorFocus`, and `useEditorSave`, plus the `useRawMode`/`RawEditorView` pair for markdown source editing. Rich BlockNote input and raw CodeMirror input both route typed `->`, `<-`, and `<->` through the shared `src/utils/arrowLigatures.ts` resolver so arrow ligatures stay consistent across mode switches while escaped ASCII sequences remain literal. Navigation history (Cmd+[/]) replaces tabs.
- **Editor** (flex, fills remaining space): Single note open at a time (no tabs — see ADR-0003). Breadcrumb bar with word count and rich-editor width toggle, BlockNote rich text editor with wikilink support, Markdown-compatible inline/display math rendering, first-class Mermaid diagram blocks, markdown-safe formatting controls, and schema-backed fenced code block highlighting via `@blocknote/code-block`. Can toggle to diff view (modified files), raw CodeMirror view, or a wide rich-editor reading surface with preserved side margins; raw CodeMirror remains full-width and unaffected by note width mode. Inline rich-editor images open in a localized shadcn lightbox on double-click while normal single-click BlockNote selection remains untouched, and tiny tracking-style images are ignored. Binary image and PDF files render through `FilePreview` as ordinary vault files using Tauri asset URLs; external-open actions call `open_vault_file_external` so the target is validated against the active vault before the native default app opens it. Unsupported/broken binaries show explicit fallback states and keyboard focus returns to the note list on `Escape`. Decomposed into `Editor` (orchestrator), `EditorContent`, `FilePreview`, `EditorRightPanel`, `SingleEditorView`, with hooks `useDiffMode`, `useEditorFocus`, and `useEditorSave`, plus the `useRawMode`/`RawEditorView` pair for markdown source editing. Rich BlockNote input and raw CodeMirror input both route typed `->`, `<-`, and `<->` through the shared `src/utils/arrowLigatures.ts` resolver so arrow ligatures stay consistent across mode switches while escaped ASCII sequences remain literal. Navigation history (Cmd+[/]) replaces tabs.
- **Inspector / AI Agent** (200-500px or 40px collapsed): Toggles between Inspector (frontmatter, relationships, instances, backlinks, git history) and AI Agent panel (the selected CLI agent with tool execution). The Sparkle icon in the breadcrumb bar toggles between them. Per-note `icon` is a suggested Inspector property and the command palette's "Set Note Icon" action opens that field directly. When viewing a Type note, the Inspector shows an **Instances** section listing all notes of that type (sorted by modified_at desc, capped at 50).
Panels are separated by `ResizeHandle` components that support drag-to-resize.

View File

@@ -368,6 +368,7 @@ files:
editor.toolbar.revealFile: 76f4ed52da9937ae432dcf5a108fabb4
editor.toolbar.copyFilePath: 64c6cec5ca57efbb8c9c93ae5fbccd27
editor.toolbar.openProperties: 948b90b60b8ce827f179e8c5b85b112e
editor.imageLightbox.title: 2c5a15c875bcdc2478c4a5cad044e10c
editor.filename.rename: c31c2b468229232ad6287e734fe67d96
editor.filename.renameToTitle: bff34a6a2dd1eb87c59403946679237f
editor.filename.trigger: 4e9739c2f937c828bbf5d1f935fe7fb9

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
}