Skip to content

Commit e035cfa

Browse files
committed
fix(file-editor): preserve pasted content and literal markdown
1 parent 7aee676 commit e035cfa

25 files changed

Lines changed: 745 additions & 1065 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: 40 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,44 @@ afterEach(async () => {
231233
})
232234

233235
describe('loaded rich editor lifecycle', () => {
236+
it.each(['paste', 'drop'] as const)(
237+
'%s uploads a standalone display-only image from another document',
238+
async (method) => {
239+
await render('before TARGET after')
240+
const editor = getEditor()
241+
await act(async () => editor.commands.setTextSelection({ from: 8, to: 14 }))
242+
if (method === 'drop')
243+
vi.spyOn(editor.view, 'posAtCoords').mockReturnValue({ pos: 8, inside: 0 })
244+
const src = '/api/workspaces/another-workspace/files/inline?fileId=another-image'
245+
const image = new File(['image'], 'image.png', { type: 'image/png' })
246+
const html = `<img src="${window.location.origin}${src}">`
247+
uploadFile.mockResolvedValueOnce({ file: { url: '/api/files/view/uploaded-image' } })
248+
const event = new MouseEvent(method, { bubbles: true, cancelable: true })
249+
Object.defineProperty(event, method === 'paste' ? 'clipboardData' : 'dataTransfer', {
250+
value: {
251+
files: [image],
252+
items: [],
253+
types: ['Files', 'text/html'],
254+
getData: (type: string) => (type === 'text/html' ? html : ''),
255+
},
256+
})
257+
await act(async () => editor.view.dom.dispatchEvent(event))
258+
expect(event.defaultPrevented).toBe(true)
259+
expect(uploadFile).toHaveBeenCalledExactlyOnceWith({
260+
workspaceId: FILE.workspaceId,
261+
file: image,
262+
folderId: null,
263+
})
264+
expect(editor.getMarkdown()).toContain('/api/files/view/uploaded-image')
265+
expect(editor.getMarkdown()).not.toContain('/inline?')
266+
const storedSrc = editor.getJSON().content?.find((node) => node.type === 'image')?.attrs?.src
267+
expect(extractEmbeddedFileRef(storedSrc)).toEqual({ fileId: 'uploaded-image' })
268+
expect(editor.state.doc.textContent).toBe(
269+
method === 'paste' ? 'before after' : 'before TARGET after'
270+
)
271+
}
272+
)
273+
234274
it('captures immediate collaborative edits with current shared frontmatter without saving', async () => {
235275
const provider = new FakeFileDocProvider()
236276
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: 64 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,67 @@ async function submit(
8182
}
8283

8384
describe('field upload completion boundary', () => {
85+
it('replaces the selected range only after a pasted image finishes uploading', async () => {
86+
const pending = Promise.withResolvers<{ url: string; alt: string }>()
87+
upload.mockReturnValueOnce(pending.promise)
88+
await render()
89+
const owner = editor()
90+
const before = owner.getJSON()
91+
await submit('paste', owner, undefined, { from: 8, to: 14 })
92+
expect(owner.getJSON()).toEqual(before)
93+
await act(async () =>
94+
pending.resolve({ url: 'https://sim.ai/replacement.png', alt: 'Replacement' })
95+
)
96+
expect(owner.state.doc.textContent).toBe('before after')
97+
expect(host.querySelectorAll('img')).toHaveLength(1)
98+
await act(async () => owner.commands.undo())
99+
expect(owner.getJSON()).toEqual(before)
100+
})
101+
102+
it.each(['paste', 'drop'] as const)(
103+
'%s preserves the whole HTML slice when the payload also contains a bitmap file',
104+
async (method) => {
105+
await render()
106+
const owner = editor()
107+
await act(async () => owner.commands.setTextSelection({ from: 8, to: 14 }))
108+
if (method === 'drop')
109+
vi.spyOn(owner.view, 'posAtCoords').mockReturnValue({ pos: 8, inside: 0 })
110+
const html =
111+
'<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>'
112+
const transfer = {
113+
files: [new File(['image'], 'image.png', { type: 'image/png' })],
114+
items: [],
115+
types: ['text/html', 'text/plain', 'Files'],
116+
getData: (type: string) =>
117+
type === 'text/html' ? html : type === 'text/plain' ? 'CaptionTail' : '',
118+
}
119+
const event = new MouseEvent(method, { bubbles: true, cancelable: true })
120+
Object.defineProperty(event, method === 'paste' ? 'clipboardData' : 'dataTransfer', {
121+
value: transfer,
122+
})
123+
await act(async () => owner.view.dom.dispatchEvent(event))
124+
expect(event.defaultPrevented).toBe(true)
125+
expect(upload).not.toHaveBeenCalled()
126+
expect(host.querySelector('h2')?.textContent).toBe('Caption')
127+
expect(owner.state.doc.textContent).toContain('Tail')
128+
expect(Array.from(host.querySelectorAll('img')).map((image) => image.alt)).toEqual([
129+
'One',
130+
'Two',
131+
])
132+
const images: Array<{ src: string; width: string; href: string | null }> = []
133+
owner.state.doc.descendants((node) => {
134+
if (node.type.name === 'image' || node.type.name === 'inlineImage') {
135+
images.push({ src: node.attrs.src, width: node.attrs.width, href: node.attrs.href })
136+
}
137+
})
138+
expect(images).toEqual([
139+
{ src: 'https://sim.ai/one.png', width: '123', href: 'https://sim.ai/link' },
140+
{ src: 'https://sim.ai/two.png', width: '234', href: null },
141+
])
142+
expect(() => owner.state.doc.check()).not.toThrow()
143+
}
144+
)
145+
84146
it('invalidates an upload even when streaming ends before completion', async () => {
85147
const pending = Promise.withResolvers<{ url: string; alt: string }>()
86148
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)