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
5 changes: 5 additions & 0 deletions .changeset/slate-yjs-collaboration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"slate-yjs": minor
---

Add Yjs collaboration bindings for Slate editors.
30 changes: 30 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions config/typescript/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@
"slate-dom/internal": ["packages/slate-dom/src/internal/index.ts"],
"slate-history": ["packages/slate-history/src/index.ts"],
"slate-hyperscript": ["packages/slate-hyperscript/src/index.ts"],
"slate-yjs": ["packages/slate-yjs/src/index.ts"],
"slate-yjs/core": ["packages/slate-yjs/src/core/index.ts"],
"slate-yjs/internal": ["packages/slate-yjs/src/internal/index.ts"],
"slate-yjs/react": ["packages/slate-yjs/src/react/index.tsx"],
"slate-react": ["packages/slate-react/src/index.ts"]
},
"resolveJsonModule": true,
Expand Down
1 change: 1 addition & 0 deletions docs/Summary.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@
- [withHistory](libraries/slate-history/with-history.md)
- [HistoryEditor](libraries/slate-history/history-editor.md)
- [History](libraries/slate-history/history.md)
- [Slate Yjs](libraries/slate-yjs/README.md)
- [Slate Hyperscript](libraries/slate-hyperscript.md)

## General
Expand Down
2 changes: 1 addition & 1 deletion docs/general/resources.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ A few resources that are helpful for building with Slate.
These Slate utilities are helpful when developing editor keyboard behavior:

- `Hotkeys` from `slate-dom` provides semantic editor checks like `Hotkeys.isBold(event)`.
- `slate-yjs` provides Yjs document binding, awareness projection, relative-position helpers, and React cursor helpers for Slate editors.

## Extensions and Plugins

