fix(editor): synchronize rendered image metadata (#35028)

* fix(editor): synchronize rendered image metadata

* fix(editor): resolve image attribute definitions once and trim metadata test

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
OrcaWin
2026-10-29 16:16:43 -07:00
committed by GitHub
co-authored by OrcaWin
parent 85522e89b8
commit fe4ceec83f
2 changed files with 112 additions and 5 deletions
@@ -1,5 +1,10 @@
import { createMarkdownTokenizerStart } from './markdown-tokenizer-start'
import type { AnyExtension } from '@tiptap/core'
import {
getRenderedAttributes,
mergeAttributes,
type AnyExtension,
type Editor
} from '@tiptap/core'
import StarterKit from '@tiptap/starter-kit'
import { RichMarkdownTrailingParagraph } from './rich-markdown-trailing-paragraph'
import Link from '@tiptap/extension-link'
@@ -108,7 +113,9 @@ export function createRichMarkdownExtensions({
}
},
addNodeView() {
return ({ node, HTMLAttributes }) => {
// The extensionManager.attributes getter recomputes across all extensions; resolve it once per editor.
let imageAttributes: Editor['extensionManager']['attributes'] | undefined
return ({ node, HTMLAttributes, editor }) => {
// A wrapper prevents native image-byte dragging from conflicting with ProseMirror’s schema-node relocation.
const dom = document.createElement('span')
// Keep the image wrapper inline so it cannot break surrounding text onto separate lines.
@@ -118,11 +125,27 @@ export function createRichMarkdownExtensions({
const img = document.createElement('img')
img.draggable = false
for (const [key, value] of Object.entries(HTMLAttributes)) {
if (key !== 'src' && value != null && value !== false) {
img.setAttribute(key, String(value))
let renderedAttributeNames = new Set<string>()
const syncImageAttributes = (attributes: Record<string, unknown>): void => {
const nextNames = new Set<string>()
for (const [key, value] of Object.entries(attributes)) {
if (key === 'src' || key === 'draggable' || value == null || value === false) {
continue
}
nextNames.add(key)
const renderedValue = String(value)
if (img.getAttribute(key) !== renderedValue) {
img.setAttribute(key, renderedValue)
}
}
for (const key of renderedAttributeNames) {
if (!nextNames.has(key)) {
img.removeAttribute(key)
}
}
renderedAttributeNames = nextNames
}
syncImageAttributes(mergeAttributes(this.options.HTMLAttributes, HTMLAttributes))
dom.appendChild(img)
let currentSrc = node.attrs.src as string | undefined
@@ -185,6 +208,17 @@ export function createRichMarkdownExtensions({
if (updatedNode.type.name !== 'image') {
return false
}
syncImageAttributes(
mergeAttributes(
this.options.HTMLAttributes,
getRenderedAttributes(
updatedNode,
(imageAttributes ??= editor.extensionManager.attributes.filter(
(attribute) => attribute.type === 'image'
))
)
)
)
const newSrc = updatedNode.attrs.src as string | undefined
const nextContextVersion = getImageContextVersion(this.storage)
if (newSrc !== currentSrc || nextContextVersion !== currentContextVersion) {
@@ -0,0 +1,73 @@
// @vitest-environment happy-dom
import { Editor } from '@tiptap/core'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { createRichMarkdownExtensions } from './rich-markdown-extensions'
import { setRichMarkdownImageResolverContext } from './rich-markdown-image-context'
import { createRichMarkdownEditorCodec } from './rich-markdown-source-transport'
import { resetLocalImageSrcStateForTests } from './useLocalImageSrc'
const readFile = vi.fn<Window['api']['fs']['readFile']>()
const originalApi = Object.getOwnPropertyDescriptor(window, 'api')
let editor: Editor | undefined
function updateImage(attributes: Record<string, unknown>): void {
const node = editor?.state.doc.nodeAt(1)
if (!editor || !node) {
throw new Error('Image node missing')
}
editor.view.dispatch(
editor.state.tr.setNodeMarkup(1, undefined, { ...node.attrs, ...attributes })
)
}
beforeEach(() => {
resetLocalImageSrcStateForTests()
readFile.mockReset().mockResolvedValue({ content: 'AA==', isBinary: true, mimeType: 'image/png' })
Object.defineProperty(window, 'api', { configurable: true, value: { fs: { readFile } } })
vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:attributes')
vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
})
afterEach(() => {
editor?.destroy()
editor = undefined
resetLocalImageSrcStateForTests()
vi.restoreAllMocks()
if (originalApi) {
Object.defineProperty(window, 'api', originalApi)
} else {
Reflect.deleteProperty(window, 'api')
}
document.body.innerHTML = ''
})
it('syncs changed and cleared metadata onto the mounted image without reloading it', async () => {
const host = document.createElement('div')
document.body.appendChild(host)
editor = new Editor({
element: host,
extensions: createRichMarkdownExtensions({ codec: createRichMarkdownEditorCodec() }),
content: '![Before](before.png "Old title")',
contentType: 'markdown'
})
setRichMarkdownImageResolverContext(editor, { filePath: '/repo/docs/readme.md' })
const image = host.querySelector('img')
await vi.waitFor(() => expect(image?.getAttribute('src')).toBe('blob:attributes'))
expect(image?.alt).toBe('Before')
updateImage({ alt: 'After', title: 'New title', width: 160, height: 90 })
expect(host.querySelector('img')).toBe(image)
expect(image?.alt).toBe('After')
expect(image?.title).toBe('New title')
expect(image?.width).toBe(160)
expect(image?.height).toBe(90)
expect(image?.draggable).toBe(false)
updateImage({ alt: null, title: null, width: null, height: null })
expect(host.querySelector('img')).toBe(image)
for (const name of ['alt', 'title', 'width', 'height']) {
expect(image?.hasAttribute(name), name).toBe(false)
}
expect(image?.getAttribute('src')).toBe('blob:attributes')
expect(readFile).toHaveBeenCalledTimes(1)
})