├── src ├── lib │ ├── hooks │ │ └── useCanavs.ts │ ├── hitTest │ │ ├── argumentType.ts │ │ ├── rectangle.ts │ │ ├── ellipse.ts │ │ ├── line.ts │ │ ├── free-hand.ts │ │ └── detectResizeHandler.ts │ ├── helperfunc │ │ ├── getRandomColor.ts │ │ ├── undo-redo.ts │ │ ├── getStrokedLine.ts │ │ └── renderCanvas.ts │ ├── utils │ │ ├── drawingUtility │ │ │ ├── drawStroke.ts │ │ │ ├── getSVGStroke.ts │ │ │ ├── hitTest.ts │ │ │ └── drawElement.ts │ │ ├── boundsUtility │ │ │ ├── isPointInPaddedBounds.ts │ │ │ └── getBounds.ts │ │ └── createYElement.ts │ ├── dragElement.ts │ ├── math │ │ └── point.ts │ ├── resizeBound.ts │ ├── drawBounds.ts │ ├── handleElement.ts │ └── resizeElement.ts ├── hooks │ └── useRoghCanvas.ts ├── app │ ├── globals.css │ ├── layout.tsx │ ├── page.tsx │ └── canvas │ │ └── page.tsx ├── Store │ ├── yjs-store.ts │ └── store.ts ├── component │ ├── toolbar.tsx │ ├── loading.tsx │ └── crazyToolbar.tsx └── types │ ├── toolbarData.ts │ └── type.ts ├── public ├── icon.png ├── vercel.svg ├── window.svg ├── file.svg ├── globe.svg └── next.svg ├── postcss.config.mjs ├── server.js ├── next.config.ts ├── server ├── package.json ├── .gitignore └── package-lock.json ├── eslint.config.mjs ├── .gitignore ├── tsconfig.json ├── package.json └── README.md /src/lib/hooks/useCanavs.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marsyg/OnlyDraw/HEAD/public/icon.png -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | const config = { 2 | plugins: ["@tailwindcss/postcss"], 3 | }; 4 | 5 | export default config; 6 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | import ws from 'ws' 2 | 3 | const wsProvider = new WebsocketProvider( 4 | 'ws://localhost:1234', 'my-roomname', 5 | doc, 6 | { WebSocketPolyfill: ws } 7 | ) -------------------------------------------------------------------------------- /next.config.ts: -------------------------------------------------------------------------------- 1 | import type { NextConfig } from "next"; 2 | 3 | const nextConfig: NextConfig = { 4 | /* config options here */ 5 | reactStrictMode: false, 6 | }; 7 | 8 | export default nextConfig; 9 | -------------------------------------------------------------------------------- /src/lib/hitTest/argumentType.ts: -------------------------------------------------------------------------------- 1 | import {point } from '@/types/type' 2 | import * as Y from 'yjs'; 3 | export type args = { 4 | point : point 5 | element : Y.Map 6 | } -------------------------------------------------------------------------------- /src/lib/helperfunc/getRandomColor.ts: -------------------------------------------------------------------------------- 1 | function getRandomColor() { 2 | 3 | const r = Math.floor(Math.random() * 256); 4 | const g = Math.floor(Math.random() * 256); 5 | const b = Math.floor(Math.random() * 256); 6 | 7 | 8 | return '#' + [r, g, b].map((x) => x.toString(16).padStart(2, '0')).join(''); 9 | } 10 | export default getRandomColor -------------------------------------------------------------------------------- /public/window.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/file.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/lib/helperfunc/undo-redo.ts: -------------------------------------------------------------------------------- 1 | import { UndoManager } from '@/Store/yjs-store'; 2 | const handleUndo = () => { 3 | if (UndoManager.undoStack.length > 0) { 4 | 5 | 6 | 7 | UndoManager.undo(); 8 | } 9 | }; 10 | 11 | const handleRedo = () => { 12 | if (UndoManager.redoStack.length > 0) { 13 | UndoManager.redo(); 14 | } 15 | }; 16 | export { handleUndo, handleRedo }; 17 | -------------------------------------------------------------------------------- /server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "server", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "scripts": { 6 | "test": "echo \"Error: no test specified\" && exit 1" 7 | }, 8 | "keywords": [], 9 | "author": "", 10 | "license": "ISC", 11 | "description": "", 12 | "dependencies": { 13 | "@y/websocket-server": "^0.1.1", 14 | "y-websocket": "^3.0.0" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/lib/hitTest/rectangle.ts: -------------------------------------------------------------------------------- 1 | import { args } from './argumentType'; 2 | export const isInsideRectangle = ({ point, element }: args) => { 3 | const x = point[0]; 4 | const y = point[1]; 5 | return ( 6 | x >= Number(element.get('x')) && 7 | x <= Number(element.get('x')) + Number(element.get('width')) && 8 | y >= Number(element.get('y')) && 9 | y <= Number(element.get('y')) + Number(element.get('height')) 10 | ); 11 | }; 12 | -------------------------------------------------------------------------------- /src/lib/utils/drawingUtility/drawStroke.ts: -------------------------------------------------------------------------------- 1 | import { getStroke } from "perfect-freehand"; 2 | import { getSvgPathFromStroke } from '@/lib/utils/drawingUtility/getSVGStroke'; 3 | export const DrawStroke = ( ctx : CanvasRenderingContext2D , strokePoints : [number , number , number][]) => { 4 | 5 | const stroke = getStroke(strokePoints) 6 | const path = getSvgPathFromStroke(stroke) 7 | ctx.fillStyle = "black"; 8 | ctx.fill(new Path2D(path)); 9 | 10 | } -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import { dirname } from "path"; 2 | import { fileURLToPath } from "url"; 3 | import { FlatCompat } from "@eslint/eslintrc"; 4 | 5 | const __filename = fileURLToPath(import.meta.url); 6 | const __dirname = dirname(__filename); 7 | 8 | const compat = new FlatCompat({ 9 | baseDirectory: __dirname, 10 | }); 11 | 12 | const eslintConfig = [ 13 | ...compat.extends("next/core-web-vitals", "next/typescript"), 14 | ]; 15 | 16 | export default eslintConfig; 17 | -------------------------------------------------------------------------------- /src/lib/helperfunc/getStrokedLine.ts: -------------------------------------------------------------------------------- 1 | type BoundaryStyle = 'solid' | 'dashed' | 'dotted'; 2 | 3 | const getStrokeLineDash = ( 4 | boundaryStyle: BoundaryStyle, 5 | strokeWidth: number 6 | ): number[] => { 7 | switch (boundaryStyle) { 8 | case 'solid': 9 | return []; 10 | case 'dashed': 11 | return [strokeWidth * 3, strokeWidth * 2]; 12 | case 'dotted': 13 | return [strokeWidth * 0.5, strokeWidth * 1.5]; 14 | default: 15 | return []; 16 | } 17 | }; 18 | export default getStrokeLineDash; 19 | -------------------------------------------------------------------------------- /src/lib/utils/drawingUtility/getSVGStroke.ts: -------------------------------------------------------------------------------- 1 | export function getSvgPathFromStroke(stroke:number[][]): string { 2 | if (!stroke.length) return ""; 3 | 4 | const d = stroke.reduce( 5 | (acc, [x0, y0], i, arr) => { 6 | const [x1, y1] = arr[(i + 1) % arr.length]; 7 | acc.push(x0.toString(), y0.toString(), ((x0 + x1) / 2).toString(), ((y0 + y1) / 2).toString()); 8 | return acc; 9 | }, 10 | ["M", ...stroke[0].map(n => n.toString()), "Q"] 11 | ); 12 | 13 | d.push("Z"); 14 | return d.join(" "); 15 | } -------------------------------------------------------------------------------- /src/lib/hitTest/ellipse.ts: -------------------------------------------------------------------------------- 1 | import { args } from './argumentType'; 2 | export const isInsideEllipse = ({ point, element }: args) => { 3 | console.log('checking if inside ellipse '); 4 | console.log(element.toJSON()); 5 | const x = point[0]; 6 | const y = point[1]; 7 | const dx = x - (Number(element.get('x')) + Number(element.get('width')) / 2); 8 | const dy = y - (Number(element.get('y')) + Number(element.get('height')) / 2); 9 | return ( 10 | dx ** 2 / (Number(element.get('width')) / 2) ** 2 + 11 | dy ** 2 / (Number(element.get('height')) / 2) ** 2 <= 12 | 1 13 | ); 14 | }; 15 | -------------------------------------------------------------------------------- /src/lib/hitTest/line.ts: -------------------------------------------------------------------------------- 1 | import { args } from './argumentType'; 2 | 3 | 4 | export function isNearLine({point , element} : args) { 5 | const x = point[0] 6 | const y = point[1] 7 | const threshold = 5 8 | const x1 = Number(element.get('x')); 9 | const y1 = Number(element.get('y')); 10 | const width = Number(element.get('width')); 11 | const height = Number(element.get('height')); 12 | 13 | const x2 = x1 + width; 14 | const y2 = y1 + height; 15 | const distance = 16 | Math.abs((y2 - y1) * x - (x2 - x1) * y + x2 * y1 - y2 * x1) / 17 | Math.hypot(y2 - y1, x2 - x1); 18 | return distance <= threshold; 19 | } 20 | -------------------------------------------------------------------------------- /server/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.* 7 | .yarn/* 8 | !.yarn/patches 9 | !.yarn/plugins 10 | !.yarn/releases 11 | !.yarn/versions 12 | 13 | # testing 14 | /coverage 15 | 16 | # next.js 17 | /.next/ 18 | /out/ 19 | 20 | # production 21 | /build 22 | 23 | # misc 24 | .DS_Store 25 | *.pem 26 | 27 | # debug 28 | npm-debug.log* 29 | yarn-debug.log* 30 | yarn-error.log* 31 | .pnpm-debug.log* 32 | 33 | # env files (can opt-in for committing if needed) 34 | .env* 35 | 36 | 37 | # typescript 38 | *.tsbuildinfo 39 | next-env.d.ts 40 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.* 7 | .yarn/* 8 | !.yarn/patches 9 | !.yarn/plugins 10 | !.yarn/releases 11 | !.yarn/versions 12 | 13 | # testing 14 | /coverage 15 | 16 | # next.js 17 | /.next/ 18 | /out/ 19 | 20 | # production 21 | /build 22 | 23 | # misc 24 | .DS_Store 25 | *.pem 26 | 27 | # debug 28 | npm-debug.log* 29 | yarn-debug.log* 30 | yarn-error.log* 31 | .pnpm-debug.log* 32 | 33 | # env files (can opt-in for committing if needed) 34 | .env* 35 | 36 | # vercel 37 | .vercel 38 | 39 | # typescript 40 | *.tsbuildinfo 41 | next-env.d.ts 42 | .env -------------------------------------------------------------------------------- /src/lib/helperfunc/renderCanvas.ts: -------------------------------------------------------------------------------- 1 | // const renderCanvas = ( 2 | // canvasRef, 3 | // yElement, 4 | // DrawElements, 5 | // selectedYElement, 6 | // DrawBounds, 7 | // Bound 8 | // ) => { 9 | // const canvas = canvasRef.current; 10 | // if (!canvas) return; 11 | // const ctx = canvas.getContext('2d'); 12 | // if (!ctx) return; 13 | 14 | // ctx.clearRect(0, 0, canvas.width, canvas.height); 15 | 16 | // yElement.forEach((el) => { 17 | // DrawElements({ ctx, element: el }); 18 | // }); 19 | 20 | // // Optional: draw bound on top of everything 21 | // if (selectedYElement && Bound) { 22 | // DrawBounds({ context: ctx, bounds: Bound }); 23 | // } 24 | // }; 25 | -------------------------------------------------------------------------------- /src/lib/dragElement.ts: -------------------------------------------------------------------------------- 1 | import { Point } from 'roughjs/bin/geometry'; 2 | import * as Y from 'yjs'; 3 | type args = { 4 | initialPosition: Point; 5 | currentPosition: Point; 6 | element: Y.Map; 7 | }; 8 | export const DragElements = ({ 9 | initialPosition, 10 | currentPosition, 11 | element, 12 | }: args) => { 13 | const offsetX = initialPosition[0] - Number(element.get('x')); 14 | 15 | const offsetY = initialPosition[1] - Number(element.get('y')); 16 | 17 | const x = currentPosition[0] - offsetX; 18 | const y = currentPosition[1] - offsetY; 19 | 20 | const updatedElement = { 21 | ...element, 22 | x: x, 23 | y: y, 24 | }; 25 | return updatedElement; 26 | }; 27 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2017", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "noEmit": true, 9 | "esModuleInterop": true, 10 | "module": "esnext", 11 | "moduleResolution": "bundler", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "jsx": "preserve", 15 | "incremental": true, 16 | "plugins": [ 17 | { 18 | "name": "next" 19 | } 20 | ], 21 | "paths": { 22 | "@/*": ["./src/*"] 23 | } 24 | }, 25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 26 | "exclude": ["node_modules"] 27 | } 28 | -------------------------------------------------------------------------------- /src/lib/utils/boundsUtility/isPointInPaddedBounds.ts: -------------------------------------------------------------------------------- 1 | import { point } from '@/types/type'; 2 | import { boundType } from './getBounds'; 3 | 4 | const BOUNDS_PADDING = 6; // Same as in drawBounds.ts 5 | 6 | export const isPointInPaddedBounds = ( 7 | point: point, 8 | bounds: boundType 9 | ): boolean => { 10 | const [px, py] = point; 11 | const { x, y, width, height } = bounds; 12 | 13 | // Calculate padded bounding box (same as in drawBounds.ts) 14 | const boxX = x - BOUNDS_PADDING; 15 | const boxY = y - BOUNDS_PADDING; 16 | const boxWidth = width + BOUNDS_PADDING * 2; 17 | const boxHeight = height + BOUNDS_PADDING * 2; 18 | 19 | return ( 20 | px >= boxX && 21 | px <= boxX + boxWidth && 22 | py >= boxY && 23 | py <= boxY + boxHeight 24 | ); 25 | }; 26 | 27 | -------------------------------------------------------------------------------- /src/hooks/useRoghCanvas.ts: -------------------------------------------------------------------------------- 1 | import { useRef, useEffect } from 'react'; 2 | import rough from 'roughjs/bin/rough'; 3 | import type { RoughCanvas } from 'roughjs/bin/canvas'; 4 | 5 | const useRoughCanvas = () => { 6 | const canvasRef = useRef(null); 7 | const roughCanvasRef = useRef(null); 8 | const generatorRef = useRef | null>( 9 | null 10 | ); 11 | 12 | useEffect(() => { 13 | if (canvasRef.current) { 14 | const rc = rough.canvas(canvasRef.current); 15 | roughCanvasRef.current = rc; 16 | generatorRef.current = rc.generator; 17 | } 18 | }, []); 19 | 20 | return { 21 | canvasRef, 22 | roughCanvas: roughCanvasRef, 23 | generator: generatorRef, 24 | }; 25 | }; 26 | 27 | export default useRoughCanvas; 28 | -------------------------------------------------------------------------------- /src/lib/math/point.ts: -------------------------------------------------------------------------------- 1 | import { point, Rectangle } from '@/types/type'; 2 | 3 | export function pointFrom( 4 | x: number, 5 | y: number, 6 | ): Point { 7 | return [x, y] as Point; 8 | } 9 | 10 | export function pointDistance

( 11 | a: P, 12 | b: P, 13 | ): number { 14 | return Math.hypot(b[0] - a[0], b[1] - a[1]); 15 | } 16 | 17 | export function pointCenter

(a: P, b: P): P { 18 | return pointFrom((a[0] + b[0]) / 2, (a[1] + b[1]) / 2); 19 | } 20 | export function pointDistanceSq

( 21 | a: P, 22 | b: P, 23 | ): number { 24 | const xDiff = b[0] - a[0]; 25 | const yDiff = b[1] - a[1]; 26 | 27 | return xDiff * xDiff + yDiff * yDiff; 28 | } 29 | export function rectangle

( 30 | topLeft: P, 31 | bottomRight: P, 32 | ): Rectangle

{ 33 | return [topLeft, bottomRight] as Rectangle

; 34 | } 35 | -------------------------------------------------------------------------------- /src/app/globals.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss"; 2 | 3 | :root { 4 | --background: #ffffff; 5 | --foreground: #171717; 6 | } 7 | 8 | @theme inline { 9 | --color-background: var(--background); 10 | --color-foreground: var(--foreground); 11 | --font-sans: var(--font-geist-sans); 12 | --font-mono: var(--font-geist-mono); 13 | } 14 | 15 | @media (prefers-color-scheme: dark) { 16 | :root { 17 | --background: #0a0a0a; 18 | --foreground: #ededed; 19 | } 20 | } 21 | 22 | body { 23 | background: var(--background); 24 | color: var(--foreground); 25 | font-family: Arial, Helvetica, sans-serif; 26 | } 27 | html, body { 28 | overscroll-behavior: none; 29 | -webkit-overflow-scrolling: touch; 30 | position: fixed; 31 | overflow: hidden; 32 | width: 100%; 33 | height: 100%; 34 | } 35 | 36 | canvas { 37 | -webkit-touch-callout: none; 38 | -webkit-user-select: none; 39 | -webkit-tap-highlight-color: transparent; 40 | } -------------------------------------------------------------------------------- /public/globe.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata , Viewport } from "next"; 2 | import { Geist, Geist_Mono } from "next/font/google"; 3 | import "./globals.css"; 4 | 5 | const geistSans = Geist({ 6 | variable: "--font-geist-sans", 7 | subsets: ["latin"], 8 | }); 9 | 10 | const geistMono = Geist_Mono({ 11 | variable: "--font-geist-mono", 12 | subsets: ["latin"], 13 | }); 14 | 15 | export const metadata: Metadata = { 16 | title: "ONLY DRAW", 17 | icons : { 18 | icon :'/icon.png' 19 | }, 20 | }; 21 | 22 | export const viewport: Viewport = { 23 | width: 'device-width', 24 | initialScale: 1, 25 | maximumScale: 1, 26 | userScalable: false, 27 | viewportFit: 'cover', 28 | } 29 | 30 | export default function RootLayout({ 31 | children, 32 | }: Readonly<{ 33 | children: React.ReactNode; 34 | }>) { 35 | return ( 36 | 37 | 39 | {children} 40 | 41 | 42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "canvas", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "framer-motion": "^12.23.24", 13 | "gl-matrix": "^3.4.4", 14 | "lucide-react": "^0.525.0", 15 | "nanoid": "^5.1.6", 16 | "next": "15.4.1", 17 | "perfect-freehand": "^1.2.2", 18 | "react": "19.1.0", 19 | "react-dom": "19.1.0", 20 | "roughjs": "^4.6.6", 21 | "unique-username-generator": "^1.5.1", 22 | "ws": "^8.18.3", 23 | "y-websocket": "^3.0.0", 24 | "yjs": "^13.6.27", 25 | "zustand": "^5.0.6" 26 | }, 27 | "devDependencies": { 28 | "@eslint/eslintrc": "^3", 29 | "@tailwindcss/postcss": "^4", 30 | "@types/node": "^20", 31 | "@types/react": "^19", 32 | "@types/react-dom": "^19", 33 | "eslint": "^9", 34 | "eslint-config-next": "15.4.1", 35 | "tailwindcss": "^4", 36 | "typescript": "^5" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/lib/utils/drawingUtility/hitTest.ts: -------------------------------------------------------------------------------- 1 | import { elementType, Stroke } from '@/types/type'; 2 | import { args } from '../../hitTest/argumentType'; 3 | 4 | import { isInsideRectangle } from '../../hitTest/rectangle'; 5 | import { isInsideEllipse } from '../../hitTest/ellipse'; 6 | import { isNearLine } from '../../hitTest/line'; 7 | import { isNearFreehand } from '../../hitTest/free-hand'; 8 | export const isPointInsideElement = ({ point, element }: args): boolean => { 9 | const type = element.get('type') as unknown as elementType; 10 | switch (type) { 11 | case elementType.Rectangle: { 12 | return isInsideRectangle({ point, element }); 13 | } 14 | case elementType.Ellipse: { 15 | console.log('ellipse is being checked '); 16 | return isInsideEllipse({ point, element }); 17 | } 18 | case elementType.Line: { 19 | return isNearLine({ point, element }); 20 | } 21 | case elementType.Freehand: { 22 | return isNearFreehand({point, element}); 23 | } 24 | default: 25 | return false; 26 | } 27 | }; 28 | -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | import { useState, useEffect } from 'react'; 3 | import dynamic from 'next/dynamic'; 4 | import Loading from '@/component/loading'; 5 | 6 | const Canvas = dynamic(() => import('@/app/canvas/page'), { 7 | ssr: false, 8 | }); 9 | 10 | export default function Home() { 11 | const [isLoading, setIsLoading] = useState(true); 12 | const [canvasLoaded, setCanvasLoaded] = useState(false); 13 | 14 | useEffect(() => { 15 | 16 | const preloadTimer = setTimeout(() => { 17 | setCanvasLoaded(true); 18 | }, 100); 19 | 20 | 21 | const loaderTimer = setTimeout(() => { 22 | setIsLoading(false); 23 | }, 1000); 24 | 25 | return () => { 26 | clearTimeout(preloadTimer); 27 | clearTimeout(loaderTimer); 28 | }; 29 | }, []); 30 | 31 | return ( 32 |

33 | {isLoading && ( 34 |
35 | 36 |
37 | )} 38 | {canvasLoaded && ( 39 |
40 | 41 |
42 | )} 43 |
44 | ); 45 | } -------------------------------------------------------------------------------- /src/Store/yjs-store.ts: -------------------------------------------------------------------------------- 1 | import * as Y from 'yjs'; 2 | // import { YElement } from '@/types/type'; 3 | import { WebsocketProvider } from 'y-websocket'; 4 | 5 | const doc = new Y.Doc(); 6 | if (!doc) { 7 | throw new Error('Failed to create Yjs document'); 8 | } 9 | console.log('Yjs store initialized'); 10 | 11 | // const wsProvider = new WebsocketProvider( 12 | // 'ws://localhost:1234', 13 | // 'my-roomname', 14 | // doc 15 | // ); 16 | export const LOCAL_ORIGIN = { local: true }; 17 | export const LIVE_ORIGIN = { live: true }; 18 | 19 | const elements = doc.getMap>('elements'); 20 | const order = doc.getArray('order'); 21 | // wsProvider.on('status', (event) => { 22 | // console.log('Provider Status:', event.status); 23 | // }); 24 | 25 | const UndoManager = new Y.UndoManager([elements], { 26 | captureTimeout: 500, 27 | trackedOrigins: new Set([LOCAL_ORIGIN]), 28 | }); 29 | 30 | UndoManager.on('stack-item-added', () => { 31 | console.log( 32 | 'Undo stack item added. Current undo stack size:', 33 | UndoManager.undoStack.length 34 | ); 35 | }); 36 | 37 | UndoManager.on('stack-item-popped', () => { 38 | console.log( 39 | 'Undo stack item removed. Current undo stack size:', 40 | UndoManager.undoStack.length 41 | ); 42 | }); 43 | export { UndoManager }; 44 | export const canvasDoc = { 45 | Y: Y, 46 | doc: doc, 47 | yElement: elements, 48 | order: order, 49 | }; 50 | 51 | export default canvasDoc; 52 | -------------------------------------------------------------------------------- /src/component/toolbar.tsx: -------------------------------------------------------------------------------- 1 | 2 | import { TOOLBAR_ITEM } from '@/types/toolbarData' 3 | import { useAppStore } from '@/Store/store'; 4 | import { actionType, elementType } from '@/types/type'; 5 | 6 | 7 | type ToolbarProps = { 8 | className?: string; 9 | }; 10 | const Toolbar: React.FC = ({ className }) => { 11 | const { setActiveToolbarId, toolbar, setCurrentTool, setIsDragging, setIsSelecting } = useAppStore() 12 | 13 | const handleClick = (id: string, action: actionType, elementType: elementType) => { 14 | setActiveToolbarId(id) 15 | 16 | setCurrentTool({ action, elementType }) 17 | if (action === actionType.Selecting) setIsSelecting(true) 18 | else { setIsSelecting(false) } 19 | if (action === actionType.Dragging) setIsDragging(true) 20 | else { setIsDragging(false) } 21 | 22 | 23 | } 24 | return ( 25 |
{ 26 | TOOLBAR_ITEM.map((item, index) => ( 27 | 33 | )) 34 | }
35 | ) 36 | } 37 | 38 | export default Toolbar -------------------------------------------------------------------------------- /src/lib/hitTest/free-hand.ts: -------------------------------------------------------------------------------- 1 | 2 | import { args } from './argumentType'; 3 | import * as Y from 'yjs'; 4 | 5 | 6 | export function isNearFreehand({ point, element }: args): boolean { 7 | const threshold = 15; 8 | const x = point[0]; 9 | const y = point[1]; 10 | const originX = element.get('x') as number; 11 | const originY = element.get('y') as number; 12 | const points = element.get('points') as Y.Array>; 13 | const stroke = points 14 | .toArray() 15 | .map((p) => [ 16 | (p.get('x') as number) + originX, 17 | (p.get('y') as number) + originY, 18 | p.get('pressure') as number, 19 | ]); 20 | 21 | for (let i = 0; i < stroke.length - 1; i++) { 22 | const [x1, y1] = stroke[i]; 23 | const [x2, y2] = stroke[i + 1]; 24 | if (isNearLineSegment(x, y, x1, y1, x2, y2, threshold)) { 25 | return true; 26 | } 27 | } 28 | return false; 29 | } 30 | 31 | 32 | function isNearLineSegment( 33 | px: number, 34 | py: number, 35 | x1: number, 36 | y1: number, 37 | x2: number, 38 | y2: number, 39 | threshold: number 40 | ): boolean { 41 | const dx = x2 - x1; 42 | const dy = y2 - y1; 43 | 44 | if (dx === 0 && dy === 0) { 45 | // Line segment is a point 46 | const dist = Math.hypot(px - x1, py - y1); 47 | return dist <= threshold; 48 | } 49 | 50 | const t = Math.max( 51 | 0, 52 | Math.min(1, ((px - x1) * dx + (py - y1) * dy) / (dx * dx + dy * dy)) 53 | ); 54 | 55 | const closestX = x1 + t * dx; 56 | const closestY = y1 + t * dy; 57 | const dist = Math.hypot(px - closestX, py - closestY); 58 | 59 | return dist <= threshold; 60 | } 61 | -------------------------------------------------------------------------------- /src/lib/resizeBound.ts: -------------------------------------------------------------------------------- 1 | import { point } from '@/types/type'; 2 | 3 | function resizeBound( 4 | handle: string, 5 | initialPosition: point, 6 | currentPosition: point, 7 | originalRect: { x: number; y: number; width: number; height: number } 8 | ) { 9 | const [startX, startY] = initialPosition; 10 | const [x, y] = currentPosition; 11 | const dx = x - startX; 12 | const dy = y - startY; 13 | 14 | let newX = originalRect.x; 15 | let newY = originalRect.y; 16 | let newWidth = originalRect.width; 17 | let newHeight = originalRect.height; 18 | 19 | switch (handle) { 20 | case 'se': 21 | newWidth += dx; 22 | newHeight += dy; 23 | break; 24 | case 'sw': 25 | newX += dx; 26 | newWidth -= dx; 27 | newHeight += dy; 28 | break; 29 | case 'ne': 30 | newY += dy; 31 | newWidth += dx; 32 | newHeight -= dy; 33 | break; 34 | case 'nw': 35 | newX += dx; 36 | newY += dy; 37 | newWidth -= dx; 38 | newHeight -= dy; 39 | break; 40 | case 'n': 41 | newY += dy; 42 | newHeight -= dy; 43 | break; 44 | case 's': 45 | newHeight += dy; 46 | break; 47 | case 'e': 48 | newWidth += dx; 49 | break; 50 | case 'w': 51 | newX += dx; 52 | newWidth -= dx; 53 | break; 54 | } 55 | 56 | if (newWidth < 0) { 57 | newX += newWidth; 58 | newWidth = Math.abs(newWidth); 59 | } 60 | if (newHeight < 0) { 61 | newY += newHeight; 62 | newHeight = Math.abs(newHeight); 63 | } 64 | 65 | return { x: newX, y: newY, width: newWidth, height: newHeight }; 66 | } 67 | export default resizeBound; 68 | -------------------------------------------------------------------------------- /src/types/toolbarData.ts: -------------------------------------------------------------------------------- 1 | import { 2 | SquareIcon, 3 | CircleIcon, 4 | MinusIcon, 5 | PencilIcon, 6 | MousePointer, 7 | Scaling, 8 | Trash, 9 | } from 'lucide-react'; 10 | import { actionType, elementType, ToolBarDataType } from './type'; 11 | 12 | export const TOOLBAR_ITEM: ToolBarDataType[] = [ 13 | { 14 | id: 'rectangle', 15 | name: 'rectangle', 16 | icon: SquareIcon, 17 | actionType: actionType.Drawing, 18 | isActive: false, 19 | elementType: elementType.Rectangle, 20 | }, 21 | { 22 | id: 'circle', 23 | name: 'circle', 24 | icon: CircleIcon, 25 | actionType: actionType.Drawing, 26 | isActive: false, 27 | elementType: elementType.Ellipse, 28 | }, 29 | { 30 | id: 'line', 31 | name: 'line', 32 | icon: MinusIcon, 33 | actionType: actionType.Drawing, 34 | isActive: false, 35 | elementType: elementType.Line, 36 | }, 37 | { 38 | id: 'freeHand', 39 | name: 'freeHand', 40 | icon: PencilIcon, 41 | actionType: actionType.Drawing, 42 | isActive: false, 43 | elementType: elementType.Freehand, 44 | }, 45 | { 46 | id: 'select', 47 | name: 'Select', 48 | icon: MousePointer, 49 | isActive: false, 50 | actionType: actionType.Selecting, 51 | elementType: elementType.Select, 52 | }, 53 | 54 | { 55 | id: 'scale', 56 | name: 'scale', 57 | icon: Scaling, 58 | actionType: actionType.Resizing, 59 | isActive: false, 60 | elementType: elementType.Select, 61 | }, 62 | { 63 | id: 'delete', 64 | name: 'delete', 65 | icon: Trash, 66 | actionType: actionType.Delete, 67 | isActive: false, 68 | elementType: elementType.Delete, 69 | }, 70 | ]; 71 | -------------------------------------------------------------------------------- /src/lib/drawBounds.ts: -------------------------------------------------------------------------------- 1 | import { boundType } from './utils/boundsUtility/getBounds'; 2 | type args = { 3 | context: CanvasRenderingContext2D; 4 | bounds: boundType; 5 | }; 6 | 7 | export const DrawBounds = ({ context, bounds }: args) => { 8 | const { x, y, width, height } = bounds; 9 | const padding = 6; 10 | const handleRadius = 4; 11 | 12 | context.save(); 13 | 14 | 15 | context.strokeStyle = 'rgba(0, 120, 255, 0.8)'; 16 | context.lineWidth = 2; 17 | context.shadowColor = 'rgba(0, 120, 255, 0.3)'; 18 | context.shadowBlur = 8; 19 | 20 | const boxX = x - padding; 21 | const boxY = y - padding; 22 | const boxWidth = width + padding * 2; 23 | const boxHeight = height + padding * 2; 24 | 25 | context.strokeRect(boxX, boxY, boxWidth, boxHeight); 26 | 27 | context.shadowBlur = 0; 28 | context.fillStyle = 'white'; 29 | context.strokeStyle = 'rgba(0, 120, 255, 0.9)'; 30 | context.lineWidth = 1.5; 31 | 32 | 33 | const handleMinX = boxX; 34 | const handleMinY = boxY; 35 | const handleMaxX = boxX + boxWidth; 36 | const handleMaxY = boxY + boxHeight; 37 | const handleMidX = boxX + boxWidth / 2; 38 | const handleMidY = boxY + boxHeight / 2; 39 | 40 | const handles = [ 41 | [handleMinX, handleMinY], 42 | [handleMidX, handleMinY], 43 | [handleMaxX, handleMinY], 44 | [handleMaxX, handleMidY], 45 | [handleMaxX, handleMaxY], 46 | [handleMidX, handleMaxY], // bottom-middle 47 | [handleMinX, handleMaxY], // bottom-left 48 | [handleMinX, handleMidY], // middle-left 49 | ]; 50 | 51 | handles.forEach(([hx, hy]) => { 52 | context.beginPath(); 53 | context.arc(hx, hy, handleRadius, 0, Math.PI * 2); 54 | context.fill(); 55 | context.stroke(); 56 | }); 57 | context.restore(); 58 | }; 59 | -------------------------------------------------------------------------------- /src/lib/hitTest/detectResizeHandler.ts: -------------------------------------------------------------------------------- 1 | 2 | import { point } from '@/types/type'; 3 | import { boundType } from '../utils/boundsUtility/getBounds'; 4 | 5 | const BOUNDS_PADDING = 8; 6 | 7 | export type args = { 8 | point: point; 9 | element: boundType; 10 | tolerance: number; 11 | }; 12 | function detectResizeHandle({ point, element, tolerance }: args) { 13 | const pointerX = point[0]; 14 | const pointerY = point[1]; 15 | const x = element.x; 16 | const y = element.y; 17 | const w = element.width; 18 | const h = element.height; 19 | 20 | 21 | const boxX = x - BOUNDS_PADDING; 22 | const boxY = y - BOUNDS_PADDING; 23 | const boxWidth = w + BOUNDS_PADDING * 2; 24 | const boxHeight = h + BOUNDS_PADDING * 2; 25 | 26 | 27 | const corners = { 28 | nw: { x: boxX, y: boxY, cursor: 'nw-resize' }, 29 | ne: { x: boxX + boxWidth, y: boxY, cursor: 'ne-resize' }, 30 | sw: { x: boxX, y: boxY + boxHeight, cursor: 'sw-resize' }, 31 | se: { x: boxX + boxWidth, y: boxY + boxHeight, cursor: 'se-resize' }, 32 | }; 33 | 34 | const sides = { 35 | n: { x: boxX + boxWidth / 2, y: boxY, cursor: 'n-resize' }, 36 | s: { x: boxX + boxWidth / 2, y: boxY + boxHeight, cursor: 's-resize' }, 37 | e: { x: boxX + boxWidth, y: boxY + boxHeight / 2, cursor: 'e-resize' }, 38 | w: { x: boxX, y: boxY + boxHeight / 2, cursor: 'w-resize' }, 39 | }; 40 | 41 | const allHandles = { ...corners, ...sides }; 42 | 43 | for (const [key, handle] of Object.entries(allHandles)) { 44 | const dx = pointerX - handle.x; 45 | const dy = pointerY - handle.y; 46 | const distance = Math.sqrt(dx * dx + dy * dy); 47 | if (distance <= tolerance) { 48 | return { direction: key, cursor: handle.cursor }; 49 | } 50 | } 51 | 52 | return null; 53 | } 54 | export default detectResizeHandle; 55 | -------------------------------------------------------------------------------- /src/lib/utils/boundsUtility/getBounds.ts: -------------------------------------------------------------------------------- 1 | import { elementType, } from '@/types/type'; 2 | 3 | import * as Y from 'yjs'; 4 | 5 | type args = { 6 | element: Y.Map; 7 | }; 8 | export type boundType = { 9 | x: number; 10 | y: number; 11 | width: number; 12 | height: number; 13 | }; 14 | export const getBounds = ({ element }: args): boundType => { 15 | const x = Number(element.get('x')); 16 | const y = Number(element.get('y')); 17 | const width = Number(element.get('width')); 18 | const height = Number(element.get('height')); 19 | const type = element.get('type') as unknown as elementType; 20 | switch (type) { 21 | case elementType.Line: 22 | case elementType.Rectangle: 23 | case elementType.Ellipse: { 24 | const x1 = Math.min(x, x + width); 25 | const y1 = Math.min(y, y + height); 26 | const x2 = Math.max(x, x + width); 27 | const y2 = Math.max(y, y + height); 28 | return { x: x1, y: y1, width: x2 - x1, height: y2 - y1 }; 29 | } 30 | 31 | case elementType.Freehand: { 32 | const strokeData = element.get('points') as Y.Array>; 33 | const points = strokeData 34 | .toArray() 35 | .map((p) => [ 36 | p.get('x') as number, 37 | p.get('y') as number, 38 | p.get('pressure') as number, 39 | ]); 40 | 41 | if (points.length === 0) { 42 | const x1 = Math.min(x, x + width); 43 | const y1 = Math.min(y, y + height); 44 | const x2 = Math.max(x, x + width); 45 | const y2 = Math.max(y, y + height); 46 | return { x: x1, y: y1, width: x2 - x1, height: y2 - y1 }; 47 | } 48 | 49 | 50 | const pad = 11; 51 | return { 52 | x: Number(element.get('x')) - pad, 53 | y: Number(element.get('y')) - pad, 54 | width: Number(element.get('width')) + 2 * pad, 55 | height: Number(element.get('height')) + 2 * pad, 56 | }; 57 | } 58 | default: 59 | return { x: 0, y: 0, width: 0, height: 0 }; 60 | } 61 | }; 62 | -------------------------------------------------------------------------------- /src/component/loading.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Image from 'next/image' 3 | 4 | const CustomLoader = () => { 5 | return ( 6 |
7 | {/* Loader Container 8 | - h-4: Height of the bar 9 | - w-full: Takes up full width of parent 10 | - bg-green-100: Very light green background for the 'empty' part 11 | - rounded-xl: 'Soft edges' (rounded corners) 12 | - overflow-hidden: Ensures the inner bar doesn't poke out of the rounded corners 13 | */} 14 |
15 | 16 | {/* Animated Bar 17 | - h-full: Matches container height 18 | - bg-green-500: The main 'Greenish' color 19 | - rounded-xl: Soft edges 20 | - animate-fill: Custom animation defined in style tag below 21 | */} 22 |
23 |
24 | 25 | {/* Optional styling for the animation itself */} 26 | 37 |
38 | ); 39 | }; 40 | function Loading() { 41 | return ( 42 |
43 |
44 | 45 |
46 |
47 | Only Draw 48 |
49 |
50 |
Loading ..
51 | 52 |
53 |
54 | ) 55 | } 56 | 57 | export default Loading -------------------------------------------------------------------------------- /src/lib/handleElement.ts: -------------------------------------------------------------------------------- 1 | import { 2 | actionType, 3 | elementType, 4 | point, 5 | OnlyDrawElement, 6 | Stroke, 7 | } from '@/types/type'; 8 | import { nanoid } from 'nanoid'; 9 | 10 | import { ElementOptions } from '@/types/type'; 11 | type DrawArgs = { 12 | action: actionType; 13 | options: ElementOptions; 14 | element: elementType | null; 15 | startPoint: point; 16 | endPoint: point; 17 | stroke?: Stroke; 18 | }; 19 | export const handleDrawElement = ({ 20 | action, 21 | element, 22 | startPoint, 23 | endPoint, 24 | options, 25 | stroke, 26 | }: DrawArgs): OnlyDrawElement | null => { 27 | if (action !== actionType.Drawing) return null; 28 | const seed = Math.floor(Math.random() * 2 ** 31).toString(); 29 | const baseProperties = { 30 | id: nanoid(), 31 | x: startPoint[0], 32 | y: startPoint[1], 33 | width: endPoint[0] - startPoint[0], 34 | height: endPoint[1] - startPoint[1], 35 | isDeleted: false, 36 | seed: seed, 37 | strokeColor: options.strokeColor, 38 | strokeWidth: options.strokeWidth, 39 | roughness: options.roughness, 40 | }; 41 | switch (element) { 42 | case elementType.Rectangle: 43 | return { 44 | ...baseProperties, 45 | type: elementType.Rectangle, 46 | fillColor: options.fillColor, 47 | fillStyle: options.fillStyle, 48 | fillWeight: options.fillWeight, 49 | boundaryStyle: options.boundaryStyle, 50 | }; 51 | 52 | case elementType.Line: 53 | return { 54 | ...baseProperties, 55 | type: elementType.Line, 56 | boundaryStyle: options.boundaryStyle, 57 | }; 58 | 59 | case elementType.Ellipse: 60 | return { 61 | ...baseProperties, 62 | type: elementType.Ellipse, 63 | fillColor: options.fillColor, 64 | fillStyle: options.fillStyle, 65 | fillWeight: options.fillWeight, 66 | boundaryStyle: options.boundaryStyle, 67 | }; 68 | 69 | case elementType.Freehand: 70 | if (!stroke) return null; 71 | return { 72 | ...baseProperties, 73 | type: elementType.Freehand, 74 | stroke: stroke, 75 | }; 76 | 77 | default: 78 | return null; 79 | } 80 | }; 81 | -------------------------------------------------------------------------------- /src/lib/resizeElement.ts: -------------------------------------------------------------------------------- 1 | import { boundType } from './utils/boundsUtility/getBounds'; 2 | import * as Y from 'yjs'; 3 | import { elementType, PointsFreeHand } from '@/types/type'; 4 | 5 | type resizeArgs = { 6 | element: Y.Map; 7 | newBounds: boundType; 8 | oldBounds: boundType; 9 | originalPoints?: PointsFreeHand[] | null; 10 | }; 11 | 12 | const resizeSimpleShape = ({ element, newBounds }: resizeArgs) => { 13 | element.set('x', newBounds.x); 14 | element.set('y', newBounds.y); 15 | element.set('width', newBounds.width); 16 | element.set('height', newBounds.height); 17 | }; 18 | 19 | export const resizeFreehand = ({ 20 | element, 21 | newBounds, 22 | oldBounds, 23 | originalPoints, 24 | }: resizeArgs) => { 25 | // const oldElementX = Number(element.get('x')); 26 | // const oldElementY = Number(element.get('y')); 27 | // const points = element.get('points') as Y.Array>; 28 | // const stroke = points 29 | // .toArray() 30 | // .map((p) => [p.get('x') as number, p.get('y') as number, 1]); 31 | // const strokeData = { points: stroke } as { points: Point[] }; 32 | 33 | if (!originalPoints || originalPoints.length === 0) { 34 | console.error('Freehand element has no stroke points to resize.'); 35 | return; 36 | } 37 | 38 | const Sx = newBounds.width / Math.max(1, oldBounds.width); 39 | const Sy = newBounds.height / Math.max(1, oldBounds.height); 40 | 41 | const newPoints: PointsFreeHand[] = originalPoints.map( 42 | ([px, py, pressure]) => { 43 | const R_primeX = px * Sx; 44 | const R_primeY = py * Sy; 45 | return [R_primeX, R_primeY, pressure]; 46 | } 47 | ); 48 | 49 | element.set('x', newBounds.x); 50 | element.set('y', newBounds.y); 51 | element.set('width', newBounds.width); 52 | element.set('height', newBounds.height); 53 | 54 | const newPointsY = new Y.Array>(); 55 | newPoints.forEach(([px, py, pressure]) => { 56 | const pointMap = new Y.Map(); 57 | pointMap.set('x', px); 58 | pointMap.set('y', py); 59 | pointMap.set('pressure', pressure); 60 | newPointsY.push([pointMap]); 61 | }); 62 | 63 | element.set('points', newPointsY); 64 | }; 65 | export const resizeElement = ({ 66 | element, 67 | newBounds, 68 | oldBounds, 69 | originalPoints, 70 | }: resizeArgs) => { 71 | const type = element.get('type') as unknown as elementType; 72 | 73 | switch (type) { 74 | case elementType.Rectangle: 75 | case elementType.Line: 76 | case elementType.Ellipse: { 77 | resizeSimpleShape({ element, newBounds, oldBounds }); 78 | break; 79 | } 80 | 81 | case elementType.Freehand: { 82 | resizeFreehand({ element, newBounds, oldBounds, originalPoints }); 83 | break; 84 | } 85 | } 86 | }; 87 | -------------------------------------------------------------------------------- /src/types/type.ts: -------------------------------------------------------------------------------- 1 | import { LucideIcon } from 'lucide-react'; 2 | 3 | import * as Y from 'yjs'; 4 | export type PointsFreeHand = [number, number, number]; 5 | export type Stroke = { 6 | points: PointsFreeHand[]; 7 | }; 8 | 9 | type baseType = { 10 | id: string; 11 | x: number; 12 | y: number; 13 | seed: string; 14 | height: number; 15 | width: number; 16 | isDeleted: boolean; 17 | strokeColor: string; 18 | strokeWidth: number; 19 | roughness: number; 20 | 21 | }; 22 | export type rectangleElement = baseType & { 23 | type: elementType.Rectangle; 24 | fillColor: string; 25 | fillStyle: string; 26 | fillWeight: number; 27 | boundaryStyle: string; 28 | 29 | }; 30 | 31 | export type ellipseElement = baseType & { 32 | type: elementType.Ellipse; 33 | fillColor: string; 34 | fillStyle: string; 35 | fillWeight: number; 36 | boundaryStyle: string; 37 | 38 | }; 39 | 40 | export type lineElement = baseType & { 41 | type: elementType.Line; 42 | boundaryStyle: string; 43 | 44 | }; 45 | 46 | 47 | export type freeHandElement = baseType & { 48 | stroke: Stroke; 49 | 50 | type: elementType.Freehand; 51 | }; 52 | export type Degrees = number; 53 | 54 | export type OnlyDrawElement = 55 | | rectangleElement 56 | | lineElement 57 | | ellipseElement 58 | | freeHandElement; 59 | 60 | export type YElement = OnlyDrawElement & { 61 | author: number; 62 | }; 63 | export interface SharedDoc { 64 | elements: Y.Map>; 65 | order: Y.Array; 66 | } 67 | export type point = [x: number, y: number]; 68 | 69 | // line is tuple of points P is a generic is a here which is extended 70 | export type line

= [p: P, q: P]; 71 | 72 | export type Vector = [u: number, v: number]; 73 | 74 | export type Rectangle

= [a: P, b: P]; 75 | 76 | export type Ellipse = { 77 | center: Point; 78 | halfWidth: number; 79 | halfHeight: number; 80 | }; 81 | 82 | export const enum actionType { 83 | Drawing = 'drawing', 84 | Selecting = 'selecting', 85 | Dragging = 'dragging', 86 | Resizing = 'resizing', 87 | Delete = 'delete', 88 | } 89 | 90 | export enum elementType { 91 | Rectangle = 'rectangle', 92 | Ellipse = 'ellipse', 93 | Line = 'line', 94 | Freehand = 'freehand', 95 | Select = 'select', 96 | Delete = 'delete', 97 | } 98 | 99 | export type ToolBarDataType = { 100 | id: string; 101 | name: string; 102 | icon: LucideIcon; 103 | elementType: elementType; 104 | actionType: actionType; 105 | isActive: boolean; 106 | }; 107 | export type ElementOptions = { 108 | strokeColor: string; 109 | strokeWidth: number; 110 | roughness: number; 111 | fillColor: string; 112 | fillStyle: string; 113 | fillWeight: number; 114 | boundaryStyle: string; 115 | 116 | }; 117 | -------------------------------------------------------------------------------- /src/lib/utils/createYElement.ts: -------------------------------------------------------------------------------- 1 | import { nanoid } from 'nanoid'; 2 | import canvasDoc from '@/Store/yjs-store'; 3 | import { OnlyDrawElement } from '@/types/type'; 4 | import * as Y from 'yjs'; 5 | const createYElement = (element: OnlyDrawElement) => { 6 | const elementId = nanoid(); 7 | const safeSeed = element.seed ?? Math.floor(Math.random() * 2 ** 31); 8 | const newElement = new canvasDoc.Y.Map(); 9 | newElement.set('id', elementId); 10 | newElement.set('type', element.type); 11 | newElement.set('x', element.x); 12 | newElement.set('y', element.y); 13 | newElement.set('seed', safeSeed); 14 | newElement.set('width', element.width); 15 | newElement.set('height', element.height); 16 | newElement.set('strokeColor', element.strokeColor); 17 | newElement.set('strokeWidth', element.strokeWidth); 18 | if (element.type === 'freehand') { 19 | const points = new canvasDoc.Y.Array>(); 20 | if (element.stroke && Array.isArray(element.stroke.points)) { 21 | element.stroke.points.forEach((relPoint: number[]) => { 22 | const pointMap = new Y.Map(); 23 | pointMap.set('x', relPoint[0] || 0); 24 | pointMap.set('y', relPoint[1] || 0); 25 | pointMap.set('pressure', relPoint[2] || 1); 26 | points.push([pointMap]); 27 | }); 28 | } else { 29 | const initialPoint = new canvasDoc.Y.Map(); 30 | initialPoint.set('x', 0); 31 | initialPoint.set('y', 0); 32 | initialPoint.set('pressure', 1); 33 | points.push([initialPoint]); 34 | } 35 | newElement.set('points', points); 36 | } else if (element.type === 'line') { 37 | newElement.set('boundaryStyle', element.boundaryStyle); 38 | newElement.set('roughness', element.roughness); 39 | } else { 40 | newElement.set('fillColor', element.fillColor); 41 | newElement.set('fillStyle', element.fillStyle); 42 | newElement.set('fillWeight', element.fillWeight); 43 | newElement.set('boundaryStyle', element.boundaryStyle); 44 | newElement.set('roughness', element.roughness); 45 | } 46 | newElement.set('isDeleted', element.isDeleted); 47 | newElement.set('author', canvasDoc.doc.clientID); 48 | 49 | return newElement; 50 | }; 51 | const updateYElement = (element: OnlyDrawElement, yElement: Y.Map) => { 52 | // console.log(`[DEBUG] Updating element ${element.id}`); 53 | // console.log( 54 | // `[DEBUG] Input seed: ${element.seed}, type: ${typeof element.seed}` 55 | // ); 56 | 57 | // const existingSeed = yElement.get('seed'); 58 | // console.log( 59 | // `[DEBUG] Existing Yjs seed: ${existingSeed}, type: ${typeof existingSeed}` 60 | // ); 61 | 62 | yElement.set('x', element.x); 63 | yElement.set('y', element.y); 64 | yElement.set('width', element.width); 65 | yElement.set('height', element.height); 66 | 67 | if (element.type === 'freehand') { 68 | const points = new canvasDoc.Y.Array>(); 69 | if (element.stroke && Array.isArray(element.stroke.points)) { 70 | element.stroke.points.forEach((relPoint: number[]) => { 71 | const pointMap = new Y.Map(); 72 | pointMap.set('x', relPoint[0] || 0); 73 | pointMap.set('y', relPoint[1] || 0); 74 | pointMap.set('pressure', relPoint[2] || 1); 75 | points.push([pointMap]); 76 | }); 77 | } else { 78 | const initialPoint = new canvasDoc.Y.Map(); 79 | initialPoint.set('x', 0); 80 | initialPoint.set('y', 0); 81 | initialPoint.set('pressure', 1); 82 | points.push([initialPoint]); 83 | } 84 | yElement.set('points', points); 85 | } else if (element.type === 'line') { 86 | yElement.set('boundaryStyle', element.boundaryStyle); 87 | yElement.set('roughness', element.roughness); 88 | } else { 89 | yElement.set('fillColor', element.fillColor); 90 | yElement.set('fillStyle', element.fillStyle); 91 | yElement.set('fillWeight', element.fillWeight); 92 | yElement.set('boundaryStyle', element.boundaryStyle); 93 | yElement.set('roughness', element.roughness); 94 | } 95 | yElement.set('isDeleted', element.isDeleted); 96 | 97 | return yElement; 98 | }; 99 | const yUtils = { createYElement, updateYElement }; 100 | export default yUtils; 101 | -------------------------------------------------------------------------------- /src/Store/store.ts: -------------------------------------------------------------------------------- 1 | import { 2 | actionType, 3 | elementType, 4 | freeHandElement, 5 | OnlyDrawElement, 6 | point, 7 | } from '@/types/type'; 8 | import { boundType } from '@/lib/utils/boundsUtility/getBounds'; 9 | import * as Y from 'yjs'; 10 | import { create } from 'zustand'; 11 | 12 | type CurrentTool = { 13 | action: actionType; 14 | elementType: elementType | null; 15 | }; 16 | export type ShapeType = 'rect' | 'circle' | 'line' | 'freehand'; 17 | export type FillStyle = 'solid' | 'hachure' | 'dots' | 'zigzag'; 18 | export type BoundaryStyle = 'solid' | 'dashed' | 'dotted'; 19 | export type AppState = { 20 | elements: OnlyDrawElement[]; 21 | currentTool: CurrentTool; 22 | selectedElementId?: string | null; 23 | toolbar: { 24 | activeToolId: string | null; 25 | }; 26 | bound: boundType | null; 27 | strokeColor: string; 28 | fillColor: string; 29 | isDragging: boolean; 30 | isSelecting: boolean; 31 | isResizing: boolean; 32 | isDrawing: boolean; 33 | pointerPosition: point; 34 | resizeHandle?: string | null; 35 | selectedYElement: Y.Map | null; 36 | isFillTransparent: boolean; 37 | strokeWidth: number; 38 | roughness: number; 39 | fillStyle: FillStyle; 40 | fillWeight: number; 41 | shapeType: ShapeType; 42 | boundaryStyle: BoundaryStyle; 43 | isAdvancedOpen: boolean; 44 | hasShadow: boolean; 45 | opacity: number; 46 | rotation: number; 47 | //actions 48 | setBound: (bound: boundType | null) => void; 49 | setStrokeColor: (color: string) => void; 50 | setFillColor: (color: string) => void; 51 | setYElement: (el: Y.Map | null) => void; 52 | setResizeHandle: (handle: string | null) => void; 53 | setCurrentTool: (tool: CurrentTool) => void; 54 | addElement: (el: OnlyDrawElement) => void; 55 | updateElement: (id: string, data: Partial) => void; 56 | setSelectedElementId: (id: string | null) => void; 57 | setIsDragging: (drag: boolean) => void; 58 | setIsDrawing: (draw: boolean) => void; 59 | setIsSelecting: (draw: boolean) => void; 60 | setIsResizing: (resize: boolean) => void; 61 | setActiveToolbarId: (id: string) => void; 62 | setPointerPosition: (pos: point) => void; 63 | setIsFillTransparent: (v: boolean) => void; 64 | setStrokeWidth: (v: number) => void; 65 | setRoughness: (v: number) => void; 66 | setFillStyle: (v: FillStyle) => void; 67 | setFillWeight: (v: number) => void; 68 | setShapeType: (v: ShapeType) => void; 69 | setBoundaryStyle: (v: BoundaryStyle) => void; 70 | setIsAdvancedOpen: (v: boolean) => void; 71 | setHasShadow: (v: boolean) => void; 72 | setOpacity: (v: number) => void; 73 | setRotation: (v: number) => void; 74 | }; 75 | 76 | export const useAppStore = create((set) => ({ 77 | elements: [], 78 | bound: null, 79 | selectedElementId: null, 80 | resizeHandle: null, 81 | currentTool: { 82 | action: actionType.Selecting, 83 | elementType: null, 84 | }, 85 | strokeColor: '#000000', 86 | fillColor: '#fab005', 87 | isDrawing: false, 88 | isDragging: false, 89 | isResizing: false, 90 | isSelecting: false, 91 | selectedYElement: null, 92 | pointerPosition: [0, 0], 93 | toolbar: { 94 | activeToolId: null, 95 | }, 96 | 97 | isFillTransparent: false, 98 | strokeWidth: 5, 99 | roughness: 5, 100 | fillStyle: 'solid', 101 | fillWeight: 10, 102 | shapeType: 'rect', 103 | boundaryStyle: 'solid', 104 | isAdvancedOpen: false, 105 | hasShadow: false, 106 | opacity: 1, 107 | rotation: 0, 108 | 109 | setBound: (bound) => set({ bound }), 110 | setStrokeColor: (strokeColor) => set({ strokeColor }), 111 | setFillColor: (fillColor) => set({ fillColor }), 112 | setYElement: (el) => set({ selectedYElement: el }), 113 | setResizeHandle: (handle) => set({ resizeHandle: handle }), 114 | setIsDrawing: (draw) => set({ isDrawing: draw }), 115 | setIsSelecting: (select) => set({ isSelecting: select }), 116 | setCurrentTool: (tool) => set({ currentTool: tool }), 117 | addElement: (el) => set((state) => ({ elements: [...state.elements, el] })), 118 | updateElement: (id, data) => 119 | set((state) => ({ 120 | elements: state.elements.map((el) => { 121 | if (el.id !== id) return el; 122 | 123 | switch (el.type) { 124 | case elementType.Rectangle: 125 | case elementType.Ellipse: 126 | case elementType.Line: 127 | return { ...el, ...data } as typeof el; 128 | case elementType.Freehand: 129 | const freehandData = data as Partial; 130 | return { 131 | ...el, 132 | ...freehandData, 133 | stroke: freehandData.stroke ?? el.stroke, 134 | }; 135 | default: 136 | return el; 137 | } 138 | }), 139 | })), 140 | 141 | setSelectedElementId: (id) => set({ selectedElementId: id }), 142 | 143 | setIsDragging: (drag) => set({ isDragging: drag }), 144 | 145 | setIsResizing: (resize) => set({ isResizing: resize }), 146 | 147 | setPointerPosition: (pos) => set({ pointerPosition: pos }), 148 | 149 | setActiveToolbarId: (id) => 150 | set((state) => ({ 151 | toolbar: { ...state.toolbar, activeToolId: id }, 152 | })), 153 | 154 | setIsFillTransparent: (v) => set({ isFillTransparent: v }), 155 | setStrokeWidth: (v) => set({ strokeWidth: v }), 156 | setRoughness: (v) => set({ roughness: v }), 157 | setFillStyle: (v) => set({ fillStyle: v }), 158 | setFillWeight: (v) => set({ fillWeight: v }), 159 | setShapeType: (v) => set({ shapeType: v }), 160 | setBoundaryStyle: (v) => set({ boundaryStyle: v }), 161 | setIsAdvancedOpen: (v) => set({ isAdvancedOpen: v }), 162 | setHasShadow: (v) => set({ hasShadow: v }), 163 | setOpacity: (v) => set({ opacity: v }), 164 | setRotation: (v) => set({ rotation: v }), 165 | })); 166 | -------------------------------------------------------------------------------- /src/lib/utils/drawingUtility/drawElement.ts: -------------------------------------------------------------------------------- 1 | import { elementType } from '@/types/type'; 2 | import getStroke from 'perfect-freehand'; 3 | import { getSvgPathFromStroke } from './getSVGStroke'; 4 | import * as Y from 'yjs'; 5 | import { Drawable } from 'roughjs/bin/core'; 6 | import { RoughCanvas } from 'roughjs/bin/canvas'; 7 | import { RoughGenerator } from 'roughjs/bin/generator'; 8 | import getStrokeLineDash from '@/lib/helperfunc/getStrokedLine'; 9 | import { BoundaryStyle } from '@/Store/store'; 10 | type DrawingArgs = { 11 | ctx: CanvasRenderingContext2D; 12 | element: Y.Map; 13 | rc: RoughCanvas; 14 | }; 15 | 16 | type CachedDrawable = { key: string; drawable: Drawable | null }; 17 | const drawableCache = new WeakMap, CachedDrawable>(); 18 | 19 | // ---------- Small deterministic PRNG (mulberry32) & helper ---------- 20 | function mulberry32(seed: number) { 21 | let t = seed >>> 0; 22 | return function () { 23 | t += 0x6d2b79f5; 24 | let r = Math.imul(t ^ (t >>> 15), t | 1); 25 | r ^= r + Math.imul(r ^ (r >>> 7), r | 61); 26 | return ((r ^ (r >>> 14)) >>> 0) / 4294967296; 27 | }; 28 | } 29 | 30 | function withSeededMath(seed: number, fn: () => T): T { 31 | const originalRandom = Math.random; 32 | try { 33 | const s = seed >>> 0 || 1; 34 | Math.random = mulberry32(s) as unknown as () => number; 35 | return fn(); 36 | } finally { 37 | Math.random = originalRandom; 38 | } 39 | } 40 | 41 | function elementDrawKey(element: Y.Map) { 42 | const x = Number(element.get('x')); 43 | const y = Number(element.get('y')); 44 | const width = Number(element.get('width')); 45 | const height = Number(element.get('height')); 46 | const seed = Number(element.get('seed')) || 0; 47 | const stroke = String(element.get('strokeColor') || ''); 48 | const strokeWidth = Number(element.get('strokeWidth') || 0); 49 | const roughness = Number(element.get('roughness') || 0); 50 | const fill = String(element.get('fillColor') || ''); 51 | const fillStyle = String(element.get('fillStyle') || ''); 52 | const fillWeight = Number(element.get('fillWeight') || 0); 53 | const boundaryStyle = String(element.get('boundaryStyle') || ''); 54 | 55 | return JSON.stringify({ 56 | x, 57 | y, 58 | width, 59 | height, 60 | seed, 61 | stroke, 62 | strokeWidth, 63 | roughness, 64 | fill, 65 | fillStyle, 66 | fillWeight, 67 | boundaryStyle, 68 | }); 69 | } 70 | 71 | function getOrCreateDrawable( 72 | generator: RoughGenerator, 73 | element: Y.Map 74 | ) { 75 | const key = elementDrawKey(element); 76 | const cached = drawableCache.get(element); 77 | if (cached && cached.key === key) { 78 | return cached.drawable; 79 | } 80 | 81 | const type = element.get('type') as unknown as elementType; 82 | const seed = parseInt(String(element.get('seed')), 10) || 0; 83 | 84 | let drawable: Drawable | null = null; 85 | const uiGap = Number(element.get('fillWeight')); 86 | const minGap = 2; 87 | const maxGap = 40; 88 | const hachureGap = maxGap - (uiGap - minGap); 89 | drawable = withSeededMath(seed, () => { 90 | switch (type) { 91 | case elementType.Rectangle: 92 | return generator.rectangle( 93 | Number(element.get('x')), 94 | Number(element.get('y')), 95 | Number(element.get('width')), 96 | Number(element.get('height')), 97 | { 98 | seed, 99 | stroke: String(element.get('strokeColor')), 100 | strokeWidth: Number(element.get('strokeWidth')), 101 | roughness: Number(element.get('roughness')), 102 | fill: String(element.get('fillColor')), 103 | fillStyle: String(element.get('fillStyle')), 104 | hachureGap: hachureGap, 105 | strokeLineDash: getStrokeLineDash( 106 | String(element.get('boundaryStyle')) as BoundaryStyle, 107 | Number(element.get('strokeWidth')) 108 | ), 109 | } 110 | ); 111 | 112 | case elementType.Line: { 113 | const x1 = Number(element.get('x')); 114 | const y1 = Number(element.get('y')); 115 | const x2 = x1 + Number(element.get('width')); 116 | const y2 = y1 + Number(element.get('height')); 117 | return generator.line(x1, y1, x2, y2, { 118 | seed, 119 | stroke: String(element.get('strokeColor')), 120 | strokeWidth: Number(element.get('strokeWidth')), 121 | roughness: Number(element.get('roughness')), 122 | strokeLineDash: getStrokeLineDash( 123 | String(element.get('boundaryStyle')) as BoundaryStyle, 124 | Number(element.get('strokeWidth')) 125 | ), 126 | }); 127 | } 128 | 129 | case elementType.Ellipse: { 130 | const x = Number(element.get('x')); 131 | const y = Number(element.get('y')); 132 | const width = Number(element.get('width')); 133 | const height = Number(element.get('height')); 134 | return generator.ellipse(x + width / 2, y + height / 2, width, height, { 135 | seed, 136 | stroke: String(element.get('strokeColor')), 137 | strokeWidth: Number(element.get('strokeWidth')), 138 | roughness: Number(element.get('roughness')), 139 | fill: String(element.get('fillColor')), 140 | fillStyle: String(element.get('fillStyle')), 141 | hachureGap: hachureGap, 142 | strokeLineDash: getStrokeLineDash( 143 | String(element.get('boundaryStyle')) as BoundaryStyle, 144 | Number(element.get('strokeWidth')) 145 | ), 146 | }); 147 | } 148 | 149 | case elementType.Freehand: { 150 | return null; 151 | } 152 | 153 | default: 154 | return null; 155 | } 156 | }); 157 | 158 | drawableCache.set(element, { key, drawable }); 159 | return drawable; 160 | } 161 | 162 | export const DrawElements = ({ ctx, element, rc }: DrawingArgs) => { 163 | const generator = rc.generator; 164 | 165 | ctx.save(); 166 | const type = element.get('type') as unknown as elementType; 167 | 168 | if (type === elementType.Freehand) { 169 | const x = Number(element.get('x')); 170 | const y = Number(element.get('y')); 171 | 172 | ctx.translate(x, y); 173 | const strokeData = element.get('points') as Y.Array>; 174 | const points = strokeData 175 | .toArray() 176 | .map((p) => [ 177 | Number(p.get('x')), 178 | Number(p.get('y')), 179 | Number(p.get('pressure') ?? 0.5), 180 | ]); 181 | 182 | if (!points) return; 183 | 184 | const options = { 185 | size: Number(element.get('strokeWidth')), 186 | thinning: 0.5, 187 | smoothing: 0.5, 188 | streamline: 0.5, 189 | easing: (t: number) => t, 190 | start: { taper: 0, easing: (t: number) => t, cap: true }, 191 | end: { taper: 100, easing: (t: number) => t, cap: true }, 192 | }; 193 | 194 | const normalizedPoints = points.map(([x, y, pressure]) => ({ 195 | x: Number(x), 196 | y: Number(y), 197 | pressure: pressure ?? 1, 198 | })); 199 | 200 | const stroke = getStroke(normalizedPoints, options); 201 | const path = getSvgPathFromStroke(stroke); 202 | 203 | const path2D = new Path2D(path); 204 | 205 | ctx.fillStyle = String(element.get('strokeColor')); 206 | ctx.fill(path2D); 207 | 208 | ctx.translate(-x, -y); 209 | ctx.restore(); 210 | return; 211 | } 212 | 213 | const drawable = getOrCreateDrawable(generator, element); 214 | if (drawable) { 215 | rc.draw(drawable); 216 | } else { 217 | const seed = parseInt(String(element.get('seed')), 10) || 0; 218 | const fallbackDrawable = withSeededMath(seed, () => 219 | generator.rectangle( 220 | Number(element.get('x')), 221 | Number(element.get('y')), 222 | Number(element.get('width')), 223 | Number(element.get('height')), 224 | { 225 | seed, 226 | stroke: String(element.get('strokeColor')), 227 | strokeWidth: Number(element.get('strokeWidth')), 228 | roughness: Number(element.get('roughness')), 229 | fill: String(element.get('fillColor')), 230 | fillStyle: String(element.get('fillStyle')), 231 | hachureGap: Number(element.get('fillWeight')), 232 | strokeLineDash: getStrokeLineDash( 233 | String(element.get('fillStyle')) as BoundaryStyle, 234 | Number(element.get('strokeWidth')) 235 | ), 236 | } 237 | ) 238 | ); 239 | if (fallbackDrawable) rc.draw(fallbackDrawable); 240 | } 241 | 242 | ctx.restore(); 243 | }; 244 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # OnlyDraw 3 | 4 | OnlyDraw is a collaborative, real-time drawing application built with Next.js and TypeScript. It combines smooth freehand strokes and sketchy rendering with CRDT-based real-time synchronization so multiple users can draw together in the same room. This README documents how to run, configure, extend, and contribute to the project. 5 | 6 | [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](#license) 7 | [![Next.js](https://img.shields.io/badge/Next.js-15.x-black)](https://nextjs.org) 8 | [![TypeScript](https://img.shields.io/badge/TypeScript-5.x-blue)](https://www.typescriptlang.org) 9 | 10 | 11 | 12 | Key Features 13 | ------------ 14 | - Real-time collaboration using Yjs + y-websocket (CRDT syncing). 15 | - Smooth freehand strokes via `perfect-freehand`. 16 | - Hand-drawn / sketchy rendering with `roughjs`. 17 | - Lightweight client state management using `zustand`. 18 | - Modern Next.js + TypeScript app structure (app directory, components, hooks, lib). 19 | 20 | 21 | Tech Stack 22 | ---------- 23 | - Next.js 15 24 | - React 19 25 | - TypeScript 26 | - Yjs, y-websocket 27 | - perfect-freehand 28 | - roughjs 29 | - zustand 30 | - framer-motion 31 | - lucide-react 32 | - nanoid, unique-username-generator 33 | - ws (Node WebSocket polyfill for server/client usage) 34 | - TailwindCSS / PostCSS for styling (devDependencies present) 35 | 36 | Repository Layout 37 | ----------------- 38 | - src/ 39 | - app/ — Next.js app entry & pages 40 | - component/ — UI components and drawing tools 41 | - hooks/ — custom React hooks 42 | - lib/ — utilities and shared helpers 43 | - Store/ — zustand stores for UI & drawing state 44 | - types/ — TypeScript types and interfaces 45 | - server/ — backend WebSocket server (y-websocket or custom wrapper) 46 | - public/ — static assets, icons, images 47 | - server.js — example client snippet or server entry (contains WebsocketProvider usage) 48 | - package.json — client app scripts & dependencies 49 | - server/package.json — server-specific dependencies 50 | - tsconfig.json — TypeScript configuration 51 | 52 | Prerequisites 53 | ------------- 54 | - Node.js 18+ (recommended) 55 | - npm (or yarn or pnpm) 56 | - Git 57 | 58 | Quick Start (Development) 59 | ------------------------- 60 | 1. Clone the repository: 61 | ```bash 62 | git clone https://github.com/marsyg/OnlyDraw.git 63 | cd OnlyDraw 64 | ``` 65 | 66 | 2. Install client dependencies: 67 | ```bash 68 | npm install 69 | # or 70 | # yarn 71 | # pnpm install 72 | ``` 73 | 74 | 3. Start a y-websocket server for collaboration 75 | 76 | Option A — Use the included server (if present and implemented) 77 | ```bash 78 | cd server 79 | npm install 80 | # If the server has an entry (e.g. index.js), run: 81 | node index.js 82 | # or if you add a script: 83 | # npm run start 84 | ``` 85 | 86 | Option B — Use the y-websocket server globally or via npx (recommended for quick local testing) 87 | ```bash 88 | # install globally 89 | npm install -g y-websocket-server 90 | 91 | # or run without installing 92 | npx y-websocket-server --port 1234 93 | 94 | # default address used by the client is ws://localhost:1234 95 | ``` 96 | 97 | 4. Start the Next.js development server (from repository root) 98 | ```bash 99 | npm run dev 100 | # Visit http://localhost:3000 101 | ``` 102 | 103 | Available NPM scripts 104 | --------------------- 105 | From root package.json: 106 | - npm run dev — run Next.js in development 107 | - npm run build — build the app for production 108 | - npm run start — run the production build 109 | - npm run lint — run ESLint checks 110 | 111 | Server package (server/package.json) may need its own start script to run the y-websocket server. 112 | 113 | Environment Configuration 114 | ------------------------- 115 | The application expects a WebSocket endpoint to connect to for real-time syncing. Use environment variables to configure the address/protocol. 116 | 117 | Create a `.env.local` in the project root (not checked in): 118 | 119 | ``` 120 | # Client-side (exposed to browser, prefix with NEXT_PUBLIC_) 121 | NEXT_PUBLIC_WS_URL=ws://localhost:1234 122 | NEXT_PUBLIC_DEFAULT_ROOM=onlydraw-room 123 | ``` 124 | 125 | A sample `.env.example`: 126 | ``` 127 | NEXT_PUBLIC_WS_URL=ws://localhost:1234 128 | NEXT_PUBLIC_DEFAULT_ROOM=onlydraw-room 129 | ``` 130 | 131 | How it works (overview) 132 | ----------------------- 133 | - The client uses Yjs documents (Y.Doc) to maintain a shared CRDT state (e.g. strokes, cursors). 134 | - y-websocket is the network provider that synchronizes Y.Doc updates between clients via a WebSocket server. 135 | - The server can be the standalone `y-websocket` server or a small wrapper around it. The project includes `server/` with dependencies for such a server. 136 | - Strokes are generated using `perfect-freehand` from pointer input to produce smooth paths, then optionally rendered with `roughjs` for a sketchy appearance. 137 | - UI and tool-state are managed with `zustand` for concise and efficient local state. 138 | 139 | Sample WebSocket provider usage 140 | ------------------------------- 141 | Below is a typical snippet (client-side) to connect to the y-websocket server. Your codebase may already include similar code (server.js snippet references this): 142 | 143 | ```ts 144 | import * as Y from 'yjs' 145 | import { WebsocketProvider } from 'y-websocket' 146 | 147 | const doc = new Y.Doc() 148 | const provider = new WebsocketProvider( 149 | process.env.NEXT_PUBLIC_WS_URL || 'ws://localhost:1234', 150 | process.env.NEXT_PUBLIC_DEFAULT_ROOM || 'onlydraw-room', 151 | doc 152 | ) 153 | 154 | // Now use `doc` to create/observe shared maps/arrays for strokes, cursors, etc. 155 | ``` 156 | 157 | Development Tips & Suggestions 158 | ------------------------------ 159 | - Add a `start` script inside `server/package.json` to simplify launching the WebSocket server (e.g., `"start": "node index.js"`). 160 | - Add a `.env.example` at repository root to make configuration clear to new contributors. 161 | - Consider adding a `Makefile` or npm script that starts both the backend (y-websocket) and the frontend concurrently for convenience. 162 | - Add unit/integration tests where appropriate. For client-side drawing interactions, integration e2e tests with Playwright can verify collaborative sync across tabs. 163 | 164 | Deployment 165 | ---------- 166 | - Vercel: Next.js app deploys easily to Vercel. Configure `NEXT_PUBLIC_WS_URL` to point to your public y-websocket server (self-hosted or hosted elsewhere). 167 | - Self-hosting: Build and serve with: 168 | ```bash 169 | npm run build 170 | npm run start 171 | ``` 172 | Make sure the y-websocket server is reachable from the deployed client (CORS and network/firewall rules permitting WebSocket traffic). 173 | 174 | Troubleshooting 175 | --------------- 176 | - If clients don't synchronize: 177 | - Confirm the WebSocket server is running on the expected host and port. 178 | - Check browser console for WebSocket connection errors (CORS, network blocked). 179 | - Build/TypeScript errors: 180 | - Ensure Node and package versions match requirements (Next 15, TS 5). 181 | - Run `npm run lint` and fix reported issues. 182 | - Performance: 183 | - For large collaborative rooms, consider chunking state or using awareness/metadata to limit network messages (Yjs awareness docs). 184 | 185 | Security Notes 186 | -------------- 187 | - If you open your y-websocket server publicly, ensure you understand who can join rooms — add authentication or room authorization if necessary. 188 | - Sanitize any uploaded or shared content and validate messages if adding persistence or file uploads. 189 | 190 | Contributing 191 | ------------ 192 | Contributions are welcome! Suggested workflow: 193 | 1. Fork the repository. 194 | 2. Create a feature branch: `git checkout -b feat/awesome-feature` 195 | 3. Implement changes, add tests if applicable, and run lint/type checks. 196 | 4. Open a pull request describing your changes. 197 | 198 | Please include: 199 | - A clear description of the problem & the solution 200 | - Steps to reproduce and test your changes 201 | - Screenshots or GIFs for UI changes 202 | 203 | Recommended development checks: 204 | ```bash 205 | 206 | npm run build 207 | ``` 208 | 209 | Roadmap (ideas) 210 | --------------- 211 | - Authentication and private rooms 212 | - Persistent storage of drawings (e.g., periodically snapshot Y.Doc to a DB) 213 | - Export to SVG/PNG and import functionality 214 | - Mobile/touch improvements and pressure/tilt support 215 | - Undo/redo improvements for collaborative contexts 216 | - Better conflict resolution UX and shape tools (rect, circle, text) 217 | 218 | FAQ 219 | --- 220 | Q: What address should the client connect to? 221 | A: Default development value is ws://localhost:1234 — override with NEXT_PUBLIC_WS_URL. 222 | 223 | Q: Why Yjs? 224 | A: Yjs enables CRDT-based real-time collaboration that synchronizes state without a central authority, enabling offline edits and conflict-free merging. 225 | 226 | 227 | -------------------------------------------------------------------------------- /src/component/crazyToolbar.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef, useState } from 'react'; 2 | import { motion, AnimatePresence } from 'framer-motion'; 3 | import { ChevronUp, Settings2, } from 'lucide-react'; 4 | 5 | import { TOOLBAR_ITEM } from '@/types/toolbarData'; 6 | import { BoundaryStyle, FillStyle, useAppStore } from '@/Store/store'; 7 | import { actionType, elementType } from '@/types/type'; 8 | 9 | // --- Helper Functions --- 10 | const getRoughnessLabel = (value: number) => { 11 | if (value < 10) return "Smooth"; 12 | if (value < 35) return "Shaky"; 13 | if (value < 65) return "Wobbly"; 14 | if (value < 90) return "Rough"; 15 | return "Chaotic"; 16 | }; 17 | 18 | 19 | const RoughStyles = () => ( 20 | 92 | ); 93 | interface RoughSketchToolboxProps { 94 | onDelete?: () => void; 95 | } 96 | 97 | export default function RoughSketchToolbox({ onDelete }: RoughSketchToolboxProps) { 98 | 99 | const [isMobile, setIsMobile] = useState(false); 100 | const [isExpanded, setIsExpanded] = useState(true); 101 | const [target, setTarget] = useState(null); 102 | 103 | useEffect(() => { 104 | const checkMobile = () => { 105 | setIsMobile(window.innerWidth < 768); 106 | }; 107 | checkMobile(); 108 | window.addEventListener('resize', checkMobile); 109 | return () => window.removeEventListener('resize', checkMobile); 110 | }, []); 111 | 112 | // Store 113 | const { 114 | strokeColor, setActiveToolbarId, toolbar, setCurrentTool, setIsDragging, setIsSelecting, setStrokeColor, 115 | fillColor, setFillColor, isFillTransparent, setIsFillTransparent, strokeWidth, setStrokeWidth, 116 | setRoughness, fillStyle, setFillStyle, fillWeight, setFillWeight, roughness, 117 | boundaryStyle, setBoundaryStyle, hasShadow, setHasShadow, opacity, setOpacity, setBound, 118 | } = useAppStore(); 119 | 120 | const colorRef = useRef(null); 121 | 122 | // Constants 123 | const swatches = ["#e03131", "#fab005", "#40c057", "#1c7ed6", "#7048e8", "#1a1a1a"]; 124 | const fillStyles = [ 125 | { id: "solid", label: "Solid" }, 126 | { id: "hachure", label: "Hachure" }, 127 | { id: "dots", label: "Dots" }, 128 | { id: "zigzag", label: "ZigZag" }, 129 | ]; 130 | const boundaryStyles = [ 131 | { id: "solid", label: "Solid" }, 132 | { id: "dashed", label: "Dashed" }, 133 | { id: "dotted", label: "Dotted" }, 134 | ]; 135 | 136 | // Handlers 137 | const handleClick = (id: string, action: actionType, elementType: elementType, func: (() => void) | undefined) => { 138 | if (action === actionType.Delete) { 139 | if (func) func(); 140 | } 141 | setActiveToolbarId(id); 142 | setCurrentTool({ action, elementType }); 143 | setIsSelecting(action === actionType.Selecting); 144 | setIsDragging(action === actionType.Dragging); 145 | setBound(null) 146 | }; 147 | 148 | const handleChange: React.ChangeEventHandler = (e) => { 149 | const value = e.target.value; 150 | if (target === "stroke") setStrokeColor(value); 151 | if (target === "fill") setFillColor(value); 152 | }; 153 | 154 | const openPicker = (type: string | null) => { 155 | setTarget(type); 156 | colorRef.current?.click(); 157 | }; 158 | 159 | return ( 160 | <> 161 | 162 | 163 | 164 | 171 | 172 | 173 |

174 | 175 | 176 | {!isExpanded && ( 177 |
178 | setIsExpanded(true)} 180 | className="w-10 h-10 flex items-center justify-center rounded-full bg-black text-white hover:bg-gray-800 transition-colors" 181 | > 182 | 183 | 184 |
185 | )} 186 | 187 | 188 |
189 | {TOOLBAR_ITEM.map((item) => { 190 | 191 | if (!isExpanded && toolbar.activeToolId !== item.id) return null; 192 | 193 | return ( 194 | { 198 | handleClick(item.id, item.actionType, item.elementType, onDelete) 199 | if (!isExpanded) setIsExpanded(true); 200 | }} 201 | whileTap={{ scale: 0.95 }} 202 | title={item.id} 203 | > 204 | {React.createElement(item.icon)} 205 | 206 | ) 207 | })} 208 | 209 | {!isExpanded && ( 210 | 216 | )} 217 |
218 | 219 | {isExpanded && ( 220 | setIsExpanded(false)} 223 | whileHover={{ scale: 1.02 }} 224 | > 225 | 226 | Hide Properties 227 | 228 | )} 229 |
230 | 231 | 232 | {isExpanded && ( 233 | 239 |
240 | 241 | 242 |
243 |
244 | 245 |
246 | 247 |
257 |
258 |
259 | 260 |
261 | 262 |
263 | 270 | 276 |
277 |
278 |
279 | 280 |
281 |
282 |
283 | 284 | {strokeWidth}px 285 |
286 | setStrokeWidth(Number(e.target.value))} 289 | className="modern-slider" 290 | /> 291 |
292 | 293 |
294 |
295 | 296 | {getRoughnessLabel(roughness)} 297 |
298 | setRoughness(Number(e.target.value))} 301 | className="modern-slider" 302 | /> 303 |
304 |
305 | 306 | 307 |
308 |
309 | 310 |
311 | {fillStyles.map((s) => ( 312 | 319 | ))} 320 |
321 |
322 | 323 | {fillStyle !== 'solid' && ( 324 |
325 |
326 | 327 | {fillWeight} 328 |
329 | setFillWeight(Number(e.target.value))} 332 | className="modern-slider" 333 | /> 334 |
335 | )} 336 | 337 |
338 | 339 |
340 | {boundaryStyles.map((s) => ( 341 | 351 | ))} 352 |
353 |
354 |
355 | 356 | 357 |
358 |
359 | 363 |
364 | 365 | setOpacity(Number(e.target.value) / 100)} 369 | className="w-12 text-right bg-gray-800 text-gray-100 border-none rounded text-xs p-1" 370 | /> 371 | % 372 |
373 |
374 |
375 | 376 | 377 |
378 | )} 379 |
380 | 381 | 382 | ); 383 | } -------------------------------------------------------------------------------- /server/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "server", 3 | "version": "1.0.0", 4 | "lockfileVersion": 3, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "server", 9 | "version": "1.0.0", 10 | "license": "ISC", 11 | "dependencies": { 12 | "@y/websocket-server": "^0.1.1", 13 | "y-websocket": "^3.0.0" 14 | } 15 | }, 16 | "node_modules/@y/websocket-server": { 17 | "version": "0.1.1", 18 | "resolved": "https://registry.npmjs.org/@y/websocket-server/-/websocket-server-0.1.1.tgz", 19 | "integrity": "sha512-pPtXm5Ceqs4orhXXHwm2I+u1mKNBDNzlrwNiI7OMwM7PlVS4WCMpiIuSB8WsYeSuISbvpXPNvaj6H1MoQBbE+g==", 20 | "license": "MIT", 21 | "dependencies": { 22 | "lib0": "^0.2.102", 23 | "y-protocols": "^1.0.5" 24 | }, 25 | "bin": { 26 | "y-websocket": "src/server.js", 27 | "y-websocket-server": "src/server.js" 28 | }, 29 | "engines": { 30 | "node": ">=16.0.0", 31 | "npm": ">=8.0.0" 32 | }, 33 | "funding": { 34 | "type": "GitHub Sponsors ❤", 35 | "url": "https://github.com/sponsors/dmonad" 36 | }, 37 | "optionalDependencies": { 38 | "ws": "^6.2.1", 39 | "y-leveldb": "^0.1.0" 40 | }, 41 | "peerDependencies": { 42 | "yjs": "^13.5.6" 43 | } 44 | }, 45 | "node_modules/abstract-leveldown": { 46 | "version": "6.2.3", 47 | "resolved": "https://registry.npmjs.org/abstract-leveldown/-/abstract-leveldown-6.2.3.tgz", 48 | "integrity": "sha512-BsLm5vFMRUrrLeCcRc+G0t2qOaTzpoJQLOubq2XM72eNpjF5UdU5o/5NvlNhx95XHcAvcl8OMXr4mlg/fRgUXQ==", 49 | "deprecated": "Superseded by abstract-level (https://github.com/Level/community#faq)", 50 | "license": "MIT", 51 | "optional": true, 52 | "dependencies": { 53 | "buffer": "^5.5.0", 54 | "immediate": "^3.2.3", 55 | "level-concat-iterator": "~2.0.0", 56 | "level-supports": "~1.0.0", 57 | "xtend": "~4.0.0" 58 | }, 59 | "engines": { 60 | "node": ">=6" 61 | } 62 | }, 63 | "node_modules/async-limiter": { 64 | "version": "1.0.1", 65 | "resolved": "https://registry.npmjs.org/async-limiter/-/async-limiter-1.0.1.tgz", 66 | "integrity": "sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==", 67 | "license": "MIT", 68 | "optional": true 69 | }, 70 | "node_modules/base64-js": { 71 | "version": "1.5.1", 72 | "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", 73 | "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", 74 | "funding": [ 75 | { 76 | "type": "github", 77 | "url": "https://github.com/sponsors/feross" 78 | }, 79 | { 80 | "type": "patreon", 81 | "url": "https://www.patreon.com/feross" 82 | }, 83 | { 84 | "type": "consulting", 85 | "url": "https://feross.org/support" 86 | } 87 | ], 88 | "license": "MIT", 89 | "optional": true 90 | }, 91 | "node_modules/buffer": { 92 | "version": "5.7.1", 93 | "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", 94 | "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", 95 | "funding": [ 96 | { 97 | "type": "github", 98 | "url": "https://github.com/sponsors/feross" 99 | }, 100 | { 101 | "type": "patreon", 102 | "url": "https://www.patreon.com/feross" 103 | }, 104 | { 105 | "type": "consulting", 106 | "url": "https://feross.org/support" 107 | } 108 | ], 109 | "license": "MIT", 110 | "optional": true, 111 | "dependencies": { 112 | "base64-js": "^1.3.1", 113 | "ieee754": "^1.1.13" 114 | } 115 | }, 116 | "node_modules/deferred-leveldown": { 117 | "version": "5.3.0", 118 | "resolved": "https://registry.npmjs.org/deferred-leveldown/-/deferred-leveldown-5.3.0.tgz", 119 | "integrity": "sha512-a59VOT+oDy7vtAbLRCZwWgxu2BaCfd5Hk7wxJd48ei7I+nsg8Orlb9CLG0PMZienk9BSUKgeAqkO2+Lw+1+Ukw==", 120 | "deprecated": "Superseded by abstract-level (https://github.com/Level/community#faq)", 121 | "license": "MIT", 122 | "optional": true, 123 | "dependencies": { 124 | "abstract-leveldown": "~6.2.1", 125 | "inherits": "^2.0.3" 126 | }, 127 | "engines": { 128 | "node": ">=6" 129 | } 130 | }, 131 | "node_modules/encoding-down": { 132 | "version": "6.3.0", 133 | "resolved": "https://registry.npmjs.org/encoding-down/-/encoding-down-6.3.0.tgz", 134 | "integrity": "sha512-QKrV0iKR6MZVJV08QY0wp1e7vF6QbhnbQhb07bwpEyuz4uZiZgPlEGdkCROuFkUwdxlFaiPIhjyarH1ee/3vhw==", 135 | "deprecated": "Superseded by abstract-level (https://github.com/Level/community#faq)", 136 | "license": "MIT", 137 | "optional": true, 138 | "dependencies": { 139 | "abstract-leveldown": "^6.2.1", 140 | "inherits": "^2.0.3", 141 | "level-codec": "^9.0.0", 142 | "level-errors": "^2.0.0" 143 | }, 144 | "engines": { 145 | "node": ">=6" 146 | } 147 | }, 148 | "node_modules/errno": { 149 | "version": "0.1.8", 150 | "resolved": "https://registry.npmjs.org/errno/-/errno-0.1.8.tgz", 151 | "integrity": "sha512-dJ6oBr5SQ1VSd9qkk7ByRgb/1SH4JZjCHSW/mr63/QcXO9zLVxvJ6Oy13nio03rxpSnVDDjFor75SjVeZWPW/A==", 152 | "license": "MIT", 153 | "optional": true, 154 | "dependencies": { 155 | "prr": "~1.0.1" 156 | }, 157 | "bin": { 158 | "errno": "cli.js" 159 | } 160 | }, 161 | "node_modules/ieee754": { 162 | "version": "1.2.1", 163 | "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", 164 | "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", 165 | "funding": [ 166 | { 167 | "type": "github", 168 | "url": "https://github.com/sponsors/feross" 169 | }, 170 | { 171 | "type": "patreon", 172 | "url": "https://www.patreon.com/feross" 173 | }, 174 | { 175 | "type": "consulting", 176 | "url": "https://feross.org/support" 177 | } 178 | ], 179 | "license": "BSD-3-Clause", 180 | "optional": true 181 | }, 182 | "node_modules/immediate": { 183 | "version": "3.3.0", 184 | "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.3.0.tgz", 185 | "integrity": "sha512-HR7EVodfFUdQCTIeySw+WDRFJlPcLOJbXfwwZ7Oom6tjsvZ3bOkCDJHehQC3nxJrv7+f9XecwazynjU8e4Vw3Q==", 186 | "license": "MIT", 187 | "optional": true 188 | }, 189 | "node_modules/inherits": { 190 | "version": "2.0.4", 191 | "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", 192 | "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", 193 | "license": "ISC", 194 | "optional": true 195 | }, 196 | "node_modules/isomorphic.js": { 197 | "version": "0.2.5", 198 | "resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.5.tgz", 199 | "integrity": "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==", 200 | "license": "MIT", 201 | "funding": { 202 | "type": "GitHub Sponsors ❤", 203 | "url": "https://github.com/sponsors/dmonad" 204 | } 205 | }, 206 | "node_modules/level": { 207 | "version": "6.0.1", 208 | "resolved": "https://registry.npmjs.org/level/-/level-6.0.1.tgz", 209 | "integrity": "sha512-psRSqJZCsC/irNhfHzrVZbmPYXDcEYhA5TVNwr+V92jF44rbf86hqGp8fiT702FyiArScYIlPSBTDUASCVNSpw==", 210 | "license": "MIT", 211 | "optional": true, 212 | "dependencies": { 213 | "level-js": "^5.0.0", 214 | "level-packager": "^5.1.0", 215 | "leveldown": "^5.4.0" 216 | }, 217 | "engines": { 218 | "node": ">=8.6.0" 219 | }, 220 | "funding": { 221 | "type": "opencollective", 222 | "url": "https://opencollective.com/level" 223 | } 224 | }, 225 | "node_modules/level-codec": { 226 | "version": "9.0.2", 227 | "resolved": "https://registry.npmjs.org/level-codec/-/level-codec-9.0.2.tgz", 228 | "integrity": "sha512-UyIwNb1lJBChJnGfjmO0OR+ezh2iVu1Kas3nvBS/BzGnx79dv6g7unpKIDNPMhfdTEGoc7mC8uAu51XEtX+FHQ==", 229 | "deprecated": "Superseded by level-transcoder (https://github.com/Level/community#faq)", 230 | "license": "MIT", 231 | "optional": true, 232 | "dependencies": { 233 | "buffer": "^5.6.0" 234 | }, 235 | "engines": { 236 | "node": ">=6" 237 | } 238 | }, 239 | "node_modules/level-concat-iterator": { 240 | "version": "2.0.1", 241 | "resolved": "https://registry.npmjs.org/level-concat-iterator/-/level-concat-iterator-2.0.1.tgz", 242 | "integrity": "sha512-OTKKOqeav2QWcERMJR7IS9CUo1sHnke2C0gkSmcR7QuEtFNLLzHQAvnMw8ykvEcv0Qtkg0p7FOwP1v9e5Smdcw==", 243 | "deprecated": "Superseded by abstract-level (https://github.com/Level/community#faq)", 244 | "license": "MIT", 245 | "optional": true, 246 | "engines": { 247 | "node": ">=6" 248 | } 249 | }, 250 | "node_modules/level-errors": { 251 | "version": "2.0.1", 252 | "resolved": "https://registry.npmjs.org/level-errors/-/level-errors-2.0.1.tgz", 253 | "integrity": "sha512-UVprBJXite4gPS+3VznfgDSU8PTRuVX0NXwoWW50KLxd2yw4Y1t2JUR5In1itQnudZqRMT9DlAM3Q//9NCjCFw==", 254 | "deprecated": "Superseded by abstract-level (https://github.com/Level/community#faq)", 255 | "license": "MIT", 256 | "optional": true, 257 | "dependencies": { 258 | "errno": "~0.1.1" 259 | }, 260 | "engines": { 261 | "node": ">=6" 262 | } 263 | }, 264 | "node_modules/level-iterator-stream": { 265 | "version": "4.0.2", 266 | "resolved": "https://registry.npmjs.org/level-iterator-stream/-/level-iterator-stream-4.0.2.tgz", 267 | "integrity": "sha512-ZSthfEqzGSOMWoUGhTXdX9jv26d32XJuHz/5YnuHZzH6wldfWMOVwI9TBtKcya4BKTyTt3XVA0A3cF3q5CY30Q==", 268 | "license": "MIT", 269 | "optional": true, 270 | "dependencies": { 271 | "inherits": "^2.0.4", 272 | "readable-stream": "^3.4.0", 273 | "xtend": "^4.0.2" 274 | }, 275 | "engines": { 276 | "node": ">=6" 277 | } 278 | }, 279 | "node_modules/level-js": { 280 | "version": "5.0.2", 281 | "resolved": "https://registry.npmjs.org/level-js/-/level-js-5.0.2.tgz", 282 | "integrity": "sha512-SnBIDo2pdO5VXh02ZmtAyPP6/+6YTJg2ibLtl9C34pWvmtMEmRTWpra+qO/hifkUtBTOtfx6S9vLDjBsBK4gRg==", 283 | "deprecated": "Superseded by browser-level (https://github.com/Level/community#faq)", 284 | "license": "MIT", 285 | "optional": true, 286 | "dependencies": { 287 | "abstract-leveldown": "~6.2.3", 288 | "buffer": "^5.5.0", 289 | "inherits": "^2.0.3", 290 | "ltgt": "^2.1.2" 291 | } 292 | }, 293 | "node_modules/level-packager": { 294 | "version": "5.1.1", 295 | "resolved": "https://registry.npmjs.org/level-packager/-/level-packager-5.1.1.tgz", 296 | "integrity": "sha512-HMwMaQPlTC1IlcwT3+swhqf/NUO+ZhXVz6TY1zZIIZlIR0YSn8GtAAWmIvKjNY16ZkEg/JcpAuQskxsXqC0yOQ==", 297 | "deprecated": "Superseded by abstract-level (https://github.com/Level/community#faq)", 298 | "license": "MIT", 299 | "optional": true, 300 | "dependencies": { 301 | "encoding-down": "^6.3.0", 302 | "levelup": "^4.3.2" 303 | }, 304 | "engines": { 305 | "node": ">=6" 306 | } 307 | }, 308 | "node_modules/level-supports": { 309 | "version": "1.0.1", 310 | "resolved": "https://registry.npmjs.org/level-supports/-/level-supports-1.0.1.tgz", 311 | "integrity": "sha512-rXM7GYnW8gsl1vedTJIbzOrRv85c/2uCMpiiCzO2fndd06U/kUXEEU9evYn4zFggBOg36IsBW8LzqIpETwwQzg==", 312 | "license": "MIT", 313 | "optional": true, 314 | "dependencies": { 315 | "xtend": "^4.0.2" 316 | }, 317 | "engines": { 318 | "node": ">=6" 319 | } 320 | }, 321 | "node_modules/leveldown": { 322 | "version": "5.6.0", 323 | "resolved": "https://registry.npmjs.org/leveldown/-/leveldown-5.6.0.tgz", 324 | "integrity": "sha512-iB8O/7Db9lPaITU1aA2txU/cBEXAt4vWwKQRrrWuS6XDgbP4QZGj9BL2aNbwb002atoQ/lIotJkfyzz+ygQnUQ==", 325 | "deprecated": "Superseded by classic-level (https://github.com/Level/community#faq)", 326 | "hasInstallScript": true, 327 | "license": "MIT", 328 | "optional": true, 329 | "dependencies": { 330 | "abstract-leveldown": "~6.2.1", 331 | "napi-macros": "~2.0.0", 332 | "node-gyp-build": "~4.1.0" 333 | }, 334 | "engines": { 335 | "node": ">=8.6.0" 336 | } 337 | }, 338 | "node_modules/levelup": { 339 | "version": "4.4.0", 340 | "resolved": "https://registry.npmjs.org/levelup/-/levelup-4.4.0.tgz", 341 | "integrity": "sha512-94++VFO3qN95cM/d6eBXvd894oJE0w3cInq9USsyQzzoJxmiYzPAocNcuGCPGGjoXqDVJcr3C1jzt1TSjyaiLQ==", 342 | "deprecated": "Superseded by abstract-level (https://github.com/Level/community#faq)", 343 | "license": "MIT", 344 | "optional": true, 345 | "dependencies": { 346 | "deferred-leveldown": "~5.3.0", 347 | "level-errors": "~2.0.0", 348 | "level-iterator-stream": "~4.0.0", 349 | "level-supports": "~1.0.0", 350 | "xtend": "~4.0.0" 351 | }, 352 | "engines": { 353 | "node": ">=6" 354 | } 355 | }, 356 | "node_modules/lib0": { 357 | "version": "0.2.114", 358 | "resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.114.tgz", 359 | "integrity": "sha512-gcxmNFzA4hv8UYi8j43uPlQ7CGcyMJ2KQb5kZASw6SnAKAf10hK12i2fjrS3Cl/ugZa5Ui6WwIu1/6MIXiHttQ==", 360 | "license": "MIT", 361 | "dependencies": { 362 | "isomorphic.js": "^0.2.4" 363 | }, 364 | "bin": { 365 | "0ecdsa-generate-keypair": "bin/0ecdsa-generate-keypair.js", 366 | "0gentesthtml": "bin/gentesthtml.js", 367 | "0serve": "bin/0serve.js" 368 | }, 369 | "engines": { 370 | "node": ">=16" 371 | }, 372 | "funding": { 373 | "type": "GitHub Sponsors ❤", 374 | "url": "https://github.com/sponsors/dmonad" 375 | } 376 | }, 377 | "node_modules/ltgt": { 378 | "version": "2.2.1", 379 | "resolved": "https://registry.npmjs.org/ltgt/-/ltgt-2.2.1.tgz", 380 | "integrity": "sha512-AI2r85+4MquTw9ZYqabu4nMwy9Oftlfa/e/52t9IjtfG+mGBbTNdAoZ3RQKLHR6r0wQnwZnPIEh/Ya6XTWAKNA==", 381 | "license": "MIT", 382 | "optional": true 383 | }, 384 | "node_modules/napi-macros": { 385 | "version": "2.0.0", 386 | "resolved": "https://registry.npmjs.org/napi-macros/-/napi-macros-2.0.0.tgz", 387 | "integrity": "sha512-A0xLykHtARfueITVDernsAWdtIMbOJgKgcluwENp3AlsKN/PloyO10HtmoqnFAQAcxPkgZN7wdfPfEd0zNGxbg==", 388 | "license": "MIT", 389 | "optional": true 390 | }, 391 | "node_modules/node-gyp-build": { 392 | "version": "4.1.1", 393 | "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.1.1.tgz", 394 | "integrity": "sha512-dSq1xmcPDKPZ2EED2S6zw/b9NKsqzXRE6dVr8TVQnI3FJOTteUMuqF3Qqs6LZg+mLGYJWqQzMbIjMtJqTv87nQ==", 395 | "license": "MIT", 396 | "optional": true, 397 | "bin": { 398 | "node-gyp-build": "bin.js", 399 | "node-gyp-build-optional": "optional.js", 400 | "node-gyp-build-test": "build-test.js" 401 | } 402 | }, 403 | "node_modules/prr": { 404 | "version": "1.0.1", 405 | "resolved": "https://registry.npmjs.org/prr/-/prr-1.0.1.tgz", 406 | "integrity": "sha512-yPw4Sng1gWghHQWj0B3ZggWUm4qVbPwPFcRG8KyxiU7J2OHFSoEHKS+EZ3fv5l1t9CyCiop6l/ZYeWbrgoQejw==", 407 | "license": "MIT", 408 | "optional": true 409 | }, 410 | "node_modules/readable-stream": { 411 | "version": "3.6.2", 412 | "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", 413 | "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", 414 | "license": "MIT", 415 | "optional": true, 416 | "dependencies": { 417 | "inherits": "^2.0.3", 418 | "string_decoder": "^1.1.1", 419 | "util-deprecate": "^1.0.1" 420 | }, 421 | "engines": { 422 | "node": ">= 6" 423 | } 424 | }, 425 | "node_modules/safe-buffer": { 426 | "version": "5.2.1", 427 | "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", 428 | "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", 429 | "funding": [ 430 | { 431 | "type": "github", 432 | "url": "https://github.com/sponsors/feross" 433 | }, 434 | { 435 | "type": "patreon", 436 | "url": "https://www.patreon.com/feross" 437 | }, 438 | { 439 | "type": "consulting", 440 | "url": "https://feross.org/support" 441 | } 442 | ], 443 | "license": "MIT", 444 | "optional": true 445 | }, 446 | "node_modules/string_decoder": { 447 | "version": "1.3.0", 448 | "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", 449 | "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", 450 | "license": "MIT", 451 | "optional": true, 452 | "dependencies": { 453 | "safe-buffer": "~5.2.0" 454 | } 455 | }, 456 | "node_modules/util-deprecate": { 457 | "version": "1.0.2", 458 | "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", 459 | "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", 460 | "license": "MIT", 461 | "optional": true 462 | }, 463 | "node_modules/ws": { 464 | "version": "6.2.3", 465 | "resolved": "https://registry.npmjs.org/ws/-/ws-6.2.3.tgz", 466 | "integrity": "sha512-jmTjYU0j60B+vHey6TfR3Z7RD61z/hmxBS3VMSGIrroOWXQEneK1zNuotOUrGyBHQj0yrpsLHPWtigEFd13ndA==", 467 | "license": "MIT", 468 | "optional": true, 469 | "dependencies": { 470 | "async-limiter": "~1.0.0" 471 | } 472 | }, 473 | "node_modules/xtend": { 474 | "version": "4.0.2", 475 | "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", 476 | "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", 477 | "license": "MIT", 478 | "optional": true, 479 | "engines": { 480 | "node": ">=0.4" 481 | } 482 | }, 483 | "node_modules/y-leveldb": { 484 | "version": "0.1.2", 485 | "resolved": "https://registry.npmjs.org/y-leveldb/-/y-leveldb-0.1.2.tgz", 486 | "integrity": "sha512-6ulEn5AXfXJYi89rXPEg2mMHAyyw8+ZfeMMdOtBbV8FJpQ1NOrcgi6DTAcXof0dap84NjHPT2+9d0rb6cFsjEg==", 487 | "license": "MIT", 488 | "optional": true, 489 | "dependencies": { 490 | "level": "^6.0.1", 491 | "lib0": "^0.2.31" 492 | }, 493 | "funding": { 494 | "type": "GitHub Sponsors ❤", 495 | "url": "https://github.com/sponsors/dmonad" 496 | }, 497 | "peerDependencies": { 498 | "yjs": "^13.0.0" 499 | } 500 | }, 501 | "node_modules/y-protocols": { 502 | "version": "1.0.6", 503 | "resolved": "https://registry.npmjs.org/y-protocols/-/y-protocols-1.0.6.tgz", 504 | "integrity": "sha512-vHRF2L6iT3rwj1jub/K5tYcTT/mEYDUppgNPXwp8fmLpui9f7Yeq3OEtTLVF012j39QnV+KEQpNqoN7CWU7Y9Q==", 505 | "license": "MIT", 506 | "dependencies": { 507 | "lib0": "^0.2.85" 508 | }, 509 | "engines": { 510 | "node": ">=16.0.0", 511 | "npm": ">=8.0.0" 512 | }, 513 | "funding": { 514 | "type": "GitHub Sponsors ❤", 515 | "url": "https://github.com/sponsors/dmonad" 516 | }, 517 | "peerDependencies": { 518 | "yjs": "^13.0.0" 519 | } 520 | }, 521 | "node_modules/y-websocket": { 522 | "version": "3.0.0", 523 | "resolved": "https://registry.npmjs.org/y-websocket/-/y-websocket-3.0.0.tgz", 524 | "integrity": "sha512-mUHy7AzkOZ834T/7piqtlA8Yk6AchqKqcrCXjKW8J1w2lPtRDjz8W5/CvXz9higKAHgKRKqpI3T33YkRFLkPtg==", 525 | "license": "MIT", 526 | "dependencies": { 527 | "lib0": "^0.2.102", 528 | "y-protocols": "^1.0.5" 529 | }, 530 | "engines": { 531 | "node": ">=16.0.0", 532 | "npm": ">=8.0.0" 533 | }, 534 | "funding": { 535 | "type": "GitHub Sponsors ❤", 536 | "url": "https://github.com/sponsors/dmonad" 537 | }, 538 | "peerDependencies": { 539 | "yjs": "^13.5.6" 540 | } 541 | }, 542 | "node_modules/yjs": { 543 | "version": "13.6.27", 544 | "resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.27.tgz", 545 | "integrity": "sha512-OIDwaflOaq4wC6YlPBy2L6ceKeKuF7DeTxx+jPzv1FHn9tCZ0ZwSRnUBxD05E3yed46fv/FWJbvR+Ud7x0L7zw==", 546 | "license": "MIT", 547 | "peer": true, 548 | "dependencies": { 549 | "lib0": "^0.2.99" 550 | }, 551 | "engines": { 552 | "node": ">=16.0.0", 553 | "npm": ">=8.0.0" 554 | }, 555 | "funding": { 556 | "type": "GitHub Sponsors ❤", 557 | "url": "https://github.com/sponsors/dmonad" 558 | } 559 | } 560 | } 561 | } 562 | -------------------------------------------------------------------------------- /src/app/canvas/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import rough from 'roughjs'; 4 | 5 | import { useCallback, useEffect, useRef, useState } from 'react'; 6 | import { useAppStore } from '@/Store/store'; 7 | import { RoughGenerator } from 'roughjs/bin/generator'; 8 | import { RoughCanvas } from 'roughjs/bin/canvas'; 9 | import { generateUsername } from "unique-username-generator"; 10 | import { OnlyDrawElement, point, PointsFreeHand } from '@/types/type'; 11 | import { actionType, elementType } from '@/types/type'; 12 | import { handleDrawElement } from '@/lib/handleElement'; 13 | import { DrawElements } from '@/lib/utils/drawingUtility/drawElement'; 14 | import { isPointInsideElement } from '@/lib/utils/drawingUtility/hitTest'; 15 | import { DrawBounds } from '@/lib/drawBounds'; 16 | import { getBounds } from '@/lib/utils/boundsUtility/getBounds'; 17 | import { isPointInPaddedBounds } from '@/lib/utils/boundsUtility/isPointInPaddedBounds'; 18 | import { UndoManager } from '@/Store/yjs-store'; 19 | import canvasDoc from '@/Store/yjs-store'; 20 | import * as Y from 'yjs'; 21 | import { Point } from 'roughjs/bin/geometry'; 22 | import yUtils from '@/lib/utils/createYElement'; 23 | import { handleUndo, handleRedo } from '@/lib/helperfunc/undo-redo'; 24 | import { LOCAL_ORIGIN, LIVE_ORIGIN } from '@/Store/yjs-store'; 25 | import detectResizeHandle from '@/lib/hitTest/detectResizeHandler'; 26 | import resizeBound from '@/lib/resizeBound'; 27 | import { resizeElement } from '@/lib/resizeElement'; 28 | import { WebsocketProvider } from 'y-websocket'; 29 | import RoughSketchToolbox from '@/component/crazyToolbar'; 30 | import { motion } from 'framer-motion'; 31 | import getRandomColor from '@/lib/helperfunc/getRandomColor'; 32 | 33 | 34 | 35 | export default function App() { 36 | const { doc, yElement, order } = canvasDoc; 37 | 38 | 39 | const { 40 | setPointerPosition, 41 | currentTool, 42 | 43 | setSelectedElementId, 44 | 45 | setIsDrawing, 46 | setIsDragging, 47 | isDragging, 48 | isDrawing, 49 | pointerPosition, 50 | 51 | isResizing, 52 | setIsResizing, 53 | resizeHandle, 54 | setResizeHandle, 55 | selectedYElement, 56 | setYElement, 57 | bound, 58 | setBound, 59 | roughness, 60 | fillColor, 61 | strokeColor, 62 | strokeWidth, 63 | fillStyle, 64 | fillWeight, 65 | boundaryStyle, 66 | 67 | } = useAppStore(); 68 | const [isTouchDevice, setIsTouchDevice] = useState(false); 69 | const [freehandPoint, setFreehandPoint] = useState([ 70 | [pointerPosition[0], pointerPosition[1], 1] as PointsFreeHand, 71 | ]); 72 | const [CursorStyle, setCursorStyle] = useState("default") 73 | const [lockedBounds, setLockedBounds] = useState(false) 74 | const roughGeneratorRef = useRef(null); 75 | const [userName, setUserName] = useState(''); 76 | const roughCanvasRef = useRef(null); 77 | 78 | 79 | const [GlobalPointerPosition, setGlobalPointerPosition] = useState(null) 80 | const canvasRef = useRef(null); 81 | 82 | const resizeStartPointerRef = useRef(null); 83 | const resizeOriginalRectRef = useRef<{ x: number; y: number; width: number; height: number } | null>(null); 84 | 85 | const flagRef = useRef(false) 86 | const animationFrameIdRef = useRef(null); 87 | 88 | const resizeHandleRef = useRef<{ direction: string; cursor: string } | null>(null); 89 | const originalPointRef = useRef(null) 90 | 91 | const renderCanvas = useCallback(() => { 92 | const canvas = canvasRef.current; 93 | if (!canvas) return; 94 | const ctx = canvas.getContext('2d'); 95 | if (!ctx) return; 96 | 97 | if (!roughCanvasRef.current) { 98 | roughCanvasRef.current = rough.canvas(canvas); 99 | roughGeneratorRef.current = roughCanvasRef.current.generator; 100 | } 101 | const rc = roughCanvasRef.current; 102 | if (isDragging || isResizing || isDrawing) { 103 | ctx.clearRect(0, 0, canvas.width, canvas.height); 104 | } else { 105 | ctx.clearRect(0, 0, canvas.width, canvas.height); 106 | } 107 | 108 | ; 109 | yElement.forEach(el => DrawElements({ ctx, element: el, rc: rc })); 110 | if (selectedYElement && bound) DrawBounds({ context: ctx, bounds: bound }); 111 | }, [isDragging, isResizing, isDrawing, yElement, selectedYElement, bound]); 112 | 113 | const scheduleRender = useCallback(() => { 114 | if (animationFrameIdRef.current) cancelAnimationFrame(animationFrameIdRef.current); 115 | animationFrameIdRef.current = requestAnimationFrame(renderCanvas); 116 | }, [renderCanvas]); 117 | 118 | 119 | const getPointerCoordinates = useCallback((e: React.PointerEvent | React.TouchEvent) => { 120 | const canvas = canvasRef.current; 121 | if (!canvas) return [0, 0] as [number, number]; 122 | 123 | const rect = canvas.getBoundingClientRect(); 124 | let clientX: number, clientY: number; 125 | 126 | if ('touches' in e && e.touches.length > 0) { 127 | // Touch event 128 | clientX = e.touches[0].clientX; 129 | clientY = e.touches[0].clientY; 130 | } else if ('clientX' in e) { 131 | // Pointer/Mouse event 132 | clientX = e.clientX; 133 | clientY = e.clientY; 134 | } else { 135 | return [0, 0] as [number, number]; 136 | } 137 | 138 | const X = clientX - rect.left; 139 | const Y = clientY - rect.top; 140 | setPointerPosition([X, Y]); 141 | return [X, Y] as [number, number]; 142 | }, [setPointerPosition]); 143 | 144 | const hitTestAtPoint = useCallback((pt: point) => { 145 | 146 | for (let i = order.length - 1; i >= 0; i--) { 147 | const elementId = order.get(i) as string; 148 | const element = yElement.get(elementId); 149 | if (!element) continue; 150 | if (isPointInsideElement({ point: pt, element })) { 151 | return { id: elementId, yEl: element }; 152 | } 153 | } 154 | 155 | return null; 156 | }, [order, yElement]); 157 | 158 | const handlePointerDown = useCallback((e: React.PointerEvent) => { 159 | 160 | const canvas = canvasRef.current; 161 | if (!canvas) return; 162 | const context = canvas.getContext('2d'); 163 | if (!context) return; 164 | 165 | const [x, y] = getPointerCoordinates(e); 166 | console.log({ getPointerCoordinates }) 167 | const initialPoint: point = [x, y]; 168 | 169 | 170 | setFreehandPoint([[initialPoint[0], initialPoint[1], 1] as PointsFreeHand]); 171 | if (currentTool.action === actionType.Selecting) { 172 | 173 | const handleHit = bound ? detectResizeHandle({ point: initialPoint, element: bound, tolerance: 10 }) : null; 174 | if (handleHit) { 175 | resizeHandleRef.current = handleHit; 176 | } 177 | 178 | let hit = null; 179 | if (lockedBounds && bound && isPointInPaddedBounds(initialPoint, bound)) { 180 | if (selectedYElement) { 181 | hit = { id: selectedYElement.get('id') as string, yEl: selectedYElement }; 182 | } 183 | } else { 184 | hit = hitTestAtPoint(initialPoint); 185 | } 186 | 187 | if (hit && hit.yEl) { 188 | const currentBounds = getBounds({ element: hit.yEl }); 189 | setYElement(hit.yEl); 190 | setBound(getBounds({ element: hit.yEl })); 191 | setIsDragging(true); 192 | const offsetXToBound = x - currentBounds.x; 193 | const offsetYToBound = y - currentBounds.y; 194 | setGlobalPointerPosition([offsetXToBound, offsetYToBound]) 195 | 196 | flagRef.current = true; 197 | } 198 | else { 199 | setLockedBounds(false) 200 | setIsDragging(false); 201 | } 202 | if (resizeHandleRef.current) { 203 | // console.log("Resize Handle Found on Pointer Down:", resizeHandleRef.current.direction); 204 | setIsResizing(true); 205 | setResizeHandle(resizeHandleRef.current.direction); 206 | setIsDragging(false); 207 | resizeStartPointerRef.current = initialPoint; 208 | 209 | const type = selectedYElement?.get('type') as unknown as elementType; 210 | if (type === 'freehand') { 211 | 212 | const stroke = selectedYElement?.get('points') as Y.Array>; 213 | // console.log(selectedYElement?.toJSON(), "yaha se ----------------------------------") 214 | 215 | const points: PointsFreeHand[] = stroke 216 | .toArray() 217 | .map((p) => [ 218 | (p.get('x') as number), 219 | (p.get('y') as number), 220 | p.get('pressure') as number, 221 | ]); 222 | 223 | originalPointRef.current = points 224 | } 225 | 226 | if (hit && hit.yEl) { 227 | resizeOriginalRectRef.current = (getBounds({ element: hit.yEl })) as { x: number; y: number; width: number; height: number }; 228 | } else if (bound) { 229 | resizeOriginalRectRef.current = (bound) as { x: number; y: number; width: number; height: number }; 230 | } 231 | 232 | } 233 | if (bound) setLockedBounds(true) 234 | else setLockedBounds(false) 235 | 236 | if (!hit && !resizeHandleRef.current) { 237 | setYElement(null) 238 | setBound(null) 239 | setLockedBounds(false) 240 | 241 | } 242 | 243 | } 244 | 245 | if (currentTool.action === actionType.Drawing) { 246 | setCursorStyle("crosshair") 247 | const element = handleDrawElement({ 248 | action: actionType.Drawing, 249 | element: currentTool.elementType, 250 | startPoint: initialPoint, 251 | endPoint: initialPoint, 252 | options: { 253 | strokeColor: strokeColor, 254 | strokeWidth: strokeWidth, 255 | fillColor: fillColor, 256 | fillStyle: fillStyle, 257 | roughness: roughness, 258 | boundaryStyle: boundaryStyle, 259 | fillWeight: fillWeight, 260 | }, 261 | stroke: { 262 | points: [[initialPoint[0], initialPoint[1], 1]], 263 | }, 264 | }); 265 | 266 | if (!element) return; 267 | 268 | try { 269 | if (!doc) throw new Error("Y.Doc is not initialized"); 270 | let createdYEl: Y.Map | null = null; 271 | doc.transact(() => { 272 | `x` 273 | createdYEl = yUtils.createYElement(element); 274 | 275 | if (!yElement.get(element.id) && createdYEl) { 276 | yElement.set(element.id, createdYEl); 277 | order.push([element.id]); 278 | } 279 | }, LOCAL_ORIGIN); 280 | 281 | if (createdYEl) { 282 | setYElement(createdYEl); 283 | 284 | setSelectedElementId(element.id); 285 | setIsDrawing(true); 286 | setGlobalPointerPosition([x, y]); 287 | 288 | } 289 | } catch (error) { 290 | console.error("error in creating y element", error); 291 | } 292 | return; 293 | } 294 | }, [getPointerCoordinates, currentTool, bound, lockedBounds, selectedYElement, hitTestAtPoint, 295 | setYElement, setBound, setIsDragging, setIsResizing, setResizeHandle, strokeColor, 296 | strokeWidth, fillColor, fillStyle, roughness, boundaryStyle, fillWeight, doc, yElement, 297 | order, setSelectedElementId, setIsDrawing] 298 | 299 | ); 300 | const lastMoveTimeRef = useRef(0); 301 | const MOVE_THROTTLE_MS = 16; 302 | 303 | const handlePointerMove = useCallback((e: React.PointerEvent) => { 304 | const [x, y] = getPointerCoordinates(e); 305 | const pt: point = [x, y]; 306 | // console.log([x, y]) 307 | // console.log("resizing status ", isResizing) 308 | const now = Date.now(); 309 | if (now - lastMoveTimeRef.current < MOVE_THROTTLE_MS && !isDrawing && !isDragging && !isResizing) { 310 | return; 311 | } 312 | lastMoveTimeRef.current = now; 313 | if (currentTool.action === actionType.Selecting && !isDragging && !isResizing) { 314 | // console.log("inside hover logic") 315 | let foundElementToSelect: Y.Map | null = null; 316 | let hit = null; 317 | if (lockedBounds && bound && isPointInPaddedBounds(pt, bound)) { 318 | 319 | if (selectedYElement) { 320 | hit = { id: selectedYElement.get('id') as string, yEl: selectedYElement }; 321 | } 322 | } else { 323 | hit = hitTestAtPoint(pt); 324 | } 325 | 326 | if (hit && !lockedBounds) { 327 | foundElementToSelect = hit.yEl; 328 | setBound(getBounds({ element: foundElementToSelect })); 329 | // console.log({ pt }) 330 | // console.log("bounds calculated at resizing ", getBounds({ element: foundElementToSelect })) 331 | 332 | } 333 | let newCursorStyle = 'default'; 334 | let newResizeHandle: { direction: string; cursor: string } | null = null; 335 | const elementToCheck = selectedYElement || foundElementToSelect; 336 | 337 | 338 | if (bound || elementToCheck) { 339 | const handleHit = detectResizeHandle({ point: pt, element: bound || getBounds({ element: elementToCheck! }), tolerance: 10 }); 340 | if (handleHit) { 341 | newResizeHandle = handleHit; 342 | newCursorStyle = handleHit.cursor; 343 | 344 | } 345 | } else { 346 | setBound(null) 347 | setYElement(null) 348 | } 349 | 350 | if (newResizeHandle) { 351 | setCursorStyle(newCursorStyle); 352 | resizeHandleRef.current = newResizeHandle; 353 | setYElement(elementToCheck); 354 | setIsDragging(false); 355 | } else if (foundElementToSelect && !lockedBounds) { 356 | 357 | setYElement(foundElementToSelect); 358 | setBound(getBounds({ element: foundElementToSelect })); 359 | setCursorStyle('grab'); 360 | resizeHandleRef.current = null; 361 | } else { 362 | setCursorStyle('default'); 363 | resizeHandleRef.current = null; 364 | } 365 | 366 | } 367 | 368 | 369 | if (!isDrawing && !isDragging && !isResizing) return; 370 | 371 | 372 | if (currentTool.action === actionType.Drawing) { 373 | if (!selectedYElement) return; 374 | 375 | 376 | const elementJSON = selectedYElement.toJSON() as OnlyDrawElement; 377 | // console.log('Original seed:', selectedYElement.get('seed')); 378 | // console.log('JSON seed:', elementJSON.seed); 379 | const type = selectedYElement.get('type') as unknown as elementType; 380 | 381 | if (type === elementType.Freehand && freehandPoint) { 382 | 383 | const newAbsPoints: PointsFreeHand[] = [ 384 | ...freehandPoint, 385 | [x, y, 1] as PointsFreeHand, 386 | ]; 387 | setFreehandPoint(newAbsPoints); 388 | 389 | const xs = newAbsPoints.map(([px]) => px); 390 | const ys = newAbsPoints.map(([, py]) => py); 391 | // console.log({ xs }) 392 | // console.log({ ys }) 393 | const minX = Math.min(...xs); 394 | const minY = Math.min(...ys); 395 | const maxX = Math.max(...xs); 396 | const maxY = Math.max(...ys); 397 | // console.log("minX:", minX); 398 | // console.log("minY:", minY); 399 | // console.log("maxX:", maxX); 400 | // console.log("maxY:", maxY); 401 | 402 | const relPoints = newAbsPoints.map( 403 | ([px, py, pressure]) => 404 | [px - minX, py - minY, pressure] as PointsFreeHand 405 | ); 406 | 407 | 408 | const updatedElement = { 409 | ...(elementJSON as Extract), 410 | x: minX, 411 | y: minY, 412 | width: maxX - minX, 413 | height: maxY - minY, 414 | stroke: { points: relPoints }, 415 | } as Extract; 416 | 417 | // console.log( 418 | // "Updated Element Values:", 419 | // { 420 | // x: minX, 421 | // y: minY, 422 | // width: maxX - minX, 423 | // height: maxY - minY, 424 | // elementJSON, 425 | // } 426 | // ); 427 | 428 | // console.log( 429 | // '[Move] Updating Freehand:', 430 | // `x: ${updatedElement.x}, y: ${updatedElement.y}`, 431 | // `Points (Count: ${updatedElement.stroke.points.length}):`, 432 | // JSON.parse(JSON.stringify(updatedElement.stroke.points)) 433 | // ); 434 | 435 | doc.transact(() => { 436 | yUtils.updateYElement(updatedElement, selectedYElement); 437 | }, LOCAL_ORIGIN) 438 | 439 | scheduleRender(); 440 | 441 | } else { 442 | const updatedElement = { 443 | ...elementJSON, 444 | width: x - elementJSON.x, 445 | height: y - elementJSON.y, 446 | }; 447 | doc.transact(() => { 448 | yUtils.updateYElement(updatedElement, selectedYElement); 449 | }, LOCAL_ORIGIN) 450 | scheduleRender(); 451 | } 452 | return; 453 | } 454 | 455 | 456 | if (isDragging) { 457 | setCursorStyle("grabbing") 458 | if (!GlobalPointerPosition || !selectedYElement || !bound) return; 459 | const newBoundX = x - GlobalPointerPosition[0]; 460 | const newBoundY = y - GlobalPointerPosition[1]; 461 | const dx = newBoundX - bound.x; 462 | const dy = newBoundY - bound.y; 463 | 464 | try { 465 | doc.transact(() => { 466 | selectedYElement.set("x", Number(selectedYElement.get("x")) + dx); 467 | selectedYElement.set("y", Number(selectedYElement.get("y")) + dy); 468 | setBound(getBounds({ element: selectedYElement })); 469 | }, LOCAL_ORIGIN); 470 | } catch (err) { 471 | console.error("Error updating Y element during drag:", err); 472 | } 473 | scheduleRender(); 474 | 475 | 476 | 477 | } 478 | if (isResizing) { 479 | // console.log("Resizing in progress..."); 480 | // console.log({ GlobalPointerPosition }) 481 | // console.log({ selectedYElement }) 482 | // console.log({ resizeHandle }) 483 | if (!GlobalPointerPosition || !selectedYElement || !resizeHandle) return; 484 | 485 | // console.log("are we resizing ?") 486 | 487 | 488 | const startPointer = resizeStartPointerRef.current 489 | const originalBound = resizeOriginalRectRef.current 490 | 491 | if (!startPointer || !originalBound || !selectedYElement || !resizeHandle || !bound) return; 492 | const resizedBound = resizeBound(resizeHandle, startPointer, [x, y], originalBound); 493 | 494 | const originalPoint = originalPointRef.current 495 | setBound(resizedBound) 496 | scheduleRender() 497 | doc.transact(() => { 498 | resizeElement({ 499 | element: selectedYElement, 500 | newBounds: resizedBound, 501 | oldBounds: originalBound, 502 | originalPoints: originalPoint 503 | }) 504 | }, LOCAL_ORIGIN) 505 | 506 | } 507 | 508 | }, [getPointerCoordinates, currentTool, isDragging, isResizing, lockedBounds, bound, 509 | selectedYElement, hitTestAtPoint, setBound, setYElement, setIsDragging, isDrawing, 510 | freehandPoint, doc, scheduleRender, GlobalPointerPosition, resizeHandle]); 511 | 512 | const handlePointerUp = useCallback((e: React.PointerEvent) => { 513 | 514 | if (!selectedYElement) return 515 | setIsDrawing(false); 516 | setIsDragging(false); 517 | 518 | 519 | flagRef.current = false; 520 | 521 | 522 | 523 | setFreehandPoint(null) 524 | 525 | resizeHandleRef.current = null; 526 | setIsResizing(false); 527 | // const element = selectedYElement?.toJSON() as OnlyDrawElement 528 | // doc.transact(() => { 529 | // yUtils.updateYElement(element, selectedYElement) 530 | // }, LOCAL_ORIGIN) 531 | UndoManager.stopCapturing(); 532 | setResizeHandle(null); 533 | setCursorStyle("default") 534 | resizeStartPointerRef.current = null; 535 | resizeOriginalRectRef.current = null; 536 | 537 | 538 | }, [selectedYElement, setIsDragging, setIsDrawing, setIsResizing, setResizeHandle]); 539 | 540 | 541 | 542 | const handleTouchStart = useCallback((e: React.TouchEvent) => { 543 | e.preventDefault(); // Prevent scrolling 544 | const canvas = canvasRef.current; 545 | if (!canvas) return; 546 | 547 | const [x, y] = getPointerCoordinates(e); 548 | const syntheticEvent = { 549 | clientX: e.touches[0].clientX, 550 | clientY: e.touches[0].clientY, 551 | currentTarget: canvas, 552 | target: canvas, 553 | } as unknown as React.PointerEvent; 554 | 555 | handlePointerDown(syntheticEvent); 556 | }, [getPointerCoordinates, handlePointerDown]); 557 | 558 | const handleTouchMove = useCallback((e: React.TouchEvent) => { 559 | e.preventDefault(); // Prevent scrolling 560 | const canvas = canvasRef.current; 561 | if (!canvas) return; 562 | 563 | const syntheticEvent = { 564 | clientX: e.touches[0].clientX, 565 | clientY: e.touches[0].clientY, 566 | currentTarget: canvas, 567 | target: canvas, 568 | } as unknown as React.PointerEvent; 569 | 570 | handlePointerMove(syntheticEvent); 571 | }, [, handlePointerMove]); 572 | 573 | const handleTouchEnd = useCallback((e: React.TouchEvent) => { 574 | e.preventDefault(); 575 | const canvas = canvasRef.current; 576 | if (!canvas) return; 577 | 578 | const syntheticEvent = { 579 | clientX: e.changedTouches[0]?.clientX || 0, 580 | clientY: e.changedTouches[0]?.clientY || 0, 581 | currentTarget: canvas, 582 | target: canvas, 583 | } as unknown as React.PointerEvent; 584 | 585 | handlePointerUp(syntheticEvent); 586 | }, [handlePointerUp]); 587 | const [isCollaborating, setIsCollaborating] = useState(false); 588 | const [roomId, setRoomId] = useState(''); 589 | const [connectionStatus, setConnectionStatus] = useState<'disconnected' | 'connecting' | 'connected'>('disconnected'); 590 | const [showRoomInput, setShowRoomInput] = useState(false); 591 | const providerRef = useRef(null); 592 | const [participants, setParticipants] = useState>([]); 593 | const awarenessHandlerRef = useRef<(() => void) | null>(null); 594 | const unloadHandlerRef = useRef<(() => void) | null>(null); 595 | const startCollaboration = useCallback(() => { 596 | if (!roomId.trim()) { 597 | alert('Please enter a room ID'); 598 | return; 599 | } 600 | 601 | try { 602 | const serverUrl = process.env.NEXT_PUBLIC_URL; 603 | if (!serverUrl) { 604 | console.error('NEXT_PUBLIC_URL is not set'); 605 | alert('Collaboration server URL is not configured.'); 606 | return; 607 | } 608 | console.log(serverUrl) 609 | 610 | setConnectionStatus('connecting'); 611 | setIsCollaborating(true); 612 | setShowRoomInput(false); 613 | 614 | providerRef.current = new WebsocketProvider( 615 | serverUrl, 616 | roomId, 617 | doc 618 | ); 619 | const awareness = providerRef.current.awareness; 620 | const finalUserName = userName.trim() !== '' ? userName.trim() : generateUsername("", 3, 5); 621 | awareness.setLocalState({ 622 | user: { 623 | name: finalUserName, 624 | color: getRandomColor() 625 | }, 626 | cursor: { x: pointerPosition[0], y: pointerPosition[1] } 627 | }); 628 | 629 | const updateParticipants = () => { 630 | console.log('awareness change', Array.from(awareness.getStates().keys())); 631 | const arr: Array<{ clientId: number; name: string; color: string; cursor?: { x: number; y: number } }> = []; 632 | for (const [clientId, state] of awareness.getStates()) { 633 | 634 | if (clientId === awareness.clientID) continue; 635 | 636 | const name = state?.user?.name ?? ''; 637 | const color = state?.user?.color ?? '#777'; 638 | const cursor = state?.cursor; 639 | arr.push({ clientId: Number(clientId), name, color, cursor }); 640 | console.log('client', clientId, 'state', state); 641 | } 642 | setParticipants(arr); 643 | }; 644 | awareness.on('change', updateParticipants); 645 | awarenessHandlerRef.current = updateParticipants; 646 | updateParticipants(); 647 | 648 | providerRef.current.on('status', (event: { status: string }) => { 649 | console.log('Provider status:', event.status); 650 | if (event.status === 'connected') { 651 | setConnectionStatus('connected'); 652 | } else if (event.status === 'disconnected') { 653 | setConnectionStatus('connecting'); 654 | } 655 | }); 656 | 657 | const onUnload = () => providerRef.current?.destroy(); 658 | window.addEventListener('beforeunload', onUnload); 659 | unloadHandlerRef.current = onUnload; 660 | console.log(`Joining room: ${roomId}`); 661 | } catch (error) { 662 | console.error('Failed to start collaboration:', error); 663 | alert('Failed to connect to room'); 664 | setConnectionStatus('disconnected'); 665 | setIsCollaborating(false); 666 | setConnectionStatus('disconnected'); 667 | } 668 | }, [roomId, doc, userName, pointerPosition]); 669 | 670 | 671 | const stopCollaboration = useCallback(() => { 672 | if (providerRef.current) { 673 | try { 674 | const awareness = providerRef.current.awareness; 675 | if (awareness && awarenessHandlerRef.current) { 676 | awareness.off('change', awarenessHandlerRef.current); 677 | awarenessHandlerRef.current = null; 678 | } 679 | } catch (e) { 680 | 681 | } 682 | 683 | if (unloadHandlerRef.current) { 684 | window.removeEventListener('beforeunload', unloadHandlerRef.current); 685 | unloadHandlerRef.current = null; 686 | } 687 | 688 | providerRef.current.destroy(); 689 | providerRef.current = null; 690 | setParticipants([]); 691 | setIsCollaborating(false); 692 | setConnectionStatus('disconnected'); 693 | console.log('Disconnected from room'); 694 | } 695 | }, []); 696 | 697 | useEffect(() => { 698 | return () => { 699 | if (providerRef.current) { 700 | try { 701 | const awareness = providerRef.current.awareness; 702 | if (awareness && awarenessHandlerRef.current) { 703 | awareness.off('change', awarenessHandlerRef.current); 704 | } 705 | } catch (e) { } 706 | providerRef.current.destroy(); 707 | providerRef.current = null; 708 | } 709 | if (unloadHandlerRef.current) { 710 | window.removeEventListener('beforeunload', unloadHandlerRef.current); 711 | unloadHandlerRef.current = null; 712 | } 713 | }; 714 | }, []); 715 | 716 | useEffect(() => { 717 | const preventDefault = (e: TouchEvent) => { 718 | if (e.touches.length > 1) return; // Allow pinch zoom 719 | e.preventDefault(); 720 | }; 721 | 722 | document.body.style.overflow = 'hidden'; 723 | document.body.style.position = 'fixed'; 724 | document.body.style.width = '100%'; 725 | document.body.style.height = '100%'; 726 | document.addEventListener('touchmove', preventDefault, { passive: false }); 727 | 728 | return () => { 729 | document.body.style.overflow = ''; 730 | document.body.style.position = ''; 731 | document.body.style.width = ''; 732 | document.body.style.height = ''; 733 | document.removeEventListener('touchmove', preventDefault); 734 | }; 735 | }, []); 736 | 737 | 738 | 739 | useEffect(() => { 740 | const handleKeyDown = (e: KeyboardEvent) => { 741 | if (e.ctrlKey && e.key === 'z') { 742 | handleUndo(); 743 | setYElement(null); 744 | setBound(null); 745 | setLockedBounds(false); 746 | scheduleRender(); 747 | } 748 | if (e.ctrlKey && e.key === 'y') { 749 | handleRedo(); 750 | setYElement(null); 751 | setBound(null); 752 | setLockedBounds(false); 753 | scheduleRender(); 754 | } 755 | console.log("Key pressed:", selectedYElement, e.key); 756 | if (e.key === 'Delete' && selectedYElement) { 757 | console.log("Deleting selected element"); 758 | 759 | // Find the outer key (the actual Map key in yElement) 760 | let elementKeyToDelete: string | null = null; 761 | yElement.forEach((value, key) => { 762 | if (value === selectedYElement) { 763 | elementKeyToDelete = key; 764 | } 765 | }); 766 | 767 | if (elementKeyToDelete) { 768 | console.log("Found element key to delete:", elementKeyToDelete); 769 | console.log("yElement before delete:", yElement.toJSON()); 770 | console.log("order before delete:", order.toArray()); 771 | 772 | doc.transact(() => { 773 | yElement.delete(elementKeyToDelete!); 774 | const index = order.toArray().indexOf(elementKeyToDelete!); 775 | if (index > -1) { 776 | order.delete(index, 1); 777 | } 778 | }, LOCAL_ORIGIN); 779 | 780 | console.log("yElement after delete:", yElement.toJSON()); 781 | console.log("order after delete:", order.toArray()); 782 | 783 | setYElement(null); 784 | setBound(null); 785 | setLockedBounds(false); 786 | scheduleRender(); 787 | } else { 788 | console.error("Could not find element key in yElement Map"); 789 | } 790 | } 791 | }; 792 | 793 | window.addEventListener('keydown', handleKeyDown); 794 | return () => window.removeEventListener('keydown', handleKeyDown); 795 | }, [doc, selectedYElement, yElement, order, scheduleRender, setYElement, setLockedBounds, setBound]); 796 | 797 | useEffect(() => { 798 | 799 | 800 | const observerDeep = (events: Array, transaction: Y.Transaction) => { 801 | scheduleRender(); 802 | // // Log simple useful info 803 | // console.log('order:', canvasDoc.order.toArray()); 804 | // console.log('yElements snapshot (string):', JSON.stringify(canvasDoc.yElement.toJSON())); 805 | // console.log('observeDeep events count', events.length, 'origin:', transaction.origin); 806 | 807 | }; 808 | 809 | // console.log(`[UNDO] Undo Stack Size: ${UndoManager.undoStack.length}`); 810 | // console.log(`[UNDO] Redo Stack Size: ${UndoManager.redoStack.length}`); 811 | 812 | canvasDoc.yElement.observeDeep(observerDeep); 813 | return () => { 814 | canvasDoc.yElement.unobserveDeep(observerDeep); 815 | }; 816 | }, [scheduleRender, yElement]); 817 | 818 | useEffect(() => { 819 | const canvas = canvasRef.current; 820 | if (!canvas) return; 821 | roughCanvasRef.current = rough.canvas(canvas); 822 | roughGeneratorRef.current = roughCanvasRef.current.generator; 823 | const setSize = () => { 824 | canvas.width = canvas.offsetWidth; 825 | canvas.height = canvas.offsetHeight; 826 | scheduleRender(); 827 | }; 828 | 829 | setSize(); 830 | window.addEventListener('resize', setSize); 831 | return () => window.removeEventListener('resize', setSize); 832 | }, [scheduleRender]); 833 | // useEffect(() => { 834 | 835 | 836 | // console.log("isdragging ---> ", isDragging) 837 | // console.log("isDrawing ---> ", isDrawing) 838 | // console.log("CursorStyle ---> ", CursorStyle) 839 | // console.log("currentTool ---> ", currentTool) 840 | // console.log("selectedYElement ---> ", selectedYElement) 841 | // console.log("resizeHandle ---> ", resizeHandle) 842 | // console.log("isResizing ---> ", isResizing) 843 | // console.log({ bound }) 844 | // console.log("lockedBounds ---> ", lockedBounds) 845 | // }, [isDragging, isDrawing, CursorStyle, currentTool, selectedYElement, resizeHandle, isResizing, lockedBounds, bound]); 846 | useEffect(() => { 847 | return () => { 848 | if (animationFrameIdRef.current) { 849 | cancelAnimationFrame(animationFrameIdRef.current); 850 | animationFrameIdRef.current = null; 851 | } 852 | }; 853 | }, []); 854 | 855 | useEffect(() => { 856 | const checkTouch = () => { 857 | setIsTouchDevice('ontouchstart' in window || navigator.maxTouchPoints > 0); 858 | }; 859 | checkTouch(); 860 | }, []); 861 | 862 | const handleDelete = useCallback(() => { 863 | if (!selectedYElement) return; 864 | 865 | let elementKeyToDelete: string | null = null; 866 | yElement.forEach((value, key) => { 867 | if (value === selectedYElement) { 868 | elementKeyToDelete = key; 869 | } 870 | }); 871 | 872 | if (elementKeyToDelete) { 873 | doc.transact(() => { 874 | yElement.delete(elementKeyToDelete!); 875 | const index = order.toArray().indexOf(elementKeyToDelete!); 876 | if (index > -1) { 877 | order.delete(index, 1); 878 | } 879 | }, LOCAL_ORIGIN); 880 | 881 | setYElement(null); 882 | setBound(null); 883 | setLockedBounds(false); 884 | scheduleRender(); 885 | } 886 | }, [selectedYElement, doc, yElement, order, setYElement, setBound, setLockedBounds, scheduleRender]); 887 | 888 | const handleUndoClick = useCallback(() => { 889 | handleUndo(); 890 | setYElement(null); 891 | setBound(null); 892 | setLockedBounds(false); 893 | scheduleRender(); 894 | }, [setYElement, setBound, setLockedBounds, scheduleRender]); 895 | 896 | const handleRedoClick = useCallback(() => { 897 | handleRedo(); 898 | setYElement(null); 899 | setBound(null); 900 | setLockedBounds(false); 901 | scheduleRender(); 902 | }, [setYElement, setBound, setLockedBounds, scheduleRender]); 903 | 904 | useEffect(() => { 905 | if (isCollaborating && providerRef.current) { 906 | const awareness = providerRef.current.awareness; 907 | const localState = awareness.getLocalState(); 908 | if (localState) { 909 | awareness.setLocalStateField('cursor', { x: pointerPosition[0], y: pointerPosition[1] }); 910 | } 911 | } 912 | }, [pointerPosition, isCollaborating]); 913 | 914 | return ( 915 |
916 | 917 | {isCollaborating && connectionStatus === 'connected' && participants.map(p => { 918 | 919 | if (!p.cursor || p.clientId === providerRef.current?.awareness.clientID) return null; 920 | return ( 921 | 933 | 941 | 947 | 948 |
955 | {p.name} 956 |
957 |
958 | ); 959 | })} 960 | {isCollaborating && connectionStatus === 'connected' && participants.length > 0 && ( 961 | 966 |
967 | {participants.map(p => ( 968 |
974 | {p.name ? p.name.charAt(0).toUpperCase() : '?'} 975 |
976 | ))} 977 |
978 |
979 | )} 980 | 984 | {!isCollaborating ? ( 985 | <> 986 | {!showRoomInput ? ( 987 | setShowRoomInput(true)} 989 | className={`${isTouchDevice ? 'px-2 py-1.5 text-[10px]' : 'px-3 py-2 text-xs'} rough-btn font-bold uppercase tracking-wide flex items-center gap-1`} 990 | whileHover={{ scale: 1.05 }} 991 | whileTap={{ scale: 0.95 }} 992 | > 993 | 🌐 994 | {!isTouchDevice && 'Collab'} 995 | 996 | ) : ( 997 | 1004 | setRoomId(e.target.value)} 1008 | placeholder='Room ID' 1009 | className={`w-full px-3 py-2 bg-gray-900 border border-gray-700 rounded-lg text-gray-100 placeholder-gray-500 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500 ${isTouchDevice ? 'text-[10px]' : 'text-xs'}`} 1010 | onKeyDown={(e) => { 1011 | if (e.key === 'Enter') startCollaboration(); 1012 | if (e.key === 'Escape') { 1013 | setShowRoomInput(false); 1014 | setRoomId(''); 1015 | } 1016 | }} 1017 | autoFocus={!isTouchDevice} 1018 | /> 1019 | setUserName(e.target.value)} 1023 | placeholder='username' 1024 | className={`w-full px-3 py-2 bg-gray-900 border border-gray-700 rounded-lg text-gray-100 placeholder-gray-500 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500 ${isTouchDevice ? 'text-[10px]' : 'text-xs'}`} 1025 | onKeyDown={(e) => { 1026 | if (e.key === 'Enter' && roomId) startCollaboration(); 1027 | if (e.key === 'Escape') { 1028 | setShowRoomInput(false); 1029 | setUserName('') 1030 | setRoomId(''); 1031 | } 1032 | }} 1033 | autoFocus={!isTouchDevice} 1034 | /> 1035 |
1036 | 1042 | 1052 |
1053 |
1054 | )} 1055 | 1056 | ) : ( 1057 | 1062 |
1063 |
1064 | {connectionStatus === 'connecting' ? ( 1065 | <> 1066 |
1067 | 1068 | Connecting 1069 | 1070 | 1071 | ) : ( 1072 | <> 1073 |
1074 | 1075 | Live 1076 | 1077 | 1078 | )} 1079 |
1080 | 1087 |
1088 |
1089 | {roomId} 1090 |
1091 |
1092 | )} 1093 |
1094 | 1095 | 1096 | 1102 | 1109 | 1110 | 1111 | 1112 | 1119 | 1120 | 1121 | 1122 | 1123 | 1139 |
1140 | ); 1141 | } --------------------------------------------------------------------------------