/** * DocumentsEditorPage — Tiptap + Yjs collaborative document editor. * * Replaces the previous contenteditable + HTML-in-Y.Text approach with * ProseMirror-via-Tiptap bound to a Y.XmlFragment. Remote keystrokes are * applied as granular PM transactions (no `innerHTML` swap), the local * caret is migrated through concurrent edits by PM's position mapping, * and remote cursors are painted by `@tiptap/extension-collaboration-caret`. */ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useParams, useNavigate, useRouteError, isRouteErrorResponse } from 'react-router-dom' import { getUserColor, useMutations, useQuery, useUser, useYjsRoom } from 'deepspace' import { ArrowLeft, AlertTriangle, List as ListIcon, RefreshCw, Share2 } from 'lucide-react' import { Badge } from '@/components/ui' import { type Editor, useEditorState } from '@tiptap/react' import { useDocEditor } from './editor/useDocEditor' import { DocEditorSurface, PAGE_HEIGHT_PX, PAGE_WIDTH_PX } from './editor/DocEditorSurface' import { DocumentsTiptapToolbar } from './editor/DocumentsTiptapToolbar' import { DocumentsOutlinePanel, DOCUMENT_OUTLINE_WIDTH_PX, type OutlineEntry, } from './DocumentsOutlinePanel' import { DocumentsPresence } from './DocumentsPresence' import { InviteDialog } from './InviteDialog' import { recordsReadyForMutation, type DocumentsDocumentFields } from './documents-library-types' import { useDocumentsEditorPresence, useDocumentsPresenceAccess, type AccessChangeKind, } from './use-documents-presence-access' import './documents-ui.css' const CANVAS_ZOOM_KEY = 'deepspace-documents-editor-canvas-zoom' const OUTLINE_OPEN_KEY = 'deepspace-documents-editor-outline-open' const KEYBOARD_ZOOM_STEP = 0.1 const DOC_NOT_FOUND_GRACE_MS = 450 function normalizeZoom(z: number): number { return Math.min(2, Math.max(0.5, Math.round(z * 1000) / 1000)) } function InlineTitle({ title, canEdit, onSave, }: { title: string canEdit: boolean onSave: (next: string) => void }) { const [editing, setEditing] = useState(false) const [value, setValue] = useState(title) const inputRef = useRef(null) useEffect(() => { setValue(title) }, [title]) useEffect(() => { if (editing && inputRef.current) { inputRef.current.focus() inputRef.current.select() } }, [editing]) const commit = () => { const t = value.trim() if (t && t !== title) onSave(t) else setValue(title) setEditing(false) } if (!canEdit || !editing) { return (

canEdit && setEditing(true)} title={canEdit ? 'Click to rename' : undefined} > {title}

) } return ( setValue(e.target.value)} onBlur={commit} onKeyDown={(e) => { if (e.key === 'Enter') commit() if (e.key === 'Escape') { setValue(title) setEditing(false) } }} className="min-w-0 flex-1 rounded border px-2 py-0.5 text-lg font-semibold outline-none focus-visible:ring-2 focus-visible:ring-ring/35" style={{ borderColor: 'var(--documents-el-line)', backgroundColor: 'var(--documents-el-bg)', color: 'var(--documents-el-text)', }} /> ) } /** * Full-screen overlay shown to the second user when the owner changes their * permissions mid-session. Three cases: * * - `downgrade`: editor → viewer. We block the editor surface so an * in-flight keystroke can't slip past the role boundary while Yjs and the * `documents` record settle on the new permission, and prompt the user to * refresh the route. Without this, the Tiptap editor view is rebuilt with * a new placeholder/extensions array (because `showReadOnlyDocUx` flips) * which races a stale `requestAnimationFrame` in `DocEditorSurface` and * throws `Cannot read properties of null (reading 'matchesNode')`. * * - `upgrade`: viewer → editor. Same editor-rebuild path on the local peer * (the placeholder string flips the other way), so a refresh gives the * user a clean Tiptap mount with editing enabled and no risk of the * stale-rAF crash in the rebuild. * * - `revoked`: the owner removed this peer from `collaborators`/`editors` * entirely. The Yjs server-side auth cache plus the locally-cached * `documents` row let the peer keep typing for a few seconds until they * reconnect; the overlay locks the UI immediately so no further edits are * attempted, and tells the user to refresh to leave. */ function AccessChangedOverlay({ kind, onRefresh, }: { kind: AccessChangeKind onRefresh: () => void }) { const title = kind === 'revoked' ? 'Your access has been removed' : kind === 'upgrade' ? 'You can now edit this document' : "You're now view-only" const body = kind === 'revoked' ? 'The owner has removed your access to this document. Refresh to continue.' : kind === 'upgrade' ? 'The owner gave you editor access. Refresh to reload the document with editing enabled.' : 'The owner changed your access to view-only. Refresh to reload the document.' return (

{title}

{body}

) } /** * Rendered while we don't yet have a Tiptap editor to mount (initial mount, * docId switch, or while Yjs is still syncing). Crucially this mirrors the * exact geometry of {@link DocEditorSurface} — same canvas, same paged * page-frame, same blank paper — so when the real surface takes over the * swap is invisible. No `animate-pulse` skeleton bars: those used to flash * for a single frame on fast loads, which read as a "flicker". */ function DocumentsEditorLoadingSurface({ label = 'Opening document...' }: { label?: string }) { return (
) } /** * Walk the ProseMirror doc once per editor update, returning headings. * Cheap (linear in node count) and keeps the outline panel in lockstep * with what's actually rendered. */ function useOutlineEntries(editor: Editor | null): OutlineEntry[] { return ( useEditorState({ editor, selector: ({ editor: e }) => { // Same defensive checks as the toolbar selector — `e.state` is null // when the underlying ProseMirror view has been torn down. if (!e || e.isDestroyed || !e.view) return [] as OutlineEntry[] try { const out: OutlineEntry[] = [] e.state.doc.descendants((node, pos) => { if (node.type.name === 'heading') { const level = (node.attrs.level as number) ?? 1 const title = node.textContent.trim() if (title) out.push({ level, title, pos }) return false } return true }) return out } catch { return [] as OutlineEntry[] } }, }) ?? [] ) } export default function DocumentsEditorPage() { const { docId } = useParams<{ docId: string }>() const navigate = useNavigate() const { user } = useUser() const { records: documents, status } = useQuery('documents', { orderBy: 'createdAt', orderDir: 'desc', }) const selectedDoc = useMemo( () => (docId ? documents.find((d) => d.recordId === docId) : null), [documents, docId], ) const { put, ready: documentsMutationsReady } = useMutations('documents') const documentWritesReady = recordsReadyForMutation(status, documentsMutationsReady) // The text helper remains available to consumers of useYjsRoom; this editor // binds Tiptap directly to Y.XmlFragment('default') on the returned document. const { doc, synced, connected, canWrite, writeAuthResolved, awareness } = useYjsRoom( docId ?? 'noop', 'content', ) const presenceAccess = useDocumentsPresenceAccess({ docId, document: selectedDoc, user, yjsCanWrite: canWrite, writeAuthResolved, }) const { isOwner, effectiveRole, effectiveCanWrite, showReadOnlyDocUx, accessChangeKind, handleAclChange, } = presenceAccess const [inviteOpen, setInviteOpen] = useState(false) const [canvasZoom, setCanvasZoom] = useState(() => { if (typeof window === 'undefined') return 1 try { const raw = sessionStorage.getItem(CANVAS_ZOOM_KEY) const v = raw == null ? 1 : Number.parseFloat(raw) return normalizeZoom(Number.isFinite(v) ? v : 1) } catch { return 1 } }) const [outlineOpen, setOutlineOpen] = useState(() => { if (typeof window === 'undefined') return true try { return window.localStorage.getItem(OUTLINE_OPEN_KEY) !== '0' } catch { return true } }) useEffect(() => { try { sessionStorage.setItem(CANVAS_ZOOM_KEY, String(canvasZoom)) } catch { /* ignore */ } }, [canvasZoom]) useEffect(() => { const onKeyDown = (event: KeyboardEvent) => { if (!(event.metaKey || event.ctrlKey) || event.altKey) return const key = event.key const isZoomIn = key === '+' || key === '=' const isZoomOut = key === '-' || key === '_' const isReset = key === '0' if (!isZoomIn && !isZoomOut && !isReset) return event.preventDefault() event.stopPropagation() setCanvasZoom((current) => { if (isReset) return 1 return normalizeZoom(current + (isZoomIn ? KEYBOARD_ZOOM_STEP : -KEYBOARD_ZOOM_STEP)) }) } window.addEventListener('keydown', onKeyDown, true) return () => window.removeEventListener('keydown', onKeyDown, true) }, []) useEffect(() => { try { window.localStorage.setItem(OUTLINE_OPEN_KEY, outlineOpen ? '1' : '0') } catch { /* ignore */ } }, [outlineOpen]) // Tiptap editor -------------------------------------------------------------- const userName = user?.name?.trim() || user?.email?.trim() || 'Guest' const userColor = useMemo(() => getUserColor(user?.id ?? 'anon'), [user?.id]) const editor = useDocEditor({ doc, awareness, userName, userColor, synced, canWrite: effectiveCanWrite, placeholder: showReadOnlyDocUx ? 'View only' : 'Start typing — toolbar above for formatting…', }) const { participants: presenceParticipants, typingNames } = useDocumentsEditorPresence({ editor, document: selectedDoc, user, synced, access: presenceAccess, }) // Outline -------------------------------------------------------------------- const outlineEntries = useOutlineEntries(editor) const jumpToHeading = useCallback( (pos: number) => { if (!editor) return editor.chain().focus().setTextSelection(pos).scrollIntoView().run() }, [editor], ) // Title save ----------------------------------------------------------------- const docTitle = selectedDoc?.data.title?.trim() || 'Untitled Document' const handleTitleSave = useCallback( async (next: string) => { if (!documentWritesReady || !selectedDoc) return await put(selectedDoc.recordId, { ...selectedDoc.data, title: next }).catch(() => {}) }, [documentWritesReady, put, selectedDoc], ) /** * Loading / not-found are intentionally NOT alternative early-return * layouts — swapping the whole page between a skeleton chrome and the real * chrome produced a visible flash on create/open. We render one full layout * and swap only the editor area for not-found vs paper vs live ProseMirror. * The Tiptap instance mounts as soon as it is ready (before Yjs `synced`); * the header, toolbar shell, and paper stay stable while the connection * finishes and document content hydrates from the room. */ const isLoading = status === 'loading' const [showNotFound, setShowNotFound] = useState(false) useEffect(() => { if (isLoading || selectedDoc) { setShowNotFound(false) return } const id = window.setTimeout(() => setShowNotFound(true), DOC_NOT_FOUND_GRACE_MS) return () => window.clearTimeout(id) }, [docId, isLoading, selectedDoc]) const docResolutionPending = isLoading || (!selectedDoc && !showNotFound) const docMissing = !isLoading && !selectedDoc && showNotFound return (
{isOwner ? ( ) : effectiveRole === 'editor' || effectiveRole === 'viewer' ? ( {effectiveRole === 'editor' ? 'Shared editor' : 'Shared viewer'} ) : null} {connected && synced ? ( Synced ) : connected ? ( Connecting… ) : ( Offline )} {effectiveRole === 'owner' ? ( Owner ) : effectiveRole === 'editor' ? ( Editor ) : effectiveRole === 'viewer' ? ( Viewer ) : effectiveCanWrite ? ( Edit ) : ( View )}
{showReadOnlyDocUx ? (
{effectiveRole === 'viewer' ? 'You have view-only access to this document. Ask the owner for editor access.' : 'You are viewing this document in read-only mode.'}
) : null} setCanvasZoom(normalizeZoom(z))} />
{outlineOpen ? ( ) : null}
{docMissing ? (

This document is private. Ask the owner for an invite.

) : editor && !docResolutionPending ? ( ) : ( )} {accessChangeKind ? ( window.location.reload()} /> ) : null}
{selectedDoc ? ( ) : null}
) } /** * Route-level error boundary. Generouted picks up the `ErrorBoundary` export * and wires it to the route's `errorElement` prop, so any render-time throw * inside this route (notably the ProseMirror `matchesNode` crash when the * owner toggles a peer's role mid-session) is contained here instead of * blowing up the whole app with the React Router default screen. * * The recovery path is a hard reload — Yjs/Tiptap state at the moment of * the crash is no longer trustworthy, and the new permissions on the doc * are already authoritative on the server. */ export function ErrorBoundary() { const error = useRouteError() const message = isRouteErrorResponse(error) ? `${error.status} ${error.statusText}` : error instanceof Error ? error.message : 'Something went wrong while loading this document.' return (

This document needs to reload

Your access to this document just changed. Refresh to load the latest version.

{message}

) }