# Optional source collaboration

Add shared editing, remote cursors, and a collaborator list when you need them.

## Try it together

Type or select text on either side and watch the other. Try independent undo, read-only mode, and reconnecting after offline edits.

### collaboration/editor.vue

```vue
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, onUnmounted, ref, shallowRef } from "vue";
import type { MsfeEditor } from "@quirekit/msfe-core";
import { getMdVersions } from "@quirekit/md-editor";
import type { ExampleLocale } from "../shared/types";
import { createDemoRoom } from "./room";
import { collaborationMessages } from "./messages";
import Peer from "./peer.vue";
import { MdActivityTimeline, MdVersionList } from "@quirekit/md-editor/vue";

const props = withDefaults(defineProps<{ /** Host control language. */ locale?: ExampleLocale }>(), { locale: "zh-CN" });
const text = computed(() => collaborationMessages[props.locale]);
const ready = ref(false), failed = ref(false);
// Opaque session/Y.Doc instances stay outside Vue deep reactivity.
const room = createDemoRoom(text.value.initial, [{ id: "a", name: text.value.left }, { id: "b", name: text.value.right }]);
const lifetime = new AbortController();
// Versions are listed once for the room and saved or restored through participant A's editor.
const left = shallowRef<MsfeEditor | null>(null);
onMounted(async () => {
  try { await room.prepare(lifetime.signal); if (!lifetime.signal.aborted) ready.value = true; }
  catch { if (!lifetime.signal.aborted) failed.value = true; }
});
onBeforeUnmount(() => { lifetime.abort(); });
onUnmounted(() => {
  // Children release their editor leases first. The disclosed disposable example then discards its room.
  void room.destroy().catch(error => { console.error("Collaboration example cleanup failed", error); });
});
</script>
<template>
  <div class="grid gap-qk-group p-qk-surface">
    <div class="grid gap-2">
      <strong class="text-qk-text-primary">{{ text.title }}</strong>
      <p class="m-0 text-qk-caption text-qk-text-secondary">{{ text.note }}</p>
      <p class="m-0 text-qk-text-secondary">{{ text.hint }}</p>
    </div>
    <p v-if="failed" class="m-0 text-qk-text-secondary" role="alert">{{ text.failed }}</p>
    <div v-else-if="ready" class="collaboration-columns">
      <Peer :session="room.sessions[0]" :room="room.comments[0]" :versions="room.activity.versions[0]" side="left" :locale="locale" @editor="left = $event" />
      <Peer :session="room.sessions[1]" :room="room.comments[1]" :versions="room.activity.versions[1]" side="right" :locale="locale" />
    </div>
    <p v-else class="m-0 text-qk-text-secondary" role="status">{{ text.loading }}</p>
    <section v-if="ready" class="grid gap-2" data-collaboration-activity>
      <strong class="text-qk-text-primary">{{ text.activity }}</strong>
      <p class="m-0 text-qk-caption text-qk-text-secondary">{{ text.activityNote }}</p>
      <MdActivityTimeline :store="room.activity.store" :document-id="room.activity.documentId" :locale="locale" />
    </section>
    <section v-if="ready && left" class="grid gap-2" data-collaboration-versions>
      <strong class="text-qk-text-primary">{{ text.versions }}</strong>
      <p class="m-0 text-qk-caption text-qk-text-secondary">{{ text.versionsNote }}</p>
      <MdVersionList :versions="room.activity.versions[0]" :document-id="room.activity.documentId" :locale="locale"
        :controller="() => left && getMdVersions(left)" :source="() => left?.state.doc.toString() ?? ''" />
    </section>
  </div>
</template>
<style scoped>
.collaboration-columns { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--qk-space-surface); align-items: start; }
@media (max-width: 700px) { .collaboration-columns { grid-template-columns: minmax(0, 1fr); } }
</style>

```

### shared/types.ts

```ts
/** Example host language; independent of the documentation router. */
export type ExampleLocale = "zh-CN" | "en";

```

### collaboration/room.ts

