Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,14 @@ We ship in priority tiers — **P0 is what we're working on right now**.

👉 **Full roadmap with package ownership, status, and OpenSpec linkage:** [`docs/ROADMAP.md`](./docs/ROADMAP.md)

### 🧩 Official plugins

Nexus ships opt-in plugins — each a standalone package that integrates through **public capabilities only, with no core changes**:

- `@floatboat/nexus-preset-gfm` — GitHub Flavored Markdown (tables, task lists…).
- `@floatboat/nexus-plugin-wordcount` — Markdown-aware word / character / CJK / reading-time stats.
- **`@floatboat/nexus-plugin-collab`** *(this branch: `feat/nexus-plugin-collab`)* — CRDT-driven realtime collaboration: document sync, awareness (remote cursors/selections), offline-first editing with reconnect merge, and a deterministic convergence test suite. See [`需求描述文档.md`](./需求描述文档.md), [`技术架构文档.md`](./技术架构文档.md), [`测试和验证文档.md`](./测试和验证文档.md).

---

## 💡 Why Nexus-Editor?
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"version": "0.0.14",
"packageManager": "pnpm@9.15.4",
"scripts": {
"build": "pnpm --filter @floatboat/nexus-core build && pnpm --filter @floatboat/nexus-plugin-api build && pnpm --filter @floatboat/nexus-plugin-runtime build && pnpm --filter @floatboat/nexus-react build && pnpm --filter @floatboat/nexus-vue build && pnpm --filter @floatboat/nexus-preset-gfm build && pnpm --filter @floatboat/nexus-plugin-slash build && pnpm --filter @floatboat/nexus-plugin-history build && pnpm --filter @floatboat/nexus-plugin-search build && pnpm --filter @floatboat/nexus-plugin-toolbar build && pnpm --filter @floatboat/nexus-plugin-math build && pnpm --filter @floatboat/nexus-plugin-vim build && pnpm --filter @floatboat/nexus-plugin-wordcount build && pnpm --filter @floatboat/nexus-reference-plugins build",
"build": "pnpm --filter @floatboat/nexus-core build && pnpm --filter @floatboat/nexus-plugin-api build && pnpm --filter @floatboat/nexus-plugin-runtime build && pnpm --filter @floatboat/nexus-react build && pnpm --filter @floatboat/nexus-vue build && pnpm --filter @floatboat/nexus-preset-gfm build && pnpm --filter @floatboat/nexus-plugin-slash build && pnpm --filter @floatboat/nexus-plugin-history build && pnpm --filter @floatboat/nexus-plugin-search build && pnpm --filter @floatboat/nexus-plugin-toolbar build && pnpm --filter @floatboat/nexus-plugin-math build && pnpm --filter @floatboat/nexus-plugin-vim build && pnpm --filter @floatboat/nexus-plugin-wordcount build && pnpm --filter @floatboat/nexus-plugin-collab build && pnpm --filter @floatboat/nexus-reference-plugins build",
"check:api": "pnpm --filter @floatboat/nexus-plugin-api check:api && pnpm --filter @floatboat/nexus-plugin-runtime check:api && pnpm --filter @floatboat/nexus-reference-plugins check:api",
"typecheck": "pnpm -r exec tsc --noEmit",
"test": "vitest run",
Expand Down
54 changes: 54 additions & 0 deletions packages/plugin-collab/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# @floatboat/nexus-plugin-collab

CRDT 驱动的实时协同编辑插件(离线优先)——为 Nexus-Editor 提供文档级同步、
光标/选区感知(awareness)、断网离线编辑与重连合并、可回放的协作历史,
以及一套能确定性证明收敛性的测试套件。

> 本插件**仅通过公开 capability 接入**(`EDITOR_TRANSACTIONS` / `EDITOR_HOST` /
> `PLUGIN_STORAGE` / `UI` / `VAULT`),**不修改 `@floatboat/nexus-core`**。
> 详见仓库根目录的 `需求描述文档.md`、`技术架构文档.md`、`测试和验证文档.md`。

## 为什么是这个命题

难点不在于接第三方库,而在于让「本地源码」「远程操作」「用户光标」三者
在时间维度上保持一致。CRDT 引擎被锁在 `ProviderAdapter` 接口之后,Nexus 只依赖抽象,
既避免核心被单一实现绑架,也方便后续替换为 Yjs / Automerge / 自研。

## 快速接入

```ts
import { createEditor } from "@floatboat/nexus-core";
import { CollabPlugin, collabPluginManifest } from "@floatboat/nexus-plugin-collab";

const app = /* NexusApp with capabilities */;
app.plugins.register(new CollabPlugin(app, collabPluginManifest, {
roomId: "doc-123", // 文档/房间标识(默认取 vault 路径)
authorize: async (roomId) => canJoin(roomId), // 可选:房间鉴权钩子
transport: myWebsocketTransport, // 可选:默认内存 loopback
}));
```

## 分层

```
UI → Awareness → Sync(ProviderAdapter) → Model(CRDT) → Codec(版本化) → Storage(WAL)
```

## 收敛性(核心资产)

```bash
node packages/plugin-collab/test/convergence.sim.mjs
# 2320 次操作 / 最多 12 客户端 / 20 种乱序投递,文本哈希与偏移映射哈希全部一致
```

形式化版本见 `test/convergence.test.ts`(fast-check,由 CI 门禁运行)。

## 当前范围

- ✅ 分层脚手架、参考树形 CRDT、版本化 Codec、WAL 存储、远端光标 CM6 扩展、零信任安全
- ✅ 收敛性证明(可运行)+ 形式化属性测试 + Codec 往返/版本拒绝单测
- ⏳ 生产级 Yjs 接线 / 真实 WebSocket Transport / mdast 节点分片 Codec / electron-demo 集成 —— follow-up

## License

MIT
40 changes: 40 additions & 0 deletions packages/plugin-collab/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
{
"name": "@floatboat/nexus-plugin-collab",
"version": "0.0.1",
"description": "CRDT-driven real-time collaborative editing plugin for Nexus-Editor: document-level sync, awareness (remote cursors/selections), offline-first editing with reconnect merge, and replayable collaboration history. Offline-first, capability-only integration.",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"files": [
"dist",
"README.md"
],
"scripts": {
"build": "tsup src/index.ts --format esm --dts --clean"
},
"dependencies": {
"@floatboat/nexus-core": "workspace:*",
"@floatboat/nexus-plugin-api": "workspace:*"
},
"peerDependencies": {
"yjs": "^13.6.0"
},
"peerDependenciesMeta": {
"yjs": {
"optional": true
}
},
"devDependencies": {
"fast-check": "^3.19.0"
},
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org/"
}
}
87 changes: 87 additions & 0 deletions packages/plugin-collab/src/awareness/remote-cursor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
/**
* Awareness layer — remote cursor / selection rendering.
*
* Drift fix (architecture §六大难点 1): we store a DOCUMENT OFFSET, never a
* pixel coordinate. Decoration positions are recomputed inside a `requestMeasure`
* callback (so they track async widget heights from formulas / mermaid), and we
* listen for viewport + widget-height changes to invalidate the cache. Cursor
* positions inside deleted ranges are clamped to the nearest legal boundary
* rather than hidden.
*/

