diff --git a/packages/provider-react/package.json b/packages/provider-react/package.json new file mode 100644 index 000000000..2a4c713ed --- /dev/null +++ b/packages/provider-react/package.json @@ -0,0 +1,35 @@ +{ + "name": "@hocuspocus/provider-react", + "version": "3.4.6-rc.2", + "description": "React bindings for Hocuspocus provider", + "homepage": "https://hocuspocus.dev", + "keywords": ["hocuspocus", "websocket", "provider", "react", "yjs"], + "license": "MIT", + "type": "module", + "main": "dist/hocuspocus-provider-react.cjs", + "module": "dist/hocuspocus-provider-react.esm.js", + "types": "dist/index.d.ts", + "exports": { + "source": { + "import": "./src/index.ts" + }, + "default": { + "import": "./dist/hocuspocus-provider-react.esm.js", + "require": "./dist/hocuspocus-provider-react.cjs", + "types": "./dist/index.d.ts" + } + }, + "files": ["src", "dist"], + "peerDependencies": { + "@hocuspocus/provider": "^3.4.6-rc.2", + "react": "^18.0.0 || ^19.0.0", + "yjs": "^13.6.8" + }, + "devDependencies": { + "@types/react": "^19.0.0", + "react": "^19.0.0" + }, + "repository": { + "url": "https://github.com/ueberdosis/hocuspocus" + } +} diff --git a/packages/provider-react/src/HocuspocusProviderComponent.tsx b/packages/provider-react/src/HocuspocusProviderComponent.tsx new file mode 100644 index 000000000..f008f4b6f --- /dev/null +++ b/packages/provider-react/src/HocuspocusProviderComponent.tsx @@ -0,0 +1,75 @@ +"use client"; + +import { HocuspocusProviderWebsocket } from "@hocuspocus/provider"; +import { useEffect, useMemo, useRef } from "react"; + +import { HocuspocusContext } from "./context.ts"; +import type { HocuspocusProviderComponentProps } from "./types.ts"; + +/** + * HocuspocusProviderComponent manages the WebSocket connection that is shared across all rooms. + * + * This component creates a single WebSocket connection that can be used by multiple + * HocuspocusRoom components, preventing connection overhead when switching between documents. + * + * @example + * ```tsx + * + * + * + * + * + * ``` + */ +export function HocuspocusProviderComponent({ + children, + url, + websocketProvider: externalWebsocketProvider, +}: HocuspocusProviderComponentProps) { + const websocketRef = useRef(null); + const destroyTimeoutRef = useRef | null>(null); + + // Create WebSocket provider once on mount + if (!websocketRef.current && !externalWebsocketProvider) { + websocketRef.current = new HocuspocusProviderWebsocket({ + url: url ?? "", + }); + } + + const websocketProvider = + externalWebsocketProvider ?? + (websocketRef.current as HocuspocusProviderWebsocket); + + // Cleanup on unmount with deferred destruction to handle StrictMode double-mount + useEffect(() => { + if (destroyTimeoutRef.current) { + clearTimeout(destroyTimeoutRef.current); + destroyTimeoutRef.current = null; + } + + return () => { + // Only destroy if we created the websocket (not externally provided) + if (!externalWebsocketProvider) { + destroyTimeoutRef.current = setTimeout(() => { + if (websocketRef.current) { + websocketRef.current.destroy(); + websocketRef.current = null; + } + }, 0); + } + }; + }, [externalWebsocketProvider]); + + const contextValue = useMemo( + () => ({ + websocketProvider, + }), + [websocketProvider], + ); + + return ( + + {children} + + ); +} diff --git a/packages/provider-react/src/HocuspocusRoom.tsx b/packages/provider-react/src/HocuspocusRoom.tsx new file mode 100644 index 000000000..ccf36772d --- /dev/null +++ b/packages/provider-react/src/HocuspocusRoom.tsx @@ -0,0 +1,116 @@ +"use client"; + +import { HocuspocusProvider } from "@hocuspocus/provider"; +import { useContext, useEffect, useMemo, useRef } from "react"; + +import { HocuspocusContext, HocuspocusRoomContext } from "./context.ts"; +import type { HocuspocusRoomProps } from "./types.ts"; + +/** + * HocuspocusRoom manages the connection to a specific document. + * + * It uses the shared WebSocket from HocuspocusProviderComponent and creates a document-specific + * provider that connects on mount and disconnects on unmount. + * + * This component handles React's StrictMode gracefully by using deferred destruction, + * preventing unnecessary reconnections during development double-mounts. + * + * @example + * ```tsx + * + * + * + * + * + * ``` + */ +export function HocuspocusRoom({ + children, + name, + document, + token, +}: HocuspocusRoomProps) { + const hocuspocusContext = useContext(HocuspocusContext); + + if (!hocuspocusContext) { + throw new Error( + "HocuspocusRoom must be used within a HocuspocusProviderComponent", + ); + } + + const { websocketProvider } = hocuspocusContext; + + const providerRef = useRef(null); + const destroyTimeoutRef = useRef | null>(null); + + // Store current props in a ref to access in cleanup without triggering re-creation + const propsRef = useRef({ name, document, token }); + propsRef.current = { name, document, token }; + + // Create or retrieve provider + // We use a ref to prevent recreation on every render + if (!providerRef.current) { + providerRef.current = new HocuspocusProvider({ + name, + websocketProvider, + document, + token, + }); + } + + const provider = providerRef.current; + + useEffect(() => { + // Cancel any pending destruction (handles StrictMode double-mount) + if (destroyTimeoutRef.current) { + clearTimeout(destroyTimeoutRef.current); + destroyTimeoutRef.current = null; + } + + // Attach the provider to the websocket so it starts syncing + provider.attach(); + + return () => { + // Deferred destruction - wait for potential remount in StrictMode + // Using setTimeout(0) allows React to remount before we destroy + destroyTimeoutRef.current = setTimeout(() => { + if (providerRef.current) { + providerRef.current.destroy(); + providerRef.current = null; + } + }, 0); + }; + }, []); + + // Handle document name changes - need to recreate provider + useEffect(() => { + // Skip on initial mount since we already created the provider + if ( + providerRef.current && + providerRef.current.configuration.name !== name + ) { + // Name changed, need to recreate provider + providerRef.current.destroy(); + providerRef.current = new HocuspocusProvider({ + name, + websocketProvider, + document: propsRef.current.document, + token: propsRef.current.token, + }); + providerRef.current.attach(); + } + }, [name, websocketProvider]); + + const contextValue = useMemo( + () => ({ + provider, + }), + [provider], + ); + + return ( + + {children} + + ); +} diff --git a/packages/provider-react/src/context.ts b/packages/provider-react/src/context.ts new file mode 100644 index 000000000..7eb228256 --- /dev/null +++ b/packages/provider-react/src/context.ts @@ -0,0 +1,18 @@ +import { createContext } from "react"; + +import type { + HocuspocusContextValue, + HocuspocusRoomContextValue, +} from "./types.ts"; + +/** + * Context for the WebSocket connection shared across rooms + */ +export const HocuspocusContext = + createContext(null); + +/** + * Context for the room/document provider + */ +export const HocuspocusRoomContext = + createContext(null); diff --git a/packages/provider-react/src/hooks/index.ts b/packages/provider-react/src/hooks/index.ts new file mode 100644 index 000000000..4f77c6463 --- /dev/null +++ b/packages/provider-react/src/hooks/index.ts @@ -0,0 +1,4 @@ +export { useHocuspocusAwareness } from "./useHocuspocusAwareness.ts"; +export { useHocuspocusConnectionStatus } from "./useHocuspocusConnectionStatus.ts"; +export { useHocuspocusProvider } from "./useHocuspocusProvider.ts"; +export { useHocuspocusSyncStatus } from "./useHocuspocusSyncStatus.ts"; diff --git a/packages/provider-react/src/hooks/useHocuspocusAwareness.ts b/packages/provider-react/src/hooks/useHocuspocusAwareness.ts new file mode 100644 index 000000000..152b0e51b --- /dev/null +++ b/packages/provider-react/src/hooks/useHocuspocusAwareness.ts @@ -0,0 +1,80 @@ +import { useCallback, useRef, useSyncExternalStore } from "react"; + +import type { CollabUser } from "../types.ts"; +import { useHocuspocusProvider } from "./useHocuspocusProvider.ts"; + +/** + * Subscribe to the list of connected users in the document. + * + * This hook uses the Yjs awareness protocol to track users currently + * connected to the document. + * + * @returns Array of user objects with their awareness state + * + * @example + * ```tsx + * function UserList() { + * const users = useHocuspocusAwareness() + * + * return ( + *
+ * {users.map(user => ( + *
+ * {user.name?.[0]} + *
+ * ))} + *
+ * ) + * } + * ``` + */ +export function useHocuspocusAwareness(): CollabUser[] { + const provider = useHocuspocusProvider(); + + // Cache the last snapshot to avoid unnecessary array allocations + const cacheRef = useRef<{ + users: CollabUser[]; + json: string; + } | null>(null); + + const subscribe = useCallback( + (onStoreChange: () => void) => { + provider.awareness?.on("change", onStoreChange); + return () => { + provider.awareness?.off("change", onStoreChange); + }; + }, + [provider], + ); + + const getSnapshot = useCallback(() => { + const awareness = provider.awareness; + if (!awareness) { + return []; + } + + const users: CollabUser[] = []; + awareness.getStates().forEach((state, clientId) => { + users.push({ + clientId, + ...state, + }); + }); + + const json = JSON.stringify(users); + + // Return cached value if unchanged to preserve referential equality + if (cacheRef.current && cacheRef.current.json === json) { + return cacheRef.current.users; + } + + cacheRef.current = { users, json }; + return users; + }, [provider]); + + return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); +} diff --git a/packages/provider-react/src/hooks/useHocuspocusConnectionStatus.ts b/packages/provider-react/src/hooks/useHocuspocusConnectionStatus.ts new file mode 100644 index 000000000..eba6ca5c9 --- /dev/null +++ b/packages/provider-react/src/hooks/useHocuspocusConnectionStatus.ts @@ -0,0 +1,62 @@ +import { useCallback, useRef, useSyncExternalStore } from "react"; + +import type { ConnectionStatus } from "../types.ts"; +import { useHocuspocusProvider } from "./useHocuspocusProvider.ts"; + +/** + * Subscribe to the connection status of the collaboration provider. + * + * This hook uses React's useSyncExternalStore for optimal integration with + * concurrent rendering features. + * + * @returns The current connection status: 'connecting', 'connected', or 'disconnected' + * + * @example + * ```tsx + * function ConnectionIndicator() { + * const status = useHocuspocusConnectionStatus() + * + * return ( + *
+ * {status === 'connected' ? 'Online' : status === 'connecting' ? 'Connecting...' : 'Offline'} + *
+ * ) + * } + * ``` + */ +export function useHocuspocusConnectionStatus(): ConnectionStatus { + const provider = useHocuspocusProvider(); + const statusRef = useRef( + provider.configuration.websocketProvider.status as ConnectionStatus, + ); + + const subscribe = useCallback( + (onStoreChange: () => void) => { + const handleStatus = (data: { status: ConnectionStatus }) => { + statusRef.current = data.status; + onStoreChange(); + }; + + provider.on("status", handleStatus); + + // Sync initial status in case it changed between render and subscribe + const currentStatus = provider.configuration.websocketProvider + .status as ConnectionStatus; + if (statusRef.current !== currentStatus) { + statusRef.current = currentStatus; + onStoreChange(); + } + + return () => { + provider.off("status", handleStatus); + }; + }, + [provider], + ); + + const getSnapshot = useCallback((): ConnectionStatus => { + return statusRef.current; + }, []); + + return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); +} diff --git a/packages/provider-react/src/hooks/useHocuspocusProvider.ts b/packages/provider-react/src/hooks/useHocuspocusProvider.ts new file mode 100644 index 000000000..70f335c32 --- /dev/null +++ b/packages/provider-react/src/hooks/useHocuspocusProvider.ts @@ -0,0 +1,37 @@ +import { useContext } from "react"; + +import { HocuspocusRoomContext } from "../context.ts"; + +/** + * Access the HocuspocusProvider instance for the current room. + * + * Must be used within a HocuspocusRoom component. + * + * @returns The HocuspocusProvider instance + * @throws Error if used outside of HocuspocusRoom + * + * @example + * ```tsx + * function Editor() { + * const provider = useHocuspocusProvider() + * + * const editor = useEditor({ + * extensions: [ + * Collaboration.configure({ document: provider.document }), + * CollaborationCursor.configure({ provider }), + * ], + * }) + * + * return + * } + * ``` + */ +export function useHocuspocusProvider() { + const context = useContext(HocuspocusRoomContext); + + if (!context) { + throw new Error("useHocuspocusProvider must be used within a HocuspocusRoom"); + } + + return context.provider; +} diff --git a/packages/provider-react/src/hooks/useHocuspocusSyncStatus.ts b/packages/provider-react/src/hooks/useHocuspocusSyncStatus.ts new file mode 100644 index 000000000..f48fbf575 --- /dev/null +++ b/packages/provider-react/src/hooks/useHocuspocusSyncStatus.ts @@ -0,0 +1,51 @@ +import { useCallback, useSyncExternalStore } from "react"; + +import type { SyncStatus } from "../types.ts"; +import { useHocuspocusProvider } from "./useHocuspocusProvider.ts"; + +/** + * Subscribe to the sync status of local changes. + * + * This hook indicates whether local changes have been synced with the server. + * + * @returns The current sync status: 'synced' or 'syncing' + * + * @example + * ```tsx + * function SaveIndicator() { + * const syncStatus = useHocuspocusSyncStatus() + * + * return ( + *
+ * {syncStatus === 'syncing' ? 'Saving...' : 'All changes saved'} + *
+ * ) + * } + * ``` + */ +export function useHocuspocusSyncStatus(): SyncStatus { + const provider = useHocuspocusProvider(); + + const subscribe = useCallback( + (onStoreChange: () => void) => { + provider.on("synced", onStoreChange); + provider.on("unsyncedChanges", onStoreChange); + + return () => { + provider.off("synced", onStoreChange); + provider.off("unsyncedChanges", onStoreChange); + }; + }, + [provider], + ); + + const getSnapshot = useCallback((): SyncStatus => { + // Check if there are unsynced changes + if (provider.unsyncedChanges > 0) { + return "syncing"; + } + return "synced"; + }, [provider]); + + return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); +} diff --git a/packages/provider-react/src/index.ts b/packages/provider-react/src/index.ts new file mode 100644 index 000000000..58f06acd1 --- /dev/null +++ b/packages/provider-react/src/index.ts @@ -0,0 +1,30 @@ +"use client"; + +// Contexts +export { HocuspocusContext, HocuspocusRoomContext } from "./context.ts"; + +// Components +export { HocuspocusProviderComponent } from "./HocuspocusProviderComponent.tsx"; +export { HocuspocusRoom } from "./HocuspocusRoom.tsx"; + +// Hooks +export { + useHocuspocusAwareness, + useHocuspocusConnectionStatus, + useHocuspocusProvider, + useHocuspocusSyncStatus, +} from "./hooks/index.ts"; + +// Utils +export { useStableCallback } from "./utils/index.ts"; + +// Types +export type { + CollabUser, + ConnectionStatus, + HocuspocusContextValue, + HocuspocusProviderComponentProps, + HocuspocusRoomContextValue, + HocuspocusRoomProps, + SyncStatus, +} from "./types.ts"; diff --git a/packages/provider-react/src/types.ts b/packages/provider-react/src/types.ts new file mode 100644 index 000000000..bc8e7886e --- /dev/null +++ b/packages/provider-react/src/types.ts @@ -0,0 +1,69 @@ +import type { HocuspocusProvider, HocuspocusProviderWebsocket } from "@hocuspocus/provider"; +import type { ReactNode } from "react"; +import type * as Y from "yjs"; + +/** + * Configuration for HocuspocusProvider component + */ +export interface HocuspocusProviderComponentProps { + children: ReactNode; + /** + * URL of your @hocuspocus/server instance + */ + url?: string; + /** + * Optional: provide your own websocket instance for full control + */ + websocketProvider?: HocuspocusProviderWebsocket; +} + +/** + * Configuration for HocuspocusRoom component + */ +export interface HocuspocusRoomProps { + children: ReactNode; + /** + * The document name (required) + */ + name: string; + /** + * Optional: bring your own Y.Doc + */ + document?: Y.Doc; + /** + * JWT token or function that returns a promise resolving to a token + */ + token?: string | (() => Promise) | (() => string); +} + +/** + * Context value for the WebSocket provider + */ +export interface HocuspocusContextValue { + websocketProvider: HocuspocusProviderWebsocket; +} + +/** + * Context value for the room/document provider + */ +export interface HocuspocusRoomContextValue { + provider: HocuspocusProvider; +} + +/** + * Connection status for the collaboration provider + */ +export type ConnectionStatus = "connecting" | "connected" | "disconnected"; + +/** + * Sync status indicating whether local changes are synced with server + */ +export type SyncStatus = "synced" | "syncing"; + +/** + * User information from awareness + */ +export interface CollabUser { + clientId: number; + [key: string]: unknown; +} diff --git a/packages/provider-react/src/utils/index.ts b/packages/provider-react/src/utils/index.ts new file mode 100644 index 000000000..a43f0b1c8 --- /dev/null +++ b/packages/provider-react/src/utils/index.ts @@ -0,0 +1 @@ +export { useStableCallback } from "./useStableCallback.ts"; diff --git a/packages/provider-react/src/utils/useStableCallback.ts b/packages/provider-react/src/utils/useStableCallback.ts new file mode 100644 index 000000000..c5b42601b --- /dev/null +++ b/packages/provider-react/src/utils/useStableCallback.ts @@ -0,0 +1,23 @@ +import { useCallback, useRef } from "react"; + +/** + * Creates a stable callback reference that always calls the latest version + * of the callback without needing to include it in dependency arrays. + * + * This is useful for event handlers that need to access current props/state + * but shouldn't cause effect re-runs when they change. + * + * @param callback - The callback function to stabilize + * @returns A stable function reference that calls the latest callback + */ +// biome-ignore lint/suspicious/noExplicitAny: generic callback type requires any +export function useStableCallback any>( + callback: T, +): T { + const callbackRef = useRef(callback); + callbackRef.current = callback; + + return useCallback((...args: Parameters) => { + return callbackRef.current(...args); + }, []) as T; +} diff --git a/playground/frontend/app/articles/[slug]/CollaborationStatus.tsx b/playground/frontend/app/articles/[slug]/CollaborationStatus.tsx index de42d667e..acb3a9f57 100644 --- a/playground/frontend/app/articles/[slug]/CollaborationStatus.tsx +++ b/playground/frontend/app/articles/[slug]/CollaborationStatus.tsx @@ -12,7 +12,7 @@ const CollaborationStatus = (props: { const [unsyncedChanges, setUnsyncedChanges] = useState(0); const [socketStatus, setSocketStatus] = useState(null); - const [isAttached, _setAttached] = useState(null); + const [isAttached, _setAttached] = useState(false); useEffect(() => { setSocketStatus(provider.configuration.websocketProvider.status); diff --git a/playground/frontend/app/layout.tsx b/playground/frontend/app/layout.tsx index 26bbaaf28..f1c0f9153 100644 --- a/playground/frontend/app/layout.tsx +++ b/playground/frontend/app/layout.tsx @@ -24,12 +24,12 @@ export default function RootLayout({ diff --git a/playground/frontend/app/react-provider/[slug]/ArticleEditor.tsx b/playground/frontend/app/react-provider/[slug]/ArticleEditor.tsx new file mode 100644 index 000000000..3e6d346d6 --- /dev/null +++ b/playground/frontend/app/react-provider/[slug]/ArticleEditor.tsx @@ -0,0 +1,143 @@ +"use client"; + +import { + HocuspocusProviderComponent, + HocuspocusRoom, +} from "@hocuspocus/provider-react"; +import React from "react"; +import CollaborationStatus from "./CollaborationStatus"; +import CollaborativeEditor from "./CollaborativeEditor"; +import ConnectedUsers from "./ConnectedUsers"; + +export default function ArticleEditor({ slug }: { slug: string }) { + return ( +
+ {/* Header */} +
+
+
+
+

+ Article #{slug} + + provider-react + +

+

+ Using @hocuspocus/provider-react hooks and components +

+
+
+
+
+ + {/* Main content */} +
+
+ {/* Two independent WebSocket connections, each with its own room */} +
+ {/* Editor 1 — own WebSocket */} + + +
+
+

+ Editor 1 +

+ + Independent WebSocket + +
+ + + + + +
+ +
+
+
+
+ + {/* Editor 2 — own WebSocket */} + + +
+
+

+ Editor 2 +

+ + Independent WebSocket + +
+ + + + + +
+ +
+
+
+
+
+ + {/* Info panel */} +
+
+
+
+ + + +
+
+
+

+ @hocuspocus/provider-react Demo +

+

+ This demo uses the new React-specific package. Each editor is + wrapped in its own{" "} + + HocuspocusProviderComponent + {" "} + (independent WebSocket) and{" "} + + HocuspocusRoom + + . Status is read via{" "} + + useHocuspocusConnectionStatus() + + ,{" "} + + useHocuspocusSyncStatus() + + , and{" "} + + useHocuspocusAwareness() + {" "} + hooks — no manual event listeners needed. +

+
+
+
+
+
+
+ ); +} diff --git a/playground/frontend/app/react-provider/[slug]/CollaborationStatus.tsx b/playground/frontend/app/react-provider/[slug]/CollaborationStatus.tsx new file mode 100644 index 000000000..7483733e1 --- /dev/null +++ b/playground/frontend/app/react-provider/[slug]/CollaborationStatus.tsx @@ -0,0 +1,129 @@ +"use client"; + +import { + useHocuspocusConnectionStatus, + useHocuspocusProvider, + useHocuspocusSyncStatus, +} from "@hocuspocus/provider-react"; +import type { onUnsyncedChangesParameters } from "@hocuspocus/provider"; +import { useEffect, useState } from "react"; + +const CollaborationStatus = () => { + const provider = useHocuspocusProvider(); + const connectionStatus = useHocuspocusConnectionStatus(); + const syncStatus = useHocuspocusSyncStatus(); + const [unsyncedChanges, setUnsyncedChanges] = useState(0); + + useEffect(() => { + const handler = (data: onUnsyncedChangesParameters) => { + setUnsyncedChanges(data.number); + }; + provider.on("unsyncedChanges", handler); + return () => { + provider.off("unsyncedChanges", handler); + }; + }, [provider]); + + const getStatusColor = (status: string) => { + switch (status) { + case "connected": + return "bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-400"; + case "connecting": + return "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-400"; + case "disconnected": + return "bg-red-100 text-red-800 dark:bg-red-900/20 dark:text-red-400"; + default: + return "bg-slate-100 text-slate-800 dark:bg-slate-800 dark:text-slate-400"; + } + }; + + return ( +
+ {/* Status indicators */} +
+

+ Connection Status +

+
+
+
+ + {connectionStatus} + +
+
+
+ + {syncStatus} + +
+
+
+ + {unsyncedChanges} unsynced + +
+
+
+ + {/* Control buttons */} +
+
+

+ WebSocket Controls +

+
+ + +
+
+ +
+

+ Provider Controls +

+
+ + +
+
+
+
+ ); +}; + +export default CollaborationStatus; diff --git a/playground/frontend/app/react-provider/[slug]/CollaborativeEditor.tsx b/playground/frontend/app/react-provider/[slug]/CollaborativeEditor.tsx new file mode 100644 index 000000000..880cee6f1 --- /dev/null +++ b/playground/frontend/app/react-provider/[slug]/CollaborativeEditor.tsx @@ -0,0 +1,49 @@ +"use client"; + +import { useHocuspocusProvider } from "@hocuspocus/provider-react"; +import { Collaboration } from "@tiptap/extension-collaboration"; +import { CollaborationCaret } from "@tiptap/extension-collaboration-caret"; +import { EditorContent, useEditor } from "@tiptap/react"; +import { StarterKit } from "@tiptap/starter-kit"; +import * as Y from "yjs"; + +const initialContent = [ + 1, 3, 223, 175, 255, 141, 2, 0, 7, 1, 7, 100, 101, 102, 97, 117, 108, 116, + 3, 9, 112, 97, 114, 97, 103, 114, 97, 112, 104, 7, 0, 223, 175, 255, 141, + 2, 0, 6, 4, 0, 223, 175, 255, 141, 2, 1, 17, 72, 101, 108, 108, 111, 32, + 87, 111, 114, 108, 100, 33, 32, 240, 159, 140, 142, 0, +]; + +const CollaborativeEditor = () => { + const provider = useHocuspocusProvider(); + + Y.applyUpdate(provider.document, Uint8Array.from(initialContent)); + + const editor = useEditor( + { + extensions: [ + StarterKit.configure({ + undoRedo: false, + }), + Collaboration.configure({ + document: provider.document, + }), + CollaborationCaret.configure({ + provider, + }), + ], + immediatelyRender: false, + editorProps: { + attributes: { + class: + "prose prose-lg max-w-none focus:outline-none min-h-[500px] p-8 bg-white/50 dark:bg-slate-900/50 rounded-xl border border-slate-200 dark:border-slate-700 shadow-sm", + }, + }, + }, + [provider.document], + ); + + return ; +}; + +export default CollaborativeEditor; diff --git a/playground/frontend/app/react-provider/[slug]/ConnectedUsers.tsx b/playground/frontend/app/react-provider/[slug]/ConnectedUsers.tsx new file mode 100644 index 000000000..569063e6f --- /dev/null +++ b/playground/frontend/app/react-provider/[slug]/ConnectedUsers.tsx @@ -0,0 +1,56 @@ +"use client"; + +import { useHocuspocusAwareness } from "@hocuspocus/provider-react"; +import { useEffect, useState } from "react"; + +const colors = [ + "bg-blue-500", + "bg-green-500", + "bg-purple-500", + "bg-pink-500", + "bg-amber-500", + "bg-cyan-500", + "bg-rose-500", + "bg-indigo-500", +]; + +const ConnectedUsers = () => { + const users = useHocuspocusAwareness(); + const [mounted, setMounted] = useState(false); + + useEffect(() => { + setMounted(true); + }, []); + + return ( +
+
+ + Users + + {mounted && ( + <> +
+ {users.map((user, i) => ( +
+ + {String(user.clientId).slice(-2)} + +
+ ))} +
+ + {users.length} connected + + + )} +
+
+ ); +}; + +export default ConnectedUsers; diff --git a/playground/frontend/app/react-provider/[slug]/page.tsx b/playground/frontend/app/react-provider/[slug]/page.tsx new file mode 100644 index 000000000..7ba309b93 --- /dev/null +++ b/playground/frontend/app/react-provider/[slug]/page.tsx @@ -0,0 +1,10 @@ +import ArticleEditor from "./ArticleEditor"; + +export default async function Page(props: { + params: Promise<{ slug: string }>; +}) { + const params = await props.params; + const slug = params.slug; + + return ; +} diff --git a/playground/frontend/app/react-provider/layout.tsx b/playground/frontend/app/react-provider/layout.tsx new file mode 100644 index 000000000..3ee935cd5 --- /dev/null +++ b/playground/frontend/app/react-provider/layout.tsx @@ -0,0 +1,7 @@ +export default function Layout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return children; +} diff --git a/playground/frontend/next.config.ts b/playground/frontend/next.config.ts index a541fa819..1fb148c7a 100644 --- a/playground/frontend/next.config.ts +++ b/playground/frontend/next.config.ts @@ -8,6 +8,8 @@ const nextConfig: NextConfig = { "@hocuspocus/provider": "../../packages/provider/src/index.ts", "@hocuspocus/common": "../../packages/common/src/index.ts", "@hocuspocus/transformer": "../../packages/transformer/src/index.ts", + "@hocuspocus/provider-react": + "../../packages/provider-react/src/index.ts", }, }, webpack: (config) => { diff --git a/playground/frontend/package.json b/playground/frontend/package.json index 9483d71a0..89cd0c1a3 100644 --- a/playground/frontend/package.json +++ b/playground/frontend/package.json @@ -10,6 +10,7 @@ }, "dependencies": { "@hocuspocus/provider": "workspace:*", + "@hocuspocus/provider-react": "workspace:*", "@tiptap/extension-collaboration": "^3.18.0", "@tiptap/extension-collaboration-caret": "^3.18.0", "@tiptap/extension-floating-menu": "^3.18.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 511c18d3f..b5038a1d4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -175,6 +175,22 @@ importers: specifier: ^13.6.8 version: 13.6.29 + packages/provider-react: + dependencies: + '@hocuspocus/provider': + specifier: ^3.4.6-rc.2 + version: 3.4.6-rc.2(y-protocols@1.0.7(yjs@13.6.29))(yjs@13.6.29) + yjs: + specifier: ^13.6.8 + version: 13.6.29 + devDependencies: + '@types/react': + specifier: ^19.0.0 + version: 19.2.14 + react: + specifier: ^19.0.0 + version: 19.2.4 + packages/server: dependencies: '@hocuspocus/common': @@ -312,6 +328,9 @@ importers: '@hocuspocus/provider': specifier: workspace:* version: link:../../packages/provider + '@hocuspocus/provider-react': + specifier: workspace:* + version: link:../../packages/provider-react '@tiptap/extension-collaboration': specifier: ^3.18.0 version: 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)(@tiptap/y-tiptap@3.0.2(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.6)(y-protocols@1.0.7(yjs@13.6.29))(yjs@13.6.29))(yjs@13.6.29) @@ -701,6 +720,15 @@ packages: '@gar/promisify@1.1.3': resolution: {integrity: sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==} + '@hocuspocus/common@3.4.6-rc.2': + resolution: {integrity: sha512-iuQyE00zGGtS7jmedIbYPPjRuf9BNRSkTWY9QWmmV+o2AFdYzmFA9pmJwHCZeguEYyVbl+AR1Xgz8hpUdT16JA==} + + '@hocuspocus/provider@3.4.6-rc.2': + resolution: {integrity: sha512-IyOOZG58EpQ0UG0KH3VnGzCtXkLsq8vbS0X5gQnEtaUeIpUk9iCJBoVAH01XlaBC/BQhE42tW3C8DrxUueN37A==} + peerDependencies: + y-protocols: ^1.0.6 + yjs: ^13.6.8 + '@hono/node-server@1.19.9': resolution: {integrity: sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==} engines: {node: '>=18.14.1'} @@ -5986,6 +6014,22 @@ snapshots: '@gar/promisify@1.1.3': optional: true + '@hocuspocus/common@3.4.6-rc.2': + dependencies: + lib0: 0.2.117 + + '@hocuspocus/provider@3.4.6-rc.2(y-protocols@1.0.7(yjs@13.6.29))(yjs@13.6.29)': + dependencies: + '@hocuspocus/common': 3.4.6-rc.2 + '@lifeomic/attempt': 3.1.0 + lib0: 0.2.117 + ws: 8.19.0 + y-protocols: 1.0.7(yjs@13.6.29) + yjs: 13.6.29 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + '@hono/node-server@1.19.9(hono@4.11.9)': dependencies: hono: 4.11.9 diff --git a/tsconfig.json b/tsconfig.json index 16285d0c0..d788d1d5f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,39 +1,29 @@ { - "compilerOptions": { - "target": "es2019", - "module": "esnext", - "strict": true, - "jsx": "preserve", - "importHelpers": true, - "resolveJsonModule": true, - "moduleResolution": "node", - "esModuleInterop": true, - "allowSyntheticDefaultImports": true, - "rewriteRelativeImportExtensions": true, - "verbatimModuleSyntax": true, - "noEmit": true, - "experimentalDecorators": true, - "sourceMap": true, - "baseUrl": ".", - "rootDir": ".", - "allowJs": false, - "checkJs": false, - "paths": { - "@hocuspocus/*": [ - "packages/*/src/index.ts", - ] - }, - "lib": [ - "esnext", - "dom" - ], - "skipLibCheck": true - }, - "include": [ - "**/*.ts" - ], - "exclude": [ - "**/node_modules", - "**/dist" - ] + "compilerOptions": { + "target": "es2019", + "module": "esnext", + "strict": true, + "jsx": "react", + "importHelpers": true, + "resolveJsonModule": true, + "moduleResolution": "node", + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "rewriteRelativeImportExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "experimentalDecorators": true, + "sourceMap": true, + "baseUrl": ".", + "rootDir": ".", + "allowJs": false, + "checkJs": false, + "paths": { + "@hocuspocus/*": ["packages/*/src/index.ts"] + }, + "lib": ["esnext", "dom"], + "skipLibCheck": true + }, + "include": ["**/*.ts", "**/*.tsx"], + "exclude": ["**/node_modules", "**/dist"] }