Collaboration on the client
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. provideradds remote carets (CollaborationCaret, labelled withuser); without it the Y.Doc is edited locally only.useris anAuthorIdentity({ id, name, color }, the app's stable user id): the optionalauthorsandcommentsfeatures attribute text and comments to it, see Authors and comments.- Without a
collaborationpropcollabFeature()contributes no extension: the editor behaves like a standalone one with JSONv-model. - Supplying
collaborationto a preset withoutcollabFeature()is a setup error: the surface throws „collaborationneeds 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 anauthormark ({ id, name, color }; one author per run, remote changes and Yjs undo/redo keep the original author's mark). Withoutcollaboration.userit attributes nothing, so standalone editors write unmarked text. Colouring is a per-user preference (editing.authorColors,editing.authorColorStyle).commentsFeature()(requirescollab) stores comment threads in the Y.Doc, in aY.Mapnamedcomments; the commented range is acommentmark. 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 acollaboration.userthe 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(VueEditorSurface/AdvancedEditor) orcommentsOpen/onCommentsOpenChange(React) is the open state of the margin column, which the host renders withCommentsMargininside the editor's scroll container;useEditorUi().toggleComments()andMod-Shift-mtoggle it, and it excludes the AI sidebar. The column lays its cards out with the framework-freelayoutMargin/createMarginControllerof@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()fromadvancedSchemaExtensions()plus the host'sschemaoption ofcreateCollabServer/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). CustomblockTypesare storage, not schema, so they may differ per client. - The host owns the document lifecycle: create the
Y.Docand theHocuspocusProviderwhen the page mounts, destroy the provider on unmount (onBeforeUnmount(() => provider.destroy())), and callforgetDocument(id)of theEditorServerhandle 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/advancedand sets the Collaborationfieldtodefault. A host block of your own (see Backend and collaboration) belongs to that schema too: pass its framework-free node in the server'sschemaoption (schema: () => [...advancedSchemaExtensions(), YourNode]); a custom server buildsgetSchema([...advancedSchemaExtensions(), YourNode])itself.
Charts: bar, line, pie and scatter over a query
The add-on @skaile/forge-editor-charts: the Block type chart, its configuration and the query it derives, the pure Vega-Lite specification, the node view with a chart editor for Vue and React, and the render target chart-svg.
Block UI (drag handle, hover actions)
Add the drag handle, hover targeting, block actions and block-range selection with blockUiFeature().