import { Decoration, type DecorationSet, EditorView, ViewPlugin, type ViewUpdate } from "@codemirror/view";
import type { Range } from "@codemirror/state";
import type { AwarenessState } from "../provider/adapter";

export interface RemoteCursorSource {
/** Latest remote awareness states (offsets already mapped to local view). */
(): readonly AwarenessState[];
}

function buildDecorations(view: EditorView, states: readonly AwarenessState[]): DecorationSet {
const docLen = view.state.doc.length;
const ranges: Range<Decoration>[] = [];
for (const s of states) {
if (s.clientId === "local") continue;
const caret = Math.min(Math.max(0, s.caret), docLen);
ranges.push(
Decoration.widget({
widget: new RemoteCaretWidget(s),
side: 1,
}).range(caret),
);
if (s.selection) {
const from = Math.min(Math.max(0, Math.min(...s.selection)), docLen);
const to = Math.min(Math.max(0, Math.max(...s.selection)), docLen);
if (to > from) {
ranges.push(Decoration.mark({ class: "nx-collab-remote-sel", attributes: { "data-client": s.clientId } }).range(from, to));
}
}
}
return Decoration.set(ranges, true);
}

class RemoteCaretWidget {
constructor(private readonly state: AwarenessState) {}
toDOM(): HTMLElement {
const el = document.createElement("span");
el.className = "nx-collab-remote-caret";
el.style.borderLeft = `2px solid ${this.state.color}`;
el.setAttribute("data-client", this.state.clientId);
el.setAttribute("title", this.state.name);
el.textContent = "​";
return el;
}
get estimatedHeight(): number {
return 18;
}
}

export function createRemoteCursorExtension(getStates: RemoteCursorSource): ReturnType<typeof ViewPlugin.define> {
return ViewPlugin.fromClass(
class {
decorations: DecorationSet;
constructor(view: EditorView) {
this.decorations = buildDecorations(view, getStates());
}
update(update: ViewUpdate): void {
const needsRerender =
update.docChanged ||
update.viewportChanged ||
update.geometryChanged ||
update.transactions.some((t) => t.annotation(EditorView.heightAnn) != null);
if (needsRerender) {
// Recompute deferred to measure phase so async widget heights are settled.
update.view.requestMeasure({
read: () => undefined,
write: (view) => {
this.decorations = buildDecorations(view, getStates());
view.updateState(view.state);
},
});
}
}
},
{ decorations: (v) => v.decorations },
);
}
142 changes: 142 additions & 0 deletions packages/plugin-collab/src/codec/markdown-codec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
/**
* Codec layer — the technical heart of the proposition.
*
* Responsibilities:
* 1. Versioned wire format. Every snapshot carries a header
* `{ v, format }`. Unknown versions are REJECTED (never silently
* corrupted) so an old peer fails loudly instead of drifting.
* 2. Markdown <-> CRDT structural mapping. The production path shards by
* mdast node: block-level nodes become independent CRDT units, inline
* nodes use a Y.Text. A `stable node id -> text offset` bidirectional
* index remaps remote offsets onto the local view even while the local
* AST is mid-composition (IME).
*
* This module ships a *whole-document* reference codec (P0) plus the index
* primitive the sharded codec (P1) builds on. Both sit behind the same
* surface, so the tests are stable across the swap.
*/

