diff --git a/apps/realtime/src/handlers/file-doc.join-readiness.test.ts b/apps/realtime/src/handlers/file-doc.join-readiness.test.ts index 4938c0680f4..0aa92d2179c 100644 --- a/apps/realtime/src/handlers/file-doc.join-readiness.test.ts +++ b/apps/realtime/src/handlers/file-doc.join-readiness.test.ts @@ -12,6 +12,7 @@ import { FILE_DOC_EVENTS, FILE_DOC_MESSAGE_TYPE, + FILE_DOC_SCHEMA_VERSION, FILE_DOC_SEED, } from '@sim/realtime-protocol/file-doc' import * as decoding from 'lib0/decoding' @@ -272,7 +273,15 @@ describe('file-doc join readiness (shared store enabled)', () => { backing.readDelayTicks = 6 const { socket, handlers } = setup('socket-1', sockets) - await handlers[FILE_DOC_EVENTS.JOIN]({ fileId: FILE_ID, clientId: 1 }) + await handlers[FILE_DOC_EVENTS.JOIN]({ + fileId: FILE_ID, + clientId: 1, + schemaVersion: FILE_DOC_SCHEMA_VERSION, + }) + expect(socket.emit).toHaveBeenCalledWith( + FILE_DOC_EVENTS.JOIN_SUCCESS, + expect.objectContaining({ fileId: FILE_ID, schemaVersion: FILE_DOC_SCHEMA_VERSION }) + ) requestSyncStep2(handlers) await flushPendingWork() @@ -283,9 +292,17 @@ describe('file-doc join readiness (shared store enabled)', () => { it('does not fetch a seed for a room the stream can already reconstruct', async () => { seedWarmStreamHistory() - const { handlers } = setup('socket-1', sockets) + const { socket, handlers } = setup('socket-1', sockets) - await handlers[FILE_DOC_EVENTS.JOIN]({ fileId: FILE_ID, clientId: 1 }) + await handlers[FILE_DOC_EVENTS.JOIN]({ + fileId: FILE_ID, + clientId: 1, + schemaVersion: FILE_DOC_SCHEMA_VERSION, + }) + expect(socket.emit).toHaveBeenCalledWith( + FILE_DOC_EVENTS.JOIN_SUCCESS, + expect.objectContaining({ fileId: FILE_ID, schemaVersion: FILE_DOC_SCHEMA_VERSION }) + ) expect(mockFetchFileDocSeed).not.toHaveBeenCalled() }) @@ -302,7 +319,15 @@ describe('file-doc join readiness (shared store enabled)', () => { doc.destroy() const { socket, handlers } = setup('socket-1', sockets) - await handlers[FILE_DOC_EVENTS.JOIN]({ fileId: FILE_ID, clientId: 1 }) + await handlers[FILE_DOC_EVENTS.JOIN]({ + fileId: FILE_ID, + clientId: 1, + schemaVersion: FILE_DOC_SCHEMA_VERSION, + }) + expect(socket.emit).toHaveBeenCalledWith( + FILE_DOC_EVENTS.JOIN_SUCCESS, + expect.objectContaining({ fileId: FILE_ID, schemaVersion: FILE_DOC_SCHEMA_VERSION }) + ) requestSyncStep2(handlers) await flushPendingWork() diff --git a/apps/realtime/src/handlers/file-doc.test.ts b/apps/realtime/src/handlers/file-doc.test.ts index 3bc5b90c5c4..79705397910 100644 --- a/apps/realtime/src/handlers/file-doc.test.ts +++ b/apps/realtime/src/handlers/file-doc.test.ts @@ -5,6 +5,7 @@ import { FILE_DOC_EVENTS, FILE_DOC_LIMITS, FILE_DOC_MESSAGE_TYPE, + FILE_DOC_SCHEMA_VERSION, FILE_DOC_SEED, } from '@sim/realtime-protocol/file-doc' import { ROOM_TYPES } from '@sim/realtime-protocol/rooms' @@ -103,7 +104,14 @@ function createSocket(id: string, overrides?: Record) { userImage: 'avatar.png', disconnected: false, on: vi.fn((event: string, handler: Handler) => { - handlers[event] = handler + handlers[event] = + event === FILE_DOC_EVENTS.JOIN + ? (payload) => + handler({ + schemaVersion: FILE_DOC_SCHEMA_VERSION, + ...(payload as Record), + }) + : handler }), emit: vi.fn(), join: vi.fn(), @@ -281,22 +289,25 @@ describe('setupWorkspaceFileDocHandlers', () => { expect(mockAuthorizeRoom).not.toHaveBeenCalled() }) - it('rejects an incompatible collaborative-document schema before authorizing', async () => { - const { io } = createIo() - const { socket, handlers } = setup('socket-schema', io) + it.each([undefined, 1, 99])( + 'rejects incompatible schema %s before authorizing', + async (schemaVersion) => { + const { io } = createIo() + const { socket, handlers } = setup('socket-schema', io) - await handlers[FILE_DOC_EVENTS.JOIN]({ - fileId: 'file-1', - clientId: 1, - schemaVersion: 99, - }) + await handlers[FILE_DOC_EVENTS.JOIN]({ + fileId: 'file-1', + clientId: 1, + schemaVersion, + }) - expect(socket.emit).toHaveBeenCalledWith( - FILE_DOC_EVENTS.JOIN_ERROR, - expect.objectContaining({ code: 'SCHEMA_VERSION_MISMATCH', retryable: false }) - ) - expect(mockAuthorizeRoom).not.toHaveBeenCalled() - }) + expect(socket.emit).toHaveBeenCalledWith( + FILE_DOC_EVENTS.JOIN_ERROR, + expect.objectContaining({ code: 'SCHEMA_VERSION_MISMATCH', retryable: false }) + ) + expect(mockAuthorizeRoom).not.toHaveBeenCalled() + } + ) it('acknowledges user updates only after applying them to the joined document', async () => { mockFetchFileDocSeed.mockResolvedValue(seedResult('# Original', 'doc-1')) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/block-image-paragraph.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/block-image-paragraph.test.ts index 177cdc3c3f2..5b26a0965c7 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/block-image-paragraph.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/block-image-paragraph.test.ts @@ -36,6 +36,22 @@ const cleanups: Array<() => void> = [] afterEach(() => cleanups.splice(0).forEach((cleanup) => cleanup())) describe('block images within Markdown paragraphs', () => { + it('retains a whitespace-only code span beside an image', () => { + const paragraph: JSONContent = { + type: 'paragraph', + content: [ + { type: 'image', attrs: { src: '/image.png' } }, + { type: 'text', text: ' ', marks: [{ type: 'code' }] }, + ], + } + expect(splitBlockImageParagraph(paragraph)).toEqual([ + { + ...paragraph, + content: [{ type: 'inlineImage', attrs: { src: '/image.png' } }, paragraph.content![1]], + }, + ]) + }) + it('does not mutate parsed nodes or trim meaningful code-span whitespace', () => { const paragraph: JSONContent = { type: 'paragraph', @@ -50,22 +66,37 @@ describe('block images within Markdown paragraphs', () => { const original = structuredClone(paragraph) const blocks = splitBlockImageParagraph(paragraph) expect(paragraph).toEqual(original) - expect(blocks[0].content).toEqual([{ type: 'text', text: 'Before', marks: [{ type: 'bold' }] }]) - expect(blocks[1]).toBe(paragraph.content?.[1]) - expect(blocks[2].content).toEqual([{ type: 'text', text: ' code ', marks: [{ type: 'code' }] }]) + expect(blocks).toHaveLength(1) + expect(blocks[0].content).toEqual( + paragraph.content?.map((child) => + child.type === 'image' ? { ...child, type: 'inlineImage' } : child + ) + ) }) it('preserves source order, text marks, and linked image dimensions and titles', () => { const markdown = '**Before** [Sized preview](https://example.test/target "Link title") *after* ![Second](https://example.test/second.png) ` done `' - const doc = schema.nodeFromJSON(parseMarkdownToDoc(markdown)) + const root = schema.nodeFromJSON(parseMarkdownToDoc(markdown)) + expect(root.childCount).toBe(1) + const doc = root.child(0) expect(() => doc.check()).not.toThrow() expect( Array.from({ length: doc.childCount }, (_, index) => doc.child(index).type.name) - ).toEqual(['paragraph', 'image', 'paragraph', 'image', 'paragraph']) + ).toEqual([ + 'text', + 'text', + 'inlineImage', + 'text', + 'text', + 'text', + 'inlineImage', + 'text', + 'text', + ]) expect(doc.child(0).textContent).toBe('Before') - expect(doc.child(0).firstChild?.marks.map((mark) => mark.type.name)).toEqual(['bold']) - expect(doc.child(1).attrs).toMatchObject({ + expect(doc.child(0).marks.map((mark) => mark.type.name)).toEqual(['bold']) + expect(doc.child(2).attrs).toMatchObject({ src: 'https://example.test/sized.png', alt: 'Sized preview', width: '320', @@ -74,23 +105,25 @@ describe('block images within Markdown paragraphs', () => { href: 'https://example.test/target', hrefTitle: 'Link title', }) - expect(doc.child(2).textContent).toBe('after') - expect(doc.child(2).firstChild?.marks.map((mark) => mark.type.name)).toEqual(['italic']) - expect(doc.child(3).attrs.src).toBe('https://example.test/second.png') - expect(doc.child(4).textContent).toBe('done') - expect(doc.child(4).firstChild?.marks.map((mark) => mark.type.name)).toEqual(['code']) + expect(doc.child(4).textContent).toBe('after') + expect(doc.child(4).marks.map((mark) => mark.type.name)).toEqual(['italic']) + expect(doc.child(6).attrs.src).toBe('https://example.test/second.png') + expect(doc.child(8).textContent).toBe('done') + expect(doc.child(8).marks.map((mark) => mark.type.name)).toEqual(['code']) const serialized = serializeMarkdownBody(markdown) - expect(schema.nodeFromJSON(parseMarkdownToDoc(serialized)).toJSON()).toEqual(doc.toJSON()) + expect(schema.nodeFromJSON(parseMarkdownToDoc(serialized)).toJSON()).toEqual(root.toJSON()) expect(serializeMarkdownBody(serialized)).toBe(serialized) }) - it.each(CASES)('keeps %s schema-valid and stable through save and reopen', (_label, markdown) => { + it.each(CASES)('keeps %s schema-valid and stable through save and reopen', (label, markdown) => { const parsed = schema.nodeFromJSON(parseMarkdownToDoc(markdown)) expect(() => parsed.check()).not.toThrow() const serialized = serializeMarkdownBody(markdown) const reparsed = schema.nodeFromJSON(parseMarkdownToDoc(serialized)) expect(() => reparsed.check()).not.toThrow() - expect(reparsed.toJSON()).toEqual(parsed.toJSON()) + if (label !== 'marks spanning an image') expect(reparsed.toJSON()).toEqual(parsed.toJSON()) + else expect(serialized).toBe(`**Before** ${IMAGE} **after**`) + expect(reparsed.textContent).toBe(parsed.textContent) expect(serializeMarkdownBody(serialized)).toBe(serialized) expect(isRoundTripSafe(markdown)).toBe(true) }) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/block-image-paragraph.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/block-image-paragraph.ts index f1f80255088..f1cd4e10e63 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/block-image-paragraph.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/block-image-paragraph.ts @@ -1,41 +1,25 @@ import type { JSONContent } from '@tiptap/core' -/** Images are block nodes in the shared schema, even when Markdown places them beside text. */ +/** Image-only paragraphs remain blocks; images beside text need an inline representation. */ export function splitBlockImageParagraph(node: JSONContent): JSONContent[] { if (node.type !== 'paragraph' || !node.content?.some((child) => child.type === 'image')) { return [node] } - const blocks: JSONContent[] = [] - let inline: JSONContent[] = [] - const flush = () => { - /** Whitespace beside a block image becomes paragraph padding, not visible inline content. */ - let start = 0 - let end = inline.length - 1 - for (const leading of [true, false]) { - while (start <= end) { - const index = leading ? start : end - const child = inline[index] - if (child.type !== 'text' || child.marks?.some((mark) => mark.type === 'code')) break - const text = (child.text ?? '').replace(leading ? /^[ \t\r\n]+/ : /[ \t\r\n]+$/, '') - if (text) { - inline[index] = { ...child, text } - break - } - if (leading) start++ - else end-- - } - } - if (start <= end) blocks.push({ ...node, content: inline.slice(start, end + 1) }) - inline = [] + if ( + node.content.some( + (child) => + child.type !== 'image' && + (child.type !== 'text' || child.text?.trim() || child.marks?.length) + ) + ) { + return [ + { + ...node, + content: node.content.map((child) => + child.type === 'image' ? { ...child, type: 'inlineImage' } : child + ), + }, + ] } - for (const child of node.content) { - if (child.type === 'image') { - flush() - blocks.push(child) - } else { - inline.push(child) - } - } - flush() - return blocks + return node.content.filter((child) => child.type === 'image') } diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/chrome-scope.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/chrome-scope.test.ts index 6548abe1cbc..f91e94ec63c 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/chrome-scope.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/chrome-scope.test.ts @@ -13,7 +13,7 @@ */ import { readFileSync } from 'node:fs' import path from 'node:path' -import { beforeAll, describe, expect, it } from 'vitest' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' const EDITOR_CSS_PATH = path.join(__dirname, 'rich-markdown-editor.css') @@ -33,9 +33,10 @@ const CHROME_MARKERS = [ ] as const let selectors: string[] = [] +let style: HTMLStyleElement beforeAll(() => { - const style = document.createElement('style') + style = document.createElement('style') style.textContent = readFileSync(EDITOR_CSS_PATH, 'utf-8') document.head.appendChild(style) if (!style.sheet) throw new Error('rich-markdown-editor.css did not parse') @@ -44,6 +45,8 @@ beforeAll(() => { .map((rule) => rule.selectorText) }) +afterAll(() => style.remove()) + describe('rich markdown chrome scoping', () => { it.each(CHROME_MARKERS)('scopes every %s rule to the shared node class', (marker) => { const matching = selectors.filter((selector) => selector.includes(marker)) @@ -53,4 +56,30 @@ describe('rich markdown chrome scoping', () => { expect(selector).not.toContain('.rich-markdown-prose') } }) + + it.each(['div', 'span'].flatMap((tag) => [false, true].map((linked) => ({ tag, linked }))))( + 'keeps the $tag image selection ring inside the image (linked: $linked)', + ({ tag, linked }) => { + const root = document.createElement('div') + root.className = 'rich-markdown-nodes' + const wrapper = document.createElement(tag) + wrapper.className = 'ProseMirror-selectednode' + const image = document.createElement('img') + if (linked) { + const link = document.createElement('a') + link.append(image) + wrapper.append(link) + } else wrapper.append(image) + root.append(wrapper) + document.body.append(root) + try { + expect(getComputedStyle(image).outlineOffset).toBe('-2px') + expect(getComputedStyle(wrapper).outline).toBe('none') + wrapper.classList.remove('ProseMirror-selectednode') + expect(getComputedStyle(image).outlineOffset).toBe('') + } finally { + root.remove() + } + } + ) }) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-collaboration.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-collaboration.ts new file mode 100644 index 00000000000..f2641f17445 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-collaboration.ts @@ -0,0 +1,24 @@ +import Collaboration from '@tiptap/extension-collaboration' +import { NodeSelection, Plugin, Selection } from '@tiptap/pm/state' + +/** + * Yjs can resolve a restored node selection into text after a structural edit. + * Normalize that invalid selection before ProseMirror renders or scrolls it. + * @see https://github.com/ueberdosis/y-tiptap/blob/main/src/plugins/sync-plugin.js + */ +export const FileCollaboration = Collaboration.extend({ + addProseMirrorPlugins() { + return [ + ...(this.parent?.() ?? []), + new Plugin({ + appendTransaction: (_transactions, _oldState, state) => { + const { selection } = state + if (selection instanceof NodeSelection && !NodeSelection.isSelectable(selection.node)) { + return state.tr.setSelection(Selection.near(selection.$from)) + } + return null + }, + }), + ] + }, +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-provider.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-provider.test.ts index 1bc27bcf55c..6a3be13551a 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-provider.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-provider.test.ts @@ -92,6 +92,7 @@ function acceptJoin( acknowledgedUpdates = true ) { fire(FILE_DOC_EVENTS.JOIN_SUCCESS, { + schemaVersion: FILE_DOC_SCHEMA_VERSION, fileId: 'file-1', clientId, docId, @@ -148,6 +149,22 @@ describe('FileDocProvider', () => { expect(messages[0][0]).toBe(FILE_DOC_MESSAGE_TYPE.SYNC) }) + it.each([undefined, 1, FILE_DOC_SCHEMA_VERSION + 1])( + 'rejects incompatible server schema %s before exchanging document state', + (schemaVersion) => { + const { provider, doc, emit, fire } = createProvider(true) + emit.mockClear() + fire(FILE_DOC_EVENTS.JOIN_SUCCESS, { + fileId: 'file-1', + clientId: doc.clientID, + schemaVersion, + }) + expect(provider.joinError).toMatchObject({ code: 'SCHEMA_VERSION_MISMATCH' }) + expect(provider.synced).toBe(false) + expect(emittedMessages(emit)).toHaveLength(0) + } + ) + it('ignores inbound sync until the current join is accepted', () => { const { provider, doc, fire } = createProvider(true) const serverDoc = new Y.Doc() @@ -1472,6 +1489,7 @@ describe('FileDocProvider', () => { ) expect(firstProvider.joinError).toMatchObject({ code: 'DOCUMENT_REPLACED' }) fire(FILE_DOC_EVENTS.JOIN_SUCCESS, { + schemaVersion: FILE_DOC_SCHEMA_VERSION, fileId: 'file-2', clientId: secondDoc.clientID, acknowledgedUpdates: true, @@ -1991,10 +2009,12 @@ describe('FileDocProvider', () => { new awarenessProtocol.Awareness(docB) ) fire(FILE_DOC_EVENTS.JOIN_SUCCESS, { + schemaVersion: FILE_DOC_SCHEMA_VERSION, fileId: 'shared-file', clientId: docA.clientID, }) fire(FILE_DOC_EVENTS.JOIN_SUCCESS, { + schemaVersion: FILE_DOC_SCHEMA_VERSION, fileId: 'shared-file', clientId: docB.clientID, }) @@ -2151,6 +2171,7 @@ describe('FileDocProvider', () => { (invalidatedDocId) => { const { provider, doc, fire } = createProvider(true) fire(FILE_DOC_EVENTS.JOIN_SUCCESS, { + schemaVersion: FILE_DOC_SCHEMA_VERSION, fileId: 'file-1', clientId: doc.clientID, docId: 'new-document', @@ -2170,6 +2191,7 @@ describe('FileDocProvider', () => { it('keeps matching-generation invalidation terminal even when its version equals JOIN', () => { const { provider, doc, fire } = createProvider(true) fire(FILE_DOC_EVENTS.JOIN_SUCCESS, { + schemaVersion: FILE_DOC_SCHEMA_VERSION, fileId: 'file-1', clientId: doc.clientID, docId: 'current-document', @@ -2188,6 +2210,7 @@ describe('FileDocProvider', () => { it('does not let a newer tombstone notification spare an older joined document', () => { const { provider, doc, fire } = createProvider(true) fire(FILE_DOC_EVENTS.JOIN_SUCCESS, { + schemaVersion: FILE_DOC_SCHEMA_VERSION, fileId: 'file-1', clientId: doc.clientID, docId: 'current-document', diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/editor-extensions.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/editor-extensions.ts index 102e6cb9551..b85ae7dc191 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/editor-extensions.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/editor-extensions.ts @@ -1,5 +1,4 @@ import type { Extensions } from '@tiptap/core' -import Collaboration from '@tiptap/extension-collaboration' import CollaborationCaret from '@tiptap/extension-collaboration-caret' import Placeholder from '@tiptap/extension-placeholder' import type { Awareness } from 'y-protocols/awareness' @@ -13,10 +12,14 @@ import { DEFAULT_CARET_COLOR, renderCaret, } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/caret-presence' +import { FileCollaboration } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-collaboration' import { LinkEmbed } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/embed/link-embed' import { createMarkdownContentExtensions } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions' import { RichMarkdownFind } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/find' -import { ResizableImage } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image' +import { + ResizableImage, + ResizableInlineImage, +} from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image' import { ImageUploadPlaceholders } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-upload' import { RichMarkdownKeymap } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap' import { MarkdownPaste } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-paste' @@ -70,6 +73,7 @@ export function createMarkdownEditorExtensions({ { codeBlock: CodeBlockWithLanguage, image: ResizableImage, + inlineImage: ResizableInlineImage, mention: MentionChip, rawHtmlBlock: RawHtmlBlockWithView, footnoteDef: FootnoteDefWithView, @@ -78,7 +82,7 @@ export function createMarkdownEditorExtensions({ ), ...(collaboration ? [ - Collaboration.configure({ document: collaboration.doc }), + FileCollaboration.configure({ document: collaboration.doc }), // CollaborationCaret reads only `provider.awareness` (created synchronously, // relayed by the socket provider once connected). `render` tags each caret // with the peer's client id and shows its name label; the selection tint is diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/editor-lifecycle.test.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/editor-lifecycle.test.tsx index 2827db496ce..028f539e057 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/editor-lifecycle.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/editor-lifecycle.test.tsx @@ -79,10 +79,6 @@ vi.mock( '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/table-menu', () => ({ TableBubbleMenu: () => null }) ) -vi.mock( - '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/image-menu', - () => ({ ImageBubbleMenu: () => null }) -) vi.mock( '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/link-hover-card', () => ({ LinkHoverCard: () => null }) @@ -336,51 +332,59 @@ describe('loaded rich editor lifecycle', () => { expect(downloadSourceRef.current?.getContent()).toBe('smaller accepted source') }) - it('exports the displayed streaming frame and holds both body and metadata during a rewrite', async () => { - const frames = new Map() - let nextFrameId = 0 - vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => { - const id = ++nextFrameId - frames.set(id, callback) - return id - }) - vi.spyOn(window, 'cancelAnimationFrame').mockImplementation((id) => frames.delete(id)) - const tick = async () => { - const pending = [...frames.values()] - frames.clear() - await act(async () => { - for (const callback of pending) callback(0) + it.each(['plain text', 'heading image'] as const)( + 'exports streamed %s and holds body and metadata during a rewrite', + async (contentType) => { + const frames = new Map() + let nextFrameId = 0 + vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => { + const id = ++nextFrameId + frames.set(id, callback) + return id + }) + vi.spyOn(window, 'cancelAnimationFrame').mockImplementation((id) => frames.delete(id)) + const tick = async () => { + const pending = [...frames.values()] + frames.clear() + await act(async () => { + for (const callback of pending) callback(0) + }) + } + const downloadSourceRef = { current: null as FileDownloadSource | null } + const prefix = contentType === 'heading image' ? '# ' : '' + const suffix = contentType === 'heading image' ? ' ![Logo](/logo.png)' : '' + const initial = `---\ntitle: Original\n---\n\n${prefix}Original body${suffix}` + await render(initial, initial, true, { downloadSourceRef }) + const replacement = `---\ntitle: Replacement\n---\n\n${prefix}Replacement body${suffix}` + await render(replacement, initial, true, { downloadSourceRef, isStreaming: true }) + await tick() + expect(getEditor().getText().trim()).toBe('Original body') + expect(downloadSourceRef.current?.getContent()).toBe(initial) + await render(replacement, replacement, true, { downloadSourceRef }) + expect(getEditor().getText().trim()).toBe('Replacement body') + if (contentType === 'heading image') { + expect(getEditor().view.dom.querySelector('h1 img')?.getAttribute('src')).toBe('/logo.png') + } + expect(downloadSourceRef.current?.getContent()).toBe(replacement) + const appended = `${replacement} and more` + await render(appended, replacement, true, { + downloadSourceRef, + isStreaming: true, + streamIsIncremental: true, }) + expect(downloadSourceRef.current?.getContent()).toBe(replacement) + await tick() + expect(downloadSourceRef.current?.getContent()).toBe(appended) + + await act(async () => root.render(null)) + await render(initial, initial, true, { downloadSourceRef, isStreaming: true }) + expect(downloadSourceRef.current?.getContent()).toBeNull() + await render(replacement, initial, true, { downloadSourceRef, isStreaming: true }) + await tick() + expect(getEditor().getText().trim()).toBe('Replacement body') + expect(downloadSourceRef.current?.getContent()).toBe(replacement) } - const downloadSourceRef = { current: null as FileDownloadSource | null } - const initial = '---\ntitle: Original\n---\n\nOriginal body' - await render(initial, initial, true, { downloadSourceRef }) - const replacement = '---\ntitle: Replacement\n---\n\nReplacement body' - await render(replacement, initial, true, { downloadSourceRef, isStreaming: true }) - await tick() - expect(getEditor().getText()).toBe('Original body') - expect(downloadSourceRef.current?.getContent()).toBe(initial) - await render(replacement, replacement, true, { downloadSourceRef }) - expect(getEditor().getText()).toBe('Replacement body') - expect(downloadSourceRef.current?.getContent()).toBe(replacement) - const appended = `${replacement} and more` - await render(appended, replacement, true, { - downloadSourceRef, - isStreaming: true, - streamIsIncremental: true, - }) - expect(downloadSourceRef.current?.getContent()).toBe(replacement) - await tick() - expect(downloadSourceRef.current?.getContent()).toBe(appended) - - await act(async () => root.render(null)) - await render(initial, initial, true, { downloadSourceRef, isStreaming: true }) - expect(downloadSourceRef.current?.getContent()).toBeNull() - await render(replacement, initial, true, { downloadSourceRef, isStreaming: true }) - await tick() - expect(getEditor().getText()).toBe('Replacement body') - expect(downloadSourceRef.current?.getContent()).toBe(replacement) - }) + ) it.each(['connecting', 'timeout', 'fatal'] as const)( 'copies selection context from the visible %s preview and switches to the live editor on sync', @@ -815,12 +819,47 @@ describe('loaded rich editor lifecycle', () => { expect(getEditor().isEditable).toBe(false) }) + it.each(['', '## '])( + 'selects a %s image without an image menu or content changes', + async (prefix) => { + const content = `${prefix}[![Logo](/image.png)](https://example.com)\n\nBody` + await render(content) + const editor = getEditor() + const before = editor.getJSON() + let imagePosition = -1 + editor.state.doc.descendants((node, pos) => { + if (node.type.name === 'image' || node.type.name === 'inlineImage') imagePosition = pos + }) + expect(imagePosition).toBeGreaterThan(-1) + await act(async () => editor.commands.setNodeSelection(imagePosition)) + expect(container.querySelector('[aria-label="Image editing"]')).toBeNull() + expect(editor.getJSON()).toEqual(before) + expect(editor.state.doc.nodeAt(imagePosition)?.attrs).toMatchObject({ + alt: 'Logo', + href: 'https://example.com', + }) + } + ) + + it('edits heading images without source fallback', async () => { + const content = '# Before ![Image](/image.png) after' + const downloadSourceRef = { current: null as FileDownloadSource | null } + await render(content, content, true, { downloadSourceRef }) + expect(getEditor().isEditable).toBe(true) + expect(container.textContent).not.toContain('Edit source') + expect(getEditor().view.dom.querySelector('h1 img')?.getAttribute('src')).toBe('/image.png') + await act(async () => getEditor().commands.insertContentAt(1, 'Edited ')) + expect(downloadSourceRef.current?.getContent()).toBe( + '# Edited Before ![Image](/image.png) after\n' + ) + expect(onChange).toHaveBeenCalled() + }) + it.each([ '[unused]: https://example.com', '1. [![foo][image]](/dest)\n\n[image]: /url', '- [ ] [foo][link]\n\n[link]: /dest', '| header |\n| --- |\n| |', - '# Before ![Image](/image.png) after', '| Header |\n| --- |\n| Before ![Image](/image.png) after |', '| Before ![Image](/image.png) after |\n| --- |\n| Cell |', ])('offers source editing without mutating unsupported content: %s', async (content) => { diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions.ts index 251c97c5cb0..e3efea0d9c3 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions.ts @@ -1,4 +1,11 @@ -import { Extension, type Extensions, type JSONContent, type Node } from '@tiptap/core' +import { + Extension, + type Extensions, + type JSONContent, + type MarkdownParseHelpers, + type MarkdownToken, + type Node, +} from '@tiptap/core' import { Code } from '@tiptap/extension-code' import { Document } from '@tiptap/extension-document' import { HardBreak } from '@tiptap/extension-hard-break' @@ -11,7 +18,10 @@ import { splitBlockImageParagraph } from '@/app/workspace/[workspaceId]/files/co import { JoiningBulletList } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/bullet-list' import { MarkdownCodeBlock } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/code-block-schema' import { Highlight } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/highlight' -import { MarkdownImage } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-schema' +import { + MarkdownImage, + MarkdownInlineImage, +} from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-schema' import { MarkdownLinkInputRule } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/link-input-rule' import { joinListInputRules } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/list-input-rules' import { MarkdownListItem } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/list-item' @@ -49,12 +59,26 @@ const MarkdownHardBreak = HardBreak.extend({ renderMarkdown: () => '
' }) const TABLE_BLOCK_PREFIX_NODES = new Set(['heading', 'blockquote', 'horizontalRule']) +function parseHeadingMarkdown(token: MarkdownToken, helpers: MarkdownParseHelpers) { + return helpers.createNode( + 'heading', + { level: token.depth || 1 }, + helpers + .parseInline(token.tokens || []) + .map((child) => (child.type === 'image' ? { ...child, type: 'inlineImage' } : child)) + ) +} + /** Input rules must respect the same table capabilities as toolbar and keyboard commands. */ const TableAwareStarterKit = StarterKit.extend({ addExtensions() { - return (this.parent?.() ?? []).map((extension) => - extension.type === 'node' && TABLE_BLOCK_PREFIX_NODES.has(extension.name) - ? extension.extend({ + return (this.parent?.() ?? []).map((extension) => { + const markdownExtension = + extension.name === 'heading' + ? extension.extend({ parseMarkdown: parseHeadingMarkdown }) + : extension + return extension.type === 'node' && TABLE_BLOCK_PREFIX_NODES.has(extension.name) + ? markdownExtension.extend({ addCommands() { const parent = this.parent?.() if (this.name !== 'horizontalRule') return parent ?? {} @@ -69,8 +93,8 @@ const TableAwareStarterKit = StarterKit.extend({ return excludeTableBlockInputRules(this.parent?.() ?? []) }, }) - : extension - ) + : markdownExtension + }) }, }) @@ -176,6 +200,7 @@ const BlockSafeParagraph = Paragraph.extend({ export interface ContentNodeViews { codeBlock?: Node image?: Node + inlineImage?: Node mention?: Node rawHtmlBlock?: Node footnoteDef?: Node @@ -233,6 +258,7 @@ export function createMarkdownContentExtensions( Highlight, codeBlock, (nodeViews.image ?? MarkdownImage).configure({ allowBase64: true }), + (nodeViews.inlineImage ?? MarkdownInlineImage).configure({ allowBase64: true }), nodeViews.mention ?? MarkdownMention, TaskList, TaskItem.extend({ diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/field-lifecycle.test.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/field-lifecycle.test.tsx index 066c036cc5f..4c06ca056a1 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/field-lifecycle.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/field-lifecycle.test.tsx @@ -13,10 +13,6 @@ vi.mock( '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/bubble-menu', () => ({ EditorBubbleMenu: () => null }) ) -vi.mock( - '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/image-menu', - () => ({ ImageBubbleMenu: () => null }) -) vi.mock( '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/link-hover-card', () => ({ LinkHoverCard: () => null }) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/field-upload.test.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/field-upload.test.tsx index 80439c8b042..25650382ef7 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/field-upload.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/field-upload.test.tsx @@ -17,10 +17,6 @@ vi.mock( '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/link-hover-card', () => ({ LinkHoverCard: () => null }) ) -vi.mock( - '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/image-menu', - () => ({ ImageBubbleMenu: () => null }) -) let host: HTMLDivElement let root: Root diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/heading-image.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/heading-image.test.ts new file mode 100644 index 00000000000..d3f1f15fd38 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/heading-image.test.ts @@ -0,0 +1,376 @@ +/** @vitest-environment jsdom */ +import { FILE_DOC_SEED } from '@sim/realtime-protocol/file-doc' +import { Editor, getSchema } from '@tiptap/core' +import { DOMParser, DOMSerializer } from '@tiptap/pm/model' +import { NodeSelection } from '@tiptap/pm/state' +import { afterEach, describe, expect, it, vi } from 'vitest' +import * as Y from 'yjs' +import { markdownToYDoc, yDocToFileMarkdown, yDocToMarkdown } from '@/lib/collab-doc/converter' +import { + applyAgentStreamFrame, + beginAgentStream, + endAgentStream, +} from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/apply-streamed-markdown' +import { FileCollaboration } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-collaboration' +import { createMarkdownContentExtensions } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions' +import { moveDraggedImageNode } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-drag-move' +import { isImageNode } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-node' +import { + beginImageUploads, + finishImageUpload, + ImageUploadPlaceholders, +} from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-upload' +import { splitFrontmatter } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-fidelity' +import { + editorNormalForm, + parseMarkdownToDoc, + serializeMarkdownBody, +} from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-parse' +import { isRoundTripSafe } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/round-trip-safety' + +const IMAGE = '![Logo](/logo.png "Title")' +const CASES = [ + ...[1, 2, 3, 4, 5, 6].map((level) => `${'#'.repeat(level)} Before ${IMAGE} after`), + `# ${IMAGE}`, + `# ${IMAGE} ${IMAGE}`, + `# **Before** ${IMAGE} *after*`, + '# Before Logo after', + `# [${IMAGE}](/destination "Link title")`, + '# Before [Logo](/destination) after', + '# Before ![Logo][logo] after\n\n[logo]: /logo.png "Title"', + `Before ${IMAGE} after\n===`, + `> # Before ${IMAGE} after`, + `- # Before ${IMAGE} after`, +] as const +const schema = getSchema(createMarkdownContentExtensions()) +const cleanups: Array<() => void> = [] +afterEach(() => cleanups.splice(0).forEach((cleanup) => cleanup())) + +function peer(seed: Y.Doc) { + const doc = new Y.Doc() + Y.applyUpdate(doc, Y.encodeStateAsUpdate(seed)) + const editor = new Editor({ + extensions: [ + ...createMarkdownContentExtensions({}, { disableHistory: true }), + ImageUploadPlaceholders, + FileCollaboration.configure({ document: doc }), + ], + editorProps: { handleScrollToSelection: () => true }, + }) + cleanups.push(() => { + editor.destroy() + doc.destroy() + }) + return { doc, editor } +} + +function imagePositions(editor: Editor) { + const positions: number[] = [] + editor.state.doc.descendants((node, position) => { + if (isImageNode(node)) positions.push(position) + }) + return positions +} + +describe('heading images', () => { + it.each(CASES)( + 'preserves heading structure through Markdown and collaboration: %s', + (markdown) => { + const parsed = schema.nodeFromJSON(parseMarkdownToDoc(markdown)) + expect(() => parsed.check()).not.toThrow() + let images = 0 + parsed.descendants((node, _pos, parent) => { + if (isImageNode(node)) { + images++ + expect(node.type.name).toBe('inlineImage') + expect(parent?.type.name).toBe('heading') + expect(node.attrs.src).toBe('/logo.png') + } + }) + expect(images).toBeGreaterThan(0) + expect(isRoundTripSafe(markdown)).toBe(true) + const serialized = serializeMarkdownBody(markdown) + expect(serializeMarkdownBody(serialized)).toBe(serialized) + const seed = markdownToYDoc(markdown) + const a = peer(seed) + seed.destroy() + expect(a.editor.getJSON()).toEqual(schema.nodeFromJSON(editorNormalForm(markdown)).toJSON()) + expect(imagePositions(a.editor)).toHaveLength(images) + expect(yDocToMarkdown(a.doc)).toBe(serialized) + } + ) + + it('retains existing block images and their attributes', () => { + const doc = schema.nodeFromJSON(parseMarkdownToDoc(`# Heading ${IMAGE}\n\n${IMAGE}`)) + expect(doc.child(0).lastChild?.type.name).toBe('inlineImage') + expect(doc.child(1).type.name).toBe('image') + expect(doc.child(0).lastChild?.attrs).toEqual(doc.child(1).attrs) + }) + + it('keeps a mixed document editable without changing frontmatter, links, or image code examples', () => { + const markdown = [ + '---\ntitle: Image support\n---', + '# Heading ![Heading image](/logo.png)', + '![Block image](/logo.png)', + '[Ordinary link](/logo.png)', + '`![Code example](/logo.png)`', + '![Reference image][shared]', + '[Ordinary reference][shared]', + '[shared]: /logo.png', + ].join('\n\n') + expect(isRoundTripSafe(markdown)).toBe(true) + const { frontmatter, body } = splitFrontmatter(markdown) + const seed = markdownToYDoc(body) + seed.getMap(FILE_DOC_SEED.configMap).set(FILE_DOC_SEED.frontmatterKey, frontmatter) + const { doc, editor } = peer(seed) + seed.destroy() + expect(imagePositions(editor)).toHaveLength(3) + editor.commands.insertContentAt(1, 'Edited ') + const saved = yDocToFileMarkdown(doc) + expect(saved).toContain('---\ntitle: Image support\n---') + expect(saved).toContain('# Edited Heading ![Heading image](/logo.png)') + expect(saved).toContain('[Ordinary link](/logo.png)') + expect(saved).toContain('[Ordinary reference](/logo.png)') + expect(saved).toContain('`![Code example](/logo.png)`') + expect(isRoundTripSafe(saved)).toBe(true) + const reopened = peer(doc) + expect(reopened.editor.getJSON()).toEqual(editor.getJSON()) + }) + + it('preserves copied heading HTML without introducing block elements inside it', () => { + const container = document.createElement('div') + container.innerHTML = '

Before Logo after

' + const doc = DOMParser.fromSchema(schema).parse(container) + expect(() => doc.check()).not.toThrow() + expect(doc.firstChild?.child(1).type.name).toBe('inlineImage') + expect(doc.firstChild?.child(1).attrs.width).toBe('320') + }) + + it.each([ + ['

Before after

', 'heading', 'inlineImage'], + ['

Before after

', 'paragraph', 'inlineImage'], + ['

', 'paragraph', 'image'], + ])('keeps pasted image layout for %s', (html, parentType, imageType) => { + const container = document.createElement('div') + container.innerHTML = html + const parsed = DOMParser.fromSchema(schema).parse(container) + expect(() => parsed.check()).not.toThrow() + expect(parsed.firstChild?.type.name).toBe(parentType) + const images: string[] = [] + parsed.descendants((node) => { + if (isImageNode(node)) images.push(node.type.name) + }) + expect(images).toEqual([imageType]) + const editor = new Editor({ + extensions: createMarkdownContentExtensions(), + content: parsed.toJSON(), + editorProps: { handleScrollToSelection: () => true }, + }) + cleanups.push(() => editor.destroy()) + editor.commands.setNodeSelection(imagePositions(editor)[0]) + editor.commands.updateAttributes(imageType, { width: '360' }) + const saved = editor.getMarkdown() + expect(saved).toContain('](/target)') + const reopened = schema.nodeFromJSON(editorNormalForm(saved)) + reopened.descendants((node) => { + if (isImageNode(node)) expect(node.attrs).toMatchObject({ href: '/target', width: '360' }) + }) + }) + + it.each(['h2', 'p'])('preserves pasted linked images in %s across peers and reload', (tag) => { + const seed = markdownToYDoc('Start') + const a = peer(seed) + const b = peer(seed) + seed.destroy() + a.editor.commands.selectAll() + a.editor.view.pasteHTML( + `<${tag}>Before Logo after`, + new Event('paste') as ClipboardEvent + ) + const position = imagePositions(a.editor)[0] + const pastedImage = a.editor.state.doc.nodeAt(position) + expect(pastedImage?.attrs).toMatchObject({ href: '/target', hrefTitle: 'Destination' }) + expect(pastedImage?.marks.some((mark) => mark.type.name === 'link')).toBe(false) + expect(a.editor.state.doc.firstChild?.firstChild?.marks[0]?.attrs.href).toBe('/target') + a.editor.commands.setNodeSelection(position) + a.editor.commands.updateAttributes('inlineImage', { width: '287' }) + Y.applyUpdate(b.doc, Y.encodeStateAsUpdate(a.doc)) + expect(b.editor.getJSON()).toEqual(a.editor.getJSON()) + const saved = yDocToFileMarkdown(b.doc) + expect(saved).toContain('](/target "Destination")') + expect(isRoundTripSafe(saved)).toBe(true) + const reopened = peer(b.doc) + expect(reopened.editor.getJSON()).toEqual(b.editor.getJSON()) + expect( + reopened.editor.state.doc.nodeAt(imagePositions(reopened.editor)[0])?.attrs + ).toMatchObject({ + href: '/target', + hrefTitle: 'Destination', + width: '287', + }) + }) + + it.each(['', '# Before ', 'Before '])( + 'keeps linked image metadata through the HTML clipboard: %s', + (prefix) => { + const seed = markdownToYDoc( + `${prefix}[Logo](/target "Destination")` + ) + const a = peer(seed) + seed.destroy() + a.editor.commands.setNodeSelection(imagePositions(a.editor)[0]) + const clipboard = a.editor.view.serializeForClipboard(a.editor.state.selection.content()) + expect(clipboard.dom.querySelector('a')?.getAttribute('href')).toBe('/target') + expect(clipboard.dom.querySelector('a')?.getAttribute('title')).toBe('Destination') + const targetSeed = markdownToYDoc(prefix ? '# Target' : 'Target') + const target = peer(targetSeed) + targetSeed.destroy() + target.editor.commands.setTextSelection(1) + target.editor.view.pasteHTML(clipboard.dom.innerHTML, new Event('paste') as ClipboardEvent) + expect(target.editor.state.doc.nodeAt(imagePositions(target.editor)[0])?.attrs).toMatchObject( + { + href: '/target', + hrefTitle: 'Destination', + width: '287', + alt: 'Logo', + } + ) + const reopened = peer(target.doc) + expect(reopened.editor.getJSON()).toEqual(target.editor.getJSON()) + } + ) + + it.each(['image', 'inlineImage'])('omits unsafe %s links from copied HTML', (type) => { + const node = schema.nodes[type].create({ src: '/logo.png', href: 'javascript:alert(1)' }) + const container = document.createElement('div') + container.append(DOMSerializer.fromSchema(schema).serializeNode(node)) + expect(container.querySelector('a')).toBeNull() + expect(container.querySelector('img')?.getAttribute('src')).toBe('/logo.png') + }) + + it('does not nest a stored image link inside an existing link mark', () => { + const node = schema.nodes.inlineImage.create({ src: '/logo.png', href: '/stored' }, null, [ + schema.marks.link.create({ href: '/current' }), + ]) + const container = document.createElement('div') + container.append(DOMSerializer.fromSchema(schema).serializeNode(node)) + expect(container.querySelectorAll('a')).toHaveLength(1) + expect(container.querySelector('a')?.getAttribute('href')).toBe('/current') + }) + + it('streams partial heading images to a peer without duplicating content or recording user undo', () => { + const seed = markdownToYDoc('# Start') + const a = peer(seed) + const b = peer(seed) + seed.destroy() + const session = beginAgentStream(a.editor) + expect(session).not.toBeNull() + try { + for (const frame of [ + '# Before ![Logo](', + '# Before ![Logo](/logo.png)', + `# Before ${IMAGE} after`, + ]) { + expect(applyAgentStreamFrame(a.editor, session!, frame)).toBe(true) + Y.applyUpdate(b.doc, Y.encodeStateAsUpdate(a.doc)) + expect(() => b.editor.state.doc.check()).not.toThrow() + expect(b.editor.getJSON()).toEqual(a.editor.getJSON()) + } + expect(imagePositions(b.editor)).toHaveLength(1) + expect(yDocToFileMarkdown(b.doc)).toBe(`# Before ${IMAGE} after\n`) + expect(a.editor.can().undo()).toBe(false) + } finally { + endAgentStream(session!) + } + }) + + it('retains heading images when converting to a paragraph and back', () => { + const seed = markdownToYDoc(`# Before ${IMAGE} after`) + const { editor } = peer(seed) + seed.destroy() + editor.commands.setTextSelection(1) + expect(editor.commands.setParagraph()).toBe(true) + const paragraph = editor.getMarkdown() + expect(isRoundTripSafe(paragraph)).toBe(true) + const reopened = schema.nodeFromJSON(editorNormalForm(paragraph)) + expect(reopened.firstChild?.type.name).toBe('paragraph') + expect(reopened.firstChild?.child(1).attrs.src).toBe('/logo.png') + expect(editor.commands.setHeading({ level: 2 })).toBe(true) + expect(imagePositions(editor)).toHaveLength(1) + }) + + it('merges concurrent text and resize edits, including undo and fresh hydration', () => { + const seed = markdownToYDoc(`# Before ${IMAGE} after`) + const a = peer(seed) + const b = peer(seed) + seed.destroy() + a.editor.commands.setNodeSelection(imagePositions(a.editor)[0]) + a.editor.commands.updateAttributes('inlineImage', { width: '320', height: null }) + b.editor.commands.insertContentAt(1, 'Peer ') + Y.applyUpdate(a.doc, Y.encodeStateAsUpdate(b.doc)) + Y.applyUpdate(b.doc, Y.encodeStateAsUpdate(a.doc)) + expect(a.editor.getJSON()).toEqual(b.editor.getJSON()) + expect(a.editor.state.doc.textContent).toContain('Peer Before') + expect(yDocToMarkdown(a.doc)).toContain('width="320"') + expect(isRoundTripSafe(yDocToMarkdown(a.doc))).toBe(true) + a.editor.commands.undo() + expect(a.editor.state.doc.textContent).toContain('Peer Before') + expect(a.editor.state.doc.nodeAt(imagePositions(a.editor)[0])?.attrs.width).toBeNull() + a.editor.commands.redo() + const reopened = peer(a.doc) + expect(reopened.editor.getJSON()).toEqual(a.editor.getJSON()) + expect(imagePositions(reopened.editor)).toHaveLength(1) + }) + + it.each(['# ', ''])( + 'preserves a block-to-inline move, peer text, and undo through collaboration: %s', + (prefix) => { + const seed = markdownToYDoc( + `${prefix}Before after\n\n[Logo](/target "Destination")\n\nPeer text` + ) + const a = peer(seed) + const b = peer(seed) + seed.destroy() + const originalAttrs = a.editor.state.doc.nodeAt(imagePositions(a.editor)[0])?.attrs + a.editor.commands.setNodeSelection(imagePositions(a.editor)[0]) + vi.spyOn(a.editor.view, 'posAtCoords').mockReturnValue({ pos: 8, inside: 0 }) + expect( + moveDraggedImageNode(a.editor.view, new MouseEvent('drop') as DragEvent, { + images: [], + html: '', + }) + ).toBe(true) + expect(a.editor.state.doc.nodeAt(8)?.type.name).toBe('inlineImage') + expect(a.editor.state.doc.nodeAt(8)?.attrs).toEqual(originalAttrs) + b.editor.commands.insertContentAt(b.editor.state.doc.content.size - 1, ' preserved') + Y.applyUpdate(a.doc, Y.encodeStateAsUpdate(b.doc)) + Y.applyUpdate(b.doc, Y.encodeStateAsUpdate(a.doc)) + expect(a.editor.getJSON()).toEqual(b.editor.getJSON()) + expect(imagePositions(b.editor)).toHaveLength(1) + expect(a.editor.commands.undo()).toBe(true) + if (a.editor.state.selection instanceof NodeSelection) { + expect(NodeSelection.isSelectable(a.editor.state.selection.node)).toBe(true) + } + expect(a.editor.state.doc.textContent).toContain('Peer text preserved') + expect(a.editor.state.doc.nodeAt(imagePositions(a.editor)[0])?.type.name).toBe('image') + expect(a.editor.commands.redo()).toBe(true) + Y.applyUpdate(b.doc, Y.encodeStateAsUpdate(a.doc)) + expect(a.editor.getJSON()).toEqual(b.editor.getJSON()) + const reopened = peer(b.doc) + expect(reopened.editor.getJSON()).toEqual(a.editor.getJSON()) + expect(reopened.editor.state.doc.nodeAt(8)?.attrs).toEqual(originalAttrs) + expect(isRoundTripSafe(yDocToFileMarkdown(b.doc))).toBe(true) + } + ) + + it('inserts uploaded images into the heading without splitting its text', () => { + const seed = markdownToYDoc('# Before after') + const { editor } = peer(seed) + seed.destroy() + const [id] = beginImageUploads(editor, { from: 8, to: 8 }, ['Logo']) + expect(finishImageUpload(editor, id, '/logo.png', 'Logo')).toBe(true) + expect(editor.state.doc.firstChild?.type.name).toBe('heading') + expect(editor.state.doc.firstChild?.child(1).type.name).toBe('inlineImage') + expect(editor.state.doc.firstChild?.textContent).toBe('Before after') + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-collaboration.test.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-collaboration.test.tsx index 62a5d465328..e5af0b2b609 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-collaboration.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-collaboration.test.tsx @@ -1,16 +1,20 @@ /** @vitest-environment jsdom */ import { act } from 'react' -import { Tooltip } from '@sim/emcn' -import Collaboration from '@tiptap/extension-collaboration' +import { NodeSelection, TextSelection } from '@tiptap/pm/state' import { Editor, EditorContent } from '@tiptap/react' import StarterKit from '@tiptap/starter-kit' +import { yUndoPluginKey } from '@tiptap/y-tiptap' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import * as Y from 'yjs' import { BlockMover } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/block-mover' -import { ResizableImage } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image' +import { FileCollaboration } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-collaboration' +import { + ResizableImage, + ResizableInlineImage, +} from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image' import { moveDraggedImageNode } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-drag-move' -import { ImageBubbleMenu } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/image-menu' +import { isImageNode } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-node' let host: HTMLDivElement let root: Root @@ -30,7 +34,8 @@ beforeEach(async () => { StarterKit.configure({ undoRedo: false }), BlockMover, ResizableImage, - Collaboration.configure({ document }), + ResizableInlineImage, + FileCollaboration.configure({ document }), ], editorProps: { handleScrollToSelection: () => true }, }) @@ -45,12 +50,7 @@ beforeEach(async () => { root = createRoot(host) vi.spyOn(local.view, 'coordsAtPos').mockReturnValue({ top: 10, bottom: 30, left: 10, right: 50 }) await act(async () => { - root.render( - - - - - ) + root.render() }) await act(async () => local.commands.setNodeSelection(imagePosition(local))) }) @@ -72,7 +72,7 @@ afterEach(async () => { function imagePosition(editor: Editor, alt?: string): number { let position = -1 editor.state.doc.descendants((node, pos) => { - if (node.type.name === 'image' && (alt === undefined || node.attrs.alt === alt)) position = pos + if (isImageNode(node) && (alt === undefined || node.attrs.alt === alt)) position = pos }) return position } @@ -108,15 +108,6 @@ function beginResize(): void { expect(image.style.width).toBe('260px') } -function changeDraft(value: string): HTMLInputElement { - const input = host.querySelector('[aria-label="Image editing"] input')! - act(() => { - Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set?.call(input, value) - input.dispatchEvent(new Event('input', { bubbles: true })) - }) - return input -} - async function addPeerSibling(sameSource = true): Promise { const position = local.state.doc.firstChild!.nodeSize peer.commands.insertContentAt(position + 1, { @@ -164,276 +155,268 @@ async function setNestedImages(depth: number): Promise { await act(async () => local.commands.setNodeSelection(imagePosition(local, 'Original'))) } -describe('image interactions during real peer Yjs updates', () => { +describe('image resizing during real peer Yjs updates', () => { + it.each(['heading', 'paragraph'])( + 'renders and scrolls a valid selection when undoing a move into a %s', + async (target) => { + local.setOptions({ editorProps: { handleScrollToSelection: () => false } }) + yUndoPluginKey.getState(local.state).undoManager.clear() + const dropPosition = target === 'heading' ? 8 : imagePosition(local) + 5 + vi.spyOn(local.view, 'posAtCoords').mockReturnValue({ pos: dropPosition, inside: 0 }) + await act(async () => { + local.view.focus() + expect( + moveDraggedImageNode(local.view, new MouseEvent('drop') as DragEvent, { + images: [], + html: '', + }) + ).toBe(true) + }) + expect(local.state.selection).toBeInstanceOf(NodeSelection) + expect(host.querySelector(`${target === 'heading' ? 'h2' : 'p'} img`)).not.toBeNull() + peer.commands.insertContentAt(peer.state.doc.content.size - 1, ' preserved') + await receivePeerUpdate() + await act(async () => { + expect(local.commands.undo()).toBe(true) + }) + expect(local.state.doc.nodeAt(imagePosition(local))?.type.name).toBe('image') + if (local.state.selection instanceof NodeSelection) { + expect(NodeSelection.isSelectable(local.state.selection.node)).toBe(true) + } + expect(local.state.doc.textContent).toContain('After image preserved') + await act(async () => { + expect(local.commands.redo()).toBe(true) + Y.applyUpdate(peerDoc, Y.encodeStateAsUpdate(localDoc)) + }) + expect(local.getJSON()).toEqual(peer.getJSON()) + expect(host.querySelector(`${target === 'heading' ? 'h2' : 'p'} img`)).not.toBeNull() + } + ) + + it('normalizes an invalid node selection without changing the document or Yjs history', async () => { + const original = local.getJSON() + const onUpdate = vi.fn() + localDoc.on('update', onUpdate) + yUndoPluginKey.getState(local.state).undoManager.clear() + await act(async () => { + local.view.dispatch(local.state.tr.setSelection(NodeSelection.create(local.state.doc, 3))) + }) + expect(local.state.selection).toBeInstanceOf(TextSelection) + expect(local.state.selection.from).toBe(3) + expect(local.getJSON()).toEqual(original) + expect(onUpdate).not.toHaveBeenCalled() + expect(local.can().undo()).toBe(false) + await act(async () => local.commands.setNodeSelection(imagePosition(local))) + expect(local.state.selection).toBeInstanceOf(NodeSelection) + expect(local.state.selection.from).toBe(imagePosition(local)) + }) + + it.each(['image', 'inlineImage'])( + 'copies a linked %s with exactly one link wrapper', + async (type) => { + const image = { + type, + attrs: { + src: '/logo.png', + alt: 'Logo', + href: '/target', + hrefTitle: 'Destination', + width: '217', + }, + } + await act(async () => { + local.commands.setContent({ + type: 'doc', + content: type === 'inlineImage' ? [{ type: 'heading', content: [image] }] : [image], + }) + local.commands.setNodeSelection(imagePosition(local)) + }) + const clipboard = local.view.serializeForClipboard(local.state.selection.content()) + expect(clipboard.dom.querySelectorAll('a')).toHaveLength(1) + expect(clipboard.dom.querySelector('a')?.getAttribute('href')).toBe('/target') + expect(clipboard.dom.querySelector('a')?.getAttribute('title')).toBe('Destination') + expect(clipboard.dom.querySelector('img')?.getAttribute('width')).toBe('217') + } + ) + + it('resizes an inline image alongside peer heading text', async () => { + await act(async () => { + local.commands.setContent( + '

Before Original after

' + ) + Y.applyUpdate(peerDoc, Y.encodeStateAsUpdate(localDoc)) + local.commands.setNodeSelection(imagePosition(local)) + }) + expect(host.querySelector('h2 img')).not.toBeNull() + expect(host.querySelector('h2 div')).toBeNull() + beginResize() + peer.commands.insertContentAt(1, 'Peer ') + await receivePeerUpdate() + pointer(window, 'pointerup', 160) + expect(imageAttributes(local)).toMatchObject({ alt: 'Original', href: null, width: '260' }) + expect(local.state.doc.firstChild?.textContent).toBe('Peer Before after') + await act(async () => Y.applyUpdate(peerDoc, Y.encodeStateAsUpdate(localDoc))) + expect(peer.getJSON()).toEqual(local.getJSON()) + }) + + it('cancels an inline image resize when a peer deletes it', async () => { + await act(async () => { + local.commands.setContent( + '

Before Original after

' + ) + Y.applyUpdate(peerDoc, Y.encodeStateAsUpdate(localDoc)) + local.commands.setNodeSelection(imagePosition(local)) + }) + beginResize() + peer.commands.setNodeSelection(imagePosition(peer)) + peer.commands.deleteSelection() + await receivePeerUpdate() + pointer(window, 'pointerup', 160) + expect(host.querySelector('img')).toBeNull() + expect(local.getJSON()).toEqual(peer.getJSON()) + }) + it.each( - (['alt', 'href', 'resize'] as const).flatMap((interaction) => - [1, 2].flatMap((depth) => - [false, true].flatMap((queued) => - ['target', 'peer'].map((moved) => ({ interaction, depth, queued, moved })) - ) + [1, 2].flatMap((depth) => + [false, true].flatMap((queued) => + ['target', 'peer'].map((moved) => ({ depth, queued, moved })) ) ) )( - 'cancels $interaction after moving the $moved containing block at depth $depth (queued: $queued)', - async ({ interaction, depth, queued, moved }) => { + 'cancels resizing after moving the $moved containing block at depth $depth (queued: $queued)', + async ({ depth, queued, moved }) => { await setNestedImages(depth) - let input: HTMLInputElement | undefined - if (interaction === 'resize') beginResize() - else { - const label = interaction === 'alt' ? 'alt text' : 'link' - act(() => - host.querySelector(`[aria-label="Edit image ${label}"]`)!.click() - ) - input = changeDraft( - interaction === 'alt' ? 'Draft for original' : 'https://sim.ai/for-original' - ) - } + beginResize() peer.commands.setNodeSelection( imagePosition(peer, moved === 'target' ? 'Original' : 'Peer image') ) expect(moved === 'target' ? peer.commands.moveBlockDown() : peer.commands.moveBlockUp()).toBe( true ) - const finish = () => { - if (input) - input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })) - else pointer(window, 'pointerup', 160) - } if (queued) { await act(async () => { Y.applyUpdate(localDoc, Y.encodeStateAsUpdate(peerDoc)) - finish() + pointer(window, 'pointerup', 160) }) } else { await receivePeerUpdate() - await act(async () => finish()) + pointer(window, 'pointerup', 160) } - expect(host.querySelector('[aria-label="Image editing"] input')).toBeNull() expect(local.getJSON()).toEqual(peer.getJSON()) } ) - it.each(['alt', 'href', 'resize'] as const)( - 'preserves %s alongside selected metadata and peer text inside nested image containers', - async (interaction) => { - await setNestedImages(2) - let input: HTMLInputElement | undefined - if (interaction === 'resize') beginResize() - else { - const label = interaction === 'alt' ? 'alt text' : 'link' - act(() => - host.querySelector(`[aria-label="Edit image ${label}"]`)!.click() - ) - input = changeDraft( - interaction === 'alt' ? 'Local corrected alt' : 'https://sim.ai/local-link' - ) + it('preserves metadata and peer text while resizing inside nested image containers', async () => { + await setNestedImages(2) + beginResize() + peer.commands.setNodeSelection(imagePosition(peer, 'Original')) + peer.commands.updateAttributes('image', { + alt: 'Peer corrected alt', + href: 'https://sim.ai/peer-link', + }) + peer.commands.insertContentAt('Earlier heading'.length + 1, ' PEER') + peer.commands.insertContentAt(imagePosition(peer, 'Peer image') - 2, ' PEER') + await receivePeerUpdate() + pointer(window, 'pointerup', 160) + expect(local.state.doc.nodeAt(imagePosition(local, 'Peer corrected alt'))?.attrs).toMatchObject( + { + alt: 'Peer corrected alt', + href: 'https://sim.ai/peer-link', + width: '260', } - peer.commands.setNodeSelection(imagePosition(peer, 'Original')) - peer.commands.updateAttributes( - 'image', - interaction === 'alt' ? { href: 'https://sim.ai/peer-link' } : { alt: 'Peer corrected alt' } - ) - peer.commands.insertContentAt('Earlier heading'.length + 1, ' PEER') - peer.commands.insertContentAt(imagePosition(peer, 'Peer image') - 2, ' PEER') - await receivePeerUpdate() - if (input) { - expect(host.querySelector('[aria-label="Image editing"] input')).toBe(input) - await act(async () => - input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })) - ) - } else pointer(window, 'pointerup', 160) - const alt = interaction === 'alt' ? 'Local corrected alt' : 'Peer corrected alt' - expect(local.state.doc.nodeAt(imagePosition(local, alt))?.attrs).toMatchObject({ - alt, - href: - interaction === 'href' - ? 'https://sim.ai/local-link' - : interaction === 'alt' - ? 'https://sim.ai/peer-link' - : null, - width: interaction === 'resize' ? '260' : '200', - }) - await act(async () => Y.applyUpdate(peerDoc, Y.encodeStateAsUpdate(localDoc))) - expect(local.getJSON()).toEqual(peer.getJSON()) - } - ) + ) + await act(async () => Y.applyUpdate(peerDoc, Y.encodeStateAsUpdate(localDoc))) + expect(local.getJSON()).toEqual(peer.getJSON()) + }) it.each(['delete', 'replace'] as const)( - 'rejects a queued draft after the peer %ss its containing block', + 'rejects a queued resize after the peer %ss its containing block', async (action) => { await setNestedImages(2) - act(() => - host.querySelector('[aria-label="Edit image alt text"]')!.click() - ) - const input = changeDraft('Uncommitted draft') + beginResize() const from = peer.state.doc.firstChild!.nodeSize const parent = peer.state.doc.child(1) peer.commands.deleteRange({ from, to: from + parent.nodeSize }) if (action === 'replace') peer.commands.insertContentAt(from, parent.toJSON()) await act(async () => { Y.applyUpdate(localDoc, Y.encodeStateAsUpdate(peerDoc)) - input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })) + pointer(window, 'pointerup', 160) }) - expect(host.querySelector('[aria-label="Image editing"] input')).toBeNull() expect(local.getJSON()).toEqual(peer.getJSON()) } ) - it.each(['alt', 'href', 'resize'] as const)( - 'cancels %s conservatively when another container image changes', - async (interaction) => { - await setNestedImages(2) - let input: HTMLInputElement | undefined - if (interaction === 'resize') beginResize() - else { - const label = interaction === 'alt' ? 'alt text' : 'link' - act(() => - host.querySelector(`[aria-label="Edit image ${label}"]`)!.click() - ) - input = changeDraft('https://sim.ai/uncommitted') - } - peer.commands.setNodeSelection(imagePosition(peer, 'Peer image')) - peer.commands.updateAttributes('image', { alt: 'Peer corrected alt', width: '480' }) - await receivePeerUpdate() - if (input) { - expect(host.querySelector('[aria-label="Image editing"] input')).toBeNull() - await act(async () => - input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })) - ) - } else pointer(window, 'pointerup', 160) - expect(local.getJSON()).toEqual(peer.getJSON()) - } - ) + it('cancels resizing conservatively when another container image changes', async () => { + await setNestedImages(2) + beginResize() + peer.commands.setNodeSelection(imagePosition(peer, 'Peer image')) + peer.commands.updateAttributes('image', { alt: 'Peer corrected alt', width: '480' }) + await receivePeerUpdate() + pointer(window, 'pointerup', 160) + expect(local.getJSON()).toEqual(peer.getJSON()) + }) it.each( - (['alt', 'href', 'resize'] as const).flatMap((interaction) => - [false, true].flatMap((sameSource) => - ['target', 'sibling'].map((moved) => ({ interaction, sameSource, moved })) - ) + [false, true].flatMap((sameSource) => + ['target', 'sibling'].map((moved) => ({ sameSource, moved })) ) )( - 'cancels $interaction after a peer moves the $moved image (same source: $sameSource)', - async ({ interaction, sameSource, moved }) => { + 'cancels resizing after a peer moves the $moved image (same source: $sameSource)', + async ({ sameSource, moved }) => { const position = await addPeerSibling(sameSource) - let input: HTMLInputElement | undefined - if (interaction === 'resize') beginResize() - else { - const label = interaction === 'alt' ? 'alt text' : 'link' - act(() => - host.querySelector(`[aria-label="Edit image ${label}"]`)!.click() - ) - input = changeDraft( - interaction === 'alt' ? 'Draft for original' : 'https://sim.ai/for-original' - ) - } + beginResize() if (moved === 'target') movePeerImage(position, position + 2) else movePeerImage(position + 1, position) await receivePeerUpdate() - if (input) { - expect(host.querySelector('[aria-label="Image editing"] input')).toBeNull() - await act(async () => { - input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })) - }) - } else pointer(window, 'pointerup', 160) + pointer(window, 'pointerup', 160) expect(local.state.doc.nodeAt(position)?.attrs.alt).toBe('Peer image') expect(local.getJSON()).toEqual(peer.getJSON()) } ) - it.each(['alt text', 'link'])( - 'rejects queued %s Apply before React renders a same-source reorder', - async (field) => { - const position = await addPeerSibling() - act(() => - host.querySelector(`[aria-label="Edit image ${field}"]`)!.click() - ) - const input = changeDraft('https://sim.ai/stale-draft') - movePeerImage(position + 1, position) - await act(async () => { - Y.applyUpdate(localDoc, Y.encodeStateAsUpdate(peerDoc)) - input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })) - }) - expect(local.getJSON()).toEqual(peer.getJSON()) - expect(host.querySelector('[aria-label="Image editing"] input')).toBeNull() - } - ) - - it('does not revive a draft after images are reordered back', async () => { - const position = await addPeerSibling() - act(() => host.querySelector('[aria-label="Edit image alt text"]')!.click()) - changeDraft('Stale draft') - movePeerImage(position + 1, position) - await receivePeerUpdate() - expect(host.querySelector('[aria-label="Image editing"] input')).toBeNull() - movePeerImage(position + 1, position) - await receivePeerUpdate() - expect(host.querySelector('[aria-label="Image editing"] input')).toBeNull() - expect(local.getJSON()).toEqual(peer.getJSON()) - }) - - it('preserves target metadata edits and text edits around an unchanged same-source sibling', async () => { + it('preserves target metadata and text edits around an unchanged same-source sibling', async () => { const position = await addPeerSibling() - act(() => host.querySelector('[aria-label="Edit image link"]')!.click()) - const input = changeDraft('https://sim.ai/local-link') + beginResize() peer.commands.setNodeSelection(position) - peer.commands.updateAttributes('image', { alt: 'Peer corrected alt' }) + peer.commands.updateAttributes('image', { + alt: 'Peer corrected alt', + href: 'https://sim.ai/peer-link', + }) peer.commands.insertContentAt('Earlier heading'.length + 1, ' PEER') await receivePeerUpdate() - expect(host.querySelector('[aria-label="Image editing"] input')).toBe(input) - await act(async () => { - input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })) - }) + pointer(window, 'pointerup', 160) const currentPosition = local.state.doc.firstChild!.nodeSize expect(local.state.doc.nodeAt(currentPosition)?.attrs).toMatchObject({ alt: 'Peer corrected alt', - href: 'https://sim.ai/local-link', + href: 'https://sim.ai/peer-link', + width: '260', }) expect(local.state.doc.nodeAt(currentPosition + 1)?.attrs.alt).toBe('Peer image') - await act(async () => { - Y.applyUpdate(peerDoc, Y.encodeStateAsUpdate(localDoc)) - }) + await act(async () => Y.applyUpdate(peerDoc, Y.encodeStateAsUpdate(localDoc))) expect(local.getJSON()).toEqual(peer.getJSON()) }) it.each(['alt', 'width', 'src'])( - 'cancels conservatively when a sibling image changes its %s', + 'cancels resizing conservatively when a sibling image changes its %s', async (field) => { const position = await addPeerSibling() - act(() => - host.querySelector('[aria-label="Edit image alt text"]')!.click() - ) - const input = changeDraft('Stale draft') + beginResize() peer.commands.setNodeSelection(position + 1) peer.commands.updateAttributes('image', { [field]: field === 'width' ? '500' : 'https://sim.ai/peer-change', }) await receivePeerUpdate() - expect(host.querySelector('[aria-label="Image editing"] input')).toBeNull() - await act(async () => { - input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })) - }) + pointer(window, 'pointerup', 160) expect(local.getJSON()).toEqual(peer.getJSON()) } ) it.each( - (['alt', 'href', 'resize'] as const).flatMap((interaction) => - [false, true].flatMap((identical) => - ['before', 'after'].map((side) => ({ interaction, identical, side })) - ) - ) + [false, true].flatMap((identical) => ['before', 'after'].map((side) => ({ identical, side }))) )( - 'cancels $interaction after a peer inserts $side the image (identical: $identical)', - async ({ interaction, identical, side }) => { - let input: HTMLInputElement | undefined - if (interaction === 'resize') beginResize() - else { - const label = interaction === 'alt' ? 'alt text' : 'link' - act(() => - host.querySelector(`[aria-label="Edit image ${label}"]`)!.click() - ) - input = changeDraft(interaction === 'alt' ? 'Local draft' : 'https://sim.ai/local-draft') - } + 'cancels resizing after a peer inserts $side the image (identical: $identical)', + async ({ identical, side }) => { + beginResize() const originalTarget = localDoc.getXmlFragment('default').get(1) peer.commands.insertContentAt(imagePosition(peer) + (side === 'after' ? 1 : 0), { type: 'image', @@ -443,67 +426,11 @@ describe('image interactions during real peer Yjs updates', () => { }) await receivePeerUpdate() expect(localDoc.getXmlFragment('default').get(1)).toBe(originalTarget) - if (input) { - expect(host.querySelector('[aria-label="Image editing"] input')).toBeNull() - act(() => - input?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })) - ) - } else pointer(window, 'pointerup', 160) + pointer(window, 'pointerup', 160) expect(local.getJSON()).toEqual(peer.getJSON()) } ) - it('rejects a queued Apply before React renders the peer insertion', async () => { - act(() => host.querySelector('[aria-label="Edit image alt text"]')!.click()) - const input = changeDraft('Stale draft') - peer.commands.insertContentAt(imagePosition(peer), { - type: 'image', - attrs: imageAttributes(peer), - }) - await act(async () => { - Y.applyUpdate(localDoc, Y.encodeStateAsUpdate(peerDoc)) - input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })) - }) - expect(local.getJSON()).toEqual(peer.getJSON()) - expect(host.querySelector('[aria-label="Image editing"] input')).toBeNull() - }) - - it('does not revive a canceled draft when the peer removes their inserted image', async () => { - act(() => host.querySelector('[aria-label="Edit image alt text"]')!.click()) - changeDraft('Stale draft') - const position = imagePosition(peer) - peer.commands.insertContentAt(position, { type: 'image', attrs: imageAttributes(peer) }) - await receivePeerUpdate() - expect(host.querySelector('[aria-label="Image editing"] input')).toBeNull() - peer.commands.deleteRange({ from: position, to: position + 1 }) - await receivePeerUpdate() - expect(host.querySelector('[aria-label="Image editing"] input')).toBeNull() - expect(local.getJSON()).toEqual(peer.getJSON()) - }) - - it.each(['cancel', 'apply', 'unmount'] as const)( - 'removes the draft guard listener on %s', - (finish) => { - const subscribe = vi.spyOn(local, 'on') - const unsubscribe = vi.spyOn(local, 'off') - act(() => - host.querySelector('[aria-label="Edit image alt text"]')!.click() - ) - const listener = subscribe.mock.calls.find(([event]) => event === 'transaction')?.[1] - expect(listener).toBeTypeOf('function') - if (finish === 'unmount') act(() => root.unmount()) - else { - const key = finish === 'cancel' ? 'Escape' : 'Enter' - act(() => - host - .querySelector('input')! - .dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true })) - ) - } - expect(unsubscribe).toHaveBeenCalledWith('transaction', listener) - } - ) - it.each(['pointerup', 'pointercancel', 'blur', 'unmount'])( 'removes the resize transaction listener after %s', (finish) => { @@ -520,33 +447,6 @@ describe('image interactions during real peer Yjs updates', () => { } ) - it('preserves peer alt text when only the local link draft changes', async () => { - act(() => - host.querySelector('button[aria-label="Edit image link"]')!.click() - ) - const input = host.querySelector('input[aria-label="Image link URL"]')! - act(() => { - Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set?.call( - input, - 'https://sim.ai/local-link' - ) - input.dispatchEvent(new Event('input', { bubbles: true })) - }) - peer.commands.setNodeSelection(imagePosition(peer)) - peer.commands.updateAttributes('image', { alt: 'Peer corrected alt' }) - await receivePeerUpdate() - expect(imageAttributes(local)?.alt).toBe('Peer corrected alt') - expect(host.querySelector('input[aria-label="Image link URL"]')).toBe(input) - await act(async () => { - input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })) - }) - - expect(imageAttributes(local)).toMatchObject({ - alt: 'Peer corrected alt', - href: 'https://sim.ai/local-link', - }) - }) - it('keeps resizing the same image after a peer heading and metadata edit', async () => { const originalImage = localDoc.getXmlFragment('default').get(1) beginResize() @@ -565,64 +465,6 @@ describe('image interactions during real peer Yjs updates', () => { expect(local.state.doc.firstChild?.textContent).toBe('Earlier heading PEER') }) - it.each(['alt', 'href', 'unchanged', 'reverted'] as const)( - 'preserves peer fields and follows the image through preceding edits: %s', - async (change) => { - const field = change === 'href' ? 'link' : 'alt text' - act(() => - host.querySelector(`button[aria-label="Edit image ${field}"]`)!.click() - ) - const input = changeDraft( - change === 'href' - ? 'https://sim.ai/local' - : change === 'unchanged' - ? 'Original' - : 'Local alt' - ) - if (change === 'reverted') changeDraft('Original') - peer.commands.insertContentAt('Earlier heading'.length + 1, ' PEER') - peer.commands.setNodeSelection(imagePosition(peer)) - peer.commands.updateAttributes('image', { alt: 'Peer alt', href: 'https://sim.ai/peer' }) - await receivePeerUpdate() - expect(host.querySelector('[aria-label="Image editing"] input')).toBe(input) - await act(async () => - input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })) - ) - expect(imageAttributes(local)).toMatchObject({ - alt: change === 'alt' ? 'Local alt' : 'Peer alt', - href: change === 'href' ? 'https://sim.ai/local' : 'https://sim.ai/peer', - }) - expect(local.state.doc.firstChild?.textContent).toBe('Earlier heading PEER') - } - ) - - it.each(['delete', 'replace', 'identical replacement'] as const)( - 'never applies an open draft to a peer replacement: %s', - async (action) => { - act(() => - host.querySelector('[aria-label="Edit image alt text"]')!.click() - ) - const input = changeDraft('Uncommitted draft') - const position = imagePosition(peer) - const originalAttributes = imageAttributes(peer) - peer.commands.deleteRange({ from: position, to: position + 1 }) - if (action !== 'delete') - peer.commands.insertContentAt(position, { - type: 'image', - attrs: - action === 'replace' - ? { ...originalAttributes, alt: 'Replacement' } - : originalAttributes, - }) - await receivePeerUpdate() - act(() => input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))) - expect(host.querySelector('[aria-label="Image editing"] input')).toBeNull() - expect(imageAttributes(local)?.alt ?? null).toBe( - action === 'delete' ? null : action === 'replace' ? 'Replacement' : 'Original' - ) - } - ) - it.each([false, true])( 'cancels a resize when the peer replaces the actual image node (identical attributes: %s)', async (identicalAttributes) => { diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-drag-move.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-drag-move.test.ts index d8a51be247e..9c0f79df51c 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-drag-move.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-drag-move.test.ts @@ -13,13 +13,17 @@ import { EditorState, NodeSelection } from '@tiptap/pm/state' import type { EditorView } from '@tiptap/pm/view' import { describe, expect, it, vi } from 'vitest' import { moveDraggedImageNode } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-drag-move' +import { isImageNode } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-node' const schema = new Schema({ nodes: { doc: { content: 'block+' }, - paragraph: { group: 'block', content: 'text*' }, + paragraph: { group: 'block', content: 'inline*' }, + heading: { group: 'block', content: 'inline*' }, + codeBlock: { group: 'block', content: 'text*', code: true }, image: { group: 'block', attrs: { src: {} }, draggable: true }, - text: {}, + inlineImage: { group: 'inline', inline: true, attrs: { src: {} }, draggable: true }, + text: { group: 'inline' }, }, }) @@ -43,7 +47,7 @@ function selectedImageView(): { view: EditorView; dispatched: { state: () => Edi get state() { return state }, - posAtCoords: () => ({ pos: 1, inside: 0 }), + posAtCoords: () => ({ pos: 0, inside: -1 }), dispatch: vi.fn((tr) => { state = state.apply(tr) }), @@ -59,12 +63,82 @@ function dropEvent(): DragEvent { function imageCount(doc: PMNode): number { let count = 0 doc.descendants((node) => { - if (node.type.name === 'image') count += 1 + if (isImageNode(node)) count += 1 }) return count } describe('moveDraggedImageNode', () => { + it.each([ + ['inside a heading', 3, 'inlineImage'], + ['inside a paragraph after the source', 12, 'inlineImage'], + ['between blocks', 9, 'image'], + ['inside a text-only code block', 18, 'image'], + ] as const)('moves a block image to %s', (_label, dropPos, expectedType) => { + const doc = schema.node('doc', null, [ + schema.node('heading', null, [schema.text('Before')]), + schema.node('image', { src: SRC }), + schema.node('paragraph', null, [schema.text('After')]), + schema.node('codeBlock', null, [schema.text('Code')]), + ]) + let state = EditorState.create({ doc, selection: NodeSelection.create(doc, 8) }) + const view = { + get state() { + return state + }, + posAtCoords: () => ({ pos: dropPos }), + dispatch: (tr) => { + state = state.apply(tr) + }, + } as unknown as EditorView + + expect(moveDraggedImageNode(view, dropEvent(), { images: [], html: imageHtml(SRC) })).toBe(true) + expect(() => state.doc.check()).not.toThrow() + expect(imageCount(state.doc)).toBe(1) + expect(state.doc.textContent).toBe(doc.textContent) + expect(state.selection).toBeInstanceOf(NodeSelection) + expect((state.selection as NodeSelection).node.type.name).toBe(expectedType) + expect((state.selection as NodeSelection).node.attrs.src).toBe(SRC) + if (expectedType === 'inlineImage') { + expect(state.selection.from).toBe(dropPos > 8 ? dropPos - 1 : dropPos) + } + }) + + it.each([ + ['document start', 0, 'image'], + ['between blocks', 10, 'image'], + ['document end', 17, 'image'], + ['inside a heading', 2, 'inlineImage'], + ['inside a paragraph', 12, 'inlineImage'], + ] as const)('keeps the moved image selected at %s', (_label, dropPos, expectedType) => { + const doc = schema.node('doc', null, [ + schema.node('heading', null, [ + schema.text('Before'), + schema.node('inlineImage', { src: SRC }), + schema.text('!'), + ]), + schema.node('paragraph', null, [schema.text('After')]), + ]) + let state = EditorState.create({ doc, selection: NodeSelection.create(doc, 7) }) + const view = { + get state() { + return state + }, + posAtCoords: () => ({ pos: dropPos }), + dispatch: (tr) => { + state = state.apply(tr) + }, + } as unknown as EditorView + + expect(moveDraggedImageNode(view, dropEvent(), { images: [], html: imageHtml(SRC) })).toBe(true) + expect(() => state.doc.check()).not.toThrow() + expect(imageCount(state.doc)).toBe(1) + expect(state.doc.textContent).toBe(doc.textContent) + expect(state.selection).toBeInstanceOf(NodeSelection) + expect((state.selection as NodeSelection).node.type.name).toBe(expectedType) + expect((state.selection as NodeSelection).node.attrs.src).toBe(SRC) + }) + it('moves the dragged image instead of leaving it to be re-uploaded', () => { const { view, dispatched } = selectedImageView() const event = dropEvent() @@ -72,7 +146,6 @@ describe('moveDraggedImageNode', () => { expect(moveDraggedImageNode(view, event, { images: [], html: imageHtml(SRC) })).toBe(true) expect(event.preventDefault).toHaveBeenCalled() expect(imageCount(dispatched.state().doc)).toBe(1) - /* Moved ahead of the paragraph it started after. */ expect(dispatched.state().doc.firstChild?.type.name).toBe('image') }) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-drag-move.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-drag-move.ts index 904f2cd5ed0..3a3961be92b 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-drag-move.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-drag-move.ts @@ -2,7 +2,8 @@ import { Fragment, Slice } from '@tiptap/pm/model' import { NodeSelection } from '@tiptap/pm/state' import { dropPoint } from '@tiptap/pm/transform' import type { EditorView } from '@tiptap/pm/view' -import { htmlReferencesSrc } from './image-paste' +import { isImageNode } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-node' +import { htmlReferencesSrc } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-paste' interface MoveDraggedImageOptions { /** Image files on the drop, from `extractImageFiles`. */ @@ -42,7 +43,7 @@ export function moveDraggedImageNode( ): boolean { const { selection } = view.state if (images.length > 1) return false - if (!(selection instanceof NodeSelection) || selection.node.type.name !== 'image') return false + if (!(selection instanceof NodeSelection) || !isImageNode(selection.node)) return false const src = selection.node.attrs.src const rendered = typeof src === 'string' ? (resolveSrc?.(src) ?? src) : undefined @@ -52,7 +53,15 @@ export function moveDraggedImageNode( const coords = view.posAtCoords({ left: event.clientX, top: event.clientY }) if (!coords) return true - const node = selection.node + const $drop = view.state.doc.resolve(coords.pos) + const { image, inlineImage } = view.state.schema.nodes + const type = $drop.parent.canReplaceWith($drop.index(), $drop.index(), inlineImage) + ? inlineImage + : image + const node = + selection.node.type === type + ? selection.node + : type.create(selection.node.attrs, null, selection.node.marks) const tr = view.state.tr const insertPos = dropPoint(view.state.doc, coords.pos, new Slice(Fragment.from(node), 0, 0)) if (insertPos === null) return true diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-input-rule.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-input-rule.test.ts index 34e252318d6..e970c16ad76 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-input-rule.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-input-rule.test.ts @@ -7,6 +7,7 @@ import { Awareness } from 'y-protocols/awareness' import * as Y from 'yjs' import { markdownToYDoc } from '@/lib/collab-doc/converter' import { createMarkdownEditorExtensions } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/editor-extensions' +import { isImageNode } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-node' const cleanups: Array<() => void> = [] afterEach(() => { @@ -129,7 +130,7 @@ describe.each([false, true])('image input rules (collaborative=%s)', (collaborat for (const chunk of chunks) inputText(editor, chunk) const images: unknown[] = [] editor.state.doc.descendants((node) => { - if (node.type.name === 'image') images.push(node.attrs) + if (isImageNode(node)) images.push(node.attrs) }) expect(images).toMatchObject([ { src: 'https://example.com/logo.png', alt: 'Audit image', title: 'Brand' }, diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-node.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-node.ts new file mode 100644 index 00000000000..bdace77c594 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-node.ts @@ -0,0 +1,11 @@ +import type { Node } from '@tiptap/pm/model' + +export function isImageNode(node: { type: { name: string } }): boolean { + return node.type.name === 'image' || node.type.name === 'inlineImage' +} + +/** Insert images inside headings without changing the existing block-image insertion elsewhere. */ +export function imageTypeAt(doc: Node, position: number): 'image' | 'inlineImage' { + const parent = doc.resolve(position).parent + return parent.type.name === 'heading' ? 'inlineImage' : 'image' +} diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-paste.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-paste.ts index 9bd35bfc1fa..ac910e93f53 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-paste.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-paste.ts @@ -1,4 +1,5 @@ import { extractImgSrcs } from '@/lib/uploads/utils/embedded-image-ref' +import { isImageNode } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-node' /** * Extract image `File` objects from a paste/drop payload. Reads `files` first, then falls back to @@ -187,7 +188,7 @@ export function findHostedImageAttrs( let found: Record | null = null doc.descendants((node) => { if (found) return false - if (node.type.name === 'image') { + if (isImageNode(node)) { const resolved = resolveImageSrc(node.attrs.src as string | undefined) const resolvedPath = resolved ? toSameOriginPath(resolved, origin) : null if (resolvedPath && targets.has(resolvedPath)) { diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-schema.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-schema.ts index 51f3889441a..90ac478a52d 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-schema.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-schema.ts @@ -1,6 +1,10 @@ -import { InputRule, type JSONContent } from '@tiptap/core' +import { InputRule, type JSONContent, mergeAttributes } from '@tiptap/core' import { Image, inputRegex } from '@tiptap/extension-image' +import { type DOMOutputSpec, Fragment, Slice } from '@tiptap/pm/model' +import { Plugin } from '@tiptap/pm/state' import { Lexer, Tokenizer } from 'marked' +import { imageTypeAt } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-node' +import { normalizeLinkHref } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-fidelity' import { createTextInputRulePlugins } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/text-input-rule' /** @@ -12,11 +16,9 @@ import { createTextInputRulePlugins } from '@/app/workspace/[workspaceId]/files/ */ /** - * A markdown linked image `[![alt](src "t")](href "t2")` — an image wrapped in a link, the canonical - * form of a README badge. `@tiptap/markdown` parses this as a link mark over an image node, but an - * image node can't carry inline marks, so the wrapping link is silently dropped. We instead tokenize - * the whole construct ourselves and hang the link target on the image node's `href` attribute, so it - * round-trips losslessly (and the file stays editable rather than opening read-only). + * Linked images store their destination in attributes because the Yjs binding does not preserve + * marks on image atoms. Tokenizing the complete Markdown link preserves README badges in both + * image representations. */ /** Escape a value for safe interpolation into a double-quoted HTML attribute. */ function escapeAttr(value: string): string { @@ -54,8 +56,10 @@ function imageMarkdown(node: JSONContent): string { const src = typeof attrs.src === 'string' ? attrs.src : '' const alt = typeof attrs.alt === 'string' ? attrs.alt : '' const title = typeof attrs.title === 'string' ? attrs.title : '' - const href = typeof attrs.href === 'string' ? attrs.href : '' - const hrefTitle = typeof attrs.hrefTitle === 'string' ? attrs.hrefTitle : '' + const link = node.marks?.find((mark) => mark.type === 'link') + const linkAttrs = link ? link.attrs : { href: attrs.href, title: attrs.hrefTitle } + const href = typeof linkAttrs?.href === 'string' ? linkAttrs.href : '' + const hrefTitle = typeof linkAttrs?.title === 'string' ? linkAttrs.title : '' const width = attrs.width const height = attrs.height let image: string @@ -81,7 +85,7 @@ function imageMarkdown(node: JSONContent): string { } interface MarkdownImageToken { - /** Set only by our linked-image tokenizer; absent on the built-in `![](src)` token. */ + /** Custom image tokens hold the source here; the built-in image token uses `href`. */ src?: string alt?: string title?: string | null @@ -135,17 +139,54 @@ const heightAttr = { attributes.height ? { height: String(attributes.height) } : {}, } -/** Link target of a linked image — markdown-only state, never emitted as an HTML `` attribute. */ -const hrefAttr = { default: null, rendered: false } -const hrefTitleAttr = { default: null, rendered: false } +/** Wrapping links belong to the image's metadata, not an HTML `` attribute. */ +const hrefAttr = { + default: null, + rendered: false, + parseHTML: (element: HTMLElement) => element.closest('a[href]')?.getAttribute('href') ?? null, +} +const hrefTitleAttr = { + default: null, + rendered: false, + parseHTML: (element: HTMLElement) => element.closest('a[href]')?.getAttribute('title') ?? null, +} /** * Image node that carries optional `width`/`height` (serialized as an HTML `` tag) and an * optional `href`/`hrefTitle` (a wrapping markdown link, for badges). Shared by the headless * round-trip path (no node view) and the live {@link ResizableImage}. */ -export const MarkdownImage = Image.extend({ +const ImageSchema = Image.extend({ addInputRules: () => [], + addAttributes() { + return { + ...this.parent?.(), + width: widthAttr, + height: heightAttr, + href: hrefAttr, + hrefTitle: hrefTitleAttr, + } + }, + renderHTML({ node, HTMLAttributes }): DOMOutputSpec { + const image: DOMOutputSpec = [ + 'img', + mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), + ] + const href = typeof node.attrs.href === 'string' ? normalizeLinkHref(node.attrs.href) : '' + if (!href || node.marks.some((mark) => mark.type.name === 'link')) return image + return [ + 'a', + { + href, + ...(typeof node.attrs.hrefTitle === 'string' ? { title: node.attrs.hrefTitle } : {}), + }, + image, + ] + }, + renderMarkdown: imageMarkdown, +}) + +export const MarkdownImage = ImageSchema.extend({ addProseMirrorPlugins() { return createTextInputRulePlugins( this.editor, @@ -154,24 +195,21 @@ export const MarkdownImage = Image.extend({ find: new RegExp(`${inputRegex.source}(?![\\s\\S])`), handler: ({ state, range, match }) => { const [, syntax, alt, src, title] = match + const from = range.from + match[0].indexOf(syntax) + const type = state.schema.nodes[imageTypeAt(state.doc, from)] state.tr - .replaceRangeWith( - range.from + match[0].indexOf(syntax), - range.to, - this.type.create({ alt, src, title: title ?? null }) - ) + .replaceRangeWith(from, range.to, type.create({ alt, src, title: title ?? null })) .scrollIntoView() }, }) ) }, - addAttributes() { + addCommands() { return { - ...this.parent?.(), - width: widthAttr, - height: heightAttr, - href: hrefAttr, - hrefTitle: hrefTitleAttr, + setImage: + (attrs) => + ({ state, commands }) => + commands.insertContent({ type: imageTypeAt(state.doc, state.selection.from), attrs }), } }, markdownTokenizer: { @@ -179,12 +217,28 @@ export const MarkdownImage = Image.extend({ level: 'inline', start: (src: string) => { const markdown = src.indexOf('[![') - const html = src.search(/\[ { + if (/^ mark.type.name === 'link') + children.push( + link + ? child.type.create( + { ...child.attrs, href: link.attrs.href, hrefTitle: link.attrs.title ?? null }, + child.content, + child.marks.filter((mark) => mark !== link) + ) + : child.content.size + ? child.copy(preservePastedImageLinks(child.content)) + : child + ) + } + return Fragment.fromArray(children) +} + +export const MarkdownInlineImage = ImageSchema.extend({ + name: 'inlineImage', + markdownTokenName: 'inlineImage', + addCommands: () => ({}), + addProseMirrorPlugins() { + return [ + new Plugin({ + props: { + transformPasted: (slice) => + new Slice(preservePastedImageLinks(slice.content), slice.openStart, slice.openEnd), + }, + }), + ] + }, + addAttributes() { + return { + ...this.parent?.(), + /** HTML links on inline images are represented by the surrounding link mark. */ + href: { default: null, rendered: false }, + hrefTitle: { default: null, rendered: false }, + } + }, + parseHTML() { + return [ + { tag: 'img[data-inline-image]', priority: 60 }, + { tag: 'img[src]', context: 'heading/', priority: 60 }, + { + tag: 'img[src]', + context: 'paragraph/', + priority: 60, + getAttrs: (element) => (element.closest('p')?.textContent?.trim() ? null : false), + }, + ] + }, +}).configure({ inline: true, HTMLAttributes: { 'data-inline-image': '' } }) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-target.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-target.ts index 9695682ff45..3aead378991 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-target.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-target.ts @@ -2,7 +2,7 @@ import { Node } from '@tiptap/pm/model' import type { ProsemirrorBinding } from '@tiptap/y-tiptap' import { XmlElement } from 'yjs' -export function getImageYTarget(binding: ProsemirrorBinding, node: Node): XmlElement | undefined { +function getImageYTarget(binding: ProsemirrorBinding, node: Node): XmlElement | undefined { for (const [type, mappedNode] of binding.mapping) { if (mappedNode === node && type instanceof XmlElement) return type } @@ -18,7 +18,7 @@ export function createImageTargetGuard(binding: ProsemirrorBinding, node: Node) const images = () => Array.from( binding.type.createTreeWalker( - (child) => child instanceof XmlElement && child.nodeName === 'image' + (child) => child instanceof XmlElement && ['image', 'inlineImage'].includes(child.nodeName) ) ) const originalImages = images().map((element) => ({ diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-upload.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-upload.ts index c85e9b37e1e..97d8450661c 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-upload.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-upload.ts @@ -3,6 +3,7 @@ import { type Editor, Extension, type Range } from '@tiptap/core' import type { Slice } from '@tiptap/pm/model' import { Plugin, PluginKey } from '@tiptap/pm/state' import { Decoration, DecorationSet } from '@tiptap/pm/view' +import { imageTypeAt } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-node' interface PendingImageUpload extends Range { id: string @@ -170,7 +171,7 @@ export function finishImageUpload(editor: Editor, id: string, src: string, alt: .chain() .insertContentAt( { from: upload.from, to: upload.to }, - { type: 'image', attrs: { src, alt } }, + { type: imageTypeAt(editor.state.doc, upload.from), attrs: { src, alt } }, { updateSelection: false } ) .command(({ tr }) => { diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image.tsx index 9e7f4dc9f13..e80f516eda9 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image.tsx @@ -1,10 +1,14 @@ import { type CSSProperties, useEffect, useMemo, useRef, useState } from 'react' import { Button, cn } from '@sim/emcn' +import type { Node } from '@tiptap/core' import { NodeSelection, Plugin } from '@tiptap/pm/state' import type { ReactNodeViewProps } from '@tiptap/react' import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react' import { type ProsemirrorBinding, ySyncPluginKey } from '@tiptap/y-tiptap' -import { MarkdownImage } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-schema' +import { + MarkdownImage, + MarkdownInlineImage, +} from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-schema' import { createImageTargetGuard } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-target' import { normalizeLinkHref } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-fidelity' import { useEditorEditable } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/use-editor-editable' @@ -254,7 +258,13 @@ export function ResizableImageView({ ) return ( - + {safeHref ? ( // The editor's handleClick is the sole navigator (gated on editable/modifier, like text links // via openOnClick:false): prevent the anchor's own navigation so a plain click in edit mode @@ -288,33 +298,40 @@ export function ResizableImageView({ } /** Live image node with the drag-to-resize view; same schema + markdown output as the headless one. */ -export const ResizableImage = MarkdownImage.extend({ - addNodeView() { - return ReactNodeViewRenderer(ResizableImageView) - }, - /** - * Guarantee a plain click on the image forms a node selection. The image body is also a native drag - * source (grab-anywhere reorder), and while prosemirror-view ≥1.32.4 no longer implicitly selects on - * drag, the reverse — a click reliably selecting — is not guaranteed for an atom whose body competes - * with the drag gesture (see the ProseMirror "Draggable and NodeViews" discussion and TipTap #4526). - * Selecting here makes it deterministic while leaving drag-to-reorder intact. Read-only clicks and - * modified clicks (Cmd/Ctrl to follow a linked badge, Shift/Alt to extend) fall through to the editor's - * `handleClick` / default behavior. - */ - addProseMirrorPlugins() { - const nodeName = this.name - return [ - ...(this.parent?.() ?? []), - new Plugin({ - props: { - handleClickOn(view, _pos, node, nodePos, event) { - if (!view.editable || node.type.name !== nodeName) return false - if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return false - view.dispatch(view.state.tr.setSelection(NodeSelection.create(view.state.doc, nodePos))) - return true +function withImageNodeView(image: Node) { + return image.extend({ + addNodeView() { + return ReactNodeViewRenderer(ResizableImageView) + }, + /** + * Guarantee a plain click on the image forms a node selection. The image body is also a native drag + * source (grab-anywhere reorder), and while prosemirror-view ≥1.32.4 no longer implicitly selects on + * drag, the reverse — a click reliably selecting — is not guaranteed for an atom whose body competes + * with the drag gesture (see the ProseMirror "Draggable and NodeViews" discussion and TipTap #4526). + * Selecting here makes it deterministic while leaving drag-to-reorder intact. Read-only clicks and + * modified clicks (Cmd/Ctrl to follow a linked badge, Shift/Alt to extend) fall through to the editor's + * `handleClick` / default behavior. + */ + addProseMirrorPlugins() { + const nodeName = this.name + return [ + ...(this.parent?.() ?? []), + new Plugin({ + props: { + handleClickOn(view, _pos, node, nodePos, event) { + if (!view.editable || node.type.name !== nodeName) return false + if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return false + view.dispatch( + view.state.tr.setSelection(NodeSelection.create(view.state.doc, nodePos)) + ) + return true + }, }, - }, - }), - ] - }, -}) + }), + ] + }, + }) +} + +export const ResizableImage = withImageNodeView(MarkdownImage) +export const ResizableInlineImage = withImageNodeView(MarkdownInlineImage) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/editor-toolbar-integration.test.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/editor-toolbar-integration.test.tsx index 817cfbf314a..c3fe548ff56 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/editor-toolbar-integration.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/editor-toolbar-integration.test.tsx @@ -3,6 +3,7 @@ import { act } from 'react' import { Tooltip } from '@sim/emcn' import { Editor } from '@tiptap/core' import { type EditorState, Plugin, type Transaction } from '@tiptap/pm/state' +import { CellSelection } from '@tiptap/pm/tables' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createMarkdownContentExtensions } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions' @@ -127,6 +128,34 @@ function changeUrl(input: HTMLInputElement, value: string): void { } describe('real editor BubbleMenu keyboard integration', () => { + it('hides the table toolbar for an image selection and restores it for a cell selection', async () => { + await act(async () => { + editor.commands.setContent( + '

Header

' + ) + let imagePosition = -1 + editor.state.doc.descendants((node, pos) => { + if (node.type.name === 'image') imagePosition = pos + }) + expect(imagePosition).toBeGreaterThan(-1) + editor.commands.setNodeSelection(imagePosition) + editor.view.focus() + }) + await frame() + expect(viewport.querySelector('[aria-label="Table editing"]')).toBeNull() + expect(viewport.querySelector('[aria-label="Text formatting"]')).toBeNull() + expect(key(editor.view.dom, 'F10', { altKey: true }).defaultPrevented).toBe(false) + await act(async () => { + const cell = editor.state.selection.$from.before(3) + editor.view.dispatch( + editor.state.tr.setSelection(CellSelection.create(editor.state.doc, cell)) + ) + editor.view.focus() + }) + await frame() + expect(viewport.querySelector('[aria-label="Table editing"]')).not.toBeNull() + }) + it('enters the formatting toolbar and returns with Escape without losing the selected text', async () => { select('format') const selection = editor.state.selection.toJSON() diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/image-menu.test.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/image-menu.test.tsx deleted file mode 100644 index c623e8ddda6..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/image-menu.test.tsx +++ /dev/null @@ -1,253 +0,0 @@ -/** @vitest-environment jsdom */ -import { act } from 'react' -import { Tooltip } from '@sim/emcn' -import { Editor } from '@tiptap/core' -import { NodeSelection } from '@tiptap/pm/state' -import { CellSelection } from '@tiptap/pm/tables' -import { createRoot, type Root } from 'react-dom/client' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { createMarkdownContentExtensions } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions' -import { BUBBLE_MENU_CLASS } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/bubble-menu-chrome' -import { ImageBubbleMenu } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/image-menu' -import { TableBubbleMenu } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/table-menu' - -let editor: Editor -let root: Root -let viewport: HTMLDivElement - -beforeEach(async () => { - vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true) - vi.useFakeTimers() - viewport = document.createElement('div') - const editorHost = document.createElement('div') - const menuHost = document.createElement('div') - viewport.append(editorHost, menuHost) - document.body.append(viewport) - editor = new Editor({ - element: editorHost, - extensions: createMarkdownContentExtensions(), - content: 'Diagram

After

', - editorProps: { handleScrollToSelection: () => true }, - }) - vi.spyOn(editor.view, 'coordsAtPos').mockReturnValue({ top: 10, bottom: 30, left: 10, right: 50 }) - root = createRoot(menuHost) - await act(async () => { - root.render( - - - - - ) - }) - await act(async () => { - editor.commands.setNodeSelection(0) - editor.view.focus() - vi.advanceTimersToNextFrame() - }) -}) - -afterEach(() => { - act(() => root.unmount()) - editor.destroy() - viewport.remove() - vi.restoreAllMocks() - vi.useRealTimers() - vi.unstubAllGlobals() -}) - -function button(label: string): HTMLButtonElement { - const element = viewport.querySelector(`button[aria-label="${label}"]`) - if (!element) throw new Error(`Missing ${label} button`) - return element -} - -function input(): HTMLInputElement { - const element = viewport.querySelector('[aria-label="Image editing"] input') - if (!element) throw new Error('Missing image toolbar input') - return element -} - -function change(value: string): void { - const field = input() - act(() => { - Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set?.call(field, value) - field.dispatchEvent(new Event('input', { bubbles: true })) - }) -} - -function key(target: HTMLElement, value: string, options: KeyboardEventInit = {}): void { - act(() => - target.dispatchEvent( - new KeyboardEvent('keydown', { - key: value, - bubbles: true, - cancelable: true, - ...options, - }) - ) - ) -} - -describe('ImageBubbleMenu', () => { - it('uses the shared floating chrome and keyboard navigation without a gear', async () => { - const toolbar = viewport.querySelector('[aria-label="Image editing"]')! - expect(toolbar.parentElement?.className).toBe(BUBBLE_MENU_CLASS) - expect(viewport.querySelector('[aria-label="Edit image details"]')).toBeNull() - key(editor.view.dom, 'F10', { altKey: true }) - await act(async () => vi.advanceTimersToNextFrame()) - expect(document.activeElement).toBe(button('Edit image alt text')) - key(button('Edit image alt text'), 'ArrowRight') - expect(document.activeElement).toBe(button('Edit image link')) - key(button('Edit image link'), 'Escape') - await act(async () => vi.advanceTimersToNextFrame()) - expect(editor.view.hasFocus()).toBe(true) - expect(editor.state.selection).toBeInstanceOf(NodeSelection) - }) - - it.each([ - { key: 'Enter', isComposing: true, keyCode: 13 }, - { key: 'Escape', isComposing: true, keyCode: 27 }, - { key: 'Enter', isComposing: false, keyCode: 229 }, - { key: 'Escape', isComposing: false, keyCode: 229 }, - ])('does not commit or cancel during composition: $key/$keyCode', (keyboard) => { - act(() => button('Edit image alt text').click()) - change('Composition draft') - const field = input() - const parentKeyDown = vi.fn() - viewport.addEventListener('keydown', parentKeyDown) - const event = new KeyboardEvent('keydown', { bubbles: true, cancelable: true, ...keyboard }) - act(() => field.dispatchEvent(event)) - viewport.removeEventListener('keydown', parentKeyDown) - expect(parentKeyDown).not.toHaveBeenCalled() - expect(event.defaultPrevented).toBe(false) - expect(input()).toBe(field) - expect(input().value).toBe('Composition draft') - expect(document.activeElement).toBe(field) - expect(editor.state.doc.firstChild?.attrs.alt).toBe('Diagram') - }) - - it('edits alt text without changing other attributes and cancels without writing', () => { - act(() => button('Edit image alt text').click()) - change('New description') - key(input(), 'Enter') - expect(editor.state.doc.firstChild?.attrs).toMatchObject({ - alt: 'New description', - width: '200', - }) - act(() => button('Edit image alt text').click()) - change('Do not save') - key(input(), 'Escape') - expect(editor.state.doc.firstChild?.attrs.alt).toBe('New description') - expect(viewport.querySelector('[aria-label="Image editing"] input')).toBeNull() - }) - - it('validates links, normalizes a valid URL, and explicitly removes a cleared link', () => { - act(() => button('Edit image link').click()) - change('javascript:alert(1)') - expect(input()).toHaveAttribute('aria-invalid', 'true') - expect(button('Apply image change').disabled).toBe(true) - key(input(), 'Enter') - expect(editor.state.doc.firstChild?.attrs.href).toBeNull() - change(' https://sim.ai/image ') - act(() => button('Apply image change').click()) - expect(editor.state.doc.firstChild?.attrs.href).toBe('https://sim.ai/image') - act(() => button('Edit image link').click()) - change('') - key(input(), 'Enter') - expect(editor.state.doc.firstChild?.attrs).toMatchObject({ alt: 'Diagram', href: null }) - }) - - it('resets dimensions and omits reset for an image without custom dimensions', () => { - act(() => button('Reset image size').click()) - expect(editor.state.doc.firstChild?.attrs).toMatchObject({ - alt: 'Diagram', - width: null, - height: null, - }) - expect(viewport.querySelector('[aria-label="Reset image size"]')).toBeNull() - }) - - it.each(['read-only', 'destroyed'] as const)( - 'rejects queued input and reset actions after the editor becomes %s', - (state) => { - const reset = button('Reset image size') - act(() => button('Edit image alt text').click()) - change('Do not save') - const field = input() - const original = editor.state.doc - act(() => { - if (state === 'read-only') editor.setEditable(false) - else editor.destroy() - field.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })) - reset.click() - }) - expect(editor.state.doc.eq(original)).toBe(true) - } - ) - - it('drops a draft when selecting a different image and does not resurrect it on return', () => { - act(() => - editor.commands.insertContentAt(editor.state.doc.content.size, { - type: 'image', - attrs: { src: '/other.png', alt: 'Other' }, - }) - ) - act(() => editor.commands.setNodeSelection(0)) - act(() => button('Edit image alt text').click()) - change('Uncommitted draft') - let otherPosition = -1 - editor.state.doc.descendants((node, pos) => { - if (node.attrs.src === '/other.png') otherPosition = pos - }) - act(() => editor.commands.setNodeSelection(otherPosition)) - expect(viewport.querySelector('[aria-label="Image editing"] input')).toBeNull() - act(() => editor.commands.setNodeSelection(0)) - act(() => button('Edit image alt text').click()) - expect(input().value).toBe('Diagram') - }) - - it.each(['read-only', 'destroyed'] as const)( - 'rejects a mounted reset button click before React rerenders for %s', - (state) => { - const reset = button('Reset image size') - const original = editor.state.doc - act(() => { - if (state === 'read-only') editor.setEditable(false) - else editor.destroy() - reset.click() - }) - expect(editor.state.doc.eq(original)).toBe(true) - } - ) - - it('shows only the image toolbar for an image selected inside a table', async () => { - await act(async () => { - editor.commands.setContent( - '

Header

' - ) - let imagePos = -1 - editor.state.doc.descendants((node, pos) => { - if (node.type.name === 'image') imagePos = pos - }) - expect(imagePos).toBeGreaterThan(-1) - editor.commands.setNodeSelection(imagePos) - vi.advanceTimersToNextFrame() - }) - expect(viewport.querySelector('[aria-label="Table editing"]')).toBeNull() - expect(button('Edit image alt text')).toBeTruthy() - key(editor.view.dom, 'F10', { altKey: true }) - await act(async () => vi.advanceTimersToNextFrame()) - expect(document.activeElement).toBe(button('Edit image alt text')) - - await act(async () => { - const cell = editor.state.selection.$from.before(3) - editor.view.dispatch( - editor.state.tr.setSelection(CellSelection.create(editor.state.doc, cell)) - ) - editor.view.focus() - vi.advanceTimersToNextFrame() - }) - expect(viewport.querySelector('[aria-label="Image editing"]')).toBeNull() - expect(viewport.querySelector('[aria-label="Table editing"]')).not.toBeNull() - }) -}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/image-menu.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/image-menu.tsx deleted file mode 100644 index 081b4d00f9f..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/image-menu.tsx +++ /dev/null @@ -1,200 +0,0 @@ -import { useCallback, useEffect, useRef, useState } from 'react' -import { Check, Link, RefreshCw, TypeText, X } from '@sim/emcn/icons' -import type { Node } from '@tiptap/pm/model' -import { NodeSelection, PluginKey } from '@tiptap/pm/state' -import { type Editor, useEditorState } from '@tiptap/react' -import { BubbleMenu } from '@tiptap/react/menus' -import { type ProsemirrorBinding, ySyncPluginKey } from '@tiptap/y-tiptap' -import type { XmlElement } from 'yjs' -import { - createImageTargetGuard, - getImageYTarget, -} from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-target' -import { normalizeLinkHref } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-fidelity' -import { BUBBLE_MENU_CLASS } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/bubble-menu-chrome' -import { - ToolbarButton, - ToolbarDivider, -} from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/toolbar-button' -import { ToolbarInput } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/toolbar-input' -import { useBubbleMenuFloating } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/use-bubble-menu-floating' -import { useEditorToolbar } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/use-editor-toolbar' - -interface ImageBubbleMenuProps { - editor: Editor - scrollContainerRef: React.RefObject -} - -interface ImageDraft { - target: Node | XmlElement - matchesTarget: (node: Node) => boolean - field: 'alt' | 'href' - initial: string - value: string -} - -function selectedImage(editor: Editor) { - if (editor.isDestroyed || !editor.isEditable) return null - const { selection } = editor.state - if (!(selection instanceof NodeSelection) || selection.node.type.name !== 'image') return null - const binding: ProsemirrorBinding | undefined = ySyncPluginKey.getState(editor.state)?.binding - const target = binding ? getImageYTarget(binding, selection.node) : selection.node - return target ? { node: selection.node, target, binding } : null -} - -const shouldShowImageMenu = ({ editor }: { editor: Editor }) => selectedImage(editor) !== null - -/** Image actions use the same floating bar, inline fields, and keyboard navigation as text/table actions. */ -export function ImageBubbleMenu({ editor, scrollContainerRef }: ImageBubbleMenuProps) { - const inputRef = useRef(null) - const [menuKey] = useState(() => new PluginKey('markdownImageMenu')) - const [draft, setDraft] = useState(null) - const selected = useEditorState({ - editor, - selector: ({ editor: current }) => selectedImage(current), - equalityFn: (a, b) => a?.node === b?.node && a?.target === b?.target, - }) - if (draft && draft.target !== selected?.target) setDraft(null) - const currentDraft = draft?.target === selected?.target ? draft : null - const editingField = currentDraft?.field - const hasCustomSize = Boolean(selected?.node.attrs.width || selected?.node.attrs.height) - const normalizedHref = - currentDraft?.field === 'href' ? normalizeLinkHref(currentDraft.value.trim()) : null - const invalidLink = - currentDraft?.field === 'href' && Boolean(currentDraft.value.trim()) && !normalizedHref - - const { appendTo } = useBubbleMenuFloating(editor, scrollContainerRef) - const canFocus = useCallback(() => selectedImage(editor) !== null, [editor]) - const toolbar = useEditorToolbar({ - editor, - pluginKey: menuKey, - canFocus, - roving: !currentDraft, - onEscape: () => setDraft(null), - }) - - const matchesDraftTarget = draft?.matchesTarget - useEffect(() => { - if (!matchesDraftTarget) return - const invalidateDraft = () => { - const image = selectedImage(editor) - if (!image || !matchesDraftTarget(image.node)) setDraft(null) - } - editor.on('transaction', invalidateDraft) - invalidateDraft() - return () => { - editor.off('transaction', invalidateDraft) - } - }, [editor, matchesDraftTarget]) - - useEffect(() => { - if (editingField) inputRef.current?.focus() - }, [editingField]) - - useEffect(() => { - if (!editor.isDestroyed) editor.commands.setMeta(menuKey, 'updatePosition') - }, [editor, menuKey, editingField, hasCustomSize, selected?.target]) - - const close = () => { - setDraft(null) - if (!editor.isDestroyed) editor.commands.focus() - } - const edit = (field: ImageDraft['field']) => { - const image = selectedImage(editor) - if (!image || image.target !== selected?.target) return - const value = typeof image.node.attrs[field] === 'string' ? image.node.attrs[field] : '' - const matchesTarget = image.binding - ? createImageTargetGuard(image.binding, image.node) - : (node: Node) => node === image.node - setDraft({ target: image.target, matchesTarget, field, initial: value, value }) - } - const apply = () => { - if (!currentDraft || invalidLink) return - const image = selectedImage(editor) - if (!image || image.target !== currentDraft.target || !currentDraft.matchesTarget(image.node)) - return - if (currentDraft.value !== currentDraft.initial) { - editor.commands.updateAttributes('image', { - [currentDraft.field]: - currentDraft.field === 'href' ? normalizedHref || null : currentDraft.value, - }) - } - close() - } - - return ( - - ) -} diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/table-menu.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/table-menu.tsx index 2340a50a6a5..4bf332419ba 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/table-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/table-menu.tsx @@ -4,6 +4,7 @@ import { NodeSelection, PluginKey } from '@tiptap/pm/state' import type { Editor } from '@tiptap/react' import { useEditorState } from '@tiptap/react' import { BubbleMenu } from '@tiptap/react/menus' +import { isImageNode } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-node' import { BUBBLE_MENU_CLASS } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/bubble-menu-chrome' import { ToolbarButton, @@ -23,7 +24,7 @@ const shouldShowTableMenu = ({ editor }: { editor: Editor }) => { return ( editor.isEditable && editor.isActive('table') && - !(selection instanceof NodeSelection && selection.node.type.name === 'image') + !(selection instanceof NodeSelection && isImageNode(selection.node)) ) } diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/ordered-list.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/ordered-list.test.ts index ef86feea046..3973917a2f3 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/ordered-list.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/ordered-list.test.ts @@ -154,7 +154,8 @@ describe('ordered-list Markdown boundaries', () => { const original = editor.getJSON() let imageSource: string | undefined editor.state.doc.descendants((node) => { - if (node.type.name === 'image') imageSource = node.attrs.src + if (node.type.name === 'image' || node.type.name === 'inlineImage') + imageSource = node.attrs.src }) expect(imageSource).toBe('https://example.com/photo.png') expect(editor.getMarkdown()).toContain('\\[ref\\] and `[ref]`') diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css index a936b509ffe..c977cce38f7 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css @@ -48,7 +48,7 @@ .rich-markdown-nodes .ProseMirror-selectednode img { outline: 2px solid var(--brand-secondary); - outline-offset: 2px; + outline-offset: -2px; } /* The inline mention chip isn't a block leaf, so it skips the heavy outline ring above and just uses diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx index 4abb0f5b703..758e5bf640a 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx @@ -46,6 +46,7 @@ import { createMarkdownEditorExtensions } from '@/app/workspace/[workspaceId]/fi import { useMarkdownFind } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/find' import { findHeadingPos } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/heading-anchors' import { moveDraggedImageNode } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-drag-move' +import { imageTypeAt } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-node' import { extractImageFiles, findHostedImageAttrs, @@ -68,7 +69,6 @@ import { parseMarkdownToDoc } from '@/app/workspace/[workspaceId]/files/componen import { isPlainTextPaste } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-paste' import { useEditorMentions } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/mention' import { EditorBubbleMenu } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/bubble-menu' -import { ImageBubbleMenu } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/image-menu' import { LinkHoverCard } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/link-hover-card' import { TableBubbleMenu } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/table-menu' import { normalizeMarkdownContent } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/normalize-content' @@ -647,7 +647,13 @@ export function LoadedRichMarkdownEditor({ const matchedAttrs = findHostedImageAttrs(editor.state.doc, imgSrcs, source.resolveImageSrc) if (!matchedAttrs) return false try { - return editor.chain().insertContentAt(range, { type: 'image', attrs: matchedAttrs }).run() + return editor + .chain() + .insertContentAt(range, { + type: imageTypeAt(editor.state.doc, range.from), + attrs: matchedAttrs, + }) + .run() } catch { return false } @@ -1463,7 +1469,6 @@ export function LoadedRichMarkdownEditor({ /> )} {editor && } - {editor && } {editor && } null, }) ) -vi.mock( - '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/image-menu', - () => ({ ImageBubbleMenu: () => null }) -) let root: Root let container: HTMLDivElement @@ -45,7 +41,35 @@ afterEach(async () => { vi.restoreAllMocks() }) -describe('shared field paste admission with real extensions', () => { +describe('shared field with real extensions', () => { + it.each(['', '## '])( + 'selects a %s image with a resize handle but no image menu', + async (prefix) => { + const onChange = vi.fn() + await act(async () => + root.render( + + ) + ) + const element = container.querySelector('.tiptap')! + const editor = element.editor + const before = editor.getJSON() + let imagePosition = -1 + editor.state.doc.descendants((node, pos) => { + if (node.type.name === 'image' || node.type.name === 'inlineImage') imagePosition = pos + }) + expect(imagePosition).toBeGreaterThan(-1) + await act(async () => editor.commands.setNodeSelection(imagePosition)) + expect(container.querySelector('[aria-label="Image editing"]')).toBeNull() + expect(container.querySelector('[aria-label="Resize image"]')).not.toBeNull() + expect(editor.getJSON()).toEqual(before) + expect(onChange).not.toHaveBeenCalled() + } + ) + it.each([false, true])('counts preserved frontmatter (near limit=%s)', async (nearLimit) => { const frontmatter = `---\ndescription: ${'f'.repeat(nearLimit ? 900 : 10)}\n---\n\n` const body = 'x'.repeat(PASTE_RENDER_THRESHOLDS.ENHANCED_TEXT_CHARACTERS - 1000) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx index 9fef6356d62..9ee81b4dad2 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx @@ -11,7 +11,6 @@ import { finishImageUpload, removeImageUpload, } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-upload' -import { ImageBubbleMenu } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/image-menu' import { assessRawMarkdownPaste } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/paste-admission' import { createMarkdownEditorExtensions } from './editor-extensions' import { moveDraggedImageNode } from './image-drag-move' @@ -412,9 +411,6 @@ function LoadedRichMarkdownField({ /> )} {editor && } - {editor && ( - - )} {uploadImage && ( { 'Before ![Image](/image.png) after\n===', '> # Before ![Image](/image.png) after', '- # Before ![Image](/image.png) after', + ])('keeps images within headings editable: %s', (source) => { + expect(isRoundTripSafe(source)).toBe(true) + }) + + it.each([ '| Header |\n| --- |\n| Before ![Image](/image.png) after |', '| Before ![Image](/image.png) after |\n| --- |\n| Cell |', '| Header |\n| --- |\n| [![Image](/image.png)](/destination) |', '| Header |\n| --- |\n| ![Image][image] |\n\n[image]: /image.png', '| Header |\n| --- |\n| Before after |', - ])('preserves unsupported heading or table images in source mode: %s', (source) => { + ])('preserves unsupported table images in source mode: %s', (source) => { expect(isRoundTripSafe(source)).toBe(false) expect(normalizeMarkdownContent(source)).toBe(source) }) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/round-trip-safety.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/round-trip-safety.ts index 795afbf0613..2822c079efd 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/round-trip-safety.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/round-trip-safety.ts @@ -91,8 +91,8 @@ function imageSources(token: Token): string[] { * adjacent equal link marks can merge losslessly. Task references are conservatively source-only: * their parser resolves definitions before a task but loses definitions appearing after it, so * rearranging otherwise valid Markdown can silently remove a destination. - * Images parsed inside headings or table-cell paragraphs violate their inline-only content and are - * discarded on Yjs hydration. Frontmatter is stored separately, not interpreted as Markdown. + * Table images remain source-only until their parsing and editing paths preserve them consistently. + * Frontmatter is stored separately, not interpreted as Markdown. */ function inspectMarkdownFidelity(content: string) { const targets = new Map() @@ -114,7 +114,7 @@ function inspectMarkdownFidelity(content: string) { if (token.type === 'html') preservedQuotes += inspectHtmlImages(token.raw).quotedEntities if (token.type === 'code' || token.type === 'codespan') preservedQuotes += token.raw.match(/"/g)?.length ?? 0 - if (token.type === 'heading' || token.type === 'table') { + if (token.type === 'table') { fidelityLexer.walkTokens([token], (child) => { if (child.type === 'image' || (child.type === 'html' && /^])/i.test(child.raw))) hasUnsupportedImageContext = true diff --git a/packages/realtime-protocol/src/file-doc.ts b/packages/realtime-protocol/src/file-doc.ts index c1cef7c4398..18e22e2e81c 100644 --- a/packages/realtime-protocol/src/file-doc.ts +++ b/packages/realtime-protocol/src/file-doc.ts @@ -44,7 +44,8 @@ export const FILE_DOC_EVENTS = { /** Schema assumed for peers from before schema negotiation was added. */ export const FILE_DOC_LEGACY_SCHEMA_VERSION = 1 -export const FILE_DOC_SCHEMA_VERSION = 1 +/** Version 2 adds inline images; version-1 peers would remove them during hydration. */ +export const FILE_DOC_SCHEMA_VERSION = 2 /** * The tag carried in the first varUint of a {@link FILE_DOC_EVENTS.MESSAGE}