```ts
import { createCollaborationSession, type CollaborationProviderFactory, type CollaborationProviderContext, type CollaborationPresenceTransport } from "@quirekit/msfe-collaboration";
import * as Y from "yjs";
import type { CollaborationDemoRoom } from "./types";
import { createPresenceRelay } from "./presence";
import { createCommentRelay } from "./comments";
import { createDemoActivity, type DemoMember } from "./activity";

/**
 * Creates a same-page relay using the public provider contract. No network or durable storage is installed.
 * @param source - Seed inserted exactly once by the room authority.
 * @param members - Identities of the two participants, used as their authenticated connection contexts.
 * @returns Two independent sessions and their shared disposable room.
 */
export function createDemoRoom(source: string, members: readonly [DemoMember, DemoMember] = [{ id: "a", name: "A" }, { id: "b", name: "B" }]): CollaborationDemoRoom {
  const document = new Y.Doc();
  document.getText("source").insert(0, source);
  const clients = new Map<Y.Doc, CollaborationProviderContext>();
  const remote = Symbol("documentation-relay");
  const presenceRelay = createPresenceRelay();
  const activity = createDemoActivity("documentation-example", document);

  // Only binary updates cross the relay. Editor presentation, selection and undo stacks stay local.
  document.on("update", (update: Uint8Array, origin: unknown) => {
    for (const [client] of clients) if (client !== origin) Y.applyUpdate(client, update, remote);
  });
  const provider = (member: DemoMember): CollaborationProviderFactory => context => {
    let presence: CollaborationPresenceTransport | undefined;
    const socketId = `${member.id}:${context.document.clientID}`;
    /** @param update - Accepted local delta, including deletes. @param origin - Transport loop marker. */
    function send(update: Uint8Array, origin: unknown): void {
      if (origin === remote) return;
      context.report({ synced: false });
      Y.applyUpdate(document, update, context.document);
      activity.changed(socketId, member);
      // This acknowledges only delivery to this in-memory room; it is never a save receipt.
      context.report({ synced: true });
    }
    /** Removes this connection only; the session keeps its document and local history. */
    function disconnect(): void {
      presence?.release(); presence = undefined;
      context.document.off("update", send);
      if (clients.delete(context.document)) activity.disconnected(socketId);
      context.report({ connection: "disconnected", synced: false });
    }
    return {
      /** @returns Ephemeral state channel only when an editor explicitly installs presence. */
      acquirePresence() { presence?.release(); return presence = presenceRelay.acquire(String(context.document.clientID)); },
      /** @param payload - Text for server-side extensions, here the activity extension's hints. @returns Whether it was delivered. */
      sendStateless(payload) { if (!clients.has(context.document)) return false; activity.stateless(socketId, payload); return true; },
      /** @param signal - Cancellable admission. @returns Fixed identity for this disposable room. */
      async open(signal) {
        signal.throwIfAborted();
        return { documentId: context.documentId, epoch: "demo-1", permission: "write", sourceFormat: { bom: false, lineEnding: "\n" } };
      },
      /** @param _manifest - Validated admission. @param signal - Cancellable initial synchronization. @returns Completion after both branches merge. */
      async connect(_manifest, signal) {
        signal.throwIfAborted();
        // Reconnection merges both offline branches with their original CRDT identities.
        Y.applyUpdate(context.document, Y.encodeStateAsUpdate(document, Y.encodeStateVector(context.document)), remote);
        activity.connected(socketId, member);
        Y.applyUpdate(document, Y.encodeStateAsUpdate(context.document, Y.encodeStateVector(document)), context.document);
        // Offline edits merged on reconnect are recorded for the member who made them.
        activity.changed(socketId, member);
        clients.set(context.document, context);
        context.document.on("update", send);
        context.report({ connection: "connected", synced: true });
      },
      disconnect,
      destroy: disconnect,
    };
  };
  const sessions = [
    createCollaborationSession({ documentId: "documentation-example", provider: provider(members[0]) }),
    createCollaborationSession({ documentId: "documentation-example", provider: provider(members[1]) }),
  ] as const;
  // Comments travel through a separate room that follows each session's connection, as a host would wire it.
  const comments = createCommentRelay(sessions, members, activity.comments);
  let preparation: Promise<void> | undefined;
  let disposal: Promise<void> | undefined;
  return {
    sessions, comments: comments.rooms,
    activity: { store: activity.store, documentId: activity.documentId, versions: [activity.versionsFor(members[0]), activity.versionsFor(members[1])] },
    /** @param signal - Page lifetime. @returns Both sessions ready for synchronous editor mounting. */
    prepare(signal) {
      return preparation ??= Promise.allSettled(sessions.map(session => session.prepare({ signal }))).then(results => {
        const failure = results.find(result => result.status === "rejected");
        if (failure?.status === "rejected") throw failure.reason;
      });
    },
    /** @returns Completion after cancellation settles and all example resources are released. */
    destroy() {
      return disposal ??= (async () => {
        // Capture an in-flight reconnect before disconnect cancels it; close requires preparation to settle.
        const pending = sessions.filter(session => session.state.connection === "connecting").map(session => session.prepare());
        sessions.forEach(session => session.disconnect());
        await Promise.allSettled([...(preparation ? [preparation] : []), ...pending]);
        // The page explicitly advertises disposable data. A real host must choose persist or checkpoint.
        await Promise.all(sessions.map(session => session.close({ mode: "discard" })));
        clients.clear(); activity.destroy(); document.destroy(); comments.destroy();
      })();
    },
  };
}

```

### collaboration/types.ts

```ts
import type { Doc } from "yjs";
import type { CollaborationSession } from "@quirekit/msfe-collaboration";
import type { ActivityStore, VersionStore } from "@quirekit/edit-history";

/** Ephemeral room owned by this documentation example, with two independent clients. */
export interface CollaborationDemoRoom {
  /** Prepared separately; neither editor shares the other's Y.Doc or history. */
  readonly sessions: readonly [CollaborationSession, CollaborationSession];
  /** Each client's replica of the comment room, synchronized only while its session is connected. */
  readonly comments: readonly [Doc, Doc];
  /** Server-confirmed activity records of the room, bounded in memory, and each member's view of the version API. */
  readonly activity: { readonly store: ActivityStore; readonly documentId: string; readonly versions: readonly [VersionStore, VersionStore] };
  /** @param signal - Page lifetime. @returns Completion after both clients receive the single seed. */
  prepare(signal: AbortSignal): Promise<void>;
  /** @returns Completion after explicitly discarding the example; call after editors release their leases. */
  destroy(): Promise<void>;
}

```

### collaboration/presence.ts

```ts
import type { CollaborationPresenceTransport } from "@quirekit/msfe-collaboration";

/** @returns A disposable same-page presence channel; production transports must supply authenticated leases and timeouts. */
export function createPresenceRelay() {
  const states = new Map<string, unknown>(), listeners = new Set<() => void>();
  /** Publishes the current in-memory snapshot without touching shared source or history. */
  function notify(): void { for (const listener of listeners) listener(); }
  return {
    /** @param clientId - This room's unique document replica id. @returns One explicit ephemeral lease. */
    acquire(clientId: string): CollaborationPresenceTransport {
      let disposed = false;
      const subscriptions = new Set<() => void>();
      return {
        clientId,
        /** @returns Current room members; receivers exclude themselves and validate payloads. */
        getStates() { return disposed ? new Map() : states; },
        /** @param value - JSON-compatible display/selection state, or null to withdraw. */
        publish(value) {
          if (disposed) return;
          if (value === null) states.delete(clientId); else states.set(clientId, JSON.parse(JSON.stringify(value)));
          notify();
        },
        /** @param listener - Display state observer. @returns Unsubscribe callback. */
        subscribe(listener) {
          if (!disposed) { listeners.add(listener); subscriptions.add(listener); }
          return () => { listeners.delete(listener); subscriptions.delete(listener); };
        },
        /** Immediately removes this member, since the in-process relay knows when its connection leaves. */
        release() {
          if (disposed) return; disposed = true;
          for (const listener of subscriptions) listeners.delete(listener);
          subscriptions.clear(); states.delete(clientId); notify();
        },
      };
    },
  };
}

```

### collaboration/comments.ts

```ts
import * as Y from "yjs";
import { Extension } from "@quirekit/msfe-core";
import { commentsState } from "@quirekit/msfe-comments";
import type { CollaborationSession } from "@quirekit/msfe-collaboration";
import type { DemoMember } from "./activity";

/** Server-side observers of the comment room, here the activity extension. */
export interface CommentRelayHooks {
  /** @param room - Authority document. */ loaded(room: Y.Doc): void;
  /** @param socketId - Replica connection. @param member - Its identity. */ connected(socketId: string, member: DemoMember): void;
  /** @param room - Authority after the update. @param socketId - Sending replica. @param member - Its identity. */ changed(room: Y.Doc, socketId: string, member: DemoMember): void;
  /** @param socketId - Closed replica connection. */ disconnected(socketId: string): void;
}

/**
 * Same-page comment room: one authority document and a replica per session. A replica relays only while its
 * session is connected, so disconnecting one side also keeps its comments offline until it reconnects.
 * @param sessions - Example sessions, in replica order.
 * @param members - Replica identities, in the same order.
 * @param hooks - Optional server-side observers.
 * @returns Replicas and a disposer.
 */
export function createCommentRelay(sessions: readonly CollaborationSession[], members?: readonly DemoMember[], hooks?: CommentRelayHooks) {
  const authority = new Y.Doc(), remote = Symbol("comment-relay");
  hooks?.loaded(authority);
  const rooms = sessions.map(() => new Y.Doc()) as unknown as readonly [Y.Doc, Y.Doc];
  const forwards = new Map<Y.Doc, (update: Uint8Array, origin: unknown) => void>();
  authority.on("update", (update: Uint8Array, origin: unknown) => {
    for (const room of forwards.keys()) if (room !== origin) Y.applyUpdate(room, update, remote);
  });
  /** @param room - Replica to connect; both offline branches merge first. */
  const link = (room: Y.Doc, index: number) => {
    if (forwards.has(room)) return;
    const member = members?.[index], socketId = member ? `${member.id}:comments` : "";
    Y.applyUpdate(room, Y.encodeStateAsUpdate(authority, Y.encodeStateVector(room)), remote);
    if (member) hooks?.connected(socketId, member);
    Y.applyUpdate(authority, Y.encodeStateAsUpdate(room, Y.encodeStateVector(authority)), room);
    if (member) hooks?.changed(authority, socketId, member);
    const forward = (update: Uint8Array, origin: unknown) => {
      if (origin === remote) return;
      Y.applyUpdate(authority, update, room);
      if (member) hooks?.changed(authority, socketId, member);
    };
    forwards.set(room, forward); room.on("update", forward);
  };
  /** @param room - Replica to take offline. */
  const unlink = (room: Y.Doc, index: number) => {
    const forward = forwards.get(room);
    if (!forward) return;
    room.off("update", forward); forwards.delete(room);
    const member = members?.[index];
    if (member) hooks?.disconnected(`${member.id}:comments`);
  };
  const unsubscribe = sessions.map((session, index) => session.subscribe(state => {
    if (state.connection === "connected") link(rooms[index]!, index); else unlink(rooms[index]!, index);
  }));
  return {
    rooms,
    /** Releases the relay and every replica. */
    destroy(): void { unsubscribe.forEach(stop => stop()); rooms.forEach((room, index) => { unlink(room, index); room.destroy(); }); authority.destroy(); },
  };
}

/**
 * @param changed - Called after a transaction that changed comment state, including remote merges.
 * @returns Editor extension feeding the example's comment panel.
 */
export function watchComments(changed: () => void) {
  return Extension.create({
    name: "exampleCommentsWatch",
    /** @param event - Accepted transaction. */
    onTransaction(event) { if (commentsState.read(event.transaction.startState) !== commentsState.read(event.transaction.state)) changed(); },
  });
}

```

### collaboration/activity.ts

