47 |
50 |
Choose background
51 |
52 |
85 |
86 | );
87 | };
88 |
89 | export default BackgroundModal;
90 |
--------------------------------------------------------------------------------
/modules/room/modules/board/components/Minimap.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useMemo, useRef, useState } from 'react';
2 |
3 | import { motion, useMotionValue } from 'framer-motion';
4 |
5 | import { CANVAS_SIZE } from '@/common/constants/canvasSize';
6 | import { useViewportSize } from '@/common/hooks/useViewportSize';
7 |
8 | import { useRefs } from '../../../hooks/useRefs';
9 | import { useBoardPosition } from '../hooks/useBoardPosition';
10 |
11 | const MiniMap = ({ dragging }: { dragging: boolean }) => {
12 | const { minimapRef } = useRefs();
13 | const boardPos = useBoardPosition();
14 | const { width, height } = useViewportSize();
15 |
16 | const [x, setX] = useState(0);
17 | const [y, setY] = useState(0);
18 |
19 | const [draggingMinimap, setDraggingMinimap] = useState(false);
20 |
21 | useEffect(() => {
22 | if (!draggingMinimap) {
23 | const unsubscribe = boardPos.x.onChange(setX);
24 | return unsubscribe;
25 | }
26 |
27 | return () => {};
28 | }, [boardPos.x, draggingMinimap]);
29 |
30 | useEffect(() => {
31 | if (!draggingMinimap) {
32 | const unsubscribe = boardPos.y.onChange(setY);
33 | return unsubscribe;
34 | }
35 |
36 | return () => {};
37 | }, [boardPos.y, draggingMinimap]);
38 |
39 | const containerRef = useRef(null);
40 |
41 | const miniX = useMotionValue(0);
42 | const miniY = useMotionValue(0);
43 |
44 | const divider = useMemo(() => {
45 | if (width > 1600) return 7;
46 | if (width > 1000) return 10;
47 | if (width > 600) return 14;
48 | return 20;
49 | }, [width]);
50 |
51 | useEffect(() => {
52 | miniX.onChange((newX) => {
53 | if (!dragging) boardPos.x.set(Math.floor(-newX * divider));
54 | });
55 | miniY.onChange((newY) => {
56 | if (!dragging) boardPos.y.set(Math.floor(-newY * divider));
57 | });
58 |
59 | return () => {
60 | miniX.clearListeners();
61 | miniY.clearListeners();
62 | };
63 | }, [boardPos.x, boardPos.y, divider, dragging, miniX, miniY]);
64 |
65 | return (
66 |
74 |
80 | setDraggingMinimap(true)}
86 | onDragEnd={() => setDraggingMinimap(false)}
87 | className="absolute top-0 left-0 cursor-grab rounded-lg border-2 border-red-500"
88 | style={{
89 | width: width / divider,
90 | height: height / divider,
91 | x: miniX,
92 | y: miniY,
93 | }}
94 | animate={{ x: -x / divider, y: -y / divider }}
95 | transition={{ duration: 0 }}
96 | />
97 |
98 | );
99 | };
100 |
101 | export default MiniMap;
102 |
--------------------------------------------------------------------------------
/common/recoil/room/room.hooks.ts:
--------------------------------------------------------------------------------
1 | import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil';
2 |
3 | import { getNextColor } from '@/common/lib/getNextColor';
4 | import { Move } from '@/common/types/global';
5 |
6 | import { DEFAULT_ROOM, roomAtom } from './room.atom';
7 |
8 | export const useRoom = () => {
9 | const room = useRecoilValue(roomAtom);
10 |
11 | return room;
12 | };
13 |
14 | export const useSetRoom = () => {
15 | const setRoom = useSetRecoilState(roomAtom);
16 |
17 | return setRoom;
18 | };
19 |
20 | export const useSetRoomId = () => {
21 | const setRoomId = useSetRecoilState(roomAtom);
22 |
23 | const handleSetRoomId = (id: string) => {
24 | setRoomId({ ...DEFAULT_ROOM, id });
25 | };
26 |
27 | return handleSetRoomId;
28 | };
29 |
30 | export const useSetUsers = () => {
31 | const setRoom = useSetRecoilState(roomAtom);
32 |
33 | const handleAddUser = (userId: string, name: string) => {
34 | setRoom((prev) => {
35 | const newUsers = prev.users;
36 | const newUsersMoves = prev.usersMoves;
37 |
38 | const color = getNextColor([...newUsers.values()].pop()?.color);
39 |
40 | newUsers.set(userId, {
41 | name,
42 | color,
43 | });
44 | newUsersMoves.set(userId, []);
45 |
46 | return { ...prev, users: newUsers, usersMoves: newUsersMoves };
47 | });
48 | };
49 |
50 | const handleRemoveUser = (userId: string) => {
51 | setRoom((prev) => {
52 | const newUsers = prev.users;
53 | const newUsersMoves = prev.usersMoves;
54 |
55 | const userMoves = newUsersMoves.get(userId);
56 |
57 | newUsers.delete(userId);
58 | newUsersMoves.delete(userId);
59 | return {
60 | ...prev,
61 | users: newUsers,
62 | usersMoves: newUsersMoves,
63 | movesWithoutUser: [...prev.movesWithoutUser, ...(userMoves || [])],
64 | };
65 | });
66 | };
67 |
68 | const handleAddMoveToUser = (userId: string, moves: Move) => {
69 | setRoom((prev) => {
70 | const newUsersMoves = prev.usersMoves;
71 | const oldMoves = prev.usersMoves.get(userId);
72 |
73 | newUsersMoves.set(userId, [...(oldMoves || []), moves]);
74 | return { ...prev, usersMoves: newUsersMoves };
75 | });
76 | };
77 |
78 | const handleRemoveMoveFromUser = (userId: string) => {
79 | setRoom((prev) => {
80 | const newUsersMoves = prev.usersMoves;
81 | const oldMoves = prev.usersMoves.get(userId);
82 | oldMoves?.pop();
83 |
84 | newUsersMoves.set(userId, oldMoves || []);
85 | return { ...prev, usersMoves: newUsersMoves };
86 | });
87 | };
88 |
89 | return {
90 | handleAddUser,
91 | handleRemoveUser,
92 | handleAddMoveToUser,
93 | handleRemoveMoveFromUser,
94 | };
95 | };
96 |
97 | export const useMyMoves = () => {
98 | const [room, setRoom] = useRecoilState(roomAtom);
99 |
100 | const handleAddMyMove = (move: Move) => {
101 | setRoom((prev) => {
102 | if (prev.myMoves[prev.myMoves.length - 1]?.options.mode === 'select')
103 | return {
104 | ...prev,
105 | myMoves: [...prev.myMoves.slice(0, prev.myMoves.length - 1), move],
106 | };
107 |
108 | return { ...prev, myMoves: [...prev.myMoves, move] };
109 | });
110 | };
111 |
112 | const handleRemoveMyMove = () => {
113 | const newMoves = [...room.myMoves];
114 | const move = newMoves.pop();
115 |
116 | setRoom((prev) => ({ ...prev, myMoves: newMoves }));
117 |
118 | return move;
119 | };
120 |
121 | return { handleAddMyMove, handleRemoveMyMove, myMoves: room.myMoves };
122 | };
123 |
--------------------------------------------------------------------------------
/modules/home/components/Home.tsx:
--------------------------------------------------------------------------------
1 | import { FormEvent, useEffect, useState } from 'react';
2 |
3 | import { useRouter } from 'next/router';
4 |
5 | import { socket } from '@/common/lib/socket';
6 | import { useSetRoomId } from '@/common/recoil/room';
7 | import { useModal } from '@/modules/modal';
8 |
9 | import NotFoundModal from '../modals/NotFound';
10 |
11 | const Home = () => {
12 | const { openModal } = useModal();
13 | const setAtomRoomId = useSetRoomId();
14 |
15 | const [roomId, setRoomId] = useState('');
16 | const [username, setUsername] = useState('');
17 |
18 | const router = useRouter();
19 |
20 | useEffect(() => {
21 | document.body.style.backgroundColor = 'white';
22 | }, []);
23 |
24 | useEffect(() => {
25 | socket.on('created', (roomIdFromServer) => {
26 | setAtomRoomId(roomIdFromServer);
27 | router.push(roomIdFromServer);
28 | });
29 |
30 | const handleJoinedRoom = (roomIdFromServer: string, failed?: boolean) => {
31 | if (!failed) {
32 | setAtomRoomId(roomIdFromServer);
33 | router.push(roomIdFromServer);
34 | } else {
35 | openModal();
36 | }
37 | };
38 |
39 | socket.on('joined', handleJoinedRoom);
40 |
41 | return () => {
42 | socket.off('created');
43 | socket.off('joined', handleJoinedRoom);
44 | };
45 | }, [openModal, roomId, router, setAtomRoomId]);
46 |
47 | useEffect(() => {
48 | socket.emit('leave_room');
49 | setAtomRoomId('');
50 | }, [setAtomRoomId]);
51 |
52 | const handleCreateRoom = () => {
53 | socket.emit('create_room', username);
54 | };
55 |
56 | const handleJoinRoom = (e: FormEvent) => {
57 | e.preventDefault();
58 |
59 | if (roomId) socket.emit('join_room', roomId, username);
60 | };
61 |
62 | return (
63 |
64 |
65 | Collabio
66 |
67 |
Real-time whiteboard
68 |
69 |
70 |
73 | setUsername(e.target.value.slice(0, 15))}
79 | />
80 |
81 |
82 |
83 |
84 |
102 |
103 |
104 |
105 |
or
106 |
107 |
108 |
109 |
110 |
Create new room
111 |
112 |
115 |
116 |
117 | );
118 | };
119 |
120 | export default Home;
121 |
--------------------------------------------------------------------------------
/modules/room/modules/toolbar/components/ToolBar.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react';
2 |
3 | import { motion } from 'framer-motion';
4 | import { useRouter } from 'next/router';
5 | import { FiChevronRight } from 'react-icons/fi';
6 | import { HiOutlineDownload } from 'react-icons/hi';
7 | import { ImExit } from 'react-icons/im';
8 | import { IoIosShareAlt } from 'react-icons/io';
9 |
10 | import { CANVAS_SIZE } from '@/common/constants/canvasSize';
11 | import { useViewportSize } from '@/common/hooks/useViewportSize';
12 | import { useModal } from '@/modules/modal';
13 |
14 | import { useRefs } from '../../../hooks/useRefs';
15 | import ShareModal from '../modals/ShareModal';
16 | import BackgroundPicker from './BackgoundPicker';
17 | import ColorPicker from './ColorPicker';
18 | import HistoryBtns from './HistoryBtns';
19 | import ImagePicker from './ImagePicker';
20 | import LineWidthPicker from './LineWidthPicker';
21 | import ModePicker from './ModePicker';
22 | import ShapeSelector from './ShapeSelector';
23 |
24 | const ToolBar = () => {
25 | const { canvasRef, bgRef } = useRefs();
26 | const { openModal } = useModal();
27 | const { width } = useViewportSize();
28 |
29 | const [opened, setOpened] = useState(false);
30 |
31 | const router = useRouter();
32 |
33 | useEffect(() => {
34 | if (width >= 1024) setOpened(true);
35 | else setOpened(false);
36 | }, [width]);
37 |
38 | const handleExit = () => router.push('/');
39 |
40 | const handleDownload = () => {
41 | const canvas = document.createElement('canvas');
42 | canvas.width = CANVAS_SIZE.width;
43 | canvas.height = CANVAS_SIZE.height;
44 |
45 | const tempCtx = canvas.getContext('2d');
46 |
47 | if (tempCtx && canvasRef.current && bgRef.current) {
48 | tempCtx.drawImage(bgRef.current, 0, 0);
49 | tempCtx.drawImage(canvasRef.current, 0, 0);
50 | }
51 |
52 | const link = document.createElement('a');
53 | link.href = canvas.toDataURL('image/png');
54 | link.download = 'canvas.png';
55 | link.click();
56 | };
57 |
58 | const handleShare = () => openModal();
59 |
60 | return (
61 | <>
62 | setOpened(!opened)}
67 | >
68 |
69 |
70 |
80 |
81 |
82 |
83 |
84 |
85 |
86 |
87 |
88 |
89 |
90 |
91 |
92 |
93 |
94 |
95 |
96 |
99 |
102 |
105 |
106 | >
107 | );
108 | };
109 |
110 | export default ToolBar;
111 |
--------------------------------------------------------------------------------
/modules/room/context/Room.context.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | createContext,
3 | Dispatch,
4 | ReactChild,
5 | RefObject,
6 | SetStateAction,
7 | useEffect,
8 | useRef,
9 | useState,
10 | } from 'react';
11 |
12 | import { MotionValue, useMotionValue } from 'framer-motion';
13 | import { toast } from 'react-toastify';
14 |
15 | import { COLORS_ARRAY } from '@/common/constants/colors';
16 | import { socket } from '@/common/lib/socket';
17 | import { useSetUsers } from '@/common/recoil/room';
18 | import { useSetRoom, useRoom } from '@/common/recoil/room/room.hooks';
19 | import { Move, User } from '@/common/types/global';
20 |
21 | export const roomContext = createContext<{
22 | x: MotionValue;
23 | y: MotionValue;
24 | undoRef: RefObject;
25 | redoRef: RefObject;
26 | canvasRef: RefObject;
27 | bgRef: RefObject;
28 | selectionRefs: RefObject;
29 | minimapRef: RefObject;
30 | moveImage: { base64: string; x?: number; y?: number };
31 | setMoveImage: Dispatch<
32 | SetStateAction<{
33 | base64: string;
34 | x?: number | undefined;
35 | y?: number | undefined;
36 | }>
37 | >;
38 | }>(null!);
39 |
40 | const RoomContextProvider = ({ children }: { children: ReactChild }) => {
41 | const setRoom = useSetRoom();
42 | const { users } = useRoom();
43 | const { handleAddUser, handleRemoveUser } = useSetUsers();
44 |
45 | const undoRef = useRef(null);
46 | const redoRef = useRef(null);
47 | const canvasRef = useRef(null);
48 | const bgRef = useRef(null);
49 | const minimapRef = useRef(null);
50 | const selectionRefs = useRef([]);
51 |
52 | const [moveImage, setMoveImage] = useState<{
53 | base64: string;
54 | x?: number;
55 | y?: number;
56 | }>({ base64: '' });
57 |
58 | useEffect(() => {
59 | if (moveImage.base64 && !moveImage.x && !moveImage.y)
60 | setMoveImage({ base64: moveImage.base64, x: 50, y: 50 });
61 | }, [moveImage]);
62 |
63 | const x = useMotionValue(0);
64 | const y = useMotionValue(0);
65 |
66 | useEffect(() => {
67 | socket.on('room', (room, usersMovesToParse, usersToParse) => {
68 | const usersMoves = new Map(JSON.parse(usersMovesToParse));
69 | const usersParsed = new Map(JSON.parse(usersToParse));
70 |
71 | const newUsers = new Map();
72 |
73 | usersParsed.forEach((name, id) => {
74 | if (id === socket.id) return;
75 |
76 | const index = [...usersParsed.keys()].indexOf(id);
77 |
78 | const color = COLORS_ARRAY[index % COLORS_ARRAY.length];
79 |
80 | newUsers.set(id, {
81 | name,
82 | color,
83 | });
84 | });
85 |
86 | setRoom((prev) => ({
87 | ...prev,
88 | users: newUsers,
89 | usersMoves,
90 | movesWithoutUser: room.drawed,
91 | }));
92 | });
93 |
94 | socket.on('new_user', (userId, username) => {
95 | toast(`${username} has joined the room.`, {
96 | position: 'top-center',
97 | theme: 'colored',
98 | });
99 |
100 | handleAddUser(userId, username);
101 | });
102 |
103 | socket.on('user_disconnected', (userId) => {
104 | toast(`${users.get(userId)?.name || 'Anonymous'} has left the room.`, {
105 | position: 'top-center',
106 | theme: 'colored',
107 | });
108 |
109 | handleRemoveUser(userId);
110 | });
111 |
112 | return () => {
113 | socket.off('room');
114 | socket.off('new_user');
115 | socket.off('user_disconnected');
116 | };
117 | }, [handleAddUser, handleRemoveUser, setRoom, users]);
118 |
119 | return (
120 |
134 | {children}
135 |
136 | );
137 | };
138 |
139 | export default RoomContextProvider;
140 |
--------------------------------------------------------------------------------
/modules/room/modules/board/components/Canvas.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react';
2 |
3 | import { motion, useDragControls } from 'framer-motion';
4 | import { BsArrowsMove } from 'react-icons/bs';
5 |
6 | import { CANVAS_SIZE } from '@/common/constants/canvasSize';
7 | import { useViewportSize } from '@/common/hooks/useViewportSize';
8 | import { socket } from '@/common/lib/socket';
9 |
10 | import { useMovesHandlers } from '../../../hooks/useMovesHandlers';
11 | import { useRefs } from '../../../hooks/useRefs';
12 | import { useBoardPosition } from '../hooks/useBoardPosition';
13 | import { useCtx } from '../hooks/useCtx';
14 | import { useDraw } from '../hooks/useDraw';
15 | import { useSocketDraw } from '../hooks/useSocketDraw';
16 | import Background from './Background';
17 | import MiniMap from './Minimap';
18 |
19 | const Canvas = () => {
20 | const { canvasRef, bgRef, undoRef, redoRef } = useRefs();
21 | const { width, height } = useViewportSize();
22 | const { x, y } = useBoardPosition();
23 | const ctx = useCtx();
24 |
25 | const [dragging, setDragging] = useState(true);
26 |
27 | const {
28 | handleEndDrawing,
29 | handleDraw,
30 | handleStartDrawing,
31 | drawing,
32 | clearOnYourMove,
33 | } = useDraw(dragging);
34 | useSocketDraw(drawing);
35 |
36 | const { handleUndo, handleRedo } = useMovesHandlers(clearOnYourMove);
37 |
38 | const dragControls = useDragControls();
39 |
40 | useEffect(() => {
41 | setDragging(false);
42 | }, []);
43 |
44 | // SETUP
45 | useEffect(() => {
46 | const undoBtn = undoRef.current;
47 | const redoBtn = redoRef.current;
48 |
49 | undoBtn?.addEventListener('click', handleUndo);
50 | redoBtn?.addEventListener('click', handleRedo);
51 |
52 | return () => {
53 | undoBtn?.removeEventListener('click', handleUndo);
54 | redoBtn?.removeEventListener('click', handleRedo);
55 | };
56 | }, [canvasRef, dragging, handleRedo, handleUndo, redoRef, undoRef]);
57 |
58 | useEffect(() => {
59 | if (ctx) socket.emit('joined_room');
60 | }, [ctx]);
61 |
62 | return (
63 |
64 | {
84 | e.preventDefault();
85 | e.stopPropagation();
86 | }}
87 | onMouseDown={(e) => {
88 | if (e.button === 2) {
89 | setDragging(true);
90 | dragControls.start(e);
91 | } else handleStartDrawing(e.clientX, e.clientY);
92 | }}
93 | onMouseUp={(e) => {
94 | if (e.button === 2) setDragging(false);
95 | else handleEndDrawing();
96 | }}
97 | onMouseMove={(e) => {
98 | handleDraw(e.clientX, e.clientY, e.shiftKey);
99 | }}
100 | onTouchStart={(e) =>
101 | handleStartDrawing(
102 | e.changedTouches[0].clientX,
103 | e.changedTouches[0].clientY
104 | )
105 | }
106 | onTouchEnd={handleEndDrawing}
107 | onTouchMove={(e) =>
108 | handleDraw(e.changedTouches[0].clientX, e.changedTouches[0].clientY)
109 | }
110 | />
111 |
112 |
113 |
114 |
115 |
123 |
124 | );
125 | };
126 |
127 | export default Canvas;
128 |
--------------------------------------------------------------------------------
/server/index.ts:
--------------------------------------------------------------------------------
1 | import { createServer } from 'http';
2 |
3 | import express from 'express';
4 | import next, { NextApiHandler } from 'next';
5 | import { Server } from 'socket.io';
6 | import { v4 } from 'uuid';
7 |
8 | import {
9 | ClientToServerEvents,
10 | Move,
11 | Room,
12 | ServerToClientEvents,
13 | } from '@/common/types/global';
14 |
15 | const port = parseInt(process.env.PORT || '3000', 10);
16 | const dev = process.env.NODE_ENV !== 'production';
17 | const nextApp = next({ dev });
18 | const nextHandler: NextApiHandler = nextApp.getRequestHandler();
19 |
20 | nextApp.prepare().then(async () => {
21 | const app = express();
22 | const server = createServer(app);
23 |
24 | const io = new Server(server);
25 |
26 | app.get('/hello', async (_, res) => {
27 | res.send('Hello World');
28 | });
29 |
30 | const rooms = new Map();
31 |
32 | const addMove = (roomId: string, socketId: string, move: Move) => {
33 | const room = rooms.get(roomId)!;
34 |
35 | if (!room.users.has(socketId)) {
36 | room.usersMoves.set(socketId, [move]);
37 | }
38 |
39 | room.usersMoves.get(socketId)!.push(move);
40 | };
41 |
42 | const undoMove = (roomId: string, socketId: string) => {
43 | const room = rooms.get(roomId)!;
44 |
45 | room.usersMoves.get(socketId)!.pop();
46 | };
47 |
48 | io.on('connection', (socket) => {
49 | const getRoomId = () => {
50 | const joinedRoom = [...socket.rooms].find((room) => room !== socket.id);
51 |
52 | if (!joinedRoom) return socket.id;
53 |
54 | return joinedRoom;
55 | };
56 |
57 | const leaveRoom = (roomId: string, socketId: string) => {
58 | const room = rooms.get(roomId);
59 | if (!room) return;
60 |
61 | const userMoves = room.usersMoves.get(socketId);
62 |
63 | if (userMoves) room.drawed.push(...userMoves);
64 | room.users.delete(socketId);
65 |
66 | socket.leave(roomId);
67 | };
68 |
69 | socket.on('create_room', (username) => {
70 | let roomId: string;
71 | do {
72 | roomId = Math.random().toString(36).substring(2, 6);
73 | } while (rooms.has(roomId));
74 |
75 | socket.join(roomId);
76 |
77 | rooms.set(roomId, {
78 | usersMoves: new Map([[socket.id, []]]),
79 | drawed: [],
80 | users: new Map([[socket.id, username]]),
81 | });
82 |
83 | io.to(socket.id).emit('created', roomId);
84 | });
85 |
86 | socket.on('check_room', (roomId) => {
87 | if (rooms.has(roomId)) socket.emit('room_exists', true);
88 | else socket.emit('room_exists', false);
89 | });
90 |
91 | socket.on('join_room', (roomId, username) => {
92 | const room = rooms.get(roomId);
93 |
94 | if (room && room.users.size < 12) {
95 | socket.join(roomId);
96 |
97 | room.users.set(socket.id, username);
98 | room.usersMoves.set(socket.id, []);
99 |
100 | io.to(socket.id).emit('joined', roomId);
101 | } else io.to(socket.id).emit('joined', '', true);
102 | });
103 |
104 | socket.on('joined_room', () => {
105 | const roomId = getRoomId();
106 |
107 | const room = rooms.get(roomId);
108 | if (!room) return;
109 |
110 | io.to(socket.id).emit(
111 | 'room',
112 | room,
113 | JSON.stringify([...room.usersMoves]),
114 | JSON.stringify([...room.users])
115 | );
116 |
117 | socket.broadcast
118 | .to(roomId)
119 | .emit('new_user', socket.id, room.users.get(socket.id) || 'Anonymous');
120 | });
121 |
122 | socket.on('leave_room', () => {
123 | const roomId = getRoomId();
124 | leaveRoom(roomId, socket.id);
125 |
126 | io.to(roomId).emit('user_disconnected', socket.id);
127 | });
128 |
129 | socket.on('draw', (move) => {
130 | const roomId = getRoomId();
131 |
132 | const timestamp = Date.now();
133 |
134 | // eslint-disable-next-line no-param-reassign
135 | move.id = v4();
136 |
137 | addMove(roomId, socket.id, { ...move, timestamp });
138 |
139 | io.to(socket.id).emit('your_move', { ...move, timestamp });
140 |
141 | socket.broadcast
142 | .to(roomId)
143 | .emit('user_draw', { ...move, timestamp }, socket.id);
144 | });
145 |
146 | socket.on('undo', () => {
147 | const roomId = getRoomId();
148 |
149 | undoMove(roomId, socket.id);
150 |
151 | socket.broadcast.to(roomId).emit('user_undo', socket.id);
152 | });
153 |
154 | socket.on('mouse_move', (x, y) => {
155 | socket.broadcast.to(getRoomId()).emit('mouse_moved', x, y, socket.id);
156 | });
157 |
158 | socket.on('send_msg', (msg) => {
159 | io.to(getRoomId()).emit('new_msg', socket.id, msg);
160 | });
161 |
162 | socket.on('disconnecting', () => {
163 | const roomId = getRoomId();
164 | leaveRoom(roomId, socket.id);
165 |
166 | io.to(roomId).emit('user_disconnected', socket.id);
167 | });
168 | });
169 |
170 | app.all('*', (req: any, res: any) => nextHandler(req, res));
171 |
172 | server.listen(port, () => {
173 | // eslint-disable-next-line no-console
174 | console.log(`> Ready on http://localhost:${port}`);
175 | });
176 | });
177 |
--------------------------------------------------------------------------------
/modules/room/modules/board/hooks/useDraw.ts:
--------------------------------------------------------------------------------
1 | import { useState } from 'react';
2 |
3 | import { DEFAULT_MOVE } from '@/common/constants/defaultMove';
4 | import { useViewportSize } from '@/common/hooks/useViewportSize';
5 | import { getPos } from '@/common/lib/getPos';
6 | import { getStringFromRgba } from '@/common/lib/rgba';
7 | import { socket } from '@/common/lib/socket';
8 | import { useOptionsValue } from '@/common/recoil/options';
9 | import { useSetSelection } from '@/common/recoil/options/options.hooks';
10 | import { useMyMoves } from '@/common/recoil/room';
11 | import { useSetSavedMoves } from '@/common/recoil/savedMoves';
12 | import { Move } from '@/common/types/global';
13 |
14 | import { drawRect, drawCircle, drawLine } from '../helpers/Canvas.helpers';
15 | import { useBoardPosition } from './useBoardPosition';
16 | import { useCtx } from './useCtx';
17 |
18 | let tempMoves: [number, number][] = [];
19 | let tempCircle = { cX: 0, cY: 0, radiusX: 0, radiusY: 0 };
20 | let tempSize = { width: 0, height: 0 };
21 | let tempImageData: ImageData | undefined;
22 |
23 | export const useDraw = (blocked: boolean) => {
24 | const options = useOptionsValue();
25 | const boardPosition = useBoardPosition();
26 | const { clearSavedMoves } = useSetSavedMoves();
27 | const { handleAddMyMove } = useMyMoves();
28 | const { setSelection, clearSelection } = useSetSelection();
29 | const vw = useViewportSize();
30 |
31 | const movedX = boardPosition.x;
32 | const movedY = boardPosition.y;
33 |
34 | const [drawing, setDrawing] = useState(false);
35 | const ctx = useCtx();
36 |
37 | const setupCtxOptions = () => {
38 | if (ctx) {
39 | ctx.lineWidth = options.lineWidth;
40 | ctx.strokeStyle = getStringFromRgba(options.lineColor);
41 | ctx.fillStyle = getStringFromRgba(options.fillColor);
42 | if (options.mode === 'eraser')
43 | ctx.globalCompositeOperation = 'destination-out';
44 | else ctx.globalCompositeOperation = 'source-over';
45 | }
46 | };
47 |
48 | const drawAndSet = () => {
49 | if (!tempImageData)
50 | tempImageData = ctx?.getImageData(
51 | movedX.get() * -1,
52 | movedY.get() * -1,
53 | vw.width,
54 | vw.height
55 | );
56 |
57 | if (tempImageData)
58 | ctx?.putImageData(tempImageData, movedX.get() * -1, movedY.get() * -1);
59 | };
60 |
61 | const handleStartDrawing = (x: number, y: number) => {
62 | if (!ctx || blocked || blocked) return;
63 |
64 | const [finalX, finalY] = [getPos(x, movedX), getPos(y, movedY)];
65 |
66 | setDrawing(true);
67 | setupCtxOptions();
68 | drawAndSet();
69 |
70 | if (options.shape === 'line' && options.mode !== 'select') {
71 | ctx.beginPath();
72 | ctx.lineTo(finalX, finalY);
73 | ctx.stroke();
74 | }
75 |
76 | tempMoves.push([finalX, finalY]);
77 | };
78 |
79 | const handleDraw = (x: number, y: number, shift?: boolean) => {
80 | if (!ctx || !drawing || blocked) return;
81 |
82 | const [finalX, finalY] = [getPos(x, movedX), getPos(y, movedY)];
83 |
84 | drawAndSet();
85 |
86 | if (options.mode === 'select') {
87 | ctx.fillStyle = 'rgba(0, 0, 0, 0.2)';
88 | drawRect(ctx, tempMoves[0], finalX, finalY, false, true);
89 | tempMoves.push([finalX, finalY]);
90 |
91 | setupCtxOptions();
92 |
93 | return;
94 | }
95 |
96 | switch (options.shape) {
97 | case 'line':
98 | if (shift) tempMoves = tempMoves.slice(0, 1);
99 |
100 | drawLine(ctx, tempMoves[0], finalX, finalY, shift);
101 |
102 | tempMoves.push([finalX, finalY]);
103 | break;
104 |
105 | case 'circle':
106 | tempCircle = drawCircle(ctx, tempMoves[0], finalX, finalY, shift);
107 | break;
108 |
109 | case 'rect':
110 | tempSize = drawRect(ctx, tempMoves[0], finalX, finalY, shift);
111 | break;
112 |
113 | default:
114 | break;
115 | }
116 | };
117 |
118 | const clearOnYourMove = () => {
119 | drawAndSet();
120 | tempImageData = undefined;
121 | };
122 |
123 | const handleEndDrawing = () => {
124 | if (!ctx || blocked) return;
125 |
126 | setDrawing(false);
127 |
128 | ctx.closePath();
129 |
130 | let addMove = true;
131 | if (options.mode === 'select' && tempMoves.length) {
132 | clearOnYourMove();
133 | let x = tempMoves[0][0];
134 | let y = tempMoves[0][1];
135 | let width = tempMoves[tempMoves.length - 1][0] - x;
136 | let height = tempMoves[tempMoves.length - 1][1] - y;
137 |
138 | if (width < 0) {
139 | width -= 4;
140 | x += 2;
141 | } else {
142 | width += 4;
143 | x -= 2;
144 | }
145 | if (height < 0) {
146 | height -= 4;
147 | y += 2;
148 | } else {
149 | height += 4;
150 | y -= 2;
151 | }
152 |
153 | if ((width < 4 || width > 4) && (height < 4 || height > 4))
154 | setSelection({ x, y, width, height });
155 | else {
156 | clearSelection();
157 | addMove = false;
158 | }
159 | }
160 |
161 | const move: Move = {
162 | ...DEFAULT_MOVE,
163 | rect: {
164 | ...tempSize,
165 | },
166 | circle: {
167 | ...tempCircle,
168 | },
169 | path: tempMoves,
170 | options,
171 | };
172 |
173 | tempMoves = [];
174 | tempCircle = { cX: 0, cY: 0, radiusX: 0, radiusY: 0 };
175 | tempSize = { width: 0, height: 0 };
176 |
177 | if (options.mode !== 'select') {
178 | socket.emit('draw', move);
179 | clearSavedMoves();
180 | } else if (addMove) handleAddMyMove(move);
181 | };
182 |
183 | return {
184 | handleEndDrawing,
185 | handleDraw,
186 | handleStartDrawing,
187 | drawing,
188 | clearOnYourMove,
189 | };
190 | };
191 |
--------------------------------------------------------------------------------
/modules/room/hooks/useMovesHandlers.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useMemo } from 'react';
2 |
3 | import { getStringFromRgba } from '@/common/lib/rgba';
4 | import { socket } from '@/common/lib/socket';
5 | import { useBackground } from '@/common/recoil/background';
6 | import { useSetSelection } from '@/common/recoil/options';
7 | import { useMyMoves, useRoom } from '@/common/recoil/room';
8 | import { useSetSavedMoves } from '@/common/recoil/savedMoves';
9 | import { Move } from '@/common/types/global';
10 |
11 | import { useCtx } from '../modules/board/hooks/useCtx';
12 | import { useRefs } from './useRefs';
13 | import { useSelection } from '../modules/board/hooks/useSelection';
14 |
15 | let prevMovesLength = 0;
16 |
17 | export const useMovesHandlers = (clearOnYourMove: () => void) => {
18 | const { canvasRef, minimapRef, bgRef } = useRefs();
19 | const room = useRoom();
20 | const { handleAddMyMove, handleRemoveMyMove } = useMyMoves();
21 | const { addSavedMove, removeSavedMove } = useSetSavedMoves();
22 | const ctx = useCtx();
23 | const bg = useBackground();
24 | const { clearSelection } = useSetSelection();
25 |
26 | const sortedMoves = useMemo(() => {
27 | const { usersMoves, movesWithoutUser, myMoves } = room;
28 |
29 | const moves = [...movesWithoutUser, ...myMoves];
30 |
31 | usersMoves.forEach((userMoves) => moves.push(...userMoves));
32 |
33 | moves.sort((a, b) => a.timestamp - b.timestamp);
34 |
35 | return moves;
36 | }, [room]);
37 |
38 | const copyCanvasToSmall = () => {
39 | if (canvasRef.current && minimapRef.current && bgRef.current) {
40 | const smallCtx = minimapRef.current.getContext('2d');
41 | if (smallCtx) {
42 | smallCtx.clearRect(0, 0, smallCtx.canvas.width, smallCtx.canvas.height);
43 | smallCtx.drawImage(
44 | bgRef.current,
45 | 0,
46 | 0,
47 | smallCtx.canvas.width,
48 | smallCtx.canvas.height
49 | );
50 | smallCtx.drawImage(
51 | canvasRef.current,
52 | 0,
53 | 0,
54 | smallCtx.canvas.width,
55 | smallCtx.canvas.height
56 | );
57 | }
58 | }
59 | };
60 |
61 | // eslint-disable-next-line react-hooks/exhaustive-deps
62 | useEffect(() => copyCanvasToSmall(), [bg]);
63 |
64 | const drawMove = (move: Move, image?: HTMLImageElement) => {
65 | const { path } = move;
66 |
67 | if (!ctx || !path.length) return;
68 |
69 | const moveOptions = move.options;
70 |
71 | if (moveOptions.mode === 'select') return;
72 |
73 | ctx.lineWidth = moveOptions.lineWidth;
74 | ctx.strokeStyle = getStringFromRgba(moveOptions.lineColor);
75 | ctx.fillStyle = getStringFromRgba(moveOptions.fillColor);
76 | if (moveOptions.mode === 'eraser')
77 | ctx.globalCompositeOperation = 'destination-out';
78 | else ctx.globalCompositeOperation = 'source-over';
79 |
80 | if (moveOptions.shape === 'image' && image)
81 | ctx.drawImage(image, path[0][0], path[0][1]);
82 |
83 | switch (moveOptions.shape) {
84 | case 'line': {
85 | ctx.beginPath();
86 | path.forEach(([x, y]) => {
87 | ctx.lineTo(x, y);
88 | });
89 |
90 | ctx.stroke();
91 | ctx.closePath();
92 | break;
93 | }
94 |
95 | case 'circle': {
96 | const { cX, cY, radiusX, radiusY } = move.circle;
97 |
98 | ctx.beginPath();
99 | ctx.ellipse(cX, cY, radiusX, radiusY, 0, 0, 2 * Math.PI);
100 | ctx.stroke();
101 | ctx.fill();
102 | ctx.closePath();
103 | break;
104 | }
105 |
106 | case 'rect': {
107 | const { width, height } = move.rect;
108 |
109 | ctx.beginPath();
110 |
111 | ctx.rect(path[0][0], path[0][1], width, height);
112 | ctx.stroke();
113 | ctx.fill();
114 |
115 | ctx.closePath();
116 | break;
117 | }
118 |
119 | default:
120 | break;
121 | }
122 |
123 | copyCanvasToSmall();
124 | };
125 |
126 | const drawAllMoves = async () => {
127 | if (!ctx) return;
128 |
129 | ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
130 |
131 | const images = await Promise.all(
132 | sortedMoves
133 | .filter((move) => move.options.shape === 'image')
134 | .map((move) => {
135 | return new Promise((resolve) => {
136 | const img = new Image();
137 | img.src = move.img.base64;
138 | img.id = move.id;
139 | img.addEventListener('load', () => resolve(img));
140 | });
141 | })
142 | );
143 |
144 | sortedMoves.forEach((move) => {
145 | if (move.options.shape === 'image') {
146 | const img = images.find((image) => image.id === move.id);
147 | if (img) drawMove(move, img);
148 | } else drawMove(move);
149 | });
150 |
151 | copyCanvasToSmall();
152 | };
153 |
154 | useSelection(drawAllMoves);
155 |
156 | useEffect(() => {
157 | socket.on('your_move', (move) => {
158 | clearOnYourMove();
159 | handleAddMyMove(move);
160 | setTimeout(clearSelection, 100);
161 | });
162 |
163 | return () => {
164 | socket.off('your_move');
165 | };
166 | }, [clearOnYourMove, clearSelection, handleAddMyMove]);
167 |
168 | useEffect(() => {
169 | if (prevMovesLength >= sortedMoves.length || !prevMovesLength) {
170 | drawAllMoves();
171 | } else {
172 | const lastMove = sortedMoves[sortedMoves.length - 1];
173 |
174 | if (lastMove.options.shape === 'image') {
175 | const img = new Image();
176 | img.src = lastMove.img.base64;
177 | img.addEventListener('load', () => drawMove(lastMove, img));
178 | } else drawMove(lastMove);
179 | }
180 |
181 | return () => {
182 | prevMovesLength = sortedMoves.length;
183 | };
184 |
185 | // eslint-disable-next-line react-hooks/exhaustive-deps
186 | }, [sortedMoves]);
187 |
188 | // eslint-disable-next-line react-hooks/exhaustive-deps
189 | const handleUndo = () => {
190 | if (ctx) {
191 | const move = handleRemoveMyMove();
192 |
193 | if (move?.options.mode === 'select') clearSelection();
194 | else if (move) {
195 | addSavedMove(move);
196 | socket.emit('undo');
197 | }
198 | }
199 | };
200 |
201 | // eslint-disable-next-line react-hooks/exhaustive-deps
202 | const handleRedo = () => {
203 | if (ctx) {
204 | const move = removeSavedMove();
205 |
206 | if (move) {
207 | socket.emit('draw', move);
208 | }
209 | }
210 | };
211 |
212 | useEffect(() => {
213 | const handleUndoRedoKeyboard = (e: KeyboardEvent) => {
214 | if (e.key === 'z' && e.ctrlKey) {
215 | handleUndo();
216 | } else if (e.key === 'y' && e.ctrlKey) {
217 | handleRedo();
218 | }
219 | };
220 |
221 | document.addEventListener('keydown', handleUndoRedoKeyboard);
222 |
223 | return () => {
224 | document.removeEventListener('keydown', handleUndoRedoKeyboard);
225 | };
226 | }, [handleUndo, handleRedo]);
227 |
228 | return { handleUndo, handleRedo };
229 | };
230 |
--------------------------------------------------------------------------------
/modules/room/modules/board/hooks/useSelection.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useMemo } from 'react';
2 |
3 | import { toast } from 'react-toastify';
4 |
5 | import { DEFAULT_MOVE } from '@/common/constants/defaultMove';
6 | import { socket } from '@/common/lib/socket';
7 | import { useOptionsValue } from '@/common/recoil/options';
8 | import { Move } from '@/common/types/global';
9 |
10 | import { useMoveImage } from '../../../hooks/useMoveImage';
11 | import { useRefs } from '../../../hooks/useRefs';
12 | import { useCtx } from './useCtx';
13 |
14 | let tempSelection = {
15 | x: 0,
16 | y: 0,
17 | width: 0,
18 | height: 0,
19 | };
20 |
21 | export const useSelection = (drawAllMoves: () => Promise) => {
22 | const ctx = useCtx();
23 | const options = useOptionsValue();
24 | const { selection } = options;
25 | const { bgRef, selectionRefs } = useRefs();
26 | const { setMoveImage } = useMoveImage();
27 |
28 | useEffect(() => {
29 | const callback = async () => {
30 | await drawAllMoves();
31 |
32 | if (ctx && selection) {
33 | setTimeout(() => {
34 | const { x, y, width, height } = selection;
35 |
36 | ctx.lineWidth = 2;
37 | ctx.strokeStyle = '#000';
38 | ctx.setLineDash([5, 10]);
39 | ctx.globalCompositeOperation = 'source-over';
40 |
41 | ctx.beginPath();
42 | ctx.rect(x, y, width, height);
43 | ctx.stroke();
44 | ctx.closePath();
45 |
46 | ctx.setLineDash([]);
47 | }, 10);
48 | }
49 | };
50 |
51 | if (
52 | tempSelection.width !== selection?.width ||
53 | tempSelection.height !== selection?.height ||
54 | tempSelection.x !== selection?.x ||
55 | tempSelection.y !== selection?.y
56 | )
57 | callback();
58 |
59 | return () => {
60 | if (selection) tempSelection = selection;
61 | };
62 |
63 | // eslint-disable-next-line react-hooks/exhaustive-deps
64 | }, [selection, ctx]);
65 |
66 | const dimension = useMemo(() => {
67 | if (selection) {
68 | let { x, y, width, height } = selection;
69 |
70 | if (width < 0) {
71 | width += 4;
72 | x -= 2;
73 | } else {
74 | width -= 4;
75 | x += 2;
76 | }
77 | if (height < 0) {
78 | height += 4;
79 | y -= 2;
80 | } else {
81 | height -= 4;
82 | y += 2;
83 | }
84 |
85 | return { x, y, width, height };
86 | }
87 |
88 | return {
89 | width: 0,
90 | height: 0,
91 | x: 0,
92 | y: 0,
93 | };
94 | }, [selection]);
95 |
96 | // eslint-disable-next-line react-hooks/exhaustive-deps
97 | const makeBlob = async (withBg?: boolean) => {
98 | if (!selection) return null;
99 |
100 | const { x, y, width, height } = dimension;
101 |
102 | const imageData = ctx?.getImageData(x, y, width, height);
103 |
104 | if (imageData) {
105 | const tempCanvas = document.createElement('canvas');
106 | tempCanvas.width = width;
107 | tempCanvas.height = height;
108 | const canvas = document.createElement('canvas');
109 | canvas.width = width;
110 | canvas.height = height;
111 | const tempCtx = canvas.getContext('2d');
112 |
113 | if (tempCtx && bgRef.current) {
114 | const bgImage = bgRef.current
115 | .getContext('2d')
116 | ?.getImageData(x, y, width, height);
117 |
118 | if (bgImage && withBg) tempCtx.putImageData(bgImage, 0, 0);
119 |
120 | const sTempCtx = tempCanvas.getContext('2d');
121 | sTempCtx?.putImageData(imageData, 0, 0);
122 |
123 | tempCtx.drawImage(tempCanvas, 0, 0);
124 |
125 | const blob: Blob = await new Promise((resolve) => {
126 | canvas.toBlob((blobGenerated) => {
127 | if (blobGenerated) resolve(blobGenerated);
128 | });
129 | });
130 |
131 | return blob;
132 | }
133 | }
134 |
135 | return null;
136 | };
137 |
138 | // eslint-disable-next-line react-hooks/exhaustive-deps
139 | const createDeleteMove = () => {
140 | if (!selection) return null;
141 |
142 | let { x, y, width, height } = dimension;
143 |
144 | if (width < 0) {
145 | width += 4;
146 | x -= 2;
147 | } else {
148 | width -= 4;
149 | x += 2;
150 | }
151 | if (height < 0) {
152 | height += 4;
153 | y -= 2;
154 | } else {
155 | height -= 4;
156 | y += 2;
157 | }
158 |
159 | const move: Move = {
160 | ...DEFAULT_MOVE,
161 | rect: {
162 | width,
163 | height,
164 | },
165 | path: [[x, y]],
166 | options: {
167 | ...options,
168 | shape: 'rect',
169 | mode: 'eraser',
170 | fillColor: { r: 0, g: 0, b: 0, a: 1 },
171 | },
172 | };
173 |
174 | socket.emit('draw', move);
175 |
176 | return move;
177 | };
178 |
179 | // eslint-disable-next-line react-hooks/exhaustive-deps
180 | const handleCopy = async () => {
181 | const blob = await makeBlob(true);
182 |
183 | if (blob)
184 | navigator.clipboard
185 | .write([
186 | new ClipboardItem({
187 | 'image/png': blob,
188 | }),
189 | ])
190 | .then(() => {
191 | toast('Copied to clipboard!', {
192 | position: 'top-center',
193 | theme: 'colored',
194 | });
195 | });
196 | };
197 |
198 | useEffect(() => {
199 | const handleSelection = async (e: KeyboardEvent) => {
200 | if (e.key === 'c' && e.ctrlKey) handleCopy();
201 | if (e.key === 'Delete' && selection) createDeleteMove();
202 | };
203 |
204 | document.addEventListener('keydown', handleSelection);
205 |
206 | return () => {
207 | document.removeEventListener('keydown', handleSelection);
208 | };
209 | }, [bgRef, createDeleteMove, ctx, handleCopy, makeBlob, options, selection]);
210 |
211 | useEffect(() => {
212 | const handleSelectionMove = async () => {
213 | if (selection) {
214 | const blob = await makeBlob();
215 | if (!blob) return;
216 |
217 | const { x, y, width, height } = dimension;
218 |
219 | const reader = new FileReader();
220 | reader.readAsDataURL(blob);
221 | reader.addEventListener('loadend', () => {
222 | const base64 = reader.result?.toString();
223 |
224 | if (base64) {
225 | createDeleteMove();
226 | setMoveImage({
227 | base64,
228 | x: Math.min(x, x + width),
229 | y: Math.min(y, y + height),
230 | });
231 | }
232 | });
233 | }
234 | };
235 |
236 | if (selectionRefs.current) {
237 | const moveBtn = selectionRefs.current[0];
238 | const copyBtn = selectionRefs.current[1];
239 | const deleteBtn = selectionRefs.current[2];
240 |
241 | moveBtn.addEventListener('click', handleSelectionMove);
242 | copyBtn.addEventListener('click', handleCopy);
243 | deleteBtn.addEventListener('click', createDeleteMove);
244 |
245 | return () => {
246 | moveBtn?.removeEventListener('click', handleSelectionMove);
247 | copyBtn?.removeEventListener('click', handleCopy);
248 | deleteBtn?.removeEventListener('click', createDeleteMove);
249 | };
250 | }
251 |
252 | return () => {};
253 | }, [
254 | createDeleteMove,
255 | dimension,
256 | handleCopy,
257 | makeBlob,
258 | selection,
259 | selectionRefs,
260 | setMoveImage,
261 | ]);
262 | };
263 |
--------------------------------------------------------------------------------