Skip to content

Collaboration

Collaboration in Vizel covers four concerns that share the same editor instance: real-time co-editing through a Yjs provider, inline comments, version history snapshots, and presence indicators carried by the collaboration provider. Each concern is opt-in and composes with the others.

ConcernHook / composable / runeFeature flagDefault storage
Real-time editinguseVizelCollaboration / createVizelCollaborationfeatures.collaboration.providerYjs provider
CommentsuseVizelComment / createVizelCommentfeatures.collaboration.comments (requires provider)localStorage
Version historyuseVizelVersionHistory / createVizelVersionHistoryfeatures.collaboration.versionHistory (always available without flag)localStorage

Real-time editing

Vizel integrates with Yjs, a CRDT-based framework that lets multiple users edit the same document simultaneously without conflicts. Setting features.collaboration.provider to any truthy value (boolean or a VizelCollaborationProvider adapter) automatically disables the built-in History extension because Yjs owns undo / redo through Y.UndoManager.

Prerequisites

Install the required peer dependencies:

bash
npm install yjs y-websocket @tiptap/extension-collaboration @tiptap/extension-collaboration-cursor

Version compatibility

Ensure your @tiptap/extension-collaboration version is compatible with the @tiptap/core version used by Vizel. Check the Tiptap documentation for version requirements.

Setup

Start a Yjs WebSocket server for development:

bash
npx y-websocket

Then create the editor and the collaboration provider:

tsx
import { useState } from "react";
import * as Y from "yjs";
import { WebsocketProvider } from "y-websocket";
import Collaboration from "@tiptap/extension-collaboration";
import CollaborationCursor from "@tiptap/extension-collaboration-cursor";
import {
  VizelEditor,
  VizelBubbleMenu,
  useVizelEditor,
  useVizelCollaboration,
} from "@vizel/react";

function CollaborativeEditor() {
  const user = { name: "Alice", color: "#ff0000" };

  const [doc] = useState(() => new Y.Doc());
  const [provider] = useState(
    () => new WebsocketProvider("ws://localhost:1234", "my-document", doc),
  );

  const { isConnected, isSynced, peerCount } = useVizelCollaboration(
    provider,
    { user },
  );

  const editor = useVizelEditor({
    features: { collaboration: true },
    extensions: [
      Collaboration.configure({ document: doc }),
      CollaborationCursor.configure({ provider, user }),
    ],
  });

  return (
    <div>
      <div className="status-bar">
        <span>{isConnected ? "Connected" : "Disconnected"}</span>
        <span>{isSynced ? "Synced" : "Syncing..."}</span>
        <span>{peerCount} peer(s)</span>
      </div>
      <VizelEditor editor={editor} />
      {editor && <VizelBubbleMenu editor={editor} />}
    </div>
  );
}
vue
<script setup lang="ts">
import * as Y from "yjs";
import { WebsocketProvider } from "y-websocket";
import Collaboration from "@tiptap/extension-collaboration";
import CollaborationCursor from "@tiptap/extension-collaboration-cursor";
import {
  VizelEditor,
  VizelBubbleMenu,
  useVizelEditor,
  useVizelCollaboration,
} from "@vizel/vue";

const user = { name: "Alice", color: "#ff0000" };
const doc = new Y.Doc();
const provider = new WebsocketProvider(
  "ws://localhost:1234",
  "my-document",
  doc,
);

const { isConnected, isSynced, peerCount } = useVizelCollaboration(
  () => provider,
  { user },
);

const editor = useVizelEditor({
  features: { collaboration: true },
  extensions: [
    Collaboration.configure({ document: doc }),
    CollaborationCursor.configure({ provider, user }),
  ],
});
</script>

<template>
  <div>
    <div class="status-bar">
      <span>{{ isConnected ? "Connected" : "Disconnected" }}</span>
      <span>{{ isSynced ? "Synced" : "Syncing..." }}</span>
      <span>{{ peerCount }} peer(s)</span>
    </div>
    <VizelEditor :editor="editor" />
    <VizelBubbleMenu v-if="editor" :editor="editor" />
  </div>
