# 可选源码协同

按需加入多人编辑、远端光标和协作者列表。

## 交互体验

在任意一侧输入或拖选，观察另一侧的变化。也可以试试独立撤销、只读和断线重连。

### 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>

```

示例使用真实 Yjs，通过同页内存通道同步。刷新或离开页面会清空内容；“已同步”不表示已保存。完整示例可在“接入代码”中查看。
下方“协作记录”由同页中继运行的服务端扩展确认后写入：作者取自连接身份，连续输入合并成一条，撤销等操作单独记一条；
记录只保留最近 200 条、改动片段保留 1 天。“历史版本”由服务端存档时生成，每 20 秒最多一个，也可以保存命名版本；
以参与者 A 的身份恢复后，B 的文档同步变化，协作记录写入“恢复了版本”。接入方式见
[MD Editor 协作记录](/guide/md-editor#多人协同时)与[历史版本](/guide/md-editor#历史版本)。

## 接入方式

协同目前是**实验能力**，需要时再安装。默认的 Core、StarterKit、Vue、React 和 MD Editor 不包含协同依赖。

### 1. 安装依赖

使用 Hocuspocus 时安装两个包；自建传输只需要第一个。

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

共享内容是 Markdown 源码。模式、主题和滚动各自独立；图片上传由应用提供稳定地址。

### 2. 创建会话

用文档 ID、服务地址和应用凭证创建 session：

```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` 用于 WebSocket 同步，`sessionEndpoint` 是应用的会话接口，返回文档身份、epoch、格式和权限。**只提供 WebSocket 地址还不能接入**；请替换示例地址和凭证函数。

### 3. 准备并挂载

继续在同一文件中挂载编辑器，`parent` 是挂载元素。辅助函数会先准备会话；路由离开时可通过取消信号中止准备。

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

### 查看完整接入模块

将下面的文件放在 `collaboration/session.ts`，与上面的调用代码配合使用。

### 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 })],
  });
}

```


新文档的创建和初始内容由服务负责。不要在每次连接时重新插入初始 Markdown，现存空文档也应保持为空。

## 通过原有 extensions 装配

已有编辑器添加 `Collaboration.configure({ session })`，省略初始 `markdown`。MD Editor、Vue 和 React 均使用原有扩展入口：

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

- **准备后挂载**：session 准备完成后再渲染组件；同一 session 同时只绑定一个编辑器。
- **保留会话**：组件卸载只解除绑定；再次挂载可继续使用正文和本地撤销历史。
- **避免深响应式**：Vue 中用普通变量、`shallowRef` 或 `markRaw` 保存 session。

## 可选光标与协作者

需要姓名光标、选区和在线成员时，再添加 `CollaborationPresence`。两个扩展使用同一个 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 => { /* 更新宿主的成员列表 */ },
  }),
]
```

`onParticipants` 返回远端连接的 `id`、`user` 和 `activity`（`editing` / `viewing`）。可以使用示例中的 `QuireAvatar` 或自己的成员 UI；光标移动不会刷新成员列表，同一用户的多个连接分别显示。

- 当前广播**主选区**。隐藏源码或组件内部的位置显示为带 `◇` 的虚线边界光标。
- 失焦后保留在线成员；断线时清理，重连后重新加入。远端更新不会移动本机焦点、选区或滚动，也不展开源码。
- presence 不进入正文、撤销或保存记录。只安装正文协同时，不分配 Awareness 或挂载光标层。

## 同步、保存与恢复

**同步成功与保存成功是两个状态。** 服务支持 checkpoint 时，调用 `await session.save()`，收到持久化回执后再显示“已保存”。

| 要做什么 | 使用方式 |
| --- | --- |
| 显示连接与保存状态 | 读取 `session.state`，通过 `session.subscribe()` 更新 UI。 |
| 断线重连 | 调用 `session.prepare()`，重新取得身份与权限。 |
| 禁止离线编辑 | 设置 `offlineEditing: 'readonly'`；默认允许在内存中编辑。 |
| 撤销自己的改动 | 使用 `editor.history`；远端修改不进入本地撤销栈。 |
| 离开文档 | 取消未完成的准备，再销毁编辑器并关闭会话。 |

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

关闭或保存失败时保留 session，提供重试或恢复副本入口。没有持久化服务时，使用 checkpoint 关闭方式；只有用户明确放弃内容才使用 `discard`。

### 状态字段与恢复处理

| 字段 | 含义 |
| --- | --- |
| `lifecycle` | 准备、可用、故障、待恢复或已关闭。 |
| `connection` | 连接中、已连接或断开。 |
| `permission` | 服务授予的读写权限。 |
| `synced` | 传输已确认当前待同步更新。 |
| `durability` | 本地修改的持久化状态；无保存能力时为 `unsupported`。 |

- 保存期间继续输入会保留 dirty 状态，删除文本也需要保存。不要用同步事件或延时器代替持久化回执。
- epoch 改变，或未保存分支被撤销写权限时，会话进入 recovery。保留 `session.checkpoint()`，再决定如何对比或导入；当前没有浏览器持久离线缓存。
- `close({ mode: 'checkpoint', persistRecovery })` 等待应用保存包含二进制身份与源码的恢复副本。组件卸载不会自动放弃内容。
- 工具栏可读取 `editor.history.canUndo`、`canRedo`，用 `subscribe()` 订阅变化；命令、快捷键和浏览器撤销使用同一历史。
- 换文档时重新装配实例；显式 `documentId` 必须与会话一致。`replaceDocument()` 返回 `source-provider-locked`，追加内容继续使用正常命令。
- `defineState().invert` 目前不能与协同历史组合，会报告 `unsupported-history-effect`。不要把 `onChange` 再接到全文覆盖或另一套 Yjs 绑定。


## 自建传输与当前边界

服务负责鉴权、写权限、房间限制和持久化。示例中的只读开关、成员姓名与颜色只用于客户端交互，不能代替服务端校验。

### 自建 provider 与已有 Y.Doc

自建传输实现 `CollaborationProviderFactory`：打开权威 manifest、同步 session 的 Y.Doc、报告连接与权限状态，并在断开时移除监听。保存能力 `saveCheckpoint` 可选。

光标通道另通过 `acquirePresence()` 返回 `CollaborationPresenceTransport`：`clientId`、`getStates()`、`publish()`、`subscribe()` 和 `release()`。未实现该能力时不要安装 presence；示例的 `presence.ts` 提供完整内存通道。Hocuspocus 会按挂载和销毁租用、撤回通道并清理定时器。

`adoptCollaborationSession()` 可借用已准备的 Y.Doc 与静态 manifest。宿主负责初始化、连接、权限及释放，session 不销毁借用的文档；动态连接报告和保存回执应通过 provider 工厂接入。


当前已验证 Chromium 下的双人编辑与远端呈现。系统输入法、Safari/Firefox、多参与者规模和生产数据库故障恢复仍待发布验收。
