├── client
├── public
│ └── favicon.ico
├── src
│ ├── assets
│ │ ├── fonts
│ │ │ ├── Roboto-Bold.ttf
│ │ │ └── Roboto-Medium.ttf
│ │ └── icons
│ │ │ └── index.jsx
│ ├── helper
│ │ ├── ui.js
│ │ ├── keys.js
│ │ ├── canvas.js
│ │ └── element.js
│ ├── api
│ │ └── socket.js
│ ├── components
│ │ ├── Credits.jsx
│ │ ├── UndoRedo.jsx
│ │ ├── Canvas.jsx
│ │ ├── Zoom.jsx
│ │ ├── ToolBar.jsx
│ │ ├── Ui.jsx
│ │ ├── Menu.jsx
│ │ ├── Collaboration.jsx
│ │ └── Style.jsx
│ ├── App.jsx
│ ├── main.jsx
│ ├── hooks
│ │ ├── useDimension.jsx
│ │ ├── useKeys.jsx
│ │ ├── useHistory.jsx
│ │ ├── useTextArea.jsx
│ │ └── useCanvas.jsx
│ ├── global
│ │ └── var.js
│ ├── views
│ │ └── WorkSpace.jsx
│ ├── provider
│ │ └── AppStates.jsx
│ └── styles
│ │ └── index.css
├── vercel.json
├── vite.config.js
├── index.html
├── .gitignore
├── .eslintrc.cjs
└── package.json
├── package.json
├── server
├── .gitignore
├── package.json
├── index.js
└── package-lock.json
├── .gitignore
└── readme.md
/client/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/toouil/sketchflow/HEAD/client/public/favicon.ico
--------------------------------------------------------------------------------
/client/src/assets/fonts/Roboto-Bold.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/toouil/sketchflow/HEAD/client/src/assets/fonts/Roboto-Bold.ttf
--------------------------------------------------------------------------------
/client/vercel.json:
--------------------------------------------------------------------------------
1 | {
2 | "rewrites": [
3 | {
4 | "source": "/(.*)",
5 | "destination": "/"
6 | }
7 | ]
8 | }
--------------------------------------------------------------------------------
/client/src/assets/fonts/Roboto-Medium.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/toouil/sketchflow/HEAD/client/src/assets/fonts/Roboto-Medium.ttf
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "scripts": {
3 | "client": "npm --prefix client run dev",
4 | "server": "npm --prefix server run dev"
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/client/src/helper/ui.js:
--------------------------------------------------------------------------------
1 | export function lockUI(lock) {
2 | if (lock) return document.body.classList.add("lock-ui");
3 | document.body.classList.remove("lock-ui");
4 | }
5 |
--------------------------------------------------------------------------------
/client/vite.config.js:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vite'
2 | import react from '@vitejs/plugin-react'
3 |
4 | export default defineConfig({
5 | plugins: [react()],
6 | })
7 |
--------------------------------------------------------------------------------
/client/src/api/socket.js:
--------------------------------------------------------------------------------
1 | import { io } from "socket.io-client";
2 | import parser from "socket.io-msgpack-parser"
3 |
4 | const BACKEND_URL = import.meta.env.VITE_APP_SERVER_URL;
5 |
6 | export const socket = io(BACKEND_URL, {
7 | parser
8 | });
--------------------------------------------------------------------------------
/client/src/helper/keys.js:
--------------------------------------------------------------------------------
1 | import { SHORT_CUTS } from "../global/var";
2 |
3 | export function shortKey(pressedKeys) {
4 | return SHORT_CUTS.some((subArray) => {
5 | const subArraySet = new Set(subArray);
6 | return [...subArraySet].every((item) => pressedKeys.has(item));
7 | });
8 | }
9 |
--------------------------------------------------------------------------------
/client/src/components/Credits.jsx:
--------------------------------------------------------------------------------
1 | import { Github } from "../assets/icons";
2 |
3 | export default function Credits() {
4 | return (
5 |
10 | );
11 | }
12 |
--------------------------------------------------------------------------------
/server/.gitignore:
--------------------------------------------------------------------------------
1 | # dependencies
2 | /node_modules
3 | /.pnp
4 | .pnp.js
5 |
6 | # testing
7 | /coverage
8 |
9 | # production
10 | /build
11 |
12 | # misc
13 | .DS_Store
14 | .env
15 | .env.local
16 | .env.development.local
17 | .env.test.local
18 | .env.production.local
19 |
20 | npm-debug.log*
21 | yarn-debug.log*
22 | yarn-error.log*
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # dependencies
2 | /node_modules
3 |
4 | # testing
5 | /coverage
6 |
7 | # production
8 | /build
9 |
10 | # env
11 | .env
12 | .env.local
13 | .env.development.local
14 | .env.test.local
15 | .env.production.local
16 |
17 | npm-debug.log*
18 | yarn-debug.log*
19 | yarn-error.log*
20 |
21 | # Editor directories and files
22 | .idea
23 | .DS_Store
--------------------------------------------------------------------------------
/client/src/App.jsx:
--------------------------------------------------------------------------------
1 | import { Route, Routes, Navigate } from "react-router-dom";
2 | import WorkSpace from "./views/WorkSpace";
3 |
4 | function App() {
5 | return (
6 |
7 | } />
8 | } />
9 |
10 | );
11 | }
12 |
13 | export default App;
--------------------------------------------------------------------------------
/client/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
57 |
58 |
Stroke
59 |
60 | {STROKE_COLORS.map((color, index) => (
61 | {
71 | setStylesStates({ strokeColor: color });
72 | updateElement(
73 | selectedElement.id,
74 | {
75 | strokeColor: color,
76 | },
77 | setElements,
78 | elements
79 | );
80 | }}
81 | >
82 | ))}
83 |
84 |
85 |
86 |
Background
87 |
88 | {BACKGROUND_COLORS.map((fill, index) => (
89 | {
99 | setStylesStates({ fill });
100 | updateElement(
101 | selectedElement.id,
102 | {
103 | fill,
104 | },
105 | setElements,
106 | elements
107 | );
108 | }}
109 | >
110 | ))}
111 |
112 |
113 |
114 |
Stroke width
115 |
116 | {
124 | setStylesStates({ strokeWidth: minmax(+target.value, [0, 20]) });
125 | updateElement(
126 | selectedElement.id,
127 | {
128 | strokeWidth: minmax(+target.value, [0, 20]),
129 | },
130 | setElements,
131 | elements
132 | );
133 | }}
134 | />
135 |
136 |
137 |
138 |
Stroke style
139 |
140 | {STROKE_STYLES.map((style, index) => (
141 | {
150 | setStylesStates({ strokeStyle: style.slug });
151 | updateElement(
152 | selectedElement.id,
153 | {
154 | strokeStyle: style.slug,
155 | },
156 | setElements,
157 | elements
158 | );
159 | }}
160 | >
161 |
162 |
163 | ))}
164 |
165 |
166 |
167 |
Angles
168 |
169 | {
176 | setStylesStates({
177 | borderRadius: minmax(+target.value, [0, 100]),
178 | });
179 | updateElement(
180 | selectedElement.id,
181 | {
182 | borderRadius: minmax(+target.value, [0, 100]),
183 | },
184 | setElements,
185 | elements
186 | );
187 | }}
188 | />
189 |
190 |
191 |
192 |
Opacity
193 |
194 | {
202 | setStylesStates({
203 | opacity: minmax(+target.value, [0, 100]),
204 | });
205 | updateElement(
206 | selectedElement.id,
207 | {
208 | opacity: minmax(+target.value, [0, 100]),
209 | },
210 | setElements,
211 | elements
212 | );
213 | }}
214 | />
215 |
216 |
217 | {selectedElement && (
218 |
219 |
220 |
Layers
221 |
222 |
227 | moveElementLayer(selectedElement.id, 0, setElements, elements)
228 | }
229 | >
230 |
231 |
232 |
237 | moveElementLayer(
238 | selectedElement.id,
239 | -1,
240 | setElements,
241 | elements
242 | )
243 | }
244 | >
245 |
246 |
247 |
252 | moveElementLayer(selectedElement.id, 1, setElements, elements)
253 | }
254 | >
255 |
256 |
257 |
262 | moveElementLayer(selectedElement.id, 2, setElements, elements)
263 | }
264 | >
265 |
266 |
267 |
268 |
269 |
270 |
271 |
Actions
272 |
273 |
276 | deleteElement(
277 | selectedElement,
278 | setElements,
279 | setSelectedElement
280 | )
281 | }
282 | title="Delete"
283 | className="itemButton option"
284 | >
285 |
286 |
287 |
292 | duplicateElement(
293 | selectedElement,
294 | setElements,
295 | setSelectedElement,
296 | 10
297 | )
298 | }
299 | >
300 |
301 |
302 |
303 |
304 |
305 | )}
306 |
307 | );
308 | }
309 |
--------------------------------------------------------------------------------
/client/src/helper/canvas.js:
--------------------------------------------------------------------------------
1 | export const imageCache = new Map();
2 | let textWriting = null;
3 |
4 | export const writing = (id) => {
5 | textWriting = id
6 | }
7 |
8 | export const shapes = {
9 | arrow: ({ x1, y1, x2, y2 }, ctx) => {
10 | const headlen = 5;
11 | const angle = Math.atan2(y2 - y1, x2 - x1);
12 |
13 | ctx.moveTo(x1, y1);
14 | ctx.lineTo(x2, y2);
15 |
16 | ctx.moveTo(x2, y2);
17 | ctx.lineTo(
18 | x2 - headlen * Math.cos(angle - Math.PI / 7),
19 | y2 - headlen * Math.sin(angle - Math.PI / 7)
20 | );
21 |
22 | ctx.lineTo(
23 | x2 - headlen * Math.cos(angle + Math.PI / 7),
24 | y2 - headlen * Math.sin(angle + Math.PI / 7)
25 | );
26 |
27 | ctx.lineTo(x2, y2);
28 | ctx.lineTo(
29 | x2 - headlen * Math.cos(angle - Math.PI / 7),
30 | y2 - headlen * Math.sin(angle - Math.PI / 7)
31 | );
32 | },
33 |
34 | line: ({ x1, y1, x2, y2 }, ctx) => {
35 | ctx.beginPath();
36 | ctx.moveTo(x1, y1);
37 | ctx.lineTo(x2, y2);
38 | },
39 |
40 | rectangle: ({ x1, y1, x2, y2, borderRadius }, ctx) => {
41 | const left = Math.min(x1, x2);
42 | const right = Math.max(x1, x2);
43 | const top = Math.min(y1, y2);
44 | const bottom = Math.max(y1, y2);
45 |
46 | const width = right - left;
47 | const height = bottom - top;
48 |
49 | const r = Math.min(borderRadius, width / 2, height / 2);
50 |
51 | ctx.beginPath();
52 | ctx.moveTo(left + r, top); // top-left
53 | ctx.lineTo(right - r, top);
54 | ctx.quadraticCurveTo(right, top, right, top + r);
55 | ctx.lineTo(right, bottom - r);
56 | ctx.quadraticCurveTo(right, bottom, right - r, bottom); // bottom-right
57 | ctx.lineTo(left + r, bottom);
58 | ctx.quadraticCurveTo(left, bottom, left, bottom - r); // bottom-left
59 | ctx.lineTo(left, top + r);
60 | ctx.quadraticCurveTo(left, top, left + r, top); // back to top-left
61 | ctx.closePath();
62 | },
63 |
64 | diamond: ({ x1, y1, x2, y2 }, ctx) => {
65 | ctx.beginPath();
66 | const width = x2 - x1;
67 | const height = y2 - y1;
68 | ctx.moveTo(x1 + width / 2, y1);
69 | ctx.lineTo(x2, y1 + height / 2);
70 | ctx.lineTo(x1 + width / 2, y2);
71 | ctx.lineTo(x1, y1 + height / 2);
72 | ctx.closePath();
73 | },
74 |
75 | circle: ({ x1, y1, x2, y2 }, ctx) => {
76 | ctx.beginPath();
77 | const width = x2 - x1;
78 | const height = y2 - y1;
79 | ctx.ellipse(
80 | x1 + width / 2,
81 | y1 + height / 2,
82 | Math.abs(width) / 2,
83 | Math.abs(height) / 2,
84 | 0,
85 | 0,
86 | 2 * Math.PI
87 | );
88 | ctx.closePath();
89 | },
90 | image: ({ id, x1, y1, x2, y2, image }, ctx) => {
91 | let cached = imageCache.get(id);
92 |
93 | const left = Math.min(x1, x2);
94 | const top = Math.min(y1, y2);
95 | const width = Math.abs(x2 - x1);
96 | const height = Math.abs(y2 - y1);
97 |
98 | if (cached) {
99 | ctx.drawImage(cached, left, top, width, height);
100 | return;
101 | }
102 |
103 | cached = new Image();
104 | cached.src = image;
105 | cached.onload = () => {
106 | ctx.drawImage(cached, left, top, width, height);
107 | imageCache.set(id, cached);
108 | };
109 | },
110 | pencil: ({ points, strokeWidth }, ctx) => {
111 | if (points.length < 2) return;
112 |
113 | ctx.beginPath();
114 | ctx.lineCap = "round";
115 | ctx.lineJoin = "round";
116 |
117 | ctx.moveTo(points[0].x, points[0].y);
118 |
119 | for (let i = 1; i < points.length - 1; i++) {
120 | const midX = (points[i].x + points[i + 1].x) / 2;
121 | const midY = (points[i].y + points[i + 1].y) / 2;
122 | ctx.quadraticCurveTo(points[i].x, points[i].y, midX, midY);
123 | }
124 |
125 | const lastpoint = points[points.length - 1];
126 |
127 | if (
128 | !(
129 | Math.abs(lastpoint.x - points[0].x) < strokeWidth &&
130 | Math.abs(lastpoint.y - points[0].y) < strokeWidth
131 | )
132 | ) {
133 | ctx.fillStyle = "transparent";
134 | }
135 | ctx.lineTo(lastpoint.x, lastpoint.y);
136 | },
137 | text: ({ id, x1, y1, text }, ctx) => {
138 | if (id == textWriting) return;
139 | ctx.font = "30px Arial";
140 | ctx.textBaseline = "top";
141 |
142 | const textLines = text.split("\n");
143 |
144 | textLines.forEach((line, index) => {
145 | ctx.fillText(line, x1, y1 + 30 * index); // x=100, y=50
146 | });
147 | },
148 | };
149 |
150 | export function distance(a, b) {
151 | return Math.sqrt(Math.pow(a.x - b.x, 2) + Math.pow(a.y - b.y, 2));
152 | }
153 |
154 | export function getFocuseDemention(element, padding) {
155 | const { x1, y1, x2, y2 } = element;
156 |
157 | if (element.tool == "line" || element.tool == "arrow")
158 | return { fx: x1, fy: y1, fw: x2, fh: y2 };
159 |
160 | const p = { min: padding, max: padding * 2 };
161 | const minX = Math.min(x1, x2);
162 | const maxX = Math.max(x1, x2);
163 | const minY = Math.min(y1, y2);
164 | const maxY = Math.max(y1, y2);
165 |
166 | return {
167 | fx: minX - p.min,
168 | fy: minY - p.min,
169 | fw: maxX - minX + p.max,
170 | fh: maxY - minY + p.max,
171 | };
172 | }
173 |
174 | export function getFocuseCorners(element, padding, position) {
175 | let { fx, fy, fw, fh } = getFocuseDemention(element, padding);
176 |
177 | if (element.tool == "line" || element.tool == "arrow") {
178 | return {
179 | line: { fx, fy, fw, fh },
180 | corners: [
181 | {
182 | slug: "l1",
183 | x: fx - position,
184 | y: fy - position,
185 | },
186 | {
187 | slug: "l2",
188 | x: fw - position,
189 | y: fh - position,
190 | },
191 | ],
192 | };
193 | }
194 | return {
195 | line: { fx, fy, fw, fh },
196 | corners: [
197 | {
198 | slug: "tl",
199 | x: fx - position,
200 | y: fy - position,
201 | },
202 | {
203 | slug: "tr",
204 | x: fx + fw - position,
205 | y: fy - position,
206 | },
207 | {
208 | slug: "bl",
209 | x: fx - position,
210 | y: fy + fh - position,
211 | },
212 | {
213 | slug: "br",
214 | x: fx + fw - position,
215 | y: fy + fh - position,
216 | },
217 | {
218 | slug: "tt",
219 | x: fx + fw / 2 - position,
220 | y: fy - position,
221 | },
222 | {
223 | slug: "rr",
224 | x: fx + fw - position,
225 | y: fy + fh / 2 - position,
226 | },
227 | {
228 | slug: "ll",
229 | x: fx - position,
230 | y: fy + fh / 2 - position,
231 | },
232 | {
233 | slug: "bb",
234 | x: fx + fw / 2 - position,
235 | y: fy + fh - position,
236 | },
237 | ],
238 | };
239 | }
240 |
241 | export function drawFocuse(element, context, padding, scale) {
242 | if (!element) return;
243 | context.beginPath();
244 | const lineWidth = 1 / scale;
245 | const square = 10 / scale;
246 | let round = square;
247 | const position = square / 2;
248 |
249 | let demention = getFocuseCorners(element, padding, position);
250 | let { fx, fy, fw, fh } = demention.line;
251 | let corners = demention.corners;
252 |
253 | context.lineWidth = lineWidth;
254 | context.strokeStyle = "#211C6A";
255 | context.fillStyle = "#EEF5FF";
256 |
257 | if (element.tool != "line" && element.tool != "arrow") {
258 | context.beginPath();
259 | context.rect(fx, fy, fw, fh);
260 | context.setLineDash([0, 0]);
261 | context.stroke();
262 | context.closePath();
263 | round = 3 / scale;
264 | }
265 |
266 | context.beginPath();
267 | corners.forEach((corner) => {
268 | context.roundRect(corner.x, corner.y, square, square, round);
269 | });
270 | context.fill();
271 | context.stroke();
272 | context.closePath();
273 | }
274 |
275 | export function draw(element, context) {
276 | const { tool, strokeWidth, strokeColor, strokeStyle, fill, opacity } =
277 | element;
278 |
279 | context.beginPath();
280 | context.lineWidth = strokeWidth;
281 | context.strokeStyle = strokeColor;
282 | context.fillStyle = tool == "text" ? strokeColor : fill;
283 |
284 | context.globalAlpha = opacity * 0.01;
285 |
286 | if (strokeStyle === "dashed")
287 | context.setLineDash([strokeWidth * 2, strokeWidth * 2]);
288 | else if (strokeStyle === "dotted")
289 | context.setLineDash([strokeWidth, strokeWidth]);
290 | else context.setLineDash([0, 0]);
291 |
292 | shapes[tool](element, context);
293 | context.fill();
294 | if (strokeWidth > 0) context.stroke();
295 |
296 | context.closePath();
297 | }
298 |
299 | function rgba(color, opacity) {
300 | if (color == "transparent") return "transparent";
301 |
302 | let matches = color.match(
303 | /^rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*(\d+(?:\.\d+)?))?\)$/
304 | );
305 | if (!matches) {
306 | throw new Error(
307 | "Invalid color format. Please provide a color in RGBA format."
308 | );
309 | }
310 | opacity /= 100;
311 | let red = parseInt(matches[1]);
312 | let green = parseInt(matches[2]);
313 | let blue = parseInt(matches[3]);
314 | let alpha = parseFloat(matches[4] * opacity || opacity);
315 |
316 | let newColor = `rgba(${red}, ${green}, ${blue}, ${alpha})`;
317 |
318 | return newColor;
319 | }
320 |
321 | export function inSelectedCorner(element, x, y, padding, scale) {
322 | if (!element) return null;
323 | padding = element.tool == "line" || element.tool == "arrow" ? 0 : padding;
324 |
325 | const square = 10 / scale;
326 | const position = square / 2;
327 |
328 | const corners = getFocuseCorners(element, padding, position).corners;
329 |
330 | const hoveredCorner = corners.find(
331 | (corner) =>
332 | x - corner.x <= square &&
333 | x - corner.x >= 0 &&
334 | y - corner.y <= square &&
335 | y - corner.y >= 0
336 | );
337 |
338 | return hoveredCorner;
339 | }
340 |
341 | export function cornerCursor(corner) {
342 | switch (corner) {
343 | case "tt":
344 | case "bb":
345 | return "s-resize";
346 | case "ll":
347 | case "rr":
348 | return "e-resize";
349 | case "tl":
350 | case "br":
351 | return "se-resize";
352 | case "tr":
353 | case "bl":
354 | return "ne-resize";
355 | case "l1":
356 | case "l2":
357 | return "pointer";
358 | }
359 | }
360 |
--------------------------------------------------------------------------------
/client/src/helper/element.js:
--------------------------------------------------------------------------------
1 | import { distance } from "./canvas";
2 | import { v4 as uuid } from "uuid";
3 |
4 | const fileNameExtention = ".sketchFlow";
5 |
6 | function pointToSegmentDistance(px, py, ax, ay, bx, by) {
7 | const dx = bx - ax;
8 | const dy = by - ay;
9 |
10 | if (dx === 0 && dy === 0) {
11 | // A and B are the same point
12 | return Math.hypot(px - ax, py - ay);
13 | }
14 |
15 | // Project point P onto segment AB, normalized between 0 and 1
16 | let t = ((px - ax) * dx + (py - ay) * dy) / (dx * dx + dy * dy);
17 | t = Math.max(0, Math.min(1, t));
18 |
19 | // Find closest point
20 | const cx = ax + t * dx;
21 | const cy = ay + t * dy;
22 |
23 | return Math.hypot(px - cx, py - cy);
24 | }
25 |
26 | export function isWithinElement(x, y, element) {
27 | let { tool, x1, y1, x2, y2, strokeWidth, points } = element;
28 |
29 | switch (tool) {
30 | case "arrow":
31 | case "line":
32 | const a = { x: x1, y: y1 };
33 | const b = { x: x2, y: y2 };
34 | const c = { x, y };
35 |
36 | const offset = distance(a, b) - (distance(a, c) + distance(b, c));
37 | return Math.abs(offset) < (0.05 * strokeWidth || 1);
38 | case "circle":
39 | const width = x2 - x1 + strokeWidth;
40 | const height = y2 - y1 + strokeWidth;
41 | x1 -= strokeWidth / 2;
42 | y1 -= strokeWidth / 2;
43 |
44 | const centreX = x1 + width / 2;
45 | const centreY = y1 + height / 2;
46 |
47 | const mouseToCentreX = centreX - x;
48 | const mouseToCentreY = centreY - y;
49 |
50 | const radiusX = Math.abs(width) / 2;
51 | const radiusY = Math.abs(height) / 2;
52 |
53 | return (
54 | (mouseToCentreX * mouseToCentreX) / (radiusX * radiusX) +
55 | (mouseToCentreY * mouseToCentreY) / (radiusY * radiusY) <=
56 | 1
57 | );
58 |
59 | case "image":
60 | case "diamond":
61 | case "rectangle":
62 | case "text":
63 | const minX = Math.min(x1, x2) - strokeWidth / 2;
64 | const maxX = Math.max(x1, x2) + strokeWidth / 2;
65 | const minY = Math.min(y1, y2) - strokeWidth / 2;
66 | const maxY = Math.max(y1, y2) + strokeWidth / 2;
67 |
68 | return x >= minX && x <= maxX && y >= minY && y <= maxY;
69 |
70 | case "pencil":
71 | for (let i = 0; i < points.length - 1; i++) {
72 | const p1 = points[i];
73 | const p2 = points[i + 1];
74 |
75 | const dist = pointToSegmentDistance(x, y, p1.x, p1.y, p2.x, p2.y);
76 | if (dist <= strokeWidth / 2 + 2) {
77 | return true;
78 | }
79 | }
80 | return false;
81 | }
82 | }
83 |
84 | export function getElementPosition(x, y, elements) {
85 | return elements.filter((element) => isWithinElement(x, y, element)).at(-1);
86 | }
87 |
88 | export function createElement({
89 | id = uuid(),
90 | x1,
91 | y1,
92 | x2,
93 | y2,
94 | style,
95 | tool,
96 | image,
97 | text,
98 | }) {
99 | switch (tool) {
100 | case "pencil":
101 | return { id, points: [{ x: x1, y: y1 }], x1, y1, x2, y2, ...style, tool };
102 | case "text":
103 | return { id, x1, y1, x2, y2, ...style, tool, text };
104 | case "image":
105 | return { id, x1, y1, x2, y2, ...style, tool, image };
106 | default:
107 | return { id, x1, y1, x2, y2, ...style, tool };
108 | }
109 | }
110 |
111 | export function updateElement(
112 | id,
113 | stateOption,
114 | setState,
115 | state,
116 | overwrite = false
117 | ) {
118 | const stateCopy = state.map(ele => {
119 | if (ele.id == id) {
120 | return {
121 | ...ele,
122 | ...stateOption,
123 | }
124 | }
125 | return ele
126 | });
127 |
128 | console.log(stateCopy)
129 |
130 | setState(stateCopy, overwrite);
131 | }
132 |
133 | export function deleteElement(s_element, setState, setSelectedElement) {
134 | if (!s_element) return;
135 |
136 | const { id } = s_element;
137 | setState((prevState) => prevState.filter((element) => element.id != id));
138 | setSelectedElement(null);
139 | }
140 |
141 | export function duplicateElement(
142 | s_element,
143 | setState,
144 | setSelected,
145 | factor,
146 | offsets = {}
147 | ) {
148 | if (!s_element) return;
149 |
150 | const { id } = s_element;
151 | setState((prevState) =>
152 | prevState
153 | .map((element) => {
154 | if (element.id == id) {
155 | const duplicated = { ...moveElement(element, factor), id: uuid() };
156 | setSelected({ ...duplicated, ...offsets });
157 | return [element, duplicated];
158 | }
159 | return element;
160 | })
161 | .flat()
162 | );
163 | }
164 |
165 | export function moveElement(element, factorX, factorY = null) {
166 | return {
167 | ...element,
168 | x1: element.x1 + factorX,
169 | y1: element.y1 + (factorY ?? factorX),
170 | x2: element.x2 + factorX,
171 | y2: element.y2 + (factorY ?? factorX),
172 | };
173 | }
174 |
175 | export function moveElementLayer(id, to, setState, state) {
176 | const index = state.findIndex((ele) => ele.id == id);
177 | const stateCopy = [...state];
178 | let replace = stateCopy[index];
179 | stateCopy.splice(index, 1);
180 |
181 | let toReplaceIndex = index;
182 | if (to == 1 && index < state.length - 1) {
183 | toReplaceIndex = index + 1;
184 | } else if (to == -1 && index > 0) {
185 | toReplaceIndex = index - 1;
186 | } else if (to == 0) {
187 | toReplaceIndex = 0;
188 | } else if (to == 2) {
189 | toReplaceIndex = state.length - 1;
190 | }
191 |
192 | const firstPart = stateCopy.slice(0, toReplaceIndex);
193 | const lastPart = stateCopy.slice(toReplaceIndex);
194 |
195 | setState([...firstPart, replace, ...lastPart]);
196 | }
197 |
198 | export function arrowMove(s_element, x, y, setState) {
199 | if (!s_element) return;
200 |
201 | const { id } = s_element;
202 | setState((prevState) =>
203 | prevState.map((element) => {
204 | if (element.id == id) {
205 | return moveElement(element, x, y);
206 | }
207 | return element;
208 | })
209 | );
210 | }
211 |
212 | export function minmax(value, interval) {
213 | return Math.max(interval[0], Math.min(value, interval[1]));
214 | }
215 |
216 | export function getElementById(id, elements) {
217 | return elements.find((element) => element.id == id);
218 | }
219 |
220 | export function adjustCoordinates(element) {
221 | const { id, x1, x2, y1, y2, tool } = element;
222 | if (tool == "line" || tool == "arrow") return { id, x1, x2, y1, y2 };
223 |
224 | const minX = Math.min(x1, x2);
225 | const maxX = Math.max(x1, x2);
226 | const minY = Math.min(y1, y2);
227 | const maxY = Math.max(y1, y2);
228 |
229 | return { id, x1: minX, y1: minY, x2: maxX, y2: maxY };
230 | }
231 |
232 | function resizeFreehand(element, newBox) {
233 | if (element.tool != "pencil") return newBox;
234 |
235 | const { x1, y1, x2, y2, points } = element;
236 | const oldW = x2 - x1;
237 | const oldH = y2 - y1;
238 | const newW = newBox.x2 - newBox.x1;
239 | const newH = newBox.y2 - newBox.y1;
240 |
241 | const scaleX = newW / oldW;
242 | const scaleY = newH / oldH;
243 |
244 | const newPoints = points.map((p) => ({
245 | x: newBox.x1 + (p.x - x1) * scaleX,
246 | y: newBox.y1 + (p.y - y1) * scaleY,
247 | }));
248 |
249 | return {
250 | points: newPoints,
251 | x1: newBox.x1,
252 | y1: newBox.y1,
253 | x2: newBox.x2,
254 | y2: newBox.y2,
255 | };
256 | }
257 |
258 | export function resizeValue(
259 | corner,
260 | type,
261 | x,
262 | y,
263 | padding,
264 | { x1, x2, y1, y2 },
265 | offset,
266 | elementOffset
267 | ) {
268 | const getPadding = (condition) => {
269 | return condition ? padding : padding * -1;
270 | };
271 |
272 | switch (corner) {
273 | case "tt":
274 | return resizeFreehand(elementOffset, {
275 | y1: y + getPadding(y < y2),
276 | x1,
277 | x2,
278 | y2,
279 | });
280 | case "bb":
281 | return resizeFreehand(elementOffset, {
282 | y2: y + getPadding(y < y1),
283 | x1,
284 | x2,
285 | y1,
286 | });
287 | case "rr":
288 | return resizeFreehand(elementOffset, {
289 | x2: x + getPadding(x < x1),
290 | x1,
291 | y1,
292 | y2,
293 | });
294 | case "ll":
295 | return resizeFreehand(elementOffset, {
296 | x1: x + getPadding(x < x2),
297 | x2,
298 | y1,
299 | y2,
300 | });
301 | case "tl":
302 | return resizeFreehand(elementOffset, {
303 | x1: x + getPadding(x < x2),
304 | y1: y + getPadding(y < y2),
305 | x2,
306 | y2,
307 | });
308 | case "tr":
309 | return resizeFreehand(elementOffset, {
310 | x2: x + getPadding(x < x1),
311 | y1: y + getPadding(y < y2),
312 | x1,
313 | y2,
314 | });
315 | case "bl":
316 | return resizeFreehand(elementOffset, {
317 | x1: x + getPadding(x < x2),
318 | y2: y + getPadding(y < y1),
319 | x2,
320 | y1,
321 | });
322 | case "br":
323 | return resizeFreehand(elementOffset, {
324 | x2: x + getPadding(x < x1),
325 | y2: y + getPadding(y < y1),
326 | x1,
327 | y1,
328 | });
329 | case "l1":
330 | return { x1: x, y1: y };
331 | case "l2":
332 | return { x2: x, y2: y };
333 | }
334 | }
335 |
336 | export function saveElements(elements) {
337 | const jsonString = JSON.stringify(elements);
338 | const blob = new Blob([jsonString], { type: "application/json" });
339 | const url = URL.createObjectURL(blob);
340 |
341 | const link = document.createElement("a");
342 | link.download = "canvas" + fileNameExtention;
343 | link.href = url;
344 | link.click();
345 | }
346 |
347 | export function uploadElements(setElements) {
348 | function uploadJSON(event) {
349 | const file = event.target.files[0];
350 | const reader = new FileReader();
351 |
352 | reader.onload = (e) => {
353 | try {
354 | const data = JSON.parse(e.target.result);
355 | setElements(data);
356 | } catch (error) {
357 | console.error("Error :", error);
358 | }
359 | };
360 |
361 | reader.readAsText(file);
362 | }
363 |
364 | const fileInput = document.createElement("input");
365 | fileInput.type = "file";
366 | fileInput.accept = fileNameExtention;
367 | fileInput.onchange = uploadJSON;
368 | fileInput.click();
369 | }
370 |
--------------------------------------------------------------------------------
/client/src/styles/index.css:
--------------------------------------------------------------------------------
1 | @font-face {
2 | font-family: primaryFont;
3 | src: url(../assets/fonts/Roboto-Bold.ttf);
4 | }
5 |
6 | @font-face {
7 | font-family: secondaryFont;
8 | src: url(../assets/fonts/Roboto-Medium.ttf);
9 | }
10 |
11 | * {
12 | margin: 0;
13 | padding: 0;
14 | box-sizing: border-box;
15 | font-family: primaryFont;
16 |
17 | user-select: none !important;
18 | }
19 |
20 | html {
21 | --background: #ffffff;
22 | --secondary-background: #f0f0f0;
23 | --background-50: #ffffff80;
24 |
25 | --primary-color: #1f1f22;
26 | }
27 |
28 | html {
29 | --bg-size: 14px;
30 |
31 | position: relative;
32 | background: radial-gradient(
33 | var(--secondary-background) 10%,
34 | var(--background) 0
35 | );
36 | background-size: var(--bg-size) var(--bg-size);
37 | background-repeat: repeat;
38 |
39 | overflow: hidden;
40 |
41 | min-height: 100vh;
42 | }
43 |
44 | body.lock-ui main.ui * {
45 | pointer-events: none;
46 | }
47 |
48 | canvas#canvas {
49 | position: fixed;
50 | cursor: var(--canvas-cursor, "default");
51 | }
52 |
53 | main.ui {
54 | position: fixed;
55 | inset: 0;
56 | z-index: 99 !important;
57 | pointer-events: none;
58 | padding: 20px;
59 |
60 | display: flex;
61 | flex-direction: column;
62 | justify-content: space-between;
63 | }
64 |
65 | main.ui header {
66 | display: flex;
67 | align-items: center;
68 | justify-content: space-between;
69 | }
70 | main.ui footer {
71 | display: flex;
72 | align-items: center;
73 | justify-content: space-between;
74 | gap: 10px;
75 | }
76 |
77 | main.ui footer > div {
78 | display: flex;
79 | gap: 10px;
80 | }
81 |
82 | html::before {
83 | content: "";
84 | position: absolute;
85 | top: 50%;
86 | transform: translateY(-50%);
87 | background: #d2d2d2;
88 | height: 1px;
89 | width: 100%;
90 | pointer-events: none;
91 | z-index: -1;
92 | }
93 | html::after {
94 | content: "";
95 | position: absolute;
96 | left: 50%;
97 | transform: translateX(-50%);
98 | background: #d2d2d2;
99 | width: 1px;
100 | height: 100%;
101 | pointer-events: none;
102 | z-index: -1;
103 | }
104 |
105 | section,
106 | .sectionStyle {
107 | pointer-events: all;
108 | width: fit-content;
109 |
110 | padding: 4px;
111 | border: 1px solid var(--secondary-background);
112 | border-radius: 8px;
113 | background: var(--background-50);
114 | backdrop-filter: blur(2px);
115 |
116 | box-shadow: 2px 2px 10px rgba(0, 0, 0, 0.1);
117 | }
118 |
119 | button:active {
120 | border: 1px solid rgb(71, 114, 180) !important;
121 | }
122 |
123 | section.toolbar {
124 | display: flex;
125 | }
126 |
127 | section.toolbar > div {
128 | --gap: 5px;
129 | display: flex;
130 | gap: var(--gap);
131 | }
132 |
133 | section.toolbar > div:not(:last-child)::after {
134 | content: "";
135 | position: relative;
136 | top: 0;
137 | left: 0;
138 | background: #c0c0c0;
139 | width: 1px;
140 | height: 60%;
141 | border-radius: 50%;
142 | margin: auto;
143 | margin-right: var(--gap);
144 | }
145 |
146 | section.toolbar .toolbutton {
147 | display: grid;
148 | place-content: center;
149 | color: var(--primary-color);
150 |
151 | width: 30px;
152 | height: 30px;
153 |
154 | background: none;
155 | border: none;
156 | border-radius: 4px;
157 | cursor: pointer;
158 | }
159 | section.toolbar .toolbutton:hover {
160 | background: rgba(224, 223, 255, 0.5);
161 | }
162 |
163 | section.toolbar .toolbutton.selected,
164 | section.toolbar .toolbutton.lock[data-lock="true"] {
165 | color: #030064;
166 | background: #e0dfffd5;
167 | }
168 |
169 | section.zoomOptions {
170 | display: flex;
171 | gap: 5px;
172 | }
173 |
174 | section.zoomOptions .zoom {
175 | display: grid;
176 | place-content: center;
177 | color: var(--primary-color);
178 |
179 | width: 30px;
180 | height: 30px;
181 |
182 | background: none;
183 | border: none;
184 | border-radius: 4px;
185 | cursor: pointer;
186 | font-size: 1.1em;
187 | font-weight: 900;
188 | }
189 |
190 | section.zoomOptions .zoom.text {
191 | width: fit-content;
192 | width: 4em !important;
193 | font-size: 15px;
194 | }
195 |
196 | section.styleOptions {
197 | position: fixed;
198 | top: 80px;
199 | left: 20px;
200 | padding: 15px;
201 |
202 | overflow: auto;
203 | max-height: calc(100vh - 140px);
204 | display: flex;
205 | flex-direction: column;
206 | gap: 10px;
207 | }
208 |
209 | section.styleOptions::-webkit-scrollbar {
210 | width: 3px;
211 | }
212 | section.styleOptions::-webkit-scrollbar-thumb {
213 | background: #ced4da;
214 | border-radius: 100px;
215 | }
216 |
217 | section.styleOptions .group p {
218 | font-size: 12px;
219 | font-family: secondaryFont;
220 | color: var(--primary-color);
221 | margin-bottom: 5px;
222 | }
223 |
224 | section.styleOptions .group .innerGroup {
225 | display: flex;
226 | align-items: center;
227 | gap: 5px;
228 | }
229 |
230 | section.styleOptions .group .innerGroup .itemButton {
231 | position: relative;
232 | border-radius: 5px;
233 | box-sizing: content-box;
234 | cursor: pointer;
235 | color: var(--primary-color);
236 |
237 | display: grid;
238 | place-content: center;
239 | }
240 |
241 | section.styleOptions .group .innerGroup .itemButton.color {
242 | border: 1px solid #81818179;
243 | background: var(--color);
244 | width: 20px;
245 | height: 20px;
246 | }
247 |
248 | section.styleOptions .group .innerGroup .itemButton.color.selected::before {
249 | content: "";
250 | position: absolute;
251 | inset: 0;
252 | top: 0;
253 | left: 0;
254 | z-index: 55;
255 | border: 1px solid rgb(71, 114, 180);
256 | border-radius: 5px;
257 | scale: 1.3;
258 | }
259 |
260 | section.styleOptions .group .innerGroup .itemButton.option.selected {
261 | color: #0f0c69;
262 | background: #c3c1ebd5;
263 | }
264 |
265 | section.styleOptions .group .innerGroup .itemButton.option {
266 | border: 1px solid #bbc0c779;
267 | background: #e4e8ea;
268 | width: 30px;
269 | height: 30px;
270 | }
271 |
272 | section.undoRedo {
273 | display: flex;
274 | align-items: center;
275 | justify-content: center;
276 | gap: 5px;
277 | }
278 |
279 | section.undoRedo > button {
280 | display: grid;
281 | place-content: center;
282 | color: var(--primary-color);
283 |
284 | width: 30px;
285 | height: 30px;
286 |
287 | background: none;
288 | border: none;
289 | border-radius: 4px;
290 | cursor: pointer;
291 | }
292 |
293 | div.menu {
294 | position: relative;
295 | pointer-events: all;
296 | }
297 |
298 | div.menu .menuBtn {
299 | display: grid;
300 | place-content: center;
301 | color: #808080;
302 | border-radius: 10px;
303 | width: 40px;
304 | height: 40px;
305 | cursor: pointer;
306 | }
307 |
308 | div.menu .menuItems {
309 | position: absolute;
310 | background: var(--background);
311 | margin-top: 10px;
312 | z-index: 9999;
313 | padding: 7px;
314 | width: 250px;
315 | display: flex;
316 | flex-direction: column;
317 | }
318 |
319 | div.menu .menuItems .menuItem * {
320 | pointer-events: none;
321 | }
322 |
323 | div.menu .menuItems .menuItem {
324 | padding: 10px 10px;
325 | display: flex;
326 | gap: 7px;
327 | border: none;
328 | background: none;
329 | cursor: pointer;
330 | border-radius: 5px;
331 | font-family: secondaryFont;
332 |
333 | color: #373737;
334 | }
335 |
336 | div.menu .menuItems .menuItem:hover {
337 | background: var(--secondary-background);
338 | }
339 |
340 | .menuBlur {
341 | position: fixed;
342 | width: 100vw;
343 | height: 100vh;
344 | z-index: 9998;
345 | top: 0;
346 | left: 0;
347 | }
348 |
349 | div.collaboration {
350 | pointer-events: all;
351 | z-index: 999;
352 | }
353 |
354 | .collaborateButton {
355 | position: relative;
356 | padding: 0 20px;
357 | height: 40px;
358 | border: 1px solid #6965db;
359 | background: rgba(73, 67, 243, 0.5);
360 | color: #ffffff;
361 | font-family: secondaryFont;
362 | cursor: pointer;
363 | }
364 |
365 | .collaborateButton.active {
366 | border: 1px solid #54b435;
367 | background: rgba(97, 226, 54, 0.5);
368 | }
369 |
370 | /* .collaborateButton.active::before {
371 | content: attr(data-users);
372 | position: absolute;
373 | top: 0;
374 | right: 0;
375 | transform: translate(50%, -50%);
376 | width: 19px;
377 | height: 19px;
378 | border-radius: 50%;
379 |
380 | display: grid;
381 | place-content: center;
382 |
383 | font-size: 0.65em;
384 |
385 | background: #73df4f;
386 | box-shadow: 2px 2px 5px rgba(0, 0, 0, 0.303);
387 | } */
388 |
389 | section.collaborationBox {
390 | position: relative;
391 | padding: 20px;
392 | z-index: 99993;
393 |
394 | width: calc(200px + 31vw);
395 | display: flex;
396 | justify-content: center;
397 | align-items: center;
398 | background: white;
399 | }
400 |
401 | div.collaborationBoxBack {
402 | position: fixed;
403 | inset: 0;
404 | background: #00000061;
405 | z-index: 99992;
406 | }
407 |
408 | div.collaborationContainer {
409 | position: fixed;
410 | inset: 0;
411 | display: grid;
412 | place-content: center;
413 | }
414 |
415 | .closeCollbBox {
416 | position: absolute;
417 | top: 10px;
418 | right: 10px;
419 | display: grid;
420 | place-content: center;
421 | padding-bottom: 1px;
422 | border-radius: 5px;
423 | width: 30px;
424 | height: 30px;
425 | background: none;
426 | border: none;
427 | color: #454545;
428 | cursor: pointer;
429 | transition: 0.3s;
430 | }
431 |
432 | .closeCollbBox:hover {
433 | background-color: #8b8b8b5a;
434 | }
435 |
436 | .collabCreate,
437 | .collabInfo {
438 | display: flex;
439 | flex-direction: column;
440 | align-items: center;
441 | justify-content: center;
442 | gap: 10px;
443 | }
444 |
445 | section.collaborationBox h2 {
446 | text-align: center;
447 | font-size: 25px;
448 | color: #6965db;
449 | }
450 |
451 | .collabCreate p {
452 | font-family: secondaryFont;
453 | color: #454545;
454 | font-size: 14px;
455 | text-align: center;
456 | margin-bottom: 5px;
457 | }
458 |
459 | .collabCreate button {
460 | font-family: secondaryFont;
461 | padding: 10px 20px;
462 | background: #6965db;
463 | border: 1px solid #6965db;
464 | border-radius: 7px;
465 | color: #ffffff;
466 | font-size: 14px;
467 | cursor: pointer;
468 | }
469 |
470 | @media (max-width: 600px) {
471 | section.collaborationBox {
472 | width: 100vw;
473 | height: 100vh;
474 | border: none;
475 | border-radius: 0;
476 | }
477 | }
478 |
479 | .collabGroup {
480 | width: 100%;
481 | }
482 |
483 | .collabLink {
484 | display: flex;
485 | justify-content: space-between;
486 | gap: 5px;
487 | width: 100%;
488 | }
489 |
490 | .collabLink button {
491 | font-family: secondaryFont;
492 | padding: 10px 15px;
493 | background: #6965db;
494 | border: 1px solid #6965db;
495 | border-radius: 7px;
496 | color: #ffffff;
497 | font-size: 12px;
498 | cursor: pointer;
499 | }
500 |
501 | .collabLink input {
502 | font-family: secondaryFont;
503 | padding: 10px 15px;
504 | border: 1px solid #5c5c5c;
505 | border-radius: 7px;
506 | color: #494949;
507 | font-size: 12px;
508 | flex-grow: 1;
509 | }
510 |
511 | .collabInfo {
512 | width: 100%;
513 | }
514 |
515 | .collabGroup label {
516 | display: block;
517 | font-size: 15px;
518 | color: var(--primary-color);
519 | margin-bottom: 5px;
520 | }
521 |
522 | .endCollab button {
523 | font-family: secondaryFont;
524 | padding: 10px 20px;
525 | background: none;
526 | border: 1px solid #d72727;
527 | border-radius: 7px;
528 | color: #d72727;
529 | font-size: 14px;
530 | cursor: pointer;
531 | }
532 |
533 | section.credits {
534 | padding: 0;
535 | }
536 |
537 | #credits {
538 | display: flex;
539 | justify-content: center;
540 | align-items: center;
541 | gap: 10px;
542 | padding: 10px 20px;
543 | color: var(--primary-color);
544 | text-decoration: none;
545 | font-size: 13px;
546 | }
547 |
548 | #credits:hover {
549 | text-decoration: underline;
550 | }
551 |
552 | textarea.textBox {
553 | all: unset;
554 | width: 50px;
555 | height: 30px;
556 | position: absolute;
557 | font: 30px Arial;
558 | overflow: hidden;
559 | white-space: pre;
560 | /* outline: none;
561 | border: none;
562 | background: none;
563 | resize: none; */
564 | }
565 |
--------------------------------------------------------------------------------
/client/src/assets/icons/index.jsx:
--------------------------------------------------------------------------------
1 | const demention = 15;
2 |
3 | export const Pencil = () => (
4 |