From fe4ceec83fd30ddf89bcb83c56f7d33eb4d88b39 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Thu, 29 Oct 2026 16:16:43 -0700 Subject: [PATCH] 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> --- .../editor/rich-markdown-extensions.ts | 44 +++++++++-- .../rich-markdown-image-attributes.test.ts | 73 +++++++++++++++++++ 2 files changed, 112 insertions(+), 5 deletions(-) create mode 100644 src/renderer/src/components/editor/rich-markdown-image-attributes.test.ts diff --git a/src/renderer/src/components/editor/rich-markdown-extensions.ts b/src/renderer/src/components/editor/rich-markdown-extensions.ts index c1cfef0a3b5..be727b3eeed 100644 --- a/src/renderer/src/components/editor/rich-markdown-extensions.ts +++ b/src/renderer/src/components/editor/rich-markdown-extensions.ts @@ -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() + const syncImageAttributes = (attributes: Record): void => { + const nextNames = new Set() + 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) { diff --git a/src/renderer/src/components/editor/rich-markdown-image-attributes.test.ts b/src/renderer/src/components/editor/rich-markdown-image-attributes.test.ts new file mode 100644 index 00000000000..e2419ab754f --- /dev/null +++ b/src/renderer/src/components/editor/rich-markdown-image-attributes.test.ts @@ -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() +const originalApi = Object.getOwnPropertyDescriptor(window, 'api') +let editor: Editor | undefined + +function updateImage(attributes: Record): 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) +})