├── common ├── constants │ ├── easings.ts │ ├── canvasSize.ts │ ├── colors.ts │ └── defaultMove.ts ├── lib │ ├── getPos.ts │ ├── rgba.ts │ ├── socket.ts │ ├── getNextColor.ts │ └── optimizeImage.ts ├── recoil │ ├── savedMoves │ │ ├── savedMoves.atom.ts │ │ ├── index.ts │ │ └── savedMoves.hooks.ts │ ├── background │ │ ├── index.ts │ │ ├── background.atom.ts │ │ └── background.hooks.ts │ ├── room │ │ ├── index.ts │ │ ├── room.atom.ts │ │ └── room.hooks.ts │ └── options │ │ ├── index.ts │ │ ├── options.atom.ts │ │ └── options.hooks.ts ├── components │ └── portal │ │ └── components │ │ └── Portal.ts ├── hooks │ └── useViewportSize.ts ├── types │ └── global.ts └── styles │ └── global.css ├── public ├── favicon.ico └── vercel.svg ├── modules ├── home │ ├── index.ts │ ├── modals │ │ └── NotFound.tsx │ └── components │ │ └── Home.tsx ├── room │ ├── index.ts │ ├── modules │ │ ├── chat │ │ │ ├── index.ts │ │ │ └── components │ │ │ │ ├── Message.tsx │ │ │ │ ├── ChatInput.tsx │ │ │ │ └── Chat.tsx │ │ ├── toolbar │ │ │ ├── index.ts │ │ │ ├── animations │ │ │ │ └── Entry.animations.ts │ │ │ ├── components │ │ │ │ ├── BackgoundPicker.tsx │ │ │ │ ├── HistoryBtns.tsx │ │ │ │ ├── ImagePicker.tsx │ │ │ │ ├── ModePicker.tsx │ │ │ │ ├── LineWidthPicker.tsx │ │ │ │ ├── ColorPicker.tsx │ │ │ │ ├── ShapeSelector.tsx │ │ │ │ └── ToolBar.tsx │ │ │ └── modals │ │ │ │ ├── ShareModal.tsx │ │ │ │ └── BackgroundModal.tsx │ │ └── board │ │ │ ├── hooks │ │ │ ├── useBoardPosition.ts │ │ │ ├── useCtx.ts │ │ │ ├── useSocketDraw.ts │ │ │ ├── useDraw.ts │ │ │ └── useSelection.ts │ │ │ ├── index.tsx │ │ │ ├── components │ │ │ ├── MousesRenderer.tsx │ │ │ ├── MousePosition.tsx │ │ │ ├── Background.tsx │ │ │ ├── SelectionBtns.tsx │ │ │ ├── UserMouse.tsx │ │ │ ├── MoveImage.tsx │ │ │ ├── Minimap.tsx │ │ │ └── Canvas.tsx │ │ │ └── helpers │ │ │ └── Canvas.helpers.ts │ ├── hooks │ │ ├── useMoveImage.ts │ │ ├── useRefs.ts │ │ └── useMovesHandlers.ts │ ├── components │ │ ├── Room.tsx │ │ ├── UserList.tsx │ │ └── NameInput.tsx │ └── context │ │ └── Room.context.tsx └── modal │ ├── index.ts │ ├── animations │ └── ModalManager.animations.ts │ ├── recoil │ ├── modal.atom.tsx │ └── modal.hooks.tsx │ └── components │ └── ModalManager.tsx ├── .eslintignore ├── next.config.js ├── postcss.config.js ├── pages ├── [roomId].tsx ├── index.tsx ├── _document.tsx └── _app.tsx ├── next-env.d.ts ├── tsconfig.server.json ├── .gitignore ├── tsconfig.json ├── tailwind.config.js ├── README.md ├── package.json ├── .eslintrc ├── .github └── workflows │ └── codesee-arch-diagram.yml └── server └── index.ts /common/constants/easings.ts: -------------------------------------------------------------------------------- 1 | export const DEFAULT_EASE = [0.6, 0.01, -0.05, 0.9]; 2 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kriziu/collabio/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /modules/home/index.ts: -------------------------------------------------------------------------------- 1 | import Home from './components/Home'; 2 | 3 | export default Home; 4 | -------------------------------------------------------------------------------- /modules/room/index.ts: -------------------------------------------------------------------------------- 1 | import Room from './components/Room'; 2 | 3 | export default Room; 4 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | out 3 | build 4 | .next 5 | 6 | tailwind.config.js 7 | postcss.config.js -------------------------------------------------------------------------------- /modules/room/modules/chat/index.ts: -------------------------------------------------------------------------------- 1 | import Chat from './components/Chat'; 2 | 3 | export default Chat; 4 | -------------------------------------------------------------------------------- /common/constants/canvasSize.ts: -------------------------------------------------------------------------------- 1 | export const CANVAS_SIZE = { 2 | width: 3500, 3 | height: 2000, 4 | }; 5 | -------------------------------------------------------------------------------- /modules/room/modules/toolbar/index.ts: -------------------------------------------------------------------------------- 1 | import ToolBar from './components/ToolBar'; 2 | 3 | export default ToolBar; 4 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | module.exports = { 3 | reactStrictMode: true, 4 | }; 5 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /common/lib/getPos.ts: -------------------------------------------------------------------------------- 1 | import { MotionValue } from 'framer-motion'; 2 | 3 | export const getPos = (pos: number, motionValue: MotionValue) => 4 | pos - motionValue.get(); 5 | -------------------------------------------------------------------------------- /modules/modal/index.ts: -------------------------------------------------------------------------------- 1 | import ModalManager from './components/ModalManager'; 2 | import { useModal } from './recoil/modal.hooks'; 3 | 4 | export { ModalManager, useModal }; 5 | -------------------------------------------------------------------------------- /common/lib/rgba.ts: -------------------------------------------------------------------------------- 1 | import { RgbaColor } from 'react-colorful'; 2 | 3 | export const getStringFromRgba = (rgba: RgbaColor) => 4 | `rgba(${rgba.r}, ${rgba.g}, ${rgba.b}, ${rgba.a})`; 5 | -------------------------------------------------------------------------------- /pages/[roomId].tsx: -------------------------------------------------------------------------------- 1 | import type { NextPage } from 'next'; 2 | 3 | import Room from '@/modules/room'; 4 | 5 | const RoomPage: NextPage = () => { 6 | return ; 7 | }; 8 | 9 | export default RoomPage; 10 | -------------------------------------------------------------------------------- /pages/index.tsx: -------------------------------------------------------------------------------- 1 | import type { NextPage } from 'next'; 2 | 3 | import Home from '@/modules/home'; 4 | 5 | const HomePage: NextPage = () => { 6 | return ; 7 | }; 8 | 9 | export default HomePage; 10 | -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /common/recoil/savedMoves/savedMoves.atom.ts: -------------------------------------------------------------------------------- 1 | import { atom } from 'recoil'; 2 | 3 | import { Move } from '@/common/types/global'; 4 | 5 | export const savedMovesAtom = atom({ 6 | key: 'saved_moves', 7 | default: [], 8 | }); 9 | -------------------------------------------------------------------------------- /common/lib/socket.ts: -------------------------------------------------------------------------------- 1 | import { io, Socket } from 'socket.io-client'; 2 | 3 | import { ClientToServerEvents, ServerToClientEvents } from '../types/global'; 4 | 5 | export const socket: Socket = io(); 6 | -------------------------------------------------------------------------------- /common/recoil/background/index.ts: -------------------------------------------------------------------------------- 1 | import { backgroundAtom } from './background.atom'; 2 | import { useBackground, useSetBackground } from './background.hooks'; 3 | 4 | export default backgroundAtom; 5 | 6 | export { useBackground, useSetBackground }; 7 | -------------------------------------------------------------------------------- /common/recoil/room/index.ts: -------------------------------------------------------------------------------- 1 | import { roomAtom } from './room.atom'; 2 | import { useRoom, useSetRoomId, useSetUsers, useMyMoves } from './room.hooks'; 3 | 4 | export default roomAtom; 5 | 6 | export { useRoom, useSetRoomId, useSetUsers, useMyMoves }; 7 | -------------------------------------------------------------------------------- /common/recoil/savedMoves/index.ts: -------------------------------------------------------------------------------- 1 | import { savedMovesAtom } from './savedMoves.atom'; 2 | import { useSavedMoves, useSetSavedMoves } from './savedMoves.hooks'; 3 | 4 | export default savedMovesAtom; 5 | 6 | export { useSavedMoves, useSetSavedMoves }; 7 | -------------------------------------------------------------------------------- /common/recoil/background/background.atom.ts: -------------------------------------------------------------------------------- 1 | import { atom } from 'recoil'; 2 | 3 | export const backgroundAtom = atom<{ mode: 'dark' | 'light'; lines: boolean }>({ 4 | key: 'bg', 5 | default: { 6 | mode: 'light', 7 | lines: true, 8 | }, 9 | }); 10 | -------------------------------------------------------------------------------- /modules/modal/animations/ModalManager.animations.ts: -------------------------------------------------------------------------------- 1 | export const bgAnimation = { 2 | closed: { opacity: 0 }, 3 | opened: { opacity: 1 }, 4 | }; 5 | 6 | export const modalAnimation = { 7 | closed: { y: -100 }, 8 | opened: { y: 0 }, 9 | exited: { y: 100 }, 10 | }; 11 | -------------------------------------------------------------------------------- /tsconfig.server.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "module": "commonjs", 5 | "outDir": "build", 6 | "target": "es2017", 7 | "isolatedModules": false, 8 | "noEmit": false 9 | }, 10 | "include": ["server"] 11 | } 12 | -------------------------------------------------------------------------------- /modules/modal/recoil/modal.atom.tsx: -------------------------------------------------------------------------------- 1 | import { atom } from 'recoil'; 2 | 3 | export const modalAtom = atom<{ 4 | modal: JSX.Element | JSX.Element[]; 5 | opened: boolean; 6 | }>({ 7 | key: 'modal', 8 | default: { 9 | modal: <>, 10 | opened: false, 11 | }, 12 | }); 13 | -------------------------------------------------------------------------------- /modules/room/modules/board/hooks/useBoardPosition.ts: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react'; 2 | 3 | import { roomContext } from '../../../context/Room.context'; 4 | 5 | export const useBoardPosition = () => { 6 | const { x, y } = useContext(roomContext); 7 | 8 | return { x, y }; 9 | }; 10 | -------------------------------------------------------------------------------- /modules/room/hooks/useMoveImage.ts: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react'; 2 | 3 | import { roomContext } from '../context/Room.context'; 4 | 5 | export const useMoveImage = () => { 6 | const { moveImage, setMoveImage } = useContext(roomContext); 7 | 8 | return { moveImage, setMoveImage }; 9 | }; 10 | -------------------------------------------------------------------------------- /common/lib/getNextColor.ts: -------------------------------------------------------------------------------- 1 | import { COLORS_ARRAY } from '../constants/colors'; 2 | 3 | export const getNextColor = (color?: string) => { 4 | const index = COLORS_ARRAY.findIndex((colorArr) => colorArr === color); 5 | 6 | if (index === -1) return COLORS_ARRAY[0]; 7 | 8 | return COLORS_ARRAY[(index + 1) % COLORS_ARRAY.length]; 9 | }; 10 | -------------------------------------------------------------------------------- /modules/room/modules/toolbar/animations/Entry.animations.ts: -------------------------------------------------------------------------------- 1 | export const EntryAnimation = { 2 | from: { 3 | y: -30, 4 | opacity: 0, 5 | transition: { 6 | duration: 0.2, 7 | }, 8 | }, 9 | 10 | to: { 11 | y: 0, 12 | opacity: 1, 13 | transition: { 14 | duration: 0.2, 15 | }, 16 | }, 17 | }; 18 | -------------------------------------------------------------------------------- /common/recoil/options/index.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-cycle */ 2 | import { optionsAtom } from './options.atom'; 3 | import { 4 | useOptions, 5 | useSetOptions, 6 | useOptionsValue, 7 | useSetSelection, 8 | } from './options.hooks'; 9 | 10 | export default optionsAtom; 11 | 12 | export { useOptions, useOptionsValue, useSetOptions, useSetSelection }; 13 | -------------------------------------------------------------------------------- /common/constants/colors.ts: -------------------------------------------------------------------------------- 1 | export const COLORS = { 2 | PURPLE: '#6B32F3', 3 | BLUE: '#408FF8', 4 | RED: '#F32D27', 5 | GREEN: '#6FCB12', 6 | GOLD: '#A89D6C', 7 | PINK: '#EB29DA', 8 | MINT: '#19CB87', 9 | RED_LIGHT: '#ED7878', 10 | CYAN: '#02CBF6', 11 | RED_DARK: '#BA1555', 12 | ORANGE: '#FF7300', 13 | }; 14 | 15 | export const COLORS_ARRAY = [...Object.values(COLORS)]; 16 | -------------------------------------------------------------------------------- /common/lib/optimizeImage.ts: -------------------------------------------------------------------------------- 1 | import FileResizer from 'react-image-file-resizer'; 2 | 3 | export const optimizeImage = (file: File, callback: (uri: string) => void) => { 4 | FileResizer.imageFileResizer( 5 | file, 6 | 700, 7 | 700, 8 | 'WEBP', 9 | 100, 10 | 0, 11 | (uri) => { 12 | callback(uri.toString()); 13 | }, 14 | 'base64' 15 | ); 16 | }; 17 | -------------------------------------------------------------------------------- /common/recoil/room/room.atom.ts: -------------------------------------------------------------------------------- 1 | import { atom } from 'recoil'; 2 | 3 | import { ClientRoom } from '@/common/types/global'; 4 | 5 | export const DEFAULT_ROOM = { 6 | id: '', 7 | users: new Map(), 8 | usersMoves: new Map(), 9 | movesWithoutUser: [], 10 | myMoves: [], 11 | }; 12 | 13 | export const roomAtom = atom({ 14 | key: 'room', 15 | default: DEFAULT_ROOM, 16 | }); 17 | -------------------------------------------------------------------------------- /common/recoil/options/options.atom.ts: -------------------------------------------------------------------------------- 1 | import { atom } from 'recoil'; 2 | 3 | import { CtxOptions } from '@/common/types/global'; 4 | 5 | export const optionsAtom = atom({ 6 | key: 'options', 7 | default: { 8 | lineColor: { r: 0, g: 0, b: 0, a: 1 }, 9 | fillColor: { r: 0, g: 0, b: 0, a: 0 }, 10 | lineWidth: 5, 11 | mode: 'draw', 12 | shape: 'line', 13 | selection: null, 14 | }, 15 | }); 16 | -------------------------------------------------------------------------------- /modules/room/hooks/useRefs.ts: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react'; 2 | 3 | import { roomContext } from '../context/Room.context'; 4 | 5 | export const useRefs = () => { 6 | const { undoRef, bgRef, canvasRef, minimapRef, redoRef, selectionRefs } = 7 | useContext(roomContext); 8 | 9 | return { 10 | undoRef, 11 | redoRef, 12 | bgRef, 13 | canvasRef, 14 | minimapRef, 15 | selectionRefs, 16 | }; 17 | }; 18 | -------------------------------------------------------------------------------- /modules/modal/recoil/modal.hooks.tsx: -------------------------------------------------------------------------------- 1 | import { useSetRecoilState } from 'recoil'; 2 | 3 | import { modalAtom } from './modal.atom'; 4 | 5 | const useModal = () => { 6 | const setModal = useSetRecoilState(modalAtom); 7 | 8 | const openModal = (modal: JSX.Element | JSX.Element[]) => 9 | setModal({ modal, opened: true }); 10 | 11 | const closeModal = () => setModal({ modal: <>, opened: false }); 12 | 13 | return { openModal, closeModal }; 14 | }; 15 | 16 | export { useModal }; 17 | -------------------------------------------------------------------------------- /modules/room/modules/toolbar/components/BackgoundPicker.tsx: -------------------------------------------------------------------------------- 1 | import { CgScreen } from 'react-icons/cg'; 2 | 3 | import { useModal } from '@/modules/modal'; 4 | 5 | import BackgroundModal from '../modals/BackgroundModal'; 6 | 7 | const BackgroundPicker = () => { 8 | const { openModal } = useModal(); 9 | 10 | return ( 11 | 14 | ); 15 | }; 16 | 17 | export default BackgroundPicker; 18 | -------------------------------------------------------------------------------- /modules/room/modules/board/index.tsx: -------------------------------------------------------------------------------- 1 | import Canvas from './components/Canvas'; 2 | import MousePosition from './components/MousePosition'; 3 | import MousesRenderer from './components/MousesRenderer'; 4 | import MoveImage from './components/MoveImage'; 5 | import SelectionBtns from './components/SelectionBtns'; 6 | 7 | const Board = () => ( 8 | <> 9 | 10 | 11 | 12 | 13 | 14 | 15 | ); 16 | 17 | export default Board; 18 | -------------------------------------------------------------------------------- /common/components/portal/components/Portal.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | 3 | import { createPortal } from 'react-dom'; 4 | 5 | const Portal = ({ children }: { children: JSX.Element | JSX.Element[] }) => { 6 | const [portal, setPortal] = useState(); 7 | 8 | useEffect(() => { 9 | const node = document.getElementById('portal'); 10 | if (node) setPortal(node); 11 | }, []); 12 | 13 | if (!portal) return null; 14 | 15 | return createPortal(children, portal); 16 | }; 17 | 18 | export default Portal; 19 | -------------------------------------------------------------------------------- /modules/room/modules/board/components/MousesRenderer.tsx: -------------------------------------------------------------------------------- 1 | import { socket } from '@/common/lib/socket'; 2 | import { useRoom } from '@/common/recoil/room'; 3 | 4 | import UserMouse from './UserMouse'; 5 | 6 | const MousesRenderer = () => { 7 | const { users } = useRoom(); 8 | 9 | return ( 10 | <> 11 | {[...users.keys()].map((userId) => { 12 | if (userId === socket.id) return null; 13 | return ; 14 | })} 15 | 16 | ); 17 | }; 18 | 19 | export default MousesRenderer; 20 | -------------------------------------------------------------------------------- /modules/room/modules/board/hooks/useCtx.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | 3 | import { useRefs } from '../../../hooks/useRefs'; 4 | 5 | export const useCtx = () => { 6 | const { canvasRef } = useRefs(); 7 | 8 | const [ctx, setCtx] = useState(); 9 | 10 | useEffect(() => { 11 | const newCtx = canvasRef.current?.getContext('2d'); 12 | 13 | if (newCtx) { 14 | newCtx.lineJoin = 'round'; 15 | newCtx.lineCap = 'round'; 16 | setCtx(newCtx); 17 | } 18 | }, [canvasRef]); 19 | 20 | return ctx; 21 | }; 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env.local 29 | .env.development.local 30 | .env.test.local 31 | .env.production.local 32 | 33 | # vercel 34 | .vercel 35 | 36 | # typescript 37 | *.tsbuildinfo 38 | -------------------------------------------------------------------------------- /common/constants/defaultMove.ts: -------------------------------------------------------------------------------- 1 | import { Move } from '../types/global'; 2 | 3 | export const DEFAULT_MOVE: Move = { 4 | circle: { 5 | cX: 0, 6 | cY: 0, 7 | radiusX: 0, 8 | radiusY: 0, 9 | }, 10 | rect: { 11 | width: 0, 12 | height: 0, 13 | }, 14 | path: [], 15 | options: { 16 | shape: 'line', 17 | mode: 'draw', 18 | lineWidth: 1, 19 | lineColor: { r: 0, g: 0, b: 0, a: 0 }, 20 | fillColor: { r: 0, g: 0, b: 0, a: 0 }, 21 | selection: null, 22 | }, 23 | id: '', 24 | img: { 25 | base64: '', 26 | }, 27 | timestamp: 0, 28 | }; 29 | -------------------------------------------------------------------------------- /common/hooks/useViewportSize.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | 3 | export const useViewportSize = () => { 4 | const [width, setWidth] = useState(0); 5 | const [height, setHeight] = useState(0); 6 | 7 | useEffect(() => { 8 | const handleResize = () => { 9 | setWidth(window.innerWidth); 10 | setHeight(window.innerHeight); 11 | }; 12 | 13 | handleResize(); 14 | 15 | window.addEventListener('resize', handleResize); 16 | 17 | return () => { 18 | window.removeEventListener('resize', handleResize); 19 | }; 20 | }, []); 21 | 22 | return { width, height }; 23 | }; 24 | -------------------------------------------------------------------------------- /modules/room/modules/chat/components/Message.tsx: -------------------------------------------------------------------------------- 1 | import { socket } from '@/common/lib/socket'; 2 | import { MessageType } from '@/common/types/global'; 3 | 4 | const Message = ({ userId, msg, username, color }: MessageType) => { 5 | const me = socket.id === userId; 6 | 7 | return ( 8 |
11 | {!me && ( 12 |
13 | {username} 14 |
15 | )} 16 |

