feat: open inline images in lightbox
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
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