;
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)
7 | [](https://nextjs.org)
8 | [](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 | setIsExpanded(true)}
212 | className="text-xs text-gray-400 mt-1 hover:text-black"
213 | >
214 | +
215 |
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 |
Stroke
245 |
246 |
247 |
openPicker('stroke')}
251 | />
252 |
253 | {swatches.slice(0, 3).map(c => (
254 | setStrokeColor(c)} style={{ backgroundColor: c }} className="w-4 h-4 rounded-full border border-gray-700" />
255 | ))}
256 |
257 |
258 |
259 |
260 |
261 |
Fill
262 |
263 |
openPicker('fill')}
267 | >
268 | {isFillTransparent &&
}
269 |
270 |
setIsFillTransparent(!isFillTransparent)}
272 | className={`text-xs p-1 rounded border ${isFillTransparent ? 'bg-gray-700 text-white border-gray-600' : 'bg-gray-900 text-gray-200 border-gray-700 hover:border-gray-500'}`}
273 | >
274 | {isFillTransparent ? 'None' : 'Fill'}
275 |
276 |
277 |
278 |
279 |
280 |
281 |
282 |
283 | Thickness
284 | {strokeWidth}px
285 |
286 |
setStrokeWidth(Number(e.target.value))}
289 | className="modern-slider"
290 | />
291 |
292 |
293 |
294 |
295 | Sloppiness
296 | {getRoughnessLabel(roughness)}
297 |
298 |
setRoughness(Number(e.target.value))}
301 | className="modern-slider"
302 | />
303 |
304 |
305 |
306 |
307 |
308 |
309 |
Fill Pattern
310 |
311 | {fillStyles.map((s) => (
312 | setFillStyle(s.id as FillStyle)}
315 | className={`text-[10px] py-1 px-1 rounded border ${fillStyle === s.id ? 'bg-black text-white border-gray-700' : 'bg-gray-900 text-gray-300 border-gray-700 hover:border-gray-500'}`}
316 | >
317 | {s.label}
318 |
319 | ))}
320 |
321 |
322 |
323 | {fillStyle !== 'solid' && (
324 |
325 |
326 | Pattern Density
327 | {fillWeight}
328 |
329 |
setFillWeight(Number(e.target.value))}
332 | className="modern-slider"
333 | />
334 |
335 | )}
336 |
337 |
338 |
Line Style
339 |
340 | {boundaryStyles.map((s) => (
341 | setBoundaryStyle(s.id as BoundaryStyle)}
344 | className={`flex-1 py-1 text-[10px] rounded border ${boundaryStyle === s.id
345 | ? 'bg-blue-900/30 border-blue-500 text-blue-300'
346 | : 'bg-gray-900 border-gray-700 text-gray-300 hover:border-gray-500'
347 | }`}
348 | >
349 | {s.label}
350 |
351 | ))}
352 |
353 |
354 |
355 |
356 |
357 |
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 |
1040 | Join
1041 |
1042 | {
1044 | setShowRoomInput(false);
1045 | setRoomId('');
1046 | }}
1047 | className={`px-3 py-2 rough-btn ${isTouchDevice ? 'text-[10px]' : 'text-xs'} uppercase font-bold`}
1048 | title="Cancel"
1049 | >
1050 | ✕
1051 |
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 |
1085 | ✕
1086 |
1087 |
1088 |
1089 | {roomId}
1090 |
1091 |
1092 | )}
1093 |
1094 |
1095 |
1096 |
1102 |
1109 | ↶
1110 |
1111 |
1112 |
1119 | ↷
1120 |
1121 |
1122 |
1123 |
1139 |
1140 | );
1141 | }
--------------------------------------------------------------------------------