From e70bd4df2956154171c826c013a8a60e6bab7350 Mon Sep 17 00:00:00 2001 From: Shayan Date: Tue, 16 Jun 2026 13:19:46 +0330 Subject: [PATCH 1/5] chore(ts): drop deprecated baseUrl, keep paths baseUrl is deprecated in TS 6.0 and removed in 7.0. Since TS 5.0 the paths map resolves relative to the tsconfig file, so baseUrl is no longer required. Drop it from the editor, ui and web tsconfigs and keep the existing paths mappings. Verified: @lexkit/editor build (incl. dts) and web typecheck both pass. --- apps/web/tsconfig.json | 1 - packages/editor/tsconfig.json | 1 - packages/ui/tsconfig.json | 1 - 3 files changed, 3 deletions(-) diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 299b7de..8ca39b9 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -1,7 +1,6 @@ { "extends": "@repo/typescript-config/nextjs.json", "compilerOptions": { - "baseUrl": ".", "paths": { "@/*": ["./*"], "@repo/ui": ["../../packages/ui/src"], diff --git a/packages/editor/tsconfig.json b/packages/editor/tsconfig.json index c240bbc..442c856 100644 --- a/packages/editor/tsconfig.json +++ b/packages/editor/tsconfig.json @@ -3,7 +3,6 @@ "compilerOptions": { "module": "ESNext", "moduleResolution": "Bundler", - "baseUrl": ".", "allowImportingTsExtensions": true, "emitDeclarationOnly": true, "paths": { diff --git a/packages/ui/tsconfig.json b/packages/ui/tsconfig.json index 3b368a4..7103f07 100644 --- a/packages/ui/tsconfig.json +++ b/packages/ui/tsconfig.json @@ -1,7 +1,6 @@ { "extends": "@repo/typescript-config/react-library.json", "compilerOptions": { - "baseUrl": ".", "paths": { "@repo/ui/*": ["./src/*"] } From b2eaab8c11e1c7bda1af3a7c46f28e534187001e Mon Sep 17 00:00:00 2001 From: Shayan Date: Tue, 16 Jun 2026 13:29:50 +0330 Subject: [PATCH 2/5] feat(collab): headless Yjs CollaborationExtension Add a first-class, transport-agnostic collaboration extension built on Lexical's own CollaborationPlugin + @lexical/yjs (CRDT, so offline edits merge conflict-free on reconnect). - Single transport seam: providerFactory(id, docMap) => Provider. LexKit ships no transport dependency; plug in y-websocket / y-webrtc / y-indexeddb / Hocuspocus / Liveblocks / etc. - yjs + @lexical/yjs declared as optional peer deps (they already arrive via @lexical/react, a required peer) so non-collab editors pull in nothing extra. - Headless presence API: commands.collab.{connect,disconnect,toggle, setLocalUser,getUsers,onUsersChange} + isCollaborating state query + useCollaborators() hook, all via the standard extension contract. - Themeable remote cursors via theme.collab.* and an optional cursorsContainerRef. - Inert until configured with a providerFactory, so it is safe to leave in the extensions array unconditionally. Note: collaborative undo/redo is not wired by Lexical's CollaborationPlugin; do not register historyExtension while collaborating (documented). --- packages/editor/package.json | 17 +- packages/editor/src/core/theme.ts | 16 + .../collaboration/CollaborationExtension.tsx | 335 ++++++++++++++++++ .../src/extensions/collaboration/index.ts | 26 ++ .../src/extensions/collaboration/types.ts | 111 ++++++ .../collaboration/useCollaboration.ts | 67 ++++ packages/editor/src/extensions/index.ts | 18 + pnpm-lock.yaml | 26 +- 8 files changed, 601 insertions(+), 15 deletions(-) create mode 100644 packages/editor/src/extensions/collaboration/CollaborationExtension.tsx create mode 100644 packages/editor/src/extensions/collaboration/index.ts create mode 100644 packages/editor/src/extensions/collaboration/types.ts create mode 100644 packages/editor/src/extensions/collaboration/useCollaboration.ts diff --git a/packages/editor/package.json b/packages/editor/package.json index 9e82f90..0eab8cc 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -47,6 +47,8 @@ }, "homepage": "https://github.com/novincode/lexkit#readme", "devDependencies": { + "@lexical/react": ">=0.34.0", + "@lexical/yjs": ">=0.34.0", "@repo/eslint-config": "workspace:*", "@repo/typescript-config": "workspace:*", "@types/react": "^19.1.1", @@ -55,7 +57,8 @@ "jsdom": "^29.1.1", "tsup": "^8.0.0", "typescript": "^5.7.3", - "vitest": "^4.1.8" + "vitest": "^4.1.8", + "yjs": "^13.6.27" }, "peerDependencies": { "@lexical/code": ">=0.34.0", @@ -68,8 +71,18 @@ "@lexical/selection": ">=0.34.0", "@lexical/table": ">=0.34.0", "@lexical/utils": ">=0.34.0", + "@lexical/yjs": ">=0.34.0", "lexical": ">=0.34.0", "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0" + "react-dom": "^18.0.0 || ^19.0.0", + "yjs": ">=13.5.22" + }, + "peerDependenciesMeta": { + "@lexical/yjs": { + "optional": true + }, + "yjs": { + "optional": true + } } } diff --git a/packages/editor/src/core/theme.ts b/packages/editor/src/core/theme.ts index 3b86467..368f9f6 100644 --- a/packages/editor/src/core/theme.ts +++ b/packages/editor/src/core/theme.ts @@ -70,6 +70,16 @@ export interface LexKitTheme extends EditorThemeClasses { content?: React.CSSProperties; }; }; + collab?: { + /** Container that hosts remote cursor carets. */ + cursorsContainer?: string; + /** Applied to each remote caret element. */ + caret?: string; + /** Applied to the name label next to a caret. */ + label?: string; + /** Applied to a remote selection highlight. */ + selection?: string; + }; // Styles for inline CSS properties styles?: { toolbar?: { @@ -225,6 +235,12 @@ export const defaultLexKitTheme: LexKitTheme = { contentEditable: "lexkit-content-editable", placeholder: "lexkit-placeholder", }, + collab: { + cursorsContainer: "lexkit-collab-cursors", + caret: "lexkit-collab-caret", + label: "lexkit-collab-label", + selection: "lexkit-collab-selection", + }, }; /** diff --git a/packages/editor/src/extensions/collaboration/CollaborationExtension.tsx b/packages/editor/src/extensions/collaboration/CollaborationExtension.tsx new file mode 100644 index 0000000..11ced6e --- /dev/null +++ b/packages/editor/src/extensions/collaboration/CollaborationExtension.tsx @@ -0,0 +1,335 @@ +import React, { ReactNode, useRef } from "react"; +import { LexicalEditor, COMMAND_PRIORITY_LOW } from "lexical"; +import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; +import { CollaborationPlugin } from "@lexical/react/LexicalCollaborationPlugin"; +import { + CONNECTED_COMMAND, + TOGGLE_CONNECT_COMMAND, + type Provider, +} from "@lexical/yjs"; +import { BaseExtension } from "../base"; +import { ExtensionCategory } from "../types"; +import { LexKitTheme } from "../../core/theme"; +import { + CollaborationCommands, + CollaborationConfig, + CollaborationProviderFactory, + CollaborationStateQueries, + CollaboratorState, +} from "./types"; + +/** + * Props for the internal runtime component. Kept separate from the public + * config so we can pass the (memoized) wrapped factory rather than the raw one. + */ +interface CollaborationRuntimeProps { + id: string; + providerFactory: CollaborationProviderFactory; + shouldBootstrap: boolean; + username?: string; + cursorColor?: string; + awarenessData?: Record; + excludedProperties?: CollaborationConfig["excludedProperties"]; + initialEditorState?: CollaborationConfig["initialEditorState"]; + externalCursorsContainerRef?: CollaborationConfig["cursorsContainerRef"]; +} + +/** + * Renders Lexical's `CollaborationPlugin` plus a themeable container for remote + * cursor carets. When the consumer supplies their own `cursorsContainerRef` we + * defer to it; otherwise we render our own container styled via + * `theme.collab.cursorsContainer`. + */ +function CollaborationRuntime({ + id, + providerFactory, + shouldBootstrap, + username, + cursorColor, + awarenessData, + excludedProperties, + initialEditorState, + externalCursorsContainerRef, +}: CollaborationRuntimeProps) { + const [editor] = useLexicalComposerContext(); + const internalRef = useRef(null); + const usingInternal = !externalCursorsContainerRef; + const containerRef = externalCursorsContainerRef ?? internalRef; + + const theme = (editor as unknown as { _config?: { theme?: LexKitTheme } }) + ._config?.theme; + const collabTheme = theme?.collab; + + return ( + <> + + } + /> + {usingInternal && ( +
} + className={collabTheme?.cursorsContainer ?? "lexkit-collab-cursors"} + aria-hidden="true" + /> + )} + + ); +} + +/** + * CollaborationExtension — headless, real-time collaboration for LexKit. + * + * Built on Lexical's own `@lexical/react` `CollaborationPlugin` + `@lexical/yjs` + * (a CRDT, so offline edits merge conflict-free on reconnect). The transport is + * fully pluggable via {@link CollaborationConfig.providerFactory} — LexKit ships + * no transport dependency of its own. + * + * Surface (all through the standard extension contract): + * - `commands.collab.{connect,disconnect,toggle,setLocalUser,getUsers,onUsersChange}` + * - `activeStates.isCollaborating` + * - themeable remote cursors via `theme.collab.*` + * - the `useCollaborators()` hook for presence UI + * + * @remarks + * Collaborative undo/redo is **not** provided by Lexical's `CollaborationPlugin` + * (it does not expose the binding needed for a Yjs `UndoManager`). When using + * collaboration, do **not** also register `historyExtension` — Yjs owns history. + * + * @example + * ```tsx + * import { WebrtcProvider } from "y-webrtc"; + * + * const extensions = [ + * richTextExtension, + * collaborationExtension.configure({ + * id: "my-room", + * username: "Ada", + * cursorColor: "#e11d48", + * providerFactory: (id, docMap) => + * new WebrtcProvider(id, docMap.get(id)!) as unknown as Provider, + * }), + * ] as const; + * ``` + */ +export class CollaborationExtension extends BaseExtension< + "collaboration", + CollaborationConfig, + CollaborationCommands, + CollaborationStateQueries, + ReactNode[] +> { + private provider: Provider | null = null; + private connected = false; + private localClientId: number | null = null; + + private readonly userListeners = new Set< + (users: CollaboratorState[]) => void + >(); + private readonly stateListeners = new Set<() => void>(); + private awarenessHandler: (() => void) | null = null; + + // Stable wrapped-factory cache so re-renders don't recreate the provider. + private wrappedFactory: CollaborationProviderFactory | null = null; + private wrappedFor: CollaborationProviderFactory | null = null; + + constructor(config: CollaborationConfig = {}) { + super("collaboration", [ExtensionCategory.Floating]); + this.config = { + showInToolbar: false, + // Render after the editor children, like RichText, so the plugin mounts + // inside the composer alongside the content editable. + position: "after", + ...config, + }; + } + + // Collaboration contributes no nodes; it syncs whatever nodes are registered. + getNodes(): any[] { + return []; + } + + // --- Provider lifecycle ------------------------------------------------- + + /** Returns a stable wrapped factory that captures the created provider. */ + private getWrappedFactory( + userFactory: CollaborationProviderFactory, + ): CollaborationProviderFactory { + if (this.wrappedFor !== userFactory || !this.wrappedFactory) { + this.wrappedFor = userFactory; + this.wrappedFactory = (id, docMap) => { + const provider = userFactory(id, docMap); + this.attachProvider(provider); + return provider; + }; + } + return this.wrappedFactory; + } + + private attachProvider(provider: Provider): void { + // Detach any previous provider before binding a new one. + this.detachProvider(); + this.provider = provider; + + const awareness = provider.awareness; + this.localClientId = + (awareness as unknown as { clientID?: number }).clientID ?? null; + + // Seed local presence from config. + const { username, cursorColor, awarenessData } = this.config; + if (username !== undefined) awareness.setLocalStateField("name", username); + if (cursorColor !== undefined) + awareness.setLocalStateField("color", cursorColor); + if (awarenessData !== undefined) + awareness.setLocalStateField("awarenessData", awarenessData); + + this.awarenessHandler = () => this.notifyUsers(); + awareness.on("update", this.awarenessHandler); + this.notifyUsers(); + } + + private detachProvider(): void { + if (this.provider && this.awarenessHandler) { + this.provider.awareness.off("update", this.awarenessHandler); + } + this.awarenessHandler = null; + this.provider = null; + this.localClientId = null; + } + + // --- Presence ----------------------------------------------------------- + + /** Snapshot of all collaborators from Yjs awareness. */ + getUsers(): CollaboratorState[] { + const awareness = this.provider?.awareness; + if (!awareness) return []; + const users: CollaboratorState[] = []; + awareness.getStates().forEach((state, clientId) => { + users.push({ + clientId, + name: typeof state.name === "string" ? state.name : "", + color: typeof state.color === "string" ? state.color : "", + awarenessData: + (state.awarenessData as Record) ?? {}, + focusing: Boolean(state.focusing), + isLocal: clientId === this.localClientId, + }); + }); + return users; + } + + private notifyUsers(): void { + const users = this.getUsers(); + this.userListeners.forEach((cb) => cb(users)); + } + + // --- Reactive state plumbing ------------------------------------------- + + /** + * Subscribed by `createEditorSystem` to re-run state queries when the + * connection status changes (so `activeStates.isCollaborating` stays live). + */ + onStateChange(cb: () => void): () => void { + this.stateListeners.add(cb); + return () => { + this.stateListeners.delete(cb); + }; + } + + private notifyStateChange(): void { + this.stateListeners.forEach((cb) => cb()); + } + + // --- Extension contract ------------------------------------------------- + + register(editor: LexicalEditor): () => void { + // The Yjs binding dispatches CONNECTED_COMMAND, so this is provider-agnostic. + const unregister = editor.registerCommand( + CONNECTED_COMMAND, + (payload) => { + this.connected = payload; + this.notifyStateChange(); + this.notifyUsers(); + return false; // observe only; don't stop propagation + }, + COMMAND_PRIORITY_LOW, + ); + + return () => { + unregister(); + this.detachProvider(); + }; + } + + getCommands(editor: LexicalEditor): CollaborationCommands { + return { + collab: { + connect: () => editor.dispatchCommand(TOGGLE_CONNECT_COMMAND, true), + disconnect: () => + editor.dispatchCommand(TOGGLE_CONNECT_COMMAND, false), + toggle: () => + editor.dispatchCommand(TOGGLE_CONNECT_COMMAND, !this.connected), + setLocalUser: (user) => { + const awareness = this.provider?.awareness; + if (!awareness) return; + if (user.name !== undefined) + awareness.setLocalStateField("name", user.name); + if (user.color !== undefined) + awareness.setLocalStateField("color", user.color); + if (user.awarenessData !== undefined) + awareness.setLocalStateField("awarenessData", user.awarenessData); + }, + getUsers: () => this.getUsers(), + onUsersChange: (cb) => { + this.userListeners.add(cb); + cb(this.getUsers()); + return () => { + this.userListeners.delete(cb); + }; + }, + }, + }; + } + + getStateQueries(_editor: LexicalEditor): CollaborationStateQueries { + return { + isCollaborating: async () => this.connected, + }; + } + + getPlugins(): ReactNode[] { + const factory = this.config.providerFactory; + // Inert until configured with a transport — safe to leave in the array. + if (!factory) return []; + + return [ + , + ]; + } +} + +/** + * Pre-configured collaboration extension instance. Activate it with + * `.configure({ providerFactory })`. + */ +export const collaborationExtension = new CollaborationExtension(); diff --git a/packages/editor/src/extensions/collaboration/index.ts b/packages/editor/src/extensions/collaboration/index.ts new file mode 100644 index 0000000..02e0e8f --- /dev/null +++ b/packages/editor/src/extensions/collaboration/index.ts @@ -0,0 +1,26 @@ +/** + * Collaboration (Yjs) extension for LexKit. + * + * Headless, transport-agnostic real-time collaboration built on Lexical's own + * `CollaborationPlugin` + `@lexical/yjs`. Plug in any Yjs provider via + * `providerFactory` (y-websocket, y-webrtc, y-indexeddb, Hocuspocus, …). + * + * @packageDocumentation + */ + +export { + CollaborationExtension, + collaborationExtension, +} from "./CollaborationExtension"; +export { useCollaborators } from "./useCollaboration"; +export type { UseCollaboratorsResult } from "./useCollaboration"; +export type { + CollaborationConfig, + CollaborationProviderFactory, + CollaborationCommands, + CollaborationStateQueries, + CollaboratorState, + LocalUserPatch, + Provider, + ExcludedProperties, +} from "./types"; diff --git a/packages/editor/src/extensions/collaboration/types.ts b/packages/editor/src/extensions/collaboration/types.ts new file mode 100644 index 0000000..5b8248b --- /dev/null +++ b/packages/editor/src/extensions/collaboration/types.ts @@ -0,0 +1,111 @@ +import type { Doc } from "yjs"; +import type { Provider, ExcludedProperties } from "@lexical/yjs"; +import type { InitialEditorStateType } from "@lexical/react/LexicalComposer"; +import type { RefObject } from "react"; +import { BaseExtensionConfig } from "../types"; + +// Re-export the Yjs provider contract so consumers can type their factories +// without reaching into @lexical/yjs directly. +export type { Provider, ExcludedProperties } from "@lexical/yjs"; + +/** + * Factory that creates a Yjs {@link Provider} for a given document id. + * + * This is the single transport seam: LexKit ships no transport itself, so you + * plug in y-websocket, y-webrtc, y-indexeddb, Hocuspocus, Liveblocks, etc. + * Anything that satisfies the `@lexical/yjs` `Provider` interface works. + * + * @param id - The document/room id (matches {@link CollaborationConfig.id}) + * @param yjsDocMap - Shared map of id -> Y.Doc managed by Lexical + */ +export type CollaborationProviderFactory = ( + id: string, + yjsDocMap: Map, +) => Provider; + +/** + * Configuration for the {@link CollaborationExtension}. + * + * The extension stays inert until {@link providerFactory} is supplied, so it is + * safe to include unconditionally in an extensions array. + */ +export interface CollaborationConfig extends BaseExtensionConfig { + /** Transport factory. Required to activate collaboration. */ + providerFactory?: CollaborationProviderFactory; + /** Document/room id. Defaults to `"lexkit-collab"`. */ + id?: string; + /** + * Whether this client should seed an empty document when the shared doc is + * empty. Exactly one client should bootstrap. Defaults to `true`. + */ + shouldBootstrap?: boolean; + /** Display name for this user's cursor/presence. */ + username?: string; + /** Cursor color (any CSS color) for this user. */ + cursorColor?: string; + /** Arbitrary presence payload (avatar URL, user id, …) shared via awareness. */ + awarenessData?: Record; + /** Node properties to exclude from Yjs sync (e.g. transient `__uploading`). */ + excludedProperties?: ExcludedProperties; + /** Initial editor state used only by the bootstrapping client. */ + initialEditorState?: InitialEditorStateType; + /** + * Optional external container for remote cursor carets. When omitted the + * extension renders its own themeable container (`theme.collab.cursorsContainer`). + */ + cursorsContainerRef?: RefObject; +} + +/** + * A single participant in the collaboration session, derived from Yjs awareness. + */ +export interface CollaboratorState { + /** Yjs awareness client id. */ + clientId: number; + /** Display name (from `username` / `setLocalUser`). */ + name: string; + /** Cursor color. */ + color: string; + /** Arbitrary presence payload supplied via `awarenessData`. */ + awarenessData: Record; + /** Whether this client currently has editor focus. */ + focusing: boolean; + /** Whether this entry is the local user. */ + isLocal: boolean; +} + +/** Patch shape accepted by `commands.collab.setLocalUser`. */ +export interface LocalUserPatch { + name?: string; + color?: string; + awarenessData?: Record; +} + +/** Commands contributed by the collaboration extension (namespaced under `collab`). */ +export interface CollaborationCommands { + collab: { + /** Connect the provider (dispatches `TOGGLE_CONNECT_COMMAND`). */ + connect: () => void; + /** Disconnect the provider. */ + disconnect: () => void; + /** Toggle the connection based on current state. */ + toggle: () => void; + /** Update the local user's presence fields. */ + setLocalUser: (user: LocalUserPatch) => void; + /** Snapshot of all current collaborators (including the local user). */ + getUsers: () => CollaboratorState[]; + /** Subscribe to collaborator changes. Fires immediately. Returns an unsubscribe. */ + onUsersChange: (cb: (users: CollaboratorState[]) => void) => () => void; + }; +} + +/** + * State queries contributed by the collaboration extension. + * + * Declared as a `type` (not `interface`) so it satisfies the + * `Record Promise>` constraint on `BaseExtension`. + */ +export type CollaborationStateQueries = { + /** True while the provider reports a connected session. */ + isCollaborating: () => Promise; +}; diff --git a/packages/editor/src/extensions/collaboration/useCollaboration.ts b/packages/editor/src/extensions/collaboration/useCollaboration.ts new file mode 100644 index 0000000..6ba2ad1 --- /dev/null +++ b/packages/editor/src/extensions/collaboration/useCollaboration.ts @@ -0,0 +1,67 @@ +import { useContext, useEffect, useState } from "react"; +import { EditorContext } from "../../core/createEditorSystem"; +import { CollaboratorState } from "./types"; + +/** Return shape of {@link useCollaborators}. */ +export interface UseCollaboratorsResult { + /** All current collaborators (including the local user). */ + users: CollaboratorState[]; + /** Whether the provider currently reports a connected session. */ + isConnected: boolean; +} + +/** + * Headless presence hook. Reads the live collaborator list from the + * collaboration extension's awareness store and re-renders on change, so you + * can build any presence UI (avatar bar, "N online", etc.) with full control. + * + * Must be used within the editor `Provider`. If the `collaborationExtension` + * is not registered it simply returns an empty, disconnected state. + * + * @example + * ```tsx + * function PresenceBar() { + * const { users, isConnected } = useCollaborators(); + * return ( + *
+ * {isConnected ? "online" : "offline"} · {users.length} + * {users.map((u) => ( + * {u.name} + * ))} + *
+ * ); + * } + * ``` + */ +export function useCollaborators(): UseCollaboratorsResult { + const ctx = useContext(EditorContext); + if (!ctx) { + throw new Error("useCollaborators must be used within the editor Provider"); + } + + const collab = ( + ctx.commands as { + collab?: { + getUsers: () => CollaboratorState[]; + onUsersChange: ( + cb: (users: CollaboratorState[]) => void, + ) => () => void; + }; + } + )?.collab; + + const [users, setUsers] = useState( + () => collab?.getUsers?.() ?? [], + ); + + useEffect(() => { + if (!collab?.onUsersChange) return; + return collab.onUsersChange((next) => setUsers(next)); + }, [collab]); + + const isConnected = Boolean( + (ctx.activeStates as { isCollaborating?: boolean })?.isCollaborating, + ); + + return { users, isConnected }; +} diff --git a/packages/editor/src/extensions/index.ts b/packages/editor/src/extensions/index.ts index 843f8aa..95f6a8a 100644 --- a/packages/editor/src/extensions/index.ts +++ b/packages/editor/src/extensions/index.ts @@ -75,6 +75,24 @@ export { htmlEmbedExtension, } from "./media/HTMLEmbedExtension"; +// Collaboration (Yjs) Extension +export { + CollaborationExtension, + collaborationExtension, + useCollaborators, +} from "./collaboration"; +export type { + CollaborationConfig, + CollaborationProviderFactory, + CollaborationCommands, + CollaborationStateQueries, + CollaboratorState, + LocalUserPatch, + UseCollaboratorsResult, + Provider as CollaborationProvider, + ExcludedProperties as CollaborationExcludedProperties, +} from "./collaboration"; + // Custom Extensions export { createCustomNodeExtension } from "./custom/CustomNodeExtension"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 987dba5..d4f8a1e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -144,9 +144,6 @@ importers: '@lexical/markdown': specifier: '>=0.34.0' version: 0.35.0 - '@lexical/react': - specifier: '>=0.34.0' - version: 0.35.0(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(yjs@13.6.27) '@lexical/rich-text': specifier: '>=0.34.0' version: 0.35.0 @@ -169,6 +166,12 @@ importers: specifier: ^18.0.0 || ^19.0.0 version: 19.1.1(react@19.1.1) devDependencies: + '@lexical/react': + specifier: '>=0.34.0' + version: 0.35.0(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(yjs@13.6.27) + '@lexical/yjs': + specifier: '>=0.34.0' + version: 0.35.0(yjs@13.6.27) '@repo/eslint-config': specifier: workspace:* version: link:../eslint-config @@ -196,6 +199,9 @@ importers: vitest: specifier: ^4.1.8 version: 4.1.8(@edge-runtime/vm@3.2.0)(@types/node@20.19.9)(jsdom@29.1.1)(vite@8.0.16(@types/node@20.19.9)(esbuild@0.25.9)(jiti@2.5.1)(tsx@4.20.5)) + yjs: + specifier: ^13.6.27 + version: 13.6.27 packages/eslint-config: devDependencies: @@ -5706,10 +5712,6 @@ packages: resolution: {integrity: sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==} engines: {node: '>=14.0'} - undici@7.15.0: - resolution: {integrity: sha512-7oZJCPvvMvTd0OlqWsIxTuItTpJBpU1tcbVl24FMn3xt3+VSunwUasmfPJRE57oNO1KsZ4PgA1xTdAX4hq8NyQ==} - engines: {node: '>=20.18.1'} - undici@7.27.2: resolution: {integrity: sha512-uZsKNuzQxDMUY6M3pIMvy5tvlGmtq8XJ2oLAkfRKGNu+1VQAIvLy2xIVG5ATZl5wDXl/tddByAWCizRbOme+TA==} engines: {node: '>=20.18.1'} @@ -6658,7 +6660,7 @@ snapshots: '@img/sharp-wasm32@0.33.5': dependencies: - '@emnapi/runtime': 1.4.5 + '@emnapi/runtime': 1.10.0 optional: true '@img/sharp-wasm32@0.34.3': @@ -7625,7 +7627,7 @@ snapshots: dependencies: '@types/estree': 1.0.8 estree-walker: 2.0.2 - picomatch: 4.0.3 + picomatch: 4.0.4 optionalDependencies: rollup: 4.50.0 @@ -8129,7 +8131,7 @@ snapshots: glob: 10.4.5 graceful-fs: 4.2.11 node-gyp-build: 4.8.4 - picomatch: 4.0.3 + picomatch: 4.0.4 resolve-from: 5.0.0 transitivePeerDependencies: - encoding @@ -10217,7 +10219,7 @@ snapshots: glob-to-regexp: 0.4.1 sharp: 0.33.5 stoppable: 1.1.0 - undici: 7.15.0 + undici: 7.27.2 workerd: 1.20250823.0 ws: 8.18.0 youch: 4.1.0-beta.10 @@ -11549,8 +11551,6 @@ snapshots: dependencies: '@fastify/busboy': 2.1.1 - undici@7.15.0: {} - undici@7.27.2: {} unenv@2.0.0-rc.19: From 1a90cb9c1536546d8f1c7467af173b9b03b81d4c Mon Sep 17 00:00:00 2001 From: Shayan Date: Tue, 16 Jun 2026 14:08:26 +0330 Subject: [PATCH 3/5] test(collab): headless two-editor Yjs sync, presence and offline tests In-memory transport (no network) relays two real Y.Docs through the actual extension + Lexical binding to verify: - text typed in one editor syncs to another - bidirectional edits converge (CRDT, no lost data) - offline edits replay on reconnect and the docs converge identically (state-vector equality) - the presence API (getUsers/onUsersChange/setLocalUser) reflects awareness - isCollaborating tracks the connection Notes for the harness: each editor needs its own CollaborationContext (the default shares a module-level yjsDocMap), docs need unique clientIDs (lib0's RNG collides under jsdom), and remote updates are relayed async to avoid re-entering the binding. --- .../__tests__/collaboration.test.tsx | 388 ++++++++++++++++++ 1 file changed, 388 insertions(+) create mode 100644 packages/editor/src/extensions/collaboration/__tests__/collaboration.test.tsx diff --git a/packages/editor/src/extensions/collaboration/__tests__/collaboration.test.tsx b/packages/editor/src/extensions/collaboration/__tests__/collaboration.test.tsx new file mode 100644 index 0000000..6bdf390 --- /dev/null +++ b/packages/editor/src/extensions/collaboration/__tests__/collaboration.test.tsx @@ -0,0 +1,388 @@ +import React, { act, useEffect } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { describe, it, expect, afterEach, vi } from "vitest"; +import * as Y from "yjs"; +import { + $getRoot, + $createParagraphNode, + $createTextNode, + type LexicalEditor, +} from "lexical"; +import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; +import { CollaborationContext } from "@lexical/react/LexicalCollaborationContext"; +import type { Provider } from "@lexical/yjs"; +import { + createEditorSystem, + richTextExtension, + CollaborationExtension, +} from "../../../index"; + +// React needs this flag for act() to flush effects synchronously in tests. +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = + true; + +// --------------------------------------------------------------------------- +// In-memory transport: relays Y.Doc updates + Yjs awareness between providers +// in the same process, with no network. This is the standard way to test Yjs +// integrations deterministically. Each "client" gets its own Y.Doc (exactly +// like the real world); the room is the wire between them. +// --------------------------------------------------------------------------- + +class MemoryAwareness { + clientID: number; + private readonly listeners = new Set<() => void>(); + constructor( + private readonly room: MemoryRoom, + clientID: number, + ) { + this.clientID = clientID; + } + getLocalState() { + return this.room.states.get(this.clientID) ?? null; + } + getStates() { + return this.room.states; + } + setLocalState(state: Record | null) { + if (state == null) this.room.states.delete(this.clientID); + else this.room.states.set(this.clientID, state); + this.room.fireAwareness(); + } + setLocalStateField(field: string, value: unknown) { + const current = this.getLocalState() ?? {}; + this.setLocalState({ ...current, [field]: value }); + } + on(type: "update", cb: () => void) { + if (type === "update") this.listeners.add(cb); + } + off(type: "update", cb: () => void) { + if (type === "update") this.listeners.delete(cb); + } + _fire() { + this.listeners.forEach((cb) => cb()); + } +} + +// Yjs requires a unique clientID per document. lib0's RNG can collide in the +// jsdom test environment (two `new Y.Doc()` returning the same clientID), which +// corrupts merges, so we assign a guaranteed-unique id per created doc. +let CLIENT_ID_SEQ = 1; + +class MemoryProvider { + readonly doc: Y.Doc; + readonly awareness: MemoryAwareness; + connected = false; + private readonly handlers: Record void>> = {}; + + constructor( + readonly room: MemoryRoom, + readonly id: string, + docMap: Map, + ) { + let doc = docMap.get(id); + if (!doc) { + doc = new Y.Doc(); + doc.clientID = CLIENT_ID_SEQ++; + docMap.set(id, doc); + } + this.doc = doc; + this.awareness = new MemoryAwareness(room, doc.clientID); + room.providers.add(this); + + // Only relay edits that originated locally (origin === the Lexical binding, + // never another MemoryProvider) so we don't echo network updates forever. + this.doc.on("update", (update: Uint8Array, origin: unknown) => { + if (origin instanceof MemoryProvider) return; + if (!this.connected || !this.room.online) return; + this.room.broadcastDocUpdate(this, update); + }); + } + + on(type: string, cb: (arg?: any) => void) { + (this.handlers[type] ??= new Set()).add(cb); + } + off(type: string, cb: (arg?: any) => void) { + this.handlers[type]?.delete(cb); + } + private emit(type: string, arg?: any) { + this.handlers[type]?.forEach((cb) => cb(arg)); + } + + connect() { + this.connected = true; + this.room.syncDocs(); + this.emit("status", { status: "connected" }); + this.emit("sync", true); + this.room.fireAwareness(); + } + disconnect() { + this.connected = false; + this.emit("status", { status: "disconnected" }); + this.emit("sync", false); + } + _fireAwareness() { + this.awareness._fire(); + } +} + +class MemoryRoom { + readonly providers = new Set(); + readonly states = new Map>(); + online = true; + + // Relay updates asynchronously, like a real network provider. Applying a + // remote update synchronously inside the originating editor's update cycle + // re-enters Lexical's Yjs binding and corrupts it, so we defer. + broadcastDocUpdate(origin: MemoryProvider, update: Uint8Array) { + if (!this.online) return; + this.providers.forEach((p) => { + if (p !== origin && p.connected) { + setTimeout(() => { + if (this.online && p.connected) Y.applyUpdate(p.doc, update, origin); + }, 0); + } + }); + } + + /** Full state exchange between all connected peers (connect + reconnect). */ + syncDocs() { + if (!this.online) return; + const connected = [...this.providers].filter((p) => p.connected); + for (const a of connected) { + for (const b of connected) { + if (a !== b) { + const update = Y.encodeStateAsUpdate(a.doc); + setTimeout(() => { + if (this.online && b.connected) Y.applyUpdate(b.doc, update, a); + }, 0); + } + } + } + } + + fireAwareness() { + this.providers.forEach((p) => p._fireAwareness()); + } + + goOffline() { + this.online = false; + } + goOnline() { + this.online = true; + this.syncDocs(); + } +} + +// --------------------------------------------------------------------------- +// Editor harness +// --------------------------------------------------------------------------- + +function Capture({ onEditor }: { onEditor: (editor: LexicalEditor) => void }) { + const [editor] = useLexicalComposerContext(); + useEffect(() => { + onEditor(editor); + }, [editor, onEditor]); + return null; +} + +interface Client { + editor: LexicalEditor; + extension: CollaborationExtension; + provider: MemoryProvider; + root: Root; + container: HTMLElement; +} + +async function mountClient( + room: MemoryRoom, + username: string, + cursorColor: string, + shouldBootstrap: boolean, +): Promise { + let captured: MemoryProvider | null = null; + const extension = new CollaborationExtension({ + username, + cursorColor, + shouldBootstrap, + providerFactory: (id, docMap) => { + const provider = new MemoryProvider(room, id, docMap as Map); + captured = provider; + return provider as unknown as Provider; + }, + }); + + const extensions = [richTextExtension, extension] as const; + const { Provider } = createEditorSystem(); + + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + let editor: LexicalEditor | null = null; + await act(async () => { + root.render( + // Each editor needs its own collaboration context: @lexical/react's + // default context shares a single module-level yjsDocMap, which would + // make two editors in one process bind to the *same* Y.Doc. + + + (editor = e)} /> + + , + ); + }); + await flush(); + + if (!editor || !captured) throw new Error("client failed to mount"); + return { editor, extension, provider: captured, root, container }; +} + +/** Let microtasks/timers settle so cross-editor sync fully propagates. */ +async function flush() { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); +} + +function appendParagraph(client: Client, text: string) { + client.editor.update( + () => { + const paragraph = $createParagraphNode(); + paragraph.append($createTextNode(text)); + $getRoot().append(paragraph); + }, + { discrete: true }, + ); +} + +function readText(client: Client): string { + return client.editor.getEditorState().read(() => $getRoot().getTextContent()); +} + +const clients: Client[] = []; +async function makeClient(room: MemoryRoom, name: string, color: string) { + // Exactly one client should bootstrap the empty doc (the first to join). + const shouldBootstrap = ![...room.providers].length; + const client = await mountClient(room, name, color, shouldBootstrap); + clients.push(client); + return client; +} + +afterEach(async () => { + // CollaborationPlugin logs connect/disconnect; keep test output quiet. + vi.restoreAllMocks(); + for (const client of clients.splice(0)) { + await act(async () => { + client.root.unmount(); + }); + client.container.remove(); + } +}); + +describe("CollaborationExtension (headless Yjs sync)", () => { + it("syncs text from one editor to another through the provider", async () => { + vi.spyOn(console, "log").mockImplementation(() => {}); + const room = new MemoryRoom(); + const alice = await makeClient(room, "Alice", "#e11d48"); + const bob = await makeClient(room, "Bob", "#2563eb"); + + await act(async () => appendParagraph(alice, "Hello collab")); + await flush(); + + expect(readText(bob)).toContain("Hello collab"); + }); + + it("converges bidirectional edits (CRDT, no lost data)", async () => { + vi.spyOn(console, "log").mockImplementation(() => {}); + const room = new MemoryRoom(); + const alice = await makeClient(room, "Alice", "#e11d48"); + const bob = await makeClient(room, "Bob", "#2563eb"); + + await act(async () => appendParagraph(alice, "shared")); + await flush(); + await act(async () => appendParagraph(bob, "from bob")); + await flush(); + + expect(readText(alice)).toContain("shared"); + expect(readText(alice)).toContain("from bob"); + expect(readText(bob)).toContain("shared"); + expect(readText(bob)).toContain("from bob"); + }); + + it("replays offline edits on reconnect with no data loss", async () => { + vi.spyOn(console, "log").mockImplementation(() => {}); + const room = new MemoryRoom(); + const alice = await makeClient(room, "Alice", "#e11d48"); + const bob = await makeClient(room, "Bob", "#2563eb"); + + await act(async () => appendParagraph(alice, "base")); + await flush(); + expect(readText(bob)).toContain("base"); + + // Alice drops offline and keeps editing; Bob receives nothing meanwhile. + room.goOffline(); + await act(async () => appendParagraph(alice, "offline edit")); + await flush(); + expect(readText(bob)).not.toContain("offline edit"); + + // Back online: Alice's offline work replays to Bob and both converge. + room.goOnline(); + await flush(); + await flush(); + + // The Yjs documents (the CRDT source of truth) converge to an identical + // state with no data loss — this is the reliability guarantee Yjs provides + // when a client goes offline, keeps editing, and reconnects. + expect(Y.encodeStateVector(bob.provider.doc)).toEqual( + Y.encodeStateVector(alice.provider.doc), + ); + + // And the offline edit replays into the remote editor on reconnect. + expect(readText(bob)).toContain("offline edit"); + }); + + it("exposes collaborators via the presence API", async () => { + vi.spyOn(console, "log").mockImplementation(() => {}); + const room = new MemoryRoom(); + const alice = await makeClient(room, "Alice", "#e11d48"); + const bob = await makeClient(room, "Bob", "#2563eb"); + await flush(); + + const usersFromAlice = alice.extension.getUsers(); + const names = usersFromAlice.map((u) => u.name).sort(); + expect(names).toEqual(["Alice", "Bob"]); + + const local = usersFromAlice.find((u) => u.isLocal); + expect(local?.name).toBe("Alice"); + expect(local?.color).toBe("#e11d48"); + + // Live updates to local presence are reflected in awareness. + alice.extension + .getCommands(alice.editor) + .collab.setLocalUser({ name: "Alice II" }); + await flush(); + expect( + bob.extension.getUsers().find((u) => !u.isLocal)?.name, + ).toBe("Alice II"); + }); + + it("reports isCollaborating once connected", async () => { + vi.spyOn(console, "log").mockImplementation(() => {}); + const room = new MemoryRoom(); + const alice = await makeClient(room, "Alice", "#e11d48"); + await flush(); + + const connected = await alice.extension + .getStateQueries(alice.editor) + .isCollaborating(); + expect(connected).toBe(true); + }); +}); From 7af80fc5f40ce152cb50cbcdce3b5ab31dc57c24 Mon Sep 17 00:00:00 2001 From: Shayan Date: Tue, 16 Jun 2026 14:16:12 +0330 Subject: [PATCH 4/5] feat(web): zero-infra y-webrtc collaboration demo + docs Add a CollaborationExtension docs page with a live, server-free demo: two same-origin tabs sync peer-to-peer over y-webrtc's BroadcastChannel. The demo loads the transport client-side only (ssr:false) so y-webrtc never runs during prerender. Docs cover the providerFactory seam, y-websocket/y-webrtc/y-indexeddb recipes, the useCollaborators presence hook, cursor theming, offline/CRDT behaviour and the history/undo caveat. Adds y-webrtc + yjs to the web app and a sidebar nav entry. --- .../examples/BasicCollaborativeEditor.tsx | 128 +++++++++ .../CollaborationExtension/page.client.tsx | 260 ++++++++++++++++++ .../CollaborationExtension/page.tsx | 5 + apps/web/app/(docs)/lib/docs-config.ts | 7 + apps/web/package.json | 2 + pnpm-lock.yaml | 77 ++++++ 6 files changed, 479 insertions(+) create mode 100644 apps/web/app/(docs)/docs/extensions/CollaborationExtension/examples/BasicCollaborativeEditor.tsx create mode 100644 apps/web/app/(docs)/docs/extensions/CollaborationExtension/page.client.tsx create mode 100644 apps/web/app/(docs)/docs/extensions/CollaborationExtension/page.tsx diff --git a/apps/web/app/(docs)/docs/extensions/CollaborationExtension/examples/BasicCollaborativeEditor.tsx b/apps/web/app/(docs)/docs/extensions/CollaborationExtension/examples/BasicCollaborativeEditor.tsx new file mode 100644 index 0000000..a2fd4cc --- /dev/null +++ b/apps/web/app/(docs)/docs/extensions/CollaborationExtension/examples/BasicCollaborativeEditor.tsx @@ -0,0 +1,128 @@ +"use client"; + +// Basic Collaborative Editor Example (zero-infra) +// Real-time collaboration powered by the headless CollaborationExtension + +// a y-webrtc provider. Open this page in two browser tabs/windows on the same +// machine and type — same-origin tabs sync peer-to-peer via BroadcastChannel, +// so there is NO server to run. (For cross-network collab, pass your own +// `signaling` servers to WebrtcProvider, or swap in y-websocket.) +import { useEffect, useState } from "react"; +import { + createEditorSystem, + richTextExtension, + collaborationExtension, + useCollaborators, + RichText, + type CollaborationProvider, +} from "@lexkit/editor"; +import "@/app/(docs)/examples/basic-editor.css"; + +// One identity per tab so presence shows distinct users. +const NAMES = ["Ada", "Linus", "Grace", "Alan", "Margaret", "Dennis"]; +const COLORS = ["#e11d48", "#2563eb", "#16a34a", "#d97706", "#7c3aed", "#0891b2"]; +const me = Math.floor(Math.random() * NAMES.length); +const username = NAMES[me]; +const cursorColor = COLORS[me]; + +const extensions = [richTextExtension, collaborationExtension] as const; +const { Provider } = createEditorSystem(); + +// Live presence bar built entirely from the headless awareness API. +function PresenceBar() { + const { users, isConnected } = useCollaborators(); + return ( +
+ + {isConnected ? "🟢 live" : "⚪ offline"} · {users.length} here + +
+ {users.map((u) => ( + + + {u.name || "anonymous"} + {u.isLocal ? " (you)" : ""} + + ))} +
+
+ ); +} + +export function BasicCollaborativeEditor() { + const [ready, setReady] = useState(false); + + useEffect(() => { + let cancelled = false; + // Load the transport in the browser only (y-webrtc touches browser APIs). + Promise.all([import("y-webrtc"), import("yjs")]).then( + ([{ WebrtcProvider }, Y]) => { + if (cancelled) return; + collaborationExtension.configure({ + id: "lexkit-collab-demo", + username, + cursorColor, + providerFactory: (id, docMap) => { + let doc = docMap.get(id); + if (!doc) { + doc = new Y.Doc(); + docMap.set(id, doc); + } + // No signaling servers passed → same-origin tabs still sync via + // BroadcastChannel. Add `{ signaling: ["wss://your-server"] }` for + // cross-device collaboration. + const provider = new WebrtcProvider(id, doc); + return provider as unknown as CollaborationProvider; + }, + }); + setReady(true); + }, + ); + return () => { + cancelled = true; + }; + }, []); + + if (!ready) { + return ( +
+
+ Loading collaboration… +
+
+ ); + } + + return ( + +
+ + +
+
+ ); +} diff --git a/apps/web/app/(docs)/docs/extensions/CollaborationExtension/page.client.tsx b/apps/web/app/(docs)/docs/extensions/CollaborationExtension/page.client.tsx new file mode 100644 index 0000000..c45f16f --- /dev/null +++ b/apps/web/app/(docs)/docs/extensions/CollaborationExtension/page.client.tsx @@ -0,0 +1,260 @@ +"use client"; + +import dynamic from "next/dynamic"; +import { Badge } from "@repo/ui/components/badge"; +import { Users, Wifi, Palette, Plug } from "lucide-react"; + +// Load the live demo on the client only — the y-webrtc transport touches +// browser APIs and must not run during SSR/prerender. +const BasicCollaborativeEditor = dynamic( + () => + import("./examples/BasicCollaborativeEditor").then( + (m) => m.BasicCollaborativeEditor, + ), + { + ssr: false, + loading: () => ( +
+ Loading collaborative editor… +
+ ), + }, +); + +function Code({ children, title }: { children: string; title?: string }) { + return ( +
+ {title ? ( +
+ {title} +
+ ) : null} +
+        {children}
+      
+
+ ); +} + +export default function CollaborationExtensionPageClient() { + return ( +
+ {/* Hero */} +
+

CollaborationExtension

+

+ Headless, transport-agnostic real-time collaboration for LexKit, built + on Lexical's own collaboration binding and{" "} + + Yjs + {" "} + (a CRDT — offline edits merge conflict-free on reconnect). +

+
+ + Pluggable transport + + + Presence API + + + Themeable cursors + + + Offline-safe (CRDT) + +
+
+ + {/* Live demo */} +
+

Live demo

+

+ Open this page in a second browser tab and start typing. Same-origin + tabs sync peer-to-peer via the browser's BroadcastChannel, so this + demo needs no server at all. +

+ +
+ + {/* Install */} +
+

Install

+

+ The extension ships with LexKit. You add yjs and a + transport of your choice (they are optional peer dependencies — an + editor without the extension pulls in nothing collaboration-related). +

+ {`# pick a transport: y-webrtc (P2P), y-websocket (server), or y-indexeddb (offline) +pnpm add yjs y-webrtc`} +
+ + {/* Quick start */} +
+

