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:
parent
379ddf2a1b
commit
a1ec8d25a7
|
|
@ -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' }
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
}
|
||||
|
|
@ -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: '',
|
||||
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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue