Skip to content

Commit b22cee6

Browse files
committed
fix(files): preserve drag targets and valid collaborative selections
1 parent e3c3618 commit b22cee6

7 files changed

Lines changed: 205 additions & 15 deletions

File tree

‎apps/realtime/src/handlers/file-doc.join-readiness.test.ts‎

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import {
1313
FILE_DOC_EVENTS,
1414
FILE_DOC_MESSAGE_TYPE,
15+
FILE_DOC_SCHEMA_VERSION,
1516
FILE_DOC_SEED,
1617
} from '@sim/realtime-protocol/file-doc'
1718
import * as decoding from 'lib0/decoding'
@@ -272,7 +273,15 @@ describe('file-doc join readiness (shared store enabled)', () => {
272273
backing.readDelayTicks = 6
273274
const { socket, handlers } = setup('socket-1', sockets)
274275

275-
await handlers[FILE_DOC_EVENTS.JOIN]({ fileId: FILE_ID, clientId: 1 })
276+
await handlers[FILE_DOC_EVENTS.JOIN]({
277+
fileId: FILE_ID,
278+
clientId: 1,
279+
schemaVersion: FILE_DOC_SCHEMA_VERSION,
280+
})
281+
expect(socket.emit).toHaveBeenCalledWith(
282+
FILE_DOC_EVENTS.JOIN_SUCCESS,
283+
expect.objectContaining({ fileId: FILE_ID, schemaVersion: FILE_DOC_SCHEMA_VERSION })
284+
)
276285
requestSyncStep2(handlers)
277286
await flushPendingWork()
278287

@@ -283,9 +292,17 @@ describe('file-doc join readiness (shared store enabled)', () => {
283292

284293
it('does not fetch a seed for a room the stream can already reconstruct', async () => {
285294
seedWarmStreamHistory()
286-
const { handlers } = setup('socket-1', sockets)
295+
const { socket, handlers } = setup('socket-1', sockets)
287296

288-
await handlers[FILE_DOC_EVENTS.JOIN]({ fileId: FILE_ID, clientId: 1 })
297+
await handlers[FILE_DOC_EVENTS.JOIN]({
298+
fileId: FILE_ID,
299+
clientId: 1,
300+
schemaVersion: FILE_DOC_SCHEMA_VERSION,
301+
})
302+
expect(socket.emit).toHaveBeenCalledWith(
303+
FILE_DOC_EVENTS.JOIN_SUCCESS,
304+
expect.objectContaining({ fileId: FILE_ID, schemaVersion: FILE_DOC_SCHEMA_VERSION })
305+
)
289306

290307
expect(mockFetchFileDocSeed).not.toHaveBeenCalled()
291308
})
@@ -302,7 +319,15 @@ describe('file-doc join readiness (shared store enabled)', () => {
302319
doc.destroy()
303320

304321
const { socket, handlers } = setup('socket-1', sockets)
305-
await handlers[FILE_DOC_EVENTS.JOIN]({ fileId: FILE_ID, clientId: 1 })
322+
await handlers[FILE_DOC_EVENTS.JOIN]({
323+
fileId: FILE_ID,
324+
clientId: 1,
325+
schemaVersion: FILE_DOC_SCHEMA_VERSION,
326+
})
327+
expect(socket.emit).toHaveBeenCalledWith(
328+
FILE_DOC_EVENTS.JOIN_SUCCESS,
329+
expect.objectContaining({ fileId: FILE_ID, schemaVersion: FILE_DOC_SCHEMA_VERSION })
330+
)
306331
requestSyncStep2(handlers)
307332
await flushPendingWork()
308333

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import Collaboration from '@tiptap/extension-collaboration'
2+
import { NodeSelection, Plugin, Selection } from '@tiptap/pm/state'
3+
4+
/**
5+
* Yjs can resolve a restored node selection into text after a structural edit.
6+
* Normalize that invalid selection before ProseMirror renders or scrolls it.
7+
* @see https://github.com/ueberdosis/y-tiptap/blob/main/src/plugins/sync-plugin.js
8+
*/
9+
export const FileCollaboration = Collaboration.extend({
10+
addProseMirrorPlugins() {
11+
return [
12+
...(this.parent?.() ?? []),
13+
new Plugin({
14+
appendTransaction: (_transactions, _oldState, state) => {
15+
const { selection } = state
16+
if (selection instanceof NodeSelection && !NodeSelection.isSelectable(selection.node)) {
17+
return state.tr.setSelection(Selection.near(selection.$from))
18+
}
19+
return null
20+
},
21+
}),
22+
]
23+
},
24+
})

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import type { Extensions } from '@tiptap/core'
2-
import Collaboration from '@tiptap/extension-collaboration'
32
import CollaborationCaret from '@tiptap/extension-collaboration-caret'
43
import Placeholder from '@tiptap/extension-placeholder'
54
import type { Awareness } from 'y-protocols/awareness'
@@ -13,6 +12,7 @@ import {
1312
DEFAULT_CARET_COLOR,
1413
renderCaret,
1514
} from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/caret-presence'
15+
import { FileCollaboration } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-collaboration'
1616
import { LinkEmbed } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/embed/link-embed'
1717
import { createMarkdownContentExtensions } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions'
1818
import { RichMarkdownFind } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/find'
@@ -82,7 +82,7 @@ export function createMarkdownEditorExtensions({
8282
),
8383
...(collaboration
8484
? [
85-
Collaboration.configure({ document: collaboration.doc }),
85+
FileCollaboration.configure({ document: collaboration.doc }),
8686
// CollaborationCaret reads only `provider.awareness` (created synchronously,
8787
// relayed by the socket provider once connected). `render` tags each caret
8888
// with the peer's client id and shows its name label; the selection tint is

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

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,19 @@
11
/** @vitest-environment jsdom */
22
import { FILE_DOC_SEED } from '@sim/realtime-protocol/file-doc'
33
import { Editor, getSchema } from '@tiptap/core'
4-
import Collaboration from '@tiptap/extension-collaboration'
54
import { DOMParser, DOMSerializer } from '@tiptap/pm/model'
6-
import { afterEach, describe, expect, it } from 'vitest'
5+
import { NodeSelection } from '@tiptap/pm/state'
6+
import { afterEach, describe, expect, it, vi } from 'vitest'
77
import * as Y from 'yjs'
88
import { markdownToYDoc, yDocToFileMarkdown, yDocToMarkdown } from '@/lib/collab-doc/converter'
99
import {
1010
applyAgentStreamFrame,
1111
beginAgentStream,
1212
endAgentStream,
1313
} from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/apply-streamed-markdown'
14+
import { FileCollaboration } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-collaboration'
1415
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'
1517
import { isImageNode } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-node'
1618
import {
1719
beginImageUploads,
@@ -51,7 +53,7 @@ function peer(seed: Y.Doc) {
5153
extensions: [
5254
...createMarkdownContentExtensions({}, { disableHistory: true }),
5355
ImageUploadPlaceholders,
54-
Collaboration.configure({ document: doc }),
56+
FileCollaboration.configure({ document: doc }),
5557
],
5658
editorProps: { handleScrollToSelection: () => true },
5759
})
@@ -320,6 +322,47 @@ describe('heading images', () => {
320322
expect(imagePositions(reopened.editor)).toHaveLength(1)
321323
})
322324

325+
it.each(['# ', ''])(
326+
'preserves a block-to-inline move, peer text, and undo through collaboration: %s',
327+
(prefix) => {
328+
const seed = markdownToYDoc(
329+
`${prefix}Before after\n\n[<img src="/logo.png" alt="Logo" width="287">](/target "Destination")\n\nPeer text`
330+
)
331+
const a = peer(seed)
332+
const b = peer(seed)
333+
seed.destroy()
334+
const originalAttrs = a.editor.state.doc.nodeAt(imagePositions(a.editor)[0])?.attrs
335+
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)
343+
expect(a.editor.state.doc.nodeAt(8)?.type.name).toBe('inlineImage')
344+
expect(a.editor.state.doc.nodeAt(8)?.attrs).toEqual(originalAttrs)
345+
b.editor.commands.insertContentAt(b.editor.state.doc.content.size - 1, ' preserved')
346+
Y.applyUpdate(a.doc, Y.encodeStateAsUpdate(b.doc))
347+
Y.applyUpdate(b.doc, Y.encodeStateAsUpdate(a.doc))
348+
expect(a.editor.getJSON()).toEqual(b.editor.getJSON())
349+
expect(imagePositions(b.editor)).toHaveLength(1)
350+
expect(a.editor.commands.undo()).toBe(true)
351+
if (a.editor.state.selection instanceof NodeSelection) {
352+
expect(NodeSelection.isSelectable(a.editor.state.selection.node)).toBe(true)
353+
}
354+
expect(a.editor.state.doc.textContent).toContain('Peer text preserved')
355+
expect(a.editor.state.doc.nodeAt(imagePositions(a.editor)[0])?.type.name).toBe('image')
356+
expect(a.editor.commands.redo()).toBe(true)
357+
Y.applyUpdate(b.doc, Y.encodeStateAsUpdate(a.doc))
358+
expect(a.editor.getJSON()).toEqual(b.editor.getJSON())
359+
const reopened = peer(b.doc)
360+
expect(reopened.editor.getJSON()).toEqual(a.editor.getJSON())
361+
expect(reopened.editor.state.doc.nodeAt(8)?.attrs).toEqual(originalAttrs)
362+
expect(isRoundTripSafe(yDocToFileMarkdown(b.doc))).toBe(true)
363+
}
364+
)
365+
323366
it('inserts uploaded images into the heading without splitting its text', () => {
324367
const seed = markdownToYDoc('# Before after')
325368
const { editor } = peer(seed)

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

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
/** @vitest-environment jsdom */
22
import { act } from 'react'
3-
import Collaboration from '@tiptap/extension-collaboration'
3+
import { NodeSelection, TextSelection } from '@tiptap/pm/state'
44
import { Editor, EditorContent } from '@tiptap/react'
55
import StarterKit from '@tiptap/starter-kit'
6+
import { yUndoPluginKey } from '@tiptap/y-tiptap'
67
import { createRoot, type Root } from 'react-dom/client'
78
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
89
import * as Y from 'yjs'
910
import { BlockMover } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/block-mover'
11+
import { FileCollaboration } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-collaboration'
1012
import {
1113
ResizableImage,
1214
ResizableInlineImage,
@@ -33,7 +35,7 @@ beforeEach(async () => {
3335
BlockMover,
3436
ResizableImage,
3537
ResizableInlineImage,
36-
Collaboration.configure({ document }),
38+
FileCollaboration.configure({ document }),
3739
],
3840
editorProps: { handleScrollToSelection: () => true },
3941
})
@@ -154,6 +156,61 @@ async function setNestedImages(depth: number): Promise<void> {
154156
}
155157

156158
describe('image resizing during real peer Yjs updates', () => {
159+
it.each(['heading', 'paragraph'])(
160+
'renders and scrolls a valid selection when undoing a move into a %s',
161+
async (target) => {
162+
local.setOptions({ editorProps: { handleScrollToSelection: () => false } })
163+
yUndoPluginKey.getState(local.state).undoManager.clear()
164+
const dropPosition = target === 'heading' ? 8 : imagePosition(local) + 5
165+
vi.spyOn(local.view, 'posAtCoords').mockReturnValue({ pos: dropPosition, inside: 0 })
166+
await act(async () => {
167+
local.view.focus()
168+
expect(
169+
moveDraggedImageNode(local.view, new MouseEvent('drop') as DragEvent, {
170+
images: [],
171+
html: '<img src="https://sim.ai/image.png">',
172+
})
173+
).toBe(true)
174+
})
175+
expect(local.state.selection).toBeInstanceOf(NodeSelection)
176+
expect(host.querySelector(`${target === 'heading' ? 'h2' : 'p'} img`)).not.toBeNull()
177+
peer.commands.insertContentAt(peer.state.doc.content.size - 1, ' preserved')
178+
await receivePeerUpdate()
179+
await act(async () => {
180+
expect(local.commands.undo()).toBe(true)
181+
})
182+
expect(local.state.doc.nodeAt(imagePosition(local))?.type.name).toBe('image')
183+
if (local.state.selection instanceof NodeSelection) {
184+
expect(NodeSelection.isSelectable(local.state.selection.node)).toBe(true)
185+
}
186+
expect(local.state.doc.textContent).toContain('After image preserved')
187+
await act(async () => {
188+
expect(local.commands.redo()).toBe(true)
189+
Y.applyUpdate(peerDoc, Y.encodeStateAsUpdate(localDoc))
190+
})
191+
expect(local.getJSON()).toEqual(peer.getJSON())
192+
expect(host.querySelector(`${target === 'heading' ? 'h2' : 'p'} img`)).not.toBeNull()
193+
}
194+
)
195+
196+
it('normalizes an invalid node selection without changing the document or Yjs history', async () => {
197+
const original = local.getJSON()
198+
const onUpdate = vi.fn()
199+
localDoc.on('update', onUpdate)
200+
yUndoPluginKey.getState(local.state).undoManager.clear()
201+
await act(async () => {
202+
local.view.dispatch(local.state.tr.setSelection(NodeSelection.create(local.state.doc, 3)))
203+
})
204+
expect(local.state.selection).toBeInstanceOf(TextSelection)
205+
expect(local.state.selection.from).toBe(3)
206+
expect(local.getJSON()).toEqual(original)
207+
expect(onUpdate).not.toHaveBeenCalled()
208+
expect(local.can().undo()).toBe(false)
209+
await act(async () => local.commands.setNodeSelection(imagePosition(local)))
210+
expect(local.state.selection).toBeInstanceOf(NodeSelection)
211+
expect(local.state.selection.from).toBe(imagePosition(local))
212+
})
213+
157214
it.each(['image', 'inlineImage'])(
158215
'copies a linked %s with exactly one link wrapper',
159216
async (type) => {

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

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ const schema = new Schema({
2020
doc: { content: 'block+' },
2121
paragraph: { group: 'block', content: 'inline*' },
2222
heading: { group: 'block', content: 'inline*' },
23+
codeBlock: { group: 'block', content: 'text*', code: true },
2324
image: { group: 'block', attrs: { src: {} }, draggable: true },
2425
inlineImage: { group: 'inline', inline: true, attrs: { src: {} }, draggable: true },
2526
text: { group: 'inline' },
@@ -46,7 +47,7 @@ function selectedImageView(): { view: EditorView; dispatched: { state: () => Edi
4647
get state() {
4748
return state
4849
},
49-
posAtCoords: () => ({ pos: 1, inside: 0 }),
50+
posAtCoords: () => ({ pos: 0, inside: -1 }),
5051
dispatch: vi.fn((tr) => {
5152
state = state.apply(tr)
5253
}),
@@ -68,6 +69,41 @@ function imageCount(doc: PMNode): number {
6869
}
6970

7071
describe('moveDraggedImageNode', () => {
72+
it.each([
73+
['inside a heading', 3, 'inlineImage'],
74+
['inside a paragraph after the source', 12, 'inlineImage'],
75+
['between blocks', 9, 'image'],
76+
['inside a text-only code block', 18, 'image'],
77+
] as const)('moves a block image to %s', (_label, dropPos, expectedType) => {
78+
const doc = schema.node('doc', null, [
79+
schema.node('heading', null, [schema.text('Before')]),
80+
schema.node('image', { src: SRC }),
81+
schema.node('paragraph', null, [schema.text('After')]),
82+
schema.node('codeBlock', null, [schema.text('Code')]),
83+
])
84+
let state = EditorState.create({ doc, selection: NodeSelection.create(doc, 8) })
85+
const view = {
86+
get state() {
87+
return state
88+
},
89+
posAtCoords: () => ({ pos: dropPos }),
90+
dispatch: (tr) => {
91+
state = state.apply(tr)
92+
},
93+
} as unknown as EditorView
94+
95+
expect(moveDraggedImageNode(view, dropEvent(), { images: [], html: imageHtml(SRC) })).toBe(true)
96+
expect(() => state.doc.check()).not.toThrow()
97+
expect(imageCount(state.doc)).toBe(1)
98+
expect(state.doc.textContent).toBe(doc.textContent)
99+
expect(state.selection).toBeInstanceOf(NodeSelection)
100+
expect((state.selection as NodeSelection).node.type.name).toBe(expectedType)
101+
expect((state.selection as NodeSelection).node.attrs.src).toBe(SRC)
102+
if (expectedType === 'inlineImage') {
103+
expect(state.selection.from).toBe(dropPos > 8 ? dropPos - 1 : dropPos)
104+
}
105+
})
106+
71107
it.each([
72108
['document start', 0, 'image'],
73109
['between blocks', 10, 'image'],

‎apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image-drag-move.ts‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,10 +53,15 @@ export function moveDraggedImageNode(
5353
const coords = view.posAtCoords({ left: event.clientX, top: event.clientY })
5454
if (!coords) return true
5555

56+
const $drop = view.state.doc.resolve(coords.pos)
57+
const { image, inlineImage } = view.state.schema.nodes
58+
const type = $drop.parent.canReplaceWith($drop.index(), $drop.index(), inlineImage)
59+
? inlineImage
60+
: image
5661
const node =
57-
selection.node.isInline && !view.state.doc.resolve(coords.pos).parent.inlineContent
58-
? view.state.schema.nodes.image.create(selection.node.attrs, null, selection.node.marks)
59-
: selection.node
62+
selection.node.type === type
63+
? selection.node
64+
: type.create(selection.node.attrs, null, selection.node.marks)
6065
const tr = view.state.tr
6166
const insertPos = dropPoint(view.state.doc, coords.pos, new Slice(Fragment.from(node), 0, 0))
6267
if (insertPos === null) return true

0 commit comments

Comments
 (0)