From 65ff44eb50c6ca77d538570ece2b9edd0045174d Mon Sep 17 00:00:00 2001 From: lucaronin Date: Thu, 30 Apr 2026 05:49:40 +0200 Subject: [PATCH] feat: open inline images in lightbox --- docs/ABSTRACTIONS.md | 2 + docs/ARCHITECTURE.md | 2 +- lara.lock | 1 + src/components/ImageLightbox.test.tsx | 44 +++++++++ src/components/ImageLightbox.tsx | 34 +++++++ src/components/SingleEditorView.tsx | 8 +- .../editor-content/EditorContentLayout.tsx | 4 + src/hooks/useImageLightbox.test.tsx | 94 +++++++++++++++++++ src/hooks/useImageLightbox.ts | 36 +++++++ src/lib/locales/de-DE.json | 1 + src/lib/locales/en.json | 1 + src/lib/locales/es-419.json | 1 + src/lib/locales/es-ES.json | 1 + src/lib/locales/fr-FR.json | 1 + src/lib/locales/it-IT.json | 1 + src/lib/locales/ja-JP.json | 1 + src/lib/locales/ko-KR.json | 1 + src/lib/locales/pt-BR.json | 1 + src/lib/locales/pt-PT.json | 1 + src/lib/locales/ru-RU.json | 1 + src/lib/locales/zh-CN.json | 1 + src/lib/locales/zh-TW.json | 1 + src/lib/productAnalytics.ts | 4 + src/utils/imageLightboxTarget.test.ts | 88 +++++++++++++++++ src/utils/imageLightboxTarget.ts | 45 +++++++++ 25 files changed, 373 insertions(+), 2 deletions(-) create mode 100644 src/components/ImageLightbox.test.tsx create mode 100644 src/components/ImageLightbox.tsx create mode 100644 src/hooks/useImageLightbox.test.tsx create mode 100644 src/hooks/useImageLightbox.ts create mode 100644 src/utils/imageLightboxTarget.test.ts create mode 100644 src/utils/imageLightboxTarget.ts diff --git a/docs/ABSTRACTIONS.md b/docs/ABSTRACTIONS.md index 62c8042e..ecd367ee 100644 --- a/docs/ABSTRACTIONS.md +++ b/docs/ABSTRACTIONS.md @@ -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. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 74fb530a..9523d485 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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. diff --git a/lara.lock b/lara.lock index 8d758600..ec433a9a 100644 --- a/lara.lock +++ b/lara.lock @@ -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 diff --git a/src/components/ImageLightbox.test.tsx b/src/components/ImageLightbox.test.tsx new file mode 100644 index 00000000..e4885048 --- /dev/null +++ b/src/components/ImageLightbox.test.tsx @@ -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( + {}} + />, + ) + + 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( + {}} + />, + ) + + expect(screen.getByRole('img', { name: '图像预览' })).toBeInTheDocument() + }) + + it('calls onClose when the dialog closes', () => { + const onClose = vi.fn() + render( + , + ) + + fireEvent.keyDown(screen.getByTestId('image-lightbox'), { key: 'Escape' }) + + expect(onClose).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/components/ImageLightbox.tsx b/src/components/ImageLightbox.tsx new file mode 100644 index 00000000..5362c5a9 --- /dev/null +++ b/src/components/ImageLightbox.tsx @@ -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 ( + { if (!nextOpen) onClose() }}> + + {title} + {image && ( + {image.alt + )} + + + ) +} diff --git a/src/components/SingleEditorView.tsx b/src/components/SingleEditorView.tsx index 0b118018..14b04b36 100644 --- a/src/components/SingleEditorView.tsx +++ b/src/components/SingleEditorView.tsx @@ -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) { } /** 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 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)} /> + ) } diff --git a/src/components/editor-content/EditorContentLayout.tsx b/src/components/editor-content/EditorContentLayout.tsx index 0ffdf417..5b398ec8 100644 --- a/src/components/editor-content/EditorContentLayout.tsx +++ b/src/components/editor-content/EditorContentLayout.tsx @@ -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} /> @@ -498,6 +501,7 @@ export function EditorContentLayout(model: EditorContentModel) { onNavigateWikilink={onNavigateWikilink} onEditorChange={onEditorChange} isDeletedPreview={isDeletedPreview} + locale={locale} /> )} diff --git a/src/hooks/useImageLightbox.test.tsx b/src/hooks/useImageLightbox.test.tsx new file mode 100644 index 00000000..350d69f7 --- /dev/null +++ b/src/hooks/useImageLightbox.test.tsx @@ -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() + 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() + }) +}) diff --git a/src/hooks/useImageLightbox.ts b/src/hooks/useImageLightbox.ts new file mode 100644 index 00000000..386a4313 --- /dev/null +++ b/src/hooks/useImageLightbox.ts @@ -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 +} + +type UseImageLightboxResult = { + image: ImageLightboxTarget | null + close: () => void +} + +export function useImageLightbox({ containerRef }: UseImageLightboxArgs): UseImageLightboxResult { + const [image, setImage] = useState(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 } +} diff --git a/src/lib/locales/de-DE.json b/src/lib/locales/de-DE.json index fd0b6bf7..48138082 100644 --- a/src/lib/locales/de-DE.json +++ b/src/lib/locales/de-DE.json @@ -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", diff --git a/src/lib/locales/en.json b/src/lib/locales/en.json index 9bd0097e..ddcc3868 100644 --- a/src/lib/locales/en.json +++ b/src/lib/locales/en.json @@ -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", diff --git a/src/lib/locales/es-419.json b/src/lib/locales/es-419.json index b34d1ffe..2ed9eb32 100644 --- a/src/lib/locales/es-419.json +++ b/src/lib/locales/es-419.json @@ -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.", diff --git a/src/lib/locales/es-ES.json b/src/lib/locales/es-ES.json index aaa5dd45..c10421ca 100644 --- a/src/lib/locales/es-ES.json +++ b/src/lib/locales/es-ES.json @@ -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", diff --git a/src/lib/locales/fr-FR.json b/src/lib/locales/fr-FR.json index 13b0ffb1..f2f73860 100644 --- a/src/lib/locales/fr-FR.json +++ b/src/lib/locales/fr-FR.json @@ -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", diff --git a/src/lib/locales/it-IT.json b/src/lib/locales/it-IT.json index 29b6fc61..ebadf8b6 100644 --- a/src/lib/locales/it-IT.json +++ b/src/lib/locales/it-IT.json @@ -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", diff --git a/src/lib/locales/ja-JP.json b/src/lib/locales/ja-JP.json index 556bdf6b..65b069a9 100644 --- a/src/lib/locales/ja-JP.json +++ b/src/lib/locales/ja-JP.json @@ -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キーを押してください。", diff --git a/src/lib/locales/ko-KR.json b/src/lib/locales/ko-KR.json index 54104d20..fd596f30 100644 --- a/src/lib/locales/ko-KR.json +++ b/src/lib/locales/ko-KR.json @@ -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 키를 누르세요.", diff --git a/src/lib/locales/pt-BR.json b/src/lib/locales/pt-BR.json index 62e65cb9..919258de 100644 --- a/src/lib/locales/pt-BR.json +++ b/src/lib/locales/pt-BR.json @@ -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", diff --git a/src/lib/locales/pt-PT.json b/src/lib/locales/pt-PT.json index 86ddb847..8ff5104d 100644 --- a/src/lib/locales/pt-PT.json +++ b/src/lib/locales/pt-PT.json @@ -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", diff --git a/src/lib/locales/ru-RU.json b/src/lib/locales/ru-RU.json index 3c80d36c..b33dcf0b 100644 --- a/src/lib/locales/ru-RU.json +++ b/src/lib/locales/ru-RU.json @@ -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, чтобы переименовать.", diff --git a/src/lib/locales/zh-CN.json b/src/lib/locales/zh-CN.json index 9045bb4a..d0afd0e1 100644 --- a/src/lib/locales/zh-CN.json +++ b/src/lib/locales/zh-CN.json @@ -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 重命名", diff --git a/src/lib/locales/zh-TW.json b/src/lib/locales/zh-TW.json index 11210d8c..b99ba40f 100644 --- a/src/lib/locales/zh-TW.json +++ b/src/lib/locales/zh-TW.json @@ -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 重新命名", diff --git a/src/lib/productAnalytics.ts b/src/lib/productAnalytics.ts index 4f1aae86..c41abc95 100644 --- a/src/lib/productAnalytics.ts +++ b/src/lib/productAnalytics.ts @@ -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 }) } diff --git a/src/utils/imageLightboxTarget.test.ts b/src/utils/imageLightboxTarget.test.ts new file mode 100644 index 00000000..c18b8de4 --- /dev/null +++ b/src/utils/imageLightboxTarget.test.ts @@ -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') + }) +}) diff --git a/src/utils/imageLightboxTarget.ts b/src/utils/imageLightboxTarget.ts new file mode 100644 index 00000000..a54d9331 --- /dev/null +++ b/src/utils/imageLightboxTarget.ts @@ -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 +}