Repository: kriziu/collabio Branch: main Commit: 9ca643c5a33b Files: 89 Total size: 100.6 KB Directory structure: gitextract_mzh6xm00/ ├── .eslintignore ├── .eslintrc ├── .github/ │ └── workflows/ │ └── codesee-arch-diagram.yml ├── .gitignore ├── README.md ├── common/ │ ├── components/ │ │ └── portal/ │ │ └── components/ │ │ └── Portal.ts │ ├── constants/ │ │ ├── canvasSize.ts │ │ ├── colors.ts │ │ ├── defaultMove.ts │ │ └── easings.ts │ ├── hooks/ │ │ └── useViewportSize.ts │ ├── lib/ │ │ ├── getNextColor.ts │ │ ├── getPos.ts │ │ ├── optimizeImage.ts │ │ ├── rgba.ts │ │ └── socket.ts │ ├── recoil/ │ │ ├── background/ │ │ │ ├── background.atom.ts │ │ │ ├── background.hooks.ts │ │ │ └── index.ts │ │ ├── options/ │ │ │ ├── index.ts │ │ │ ├── options.atom.ts │ │ │ └── options.hooks.ts │ │ ├── room/ │ │ │ ├── index.ts │ │ │ ├── room.atom.ts │ │ │ └── room.hooks.ts │ │ └── savedMoves/ │ │ ├── index.ts │ │ ├── savedMoves.atom.ts │ │ └── savedMoves.hooks.ts │ ├── styles/ │ │ └── global.css │ └── types/ │ └── global.ts ├── modules/ │ ├── home/ │ │ ├── components/ │ │ │ └── Home.tsx │ │ ├── index.ts │ │ └── modals/ │ │ └── NotFound.tsx │ ├── modal/ │ │ ├── animations/ │ │ │ └── ModalManager.animations.ts │ │ ├── components/ │ │ │ └── ModalManager.tsx │ │ ├── index.ts │ │ └── recoil/ │ │ ├── modal.atom.tsx │ │ └── modal.hooks.tsx │ └── room/ │ ├── components/ │ │ ├── NameInput.tsx │ │ ├── Room.tsx │ │ └── UserList.tsx │ ├── context/ │ │ └── Room.context.tsx │ ├── hooks/ │ │ ├── useMoveImage.ts │ │ ├── useMovesHandlers.ts │ │ └── useRefs.ts │ ├── index.ts │ └── modules/ │ ├── board/ │ │ ├── components/ │ │ │ ├── Background.tsx │ │ │ ├── Canvas.tsx │ │ │ ├── Minimap.tsx │ │ │ ├── MousePosition.tsx │ │ │ ├── MousesRenderer.tsx │ │ │ ├── MoveImage.tsx │ │ │ ├── SelectionBtns.tsx │ │ │ └── UserMouse.tsx │ │ ├── helpers/ │ │ │ └── Canvas.helpers.ts │ │ ├── hooks/ │ │ │ ├── useBoardPosition.ts │ │ │ ├── useCtx.ts │ │ │ ├── useDraw.ts │ │ │ ├── useSelection.ts │ │ │ └── useSocketDraw.ts │ │ └── index.tsx │ ├── chat/ │ │ ├── components/ │ │ │ ├── Chat.tsx │ │ │ ├── ChatInput.tsx │ │ │ └── Message.tsx │ │ └── index.ts │ └── toolbar/ │ ├── animations/ │ │ └── Entry.animations.ts │ ├── components/ │ │ ├── BackgoundPicker.tsx │ │ ├── ColorPicker.tsx │ │ ├── HistoryBtns.tsx │ │ ├── ImagePicker.tsx │ │ ├── LineWidthPicker.tsx │ │ ├── ModePicker.tsx │ │ ├── ShapeSelector.tsx │ │ └── ToolBar.tsx │ ├── index.ts │ └── modals/ │ ├── BackgroundModal.tsx │ └── ShareModal.tsx ├── next-env.d.ts ├── next.config.js ├── package.json ├── pages/ │ ├── [roomId].tsx │ ├── _app.tsx │ ├── _document.tsx │ └── index.tsx ├── postcss.config.js ├── server/ │ └── index.ts ├── tailwind.config.js ├── tsconfig.json └── tsconfig.server.json ================================================ FILE CONTENTS ================================================ ================================================ FILE: .eslintignore ================================================ node_modules out build .next tailwind.config.js postcss.config.js ================================================ FILE: .eslintrc ================================================ { // Configuration for JavaScript files "extends": [ "airbnb-base", "next/core-web-vitals", "plugin:prettier/recommended" ], "rules": { "prettier/prettier": [ "error", { "singleQuote": true, "semi": true } ] }, "overrides": [ // Configuration for TypeScript files { "files": ["**/*.ts", "**/*.tsx"], "plugins": ["@typescript-eslint", "unused-imports", "tailwindcss"], "extends": [ "plugin:tailwindcss/recommended", "airbnb-typescript", "next/core-web-vitals", "plugin:prettier/recommended" ], "parserOptions": { "project": "./tsconfig.json" }, "rules": { "prettier/prettier": [ "error", { "singleQuote": true, "endOfLine": "auto", "semi": true } ], "react/destructuring-assignment": "off", // Vscode doesn't support automatically destructuring, it's a pain to add a new variable "jsx-a11y/anchor-is-valid": "off", // Next.js use his own internal link system "react/require-default-props": "off", // Allow non-defined react props as undefined "react/jsx-props-no-spreading": "off", // _app.tsx uses spread operator and also, react-hook-form "@next/next/no-img-element": "off", // We currently not using next/image because it isn't supported with SSG mode "import/order": [ "error", { "groups": ["builtin", "external", "internal"], "pathGroups": [ { "pattern": "react", "group": "external", "position": "before" } ], "pathGroupsExcludedImportTypes": ["react"], "newlines-between": "always", "alphabetize": { "order": "asc", "caseInsensitive": true } } ], "@typescript-eslint/comma-dangle": "off", // Avoid conflict rule between Eslint and Prettier "import/prefer-default-export": "off", // Named export is easier to refactor automatically "class-methods-use-this": "off", // _document.tsx use render method without `this` keyword "tailwindcss/classnames-order": [ "warn", { "officialSorting": true } ], // Follow the same ordering as the official plugin `prettier-plugin-tailwindcss` "@typescript-eslint/no-unused-vars": "off", "unused-imports/no-unused-imports": "error", "unused-imports/no-unused-vars": [ "error", { "argsIgnorePattern": "^_" } ] } } ] } ================================================ FILE: .github/workflows/codesee-arch-diagram.yml ================================================ on: push: branches: - main pull_request_target: types: [opened, synchronize, reopened] name: CodeSee Map jobs: test_map_action: runs-on: ubuntu-latest continue-on-error: true name: Run CodeSee Map Analysis steps: - name: checkout id: checkout uses: actions/checkout@v2 with: repository: ${{ github.event.pull_request.head.repo.full_name }} ref: ${{ github.event.pull_request.head.ref }} fetch-depth: 0 # codesee-detect-languages has an output with id languages. - name: Detect Languages id: detect-languages uses: Codesee-io/codesee-detect-languages-action@latest - name: Configure JDK 16 uses: actions/setup-java@v2 if: ${{ fromJSON(steps.detect-languages.outputs.languages).java }} with: java-version: '16' distribution: 'zulu' # CodeSee Maps Go support uses a static binary so there's no setup step required. - name: Configure Node.js 14 uses: actions/setup-node@v2 if: ${{ fromJSON(steps.detect-languages.outputs.languages).javascript }} with: node-version: '14' - name: Configure Python 3.x uses: actions/setup-python@v2 if: ${{ fromJSON(steps.detect-languages.outputs.languages).python }} with: python-version: '3.10' architecture: 'x64' - name: Configure Ruby '3.x' uses: ruby/setup-ruby@v1 if: ${{ fromJSON(steps.detect-languages.outputs.languages).ruby }} with: ruby-version: '3.0' # We need the rust toolchain because it uses rustc and cargo to inspect the package - name: Configure Rust 1.x stable uses: actions-rs/toolchain@v1 if: ${{ fromJSON(steps.detect-languages.outputs.languages).rust }} with: toolchain: stable - name: Generate Map id: generate-map uses: Codesee-io/codesee-map-action@latest with: step: map api_token: ${{ secrets.CODESEE_ARCH_DIAG_API_TOKEN }} github_ref: ${{ github.ref }} languages: ${{ steps.detect-languages.outputs.languages }} - name: Upload Map id: upload-map uses: Codesee-io/codesee-map-action@latest with: step: mapUpload api_token: ${{ secrets.CODESEE_ARCH_DIAG_API_TOKEN }} github_ref: ${{ github.ref }} - name: Insights id: insights uses: Codesee-io/codesee-map-action@latest with: step: insights api_token: ${{ secrets.CODESEE_ARCH_DIAG_API_TOKEN }} github_ref: ${{ github.ref }} ================================================ FILE: .gitignore ================================================ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. # dependencies /node_modules /.pnp .pnp.js # testing /coverage # next.js /.next/ /out/ # production /build # misc .DS_Store *.pem # debug npm-debug.log* yarn-debug.log* yarn-error.log* # local env files .env.local .env.development.local .env.test.local .env.production.local # vercel .vercel # typescript *.tsbuildinfo ================================================ FILE: README.md ================================================ # Collabio | Online whiteboard Real-time whiteboard made with Next.JS and Socket.IO ## Features - Drawing lines, circles and rectangles - Eraser - Undo/Redo - Real-time mouse tracking - Chatting - Placing images - Moving selected area - Saving canvas - Changing backgrounds - Sharing ## Made using - Next.JS - Recoil - TailwindCSS - Framer Motion - Socket.IO ## Demo LIVE DEMO: https://collabio-kriziu.herokuapp.com ## Installation Clone repository, install all npm packages and run like normal Next.JS application. ## Screenshots #### Home page ![home page](https://i.imgur.com/00CZlrR.png) #### Board page ![Board page](https://i.imgur.com/0v4Y8XP.png) ================================================ FILE: common/components/portal/components/Portal.ts ================================================ import { useEffect, useState } from 'react'; import { createPortal } from 'react-dom'; const Portal = ({ children }: { children: JSX.Element | JSX.Element[] }) => { const [portal, setPortal] = useState(); useEffect(() => { const node = document.getElementById('portal'); if (node) setPortal(node); }, []); if (!portal) return null; return createPortal(children, portal); }; export default Portal; ================================================ FILE: common/constants/canvasSize.ts ================================================ export const CANVAS_SIZE = { width: 3500, height: 2000, }; ================================================ FILE: common/constants/colors.ts ================================================ export const COLORS = { PURPLE: '#6B32F3', BLUE: '#408FF8', RED: '#F32D27', GREEN: '#6FCB12', GOLD: '#A89D6C', PINK: '#EB29DA', MINT: '#19CB87', RED_LIGHT: '#ED7878', CYAN: '#02CBF6', RED_DARK: '#BA1555', ORANGE: '#FF7300', }; export const COLORS_ARRAY = [...Object.values(COLORS)]; ================================================ FILE: common/constants/defaultMove.ts ================================================ import { Move } from '../types/global'; export const DEFAULT_MOVE: Move = { circle: { cX: 0, cY: 0, radiusX: 0, radiusY: 0, }, rect: { width: 0, height: 0, }, path: [], options: { shape: 'line', mode: 'draw', lineWidth: 1, lineColor: { r: 0, g: 0, b: 0, a: 0 }, fillColor: { r: 0, g: 0, b: 0, a: 0 }, selection: null, }, id: '', img: { base64: '', }, timestamp: 0, }; ================================================ FILE: common/constants/easings.ts ================================================ export const DEFAULT_EASE = [0.6, 0.01, -0.05, 0.9]; ================================================ FILE: common/hooks/useViewportSize.ts ================================================ import { useEffect, useState } from 'react'; export const useViewportSize = () => { const [width, setWidth] = useState(0); const [height, setHeight] = useState(0); useEffect(() => { const handleResize = () => { setWidth(window.innerWidth); setHeight(window.innerHeight); }; handleResize(); window.addEventListener('resize', handleResize); return () => { window.removeEventListener('resize', handleResize); }; }, []); return { width, height }; }; ================================================ FILE: common/lib/getNextColor.ts ================================================ import { COLORS_ARRAY } from '../constants/colors'; export const getNextColor = (color?: string) => { const index = COLORS_ARRAY.findIndex((colorArr) => colorArr === color); if (index === -1) return COLORS_ARRAY[0]; return COLORS_ARRAY[(index + 1) % COLORS_ARRAY.length]; }; ================================================ FILE: common/lib/getPos.ts ================================================ import { MotionValue } from 'framer-motion'; export const getPos = (pos: number, motionValue: MotionValue) => pos - motionValue.get(); ================================================ FILE: common/lib/optimizeImage.ts ================================================ import FileResizer from 'react-image-file-resizer'; export const optimizeImage = (file: File, callback: (uri: string) => void) => { FileResizer.imageFileResizer( file, 700, 700, 'WEBP', 100, 0, (uri) => { callback(uri.toString()); }, 'base64' ); }; ================================================ FILE: common/lib/rgba.ts ================================================ import { RgbaColor } from 'react-colorful'; export const getStringFromRgba = (rgba: RgbaColor) => `rgba(${rgba.r}, ${rgba.g}, ${rgba.b}, ${rgba.a})`; ================================================ FILE: common/lib/socket.ts ================================================ import { io, Socket } from 'socket.io-client'; import { ClientToServerEvents, ServerToClientEvents } from '../types/global'; export const socket: Socket = io(); ================================================ FILE: common/recoil/background/background.atom.ts ================================================ import { atom } from 'recoil'; export const backgroundAtom = atom<{ mode: 'dark' | 'light'; lines: boolean }>({ key: 'bg', default: { mode: 'light', lines: true, }, }); ================================================ FILE: common/recoil/background/background.hooks.ts ================================================ import { useEffect } from 'react'; import { useRecoilValue, useSetRecoilState } from 'recoil'; import { backgroundAtom } from './background.atom'; export const useBackground = () => { const bg = useRecoilValue(backgroundAtom); useEffect(() => { const root = window.document.documentElement; if (bg.mode === 'dark') { root.classList.remove('light'); root.classList.add('dark'); } else { root.classList.remove('dark'); root.classList.add('light'); } }, [bg.mode]); return bg; }; export const useSetBackground = () => { const setBg = useSetRecoilState(backgroundAtom); const setBackground = (mode: 'dark' | 'light', lines: boolean) => { setBg({ mode, lines, }); }; return setBackground; }; ================================================ FILE: common/recoil/background/index.ts ================================================ import { backgroundAtom } from './background.atom'; import { useBackground, useSetBackground } from './background.hooks'; export default backgroundAtom; export { useBackground, useSetBackground }; ================================================ FILE: common/recoil/options/index.ts ================================================ /* eslint-disable import/no-cycle */ import { optionsAtom } from './options.atom'; import { useOptions, useSetOptions, useOptionsValue, useSetSelection, } from './options.hooks'; export default optionsAtom; export { useOptions, useOptionsValue, useSetOptions, useSetSelection }; ================================================ FILE: common/recoil/options/options.atom.ts ================================================ import { atom } from 'recoil'; import { CtxOptions } from '@/common/types/global'; export const optionsAtom = atom({ key: 'options', default: { lineColor: { r: 0, g: 0, b: 0, a: 1 }, fillColor: { r: 0, g: 0, b: 0, a: 0 }, lineWidth: 5, mode: 'draw', shape: 'line', selection: null, }, }); ================================================ FILE: common/recoil/options/options.hooks.ts ================================================ import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil'; import { optionsAtom } from './options.atom'; export const useOptionsValue = () => { const options = useRecoilValue(optionsAtom); return options; }; export const useSetOptions = () => { const setOptions = useSetRecoilState(optionsAtom); return setOptions; }; export const useOptions = () => { const options = useRecoilState(optionsAtom); return options; }; export const useSetSelection = () => { const setOptions = useSetOptions(); const setSelection = (rect: { x: number; y: number; width: number; height: number; }) => { setOptions((prev) => ({ ...prev, selection: rect })); }; const clearSelection = () => { setOptions((prev) => ({ ...prev, selection: null })); }; return { setSelection, clearSelection }; }; ================================================ FILE: common/recoil/room/index.ts ================================================ import { roomAtom } from './room.atom'; import { useRoom, useSetRoomId, useSetUsers, useMyMoves } from './room.hooks'; export default roomAtom; export { useRoom, useSetRoomId, useSetUsers, useMyMoves }; ================================================ FILE: common/recoil/room/room.atom.ts ================================================ import { atom } from 'recoil'; import { ClientRoom } from '@/common/types/global'; export const DEFAULT_ROOM = { id: '', users: new Map(), usersMoves: new Map(), movesWithoutUser: [], myMoves: [], }; export const roomAtom = atom({ key: 'room', default: DEFAULT_ROOM, }); ================================================ FILE: common/recoil/room/room.hooks.ts ================================================ import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil'; import { getNextColor } from '@/common/lib/getNextColor'; import { Move } from '@/common/types/global'; import { DEFAULT_ROOM, roomAtom } from './room.atom'; export const useRoom = () => { const room = useRecoilValue(roomAtom); return room; }; export const useSetRoom = () => { const setRoom = useSetRecoilState(roomAtom); return setRoom; }; export const useSetRoomId = () => { const setRoomId = useSetRecoilState(roomAtom); const handleSetRoomId = (id: string) => { setRoomId({ ...DEFAULT_ROOM, id }); }; return handleSetRoomId; }; export const useSetUsers = () => { const setRoom = useSetRecoilState(roomAtom); const handleAddUser = (userId: string, name: string) => { setRoom((prev) => { const newUsers = prev.users; const newUsersMoves = prev.usersMoves; const color = getNextColor([...newUsers.values()].pop()?.color); newUsers.set(userId, { name, color, }); newUsersMoves.set(userId, []); return { ...prev, users: newUsers, usersMoves: newUsersMoves }; }); }; const handleRemoveUser = (userId: string) => { setRoom((prev) => { const newUsers = prev.users; const newUsersMoves = prev.usersMoves; const userMoves = newUsersMoves.get(userId); newUsers.delete(userId); newUsersMoves.delete(userId); return { ...prev, users: newUsers, usersMoves: newUsersMoves, movesWithoutUser: [...prev.movesWithoutUser, ...(userMoves || [])], }; }); }; const handleAddMoveToUser = (userId: string, moves: Move) => { setRoom((prev) => { const newUsersMoves = prev.usersMoves; const oldMoves = prev.usersMoves.get(userId); newUsersMoves.set(userId, [...(oldMoves || []), moves]); return { ...prev, usersMoves: newUsersMoves }; }); }; const handleRemoveMoveFromUser = (userId: string) => { setRoom((prev) => { const newUsersMoves = prev.usersMoves; const oldMoves = prev.usersMoves.get(userId); oldMoves?.pop(); newUsersMoves.set(userId, oldMoves || []); return { ...prev, usersMoves: newUsersMoves }; }); }; return { handleAddUser, handleRemoveUser, handleAddMoveToUser, handleRemoveMoveFromUser, }; }; export const useMyMoves = () => { const [room, setRoom] = useRecoilState(roomAtom); const handleAddMyMove = (move: Move) => { setRoom((prev) => { if (prev.myMoves[prev.myMoves.length - 1]?.options.mode === 'select') return { ...prev, myMoves: [...prev.myMoves.slice(0, prev.myMoves.length - 1), move], }; return { ...prev, myMoves: [...prev.myMoves, move] }; }); }; const handleRemoveMyMove = () => { const newMoves = [...room.myMoves]; const move = newMoves.pop(); setRoom((prev) => ({ ...prev, myMoves: newMoves })); return move; }; return { handleAddMyMove, handleRemoveMyMove, myMoves: room.myMoves }; }; ================================================ FILE: common/recoil/savedMoves/index.ts ================================================ import { savedMovesAtom } from './savedMoves.atom'; import { useSavedMoves, useSetSavedMoves } from './savedMoves.hooks'; export default savedMovesAtom; export { useSavedMoves, useSetSavedMoves }; ================================================ FILE: common/recoil/savedMoves/savedMoves.atom.ts ================================================ import { atom } from 'recoil'; import { Move } from '@/common/types/global'; export const savedMovesAtom = atom({ key: 'saved_moves', default: [], }); ================================================ FILE: common/recoil/savedMoves/savedMoves.hooks.ts ================================================ import { useRecoilValue, useSetRecoilState } from 'recoil'; import { Move } from '@/common/types/global'; import { savedMovesAtom } from './savedMoves.atom'; export const useSetSavedMoves = () => { const setSavedMoves = useSetRecoilState(savedMovesAtom); const addSavedMove = (move: Move) => { if (move.options.mode === 'select') return; setSavedMoves((prevMoves) => [move, ...prevMoves]); }; const removeSavedMove = () => { let move: Move | undefined; setSavedMoves((prevMoves) => { move = prevMoves.at(0); return prevMoves.slice(1); }); return move; }; const clearSavedMoves = () => { setSavedMoves([]); }; return { addSavedMove, removeSavedMove, clearSavedMoves }; }; export const useSavedMoves = () => { const savedMoves = useRecoilValue(savedMovesAtom); return savedMoves; }; ================================================ FILE: common/styles/global.css ================================================ @tailwind base; @tailwind components; @tailwind utilities; * { @apply font-montserrat font-medium focus:outline-none focus:ring focus:ring-red-500; } @layer components { .btn-icon { @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; } .btn { @apply rounded-xl bg-black p-5 py-1 text-white transition-all hover:scale-105 active:scale-100; } .input { @apply rounded-xl border p-5 py-1; } .overflow-overlay { overflow-y: scroll; overflow-y: overlay; } } .drag { cursor: grab; } body { min-height: 100vh; min-height: -webkit-fill-available; overscroll-behavior: contain; cursor: url('data:image/svg+xml;utf8,'), auto; } html { height: -webkit-fill-available; } #__next, #portal { z-index: 0; position: absolute; top: 0; bottom: 0; left: 0; right: 0; overflow-x: hidden; overflow-y: scroll; overflow-y: overlay; } #portal { z-index: 1; } input[type='range']::-webkit-slider-thumb { -webkit-appearance: none; height: 1.2rem; width: 1.2rem; border-radius: 50%; background: #000; cursor: pointer; } /* All the same stuff for Firefox */ input[type='range']::-moz-range-thumb { height: 1.2rem; width: 1.2rem; border-radius: 50%; background: #000; cursor: pointer; } /* All the same stuff for IE */ input[type='range']::-ms-thumb { height: 1.2rem; width: 1.2rem; border-radius: 50%; background: #000; cursor: pointer; } *::-webkit-scrollbar-track { -webkit-box-shadow: none !important; background-color: transparent !important; } *::-webkit-scrollbar { width: 6px !important; position: absolute; background-color: transparent; } *::-webkit-scrollbar-thumb { @apply bg-black/75; } ================================================ FILE: common/types/global.ts ================================================ import { RgbaColor } from 'react-colorful'; export type Shape = 'line' | 'circle' | 'rect' | 'image'; export type CtxMode = 'eraser' | 'draw' | 'select'; export interface CtxOptions { lineWidth: number; lineColor: RgbaColor; fillColor: RgbaColor; shape: Shape; mode: CtxMode; selection: { x: number; y: number; width: number; height: number; } | null; } export interface Move { circle: { cX: number; cY: number; radiusX: number; radiusY: number; }; rect: { width: number; height: number; }; img: { base64: string; }; path: [number, number][]; options: CtxOptions; timestamp: number; id: string; } export type Room = { usersMoves: Map; drawed: Move[]; users: Map; }; export interface User { name: string; color: string; } export interface ClientRoom { id: string; usersMoves: Map; movesWithoutUser: Move[]; myMoves: Move[]; users: Map; } export interface MessageType { userId: string; username: string; color: string; msg: string; id: number; } export interface ServerToClientEvents { room_exists: (exists: boolean) => void; joined: (roomId: string, failed?: boolean) => void; room: (room: Room, usersMovesToParse: string, usersToParse: string) => void; created: (roomId: string) => void; your_move: (move: Move) => void; user_draw: (move: Move, userId: string) => void; user_undo(userId: string): void; mouse_moved: (x: number, y: number, userId: string) => void; new_user: (userId: string, username: string) => void; user_disconnected: (userId: string) => void; new_msg: (userId: string, msg: string) => void; } export interface ClientToServerEvents { check_room: (roomId: string) => void; draw: (move: Move) => void; mouse_move: (x: number, y: number) => void; undo: () => void; create_room: (username: string) => void; join_room: (room: string, username: string) => void; joined_room: () => void; leave_room: () => void; send_msg: (msg: string) => void; } ================================================ FILE: modules/home/components/Home.tsx ================================================ import { FormEvent, useEffect, useState } from 'react'; import { useRouter } from 'next/router'; import { socket } from '@/common/lib/socket'; import { useSetRoomId } from '@/common/recoil/room'; import { useModal } from '@/modules/modal'; import NotFoundModal from '../modals/NotFound'; const Home = () => { const { openModal } = useModal(); const setAtomRoomId = useSetRoomId(); const [roomId, setRoomId] = useState(''); const [username, setUsername] = useState(''); const router = useRouter(); useEffect(() => { document.body.style.backgroundColor = 'white'; }, []); useEffect(() => { socket.on('created', (roomIdFromServer) => { setAtomRoomId(roomIdFromServer); router.push(roomIdFromServer); }); const handleJoinedRoom = (roomIdFromServer: string, failed?: boolean) => { if (!failed) { setAtomRoomId(roomIdFromServer); router.push(roomIdFromServer); } else { openModal(); } }; socket.on('joined', handleJoinedRoom); return () => { socket.off('created'); socket.off('joined', handleJoinedRoom); }; }, [openModal, roomId, router, setAtomRoomId]); useEffect(() => { socket.emit('leave_room'); setAtomRoomId(''); }, [setAtomRoomId]); const handleCreateRoom = () => { socket.emit('create_room', username); }; const handleJoinRoom = (e: FormEvent) => { e.preventDefault(); if (roomId) socket.emit('join_room', roomId, username); }; return (

Collabio

Real-time whiteboard

setUsername(e.target.value.slice(0, 15))} />
setRoomId(e.target.value)} />