```ts
import { v4 as uuidv4 } from "uuid";
import type * as Y from "yjs";
import type { VersionStore } from "@quirekit/edit-history";
import { createMemoryActivityStore, createMemoryVersionStore } from "@quirekit/edit-history/memory";
import { createMsfeActivityExtension } from "@quirekit/msfe-collaboration-hocuspocus/server";

/** Participant identity the relay treats as an authenticated connection context. */
export interface DemoMember {
  /** Stable member identity. */ readonly id: string;
  /** Display name. */ readonly name: string;
}

/**
 * Runs the real server-side activity extension inside the same-page relay. Records are bounded on every append
 * (newest 200, content for one day), the way a production store should cap growth. Versions are checkpoints the
 * host takes while storing the document, at most one per `checkpointMs`, plus named versions saved by members.
 * @param documentName - Source room name; its comment room is `${documentName}:comments`.
 * @param document - Relay authority document of the source room.
 * @returns Hook feeds for both relays, the bounded store and a disposer.
 */
export function createDemoActivity(documentName: string, document: Y.Doc) {
  const commentsName = `${documentName}:comments`;
  const store = createMemoryActivityStore({ retention: { fullDays: 1, maxEvents: 200 } }), versions = createMemoryVersionStore();
  const checkpointMs = 20_000, epoch = "demo-1";
  let checkpointAt = 0, sequence = 0;
  void versions.create({ documentId: documentName, epoch, sequence, trigger: "import" }, { format: "markdown", value: document.getText("source").toString() });
  const extension = createMsfeActivityExtension<DemoMember>({ mergeMs: 2000,
    resolveActor: member => ({ id: member.id, name: member.name, trust: "verified" }),
    commentsRoom: name => name === commentsName,
    resolveDocument: name => name === documentName || name === commentsName ? { documentId: documentName, epoch: "demo-1" } : null });
  void extension.afterLoadDocument({ documentName, document });
  // Stands in for Hocuspocus's debounced document store: pending events are written together, failures retried.
  const timer = setInterval(() => {
    for (const name of [documentName, commentsName]) {
      const events = extension.takeActivity(name);
      if (!events.length) continue;
      store.append(events).then(receipts => {
        sequence = Math.max(sequence, ...receipts.map(receipt => receipt.sequence));
        // The stored source becomes an automatic version; unchanged content reuses the latest one.
        if (name !== documentName || Date.now() - checkpointAt < checkpointMs) return;
        checkpointAt = Date.now();
        void versions.create({ documentId: documentName, epoch, sequence, trigger: "auto" }, { format: "markdown", value: document.getText("source").toString() });
      }, () => extension.restoreActivity(name, events));
    }
  }, 1000);
  // In Node (documentation tests) the timer must not keep the process alive.
  (timer as { unref?: () => void }).unref?.();
  /**
   * Stands in for the host's authenticated version API: the actor comes from the caller's identity, never from the
   * request, and saving a named version is recorded as server-confirmed activity.
   * @param member - Authenticated caller.
   * @returns Version store bound to that caller.
   */
  const versionsFor = (member: DemoMember): VersionStore => ({
    list: query => versions.list(query),
    read: id => versions.read(id),
    async create(version, content) {
      const actor = { id: member.id, name: member.name, trust: "verified" as const };
      const record = await versions.create({ ...version, sequence, actor }, content);
      // An automatic save may reuse an identical named version; only an explicit named save is recorded.
      if (version.trigger === "named" && record.name) await store.append([{ documentId: documentName, epoch, operationId: uuidv4(), actor,
        source: "human", kind: "version.create", kindVersion: 1, facts: { versionId: record.id, name: record.name } }]);
      return record;
    },
  });
  return { store, documentId: documentName, versionsFor,
    /** @param socketId - Connection. @param member - Its identity. */
    connected: (socketId: string, member: DemoMember) => void extension.connected({ documentName, socketId, context: member }),
    /** @param socketId - Connection whose update was just applied to the authority. @param member - Its identity. */
    changed: (socketId: string, member: DemoMember) => void extension.onChange({ documentName, document, socketId, context: member }),
    /** @param socketId - Connection. @param payload - Stateless text, such as an activity hint. */
    stateless: (socketId: string, payload: string) => void extension.onStateless({ documentName, payload, connection: { socketId } }),
    /** @param socketId - Closed connection. */
    disconnected: (socketId: string) => void extension.onDisconnect({ documentName, socketId }),
    /** Comment room feeds: the room shares the source session, so it records comment actions but no joins. */
    comments: {
      /** @param room - Comment room authority document. */
      loaded: (room: Y.Doc) => void extension.afterLoadDocument({ documentName: commentsName, document: room }),
      /** @param socketId - Replica connection. @param member - Its identity. */
      connected: (socketId: string, member: DemoMember) => void extension.connected({ documentName: commentsName, socketId, context: member }),
      /** @param room - Comment room authority after the update. @param socketId - Sending replica. @param member - Its identity. */
      changed: (room: Y.Doc, socketId: string, member: DemoMember) => void extension.onChange({ documentName: commentsName, document: room, socketId, context: member }),
      /** @param socketId - Closed replica connection. */
      disconnected: (socketId: string) => void extension.onDisconnect({ documentName: commentsName, socketId }),
    },
    /** Stops the store timer. */
    destroy: () => clearInterval(timer),
  };
}

```

### collaboration/messages.ts

```ts
/** Complete host copy; document language is selected once when the example is created. */
export const collaborationMessages = {
  "zh-CN": {
    title: "一起编辑同一份 Markdown",
    note: "同页协同模拟 · 仅保存在内存，离开页面或刷新后清空。",
    hint: "在任意一侧输入或拖选，另一侧会显示姓名光标与选区。虚线光标表示隐藏源码所在块；失焦后仍保留在线成员。选中文字后可添加评论，评论经独立房间同步，不写入 Markdown。",
    comments: "评论", addComment: "评论选中内容", noComments: "选中文字后添加评论，另一侧会同步显示。",
    collaborators: "协作者", waiting: "等待其他协作者加入", presenceOffline: "离线 · 协作者暂不可见", editing: "正在编辑", viewing: "在线",
    left: "参与者 A", right: "参与者 B", leftEditor: "参与者 A 的 Markdown", rightEditor: "参与者 B 的 Markdown",
    connected: "已同步", offline: "已断开", connecting: "正在连接", pending: "正在同步",
    live: "实时预览", source: "源码", modes: "编辑模式", undo: "撤销", redo: "重做",
    disconnect: "断开连接", reconnect: "重新连接", readOnly: "只读", readOnlyHint: "禁止本地修改，继续接收另一侧的更新。",
    activity: "协作记录", activityNote: "由同页中继里的服务端扩展确认后写入，作者来自连接身份；只保留最近 200 条，改动片段保留 1 天，刷新即清空。",
    versions: "历史版本", versionsNote: "服务端存档时每 20 秒最多生成一个自动版本，也可以保存命名版本；以参与者 A 的身份预览、对比和恢复，恢复会同步给 B 并记入协作记录。",
    loading: "正在准备两个独立会话…", failed: "示例加载失败，请刷新页面重试。", connectionFailed: "连接失败，修改仍在当前页面，可重新连接。",
    initial: "# 一起写作\n\n这是一份共享的 **Markdown** 文档。\n\n- 在左侧写下想法\n- 在右侧补充细节\n- 试试独立撤销和断线重连\n",
  },
  en: {
    title: "Write the same Markdown together",
    note: "Same-page simulation · Kept in memory; leaving or refreshing clears this example.",
    hint: "Type or select on either side to see a named cursor and selection in the other. Dashed cursors mark hidden-source blocks. Members stay online after blur. Select text to comment; comments sync through a separate room and never enter the Markdown.",
    comments: "Comments", addComment: "Comment on selection", noComments: "Select text and add a comment; it appears on the other side too.",
    collaborators: "Collaborators", waiting: "Waiting for collaborators", presenceOffline: "Offline · Collaborators unavailable", editing: "Editing", viewing: "Online",
    left: "Participant A", right: "Participant B", leftEditor: "Participant A Markdown", rightEditor: "Participant B Markdown",
    connected: "Synced", offline: "Offline", connecting: "Connecting", pending: "Syncing",
    live: "Live", source: "Source", modes: "Editor mode", undo: "Undo", redo: "Redo",
    disconnect: "Disconnect", reconnect: "Reconnect", readOnly: "Read only", readOnlyHint: "Block local edits while receiving changes from the other side.",
    activity: "Activity", activityNote: "Confirmed by the server extension in the same-page relay, with authors taken from the connection; only the newest 200 entries are kept, with changes for one day, and a refresh clears them.",
    versions: "Versions", versionsNote: "The server takes at most one automatic version every 20 seconds while storing, and members can save named versions. Preview, compare and restore act as participant A; a restore reaches B and appears in the activity.",
    loading: "Preparing two independent sessions…", failed: "The example could not load. Refresh the page to try again.", connectionFailed: "Connection failed. Your edits remain on this page; try reconnecting.",
    initial: "# Write together\n\nThis is one shared **Markdown** document.\n\n- Add an idea on the left\n- Add details on the right\n- Try local undo and reconnecting\n",
  },
} as const;

```

