Convert each document source (markdown, user edits, GitHub content) into a TipTap JSON tree, load each into a separate Y.Doc via mergeDocument, then call Y.applyUpdate to merge one into the other. Yjs CRDT semantics guarantee both sets of changes survive. Read the result back as TipTap JSON with yDocToJson.
markdown A ──► toTipTap() ──► TipTapNode ──► mergeDocument(doc1, ...) ──┐
├─► Y.applyUpdate(doc1, update2)
markdown B ──► toTipTap() ──► TipTapNode ──► mergeDocument(doc2, ...) ──┘ │
yDocToJson(doc1)
│
TipTapNode (merged)
The critical detail: mergeDocument doesn't "merge" two trees directly. It diffs a ProseMirror tree against a Yjs XmlFragment and applies minimal CRDT mutations. The actual merge happens when Yjs applies updates from one doc to another.
| Package | Version | Role |
|---|---|---|
yjs |
^13.x | CRDT document model. Y.Doc, Y.XmlFragment, Y.XmlElement, Y.XmlText |
y-prosemirror |
^1.2.x | Bridge between Yjs and ProseMirror. Provides updateYFragment, yXmlFragmentToProsemirrorJSON |
@tiptap/core |
^2.x | getSchema() to build ProseMirror schema from TipTap extensions |
prosemirror-model |
^1.x | Node, Mark, Schema types. schema.nodeFromJSON() to parse TipTap JSON |
lib0 |
(transitive via yjs) | simpleDiff used inside updateYFragment for text-level diffing |
No patches are applied to any of these dependencies in the holocron codebase. They are used as-is from npm. The updateYFragment function is exported from y-prosemirror and marked as @unstable and @private, but it works reliably.
┌─────────────────────────────────────────────────────────────────────────────────────────┐
│ Full Pipeline │
│ │
│ ┌──────────┐ ┌──────────┐ ┌─────────────────┐ ┌─────────────────────────┐ │
│ │ Markdown │────►│ toTipTap │────►│ TipTapNode │────►│ jsonToProsemirror │ │
│ │ string │ │ │ │ (JSON tree) │ │ schema.nodeFromJSON() │ │
│ └──────────┘ └──────────┘ └─────────────────┘ └───────────┬─────────────┘ │
│ │ │
│ ProseMirror Node │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────────────────────────────────────────┐ │
│ │ updateYFragment (y-prosemirror) │ │
│ │ │ │
│ │ Existing Y.XmlFragment ◄──── structural diff ────► New ProseMirror Node │ │
│ │ │ │
│ │ 1. Sync attributes (setAttribute/removeAttribute) │ │
│ │ 2. Match children from left ──► stable left region │ │
│ │ 3. Match children from right ──► stable right region │ │
│ │ 4. Process unstable middle (update, replace, or insert) │ │
│ │ 5. Cleanup (delete extras, insert new) │ │
│ │ │ │
│ │ All mutations go through Yjs ──► become CRDT operations │ │
│ └──────────────────────────────────────────────────────────────────────────────────┘ │
│ │
│ Y.Doc (contains merged CRDT state) │
│ │ │
│ ▼ │
│ yDocToJson(doc) ──► TipTapNode │
│ │
└─────────────────────────────────────────────────────────────────────────────────────────┘
// The TipTap/ProseMirror JSON tree structure
interface TipTapNode {
type: string // 'doc', 'heading', 'paragraph', 'codeBlock', etc.
attrs?: Record<string, any>
marks?: Array<{
type: { name: string }
attrs?: any
}>
content?: TipTapNode[]
text?: string // only for text nodes
}
// Block types used in the schema
const blockTypes = {
blockquote: 'blockquote',
paragraph: 'paragraph',
heading: 'heading',
codeBlock: 'codeBlock',
bulletList: 'bulletList',
orderedList: 'orderedList',
listItem: 'listItem',
horizontalRule: 'horizontalRule',
hardBreak: 'hardBreak',
text: 'text',
image: 'image',
table: 'table',
tableRow: 'tableRow',
tableCell: 'tableCell',
tableHeader: 'tableHeader',
mdxCode: 'mdxCode',
yamlFrontmatter: 'yamlFrontmatter',
htmlCode: 'htmlCode',
doc: 'doc',
taskItem: 'taskItem',
taskList: 'taskList',
} as const
// Mark types for inline formatting
const markTypes = {
italic: 'italic',
bold: 'bold',
strike: 'strike',
link: 'link',
code: 'code',
dummyMdxExpression: 'dummyMdxExpression',
mdxExpression: 'mdxExpression',
} as constTakes a Y.Doc, a TipTap JSON node, and a page slug. Converts the JSON to a ProseMirror Node using the schema, then calls updateYFragment to diff-and-patch the Yjs document.
import { getSchema } from '@tiptap/core'
import { Node } from 'prosemirror-model'
import * as Y from 'yjs'
import { updateYFragment } from 'y-prosemirror'
// Schema is memoized per file extension (.md, .mdx)
// because different extensions enable different TipTap extensions
const schemaGetter = memoize(
(pageSlug: string) => getSchema(createExtensions({ pageSlug })),
{ transformKey: ([pageSlug]) => [getExtension(pageSlug || '')] },
)
function jsonToProsemirror(node: TipTapNode | Node, pageSlug: string): Node {
if (node instanceof Node) {
return node
}
return schemaGetter(pageSlug).nodeFromJSON(node)
}
function prosemirrorToYXmlFragment(
doc: any,
xmlFragment: Y.XmlFragment,
mapping: Map<any, any> = new Map(),
): Y.XmlFragment {
const type = xmlFragment || new Y.XmlFragment()
const ydoc = type.doc
? type.doc
: { transact: (transaction) => transaction(undefined) }
updateYFragment(ydoc, type, doc, mapping)
return type
}
function mergeDocument(
currentDoc: Y.Doc,
node: TipTapNode | Node,
pageSlug: string,
) {
const pDoc = jsonToProsemirror(node, pageSlug)
const mapping = new Map()
const frag = currentDoc.getXmlFragment('default')
prosemirrorToYXmlFragment(pDoc, frag, mapping)
return { mapping, pDoc }
}import { yXmlFragmentToProsemirrorJSON } from 'y-prosemirror'
function yDocToJson(yDoc: Y.Doc): TipTapNode {
const fragment = yDoc.getXmlFragment('default')
const json = yXmlFragmentToProsemirrorJSON(fragment) as any
// Fix: empty paragraphs need an empty content array
for (let child of json.content || []) {
if (child.type === 'paragraph' && !child.content) {
child.content = []
}
}
return json
}function toTipTap({
str,
addPosition = false,
pageSlug = '.mdx',
}): { node?: TipTapNode; error?: Error } {
// Uses remark + remarkGfm + remarkMdx to parse markdown into mdast,
// then a custom MdastToProseMirror visitor converts to TipTap JSON.
// Returns { node, error }
}This is the function that does the actual structural diff. It is called recursively for nested elements.
// Full source from y-prosemirror/src/plugins/sync-plugin.js
// ═══════════════════════════════════════════════════════════════════
// Helper: deep attribute comparison (ignores 'ychange' key)
// ═══════════════════════════════════════════════════════════════════
const equalAttrs = (pattrs, yattrs) => {
const keys = Object.keys(pattrs).filter((key) => pattrs[key] !== null)
let eq =
keys.length ===
Object.keys(yattrs).filter((key) => yattrs[key] !== null).length
for (let i = 0; i < keys.length && eq; i++) {
const key = keys[i]
const l = pattrs[key]
const r = yattrs[key]
eq = key === 'ychange' || l === r ||
(isObject(l) && isObject(r) && equalAttrs(l, r))
}
return eq
}
// ═══════════════════════════════════════════════════════════════════
// Helper: group consecutive text nodes into arrays
// Element nodes stay as-is. This normalizes the children for diffing.
//
// Input: [TextNode("hello"), TextNode(" world"), ElementNode(heading)]
// Output: [[TextNode("hello"), TextNode(" world")], ElementNode(heading)]
// ═══════════════════════════════════════════════════════════════════
const normalizePNodeContent = (pnode) => {
const c = pnode.content.content
const res = []
for (let i = 0; i < c.length; i++) {
const n = c[i]
if (n.isText) {
const textNodes = []
for (let tnode = c[i]; i < c.length && tnode.isText; tnode = c[++i]) {
textNodes.push(tnode)
}
i--
res.push(textNodes)
} else {
res.push(n)
}
}
return res
}
// ═══════════════════════════════════════════════════════════════════
// Helper: compare Y.XmlText with ProseMirror text nodes
// Compares text content AND marks/attributes
// ═══════════════════════════════════════════════════════════════════
const equalYTextPText = (ytext, ptexts) => {
const delta = ytext.toDelta()
return delta.length === ptexts.length &&
delta.every((d, i) =>
d.insert === ptexts[i].text &&
Object.keys(d.attributes || {}).length === ptexts[i].marks.length &&
ptexts[i].marks.every((mark) =>
equalAttrs(d.attributes[mark.type.name] || {}, mark.attrs)
)
)
}
// ═══════════════════════════════════════════════════════════════════
// Helper: deep equality between a Yjs type and a ProseMirror node
// ═══════════════════════════════════════════════════════════════════
const equalYTypePNode = (ytype, pnode) => {
if (
ytype instanceof Y.XmlElement && !(pnode instanceof Array) &&
matchNodeName(ytype, pnode)
) {
const normalizedContent = normalizePNodeContent(pnode)
return ytype._length === normalizedContent.length &&
equalAttrs(ytype.getAttributes(), pnode.attrs) &&
ytype.toArray().every((ychild, i) =>
equalYTypePNode(ychild, normalizedContent[i])
)
}
return ytype instanceof Y.XmlText && pnode instanceof Array &&
equalYTextPText(ytype, pnode)
}
// ═══════════════════════════════════════════════════════════════════
// Helper: check if a mapping value is identical to content
// ═══════════════════════════════════════════════════════════════════
const mappedIdentity = (mapped, pcontent) =>
mapped === pcontent ||
(mapped instanceof Array && pcontent instanceof Array &&
mapped.length === pcontent.length && mapped.every((a, i) =>
pcontent[i] === a
))
// ═══════════════════════════════════════════════════════════════════
// Helper: compute how well a Yjs element matches a ProseMirror node
// by counting matching children from both ends.
// Used to decide which side to update when both left and right match.
// ═══════════════════════════════════════════════════════════════════
const computeChildEqualityFactor = (ytype, pnode, mapping) => {
const yChildren = ytype.toArray()
const pChildren = normalizePNodeContent(pnode)
const pChildCnt = pChildren.length
const yChildCnt = yChildren.length
const minCnt = Math.min(yChildCnt, pChildCnt)
let left = 0
let right = 0
let foundMappedChild = false
for (; left < minCnt; left++) {
const leftY = yChildren[left]
const leftP = pChildren[left]
if (mappedIdentity(mapping.get(leftY), leftP)) {
foundMappedChild = true
} else if (!equalYTypePNode(leftY, leftP)) {
break
}
}
for (; left + right < minCnt; right++) {
const rightY = yChildren[yChildCnt - right - 1]
const rightP = pChildren[pChildCnt - right - 1]
if (mappedIdentity(mapping.get(rightY), rightP)) {
foundMappedChild = true
} else if (!equalYTypePNode(rightY, rightP)) {
break
}
}
return {
equalityFactor: left + right,
foundMappedChild
}
}
// ═══════════════════════════════════════════════════════════════════
// Helper: extract string content and formatting from Y.XmlText
// Walks the internal linked list of Yjs items
// ═══════════════════════════════════════════════════════════════════
const ytextTrans = (ytext) => {
let str = ''
let n = ytext._start
const nAttrs = {}
while (n !== null) {
if (!n.deleted) {
if (n.countable && n.content instanceof Y.ContentString) {
str += n.content.str
} else if (n.content instanceof Y.ContentFormat) {
nAttrs[n.content.key] = null
}
}
n = n.right
}
return { str, nAttrs }
}
// ═══════════════════════════════════════════════════════════════════
// Helper: convert ProseMirror marks to Yjs attributes
// ═══════════════════════════════════════════════════════════════════
const marksToAttributes = (marks) => {
const pattrs = {}
marks.forEach((mark) => {
if (mark.type.name !== 'ychange') {
pattrs[mark.type.name] = mark.attrs
}
})
return pattrs
}
// ═══════════════════════════════════════════════════════════════════
// Helper: update Y.XmlText content using character-level diff
// Uses lib0/simpleDiff for efficient text patching
// ═══════════════════════════════════════════════════════════════════
const updateYText = (ytext, ptexts, mapping) => {
mapping.set(ytext, ptexts)
const { nAttrs, str } = ytextTrans(ytext)
const content = ptexts.map((p) => ({
insert: p.text,
attributes: Object.assign({}, nAttrs, marksToAttributes(p.marks))
}))
const { insert, remove, index } = simpleDiff(
str,
content.map((c) => c.insert).join('')
)
ytext.delete(index, remove)
ytext.insert(index, insert)
ytext.applyDelta(
content.map((c) => ({ retain: c.insert.length, attributes: c.attributes }))
)
}
const matchNodeName = (yElement, pNode) =>
!(pNode instanceof Array) && yElement.nodeName === pNode.type.name
// ═══════════════════════════════════════════════════════════════════
// MAIN: updateYFragment
// Recursively diffs a Yjs XmlFragment against a ProseMirror Node,
// applying minimal CRDT mutations to make the Yjs tree match.
// ═══════════════════════════════════════════════════════════════════
const updateYFragment = (y, yDomFragment, pNode, mapping) => {
if (
yDomFragment instanceof Y.XmlElement &&
yDomFragment.nodeName !== pNode.type.name
) {
throw new Error('node name mismatch!')
}
mapping.set(yDomFragment, pNode)
// ── Step 1: Sync attributes ──────────────────────────────────
if (yDomFragment instanceof Y.XmlElement) {
const yDomAttrs = yDomFragment.getAttributes()
const pAttrs = pNode.attrs
for (const key in pAttrs) {
if (pAttrs[key] !== null) {
if (yDomAttrs[key] !== pAttrs[key] && key !== 'ychange') {
yDomFragment.setAttribute(key, pAttrs[key])
}
} else {
yDomFragment.removeAttribute(key)
}
}
for (const key in yDomAttrs) {
if (pAttrs[key] === undefined) {
yDomFragment.removeAttribute(key)
}
}
}
// ── Step 2-3: Find stable left and right regions ─────────────
const pChildren = normalizePNodeContent(pNode)
const pChildCnt = pChildren.length
const yChildren = yDomFragment.toArray()
const yChildCnt = yChildren.length
const minCnt = Math.min(pChildCnt, yChildCnt)
let left = 0
let right = 0
// Scan from left: advance while children match
for (; left < minCnt; left++) {
const leftY = yChildren[left]
const leftP = pChildren[left]
if (!mappedIdentity(mapping.get(leftY), leftP)) {
if (equalYTypePNode(leftY, leftP)) {
mapping.set(leftY, leftP)
} else {
break
}
}
}
// Scan from right: advance while children match
for (; right + left + 1 < minCnt; right++) {
const rightY = yChildren[yChildCnt - right - 1]
const rightP = pChildren[pChildCnt - right - 1]
if (!mappedIdentity(mapping.get(rightY), rightP)) {
if (equalYTypePNode(rightY, rightP)) {
mapping.set(rightY, rightP)
} else {
break
}
}
}
// ── Step 4: Process the unstable middle ──────────────────────
//
// children: [ stable left | UNSTABLE MIDDLE | stable right ]
// 0..left-1 left..end-right end-right+1..end
//
y.transact(() => {
while (yChildCnt - left - right > 0 && pChildCnt - left - right > 0) {
const leftY = yChildren[left]
const leftP = pChildren[left]
const rightY = yChildren[yChildCnt - right - 1]
const rightP = pChildren[pChildCnt - right - 1]
if (leftY instanceof Y.XmlText && leftP instanceof Array) {
// Both are text: update in-place with character diff
if (!equalYTextPText(leftY, leftP)) {
updateYText(leftY, leftP, mapping)
}
left += 1
} else {
let updateLeft = leftY instanceof Y.XmlElement &&
matchNodeName(leftY, leftP)
let updateRight = rightY instanceof Y.XmlElement &&
matchNodeName(rightY, rightP)
if (updateLeft && updateRight) {
// Both sides could be updated. Pick the better match.
const equalityLeft = computeChildEqualityFactor(leftY, leftP, mapping)
const equalityRight = computeChildEqualityFactor(rightY, rightP, mapping)
if (equalityLeft.foundMappedChild && !equalityRight.foundMappedChild) {
updateRight = false
} else if (!equalityLeft.foundMappedChild && equalityRight.foundMappedChild) {
updateLeft = false
} else if (equalityLeft.equalityFactor < equalityRight.equalityFactor) {
updateLeft = false
} else {
updateRight = false
}
}
if (updateLeft) {
// Recursively update the left child
updateYFragment(y, leftY, leftP, mapping)
left += 1
} else if (updateRight) {
// Recursively update the right child
updateYFragment(y, rightY, rightP, mapping)
right += 1
} else {
// No match: delete old, insert new
mapping.delete(yDomFragment.get(left))
yDomFragment.delete(left, 1)
yDomFragment.insert(left, [
createTypeFromTextOrElementNode(leftP, mapping)
])
left += 1
}
}
}
// ── Step 5: Cleanup ──────────────────────────────────────
const yDelLen = yChildCnt - left - right
if (
yChildCnt === 1 && pChildCnt === 0 &&
yChildren[0] instanceof Y.XmlText
) {
// Edge case: keep Y.Text object alive to retain remote changes
mapping.delete(yChildren[0])
yChildren[0].delete(0, yChildren[0].length)
} else if (yDelLen > 0) {
yDomFragment.slice(left, left + yDelLen).forEach(
type => mapping.delete(type)
)
yDomFragment.delete(left, yDelLen)
}
// Insert remaining new ProseMirror children
if (left + right < pChildCnt) {
const ins = []
for (let i = left; i < pChildCnt - right; i++) {
ins.push(createTypeFromTextOrElementNode(pChildren[i], mapping))
}
yDomFragment.insert(left, ins)
}
}, ySyncPluginKey)
}You have a document stored as a Yjs binary (from user edits in the editor). Separately, someone updates the markdown on GitHub. You want to merge the GitHub version into the user's version, preserving both sets of changes.
┌──────────────────────────────┐
│ Common ancestor (base) │
│ "# hello" │
└──────────────┬────────────────┘
│
┌──────────────┴────────────────┐
│ │
▼ ▼
┌─────────────────────────────┐ ┌─────────────────────────────┐
│ User edits (doc1) │ │ GitHub markdown (doc2) │
│ "# hello" │ │ "# hello" │
│ "in the middle" │ │ "<SomeJsx />" │
└──────────────┬──────────────┘ └──────────────┬──────────────┘
│ │
│ Y.applyUpdate(doc1, update2) │
│◄────────────────────────────────┘
│
▼
┌─────────────────────────────┐
│ Merged result (doc1) │
│ "# hello" │
│ "in the middle" │
│ "<SomeJsx />" │
└─────────────────────────────┘
The Yjs document generated from GitHub markdown is created on the fly from the markdown content. It has no editing history, no vector clock state. It's a fresh Y.Doc with a single snapshot loaded via mergeDocument.
To make the merge work, you need a shared base state. Without it, Yjs sees two completely independent documents and the merge produces duplicated content (both full trees appear).
import * as Y from 'yjs'
// ── Step 1: Create the base document ────────────────────────────
// This represents the common ancestor both sides started from.
const baseDoc = new Y.Doc()
mergeDocument(baseDoc, baseNode, '.mdx')
// ── Step 2: Fork into two branches ──────────────────────────────
// doc1 = user's editing doc (could also be loaded from stored Yjs binary)
// doc2 = GitHub's version (generated fresh from markdown)
const doc1 = new Y.Doc()
// CRITICAL: same clientID so Yjs treats the base as shared history
doc1.clientID = baseDoc.clientID
Y.applyUpdate(doc1, Y.encodeStateAsUpdate(baseDoc))
const doc2 = new Y.Doc()
doc2.clientID = baseDoc.clientID
Y.applyUpdate(doc2, Y.encodeStateAsUpdate(baseDoc))
// ── Step 3: Apply divergent changes ─────────────────────────────
// User edits their doc
mergeDocument(doc1, userEditedNode, '.mdx')
// GitHub content is loaded into doc2
mergeDocument(doc2, githubNode, '.mdx')
// ── Step 4: Merge ───────────────────────────────────────────────
Y.applyUpdate(doc1, Y.encodeStateAsUpdate(doc2))
// ── Step 5: Read result ─────────────────────────────────────────
const mergedJson: TipTapNode = yDocToJson(doc1)doc1.clientID = baseDoc.clientID
This is critical. When you set the same clientID, the base operations in doc1 and doc2 share the same origin. Yjs recognizes them as the same operations and doesn't duplicate them. Without this, each doc's base content looks like independent insertions and you get the content doubled.
If the user's document comes from a stored Yjs binary (from the websocket/database), you already have history. The flow becomes:
// Load the user's existing Y.Doc (has full editing history)
const userDoc = new Y.Doc()
Y.applyUpdate(userDoc, storedYjsBinary)
// Create a fresh doc for GitHub content, forked from the user's current state
const githubDoc = new Y.Doc()
githubDoc.clientID = userDoc.clientID
Y.applyUpdate(githubDoc, Y.encodeStateAsUpdate(userDoc))
// Now apply the new GitHub markdown on top
mergeDocument(githubDoc, githubNode, '.mdx')
// Merge GitHub changes back into user doc
Y.applyUpdate(userDoc, Y.encodeStateAsUpdate(githubDoc))
// Read result
const merged = yDocToJson(userDoc)import * as Y from 'yjs'
import dedent from 'dedent'
// These come from your codebase
import { mergeDocument } from './editor-utils'
import { toTipTap } from './markdown-to-prosemirror'
import { yDocToJson } from './utils'
import { getMarkdown } from './editor-utils'
// ── Parse markdown into TipTap JSON ─────────────────────────────
const base = toTipTap({
pageSlug: '.mdx',
str: dedent`
# hello
`,
}).node!
const userVersion = toTipTap({
pageSlug: '.mdx',
str: dedent`
# hello
in the middle
`,
}).node!
const githubVersion = toTipTap({
pageSlug: '.mdx',
str: dedent`
# hello
<SomeJsx />
`,
}).node!
// ── Create base Y.Doc ───────────────────────────────────────────
const doc1 = new Y.Doc()
mergeDocument(doc1, base, '.mdx')
// ── Fork doc2 from same base ────────────────────────────────────
const doc2 = new Y.Doc()
doc2.clientID = doc1.clientID
Y.applyUpdate(doc2, Y.encodeStateAsUpdate(doc1))
// ── Apply divergent edits ───────────────────────────────────────
mergeDocument(doc1, userVersion, '.mdx')
mergeDocument(doc2, githubVersion, '.mdx')
// ── Merge ───────────────────────────────────────────────────────
Y.applyUpdate(doc1, Y.encodeStateAsUpdate(doc2))
// ── Read result ─────────────────────────────────────────────────
const mergedJson = yDocToJson(doc1)
const mergedMarkdown = getMarkdown(mergedJson, '.mdx')
console.log(mergedMarkdown)
// Output contains all three:
// # hello
// in the middle
// <SomeJsx />Given an existing Yjs tree and a new ProseMirror tree:
Yjs XmlFragment (before): ProseMirror Node (target):
┌──────────────────────┐ ┌──────────────────────┐
│ XmlElement(heading) │ ◄═══► │ Node(heading) │ ✓ match (left=1)
│ XmlText("old text") │ ◄═══► │ [Text("old text")] │ ✓ match (left=2)
│ XmlElement(paragraph)│ ✗ ─── │ Node(codeBlock) │ ✗ mismatch
│ XmlText("footer") │ ◄═══► │ [Text("footer")] │ ✓ match (right=1)
└──────────────────────┘ └──────────────────────┘
Result: left=2, right=1
Stable left: [heading, "old text"] ──► no changes needed
Unstable middle: [paragraph] vs [codeBlock] ──► delete paragraph, insert codeBlock
Stable right: ["footer"] ──► no changes needed
For the unstable middle, the algorithm tries in order:
- Text nodes: update in-place with
simpleDiff(character-level insert/delete) - Same-named elements: recursively call
updateYFragmentto update children - Different elements: delete old Yjs node, create new one from ProseMirror node
When both left and right candidates match the node name, computeChildEqualityFactor breaks the tie by checking which side has more matching children or a previously mapped child.
Yjs CRDTs have deterministic conflict resolution. You cannot configure "mine wins" or "theirs wins".
| Conflict type | Resolution |
|---|---|
| Concurrent insertions at same position | Both survive. Ordered by clientID (lower ID first) |
| Concurrent text edits at different positions | Both applied cleanly (no conflict) |
| Concurrent text edits at same position | Both insertions appear, ordered by clientID |
| One side deletes, other side edits | Edit wins (deleted content may reappear if edited concurrently) |
| Concurrent attribute changes | Last-writer-wins per attribute key (by Lamport timestamp) |
| One side deletes a node, other side inserts inside it | The insertion is lost (parent was deleted) |
After merging, convert the TipTap JSON back to markdown:
import { getMarkdown } from './editor-utils'
const markdown: string = getMarkdown(mergedJson, '.mdx')This uses a custom MarkdownSerializer (from tiptap-markdown, customized) that walks the ProseMirror node tree and serializes each node type back to markdown syntax.