Skip to content

Commit 305b5ef

Browse files
authored
fix(file-editor): preserve pasted content and literal markdown (#7641)
* fix(file-editor): preserve pasted content and literal markdown * fix(file-editor): retain rich fragments through image uploads
1 parent 24aa920 commit 305b5ef

27 files changed

Lines changed: 941 additions & 1072 deletions

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/bullet-list.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,14 @@ export const JoiningBulletList = BulletList.extend({
1010
addInputRules() {
1111
return joinListInputRules(this.parent?.() ?? [], this.type)
1212
},
13+
renderMarkdown(node, helpers, context) {
14+
const firstParagraph = node.content?.[0]?.content?.[0]
15+
const startsEmpty = firstParagraph?.type === 'paragraph' && !firstParagraph.content?.length
16+
const nested = context.parentType === 'listItem' || context.parentType === 'taskItem'
17+
const followsText =
18+
context.previousNode?.type === 'paragraph' && Boolean(context.previousNode.content?.length)
19+
const rendered = helpers.renderChildren(node.content ?? [], '\n')
20+
/** Separate an opening empty bullet from its parent's text so it cannot become a Setext heading. */
21+
return nested && startsEmpty && followsText ? `\n${rendered}` : rendered
22+
},
1323
})

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/editor-lifecycle.test.tsx

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { exportWorkspaceFileSnapshotBodySchema } from '@/lib/api/contracts/works
1212
import { SIM_SELECTION_MIME } from '@/lib/copilot/chat/selection-clipboard'
1313
import type { FileDownloadSource } from '@/lib/uploads/client/download'
1414
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
15+
import { extractEmbeddedFileRef } from '@/lib/uploads/utils/embedded-image-ref'
1516
import { useFileDocCollaboration } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/use-file-doc-collaboration'
1617
import { createMarkdownContentExtensions } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions'
1718
import { ImageUploadPlaceholders } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-upload'
@@ -215,6 +216,7 @@ async function pasteImage(editor: Editor) {
215216

216217
beforeEach(() => {
217218
vi.clearAllMocks()
219+
uploadFile.mockReset()
218220
collaborationRef.current = null
219221
vi.spyOn(toast, 'warning').mockReturnValue('test-toast')
220222
vi.spyOn(toast, 'info').mockReturnValue('uploading-toast')
@@ -231,6 +233,58 @@ afterEach(async () => {
231233
})
232234

233235
describe('loaded rich editor lifecycle', () => {
236+
it.each([
237+
{ method: 'paste', caption: false },
238+
{ method: 'drop', caption: false },
239+
{ method: 'paste', caption: true },
240+
{ method: 'drop', caption: true },
241+
] as const)(
242+
'$method uploads a display-only image from another document (caption: $caption)',
243+
async ({ method, caption }) => {
244+
await render('before TARGET after')
245+
const editor = getEditor()
246+
await act(async () => editor.commands.setTextSelection({ from: 8, to: 14 }))
247+
if (method === 'drop')
248+
vi.spyOn(editor.view, 'posAtCoords').mockReturnValue({ pos: 8, inside: 0 })
249+
const src = '/api/workspaces/another-workspace/files/inline?fileId=another-image'
250+
const image = new File(['image'], 'image.png', { type: 'image/png' })
251+
const img = `<img src="${window.location.origin}${src}" alt="Original" width="140">`
252+
const html = caption ? `<p>Caption<a href="/destination">${img}</a></p><p>Tail</p>` : img
253+
uploadFile.mockResolvedValueOnce({ file: { url: '/api/files/view/uploaded-image' } })
254+
const event = new MouseEvent(method, { bubbles: true, cancelable: true })
255+
Object.defineProperty(event, method === 'paste' ? 'clipboardData' : 'dataTransfer', {
256+
value: {
257+
files: [image],
258+
items: [],
259+
types: ['Files', 'text/html'],
260+
getData: (type: string) => (type === 'text/html' ? html : ''),
261+
},
262+
})
263+
await act(async () => editor.view.dom.dispatchEvent(event))
264+
expect(event.defaultPrevented).toBe(true)
265+
expect(uploadFile).toHaveBeenCalledExactlyOnceWith({
266+
workspaceId: FILE.workspaceId,
267+
file: image,
268+
folderId: null,
269+
})
270+
expect(editor.getMarkdown()).toContain('/api/files/view/uploaded-image')
271+
expect(editor.getMarkdown()).not.toContain('/inline?')
272+
let storedSrc = ''
273+
editor.state.doc.descendants((node) => {
274+
if (node.type.name === 'image' || node.type.name === 'inlineImage')
275+
storedSrc = node.attrs.src
276+
})
277+
expect(extractEmbeddedFileRef(storedSrc)).toEqual({ fileId: 'uploaded-image' })
278+
expect(editor.state.doc.textContent).toBe(
279+
`before ${caption ? 'CaptionTail' : ''}${method === 'paste' ? '' : 'TARGET'} after`
280+
)
281+
expect(editor.view.dom.querySelector('img')?.getAttribute('alt')).toBe('Original')
282+
expect(editor.getMarkdown()).toContain('width="140"')
283+
if (caption)
284+
expect(editor.view.dom.querySelector('a')?.getAttribute('href')).toBe('/destination')
285+
}
286+
)
287+
234288
it('captures immediate collaborative edits with current shared frontmatter without saving', async () => {
235289
const provider = new FakeFileDocProvider()
236290
const doc = new Y.Doc()

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,16 @@ const BlockSafeParagraph = Paragraph.extend({
174174
},
175175
renderMarkdown: (node: JSONContent, h, context) => {
176176
if (!node.content?.length && context.parentType === 'blockquote') return '<p></p>'
177-
const rendered = h.renderChildren(node.content ?? [])
177+
let rendered = h.renderChildren(node.content ?? [])
178+
const first = node.content?.[0]
179+
if (
180+
context.parentType === 'blockquote' &&
181+
first?.type === 'text' &&
182+
!first.marks?.length &&
183+
/^\[![A-Za-z]+\]/.test(first.text ?? '')
184+
) {
185+
rendered = rendered.replace(/^\\\[!([A-Za-z]+)\\\]/, '[!$1]')
186+
}
178187
let codeDelimiter = 0
179188
return rendered
180189
.split('\n')

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/field-upload.test.tsx

Lines changed: 109 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,9 +65,10 @@ function editor() {
6565
async function submit(
6666
method: 'paste' | 'drop',
6767
target: Editor,
68-
files = [new File(['image'], 'image.png', { type: 'image/png' })]
68+
files = [new File(['image'], 'image.png', { type: 'image/png' })],
69+
selection: number | { from: number; to: number } = 8
6970
) {
70-
await act(async () => target.commands.setTextSelection(8))
71+
await act(async () => target.commands.setTextSelection(selection))
7172
if (method === 'drop') vi.spyOn(target.view, 'posAtCoords').mockReturnValue({ pos: 8, inside: 0 })
7273
const transfer = { files, items: [], types: ['Files'], getData: () => '' }
7374
const event = new Event(method, { bubbles: true, cancelable: true })
@@ -81,6 +82,112 @@ async function submit(
8182
}
8283

8384
describe('field upload completion boundary', () => {
85+
it.each(['paste', 'drop'] as const)(
86+
'%s uploads a non-portable image without losing its accompanying fragment',
87+
async (method) => {
88+
const pending = Promise.withResolvers<{ url: string; alt: string }>()
89+
upload.mockReturnValueOnce(pending.promise)
90+
await render()
91+
const owner = editor()
92+
const before = owner.getJSON()
93+
await act(async () => owner.commands.setTextSelection({ from: 8, to: 14 }))
94+
if (method === 'drop')
95+
vi.spyOn(owner.view, 'posAtCoords').mockReturnValue({ pos: 8, inside: 0 })
96+
const html =
97+
'<p>Lead</p><h2>Caption</h2><p><a href="/destination"><img src="/api/workspaces/source/files/inline?fileId=image" alt="Original alt" width="123"></a><strong>Tail</strong><img src="/other.png" alt="Other"></p>'
98+
const event = new MouseEvent(method, { bubbles: true, cancelable: true })
99+
Object.defineProperty(event, method === 'paste' ? 'clipboardData' : 'dataTransfer', {
100+
value: {
101+
files: [new File(['image'], 'image.png', { type: 'image/png' })],
102+
items: [],
103+
types: ['Files', 'text/html'],
104+
getData: (type: string) => (type === 'text/html' ? html : ''),
105+
},
106+
})
107+
await act(async () => owner.view.dom.dispatchEvent(event))
108+
expect(upload).toHaveBeenCalledOnce()
109+
expect(owner.getJSON()).toEqual(before)
110+
await act(async () => owner.commands.insertContentAt(1, 'prefix '))
111+
await act(async () => pending.resolve({ url: '/api/files/view/uploaded', alt: 'New alt' }))
112+
expect(owner.state.doc.textContent).toBe(
113+
method === 'paste'
114+
? 'prefix before LeadCaptionTail after'
115+
: 'prefix before LeadCaptionTailTARGET after'
116+
)
117+
expect(host.querySelector('h2')?.textContent).toBe('Caption')
118+
expect(host.querySelector('strong')?.textContent).toBe('Tail')
119+
expect(host.querySelector('a')?.getAttribute('href')).toBe('/destination')
120+
expect(
121+
Array.from(host.querySelectorAll('img')).map((image) => image.getAttribute('src'))
122+
).toEqual(['/api/files/view/uploaded', '/other.png'])
123+
expect(host.querySelector('img')?.getAttribute('alt')).toBe('Original alt')
124+
expect(owner.getMarkdown()).toContain('width="123"')
125+
expect(owner.getMarkdown()).not.toContain('/inline?')
126+
expect(() => owner.state.doc.check()).not.toThrow()
127+
}
128+
)
129+
130+
it('replaces the selected range only after a pasted image finishes uploading', async () => {
131+
const pending = Promise.withResolvers<{ url: string; alt: string }>()
132+
upload.mockReturnValueOnce(pending.promise)
133+
await render()
134+
const owner = editor()
135+
const before = owner.getJSON()
136+
await submit('paste', owner, undefined, { from: 8, to: 14 })
137+
expect(owner.getJSON()).toEqual(before)
138+
await act(async () =>
139+
pending.resolve({ url: 'https://sim.ai/replacement.png', alt: 'Replacement' })
140+
)
141+
expect(owner.state.doc.textContent).toBe('before after')
142+
expect(host.querySelectorAll('img')).toHaveLength(1)
143+
await act(async () => owner.commands.undo())
144+
expect(owner.getJSON()).toEqual(before)
145+
})
146+
147+
it.each(['paste', 'drop'] as const)(
148+
'%s preserves the whole HTML slice when the payload also contains a bitmap file',
149+
async (method) => {
150+
await render()
151+
const owner = editor()
152+
await act(async () => owner.commands.setTextSelection({ from: 8, to: 14 }))
153+
if (method === 'drop')
154+
vi.spyOn(owner.view, 'posAtCoords').mockReturnValue({ pos: 8, inside: 0 })
155+
const html =
156+
'<p>Lead</p><h2>Caption</h2><p><a href="https://sim.ai/link"><img src="https://sim.ai/one.png" alt="One" width="123"></a>Tail<img src="https://sim.ai/two.png" alt="Two" width="234"></p>'
157+
const transfer = {
158+
files: [new File(['image'], 'image.png', { type: 'image/png' })],
159+
items: [],
160+
types: ['text/html', 'text/plain', 'Files'],
161+
getData: (type: string) =>
162+
type === 'text/html' ? html : type === 'text/plain' ? 'CaptionTail' : '',
163+
}
164+
const event = new MouseEvent(method, { bubbles: true, cancelable: true })
165+
Object.defineProperty(event, method === 'paste' ? 'clipboardData' : 'dataTransfer', {
166+
value: transfer,
167+
})
168+
await act(async () => owner.view.dom.dispatchEvent(event))
169+
expect(event.defaultPrevented).toBe(true)
170+
expect(upload).not.toHaveBeenCalled()
171+
expect(host.querySelector('h2')?.textContent).toBe('Caption')
172+
expect(owner.state.doc.textContent).toContain('Tail')
173+
expect(Array.from(host.querySelectorAll('img')).map((image) => image.alt)).toEqual([
174+
'One',
175+
'Two',
176+
])
177+
const images: Array<{ src: string; width: string; href: string | null }> = []
178+
owner.state.doc.descendants((node) => {
179+
if (node.type.name === 'image' || node.type.name === 'inlineImage') {
180+
images.push({ src: node.attrs.src, width: node.attrs.width, href: node.attrs.href })
181+
}
182+
})
183+
expect(images).toEqual([
184+
{ src: 'https://sim.ai/one.png', width: '123', href: 'https://sim.ai/link' },
185+
{ src: 'https://sim.ai/two.png', width: '234', href: null },
186+
])
187+
expect(() => owner.state.doc.check()).not.toThrow()
188+
}
189+
)
190+
84191
it('invalidates an upload even when streaming ends before completion', async () => {
85192
const pending = Promise.withResolvers<{ url: string; alt: string }>()
86193
upload.mockReturnValueOnce(pending.promise)

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/heading-image.test.ts

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { FILE_DOC_SEED } from '@sim/realtime-protocol/file-doc'
33
import { Editor, getSchema } from '@tiptap/core'
44
import { DOMParser, DOMSerializer } from '@tiptap/pm/model'
55
import { NodeSelection } from '@tiptap/pm/state'
6-
import { afterEach, describe, expect, it, vi } from 'vitest'
6+
import { afterEach, describe, expect, it } from 'vitest'
77
import * as Y from 'yjs'
88
import { markdownToYDoc, yDocToFileMarkdown, yDocToMarkdown } from '@/lib/collab-doc/converter'
99
import {
@@ -13,7 +13,7 @@ import {
1313
} from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/apply-streamed-markdown'
1414
import { FileCollaboration } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-collaboration'
1515
import { createMarkdownContentExtensions } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions'
16-
import { moveDraggedImageNode } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-drag-move'
16+
import { dispatchEditorDrop } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-drop.test-helpers'
1717
import { isImageNode } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-node'
1818
import {
1919
beginImageUploads,
@@ -333,13 +333,7 @@ describe('heading images', () => {
333333
seed.destroy()
334334
const originalAttrs = a.editor.state.doc.nodeAt(imagePositions(a.editor)[0])?.attrs
335335
a.editor.commands.setNodeSelection(imagePositions(a.editor)[0])
336-
vi.spyOn(a.editor.view, 'posAtCoords').mockReturnValue({ pos: 8, inside: 0 })
337-
expect(
338-
moveDraggedImageNode(a.editor.view, new MouseEvent('drop') as DragEvent, {
339-
images: [],
340-
html: '<img src="/logo.png">',
341-
})
342-
).toBe(true)
336+
expect(dispatchEditorDrop(a.editor, 8).defaultPrevented).toBe(true)
343337
expect(a.editor.state.doc.nodeAt(8)?.type.name).toBe('inlineImage')
344338
expect(a.editor.state.doc.nodeAt(8)?.attrs).toEqual(originalAttrs)
345339
b.editor.commands.insertContentAt(b.editor.state.doc.content.size - 1, ' preserved')

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-collaboration.test.tsx

Lines changed: 3 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import {
1313
ResizableImage,
1414
ResizableInlineImage,
1515
} from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image'
16-
import { moveDraggedImageNode } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-drag-move'
16+
import { dispatchEditorDrop } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-drop.test-helpers'
1717
import { isImageNode } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-node'
1818

1919
let host: HTMLDivElement
@@ -126,16 +126,8 @@ async function addPeerSibling(sameSource = true): Promise<number> {
126126
}
127127

128128
function movePeerImage(from: number, to: number): void {
129-
const image = peer.state.doc.nodeAt(from)!
130129
peer.commands.setNodeSelection(from)
131-
vi.spyOn(peer.view, 'posAtCoords').mockReturnValue({ pos: to, inside: 0 })
132-
expect(
133-
moveDraggedImageNode(
134-
peer.view,
135-
new MouseEvent('drop', { clientX: 0, clientY: 0, cancelable: true }) as DragEvent,
136-
{ images: [], html: `<img src="${image.attrs.src}">` }
137-
)
138-
).toBe(true)
130+
expect(dispatchEditorDrop(peer, to).defaultPrevented).toBe(true)
139131
}
140132

141133
async function setNestedImages(depth: number): Promise<void> {
@@ -220,15 +212,9 @@ describe('image resizing during real peer Yjs updates', () => {
220212
local.setOptions({ editorProps: { handleScrollToSelection: () => false } })
221213
yUndoPluginKey.getState(local.state).undoManager.clear()
222214
const dropPosition = target === 'heading' ? 8 : imagePosition(local) + 5
223-
vi.spyOn(local.view, 'posAtCoords').mockReturnValue({ pos: dropPosition, inside: 0 })
224215
await act(async () => {
225216
local.view.focus()
226-
expect(
227-
moveDraggedImageNode(local.view, new MouseEvent('drop') as DragEvent, {
228-
images: [],
229-
html: '<img src="https://sim.ai/image.png">',
230-
})
231-
).toBe(true)
217+
expect(dispatchEditorDrop(local, dropPosition).defaultPrevented).toBe(true)
232218
})
233219
expect(local.state.selection).toBeInstanceOf(NodeSelection)
234220
expect(host.querySelector(`${target === 'heading' ? 'h2' : 'p'} img`)).not.toBeNull()

0 commit comments

Comments
 (0)