/**
* Tiptap editor hook bound to a Yjs `XmlFragment` + Awareness.
*
* Why Tiptap vs the old contenteditable approach:
* - Y.XmlFragment is a structured CRDT — remote keystrokes become
* fine-grained ProseMirror transactions, not an `innerHTML` swap.
* - Local caret survives concurrent remote edits via ProseMirror's
* position-mapping system (no manual probe / restore).
* - Remote cursors come from `@tiptap/extension-collaboration-caret`
* reading the same Awareness instance our WS hook already wires.
* - No HTML round-trip in storage, so browser-inserted artefacts
* (` `, `
`, IME composition state) cannot
* corrupt the shared document.
*/
import { useEffect, useMemo, useRef } from 'react'
import { useEditor, type AnyExtension, type Editor } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import Collaboration from '@tiptap/extension-collaboration'
import { CollaborationCaret } from '@tiptap/extension-collaboration-caret'
import Placeholder from '@tiptap/extension-placeholder'
import Typography from '@tiptap/extension-typography'
import TextAlign from '@tiptap/extension-text-align'
import { TextStyle } from '@tiptap/extension-text-style'
import Color from '@tiptap/extension-color'
import Highlight from '@tiptap/extension-highlight'
import Subscript from '@tiptap/extension-subscript'
import Superscript from '@tiptap/extension-superscript'
import TaskList from '@tiptap/extension-task-list'
import TaskItem from '@tiptap/extension-task-item'
import type { Awareness } from 'deepspace'
import type * as Y from 'yjs'
type CollaborationUser = {
name?: string | null
color?: string | null
}
const DEFAULT_COLLABORATION_COLOR = '#2563eb'
const DISALLOWED_COLLABORATION_COLORS = new Set([
'#fff',
'#ffff',
'#ffffff',
'#ffffffff',
'white',
'rgb(255, 255, 255)',
'rgba(255, 255, 255, 1)',
])
export interface UseDocEditorOptions {
doc: Y.Doc
/** Awareness instance shared with the Yjs WS hook (or null while not yet connected). */
awareness: Awareness | null
userName: string
userColor: string
synced: boolean
canWrite: boolean
placeholder?: string
}
function collaborationColorFor(user: CollaborationUser): string {
const color = user.color?.trim()
if (!color || DISALLOWED_COLLABORATION_COLORS.has(color.toLowerCase())) {
return DEFAULT_COLLABORATION_COLOR
}
return color
}
function renderCollaborationCaret(user: CollaborationUser): HTMLElement {
const color = collaborationColorFor(user)
const cursor = document.createElement('span')
cursor.classList.add('collaboration-carets__caret')
cursor.style.setProperty('--collaboration-caret-color', color)
cursor.setAttribute('aria-hidden', 'true')
const label = document.createElement('span')
label.classList.add('collaboration-carets__label')
label.style.backgroundColor = `color-mix(in srgb, ${color} 16%, var(--documents-editor-paper))`
label.style.color = color
label.textContent = user.name || 'Collaborator'
cursor.appendChild(label)
return cursor
}
function renderCollaborationSelection(user: CollaborationUser) {
const color = collaborationColorFor(user)
return {
class: 'ProseMirror-yjs-selection collaboration-carets__selection',
style: `background-color: color-mix(in srgb, ${color} 20%, transparent)`,
}
}
/** Pasted HTML sometimes carries our own collab caret nodes — scrub them. */
function stripCollaborationArtifactsFromHTML(html: string): string {
if (typeof document === 'undefined') return html
const container = document.createElement('div')
container.innerHTML = html
container
.querySelectorAll('.collaboration-carets__caret, .collaboration-carets__label')
.forEach((node) => node.remove())
container
.querySelectorAll('.ProseMirror-yjs-selection, .collaboration-carets__selection')
.forEach((node) => {
node.replaceWith(...Array.from(node.childNodes))
})
return container.innerHTML
}
export function useDocEditor({
doc,
awareness,
userName,
userColor,
synced,
canWrite,
placeholder = 'Start typing — toolbar above for formatting…',
}: UseDocEditorOptions): Editor | null {
/** Structured CRDT field that ProseMirror binds to. */
const fragment = useMemo(() => doc.getXmlFragment('default'), [doc])
/** CollaborationCaret expects a provider-shaped `{ awareness }` object. */
const provider = useMemo(() => (awareness ? { awareness } : null), [awareness])
const extensions = useMemo(() => {
const exts: AnyExtension[] = [
StarterKit.configure({
// Yjs has its own undo/redo history via the Collaboration extension; keeping
// both enabled corrupts the shared document on undo.
undoRedo: false,
link: {
openOnClick: false,
autolink: true,
HTMLAttributes: { rel: 'noopener noreferrer', target: '_blank' },
},
}),
Collaboration.configure({ fragment }),
TextAlign.configure({ types: ['heading', 'paragraph'] }),
TextStyle,
Color,
Highlight.configure({
multicolor: true,
HTMLAttributes: { class: 'documents-text-highlight' },
}),
Subscript,
Superscript,
TaskList,
TaskItem.configure({ nested: true }),
Typography,
Placeholder.configure({ placeholder }),
]
if (provider) {
exts.push(
CollaborationCaret.configure({
provider,
user: { name: userName, color: userColor },
render: renderCollaborationCaret,
selectionRender: renderCollaborationSelection,
}),
)
}
return exts
}, [fragment, provider, userName, userColor, placeholder])
const editor = useEditor(
{
editable: synced && canWrite,
// Tiptap renders synchronously by default which trips up React's strict mode
// in dev (the editor view is destroyed and re-created, and any subscriber
// that runs in between throws on `editor.view`). Defer to a useEffect.
immediatelyRender: false,
extensions,
editorProps: {
transformPastedHTML: stripCollaborationArtifactsFromHTML,
attributes: {
class: 'documents-tiptap-editor',
'data-testid': 'documents-tiptap-editor',
},
},
},
[extensions],
)
/**
* Toggle editable without thrashing the editor: setEditable is cheap and
* preserves selection/awareness, whereas re-creating the editor on every
* `synced`/`canWrite` change would flicker remote carets and reset undo.
*/
const prevEditable = useRef(false)
useEffect(() => {
const editable = synced && canWrite
if (editor && editable !== prevEditable.current) {
prevEditable.current = editable
editor.setEditable(editable)
}
}, [editor, synced, canWrite])
return editor
}