Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 29 additions & 4 deletions apps/realtime/src/handlers/file-doc.join-readiness.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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()

Expand All @@ -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()
})
Expand All @@ -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()

Expand Down
41 changes: 26 additions & 15 deletions apps/realtime/src/handlers/file-doc.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -103,7 +104,14 @@ function createSocket(id: string, overrides?: Record<string, unknown>) {
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<string, unknown>),
})
: handler
}),
emit: vi.fn(),
join: vi.fn(),
Expand Down Expand Up @@ -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'))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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** [<img src="https://example.test/sized.png" alt="Sized preview" width="320" height="180" title="Image title">](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',
Expand All @@ -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)
})
Expand Down
Original file line number Diff line number Diff line change
@@ -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')
}
Original file line number Diff line number Diff line change
Expand Up @@ -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')

Expand All @@ -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')
Expand All @@ -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))
Expand All @@ -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()
}
}
)
})
Original file line number Diff line number Diff line change
@@ -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
},
}),
]
},
})
Loading
Loading