Expand All @@ -17,7 +18,6 @@ These extensions and plugins add additional features and capabilities to Slate:
- [Plate](https://github.com/udecode/plate) Rich text editor plugin system for Slate & React
- [`slate-angular`](https://github.com/worktile/slate-angular) Angular-based view layer, which is a useful supplement to
Slate for building a rich text editor using Angular.
- [`slate-yjs`](https://github.com/BitPhinix/slate-yjs/) Collaborative editing utilities for Slate leveraging Yjs
- [`slate-collaborative`](https://github.com/cudr/slate-collaborative) Collaborative editing utilities for Slate
leveraging Automerge
- [`slate-vue3`](https://github.com/Guan-Erjia/slate-vue3) Which is a useful supplement to Slate for building a rich text editor using Vue3, integrated all functions in an npm package
Expand Down
117 changes: 117 additions & 0 deletions docs/libraries/slate-yjs/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
# Slate Yjs

`slate-yjs` binds a Slate editor to a Yjs document through Slate's extension
runtime. The package owns the Yjs root, awareness state, relative-position
helpers, remote import metadata, and React cursor projection helpers.

## Basic setup

Create a `Y.Doc`, choose a shared `Y.XmlText` root, and extend the editor with
the controller's extension.

```tsx
import { createEditor } from 'slate'
import { createYjsExtension, createYjsLocalAwareness } from 'slate-yjs'
import * as Y from 'yjs'

const editor = createEditor()
const doc = new Y.Doc()
const sharedRoot = doc.get('content', Y.XmlText)
const awareness = createYjsLocalAwareness(doc.clientID)

const yjs = createYjsExtension({
awareness,
sharedRoot,
})

const unextend = editor.extend(yjs.extension)

yjs.connect()
```

Call `disconnect()` and the `unextend` cleanup when the editor leaves the
collaboration session.

```tsx
yjs.disconnect()
unextend()
```

## Public surface

The root export and `slate-yjs/core` expose the same core helpers:

- `createYjsExtension(options)` creates the controller and Slate extension.
- `createYjsLocalAwareness(clientID)` creates a deterministic awareness object
for tests, examples, and local transports.
- `connectYjsLocalAwareness(a, b)` connects two local awareness objects.
- `slatePointToYRelativePosition(...)` and
`yRelativePositionToSlatePoint(...)` map Slate points to Yjs relative
positions.
- `slateRangeToYRelativeRange(...)` and `yRelativeRangeToSlateRange(...)` map
ranges for cursor and selection transport.
- `readSlateValueFromYjs(...)`, `writeSlateValueToYjs(...)`, and
`applyYjsEventsToEditor(...)` provide the codec and remote import path.

The `slate-yjs/react` export contains React helpers:

- `useYjsControllerState(controller)`
- `useRemoteCursorStates(controller)`
- `useRemoteCursorDecorations(controller)`
- `RemoteCursorOverlay`

## Controller state

The controller exposes a small state object for UI and diagnostics.

```tsx
const state = yjs.getState()

state.connection // 'connected' | 'disconnected' | 'paused'
state.exports
state.imports
state.revision
```

Local document commits are exported when the controller is connected. Remote
imports enter Slate through `editor.update(...)` with collaboration metadata,
history skip policy, and selection side-effect suppression.

Selection-only commits are written to awareness instead of the Yjs document.
Remote cursor data is projected from awareness into Slate ranges with Yjs
relative positions.

## React cursor projection

Use `useRemoteCursorDecorations` with `Editable` when remote cursor ranges
should render inside text.

```tsx
import { Editable, Slate } from 'slate-react'
import { useRemoteCursorDecorations } from 'slate-yjs/react'

const EditorView = ({ controller, editor }) => {
const decorate = useRemoteCursorDecorations(controller)

return (
<Slate editor={editor}>
<Editable decorate={decorate} />
</Slate>
)
}
```

Use `RemoteCursorOverlay` for a compact peer list or demo overlay.

```tsx
<RemoteCursorOverlay controller={controller} />
```

## Example

The `Yjs Collaboration` example runs two local Slate editors against two Yjs
documents with an in-memory transport. It covers document edits, awareness
cursor projection, pause/resume recovery, undo/redo, Unicode text, and reset
controls.

Open `/examples/yjs-collaboration` in the examples site.
36 changes: 36 additions & 0 deletions docs/plans/2026-05-14-yjs-select-all-delete.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Yjs Select-All Delete Regression

## Goal

Reproduce and fix the collaboration example bug where keyboard select-all followed by Delete does not delete the full document.

## Evidence

- Focused Playwright repro uses `/examples/yjs-collaboration`.
- `Meta+A` is intercepted by Slate, but browser selection stays collapsed/empty.
- `Delete` then imports DOM selection and deletes only the first character, changing `Alpha shared document` to `lpha shared document`.
- Focused test is currently blocked by `next/font/google` fetching Roboto during the Next build.

## Plan

1. Remove the font build blocker from the example site.
2. Preserve model-owned select-all through the following destructive keydown.
3. Run the focused Playwright regression.
4. Run the relevant package/example checks.

## Progress

- Repro test added in `playwright/integration/examples/yjs-collaboration.test.ts`.
- Initial select-all preference patch added in `packages/slate-react/src/editable/keyboard-input-strategy.ts`.
- Removed `next/font/google` from the example app so Playwright builds do not depend on fetching Google font artifacts.
- Keydown kernel now preserves an expanded preferred model selection for Delete instead of force-importing a collapsed DOM selection.
- Full-block delete now preserves Slate's non-empty root invariant by inserting an empty paragraph when the delete removes the whole document.
- Verification passed:
- `bunx playwright test playwright/integration/examples/yjs-collaboration.test.ts --project=chromium --grep "keyboard select-all"`
- `bunx playwright test playwright/integration/examples/yjs-collaboration.test.ts --project=chromium`
- `bun lint:fix`
- `bun --filter slate-react typecheck`
- `bun typecheck:site`
- `bun --filter slate-react test`
- `bun lint`
- `bun check`
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
---
title: Model Select-All Delete DOM Selection
date: 2026-05-14
category: docs/solutions/logic-errors
module: slate-react editable runtime
problem_type: logic_error
component: tooling
symptoms:
- Keyboard select-all followed by Delete removed only the first character.
- Preserving the model selection exposed a crash after deleting the only root block.
root_cause: logic_error
resolution_type: code_fix
severity: high
tags: [slate-react, selection, keyboard, delete, playwright]
---

# Model Select-All Delete DOM Selection

## Problem

Keyboard select-all in a model-owned Slate editor can create a valid expanded model selection without creating a matching browser DOM range. The following Delete keydown must preserve that model selection instead of importing the collapsed DOM selection.

## Symptoms

- `Meta+A` followed by `Delete` in the Yjs collaboration example changed `Alpha shared document` to `lpha shared document`.
- After preserving the expanded model selection, full-block delete removed the only top-level block and React rendered the error boundary with `Cannot get the start point in the node at path [] because it has no start text node.`

## What Didn't Work

- Setting model-selection preference during select-all was not enough by itself. The next destructive keydown still forced a DOM selection import and overwrote the expanded model selection.

## Solution

Preserve an expanded preferred model selection during Delete keydown preparation:

```ts
const shouldPreservePreferredModelSelection =
intent === 'delete' &&
inputController.preferModelSelectionForInputRef.current &&
selectionBefore !== null &&
RangeApi.isExpanded(selectionBefore)

const shouldForceDOMImport =
!shouldPreservePreferredModelSelection &&
(intent === 'delete' ||
intent === 'format' ||
intent === 'insert-break' ||
intent === 'model-selection-move')
```

When full-block delete removes the whole document, insert an empty paragraph and collapse selection into it:

```ts
if (removesWholeDocument) {
const selectionPoint = { path: [0, 0], offset: 0 }

tx.nodes.insert(createDefaultParagraph(), { at: [0] })
tx.selection.set({ anchor: selectionPoint, focus: selectionPoint })
}
```

## Why This Works

The model selection is the source of truth after Slate handles keyboard select-all. Delete should use that range, because the DOM selection can still be collapsed or empty. Once the expanded range deletes the selected root block, Slate still needs a valid text node at the root so rendering and later selection reads have a legal start point.

## Prevention

- Browser regression tests for keyboard selection should assert the edited document text, not only absence of page errors.
- Full-document delete tests should assert the editor remains renderable and synchronized after the visible text becomes empty.

## Related Issues

- `playwright/integration/examples/yjs-collaboration.test.ts`
35 changes: 31 additions & 4 deletions docs/walkthroughs/07-enabling-collaborative-editing.md
Original file line number Diff line number Diff line change
Expand Up @@ -209,12 +209,39 @@ editor.extend(
That shape gives product frameworks a migration backbone without turning raw
Slate into a framework adapter.

## Use `slate-yjs` for Yjs

The `slate-yjs` package is the Yjs adapter for this substrate. It creates a
Slate extension controller around a shared `Y.XmlText` root, exports local
commits into Yjs, imports remote Yjs events through `editor.update(...)`, and
keeps selection-only traffic in awareness.

```tsx
import { createEditor } from 'slate'
import { createYjsExtension, createYjsLocalAwareness } from 'slate-yjs'
import * as Y from 'yjs'

const editor = createEditor()
const doc = new Y.Doc()
const sharedRoot = doc.get('content', Y.XmlText)
const yjs = createYjsExtension({
awareness: createYjsLocalAwareness(doc.clientID),
sharedRoot,
})

const unextend = editor.extend(yjs.extension)
yjs.connect()
```

React helpers in `slate-yjs/react` expose controller state, remote cursor
states, cursor decorations, and a small cursor overlay component. The examples
site includes a local two-editor Yjs collaboration example at
`/examples/yjs-collaboration`.

## What this page does not cover

This page does not provide a full multiplayer recipe. It does not configure a
provider, draw remote cursors, or define a CRDT merge policy. Those belong in
adapter packages that can prove their behavior against Slate's operation and
browser contracts.
This page does not choose a hosted provider, persistence model, authorization
policy, or product collaboration UI. Those belong to the app or provider layer.

You now have the contract those adapters build on: commits for observation,
operations for replay, tags for routing, and local runtime ids for projection.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@
"slate-dom": "workspace:*",
"slate-history": "workspace:*",
"slate-hyperscript": "workspace:*",
"slate-yjs": "workspace:*",
"slate-react": "workspace:*",
"tsdown": "^0.16.6",
"turbo": "2.9.5",
Expand Down
Loading
Loading