### collaboration/peer.vue

```vue
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, shallowRef, watch } from "vue";
import { useMsfeEditor, VueNodeViewHost } from "@quirekit/msfe-vue";
import { StarterKit } from "@quirekit/msfe-starter-kit";
import { Collaboration, type CollaborationSession } from "@quirekit/msfe-collaboration";
import { CollaborationPresence, type CollaborationParticipant } from "@quirekit/msfe-collaboration/presence";
import { QuireButton, QuireSegmented, QuireSwitch } from "@quirekit/ui/vue";
import { Appearance } from "../shared/appearance";
import type { ExampleLocale } from "../shared/types";
import { collaborationMessages } from "./messages";
import { Comments } from "@quirekit/msfe-comments";
import { CommentsCollaboration } from "@quirekit/msfe-comments/collaboration";
import type { Doc } from "yjs";
import Roster from "./roster.vue";
import { MdActivity } from "@quirekit/md-editor";
import { encodeActivityHint } from "@quirekit/edit-history/writer";
import type { VersionStore } from "@quirekit/edit-history";
import type { MsfeEditor } from "@quirekit/msfe-core";
import CommentPanel from "./comment-panel.vue";
import { watchComments } from "./comments";

const props = defineProps<{
  /** Prepared session, held outside Vue deep reactivity. */ session: CollaborationSession;
  /** This participant's replica of the comment room. */ room: Doc;
  /** Independent participant identity within the example. */ side: "left" | "right";
  /** Language of all controls and accessible labels. */ locale: ExampleLocale;
  /** This member's view of the host version API. */ versions: VersionStore;
}>();
const emit = defineEmits<{ /** Mounted editor, for room-level controls such as the version list. */ editor: [editor: MsfeEditor | null] }>();
const text = computed(() => collaborationMessages[props.locale]);
const parent = shallowRef<HTMLElement | null>(null);
const state = shallowRef(props.session.state);
const readOnly = ref(false), mode = ref("live"), busy = ref(false), failed = ref(false);
const history = shallowRef({ canUndo: false, canRedo: false });
const participants = shallowRef<readonly CollaborationParticipant[]>([]);
const color = props.side === "left" ? "#2563eb" : "#a855b5";
const commenter = { id: props.side === "left" ? "a" : "b", name: text.value[props.side] };
const commentsVersion = ref(0);
const lifetime = new AbortController();
const unsubscribe = props.session.subscribe(value => { state.value = value; });
const modes = computed(() => [{ value: "live", label: text.value.live }, { value: "source", label: text.value.source }]);
const status = computed(() => state.value.connection === "disconnected" ? text.value.offline
  : state.value.connection === "connecting" ? text.value.connecting : state.value.synced ? text.value.connected : text.value.pending);
const { editor, host } = useMsfeEditor(parent, () => ({
  // Initial source comes from the prepared session. No markdown prop or onChange mirroring is needed.
  mode: "live", label: props.side === "left" ? text.value.leftEditor : text.value.rightEditor,
  extensions: [StarterKit, Appearance, Collaboration.configure({ session: props.session }),
    // Presence is a separate opt-in. The host owns its display identity and member UI.
    CollaborationPresence.configure({ session: props.session,
      user: { id: props.side === "left" ? "a" : "b", name: text.value[props.side], color },
      onParticipants: value => { participants.value = value; } }),
    // Comments stay out of the Markdown; the room carries them, and only the source session carries text.
    Comments.configure({ user: commenter }), CommentsCollaboration.configure({ session: props.session, room: props.room }),
    watchComments(() => { commentsVersion.value++; }),
    // The server confirms activity; the editor only hints labelled operations such as paste and undo.
    MdActivity.configure({ store: null, actor: null, versions: props.versions, documentId: "documentation-example", epoch: "demo-1",
      hints: hint => props.session.sendStateless(encodeActivityHint(hint)) })],
  onError: () => { failed.value = true; },
}));
watch(editor, (value, _old, cleanup) => {
  emit("editor", value ?? null);
  if (!value) return;
  history.value = { canUndo: value.history.canUndo, canRedo: value.history.canRedo };
  cleanup(value.history.subscribe(next => { history.value = next; }));
}, { flush: "sync" });
onBeforeUnmount(() => { lifetime.abort(); unsubscribe(); });

/** @param value - Participant-local presentation; never sent through the provider. */
function changeMode(value: string | null | undefined): void {
  if (value === "live" || value === "source") { editor.value?.setMode(value); mode.value = value; }
}
/** @param value - Host editing restriction; production authorization still belongs to the server. */
function changeReadOnly(value: boolean | undefined): void { readOnly.value = value === true; editor.value?.setReadOnly(readOnly.value); }
/** @returns Completion after disconnecting or merging the retained offline branch. */
async function toggleConnection(): Promise<void> {
  if (busy.value) return;
  if (state.value.connection === "connected") { props.session.disconnect(); return; }
  busy.value = true; failed.value = false;
  try { await props.session.prepare({ signal: lifetime.signal }); }
  catch { if (!lifetime.signal.aborted) failed.value = true; }
  finally { busy.value = false; }
}
</script>
<template>
  <section class="collaboration-peer min-w-0 overflow-hidden rounded-qk-control border border-qk-border-subtle" :data-peer="side" :aria-label="text[side]">
    <div class="grid gap-qk-group border-b border-qk-border-subtle bg-qk-surface-subtle p-qk-surface-compact">
      <div class="flex flex-wrap items-center justify-between gap-2">
        <strong class="flex items-center gap-2 text-qk-text-primary"><span class="h-2 w-2 rounded-full" :style="{ backgroundColor: color }" />{{ text[side] }}</strong>
        <span class="text-qk-caption text-qk-text-secondary" role="status">{{ status }}</span>
      </div>
      <div class="flex flex-wrap items-center justify-between gap-2">
        <QuireSegmented :model-value="mode" :items="modes" :label="text.modes" size="sm" @update:model-value="changeMode" />
        <div class="flex gap-1">
          <QuireButton size="sm" variant="ghost" :disabled="!history.canUndo" @click="editor?.undo()">{{ text.undo }}</QuireButton>
          <QuireButton size="sm" variant="ghost" :disabled="!history.canRedo" @click="editor?.redo()">{{ text.redo }}</QuireButton>
        </div>
      </div>
      <div class="flex flex-wrap items-center justify-between gap-2">
        <QuireButton size="sm" variant="outline" :busy="busy" @click="toggleConnection">{{ state.connection === 'connected' ? text.disconnect : text.reconnect }}</QuireButton>
        <QuireSwitch :checked="readOnly" :label="text.readOnly" :title="text.readOnlyHint" size="sm" @update:checked="changeReadOnly" />
      </div>
      <Roster :participants="participants" :online="state.connection === 'connected'" :locale="locale" />
    </div>
    <div v-if="failed" class="p-qk-surface-compact text-qk-text-secondary" role="alert">{{ text.connectionFailed }}</div>
    <div ref="parent" class="min-w-0" />
    <VueNodeViewHost :host="host" />
    <CommentPanel :editor="editor" :user="commenter" :version="commentsVersion" :locale="locale" />
  </section>
</template>

```