{msg}

17 |
18 | ); 19 | }; 20 | 21 | export default Message; 22 | -------------------------------------------------------------------------------- /modules/home/modals/NotFound.tsx: -------------------------------------------------------------------------------- 1 | import { AiOutlineClose } from 'react-icons/ai'; 2 | 3 | import { useModal } from '@/modules/modal'; 4 | 5 | const NotFoundModal = ({ id }: { id: string }) => { 6 | const { closeModal } = useModal(); 7 | 8 | return ( 9 |
10 | 13 |

14 | Room with id "{id}" does not exist or is full! 15 |

16 |

Try to join room later.

17 |
18 | ); 19 | }; 20 | 21 | export default NotFoundModal; 22 | -------------------------------------------------------------------------------- /pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import { Html, Main, NextScript, Head } from 'next/document'; 2 | 3 | const document = () => ( 4 | 5 | 6 | 7 | 12 | 16 | 17 | 18 |
19 |
20 | 21 | 22 | 23 | ); 24 | 25 | export default document; 26 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "CommonJS", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "incremental": true, 17 | "downlevelIteration": true, 18 | 19 | "baseUrl": ".", 20 | "paths": { 21 | "@/*": ["./*"] 22 | } 23 | }, 24 | 25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], 26 | "exclude": ["node_modules"] 27 | } 28 | -------------------------------------------------------------------------------- /modules/room/components/Room.tsx: -------------------------------------------------------------------------------- 1 | import { useRoom } from '@/common/recoil/room'; 2 | 3 | import RoomContextProvider from '../context/Room.context'; 4 | import Board from '../modules/board'; 5 | import Chat from '../modules/chat'; 6 | import ToolBar from '../modules/toolbar'; 7 | import NameInput from './NameInput'; 8 | import UserList from './UserList'; 9 | 10 | const Room = () => { 11 | const room = useRoom(); 12 | 13 | if (!room.id) return ; 14 | 15 | return ( 16 | 17 |
18 | 19 | 20 | 21 | 22 |
23 |
24 | ); 25 | }; 26 | 27 | export default Room; 28 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | content: [ 3 | './pages/**/*.{js,ts,jsx,tsx}', 4 | './common/**/*.{js,ts,jsx,tsx}', 5 | './modules/**/*.{js,ts,jsx,tsx}', 6 | ], 7 | darkMode: 'class', 8 | theme: { 9 | fontSize: { 10 | xs: '0.75rem', 11 | sm: '0.875rem', 12 | base: '1rem', 13 | lg: '1.125rem', 14 | xl: '1.25rem', 15 | '2xl': '1.5rem', 16 | '3xl': '1.875rem', 17 | '4xl': '2.25rem', 18 | '5xl': '3rem', 19 | '6xl': '4rem', 20 | extra: '6rem', 21 | }, 22 | extend: { 23 | colors: {}, 24 | width: { 25 | 160: '40rem', 26 | }, 27 | }, 28 | fontFamily: { 29 | montserrat: ['Montserrat', 'sans-serif'], 30 | }, 31 | }, 32 | plugins: [], 33 | }; 34 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Collabio | Online whiteboard 2 | 3 | Real-time whiteboard made with Next.JS and Socket.IO 4 | ## Features 5 | 6 | - Drawing lines, circles and rectangles 7 | - Eraser 8 | - Undo/Redo 9 | - Real-time mouse tracking 10 | - Chatting 11 | - Placing images 12 | - Moving selected area 13 | - Saving canvas 14 | - Changing backgrounds 15 | - Sharing 16 | ## Made using 17 | - Next.JS 18 | - Recoil 19 | - TailwindCSS 20 | - Framer Motion 21 | - Socket.IO 22 | ## Demo 23 | 24 | LIVE DEMO: https://collabio-kriziu.herokuapp.com 25 | 26 | 27 | ## Installation 28 | Clone repository, install all npm packages and run like normal Next.JS application. 29 | ## Screenshots 30 | 31 | #### Home page 32 | ![home page](https://i.imgur.com/00CZlrR.png) 33 | 34 | #### Board page 35 | ![Board page](https://i.imgur.com/0v4Y8XP.png) 36 | -------------------------------------------------------------------------------- /modules/room/components/UserList.tsx: -------------------------------------------------------------------------------- 1 | import { useRoom } from '@/common/recoil/room'; 2 | 3 | const UserList = () => { 4 | const { users } = useRoom(); 5 | 6 | return ( 7 |
8 | {[...users.keys()].map((userId, index) => { 9 | return ( 10 |
18 | {users.get(userId)?.name.split('')[0] || 'A'} 19 |
20 | ); 21 | })} 22 |
23 | ); 24 | }; 25 | 26 | export default UserList; 27 | -------------------------------------------------------------------------------- /modules/room/modules/toolbar/components/HistoryBtns.tsx: -------------------------------------------------------------------------------- 1 | import { FaRedo, FaUndo } from 'react-icons/fa'; 2 | 3 | import { useMyMoves } from '@/common/recoil/room'; 4 | import { useSavedMoves } from '@/common/recoil/savedMoves'; 5 | 6 | import { useRefs } from '../../../hooks/useRefs'; 7 | 8 | const HistoryBtns = () => { 9 | const { redoRef, undoRef } = useRefs(); 10 | 11 | const { myMoves } = useMyMoves(); 12 | const savedMoves = useSavedMoves(); 13 | 14 | return ( 15 | <> 16 | 23 | 30 | 31 | ); 32 | }; 33 | 34 | export default HistoryBtns; 35 | -------------------------------------------------------------------------------- /modules/room/modules/chat/components/ChatInput.tsx: -------------------------------------------------------------------------------- 1 | import { FormEvent, useState } from 'react'; 2 | 3 | import { AiOutlineSend } from 'react-icons/ai'; 4 | 5 | import { socket } from '@/common/lib/socket'; 6 | 7 | const ChatInput = () => { 8 | const [msg, setMsg] = useState(''); 9 | 10 | const handleSubmit = (e: FormEvent) => { 11 | e.preventDefault(); 12 | 13 | socket.emit('send_msg', msg); 14 | 15 | setMsg(''); 16 | }; 17 | 18 | return ( 19 |
20 | setMsg(e.target.value)} 24 | /> 25 | 28 |
29 | ); 30 | }; 31 | 32 | export default ChatInput; 33 | -------------------------------------------------------------------------------- /common/recoil/background/background.hooks.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | 3 | import { useRecoilValue, useSetRecoilState } from 'recoil'; 4 | 5 | import { backgroundAtom } from './background.atom'; 6 | 7 | export const useBackground = () => { 8 | const bg = useRecoilValue(backgroundAtom); 9 | 10 | useEffect(() => { 11 | const root = window.document.documentElement; 12 | 13 | if (bg.mode === 'dark') { 14 | root.classList.remove('light'); 15 | root.classList.add('dark'); 16 | } else { 17 | root.classList.remove('dark'); 18 | root.classList.add('light'); 19 | } 20 | }, [bg.mode]); 21 | 22 | return bg; 23 | }; 24 | 25 | export const useSetBackground = () => { 26 | const setBg = useSetRecoilState(backgroundAtom); 27 | 28 | const setBackground = (mode: 'dark' | 'light', lines: boolean) => { 29 | setBg({ 30 | mode, 31 | lines, 32 | }); 33 | }; 34 | 35 | return setBackground; 36 | }; 37 | -------------------------------------------------------------------------------- /pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import '../common/styles/global.css'; 2 | import { MotionConfig } from 'framer-motion'; 3 | import type { AppProps } from 'next/app'; 4 | import Head from 'next/head'; 5 | import { ToastContainer } from 'react-toastify'; 6 | import { RecoilRoot } from 'recoil'; 7 | 8 | import { DEFAULT_EASE } from '@/common/constants/easings'; 9 | import { ModalManager } from '@/modules/modal'; 10 | 11 | import 'react-toastify/dist/ReactToastify.min.css'; 12 | 13 | const App = ({ Component, pageProps }: AppProps) => { 14 | return ( 15 | <> 16 | 17 | Collabio | Online Whiteboard 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | ); 29 | }; 30 | 31 | export default App; 32 | -------------------------------------------------------------------------------- /common/recoil/options/options.hooks.ts: -------------------------------------------------------------------------------- 1 | import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil'; 2 | 3 | import { optionsAtom } from './options.atom'; 4 | 5 | export const useOptionsValue = () => { 6 | const options = useRecoilValue(optionsAtom); 7 | 8 | return options; 9 | }; 10 | 11 | export const useSetOptions = () => { 12 | const setOptions = useSetRecoilState(optionsAtom); 13 | 14 | return setOptions; 15 | }; 16 | 17 | export const useOptions = () => { 18 | const options = useRecoilState(optionsAtom); 19 | 20 | return options; 21 | }; 22 | 23 | export const useSetSelection = () => { 24 | const setOptions = useSetOptions(); 25 | 26 | const setSelection = (rect: { 27 | x: number; 28 | y: number; 29 | width: number; 30 | height: number; 31 | }) => { 32 | setOptions((prev) => ({ ...prev, selection: rect })); 33 | }; 34 | 35 | const clearSelection = () => { 36 | setOptions((prev) => ({ ...prev, selection: null })); 37 | }; 38 | 39 | return { setSelection, clearSelection }; 40 | }; 41 | -------------------------------------------------------------------------------- /common/recoil/savedMoves/savedMoves.hooks.ts: -------------------------------------------------------------------------------- 1 | import { useRecoilValue, useSetRecoilState } from 'recoil'; 2 | 3 | import { Move } from '@/common/types/global'; 4 | 5 | import { savedMovesAtom } from './savedMoves.atom'; 6 | 7 | export const useSetSavedMoves = () => { 8 | const setSavedMoves = useSetRecoilState(savedMovesAtom); 9 | 10 | const addSavedMove = (move: Move) => { 11 | if (move.options.mode === 'select') return; 12 | 13 | setSavedMoves((prevMoves) => [move, ...prevMoves]); 14 | }; 15 | 16 | const removeSavedMove = () => { 17 | let move: Move | undefined; 18 | 19 | setSavedMoves((prevMoves) => { 20 | move = prevMoves.at(0); 21 | 22 | return prevMoves.slice(1); 23 | }); 24 | 25 | return move; 26 | }; 27 | 28 | const clearSavedMoves = () => { 29 | setSavedMoves([]); 30 | }; 31 | 32 | return { addSavedMove, removeSavedMove, clearSavedMoves }; 33 | }; 34 | 35 | export const useSavedMoves = () => { 36 | const savedMoves = useRecoilValue(savedMovesAtom); 37 | 38 | return savedMoves; 39 | }; 40 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | -------------------------------------------------------------------------------- /modules/room/modules/board/hooks/useSocketDraw.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | 3 | import { socket } from '@/common/lib/socket'; 4 | import { useSetUsers } from '@/common/recoil/room'; 5 | import { Move } from '@/common/types/global'; 6 | 7 | export const useSocketDraw = (drawing: boolean) => { 8 | const { handleAddMoveToUser, handleRemoveMoveFromUser } = useSetUsers(); 9 | 10 | useEffect(() => { 11 | let moveToDrawLater: Move | undefined; 12 | let userIdLater = ''; 13 | 14 | socket.on('user_draw', (move, userId) => { 15 | if (!drawing) { 16 | handleAddMoveToUser(userId, move); 17 | } else { 18 | moveToDrawLater = move; 19 | userIdLater = userId; 20 | } 21 | }); 22 | 23 | return () => { 24 | socket.off('user_draw'); 25 | 26 | if (moveToDrawLater && userIdLater) { 27 | handleAddMoveToUser(userIdLater, moveToDrawLater); 28 | } 29 | }; 30 | }, [drawing, handleAddMoveToUser]); 31 | 32 | useEffect(() => { 33 | socket.on('user_undo', (userId) => { 34 | handleRemoveMoveFromUser(userId); 35 | }); 36 | 37 | return () => { 38 | socket.off('user_undo'); 39 | }; 40 | }, [handleRemoveMoveFromUser]); 41 | }; 42 | -------------------------------------------------------------------------------- /modules/room/modules/toolbar/modals/ShareModal.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | 3 | import { AiOutlineClose } from 'react-icons/ai'; 4 | 5 | import { useRoom } from '@/common/recoil/room'; 6 | import { useModal } from '@/modules/modal'; 7 | 8 | const ShareModal = () => { 9 | const { id } = useRoom(); 10 | const { closeModal } = useModal(); 11 | 12 | const [url, setUrl] = useState(''); 13 | 14 | useEffect(() => setUrl(window.location.href), []); 15 | 16 | const handleCopy = () => navigator.clipboard.writeText(url); 17 | 18 | return ( 19 |
20 | 23 |