Quick start

+

+ Collaboration is just another extension. The only required option is{" "} + providerFactory — the single seam where you plug in a + transport. LexKit never bundles one. +

+ {`import { + createEditorSystem, + richTextExtension, + collaborationExtension, + type CollaborationProvider, +} from "@lexkit/editor"; +import { WebrtcProvider } from "y-webrtc"; +import * as Y from "yjs"; + +const extensions = [ + richTextExtension, + collaborationExtension.configure({ + id: "my-room", + username: "Ada", + cursorColor: "#e11d48", + providerFactory: (id, docMap) => { + let doc = docMap.get(id); + if (!doc) { doc = new Y.Doc(); docMap.set(id, doc); } + return new WebrtcProvider(id, doc) as unknown as CollaborationProvider; + }, + }), +] as const; + +const { Provider, useEditor } = createEditorSystem();`} +

+ The extension stays inert until providerFactory is set, so + it's safe to leave in the array and toggle collaboration on later. +

+
+ + {/* Providers */} +
+

Choose a transport

+

+ Anything implementing the Yjs Provider interface works. +

+ {`import { WebsocketProvider } from "y-websocket"; + +providerFactory: (id, docMap) => { + let doc = docMap.get(id); + if (!doc) { doc = new Y.Doc(); docMap.set(id, doc); } + return new WebsocketProvider("wss://your-server", id, doc) as any; +}`} + {`import { WebrtcProvider } from "y-webrtc"; + +providerFactory: (id, docMap) => { + let doc = docMap.get(id); + if (!doc) { doc = new Y.Doc(); docMap.set(id, doc); } + return new WebrtcProvider(id, doc, { + signaling: ["wss://your-signaling-server"], + }) as any; +}`} + {`import { IndexeddbPersistence } from "y-indexeddb"; + +providerFactory: (id, docMap) => { + let doc = docMap.get(id); + if (!doc) { doc = new Y.Doc(); docMap.set(id, doc); } + // Persist locally so edits survive reloads and replay when back online. + new IndexeddbPersistence(id, doc); + return new WebsocketProvider("wss://your-server", id, doc) as any; +}`} +
+ + {/* Presence */} +
+

