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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# Dependencies
node_modules/
.pnpm-store/

# Build outputs
dist/
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ A real Electron app with file IO, live preview, and every plugin enabled — the
## 📦 Packages

<details>
<summary><b>Full package list (11 packages)</b> — click to expand</summary>
<summary><b>Package list</b> — click to expand</summary>

| Package | Description |
|---|---|
Expand All @@ -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 |
Expand Down
3 changes: 2 additions & 1 deletion README.zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ pnpm dev:electron-demo
## 📦 包列表

<details>
<summary><b>完整包列表(11 个包)</b> —— 点击展开</summary>
<summary><b>包列表</b> —— 点击展开</summary>

| 包名 | 说明 |
|---|---|
Expand All @@ -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` | 工具栏基础组件与格式化命令 |
Expand Down
2 changes: 1 addition & 1 deletion docs/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down
2 changes: 1 addition & 1 deletion docs/ROADMAP.zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 完成 |

Expand Down
50 changes: 50 additions & 0 deletions openspec/changes/add-crdt-collaboration/design.md
Original file line number Diff line number Diff line change
@@ -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.
20 changes: 20 additions & 0 deletions openspec/changes/add-crdt-collaboration/pr.md
Original file line number Diff line number Diff line change
@@ -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.
32 changes: 32 additions & 0 deletions openspec/changes/add-crdt-collaboration/proposal.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
28 changes: 28 additions & 0 deletions openspec/changes/add-crdt-collaboration/tasks.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
20 changes: 20 additions & 0 deletions packages/core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
16 changes: 16 additions & 0 deletions packages/core/src/editor-history.ts
Original file line number Diff line number Diff line change
@@ -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<EditorHistory, EditorHistory | null>({
combine(values) {
if (values.length > 1) throw new Error("Only one editor history backend may be installed");
return values[0] ?? null;
},
});
5 changes: 3 additions & 2 deletions packages/core/src/editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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) {
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export { createEditor } from "./editor";
export { editorHistory, type EditorHistory } from "./editor-history";
export {
DynamicEditorContributionSink,
EDITOR_PLUGIN_PRIORITY_MAX,
Expand Down
Loading