Invite

24 |

25 | Room id:

{id}

26 |

27 |
28 | 29 | 32 |
33 |
34 | ); 35 | }; 36 | 37 | export default ShareModal; 38 | -------------------------------------------------------------------------------- /modules/room/modules/board/components/MousePosition.tsx: -------------------------------------------------------------------------------- 1 | import { useRef } from 'react'; 2 | 3 | import { motion } from 'framer-motion'; 4 | import { useInterval, useMouse } from 'react-use'; 5 | 6 | import { getPos } from '@/common/lib/getPos'; 7 | import { socket } from '@/common/lib/socket'; 8 | 9 | import { useBoardPosition } from '../hooks/useBoardPosition'; 10 | 11 | const MousePosition = () => { 12 | const { x, y } = useBoardPosition(); 13 | 14 | const prevPosition = useRef({ x: 0, y: 0 }); 15 | 16 | const ref = useRef(null); 17 | 18 | const { docX, docY } = useMouse(ref); 19 | 20 | const touchDevice = window.matchMedia('(pointer: coarse)').matches; 21 | 22 | useInterval(() => { 23 | if ( 24 | (prevPosition.current.x !== docX || prevPosition.current.y !== docY) && 25 | !touchDevice 26 | ) { 27 | socket.emit('mouse_move', getPos(docX, x), getPos(docY, y)); 28 | prevPosition.current = { x: docX, y: docY }; 29 | } 30 | }, 150); 31 | 32 | if (touchDevice) return null; 33 | 34 | return ( 35 | 41 | {getPos(docX, x).toFixed(0)} | {getPos(docY, y).toFixed(0)} 42 | 43 | ); 44 | }; 45 | 46 | export default MousePosition; 47 | -------------------------------------------------------------------------------- /modules/room/modules/toolbar/components/ImagePicker.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | 3 | import { BsFillImageFill } from 'react-icons/bs'; 4 | 5 | import { optimizeImage } from '@/common/lib/optimizeImage'; 6 | 7 | import { useMoveImage } from '../../../hooks/useMoveImage'; 8 | 9 | const ImagePicker = () => { 10 | const { setMoveImage } = useMoveImage(); 11 | 12 | useEffect(() => { 13 | const handlePaste = (e: ClipboardEvent) => { 14 | const items = e.clipboardData?.items; 15 | if (items) { 16 | // eslint-disable-next-line no-restricted-syntax 17 | for (const item of items) { 18 | if (item.type.includes('image')) { 19 | const file = item.getAsFile(); 20 | if (file) 21 | optimizeImage(file, (uri) => setMoveImage({ base64: uri })); 22 | } 23 | } 24 | } 25 | }; 26 | 27 | document.addEventListener('paste', handlePaste); 28 | 29 | return () => { 30 | document.removeEventListener('paste', handlePaste); 31 | }; 32 | }, [setMoveImage]); 33 | 34 | const handleImageInput = () => { 35 | const fileInput = document.createElement('input'); 36 | fileInput.type = 'file'; 37 | fileInput.accept = 'image/*'; 38 | fileInput.click(); 39 | 40 | fileInput.addEventListener('change', () => { 41 | if (fileInput && fileInput.files) { 42 | const file = fileInput.files[0]; 43 | optimizeImage(file, (uri) => setMoveImage({ base64: uri })); 44 | } 45 | }); 46 | }; 47 | 48 | return ( 49 | 52 | ); 53 | }; 54 | 55 | export default ImagePicker; 56 | -------------------------------------------------------------------------------- /modules/room/modules/board/components/Background.tsx: -------------------------------------------------------------------------------- 1 | import { RefObject, useEffect } from 'react'; 2 | 3 | import { motion } from 'framer-motion'; 4 | 5 | import { CANVAS_SIZE } from '@/common/constants/canvasSize'; 6 | import { useBackground } from '@/common/recoil/background'; 7 | 8 | import { useBoardPosition } from '../hooks/useBoardPosition'; 9 | 10 | const Background = ({ bgRef }: { bgRef: RefObject }) => { 11 | const bg = useBackground(); 12 | const { x, y } = useBoardPosition(); 13 | 14 | useEffect(() => { 15 | const ctx = bgRef.current?.getContext('2d'); 16 | 17 | if (ctx) { 18 | ctx.fillStyle = bg.mode === 'dark' ? '#222' : '#fff'; 19 | ctx.fillRect(0, 0, CANVAS_SIZE.width, CANVAS_SIZE.height); 20 | 21 | document.body.style.backgroundColor = 22 | bg.mode === 'dark' ? '#222' : '#fff'; 23 | 24 | if (bg.lines) { 25 | ctx.lineWidth = 1; 26 | ctx.strokeStyle = bg.mode === 'dark' ? '#444' : '#ddd'; 27 | for (let i = 0; i < CANVAS_SIZE.height; i += 25) { 28 | ctx.beginPath(); 29 | ctx.moveTo(0, i); 30 | ctx.lineTo(ctx.canvas.width, i); 31 | ctx.stroke(); 32 | } 33 | 34 | for (let i = 0; i < CANVAS_SIZE.width; i += 25) { 35 | ctx.beginPath(); 36 | ctx.moveTo(i, 0); 37 | ctx.lineTo(i, ctx.canvas.height); 38 | ctx.stroke(); 39 | } 40 | } 41 | } 42 | }, [bgRef, bg]); 43 | 44 | return ( 45 | 52 | ); 53 | }; 54 | 55 | export default Background; 56 | -------------------------------------------------------------------------------- /modules/room/modules/toolbar/components/ModePicker.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | 3 | import { AiOutlineSelect } from 'react-icons/ai'; 4 | import { BsPencilFill } from 'react-icons/bs'; 5 | import { FaEraser } from 'react-icons/fa'; 6 | 7 | import { useOptions, useSetSelection } from '@/common/recoil/options'; 8 | 9 | const ModePicker = () => { 10 | const [options, setOptions] = useOptions(); 11 | const { clearSelection } = useSetSelection(); 12 | 13 | useEffect(() => { 14 | clearSelection(); 15 | 16 | // eslint-disable-next-line react-hooks/exhaustive-deps 17 | }, [options.mode]); 18 | 19 | return ( 20 | <> 21 | 34 | 35 | 48 | 49 | 62 | 63 | ); 64 | }; 65 | 66 | export default ModePicker; 67 | -------------------------------------------------------------------------------- /modules/room/modules/toolbar/components/LineWidthPicker.tsx: -------------------------------------------------------------------------------- 1 | import { useRef, useState } from 'react'; 2 | 3 | import { AnimatePresence, motion } from 'framer-motion'; 4 | import { BsBorderWidth } from 'react-icons/bs'; 5 | import { useClickAway } from 'react-use'; 6 | 7 | import { useOptions } from '@/common/recoil/options'; 8 | 9 | import { EntryAnimation } from '../animations/Entry.animations'; 10 | 11 | const LineWidthPicker = () => { 12 | const [options, setOptions] = useOptions(); 13 | 14 | const ref = useRef(null); 15 | 16 | const [opened, setOpened] = useState(false); 17 | 18 | useClickAway(ref, () => setOpened(false)); 19 | 20 | return ( 21 |
22 | 29 | 30 | {opened && ( 31 | 38 | 44 | setOptions((prev) => ({ 45 | ...prev, 46 | lineWidth: parseInt(e.target.value, 10), 47 | })) 48 | } 49 | className="h-4 w-full cursor-pointer appearance-none rounded-lg bg-gray-200" 50 | /> 51 | 52 | )} 53 | 54 |
55 | ); 56 | }; 57 | 58 | export default LineWidthPicker; 59 | -------------------------------------------------------------------------------- /modules/modal/components/ModalManager.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | 3 | import { AnimatePresence, motion } from 'framer-motion'; 4 | import { useRecoilState } from 'recoil'; 5 | 6 | import Portal from '@/common/components/portal/components/Portal'; 7 | 8 | import { 9 | bgAnimation, 10 | modalAnimation, 11 | } from '../animations/ModalManager.animations'; 12 | import { modalAtom } from '../recoil/modal.atom'; 13 | 14 | const ModalManager = () => { 15 | const [{ opened, modal }, setModal] = useRecoilState(modalAtom); 16 | 17 | const [portalNode, setPortalNode] = useState(); 18 | 19 | useEffect(() => { 20 | if (!portalNode) { 21 | const node = document.getElementById('portal'); 22 | if (node) setPortalNode(node); 23 | return; 24 | } 25 | 26 | if (opened) { 27 | portalNode.style.pointerEvents = 'all'; 28 | } else { 29 | portalNode.style.pointerEvents = 'none'; 30 | } 31 | }, [opened, portalNode]); 32 | 33 | return ( 34 | 35 | setModal({ modal: <>, opened: false })} 38 | variants={bgAnimation} 39 | initial="closed" 40 | animate={opened ? 'opened' : 'closed'} 41 | > 42 | 43 | {opened && ( 44 | e.stopPropagation()} 50 | className="p-6" 51 | > 52 | {modal} 53 | 54 | )} 55 | 56 | 57 | 58 | ); 59 | }; 60 | 61 | export default ModalManager; 62 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "collabio", 3 | "private": true, 4 | "scripts": { 5 | "dev": "nodemon server/index.ts", 6 | "dev:client": "ts-node server/index.ts", 7 | "build:server": "tsc --project tsconfig.server.json", 8 | "build:next": "next build", 9 | "build": "npm-run-all build:*", 10 | "start": "NODE_ENV=production node build/server/index.js", 11 | "lint": "next lint" 12 | }, 13 | "dependencies": { 14 | "express": "^4.17.3", 15 | "framer-motion": "^6.3.3", 16 | "next": "latest", 17 | "react": "^17.0.2", 18 | "react-colorful": "^5.5.1", 19 | "react-dom": "^17.0.2", 20 | "react-icons": "^4.3.1", 21 | "react-image-file-resizer": "^0.4.8", 22 | "react-toastify": "^9.0.3", 23 | "react-use": "^17.3.2", 24 | "recoil": "^0.7.3-alpha.2", 25 | "socket.io": "^4.5.0", 26 | "socket.io-client": "^4.5.0", 27 | "uuid": "^8.3.2" 28 | }, 29 | "devDependencies": { 30 | "@types/express": "^4.17.13", 31 | "@types/node": "17.0.4", 32 | "@types/react": "17.0.38", 33 | "@types/react-dom": "^18.0.4", 34 | "@types/uuid": "^8.3.4", 35 | "autoprefixer": "^10.4.0", 36 | "eslint": "8.2.0", 37 | "eslint-config-airbnb": "^19.0.4", 38 | "eslint-config-airbnb-typescript": "^16.1.4", 39 | "eslint-config-next": "^12.1.2", 40 | "eslint-config-prettier": "^8.5.0", 41 | "eslint-plugin-import": "^2.25.4", 42 | "eslint-plugin-jsx-a11y": "^6.5.1", 43 | "eslint-plugin-prettier": "^4.0.0", 44 | "eslint-plugin-react": "^7.29.4", 45 | "eslint-plugin-react-hooks": "^4.3.0", 46 | "eslint-plugin-tailwindcss": "^3.5.0", 47 | "eslint-plugin-unused-imports": "^2.0.0", 48 | "npm-run-all": "^4.1.5", 49 | "postcss": "^8.4.5", 50 | "prettier": "^2.6.1", 51 | "prettier-plugin-tailwindcss": "^0.1.8", 52 | "tailwindcss": "^3.0.7", 53 | "typescript": "4.5.4" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /modules/room/modules/board/components/SelectionBtns.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | 3 | import { AiOutlineDelete } from 'react-icons/ai'; 4 | import { BsArrowsMove } from 'react-icons/bs'; 5 | import { FiCopy } from 'react-icons/fi'; 6 | 7 | import { useOptionsValue } from '@/common/recoil/options'; 8 | 9 | import { useRefs } from '../../../hooks/useRefs'; 10 | import { useBoardPosition } from '../hooks/useBoardPosition'; 11 | 12 | const SelectionBtns = () => { 13 | const { selection } = useOptionsValue(); 14 | const { selectionRefs } = useRefs(); 15 | const boardPos = useBoardPosition(); 16 | 17 | const [boardX, setX] = useState(0); 18 | const [boardY, setY] = useState(0); 19 | 20 | useEffect(() => { 21 | const unsubscribe = boardPos.x.onChange(setX); 22 | return unsubscribe; 23 | }, [boardPos.x]); 24 | 25 | useEffect(() => { 26 | const unsubscribe = boardPos.y.onChange(setY); 27 | return unsubscribe; 28 | }, [boardPos.y]); 29 | 30 | let top = -40; 31 | let left = -40; 32 | 33 | if (selection) { 34 | const { x, y, width, height } = selection; 35 | top = Math.min(y, y + height) - 40 + boardY; 36 | left = Math.min(x, x + width) + boardX; 37 | } 38 | 39 | return ( 40 |
44 | 52 | 60 | 68 |
69 | ); 70 | }; 71 | 72 | export default SelectionBtns; 73 | -------------------------------------------------------------------------------- /modules/room/modules/board/helpers/Canvas.helpers.ts: -------------------------------------------------------------------------------- 1 | const getWidthAndHeight = ( 2 | x: number, 3 | y: number, 4 | from: [number, number], 5 | shift?: boolean 6 | ) => { 7 | let width = x - from[0]; 8 | let height = y - from[1]; 9 | 10 | if (shift) { 11 | if (Math.abs(width) > Math.abs(height)) { 12 | if ((width > 0 && height < 0) || (width < 0 && height > 0)) 13 | width = -height; 14 | else width = height; 15 | } else if ((height > 0 && width < 0) || (height < 0 && width > 0)) 16 | height = -width; 17 | else height = width; 18 | } else { 19 | width = x - from[0]; 20 | height = y - from[1]; 21 | } 22 | 23 | return { width, height }; 24 | }; 25 | 26 | export const drawCircle = ( 27 | ctx: CanvasRenderingContext2D, 28 | from: [number, number], 29 | x: number, 30 | y: number, 31 | shift?: boolean 32 | ) => { 33 | ctx.beginPath(); 34 | 35 | const { width, height } = getWidthAndHeight(x, y, from, shift); 36 | 37 | const cX = from[0] + width / 2; 38 | const cY = from[1] + height / 2; 39 | const radiusX = Math.abs(width / 2); 40 | const radiusY = Math.abs(height / 2); 41 | 42 | ctx.ellipse(cX, cY, radiusX, radiusY, 0, 0, 2 * Math.PI); 43 | 44 | ctx.stroke(); 45 | ctx.fill(); 46 | ctx.closePath(); 47 | 48 | return { cX, cY, radiusX, radiusY }; 49 | }; 50 | 51 | export const drawRect = ( 52 | ctx: CanvasRenderingContext2D, 53 | from: [number, number], 54 | x: number, 55 | y: number, 56 | shift?: boolean, 57 | fill?: boolean 58 | ) => { 59 | ctx.beginPath(); 60 | 61 | const { width, height } = getWidthAndHeight(x, y, from, shift); 62 | 63 | if (fill) ctx.fillRect(from[0], from[1], width, height); 64 | else ctx.rect(from[0], from[1], width, height); 65 | 66 | ctx.stroke(); 67 | ctx.fill(); 68 | ctx.closePath(); 69 | 70 | return { width, height }; 71 | }; 72 | 73 | export const drawLine = ( 74 | ctx: CanvasRenderingContext2D, 75 | from: [number, number], 76 | x: number, 77 | y: number, 78 | shift?: boolean 79 | ) => { 80 | if (shift) { 81 | ctx.beginPath(); 82 | ctx.lineTo(from[0], from[1]); 83 | ctx.lineTo(x, y); 84 | ctx.stroke(); 85 | ctx.closePath(); 86 | 87 | return; 88 | } 89 | 90 | ctx.lineTo(x, y); 91 | ctx.stroke(); 92 | }; 93 | -------------------------------------------------------------------------------- /modules/room/modules/toolbar/components/ColorPicker.tsx: -------------------------------------------------------------------------------- 1 | import { useRef, useState } from 'react'; 2 | 3 | import { AnimatePresence, motion } from 'framer-motion'; 4 | import { RgbaColorPicker } from 'react-colorful'; 5 | import { BsPaletteFill } from 'react-icons/bs'; 6 | import { useClickAway } from 'react-use'; 7 | 8 | import { useOptions } from '@/common/recoil/options/options.hooks'; 9 | 10 | import { EntryAnimation } from '../animations/Entry.animations'; 11 | 12 | const ColorPicker = () => { 13 | const [options, setOptions] = useOptions(); 14 | 15 | const ref = useRef(null); 16 | 17 | const [opened, setOpened] = useState(false); 18 | 19 | useClickAway(ref, () => setOpened(false)); 20 | 21 | return ( 22 |
23 | 30 | 31 | {opened && ( 32 | 39 |

40 | Line color 41 |

42 | { 45 | setOptions({ 46 | ...options, 47 | lineColor: e, 48 | }); 49 | }} 50 | className="mb-5" 51 | /> 52 |

53 | Fill color 54 |

55 | { 58 | setOptions({ 59 | ...options, 60 | fillColor: e, 61 | }); 62 | }} 63 | /> 64 |
65 | )} 66 |
67 |
68 | ); 69 | }; 70 | 71 | export default ColorPicker; 72 | -------------------------------------------------------------------------------- /modules/room/modules/board/components/UserMouse.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | 3 | import { motion } from 'framer-motion'; 4 | import { BsCursorFill } from 'react-icons/bs'; 5 | 6 | import { socket } from '@/common/lib/socket'; 7 | import { useRoom } from '@/common/recoil/room'; 8 | 9 | import { useBoardPosition } from '../hooks/useBoardPosition'; 10 | 11 | const UserMouse = ({ userId }: { userId: string }) => { 12 | const { users } = useRoom(); 13 | const boardPos = useBoardPosition(); 14 | 15 | const [msg, setMsg] = useState(''); 16 | const [x, setX] = useState(boardPos.x.get()); 17 | const [y, setY] = useState(boardPos.y.get()); 18 | const [pos, setPos] = useState({ x: -1, y: -1 }); 19 | 20 | useEffect(() => { 21 | socket.on('mouse_moved', (newX, newY, socketIdMoved) => { 22 | if (socketIdMoved === userId) { 23 | setPos({ x: newX, y: newY }); 24 | } 25 | }); 26 | 27 | const handleNewMsg = (msgUserId: string, newMsg: string) => { 28 | if (msgUserId === userId) { 29 | setMsg(newMsg); 30 | 31 | setTimeout(() => { 32 | setMsg(''); 33 | }, 3000); 34 | } 35 | }; 36 | socket.on('new_msg', handleNewMsg); 37 | 38 | return () => { 39 | socket.off('mouse_moved'); 40 | socket.off('new_msg', handleNewMsg); 41 | }; 42 | }, [userId]); 43 | 44 | useEffect(() => { 45 | const unsubscribe = boardPos.x.onChange(setX); 46 | return unsubscribe; 47 | }, [boardPos.x]); 48 | 49 | useEffect(() => { 50 | const unsubscribe = boardPos.y.onChange(setY); 51 | return unsubscribe; 52 | }, [boardPos.y]); 53 | 54 | return ( 55 | 63 | 64 | {msg && ( 65 |

66 | {msg} 67 |

68 | )} 69 |

{users.get(userId)?.name || 'Anonymous'}

70 |
71 | ); 72 | }; 73 | 74 | export default UserMouse; 75 | -------------------------------------------------------------------------------- /common/types/global.ts: -------------------------------------------------------------------------------- 1 | import { RgbaColor } from 'react-colorful'; 2 | 3 | export type Shape = 'line' | 'circle' | 'rect' | 'image'; 4 | export type CtxMode = 'eraser' | 'draw' | 'select'; 5 | 6 | export interface CtxOptions { 7 | lineWidth: number; 8 | lineColor: RgbaColor; 9 | fillColor: RgbaColor; 10 | shape: Shape; 11 | mode: CtxMode; 12 | selection: { 13 | x: number; 14 | y: number; 15 | width: number; 16 | height: number; 17 | } | null; 18 | } 19 | 20 | export interface Move { 21 | circle: { 22 | cX: number; 23 | cY: number; 24 | radiusX: number; 25 | radiusY: number; 26 | }; 27 | rect: { 28 | width: number; 29 | height: number; 30 | }; 31 | img: { 32 | base64: string; 33 | }; 34 | path: [number, number][]; 35 | options: CtxOptions; 36 | timestamp: number; 37 | id: string; 38 | } 39 | 40 | export type Room = { 41 | usersMoves: Map; 42 | drawed: Move[]; 43 | users: Map; 44 | }; 45 | 46 | export interface User { 47 | name: string; 48 | color: string; 49 | } 50 | 51 | export interface ClientRoom { 52 | id: string; 53 | usersMoves: Map; 54 | movesWithoutUser: Move[]; 55 | myMoves: Move[]; 56 | users: Map; 57 | } 58 | 59 | export interface MessageType { 60 | userId: string; 61 | username: string; 62 | color: string; 63 | msg: string; 64 | id: number; 65 | } 66 | 67 | export interface ServerToClientEvents { 68 | room_exists: (exists: boolean) => void; 69 | joined: (roomId: string, failed?: boolean) => void; 70 | room: (room: Room, usersMovesToParse: string, usersToParse: string) => void; 71 | created: (roomId: string) => void; 72 | your_move: (move: Move) => void; 73 | user_draw: (move: Move, userId: string) => void; 74 | user_undo(userId: string): void; 75 | mouse_moved: (x: number, y: number, userId: string) => void; 76 | new_user: (userId: string, username: string) => void; 77 | user_disconnected: (userId: string) => void; 78 | new_msg: (userId: string, msg: string) => void; 79 | } 80 | 81 | export interface ClientToServerEvents { 82 | check_room: (roomId: string) => void; 83 | draw: (move: Move) => void; 84 | mouse_move: (x: number, y: number) => void; 85 | undo: () => void; 86 | create_room: (username: string) => void; 87 | join_room: (room: string, username: string) => void; 88 | joined_room: () => void; 89 | leave_room: () => void; 90 | send_msg: (msg: string) => void; 91 | } 92 | -------------------------------------------------------------------------------- /modules/room/modules/toolbar/components/ShapeSelector.tsx: -------------------------------------------------------------------------------- 1 | import { useRef, useState } from 'react'; 2 | 3 | import { motion, AnimatePresence } from 'framer-motion'; 4 | import { BiRectangle } from 'react-icons/bi'; 5 | import { BsCircle } from 'react-icons/bs'; 6 | import { CgShapeZigzag } from 'react-icons/cg'; 7 | import { useClickAway } from 'react-use'; 8 | 9 | import { useOptions } from '@/common/recoil/options'; 10 | import { Shape } from '@/common/types/global'; 11 | 12 | import { EntryAnimation } from '../animations/Entry.animations'; 13 | 14 | const ShapeSelector = () => { 15 | const [options, setOptions] = useOptions(); 16 | 17 | const ref = useRef(null); 18 | 19 | const [opened, setOpened] = useState(false); 20 | 21 | useClickAway(ref, () => setOpened(false)); 22 | 23 | const handleShapeChange = (shape: Shape) => { 24 | setOptions((prev) => ({ 25 | ...prev, 26 | shape, 27 | })); 28 | 29 | setOpened(false); 30 | }; 31 | 32 | return ( 33 |
34 | 43 | 44 | 45 | {opened && ( 46 | 53 | 59 | 60 | 66 | 67 | 73 | 74 | )} 75 | 76 |
77 | ); 78 | }; 79 | 80 | export default ShapeSelector; 81 | -------------------------------------------------------------------------------- /modules/room/components/NameInput.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 NotFoundModal from '@/modules/home/modals/NotFound'; 8 | import { useModal } from '@/modules/modal'; 9 | 10 | const NameInput = () => { 11 | const setRoomId = useSetRoomId(); 12 | const { openModal } = useModal(); 13 | 14 | const [name, setName] = useState(''); 15 | 16 | const router = useRouter(); 17 | const roomId = (router.query.roomId || '').toString(); 18 | 19 | useEffect(() => { 20 | if (!roomId) return; 21 | 22 | socket.emit('check_room', roomId); 23 | 24 | socket.on('room_exists', (exists) => { 25 | if (!exists) { 26 | router.push('/'); 27 | } 28 | }); 29 | 30 | // eslint-disable-next-line consistent-return 31 | return () => { 32 | socket.off('room_exists'); 33 | }; 34 | }, [roomId, router]); 35 | 36 | useEffect(() => { 37 | const handleJoined = (roomIdFromServer: string, failed?: boolean) => { 38 | if (failed) { 39 | router.push('/'); 40 | openModal(); 41 | } else setRoomId(roomIdFromServer); 42 | }; 43 | 44 | socket.on('joined', handleJoined); 45 | 46 | return () => { 47 | socket.off('joined', handleJoined); 48 | }; 49 | }, [openModal, router, setRoomId]); 50 | 51 | const handleJoinRoom = (e: FormEvent) => { 52 | e.preventDefault(); 53 | 54 | socket.emit('join_room', roomId, name); 55 | }; 56 | 57 | return ( 58 |
62 |

63 | Collabio 64 |

65 |

Real-time whiteboard

66 | 67 |
68 | 71 | setName(e.target.value.slice(0, 15))} 77 | /> 78 |
79 | 80 | 83 |
84 | ); 85 | }; 86 | 87 | export default NameInput; 88 | -------------------------------------------------------------------------------- /modules/room/modules/board/components/MoveImage.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | 3 | import { motion, useMotionValue } from 'framer-motion'; 4 | import { AiOutlineCheck, AiOutlineClose } from 'react-icons/ai'; 5 | 6 | import { DEFAULT_MOVE } from '@/common/constants/defaultMove'; 7 | import { getPos } from '@/common/lib/getPos'; 8 | import { socket } from '@/common/lib/socket'; 9 | import { Move } from '@/common/types/global'; 10 | 11 | import { useMoveImage } from '../../../hooks/useMoveImage'; 12 | import { useBoardPosition } from '../hooks/useBoardPosition'; 13 | 14 | const MoveImage = () => { 15 | const { x, y } = useBoardPosition(); 16 | const { moveImage, setMoveImage } = useMoveImage(); 17 | 18 | const imageX = useMotionValue(moveImage.x || 50); 19 | const imageY = useMotionValue(moveImage.y || 50); 20 | 21 | useEffect(() => { 22 | if (moveImage.x) imageX.set(moveImage.x); 23 | else imageX.set(50); 24 | if (moveImage.y) imageY.set(moveImage.y); 25 | else imageY.set(50); 26 | }, [imageX, imageY, moveImage.x, moveImage.y]); 27 | 28 | const handlePlaceImage = () => { 29 | const [finalX, finalY] = [getPos(imageX.get(), x), getPos(imageY.get(), y)]; 30 | 31 | const move: Move = { 32 | ...DEFAULT_MOVE, 33 | img: { base64: moveImage.base64 }, 34 | path: [[finalX, finalY]], 35 | options: { 36 | ...DEFAULT_MOVE.options, 37 | selection: null, 38 | shape: 'image', 39 | }, 40 | }; 41 | 42 | socket.emit('draw', move); 43 | 44 | setMoveImage({ base64: '' }); 45 | imageX.set(50); 46 | imageY.set(50); 47 | }; 48 | 49 | if (!moveImage.base64) return null; 50 | 51 | return ( 52 | 59 |
60 | 66 | 72 |
73 | image to place 78 |
79 | ); 80 | }; 81 | 82 | export default MoveImage; 83 | -------------------------------------------------------------------------------- /common/styles/global.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | * { 6 | @apply font-montserrat font-medium focus:outline-none focus:ring focus:ring-red-500; 7 | } 8 | 9 | @layer components { 10 | .btn-icon { 11 | @apply flex h-7 w-7 items-center justify-center rounded-md text-xl text-white transition-all hover:scale-125 active:scale-100 disabled:opacity-25; 12 | } 13 | 14 | .btn { 15 | @apply rounded-xl bg-black p-5 py-1 text-white transition-all hover:scale-105 active:scale-100; 16 | } 17 | 18 | .input { 19 | @apply rounded-xl border p-5 py-1; 20 | } 21 | 22 | .overflow-overlay { 23 | overflow-y: scroll; 24 | overflow-y: overlay; 25 | } 26 | } 27 | 28 | .drag { 29 | cursor: grab; 30 | } 31 | 32 | body { 33 | min-height: 100vh; 34 | min-height: -webkit-fill-available; 35 | overscroll-behavior: contain; 36 | cursor: url('data:image/svg+xml;utf8,'), 37 | auto; 38 | } 39 | html { 40 | height: -webkit-fill-available; 41 | } 42 | 43 | #__next, 44 | #portal { 45 | z-index: 0; 46 | position: absolute; 47 | top: 0; 48 | bottom: 0; 49 | left: 0; 50 | right: 0; 51 | overflow-x: hidden; 52 | overflow-y: scroll; 53 | overflow-y: overlay; 54 | } 55 | 56 | #portal { 57 | z-index: 1; 58 | } 59 | 60 | input[type='range']::-webkit-slider-thumb { 61 | -webkit-appearance: none; 62 | height: 1.2rem; 63 | width: 1.2rem; 64 | border-radius: 50%; 65 | background: #000; 66 | cursor: pointer; 67 | } 68 | 69 | /* All the same stuff for Firefox */ 70 | input[type='range']::-moz-range-thumb { 71 | height: 1.2rem; 72 | width: 1.2rem; 73 | border-radius: 50%; 74 | background: #000; 75 | cursor: pointer; 76 | } 77 | 78 | /* All the same stuff for IE */ 79 | input[type='range']::-ms-thumb { 80 | height: 1.2rem; 81 | width: 1.2rem; 82 | border-radius: 50%; 83 | background: #000; 84 | cursor: pointer; 85 | } 86 | 87 | *::-webkit-scrollbar-track { 88 | -webkit-box-shadow: none !important; 89 | background-color: transparent !important; 90 | } 91 | 92 | *::-webkit-scrollbar { 93 | width: 6px !important; 94 | position: absolute; 95 | background-color: transparent; 96 | } 97 | 98 | *::-webkit-scrollbar-thumb { 99 | @apply bg-black/75; 100 | } 101 | -------------------------------------------------------------------------------- /modules/room/modules/chat/components/Chat.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, useState } from 'react'; 2 | 3 | import { motion } from 'framer-motion'; 4 | import { BsFillChatFill } from 'react-icons/bs'; 5 | import { FaChevronDown } from 'react-icons/fa'; 6 | import { useList } from 'react-use'; 7 | 8 | import { socket } from '@/common/lib/socket'; 9 | import { useRoom } from '@/common/recoil/room'; 10 | import { MessageType } from '@/common/types/global'; 11 | 12 | import ChatInput from './ChatInput'; 13 | import Message from './Message'; 14 | 15 | const Chat = () => { 16 | const room = useRoom(); 17 | 18 | const msgList = useRef(null); 19 | 20 | const [newMsg, setNewMsg] = useState(false); 21 | const [opened, setOpened] = useState(false); 22 | const [msgs, handleMsgs] = useList([]); 23 | 24 | useEffect(() => { 25 | const handleNewMsg = (userId: string, msg: string) => { 26 | const user = room.users.get(userId); 27 | 28 | handleMsgs.push({ 29 | userId, 30 | msg, 31 | id: msgs.length + 1, 32 | username: user?.name || 'Anonymous', 33 | color: user?.color || '#000', 34 | }); 35 | 36 | msgList.current?.scroll({ top: msgList.current?.scrollHeight }); 37 | 38 | if (!opened) setNewMsg(true); 39 | }; 40 | 41 | socket.on('new_msg', handleNewMsg); 42 | 43 | return () => { 44 | socket.off('new_msg', handleNewMsg); 45 | }; 46 | }, [handleMsgs, msgs, opened, room.users]); 47 | 48 | return ( 49 | 54 | 78 |
79 |
80 | {msgs.map((msg) => ( 81 | 82 | ))} 83 |
84 | 85 |
86 |
87 | ); 88 | }; 89 | 90 | export default Chat; 91 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | // Configuration for JavaScript files 3 | "extends": [ 4 | "airbnb-base", 5 | "next/core-web-vitals", 6 | "plugin:prettier/recommended" 7 | ], 8 | "rules": { 9 | "prettier/prettier": [ 10 | "error", 11 | { 12 | "singleQuote": true, 13 | "semi": true 14 | } 15 | ] 16 | }, 17 | "overrides": [ 18 | // Configuration for TypeScript files 19 | { 20 | "files": ["**/*.ts", "**/*.tsx"], 21 | "plugins": ["@typescript-eslint", "unused-imports", "tailwindcss"], 22 | "extends": [ 23 | "plugin:tailwindcss/recommended", 24 | "airbnb-typescript", 25 | "next/core-web-vitals", 26 | "plugin:prettier/recommended" 27 | ], 28 | "parserOptions": { 29 | "project": "./tsconfig.json" 30 | }, 31 | "rules": { 32 | "prettier/prettier": [ 33 | "error", 34 | { 35 | "singleQuote": true, 36 | "endOfLine": "auto", 37 | "semi": true 38 | } 39 | ], 40 | "react/destructuring-assignment": "off", // Vscode doesn't support automatically destructuring, it's a pain to add a new variable 41 | "jsx-a11y/anchor-is-valid": "off", // Next.js use his own internal link system 42 | "react/require-default-props": "off", // Allow non-defined react props as undefined 43 | "react/jsx-props-no-spreading": "off", // _app.tsx uses spread operator and also, react-hook-form 44 | "@next/next/no-img-element": "off", // We currently not using next/image because it isn't supported with SSG mode 45 | "import/order": [ 46 | "error", 47 | { 48 | "groups": ["builtin", "external", "internal"], 49 | "pathGroups": [ 50 | { 51 | "pattern": "react", 52 | "group": "external", 53 | "position": "before" 54 | } 55 | ], 56 | "pathGroupsExcludedImportTypes": ["react"], 57 | "newlines-between": "always", 58 | "alphabetize": { 59 | "order": "asc", 60 | "caseInsensitive": true 61 | } 62 | } 63 | ], 64 | "@typescript-eslint/comma-dangle": "off", // Avoid conflict rule between Eslint and Prettier 65 | "import/prefer-default-export": "off", // Named export is easier to refactor automatically 66 | "class-methods-use-this": "off", // _document.tsx use render method without `this` keyword 67 | "tailwindcss/classnames-order": [ 68 | "warn", 69 | { 70 | "officialSorting": true 71 | } 72 | ], // Follow the same ordering as the official plugin `prettier-plugin-tailwindcss` 73 | "@typescript-eslint/no-unused-vars": "off", 74 | "unused-imports/no-unused-imports": "error", 75 | "unused-imports/no-unused-vars": [ 76 | "error", 77 | { "argsIgnorePattern": "^_" } 78 | ] 79 | } 80 | } 81 | ] 82 | } 83 | -------------------------------------------------------------------------------- /.github/workflows/codesee-arch-diagram.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: 4 | - main 5 | pull_request_target: 6 | types: [opened, synchronize, reopened] 7 | 8 | name: CodeSee Map 9 | 10 | jobs: 11 | test_map_action: 12 | runs-on: ubuntu-latest 13 | continue-on-error: true 14 | name: Run CodeSee Map Analysis 15 | steps: 16 | - name: checkout 17 | id: checkout 18 | uses: actions/checkout@v2 19 | with: 20 | repository: ${{ github.event.pull_request.head.repo.full_name }} 21 | ref: ${{ github.event.pull_request.head.ref }} 22 | fetch-depth: 0 23 | 24 | # codesee-detect-languages has an output with id languages. 25 | - name: Detect Languages 26 | id: detect-languages 27 | uses: Codesee-io/codesee-detect-languages-action@latest 28 | 29 | - name: Configure JDK 16 30 | uses: actions/setup-java@v2 31 | if: ${{ fromJSON(steps.detect-languages.outputs.languages).java }} 32 | with: 33 | java-version: '16' 34 | distribution: 'zulu' 35 | 36 | # CodeSee Maps Go support uses a static binary so there's no setup step required. 37 | 38 | - name: Configure Node.js 14 39 | uses: actions/setup-node@v2 40 | if: ${{ fromJSON(steps.detect-languages.outputs.languages).javascript }} 41 | with: 42 | node-version: '14' 43 | 44 | - name: Configure Python 3.x 45 | uses: actions/setup-python@v2 46 | if: ${{ fromJSON(steps.detect-languages.outputs.languages).python }} 47 | with: 48 | python-version: '3.10' 49 | architecture: 'x64' 50 | 51 | - name: Configure Ruby '3.x' 52 | uses: ruby/setup-ruby@v1 53 | if: ${{ fromJSON(steps.detect-languages.outputs.languages).ruby }} 54 | with: 55 | ruby-version: '3.0' 56 | 57 | # We need the rust toolchain because it uses rustc and cargo to inspect the package 58 | - name: Configure Rust 1.x stable 59 | uses: actions-rs/toolchain@v1 60 | if: ${{ fromJSON(steps.detect-languages.outputs.languages).rust }} 61 | with: 62 | toolchain: stable 63 | 64 | - name: Generate Map 65 | id: generate-map 66 | uses: Codesee-io/codesee-map-action@latest 67 | with: 68 | step: map 69 | api_token: ${{ secrets.CODESEE_ARCH_DIAG_API_TOKEN }} 70 | github_ref: ${{ github.ref }} 71 | languages: ${{ steps.detect-languages.outputs.languages }} 72 | 73 | - name: Upload Map 74 | id: upload-map 75 | uses: Codesee-io/codesee-map-action@latest 76 | with: 77 | step: mapUpload 78 | api_token: ${{ secrets.CODESEE_ARCH_DIAG_API_TOKEN }} 79 | github_ref: ${{ github.ref }} 80 | 81 | - name: Insights 82 | id: insights 83 | uses: Codesee-io/codesee-map-action@latest 84 | with: 85 | step: insights 86 | api_token: ${{ secrets.CODESEE_ARCH_DIAG_API_TOKEN }} 87 | github_ref: ${{ github.ref }} 88 | -------------------------------------------------------------------------------- /modules/room/modules/toolbar/modals/BackgroundModal.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | 3 | import { AiOutlineClose } from 'react-icons/ai'; 4 | 5 | import { useBackground, useSetBackground } from '@/common/recoil/background'; 6 | import { useModal } from '@/modules/modal'; 7 | 8 | const BackgroundModal = () => { 9 | const { closeModal } = useModal(); 10 | const setBackground = useSetBackground(); 11 | const bg = useBackground(); 12 | 13 | useEffect(() => closeModal, [bg, closeModal]); 14 | 15 | const renderBg = ( 16 | ref: HTMLCanvasElement | null, 17 | mode: 'dark' | 'light', 18 | lines: boolean 19 | ) => { 20 | const ctx = ref?.getContext('2d'); 21 | if (ctx) { 22 | ctx.fillStyle = mode === 'dark' ? '#222' : '#fff'; 23 | ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height); 24 | 25 | if (lines) { 26 | ctx.lineWidth = 1; 27 | ctx.strokeStyle = mode === 'dark' ? '#444' : '#ddd'; 28 | for (let i = 0; i < ctx.canvas.height; i += 10) { 29 | ctx.beginPath(); 30 | ctx.moveTo(0, i); 31 | ctx.lineTo(ctx.canvas.width, i); 32 | ctx.stroke(); 33 | } 34 | 35 | for (let i = 0; i < ctx.canvas.width; i += 10) { 36 | ctx.beginPath(); 37 | ctx.moveTo(i, 0); 38 | ctx.lineTo(i, ctx.canvas.height); 39 | ctx.stroke(); 40 | } 41 | } 42 | } 43 | }; 44 | 45 | return ( 46 |
47 | 50 |

Choose background

51 |
52 | setBackground('dark', true)} 58 | ref={(ref) => renderBg(ref, 'dark', true)} 59 | /> 60 | setBackground('light', true)} 66 | ref={(ref) => renderBg(ref, 'light', true)} 67 | /> 68 | setBackground('dark', false)} 74 | ref={(ref) => renderBg(ref, 'dark', false)} 75 | /> 76 | setBackground('light', false)} 82 | ref={(ref) => renderBg(ref, 'light', false)} 83 | /> 84 |
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 |
88 | 91 | setRoomId(e.target.value)} 97 | /> 98 | 101 |
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 | --------------------------------------------------------------------------------