### shared/appearance.ts

```ts
import { Extension } from "@quirekit/msfe-core";
import { EditorView } from "@quirekit/cm-view";
import { Prec } from "@quirekit/cm-state";
import { HighlightStyle, syntaxHighlighting } from "@quirekit/cm-language";
import { tags } from "@lezer/highlight";
import { themeVariableReferences as token } from "@quirekit/ui";

/** Consumer-owned theme; source semantics stay in the installed feature packages. */
export const Appearance = Extension.create({ name: "documentationAppearance",
  /** @returns Scoped typography, surfaces and syntax colors from the host design system. */
  addCodeMirrorExtensions() {
    return Prec.high([
      EditorView.theme({
        "&": { ...token.typography.body, color: token.color.text.primary, backgroundColor: token.color.surface.page, minHeight: "180px" },
        ".cm-scroller": { fontFamily: "inherit", lineHeight: "1.8" },
        ".cm-content": { padding: token.spacing.surface, caretColor: token.color.text.primary },
        ".cm-line": { padding: "0" },
        ".cm-gutters": { color: token.color.text.tertiary, backgroundColor: token.color.surface.subtle, border: "none" },
        ".cm-activeLine, .cm-activeLineGutter": { backgroundColor: "transparent" },
        ".cm-selectionBackground, &.cm-focused .cm-selectionBackground": { backgroundColor: token.color.selection.document },
        ".cm-cursor": { borderLeftColor: token.color.text.primary },
        ".msfe-h1": { ...token.typography.pageTitle },
        ".msfe-h2, .msfe-h3": { ...token.typography.sectionTitle },
        ".msfe-heading": { paddingBlock: token.spacing.inline },
        ".msfe-link": { color: token.color.text.accent },
        ".msfe-code-line, .msfe-inline-code": { ...token.typography.code },
        ".consumer-status": { verticalAlign: "middle" },
        "&.cm-focused": { outline: "none" },
      }),
      syntaxHighlighting(HighlightStyle.define([
        { tag: tags.heading, color: token.color.text.primary, fontWeight: "600" },
        { tag: tags.strong, fontWeight: "700" },
        { tag: tags.emphasis, fontStyle: "italic" },
        { tag: [tags.link, tags.url], color: token.color.text.accent },
        { tag: tags.processingInstruction, color: token.color.text.tertiary },
        { tag: tags.monospace, color: token.color.text.secondary },
      ])),
    ]);
  },
});

```

### collaboration/roster.vue

```vue
<script setup lang="ts">
import { QuireAvatar } from "@quirekit/ui/vue";
import type { CollaborationParticipant } from "@quirekit/msfe-collaboration/presence";
import type { ExampleLocale } from "../shared/types";
import { collaborationMessages } from "./messages";

defineProps<{
  /** Remote members only; cursor-only changes preserve this array. */ participants: readonly CollaborationParticipant[];
  /** Local source connection. */ online: boolean;
  /** Host-localized labels. */ locale: ExampleLocale;
}>();
</script>
<template>
  <div class="flex min-h-8 flex-wrap items-center gap-2 text-qk-caption" data-presence-roster :aria-label="collaborationMessages[locale].collaborators">
    <span v-if="!online || !participants.length" class="text-qk-text-tertiary">{{ collaborationMessages[locale][online ? 'waiting' : 'presenceOffline'] }}</span>
    <span v-for="peer in participants" v-else :key="peer.id" class="flex min-w-0 items-center gap-2" :data-collaborator="peer.user.id" :data-activity="peer.activity">
      <span class="collaboration-avatar" :style="{ borderColor: peer.user.color }">
        <QuireAvatar :name="peer.user.name" :fallback="peer.user.id.toUpperCase()" size="xs" />
      </span>
      <span class="truncate text-qk-text-secondary">{{ peer.user.name }} · {{ collaborationMessages[locale][peer.activity] }}</span>
    </span>
  </div>
</template>
<style scoped>
.collaboration-avatar { display: inline-flex; padding: 2px; border: 2px solid; border-radius: 50%; }
</style>

```

### collaboration/comment-panel.vue

