Fix local markdown image resolution and PDF export inlining (#6253)

* Resolve local images on editor context switch and inline blobs for PDF

* Inline renderer-scoped blob URLs as base64 data URLs during PDF export, ensuring images render properly in the separate PDF print window.
* Track the active editor document's context with a versioning and subscription scheme to trigger reloads of relative images on context switch.

* test commit
This commit is contained in:
Jinjing 2026-06-24 13:11:08 -07:00 committed by GitHub
parent 379ddf2a1b
commit a1ec8d25a7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 648 additions and 319 deletions

View File

@ -0,0 +1,55 @@
// @vitest-environment happy-dom
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { toast } from 'sonner'
import { exportActiveMarkdownToPdf } from './export-active-markdown'
import { getActiveMarkdownExportPayload } from './markdown-export-extract'
vi.mock('sonner', () => ({
toast: {
dismiss: vi.fn(),
error: vi.fn(),
loading: vi.fn(() => 'toast-id'),
success: vi.fn()
}
}))
vi.mock('@/i18n/i18n', () => ({
translate: (_key: string, fallback: string) => fallback
}))
vi.mock('./markdown-export-extract', () => ({
getActiveMarkdownExportPayload: vi.fn()
}))
describe('exportActiveMarkdownToPdf', () => {
beforeEach(() => {
vi.clearAllMocks()
Object.defineProperty(window, 'api', {
configurable: true,
value: {
export: {
htmlToPdf: vi.fn()
}
}
})
})
it('surfaces payload extraction failures through the export toast', async () => {
vi.mocked(getActiveMarkdownExportPayload).mockRejectedValue(
new Error('Failed to inline image for PDF export: Unable to fetch blob image')
)
await exportActiveMarkdownToPdf({
fileId: '/repo/docs/readme.md',
root: document.createElement('div')
})
expect(toast.loading).toHaveBeenCalledWith('Exporting PDF...')
expect(window.api.export.htmlToPdf).not.toHaveBeenCalled()
expect(toast.error).toHaveBeenCalledWith(
'Failed to inline image for PDF export: Unable to fetch blob image',
{ id: 'toast-id' }
)
})
})

View File

@ -10,17 +10,18 @@ export async function exportActiveMarkdownToPdf(options: {
fileId: string
root: ParentNode | null
}): Promise<void> {
const payload = getActiveMarkdownExportPayload(options)
if (!payload) {
// Why: stale panel refs can survive a dropdown click; keep export defensive
// even though the local Markdown menu disables unreachable states.
return
}
const toastId = toast.loading(
translate('auto.components.editor.export.active.markdown.d4a901e0ad', 'Exporting PDF...')
)
try {
const payload = await getActiveMarkdownExportPayload(options)
if (!payload) {
// Why: stale panel refs can survive a dropdown click; keep export defensive
// even though the local Markdown menu disables unreachable states.
toast.dismiss(toastId)
return
}
const result = await window.api.export.htmlToPdf({
html: payload.html,
title: payload.title

View File

@ -0,0 +1,69 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { getActiveMarkdownExportPayload } from './markdown-export-extract'
vi.mock('@/store', () => ({
useAppStore: {
getState: vi.fn()
}
}))
describe('getActiveMarkdownExportPayload', () => {
beforeEach(async () => {
vi.clearAllMocks()
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
blob: async () => new Blob([new Uint8Array([1, 2, 3])], { type: 'image/png' })
})
)
const { useAppStore } = await import('@/store')
vi.mocked(useAppStore.getState).mockReturnValue({
openFiles: [
{
id: '/repo/docs/readme.md',
filePath: '/repo/docs/readme.md',
relativePath: 'docs/readme.md',
mode: 'edit'
}
]
} as never)
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('embeds blob image sources so the PDF export window can render local images', async () => {
const root = document.createElement('div')
root.innerHTML =
'<div class="ProseMirror"><p><img src="blob:rich-local-image" alt="diagram"></p></div>'
const payload = await getActiveMarkdownExportPayload({
fileId: '/repo/docs/readme.md',
root
})
expect(fetch).toHaveBeenCalledWith('blob:rich-local-image')
expect(payload?.html).toContain('src="data:image/png;base64,AQID"')
expect(payload?.html).not.toContain('blob:rich-local-image')
})
it('fails extraction when a blob image cannot be inlined', async () => {
vi.mocked(fetch).mockResolvedValue({
ok: false
} as Response)
const root = document.createElement('div')
root.innerHTML =
'<div class="ProseMirror"><p><img src="blob:missing-local-image" alt="diagram"></p></div>'
await expect(
getActiveMarkdownExportPayload({
fileId: '/repo/docs/readme.md',
root
})
).rejects.toThrow('Failed to inline image for PDF export')
})
})

View File

@ -40,13 +40,13 @@ function findDocumentSubtree(root: ParentNode): Element | null {
* markdown surface. Returns null when the requested file is stale or the
* surface is in a mode (Monaco source) that does not render a document DOM.
*/
export function getActiveMarkdownExportPayload({
export async function getActiveMarkdownExportPayload({
fileId,
root
}: {
fileId: string
root: ParentNode | null
}): MarkdownExportPayload | null {
}): Promise<MarkdownExportPayload | null> {
if (!root) {
return null
}
@ -71,6 +71,9 @@ export function getActiveMarkdownExportPayload({
node.remove()
}
}
// Why: local-image previews use renderer-scoped blob URLs; the hidden PDF
// window cannot dereference them, so embed the bytes before export.
await inlineBlobImageSources(clone)
const renderedHtml = clone.innerHTML.trim()
if (!renderedHtml) {
@ -81,3 +84,40 @@ export function getActiveMarkdownExportPayload({
const html = buildMarkdownExportHtml({ title, renderedHtml })
return { title, html }
}
async function inlineBlobImageSources(root: Element): Promise<void> {
const images = Array.from(root.querySelectorAll<HTMLImageElement>('img[src^="blob:"]'))
await Promise.all(
images.map(async (image) => {
const src = image.getAttribute('src')
if (!src) {
return
}
image.setAttribute('src', await readBlobImageAsDataUrl(src))
})
)
}
async function readBlobImageAsDataUrl(src: string): Promise<string> {
try {
const response = await fetch(src)
if (!response.ok) {
throw new Error('Unable to fetch blob image')
}
const blob = await response.blob()
const bytes = new Uint8Array(await blob.arrayBuffer())
return `data:${blob.type || 'application/octet-stream'};base64,${bytesToBase64(bytes)}`
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
throw new Error(`Failed to inline image for PDF export: ${message}`)
}
}
function bytesToBase64(bytes: Uint8Array): string {
let binary = ''
const chunkSize = 0x8000
for (let index = 0; index < bytes.length; index += chunkSize) {
binary += String.fromCharCode(...bytes.subarray(index, index + chunkSize))
}
return btoa(binary)
}

View File

@ -0,0 +1,265 @@
import type { Editor, UseEditorOptions } from '@tiptap/react'
import { handleRichMarkdownCut } from './rich-markdown-cut-handler'
import { handleRichMarkdownPaste } from './rich-markdown-paste-handler'
import { encodeRawMarkdownHtmlForRichEditor } from './raw-markdown-html'
import { normalizeSoftBreaks } from './rich-markdown-normalize'
import { autoFocusRichEditor } from './rich-markdown-auto-focus'
import {
syncSlashMenu,
type SlashCommand,
type SlashMenuState
} from './rich-markdown-slash-commands'
import {
syncDocLinkMenu,
type DocLinkMenuRow,
type DocLinkMenuState
} from './rich-markdown-commands'
import { isSingleEmptyTopLevelOrderedList } from './rich-markdown-list-continuation'
import { getLinkBubblePosition, type LinkBubbleState } from './RichMarkdownLinkBubble'
import {
handleRichMarkdownEditorClick,
type ActivateMarkdownLink,
type RichMarkdownRuntimeSettings
} from './rich-markdown-editor-click-routing'
import { createRichMarkdownKeyHandler } from './rich-markdown-key-handler'
import {
createRichMarkdownImageResolverContext,
setRichMarkdownImageResolverContext
} from './rich-markdown-image-context'
import type { MutableRefObject, Dispatch, SetStateAction } from 'react'
import type { DiffComment } from '../../../../shared/types'
export type EditorConfigParams = {
content: string
filePath: string
worktreeId: string
worktreeRoot: string | null
runtimeEnvironmentId?: string | null
isMac: boolean
settings: RichMarkdownRuntimeSettings
activateMarkdownLink: ActivateMarkdownLink
rootRef: MutableRefObject<HTMLDivElement | null>
editorRef: MutableRefObject<Editor | null>
lastCommittedMarkdownRef: MutableRefObject<string>
onContentChangeRef: MutableRefObject<(content: string) => void>
onDirtyStateHintRef: MutableRefObject<(dirty: boolean) => void>
onSaveRef: MutableRefObject<(content: string) => void>
onOpenDocLinkRef: MutableRefObject<((target: string) => void) | undefined>
isEditingLinkRef: MutableRefObject<boolean>
slashMenuRef: MutableRefObject<SlashMenuState | null>
filteredSlashCommandsRef: MutableRefObject<SlashCommand[]>
selectedCommandIndexRef: MutableRefObject<number>
docLinkMenuRef: MutableRefObject<DocLinkMenuState | null>
filteredDocLinkRowsRef: MutableRefObject<DocLinkMenuRow[]>
selectedDocLinkIndexRef: MutableRefObject<number>
handleLocalImagePickRef: MutableRefObject<() => void>
handleEmojiPickRef: MutableRefObject<(menu: SlashMenuState) => void>
typedEmptyOrderedListMarkerRef: MutableRefObject<boolean>
cancelAutoFocusRef: MutableRefObject<(() => void) | null>
serializeTimerRef: MutableRefObject<number | null>
isInitializingRef: MutableRefObject<boolean>
isApplyingProgrammaticUpdateRef: MutableRefObject<boolean>
markdownCommentsRef: MutableRefObject<DiffComment[]>
markdownSourceLineOffsetRef: MutableRefObject<number>
flushPendingSerialization: () => void
openSearchRef: MutableRefObject<() => void>
syncAnnotationTarget: (editor: Editor) => void
clearAnnotationTarget: () => void
scrollRichMarkdownReviewNoteCardIntoView: (commentId: string) => void
setIsEditingLink: Dispatch<SetStateAction<boolean>>
setLinkBubble: Dispatch<SetStateAction<LinkBubbleState | null>>
setSelectedCommandIndex: Dispatch<SetStateAction<number>>
setSelectedDocLinkIndex: Dispatch<SetStateAction<number>>
setSlashMenu: Dispatch<SetStateAction<SlashMenuState | null>>
setDocLinkMenu: Dispatch<SetStateAction<DocLinkMenuState | null>>
}
export function createRichMarkdownEditorConfig(params: EditorConfigParams): UseEditorOptions {
const {
content,
filePath,
worktreeId,
worktreeRoot,
runtimeEnvironmentId,
isMac,
settings,
activateMarkdownLink,
rootRef,
editorRef,
lastCommittedMarkdownRef,
onContentChangeRef,
onDirtyStateHintRef,
onSaveRef,
onOpenDocLinkRef,
isEditingLinkRef,
slashMenuRef,
filteredSlashCommandsRef,
selectedCommandIndexRef,
docLinkMenuRef,
filteredDocLinkRowsRef,
selectedDocLinkIndexRef,
handleLocalImagePickRef,
handleEmojiPickRef,
typedEmptyOrderedListMarkerRef,
cancelAutoFocusRef,
serializeTimerRef,
isInitializingRef,
isApplyingProgrammaticUpdateRef,
markdownCommentsRef,
markdownSourceLineOffsetRef,
flushPendingSerialization,
openSearchRef,
syncAnnotationTarget,
clearAnnotationTarget,
scrollRichMarkdownReviewNoteCardIntoView,
setIsEditingLink,
setLinkBubble,
setSelectedCommandIndex,
setSelectedDocLinkIndex,
setSlashMenu,
setDocLinkMenu
} = params
return {
immediatelyRender: false,
content: encodeRawMarkdownHtmlForRichEditor(content),
contentType: 'markdown' as const,
editorProps: {
attributes: {
class: 'rich-markdown-editor',
spellcheck: 'true'
},
handleDOMEvents: {
cut: handleRichMarkdownCut
},
handlePaste: (_view, event) =>
handleRichMarkdownPaste({
editor: editorRef.current,
event,
filePath,
worktreeId,
runtimeEnvironmentId
}),
handleTextInput: (view, from, to, text) => {
typedEmptyOrderedListMarkerRef.current = false
if (text !== ' ' || from !== to || !view.state.selection.empty) {
return false
}
const { $from } = view.state.selection
const beforeCursor = $from.parent.textBetween(0, $from.parentOffset, '\0', '\0')
typedEmptyOrderedListMarkerRef.current = /^\d+\.$/.test(beforeCursor)
return false
},
handleKeyDown: createRichMarkdownKeyHandler({
isMac,
editorRef,
rootRef,
lastCommittedMarkdownRef,
onContentChangeRef,
onSaveRef,
isEditingLinkRef,
slashMenuRef,
filteredSlashCommandsRef,
selectedCommandIndexRef,
docLinkMenuRef,
filteredDocLinkRowsRef,
selectedDocLinkIndexRef,
handleLocalImagePickRef,
handleEmojiPickRef,
typedEmptyOrderedListMarkerRef,
flushPendingSerialization,
openSearchRef,
setIsEditingLink,
setLinkBubble,
setSelectedCommandIndex,
setSelectedDocLinkIndex,
setSlashMenu,
setDocLinkMenu
}),
handleClick: (view, pos, event) => {
return handleRichMarkdownEditorClick({
activateMarkdownLink,
editorRef,
event,
filePath,
isMac,
markdownCommentsRef,
markdownSourceLineOffsetRef,
onOpenDocLinkRef,
pos,
rootRef,
runtimeEnvironmentId,
scrollRichMarkdownReviewNoteCardIntoView,
settings,
view,
worktreeId,
worktreeRoot
})
}
},
onFocus: () => {
window.api.ui.setMarkdownEditorFocused(true)
},
onBlur: () => {
window.api.ui.setMarkdownEditorFocused(false)
clearAnnotationTarget()
},
onCreate: ({ editor: nextEditor }) => {
normalizeSoftBreaks(nextEditor)
lastCommittedMarkdownRef.current = content
isInitializingRef.current = false
cancelAutoFocusRef.current?.()
cancelAutoFocusRef.current = autoFocusRichEditor(nextEditor, rootRef.current)
},
onBeforeCreate: ({ editor: nextEditor }) => {
setRichMarkdownImageResolverContext(
nextEditor,
createRichMarkdownImageResolverContext({
filePath,
runtimeEnvironmentId,
settings,
worktreeId,
worktreeRoot
})
)
},
onUpdate: ({ editor: nextEditor }) => {
syncSlashMenu(nextEditor, rootRef.current, setSlashMenu)
syncDocLinkMenu(nextEditor, rootRef.current, setDocLinkMenu)
if (!isSingleEmptyTopLevelOrderedList(nextEditor)) {
typedEmptyOrderedListMarkerRef.current = false
}
if (isInitializingRef.current || isApplyingProgrammaticUpdateRef.current) {
return
}
onDirtyStateHintRef.current(true)
if (serializeTimerRef.current !== null) {
window.clearTimeout(serializeTimerRef.current)
}
serializeTimerRef.current = window.setTimeout(() => {
serializeTimerRef.current = null
try {
const markdown = nextEditor.getMarkdown()
lastCommittedMarkdownRef.current = markdown
onContentChangeRef.current(markdown)
} catch {
// Why: save/restart flows should never crash the UI just because
// the editor was torn down between scheduling and serializing.
}
}, 300)
},
onSelectionUpdate: ({ editor: nextEditor }) => {
syncSlashMenu(nextEditor, rootRef.current, setSlashMenu)
syncDocLinkMenu(nextEditor, rootRef.current, setDocLinkMenu)
syncAnnotationTarget(nextEditor)
setIsEditingLink(false)
if (nextEditor.isActive('link')) {
const attrs = nextEditor.getAttributes('link')
const pos = getLinkBubblePosition(nextEditor, rootRef.current)
setLinkBubble(pos ? { href: (attrs.href as string) || '', ...pos } : null)
} else {
setLinkBubble(null)
}
}
}
}

View File

@ -60,7 +60,12 @@ export function createRichMarkdownExtensions({
// and works identically in dev and production modes.
Image.extend({
addStorage() {
return { filePath: '', runtimeContext: undefined as RuntimeFileOperationArgs | undefined }
return {
contextVersion: 0,
filePath: '',
reloadListeners: new Set<() => void>(),
runtimeContext: undefined as RuntimeFileOperationArgs | undefined
}
},
addNodeView() {
return ({ node, HTMLAttributes }) => {
@ -81,15 +86,17 @@ export function createRichMarkdownExtensions({
dom.appendChild(img)
let currentSrc = node.attrs.src as string | undefined
let currentContextVersion = getImageContextVersion(this.storage)
const loadImage = (src: string | undefined): void => {
const fp = this.storage.filePath as string
const runtimeContext = this.storage.runtimeContext as
| RuntimeFileOperationArgs
| undefined
const contextVersionAtLoad = getImageContextVersion(this.storage)
if (src && fp) {
void loadLocalImageSrc(src, fp, undefined, runtimeContext).then((resolved) => {
if (currentSrc !== src) {
if (currentSrc !== src || currentContextVersion !== contextVersionAtLoad) {
return
}
if (resolved) {
@ -116,6 +123,14 @@ export function createRichMarkdownExtensions({
const unsubscribe = onImageCacheInvalidated(() => {
loadImage(currentSrc)
})
const reloadForContextChange = (): void => {
currentContextVersion = getImageContextVersion(this.storage)
loadImage(currentSrc)
}
const reloadListeners = this.storage.reloadListeners
if (reloadListeners instanceof Set) {
reloadListeners.add(reloadForContextChange)
}
return {
dom,
@ -124,13 +139,18 @@ export function createRichMarkdownExtensions({
return false
}
const newSrc = updatedNode.attrs.src as string | undefined
if (newSrc !== currentSrc) {
const nextContextVersion = getImageContextVersion(this.storage)
if (newSrc !== currentSrc || nextContextVersion !== currentContextVersion) {
currentSrc = newSrc
currentContextVersion = nextContextVersion
loadImage(newSrc)
}
return true
},
destroy: () => {
if (reloadListeners instanceof Set) {
reloadListeners.delete(reloadForContextChange)
}
unsubscribe()
}
}
@ -184,3 +204,8 @@ export function createRichMarkdownExtensions({
return extensions
}
function getImageContextVersion(storage: Record<string, unknown>): number {
const version = storage.contextVersion
return typeof version === 'number' ? version : 0
}

View File

@ -0,0 +1,89 @@
import type { Editor } from '@tiptap/core'
import { getConnectionId } from '@/lib/connection-context'
import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client'
import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client'
export type RichMarkdownImageRuntimeContext = Omit<RuntimeFileOperationArgs, 'connectionId'> & {
connectionId?: string | null
}
export type RichMarkdownImageResolverContext = {
filePath: string
runtimeContext?: RichMarkdownImageRuntimeContext
}
export type RichMarkdownImageResolverSettings = Parameters<typeof settingsForRuntimeOwner>[0]
type RichMarkdownImageStorage = {
image?: {
contextVersion?: number
filePath: string
reloadListeners?: Set<() => void>
runtimeContext?: RichMarkdownImageRuntimeContext
}
}
export function createRichMarkdownImageResolverContext({
filePath,
runtimeEnvironmentId,
settings,
worktreeId,
worktreeRoot
}: {
filePath: string
runtimeEnvironmentId?: string | null
settings: RichMarkdownImageResolverSettings
worktreeId: string
worktreeRoot: string | null
}): RichMarkdownImageResolverContext {
return {
filePath,
runtimeContext: worktreeRoot
? {
settings: settingsForRuntimeOwner(settings, runtimeEnvironmentId),
worktreeId,
worktreePath: worktreeRoot,
connectionId: getConnectionId(worktreeId)
}
: undefined
}
}
export function setRichMarkdownImageResolverContext(
editor: Editor,
context: RichMarkdownImageResolverContext
): boolean {
const storage = editor.storage as unknown as RichMarkdownImageStorage
const imageStorage = storage.image ?? {
filePath: ''
}
const previousSignature = getRichMarkdownImageContextSignature({
filePath: imageStorage.filePath,
runtimeContext: imageStorage.runtimeContext
})
const nextSignature = getRichMarkdownImageContextSignature(context)
if (previousSignature === nextSignature) {
return false
}
// Why: nodeViews need a cheap change signal because the markdown src can
// remain identical while the file/runtime resolver context changes.
imageStorage.filePath = context.filePath
imageStorage.runtimeContext = context.runtimeContext
imageStorage.contextVersion = (imageStorage.contextVersion ?? 0) + 1
storage.image = imageStorage
for (const listener of imageStorage.reloadListeners ?? []) {
listener()
}
return true
}
function getRichMarkdownImageContextSignature(context: RichMarkdownImageResolverContext): string {
return [
context.filePath,
context.runtimeContext?.settings?.activeRuntimeEnvironmentId?.trim() ?? 'client',
context.runtimeContext?.connectionId ?? 'local',
context.runtimeContext?.worktreeId ?? 'unknown-worktree',
context.runtimeContext?.worktreePath ?? ''
].join('\0')
}

View File

@ -0,0 +1,66 @@
// @vitest-environment happy-dom
import { Editor } from '@tiptap/core'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createRichMarkdownExtensions } from './rich-markdown-extensions'
import { resetLocalImageSrcStateForTests } from './useLocalImageSrc'
import { setRichMarkdownImageResolverContext } from './rich-markdown-image-context'
async function flushPromises(): Promise<void> {
for (let index = 0; index < 5; index += 1) {
await Promise.resolve()
}
await new Promise((resolve) => setTimeout(resolve, 0))
}
describe('rich markdown local images', () => {
beforeEach(() => {
resetLocalImageSrcStateForTests()
vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:rich-local-image')
globalThis.window.api = {
...globalThis.window.api,
fs: {
readFile: vi.fn().mockResolvedValue({
content: 'AA==',
isBinary: true,
mimeType: 'image/png'
})
}
} as unknown as Window['api']
})
afterEach(() => {
resetLocalImageSrcStateForTests()
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
it('reloads persisted relative images after the markdown file context is assigned', async () => {
const host = document.createElement('div')
document.body.appendChild(host)
const editor = new Editor({
element: host,
extensions: createRichMarkdownExtensions(),
content: '![](diagram.png)',
contentType: 'markdown'
})
try {
const img = host.querySelector('img')
expect(img).not.toBeNull()
expect(window.api.fs.readFile).not.toHaveBeenCalled()
setRichMarkdownImageResolverContext(editor, { filePath: '/repo/docs/readme.md' })
await flushPromises()
expect(window.api.fs.readFile).toHaveBeenCalledWith({
filePath: '/repo/docs/diagram.png',
connectionId: undefined
})
expect(host.querySelector('img')?.src).toBe('blob:rich-local-image')
} finally {
editor.destroy()
}
})
})

View File

@ -1,299 +1,25 @@
import { type Dispatch, type MutableRefObject, type SetStateAction, useMemo } from 'react'
import { useMemo } from 'react'
import { useEditor, type Editor } from '@tiptap/react'
import { createRichMarkdownExtensions } from './rich-markdown-extensions'
import { createRichMarkdownKeyHandler } from './rich-markdown-key-handler'
import { handleRichMarkdownCut } from './rich-markdown-cut-handler'
import { handleRichMarkdownPaste } from './rich-markdown-paste-handler'
import { encodeRawMarkdownHtmlForRichEditor } from './raw-markdown-html'
import { normalizeSoftBreaks } from './rich-markdown-normalize'
import { autoFocusRichEditor } from './rich-markdown-auto-focus'
import {
syncDocLinkMenu,
type DocLinkMenuRow,
type DocLinkMenuState
} from './rich-markdown-commands'
import {
syncSlashMenu,
type SlashCommand,
type SlashMenuState
} from './rich-markdown-slash-commands'
import { isSingleEmptyTopLevelOrderedList } from './rich-markdown-list-continuation'
import { getLinkBubblePosition, type LinkBubbleState } from './RichMarkdownLinkBubble'
import {
handleRichMarkdownEditorClick,
type ActivateMarkdownLink,
type RichMarkdownRuntimeSettings
} from './rich-markdown-editor-click-routing'
import type { DiffComment } from '../../../../shared/types'
createRichMarkdownEditorConfig,
type EditorConfigParams
} from './rich-markdown-editor-config'
const richMarkdownExtensions = createRichMarkdownExtensions({ includePlaceholder: true })
export function useRichMarkdownEditorInstance({
content,
filePath,
worktreeId,
worktreeRoot,
runtimeEnvironmentId,
isMac,
settings,
activateMarkdownLink,
rootRef,
editorRef,
lastCommittedMarkdownRef,
onContentChangeRef,
onDirtyStateHintRef,
onSaveRef,
onOpenDocLinkRef,
isEditingLinkRef,
slashMenuRef,
filteredSlashCommandsRef,
selectedCommandIndexRef,
docLinkMenuRef,
filteredDocLinkRowsRef,
selectedDocLinkIndexRef,
handleLocalImagePickRef,
handleEmojiPickRef,
typedEmptyOrderedListMarkerRef,
cancelAutoFocusRef,
serializeTimerRef,
isInitializingRef,
isApplyingProgrammaticUpdateRef,
markdownCommentsRef,
markdownSourceLineOffsetRef,
flushPendingSerialization,
openSearchRef,
syncAnnotationTarget,
clearAnnotationTarget,
scrollRichMarkdownReviewNoteCardIntoView,
setIsEditingLink,
setLinkBubble,
setSelectedCommandIndex,
setSelectedDocLinkIndex,
setSlashMenu,
setDocLinkMenu
}: {
content: string
filePath: string
worktreeId: string
worktreeRoot: string | null
runtimeEnvironmentId?: string | null
isMac: boolean
settings: RichMarkdownRuntimeSettings
activateMarkdownLink: ActivateMarkdownLink
rootRef: MutableRefObject<HTMLDivElement | null>
editorRef: MutableRefObject<Editor | null>
lastCommittedMarkdownRef: MutableRefObject<string>
onContentChangeRef: MutableRefObject<(content: string) => void>
onDirtyStateHintRef: MutableRefObject<(dirty: boolean) => void>
onSaveRef: MutableRefObject<(content: string) => void>
onOpenDocLinkRef: MutableRefObject<((target: string) => void) | undefined>
isEditingLinkRef: MutableRefObject<boolean>
slashMenuRef: MutableRefObject<SlashMenuState | null>
filteredSlashCommandsRef: MutableRefObject<SlashCommand[]>
selectedCommandIndexRef: MutableRefObject<number>
docLinkMenuRef: MutableRefObject<DocLinkMenuState | null>
filteredDocLinkRowsRef: MutableRefObject<DocLinkMenuRow[]>
selectedDocLinkIndexRef: MutableRefObject<number>
handleLocalImagePickRef: MutableRefObject<() => void>
handleEmojiPickRef: MutableRefObject<(menu: SlashMenuState) => void>
typedEmptyOrderedListMarkerRef: MutableRefObject<boolean>
cancelAutoFocusRef: MutableRefObject<(() => void) | null>
serializeTimerRef: MutableRefObject<number | null>
isInitializingRef: MutableRefObject<boolean>
isApplyingProgrammaticUpdateRef: MutableRefObject<boolean>
markdownCommentsRef: MutableRefObject<DiffComment[]>
markdownSourceLineOffsetRef: MutableRefObject<number>
flushPendingSerialization: () => void
openSearchRef: MutableRefObject<() => void>
syncAnnotationTarget: (editor: Editor) => void
clearAnnotationTarget: () => void
scrollRichMarkdownReviewNoteCardIntoView: (commentId: string) => void
setIsEditingLink: Dispatch<SetStateAction<boolean>>
setLinkBubble: Dispatch<SetStateAction<LinkBubbleState | null>>
setSelectedCommandIndex: Dispatch<SetStateAction<number>>
setSelectedDocLinkIndex: Dispatch<SetStateAction<number>>
setSlashMenu: Dispatch<SetStateAction<SlashMenuState | null>>
setDocLinkMenu: Dispatch<SetStateAction<DocLinkMenuState | null>>
}): Editor | null {
export function useRichMarkdownEditorInstance(params: EditorConfigParams): Editor | null {
const editor = useEditor(
useMemo(
() => ({
immediatelyRender: false,
extensions: richMarkdownExtensions,
content: encodeRawMarkdownHtmlForRichEditor(content),
contentType: 'markdown' as const,
editorProps: {
attributes: {
class: 'rich-markdown-editor',
spellcheck: 'true'
},
handleDOMEvents: {
cut: handleRichMarkdownCut
},
handlePaste: (_view, event) =>
handleRichMarkdownPaste({
editor: editorRef.current,
event,
filePath,
worktreeId,
runtimeEnvironmentId
}),
handleTextInput: (view, from, to, text) => {
typedEmptyOrderedListMarkerRef.current = false
if (text !== ' ' || from !== to || !view.state.selection.empty) {
return false
}
const { $from } = view.state.selection
const beforeCursor = $from.parent.textBetween(0, $from.parentOffset, '\0', '\0')
typedEmptyOrderedListMarkerRef.current = /^\d+\.$/.test(beforeCursor)
return false
},
handleKeyDown: createRichMarkdownKeyHandler({
isMac,
editorRef,
rootRef,
lastCommittedMarkdownRef,
onContentChangeRef,
onSaveRef,
isEditingLinkRef,
slashMenuRef,
filteredSlashCommandsRef,
selectedCommandIndexRef,
docLinkMenuRef,
filteredDocLinkRowsRef,
selectedDocLinkIndexRef,
handleLocalImagePickRef,
handleEmojiPickRef,
typedEmptyOrderedListMarkerRef,
flushPendingSerialization,
openSearchRef,
setIsEditingLink,
setLinkBubble,
setSelectedCommandIndex,
setSelectedDocLinkIndex,
setSlashMenu,
setDocLinkMenu
}),
handleClick: (view, pos, event) => {
return handleRichMarkdownEditorClick({
activateMarkdownLink,
editorRef,
event,
filePath,
isMac,
markdownCommentsRef,
markdownSourceLineOffsetRef,
onOpenDocLinkRef,
pos,
rootRef,
runtimeEnvironmentId,
scrollRichMarkdownReviewNoteCardIntoView,
settings,
view,
worktreeId,
worktreeRoot
})
}
},
onFocus: () => {
window.api.ui.setMarkdownEditorFocused(true)
},
onBlur: () => {
window.api.ui.setMarkdownEditorFocused(false)
clearAnnotationTarget()
},
onCreate: ({ editor: nextEditor }) => {
normalizeSoftBreaks(nextEditor)
lastCommittedMarkdownRef.current = content
isInitializingRef.current = false
cancelAutoFocusRef.current?.()
cancelAutoFocusRef.current = autoFocusRichEditor(nextEditor, rootRef.current)
},
onUpdate: ({ editor: nextEditor }) => {
syncSlashMenu(nextEditor, rootRef.current, setSlashMenu)
syncDocLinkMenu(nextEditor, rootRef.current, setDocLinkMenu)
if (!isSingleEmptyTopLevelOrderedList(nextEditor)) {
typedEmptyOrderedListMarkerRef.current = false
}
if (isInitializingRef.current || isApplyingProgrammaticUpdateRef.current) {
return
}
onDirtyStateHintRef.current(true)
if (serializeTimerRef.current !== null) {
window.clearTimeout(serializeTimerRef.current)
}
serializeTimerRef.current = window.setTimeout(() => {
serializeTimerRef.current = null
try {
const markdown = nextEditor.getMarkdown()
lastCommittedMarkdownRef.current = markdown
onContentChangeRef.current(markdown)
} catch {
// Why: save/restart flows should never crash the UI just because
// the editor was torn down between scheduling and serializing.
}
}, 300)
},
onSelectionUpdate: ({ editor: nextEditor }) => {
syncSlashMenu(nextEditor, rootRef.current, setSlashMenu)
syncDocLinkMenu(nextEditor, rootRef.current, setDocLinkMenu)
syncAnnotationTarget(nextEditor)
setIsEditingLink(false)
if (nextEditor.isActive('link')) {
const attrs = nextEditor.getAttributes('link')
const pos = getLinkBubblePosition(nextEditor, rootRef.current)
setLinkBubble(pos ? { href: (attrs.href as string) || '', ...pos } : null)
} else {
setLinkBubble(null)
}
}
...createRichMarkdownEditorConfig(params)
}),
[
activateMarkdownLink,
cancelAutoFocusRef,
clearAnnotationTarget,
content,
docLinkMenuRef,
editorRef,
filePath,
filteredDocLinkRowsRef,
filteredSlashCommandsRef,
flushPendingSerialization,
handleEmojiPickRef,
handleLocalImagePickRef,
isApplyingProgrammaticUpdateRef,
isEditingLinkRef,
isInitializingRef,
isMac,
lastCommittedMarkdownRef,
markdownCommentsRef,
markdownSourceLineOffsetRef,
onContentChangeRef,
onDirtyStateHintRef,
onOpenDocLinkRef,
onSaveRef,
openSearchRef,
rootRef,
runtimeEnvironmentId,
scrollRichMarkdownReviewNoteCardIntoView,
selectedCommandIndexRef,
selectedDocLinkIndexRef,
serializeTimerRef,
setDocLinkMenu,
setIsEditingLink,
setLinkBubble,
setSelectedCommandIndex,
setSelectedDocLinkIndex,
setSlashMenu,
settings,
slashMenuRef,
syncAnnotationTarget,
typedEmptyOrderedListMarkerRef,
worktreeId,
worktreeRoot
]
// Dependencies are the same as the params object keys
// eslint-disable-next-line react-hooks/exhaustive-deps
Object.values(params)
)
)
editorRef.current = editor ?? null
params.editorRef.current = editor ?? null
return editor
}

View File

@ -1,13 +1,16 @@
import { useEffect } from 'react'
import type { Dispatch, MutableRefObject, SetStateAction } from 'react'
import type { Editor } from '@tiptap/react'
import { getConnectionId } from '@/lib/connection-context'
import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client'
import type { MarkdownDocument } from '../../../../shared/types'
import { encodeRawMarkdownHtmlForRichEditor } from './raw-markdown-html'
import { syncDocLinkMenu, type DocLinkMenuState } from './rich-markdown-commands'
import { normalizeSoftBreaks } from './rich-markdown-normalize'
import { syncSlashMenu, type SlashMenuState } from './rich-markdown-slash-commands'
import {
createRichMarkdownImageResolverContext,
setRichMarkdownImageResolverContext,
type RichMarkdownImageResolverSettings
} from './rich-markdown-image-context'
type RichMarkdownProgrammaticSyncOptions = {
content: string
@ -20,22 +23,13 @@ type RichMarkdownProgrammaticSyncOptions = {
markdownDocuments?: MarkdownDocument[]
rootRef: MutableRefObject<HTMLDivElement | null>
runtimeEnvironmentId?: string | null
settings: Parameters<typeof settingsForRuntimeOwner>[0]
settings: RichMarkdownImageResolverSettings
slashMenuSetter: Dispatch<SetStateAction<SlashMenuState | null>>
worktreeId: string
worktreeRoot: string | null
}
type RichMarkdownEditorStorage = {
image: {
filePath: string
runtimeContext?: {
connectionId: string | null | undefined
settings: ReturnType<typeof settingsForRuntimeOwner>
worktreeId: string
worktreePath: string
}
}
markdownDocLink: {
documents: MarkdownDocument[]
}
@ -63,17 +57,16 @@ export function useRichMarkdownProgrammaticSync({
}
isApplyingProgrammaticUpdateRef.current = true
try {
const storage = editor.storage as unknown as RichMarkdownEditorStorage
storage.image.filePath = filePath
storage.image.runtimeContext = worktreeRoot
? {
settings: settingsForRuntimeOwner(settings, runtimeEnvironmentId),
worktreeId,
worktreePath: worktreeRoot,
connectionId: getConnectionId(worktreeId)
}
: undefined
editor.view.dispatch(editor.state.tr)
setRichMarkdownImageResolverContext(
editor,
createRichMarkdownImageResolverContext({
filePath,
runtimeEnvironmentId,
settings,
worktreeId,
worktreeRoot
})
)
} finally {
isApplyingProgrammaticUpdateRef.current = false
}