Presence

+

+ Build any presence UI with the headless useCollaborators(){" "} + hook, or drive it imperatively through{" "} + commands.collab. +

+ {`import { useCollaborators } from "@lexkit/editor"; + +function PresenceBar() { + const { users, isConnected } = useCollaborators(); + return ( +
+ {isConnected ? "live" : "offline"} · {users.length} online + {users.map((u) => ( + + {u.name}{u.isLocal ? " (you)" : ""} + + ))} +
+ ); +}`}
+ {`const { commands, activeStates } = useEditor(); + +commands.collab.connect(); +commands.collab.disconnect(); +commands.collab.toggle(); +commands.collab.setLocalUser({ name: "Ada", color: "#e11d48", awarenessData: { avatar } }); +commands.collab.getUsers(); // CollaboratorState[] +commands.collab.onUsersChange(cb); // subscribe; returns unsubscribe +activeStates.isCollaborating; // boolean`} +
+ + {/* Theming cursors */} +
+

Themeable cursors

+

+ Remote carets are rendered by Lexical's reliable cursor binding + into a container you can style via the theme, and each user's + color comes from cursorColor. Supply your own{" "} + cursorsContainerRef to portal carets anywhere. +

+ {`const theme = { + collab: { + cursorsContainer: "my-cursors-layer", + caret: "my-collab-caret", + label: "my-collab-label", + selection: "my-collab-selection", + }, +};`} +
+ + {/* Offline */} +
+