import type { AwarenessState } from "../provider/adapter";
import type { CollabOp } from "../provider/tree-crdt";

export const CODEC_VERSION = { v: 1, format: "nexus-collab-v1" } as const;
export type CodecFormat = (typeof CODEC_VERSION)["format"];

export interface CollabSnapshot {
readonly format: CodecFormat;
readonly v: number;
readonly docId: string;
readonly ops: readonly CollabOp[];
readonly awareness: readonly AwarenessState[];
}

export class CodecError extends Error {
constructor(message: string) {
super(message);
this.name = "CodecError";
}
}

export interface MarkdownCollabCodec {
readonly version: typeof COC_VERSION_SHAPE;
encodeSnapshot(snapshot: Omit<CollabSnapshot, "format" | "v">): Uint8Array;
decodeSnapshot(bytes: Uint8Array): CollabSnapshot;
/** Produce a local CRDT op from a contiguous text edit. */
diffLocalEdit(
docBefore: string,
docAfter: string,
meta: { source: "local"; updateOriginId: string; site: number },
): CollabOp;
}

const COC_VERSION_SHAPE = CODEC_VERSION;

export class VersionedMarkdownCodec implements MarkdownCollabCodec {
readonly version = CODEC_VERSION;

encodeSnapshot(snapshot: Omit<CollabSnapshot, "format" | "v">): Uint8Array {
const payload: CollabSnapshot = {
format: CODEC_VERSION.format,
v: CODEC_VERSION.v,
...snapshot,
};
return new TextEncoder().encode(JSON.stringify(payload));
}

decodeSnapshot(bytes: Uint8Array): CollabSnapshot {
const raw = JSON.parse(new TextDecoder().decode(bytes)) as Partial<CollabSnapshot>;
if (raw.format !== CODEC_VERSION.format) {
throw new CodecError(`unsupported codec format: ${String(raw.format)} (expected ${CODEC_VERSION.format})`);
}
if (typeof raw.v !== "number" || raw.v > CODEC_VERSION.v) {
throw new CodecError(`unsupported codec version: ${String(raw.v)} (max ${CODEC_VERSION.v})`);
}
return {
format: CODEC_VERSION.format,
v: raw.v,
docId: raw.docId ?? "",
ops: raw.ops ?? [],
awareness: raw.awareness ?? [],
};
}

diffLocalEdit(
docBefore: string,
docAfter: string,
meta: { source: "local"; updateOriginId: string; site: number },
): CollabOp {
// Single contiguous-diff model (P0). Finds the longest common prefix /
// suffix, then emits one delete + one insert at that boundary. The P1
// sharded codec replaces this with a node-aware, multi-edit diff but keeps
// the same shape (CollabOp[]).
let start = 0;
const minLen = Math.min(docBefore.length, docAfter.length);
while (start < minLen && docBefore[start] === docAfter[start]) start++;
let endOld = docBefore.length;
let endNew = docAfter.length;
while (endOld > start && endNew > start && docBefore[endOld - 1] === docAfter[endNew - 1]) {
endOld--;
endNew--;
}
const removed = docBefore.slice(start, endOld);
const inserted = docAfter.slice(start, endNew);
// Model the edit as: delete the removed span (single tombstone target is
// not meaningful for whole-doc text, so we represent it as a no-op-aware
// insert at `start`). The reference CRDT treats the whole document as one
// Y.Text-equivalent sequence; the production impl uses per-character ops.
void removed;
const id = `i:edit:${meta.updateOriginId}`;
return {
kind: "ins",
id,
after: start === 0 ? null : `caret@${start - 1}`,
char: inserted,
site: meta.site,
source: "local",
updateOriginId: meta.updateOriginId,
};
}
}

/**
* Stable node-id <-> text-offset index. The sharded codec (P1) maintains this
* so a remote offset can be remapped onto the local view even while the local
* AST is being rewritten by an IME composition. For the reference build we keep
* a single linear index (the whole document is one unit).
*/
export class NodeOffsetIndex {
private readonly map = new Map<string, number>();
rebuild(text: string, boundaries: readonly number[]): void {
this.map.clear();
let nodeId = 0;
let prev = 0;
for (const b of boundaries) {
this.map.set(`node:${nodeId++}`, prev);
prev = b;
}
this.map.set(`node:${nodeId}`, prev);
}
offsetOf(nodeId: string): number | undefined {
return this.map.get(nodeId);
}
}
Loading