Guides

Collaboration on the client

Collaboration is a feature: mount a surface with a Yjs session, per-client undo, authors and comments, positions that survive concurrent edits, and matching schemas.

Mounting a session

Collaboration is a feature, not a mode: the preset contains collabFeature(), and the surface is mounted with a session.

<script setup lang="ts">
import { HocuspocusProvider } from '@hocuspocus/provider'
import { collabFeature } from '@skaile/forge-editor/collab/vue'
import { coreContentFeature, coreUiFeature, defineEditor } from '@skaile/forge-editor/core'
import { EditorSurface } from '@skaile/forge-editor/vue'
import * as Y from 'yjs'

const preset = defineEditor({ features: [coreContentFeature(), coreUiFeature(), collabFeature()] })
const ydoc = new Y.Doc()
const provider = new HocuspocusProvider({
  url: `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/collab/${id}`,
  name: id,
  document: ydoc,
})
onBeforeUnmount(() => provider.destroy())
</script>

<template>
  <EditorSurface :preset="preset" :collaboration="{ ydoc, provider, user: { id: 'u1', name: 'Ada', color: '#3b82f6' } }" />
</template>

React is the same with @skaile/forge-editor/collab/react and the React EditorSurface; the Y.Doc and provider are created once per document id and destroyed in an effect cleanup:

import { HocuspocusProvider } from '@hocuspocus/provider'
import { collabFeature } from '@skaile/forge-editor/collab/react'
import { coreContentFeature, coreUiFeature, defineEditor } from '@skaile/forge-editor/core'
import { EditorSurface } from '@skaile/forge-editor/react'
import { useEffect, useMemo, useState } from 'react'
import * as Y from 'yjs'

const preset = defineEditor({ features: [coreContentFeature(), coreUiFeature(), collabFeature()] })

function CollaborativeEditor({ id }: { id: string }) {
  const [session, setSession] = useState<{ ydoc: Y.Doc, provider: HocuspocusProvider } | null>(null)
  useEffect(() => {
    const ydoc = new Y.Doc()
    const provider = new HocuspocusProvider({ url: `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/collab/${id}`, name: id, document: ydoc })
    setSession({ ydoc, provider })
    return () => { provider.destroy(); setSession(null) }
  }, [id])
  const collaboration = useMemo(() => session && { ...session, user: { id: 'u1', name: 'Ada', color: '#3b82f6' } }, [session])
  return collaboration ? <EditorSurface key={id} preset={preset} collaboration={collaboration} /> : null
}
  • The Y.Doc is the document: there is no v-model. Core turns its own undo history off; the Collaboration extension brings a per-client undo/redo (Mod-z, Mod-y, Shift-Mod-z), so undo never reverts another user's edits.
  • provider adds remote carets (CollaborationCaret, labelled with user); without it the Y.Doc is edited locally only. user is an AuthorIdentity ({ id, name, color }, the app's stable user id): the optional authors and comments features attribute text and comments to it, see Authors and comments.
  • Without a collaboration prop collabFeature() contributes no extension: the editor behaves like a standalone one with JSON v-model.
  • Supplying collaboration to a preset without collabFeature() is a setup error: the surface throws „collaboration needs a preset with the collaboration feature“.
  • The mode is fixed at mount. Remount (change key) when the document changes, so Y.Doc, provider and editor never bleed between documents.
  • Include @skaile/forge-editor/styles/collab.css (the Nuxt module adds it) for the caret styles.

Authors and comments

Two optional features build on the session's user; the advanced editor contains both.

  • authorsFeature() marks every text run the user inserts with an author mark ({ id, name, color }; one author per run, remote changes and Yjs undo/redo keep the original author's mark). Without collaboration.user it attributes nothing, so standalone editors write unmarked text. Colouring is a per-user preference (editing.authorColors, editing.authorColorStyle).
  • commentsFeature() (requires collab) stores comment threads in the Y.Doc, in a Y.Map named comments; the commented range is a comment mark. Threads therefore sync live and persist with the document, but they are not part of the JSON/Markdown adapter content. Without a Y.Doc and a collaboration.user the UI says „Kommentare gibt es nur in gemeinsam bearbeiteten Dokumenten.“ and nothing can be written.
  • Both marks are in advancedSchemaExtensions(), so the server's Yjs↔JSON conversion keeps them. They have no Markdown form: Markdown export, imports and the AI context ignore them (DocumentMarkdown).
  • Surface contract: v-model:comments-open (Vue EditorSurface/AdvancedEditor) or commentsOpen/onCommentsOpenChange (React) is the open state of the margin column, which the host renders with CommentsMargin inside the editor's scroll container; useEditorUi().toggleComments() and Mod-Shift-m toggle it, and it excludes the AI sidebar. The column lays its cards out with the framework-free layoutMargin / createMarginController of @skaile/forge-editor/comments: the active thread sits exactly at its text's line, the others are one-line cards out of its way, resolved threads sit behind „Erledigt (n)“, and a thread whose text was deleted stays greyed out at the deletion point („Text gelöscht“) until undo re-attaches it (Authors and comments guide).

Positions that survive concurrent edits

collabFeature() supplies DocAnchors with yjsAnchorResolver: ranges captured through captureRange (AI reviews, ghost text, chat ops, the selection menu) are stored as Yjs relative positions and re-resolved after every remote change and undo/redo. Without the collab feature anchors map through local transactions only.

The resolver binds to the Yjs types of the editor's own Y.Doc. The package pins one yjs instance as a peer dependency: install a single copy in the host, or Yjs warns „already imported“ and instanceof checks fail.

Matching schemas: who owns the document

The Yjs document is only valid if every participant uses the same schema.

  • The server builds collabSchema() from advancedSchemaExtensions() plus the host's schema option of createCollabServer/installEditorServer (the package's collab route) and migrates and validates seeded content with it.
  • The client must mount a preset with the same schema: advancedEditor()/AdvancedEditor, or your own preset whose features contribute the same nodes and marks (media, transcript, Typed blocks, mentions, authors and comments marks, block IDs). Custom blockTypes are storage, not schema, so they may differ per client.
  • The host owns the document lifecycle: create the Y.Doc and the HocuspocusProvider when the page mounts, destroy the provider on unmount (onBeforeUnmount(() => provider.destroy())), and call forgetDocument(id) of the EditorServer handle on the server before deleting a document (see Deleting a document). The library never creates a provider for you.
  • A plain-Vue or React app brings its own Hocuspocus server, builds its schema from /schema/advanced and sets the Collaboration field to default. A host block of your own (see Backend and collaboration) belongs to that schema too: pass its framework-free node in the server's schema option (schema: () => [...advancedSchemaExtensions(), YourNode]); a custom server builds getSchema([...advancedSchemaExtensions(), YourNode]) itself.
Copyright © 2026