From 2a97eca23d864351c7d599f9aa350c388f833b8b Mon Sep 17 00:00:00 2001 From: ljxxy Date: Tue, 22 Sep 2026 09:21:15 +0800 Subject: [PATCH] feat: add transport-independent Yjs collaboration --- .gitignore | 1 + README.md | 3 +- README.zh.md | 3 +- docs/ROADMAP.md | 2 +- docs/ROADMAP.zh.md | 2 +- .../changes/add-crdt-collaboration/design.md | 50 ++++ openspec/changes/add-crdt-collaboration/pr.md | 20 ++ .../add-crdt-collaboration/proposal.md | 32 +++ .../specs/collaboration/spec.md | 50 ++++ .../specs/plugin-editor-extensions/spec.md | 27 +++ .../changes/add-crdt-collaboration/tasks.md | 28 +++ package.json | 4 +- packages/core/README.md | 20 ++ packages/core/src/editor-history.ts | 16 ++ packages/core/src/editor.ts | 5 +- packages/core/src/index.ts | 1 + packages/core/src/live-preview.ts | 11 +- packages/core/src/transaction-pipeline.ts | 14 +- packages/core/src/types.ts | 2 + packages/core/test/editor-history.test.ts | 48 ++++ packages/core/test/live-preview.test.ts | 21 ++ .../core/test/transaction-pipeline.test.ts | 24 ++ packages/plugin-collab/README.md | 137 +++++++++++ packages/plugin-collab/api/consumer.ts | 25 ++ packages/plugin-collab/examples/index.html | 49 ++++ packages/plugin-collab/examples/main.ts | 76 +++++++ packages/plugin-collab/examples/vite-env.d.ts | 1 + packages/plugin-collab/package.json | 41 ++++ packages/plugin-collab/src/binding.ts | 173 ++++++++++++++ packages/plugin-collab/src/index.ts | 62 +++++ packages/plugin-collab/src/presence.ts | 151 +++++++++++++ packages/plugin-collab/src/selection.ts | 34 +++ packages/plugin-collab/src/unicode.ts | 32 +++ .../plugin-collab/test/collaboration.test.ts | 213 ++++++++++++++++++ packages/plugin-collab/test/helpers.ts | 63 ++++++ packages/plugin-collab/test/history.test.ts | 132 +++++++++++ packages/plugin-collab/test/presence.test.ts | 132 +++++++++++ packages/plugin-collab/tsconfig.api.json | 10 + packages/plugin-collab/tsconfig.json | 4 + pnpm-lock.yaml | 58 +++++ tsconfig.base.json | 1 + vitest.config.ts | 1 + 42 files changed, 1765 insertions(+), 14 deletions(-) create mode 100644 openspec/changes/add-crdt-collaboration/design.md create mode 100644 openspec/changes/add-crdt-collaboration/pr.md create mode 100644 openspec/changes/add-crdt-collaboration/proposal.md create mode 100644 openspec/changes/add-crdt-collaboration/specs/collaboration/spec.md create mode 100644 openspec/changes/add-crdt-collaboration/specs/plugin-editor-extensions/spec.md create mode 100644 openspec/changes/add-crdt-collaboration/tasks.md create mode 100644 packages/core/src/editor-history.ts create mode 100644 packages/core/test/editor-history.test.ts create mode 100644 packages/plugin-collab/README.md create mode 100644 packages/plugin-collab/api/consumer.ts create mode 100644 packages/plugin-collab/examples/index.html create mode 100644 packages/plugin-collab/examples/main.ts create mode 100644 packages/plugin-collab/examples/vite-env.d.ts create mode 100644 packages/plugin-collab/package.json create mode 100644 packages/plugin-collab/src/binding.ts create mode 100644 packages/plugin-collab/src/index.ts create mode 100644 packages/plugin-collab/src/presence.ts create mode 100644 packages/plugin-collab/src/selection.ts create mode 100644 packages/plugin-collab/src/unicode.ts create mode 100644 packages/plugin-collab/test/collaboration.test.ts create mode 100644 packages/plugin-collab/test/helpers.ts create mode 100644 packages/plugin-collab/test/history.test.ts create mode 100644 packages/plugin-collab/test/presence.test.ts create mode 100644 packages/plugin-collab/tsconfig.api.json create mode 100644 packages/plugin-collab/tsconfig.json diff --git a/.gitignore b/.gitignore index 885de9a5..64cbe520 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ # Dependencies node_modules/ +.pnpm-store/ # Build outputs dist/ diff --git a/README.md b/README.md index e12a11ae..d565a3ed 100644 --- a/README.md +++ b/README.md @@ -165,7 +165,7 @@ A real Electron app with file IO, live preview, and every plugin enabled — the ## 📦 Packages
-Full package list (11 packages) — click to expand +Package list — click to expand | Package | Description | |---|---| @@ -174,6 +174,7 @@ A real Electron app with file IO, live preview, and every plugin enabled — the | `@floatboat/nexus-vue` | Vue 3 binding — `useEditor` composable | | `@floatboat/nexus-preset-gfm` | GitHub Flavored Markdown preset (tables, strikethrough, task lists) | | `@floatboat/nexus-plugin-history` | Undo/redo with `Ctrl+Z` / `Ctrl+Shift+Z` | +| `@floatboat/nexus-plugin-collab` | Optional Yjs collaboration, offline merge, selective undo and remote cursors — [integration guide](./packages/plugin-collab/README.md) | | `@floatboat/nexus-plugin-search` | Search and replace helpers | | `@floatboat/nexus-plugin-slash` | Slash command detection, ranking, and a vanilla-DOM floating menu UI | | `@floatboat/nexus-plugin-toolbar` | Toolbar primitives for formatting commands | diff --git a/README.zh.md b/README.zh.md index 9f749dfc..1d5d5e05 100644 --- a/README.zh.md +++ b/README.zh.md @@ -165,7 +165,7 @@ pnpm dev:electron-demo ## 📦 包列表
-完整包列表(11 个包) —— 点击展开 +包列表 —— 点击展开 | 包名 | 说明 | |---|---| @@ -174,6 +174,7 @@ pnpm dev:electron-demo | `@floatboat/nexus-vue` | Vue 3 绑定 —— `useEditor` 组合式函数 | | `@floatboat/nexus-preset-gfm` | GitHub Flavored Markdown 预设(表格、删除线、任务列表) | | `@floatboat/nexus-plugin-history` | 撤销/重做,支持 `Ctrl+Z` / `Ctrl+Shift+Z` | +| `@floatboat/nexus-plugin-collab` | 可选 Yjs 协作:离线合并、独立撤销和协作者光标 —— [接入指南](./packages/plugin-collab/README.md) | | `@floatboat/nexus-plugin-search` | 搜索替换辅助函数 | | `@floatboat/nexus-plugin-slash` | 斜杠命令检测、排序与 vanilla DOM 浮层菜单 UI | | `@floatboat/nexus-plugin-toolbar` | 工具栏基础组件与格式化命令 | diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 7c04d0e6..0960b472 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -66,7 +66,7 @@ Plugin-platform documentation (Chinese): [native API](./plugins/native-plugin-ap | # | Feature | Package | Priority | Status | Needs OpenSpec | Notes | |---|---|---|---|---|---|---| -| 18 | Realtime collaboration (OT / CRDT) | new `plugin-collab` | P3 | planned | Yes | Large feature; start with a tech-selection design doc | +| 18 | Realtime collaboration (OT / CRDT) | new `plugin-collab` | P3 | planned | Yes | Draft implementation and design: `add-crdt-collaboration`; pending maintainer review | | 19 | Version history / snapshots | `core` + host storage | P2 | planned | Yes | electron-demo lands the reference impl first | | 20 | Shared comments / @mention | new `plugin-annotation` | P3 | planned | Yes | Depends on #18 | diff --git a/docs/ROADMAP.zh.md b/docs/ROADMAP.zh.md index 11d0f65b..1edb70c7 100644 --- a/docs/ROADMAP.zh.md +++ b/docs/ROADMAP.zh.md @@ -66,7 +66,7 @@ | # | 功能 | 归属包 | 优先级 | 状态 | 需要 OpenSpec | 备注 | |---|---|---|---|---|---|---| -| 18 | 实时协作(OT / CRDT) | 新包 `plugin-collab` | P3 | planned | 是 | 大特性,先做技术选型 design doc | +| 18 | 实时协作(OT / CRDT) | 新包 `plugin-collab` | P3 | planned | 是 | 实现草稿及设计:`add-crdt-collaboration`;待维护者评审 | | 19 | 版本历史 / 快照 | `core` + 宿主存储 | P2 | planned | 是 | electron-demo 先落地参考实现 | | 20 | 共享注释 / @mention | 新包 `plugin-annotation` | P3 | planned | 是 | 依赖 #18 完成 | diff --git a/openspec/changes/add-crdt-collaboration/design.md b/openspec/changes/add-crdt-collaboration/design.md new file mode 100644 index 00000000..cbd5a616 --- /dev/null +++ b/openspec/changes/add-crdt-collaboration/design.md @@ -0,0 +1,50 @@ +## Context + +Nexus stores Markdown in CodeMirror. Yjs owns the replicated character sequence; +CodeMirror is a local projection. Providers and persistence belong to the host. +The integration must preserve this boundary even while disconnected. + +## Decisions + +1. Expose `createCollaborativeEditor()` with the normal editor configuration, + excluding `initialValue`. Initialize from the supplied attached `Y.Text`. + Never seed an empty replica automatically: two offline clients independently + inserting the same initial text would duplicate it after synchronization. +2. Translate accepted CodeMirror change sets into one Yjs transaction. Use a + unique binding object as origin so each editor owns its undo history, even + when two editors share a Y.Doc. Remote CRDT deltas become CodeMirror changes. +3. Remote transactions bypass local transaction filters but still notify update + listeners. Rejecting a replicated operation would diverge the local projection. + Authorization must happen at the provider boundary before applying Yjs updates. +4. Route the public undo/redo API through an optional generic CodeMirror history + facet. The core has no Yjs dependency. Collaborative history cannot be combined + with CodeMirror's positional history. Undo records relative selections so + remote insertions do not invalidate cursor restoration. +5. Awareness uses Yjs relative positions and the conventional cursor/user fields. + User names are text, never HTML. Ignore malformed or unrelated remote cursors. + Destroy only binding-owned resources, not the host's document or awareness. +6. Collaborative tables use source editing. Existing table widgets defer cell + edits until blur and retain stale row offsets during interaction; enabling + them would risk overwriting remote edits. Other live preview remains enabled. + +## Alternatives + +- Whole-document synchronization cannot preserve concurrent changes. +- A bundled WebSocket service would impose infrastructure on a headless engine. +- OT requires a central authority and a different offline protocol. +- A custom CRDT is unnecessary; Yjs supplies convergence, state vectors and undo. + +## Scope and trade-offs + +This release supports plain-text Y.Text values. Rich-text attributes, embedded +objects, shared comments, and collaborative cell widgets are outside scope. +The host seeds a document once, exchanges binary Yjs updates, and authenticates +peers. It must not use framework controlled-value feedback to mirror each edit. +Local `setDocument()` remains an explicit shared replacement, not a room switch. + +## Verification + +Use real Y.Doc replicas with a deterministic in-memory network: simultaneous +insert/delete, partitions, duplicate delivery, reordered delivery, state-vector +reconnection, Unicode, selective undo, multiple editors, awareness, filters and +mount/destroy cycles. Run the repository's type, API, unit and build checks. diff --git a/openspec/changes/add-crdt-collaboration/pr.md b/openspec/changes/add-crdt-collaboration/pr.md new file mode 100644 index 00000000..f5e3705f --- /dev/null +++ b/openspec/changes/add-crdt-collaboration/pr.md @@ -0,0 +1,20 @@ +# Add transport-independent Yjs collaboration + +Synchronizing Markdown snapshots loses concurrent edits and mixes peer changes +into local history. Add an optional `@floatboat/nexus-plugin-collab` package that +binds each editor to host-owned Y.Text, merges offline operations, and selectively +undoes only that editor's edits. Include relative selections, optional awareness +cursors, lifecycle cleanup, integration documentation and a two-replica demo. + +Core gains a generic history backend, authoritative remote transaction handling, +and an opt-in table source mode. Collaboration uses table source editing to avoid +deferred cell commits overwriting peer changes; other live preview remains active. +Hosts supply providers, authentication, persistence and one-time initialization. + +Validation: 72 test files / 932 tests passed; workspace typecheck, package builds, +public API checks, demo build, strict OpenSpec validation and diff whitespace +checks passed. Demo build reports large chunks; interactive browser smoke testing +was not performed in this pass. + +This is an AI-assisted draft contribution for maintainer review. The proposal, +dependency choice and roadmap priority remain subject to maintainer approval. diff --git a/openspec/changes/add-crdt-collaboration/proposal.md b/openspec/changes/add-crdt-collaboration/proposal.md new file mode 100644 index 00000000..259b0b5d --- /dev/null +++ b/openspec/changes/add-crdt-collaboration/proposal.md @@ -0,0 +1,32 @@ +# Change: Transport-independent CRDT collaboration + +## Why + +Roadmap item 18 requires a collaboration primitive that preserves Markdown as +the document. Concurrent edits cannot safely be synchronized with whole-document +`setDocument()` calls: those calls lose concurrent work and mix remote edits into +local undo history. + +## What Changes + +- Add an optional `plugin-collab` package backed by host-owned Yjs documents. +- Bind CodeMirror changes to CRDT operations and apply remote operations without + re-publishing them or letting local filters reject already-committed CRDT state. +- Add per-editor selective undo, relative selection restoration, optional + awareness cursors, and deterministic resource cleanup. +- Preserve live preview while editing collaborative tables as Markdown source. +- Add transport simulation tests for partitions, duplicate and reordered updates. + +## Impact + +- Affected specs: collaboration, plugin-editor-extensions. +- Affected code: core history dispatch, transaction pipeline, live-preview config; + new plugin-collab package and workspace package registration. +- No network provider, server, authentication, or persistent storage is bundled. +- Yjs and y-protocols are MIT-licensed peer dependencies of the optional package. + +## Review status + +Proposed for maintainer review. This draft includes a reference implementation +requested by the contributor; it does not imply maintainer approval of the +proposal, dependencies, roadmap priority, or AI-assisted contribution. diff --git a/openspec/changes/add-crdt-collaboration/specs/collaboration/spec.md b/openspec/changes/add-crdt-collaboration/specs/collaboration/spec.md new file mode 100644 index 00000000..19cbc5e9 --- /dev/null +++ b/openspec/changes/add-crdt-collaboration/specs/collaboration/spec.md @@ -0,0 +1,50 @@ +## ADDED Requirements + +### Requirement: Host-owned collaborative documents +The editor SHALL initialize from a host-owned attached plain-text Y.Text without +seeding or destroying that document and without requiring a network provider. + +#### Scenario: Joining an existing room +- **WHEN** an editor joins a replicated document containing Markdown +- **THEN** it displays that document without inserting a second copy + +### Requirement: Concurrent convergence +The binding SHALL translate accepted local edits into CRDT operations and apply +remote deltas without feedback, including after offline concurrent editing. + +#### Scenario: Reordered and duplicate messages +- **WHEN** disconnected replicas edit and later exchange reordered duplicate updates +- **THEN** all replicas and their editor projections converge without lost operations + +### Requirement: Selective undo +Each editor SHALL undo only its own operations and restore selections using +relative positions. Public API, keyboard and beforeinput history SHALL agree. + +#### Scenario: Remote changes between edit and undo +- **WHEN** one editor undoes its edit after a peer inserts text +- **THEN** the peer's insertion remains and redo restores only the local edit + +### Requirement: Remote transaction authority +Committed remote operations SHALL bypass local veto/rewriting filters while +notifying editor change callbacks and transaction observers. + +#### Scenario: Local filter rejects typing +- **WHEN** a local filter rejects edits and a remote update arrives +- **THEN** local typing remains rejected and the remote update still appears + +### Requirement: Collaboration presence and ownership +Optional awareness SHALL render relative remote cursors safely. Destroying an +editor SHALL remove its listeners, history and cursor without destroying +host-owned document or awareness state. + +#### Scenario: Repeated mounting +- **WHEN** editors are repeatedly created and destroyed with the same host resources +- **THEN** no binding-owned listeners or cursors remain after destruction + +### Requirement: Safe live preview +Collaborative live preview SHALL edit tables through Markdown transactions, +without enabling deferred contentEditable cell changes. + +#### Scenario: Remote edits to a focused table +- **WHEN** a peer changes a table while another editor is editing its source +- **THEN** both edits participate in CRDT synchronization without stale cell commits diff --git a/openspec/changes/add-crdt-collaboration/specs/plugin-editor-extensions/spec.md b/openspec/changes/add-crdt-collaboration/specs/plugin-editor-extensions/spec.md new file mode 100644 index 00000000..a2f0edec --- /dev/null +++ b/openspec/changes/add-crdt-collaboration/specs/plugin-editor-extensions/spec.md @@ -0,0 +1,27 @@ +## ADDED Requirements + +### Requirement: Alternative history backend +Core SHALL allow one CodeMirror history backend to override public undo and redo +without importing a CRDT library. Without a backend existing history SHALL remain +unchanged. An empty alternative history SHALL NOT fall back to positional undo. + +#### Scenario: Collaborative history is empty +- **WHEN** the alternative backend returns false from undo +- **THEN** editor.undo returns false without attempting a different history + +### Requirement: Replicated transaction projection +Core SHALL apply transactions annotated as remote without local veto or rewrite, +including batches consisting entirely of remote transactions, and SHALL notify +transaction observers after commit. + +#### Scenario: Remote batch with a local veto +- **WHEN** an authoritative remote batch arrives while a local filter rejects edits +- **THEN** the batch commits and observers receive its final document and origins + +### Requirement: Transactional table source mode +Live preview SHALL provide an opt-in source mode for tables while preserving the +existing editable table widget as the default. + +#### Scenario: Source mode with other Markdown +- **WHEN** a document contains a table and a heading in source-table mode +- **THEN** the table remains Markdown source and the heading retains live preview diff --git a/openspec/changes/add-crdt-collaboration/tasks.md b/openspec/changes/add-crdt-collaboration/tasks.md new file mode 100644 index 00000000..4fb7cf26 --- /dev/null +++ b/openspec/changes/add-crdt-collaboration/tasks.md @@ -0,0 +1,28 @@ +## 1. Design +- [x] 1.1 Document CRDT ownership, initialization and provider boundaries. +- [x] 1.2 Specify selective undo, remote filters and table interaction policy. + +## 2. Implementation +- [x] 2.1 Add generic history dispatch and source-table preview mode. +- [x] 2.2 Implement Yjs binding, selective undo and relative selections. +- [x] 2.3 Implement optional awareness with owned resource cleanup. +- [x] 2.4 Add package documentation and a runnable collaboration example. + +## 3. Validation +- [x] 3.1 Test convergence under partitions, reordering and duplicate delivery. +- [x] 3.2 Test editor integration, undo, awareness and lifecycle cleanup. +- [x] 3.3 Run typecheck, public API checks, tests and builds. +- [x] 3.4 Validate OpenSpec and review the final diff. + +## Validation results (2026-09-22) +- `pnpm test`: 72 files, 932 tests passed. +- `pnpm typecheck`: passed for the workspace. +- `pnpm build`: passed for all packages. +- `pnpm check:api`: passed, including the collaboration package's built declaration consumer. +- `pnpm --filter @floatboat/nexus-plugin-collab build:demo`: passed; Vite reports large chunks. +- `openspec validate add-crdt-collaboration --strict`: passed. +- `git diff --check`: passed. + +Checks used the repository's pinned pnpm 9.15.4. The example was build-validated; +interactive browser smoke testing was not performed in this pass. +Maintainer approval remains pending as described in proposal.md. diff --git a/package.json b/package.json index 18beea66..03b77cf4 100644 --- a/package.json +++ b/package.json @@ -4,8 +4,8 @@ "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", - "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", + "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 && pnpm --filter @floatboat/nexus-plugin-collab 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 && pnpm --filter @floatboat/nexus-plugin-collab check:api", "typecheck": "pnpm -r exec tsc --noEmit", "test": "vitest run", "dev:electron-demo": "pnpm --filter @floatboat/nexus-electron-demo dev", diff --git a/packages/core/README.md b/packages/core/README.md index 267f274e..8c5c9d41 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -74,3 +74,23 @@ Multiple ranges in `setSelections` require `multiCursor: true` — without the f ## Other config highlights See the `EditorConfig` type for the full surface: `livePreview`, `plugins`, `theme` / `setTheme`, `locale`, `readOnly`, `tabSize`, `direction`, `indentGuides`, `parseDelayMs`, `slashMenuLimit`, `onChange` / `onFocus` / `onBlur` / `onAssetUpload`. + +### Collaborative hosts + +Use [`@floatboat/nexus-plugin-collab`](../plugin-collab/README.md) to bind an editor +to host-owned Yjs state. The core itself has no CRDT dependency. + +`livePreview: { tableMode: "source" }` keeps tables as editable Markdown while +retaining preview for other nodes. The default `"widget"` mode is unchanged. +Collaboration selects source mode because cell widgets defer their edits until blur. + +Low-level extensions can supply `editorHistory.of({ undo, redo })` with CodeMirror +commands to override public `editor.undo()` / `editor.redo()`. Only one backend +may be supplied. A backend returning `false` does not fall back to local history. +Keyboard bindings remain the extension's responsibility. + +Transactions annotated `Transaction.remote.of(true)` represent already-committed +replicated state. They bypass Nexus veto/rewriting filters and still notify update +listeners. Use CodeMirror's `filter: false` to bypass its own filters as well. +Local transactions continue through both filter layers. This is a consistency +contract, not an authorization mechanism. diff --git a/packages/core/src/editor-history.ts b/packages/core/src/editor-history.ts new file mode 100644 index 00000000..08285500 --- /dev/null +++ b/packages/core/src/editor-history.ts @@ -0,0 +1,16 @@ +import { Facet } from "@codemirror/state"; +import type { Command } from "@codemirror/view"; + +/** An alternative history backend, such as selective CRDT undo. */ +export interface EditorHistory { + undo: Command; + redo: Command; +} + +/** Overrides public undo/redo; an empty backend does not fall back to local history. */ +export const editorHistory = Facet.define({ + combine(values) { + if (values.length > 1) throw new Error("Only one editor history backend may be installed"); + return values[0] ?? null; + }, +}); diff --git a/packages/core/src/editor.ts b/packages/core/src/editor.ts index 4d356ea2..bbe211d8 100644 --- a/packages/core/src/editor.ts +++ b/packages/core/src/editor.ts @@ -15,6 +15,7 @@ import remarkRehype from "remark-rehype"; import { unified } from "unified"; import { EventEmitter } from "./event-emitter"; +import { editorHistory } from "./editor-history"; import { DynamicEditorContributionSink } from "./dynamic-contributions"; import { CoreEditorTransactionPipeline } from "./transaction-pipeline"; import { @@ -901,11 +902,11 @@ export function createEditor(config: EditorConfig): EditorAPI { }, undo() { if (destroyed) return false; - return cmUndo(view); + return (view.state.facet(editorHistory)?.undo ?? cmUndo)(view); }, redo() { if (destroyed) return false; - return cmRedo(view); + return (view.state.facet(editorHistory)?.redo ?? cmRedo)(view); }, focus() { if (destroyed) { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 11b2a643..7e9bddde 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,4 +1,5 @@ export { createEditor } from "./editor"; +export { editorHistory, type EditorHistory } from "./editor-history"; export { DynamicEditorContributionSink, EDITOR_PLUGIN_PRIORITY_MAX, diff --git a/packages/core/src/live-preview.ts b/packages/core/src/live-preview.ts index 24c55625..7b03ef46 100644 --- a/packages/core/src/live-preview.ts +++ b/packages/core/src/live-preview.ts @@ -26,6 +26,7 @@ const COMPOSITION_REDECORATE_DELAY_MS = 60; interface NormalizedLivePreviewConfig { enabled: boolean; + tableMode: "widget" | "source"; renderers: Partial>; labels: Required; } @@ -88,13 +89,14 @@ function normalizeConfig( config: boolean | LivePreviewConfig | undefined ): NormalizedLivePreviewConfig { if (!config) { - return { enabled: false, renderers: {}, labels: DEFAULT_LABELS }; + return { enabled: false, tableMode: "widget", renderers: {}, labels: DEFAULT_LABELS }; } if (config === true) { - return { enabled: true, renderers: {}, labels: DEFAULT_LABELS }; + return { enabled: true, tableMode: "widget", renderers: {}, labels: DEFAULT_LABELS }; } return { enabled: config.enabled ?? true, + tableMode: config.tableMode ?? "widget", renderers: config.renderers ?? {}, labels: { ...DEFAULT_LABELS, ...config.labels } }; @@ -1071,6 +1073,11 @@ function buildDecorations( for (const range of ranges) { if (parentSpans.some(([from, to]) => range.from >= from && range.to <= to)) continue; + if (range.node.type === "table" && config.tableMode === "source") { + parentSpans.push([range.from, range.to]); + continue; + } + if (range.node.type === "heading" && !config.renderers.heading) { buildHeadingDecorations( range as { from: number; to: number; node: Heading }, diff --git a/packages/core/src/transaction-pipeline.ts b/packages/core/src/transaction-pipeline.ts index 7b4d089c..41be8e6d 100644 --- a/packages/core/src/transaction-pipeline.ts +++ b/packages/core/src/transaction-pipeline.ts @@ -248,6 +248,7 @@ export class CoreEditorTransactionPipeline implements EditorTransactionContribut private mergeNativeBatch(state: EditorState, transactions: readonly Transaction[]): Transaction { if (transactions.length === 1) return transactions[0]; + const remote = transactions.every((transaction) => transaction.annotation(Transaction.remote)); const origins: string[] = []; const specs = transactions.map((transaction, index): TransactionSpec => { for (const origin of transaction.annotation(editorTransactionOrigin) ?? []) { @@ -257,9 +258,10 @@ export class CoreEditorTransactionPipeline implements EditorTransactionContribut changes: transaction.changes, selection: transaction.selection, effects: transaction.effects, - annotations: index === transactions.length - 1 && origins.length > 0 - ? editorTransactionOrigin.of(Object.freeze(origins)) - : undefined, + annotations: index === transactions.length - 1 ? [ + ...(origins.length > 0 ? [editorTransactionOrigin.of(Object.freeze(origins))] : []), + ...(remote ? [Transaction.remote.of(true), Transaction.addToHistory.of(false)] : []), + ] : undefined, scrollIntoView: transaction.scrollIntoView, userEvent: transaction.annotation(Transaction.userEvent), sequential: index > 0, @@ -273,7 +275,11 @@ export class CoreEditorTransactionPipeline implements EditorTransactionContribut initial: Transaction, view: EditorView ): CoreEditorTransactionDispatchResult { - const filtered = this.applyFilters(initial); + // Replicated operations have already committed to the shared document. + // A local veto or rewrite would make this editor diverge from its peers. + const filtered: FilterPreparation = initial.annotation(Transaction.remote) + ? { status: "accepted", prepared: { transaction: initial, context: makeContext(this.host.editor, initial) } } + : this.applyFilters(initial); if (filtered.status === "rejected") return filtered; const { transaction, context } = filtered.prepared; diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index b40ebffd..656a4a74 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -72,6 +72,8 @@ export interface LivePreviewLabels { export interface LivePreviewConfig { enabled?: boolean; + /** Source mode keeps table edits in document transactions (e.g. for collaboration). */ + tableMode?: "widget" | "source"; renderers?: Partial>; labels?: LivePreviewLabels; } diff --git a/packages/core/test/editor-history.test.ts b/packages/core/test/editor-history.test.ts new file mode 100644 index 00000000..decaa91a --- /dev/null +++ b/packages/core/test/editor-history.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it, vi } from "vitest"; +import { history } from "@codemirror/commands"; + +import { createEditor, editorHistory } from "../src"; + +describe("alternative editor history", () => { + it("preserves positional undo and redo when no alternative backend is installed", () => { + const editor = createEditor({ + container: document.createElement("div"), initialValue: "before", + plugins: [{ name: "history", cmExtensions: [history()] }], + }); + try { + editor.replaceRange(0, 6, "after"); + expect(editor.undo()).toBe(true); + expect(editor.getDocument()).toBe("before"); + expect(editor.redo()).toBe(true); + expect(editor.getDocument()).toBe("after"); + } finally { + editor.destroy(); + } + }); + + it("uses the selected backend without falling back when its history is empty", () => { + const undo = vi.fn(() => false); + const redo = vi.fn(() => false); + const editor = createEditor({ + container: document.createElement("div"), initialValue: "before", + plugins: [{ name: "history", cmExtensions: [history(), editorHistory.of({ undo, redo })] }], + }); + editor.setDocument("after"); + expect(editor.undo()).toBe(false); + expect(editor.redo()).toBe(false); + expect(editor.getDocument()).toBe("after"); + expect(undo).toHaveBeenCalledTimes(1); + expect(redo).toHaveBeenCalledTimes(1); + editor.destroy(); + editor.undo(); + expect(undo).toHaveBeenCalledTimes(1); + }); + + it("rejects ambiguous history backends", () => { + const backend = { undo: () => false, redo: () => false }; + expect(() => createEditor({ + container: document.createElement("div"), + plugins: [{ name: "ambiguous", cmExtensions: [editorHistory.of(backend), editorHistory.of({ ...backend })] }], + })).toThrow("Only one"); + }); +}); diff --git a/packages/core/test/live-preview.test.ts b/packages/core/test/live-preview.test.ts index f0beda39..436c9d36 100644 --- a/packages/core/test/live-preview.test.ts +++ b/packages/core/test/live-preview.test.ts @@ -57,6 +57,27 @@ async function nextAnimationFrame(): Promise { } describe("live preview", () => { + it("keeps tables in source mode while retaining heading preview", () => { + const container = document.createElement("div"); + const initialValue = "# Title\n\n| A | B |\n| --- | --- |\n| x | y |\n\nOutside"; + const editor = createEditor({ + container, initialValue, livePreview: { tableMode: "source" }, + plugins: [createGfmPreset()], + }); + try { + editor.setSelection(initialValue.length); + expect(container.querySelector(".nexus-table-wrapper")).toBeNull(); + expect(container.textContent).toContain("| A | B |"); + expect(container.querySelector('[data-heading-level="1"]')).not.toBeNull(); + const from = initialValue.indexOf("x | "); + editor.replaceRange(from, from + 1, "edited"); + expect(editor.getDocument()).toContain("| edited | y |"); + expect(container.querySelector(".nexus-table-wrapper")).toBeNull(); + } finally { + editor.destroy(); + } + }); + // ── Inline formatting ── // Note: inline markers are hidden when cursor is on a DIFFERENT line (line-level detection). // Tests use multi-line content with cursor moved to a separate line. diff --git a/packages/core/test/transaction-pipeline.test.ts b/packages/core/test/transaction-pipeline.test.ts index 7c55e26d..2634fd31 100644 --- a/packages/core/test/transaction-pipeline.test.ts +++ b/packages/core/test/transaction-pipeline.test.ts @@ -40,6 +40,30 @@ function transaction( } describe("core editor transaction pipeline", () => { + it("projects remote batches without local veto or history capture and still notifies", () => { + const { editor, view } = createTestEditor(); + const filter = vi.fn(() => ({ action: "reject" as const })); + const listener = vi.fn(); + editor.getContributionSink().registerTransactionFilter("local", filter); + editor.getContributionSink().registerUpdateListener("audit", listener); + const first = view.state.update({ + changes: { from: 0, insert: "1" }, filter: false, + annotations: [Transaction.remote.of(true), editorTransactionOrigin.of(["remote-a"])], + }); + const second = first.state.update({ + changes: { from: 1, insert: "2" }, filter: false, + annotations: [Transaction.remote.of(true), editorTransactionOrigin.of(["remote-b"])], + }); + view.dispatch([first, second]); + expect(editor.getDocument()).toBe("12abcd"); + expect(filter).not.toHaveBeenCalled(); + expect(listener).toHaveBeenCalledTimes(1); + expect(listener.mock.calls[0][0].origin).toEqual(["remote-a", "remote-b"]); + editor.replaceRange(0, 0, "blocked"); + expect(filter).toHaveBeenCalledTimes(1); + expect(editor.getDocument()).toBe("12abcd"); + editor.destroy(); + }); it("filters before commit and only notifies listeners with the final update", () => { const { editor } = createTestEditor(); const sink = editor.getContributionSink(); diff --git a/packages/plugin-collab/README.md b/packages/plugin-collab/README.md new file mode 100644 index 00000000..cc5f4c60 --- /dev/null +++ b/packages/plugin-collab/README.md @@ -0,0 +1,137 @@ +# @floatboat/nexus-plugin-collab + +Collaborative Markdown editing with Yjs: concurrent offline edits, selective +undo/redo and optional remote cursors. The host owns networking, persistence, +authentication and room selection. No server or network provider is bundled. + +## Quick start + +```sh +pnpm add @floatboat/nexus-plugin-collab yjs y-protocols +``` + +```ts +import * as Y from "yjs"; +import { Awareness } from "y-protocols/awareness"; +import { createCollaborativeEditor } from "@floatboat/nexus-plugin-collab"; + +const doc = new Y.Doc(); +const text = doc.getText("markdown"); +const awareness = new Awareness(doc); +awareness.setLocalStateField("user", { name: "Alice", color: "#4f46e5" }); + +// Restore persisted Yjs state and connect your provider here. Seed new rooms +// ONCE at their authority, never independently on every joining client. +const editor = createCollaborativeEditor({ + container: document.querySelector("#editor")!, + text, + awareness, + livePreview: true, + onChange(markdown, ast) { + console.log(markdown, ast); + }, +}); + +editor.replaceSelection("Hello"); +editor.stopCapturing(); // Next edit starts a separate undo group. +editor.undo(); // Only this editor's edits are undone. +editor.redo(); + +// Detach editors before disposing host-owned resources. +editor.destroy(); +awareness.destroy(); +doc.destroy(); +``` + +When using a provider with its own awareness instance, pass `provider.awareness` +instead of constructing another instance. Exchange Yjs binary updates, not +Markdown snapshots. A joining replica must restore the same persisted CRDT +history; equal plain-text strings independently inserted into two documents do +not have the same CRDT identity. + +## API + +`createCollaborativeEditor(config): CollaborativeEditorAPI` accepts the normal +`EditorConfig` except `initialValue`, plus: + +| Option | Meaning | +| --- | --- | +| `text: Y.Text` | Required, attached to a live host-owned Y.Doc; plain text only. | +| `awareness?: Awareness` | Optional, from the same document. | +| `captureTimeout?: number` | Local undo grouping interval in milliseconds; default 500, zero disables grouping. | + +The returned object is an `EditorAPI` with `stopCapturing()`. Public `undo()` and +`redo()`, Mod-Z / Mod-Shift-Z / Mod-Y, and native `beforeinput` history events all +use the same selective history. Each mounted editor has a unique origin, even +when several editors share a document. Host and peer edits are not tracked. +Selections are stored as CRDT relative positions and survive remote insertions. + +## Integration contracts + +- **Initialization:** the shared text is authoritative, including an empty room. + There is no automatic seeding or fallback to local initial content. +- **History:** omit `createHistoryPlugin()` and CodeMirror's `history()` extension. + Combining positional history with CRDT history throws before attaching resources. +- **Changes:** `onChange` fires for both local and remote edits. Use Yjs/provider + events to persist binary state; do not feed the callback back into `setDocument`. +- **Replacement:** `setDocument()` deliberately replaces the shared document. + It is not a way to switch rooms. Destroy the editor and create a new one for + a different Y.Text. A silent replacement still replicates; `silent` only + suppresses local change notifications. +- **Frameworks:** mount this factory in a React effect or Vue mounted hook and + destroy it during cleanup. Do not combine it with a controlled Markdown + `value` / `v-model` loop. Existing framework wrappers retain their original + standalone editor factory. +- **Filtering:** local changes pass through normal CodeMirror and Nexus filters. + Committed remote transactions carry `Transaction.remote` and origin + `collaboration:remote`; they bypass veto/rewriting filters while still notifying + change callbacks and transaction observers. Providers enforce authorization + before applying updates. This annotation is not a security boundary. +- **Live preview:** ordinary preview remains available. Tables intentionally stay + in Markdown source mode, so every edit is a CRDT transaction. The current cell + widgets defer edits until blur and are not safe for concurrent remote changes. + Custom editable widgets must likewise commit edits through document transactions. +- **Unicode:** the initial shared text must contain well-formed Unicode. Local + edits that split surrogate pairs are normalized to U+FFFD within the same + transaction, matching Yjs's encoding. CRLF characters are preserved as data. + Providers and host-side mutations must supply plain text without embeds or + rich-text attributes; custom transaction rewrites must preserve valid Unicode. +- **Presence:** uses the conventional awareness `user` and `cursor` fields. + `user.name` is rendered as text (up to 100 characters); `user.color` accepts + six-digit hex colors. The focused editor owns the local cursor. Destroying it + clears only that cursor, retaining the host's other awareness fields. +- **Ownership:** destroy editors before destroying their document or awareness. + The binding removes its listeners and UndoManager; it never destroys host-owned + resources. Awareness timers and provider connections remain the host's job. + +## Try two independent replicas + +From the repository root: + +```sh +pnpm build +pnpm --filter @floatboat/nexus-plugin-collab demo +``` + +The example connects two Y.Doc instances through an in-memory transport. Pause +the connection, edit both sides, then reconnect. State-vector synchronization +merges offline changes. Each pane has independent undo/redo. This is a transport +demonstration, not a production network provider. + +## Validation + +```sh +pnpm test -- packages/plugin-collab/test +pnpm --filter @floatboat/nexus-plugin-collab build +pnpm --filter @floatboat/nexus-plugin-collab check:api +pnpm --filter @floatboat/nexus-plugin-collab build:demo +``` + +Tests use real CRDT replicas and cover simultaneous edits, overlapping deletions, +state-vector reconnection, duplicate/reordered delivery, four-peer randomized +edit schedules, Unicode boundaries, undo isolation, relative selections, filters, +read-only receivers, table source mode, presence and repeated teardown. + +Yjs and y-protocols are MIT-licensed peer dependencies. Keeping them external +avoids duplicate Yjs instances when the host already has a provider. The core +package does not import either dependency. diff --git a/packages/plugin-collab/api/consumer.ts b/packages/plugin-collab/api/consumer.ts new file mode 100644 index 00000000..6042fba7 --- /dev/null +++ b/packages/plugin-collab/api/consumer.ts @@ -0,0 +1,25 @@ +// Compile against the published declarations, without workspace source aliases. +import { createCollaborativeEditor, type CollaborativeEditorAPI, type CollaborativeEditorConfig } from "../dist/index.js"; +import type { EditorAPI } from "@floatboat/nexus-core"; +import { Awareness } from "y-protocols/awareness"; +import * as Y from "yjs"; + +const doc = new Y.Doc(); +const config: CollaborativeEditorConfig = { + container: document.createElement("div"), + text: doc.getText("markdown"), + awareness: new Awareness(doc), + captureTimeout: 0, + livePreview: { tableMode: "source" }, + onChange(markdown) { markdown.toUpperCase(); }, +}; +const editor: CollaborativeEditorAPI = createCollaborativeEditor(config); +const core: EditorAPI = editor; +editor.stopCapturing(); +const didUndo: boolean = core.undo(); +void didUndo; + +// @ts-expect-error Shared text is mandatory. +createCollaborativeEditor({ container: config.container }); +// @ts-expect-error Initialization belongs to the host-owned shared text. +createCollaborativeEditor({ ...config, initialValue: "duplicate seed" }); diff --git a/packages/plugin-collab/examples/index.html b/packages/plugin-collab/examples/index.html new file mode 100644 index 00000000..d8e73f7f --- /dev/null +++ b/packages/plugin-collab/examples/index.html @@ -0,0 +1,49 @@ + + + + + + Nexus collaborative Markdown + + + +
+

Collaborative Markdown

+

Pause the connection, edit both copies, then reconnect. Both writers keep their work.

+
+ + Connected · documents agree +
+
+
+
Alice
+
+
+
+
Bob
+
+
+
+

Two independent Yjs replicas. No server, account or network request. Tables are edited as Markdown source.

+
+ + + diff --git a/packages/plugin-collab/examples/main.ts b/packages/plugin-collab/examples/main.ts new file mode 100644 index 00000000..5ba23682 --- /dev/null +++ b/packages/plugin-collab/examples/main.ts @@ -0,0 +1,76 @@ +import * as Y from "yjs"; +import { Awareness, applyAwarenessUpdate, encodeAwarenessUpdate } from "y-protocols/awareness"; +import { createCollaborativeEditor } from "../src/index"; + +function element(id: string): T { + const found = document.getElementById(id); + if (!found) throw new Error(`Missing example element: ${id}`); + return found as T; +} + +const docs = [new Y.Doc(), new Y.Doc()]; +docs[0].getText("markdown").insert(0, "# Write together\n\nThis is **shared Markdown**.\n\n- Edit either pane\n- Try independent undo\n- Disconnect and merge offline work\n\n| Writer | Status |\n| --- | --- |\n| Alice | Ready |\n| Bob | Ready |\n"); +Y.applyUpdate(docs[1], Y.encodeStateAsUpdate(docs[0])); +const awareness = docs.map((doc) => new Awareness(doc)); +const transportOrigin = Symbol("example-transport"); +let connected = true; +let disposed = false; + +function updateStatus(): void { + const equal = docs[0].getText("markdown").toString() === docs[1].getText("markdown").toString(); + element("status").textContent = `${connected ? "Connected" : "Offline"} · documents ${equal ? "agree" : "differ"}`; +} + +docs.forEach((doc, index) => { + const other = 1 - index; + doc.on("update", (update: Uint8Array, origin: unknown) => { + if (connected && origin !== transportOrigin) Y.applyUpdate(docs[other], update, transportOrigin); + updateStatus(); + }); + awareness[index].on("update", ({ added, updated, removed }: { + added: number[]; updated: number[]; removed: number[]; + }, origin: unknown) => { + if (!connected || origin === transportOrigin) return; + applyAwarenessUpdate( + awareness[other], encodeAwarenessUpdate(awareness[index], [...added, ...updated, ...removed]), transportOrigin, + ); + }); +}); + +const editors = ["alice", "bob"].map((name, index) => { + awareness[index].setLocalStateField("user", { + name: index === 0 ? "Alice" : "Bob", color: index === 0 ? "#4f46e5" : "#0f766e", + }); + const editor = createCollaborativeEditor({ + container: element(name), text: docs[index].getText("markdown"), awareness: awareness[index], + livePreview: true, + }); + element(`${name}-undo`).addEventListener("click", () => editor.undo()); + element(`${name}-redo`).addEventListener("click", () => editor.redo()); + return editor; +}); + +element("connection").addEventListener("click", () => { + connected = !connected; + if (connected) { + docs.forEach((source, index) => { + const target = docs[1 - index]; + Y.applyUpdate(target, Y.encodeStateAsUpdate(source, Y.encodeStateVector(target)), transportOrigin); + applyAwarenessUpdate(awareness[1 - index], encodeAwarenessUpdate(awareness[index], [source.clientID]), transportOrigin); + }); + } + element("connection").textContent = connected ? "Pause connection" : "Reconnect and merge"; + element("connection").setAttribute("aria-pressed", String(!connected)); + updateStatus(); +}); + +function dispose(): void { + if (disposed) return; + disposed = true; + editors.forEach((editor) => editor.destroy()); + awareness.forEach((state) => state.destroy()); + docs.forEach((doc) => doc.destroy()); +} + +window.addEventListener("pagehide", dispose, { once: true }); +if (import.meta.hot) import.meta.hot.dispose(dispose); diff --git a/packages/plugin-collab/examples/vite-env.d.ts b/packages/plugin-collab/examples/vite-env.d.ts new file mode 100644 index 00000000..11f02fe2 --- /dev/null +++ b/packages/plugin-collab/examples/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/packages/plugin-collab/package.json b/packages/plugin-collab/package.json new file mode 100644 index 00000000..3ef52cdd --- /dev/null +++ b/packages/plugin-collab/package.json @@ -0,0 +1,41 @@ +{ + "name": "@floatboat/nexus-plugin-collab", + "version": "0.0.14", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "check:api": "pnpm --filter @floatboat/nexus-core build && pnpm build && tsc -p tsconfig.api.json", + "build": "tsup src/index.ts --format esm --dts --clean", + "demo": "vite examples --host 127.0.0.1", + "build:demo": "vite build examples" + }, + "dependencies": { + "@codemirror/commands": "^6.8.1", + "@codemirror/state": "^6.6.0", + "@codemirror/view": "^6.41.0", + "@floatboat/nexus-core": "workspace:*" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "peerDependencies": { + "yjs": "^13.6.27", + "y-protocols": "^1.0.6" + }, + "devDependencies": { + "yjs": "^13.6.27", + "y-protocols": "^1.0.6", + "vite": "^6.3.0" + } +} diff --git a/packages/plugin-collab/src/binding.ts b/packages/plugin-collab/src/binding.ts new file mode 100644 index 00000000..4ca2e676 --- /dev/null +++ b/packages/plugin-collab/src/binding.ts @@ -0,0 +1,173 @@ +import { historyField } from "@codemirror/commands"; +import { EditorState, Prec, StateField, Text, Transaction, type ChangeSpec, type Extension } from "@codemirror/state"; +import { EditorView, ViewPlugin, keymap, type ViewUpdate } from "@codemirror/view"; +import { editorHistory, editorTransactionOrigin } from "@floatboat/nexus-core"; +import * as Y from "yjs"; + +import { captureSelection, restoreSelection, type RelativeSelection } from "./selection"; +import { unicodeTransactions } from "./unicode"; + +type HistoryItem = Y.UndoManager["undoStack"][number]; + +/** Only the binding owns this origin, so editors sharing one Y.Doc still undo independently. */ +class CollaborationBinding { + private readonly undoManager: Y.UndoManager; + private readonly selectionKey = Symbol("selection-before-edit"); + private selectionBefore: RelativeSelection | null = null; + private captureHistory = true; + + constructor( + private readonly view: EditorView, + private readonly text: Y.Text, + captureTimeout: number, + ) { + this.undoManager = new Y.UndoManager(text, { + trackedOrigins: new Set([this]), + captureTimeout, + captureTransaction: () => this.captureHistory, + }); + text.observe(this.receive); + this.undoManager.on("stack-item-added", this.rememberSelection); + this.undoManager.on("stack-item-popped", this.restoreSelection); + } + + private readonly receive = (event: Y.YTextEvent, transaction: Y.Transaction): void => { + if (transaction.origin === this) return; + const changes: ChangeSpec[] = []; + let from = 0; + for (const operation of event.delta) { + if (operation.retain !== undefined) from += operation.retain; + if (operation.delete !== undefined) { + changes.push({ from, to: from + operation.delete }); + from += operation.delete; + } + if (operation.insert !== undefined) { + if (typeof operation.insert !== "string") { + throw new TypeError("Collaborative Markdown requires a plain-text Y.Text"); + } + changes.push({ from, insert: Text.of(operation.insert.split("\n")) }); + } + } + if (changes.length === 0) return; + this.view.dispatch({ + changes, + filter: false, + annotations: [ + Transaction.remote.of(true), + Transaction.addToHistory.of(false), + editorTransactionOrigin.of(["collaboration:remote"]), + ], + }); + }; + + update(update: ViewUpdate): void { + if (!update.docChanged || update.transactions.every((tr) => tr.annotation(Transaction.remote))) return; + this.selectionBefore = captureSelection(this.text, update.startState.selection); + this.captureHistory = update.transactions.some((tr) => tr.annotation(Transaction.addToHistory) !== false); + if (!this.captureHistory) this.undoManager.stopCapturing(); + try { + this.text.doc!.transact(() => { + let offset = 0; + update.changes.iterChanges((from, to, _fromAfter, _toAfter, inserted) => { + if (to > from) this.text.delete(from + offset, to - from); + if (inserted.length > 0) this.text.insert(from + offset, inserted.toString()); + offset += inserted.length - (to - from); + }); + }, this); + } finally { + this.selectionBefore = null; + this.captureHistory = true; + } + } + + private readonly rememberSelection = ({ stackItem }: { stackItem: HistoryItem }): void => { + // A grouped undo should restore the selection before the first edit in that group. + if (this.selectionBefore && !stackItem.meta.has(this.selectionKey)) { + stackItem.meta.set(this.selectionKey, this.selectionBefore); + } + }; + + private readonly restoreSelection = ({ stackItem }: { stackItem: HistoryItem }): void => { + const relative = stackItem.meta.get(this.selectionKey) as RelativeSelection | undefined; + const selection = relative && restoreSelection(this.text, relative); + if (selection) this.view.dispatch({ selection, scrollIntoView: true }); + }; + + undo(): boolean { + return this.runHistory("undo"); + } + + redo(): boolean { + return this.runHistory("redo"); + } + + stopCapturing(): void { + this.undoManager.stopCapturing(); + } + + private runHistory(action: "undo" | "redo"): boolean { + if (this.view.state.readOnly) return false; + this.selectionBefore = captureSelection(this.text, this.view.state.selection); + try { + return this.undoManager[action]() !== null; + } finally { + this.selectionBefore = null; + } + } + + destroy(): void { + this.text.unobserve(this.receive); + this.undoManager.off("stack-item-added", this.rememberSelection); + this.undoManager.off("stack-item-popped", this.restoreSelection); + this.undoManager.destroy(); + } +} + +export function createBinding(text: Y.Text, captureTimeout: number): { + readonly extension: Extension; + stopCapturing(): void; +} { + let binding: CollaborationBinding | null = null; + const plugin = ViewPlugin.define((view) => { + binding = new CollaborationBinding(view, text, captureTimeout); + return binding; + }); + const undo = (view: EditorView): boolean => view.plugin(plugin)?.undo() ?? false; + const redo = (view: EditorView): boolean => view.plugin(plugin)?.redo() ?? false; + const validate = StateField.define({ + create(state) { + if (state.field(historyField, false)) { + throw new Error("Collaboration replaces plugin-history; remove the local history extension"); + } + if (state.doc.toString() !== text.toString()) { + throw new Error("The editor and collaborative document must start with identical text"); + } + return null; + }, + update: (value) => value, + }); + + return { + extension: [ + validate, + // Preserve CR characters as data; implicit CRLF normalization changes CRDT offsets. + EditorState.lineSeparator.of("\n"), + unicodeTransactions(), + plugin, + editorHistory.of({ undo, redo }), + Prec.highest(keymap.of([ + { key: "Mod-z", run: (view) => { undo(view); return true; } }, + { key: "Mod-Shift-z", run: (view) => { redo(view); return true; } }, + { key: "Mod-y", run: (view) => { redo(view); return true; } }, + ])), + EditorView.domEventHandlers({ + beforeinput(event, view) { + if (event.inputType === "historyUndo") { undo(view); return true; } + if (event.inputType === "historyRedo") { redo(view); return true; } + return false; + }, + }), + ], + stopCapturing: () => binding?.stopCapturing(), + }; +} diff --git a/packages/plugin-collab/src/index.ts b/packages/plugin-collab/src/index.ts new file mode 100644 index 00000000..c6090867 --- /dev/null +++ b/packages/plugin-collab/src/index.ts @@ -0,0 +1,62 @@ +import { createEditor, type EditorAPI, type EditorConfig, type LivePreviewConfig } from "@floatboat/nexus-core"; +import type { Awareness } from "y-protocols/awareness"; +import type * as Y from "yjs"; + +import { createBinding } from "./binding"; +import { createPresence } from "./presence"; +import { isWellFormed } from "./unicode"; + +export interface CollaborativeEditorConfig extends Omit { + /** Host-owned, attached plain-text shared type. Seed it once before joining. */ + text: Y.Text; + /** Optional host-owned awareness. Its document must match text.doc. */ + awareness?: Awareness; + /** Local edits within this interval form an undo group. Default: 500 ms. */ + captureTimeout?: number; +} + +export interface CollaborativeEditorAPI extends EditorAPI { + /** Start a new undo group, for example before inserting a template. */ + stopCapturing(): void; +} + +function collaborativePreview(config: EditorConfig["livePreview"]): EditorConfig["livePreview"] { + if (!config) return config; + const preview: LivePreviewConfig = config === true ? {} : config; + return { ...preview, tableMode: "source" }; +} + +/** Create a Markdown editor backed by a host-owned Yjs document. */ +export function createCollaborativeEditor(config: CollaborativeEditorConfig): CollaborativeEditorAPI { + const { text, awareness, captureTimeout = 500, ...editorConfig } = config; + if (!text.doc || text.doc.isDestroyed) { + throw new TypeError("Collaboration requires a Y.Text attached to a live Y.Doc"); + } + if (text.toDelta().some((part: { insert: unknown; attributes?: unknown }) => typeof part.insert !== "string" || part.attributes)) { + throw new TypeError("Collaborative Markdown requires plain text without embeds or formatting attributes"); + } + if (awareness && awareness.doc !== text.doc) { + throw new TypeError("Awareness and Y.Text must belong to the same Y.Doc"); + } + if (!isWellFormed(text.toString())) { + throw new TypeError("The shared document must contain well-formed Unicode"); + } + if (!Number.isFinite(captureTimeout) || captureTimeout < 0) { + throw new RangeError("captureTimeout must be a finite non-negative number"); + } + + const binding = createBinding(text, captureTimeout); + const editor = createEditor({ + ...editorConfig, + initialValue: text.toString(), + livePreview: collaborativePreview(editorConfig.livePreview), + plugins: [ + ...(editorConfig.plugins ?? []), + { + name: "plugin-collab", + cmExtensions: [binding.extension, ...(awareness ? [createPresence(text, awareness)] : [])], + }, + ], + }); + return Object.assign(editor, { stopCapturing: binding.stopCapturing }); +} diff --git a/packages/plugin-collab/src/presence.ts b/packages/plugin-collab/src/presence.ts new file mode 100644 index 00000000..63f45ecb --- /dev/null +++ b/packages/plugin-collab/src/presence.ts @@ -0,0 +1,151 @@ +import { StateEffect, type Extension, type Range } from "@codemirror/state"; +import { Decoration, EditorView, ViewPlugin, WidgetType, type DecorationSet, type ViewUpdate } from "@codemirror/view"; +import type { Awareness } from "y-protocols/awareness"; +import * as Y from "yjs"; + +import { resolvePosition } from "./selection"; + +const refreshPresence = StateEffect.define(); +const cursorOwners = new WeakMap(); +const defaultColor = "#4f46e5"; + +function record(value: unknown): value is Record { + return value !== null && typeof value === "object"; +} + +function readPosition(text: Y.Text, value: unknown): number | null { + if (!record(value)) return null; + try { + return resolvePosition(text, Y.createRelativePositionFromJSON(value)); + } catch { + return null; + } +} + +class RemoteCaret extends WidgetType { + constructor(private readonly name: string, private readonly color: string) { super(); } + + eq(other: RemoteCaret): boolean { + return this.name === other.name && this.color === other.color; + } + + toDOM(view: EditorView): HTMLElement { + const caret = view.dom.ownerDocument.createElement("span"); + caret.className = "nexus-collab-caret"; + caret.style.borderColor = this.color; + caret.setAttribute("aria-label", this.name); + const label = view.dom.ownerDocument.createElement("span"); + label.className = "nexus-collab-name"; + label.style.backgroundColor = this.color; + label.textContent = this.name; + caret.appendChild(label); + return caret; + } + + ignoreEvent(): boolean { return true; } +} + +function decorations(text: Y.Text, awareness: Awareness): DecorationSet { + const ranges: Range[] = []; + for (const [clientId, state] of awareness.getStates()) { + if (clientId === awareness.clientID || !record(state.cursor)) continue; + const anchor = readPosition(text, state.cursor.anchor); + const head = readPosition(text, state.cursor.head); + if (anchor === null || head === null) continue; + const user: Record = record(state.user) ? state.user : {}; + const name = typeof user.name === "string" ? user.name.slice(0, 100) : "Anonymous"; + const color = typeof user.color === "string" && /^#[\da-f]{6}$/i.test(user.color) + ? user.color : defaultColor; + if (anchor !== head) { + ranges.push(Decoration.mark({ + class: "nexus-collab-selection", + attributes: { style: `background-color: ${color}33` }, + }).range(Math.min(anchor, head), Math.max(anchor, head))); + } + ranges.push(Decoration.widget({ widget: new RemoteCaret(name, color), side: 1 }).range(head)); + } + return Decoration.set(ranges, true); +} + +export function createPresence(text: Y.Text, awareness: Awareness): Extension { + const plugin = ViewPlugin.fromClass(class { + decorations: DecorationSet; + private destroyed = false; + private refreshQueued = false; + + constructor(private readonly view: EditorView) { + this.decorations = decorations(text, awareness); + awareness.on("change", this.onAwarenessChange); + } + + private readonly onAwarenessChange = (): void => { + if (this.refreshQueued || this.destroyed) return; + this.refreshQueued = true; + // Publishing the local cursor can happen inside a CodeMirror update. + // Re-entering view.dispatch there would invalidate its update lifecycle. + queueMicrotask(() => { + this.refreshQueued = false; + if (!this.destroyed) this.view.dispatch({ effects: refreshPresence.of(null) }); + }); + }; + + update(update: ViewUpdate): void { + if (update.docChanged || update.transactions.some((tr) => tr.effects.some((effect) => effect.is(refreshPresence)))) { + this.decorations = decorations(text, awareness); + } + if (update.selectionSet || update.docChanged || update.focusChanged) this.publishCursor(); + } + + private publishCursor(): void { + if (!this.view.hasFocus) { + this.clearCursor(); + return; + } + const selection = this.view.state.selection.main; + const cursor = { + anchor: Y.createRelativePositionFromTypeIndex(text, selection.anchor, selection.assoc), + head: Y.createRelativePositionFromTypeIndex(text, selection.head, selection.assoc), + }; + cursorOwners.set(awareness, this); + if (JSON.stringify(awareness.getLocalState()?.cursor) !== JSON.stringify(cursor)) { + awareness.setLocalStateField("cursor", cursor); + } + } + + private clearCursor(): void { + if (cursorOwners.get(awareness) !== this) return; + cursorOwners.delete(awareness); + awareness.setLocalStateField("cursor", null); + } + + destroy(): void { + this.destroyed = true; + awareness.off("change", this.onAwarenessChange); + this.clearCursor(); + } + }, { decorations: (value) => value.decorations }); + + return [plugin, EditorView.baseTheme({ + ".nexus-collab-caret": { + position: "relative", + borderLeft: "2px solid", + marginLeft: "-1px", + marginRight: "-1px", + pointerEvents: "none", + }, + ".nexus-collab-name": { + position: "absolute", + bottom: "100%", + left: "-2px", + padding: "1px 4px", + borderRadius: "3px 3px 3px 0", + color: "white", + fontSize: "11px", + lineHeight: "16px", + whiteSpace: "nowrap", + opacity: "0", + transition: "opacity .15s", + }, + "&:hover .nexus-collab-name": { opacity: "1" }, + })]; +} diff --git a/packages/plugin-collab/src/selection.ts b/packages/plugin-collab/src/selection.ts new file mode 100644 index 00000000..7ade022f --- /dev/null +++ b/packages/plugin-collab/src/selection.ts @@ -0,0 +1,34 @@ +import { EditorSelection } from "@codemirror/state"; +import * as Y from "yjs"; + +export interface RelativeSelection { + readonly ranges: readonly { anchor: Y.RelativePosition; head: Y.RelativePosition }[]; + readonly mainIndex: number; +} + +export function captureSelection(text: Y.Text, selection: EditorSelection): RelativeSelection { + return { + ranges: selection.ranges.map((range) => ({ + anchor: Y.createRelativePositionFromTypeIndex(text, range.anchor, range.assoc), + head: Y.createRelativePositionFromTypeIndex(text, range.head, range.assoc), + })), + mainIndex: selection.mainIndex, + }; +} + +export function resolvePosition(text: Y.Text, position: Y.RelativePosition): number | null { + if (!text.doc) return null; + const absolute = Y.createAbsolutePositionFromRelativePosition(position, text.doc); + return absolute?.type === text ? Math.min(absolute.index, text.length) : null; +} + +export function restoreSelection(text: Y.Text, selection: RelativeSelection): EditorSelection | null { + const ranges = []; + for (const range of selection.ranges) { + const anchor = resolvePosition(text, range.anchor); + const head = resolvePosition(text, range.head); + if (anchor === null || head === null) return null; + ranges.push(EditorSelection.range(anchor, head)); + } + return EditorSelection.create(ranges, selection.mainIndex); +} diff --git a/packages/plugin-collab/src/unicode.ts b/packages/plugin-collab/src/unicode.ts new file mode 100644 index 00000000..5dcadca0 --- /dev/null +++ b/packages/plugin-collab/src/unicode.ts @@ -0,0 +1,32 @@ +import { EditorState, type ChangeSpec, type Extension } from "@codemirror/state"; + +const loneSurrogate = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(? { + if (!transaction.docChanged) return transaction; + const repairs = new Map(); + transaction.changes.iterChangedRanges((_from, _to, from, to) => { + const start = Math.max(0, from - 1); + const end = Math.min(transaction.newDoc.length, to + 1); + const contextStart = Math.max(0, start - 1); + const contextEnd = Math.min(transaction.newDoc.length, end + 1); + const context = transaction.newDoc.sliceString(contextStart, contextEnd); + for (const match of context.matchAll(loneSurrogate)) { + const position = contextStart + match.index; + if (position >= start && position < end) { + repairs.set(position, { from: position, to: position + 1, insert: "\uFFFD" }); + } + } + }); + return repairs.size === 0 ? transaction : [ + transaction, + { changes: [...repairs.values()], sequential: true }, + ]; + }); +} diff --git a/packages/plugin-collab/test/collaboration.test.ts b/packages/plugin-collab/test/collaboration.test.ts new file mode 100644 index 00000000..6c9833c1 --- /dev/null +++ b/packages/plugin-collab/test/collaboration.test.ts @@ -0,0 +1,213 @@ +import { EditorState, Transaction } from "@codemirror/state"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import * as Y from "yjs"; + +import { createHistoryPlugin } from "@floatboat/nexus-plugin-history"; +import { createCollaborativeEditor } from "../src/index"; +import { mount, Network } from "./helpers"; + +const cleanups: (() => void)[] = []; +afterEach(() => { cleanups.splice(0).reverse().forEach((cleanup) => cleanup()); }); + +function peers(count = 2, initial = "hello") { + const network = new Network(count, initial); + cleanups.push(() => network.destroy()); + const clients = network.docs.map((_, index) => { + const client = mount(network.text(index)); + cleanups.push(() => client.destroy()); + return client; + }); + return { network, clients }; +} + +describe("collaborative Markdown", () => { + it("normalizes split surrogate pairs and lone surrogate insertions atomically", () => { + const { network, clients: [alice, bob] } = peers(2, "😀!"); + alice.editor.replaceRange(1, 2, ""); + network.flush(); + expect(alice.editor.getDocument()).toBe("\uFFFD!"); + expect(bob.editor.getDocument()).toBe("\uFFFD!"); + expect(alice.editor.undo()).toBe(true); + expect(alice.editor.getDocument()).toBe("😀!"); + alice.editor.replaceRange(2, 2, "\uD800"); + network.flush(true, true); + expect(alice.editor.getDocument()).toBe("😀\uFFFD!"); + expect(bob.editor.getDocument()).toBe(alice.editor.getDocument()); + }); + + it("replicates silent replacements while suppressing only the local callback", () => { + const { network, clients: [alice, bob] } = peers(); + const localChange = vi.fn(); + const remoteChange = vi.fn(); + alice.editor.on("change", localChange); + bob.editor.on("change", remoteChange); + alice.editor.setDocument("# Replacement", { silent: true }); + network.flush(); + expect(localChange).not.toHaveBeenCalled(); + expect(remoteChange).toHaveBeenCalledTimes(1); + expect(bob.editor.getDocument()).toBe("# Replacement"); + expect(alice.editor.getAst().children[0].type).toBe("heading"); + }); + + it("keeps CRDT state in sync through composition transactions and remote edits", () => { + const { network, clients: [alice, bob] } = peers(2, ""); + alice.view.contentDOM.dispatchEvent(new CompositionEvent("compositionstart", { bubbles: true })); + alice.view.dispatch({ changes: { from: 0, insert: "ni" }, userEvent: "input.type.compose" }); + network.flush(); + bob.editor.replaceRange(2, 2, " peer"); + network.flush(); + alice.view.dispatch({ changes: { from: 0, to: 2, insert: "你" }, userEvent: "input.type.compose" }); + alice.view.contentDOM.dispatchEvent(new CompositionEvent("compositionend", { bubbles: true })); + network.flush(); + expect(alice.editor.getDocument()).toBe("你 peer"); + expect(bob.editor.getDocument()).toBe("你 peer"); + }); + it("joins existing content without seeding, including an empty room", () => { + const { network, clients } = peers(2, "# Shared\n\n你好 👩🏽‍💻\r\n"); + expect(network.messages).toHaveLength(0); + expect(clients[0].editor.getDocument()).toBe(network.text(0).toString()); + expect(clients[1].editor.getAst().children[0].type).toBe("heading"); + const empty = peers(2, ""); + expect(empty.clients[0].editor.getDocument()).toBe(""); + expect(empty.network.messages).toHaveLength(0); + }); + + it("converges concurrent insertions and overlapping deletions with reordered duplicates", () => { + const { network, clients: [alice, bob] } = peers(2, "abcdef"); + alice.editor.replaceRange(1, 4, "Alice"); + bob.editor.replaceRange(2, 5, "Bob"); + network.flush(true, true); + expect(alice.editor.getDocument()).toBe(bob.editor.getDocument()); + expect(alice.editor.getDocument()).toContain("Alice"); + expect(alice.editor.getDocument()).toContain("Bob"); + expect(network.messages).toHaveLength(0); + expect(alice.editor.getDocument()).toBe(network.text(0).toString()); + }); + + it("recovers dropped messages through state-vector synchronization", () => { + const { network, clients: [alice, bob] } = peers(); + alice.editor.replaceRange(0, 0, "offline A "); + bob.editor.replaceRange(5, 5, " offline B"); + network.messages.length = 0; + network.reconnect(); + expect(alice.editor.getDocument()).toBe("offline A hello offline B"); + expect(bob.editor.getDocument()).toBe(alice.editor.getDocument()); + alice.editor.replaceRange(0, 0, "online "); + network.flush(); + expect(bob.editor.getDocument()).toBe(alice.editor.getDocument()); + }); + + it("applies disjoint changes and multi-cursor edits as one CRDT transaction", () => { + const { network, clients: [alice, bob] } = peers(2, "one two three"); + alice.view.dispatch({ changes: [{ from: 0, to: 3, insert: "1" }, { from: 8, to: 13, insert: "3" }] }); + expect(network.messages).toHaveLength(1); + network.flush(); + expect(bob.editor.getDocument()).toBe("1 two 3"); + expect(alice.editor.undo()).toBe(true); + network.flush(); + expect(bob.editor.getDocument()).toBe("one two three"); + }); + + it("projects remote edits through change callbacks and AST without echo", () => { + const { network, clients: [alice, bob] } = peers(2, "text"); + const changed = vi.fn(); + bob.editor.on("change", changed); + alice.editor.replaceRange(0, 0, "# "); + network.flush(); + expect(changed).toHaveBeenCalledTimes(1); + expect(bob.editor.getAst().children[0].type).toBe("heading"); + expect(network.messages).toHaveLength(0); + }); + + it("bypasses local vetoes only for committed remote changes, notifying observers", () => { + const { network, clients: [alice, bob] } = peers(); + const filter = vi.fn(() => ({ action: "reject" as const })); + const observed = vi.fn(); + bob.editor.getContributionSink().registerTransactionFilter("local-policy", filter); + bob.editor.getContributionSink().registerUpdateListener("audit", observed); + bob.editor.replaceRange(0, 0, "rejected"); + expect(bob.editor.getDocument()).toBe("hello"); + alice.editor.replaceRange(0, 0, "accepted "); + network.flush(); + expect(bob.editor.getDocument()).toBe("accepted hello"); + expect(filter).toHaveBeenCalledTimes(1); + expect(observed).toHaveBeenCalledTimes(1); + expect(observed.mock.calls[0][0].origin).toEqual(["collaboration:remote"]); + }); + + it("honors CodeMirror filters for local changes without diverging on remote changes", () => { + const network = new Network(2); + cleanups.push(() => network.destroy()); + const client = mount(network.text(0), { + plugins: [{ name: "filter", cmExtensions: [EditorState.changeFilter.of(() => false)] }], + }); + cleanups.push(() => client.destroy()); + client.editor.replaceRange(0, 0, "blocked"); + expect(network.text(0).toString()).toBe(""); + network.text(1).insert(0, "remote"); + network.flush(); + expect(client.editor.getDocument()).toBe("remote"); + }); + + it("keeps read-only replicas up to date without allowing undo", () => { + const network = new Network(2, "start"); + cleanups.push(() => network.destroy()); + const client = mount(network.text(0), { readOnly: true }); + cleanups.push(() => client.destroy()); + network.text(1).insert(0, "remote "); + network.flush(); + expect(client.editor.getDocument()).toBe("remote start"); + expect(client.editor.undo()).toBe(false); + }); + + it("retains normal live preview while exposing table source for transactional editing", () => { + const network = new Network(2, "# Title\n\n| A | B |\n| --- | --- |\n| x | y |\n"); + cleanups.push(() => network.destroy()); + const client = mount(network.text(0), { livePreview: true }); + cleanups.push(() => client.destroy()); + expect(client.container.querySelector(".nexus-table-wrapper")).toBeNull(); + expect(client.container.textContent).toContain("| A | B |"); + const from = client.editor.getDocument().indexOf("x |"); + client.editor.replaceRange(from, from + 1, "local"); + network.text(1).insert(0, "remote\n\n"); + network.flush(true, true); + expect(client.editor.getDocument()).toBe(network.text(1).toString()); + expect(client.editor.getDocument()).toContain("| local | y |"); + }); + + it("converges four replicas across deterministic edit and partition schedules", () => { + const { network, clients } = peers(4, "Markdown\n"); + let seed = 173; + const random = (limit: number) => { + seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0; + return seed % limit; + }; + for (let step = 0; step < 160; step++) { + const client = clients[random(clients.length)]; + const length = client.editor.getDocument().length; + const from = random(length + 1); + const to = Math.min(length, from + random(4)); + client.editor.replaceRange(from, to, ["中", "a", "\n", "", "👩🏽‍💻"][random(5)]); + if (step % 13 === 0) network.flush(true, true); + if (step % 29 === 0) { network.messages.length = 0; network.reconnect(); } + } + network.reconnect(); + const expected = network.text(0).toString(); + for (let index = 0; index < clients.length; index++) { + expect(network.text(index).toString()).toBe(expected); + expect(clients[index].editor.getDocument()).toBe(expected); + } + }); + + it("rejects incompatible history and invalid document ownership before binding", () => { + const doc = new Y.Doc(); + cleanups.push(() => doc.destroy()); + const container = document.createElement("div"); + expect(() => createCollaborativeEditor({ container, text: new Y.Text() })).toThrow("attached"); + expect(() => createCollaborativeEditor({ container, text: doc.getText(), captureTimeout: -1 })).toThrow("captureTimeout"); + expect(() => createCollaborativeEditor({ container, text: doc.getText(), plugins: [createHistoryPlugin()] })).toThrow("history"); + const rich = doc.getText("rich"); + rich.insertEmbed(0, { image: "example" }); + expect(() => createCollaborativeEditor({ container, text: rich })).toThrow("plain text"); + }); +}); diff --git a/packages/plugin-collab/test/helpers.ts b/packages/plugin-collab/test/helpers.ts new file mode 100644 index 00000000..02c0b63a --- /dev/null +++ b/packages/plugin-collab/test/helpers.ts @@ -0,0 +1,63 @@ +import { EditorView, ViewPlugin } from "@codemirror/view"; +import * as Y from "yjs"; + +import { createCollaborativeEditor, type CollaborativeEditorConfig } from "../src/index"; + +export function mount(text: Y.Text, options: Partial = {}) { + const container = document.createElement("div"); + document.body.appendChild(container); + let view!: EditorView; + const editor = createCollaborativeEditor({ + container, + text, + ...options, + plugins: [ + ...(options.plugins ?? []), + { name: "test-view", cmExtensions: [ViewPlugin.define((instance) => { view = instance; return {}; })] }, + ], + }); + return { editor, view, container, destroy() { editor.destroy(); container.remove(); } }; +} + +/** Real Yjs replicas; the test controls when and in which order messages arrive. */ +export class Network { + readonly docs: Y.Doc[]; + readonly messages: { sender: number; update: Uint8Array }[] = []; + private readonly origin = Symbol("test-network"); + + constructor(count: number, initial = "") { + this.docs = Array.from({ length: count }, () => new Y.Doc()); + this.docs[0].getText("markdown").insert(0, initial); + const seed = Y.encodeStateAsUpdate(this.docs[0]); + for (const doc of this.docs.slice(1)) Y.applyUpdate(doc, seed); + this.docs.forEach((doc, sender) => doc.on("update", (update: Uint8Array, origin: unknown) => { + if (origin !== this.origin) this.messages.push({ sender, update }); + })); + } + + text(index: number): Y.Text { return this.docs[index].getText("markdown"); } + + flush(reverse = false, duplicate = false): void { + const messages = this.messages.splice(0); + if (reverse) messages.reverse(); + for (const message of messages) { + this.docs.forEach((doc, index) => { + if (index === message.sender) return; + Y.applyUpdate(doc, message.update, this.origin); + if (duplicate) Y.applyUpdate(doc, message.update, this.origin); + }); + } + } + + reconnect(): void { + for (const source of this.docs) { + for (const target of this.docs) { + if (source === target) continue; + Y.applyUpdate(target, Y.encodeStateAsUpdate(source, Y.encodeStateVector(target)), this.origin); + } + } + this.messages.length = 0; + } + + destroy(): void { this.docs.forEach((doc) => doc.destroy()); } +} diff --git a/packages/plugin-collab/test/history.test.ts b/packages/plugin-collab/test/history.test.ts new file mode 100644 index 00000000..d9a51a3d --- /dev/null +++ b/packages/plugin-collab/test/history.test.ts @@ -0,0 +1,132 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { Transaction } from "@codemirror/state"; +import * as Y from "yjs"; + +import { mount, Network } from "./helpers"; + +const cleanups: (() => void)[] = []; +afterEach(() => cleanups.splice(0).reverse().forEach((cleanup) => cleanup())); + +function peers() { + const network = new Network(2, "hello"); + cleanups.push(() => network.destroy()); + const alice = mount(network.text(0)); + const bob = mount(network.text(1)); + cleanups.push(() => alice.destroy(), () => bob.destroy()); + return { network, alice, bob }; +} + +describe("selective collaboration history", () => { + it("replicates history-excluded changes without putting them in undo", () => { + const { network, alice, bob } = peers(); + alice.view.dispatch({ changes: { from: 0, insert: "system " }, annotations: Transaction.addToHistory.of(false) }); + network.flush(); + expect(bob.editor.getDocument()).toBe("system hello"); + expect(alice.editor.undo()).toBe(false); + alice.editor.replaceRange(0, 0, "local "); + alice.editor.undo(); + expect(alice.editor.getDocument()).toBe("system hello"); + }); + it("undoes and redoes only local operations after a remote insertion", () => { + const { network, alice, bob } = peers(); + alice.editor.replaceRange(5, 5, " Alice"); + network.flush(); + bob.editor.replaceRange(0, 0, "Bob "); + network.flush(); + expect(alice.editor.undo()).toBe(true); + network.flush(); + expect(alice.editor.getDocument()).toBe("Bob hello"); + expect(bob.editor.getDocument()).toBe("Bob hello"); + expect(alice.editor.undo()).toBe(false); + expect(alice.editor.redo()).toBe(true); + network.flush(); + expect(bob.editor.getDocument()).toBe("Bob hello Alice"); + expect(bob.editor.undo()).toBe(true); + network.flush(); + expect(alice.editor.getDocument()).toBe("hello Alice"); + }); + + it("restores a relative selection after remote edits shift its absolute offset", () => { + const { network, alice, bob } = peers(); + alice.editor.setSelection(5); + alice.editor.replaceSelection("!"); + network.flush(); + bob.editor.replaceRange(0, 0, "before "); + network.flush(); + alice.editor.undo(); + expect(alice.editor.getSelection()).toEqual({ anchor: 12, head: 12 }); + alice.editor.redo(); + expect(alice.editor.getDocument()).toBe("before hello!"); + expect(alice.editor.getSelection()).toEqual({ anchor: 13, head: 13 }); + }); + + it("keeps independent undo origins for editors sharing the same Y.Doc", () => { + const doc = new Y.Doc(); + cleanups.push(() => doc.destroy()); + const text = doc.getText("markdown"); + const alice = mount(text); + const bob = mount(text); + cleanups.push(() => alice.destroy(), () => bob.destroy()); + alice.editor.replaceRange(0, 0, "A"); + bob.editor.replaceRange(1, 1, "B"); + alice.editor.undo(); + expect(alice.editor.getDocument()).toBe("B"); + expect(bob.editor.getDocument()).toBe("B"); + expect(bob.editor.undo()).toBe(true); + expect(text.toString()).toBe(""); + expect(alice.editor.redo()).toBe(true); + expect(text.toString()).toBe("A"); + }); + + it("does not undo host changes or initial content", () => { + const { network, alice } = peers(); + network.text(0).insert(0, "host "); + expect(alice.editor.undo()).toBe(false); + alice.editor.replaceRange(0, 0, "local "); + alice.editor.undo(); + expect(alice.editor.getDocument()).toBe("host hello"); + }); + + it("groups edits until an explicit undo boundary", () => { + const { alice } = peers(); + alice.editor.replaceRange(5, 5, "a"); + alice.editor.replaceRange(6, 6, "b"); + alice.editor.stopCapturing(); + alice.editor.replaceRange(7, 7, "c"); + alice.editor.undo(); + expect(alice.editor.getDocument()).toBe("helloab"); + alice.editor.undo(); + expect(alice.editor.getDocument()).toBe("hello"); + }); + + it("routes keyboard and native history input to the same backend", () => { + const { alice } = peers(); + alice.editor.replaceRange(5, 5, "!"); + alice.view.contentDOM.dispatchEvent(new KeyboardEvent("keydown", { + key: "z", ctrlKey: true, bubbles: true, cancelable: true, + })); + expect(alice.editor.getDocument()).toBe("hello"); + const redo = new InputEvent("beforeinput", { inputType: "historyRedo", bubbles: true, cancelable: true }); + alice.view.contentDOM.dispatchEvent(redo); + expect(redo.defaultPrevented).toBe(true); + expect(alice.editor.getDocument()).toBe("hello!"); + alice.view.contentDOM.dispatchEvent(new InputEvent("beforeinput", { + inputType: "historyUndo", bubbles: true, cancelable: true, + })); + expect(alice.editor.getDocument()).toBe("hello"); + }); + + it("restores multiple selections through undo", () => { + const doc = new Y.Doc(); + doc.getText().insert(0, "ab cd"); + const client = mount(doc.getText(), { multiCursor: true }); + cleanups.push(() => doc.destroy(), () => client.destroy()); + client.editor.setSelections([{ anchor: 2 }, { anchor: 5 }], 0); + client.editor.replaceSelection("!"); + expect(client.editor.getDocument()).toBe("ab! cd!"); + client.editor.undo(); + expect(client.editor.getSelections()).toEqual({ + ranges: [{ anchor: 2, head: 2 }, { anchor: 5, head: 5 }], mainIndex: 0, + }); + }); +}); diff --git a/packages/plugin-collab/test/presence.test.ts b/packages/plugin-collab/test/presence.test.ts new file mode 100644 index 00000000..c3658ce5 --- /dev/null +++ b/packages/plugin-collab/test/presence.test.ts @@ -0,0 +1,132 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { Awareness, applyAwarenessUpdate, encodeAwarenessUpdate } from "y-protocols/awareness"; +import * as Y from "yjs"; + +import { createCollaborativeEditor } from "../src/index"; +import { mount, Network } from "./helpers"; + +const cleanups: (() => void)[] = []; +afterEach(() => cleanups.splice(0).reverse().forEach((cleanup) => cleanup())); + +function presence() { + const network = new Network(2, "hello world"); + const local = new Awareness(network.docs[0]); + const remote = new Awareness(network.docs[1]); + const client = mount(network.text(0), { awareness: local }); + cleanups.push(() => network.destroy(), () => local.destroy(), () => remote.destroy(), () => client.destroy()); + const publish = () => applyAwarenessUpdate(local, encodeAwarenessUpdate(remote, [remote.clientID]), "test"); + const cursor = (anchor: number, head = anchor) => ({ + anchor: Y.createRelativePositionFromTypeIndex(network.text(1), anchor), + head: Y.createRelativePositionFromTypeIndex(network.text(1), head), + }); + return { network, local, remote, client, publish, cursor }; +} + +describe("collaborative presence", () => { + it("renders remote selections with safe labels and colors", async () => { + const { remote, client, publish, cursor } = presence(); + remote.setLocalState({ + user: { name: "", color: "red; position:fixed" }, + cursor: cursor(1, 4), + }); + publish(); + await Promise.resolve(); + expect(client.container.querySelector(".nexus-collab-selection")?.textContent).toBe("ell"); + expect(client.container.querySelector(".nexus-collab-name")?.textContent).toBe(""); + expect(client.container.querySelector(".nexus-collab-name img")).toBeNull(); + expect(client.container.querySelector(".nexus-collab-caret")?.style.borderColor).toBe("rgb(79, 70, 229)"); + }); + + it("ignores invalid and unrelated relative positions", async () => { + const { network, remote, client, publish } = presence(); + for (const cursor of [ + { anchor: null, head: 5 }, + { anchor: { item: { client: "bad" } }, head: {} }, + { + anchor: Y.createRelativePositionFromTypeIndex(network.docs[1].getText("other"), 0), + head: Y.createRelativePositionFromTypeIndex(network.docs[1].getText("other"), 0), + }, + ]) { + remote.setLocalState({ cursor }); + publish(); + await Promise.resolve(); + expect(client.container.querySelector(".nexus-collab-caret")).toBeNull(); + } + }); + + it("maps relative selections through remote text updates and removes departed peers", async () => { + const { network, remote, client, publish, cursor } = presence(); + remote.setLocalState({ cursor: cursor(6, 11) }); + publish(); + await Promise.resolve(); + network.text(1).insert(0, "before "); + network.flush(); + expect(client.container.querySelector(".nexus-collab-selection")?.textContent).toBe("world"); + remote.setLocalState(null); + publish(); + await Promise.resolve(); + expect(client.container.querySelector(".nexus-collab-caret")).toBeNull(); + }); + + it("clears only its cursor and preserves host user state on destruction", () => { + const { local, client } = presence(); + local.setLocalStateField("user", { name: "Alice" }); + client.editor.focus(); + client.editor.setSelection(3); + expect(local.getLocalState()?.cursor).toBeTruthy(); + client.destroy(); + expect(local.getLocalState()?.cursor).toBeNull(); + expect(local.getLocalState()?.user).toEqual({ name: "Alice" }); + expect(local.doc.isDestroyed).toBe(false); + }); + + it("does not clear another editor's active cursor on shared awareness", () => { + const { network, local, client } = presence(); + const other = mount(network.text(0), { awareness: local }); + cleanups.push(() => other.destroy()); + client.editor.focus(); + client.editor.setSelection(1); + other.editor.focus(); + other.editor.setSelection(5); + const activeCursor = local.getLocalState()?.cursor; + client.destroy(); + expect(local.getLocalState()?.cursor).toEqual(activeCursor); + }); + + it("unsubscribes on repeated destruction without destroying host resources", async () => { + const doc = new Y.Doc(); + const awareness = new Awareness(doc); + cleanups.push(() => doc.destroy(), () => awareness.destroy()); + const text = doc.getText("markdown"); + const observe = vi.spyOn(text, "observe"); + const unobserve = vi.spyOn(text, "unobserve"); + const on = vi.spyOn(awareness, "on"); + const off = vi.spyOn(awareness, "off"); + const docOn = vi.spyOn(doc, "on"); + const docOff = vi.spyOn(doc, "off"); + for (let index = 0; index < 10; index++) { + const client = mount(text, { awareness }); + client.editor.replaceRange(0, 0, "x"); + awareness.setLocalStateField("test", index); + client.destroy(); + client.destroy(); + } + await Promise.resolve(); + expect(observe).toHaveBeenCalledTimes(10); + expect(unobserve.mock.calls).toEqual(observe.mock.calls); + expect(off.mock.calls).toEqual(on.mock.calls); + for (const [event, listener] of docOn.mock.calls) { + expect(docOff.mock.calls).toContainEqual([event, listener]); + } + text.insert(0, "still alive"); + expect(doc.isDestroyed).toBe(false); + expect(awareness.getLocalState()?.test).toBe(9); + }); + + it("rejects awareness owned by a different document", () => { + const { network, remote } = presence(); + expect(() => createCollaborativeEditor({ + container: document.createElement("div"), text: network.text(0), awareness: remote, + })).toThrow("same Y.Doc"); + }); +}); diff --git a/packages/plugin-collab/tsconfig.api.json b/packages/plugin-collab/tsconfig.api.json new file mode 100644 index 00000000..9a27fa09 --- /dev/null +++ b/packages/plugin-collab/tsconfig.api.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "paths": {}, + "noEmit": true, + "skipLibCheck": false, + "types": [] + }, + "include": ["api/**/*.ts"] +} diff --git a/packages/plugin-collab/tsconfig.json b/packages/plugin-collab/tsconfig.json new file mode 100644 index 00000000..fdb44cdc --- /dev/null +++ b/packages/plugin-collab/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src/**/*.ts", "test/**/*.ts", "examples/**/*.ts"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0382fb79..96e94c09 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -154,6 +154,31 @@ importers: specifier: workspace:* version: link:../core + packages/plugin-collab: + dependencies: + '@codemirror/commands': + specifier: ^6.8.1 + version: 6.10.3 + '@codemirror/state': + specifier: ^6.6.0 + version: 6.6.0 + '@codemirror/view': + specifier: ^6.41.0 + version: 6.41.0 + '@floatboat/nexus-core': + specifier: workspace:* + version: link:../core + devDependencies: + vite: + specifier: ^6.3.0 + version: 6.4.2(@types/node@24.12.2) + y-protocols: + specifier: ^1.0.6 + version: 1.0.7(yjs@13.6.32) + yjs: + specifier: ^13.6.27 + version: 13.6.32 + packages/plugin-history: dependencies: '@codemirror/commands': @@ -2478,6 +2503,9 @@ packages: resolution: {integrity: sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==} engines: {node: '>=18'} + isomorphic.js@0.2.5: + resolution: {integrity: sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==} + jackspeak@3.4.3: resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} @@ -2566,6 +2594,11 @@ packages: resolution: {integrity: sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==} engines: {node: '>= 0.6.3'} + lib0@0.2.117: + resolution: {integrity: sha512-DeXj9X5xDCjgKLU/7RR+/HQEVzuuEUiwldwOGsHK/sfAfELGWEyTcf0x+uOvCvK3O2zPmZePXWL85vtia6GyZw==} + engines: {node: '>=16'} + hasBin: true + lilconfig@3.1.3: resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} engines: {node: '>=14'} @@ -3775,6 +3808,12 @@ packages: xmlchars@2.2.0: resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + y-protocols@1.0.7: + resolution: {integrity: sha512-YSVsLoXxO67J6eE/nV4AtFtT3QEotZf5sK5BHxFBXso7VDUT3Tx07IfA6hsu5Q5OmBdMkQVmFZ9QOA7fikWvnw==} + engines: {node: '>=16.0.0', npm: '>=8.0.0'} + peerDependencies: + yjs: ^13.0.0 + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -3793,6 +3832,10 @@ packages: yauzl@2.10.0: resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==} + yjs@13.6.32: + resolution: {integrity: sha512-lfiJIIC4Xayt5ItynE407ehlE03pCjeOc4hkR4yxxvvNJ4kuiN25B0g+Qp8XagYz361LLL7DCzR5bvFJ81QKtQ==} + engines: {node: '>=16.0.0', npm: '>=8.0.0'} + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -6067,6 +6110,8 @@ snapshots: isexe@3.1.5: {} + isomorphic.js@0.2.5: {} + jackspeak@3.4.3: dependencies: '@isaacs/cliui': 8.0.2 @@ -6175,6 +6220,10 @@ snapshots: dependencies: readable-stream: 2.3.8 + lib0@0.2.117: + dependencies: + isomorphic.js: 0.2.5 + lilconfig@3.1.3: {} lines-and-columns@1.2.4: {} @@ -7610,6 +7659,11 @@ snapshots: xmlchars@2.2.0: {} + y-protocols@1.0.7(yjs@13.6.32): + dependencies: + lib0: 0.2.117 + yjs: 13.6.32 + y18n@5.0.8: {} yallist@4.0.0: {} @@ -7631,6 +7685,10 @@ snapshots: buffer-crc32: 0.2.13 fd-slicer: 1.1.0 + yjs@13.6.32: + dependencies: + lib0: 0.2.117 + yocto-queue@0.1.0: {} zip-stream@4.1.1: diff --git a/tsconfig.base.json b/tsconfig.base.json index 418ff47f..17dfa3e7 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -6,6 +6,7 @@ "moduleResolution": "Bundler", "jsx": "react-jsx", "paths": { + "@floatboat/nexus-plugin-collab": ["packages/plugin-collab/src/index.ts"], "@floatboat/nexus-core": ["packages/core/src/index.ts"], "@floatboat/nexus-plugin-api": ["packages/plugin-api/src/index.ts"], "@floatboat/nexus-plugin-runtime": ["packages/plugin-runtime/src/index.ts"], diff --git a/vitest.config.ts b/vitest.config.ts index d7e39059..e15086bf 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -4,6 +4,7 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ resolve: { alias: { + "@floatboat/nexus-plugin-collab": path.resolve(__dirname, "packages/plugin-collab/src/index.ts"), "@floatboat/nexus-core": path.resolve(__dirname, "packages/core/src/index.ts"), "@floatboat/nexus-plugin-api": path.resolve(__dirname, "packages/plugin-api/src/index.ts"), "@floatboat/nexus-plugin-runtime": path.resolve(__dirname, "packages/plugin-runtime/src/index.ts"),