```vue
<script setup lang="ts">
import { computed, shallowRef } from "vue";
import type { MsfeEditor } from "@quirekit/msfe-core";
import { getComments, type CommentPerson, type CommentRange } from "@quirekit/msfe-comments";
import { QuireButton, QuireCommentDraft, QuireCommentThread, type QuireKitCommentMention } from "@quirekit/ui/vue";
import type { ExampleLocale } from "../shared/types";
import { collaborationMessages } from "./messages";

const props = defineProps<{
  /** Mounted editor, or null/undefined while mounting. */ editor: MsfeEditor | null | undefined;
  /** Comment author for this participant. */ user: CommentPerson;
  /** Bumped whenever comment state changes, locally or from the room. */ version: number;
  /** Control language. */ locale: ExampleLocale;
}>();
const text = computed(() => collaborationMessages[props.locale]);
const controller = computed(() => props.editor ? getComments(props.editor) : null);
/** Range captured when the draft opened, so moving the caret while typing does not move the comment. */
const draft = shallowRef<{ readonly range: CommentRange; readonly quote: string } | null>(null);
const threads = computed(() => {
  void props.version;
  const source = props.editor?.state.doc.toString() ?? "";
  return (controller.value?.threads() ?? []).map(view => ({ view, quote: view.range ? source.slice(view.range.from, view.range.to) : view.thread.anchor?.quote.exact ?? "",
    messages: view.thread.messages.map(message => ({ id: message.id, author: { name: message.author.name }, time: message.createdAt, body: message.body,
      edited: !!message.editedAt, editable: message.author.id === props.user.id, deletable: message.author.id === props.user.id })) }));
});
const active = computed(() => { void props.version; return controller.value?.active() ?? null; });

/** Opens a draft on the current selection; an empty selection makes a point comment. */
function open(): void {
  const editor = props.editor; if (!editor) return;
  const { from, to } = editor.state.selection.main;
  draft.value = { range: { from, to }, quote: editor.state.doc.sliceString(from, to) };
}
/** @param body - Root message. @param mentions - People mentioned. */
function submit(body: string, mentions: readonly QuireKitCommentMention[]): void {
  if (draft.value && controller.value?.add({ body, mentions: mentions.map(({ id, label, url }) => ({ id, label, url })) }, draft.value.range).ok) draft.value = null;
}
/** @param id - Thread to focus and reveal in the editor. */
function activate(id: string): void {
  controller.value?.activate(id);
  const range = threads.value.find(item => item.view.thread.id === id)?.view.range;
  if (range) props.editor?.scrollTo(range.from);
}
</script>

<template>
  <div class="comment-panel grid gap-2 border-t border-qk-border-subtle p-qk-surface-compact">
    <div class="flex items-center justify-between gap-2">
      <strong class="text-qk-caption text-qk-text-secondary">{{ text.comments }} · {{ threads.length }}</strong>
      <QuireButton size="sm" variant="outline" :disabled="!controller?.writable() || !!draft" @click="open">{{ text.addComment }}</QuireButton>
    </div>
    <QuireCommentDraft v-if="draft" :quote="draft.quote" :author="{ name: user.name }" @submit="submit" @cancel="draft = null" />
    <p v-if="!threads.length && !draft" class="m-0 text-qk-caption text-qk-text-secondary">{{ text.noComments }}</p>
    <QuireCommentThread v-for="item in threads" :key="item.view.thread.id" :messages="item.messages" :quote="item.quote"
      :quote-state="item.view.status === 'orphaned' ? 'unavailable' : item.view.status === 'detached' ? 'deleted' : item.quote ? 'text' : 'point'"
      :resolved="item.view.thread.resolved" :active="active === item.view.thread.id" :resolvable="item.view.resolvable" :replyable="!!controller?.writable()"
      @activate="activate(item.view.thread.id)" @resolve="controller?.resolve(item.view.thread.id)" @reopen="controller?.reopen(item.view.thread.id)"
      @reply="body => controller?.reply(item.view.thread.id, { body })" @edit="(id, body) => controller?.edit(item.view.thread.id, id, { body })"
      @delete="id => controller?.remove(item.view.thread.id, id)" />
  </div>
</template>

<style scoped>
.comment-panel { max-height: 360px; overflow-y: auto; }
</style>

```

