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

#### Board page

================================================
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<HTMLElement>();
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<ServerToClientEvents, ClientToServerEvents> = 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<CtxOptions>({
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<ClientRoom>({
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<Move[]>({
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,<svg xmlns="http://www.w3.org/2000/svg" style="transform: rotate(-90deg); stroke: white; stroke-width: 1px;" version="1.1" width="16" height="16"><path d="M14.082 2.182a.5.5 0 0 1 .103.557L8.528 15.467a.5.5 0 0 1-.917-.007L5.57 10.694.803 8.652a.5.5 0 0 1-.006-.916l12.728-5.657a.5.5 0 0 1 .556.103z"></path></svg>'),
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<string, Move[]>;
drawed: Move[];
users: Map<string, string>;
};
export interface User {
name: string;
color: string;
}
export interface ClientRoom {
id: string;
usersMoves: Map<string, Move[]>;
movesWithoutUser: Move[];
myMoves: Move[];
users: Map<string, User>;
}
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(<NotFoundModal id={roomId} />);
}
};
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<HTMLFormElement>) => {
e.preventDefault();
if (roomId) socket.emit('join_room', roomId, username);
};
return (
<div className="flex flex-col items-center py-24">
<h1 className="text-5xl font-extrabold leading-tight sm:text-extra">
Collabio
</h1>
<h3 className="text-xl sm:text-2xl">Real-time whiteboard</h3>
<div className="mt-10 flex flex-col gap-2">
<label className="self-start font-bold leading-tight">
Enter your name
</label>
<input
className="input"
id="room-id"
placeholder="Username..."
value={username}
onChange={(e) => setUsername(e.target.value.slice(0, 15))}
/>
</div>
<div className="my-8 h-px w-96 bg-zinc-200" />
<form
className="flex flex-col items-center gap-3"
onSubmit={handleJoinRoom}
>
<label htmlFor="room-id" className="self-start font-bold leading-tight">
Enter room id
</label>
<input
className="input"
id="room-id"
placeholder="Room id..."
value={roomId}
onChange={(e) => setRoomId(e.target.value)}
/>
<button className="btn" type="submit">
Join
</button>
</form>
<div className="my-8 flex w-96 items-center gap-2">
<div className="h-px w-full bg-zinc-200" />
<p className="text-zinc-400">or</p>
<div className="h-px w-full bg-zinc-200" />
</div>
<div className="flex flex-col items-center gap-2">
<h5 className="self-start font-bold leading-tight">Create new room</h5>
<button className="btn" onClick={handleCreateRoom}>
Create
</button>
</div>
</div>
);
};
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 (
<div className="relative flex flex-col items-center rounded-md bg-white p-10 ">
<button onClick={closeModal} className="absolute top-5 right-5">
<AiOutlineClose />
</button>
<h2 className="text-lg font-bold">
Room with id "{id}" does not exist or is full!
</h2>
<h3>Try to join room later.</h3>
</div>
);
};
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<HTMLElement>();
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 (
<Portal>
<motion.div
className="absolute z-40 flex min-h-full w-full items-center justify-center bg-black/80"
onClick={() => setModal({ modal: <></>, opened: false })}
variants={bgAnimation}
initial="closed"
animate={opened ? 'opened' : 'closed'}
>
<AnimatePresence>
{opened && (
<motion.div
variants={modalAnimation}
initial="closed"
animate="opened"
exit="exited"
onClick={(e) => e.stopPropagation()}
className="p-6"
>
{modal}
</motion.div>
)}
</AnimatePresence>
</motion.div>
</Portal>
);
};
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(<NotFoundModal id={roomIdFromServer} />);
} else setRoomId(roomIdFromServer);
};
socket.on('joined', handleJoined);
return () => {
socket.off('joined', handleJoined);
};
}, [openModal, router, setRoomId]);
const handleJoinRoom = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
socket.emit('join_room', roomId, name);
};
return (
<form
className="my-24 flex flex-col items-center"
onSubmit={handleJoinRoom}
>
<h1 className="text-5xl font-extrabold leading-tight sm:text-extra">
Collabio
</h1>
<h3 className="text-xl sm:text-2xl">Real-time whiteboard</h3>
<div className="mt-10 mb-3 flex flex-col gap-2">
<label className="self-start font-bold leading-tight">
Enter your name
</label>
<input
className="rounded-xl border p-5 py-1"
id="room-id"
placeholder="Username..."
value={name}
onChange={(e) => setName(e.target.value.slice(0, 15))}
/>
</div>
<button className="btn" type="submit">
Enter room
</button>
</form>
);
};
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 <NameInput />;
return (
<RoomContextProvider>
<div className="relative h-full w-full overflow-hidden">
<UserList />
<ToolBar />
<Board />
<Chat />
</div>
</RoomContextProvider>
);
};
export default Room;
================================================
FILE: modules/room/components/UserList.tsx
================================================
import { useRoom } from '@/common/recoil/room';
const UserList = () => {
const { users } = useRoom();
return (
<div className="pointer-events-none absolute z-30 flex p-5">
{[...users.keys()].map((userId, index) => {
return (
<div
key={userId}
className="flex h-5 w-5 select-none items-center justify-center rounded-full text-xs text-white md:h-8 md:w-8 md:text-base lg:h-12 lg:w-12"
style={{
backgroundColor: users.get(userId)?.color || 'black',
marginLeft: index !== 0 ? '-0.5rem' : 0,
}}
>
{users.get(userId)?.name.split('')[0] || 'A'}
</div>
);
})}
</div>
);
};
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<number>;
y: MotionValue<number>;
undoRef: RefObject<HTMLButtonElement>;
redoRef: RefObject<HTMLButtonElement>;
canvasRef: RefObject<HTMLCanvasElement>;
bgRef: RefObject<HTMLCanvasElement>;
selectionRefs: RefObject<HTMLButtonElement[]>;
minimapRef: RefObject<HTMLCanvasElement>;
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<HTMLButtonElement>(null);
const redoRef = useRef<HTMLButtonElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const bgRef = useRef<HTMLCanvasElement>(null);
const minimapRef = useRef<HTMLCanvasElement>(null);
const selectionRefs = useRef<HTMLButtonElement[]>([]);
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<string, Move[]>(JSON.parse(usersMovesToParse));
const usersParsed = new Map<string, string>(JSON.parse(usersToParse));
const newUsers = new Map<string, User>();
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 (
<roomContext.Provider
value={{
x,
y,
bgRef,
undoRef,
redoRef,
canvasRef,
setMoveImage,
moveImage,
minimapRef,
selectionRefs,
}}
>
{children}
</roomContext.Provider>
);
};
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<HTMLImageElement>((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<HTMLCanvasElement> }) => {
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 (
<motion.canvas
ref={bgRef}
width={CANVAS_SIZE.width}
height={CANVAS_SIZE.height}
className="absolute top-0 bg-zinc-100"
style={{ x, y }}
/>
);
};
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 (
<div className="relative h-full w-full overflow-hidden">
<motion.canvas
// SETTINGS
ref={canvasRef}
width={CANVAS_SIZE.width}
height={CANVAS_SIZE.height}
className={`absolute top-0 z-10 ${dragging && 'cursor-move'}`}
style={{ x, y }}
// DRAG
drag={dragging}
dragConstraints={{
left: -(CANVAS_SIZE.width - width),
right: 0,
top: -(CANVAS_SIZE.height - height),
bottom: 0,
}}
dragControls={dragControls}
dragElastic={0}
dragTransition={{ power: 0, timeConstant: 0 }}
// HANDLERS
onContextMenu={(e) => {
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)
}
/>
<Background bgRef={bgRef} />
<MiniMap dragging={dragging} />
<button
className={`absolute bottom-14 right-5 z-10 rounded-xl md:bottom-5 ${
dragging ? 'bg-green-500' : 'bg-zinc-300 text-black'
} p-3 text-lg text-white`}
onClick={() => setDragging((prev) => !prev)}
>
<BsArrowsMove />
</button>
</div>
);
};
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<HTMLDivElement>(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 (
<div
className="absolute right-10 top-10 z-30 overflow-hidden rounded-lg shadow-lg"
style={{
width: CANVAS_SIZE.width / divider,
height: CANVAS_SIZE.height / divider,
}}
ref={containerRef}
>
<canvas
ref={minimapRef}
width={CANVAS_SIZE.width}
height={CANVAS_SIZE.height}
className="h-full w-full"
/>
<motion.div
drag
dragConstraints={containerRef}
dragElastic={0}
dragTransition={{ power: 0, timeConstant: 0 }}
onDragStart={() => 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 }}
/>
</div>
);
};
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<HTMLDivElement>(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 (
<motion.div
ref={ref}
className="pointer-events-none absolute top-0 left-0 z-50 select-none transition-colors dark:text-white"
animate={{ x: docX + 15, y: docY + 15 }}
transition={{ duration: 0.05, ease: 'linear' }}
>
{getPos(docX, x).toFixed(0)} | {getPos(docY, y).toFixed(0)}
</motion.div>
);
};
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 <UserMouse userId={userId} key={userId} />;
})}
</>
);
};
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 (
<motion.div
drag
dragElastic={0}
dragTransition={{ power: 0.03, timeConstant: 50 }}
className="absolute top-0 z-20 cursor-grab"
style={{ x: imageX, y: imageY }}
>
<div className="absolute bottom-full mb-2 flex gap-3">
<button
className="rounded-full bg-gray-200 p-2"
onClick={handlePlaceImage}
>
<AiOutlineCheck />
</button>
<button
className="rounded-full bg-gray-200 p-2"
onClick={() => setMoveImage({ base64: '' })}
>
<AiOutlineClose />
</button>
</div>
<img
className="pointer-events-none"
alt="image to place"
src={moveImage.base64}
/>
</motion.div>
);
};
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 (
<div
className="absolute top-0 left-0 z-50 flex items-center justify-center gap-2"
style={{ top, left }}
>
<button
className="rounded-full bg-gray-200 p-2"
ref={(ref) => {
if (ref && selectionRefs.current) selectionRefs.current[0] = ref;
}}
>
<BsArrowsMove />
</button>
<button
className="rounded-full bg-gray-200 p-2"
ref={(ref) => {
if (ref && selectionRefs.current) selectionRefs.current[1] = ref;
}}
>
<FiCopy />
</button>
<button
className="rounded-full bg-gray-200 p-2"
ref={(ref) => {
if (ref && selectionRefs.current) selectionRefs.current[2] = ref;
}}
>
<AiOutlineDelete />
</button>
</div>
);
};
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 (
<motion.div
className={`pointer-events-none absolute top-0 left-0 z-20 text-blue-800 ${
pos.x === -1 && 'hidden'
}`}
style={{ color: users.get(userId)?.color }}
animate={{ x: pos.x + x, y: pos.y + y }}
transition={{ duration: 0.2, ease: 'linear' }}
>
<BsCursorFill className="-rotate-90" />
{msg && (
<p className="absolute top-full left-5 max-h-20 max-w-[15rem] overflow-hidden text-ellipsis rounded-md bg-zinc-900 p-1 px-3 text-white">
{msg}
</p>
)}
<p className="ml-2">{users.get(userId)?.name || 'Anonymous'}</p>
</motion.div>
);
};
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<CanvasRenderingContext2D>();
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<void>) => {
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 = () => (
<>
<Canvas />
<MousePosition />
<MousesRenderer />
<MoveImage />
<SelectionBtns />
</>
);
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<HTMLDivElement>(null);
const [newMsg, setNewMsg] = useState(false);
const [opened, setOpened] = useState(false);
const [msgs, handleMsgs] = useList<MessageType>([]);
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 (
<motion.div
className="absolute bottom-0 z-50 flex h-[300px] w-full flex-col overflow-hidden rounded-t-md sm:left-36 sm:w-[30rem]"
animate={{ y: opened ? 0 : 260 }}
transition={{ duration: 0.2 }}
>
<button
className="flex w-full cursor-pointer items-center justify-between bg-zinc-900 py-2 px-10 font-semibold text-white"
onClick={() => {
setOpened((prev) => !prev);
setNewMsg(false);
}}
>
<div className="flex items-center gap-2">
<BsFillChatFill className="mt-[-2px]" />
Chat
{newMsg && (
<p className="rounded-md bg-green-500 px-1 font-semibold text-green-900">
New!
</p>
)}
</div>
<motion.div
animate={{ rotate: opened ? 0 : 180 }}
transition={{ duration: 0.2 }}
>
<FaChevronDown />
</motion.div>
</button>
<div className="flex flex-1 flex-col justify-between bg-white p-3">
<div className="h-[190px] overflow-y-scroll pr-2" ref={msgList}>
{msgs.map((msg) => (
<Message key={msg.id} {...msg} />
))}
</div>
<ChatInput />
</div>
</motion.div>
);
};
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<HTMLFormElement>) => {
e.preventDefault();
socket.emit('send_msg', msg);
setMsg('');
};
return (
<form className="flex w-full items-center gap-2" onSubmit={handleSubmit}>
<input
className="w-full rounded-xl border border-zinc-300 p-5 py-1"
value={msg}
onChange={(e) => setMsg(e.target.value)}
/>
<button className="btn-icon h-full w-10 bg-black" type="submit">
<AiOutlineSend />
</button>
</form>
);
};
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 (
<div
className={`my-2 flex gap-2 text-clip ${me && 'justify-end text-right'}`}
>
{!me && (
<h5 style={{ color }} className="font-bold">
{username}
</h5>
)}
<p style={{ wordBreak: 'break-all' }}>{msg}</p>
</div>
);
};
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 (
<button className="btn-icon" onClick={() => openModal(<BackgroundModal />)}>
<CgScreen />
</button>
);
};
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<HTMLDivElement>(null);
const [opened, setOpened] = useState(false);
useClickAway(ref, () => setOpened(false));
return (
<div className="relative flex items-center" ref={ref}>
<button
className="btn-icon"
onClick={() => setOpened(!opened)}
disabled={options.mode === 'select'}
>
<BsPaletteFill />
</button>
<AnimatePresence>
{opened && (
<motion.div
className="absolute left-10 mt-24 sm:left-14"
variants={EntryAnimation}
initial="from"
animate="to"
exit="from"
>
<h2 className="ml-3 font-semibold text-black dark:text-white">
Line color
</h2>
<RgbaColorPicker
color={options.lineColor}
onChange={(e) => {
setOptions({
...options,
lineColor: e,
});
}}
className="mb-5"
/>
<h2 className="ml-3 font-semibold text-black dark:text-white">
Fill color
</h2>
<RgbaColorPicker
color={options.fillColor}
onChange={(e) => {
setOptions({
...options,
fillColor: e,
});
}}
/>
</motion.div>
)}
</AnimatePresence>
</div>
);
};
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 (
<>
<button
className="btn-icon text-xl"
ref={redoRef}
disabled={!savedMoves.length}
>
<FaRedo />
</button>
<button
className="btn-icon text-xl"
ref={undoRef}
disabled={!myMoves.length}
>
<FaUndo />
</button>
</>
);
};
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 (
<button className="btn-icon text-xl" onClick={handleImageInput}>
<BsFillImageFill />
</button>
);
};
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<HTMLDivElement>(null);
const [opened, setOpened] = useState(false);
useClickAway(ref, () => setOpened(false));
return (
<div className="relative flex items-center" ref={ref}>
<button
className="btn-icon text-xl"
onClick={() => setOpened(!opened)}
disabled={options.mode === 'select'}
>
<BsBorderWidth />
</button>
<AnimatePresence>
{opened && (
<motion.div
className="absolute top-[6px] left-14 w-36"
variants={EntryAnimation}
initial="from"
animate="to"
exit="from"
>
<input
type="range"
min={1}
max={20}
value={options.lineWidth}
onChange={(e) =>
setOptions((prev) => ({
...prev,
lineWidth: parseInt(e.target.value, 10),
}))
}
className="h-4 w-full cursor-pointer appearance-none rounded-lg bg-gray-200"
/>
</motion.div>
)}
</AnimatePresence>
</div>
);
};
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 (
<>
<button
className={`btn-icon text-xl ${
options.mode === 'draw' && 'bg-green-400'
}`}
onClick={() => {
setOptions((prev) => ({
...prev,
mode: 'draw',
}));
}}
>
<BsPencilFill />
</button>
<button
className={`btn-icon text-xl ${
options.mode === 'eraser' && 'bg-green-400'
}`}
onClick={() => {
setOptions((prev) => ({
...prev,
mode: 'eraser',
}));
}}
>
<FaEraser />
</button>
<button
className={`btn-icon text-2xl ${
options.mode === 'select' && 'bg-green-400'
}`}
onClick={() => {
setOptions((prev) => ({
...prev,
mode: 'select',
}));
}}
>
<AiOutlineSelect />
</button>
</>
);
};
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<HTMLDivElement>(null);
const [opened, setOpened] = useState(false);
useClickAway(ref, () => setOpened(false));
const handleShapeChange = (shape: Shape) => {
setOptions((prev) => ({
...prev,
shape,
}));
setOpened(false);
};
return (
<div className="relative flex items-center" ref={ref}>
<button
className="btn-icon text-2xl"
disabled={options.mode === 'select'}
onClick={() => setOpened((prev) => !prev)}
>
{options.shape === 'circle' && <BsCircle />}
{options.shape === 'rect' && <BiRectangle />}
{options.shape === 'line' && <CgShapeZigzag />}
</button>
<AnimatePresence>
{opened && (
<motion.div
className="absolute left-14 z-10 flex gap-1 rounded-lg border bg-zinc-900 p-2 md:border-0"
variants={EntryAnimation}
initial="from"
animate="to"
exit="from"
>
<button
className="btn-icon text-2xl"
onClick={() => handleShapeChange('line')}
>
<CgShapeZigzag />
</button>
<button
className="btn-icon text-2xl"
onClick={() => handleShapeChange('rect')}
>
<BiRectangle />
</button>
<button
className="btn-icon text-2xl"
onClick={() => handleShapeChange('circle')}
>
<BsCircle />
</button>
</motion.div>
)}
</AnimatePresence>
</div>
);
};
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(<ShareModal />);
return (
<>
<motion.button
className="btn-icon absolute bottom-1/2 -left-2 z-50 h-10 w-10 rounded-full bg-black text-2xl transition-none lg:hidden"
animate={{ rotate: opened ? 0 : 180 }}
transition={{ duration: 0.2 }}
onClick={() => setOpened(!opened)}
>
<FiChevronRight />
</motion.button>
<motion.div
className="absolute left-10 top-[50%] z-50 grid grid-cols-2 items-center gap-5 rounded-lg bg-zinc-900 p-5 text-white 2xl:grid-cols-1"
animate={{
x: opened ? 0 : -160,
y: '-50%',
}}
transition={{
duration: 0.2,
}}
>
<HistoryBtns />
<div className="h-px w-full bg-white 2xl:hidden" />
<div className="h-px w-full bg-white" />
<ShapeSelector />
<ColorPicker />
<LineWidthPicker />
<ModePicker />
<ImagePicker />
<div className="2xl:hidden"></div>
<div className="h-px w-full bg-white 2xl:hidden" />
<div className="h-px w-full bg-white" />
<BackgroundPicker />
<button className="btn-icon text-2xl" onClick={handleShare}>
<IoIosShareAlt />
</button>
<button className="btn-icon text-2xl" onClick={handleDownload}>
<HiOutlineDownload />
</button>
<button className="btn-icon text-xl" onClick={handleExit}>
<ImExit />
</button>
</motion.div>
</>
);
};
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 (
<div className="relative flex flex-col items-center rounded-md bg-white p-10">
<button onClick={closeModal} className="absolute top-5 right-5">
<AiOutlineClose />
</button>
<h2 className="mb-4 text-2xl font-bold">Choose background</h2>
<div className="grid gap-5 sm:grid-cols-2">
<canvas
className="h-48 w-64 cursor-pointer rounded-md border-2"
tabIndex={0}
width={256}
height={192}
onClick={() => setBackground('dark', true)}
ref={(ref) => renderBg(ref, 'dark', true)}
/>
<canvas
className="h-48 w-64 cursor-pointer rounded-md border-2"
tabIndex={0}
width={256}
height={192}
onClick={() => setBackground('light', true)}
ref={(ref) => renderBg(ref, 'light', true)}
/>
<canvas
className="h-48 w-64 cursor-pointer rounded-md border-2"
tabIndex={0}
width={256}
height={192}
onClick={() => setBackground('dark', false)}
ref={(ref) => renderBg(ref, 'dark', false)}
/>
<canvas
className="h-48 w-64 cursor-pointer rounded-md border-2"
tabIndex={0}
width={256}
height={192}
onClick={() => setBackground('light', false)}
ref={(ref) => renderBg(ref, 'light', false)}
/>
</div>
</div>
);
};
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 (
<div className="relative flex flex-col items-center rounded-md bg-white p-10 pt-5">
<button onClick={closeModal} className="absolute top-5 right-5">
<AiOutlineClose />
</button>
<h2 className="text-2xl font-bold">Invite</h2>
<h3>
Room id: <p className="inline font-bold">{id}</p>
</h3>
<div className="relative mt-2">
<input type="text" value={url} readOnly className="input sm:w-96" />
<button className="btn absolute right-0 h-full" onClick={handleCopy}>
Copy
</button>
</div>
</div>
);
};
export default ShareModal;
================================================
FILE: next-env.d.ts
================================================
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// 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 <Room />;
};
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 (
<>
<Head>
<title>Collabio | Online Whiteboard</title>
<link rel="icon" href="/favicon.ico" />
</Head>
<RecoilRoot>
<ToastContainer />
<MotionConfig transition={{ ease: DEFAULT_EASE }}>
<ModalManager />
<Component {...pageProps} />
</MotionConfig>
</RecoilRoot>
</>
);
};
export default App;
================================================
FILE: pages/_document.tsx
================================================
import { Html, Main, NextScript, Head } from 'next/document';
const document = () => (
<Html>
<Head>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link
rel="preconnect"
href="https://fonts.gstatic.com"
crossOrigin="allow"
/>
<link
href="https://fonts.googleapis.com/css2?family=Montserrat:wght@100;300;400;500;600;700;800;900&display=swap"
rel="stylesheet"
/>
</Head>
<body>
<div id="portal"></div>
<Main />
<NextScript />
</body>
</Html>
);
export default document;
================================================
FILE: pages/index.tsx
================================================
import type { NextPage } from 'next';
import Home from '@/modules/home';
const HomePage: NextPage = () => {
return <Home />;
};
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<ClientToServerEvents, ServerToClientEvents>(server);
app.get('/hello', async (_, res) => {
res.send('Hello World');
});
const rooms = new Map<string, Room>();
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"]
}
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
SYMBOL INDEX (16 symbols across 6 files)
FILE: common/constants/canvasSize.ts
constant CANVAS_SIZE (line 1) | const CANVAS_SIZE = {
FILE: common/constants/colors.ts
constant COLORS (line 1) | const COLORS = {
constant COLORS_ARRAY (line 15) | const COLORS_ARRAY = [...Object.values(COLORS)];
FILE: common/constants/defaultMove.ts
constant DEFAULT_MOVE (line 3) | const DEFAULT_MOVE: Move = {
FILE: common/constants/easings.ts
constant DEFAULT_EASE (line 1) | const DEFAULT_EASE = [0.6, 0.01, -0.05, 0.9];
FILE: common/recoil/room/room.atom.ts
constant DEFAULT_ROOM (line 5) | const DEFAULT_ROOM = {
FILE: common/types/global.ts
type Shape (line 3) | type Shape = 'line' | 'circle' | 'rect' | 'image';
type CtxMode (line 4) | type CtxMode = 'eraser' | 'draw' | 'select';
type CtxOptions (line 6) | interface CtxOptions {
type Move (line 20) | interface Move {
type Room (line 40) | type Room = {
type User (line 46) | interface User {
type ClientRoom (line 51) | interface ClientRoom {
type MessageType (line 59) | interface MessageType {
type ServerToClientEvents (line 67) | interface ServerToClientEvents {
type ClientToServerEvents (line 81) | interface ClientToServerEvents {
Condensed preview — 89 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (114K chars).
[
{
"path": ".eslintignore",
"chars": 66,
"preview": "node_modules\nout\nbuild\n.next\n\ntailwind.config.js\npostcss.config.js"
},
{
"path": ".eslintrc",
"chars": 2713,
"preview": "{\n // Configuration for JavaScript files\n \"extends\": [\n \"airbnb-base\",\n \"next/core-web-vitals\",\n \"plugin:pret"
},
{
"path": ".github/workflows/codesee-arch-diagram.yml",
"chars": 2690,
"preview": "on:\n push:\n branches:\n - main\n pull_request_target:\n types: [opened, synchronize, reopened]\n\nname: CodeSee "
},
{
"path": ".gitignore",
"chars": 414,
"preview": "# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.\n\n# dependencies\n/node_modules\n/.pn"
},
{
"path": "README.md",
"chars": 662,
"preview": "# Collabio | Online whiteboard\n\nReal-time whiteboard made with Next.JS and Socket.IO\n## Features\n\n- Drawing lines, circl"
},
{
"path": "common/components/portal/components/Portal.ts",
"chars": 434,
"preview": "import { useEffect, useState } from 'react';\n\nimport { createPortal } from 'react-dom';\n\nconst Portal = ({ children }: {"
},
{
"path": "common/constants/canvasSize.ts",
"chars": 63,
"preview": "export const CANVAS_SIZE = {\n width: 3500,\n height: 2000,\n};\n"
},
{
"path": "common/constants/colors.ts",
"chars": 306,
"preview": "export const COLORS = {\n PURPLE: '#6B32F3',\n BLUE: '#408FF8',\n RED: '#F32D27',\n GREEN: '#6FCB12',\n GOLD: '#A89D6C',"
},
{
"path": "common/constants/defaultMove.ts",
"chars": 443,
"preview": "import { Move } from '../types/global';\n\nexport const DEFAULT_MOVE: Move = {\n circle: {\n cX: 0,\n cY: 0,\n radiu"
},
{
"path": "common/constants/easings.ts",
"chars": 53,
"preview": "export const DEFAULT_EASE = [0.6, 0.01, -0.05, 0.9];\n"
},
{
"path": "common/hooks/useViewportSize.ts",
"chars": 504,
"preview": "import { useEffect, useState } from 'react';\n\nexport const useViewportSize = () => {\n const [width, setWidth] = useStat"
},
{
"path": "common/lib/getNextColor.ts",
"chars": 284,
"preview": "import { COLORS_ARRAY } from '../constants/colors';\n\nexport const getNextColor = (color?: string) => {\n const index = C"
},
{
"path": "common/lib/getPos.ts",
"chars": 138,
"preview": "import { MotionValue } from 'framer-motion';\n\nexport const getPos = (pos: number, motionValue: MotionValue) =>\n pos - m"
},
{
"path": "common/lib/optimizeImage.ts",
"chars": 296,
"preview": "import FileResizer from 'react-image-file-resizer';\n\nexport const optimizeImage = (file: File, callback: (uri: string) ="
},
{
"path": "common/lib/rgba.ts",
"chars": 153,
"preview": "import { RgbaColor } from 'react-colorful';\n\nexport const getStringFromRgba = (rgba: RgbaColor) =>\n `rgba(${rgba.r}, ${"
},
{
"path": "common/lib/socket.ts",
"chars": 207,
"preview": "import { io, Socket } from 'socket.io-client';\n\nimport { ClientToServerEvents, ServerToClientEvents } from '../types/glo"
},
{
"path": "common/recoil/background/background.atom.ts",
"chars": 184,
"preview": "import { atom } from 'recoil';\n\nexport const backgroundAtom = atom<{ mode: 'dark' | 'light'; lines: boolean }>({\n key: "
},
{
"path": "common/recoil/background/background.hooks.ts",
"chars": 772,
"preview": "import { useEffect } from 'react';\n\nimport { useRecoilValue, useSetRecoilState } from 'recoil';\n\nimport { backgroundAtom"
},
{
"path": "common/recoil/background/index.ts",
"chars": 199,
"preview": "import { backgroundAtom } from './background.atom';\nimport { useBackground, useSetBackground } from './background.hooks'"
},
{
"path": "common/recoil/options/index.ts",
"chars": 289,
"preview": "/* eslint-disable import/no-cycle */\nimport { optionsAtom } from './options.atom';\nimport {\n useOptions,\n useSetOption"
},
{
"path": "common/recoil/options/options.atom.ts",
"chars": 333,
"preview": "import { atom } from 'recoil';\n\nimport { CtxOptions } from '@/common/types/global';\n\nexport const optionsAtom = atom<Ctx"
},
{
"path": "common/recoil/options/options.hooks.ts",
"chars": 849,
"preview": "import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil';\n\nimport { optionsAtom } from './options.atom"
},
{
"path": "common/recoil/room/index.ts",
"chars": 205,
"preview": "import { roomAtom } from './room.atom';\nimport { useRoom, useSetRoomId, useSetUsers, useMyMoves } from './room.hooks';\n\n"
},
{
"path": "common/recoil/room/room.atom.ts",
"chars": 300,
"preview": "import { atom } from 'recoil';\n\nimport { ClientRoom } from '@/common/types/global';\n\nexport const DEFAULT_ROOM = {\n id:"
},
{
"path": "common/recoil/room/room.hooks.ts",
"chars": 3063,
"preview": "import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil';\n\nimport { getNextColor } from '@/common/lib/"
},
{
"path": "common/recoil/savedMoves/index.ts",
"chars": 199,
"preview": "import { savedMovesAtom } from './savedMoves.atom';\nimport { useSavedMoves, useSetSavedMoves } from './savedMoves.hooks'"
},
{
"path": "common/recoil/savedMoves/savedMoves.atom.ts",
"chars": 165,
"preview": "import { atom } from 'recoil';\n\nimport { Move } from '@/common/types/global';\n\nexport const savedMovesAtom = atom<Move[]"
},
{
"path": "common/recoil/savedMoves/savedMoves.hooks.ts",
"chars": 854,
"preview": "import { useRecoilValue, useSetRecoilState } from 'recoil';\n\nimport { Move } from '@/common/types/global';\n\nimport { sav"
},
{
"path": "common/styles/global.css",
"chars": 2112,
"preview": "@tailwind base;\n@tailwind components;\n@tailwind utilities;\n\n* {\n @apply font-montserrat font-medium focus:outline-none "
},
{
"path": "common/types/global.ts",
"chars": 2078,
"preview": "import { RgbaColor } from 'react-colorful';\n\nexport type Shape = 'line' | 'circle' | 'rect' | 'image';\nexport type CtxMo"
},
{
"path": "modules/home/components/Home.tsx",
"chars": 3235,
"preview": "import { FormEvent, useEffect, useState } from 'react';\n\nimport { useRouter } from 'next/router';\n\nimport { socket } fro"
},
{
"path": "modules/home/index.ts",
"chars": 60,
"preview": "import Home from './components/Home';\n\nexport default Home;\n"
},
{
"path": "modules/home/modals/NotFound.tsx",
"chars": 601,
"preview": "import { AiOutlineClose } from 'react-icons/ai';\n\nimport { useModal } from '@/modules/modal';\n\nconst NotFoundModal = ({ "
},
{
"path": "modules/modal/animations/ModalManager.animations.ts",
"chars": 185,
"preview": "export const bgAnimation = {\n closed: { opacity: 0 },\n opened: { opacity: 1 },\n};\n\nexport const modalAnimation = {\n c"
},
{
"path": "modules/modal/components/ModalManager.tsx",
"chars": 1603,
"preview": "import { useEffect, useState } from 'react';\n\nimport { AnimatePresence, motion } from 'framer-motion';\nimport { useRecoi"
},
{
"path": "modules/modal/index.ts",
"chars": 139,
"preview": "import ModalManager from './components/ModalManager';\nimport { useModal } from './recoil/modal.hooks';\n\nexport { ModalMa"
},
{
"path": "modules/modal/recoil/modal.atom.tsx",
"chars": 201,
"preview": "import { atom } from 'recoil';\n\nexport const modalAtom = atom<{\n modal: JSX.Element | JSX.Element[];\n opened: boolean;"
},
{
"path": "modules/modal/recoil/modal.hooks.tsx",
"chars": 395,
"preview": "import { useSetRecoilState } from 'recoil';\n\nimport { modalAtom } from './modal.atom';\n\nconst useModal = () => {\n const"
},
{
"path": "modules/room/components/NameInput.tsx",
"chars": 2200,
"preview": "import { FormEvent, useEffect, useState } from 'react';\n\nimport { useRouter } from 'next/router';\n\nimport { socket } fro"
},
{
"path": "modules/room/components/Room.tsx",
"chars": 630,
"preview": "import { useRoom } from '@/common/recoil/room';\n\nimport RoomContextProvider from '../context/Room.context';\nimport Board"
},
{
"path": "modules/room/components/UserList.tsx",
"chars": 753,
"preview": "import { useRoom } from '@/common/recoil/room';\n\nconst UserList = () => {\n const { users } = useRoom();\n\n return (\n "
},
{
"path": "modules/room/context/Room.context.tsx",
"chars": 3638,
"preview": "import {\n createContext,\n Dispatch,\n ReactChild,\n RefObject,\n SetStateAction,\n useEffect,\n useRef,\n useState,\n} "
},
{
"path": "modules/room/hooks/useMoveImage.ts",
"chars": 234,
"preview": "import { useContext } from 'react';\n\nimport { roomContext } from '../context/Room.context';\n\nexport const useMoveImage ="
},
{
"path": "modules/room/hooks/useMovesHandlers.ts",
"chars": 6088,
"preview": "import { useEffect, useMemo } from 'react';\n\nimport { getStringFromRgba } from '@/common/lib/rgba';\nimport { socket } fr"
},
{
"path": "modules/room/hooks/useRefs.ts",
"chars": 336,
"preview": "import { useContext } from 'react';\n\nimport { roomContext } from '../context/Room.context';\n\nexport const useRefs = () ="
},
{
"path": "modules/room/index.ts",
"chars": 60,
"preview": "import Room from './components/Room';\n\nexport default Room;\n"
},
{
"path": "modules/room/modules/board/components/Background.tsx",
"chars": 1477,
"preview": "import { RefObject, useEffect } from 'react';\n\nimport { motion } from 'framer-motion';\n\nimport { CANVAS_SIZE } from '@/c"
},
{
"path": "modules/room/modules/board/components/Canvas.tsx",
"chars": 3701,
"preview": "import { useEffect, useState } from 'react';\n\nimport { motion, useDragControls } from 'framer-motion';\nimport { BsArrows"
},
{
"path": "modules/room/modules/board/components/Minimap.tsx",
"chars": 2760,
"preview": "import { useEffect, useMemo, useRef, useState } from 'react';\n\nimport { motion, useMotionValue } from 'framer-motion';\n\n"
},
{
"path": "modules/room/modules/board/components/MousePosition.tsx",
"chars": 1245,
"preview": "import { useRef } from 'react';\n\nimport { motion } from 'framer-motion';\nimport { useInterval, useMouse } from 'react-us"
},
{
"path": "modules/room/modules/board/components/MousesRenderer.tsx",
"chars": 421,
"preview": "import { socket } from '@/common/lib/socket';\nimport { useRoom } from '@/common/recoil/room';\n\nimport UserMouse from './"
},
{
"path": "modules/room/modules/board/components/MoveImage.tsx",
"chars": 2225,
"preview": "import { useEffect } from 'react';\n\nimport { motion, useMotionValue } from 'framer-motion';\nimport { AiOutlineCheck, AiO"
},
{
"path": "modules/room/modules/board/components/SelectionBtns.tsx",
"chars": 1873,
"preview": "import { useEffect, useState } from 'react';\n\nimport { AiOutlineDelete } from 'react-icons/ai';\nimport { BsArrowsMove } "
},
{
"path": "modules/room/modules/board/components/UserMouse.tsx",
"chars": 2057,
"preview": "import { useEffect, useState } from 'react';\n\nimport { motion } from 'framer-motion';\nimport { BsCursorFill } from 'reac"
},
{
"path": "modules/room/modules/board/helpers/Canvas.helpers.ts",
"chars": 1843,
"preview": "const getWidthAndHeight = (\n x: number,\n y: number,\n from: [number, number],\n shift?: boolean\n) => {\n let width = x"
},
{
"path": "modules/room/modules/board/hooks/useBoardPosition.ts",
"chars": 206,
"preview": "import { useContext } from 'react';\n\nimport { roomContext } from '../../../context/Room.context';\n\nexport const useBoard"
},
{
"path": "modules/room/modules/board/hooks/useCtx.ts",
"chars": 451,
"preview": "import { useEffect, useState } from 'react';\n\nimport { useRefs } from '../../../hooks/useRefs';\n\nexport const useCtx = ("
},
{
"path": "modules/room/modules/board/hooks/useDraw.ts",
"chars": 4926,
"preview": "import { useState } from 'react';\n\nimport { DEFAULT_MOVE } from '@/common/constants/defaultMove';\nimport { useViewportSi"
},
{
"path": "modules/room/modules/board/hooks/useSelection.ts",
"chars": 6302,
"preview": "import { useEffect, useMemo } from 'react';\n\nimport { toast } from 'react-toastify';\n\nimport { DEFAULT_MOVE } from '@/co"
},
{
"path": "modules/room/modules/board/hooks/useSocketDraw.ts",
"chars": 1018,
"preview": "import { useEffect } from 'react';\n\nimport { socket } from '@/common/lib/socket';\nimport { useSetUsers } from '@/common/"
},
{
"path": "modules/room/modules/board/index.tsx",
"chars": 420,
"preview": "import Canvas from './components/Canvas';\nimport MousePosition from './components/MousePosition';\nimport MousesRenderer "
},
{
"path": "modules/room/modules/chat/components/Chat.tsx",
"chars": 2563,
"preview": "import { useEffect, useRef, useState } from 'react';\n\nimport { motion } from 'framer-motion';\nimport { BsFillChatFill } "
},
{
"path": "modules/room/modules/chat/components/ChatInput.tsx",
"chars": 759,
"preview": "import { FormEvent, useState } from 'react';\n\nimport { AiOutlineSend } from 'react-icons/ai';\n\nimport { socket } from '@"
},
{
"path": "modules/room/modules/chat/components/Message.tsx",
"chars": 522,
"preview": "import { socket } from '@/common/lib/socket';\nimport { MessageType } from '@/common/types/global';\n\nconst Message = ({ u"
},
{
"path": "modules/room/modules/chat/index.ts",
"chars": 60,
"preview": "import Chat from './components/Chat';\n\nexport default Chat;\n"
},
{
"path": "modules/room/modules/toolbar/animations/Entry.animations.ts",
"chars": 210,
"preview": "export const EntryAnimation = {\n from: {\n y: -30,\n opacity: 0,\n transition: {\n duration: 0.2,\n },\n },"
},
{
"path": "modules/room/modules/toolbar/components/BackgoundPicker.tsx",
"chars": 384,
"preview": "import { CgScreen } from 'react-icons/cg';\n\nimport { useModal } from '@/modules/modal';\n\nimport BackgroundModal from '.."
},
{
"path": "modules/room/modules/toolbar/components/ColorPicker.tsx",
"chars": 1957,
"preview": "import { useRef, useState } from 'react';\n\nimport { AnimatePresence, motion } from 'framer-motion';\nimport { RgbaColorPi"
},
{
"path": "modules/room/modules/toolbar/components/HistoryBtns.tsx",
"chars": 727,
"preview": "import { FaRedo, FaUndo } from 'react-icons/fa';\n\nimport { useMyMoves } from '@/common/recoil/room';\nimport { useSavedMo"
},
{
"path": "modules/room/modules/toolbar/components/ImagePicker.tsx",
"chars": 1447,
"preview": "import { useEffect } from 'react';\n\nimport { BsFillImageFill } from 'react-icons/bs';\n\nimport { optimizeImage } from '@/"
},
{
"path": "modules/room/modules/toolbar/components/LineWidthPicker.tsx",
"chars": 1580,
"preview": "import { useRef, useState } from 'react';\n\nimport { AnimatePresence, motion } from 'framer-motion';\nimport { BsBorderWid"
},
{
"path": "modules/room/modules/toolbar/components/ModePicker.tsx",
"chars": 1466,
"preview": "import { useEffect } from 'react';\n\nimport { AiOutlineSelect } from 'react-icons/ai';\nimport { BsPencilFill } from 'reac"
},
{
"path": "modules/room/modules/toolbar/components/ShapeSelector.tsx",
"chars": 2179,
"preview": "import { useRef, useState } from 'react';\n\nimport { motion, AnimatePresence } from 'framer-motion';\nimport { BiRectangle"
},
{
"path": "modules/room/modules/toolbar/components/ToolBar.tsx",
"chars": 3353,
"preview": "import { useEffect, useState } from 'react';\n\nimport { motion } from 'framer-motion';\nimport { useRouter } from 'next/ro"
},
{
"path": "modules/room/modules/toolbar/index.ts",
"chars": 69,
"preview": "import ToolBar from './components/ToolBar';\n\nexport default ToolBar;\n"
},
{
"path": "modules/room/modules/toolbar/modals/BackgroundModal.tsx",
"chars": 2674,
"preview": "import { useEffect } from 'react';\n\nimport { AiOutlineClose } from 'react-icons/ai';\n\nimport { useBackground, useSetBack"
},
{
"path": "modules/room/modules/toolbar/modals/ShareModal.tsx",
"chars": 1072,
"preview": "import { useEffect, useState } from 'react';\n\nimport { AiOutlineClose } from 'react-icons/ai';\n\nimport { useRoom } from "
},
{
"path": "next-env.d.ts",
"chars": 201,
"preview": "/// <reference types=\"next\" />\n/// <reference types=\"next/image-types/global\" />\n\n// NOTE: This file should not be edite"
},
{
"path": "next.config.js",
"chars": 88,
"preview": "/** @type {import('next').NextConfig} */\nmodule.exports = {\n reactStrictMode: true,\n};\n"
},
{
"path": "package.json",
"chars": 1668,
"preview": "{\n \"name\": \"collabio\",\n \"private\": true,\n \"scripts\": {\n \"dev\": \"nodemon server/index.ts\",\n \"dev:client\": \"ts-no"
},
{
"path": "pages/[roomId].tsx",
"chars": 158,
"preview": "import type { NextPage } from 'next';\n\nimport Room from '@/modules/room';\n\nconst RoomPage: NextPage = () => {\n return <"
},
{
"path": "pages/_app.tsx",
"chars": 855,
"preview": "import '../common/styles/global.css';\nimport { MotionConfig } from 'framer-motion';\nimport type { AppProps } from 'next/"
},
{
"path": "pages/_document.tsx",
"chars": 594,
"preview": "import { Html, Main, NextScript, Head } from 'next/document';\n\nconst document = () => (\n <Html>\n <Head>\n <link "
},
{
"path": "pages/index.tsx",
"chars": 158,
"preview": "import type { NextPage } from 'next';\n\nimport Home from '@/modules/home';\n\nconst HomePage: NextPage = () => {\n return <"
},
{
"path": "postcss.config.js",
"chars": 83,
"preview": "module.exports = {\n plugins: {\n tailwindcss: {},\n autoprefixer: {},\n },\n};\n"
},
{
"path": "server/index.ts",
"chars": 4458,
"preview": "import { createServer } from 'http';\n\nimport express from 'express';\nimport next, { NextApiHandler } from 'next';\nimport"
},
{
"path": "tailwind.config.js",
"chars": 625,
"preview": "module.exports = {\n content: [\n './pages/**/*.{js,ts,jsx,tsx}',\n './common/**/*.{js,ts,jsx,tsx}',\n './modules/"
},
{
"path": "tsconfig.json",
"chars": 608,
"preview": "{\n \"compilerOptions\": {\n \"target\": \"es5\",\n \"lib\": [\"dom\", \"dom.iterable\", \"esnext\"],\n \"allowJs\": true,\n \"sk"
},
{
"path": "tsconfig.server.json",
"chars": 211,
"preview": "{\n \"extends\": \"./tsconfig.json\",\n \"compilerOptions\": {\n \"module\": \"commonjs\",\n \"outDir\": \"build\",\n \"target\": "
}
]
About this extraction
This page contains the full source code of the kriziu/collabio GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 89 files (100.6 KB), approximately 29.8k tokens, and a symbol index with 16 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.