</template>
svelte
<script lang="ts">
import * as Y from "yjs";
import { WebsocketProvider } from "y-websocket";
import Collaboration from "@tiptap/extension-collaboration";
import CollaborationCursor from "@tiptap/extension-collaboration-cursor";
import {
  VizelEditor,
  VizelBubbleMenu,
  createVizelEditor,
  createVizelCollaboration,
} from "@vizel/svelte";

const user = { name: "Alice", color: "#ff0000" };
const doc = new Y.Doc();
const provider = new WebsocketProvider(
  "ws://localhost:1234",
  "my-document",
  doc,
);

const collab = createVizelCollaboration(() => provider, { user });

const editor = createVizelEditor({
  features: { collaboration: true },
  extensions: [
    Collaboration.configure({ document: doc }),
    CollaborationCursor.configure({ provider, user }),
  ],
});
</script>

<div>
  <div class="status-bar">
    <span>{collab.isConnected ? "Connected" : "Disconnected"}</span>
    <span>{collab.isSynced ? "Synced" : "Syncing..."}</span>
    <span>{collab.peerCount} peer(s)</span>
  </div>
  <VizelEditor editor={editor.current} />
  {#if editor.current}
    <VizelBubbleMenu editor={editor.current} />
  {/if}
</div>

Collaboration options and return values

OptionTypeDefaultDescription
enabledbooleantrueEnable state tracking
user{ name, color }RequiredCurrent user info for cursor display
onConnect() => voidFires when the client connects
onDisconnect() => voidFires when the client disconnects
onSynced() => voidFires when initial sync completes
onError(error) => voidFires on a provider error
onPeersChange(count) => voidFires when the peer count changes
Return valueDescription
isConnectedWhether the client is connected to the server
isSyncedWhether initial document sync is complete
peerCountNumber of connected peers (including self)
errorLast error that occurred
connect()Connect to the server
disconnect()Disconnect from the server
updateUser(user)Update cursor user information

Server setup

For local development, npx y-websocket is sufficient. For production:

js
// server.js
import { WebSocketServer } from "ws";
import { setupWSConnection } from "y-websocket/bin/utils";

const wss = new WebSocketServer({ port: 1234 });
wss.on("connection", (ws, req) => setupWSConnection(ws, req));

Persist documents with LevelDB through the YPERSISTENCE environment variable or y-leveldb. Yjs supports alternative transports (y-webrtc for peer-to-peer, @hocuspocus/provider for auth and extensibility) that drop into the same useVizelCollaboration call.

Comments

Comments add inline annotations to any text selection. The feature is opt-in and ships with localStorage persistence by default.

Quick start

tsx
import {
  useVizelEditor,
  useVizelComment,
  VizelProvider,
  VizelEditor,
} from "@vizel/react";

function Editor() {
  const editor = useVizelEditor({ features: { comment: true } });
  const { comments, addComment, resolveComment, removeComment, setActiveComment } =
    useVizelComment(editor, { key: "my-doc-comments" });

  return (
    <VizelProvider editor={editor}>
      <VizelEditor />
      <button onClick={() => {
        const text = prompt("Enter comment:");
        if (text) addComment(text, "Author");
      }}>
        Add Comment
      </button>
      <ul>
        {comments.map((c) => (
          <li key={c.id} onClick={() => setActiveComment(c.id)}>
            {c.text}
            <button onClick={() => resolveComment(c.id)}>Resolve</button>
            <button onClick={() => removeComment(c.id)}>Delete</button>
          </li>
        ))}
      </ul>
    </VizelProvider>
  );
}
vue
<script setup lang="ts">
import {
  useVizelEditor,
  useVizelComment,
  VizelProvider,
  VizelEditor,
} from "@vizel/vue";

const editor = useVizelEditor({ features: { comment: true } });
const { comments, addComment, resolveComment, removeComment, setActiveComment } =
  useVizelComment(() => editor.value, { key: "my-doc-comments" });
</script>

<template>
  <VizelProvider :editor="editor">
    <VizelEditor />
    <button @click="() => {
      const text = prompt('Enter comment:');
      if (text) addComment(text, 'Author');
    }">
      Add Comment
    </button>
    <ul>
      <li v-for="c in comments" :key="c.id" @click="setActiveComment(c.id)">
        {{ c.text }}
        <button @click="resolveComment(c.id)">Resolve</button>
        <button @click="removeComment(c.id)">Delete</button>
      </li>
    </ul>
  </VizelProvider>
