diff --git a/.changeset/slate-yjs-collaboration.md b/.changeset/slate-yjs-collaboration.md new file mode 100644 index 0000000000..6e2649fc78 --- /dev/null +++ b/.changeset/slate-yjs-collaboration.md @@ -0,0 +1,5 @@ +--- +"slate-yjs": minor +--- + +Add Yjs collaboration bindings for Slate editors. diff --git a/bun.lock b/bun.lock index 9972ae17a8..3dee8850f3 100644 --- a/bun.lock +++ b/bun.lock @@ -40,6 +40,7 @@ "slate-history": "workspace:*", "slate-hyperscript": "workspace:*", "slate-react": "workspace:*", + "slate-yjs": "workspace:*", "tsdown": "^0.16.6", "turbo": "2.9.5", "typescript": "6.0.3", @@ -137,6 +138,27 @@ "slate-dom": ">=0.119.1", }, }, + "packages/slate-yjs": { + "name": "slate-yjs", + "version": "0.124.1", + "dependencies": { + "yjs": "^13.6.30", + }, + "devDependencies": { + "@testing-library/react": "^16.3.2", + "@types/node": "^20.8.7", + "@types/react": "^19.2.14", + "react": "^19.2.5", + "slate": "workspace:*", + }, + "peerDependencies": { + "react": ">=19.0.0", + "slate": ">=0.124.0", + }, + "optionalPeers": [ + "react", + ], + }, }, "packages": { "@adobe/css-tools": ["@adobe/css-tools@4.4.4", "", {}, "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg=="], @@ -929,6 +951,8 @@ "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + "isomorphic.js": ["isomorphic.js@0.2.5", "", {}, "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw=="], + "jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="], "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], @@ -957,6 +981,8 @@ "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "1.2.1", "type-check": "0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], + "lib0": ["lib0@0.2.117", "", { "dependencies": { "isomorphic.js": "^0.2.4" }, "bin": { "0serve": "bin/0serve.js", "0gentesthtml": "bin/gentesthtml.js", "0ecdsa-generate-keypair": "bin/0ecdsa-generate-keypair.js" } }, "sha512-DeXj9X5xDCjgKLU/7RR+/HQEVzuuEUiwldwOGsHK/sfAfELGWEyTcf0x+uOvCvK3O2zPmZePXWL85vtia6GyZw=="], + "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "2.1.2" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], @@ -1179,6 +1205,8 @@ "slate-react": ["slate-react@workspace:packages/slate-react"], + "slate-yjs": ["slate-yjs@workspace:packages/slate-yjs"], + "source-map": ["source-map@0.5.7", "", {}, "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ=="], "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], @@ -1293,6 +1321,8 @@ "yaml": ["yaml@1.10.3", "", {}, "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA=="], + "yjs": ["yjs@13.6.30", "", { "dependencies": { "lib0": "^0.2.99" } }, "sha512-vv/9h42eCMC81ZHDFswuu/MKzkl/vyq1BhaNGfHyOonwlG4CJbQF4oiBBJPvfdeCt/PlVDWh7Nov9D34YY09uQ=="], + "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], diff --git a/config/typescript/tsconfig.json b/config/typescript/tsconfig.json index 0b44f8ea78..fcfb240da5 100644 --- a/config/typescript/tsconfig.json +++ b/config/typescript/tsconfig.json @@ -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, diff --git a/docs/Summary.md b/docs/Summary.md index 5f77919f8c..f398d173ac 100644 --- a/docs/Summary.md +++ b/docs/Summary.md @@ -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 diff --git a/docs/general/resources.md b/docs/general/resources.md index bebee8dac2..504b493cb0 100644 --- a/docs/general/resources.md +++ b/docs/general/resources.md @@ -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 @@ -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 diff --git a/docs/libraries/slate-yjs/README.md b/docs/libraries/slate-yjs/README.md new file mode 100644 index 0000000000..e3e7a79049 --- /dev/null +++ b/docs/libraries/slate-yjs/README.md @@ -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 ( + + + + ) +} +``` + +Use `RemoteCursorOverlay` for a compact peer list or demo overlay. + +```tsx + +``` + +## 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. diff --git a/docs/plans/2026-05-14-yjs-select-all-delete.md b/docs/plans/2026-05-14-yjs-select-all-delete.md new file mode 100644 index 0000000000..779130258b --- /dev/null +++ b/docs/plans/2026-05-14-yjs-select-all-delete.md @@ -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` diff --git a/docs/solutions/logic-errors/model-select-all-delete-dom-selection-2026-05-14.md b/docs/solutions/logic-errors/model-select-all-delete-dom-selection-2026-05-14.md new file mode 100644 index 0000000000..f83f29928b --- /dev/null +++ b/docs/solutions/logic-errors/model-select-all-delete-dom-selection-2026-05-14.md @@ -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` diff --git a/docs/walkthroughs/07-enabling-collaborative-editing.md b/docs/walkthroughs/07-enabling-collaborative-editing.md index d5b95be371..70b9fc3a30 100644 --- a/docs/walkthroughs/07-enabling-collaborative-editing.md +++ b/docs/walkthroughs/07-enabling-collaborative-editing.md @@ -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. diff --git a/package.json b/package.json index 645873b5a3..612577b20e 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/slate-react/src/editable/editing-kernel.ts b/packages/slate-react/src/editable/editing-kernel.ts index 719d277862..f221a289bc 100644 --- a/packages/slate-react/src/editable/editing-kernel.ts +++ b/packages/slate-react/src/editable/editing-kernel.ts @@ -1114,11 +1114,18 @@ export const prepareEditableKeyDownKernel = ({ ? 'model-owned' : 'no-op' + const shouldPreservePreferredModelSelection = + intent === 'delete' && + inputController.preferModelSelectionForInputRef.current && + selectionBefore !== null && + RangeApi.isExpanded(selectionBefore) + const shouldForceDOMImport = - intent === 'delete' || - intent === 'format' || - intent === 'insert-break' || - intent === 'model-selection-move' + !shouldPreservePreferredModelSelection && + (intent === 'delete' || + intent === 'format' || + intent === 'insert-break' || + intent === 'model-selection-move') return { command, diff --git a/packages/slate-react/src/editable/keyboard-input-strategy.ts b/packages/slate-react/src/editable/keyboard-input-strategy.ts index 6e95f300e4..a1b17c9e65 100644 --- a/packages/slate-react/src/editable/keyboard-input-strategy.ts +++ b/packages/slate-react/src/editable/keyboard-input-strategy.ts @@ -209,15 +209,15 @@ export const applyEditableKeyDown = ({ if (isSelectAllHotkey(nativeEvent)) { event.preventDefault() - applyEditableCommand({ command: { kind: 'select-all' }, editor }) const shellRenderingStrategy = isShellRenderingStrategy(renderingStrategy) - if (shellRenderingStrategy) { - setEditableModelSelectionPreference({ - inputController, - preferModelSelection: true, - selectionSource: 'shell-backed', - }) - } + setEditableModelSelectionPreference({ + inputController, + preferModelSelection: true, + selectionSource: shellRenderingStrategy + ? 'shell-backed' + : 'model-owned', + }) + applyEditableCommand({ command: { kind: 'select-all' }, editor }) setExplicitShellBackedSelection(shellRenderingStrategy) forceRender() return keyDownHandled() diff --git a/packages/slate-react/src/editable/mutation-controller.ts b/packages/slate-react/src/editable/mutation-controller.ts index b3e14e9f04..d95ac228a9 100644 --- a/packages/slate-react/src/editable/mutation-controller.ts +++ b/packages/slate-react/src/editable/mutation-controller.ts @@ -455,10 +455,24 @@ const applyFullBlockDeleteFragment = ( return false } + const children = editor.read((state) => state.value.get()) + const removesWholeDocument = + blockPaths.length === children.length && + blockPaths.every( + (blockPath, index) => blockPath.length === 1 && blockPath[0] === index + ) + editor.update((tx) => { for (const blockPath of [...blockPaths].reverse()) { tx.nodes.remove({ at: blockPath }) } + + if (removesWholeDocument) { + const selectionPoint = { path: [0, 0], offset: 0 } + + tx.nodes.insert(createDefaultParagraph(), { at: [0] }) + tx.selection.set({ anchor: selectionPoint, focus: selectionPoint }) + } }) return true diff --git a/packages/slate-react/test/kernel-authority-audit-contract.ts b/packages/slate-react/test/kernel-authority-audit-contract.ts index cf2a50544b..aae1225f43 100644 --- a/packages/slate-react/test/kernel-authority-audit-contract.ts +++ b/packages/slate-react/test/kernel-authority-audit-contract.ts @@ -891,11 +891,11 @@ test('direct force render calls have explicit runtime owners', () => { 'Browser proof handles may force the view after explicit semantic test actions and remote operation replay until proof transport is split from runtime repair.', }, 'packages/slate-react/src/editable/keyboard-input-strategy.ts': { - count: 5, + count: 1, next: 'worker', owner: 'Keyboard input worker', rationale: - 'Keyboard worker still directly forces render for caret movement fallbacks and model-owned history before repair/view runtime owns those requests.', + 'Keyboard worker directly forces render only for select-all shell selection handoff; model-owned history and command repair use repair requests.', }, 'packages/slate-react/src/editable/mutation-controller.ts': { count: 1, diff --git a/packages/slate-yjs/package.json b/packages/slate-yjs/package.json new file mode 100644 index 0000000000..54cba617c1 --- /dev/null +++ b/packages/slate-yjs/package.json @@ -0,0 +1,74 @@ +{ + "name": "slate-yjs", + "description": "Yjs collaboration bindings for Slate.", + "version": "0.124.1", + "license": "MIT", + "repository": "git://github.com/ianstormtaylor/slate.git", + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" + }, + "./core": { + "types": "./dist/core/index.d.ts", + "import": "./dist/core/index.js", + "default": "./dist/core/index.js" + }, + "./react": { + "types": "./dist/react/index.d.ts", + "import": "./dist/react/index.js", + "default": "./dist/react/index.js" + }, + "./internal": { + "types": "./dist/internal/index.d.ts", + "import": "./dist/internal/index.js", + "default": "./dist/internal/index.js" + } + }, + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "sideEffects": false, + "files": [ + "dist/" + ], + "scripts": { + "build": "tsdown --config ./tsdown.config.mts --log-level warn", + "clean": "rimraf dist lib", + "test": "bun test --preload ../../config/bun-test-setup.ts ./test", + "typecheck": "tsc --project ./tsconfig.test.json --noEmit" + }, + "dependencies": { + "yjs": "^13.6.30" + }, + "peerDependencies": { + "react": ">=19.0.0", + "slate": ">=0.124.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + } + }, + "devDependencies": { + "@testing-library/react": "^16.3.2", + "@types/node": "^20.8.7", + "@types/react": "^19.2.14", + "react": "^19.2.5", + "slate": "workspace:*" + }, + "subpaths": [ + "core", + "react", + "internal" + ], + "keywords": [ + "collaboration", + "crdt", + "editor", + "slate", + "yjs" + ] +} diff --git a/packages/slate-yjs/src/core/awareness.ts b/packages/slate-yjs/src/core/awareness.ts new file mode 100644 index 0000000000..eff41d31ce --- /dev/null +++ b/packages/slate-yjs/src/core/awareness.ts @@ -0,0 +1,126 @@ +import type { + YjsAwarenessChange, + YjsAwarenessState, + YjsLocalAwareness, +} from './types' + +class LocalAwareness implements YjsLocalAwareness { + clientID: number + + private readonly listeners = new Set<(event: YjsAwarenessChange) => void>() + private localState: YjsAwarenessState | null = null + private readonly states = new Map() + + constructor(clientID: number) { + this.clientID = clientID + } + + applyRemoteState(clientId: number, state: YjsAwarenessState | null) { + const hadState = this.states.has(clientId) + const currentState = this.states.get(clientId) ?? null + + if (JSON.stringify(currentState) === JSON.stringify(state)) { + return + } + + if (state) { + this.states.set(clientId, { ...state }) + this.emit({ + added: hadState ? [] : [clientId], + removed: [], + updated: hadState ? [clientId] : [], + }) + } else if (hadState) { + this.states.delete(clientId) + this.emit({ added: [], removed: [clientId], updated: [] }) + } + } + + getLocalState() { + return this.localState ? { ...this.localState } : null + } + + getStates() { + return new Map(this.states) + } + + off(event: 'change', listener: (event: YjsAwarenessChange) => void) { + if (event === 'change') { + this.listeners.delete(listener) + } + } + + on(event: 'change', listener: (event: YjsAwarenessChange) => void) { + if (event === 'change') { + this.listeners.add(listener) + } + } + + setLocalState(state: YjsAwarenessState | null) { + const hadState = this.localState !== null + this.localState = state ? { ...state } : null + + if (this.localState) { + this.states.set(this.clientID, this.localState) + this.emit({ + added: hadState ? [] : [this.clientID], + removed: [], + updated: hadState ? [this.clientID] : [], + }) + } else { + this.states.delete(this.clientID) + this.emit({ + added: [], + removed: hadState ? [this.clientID] : [], + updated: [], + }) + } + } + + setLocalStateField(field: string, value: unknown) { + this.setLocalState({ + ...(this.localState ?? {}), + [field]: value, + }) + } + + private emit(event: YjsAwarenessChange) { + for (const listener of this.listeners) { + listener(event) + } + } +} + +/** + * Create a deterministic awareness object for tests, examples, and local-only + * transports. Real providers can pass their own awareness implementation. + */ +export const createYjsLocalAwareness = (clientID: number): YjsLocalAwareness => + new LocalAwareness(clientID) + +/** + * Connect two local awareness objects without a network provider. + */ +export const connectYjsLocalAwareness = ( + awarenessA: YjsLocalAwareness, + awarenessB: YjsLocalAwareness +) => { + const syncA = () => { + awarenessB.applyRemoteState(awarenessA.clientID, awarenessA.getLocalState()) + } + const syncB = () => { + awarenessA.applyRemoteState(awarenessB.clientID, awarenessB.getLocalState()) + } + + awarenessA.on('change', syncA) + awarenessB.on('change', syncB) + syncA() + syncB() + + return () => { + awarenessA.off('change', syncA) + awarenessB.off('change', syncB) + awarenessA.applyRemoteState(awarenessB.clientID, null) + awarenessB.applyRemoteState(awarenessA.clientID, null) + } +} diff --git a/packages/slate-yjs/src/core/codec.ts b/packages/slate-yjs/src/core/codec.ts new file mode 100644 index 0000000000..2b2bf9f4fc --- /dev/null +++ b/packages/slate-yjs/src/core/codec.ts @@ -0,0 +1,454 @@ +import { + type Descendant, + type Operation, + OperationApi, + type Path, + PathApi, + type Point, + type Range, + type Editor as SlateEditor, + TextApi, + type Value, +} from 'slate' +import { Editor } from 'slate/internal' +import * as Y from 'yjs' + +import type { + EncodedYRelativePosition, + YjsApplyEventsInput, + YjsEncodeCommitInput, + YjsPointMappingInput, + YjsPointOptions, + YjsRelativeRange, +} from './types' + +export const SLATE_YJS_VALUE_ATTRIBUTE = 'slate:value' +export const SLATE_YJS_OPERATIONS_ATTRIBUTE = 'slate:operations' +export const SLATE_YJS_VERSION_ATTRIBUTE = 'slate:version' + +export const remoteYjsUpdateOptions = { + metadata: { + collab: { origin: 'remote', saveToHistory: false }, + history: { mode: 'skip' }, + selection: { dom: 'preserve', focus: false, scroll: false }, + }, + tag: ['collaboration', 'remote-import'], +} as const + +type TextSpan = { + end: number + path: Path + start: number + text: string +} + +const emptyParagraph = (): Descendant => ({ + type: 'paragraph', + children: [{ text: '' }], +}) + +export const clone = (value: T): T => JSON.parse(JSON.stringify(value)) as T + +const normalizeSlateValue = (value: unknown): Value => { + if (!Array.isArray(value) || value.length === 0) { + return [emptyParagraph()] as Value + } + + return clone(value) as Value +} + +const valueFromInput = (input: YjsPointMappingInput): Value => + Array.isArray(input) ? input : Editor.getSnapshot(input).children + +const collectTextSpans = (value: Value): TextSpan[] => { + const spans: TextSpan[] = [] + let offset = 0 + + const visit = (node: Descendant, path: Path) => { + if (TextApi.isText(node)) { + const text = node.text + const start = offset + offset += text.length + spans.push({ end: offset, path, start, text }) + + return + } + + node.children.forEach((child, index) => { + visit(child as Descendant, [...path, index]) + }) + } + + value.forEach((node, index) => { + visit(node as Descendant, [index]) + }) + + if (spans.length === 0) { + spans.push({ end: 0, path: [0, 0], start: 0, text: '' }) + } + + return spans +} + +export const slateValueToYText = (value: Value): string => + collectTextSpans(value) + .map((span) => span.text) + .join('') + +const getTextAtPath = (value: Value, path: Path): { text: string } | null => { + let node: unknown = { children: value } + + for (const index of path) { + if ( + node && + typeof node === 'object' && + 'children' in node && + Array.isArray((node as { children: unknown[] }).children) + ) { + node = (node as { children: unknown[] }).children[index] + } else { + return null + } + } + + return TextApi.isText(node) ? node : null +} + +const setTextAtPath = (value: Value, path: Path, text: string) => { + const node = getTextAtPath(value, path) + + if (node) { + node.text = text + } +} + +const valueWithLinearText = (value: Value, text: string): Value => { + const nextValue = clone(value) + const spans = collectTextSpans(nextValue) + let offset = 0 + + spans.forEach((span, index) => { + const length = + index === spans.length - 1 ? text.length - offset : span.text.length + const nextText = text.slice(offset, offset + Math.max(0, length)) + + setTextAtPath(nextValue, span.path, nextText) + offset += Math.max(0, length) + }) + + return nextValue +} + +export const isSlateRangeInValue = ( + value: Value, + range: Range | null +): range is Range => { + if (!range) { + return false + } + + for (const point of [range.anchor, range.focus]) { + const text = getTextAtPath(value, point.path) + + if (!text || point.offset < 0 || point.offset > text.text.length) { + return false + } + } + + return true +} + +export const slatePointToTextOffset = (value: Value, point: Point): number => { + const spans = collectTextSpans(value) + const span = spans.find((candidate) => + PathApi.equals(candidate.path, point.path) + ) + + if (!span) { + throw new Error(`Cannot map Slate point at ${point.path.join('.')} to Yjs`) + } + + if (point.offset < 0 || point.offset > span.text.length) { + throw new Error(`Cannot map Slate point with offset ${point.offset} to Yjs`) + } + + return span.start + point.offset +} + +export const textOffsetToSlatePoint = ( + value: Value, + offset: number, + assoc = 0 +): Point | null => { + const spans = collectTextSpans(value) + + if (spans.length === 0) { + return null + } + + const clampedOffset = Math.max(0, Math.min(offset, spans.at(-1)?.end ?? 0)) + + if (assoc < 0) { + for (let index = spans.length - 1; index >= 0; index--) { + const span = spans[index]! + + if (clampedOffset >= span.start && clampedOffset <= span.end) { + return { + path: [...span.path], + offset: Math.min(span.text.length, clampedOffset - span.start), + } + } + } + } + + for (const span of spans) { + if (clampedOffset >= span.start && clampedOffset <= span.end) { + return { + path: [...span.path], + offset: Math.max(0, clampedOffset - span.start), + } + } + } + + const last = spans.at(-1) + + return last ? { path: [...last.path], offset: last.text.length } : null +} + +export const encodeYRelativePosition = ( + position: Y.RelativePosition +): EncodedYRelativePosition => Array.from(Y.encodeRelativePosition(position)) + +export const decodeYRelativePosition = ( + position: EncodedYRelativePosition +): Y.RelativePosition => Y.decodeRelativePosition(Uint8Array.from(position)) + +export const slatePointToYRelativePosition = ( + sharedRoot: Y.XmlText, + input: YjsPointMappingInput, + point: Point, + options: YjsPointOptions = {} +): Y.RelativePosition => { + const offset = slatePointToTextOffset(valueFromInput(input), point) + + return Y.createRelativePositionFromTypeIndex( + sharedRoot, + Math.min(offset, sharedRoot.length), + options.assoc ?? 0 + ) +} + +export const yRelativePositionToSlatePoint = ( + sharedRoot: Y.XmlText, + input: YjsPointMappingInput, + position: Y.RelativePosition +): Point | null => { + const doc = sharedRoot.doc + + if (!doc) { + throw new Error( + 'Cannot resolve a Yjs relative position before sharedRoot is attached to a Y.Doc' + ) + } + + const absolute = Y.createAbsolutePositionFromRelativePosition(position, doc) + + if (!absolute || absolute.type !== sharedRoot) { + return null + } + + return textOffsetToSlatePoint( + valueFromInput(input), + absolute.index, + absolute.assoc + ) +} + +export const slateRangeToYRelativeRange = ( + sharedRoot: Y.XmlText, + input: YjsPointMappingInput, + range: Range +): YjsRelativeRange => ({ + anchor: slatePointToYRelativePosition(sharedRoot, input, range.anchor, { + assoc: 0, + }), + focus: slatePointToYRelativePosition(sharedRoot, input, range.focus, { + assoc: -1, + }), +}) + +export const yRelativeRangeToSlateRange = ( + sharedRoot: Y.XmlText, + input: YjsPointMappingInput, + range: YjsRelativeRange +): Range | null => { + const anchor = yRelativePositionToSlatePoint(sharedRoot, input, range.anchor) + const focus = yRelativePositionToSlatePoint(sharedRoot, input, range.focus) + + return anchor && focus ? { anchor, focus } : null +} + +const replaceYTextContent = (sharedRoot: Y.XmlText, next: string) => { + const current = sharedRoot.toString() + + if (current === next) { + return + } + + let prefix = 0 + while ( + prefix < current.length && + prefix < next.length && + current[prefix] === next[prefix] + ) { + prefix++ + } + + let suffix = 0 + while ( + suffix < current.length - prefix && + suffix < next.length - prefix && + current.at(-1 - suffix) === next.at(-1 - suffix) + ) { + suffix++ + } + + const deleteLength = current.length - prefix - suffix + const insertText = next.slice(prefix, next.length - suffix) + + if (deleteLength > 0) { + sharedRoot.delete(prefix, deleteLength) + } + if (insertText.length > 0) { + sharedRoot.insert(prefix, insertText) + } +} + +export const writeSlateValueToYjs = (sharedRoot: Y.XmlText, value: Value) => { + const normalized = normalizeSlateValue(value) + replaceYTextContent(sharedRoot, slateValueToYText(normalized)) + sharedRoot.setAttribute(SLATE_YJS_VALUE_ATTRIBUTE, normalized) + sharedRoot.setAttribute( + SLATE_YJS_VERSION_ATTRIBUTE, + Number(sharedRoot.getAttribute(SLATE_YJS_VERSION_ATTRIBUTE) ?? 0) + 1 + ) +} + +export const readSlateValueFromYjs = (sharedRoot: Y.XmlText): Value | null => { + const stored = sharedRoot.getAttribute(SLATE_YJS_VALUE_ATTRIBUTE) + + if (stored) { + const normalized = normalizeSlateValue(stored) + const text = sharedRoot.toString() + + return slateValueToYText(normalized) === text + ? normalized + : valueWithLinearText(normalized, text) + } + + if (sharedRoot.length === 0) { + return null + } + + return [ + { + type: 'paragraph', + children: [{ text: sharedRoot.toString() }], + }, + ] as Value +} + +export const readSlateOperationsFromYjs = ( + sharedRoot: Y.XmlText +): Operation[] => { + const operations = sharedRoot.getAttribute(SLATE_YJS_OPERATIONS_ATTRIBUTE) + + return OperationApi.isOperationList(operations) + ? (clone(operations) as Operation[]) + : [] +} + +export const encodeSlateCommitToYjs = ({ + editor, + operations, + origin, + sharedRoot, +}: YjsEncodeCommitInput) => { + const doc = sharedRoot.doc + const value = Editor.getSnapshot(editor).children + + if (!doc) { + throw new Error( + 'Cannot encode Slate commits before sharedRoot is attached to a Y.Doc' + ) + } + + doc.transact(() => { + writeSlateValueToYjs(sharedRoot, value) + sharedRoot.setAttribute(SLATE_YJS_OPERATIONS_ATTRIBUTE, clone(operations)) + }, origin) +} + +export const reconcileYjsSnapshot = ( + editor: SlateEditor, + sharedRoot: Y.XmlText +) => { + const value = readSlateValueFromYjs(sharedRoot) + + if (!value) { + return false + } + + const snapshot = Editor.getSnapshot(editor) + const selection = isSlateRangeInValue(value, snapshot.selection) + ? snapshot.selection + : null + + editor.update((tx) => { + tx.value.replace({ + children: value, + marks: snapshot.marks, + selection, + }) + }, remoteYjsUpdateOptions) + + return true +} + +export const applyYjsEventsToEditor = ({ + editor, + events = [], + sharedRoot, +}: YjsApplyEventsInput) => { + for (const event of events) { + void event.delta + } + + const operations = readSlateOperationsFromYjs(sharedRoot) + + if (operations.length > 0) { + try { + editor.update((tx) => { + tx.operations.replay(operations) + }, remoteYjsUpdateOptions) + + const remoteValue = readSlateValueFromYjs(sharedRoot) + + if ( + remoteValue && + JSON.stringify(Editor.getSnapshot(editor).children) !== + JSON.stringify(remoteValue) + ) { + return reconcileYjsSnapshot(editor, sharedRoot) + } + + return true + } catch { + return reconcileYjsSnapshot(editor, sharedRoot) + } + } + + return reconcileYjsSnapshot(editor, sharedRoot) +} + +export const applyYjsSnapshotToEditor = reconcileYjsSnapshot diff --git a/packages/slate-yjs/src/core/controller.ts b/packages/slate-yjs/src/core/controller.ts new file mode 100644 index 0000000000..4b5d265641 --- /dev/null +++ b/packages/slate-yjs/src/core/controller.ts @@ -0,0 +1,482 @@ +import { + type Descendant, + defineEditorExtension, + type EditorCommit, + type EditorExtension, + PathApi, + type Range, +} from 'slate' +import { Editor } from 'slate/internal' +import * as Y from 'yjs' + +import { + applyYjsEventsToEditor, + clone, + decodeYRelativePosition, + encodeSlateCommitToYjs, + encodeYRelativePosition, + isSlateRangeInValue, + readSlateValueFromYjs, + remoteYjsUpdateOptions, + slateRangeToYRelativeRange, + writeSlateValueToYjs, + yRelativeRangeToSlateRange, +} from './codec' +import type { + EncodedYRelativeRange, + YjsAwareness, + YjsAwarenessChange, + YjsController, + YjsControllerState, + YjsExtensionOptions, + YjsRemoteCursorDecorationData, + YjsRemoteCursorState, +} from './types' + +const DEFAULT_ORIGIN = Symbol('slate-yjs-local-origin') + +const isRemoteOrSkippedCommit = (commit: EditorCommit) => + commit.tags.includes('skip-collab') || + commit.tags.includes('collaboration') || + commit.metadata.collab?.origin === 'remote' + +const isSelectionOnlyCommit = (commit: EditorCommit) => + commit.operations.length > 0 && + commit.operations.every((operation) => operation.type === 'set_selection') + +const hasSameValue = (a: unknown, b: unknown) => + JSON.stringify(a) === JSON.stringify(b) + +class SlateYjsController implements YjsController { + awareness: YjsAwareness | null + editor: YjsController['editor'] = null + extension: EditorExtension + origin: unknown + sharedRoot: Y.XmlText + undoManager: Y.UndoManager + + private applyingRemote = false + private readonly awarenessField: string + private awarenessListener: ((event: YjsAwarenessChange) => void) | null = null + private connection: YjsControllerState['connection'] = 'disconnected' + private readonly cursorDataField: string + private exports = 0 + private imports = 0 + private readonly listeners = new Set<() => void>() + private observeYjsEvents: + | ((events: Y.YEvent[], transaction: Y.Transaction) => void) + | null = null + private revision = 0 + private runtimeState: { + set: ( + value: + | YjsControllerState + | ((previous: YjsControllerState) => YjsControllerState) + ) => void + } | null = null + private stateSnapshot: YjsControllerState = { + connection: 'disconnected', + exports: 0, + imports: 0, + revision: 0, + } + + constructor(options: YjsExtensionOptions) { + this.sharedRoot = options.sharedRoot + this.awareness = options.awareness ?? null + this.origin = options.origin ?? DEFAULT_ORIGIN + this.undoManager = + options.undoManager ?? + new Y.UndoManager(this.sharedRoot, { + trackedOrigins: new Set([this.origin]), + }) + this.awarenessField = options.awarenessField ?? 'selection' + this.cursorDataField = options.cursorDataField ?? 'user' + + this.extension = defineEditorExtension({ + name: 'slate-yjs', + register: (context) => { + this.editor = context.editor + this.runtimeState = context.runtimeState( + this.getState() + ) + + return { + cleanup: () => { + this.disconnect() + this.editor = null + this.runtimeState = null + }, + commitListeners: [ + (commit) => { + this.handleCommit(commit) + }, + ], + } + }, + }) + } + + connect() { + const editor = this.requireEditor() + + if (this.connection === 'connected') { + return + } + + this.assertAttached() + this.observeYjsEvents = (events, transaction) => { + if (transaction.origin === this.origin) { + return + } + + this.importRemoteEvents(events) + } + this.sharedRoot.observeDeep(this.observeYjsEvents) + + if (this.awareness) { + this.awarenessListener = () => { + this.notify() + } + this.awareness.on('change', this.awarenessListener) + } + + const remoteValue = readSlateValueFromYjs(this.sharedRoot) + if (remoteValue) { + this.importRemoteSnapshot() + } else { + this.sharedRoot.doc!.transact(() => { + writeSlateValueToYjs( + this.sharedRoot, + Editor.getSnapshot(editor).children + ) + }, this.origin) + } + + this.setConnection('connected') + this.exportSelection() + } + + disconnect() { + if (this.connection === 'disconnected') { + return + } + + if (this.observeYjsEvents) { + this.sharedRoot.unobserveDeep(this.observeYjsEvents) + this.observeYjsEvents = null + } + if (this.awareness && this.awarenessListener) { + this.awareness.off('change', this.awarenessListener) + this.awarenessListener = null + } + + this.awareness?.setLocalStateField(this.awarenessField, null) + this.setConnection('disconnected') + } + + exportSelection( + range: Range | null = this.editor + ? Editor.getSnapshot(this.editor).selection + : null + ) { + if (!this.awareness || !this.editor || this.connection !== 'connected') { + return + } + + if (!range) { + this.awareness.setLocalStateField(this.awarenessField, null) + return + } + + const value = Editor.getSnapshot(this.editor).children + if (!isSlateRangeInValue(value, range)) { + this.awareness.setLocalStateField(this.awarenessField, null) + return + } + + const relativeRange = slateRangeToYRelativeRange( + this.sharedRoot, + value, + range + ) + + this.awareness.setLocalStateField(this.awarenessField, { + anchor: encodeYRelativePosition(relativeRange.anchor), + focus: encodeYRelativePosition(relativeRange.focus), + } satisfies EncodedYRelativeRange) + } + + getRemoteCursorDecorations( + entry: [Descendant, number[]] + ): readonly { + data: YjsRemoteCursorDecorationData + key: string + range: Range + }[] { + const [, path] = entry + + return this.getRemoteCursorStates() + .filter( + (cursor): cursor is YjsRemoteCursorState & { range: Range } => + Boolean(cursor.range) && + (PathApi.equals(cursor.range!.anchor.path, path) || + PathApi.equals(cursor.range!.focus.path, path)) + ) + .map((cursor) => ({ + data: { cursor }, + key: `slate-yjs-cursor:${cursor.clientId}`, + range: cursor.range, + })) + } + + getRemoteCursorStates< + TData = unknown, + >(): readonly YjsRemoteCursorState[] { + if (!this.awareness || !this.editor) { + return [] + } + + const states: YjsRemoteCursorState[] = [] + + for (const [clientId, state] of this.awareness.getStates()) { + if (clientId === this.awareness.clientID) { + continue + } + + const encodedRange = state[this.awarenessField] as + | EncodedYRelativeRange + | null + | undefined + const relativeRange = encodedRange + ? { + anchor: decodeYRelativePosition(encodedRange.anchor), + focus: decodeYRelativePosition(encodedRange.focus), + } + : null + const range = relativeRange + ? yRelativeRangeToSlateRange( + this.sharedRoot, + this.editor, + relativeRange + ) + : null + + states.push({ + clientId, + data: (state[this.cursorDataField] as TData | undefined) ?? null, + range, + relativeRange, + user: (state.user as YjsRemoteCursorState['user']) ?? null, + }) + } + + return states + } + + getState(): YjsControllerState { + return this.stateSnapshot + } + + pause() { + if (this.connection === 'connected') { + this.setConnection('paused') + } + } + + redo() { + const editorWithRedo = this.editor as typeof this.editor & { + redo?: () => void + } + + if (editorWithRedo?.redo) { + editorWithRedo.redo() + } else { + this.undoManager.redo() + } + } + + reconcile() { + this.importRemoteSnapshot() + } + + resume() { + if (this.connection === 'paused') { + this.setConnection('connected') + this.importRemoteSnapshot() + this.exportSelection() + } + } + + subscribe(listener: () => void) { + this.listeners.add(listener) + + return () => { + this.listeners.delete(listener) + } + } + + undo() { + const editorWithUndo = this.editor as typeof this.editor & { + undo?: () => void + } + + if (editorWithUndo?.undo) { + editorWithUndo.undo() + } else { + this.undoManager.undo() + } + } + + private assertAttached() { + if (!this.sharedRoot.doc) { + throw new Error('slate-yjs requires sharedRoot to be attached to a Y.Doc') + } + } + + private handleCommit(commit: EditorCommit) { + if ( + !this.editor || + this.applyingRemote || + this.connection !== 'connected' || + isRemoteOrSkippedCommit(commit) + ) { + return + } + + if (isSelectionOnlyCommit(commit)) { + this.exportSelection(commit.selectionAfter) + this.notify() + + return + } + + encodeSlateCommitToYjs({ + editor: this.editor, + operations: commit.operations, + origin: this.origin, + sharedRoot: this.sharedRoot, + }) + this.exports++ + this.exportSelection(commit.selectionAfter) + this.writeRuntimeState() + this.notify() + } + + private importRemoteEvents(events: readonly Y.YEvent[]) { + if (!this.editor || this.connection !== 'connected') { + return + } + + this.applyingRemote = true + try { + if ( + applyYjsEventsToEditor({ + editor: this.editor, + events, + sharedRoot: this.sharedRoot, + }) + ) { + this.imports++ + } + } finally { + this.applyingRemote = false + } + + this.exportSelection() + this.writeRuntimeState() + this.notify() + } + + private importRemoteSnapshot() { + if (!this.editor) { + return + } + + const remoteValue = readSlateValueFromYjs(this.sharedRoot) + const currentValue = Editor.getSnapshot(this.editor).children + + if (!remoteValue || hasSameValue(remoteValue, currentValue)) { + return + } + + this.applyingRemote = true + try { + const snapshot = Editor.getSnapshot(this.editor) + const selection = isSlateRangeInValue(remoteValue, snapshot.selection) + ? snapshot.selection + : null + + this.editor.update((tx) => { + tx.value.replace({ + children: clone(remoteValue), + marks: snapshot.marks, + selection, + }) + }, remoteYjsUpdateOptions) + this.imports++ + } finally { + this.applyingRemote = false + } + + this.writeRuntimeState() + this.notify() + } + + private notify() { + this.revision++ + this.writeRuntimeState() + + for (const listener of this.listeners) { + listener() + } + } + + private requireEditor() { + if (!this.editor) { + throw new Error( + 'Extend an editor with controller.extension before connecting slate-yjs' + ) + } + + return this.editor + } + + private setConnection(connection: YjsControllerState['connection']) { + if (this.connection === connection) { + return + } + + this.connection = connection + this.writeRuntimeState() + this.notify() + } + + private writeRuntimeState() { + const nextState = { + connection: this.connection, + exports: this.exports, + imports: this.imports, + revision: this.revision, + } + + if ( + nextState.connection !== this.stateSnapshot.connection || + nextState.exports !== this.stateSnapshot.exports || + nextState.imports !== this.stateSnapshot.imports || + nextState.revision !== this.stateSnapshot.revision + ) { + this.stateSnapshot = nextState + } + + this.runtimeState?.set(this.stateSnapshot) + } +} + +/** + * Create a Slate v2 extension controller that synchronizes editor commits with + * a `Y.XmlText` root. + */ +export const createYjsExtension = ( + options: YjsExtensionOptions +): YjsController => new SlateYjsController(options) diff --git a/packages/slate-yjs/src/core/index.ts b/packages/slate-yjs/src/core/index.ts new file mode 100644 index 0000000000..1268c64e45 --- /dev/null +++ b/packages/slate-yjs/src/core/index.ts @@ -0,0 +1,4 @@ +export * from './awareness' +export * from './codec' +export * from './controller' +export * from './types' diff --git a/packages/slate-yjs/src/core/types.ts b/packages/slate-yjs/src/core/types.ts new file mode 100644 index 0000000000..6f8a185583 --- /dev/null +++ b/packages/slate-yjs/src/core/types.ts @@ -0,0 +1,154 @@ +import type { + Descendant, + EditorExtension, + Operation, + Range, + Editor as SlateEditor, + Value, +} from 'slate' +import type * as Y from 'yjs' + +export type YjsConnectionState = 'connected' | 'disconnected' | 'paused' + +export type YjsUserState = { + color?: string + name?: string +} & Record + +export type EncodedYRelativePosition = readonly number[] + +export type EncodedYRelativeRange = { + anchor: EncodedYRelativePosition + focus: EncodedYRelativePosition +} + +export type YjsAwarenessSelection = EncodedYRelativeRange + +export type YjsAwarenessState = { + selection?: YjsAwarenessSelection | null + user?: YjsUserState +} & Record + +export type YjsAwarenessChange = { + added: number[] + removed: number[] + updated: number[] +} + +export type YjsAwareness = { + clientID: number + getLocalState: () => YjsAwarenessState | null + getStates: () => Map + off: (event: 'change', listener: (event: YjsAwarenessChange) => void) => void + on: (event: 'change', listener: (event: YjsAwarenessChange) => void) => void + setLocalState: (state: YjsAwarenessState | null) => void + setLocalStateField: (field: string, value: unknown) => void +} + +export type YjsLocalAwareness = YjsAwareness & { + applyRemoteState: (clientId: number, state: YjsAwarenessState | null) => void +} + +export type YjsRelativeRange = { + anchor: Y.RelativePosition + focus: Y.RelativePosition +} + +export type YjsRemoteCursorState = { + clientId: number + data: TData | null + range: Range | null + relativeRange: YjsRelativeRange | null + user: YjsUserState | null +} + +export type YjsRemoteCursorDecorationData = { + cursor: YjsRemoteCursorState +} + +export type YjsExtensionOptions = { + /** + * Field used inside awareness states for encoded Slate selections. + * + * @default 'selection' + */ + awarenessField?: string + /** + * Awareness instance supplied by a provider or deterministic local transport. + */ + awareness?: YjsAwareness + /** + * Field used inside awareness states for user metadata. + * + * @default 'user' + */ + cursorDataField?: string + /** + * Local transaction origin used to suppress Yjs echo loops. + */ + origin?: unknown + /** + * Shared Yjs root that stores the Slate snapshot and linear text mirror. + */ + sharedRoot: Y.XmlText + /** + * UndoManager scoped to the shared root. When omitted, the controller creates + * one that tracks only the local origin. + */ + undoManager?: Y.UndoManager +} + +export type YjsControllerState = { + connection: YjsConnectionState + exports: number + imports: number + revision: number +} + +export type YjsController = { + awareness: YjsAwareness | null + connect: () => void + disconnect: () => void + editor: SlateEditor | null + extension: EditorExtension + exportSelection: (range?: Range | null) => void + getRemoteCursorDecorations: ( + entry: [Descendant, number[]] + ) => readonly { + data: YjsRemoteCursorDecorationData + key: string + range: Range + }[] + getRemoteCursorStates: < + TData = unknown, + >() => readonly YjsRemoteCursorState[] + getState: () => YjsControllerState + origin: unknown + pause: () => void + redo: () => void + reconcile: () => void + resume: () => void + sharedRoot: Y.XmlText + subscribe: (listener: () => void) => () => void + undo: () => void + undoManager: Y.UndoManager +} + +export type YjsApplyEventsInput = { + editor: SlateEditor + events?: readonly Y.YEvent[] + sharedRoot: Y.XmlText +} + +export type YjsEncodeCommitInput = { + editor: SlateEditor + operations: readonly Operation[] + origin: unknown + sharedRoot: Y.XmlText +} + +export type YjsPointMappingInput = SlateEditor | Value + +export type YjsPointOptions = { + assoc?: -1 | 0 | 1 +} diff --git a/packages/slate-yjs/src/index.ts b/packages/slate-yjs/src/index.ts new file mode 100644 index 0000000000..46d458ad7f --- /dev/null +++ b/packages/slate-yjs/src/index.ts @@ -0,0 +1 @@ +export * from './core' diff --git a/packages/slate-yjs/src/internal/index.ts b/packages/slate-yjs/src/internal/index.ts new file mode 100644 index 0000000000..928764947a --- /dev/null +++ b/packages/slate-yjs/src/internal/index.ts @@ -0,0 +1,6 @@ +export { + remoteYjsUpdateOptions, + SLATE_YJS_OPERATIONS_ATTRIBUTE, + SLATE_YJS_VALUE_ATTRIBUTE, + SLATE_YJS_VERSION_ATTRIBUTE, +} from '../core/codec' diff --git a/packages/slate-yjs/src/react/index.tsx b/packages/slate-yjs/src/react/index.tsx new file mode 100644 index 0000000000..f564cbd234 --- /dev/null +++ b/packages/slate-yjs/src/react/index.tsx @@ -0,0 +1,93 @@ +import type { ReactNode } from 'react' +import { useCallback, useMemo, useSyncExternalStore } from 'react' +import type { Descendant } from 'slate' + +import type { YjsController, YjsRemoteCursorState } from '../core' + +const subscribeController = (controller: YjsController, listener: () => void) => + controller.subscribe(listener) + +export const useYjsControllerState = (controller: YjsController) => + useSyncExternalStore( + (listener) => subscribeController(controller, listener), + () => controller.getState(), + () => controller.getState() + ) + +export const useRemoteCursorStates = ( + controller: YjsController +): readonly YjsRemoteCursorState[] => { + useYjsControllerState(controller) + + return controller.getRemoteCursorStates() +} + +export const useRemoteCursorDecorations = ( + controller: YjsController +) => { + const state = useYjsControllerState(controller) + + return useCallback( + (entry: [Descendant, number[]]) => + controller.getRemoteCursorDecorations(entry), + [controller, state.revision] + ) +} + +export const useRemoteCursorOverlayPositions = ( + controller: YjsController +) => { + const cursors = useRemoteCursorStates(controller) + + return useMemo( + () => + cursors.map((cursor) => ({ + clientId: cursor.clientId, + color: cursor.user?.color ?? '#2563eb', + name: cursor.user?.name ?? `Peer ${cursor.clientId}`, + range: cursor.range, + })), + [cursors] + ) +} + +export const getRemoteCursorsOnLeaf = (leaf: { + yjsRemoteCursorStates?: readonly YjsRemoteCursorState[] +}) => leaf.yjsRemoteCursorStates ?? [] + +export const getRemoteCaretsOnLeaf = getRemoteCursorsOnLeaf + +export type RemoteCursorOverlayProps = { + className?: string + controller: YjsController + renderCursor?: (cursor: YjsRemoteCursorState) => ReactNode +} + +export const RemoteCursorOverlay = ({ + className, + controller, + renderCursor, +}: RemoteCursorOverlayProps) => { + const cursors = useRemoteCursorStates(controller) + + return ( +
+ {cursors.map((cursor) => ( + + {renderCursor + ? renderCursor(cursor) + : (cursor.user?.name ?? `Peer ${cursor.clientId}`)} + + ))} +
+ ) +} + +export type { + YjsRemoteCursorDecorationData, + YjsRemoteCursorState, +} from '../core' diff --git a/packages/slate-yjs/test/codec.test.ts b/packages/slate-yjs/test/codec.test.ts new file mode 100644 index 0000000000..673cdb4efd --- /dev/null +++ b/packages/slate-yjs/test/codec.test.ts @@ -0,0 +1,97 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' +import type { Point, Range, Value } from 'slate' +import * as Y from 'yjs' + +import { + decodeYRelativePosition, + encodeYRelativePosition, + readSlateValueFromYjs, + slatePointToTextOffset, + slatePointToYRelativePosition, + slateRangeToYRelativeRange, + slateValueToYText, + writeSlateValueToYjs, + yRelativePositionToSlatePoint, + yRelativeRangeToSlateRange, +} from '../src/core' + +const paragraph = (text: string): Value[number] => ({ + type: 'paragraph', + children: [{ text }], +}) + +const createRoot = () => { + const doc = new Y.Doc() + + return doc.get('content', Y.XmlText) as Y.XmlText +} + +describe('slate-yjs codec', () => { + it('stores Slate structure while mirroring linear Yjs text', () => { + const root = createRoot() + const value: Value = [paragraph('alpha'), paragraph('beta')] + + writeSlateValueToYjs(root, value) + + assert.equal(slateValueToYText(value), 'alphabeta') + assert.equal(root.toString(), 'alphabeta') + assert.deepEqual(readSlateValueFromYjs(root), value) + + root.insert(root.length, '!') + + assert.deepEqual(readSlateValueFromYjs(root), [ + paragraph('alpha'), + paragraph('beta!'), + ]) + }) + + it('round-trips Slate points through Yjs relative positions after remote text edits', () => { + const root = createRoot() + const value: Value = [paragraph('abcdef')] + const point: Point = { path: [0, 0], offset: 3 } + + writeSlateValueToYjs(root, value) + + const relativePosition = slatePointToYRelativePosition(root, value, point) + const encoded = encodeYRelativePosition(relativePosition) + + root.insert(0, 'XY') + + const nextValue = readSlateValueFromYjs(root) + + assert(nextValue) + assert.deepEqual( + yRelativePositionToSlatePoint( + root, + nextValue, + decodeYRelativePosition(encoded) + ), + { path: [0, 0], offset: 5 } + ) + }) + + it('maps ranges and unicode offsets without splitting surrogate pairs manually', () => { + const root = createRoot() + const text = 'Iñtërnâtiônàlizætiøn☃💩\uFEFF' + const value: Value = [paragraph(text)] + const range: Range = { + anchor: { path: [0, 0], offset: 1 }, + focus: { path: [0, 0], offset: text.length }, + } + + writeSlateValueToYjs(root, value) + + assert.equal( + slatePointToTextOffset(value, { path: [0, 0], offset: text.length }), + text.length + ) + + const relativeRange = slateRangeToYRelativeRange(root, value, range) + + assert.deepEqual( + yRelativeRangeToSlateRange(root, value, relativeRange), + range + ) + }) +}) diff --git a/packages/slate-yjs/test/controller.test.ts b/packages/slate-yjs/test/controller.test.ts new file mode 100644 index 0000000000..466381e21e --- /dev/null +++ b/packages/slate-yjs/test/controller.test.ts @@ -0,0 +1,215 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' +import { createEditor, type Range, type Value } from 'slate' +import { Editor } from 'slate/internal' +import * as Y from 'yjs' + +import { + connectYjsLocalAwareness, + createYjsExtension, + createYjsLocalAwareness, + type YjsController, + type YjsLocalAwareness, +} from '../src' + +const paragraph = (text: string): Value[number] => ({ + type: 'paragraph', + children: [{ text }], +}) + +const collapsed = (offset: number): Range => ({ + anchor: { path: [0, 0], offset }, + focus: { path: [0, 0], offset }, +}) + +const selection = (anchor: number, focus: number): Range => ({ + anchor: { path: [0, 0], offset: anchor }, + focus: { path: [0, 0], offset: focus }, +}) + +const createSeededEditor = (text: string) => { + const editor = createEditor() + + Editor.replace(editor, { + children: [paragraph(text)], + marks: null, + selection: collapsed(text.length), + }) + + return editor +} + +const endPoint = (editor: ReturnType) => ({ + path: [0, 0], + offset: Editor.string(editor, [0]).length, +}) + +const createPeer = ({ + clientID, + doc, + text, + user, +}: { + clientID: number + doc: Y.Doc + text: string + user: { color: string; name: string } +}) => { + const sharedRoot = doc.get('content', Y.XmlText) as Y.XmlText + const awareness = createYjsLocalAwareness(clientID) + const controller = createYjsExtension({ awareness, sharedRoot }) + const editor = createSeededEditor(text) + const unextend = editor.extend(controller.extension) + + awareness.setLocalState({ user }) + + return { awareness, controller, editor, unextend } +} + +const syncDocs = (source: Y.Doc, target: Y.Doc) => { + Y.applyUpdate(target, Y.encodeStateAsUpdate(source)) +} + +const connectPeerControllers = ( + left: { controller: YjsController }, + right: { controller: YjsController } +) => { + left.controller.connect() + right.controller.connect() +} + +describe('slate-yjs controller', () => { + it('exports local commits and imports remote Yjs events without editor monkey patches', () => { + const leftDoc = new Y.Doc() + const rightDoc = new Y.Doc() + const left = createPeer({ + clientID: 1, + doc: leftDoc, + text: 'one', + user: { color: '#2563eb', name: 'Left' }, + }) + const right = createPeer({ + clientID: 2, + doc: rightDoc, + text: '', + user: { color: '#059669', name: 'Right' }, + }) + + left.controller.connect() + syncDocs(leftDoc, rightDoc) + right.controller.connect() + + assert.equal('apply' in left.editor, false) + assert.equal('onChange' in left.editor, false) + assert.equal('connectYjs' in left.editor, false) + assert.equal(Editor.string(right.editor, []), 'one') + + left.editor.update((tx) => { + tx.text.insert('!', { at: endPoint(left.editor) }) + }) + syncDocs(leftDoc, rightDoc) + + assert.equal(Editor.string(left.editor, []), 'one!') + assert.equal(Editor.string(right.editor, []), 'one!') + assert.equal(left.controller.getState().exports, 1) + assert.equal(right.controller.getState().imports, 2) + assert.deepEqual(Editor.getLastCommit(right.editor)?.tags, [ + 'collaboration', + 'remote-import', + ]) + assert.deepEqual(Editor.getLastCommit(right.editor)?.metadata, { + collab: { origin: 'remote', saveToHistory: false }, + history: { mode: 'skip' }, + selection: { dom: 'preserve', focus: false, scroll: false }, + }) + + left.unextend() + right.unextend() + }) + + it('keeps selection-only commits in awareness instead of document exports', () => { + const leftDoc = new Y.Doc() + const rightDoc = new Y.Doc() + const left = createPeer({ + clientID: 1, + doc: leftDoc, + text: 'alpha', + user: { color: '#2563eb', name: 'Left' }, + }) + const right = createPeer({ + clientID: 2, + doc: rightDoc, + text: '', + user: { color: '#059669', name: 'Right' }, + }) + const disconnectAwareness = connectYjsLocalAwareness( + left.awareness as YjsLocalAwareness, + right.awareness as YjsLocalAwareness + ) + + left.controller.connect() + syncDocs(leftDoc, rightDoc) + right.controller.connect() + + left.editor.update((tx) => { + tx.selection.set(selection(1, 4)) + }) + + assert.equal(left.controller.getState().exports, 0) + const [remoteCursor] = right.controller.getRemoteCursorStates() + + assert(remoteCursor) + assert.equal(remoteCursor.clientId, 1) + assert.deepEqual(remoteCursor.data, { color: '#2563eb', name: 'Left' }) + assert.deepEqual(remoteCursor.range, selection(1, 4)) + assert(remoteCursor.relativeRange) + assert.deepEqual(remoteCursor.user, { color: '#2563eb', name: 'Left' }) + + left.controller.disconnect() + + assert.equal(right.controller.getRemoteCursorStates()[0]?.range, null) + + disconnectAwareness() + left.unextend() + right.unextend() + }) + + it('can pause remote imports and reconcile the skipped Yjs snapshot on resume', () => { + const leftDoc = new Y.Doc() + const rightDoc = new Y.Doc() + const left = createPeer({ + clientID: 1, + doc: leftDoc, + text: 'draft', + user: { color: '#2563eb', name: 'Left' }, + }) + const right = createPeer({ + clientID: 2, + doc: rightDoc, + text: '', + user: { color: '#059669', name: 'Right' }, + }) + + connectPeerControllers(left, right) + syncDocs(leftDoc, rightDoc) + right.controller.reconcile() + right.controller.pause() + + left.editor.update((tx) => { + tx.text.insert('?', { at: endPoint(left.editor) }) + }) + syncDocs(leftDoc, rightDoc) + + assert.equal(Editor.string(left.editor, []), 'draft?') + assert.equal(Editor.string(right.editor, []), 'draft') + assert.equal(right.controller.getState().connection, 'paused') + + right.controller.resume() + + assert.equal(Editor.string(right.editor, []), 'draft?') + assert.equal(right.controller.getState().connection, 'connected') + + left.unextend() + right.unextend() + }) +}) diff --git a/packages/slate-yjs/test/react.test.tsx b/packages/slate-yjs/test/react.test.tsx new file mode 100644 index 0000000000..8f58c63929 --- /dev/null +++ b/packages/slate-yjs/test/react.test.tsx @@ -0,0 +1,107 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' +import { act, render, renderHook } from '@testing-library/react' +import { createEditor, type Value } from 'slate' +import { Editor } from 'slate/internal' +import * as Y from 'yjs' + +import { + connectYjsLocalAwareness, + createYjsExtension, + createYjsLocalAwareness, +} from '../src' +import { RemoteCursorOverlay, useYjsControllerState } from '../src/react' + +const paragraph = (text: string): Value[number] => ({ + type: 'paragraph', + children: [{ text }], +}) + +const createEditorWithText = (text: string) => { + const editor = createEditor() + + Editor.replace(editor, { + children: [paragraph(text)], + marks: null, + selection: { + anchor: { path: [0, 0], offset: text.length }, + focus: { path: [0, 0], offset: text.length }, + }, + }) + + return editor +} + +describe('slate-yjs React bindings', () => { + it('subscribes to controller state and renders remote cursor overlays', () => { + const leftDoc = new Y.Doc() + const rightDoc = new Y.Doc() + const leftRoot = leftDoc.get('content', Y.XmlText) as Y.XmlText + const rightRoot = rightDoc.get('content', Y.XmlText) as Y.XmlText + const leftAwareness = createYjsLocalAwareness(1) + const rightAwareness = createYjsLocalAwareness(2) + const leftController = createYjsExtension({ + awareness: leftAwareness, + sharedRoot: leftRoot, + }) + const rightController = createYjsExtension({ + awareness: rightAwareness, + sharedRoot: rightRoot, + }) + const leftEditor = createEditorWithText('alpha') + const rightEditor = createEditorWithText('') + const unextendLeft = leftEditor.extend(leftController.extension) + const unextendRight = rightEditor.extend(rightController.extension) + const disconnectAwareness = connectYjsLocalAwareness( + leftAwareness, + rightAwareness + ) + + leftAwareness.setLocalState({ + user: { color: '#2563eb', name: 'Left' }, + }) + rightAwareness.setLocalState({ + user: { color: '#059669', name: 'Right' }, + }) + + const { result } = renderHook(() => useYjsControllerState(leftController)) + + assert.equal(result.current.connection, 'disconnected') + + act(() => { + leftController.connect() + Y.applyUpdate(rightDoc, Y.encodeStateAsUpdate(leftDoc)) + rightController.connect() + }) + + assert.equal(result.current.connection, 'connected') + + const rendered = render( + + ) + + assert.equal(rendered.container.textContent, 'Left') + + act(() => { + leftEditor.update((tx) => { + tx.selection.set({ + anchor: { path: [0, 0], offset: 1 }, + focus: { path: [0, 0], offset: 4 }, + }) + }) + }) + + assert.equal(rendered.container.textContent, 'Left') + assert.equal( + rendered.container.querySelector('[data-slate-yjs-remote-cursor="1"]') + ?.textContent, + 'Left' + ) + + act(() => { + disconnectAwareness() + unextendLeft() + unextendRight() + }) + }) +}) diff --git a/packages/slate-yjs/tsconfig.build.json b/packages/slate-yjs/tsconfig.build.json new file mode 100644 index 0000000000..95b2fc6e63 --- /dev/null +++ b/packages/slate-yjs/tsconfig.build.json @@ -0,0 +1,13 @@ +{ + "extends": "../../config/typescript/tsconfig.json", + "include": ["src/**/*"], + "compilerOptions": { + "composite": true, + "declaration": true, + "declarationMap": true, + "jsx": "react-jsx", + "rootDir": "./src", + "outDir": "./lib", + "types": ["react"] + } +} diff --git a/packages/slate-yjs/tsconfig.json b/packages/slate-yjs/tsconfig.json new file mode 100644 index 0000000000..c84050e3e2 --- /dev/null +++ b/packages/slate-yjs/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../config/typescript/tsconfig.json", + "include": ["src/**/*"], + "compilerOptions": { + "jsx": "react-jsx", + "types": ["react"] + } +} diff --git a/packages/slate-yjs/tsconfig.test.json b/packages/slate-yjs/tsconfig.test.json new file mode 100644 index 0000000000..29602eb9bf --- /dev/null +++ b/packages/slate-yjs/tsconfig.test.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "include": ["src/**/*", "test/**/*"], + "compilerOptions": { + "isolatedModules": true, + "noEmit": true, + "types": ["node", "react", "react-dom"] + } +} diff --git a/packages/slate-yjs/tsdown.config.mts b/packages/slate-yjs/tsdown.config.mts new file mode 100644 index 0000000000..32eb46fd6b --- /dev/null +++ b/packages/slate-yjs/tsdown.config.mts @@ -0,0 +1,24 @@ +import { defineConfig } from 'tsdown' + +const enableSourcemaps = !process.env.CI + +export default defineConfig({ + entry: { + index: 'src/index.ts', + 'core/index': 'src/core/index.ts', + 'internal/index': 'src/internal/index.ts', + 'react/index': 'src/react/index.tsx', + }, + format: ['esm'], + clean: true, + platform: 'neutral', + tsconfig: 'tsconfig.build.json', + sourcemap: enableSourcemaps, + dts: { + bundle: true, + sourcemap: enableSourcemaps, + }, + outExtensions: () => ({ + js: '.js', + }), +}) diff --git a/playwright/integration/examples/yjs-collaboration.test.ts b/playwright/integration/examples/yjs-collaboration.test.ts new file mode 100644 index 0000000000..213463d023 --- /dev/null +++ b/playwright/integration/examples/yjs-collaboration.test.ts @@ -0,0 +1,87 @@ +import { expect, test } from '@playwright/test' + +test.describe('yjs collaboration example', () => { + test('does not throw when deleting a keyboard select-all range', async ({ + page, + }) => { + const pageErrors: string[] = [] + page.on('pageerror', (error) => pageErrors.push(error.message)) + + await page.goto('/examples/yjs-collaboration') + await expect(page.locator('#yjs-left-text')).toHaveText( + 'Alpha shared document' + ) + + await page.locator('#yjs-left-editor').click() + const selectAllHotkey = await page + .locator('#yjs-left-editor') + .evaluate(() => + /Mac OS X/.test(navigator.userAgent) ? 'Meta+A' : 'Control+A' + ) + + await page.keyboard.press(selectAllHotkey) + await page.keyboard.press('Delete') + + await expect(page.locator('#yjs-left-text')).toHaveText('') + await expect(page.locator('#yjs-right-text')).toHaveText('') + await expect(page.locator('#yjs-shared-text')).toHaveText('') + await expect.poll(() => pageErrors).toEqual([]) + }) + + test('syncs document edits, awareness cursors, and paused peer recovery', async ({ + page, + }) => { + await page.goto('/examples/yjs-collaboration') + + await expect(page.locator('#yjs-left-text')).toHaveText( + 'Alpha shared document' + ) + await expect(page.locator('#yjs-right-text')).toHaveText( + 'Alpha shared document' + ) + await expect(page.locator('#yjs-left-connection')).toHaveText('connected') + await expect(page.locator('#yjs-right-connection')).toHaveText('connected') + + await page.getByRole('button', { name: 'Left insert' }).click() + await expect(page.locator('#yjs-right-text')).toHaveText( + 'Alpha shared document!' + ) + await expect(page.locator('#yjs-left-exports')).toHaveText('out 1') + await expect(page.locator('#yjs-right-imports')).toContainText('in') + + await page.getByRole('button', { name: 'Right insert' }).click() + await expect(page.locator('#yjs-left-text')).toHaveText( + 'Alpha shared document!?' + ) + + await page.getByRole('button', { name: 'Left selection' }).click() + await expect(page.locator('#yjs-right-cursors')).toContainText('Left:1-5') + await expect( + page.locator('[data-test-id="yjs-right-remote-cursor-segment"]') + ).toContainText('lpha') + + await page.getByRole('button', { name: 'Pause right' }).click() + await expect(page.locator('#yjs-right-connection')).toHaveText('paused') + await page.getByRole('button', { name: 'Left insert' }).click() + await expect(page.locator('#yjs-left-text')).toHaveText( + 'Alpha shared document!?!' + ) + await expect(page.locator('#yjs-right-text')).toHaveText( + 'Alpha shared document!?' + ) + + await page.getByRole('button', { name: 'Resume right' }).click() + await expect(page.locator('#yjs-right-connection')).toHaveText('connected') + await expect(page.locator('#yjs-right-text')).toHaveText( + 'Alpha shared document!?!' + ) + + await page.getByRole('button', { name: 'Unicode' }).click() + await expect(page.locator('#yjs-shared-text')).toContainText( + 'Iñtërnâtiônàlizætiøn☃💩' + ) + await expect(page.locator('#yjs-right-text')).toContainText( + 'Iñtërnâtiônàlizætiøn☃💩' + ) + }) +}) diff --git a/site/constants/examples.ts b/site/constants/examples.ts index f53d57843b..6617aa2c04 100644 --- a/site/constants/examples.ts +++ b/site/constants/examples.ts @@ -31,6 +31,7 @@ export const EXAMPLE_NAMES_AND_PATHS = [ ['Shadow DOM', 'shadow-dom'], ['Styling', 'styling'], ['Tables', 'tables'], + ['Yjs Collaboration', 'yjs-collaboration'], ] as const export const HIDDEN_EXAMPLES = [ diff --git a/site/examples/ts/yjs-collaboration.tsx b/site/examples/ts/yjs-collaboration.tsx new file mode 100644 index 0000000000..4d9c8cc1da --- /dev/null +++ b/site/examples/ts/yjs-collaboration.tsx @@ -0,0 +1,607 @@ +import { css, cx } from '@emotion/css' +import { useCallback, useEffect, useMemo, useState } from 'react' +import type { Range, Editor as SlateEditor, Value } from 'slate' +import { Editor } from 'slate/internal' +import { withHistory } from 'slate-history' +import { + Editable, + type ReactEditor, + Slate, + useEditorSelector, + useSlateEditor, +} from 'slate-react' +import { + connectYjsLocalAwareness, + createYjsExtension, + createYjsLocalAwareness, + type YjsController, + type YjsLocalAwareness, +} from 'slate-yjs' +import { + RemoteCursorOverlay, + useRemoteCursorDecorations, + useRemoteCursorStates, + useYjsControllerState, + type YjsRemoteCursorState, +} from 'slate-yjs/react' +import * as Y from 'yjs' + +const initialValue: Value = [ + { + type: 'paragraph', + children: [{ text: 'Alpha shared document' }], + }, +] + +const emptyValue: Value = [ + { + type: 'paragraph', + children: [{ text: '' }], + }, +] + +type PeerEnvironment = { + awareness: YjsLocalAwareness + controller: YjsController + doc: Y.Doc +} + +type CollaborationEnvironment = { + left: PeerEnvironment + right: PeerEnvironment +} + +const panelCss = css` + max-width: 1180px; + margin: 32px auto 56px; + padding: 0 24px; + color: #172033; + font-family: + ui-sans-serif, + system-ui, + -apple-system, + BlinkMacSystemFont, + "Segoe UI", + sans-serif; +` + +const headerCss = css` + display: flex; + flex-wrap: wrap; + align-items: end; + justify-content: space-between; + gap: 16px; + margin-bottom: 18px; +` + +const titleCss = css` + margin: 0; + font-size: 28px; + line-height: 1.1; +` + +const metricRowCss = css` + display: flex; + flex-wrap: wrap; + gap: 8px; +` + +const metricCss = css` + display: inline-flex; + align-items: center; + gap: 6px; + border: 1px solid #d7dde8; + border-radius: 999px; + padding: 6px 10px; + background: #f8fafc; + color: #334155; + font-size: 13px; + font-variant-numeric: tabular-nums; +` + +const controlsCss = css` + display: flex; + flex-wrap: wrap; + gap: 8px; + margin: 0 0 18px; +` + +const buttonCss = css` + border: 1px solid #b9c3d4; + border-radius: 8px; + background: white; + color: #172033; + padding: 8px 11px; + cursor: pointer; + font-weight: 650; + + &:hover { + border-color: #2563eb; + color: #1d4ed8; + } + + &:focus-visible { + outline: 2px solid #2563eb; + outline-offset: 2px; + } +` + +const peerGridCss = css` + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; + + @media (max-width: 760px) { + grid-template-columns: 1fr; + } +` + +const peerPanelCss = css` + border: 1px solid #d7dde8; + border-radius: 8px; + background: white; + overflow: hidden; +` + +const peerHeaderCss = css` + display: flex; + justify-content: space-between; + gap: 12px; + padding: 14px 16px; + border-bottom: 1px solid #e4e8f0; + background: #f8fafc; +` + +const peerTitleCss = css` + margin: 0; + font-size: 16px; +` + +const editorWrapCss = css` + position: relative; + min-height: 180px; + padding: 14px 16px 18px; +` + +const editorCss = css` + min-height: 118px; + border: 1px solid #e4e8f0; + border-radius: 8px; + padding: 14px; + line-height: 1.55; + + &:focus { + outline: 2px solid #2563eb; + outline-offset: 2px; + } +` + +const readoutCss = css` + display: grid; + grid-template-columns: minmax(120px, 160px) minmax(0, 1fr); + gap: 8px; + padding: 0 16px 14px; + font-size: 13px; +` + +const codeCss = css` + overflow: hidden; + border-radius: 6px; + background: #111827; + color: white; + padding: 4px 8px; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + text-overflow: ellipsis; + white-space: nowrap; +` + +const overlayCss = css` + position: absolute; + top: 12px; + right: 16px; + z-index: 1; + display: flex; + gap: 6px; + font-size: 12px; + font-weight: 700; +` + +const remoteCursorChipCss = css` + border-radius: 999px; + background: white; + padding: 3px 8px; + box-shadow: 0 0 0 1px #d7dde8; +` + +const sharedCss = css` + margin-top: 16px; + border-top: 1px solid #e4e8f0; + padding-top: 14px; +` + +const createEnvironment = (): CollaborationEnvironment => { + const leftDoc = new Y.Doc() + const rightDoc = new Y.Doc() + const leftRoot = leftDoc.get('content', Y.XmlText) as Y.XmlText + const rightRoot = rightDoc.get('content', Y.XmlText) as Y.XmlText + const leftAwareness = createYjsLocalAwareness(1) + const rightAwareness = createYjsLocalAwareness(2) + + leftAwareness.setLocalState({ + user: { color: '#2563eb', name: 'Left' }, + }) + rightAwareness.setLocalState({ + user: { color: '#059669', name: 'Right' }, + }) + + return { + left: { + awareness: leftAwareness, + controller: createYjsExtension({ + awareness: leftAwareness, + sharedRoot: leftRoot, + }), + doc: leftDoc, + }, + right: { + awareness: rightAwareness, + controller: createYjsExtension({ + awareness: rightAwareness, + sharedRoot: rightRoot, + }), + doc: rightDoc, + }, + } +} + +const connectLocalDocs = (leftDoc: Y.Doc, rightDoc: Y.Doc) => { + const syncLeftToRight = (update: Uint8Array, origin: unknown) => { + if (origin !== rightDoc) { + Y.applyUpdate(rightDoc, update, leftDoc) + } + } + const syncRightToLeft = (update: Uint8Array, origin: unknown) => { + if (origin !== leftDoc) { + Y.applyUpdate(leftDoc, update, rightDoc) + } + } + + leftDoc.on('update', syncLeftToRight) + rightDoc.on('update', syncRightToLeft) + + return () => { + leftDoc.off('update', syncLeftToRight) + rightDoc.off('update', syncRightToLeft) + } +} + +const endPoint = (editor: SlateEditor) => ({ + path: [0, 0], + offset: Editor.string(editor, [0]).length, +}) + +const boundedSelection = (editor: SlateEditor): Range => { + const text = Editor.string(editor, [0]) + const end = Math.min(Math.max(text.length, 1), 5) + + return { + anchor: { path: [0, 0], offset: Math.min(1, end) }, + focus: { path: [0, 0], offset: end }, + } +} + +const insertText = (editor: SlateEditor, text: string) => { + editor.update((tx) => { + tx.text.insert(text, { at: endPoint(editor) }) + }) +} + +const selectPreviewRange = (editor: SlateEditor) => { + editor.update((tx) => { + tx.selection.set(boundedSelection(editor)) + }) +} + +const PeerReadout = ({ + controller, + prefix, +}: { + controller: YjsController + prefix: string +}) => { + const text = useEditorSelector((editor) => Editor.string(editor, [])) + const cursors = useRemoteCursorStates(controller) + + return ( +
+ Text + + {text} + + Remote cursors + + {cursors.length === 0 + ? 'none' + : cursors + .map((cursor) => + cursor.range + ? `${cursor.user?.name ?? cursor.clientId}:${cursor.range.anchor.offset}-${cursor.range.focus.offset}` + : `${cursor.user?.name ?? cursor.clientId}:none` + ) + .join('|')} + +
+ ) +} + +const PeerPanel = ({ + accent, + controller, + editor, + label, + prefix, +}: { + accent: string + controller: YjsController + editor: ReactEditor + label: string + prefix: string +}) => { + const state = useYjsControllerState(controller) + const decorate = useRemoteCursorDecorations(controller) + + return ( +
+
+

{label}

+
+ + {state.connection} + + + out {state.exports} + + + in {state.imports} + +
+
+ +
+ ( + + {cursor.user?.name ?? `Peer ${cursor.clientId}`} + + )} + /> + { + const cursor = segment.slices + .map( + (slice) => + ( + slice.data as + | { cursor?: YjsRemoteCursorState } + | undefined + )?.cursor + ) + .find(Boolean) + + return cursor ? ( + + {children} + + ) : ( + children + ) + }} + spellCheck={false} + /> +
+ +
+
+ ) +} + +const SharedSnapshot = ({ + environment, +}: { + environment: CollaborationEnvironment +}) => { + const left = useYjsControllerState(environment.left.controller) + const right = useYjsControllerState(environment.right.controller) + const sharedText = environment.left.doc.get('content', Y.XmlText).toString() + + return ( +
+ Shared text + + {sharedText} + + Revisions + + {left.revision}:{right.revision} + +
+ ) +} + +const CollaborationSession = ({ onReset }: { onReset: () => void }) => { + const environment = useMemo(() => createEnvironment(), []) + const leftEditor = useSlateEditor({ + initialValue, + withEditor: withHistory, + }) + const rightEditor = useSlateEditor({ + initialValue: emptyValue, + withEditor: withHistory, + }) + + useEffect(() => { + const disconnectDocs = connectLocalDocs( + environment.left.doc, + environment.right.doc + ) + const disconnectAwareness = connectYjsLocalAwareness( + environment.left.awareness, + environment.right.awareness + ) + const unextendLeft = leftEditor.extend( + environment.left.controller.extension + ) + const unextendRight = rightEditor.extend( + environment.right.controller.extension + ) + + environment.left.controller.connect() + environment.right.controller.connect() + + return () => { + environment.left.controller.disconnect() + environment.right.controller.disconnect() + unextendLeft() + unextendRight() + disconnectAwareness() + disconnectDocs() + } + }, [environment, leftEditor, rightEditor]) + + const insertLeft = useCallback(() => { + insertText(leftEditor, '!') + }, [leftEditor]) + const insertRight = useCallback(() => { + insertText(rightEditor, '?') + }, [rightEditor]) + const selectLeft = useCallback(() => { + selectPreviewRange(leftEditor) + }, [leftEditor]) + const selectRight = useCallback(() => { + selectPreviewRange(rightEditor) + }, [rightEditor]) + const insertConcurrent = useCallback(() => { + insertText(leftEditor, ' L') + insertText(rightEditor, ' R') + }, [leftEditor, rightEditor]) + const insertUnicode = useCallback(() => { + insertText(leftEditor, ' Iñtërnâtiônàlizætiøn☃💩\uFEFF') + }, [leftEditor]) + + return ( +
+
+

Yjs Collaboration

+
+ offline transport + two editors +
+
+
+ + + + + + + + + + + +
+
+ + +
+ +
+ ) +} + +const YjsCollaborationExample = () => { + const [sessionKey, setSessionKey] = useState(0) + + return ( + { + setSessionKey((key) => key + 1) + }} + /> + ) +} + +export default YjsCollaborationExample diff --git a/site/pages/_app.tsx b/site/pages/_app.tsx index 6d436f94b9..9212183e8a 100644 --- a/site/pages/_app.tsx +++ b/site/pages/_app.tsx @@ -1,16 +1,8 @@ import type { AppProps } from 'next/app' -import { Roboto } from 'next/font/google' import { type ErrorInfo, useState } from 'react' import { ErrorBoundary } from 'react-error-boundary' import { ExampleLayout, Warning } from '../components/ExampleLayout' -const roboto = Roboto({ - weight: ['400', '700'], - style: ['normal', 'italic'], - subsets: ['latin', 'latin-ext'], - display: 'swap', -}) - function ErrorFallback({ error, resetErrorBoundary, @@ -33,7 +25,7 @@ export default function App({ Component, pageProps }: AppProps) { const [error, setError] = useState(undefined) const [stackTrace, setStackTrace] = useState(undefined) return ( -
+
{ diff --git a/site/pages/examples/[example].tsx b/site/pages/examples/[example].tsx index cc7c988223..a626e8208d 100644 --- a/site/pages/examples/[example].tsx +++ b/site/pages/examples/[example].tsx @@ -53,6 +53,7 @@ const EXAMPLE_IMPORTERS: Record< 'shadow-dom': () => import('../../examples/ts/shadow-dom'), styling: () => import('../../examples/ts/styling'), tables: () => import('../../examples/ts/tables'), + 'yjs-collaboration': () => import('../../examples/ts/yjs-collaboration'), } const EXAMPLES: ExampleTuple[] = EXAMPLE_NAMES_AND_PATHS.map(([name, path]) => [ diff --git a/site/tsconfig.json b/site/tsconfig.json index 9655aba18f..272138e76d 100644 --- a/site/tsconfig.json +++ b/site/tsconfig.json @@ -22,6 +22,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/internal": ["../packages/slate/src/internal/index.ts"], "slate-react": ["../packages/slate-react/src/index.ts"] },