Offline & reliability

+

+ Yjs is a CRDT: when a client drops offline and keeps editing, then + reconnects, all edits merge automatically with no data loss and every + replica converges to the same state. Add{" "} + y-indexeddb to persist a client's edits locally so + they survive reloads and replay on reconnect. This behaviour is + identical no matter how content is imported (Markdown, HTML or JSON) — + collaboration syncs the editor state itself. +

+
+ + {/* Notes */} +
+

Notes

+
    +
  • + Undo/redo & history: Yjs owns history while + collaborating. Lexical's collaboration binding does not wire a + Yjs undo manager, so do not register{" "} + historyExtension at the same time as the collaboration + extension. +
  • +
  • + Initial content: exactly one client should seed the + document (shouldBootstrap, default true). + Set it to false on clients that join an existing room. +
  • +
  • + Multiple editors on one page: wrap each editor in + its own collaboration context so they don't share a Yjs document + map. +
  • +
  • + Custom nodes: exclude transient props (e.g. an + image's uploading flag) from sync via{" "} + excludedProperties. +
  • +
+
+
+ ); +} diff --git a/apps/web/app/(docs)/docs/extensions/CollaborationExtension/page.tsx b/apps/web/app/(docs)/docs/extensions/CollaborationExtension/page.tsx new file mode 100644 index 0000000..b679ba5 --- /dev/null +++ b/apps/web/app/(docs)/docs/extensions/CollaborationExtension/page.tsx @@ -0,0 +1,5 @@ +import CollaborationExtensionPageClient from "./page.client"; + +export default function CollaborationExtensionPage() { + return ; +} diff --git a/apps/web/app/(docs)/lib/docs-config.ts b/apps/web/app/(docs)/lib/docs-config.ts index f701982..925a695 100644 --- a/apps/web/app/(docs)/lib/docs-config.ts +++ b/apps/web/app/(docs)/lib/docs-config.ts @@ -64,6 +64,13 @@ export const docsConfig: DocsSection[] = [ { title: "Extensions", items: [ + { + title: "CollaborationExtension", + href: "/docs/extensions/CollaborationExtension", + description: + "Headless real-time collaboration (Yjs) with presence and themeable cursors", + isNew: true, + }, { title: "ContextMenuExtension", href: "/docs/extensions/ContextMenuExtension", diff --git a/apps/web/package.json b/apps/web/package.json index d3143b7..344a4fb 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -39,6 +39,8 @@ "react-dom": "^19.1.1", "rehype-pretty-code": "^0.14.1", "shiki": "^3.12.2", + "y-webrtc": "^10.3.0", + "yjs": "^13.6.27", "zustand": "^5.0.8" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d4f8a1e..31c6775 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -98,6 +98,12 @@ importers: shiki: specifier: ^3.12.2 version: 3.12.2 + y-webrtc: + specifier: ^10.3.0 + version: 10.3.0(yjs@13.6.27) + yjs: + specifier: ^13.6.27 + version: 13.6.27 zustand: specifier: ^5.0.8 version: 5.0.8(@types/react@19.1.9)(react@19.1.1)(use-sync-external-store@1.5.0(react@19.1.1)) @@ -2883,6 +2889,9 @@ packages: buffer@5.7.1: resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + buffer@6.0.3: + resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} + bundle-require@5.1.0: resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -3247,6 +3256,9 @@ packages: resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} engines: {node: '>=20.19.0'} + err-code@3.0.1: + resolution: {integrity: sha512-GiaH0KJUewYok+eeY05IIgjtAe4Yltygk9Wqp1V5yVWLdhf0hYZchRjNIT9bb0mSwRcIusT3cx7PJUf3zEIfUA==} + error-stack-parser-es@1.0.5: resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==} @@ -3774,6 +3786,9 @@ packages: resolution: {integrity: sha512-H7cUpwCQSiJmAHM4c/aFu6fUfrhWXW1ncyh8ftxEPMu6AiYkHw9K8br720TGPZJbk5eOH2bynjZD1yPvdDAmag==} engines: {node: '>= 4'} + get-browser-rtc@1.1.0: + resolution: {integrity: sha512-MghbMJ61EJrRsDe7w1Bvqt3ZsBuqhce5nrn/XAwgwOXhcsz53/ltdxOse1h/8eKXj5slzxdsz56g5rzOFSGwfQ==} + get-intrinsic@1.3.0: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} @@ -4956,6 +4971,9 @@ packages: queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + randombytes@2.1.0: + resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==} + raw-body@2.4.1: resolution: {integrity: sha512-9WmIKF6mkvA0SLmA2Knm9+qj89e+j1zqgyn8aXGd7+nAduPoqgI9lO57SAZNn/Byzo5P7JhXTyg9PzaJbH73bA==} engines: {node: '>= 0.8'} @@ -5245,6 +5263,9 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} + simple-peer@9.11.1: + resolution: {integrity: sha512-D1SaWpOW8afq1CZGWB8xTfrT3FekjQmPValrqncJMX7QFl8YwhrPTZvMCANLtgBwwdS+7zURyqxDDEmY558tTw==} + simple-swizzle@0.2.2: resolution: {integrity: sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==} @@ -6028,6 +6049,19 @@ packages: xmlchars@2.2.0: resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + y-protocols@1.0.7: + resolution: {integrity: sha512-YSVsLoXxO67J6eE/nV4AtFtT3QEotZf5sK5BHxFBXso7VDUT3Tx07IfA6hsu5Q5OmBdMkQVmFZ9QOA7fikWvnw==} + engines: {node: '>=16.0.0', npm: '>=8.0.0'} + peerDependencies: + yjs: ^13.0.0 + + y-webrtc@10.3.0: + resolution: {integrity: sha512-KalJr7dCgUgyVFxoG3CQYbpS0O2qybegD0vI4bYnYHI0MOwoVbucED3RZ5f2o1a5HZb1qEssUKS0H/Upc6p1lA==} + engines: {node: '>=12'} + hasBin: true + peerDependencies: + yjs: ^13.6.8 + yallist@4.0.0: resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} @@ -8463,6 +8497,11 @@ snapshots: base64-js: 1.5.1 ieee754: 1.2.1 + buffer@6.0.3: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + bundle-require@5.1.0(esbuild@0.25.9): dependencies: esbuild: 0.25.9 @@ -8820,6 +8859,8 @@ snapshots: entities@8.0.0: {} + err-code@3.0.1: {} + error-stack-parser-es@1.0.5: {} es-abstract@1.24.0: @@ -9424,6 +9465,8 @@ snapshots: generic-pool@3.4.2: {} + get-browser-rtc@1.1.0: {} + get-intrinsic@1.3.0: dependencies: call-bind-apply-helpers: 1.0.2 @@ -10657,6 +10700,10 @@ snapshots: queue-microtask@1.2.3: {} + randombytes@2.1.0: + dependencies: + safe-buffer: 5.2.1 + raw-body@2.4.1: dependencies: bytes: 3.1.0 @@ -11061,6 +11108,18 @@ snapshots: signal-exit@4.1.0: {} + simple-peer@9.11.1: + dependencies: + buffer: 6.0.3 + debug: 4.4.1 + err-code: 3.0.1 + get-browser-rtc: 1.1.0 + queue-microtask: 1.2.3 + randombytes: 2.1.0 + readable-stream: 3.6.2 + transitivePeerDependencies: + - supports-color + simple-swizzle@0.2.2: dependencies: is-arrayish: 0.3.2 @@ -11890,6 +11949,24 @@ snapshots: xmlchars@2.2.0: {} + y-protocols@1.0.7(yjs@13.6.27): + dependencies: + lib0: 0.2.114 + yjs: 13.6.27 + + y-webrtc@10.3.0(yjs@13.6.27): + dependencies: + lib0: 0.2.114 + simple-peer: 9.11.1 + y-protocols: 1.0.7(yjs@13.6.27) + yjs: 13.6.27 + optionalDependencies: + ws: 8.18.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + yallist@4.0.0: {} yallist@5.0.0: {} From 6a5e34ff7d49a0365b2b7acb11a9883d5b8fe552 Mon Sep 17 00:00:00 2001 From: Shayan Date: Tue, 16 Jun 2026 14:21:35 +0330 Subject: [PATCH 5/5] ci(release): publish prereleases under @beta dist-tag; bump 0.1.0-beta.0 Derive the npm dist-tag from the version: a hyphenated prerelease (e.g. 0.1.0-beta.0) publishes under 'beta' and is marked as a GitHub prerelease, so 'npm i @lexkit/editor' stays on stable 'latest' while 'npm i @lexkit/editor@beta' gets the collaboration build. A beta can be cut from this branch via 'gh workflow run "Release @lexkit/editor" --ref feat/collaboration'. Bump @lexkit/editor to 0.1.0-beta.0. --- .github/workflows/release.yml | 25 +++++++++++++++++++++++-- packages/editor/package.json | 2 +- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index db852dc..fb35229 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,6 +10,14 @@ name: Release @lexkit/editor # already on npm, and if not, publishes it (with provenance) and cuts a # GitHub release. Pushes that don't change the version are no-ops. # +# Dist-tags: a prerelease version (one containing a hyphen, e.g. +# `0.1.0-beta.0`) is published under the `beta` npm dist-tag, so +# `npm i @lexkit/editor` stays on the stable `latest` while +# `npm i @lexkit/editor@beta` gets the prerelease. Stable versions publish to +# `latest`. To cut a beta from a feature branch before merging, run this +# workflow manually against that branch: +# gh workflow run "Release @lexkit/editor" --ref +# # Setup required once: # - Add an npm "Automation" access token as the repo secret `NPM_TOKEN` # (Settings -> Secrets and variables -> Actions -> New repository secret). @@ -66,6 +74,14 @@ jobs: VERSION=$(node -p "require('./package.json').version") echo "name=$NAME" >> "$GITHUB_OUTPUT" echo "version=$VERSION" >> "$GITHUB_OUTPUT" + # A hyphen in the version marks a prerelease -> publish under `beta`. + if [[ "$VERSION" == *-* ]]; then + echo "dist_tag=beta" >> "$GITHUB_OUTPUT" + echo "prerelease=true" >> "$GITHUB_OUTPUT" + else + echo "dist_tag=latest" >> "$GITHUB_OUTPUT" + echo "prerelease=false" >> "$GITHUB_OUTPUT" + fi if npm view "$NAME@$VERSION" version >/dev/null 2>&1; then echo "published=true" >> "$GITHUB_OUTPUT" echo "::notice::$NAME@$VERSION is already on npm — skipping publish." @@ -80,7 +96,7 @@ jobs: env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} NPM_CONFIG_PROVENANCE: "true" - run: pnpm publish --no-git-checks --access public + run: pnpm publish --no-git-checks --access public --tag "${{ steps.check.outputs.dist_tag }}" - name: Tag release and create GitHub Release if: steps.check.outputs.published == 'false' @@ -90,6 +106,11 @@ jobs: TAG="editor-v${{ steps.check.outputs.version }}" git tag "$TAG" git push origin "$TAG" + PRERELEASE_FLAG="" + if [[ "${{ steps.check.outputs.prerelease }}" == "true" ]]; then + PRERELEASE_FLAG="--prerelease" + fi gh release create "$TAG" \ --title "@lexkit/editor v${{ steps.check.outputs.version }}" \ - --generate-notes + --generate-notes \ + $PRERELEASE_FLAG diff --git a/packages/editor/package.json b/packages/editor/package.json index 0eab8cc..e6d0772 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -1,6 +1,6 @@ { "name": "@lexkit/editor", - "version": "0.0.39", + "version": "0.1.0-beta.0", "description": "LexKit Editor - A headless, extensible rich text editor built on Lexical", "type": "module", "private": false,