Skip to content

Commit

Permalink
Merge pull request #3835 from calctree/dpnova/feat/yjs-remote-cursor-…
Browse files Browse the repository at this point in the history
…overlay

feat: show remote cursors when in collaboration.
  • Loading branch information
zbeyens authored Dec 6, 2024
2 parents 6fad759 + 7b7f7b3 commit aceef6a
Show file tree
Hide file tree
Showing 10 changed files with 331 additions and 91 deletions.
32 changes: 30 additions & 2 deletions apps/www/content/docs/collaboration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ title: Collaboration
## Features

- The yjs plugin enables support for collaboration using [slate-yjs](https://docs.slate-yjs.dev/) and [Hocuspocus](https://docs.slate-yjs.dev/walkthroughs/collaboration-hocuspocus).
- By default remote cursors are not rendered unless you install the remote cursor overlay and include it in the config.
- Cursors are rendered slightly faded and become solid on hover. Use the `data` field in `cursorOptions` to customise the display name and color.

</PackageInfo>

Expand All @@ -28,22 +30,48 @@ npm install @udecode/plate-yjs

```tsx
import { YjsPlugin } from '@udecode/plate-yjs/react';
import { RemoteCursorOverlay } from '@/components/plate-ui/remote-cursor-overlay';

const editor = createPlateEditor({
plugins: [
// ...otherPlugins,
YjsPlugin.configure({
options: {
render: {
afterEditable: RemoteCursorOverlay,
},
cursorOptions: {
autoSend: true,
data: { name: 'A plate user', color: '#5AC990' },
},
disableCursors: false,
hocuspocusProviderOptions: {
url: "https://hocuspocus.test/hocuspocus",
name: "test",
url: 'https://hocuspocus.test/hocuspocus',
name: 'test',
},
},
}),
],
});
```

### Editor Container

The editor requires a container component above `PlateContent` to ensure correct cursor overlay positioning:

```tsx
export const EditorContainer = (
props: React.HTMLAttributes<HTMLDivElement>
) => {
const editor = useEditorRef();
const containerRef = useEditorContainerRef();

return <div id={editor.uid} ref={containerRef} {...props} />;
};
```

This component is available in [Editor](/docs/components/editor).

## Backend

Follow the backend instructions in [Hocuspocus docs](https://tiptap.dev/hocuspocus/getting-started).
Expand Down
1 change: 1 addition & 0 deletions apps/www/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@
"@radix-ui/react-toggle-group": "^1.1.0",
"@radix-ui/react-toolbar": "^1.1.0",
"@radix-ui/react-tooltip": "^1.1.2",
"@slate-yjs/react": "1.1.0",
"@udecode/cmdk": "workspace:^",
"@udecode/cn": "workspace:^",
"@udecode/plate": "workspace:^",
Expand Down
24 changes: 24 additions & 0 deletions apps/www/public/r/index.json
Original file line number Diff line number Diff line change
Expand Up @@ -1476,6 +1476,30 @@
"registryDependencies": [],
"type": "registry:ui"
},
{
"dependencies": [
"@slate-yjs/react",
"@udecode/plate-core"
],
"doc": {
"description": "A cursor overlay to display multiplayer cursors in the yjs plugin.",
"docs": [
{
"route": "/docs/collaboration"
}
],
"examples": []
},
"files": [
{
"path": "plate-ui/remote-cursor-overlay.tsx",
"type": "registry:ui"
}
],
"name": "remote-cursor-overlay",
"registryDependencies": [],
"type": "registry:ui"
},
{
"dependencies": [
"@udecode/plate-ai",
Expand Down
26 changes: 26 additions & 0 deletions apps/www/public/r/styles/default/remote-cursor-overlay.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
{
"dependencies": [
"@slate-yjs/react",
"@udecode/plate-core"
],
"doc": {
"description": "A cursor overlay to display multiplayer cursors in the yjs plugin.",
"docs": [
{
"route": "/docs/collaboration"
}
],
"examples": []
},
"files": [
{
"content": "import React, {\n type CSSProperties,\n type PropsWithChildren,\n useState,\n} from 'react';\n\nimport {\n type CursorOverlayData,\n useRemoteCursorOverlayPositions,\n} from '@slate-yjs/react';\nimport { useEditorContainerRef } from '@udecode/plate-core/react';\n\nexport function addAlpha(hexColor: string, opacity: number): string {\n const normalized = Math.round(Math.min(Math.max(opacity, 0), 1) * 255);\n\n return hexColor + normalized.toString(16).toUpperCase();\n}\n\nexport type CursorData = {\n color: string;\n name: string;\n};\n\ntype CaretProps = Pick<CursorOverlayData<CursorData>, 'caretPosition' | 'data'>;\nconst cursorOpacity = 0.7;\nconst hoverOpacity = 1;\n\nfunction Caret({ caretPosition, data }: CaretProps) {\n const [isHover, setIsHover] = useState(false);\n\n const handleMouseEnter = () => {\n setIsHover(true);\n };\n const handleMouseLeave = () => {\n setIsHover(false);\n };\n const caretStyle: CSSProperties = {\n ...caretPosition,\n background: data?.color,\n opacity: cursorOpacity,\n transition: 'opacity 0.2s',\n };\n const caretStyleHover = { ...caretStyle, opacity: hoverOpacity };\n\n const labelStyle: CSSProperties = {\n background: data?.color,\n opacity: cursorOpacity,\n transform: 'translateY(-100%)',\n transition: 'opacity 0.2s',\n };\n const labelStyleHover = { ...labelStyle, opacity: hoverOpacity };\n\n return (\n <div\n className=\"absolute w-0.5\"\n style={isHover ? caretStyleHover : caretStyle}\n >\n <div\n className=\"absolute top-0 whitespace-nowrap rounded rounded-bl-none px-1.5 py-0.5 text-xs text-white\"\n style={isHover ? labelStyleHover : labelStyle}\n onMouseEnter={handleMouseEnter}\n onMouseLeave={handleMouseLeave}\n >\n {data?.name}\n </div>\n </div>\n );\n}\n\nfunction RemoteSelection({\n caretPosition,\n data,\n selectionRects,\n}: CursorOverlayData<CursorData>) {\n if (!data) {\n return null;\n }\n\n const selectionStyle: CSSProperties = {\n // Add a opacity to the background color\n backgroundColor: addAlpha(data.color, 0.5),\n };\n\n return (\n <React.Fragment>\n {selectionRects.map((position, i) => (\n <div\n key={i}\n className=\"pointer-events-none absolute\"\n style={{ ...selectionStyle, ...position }}\n ></div>\n ))}\n {caretPosition && <Caret caretPosition={caretPosition} data={data} />}\n </React.Fragment>\n );\n}\n\ntype RemoteCursorsProps = PropsWithChildren<{\n className?: string;\n}>;\n\nexport function RemoteCursorOverlay({ children }: RemoteCursorsProps) {\n const containerRef = useEditorContainerRef();\n const [cursors] = useRemoteCursorOverlayPositions<CursorData>({\n containerRef,\n });\n\n return (\n <>\n {children}\n {cursors.map((cursor) => (\n <RemoteSelection key={cursor.clientId} {...cursor} />\n ))}\n </>\n );\n}\n",
"path": "plate-ui/remote-cursor-overlay.tsx",
"target": "components/plate-ui/remote-cursor-overlay.tsx",
"type": "registry:ui"
}
],
"name": "remote-cursor-overlay",
"registryDependencies": [],
"type": "registry:ui"
}
16 changes: 16 additions & 0 deletions apps/www/src/__registry__/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -877,6 +877,22 @@ export const Index: Record<string, any> = {
subcategory: "",
chunks: []
},
"remote-cursor-overlay": {
name: "remote-cursor-overlay",
description: "",
type: "registry:ui",
registryDependencies: [],
files: [{
path: "src/registry/default/plate-ui/remote-cursor-overlay.tsx",
type: "registry:ui",
target: ""
}],
component: React.lazy(() => import("@/registry/default/plate-ui/remote-cursor-overlay.tsx")),
source: "",
category: "",
subcategory: "",
chunks: []
},
"ai-menu": {
name: "ai-menu",
description: "",
Expand Down
110 changes: 110 additions & 0 deletions apps/www/src/registry/default/plate-ui/remote-cursor-overlay.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
'use client';
// Lifted from slate-yjs https://github.com/BitPhinix/slate-yjs/blob/main/examples/frontend/src/pages/RemoteCursorOverlay/Overlay.tsx

import React, { type CSSProperties, useState } from 'react';

import {
type CursorOverlayData,
useRemoteCursorOverlayPositions,
} from '@slate-yjs/react';
import { useEditorContainerRef } from '@udecode/plate-common/react';

export function addAlpha(hexColor: string, opacity: number): string {
const normalized = Math.round(Math.min(Math.max(opacity, 0), 1) * 255);

return hexColor + normalized.toString(16).toUpperCase();
}

export type CursorData = {
color: string;
name: string;
};

type CaretProps = Pick<CursorOverlayData<CursorData>, 'caretPosition' | 'data'>;
const cursorOpacity = 0.7;
const hoverOpacity = 1;

function Caret({ caretPosition, data }: CaretProps) {
const [isHover, setIsHover] = useState(false);

const handleMouseEnter = () => {
setIsHover(true);
};
const handleMouseLeave = () => {
setIsHover(false);
};
const caretStyle: CSSProperties = {
...caretPosition,
background: data?.color,
opacity: cursorOpacity,
transition: 'opacity 0.2s',
};
const caretStyleHover = { ...caretStyle, opacity: hoverOpacity };

const labelStyle: CSSProperties = {
background: data?.color,
opacity: cursorOpacity,
transform: 'translateY(-100%)',
transition: 'opacity 0.2s',
};
const labelStyleHover = { ...labelStyle, opacity: hoverOpacity };

return (
<div
className="absolute w-0.5"
style={isHover ? caretStyleHover : caretStyle}
>
<div
className="absolute top-0 whitespace-nowrap rounded rounded-bl-none px-1.5 py-0.5 text-xs text-white"
style={isHover ? labelStyleHover : labelStyle}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{data?.name}
</div>
</div>
);
}

function RemoteSelection({
caretPosition,
data,
selectionRects,
}: CursorOverlayData<CursorData>) {
if (!data) {
return null;
}

const selectionStyle: CSSProperties = {
// Add a opacity to the background color
backgroundColor: addAlpha(data.color, 0.5),
};

return (
<React.Fragment>
{selectionRects.map((position, i) => (
<div
key={i}
className="pointer-events-none absolute"
style={{ ...selectionStyle, ...position }}
></div>
))}
{caretPosition && <Caret caretPosition={caretPosition} data={data} />}
</React.Fragment>
);
}

export function RemoteCursorOverlay() {
const containerRef = useEditorContainerRef();
const [cursors] = useRemoteCursorOverlayPositions<CursorData>({
containerRef,
});

return (
<>
{cursors.map((cursor) => (
<RemoteSelection key={cursor.clientId} {...cursor} />
))}
</>
);
}
15 changes: 15 additions & 0 deletions apps/www/src/registry/registry-ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1723,6 +1723,21 @@ export const uiPrimitives: Registry = [
registryDependencies: [],
type: 'registry:ui',
},
{
dependencies: ['@slate-yjs/react'],
doc: {
description:
'A cursor overlay to display multiplayer cursors in the yjs plugin.',
docs: [{ route: '/docs/collaboration' }],
examples: [],
},
files: [
{ path: 'plate-ui/remote-cursor-overlay.tsx', type: 'registry:ui' },
],
name: 'remote-cursor-overlay',
registryDependencies: [],
type: 'registry:ui',
},
];

export const ui: Registry = [...uiNodes, ...uiPrimitives, ...uiComponents];
3 changes: 2 additions & 1 deletion packages/markdown/src/lib/remark-slate/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@
* @file Automatically generated by barrelsby.
*/

export * from './remarkDefaultCompiler';
export * from './remarkDefaultElementRules';
export * from './remarkDefaultTextRules';
export * from './remarkSplitLineBreaksCompiler';
export * from './remarkPlugin';
export * from './remarkSplitLineBreaksCompiler';
export * from './remarkTextTypes';
export * from './remarkTransformElement';
export * from './remarkTransformElementChildren';
Expand Down
4 changes: 3 additions & 1 deletion packages/yjs/src/react/YjsPlugin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,7 @@ import { YjsAboveEditable } from './YjsAboveEditable';

/** Enables support for real-time collaboration using Yjs. */
export const YjsPlugin = toPlatePlugin(BaseYjsPlugin, {
render: { aboveEditable: YjsAboveEditable },
render: {
aboveEditable: YjsAboveEditable,
},
});
Loading

0 comments on commit aceef6a

Please sign in to comment.