Simultaneous Cursors
This demo shows the real-time collaborative version of simple drawing, cursor animation with Yorkie and React.
App.jsx
1import { useEffect, useState, useRef } from 'react';2import {3 YorkieProvider,4 DocumentProvider,5 useDocument,6} from '@yorkie-js/react';7import Cursor from './components/Cursor';8import CursorSelections from './components/CursorSelections';9import './App.css';1011// Generate a visually distinctive random color12// (pastel-ish for good contrast on white)13function generateRandomColor() {14 // HSL to hex conversion for consistent vivid colors15 const h = Math.floor(Math.random() * 360); // full hue range16 const s = 70; // saturation percentage17 const l = 55; // lightness percentage18 const toHex = (v) => v.toString(16).padStart(2, '0');1920 // Convert HSL to RGB21 const c = (1 - Math.abs((2 * l) / 100 - 1)) * (s / 100);22 const x = c * (1 - Math.abs(((h / 60) % 2) - 1));23 const m = l / 100 - c / 2;24 let r1, g1, b1;25 if (h < 60) [r1, g1, b1] = [c, x, 0];26 else if (h < 120) [r1, g1, b1] = [x, c, 0];27 else if (h < 180) [r1, g1, b1] = [0, c, x];28 else if (h < 240) [r1, g1, b1] = [0, x, c];29 else if (h < 300) [r1, g1, b1] = [x, 0, c];30 else [r1, g1, b1] = [c, 0, x];31 const r = Math.round((r1 + m) * 255);32 const g = Math.round((g1 + m) * 255);33 const b = Math.round((b1 + m) * 255);34 return `#${toHex(r)}${toHex(g)}${toHex(b)}`;35}3637// `initialPresence` is the initial cursor state for each user.38const initialPresence = {39 cursorShape: 'cursor',40 cursor: { xPos: 0, yPos: 0 },41 pointerDown: false,42 // Assign a per-user random color (same value is used for both cursor stroke + pen drawing)43 color: generateRandomColor(),44 fadeEnabled: false,45 overInteractive: false,46};4748// `pixcelThreshold` is the minimum distance to consider a cursor movement.49const pixcelThreshold = 2;5051function CursorsCanvas() {52 const { doc, presences, update, loading, error } = useDocument();53 const [fadeEnabled, setFadeEnabled] = useState(false);54 const [color, setColor] = useState(initialPresence.color);55 const myClientIDRef = useRef(null);56 const pendingRef = useRef(null);57 const lastSentRef = useRef({ x: 0, y: 0 });58 const pointerDownRef = useRef(false);5960 const handleCursorShapeSelect = (cursorShape) => {61 update((_, p) => p.set({ cursorShape }));62 };6364 const handleFadeSet = (next) => {65 setFadeEnabled(next);66 update((_, p) => p.set({ fadeEnabled: next }));67 };68 const handleColorChange = (newColor) => {69 setColor(newColor);70 update((_, p) => p.set({ color: newColor }));71 };7273 useEffect(() => {74 if (loading || error) return;75 const interactiveSelector =76 'button, input, select, textarea, [role="button"], a, [data-native-cursor]';7778 const onDown = () => {79 pointerDownRef.current = true;80 update((_, p) => p.set({ pointerDown: true }));81 };82 const onUp = () => {83 pointerDownRef.current = false;84 update((_, p) => p.set({ pointerDown: false }));85 };86 const onMove = (e) => {87 const overInteractive = !!(88 e.target.closest && e.target.closest(interactiveSelector)89 );90 pendingRef.current = {91 x: e.clientX,92 y: e.clientY,93 overInteractive,94 };95 };9697 let frameID;98 const loop = () => {99 const pending = pendingRef.current;100 if (pending) {101 const last = lastSentRef.current;102 const dx = pending.x - last.x;103 const dy = pending.y - last.y;104 const dist = Math.hypot(dx, dy);105 const forceSend = pointerDownRef.current;106 if (forceSend || dist >= pixcelThreshold) {107 lastSentRef.current = { x: pending.x, y: pending.y };108 update((_, p) =>109 p.set({110 cursor: { xPos: pending.x, yPos: pending.y },111 overInteractive: pending.overInteractive,112 }),113 );114 }115 pendingRef.current = null;116 }117 frameID = requestAnimationFrame(loop);118 };119 frameID = requestAnimationFrame(loop);120121 window.addEventListener('mousedown', onDown);122 window.addEventListener('mouseup', onUp);123 window.addEventListener('mousemove', onMove);124125 return () => {126 cancelAnimationFrame(frameID);127 window.removeEventListener('mousedown', onDown);128 window.removeEventListener('mouseup', onUp);129 window.removeEventListener('mousemove', onMove);130 };131 }, [loading, error, update]);132133 useEffect(() => {134 if (!doc) return;135136 const myPresence = doc.getMyPresence?.();137 if (myPresence) {138 setFadeEnabled(myPresence.fadeEnabled ?? false);139 setColor(myPresence.color ?? initialPresence.color);140 }141 }, [presences, doc]);142143 if (loading) return <div className="general-container">Loading...</div>;144 if (error)145 return <div className="general-container">Error: {error.message}</div>;146147 return (148 <div149 className="general-container"150 onMouseDown={(e) => {151 const tag = e.target.tagName;152 if (['INPUT', 'SELECT', 'TEXTAREA', 'BUTTON'].includes(tag)) return;153 e.preventDefault();154 }}155 >156 {presences.map(({ clientID, presence }, idx) => {157 const {158 cursorShape,159 cursor,160 pointerDown,161 color: presColor = '#000000',162 fadeEnabled: presFade = false,163 overInteractive = false,164 } = presence;165166 if (idx === 0 && myClientIDRef.current === null) {167 myClientIDRef.current = clientID;168 }169 const isLocal = clientID === myClientIDRef.current;170171 return (172 cursor && (173 <Cursor174 key={clientID}175 selectedCursorShape={cursorShape}176 x={cursor.xPos}177 y={cursor.yPos}178 pointerDown={pointerDown}179 fadeEnabled={presFade}180 color={presColor}181 overInteractive={overInteractive}182 animate={!isLocal}183 />184 )185 );186 })}187188 <CursorSelections189 handleCursorShapeSelect={handleCursorShapeSelect}190 clientsLength={presences.length}191 fadeEnabled={fadeEnabled}192 onFadeSet={handleFadeSet}193 color={color}194 onColorChange={handleColorChange}195 />196 </div>197 );198}199200export default function App() {201 return (202 <YorkieProvider203 apiKey={import.meta.env.VITE_YORKIE_API_KEY}204 rpcAddr={import.meta.env.VITE_YORKIE_API_ADDR}205 >206 <DocumentProvider207 docKey="simultaneous-cursors"208 initialPresence={initialPresence}209 >210 <CursorsCanvas />211 </DocumentProvider>212 </YorkieProvider>213 );214}
- User 1
- User 2
- User 1
- User 2