or

Create new room
); }; export default Home; ================================================ FILE: modules/home/index.ts ================================================ import Home from './components/Home'; export default Home; ================================================ FILE: modules/home/modals/NotFound.tsx ================================================ import { AiOutlineClose } from 'react-icons/ai'; import { useModal } from '@/modules/modal'; const NotFoundModal = ({ id }: { id: string }) => { const { closeModal } = useModal(); return (

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

Try to join room later.

); }; export default NotFoundModal; ================================================ FILE: modules/modal/animations/ModalManager.animations.ts ================================================ export const bgAnimation = { closed: { opacity: 0 }, opened: { opacity: 1 }, }; export const modalAnimation = { closed: { y: -100 }, opened: { y: 0 }, exited: { y: 100 }, }; ================================================ FILE: modules/modal/components/ModalManager.tsx ================================================ import { useEffect, useState } from 'react'; import { AnimatePresence, motion } from 'framer-motion'; import { useRecoilState } from 'recoil'; import Portal from '@/common/components/portal/components/Portal'; import { bgAnimation, modalAnimation, } from '../animations/ModalManager.animations'; import { modalAtom } from '../recoil/modal.atom'; const ModalManager = () => { const [{ opened, modal }, setModal] = useRecoilState(modalAtom); const [portalNode, setPortalNode] = useState(); useEffect(() => { if (!portalNode) { const node = document.getElementById('portal'); if (node) setPortalNode(node); return; } if (opened) { portalNode.style.pointerEvents = 'all'; } else { portalNode.style.pointerEvents = 'none'; } }, [opened, portalNode]); return ( setModal({ modal: <>, opened: false })} variants={bgAnimation} initial="closed" animate={opened ? 'opened' : 'closed'} > {opened && ( e.stopPropagation()} className="p-6" > {modal} )} ); }; export default ModalManager; ================================================ FILE: modules/modal/index.ts ================================================ import ModalManager from './components/ModalManager'; import { useModal } from './recoil/modal.hooks'; export { ModalManager, useModal }; ================================================ FILE: modules/modal/recoil/modal.atom.tsx ================================================ import { atom } from 'recoil'; export const modalAtom = atom<{ modal: JSX.Element | JSX.Element[]; opened: boolean; }>({ key: 'modal', default: { modal: <>, opened: false, }, }); ================================================ FILE: modules/modal/recoil/modal.hooks.tsx ================================================ import { useSetRecoilState } from 'recoil'; import { modalAtom } from './modal.atom'; const useModal = () => { const setModal = useSetRecoilState(modalAtom); const openModal = (modal: JSX.Element | JSX.Element[]) => setModal({ modal, opened: true }); const closeModal = () => setModal({ modal: <>, opened: false }); return { openModal, closeModal }; }; export { useModal }; ================================================ FILE: modules/room/components/NameInput.tsx ================================================ import { FormEvent, useEffect, useState } from 'react'; import { useRouter } from 'next/router'; import { socket } from '@/common/lib/socket'; import { useSetRoomId } from '@/common/recoil/room'; import NotFoundModal from '@/modules/home/modals/NotFound'; import { useModal } from '@/modules/modal'; const NameInput = () => { const setRoomId = useSetRoomId(); const { openModal } = useModal(); const [name, setName] = useState(''); const router = useRouter(); const roomId = (router.query.roomId || '').toString(); useEffect(() => { if (!roomId) return; socket.emit('check_room', roomId); socket.on('room_exists', (exists) => { if (!exists) { router.push('/'); } }); // eslint-disable-next-line consistent-return return () => { socket.off('room_exists'); }; }, [roomId, router]); useEffect(() => { const handleJoined = (roomIdFromServer: string, failed?: boolean) => { if (failed) { router.push('/'); openModal(); } else setRoomId(roomIdFromServer); }; socket.on('joined', handleJoined); return () => { socket.off('joined', handleJoined); }; }, [openModal, router, setRoomId]); const handleJoinRoom = (e: FormEvent) => { e.preventDefault(); socket.emit('join_room', roomId, name); }; return (

Collabio

Real-time whiteboard

setName(e.target.value.slice(0, 15))} />
); }; export default NameInput; ================================================ FILE: modules/room/components/Room.tsx ================================================ import { useRoom } from '@/common/recoil/room'; import RoomContextProvider from '../context/Room.context'; import Board from '../modules/board'; import Chat from '../modules/chat'; import ToolBar from '../modules/toolbar'; import NameInput from './NameInput'; import UserList from './UserList'; const Room = () => { const room = useRoom(); if (!room.id) return ; return (
); }; export default Room; ================================================ FILE: modules/room/components/UserList.tsx ================================================ import { useRoom } from '@/common/recoil/room'; const UserList = () => { const { users } = useRoom(); return (
{[...users.keys()].map((userId, index) => { return (
{users.get(userId)?.name.split('')[0] || 'A'}
); })}
); }; export default UserList; ================================================ FILE: modules/room/context/Room.context.tsx ================================================ import { createContext, Dispatch, ReactChild, RefObject, SetStateAction, useEffect, useRef, useState, } from 'react'; import { MotionValue, useMotionValue } from 'framer-motion'; import { toast } from 'react-toastify'; import { COLORS_ARRAY } from '@/common/constants/colors'; import { socket } from '@/common/lib/socket'; import { useSetUsers } from '@/common/recoil/room'; import { useSetRoom, useRoom } from '@/common/recoil/room/room.hooks'; import { Move, User } from '@/common/types/global'; export const roomContext = createContext<{ x: MotionValue; y: MotionValue; undoRef: RefObject; redoRef: RefObject; canvasRef: RefObject; bgRef: RefObject; selectionRefs: RefObject; minimapRef: RefObject; moveImage: { base64: string; x?: number; y?: number }; setMoveImage: Dispatch< SetStateAction<{ base64: string; x?: number | undefined; y?: number | undefined; }> >; }>(null!); const RoomContextProvider = ({ children }: { children: ReactChild }) => { const setRoom = useSetRoom(); const { users } = useRoom(); const { handleAddUser, handleRemoveUser } = useSetUsers(); const undoRef = useRef(null); const redoRef = useRef(null); const canvasRef = useRef(null); const bgRef = useRef(null); const minimapRef = useRef(null); const selectionRefs = useRef([]); const [moveImage, setMoveImage] = useState<{ base64: string; x?: number; y?: number; }>({ base64: '' }); useEffect(() => { if (moveImage.base64 && !moveImage.x && !moveImage.y) setMoveImage({ base64: moveImage.base64, x: 50, y: 50 }); }, [moveImage]); const x = useMotionValue(0); const y = useMotionValue(0); useEffect(() => { socket.on('room', (room, usersMovesToParse, usersToParse) => { const usersMoves = new Map(JSON.parse(usersMovesToParse)); const usersParsed = new Map(JSON.parse(usersToParse)); const newUsers = new Map(); usersParsed.forEach((name, id) => { if (id === socket.id) return; const index = [...usersParsed.keys()].indexOf(id); const color = COLORS_ARRAY[index % COLORS_ARRAY.length]; newUsers.set(id, { name, color, }); }); setRoom((prev) => ({ ...prev, users: newUsers, usersMoves, movesWithoutUser: room.drawed, })); }); socket.on('new_user', (userId, username) => { toast(`${username} has joined the room.`, { position: 'top-center', theme: 'colored', }); handleAddUser(userId, username); }); socket.on('user_disconnected', (userId) => { toast(`${users.get(userId)?.name || 'Anonymous'} has left the room.`, { position: 'top-center', theme: 'colored', }); handleRemoveUser(userId); }); return () => { socket.off('room'); socket.off('new_user'); socket.off('user_disconnected'); }; }, [handleAddUser, handleRemoveUser, setRoom, users]); return ( {children} ); }; export default RoomContextProvider; ================================================ FILE: modules/room/hooks/useMoveImage.ts ================================================ import { useContext } from 'react'; import { roomContext } from '../context/Room.context'; export const useMoveImage = () => { const { moveImage, setMoveImage } = useContext(roomContext); return { moveImage, setMoveImage }; }; ================================================ FILE: modules/room/hooks/useMovesHandlers.ts ================================================ import { useEffect, useMemo } from 'react'; import { getStringFromRgba } from '@/common/lib/rgba'; import { socket } from '@/common/lib/socket'; import { useBackground } from '@/common/recoil/background'; import { useSetSelection } from '@/common/recoil/options'; import { useMyMoves, useRoom } from '@/common/recoil/room'; import { useSetSavedMoves } from '@/common/recoil/savedMoves'; import { Move } from '@/common/types/global'; import { useCtx } from '../modules/board/hooks/useCtx'; import { useRefs } from './useRefs'; import { useSelection } from '../modules/board/hooks/useSelection'; let prevMovesLength = 0; export const useMovesHandlers = (clearOnYourMove: () => void) => { const { canvasRef, minimapRef, bgRef } = useRefs(); const room = useRoom(); const { handleAddMyMove, handleRemoveMyMove } = useMyMoves(); const { addSavedMove, removeSavedMove } = useSetSavedMoves(); const ctx = useCtx(); const bg = useBackground(); const { clearSelection } = useSetSelection(); const sortedMoves = useMemo(() => { const { usersMoves, movesWithoutUser, myMoves } = room; const moves = [...movesWithoutUser, ...myMoves]; usersMoves.forEach((userMoves) => moves.push(...userMoves)); moves.sort((a, b) => a.timestamp - b.timestamp); return moves; }, [room]); const copyCanvasToSmall = () => { if (canvasRef.current && minimapRef.current && bgRef.current) { const smallCtx = minimapRef.current.getContext('2d'); if (smallCtx) { smallCtx.clearRect(0, 0, smallCtx.canvas.width, smallCtx.canvas.height); smallCtx.drawImage( bgRef.current, 0, 0, smallCtx.canvas.width, smallCtx.canvas.height ); smallCtx.drawImage( canvasRef.current, 0, 0, smallCtx.canvas.width, smallCtx.canvas.height ); } } }; // eslint-disable-next-line react-hooks/exhaustive-deps useEffect(() => copyCanvasToSmall(), [bg]); const drawMove = (move: Move, image?: HTMLImageElement) => { const { path } = move; if (!ctx || !path.length) return; const moveOptions = move.options; if (moveOptions.mode === 'select') return; ctx.lineWidth = moveOptions.lineWidth; ctx.strokeStyle = getStringFromRgba(moveOptions.lineColor); ctx.fillStyle = getStringFromRgba(moveOptions.fillColor); if (moveOptions.mode === 'eraser') ctx.globalCompositeOperation = 'destination-out'; else ctx.globalCompositeOperation = 'source-over'; if (moveOptions.shape === 'image' && image) ctx.drawImage(image, path[0][0], path[0][1]); switch (moveOptions.shape) { case 'line': { ctx.beginPath(); path.forEach(([x, y]) => { ctx.lineTo(x, y); }); ctx.stroke(); ctx.closePath(); break; } case 'circle': { const { cX, cY, radiusX, radiusY } = move.circle; ctx.beginPath(); ctx.ellipse(cX, cY, radiusX, radiusY, 0, 0, 2 * Math.PI); ctx.stroke(); ctx.fill(); ctx.closePath(); break; } case 'rect': { const { width, height } = move.rect; ctx.beginPath(); ctx.rect(path[0][0], path[0][1], width, height); ctx.stroke(); ctx.fill(); ctx.closePath(); break; } default: break; } copyCanvasToSmall(); }; const drawAllMoves = async () => { if (!ctx) return; ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); const images = await Promise.all( sortedMoves .filter((move) => move.options.shape === 'image') .map((move) => { return new Promise((resolve) => { const img = new Image(); img.src = move.img.base64; img.id = move.id; img.addEventListener('load', () => resolve(img)); }); }) ); sortedMoves.forEach((move) => { if (move.options.shape === 'image') { const img = images.find((image) => image.id === move.id); if (img) drawMove(move, img); } else drawMove(move); }); copyCanvasToSmall(); }; useSelection(drawAllMoves); useEffect(() => { socket.on('your_move', (move) => { clearOnYourMove(); handleAddMyMove(move); setTimeout(clearSelection, 100); }); return () => { socket.off('your_move'); }; }, [clearOnYourMove, clearSelection, handleAddMyMove]); useEffect(() => { if (prevMovesLength >= sortedMoves.length || !prevMovesLength) { drawAllMoves(); } else { const lastMove = sortedMoves[sortedMoves.length - 1]; if (lastMove.options.shape === 'image') { const img = new Image(); img.src = lastMove.img.base64; img.addEventListener('load', () => drawMove(lastMove, img)); } else drawMove(lastMove); } return () => { prevMovesLength = sortedMoves.length; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [sortedMoves]); // eslint-disable-next-line react-hooks/exhaustive-deps const handleUndo = () => { if (ctx) { const move = handleRemoveMyMove(); if (move?.options.mode === 'select') clearSelection(); else if (move) { addSavedMove(move); socket.emit('undo'); } } }; // eslint-disable-next-line react-hooks/exhaustive-deps const handleRedo = () => { if (ctx) { const move = removeSavedMove(); if (move) { socket.emit('draw', move); } } }; useEffect(() => { const handleUndoRedoKeyboard = (e: KeyboardEvent) => { if (e.key === 'z' && e.ctrlKey) { handleUndo(); } else if (e.key === 'y' && e.ctrlKey) { handleRedo(); } }; document.addEventListener('keydown', handleUndoRedoKeyboard); return () => { document.removeEventListener('keydown', handleUndoRedoKeyboard); }; }, [handleUndo, handleRedo]); return { handleUndo, handleRedo }; }; ================================================ FILE: modules/room/hooks/useRefs.ts ================================================ import { useContext } from 'react'; import { roomContext } from '../context/Room.context'; export const useRefs = () => { const { undoRef, bgRef, canvasRef, minimapRef, redoRef, selectionRefs } = useContext(roomContext); return { undoRef, redoRef, bgRef, canvasRef, minimapRef, selectionRefs, }; }; ================================================ FILE: modules/room/index.ts ================================================ import Room from './components/Room'; export default Room; ================================================ FILE: modules/room/modules/board/components/Background.tsx ================================================ import { RefObject, useEffect } from 'react'; import { motion } from 'framer-motion'; import { CANVAS_SIZE } from '@/common/constants/canvasSize'; import { useBackground } from '@/common/recoil/background'; import { useBoardPosition } from '../hooks/useBoardPosition'; const Background = ({ bgRef }: { bgRef: RefObject }) => { const bg = useBackground(); const { x, y } = useBoardPosition(); useEffect(() => { const ctx = bgRef.current?.getContext('2d'); if (ctx) { ctx.fillStyle = bg.mode === 'dark' ? '#222' : '#fff'; ctx.fillRect(0, 0, CANVAS_SIZE.width, CANVAS_SIZE.height); document.body.style.backgroundColor = bg.mode === 'dark' ? '#222' : '#fff'; if (bg.lines) { ctx.lineWidth = 1; ctx.strokeStyle = bg.mode === 'dark' ? '#444' : '#ddd'; for (let i = 0; i < CANVAS_SIZE.height; i += 25) { ctx.beginPath(); ctx.moveTo(0, i); ctx.lineTo(ctx.canvas.width, i); ctx.stroke(); } for (let i = 0; i < CANVAS_SIZE.width; i += 25) { ctx.beginPath(); ctx.moveTo(i, 0); ctx.lineTo(i, ctx.canvas.height); ctx.stroke(); } } } }, [bgRef, bg]); return ( ); }; export default Background; ================================================ FILE: modules/room/modules/board/components/Canvas.tsx ================================================ import { useEffect, useState } from 'react'; import { motion, useDragControls } from 'framer-motion'; import { BsArrowsMove } from 'react-icons/bs'; import { CANVAS_SIZE } from '@/common/constants/canvasSize'; import { useViewportSize } from '@/common/hooks/useViewportSize'; import { socket } from '@/common/lib/socket'; import { useMovesHandlers } from '../../../hooks/useMovesHandlers'; import { useRefs } from '../../../hooks/useRefs'; import { useBoardPosition } from '../hooks/useBoardPosition'; import { useCtx } from '../hooks/useCtx'; import { useDraw } from '../hooks/useDraw'; import { useSocketDraw } from '../hooks/useSocketDraw'; import Background from './Background'; import MiniMap from './Minimap'; const Canvas = () => { const { canvasRef, bgRef, undoRef, redoRef } = useRefs(); const { width, height } = useViewportSize(); const { x, y } = useBoardPosition(); const ctx = useCtx(); const [dragging, setDragging] = useState(true); const { handleEndDrawing, handleDraw, handleStartDrawing, drawing, clearOnYourMove, } = useDraw(dragging); useSocketDraw(drawing); const { handleUndo, handleRedo } = useMovesHandlers(clearOnYourMove); const dragControls = useDragControls(); useEffect(() => { setDragging(false); }, []); // SETUP useEffect(() => { const undoBtn = undoRef.current; const redoBtn = redoRef.current; undoBtn?.addEventListener('click', handleUndo); redoBtn?.addEventListener('click', handleRedo); return () => { undoBtn?.removeEventListener('click', handleUndo); redoBtn?.removeEventListener('click', handleRedo); }; }, [canvasRef, dragging, handleRedo, handleUndo, redoRef, undoRef]); useEffect(() => { if (ctx) socket.emit('joined_room'); }, [ctx]); return (
{ e.preventDefault(); e.stopPropagation(); }} onMouseDown={(e) => { if (e.button === 2) { setDragging(true); dragControls.start(e); } else handleStartDrawing(e.clientX, e.clientY); }} onMouseUp={(e) => { if (e.button === 2) setDragging(false); else handleEndDrawing(); }} onMouseMove={(e) => { handleDraw(e.clientX, e.clientY, e.shiftKey); }} onTouchStart={(e) => handleStartDrawing( e.changedTouches[0].clientX, e.changedTouches[0].clientY ) } onTouchEnd={handleEndDrawing} onTouchMove={(e) => handleDraw(e.changedTouches[0].clientX, e.changedTouches[0].clientY) } />
); }; export default Canvas; ================================================ FILE: modules/room/modules/board/components/Minimap.tsx ================================================ import { useEffect, useMemo, useRef, useState } from 'react'; import { motion, useMotionValue } from 'framer-motion'; import { CANVAS_SIZE } from '@/common/constants/canvasSize'; import { useViewportSize } from '@/common/hooks/useViewportSize'; import { useRefs } from '../../../hooks/useRefs'; import { useBoardPosition } from '../hooks/useBoardPosition'; const MiniMap = ({ dragging }: { dragging: boolean }) => { const { minimapRef } = useRefs(); const boardPos = useBoardPosition(); const { width, height } = useViewportSize(); const [x, setX] = useState(0); const [y, setY] = useState(0); const [draggingMinimap, setDraggingMinimap] = useState(false); useEffect(() => { if (!draggingMinimap) { const unsubscribe = boardPos.x.onChange(setX); return unsubscribe; } return () => {}; }, [boardPos.x, draggingMinimap]); useEffect(() => { if (!draggingMinimap) { const unsubscribe = boardPos.y.onChange(setY); return unsubscribe; } return () => {}; }, [boardPos.y, draggingMinimap]); const containerRef = useRef(null); const miniX = useMotionValue(0); const miniY = useMotionValue(0); const divider = useMemo(() => { if (width > 1600) return 7; if (width > 1000) return 10; if (width > 600) return 14; return 20; }, [width]); useEffect(() => { miniX.onChange((newX) => { if (!dragging) boardPos.x.set(Math.floor(-newX * divider)); }); miniY.onChange((newY) => { if (!dragging) boardPos.y.set(Math.floor(-newY * divider)); }); return () => { miniX.clearListeners(); miniY.clearListeners(); }; }, [boardPos.x, boardPos.y, divider, dragging, miniX, miniY]); return (
setDraggingMinimap(true)} onDragEnd={() => setDraggingMinimap(false)} className="absolute top-0 left-0 cursor-grab rounded-lg border-2 border-red-500" style={{ width: width / divider, height: height / divider, x: miniX, y: miniY, }} animate={{ x: -x / divider, y: -y / divider }} transition={{ duration: 0 }} />
); }; export default MiniMap; ================================================ FILE: modules/room/modules/board/components/MousePosition.tsx ================================================ import { useRef } from 'react'; import { motion } from 'framer-motion'; import { useInterval, useMouse } from 'react-use'; import { getPos } from '@/common/lib/getPos'; import { socket } from '@/common/lib/socket'; import { useBoardPosition } from '../hooks/useBoardPosition'; const MousePosition = () => { const { x, y } = useBoardPosition(); const prevPosition = useRef({ x: 0, y: 0 }); const ref = useRef(null); const { docX, docY } = useMouse(ref); const touchDevice = window.matchMedia('(pointer: coarse)').matches; useInterval(() => { if ( (prevPosition.current.x !== docX || prevPosition.current.y !== docY) && !touchDevice ) { socket.emit('mouse_move', getPos(docX, x), getPos(docY, y)); prevPosition.current = { x: docX, y: docY }; } }, 150); if (touchDevice) return null; return ( {getPos(docX, x).toFixed(0)} | {getPos(docY, y).toFixed(0)} ); }; export default MousePosition; ================================================ FILE: modules/room/modules/board/components/MousesRenderer.tsx ================================================ import { socket } from '@/common/lib/socket'; import { useRoom } from '@/common/recoil/room'; import UserMouse from './UserMouse'; const MousesRenderer = () => { const { users } = useRoom(); return ( <> {[...users.keys()].map((userId) => { if (userId === socket.id) return null; return ; })} ); }; export default MousesRenderer; ================================================ FILE: modules/room/modules/board/components/MoveImage.tsx ================================================ import { useEffect } from 'react'; import { motion, useMotionValue } from 'framer-motion'; import { AiOutlineCheck, AiOutlineClose } from 'react-icons/ai'; import { DEFAULT_MOVE } from '@/common/constants/defaultMove'; import { getPos } from '@/common/lib/getPos'; import { socket } from '@/common/lib/socket'; import { Move } from '@/common/types/global'; import { useMoveImage } from '../../../hooks/useMoveImage'; import { useBoardPosition } from '../hooks/useBoardPosition'; const MoveImage = () => { const { x, y } = useBoardPosition(); const { moveImage, setMoveImage } = useMoveImage(); const imageX = useMotionValue(moveImage.x || 50); const imageY = useMotionValue(moveImage.y || 50); useEffect(() => { if (moveImage.x) imageX.set(moveImage.x); else imageX.set(50); if (moveImage.y) imageY.set(moveImage.y); else imageY.set(50); }, [imageX, imageY, moveImage.x, moveImage.y]); const handlePlaceImage = () => { const [finalX, finalY] = [getPos(imageX.get(), x), getPos(imageY.get(), y)]; const move: Move = { ...DEFAULT_MOVE, img: { base64: moveImage.base64 }, path: [[finalX, finalY]], options: { ...DEFAULT_MOVE.options, selection: null, shape: 'image', }, }; socket.emit('draw', move); setMoveImage({ base64: '' }); imageX.set(50); imageY.set(50); }; if (!moveImage.base64) return null; return (
image to place
); }; export default MoveImage; ================================================ FILE: modules/room/modules/board/components/SelectionBtns.tsx ================================================ import { useEffect, useState } from 'react'; import { AiOutlineDelete } from 'react-icons/ai'; import { BsArrowsMove } from 'react-icons/bs'; import { FiCopy } from 'react-icons/fi'; import { useOptionsValue } from '@/common/recoil/options'; import { useRefs } from '../../../hooks/useRefs'; import { useBoardPosition } from '../hooks/useBoardPosition'; const SelectionBtns = () => { const { selection } = useOptionsValue(); const { selectionRefs } = useRefs(); const boardPos = useBoardPosition(); const [boardX, setX] = useState(0); const [boardY, setY] = useState(0); useEffect(() => { const unsubscribe = boardPos.x.onChange(setX); return unsubscribe; }, [boardPos.x]); useEffect(() => { const unsubscribe = boardPos.y.onChange(setY); return unsubscribe; }, [boardPos.y]); let top = -40; let left = -40; if (selection) { const { x, y, width, height } = selection; top = Math.min(y, y + height) - 40 + boardY; left = Math.min(x, x + width) + boardX; } return (
); }; export default SelectionBtns; ================================================ FILE: modules/room/modules/board/components/UserMouse.tsx ================================================ import { useEffect, useState } from 'react'; import { motion } from 'framer-motion'; import { BsCursorFill } from 'react-icons/bs'; import { socket } from '@/common/lib/socket'; import { useRoom } from '@/common/recoil/room'; import { useBoardPosition } from '../hooks/useBoardPosition'; const UserMouse = ({ userId }: { userId: string }) => { const { users } = useRoom(); const boardPos = useBoardPosition(); const [msg, setMsg] = useState(''); const [x, setX] = useState(boardPos.x.get()); const [y, setY] = useState(boardPos.y.get()); const [pos, setPos] = useState({ x: -1, y: -1 }); useEffect(() => { socket.on('mouse_moved', (newX, newY, socketIdMoved) => { if (socketIdMoved === userId) { setPos({ x: newX, y: newY }); } }); const handleNewMsg = (msgUserId: string, newMsg: string) => { if (msgUserId === userId) { setMsg(newMsg); setTimeout(() => { setMsg(''); }, 3000); } }; socket.on('new_msg', handleNewMsg); return () => { socket.off('mouse_moved'); socket.off('new_msg', handleNewMsg); }; }, [userId]); useEffect(() => { const unsubscribe = boardPos.x.onChange(setX); return unsubscribe; }, [boardPos.x]); useEffect(() => { const unsubscribe = boardPos.y.onChange(setY); return unsubscribe; }, [boardPos.y]); return ( {msg && (

{msg}

)}

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

); }; export default UserMouse; ================================================ FILE: modules/room/modules/board/helpers/Canvas.helpers.ts ================================================ const getWidthAndHeight = ( x: number, y: number, from: [number, number], shift?: boolean ) => { let width = x - from[0]; let height = y - from[1]; if (shift) { if (Math.abs(width) > Math.abs(height)) { if ((width > 0 && height < 0) || (width < 0 && height > 0)) width = -height; else width = height; } else if ((height > 0 && width < 0) || (height < 0 && width > 0)) height = -width; else height = width; } else { width = x - from[0]; height = y - from[1]; } return { width, height }; }; export const drawCircle = ( ctx: CanvasRenderingContext2D, from: [number, number], x: number, y: number, shift?: boolean ) => { ctx.beginPath(); const { width, height } = getWidthAndHeight(x, y, from, shift); const cX = from[0] + width / 2; const cY = from[1] + height / 2; const radiusX = Math.abs(width / 2); const radiusY = Math.abs(height / 2); ctx.ellipse(cX, cY, radiusX, radiusY, 0, 0, 2 * Math.PI); ctx.stroke(); ctx.fill(); ctx.closePath(); return { cX, cY, radiusX, radiusY }; }; export const drawRect = ( ctx: CanvasRenderingContext2D, from: [number, number], x: number, y: number, shift?: boolean, fill?: boolean ) => { ctx.beginPath(); const { width, height } = getWidthAndHeight(x, y, from, shift); if (fill) ctx.fillRect(from[0], from[1], width, height); else ctx.rect(from[0], from[1], width, height); ctx.stroke(); ctx.fill(); ctx.closePath(); return { width, height }; }; export const drawLine = ( ctx: CanvasRenderingContext2D, from: [number, number], x: number, y: number, shift?: boolean ) => { if (shift) { ctx.beginPath(); ctx.lineTo(from[0], from[1]); ctx.lineTo(x, y); ctx.stroke(); ctx.closePath(); return; } ctx.lineTo(x, y); ctx.stroke(); }; ================================================ FILE: modules/room/modules/board/hooks/useBoardPosition.ts ================================================ import { useContext } from 'react'; import { roomContext } from '../../../context/Room.context'; export const useBoardPosition = () => { const { x, y } = useContext(roomContext); return { x, y }; }; ================================================ FILE: modules/room/modules/board/hooks/useCtx.ts ================================================ import { useEffect, useState } from 'react'; import { useRefs } from '../../../hooks/useRefs'; export const useCtx = () => { const { canvasRef } = useRefs(); const [ctx, setCtx] = useState(); useEffect(() => { const newCtx = canvasRef.current?.getContext('2d'); if (newCtx) { newCtx.lineJoin = 'round'; newCtx.lineCap = 'round'; setCtx(newCtx); } }, [canvasRef]); return ctx; }; ================================================ FILE: modules/room/modules/board/hooks/useDraw.ts ================================================ import { useState } from 'react'; import { DEFAULT_MOVE } from '@/common/constants/defaultMove'; import { useViewportSize } from '@/common/hooks/useViewportSize'; import { getPos } from '@/common/lib/getPos'; import { getStringFromRgba } from '@/common/lib/rgba'; import { socket } from '@/common/lib/socket'; import { useOptionsValue } from '@/common/recoil/options'; import { useSetSelection } from '@/common/recoil/options/options.hooks'; import { useMyMoves } from '@/common/recoil/room'; import { useSetSavedMoves } from '@/common/recoil/savedMoves'; import { Move } from '@/common/types/global'; import { drawRect, drawCircle, drawLine } from '../helpers/Canvas.helpers'; import { useBoardPosition } from './useBoardPosition'; import { useCtx } from './useCtx'; let tempMoves: [number, number][] = []; let tempCircle = { cX: 0, cY: 0, radiusX: 0, radiusY: 0 }; let tempSize = { width: 0, height: 0 }; let tempImageData: ImageData | undefined; export const useDraw = (blocked: boolean) => { const options = useOptionsValue(); const boardPosition = useBoardPosition(); const { clearSavedMoves } = useSetSavedMoves(); const { handleAddMyMove } = useMyMoves(); const { setSelection, clearSelection } = useSetSelection(); const vw = useViewportSize(); const movedX = boardPosition.x; const movedY = boardPosition.y; const [drawing, setDrawing] = useState(false); const ctx = useCtx(); const setupCtxOptions = () => { if (ctx) { ctx.lineWidth = options.lineWidth; ctx.strokeStyle = getStringFromRgba(options.lineColor); ctx.fillStyle = getStringFromRgba(options.fillColor); if (options.mode === 'eraser') ctx.globalCompositeOperation = 'destination-out'; else ctx.globalCompositeOperation = 'source-over'; } }; const drawAndSet = () => { if (!tempImageData) tempImageData = ctx?.getImageData( movedX.get() * -1, movedY.get() * -1, vw.width, vw.height ); if (tempImageData) ctx?.putImageData(tempImageData, movedX.get() * -1, movedY.get() * -1); }; const handleStartDrawing = (x: number, y: number) => { if (!ctx || blocked || blocked) return; const [finalX, finalY] = [getPos(x, movedX), getPos(y, movedY)]; setDrawing(true); setupCtxOptions(); drawAndSet(); if (options.shape === 'line' && options.mode !== 'select') { ctx.beginPath(); ctx.lineTo(finalX, finalY); ctx.stroke(); } tempMoves.push([finalX, finalY]); }; const handleDraw = (x: number, y: number, shift?: boolean) => { if (!ctx || !drawing || blocked) return; const [finalX, finalY] = [getPos(x, movedX), getPos(y, movedY)]; drawAndSet(); if (options.mode === 'select') { ctx.fillStyle = 'rgba(0, 0, 0, 0.2)'; drawRect(ctx, tempMoves[0], finalX, finalY, false, true); tempMoves.push([finalX, finalY]); setupCtxOptions(); return; } switch (options.shape) { case 'line': if (shift) tempMoves = tempMoves.slice(0, 1); drawLine(ctx, tempMoves[0], finalX, finalY, shift); tempMoves.push([finalX, finalY]); break; case 'circle': tempCircle = drawCircle(ctx, tempMoves[0], finalX, finalY, shift); break; case 'rect': tempSize = drawRect(ctx, tempMoves[0], finalX, finalY, shift); break; default: break; } }; const clearOnYourMove = () => { drawAndSet(); tempImageData = undefined; }; const handleEndDrawing = () => { if (!ctx || blocked) return; setDrawing(false); ctx.closePath(); let addMove = true; if (options.mode === 'select' && tempMoves.length) { clearOnYourMove(); let x = tempMoves[0][0]; let y = tempMoves[0][1]; let width = tempMoves[tempMoves.length - 1][0] - x; let height = tempMoves[tempMoves.length - 1][1] - y; if (width < 0) { width -= 4; x += 2; } else { width += 4; x -= 2; } if (height < 0) { height -= 4; y += 2; } else { height += 4; y -= 2; } if ((width < 4 || width > 4) && (height < 4 || height > 4)) setSelection({ x, y, width, height }); else { clearSelection(); addMove = false; } } const move: Move = { ...DEFAULT_MOVE, rect: { ...tempSize, }, circle: { ...tempCircle, }, path: tempMoves, options, }; tempMoves = []; tempCircle = { cX: 0, cY: 0, radiusX: 0, radiusY: 0 }; tempSize = { width: 0, height: 0 }; if (options.mode !== 'select') { socket.emit('draw', move); clearSavedMoves(); } else if (addMove) handleAddMyMove(move); }; return { handleEndDrawing, handleDraw, handleStartDrawing, drawing, clearOnYourMove, }; }; ================================================ FILE: modules/room/modules/board/hooks/useSelection.ts ================================================ import { useEffect, useMemo } from 'react'; import { toast } from 'react-toastify'; import { DEFAULT_MOVE } from '@/common/constants/defaultMove'; import { socket } from '@/common/lib/socket'; import { useOptionsValue } from '@/common/recoil/options'; import { Move } from '@/common/types/global'; import { useMoveImage } from '../../../hooks/useMoveImage'; import { useRefs } from '../../../hooks/useRefs'; import { useCtx } from './useCtx'; let tempSelection = { x: 0, y: 0, width: 0, height: 0, }; export const useSelection = (drawAllMoves: () => Promise) => { const ctx = useCtx(); const options = useOptionsValue(); const { selection } = options; const { bgRef, selectionRefs } = useRefs(); const { setMoveImage } = useMoveImage(); useEffect(() => { const callback = async () => { await drawAllMoves(); if (ctx && selection) { setTimeout(() => { const { x, y, width, height } = selection; ctx.lineWidth = 2; ctx.strokeStyle = '#000'; ctx.setLineDash([5, 10]); ctx.globalCompositeOperation = 'source-over'; ctx.beginPath(); ctx.rect(x, y, width, height); ctx.stroke(); ctx.closePath(); ctx.setLineDash([]); }, 10); } }; if ( tempSelection.width !== selection?.width || tempSelection.height !== selection?.height || tempSelection.x !== selection?.x || tempSelection.y !== selection?.y ) callback(); return () => { if (selection) tempSelection = selection; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [selection, ctx]); const dimension = useMemo(() => { if (selection) { let { x, y, width, height } = selection; if (width < 0) { width += 4; x -= 2; } else { width -= 4; x += 2; } if (height < 0) { height += 4; y -= 2; } else { height -= 4; y += 2; } return { x, y, width, height }; } return { width: 0, height: 0, x: 0, y: 0, }; }, [selection]); // eslint-disable-next-line react-hooks/exhaustive-deps const makeBlob = async (withBg?: boolean) => { if (!selection) return null; const { x, y, width, height } = dimension; const imageData = ctx?.getImageData(x, y, width, height); if (imageData) { const tempCanvas = document.createElement('canvas'); tempCanvas.width = width; tempCanvas.height = height; const canvas = document.createElement('canvas'); canvas.width = width; canvas.height = height; const tempCtx = canvas.getContext('2d'); if (tempCtx && bgRef.current) { const bgImage = bgRef.current .getContext('2d') ?.getImageData(x, y, width, height); if (bgImage && withBg) tempCtx.putImageData(bgImage, 0, 0); const sTempCtx = tempCanvas.getContext('2d'); sTempCtx?.putImageData(imageData, 0, 0); tempCtx.drawImage(tempCanvas, 0, 0); const blob: Blob = await new Promise((resolve) => { canvas.toBlob((blobGenerated) => { if (blobGenerated) resolve(blobGenerated); }); }); return blob; } } return null; }; // eslint-disable-next-line react-hooks/exhaustive-deps const createDeleteMove = () => { if (!selection) return null; let { x, y, width, height } = dimension; if (width < 0) { width += 4; x -= 2; } else { width -= 4; x += 2; } if (height < 0) { height += 4; y -= 2; } else { height -= 4; y += 2; } const move: Move = { ...DEFAULT_MOVE, rect: { width, height, }, path: [[x, y]], options: { ...options, shape: 'rect', mode: 'eraser', fillColor: { r: 0, g: 0, b: 0, a: 1 }, }, }; socket.emit('draw', move); return move; }; // eslint-disable-next-line react-hooks/exhaustive-deps const handleCopy = async () => { const blob = await makeBlob(true); if (blob) navigator.clipboard .write([ new ClipboardItem({ 'image/png': blob, }), ]) .then(() => { toast('Copied to clipboard!', { position: 'top-center', theme: 'colored', }); }); }; useEffect(() => { const handleSelection = async (e: KeyboardEvent) => { if (e.key === 'c' && e.ctrlKey) handleCopy(); if (e.key === 'Delete' && selection) createDeleteMove(); }; document.addEventListener('keydown', handleSelection); return () => { document.removeEventListener('keydown', handleSelection); }; }, [bgRef, createDeleteMove, ctx, handleCopy, makeBlob, options, selection]); useEffect(() => { const handleSelectionMove = async () => { if (selection) { const blob = await makeBlob(); if (!blob) return; const { x, y, width, height } = dimension; const reader = new FileReader(); reader.readAsDataURL(blob); reader.addEventListener('loadend', () => { const base64 = reader.result?.toString(); if (base64) { createDeleteMove(); setMoveImage({ base64, x: Math.min(x, x + width), y: Math.min(y, y + height), }); } }); } }; if (selectionRefs.current) { const moveBtn = selectionRefs.current[0]; const copyBtn = selectionRefs.current[1]; const deleteBtn = selectionRefs.current[2]; moveBtn.addEventListener('click', handleSelectionMove); copyBtn.addEventListener('click', handleCopy); deleteBtn.addEventListener('click', createDeleteMove); return () => { moveBtn?.removeEventListener('click', handleSelectionMove); copyBtn?.removeEventListener('click', handleCopy); deleteBtn?.removeEventListener('click', createDeleteMove); }; } return () => {}; }, [ createDeleteMove, dimension, handleCopy, makeBlob, selection, selectionRefs, setMoveImage, ]); }; ================================================ FILE: modules/room/modules/board/hooks/useSocketDraw.ts ================================================ import { useEffect } from 'react'; import { socket } from '@/common/lib/socket'; import { useSetUsers } from '@/common/recoil/room'; import { Move } from '@/common/types/global'; export const useSocketDraw = (drawing: boolean) => { const { handleAddMoveToUser, handleRemoveMoveFromUser } = useSetUsers(); useEffect(() => { let moveToDrawLater: Move | undefined; let userIdLater = ''; socket.on('user_draw', (move, userId) => { if (!drawing) { handleAddMoveToUser(userId, move); } else { moveToDrawLater = move; userIdLater = userId; } }); return () => { socket.off('user_draw'); if (moveToDrawLater && userIdLater) { handleAddMoveToUser(userIdLater, moveToDrawLater); } }; }, [drawing, handleAddMoveToUser]); useEffect(() => { socket.on('user_undo', (userId) => { handleRemoveMoveFromUser(userId); }); return () => { socket.off('user_undo'); }; }, [handleRemoveMoveFromUser]); }; ================================================ FILE: modules/room/modules/board/index.tsx ================================================ import Canvas from './components/Canvas'; import MousePosition from './components/MousePosition'; import MousesRenderer from './components/MousesRenderer'; import MoveImage from './components/MoveImage'; import SelectionBtns from './components/SelectionBtns'; const Board = () => ( <> ); export default Board; ================================================ FILE: modules/room/modules/chat/components/Chat.tsx ================================================ import { useEffect, useRef, useState } from 'react'; import { motion } from 'framer-motion'; import { BsFillChatFill } from 'react-icons/bs'; import { FaChevronDown } from 'react-icons/fa'; import { useList } from 'react-use'; import { socket } from '@/common/lib/socket'; import { useRoom } from '@/common/recoil/room'; import { MessageType } from '@/common/types/global'; import ChatInput from './ChatInput'; import Message from './Message'; const Chat = () => { const room = useRoom(); const msgList = useRef(null); const [newMsg, setNewMsg] = useState(false); const [opened, setOpened] = useState(false); const [msgs, handleMsgs] = useList([]); useEffect(() => { const handleNewMsg = (userId: string, msg: string) => { const user = room.users.get(userId); handleMsgs.push({ userId, msg, id: msgs.length + 1, username: user?.name || 'Anonymous', color: user?.color || '#000', }); msgList.current?.scroll({ top: msgList.current?.scrollHeight }); if (!opened) setNewMsg(true); }; socket.on('new_msg', handleNewMsg); return () => { socket.off('new_msg', handleNewMsg); }; }, [handleMsgs, msgs, opened, room.users]); return (
{msgs.map((msg) => ( ))}
); }; export default Chat; ================================================ FILE: modules/room/modules/chat/components/ChatInput.tsx ================================================ import { FormEvent, useState } from 'react'; import { AiOutlineSend } from 'react-icons/ai'; import { socket } from '@/common/lib/socket'; const ChatInput = () => { const [msg, setMsg] = useState(''); const handleSubmit = (e: FormEvent) => { e.preventDefault(); socket.emit('send_msg', msg); setMsg(''); }; return (
setMsg(e.target.value)} />
); }; export default ChatInput; ================================================ FILE: modules/room/modules/chat/components/Message.tsx ================================================ import { socket } from '@/common/lib/socket'; import { MessageType } from '@/common/types/global'; const Message = ({ userId, msg, username, color }: MessageType) => { const me = socket.id === userId; return (
{!me && (
{username}
)}

{msg}

); }; export default Message; ================================================ FILE: modules/room/modules/chat/index.ts ================================================ import Chat from './components/Chat'; export default Chat; ================================================ FILE: modules/room/modules/toolbar/animations/Entry.animations.ts ================================================ export const EntryAnimation = { from: { y: -30, opacity: 0, transition: { duration: 0.2, }, }, to: { y: 0, opacity: 1, transition: { duration: 0.2, }, }, }; ================================================ FILE: modules/room/modules/toolbar/components/BackgoundPicker.tsx ================================================ import { CgScreen } from 'react-icons/cg'; import { useModal } from '@/modules/modal'; import BackgroundModal from '../modals/BackgroundModal'; const BackgroundPicker = () => { const { openModal } = useModal(); return ( ); }; export default BackgroundPicker; ================================================ FILE: modules/room/modules/toolbar/components/ColorPicker.tsx ================================================ import { useRef, useState } from 'react'; import { AnimatePresence, motion } from 'framer-motion'; import { RgbaColorPicker } from 'react-colorful'; import { BsPaletteFill } from 'react-icons/bs'; import { useClickAway } from 'react-use'; import { useOptions } from '@/common/recoil/options/options.hooks'; import { EntryAnimation } from '../animations/Entry.animations'; const ColorPicker = () => { const [options, setOptions] = useOptions(); const ref = useRef(null); const [opened, setOpened] = useState(false); useClickAway(ref, () => setOpened(false)); return (
{opened && (

Line color

{ setOptions({ ...options, lineColor: e, }); }} className="mb-5" />

Fill color

{ setOptions({ ...options, fillColor: e, }); }} />
)}
); }; export default ColorPicker; ================================================ FILE: modules/room/modules/toolbar/components/HistoryBtns.tsx ================================================ import { FaRedo, FaUndo } from 'react-icons/fa'; import { useMyMoves } from '@/common/recoil/room'; import { useSavedMoves } from '@/common/recoil/savedMoves'; import { useRefs } from '../../../hooks/useRefs'; const HistoryBtns = () => { const { redoRef, undoRef } = useRefs(); const { myMoves } = useMyMoves(); const savedMoves = useSavedMoves(); return ( <> ); }; export default HistoryBtns; ================================================ FILE: modules/room/modules/toolbar/components/ImagePicker.tsx ================================================ import { useEffect } from 'react'; import { BsFillImageFill } from 'react-icons/bs'; import { optimizeImage } from '@/common/lib/optimizeImage'; import { useMoveImage } from '../../../hooks/useMoveImage'; const ImagePicker = () => { const { setMoveImage } = useMoveImage(); useEffect(() => { const handlePaste = (e: ClipboardEvent) => { const items = e.clipboardData?.items; if (items) { // eslint-disable-next-line no-restricted-syntax for (const item of items) { if (item.type.includes('image')) { const file = item.getAsFile(); if (file) optimizeImage(file, (uri) => setMoveImage({ base64: uri })); } } } }; document.addEventListener('paste', handlePaste); return () => { document.removeEventListener('paste', handlePaste); }; }, [setMoveImage]); const handleImageInput = () => { const fileInput = document.createElement('input'); fileInput.type = 'file'; fileInput.accept = 'image/*'; fileInput.click(); fileInput.addEventListener('change', () => { if (fileInput && fileInput.files) { const file = fileInput.files[0]; optimizeImage(file, (uri) => setMoveImage({ base64: uri })); } }); }; return ( ); }; export default ImagePicker; ================================================ FILE: modules/room/modules/toolbar/components/LineWidthPicker.tsx ================================================ import { useRef, useState } from 'react'; import { AnimatePresence, motion } from 'framer-motion'; import { BsBorderWidth } from 'react-icons/bs'; import { useClickAway } from 'react-use'; import { useOptions } from '@/common/recoil/options'; import { EntryAnimation } from '../animations/Entry.animations'; const LineWidthPicker = () => { const [options, setOptions] = useOptions(); const ref = useRef(null); const [opened, setOpened] = useState(false); useClickAway(ref, () => setOpened(false)); return (
{opened && ( setOptions((prev) => ({ ...prev, lineWidth: parseInt(e.target.value, 10), })) } className="h-4 w-full cursor-pointer appearance-none rounded-lg bg-gray-200" /> )}
); }; export default LineWidthPicker; ================================================ FILE: modules/room/modules/toolbar/components/ModePicker.tsx ================================================ import { useEffect } from 'react'; import { AiOutlineSelect } from 'react-icons/ai'; import { BsPencilFill } from 'react-icons/bs'; import { FaEraser } from 'react-icons/fa'; import { useOptions, useSetSelection } from '@/common/recoil/options'; const ModePicker = () => { const [options, setOptions] = useOptions(); const { clearSelection } = useSetSelection(); useEffect(() => { clearSelection(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [options.mode]); return ( <> ); }; export default ModePicker; ================================================ FILE: modules/room/modules/toolbar/components/ShapeSelector.tsx ================================================ import { useRef, useState } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; import { BiRectangle } from 'react-icons/bi'; import { BsCircle } from 'react-icons/bs'; import { CgShapeZigzag } from 'react-icons/cg'; import { useClickAway } from 'react-use'; import { useOptions } from '@/common/recoil/options'; import { Shape } from '@/common/types/global'; import { EntryAnimation } from '../animations/Entry.animations'; const ShapeSelector = () => { const [options, setOptions] = useOptions(); const ref = useRef(null); const [opened, setOpened] = useState(false); useClickAway(ref, () => setOpened(false)); const handleShapeChange = (shape: Shape) => { setOptions((prev) => ({ ...prev, shape, })); setOpened(false); }; return (
{opened && ( )}
); }; export default ShapeSelector; ================================================ FILE: modules/room/modules/toolbar/components/ToolBar.tsx ================================================ import { useEffect, useState } from 'react'; import { motion } from 'framer-motion'; import { useRouter } from 'next/router'; import { FiChevronRight } from 'react-icons/fi'; import { HiOutlineDownload } from 'react-icons/hi'; import { ImExit } from 'react-icons/im'; import { IoIosShareAlt } from 'react-icons/io'; import { CANVAS_SIZE } from '@/common/constants/canvasSize'; import { useViewportSize } from '@/common/hooks/useViewportSize'; import { useModal } from '@/modules/modal'; import { useRefs } from '../../../hooks/useRefs'; import ShareModal from '../modals/ShareModal'; import BackgroundPicker from './BackgoundPicker'; import ColorPicker from './ColorPicker'; import HistoryBtns from './HistoryBtns'; import ImagePicker from './ImagePicker'; import LineWidthPicker from './LineWidthPicker'; import ModePicker from './ModePicker'; import ShapeSelector from './ShapeSelector'; const ToolBar = () => { const { canvasRef, bgRef } = useRefs(); const { openModal } = useModal(); const { width } = useViewportSize(); const [opened, setOpened] = useState(false); const router = useRouter(); useEffect(() => { if (width >= 1024) setOpened(true); else setOpened(false); }, [width]); const handleExit = () => router.push('/'); const handleDownload = () => { const canvas = document.createElement('canvas'); canvas.width = CANVAS_SIZE.width; canvas.height = CANVAS_SIZE.height; const tempCtx = canvas.getContext('2d'); if (tempCtx && canvasRef.current && bgRef.current) { tempCtx.drawImage(bgRef.current, 0, 0); tempCtx.drawImage(canvasRef.current, 0, 0); } const link = document.createElement('a'); link.href = canvas.toDataURL('image/png'); link.download = 'canvas.png'; link.click(); }; const handleShare = () => openModal(); return ( <> setOpened(!opened)} >
); }; export default ToolBar; ================================================ FILE: modules/room/modules/toolbar/index.ts ================================================ import ToolBar from './components/ToolBar'; export default ToolBar; ================================================ FILE: modules/room/modules/toolbar/modals/BackgroundModal.tsx ================================================ import { useEffect } from 'react'; import { AiOutlineClose } from 'react-icons/ai'; import { useBackground, useSetBackground } from '@/common/recoil/background'; import { useModal } from '@/modules/modal'; const BackgroundModal = () => { const { closeModal } = useModal(); const setBackground = useSetBackground(); const bg = useBackground(); useEffect(() => closeModal, [bg, closeModal]); const renderBg = ( ref: HTMLCanvasElement | null, mode: 'dark' | 'light', lines: boolean ) => { const ctx = ref?.getContext('2d'); if (ctx) { ctx.fillStyle = mode === 'dark' ? '#222' : '#fff'; ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height); if (lines) { ctx.lineWidth = 1; ctx.strokeStyle = mode === 'dark' ? '#444' : '#ddd'; for (let i = 0; i < ctx.canvas.height; i += 10) { ctx.beginPath(); ctx.moveTo(0, i); ctx.lineTo(ctx.canvas.width, i); ctx.stroke(); } for (let i = 0; i < ctx.canvas.width; i += 10) { ctx.beginPath(); ctx.moveTo(i, 0); ctx.lineTo(i, ctx.canvas.height); ctx.stroke(); } } } }; return (

Choose background

setBackground('dark', true)} ref={(ref) => renderBg(ref, 'dark', true)} /> setBackground('light', true)} ref={(ref) => renderBg(ref, 'light', true)} /> setBackground('dark', false)} ref={(ref) => renderBg(ref, 'dark', false)} /> setBackground('light', false)} ref={(ref) => renderBg(ref, 'light', false)} />
); }; export default BackgroundModal; ================================================ FILE: modules/room/modules/toolbar/modals/ShareModal.tsx ================================================ import { useEffect, useState } from 'react'; import { AiOutlineClose } from 'react-icons/ai'; import { useRoom } from '@/common/recoil/room'; import { useModal } from '@/modules/modal'; const ShareModal = () => { const { id } = useRoom(); const { closeModal } = useModal(); const [url, setUrl] = useState(''); useEffect(() => setUrl(window.location.href), []); const handleCopy = () => navigator.clipboard.writeText(url); return (

Invite

Room id:

{id}

); }; export default ShareModal; ================================================ FILE: next-env.d.ts ================================================ /// /// // NOTE: This file should not be edited // see https://nextjs.org/docs/basic-features/typescript for more information. ================================================ FILE: next.config.js ================================================ /** @type {import('next').NextConfig} */ module.exports = { reactStrictMode: true, }; ================================================ FILE: package.json ================================================ { "name": "collabio", "private": true, "scripts": { "dev": "nodemon server/index.ts", "dev:client": "ts-node server/index.ts", "build:server": "tsc --project tsconfig.server.json", "build:next": "next build", "build": "npm-run-all build:*", "start": "NODE_ENV=production node build/server/index.js", "lint": "next lint" }, "dependencies": { "express": "^4.17.3", "framer-motion": "^6.3.3", "next": "latest", "react": "^17.0.2", "react-colorful": "^5.5.1", "react-dom": "^17.0.2", "react-icons": "^4.3.1", "react-image-file-resizer": "^0.4.8", "react-toastify": "^9.0.3", "react-use": "^17.3.2", "recoil": "^0.7.3-alpha.2", "socket.io": "^4.5.0", "socket.io-client": "^4.5.0", "uuid": "^8.3.2" }, "devDependencies": { "@types/express": "^4.17.13", "@types/node": "17.0.4", "@types/react": "17.0.38", "@types/react-dom": "^18.0.4", "@types/uuid": "^8.3.4", "autoprefixer": "^10.4.0", "eslint": "8.2.0", "eslint-config-airbnb": "^19.0.4", "eslint-config-airbnb-typescript": "^16.1.4", "eslint-config-next": "^12.1.2", "eslint-config-prettier": "^8.5.0", "eslint-plugin-import": "^2.25.4", "eslint-plugin-jsx-a11y": "^6.5.1", "eslint-plugin-prettier": "^4.0.0", "eslint-plugin-react": "^7.29.4", "eslint-plugin-react-hooks": "^4.3.0", "eslint-plugin-tailwindcss": "^3.5.0", "eslint-plugin-unused-imports": "^2.0.0", "npm-run-all": "^4.1.5", "postcss": "^8.4.5", "prettier": "^2.6.1", "prettier-plugin-tailwindcss": "^0.1.8", "tailwindcss": "^3.0.7", "typescript": "4.5.4" } } ================================================ FILE: pages/[roomId].tsx ================================================ import type { NextPage } from 'next'; import Room from '@/modules/room'; const RoomPage: NextPage = () => { return ; }; export default RoomPage; ================================================ FILE: pages/_app.tsx ================================================ import '../common/styles/global.css'; import { MotionConfig } from 'framer-motion'; import type { AppProps } from 'next/app'; import Head from 'next/head'; import { ToastContainer } from 'react-toastify'; import { RecoilRoot } from 'recoil'; import { DEFAULT_EASE } from '@/common/constants/easings'; import { ModalManager } from '@/modules/modal'; import 'react-toastify/dist/ReactToastify.min.css'; const App = ({ Component, pageProps }: AppProps) => { return ( <> Collabio | Online Whiteboard ); }; export default App; ================================================ FILE: pages/_document.tsx ================================================ import { Html, Main, NextScript, Head } from 'next/document'; const document = () => (
); export default document; ================================================ FILE: pages/index.tsx ================================================ import type { NextPage } from 'next'; import Home from '@/modules/home'; const HomePage: NextPage = () => { return ; }; export default HomePage; ================================================ FILE: postcss.config.js ================================================ module.exports = { plugins: { tailwindcss: {}, autoprefixer: {}, }, }; ================================================ FILE: server/index.ts ================================================ import { createServer } from 'http'; import express from 'express'; import next, { NextApiHandler } from 'next'; import { Server } from 'socket.io'; import { v4 } from 'uuid'; import { ClientToServerEvents, Move, Room, ServerToClientEvents, } from '@/common/types/global'; const port = parseInt(process.env.PORT || '3000', 10); const dev = process.env.NODE_ENV !== 'production'; const nextApp = next({ dev }); const nextHandler: NextApiHandler = nextApp.getRequestHandler(); nextApp.prepare().then(async () => { const app = express(); const server = createServer(app); const io = new Server(server); app.get('/hello', async (_, res) => { res.send('Hello World'); }); const rooms = new Map(); const addMove = (roomId: string, socketId: string, move: Move) => { const room = rooms.get(roomId)!; if (!room.users.has(socketId)) { room.usersMoves.set(socketId, [move]); } room.usersMoves.get(socketId)!.push(move); }; const undoMove = (roomId: string, socketId: string) => { const room = rooms.get(roomId)!; room.usersMoves.get(socketId)!.pop(); }; io.on('connection', (socket) => { const getRoomId = () => { const joinedRoom = [...socket.rooms].find((room) => room !== socket.id); if (!joinedRoom) return socket.id; return joinedRoom; }; const leaveRoom = (roomId: string, socketId: string) => { const room = rooms.get(roomId); if (!room) return; const userMoves = room.usersMoves.get(socketId); if (userMoves) room.drawed.push(...userMoves); room.users.delete(socketId); socket.leave(roomId); }; socket.on('create_room', (username) => { let roomId: string; do { roomId = Math.random().toString(36).substring(2, 6); } while (rooms.has(roomId)); socket.join(roomId); rooms.set(roomId, { usersMoves: new Map([[socket.id, []]]), drawed: [], users: new Map([[socket.id, username]]), }); io.to(socket.id).emit('created', roomId); }); socket.on('check_room', (roomId) => { if (rooms.has(roomId)) socket.emit('room_exists', true); else socket.emit('room_exists', false); }); socket.on('join_room', (roomId, username) => { const room = rooms.get(roomId); if (room && room.users.size < 12) { socket.join(roomId); room.users.set(socket.id, username); room.usersMoves.set(socket.id, []); io.to(socket.id).emit('joined', roomId); } else io.to(socket.id).emit('joined', '', true); }); socket.on('joined_room', () => { const roomId = getRoomId(); const room = rooms.get(roomId); if (!room) return; io.to(socket.id).emit( 'room', room, JSON.stringify([...room.usersMoves]), JSON.stringify([...room.users]) ); socket.broadcast .to(roomId) .emit('new_user', socket.id, room.users.get(socket.id) || 'Anonymous'); }); socket.on('leave_room', () => { const roomId = getRoomId(); leaveRoom(roomId, socket.id); io.to(roomId).emit('user_disconnected', socket.id); }); socket.on('draw', (move) => { const roomId = getRoomId(); const timestamp = Date.now(); // eslint-disable-next-line no-param-reassign move.id = v4(); addMove(roomId, socket.id, { ...move, timestamp }); io.to(socket.id).emit('your_move', { ...move, timestamp }); socket.broadcast .to(roomId) .emit('user_draw', { ...move, timestamp }, socket.id); }); socket.on('undo', () => { const roomId = getRoomId(); undoMove(roomId, socket.id); socket.broadcast.to(roomId).emit('user_undo', socket.id); }); socket.on('mouse_move', (x, y) => { socket.broadcast.to(getRoomId()).emit('mouse_moved', x, y, socket.id); }); socket.on('send_msg', (msg) => { io.to(getRoomId()).emit('new_msg', socket.id, msg); }); socket.on('disconnecting', () => { const roomId = getRoomId(); leaveRoom(roomId, socket.id); io.to(roomId).emit('user_disconnected', socket.id); }); }); app.all('*', (req: any, res: any) => nextHandler(req, res)); server.listen(port, () => { // eslint-disable-next-line no-console console.log(`> Ready on http://localhost:${port}`); }); }); ================================================ FILE: tailwind.config.js ================================================ module.exports = { content: [ './pages/**/*.{js,ts,jsx,tsx}', './common/**/*.{js,ts,jsx,tsx}', './modules/**/*.{js,ts,jsx,tsx}', ], darkMode: 'class', theme: { fontSize: { xs: '0.75rem', sm: '0.875rem', base: '1rem', lg: '1.125rem', xl: '1.25rem', '2xl': '1.5rem', '3xl': '1.875rem', '4xl': '2.25rem', '5xl': '3rem', '6xl': '4rem', extra: '6rem', }, extend: { colors: {}, width: { 160: '40rem', }, }, fontFamily: { montserrat: ['Montserrat', 'sans-serif'], }, }, plugins: [], }; ================================================ FILE: tsconfig.json ================================================ { "compilerOptions": { "target": "es5", "lib": ["dom", "dom.iterable", "esnext"], "allowJs": true, "skipLibCheck": true, "strict": true, "forceConsistentCasingInFileNames": true, "noEmit": true, "esModuleInterop": true, "module": "CommonJS", "moduleResolution": "node", "resolveJsonModule": true, "isolatedModules": true, "jsx": "preserve", "incremental": true, "downlevelIteration": true, "baseUrl": ".", "paths": { "@/*": ["./*"] } }, "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], "exclude": ["node_modules"] } ================================================ FILE: tsconfig.server.json ================================================ { "extends": "./tsconfig.json", "compilerOptions": { "module": "commonjs", "outDir": "build", "target": "es2017", "isolatedModules": false, "noEmit": false }, "include": ["server"] }