This example runs real Yjs through an in-memory channel. Refreshing or leaving clears the content; “Synced” does not mean saved. Open “Integration code” for the complete example.
The Activity list below is confirmed by the server extension running in the same-page relay: authors come from the
connection, typing merges into one entry and operations such as undo get their own; only the newest 200 entries are kept,
with changes for one day. Versions are taken by the server while storing, at most one every 20 seconds, and named versions
can be saved too; restoring as participant A updates B's document and records "Restored a version". See
[MD Editor activity](/en/guide/md-editor#with-collaboration) and [versions](/en/guide/md-editor#versions) for the integration.

## Connect an application

Collaboration is **experimental** and opt-in. Core, StarterKit, Vue, React, and MD Editor do not include collaboration dependencies by default.

### 1. Install the packages

Install both packages for Hocuspocus, or only the first for a custom transport.

```sh
pnpm add @quirekit/msfe-collaboration @quirekit/msfe-collaboration-hocuspocus
```

Markdown source is shared. Display mode, theme, and scrolling stay local; your application supplies stable URLs for uploaded images.

### 2. Create a session

Provide a document ID, service endpoints, and application credentials:

```ts [collaboration.ts]
import { sharedSource, mountSharedSource } from './collaboration/session'

const session = sharedSource('document-123', {
  url: 'wss://your-service.example/collaboration',
  sessionEndpoint: 'https://your-service.example/api/collaboration/open',
  token: () => applicationAccessToken(),
})
```

`url` carries WebSocket synchronization. Your `sessionEndpoint` returns document identity, epoch, format, and permissions. **A WebSocket URL alone is not enough.** Replace the example URLs and credential function.

### 3. Prepare and mount

Continue in the same file to mount the editor; `parent` is the mount element. The helper prepares the session first, with an abort signal to cancel preparation when the route exits.

```ts [collaboration.ts]
const controller = new AbortController()
const editor = await mountSharedSource(parent, session, controller.signal)
```

### View the complete integration module

Save this file as `collaboration/session.ts` alongside the calling code above.

### collaboration/session.ts

```ts
import { createMsfeEditor, type MsfeEditor } from "@quirekit/msfe-core";
import { StarterKit } from "@quirekit/msfe-starter-kit";
import {
  Collaboration,
  createCollaborationSession,
  type CollaborationSession,
} from "@quirekit/msfe-collaboration";
import {
  hocuspocusProvider,
  type HocuspocusProviderOptions,
} from "@quirekit/msfe-collaboration-hocuspocus";

/**
 * @param documentId - Existing application document.
 * @param service - Application admission and transport endpoints.
 * @returns Host-owned session, retained even if preparation fails.
 */
export function sharedSource(
  documentId: string,
  service: HocuspocusProviderOptions,
): CollaborationSession {
  return createCollaborationSession({ documentId, provider: hocuspocusProvider(service) });
}

/**
 * @param parent - Editor mount.
 * @param session - Host-owned source and history.
 * @param signal - Route cancellation.
 * @returns Mounted editor after authoritative preparation.
 */
export async function mountSharedSource(
  parent: HTMLElement,
  session: CollaborationSession,
  signal: AbortSignal,
): Promise<MsfeEditor> {
  // Finish admission and loading before allocating an editor.
  await session.prepare({ signal });
  signal.throwIfAborted();
  // Session owns initial content and identity; extensions keep planning source edits.
  return createMsfeEditor({
    parent,
    extensions: [StarterKit, Collaboration.configure({ session })],
  });
}

```


The service creates new documents and seeds their initial content. Do not reinsert initial Markdown on every connection; existing empty documents must stay empty.

## Use the existing extensions option

Add `Collaboration.configure({ session })` and omit initial `markdown`. MD Editor, Vue, and React use their existing extension entry points:

```vue
<MdEditor :extensions="[Collaboration.configure({ session })]" />
```

- **Prepare before mounting.** Render the component after the session is ready. One session binds to one editor at a time.
- **Retain the session.** Unmounting releases the editor binding; remounting can reuse its source and local undo history.
- **Avoid deep reactivity.** In Vue, keep the session in a plain variable, `shallowRef`, or `markRaw`.

## Optional cursors and collaborators

Add `CollaborationPresence` for named cursors, selections, and online members. Both extensions use the same session:

```ts [presence.ts]
import { Collaboration } from '@quirekit/msfe-collaboration'
import { CollaborationPresence } from '@quirekit/msfe-collaboration/presence'

const extensions = [
  Collaboration.configure({ session }),
  CollaborationPresence.configure({
    session,
    user: { id: 'alice', name: 'Alice', color: '#2563eb' },
    onParticipants: peers => { /* Update your collaborator list. */ },
  }),
]
```

`onParticipants` returns each remote connection's `id`, `user`, and `activity` (`editing` / `viewing`). Use `QuireAvatar` as in the example, or your own member UI. Cursor movement does not refresh the roster; multiple connections from one user remain separate.

- Only the **primary selection** is broadcast. Positions inside hidden syntax or widgets use a dashed boundary cursor marked with `◇`.
- Blurring keeps the member online. Disconnecting clears presence; reconnecting rejoins. Remote updates do not move local focus, selection, or scrolling, or reveal source syntax.
- Presence stays out of source, undo, and saved checkpoints. Source-only collaboration allocates no Awareness instance or cursor layer.

## Sync, save, and recover

**Synced and saved are separate states.** When the service supports checkpoints, call `await session.save()` and show “Saved” only after the durable receipt arrives.

| Task | API |
| --- | --- |
| Show connection and save status | Read `session.state` and update UI through `session.subscribe()`. |
| Reconnect | Call `session.prepare()` to refresh identity and permissions. |
| Disable offline editing | Set `offlineEditing: 'readonly'`; the default allows in-memory edits. |
| Undo local changes | Use `editor.history`; remote edits stay out of the local undo stack. |
| Leave a document | Cancel unfinished preparation, destroy the editor, then close the session. |

```ts [cleanup.ts]
controller.abort()
editor.destroy()
await session.close({ mode: 'persist' })
```

Keep the session after a failed save or close and offer retry or recovery. Without a persistence service, use checkpoint closing; use `discard` only when the user explicitly abandons the content.

### State fields and recovery handling

| Field | Meaning |
| --- | --- |
| `lifecycle` | Preparing, ready, failed, recovery, or closed. |
| `connection` | Connecting, connected, or disconnected. |
| `permission` | Service-granted read/write permission. |
| `synced` | The transport has acknowledged pending updates. |
| `durability` | Persistence state of local edits; `unsupported` without save support. |

- Typing during a save preserves dirty state; deletions also need saving. Do not substitute sync events or timers for a durable receipt.
- An epoch change or revoked write permission on an unsaved branch enters recovery. Retain `session.checkpoint()` before deciding how to compare or import it. Persistent browser offline storage is not installed.
- `close({ mode: 'checkpoint', persistRecovery })` waits for the application to save a recovery copy containing binary identity and source. Unmounting does not discard content.
- Toolbars can read `editor.history.canUndo` and `canRedo`, and observe changes with `subscribe()`. Commands, shortcuts, and browser undo share one history.
- Remount for a different document. An explicit `documentId` must match the session. `replaceDocument()` returns `source-provider-locked`; normal commands can still insert content.
- `defineState().invert` currently reports `unsupported-history-effect` with collaboration history. Do not wire `onChange` to a full-source overwrite or another Yjs binding.


## Custom transports and current limits

The service owns authentication, write permissions, room limits, and persistence. The example's read-only switch, member names, and colors are client UI; they do not replace server checks.

### Custom providers and existing Y.Doc instances

Implement `CollaborationProviderFactory` to open an authoritative manifest, synchronize the session's Y.Doc, report connection and permission changes, and remove listeners on disconnect. `saveCheckpoint` is optional.

For cursors, `acquirePresence()` returns `CollaborationPresenceTransport`: `clientId`, `getStates()`, `publish()`, `subscribe()`, and `release()`. Install presence only when the provider supports it. The example's `presence.ts` supplies a complete in-memory channel; Hocuspocus acquires and withdraws its channel on mount and destruction, cleaning up timers.

`adoptCollaborationSession()` borrows a prepared Y.Doc with a static manifest. The host owns initialization, connections, permissions, and disposal; the session does not destroy the borrowed document. Use a provider factory for dynamic connection reports and durable receipts.


Two-person editing and remote presence have been verified in Chromium. System IMEs, Safari/Firefox, larger participant groups, and production database recovery still need release acceptance.
