Skip to content

Commit

Permalink
feat: show remote cursors when in collaboration.
Browse files Browse the repository at this point in the history
  • Loading branch information
dpnova committed Dec 6, 2024
1 parent 6fad759 commit 4d07c09
Show file tree
Hide file tree
Showing 6 changed files with 241 additions and 90 deletions.
5 changes: 5 additions & 0 deletions .changeset/odd-radios-smoke.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@udecode/plate-yjs': patch
---

Add a small component to show remote cursors.
6 changes: 4 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 rendered slightly faded and become solid on hover.
- To customize, copy `YjsAboveEditable.tsx` and `Overlay.tsx` into your project and override the `options.render.aboveEditable` option to this plugin.

</PackageInfo>

Expand All @@ -35,8 +37,8 @@ const editor = createPlateEditor({
YjsPlugin.configure({
options: {
hocuspocusProviderOptions: {
url: "https://hocuspocus.test/hocuspocus",
name: "test",
url: 'https://hocuspocus.test/hocuspocus',
name: 'test',
},
},
}),
Expand Down
2 changes: 2 additions & 0 deletions packages/yjs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@
"dependencies": {
"@hocuspocus/provider": "^2.13.5",
"@slate-yjs/core": "^1.0.2",
"@slate-yjs/react": "1.1.0",
"@udecode/cn": "workspace:^",
"yjs": "^13.6.19"
},
"devDependencies": {
Expand Down
123 changes: 123 additions & 0 deletions packages/yjs/src/react/Overlay.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
// Lifted from slate-yjs https://github.com/BitPhinix/slate-yjs/blob/main/examples/frontend/src/pages/RemoteCursorOverlay/Overlay.tsx

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

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

import { cn } from '@udecode/cn';

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>
);
}

type RemoteCursorsProps = PropsWithChildren<{
className?: string;
}>;

export function RemoteCursorOverlay({
children,
className,
}: RemoteCursorsProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [cursors] = useRemoteCursorOverlayPositions<CursorData>({
containerRef,
});

return (
<div ref={containerRef} className={cn('relative', className)}>
{children}
{cursors.map((cursor) => (
<RemoteSelection key={cursor.clientId} {...cursor} />
))}
</div>
);
}
3 changes: 2 additions & 1 deletion packages/yjs/src/react/YjsAboveEditable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { YjsEditor } from '@slate-yjs/core';
import { useEditorPlugin } from '@udecode/plate-common/react';

import { type YjsConfig, BaseYjsPlugin } from '../lib/BaseYjsPlugin';
import { RemoteCursorOverlay } from './Overlay';

export const YjsAboveEditable: React.FC<{
children: React.ReactNode;
Expand All @@ -28,5 +29,5 @@ export const YjsAboveEditable: React.FC<{

if (!isSynced) return null;

return <>{children}</>;
return <RemoteCursorOverlay>{children}</RemoteCursorOverlay>;
};
Loading

0 comments on commit 4d07c09

Please sign in to comment.