</template>
svelte
<script lang="ts">
import {
  createVizelEditor,
  createVizelComment,
  VizelProvider,
  VizelEditor,
} from "@vizel/svelte";

const editor = createVizelEditor({ features: { comment: true } });
const comment = createVizelComment(() => editor.current, {
  key: "my-doc-comments",
});
</script>

<VizelProvider editor={editor.current}>
  <VizelEditor />
  <button onclick={() => {
    const text = prompt("Enter comment:");
    if (text) comment.addComment(text, "Author");
  }}>
    Add Comment
  </button>
  <ul>
    {#each comment.comments as c}
      <li onclick={() => comment.setActiveComment(c.id)}>
        {c.text}
        <button onclick={() => comment.resolveComment(c.id)}>Resolve</button>
        <button onclick={() => comment.removeComment(c.id)}>Delete</button>
      </li>
    {/each}
  </ul>
</VizelProvider>

Comment options

PropertyTypeDefaultDescription
enabledbooleantrueEnable comments
storageVizelCommentStorage"localStorage"Storage backend
keystring"vizel-comments"Storage key for localStorage
onAdd(comment) => voidFires after a comment is added
onRemove(commentId) => voidFires after a comment is removed
onResolve(comment) => voidFires after a comment is resolved
onReopen(comment) => voidFires after a comment is reopened
onError(error) => voidFires on a storage error

A custom storage backend must implement both save and load:

ts
useVizelComment(editor, {
  storage: {
    save: async (comments) => {
      await fetch("/api/comments", {
        method: "PUT",
        body: JSON.stringify(comments),
      });
    },
    load: async () => {
      const res = await fetch("/api/comments");
      return res.json();
    },
  },
});

Comment data model

ts
interface VizelComment {
  id: string;
  text: string;
  author?: string;
  createdAt: number;
  resolved: boolean;
  replies: VizelCommentReply[];
}

interface VizelCommentReply {
  id: string;
  text: string;
  author?: string;
  createdAt: number;
}

Comment return values

PropertyTypeDescription
commentsVizelComment[]All stored comments (newest first)
activeCommentIdstring | nullCurrently active comment ID
isLoadingbooleanWhether comments are loading
errorError | nullLast error
addComment(text, author?)Promise<VizelComment | null>Add a comment to the selection
removeComment(id)Promise<void>Remove a comment and its mark
resolveComment(id)Promise<boolean>Mark a comment as resolved
reopenComment(id)Promise<boolean>Reopen a resolved comment
replyToComment(id, text, author?)Promise<VizelCommentReply | null>Add a reply
setActiveComment(id)voidSet the active comment
loadComments()Promise<VizelComment[]>Reload from storage
getCommentById(id)VizelComment | undefinedLookup a comment

Comment styling

Comment highlights ship two CSS classes that you can theme with custom properties:

ClassDescription
.vizel-comment-markerBase highlight for commented text
.vizel-comment-marker--activeHighlight for the active comment
css
:root {
  --vizel-comment-bg: rgba(255, 212, 100, 0.3);
  --vizel-comment-border: rgba(255, 180, 50, 0.6);
  --vizel-comment-active-bg: rgba(255, 180, 50, 0.5);
  --vizel-comment-active-border: rgba(255, 150, 0, 0.8);
}

Version history

Version history captures document snapshots that you can restore later. The hook works without any feature flag because snapshots are derived from the live editor instance.

Quick start

tsx
import {
  useVizelEditor,
  useVizelVersionHistory,
  VizelProvider,
  VizelEditor,
} from "@vizel/react";

function Editor() {
  const editor = useVizelEditor({});
  const { snapshots, saveVersion, restoreVersion, deleteVersion } =
    useVizelVersionHistory(editor, {
      maxVersions: 20,
      key: "my-doc-versions",
    });

  return (
    <VizelProvider editor={editor}>
      <VizelEditor />
      <button onClick={() => saveVersion("Manual save")}>Save Version</button>
      <ul>
        {snapshots.map((s) => (
          <li key={s.id}>
            {s.description ?? "Untitled"} —{" "}
            {new Date(s.timestamp).toLocaleString()}
            <button onClick={() => restoreVersion(s.id)}>Restore</button>
            <button onClick={() => deleteVersion(s.id)}>Delete</button>
          </li>
        ))}
      </ul>
    </VizelProvider>
  );
}
vue
<script setup lang="ts">
import {
  useVizelEditor,
  useVizelVersionHistory,
  VizelProvider,
  VizelEditor,
} from "@vizel/vue";

const editor = useVizelEditor({});
const { snapshots, saveVersion, restoreVersion, deleteVersion } =
  useVizelVersionHistory(() => editor.value, {
    maxVersions: 20,
    key: "my-doc-versions",
  });
</script>

<template>
  <VizelProvider :editor="editor">
    <VizelEditor />
    <button @click="saveVersion('Manual save')">Save Version</button>
    <ul>
      <li v-for="s in snapshots" :key="s.id">
        {{ s.description ?? "Untitled" }} —
        {{ new Date(s.timestamp).toLocaleString() }}
        <button @click="restoreVersion(s.id)">Restore</button>
        <button @click="deleteVersion(s.id)">Delete</button>
      </li>
    </ul>
  </VizelProvider>
</template>
svelte
<script lang="ts">
import {
  createVizelEditor,
  createVizelVersionHistory,
  VizelProvider,
  VizelEditor,
} from "@vizel/svelte";

const editor = createVizelEditor({});
const history = createVizelVersionHistory(() => editor.current, {
  maxVersions: 20,
  key: "my-doc-versions",
});
</script>

<VizelProvider editor={editor.current}>
  <VizelEditor />
  <button onclick={() => history.saveVersion("Manual save")}>
    Save Version
  </button>
  <ul>
    {#each history.snapshots as s}
      <li>
        {s.description ?? "Untitled"} —
        {new Date(s.timestamp).toLocaleString()}
        <button onclick={() => history.restoreVersion(s.id)}>Restore</button>
        <button onclick={() => history.deleteVersion(s.id)}>Delete</button>
      </li>
    {/each}
  </ul>
</VizelProvider>

Version history options and return values

OptionTypeDefaultDescription
enabledbooleantrueEnable version history
maxVersionsnumber50Maximum number of snapshots to keep
storageVizelVersionStorage"localStorage"Storage backend
keystring"vizel-versions"Storage key for localStorage
onSave(snapshot) => voidFires after a snapshot is saved
onRestore(snapshot) => voidFires after a snapshot is restored
onError(error) => voidFires on a storage error
Return valueDescription
snapshotsVizelVersionSnapshot[] (newest first)
isLoadingWhether history is loading
errorLast error
saveVersion(desc?, author?)Save the current state
restoreVersion(id)Restore a version
loadVersions()Reload from storage
deleteVersion(id)Delete a single version
clearVersions()Delete every version

A snapshot carries the document content as JSON, a unique id, a Unix timestamp, and optional description and author fields:

ts
interface VizelVersionSnapshot {
  id: string;
  content: JSONContent;
  timestamp: number;
  description?: string;
  author?: string;
}

Key concepts

  1. CRDT. Yjs uses Conflict-free Replicated Data Types to merge concurrent edits without conflicts. Each client edits independently and Yjs merges changes automatically.
  2. Awareness. The Yjs Awareness protocol carries ephemeral state like cursor positions, user names, and colors. Awareness state is separate from the document and is not persisted.
  3. History extension conflict. Always set features.collaboration.provider (boolean or adapter) when Yjs is in play. The built-in History extension does not understand CRDT operations and conflicts with Y.UndoManager.
  4. Offline support. Yjs stores edits made offline locally and syncs them on reconnect without data loss.

Troubleshooting

SymptomResolution
Undo / redo behaves unexpectedlySet features.collaboration.provider (boolean or adapter) so the built-in History extension is disabled
WebSocket disconnects in productionVerify the reverse proxy supports WebSocket upgrades; check the ws:// vs wss:// protocol
Remote cursors appear without colorPass user.color to both CollaborationCursor.configure() and the collaboration hook / composable / rune
Comments do not persistConfirm the storage backend resolves both save and load

Try this if you want to ...

  • ... configure the editor instance itself → Editor
  • ... reorder, duplicate, or delete whole blocks → Blocks
  • ... change the Markdown flavor → Markdown
  • ... render Markdown on the server → SSR

Released under the MIT License.