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/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/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/package.json b/packages/editor/package.json index 9e82f90..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, @@ -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/__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); + }); +}); 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/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/*"] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 987dba5..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)) @@ -144,9 +150,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 +172,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 +205,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: @@ -2877,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} @@ -3241,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==} @@ -3768,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'} @@ -4950,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'} @@ -5239,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==} @@ -5706,10 +5733,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'} @@ -6026,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==} @@ -6658,7 +6694,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 +7661,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 +8165,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 @@ -8461,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 @@ -8818,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: @@ -9422,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 @@ -10217,7 +10262,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 @@ -10655,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 @@ -11059,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 @@ -11549,8 +11610,6 @@ snapshots: dependencies: '@fastify/busboy': 2.1.1 - undici@7.15.0: {} - undici@7.27.2: {} unenv@2.0.0-rc.19: @@ -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: {}