├── .github └── workflows │ └── limit-issue-creation.yml ├── Contributing.md ├── README.md ├── eslint.config.js ├── index.html ├── package.json ├── public └── vite.svg ├── src ├── App.css ├── App.jsx ├── assets │ └── react.svg ├── index.css └── main.jsx └── vite.config.js /.github/workflows/limit-issue-creation.yml: -------------------------------------------------------------------------------- 1 | name: Limit Issue Creation 2 | 3 | on: 4 | issues: 5 | types: [opened] 6 | 7 | jobs: 8 | close-issue: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Close issue if not owner or admin 12 | env: 13 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 14 | run: | 15 | ISSUE_CREATOR=$(jq -r .issue.user.login "$GITHUB_EVENT_PATH") 16 | REPO_OWNER=$(jq -r .repository.owner.login "$GITHUB_EVENT_PATH") 17 | REPO_ADMINS=$(curl -s -H "Authorization: token $GITHUB_TOKEN" "https://api.github.com/repos/${{ github.repository }}/collaborators?affiliation=direct" | jq -r '.[] | select(.permissions.admin == true) | .login') 18 | 19 | IS_ADMIN=false 20 | for ADMIN in $REPO_ADMINS; do 21 | if [ "$ISSUE_CREATOR" == "$ADMIN" ]; then 22 | IS_ADMIN=true 23 | break 24 | fi 25 | done 26 | 27 | if [ "$ISSUE_CREATOR" != "$REPO_OWNER" ] && [ "$IS_ADMIN" != true ]; then 28 | ISSUE_NUMBER=$(jq -r .issue.number "$GITHUB_EVENT_PATH") 29 | curl -X PATCH \ 30 | -H "Authorization: token $GITHUB_TOKEN" \ 31 | -H "Accept: application/vnd.github.v3+json" \ 32 | https://api.github.com/repos/${{ github.repository }}/issues/$ISSUE_NUMBER \ 33 | -d '{"state": "closed"}' 34 | 35 | curl -X POST \ 36 | -H "Authorization: token $GITHUB_TOKEN" \ 37 | -H "Accept: application/vnd.github.v3+json" \ 38 | https://api.github.com/repos/${{ github.repository }}/issues/$ISSUE_NUMBER/comments \ 39 | -d '{"body": "This issue was automatically closed because only the repository owner or admins can create issues."}' 40 | fi 41 | -------------------------------------------------------------------------------- /Contributing.md: -------------------------------------------------------------------------------- 1 | # Contribution Guidelines 2 | 3 | This documentation is a set of guidelines to help you contribute to this repository. 4 | 5 | First off, thanks for taking the time to contribute! 🙌 6 | 7 | Please make sure to read the relevant section before making your contribution. 8 | 9 | We look forward to your contributions. 🎉 10 | 11 | ### Getting starting with contributing? 💡 12 | 13 | You can follow the following articles for the basics of Git/GitHub and contributing to a repository. 14 | The Project Maintainers will be glad to help you out if you are stuck during the contribution process. :) 15 | 16 | - [Basics of git and github](https://www.w3schools.com/git/git_intro.asp?remote=github) 17 | - [Contributing to Open Source for the first time](https://www.youtube.com/watch?v=c6b6B9oN4Vg) 18 | 19 | ## Contibute to this project 20 | 21 | You can follow along the workflow given below to contribute to this project: 22 | 23 | ### 1. Find an issue 24 | 25 | - Pick and issue from the existing issues or create your own issue to start contributing. 26 | - After a maintainer assigns you the issue, you can begin working on it!! 27 | 28 | ### 2. Fork and clone the repository 29 | 30 | - Fork the repository. This creates a copy of this repository to your own GitHub repositories. 31 | - Clone the forked repository to your own computer. This will create a copy of the repository on your own PC. Copy the forked repository link and run the following command: 32 | ```bash 33 | git clone 34 | ``` 35 | - Change your current directory to the directory of the project repository. 36 | ```bash 37 | cd 38 | ``` 39 | - Add a reference of the source repository to make sure your local copy is up to date. 40 | ```bash 41 | git remote add upstream 42 | ``` 43 | 44 | ### 3. Create a new branch 45 | 46 | Create a new branch. Set a name descriptive of the issue that you are solving. The following command will create and switch to a new branch. 47 | 48 | ```bash 49 | git checkout -b Branch_Name 50 | ``` 51 | 52 | ### 4. Work on your chosen issue!!! 53 | 54 | - Work on the issue(s) assigned to you. 55 | - After you've completed your work, add changes to your branch using: 56 | 57 | ```bash 58 | # To add all new files to branch Branch_Name 59 | git add . 60 | 61 | # To add only a few files to Branch_Name 62 | git add 63 | ``` 64 | 65 | ### 5. Commit your changes 66 | 67 | - Commit your changes to the repository, remember to add a descriptive message for the changes you are making 68 | 69 | ```bash 70 | git commit -m "message" 71 | ``` 72 | 73 | ### 6. Sync the fork with your local copy 74 | 75 | - Make sure your branch is synced to the source repository before pushing changes, use the following commands: 76 | 77 | ```bash 78 | git fetch upstream 79 | git checkout Branch_Name 80 | git merge upstream/main 81 | ``` 82 | 83 | ### 7. Push your changes 84 | 85 | - When you think your code is ready to be reviewed, push your changes to your repository 86 | 87 | ```bash 88 | git push -u origin Branch_Name 89 | ``` 90 | 91 | ### 8. Pull request 92 | 93 | - Go to your repository in browser and click on compare and pull requests. 94 | Then add a title and description to your pull request that explains your contribution. 95 | 96 | 97 | - Congrats! Your Pull Request has been submitted and will be reviewed by the moderators and merged. 🥳 98 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Collaborative Drawing Canvas 2 | 3 | ### **Live Link** 4 | https://drawing-canvas-react.netlify.app/ 5 | 6 | ### **Description** 7 | 8 | 9 | A **Collaborative Drawing Canvas** app that allows users to draw, customize, and sync their work across multiple browser tabs, **without a backend** or **WebSocket**, ensuring state persistence through **LocalStorage**. This project is a fun challenge for **React** enthusiasts, designed to simulate multi-user collaboration in a unique offline-first approach. 10 | 11 | This project is built with **React** and provides multiple drawing tools, undo/redo functionality, color selection, and more advanced features like canvas size adjustments and profile-based collaboration. 12 | 13 | 14 | This project is a sincere attempt by MSTC, DA-IICT to encourage Open Source contribution. Make the best out of the ongoing Hacktoberfest 2024 by contributing to for-the-community projects. This project participates in Hacktoberest 2024 and all successful PRs made here will be counted among the at least 4 successful pull requests that you'd need to make in order to be eligible for the Hacktoberfest appreciation (Digital reward/(plant a tree / get a tee)). 15 | 16 | 17 | 18 | --- 19 | 20 | > **Collaborative Drawing Canvas (Offline)** is a **React** project for Hacktoberfest 2024, maintained by MSTC DA-IICT. 21 | 22 | > We are building a **real-time canvas** where users can draw and sync their canvas state across tabs using **LocalStorage** while enjoying a fully offline experience. 23 | 24 | > We need a **React Developer** :technologist: to help us solve issues and build out advanced features. 25 | 26 | ## **Features** 27 | - **Freehand Drawing**: Users can draw on a canvas using a customizable pen tool. 28 | - **Color and Brush Size Selection**: Pick different colors and brush sizes for drawing. 29 | - **Undo/Redo Functionality**: Easily revert or restore previous drawing actions. 30 | - **Multi-Tab Synchronization**: Drawings persist and sync across multiple tabs using `LocalStorage`. 31 | - **Save/Load Drawings**: Save your canvas state locally and reload it anytime. 32 | - **Export as Image**: Export the current drawing as a PNG or JPG image. 33 | - **Shape Drawing**: Draw predefined shapes (rectangles, circles, lines) on the canvas. 34 | - **Canvas Size Adjustment**: Dynamically resize the canvas while retaining the artwork. 35 | - **Offline Mode**: Works fully offline, with no need for backend or internet connection. 36 | 37 | --- 38 | 39 | ### :hammer_and_wrench: **Skills** 40 | * JavaScript (React) 41 | * HTML/CSS 42 | * LocalStorage/IndexedDB 43 | 44 | --- 45 | 46 | ### :dart: **What you have to do** 47 | 1. Check `contributing.md` for details on how to work with GitHub. 48 | 2. Head to the "`Issues`" tab and select an issue that interests you. 49 | 3. Read the issue - Understand it - Leave a comment - Start working! 50 | 4. New and creative approaches are highly appreciated. 51 | 5. Submit a `PR` for review. 52 | 53 | --- 54 | 55 | ### :desktop_computer: **Some resources to get started** 56 | - [React Documentation](https://reactjs.org/docs/getting-started.html) 57 | - [JavaScript Event Listeners](https://developer.mozilla.org/en-US/docs/Web/API/EventListener) 58 | - [LocalStorage Guide](https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage) 59 | - [Three.js Documentation](https://threejs.org) 60 | 61 | Happy Developing! :innocent: 62 | 63 | --- 64 | 65 | ### 🔗 Connect with us 66 | Get in touch with us on [LinkedIn](https://www.linkedin.com/company/microsoft-student-technical-club-da-iict/) / [Instagram](https://www.instagram.com/mstc_daiict/) 67 | 68 | Write to us at microsoftclub@daiict.ac.in 69 | 70 | 71 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from '@eslint/js' 2 | import globals from 'globals' 3 | import react from 'eslint-plugin-react' 4 | import reactHooks from 'eslint-plugin-react-hooks' 5 | import reactRefresh from 'eslint-plugin-react-refresh' 6 | 7 | export default [ 8 | { ignores: ['dist'] }, 9 | { 10 | files: ['**/*.{js,jsx}'], 11 | languageOptions: { 12 | ecmaVersion: 2020, 13 | globals: globals.browser, 14 | parserOptions: { 15 | ecmaVersion: 'latest', 16 | ecmaFeatures: { jsx: true }, 17 | sourceType: 'module', 18 | }, 19 | }, 20 | settings: { react: { version: '18.3' } }, 21 | plugins: { 22 | react, 23 | 'react-hooks': reactHooks, 24 | 'react-refresh': reactRefresh, 25 | }, 26 | rules: { 27 | ...js.configs.recommended.rules, 28 | ...react.configs.recommended.rules, 29 | ...react.configs['jsx-runtime'].rules, 30 | ...reactHooks.configs.recommended.rules, 31 | 'react/jsx-no-target-blank': 'off', 32 | 'react-refresh/only-export-components': [ 33 | 'warn', 34 | { allowConstantExport: true }, 35 | ], 36 | }, 37 | }, 38 | ] 39 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Vite + React 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "drawing-canvas", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "vite build", 9 | "lint": "eslint .", 10 | "preview": "vite preview" 11 | }, 12 | "dependencies": { 13 | "react": "^18.3.1", 14 | "react-dom": "^18.3.1", 15 | "jspdf": "^2.5.2" 16 | }, 17 | "devDependencies": { 18 | "@eslint/js": "^9.9.0", 19 | "@types/react": "^18.3.3", 20 | "@types/react-dom": "^18.3.0", 21 | "@vitejs/plugin-react": "^4.3.1", 22 | "eslint": "^9.9.0", 23 | "eslint-plugin-react": "^7.35.0", 24 | "eslint-plugin-react-hooks": "^5.1.0-rc.0", 25 | "eslint-plugin-react-refresh": "^0.4.9", 26 | "globals": "^15.9.0", 27 | "vite": "^5.4.1" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/App.css: -------------------------------------------------------------------------------- 1 | .App { 2 | text-align: center; 3 | display: flex; 4 | flex-direction: column; 5 | height: 100vh; 6 | } 7 | 8 | .app-title { 9 | padding: 20px 0; 10 | margin: 0; 11 | } 12 | 13 | .main-content { 14 | display: flex; 15 | flex-direction: column; 16 | flex: 1; 17 | } 18 | 19 | .toolbar { 20 | position: relative; 21 | z-index: 1000; /* Ensure toolbar is above canvas */ 22 | padding: 20px; 23 | } 24 | 25 | /* Grid view styles */ 26 | .App.grid .main-content { 27 | flex-direction: column; 28 | } 29 | 30 | .App.grid .toolbar { 31 | width: 100%; 32 | overflow-x: auto; 33 | } 34 | 35 | .App.grid .toolbar-items { 36 | display: flex; 37 | justify-content: center; 38 | flex-wrap: wrap; 39 | gap: 10px; 40 | } 41 | 42 | .App.grid .tool-button, 43 | .App.grid label, 44 | .App.grid input, 45 | .App.grid .dropdown { 46 | margin: 5px; 47 | } 48 | 49 | /* List view styles */ 50 | .App.list .main-content { 51 | flex-direction: row; 52 | } 53 | 54 | .toolbar.list-view { 55 | width: 200px; 56 | overflow-y: auto; 57 | border-right: 1px solid #ccc; 58 | padding: 20px; 59 | } 60 | 61 | .toolbar.list-view .toolbar-items { 62 | display: flex; 63 | flex-direction: column; 64 | align-items: flex-start; 65 | } 66 | 67 | .canvas-container { 68 | position: relative; 69 | z-index: 1; /* Lower than toolbar and dropdowns */ 70 | flex: 1; 71 | display: flex; 72 | justify-content: center; 73 | align-items: center; 74 | } 75 | 76 | canvas { 77 | border: 2px solid black; 78 | cursor: crosshair; 79 | } 80 | 81 | .dropdown { 82 | position: relative; 83 | } 84 | 85 | .dropdown-menu { 86 | position: absolute; 87 | top: 100%; /* Position below the button */ 88 | right: 0; 89 | background-color: #000000; 90 | border: 1px solid #ccc; 91 | box-shadow: 0px 2px 10px rgba(0, 0, 0, 0.1); 92 | z-index: 1001; /* Even higher than toolbar */ 93 | padding: 10px; 94 | min-width: 120px; 95 | border-radius: 4px; 96 | text-decoration: none; 97 | } 98 | 99 | .dropdown-menu li { 100 | cursor: pointer; 101 | } 102 | 103 | .tool-button { 104 | background-color: white; 105 | color: black; 106 | border: 2px solid black; 107 | padding: 10px; 108 | cursor: pointer; 109 | transition: background-color 0.3s, color 0.3s; 110 | } 111 | 112 | .tool-button.selected { 113 | background-color: black; 114 | color: white; 115 | } 116 | 117 | .toolbar.list-view .tool-button, 118 | .toolbar.list-view label, 119 | .toolbar.list-view input, 120 | .toolbar.list-view .dropdown { 121 | width: 100%; 122 | margin: 5px 0; 123 | } 124 | 125 | .toolbar.list-view input[type="color"], 126 | .toolbar.list-view input[type="range"] { 127 | width: 100%; 128 | } 129 | 130 | /* For the settings dropdown specifically */ 131 | .settings-dropdown .dropdown-menu { 132 | right: 0; /* Align to the right side of the button */ 133 | left: auto; /* Override the left: 0 from the general dropdown */ 134 | } 135 | 136 | /* Ensure dropdown is visible in both grid and list views */ 137 | .App.grid .dropdown-menu, 138 | .App.list .dropdown-menu { 139 | position: absolute; 140 | } 141 | 142 | -------------------------------------------------------------------------------- /src/App.jsx: -------------------------------------------------------------------------------- 1 | import React, { useRef, useState, useEffect } from 'react'; 2 | import './App.css'; 3 | 4 | function App() { 5 | const canvasRef = useRef(null); 6 | const contextRef = useRef(null); 7 | const [isDrawing, setIsDrawing] = useState(false); 8 | const [fileName, setFileName] = useState('drawing'); 9 | const [isDropdownOpen, setIsDropdownOpen] = useState(false); 10 | const [currentTool, setCurrentTool] = useState('brush'); 11 | const [startX, setStartX] = useState(0); 12 | const [startY, setStartY] = useState(0); 13 | const [canvasData, setCanvasData] = useState(null); 14 | const [isSettingsOpen, setIsSettingsOpen] = useState(false); 15 | const [viewMode, setViewMode] = useState('grid'); 16 | const [canvasHistory, setCanvasHistory] = useState([]); 17 | const [currentHistoryIndex, setCurrentHistoryIndex] = useState(-1); 18 | 19 | // Unified state for tool properties (color, size) 20 | // Unified state for tool properties (color, size) 21 | const [toolProperties, setToolProperties] = useState({ 22 | brush: { color: '#000000', size: 5 }, 23 | rectangle: { color: '#000000', size: 5 }, 24 | circle: { color: '#000000', size: 5 }, 25 | line: { color: '#000000', size: 5 } 26 | }); 27 | 28 | useEffect(() => { 29 | const savedToolProperties = localStorage.getItem('toolProperties'); 30 | if (savedToolProperties) { 31 | setToolProperties(JSON.parse(savedToolProperties)); 32 | } 33 | 34 | const canvas = canvasRef.current; 35 | canvas.width = window.innerWidth * 2; 36 | canvas.height = window.innerHeight * 2; 37 | canvas.style.width = `${window.innerWidth}px`; 38 | canvas.style.height = `${window.innerHeight}px`; 39 | 40 | const context = canvas.getContext('2d'); 41 | context.scale(2, 2); 42 | context.lineCap = 'round'; 43 | contextRef.current = context; 44 | 45 | loadCanvasData(); 46 | loadFileName(); 47 | 48 | window.addEventListener('storage', syncCanvasAcrossTabs); 49 | 50 | return () => { 51 | window.removeEventListener('storage', syncCanvasAcrossTabs); 52 | }; 53 | 54 | const savedCanvasHistory = localStorage.getItem('canvasHistory'); 55 | if (savedCanvasHistory) { 56 | setCanvasHistory(JSON.parse(savedCanvasHistory)); 57 | setCurrentHistoryIndex(JSON.parse(localStorage.getItem('currentHistoryIndex'))); 58 | } 59 | }, []); 60 | 61 | useEffect(() => { 62 | // Save the canvas history to local storage 63 | localStorage.setItem('canvasHistory', JSON.stringify(canvasHistory)); 64 | localStorage.setItem('currentHistoryIndex', JSON.stringify(currentHistoryIndex)); 65 | }, [canvasHistory, currentHistoryIndex]); 66 | 67 | useEffect(() => { 68 | if (contextRef.current) { 69 | const { color, size } = toolProperties[currentTool]; 70 | contextRef.current.strokeStyle = color; 71 | contextRef.current.lineWidth = size; 72 | } 73 | }, [currentTool, toolProperties]); 74 | 75 | const updateToolProperties = (tool, property, value) => { 76 | setToolProperties((prevProperties) => { 77 | const newProperties = { 78 | ...prevProperties, 79 | [tool]: { ...prevProperties[tool], [property]: value }, 80 | }; 81 | localStorage.setItem('toolProperties', JSON.stringify(newProperties)); 82 | return newProperties; 83 | }); 84 | }; 85 | 86 | const startDrawing = ({ nativeEvent }) => { 87 | const { offsetX, offsetY } = nativeEvent; 88 | setStartX(offsetX); 89 | setStartY(offsetY); 90 | setIsDrawing(true); 91 | 92 | setCanvasData(canvasRef.current.toDataURL()); 93 | if (currentTool === 'brush') { 94 | contextRef.current.beginPath(); 95 | contextRef.current.moveTo(offsetX, offsetY); 96 | } 97 | }; 98 | 99 | const drawShape = ({ nativeEvent }) => { 100 | if (!isDrawing) return; 101 | const { offsetX, offsetY } = nativeEvent; 102 | 103 | if (currentTool === 'brush') { 104 | contextRef.current.lineTo(offsetX, offsetY); 105 | contextRef.current.stroke(); 106 | return; 107 | } 108 | 109 | const image = new Image(); 110 | image.src = canvasData; 111 | image.onload = () => { 112 | contextRef.current.clearRect(0, 0, canvasRef.current.width, canvasRef.current.height); 113 | contextRef.current.drawImage(image, 0, 0, canvasRef.current.width / 2, canvasRef.current.height / 2); 114 | 115 | switch (currentTool) { 116 | case 'rectangle': 117 | drawRectangle(startX, startY, offsetX, offsetY); 118 | break; 119 | case 'circle': 120 | drawCircle(startX, startY, offsetX, offsetY); 121 | break; 122 | case 'line': 123 | drawLine(startX, startY, offsetX, offsetY); 124 | break; 125 | default: 126 | break; 127 | } 128 | }; 129 | }; 130 | 131 | const finishDrawing = () => { 132 | setIsDrawing(false); 133 | saveCanvasData(); 134 | if (currentTool === 'brush') { 135 | contextRef.current.closePath(); 136 | } 137 | }; 138 | 139 | const drawRectangle = (x1, y1, x2, y2) => { 140 | const width = x2 - x1; 141 | const height = y2 - y1; 142 | contextRef.current.beginPath(); 143 | contextRef.current.rect(x1, y1, width, height); 144 | contextRef.current.stroke(); 145 | contextRef.current.closePath(); 146 | }; 147 | 148 | const drawCircle = (x1, y1, x2, y2) => { 149 | const radius = Math.sqrt(Math.pow(x2 - x1, 2) + Math.pow(y2 - y1, 2)); 150 | contextRef.current.beginPath(); 151 | contextRef.current.arc(x1, y1, radius, 0, 2 * Math.PI); 152 | contextRef.current.stroke(); 153 | contextRef.current.closePath(); 154 | }; 155 | 156 | const drawLine = (x1, y1, x2, y2) => { 157 | contextRef.current.beginPath(); 158 | contextRef.current.moveTo(x1, y1); 159 | contextRef.current.lineTo(x2, y2); 160 | contextRef.current.stroke(); 161 | contextRef.current.closePath(); 162 | }; 163 | 164 | const clearCanvas = () => { 165 | contextRef.current.clearRect(0, 0, canvasRef.current.width, canvasRef.current.height); 166 | saveCanvasData(); 167 | }; 168 | 169 | const exportCanvasAsImage = (format) => { 170 | const canvas = canvasRef.current; 171 | const image = canvas.toDataURL(`image/${format}`); 172 | const anchor = document.createElement('a'); 173 | anchor.href = image; 174 | anchor.download = `${fileName}.${format}`; 175 | anchor.click(); 176 | setIsDropdownOpen(false); 177 | }; 178 | 179 | const downloadPDF = () => { 180 | const canvas = canvasRef.current; 181 | const pdf = new jsPDF({ 182 | orientation: 'landscape', 183 | unit: 'px', 184 | format: [canvas.width, canvas.height], 185 | putOnlyUsedFonts: true, 186 | floatPrecision: 16, 187 | }); 188 | 189 | const imgData = canvas.toDataURL('image/jpeg'); 190 | pdf.addImage(imgData, 'JPEG', 0, 0, canvas.width, canvas.height); 191 | pdf.save(`${fileName}.pdf`); 192 | setIsDropdownOpen(false); 193 | }; 194 | 195 | const handleDownload = () => { 196 | if (format === 'pdf') { 197 | downloadPDF(); 198 | } else { 199 | exportCanvasAsImage(format); 200 | } 201 | }; 202 | 203 | const saveCanvasData = () => { 204 | const canvas = canvasRef.current; 205 | const canvasData = canvas.toDataURL(); 206 | localStorage.setItem('savedCanvas', canvasData); 207 | localStorage.setItem('canvasUpdateTime', Date.now()); 208 | const newCanvasHistory = [...canvasHistory]; 209 | newCanvasHistory.push(canvasRef.current.toDataURL()); 210 | setCanvasHistory(newCanvasHistory); 211 | setCurrentHistoryIndex(newCanvasHistory.length - 1); 212 | }; 213 | 214 | const loadCanvasData = () => { 215 | const savedCanvas = localStorage.getItem('savedCanvas'); 216 | if (savedCanvas) { 217 | const canvas = canvasRef.current; 218 | const context = contextRef.current; 219 | const image = new Image(); 220 | image.src = savedCanvas; 221 | image.onload = () => { 222 | context.clearRect(0, 0, canvas.width, canvas.height); 223 | context.drawImage(image, 0, 0, canvas.width / 2, canvas.height / 2); 224 | }; 225 | } 226 | }; 227 | 228 | const saveFileName = (newFileName) => { 229 | localStorage.setItem('fileName', newFileName); 230 | }; 231 | 232 | const loadFileName = () => { 233 | const savedFileName = localStorage.getItem('fileName'); 234 | if (savedFileName) { 235 | setFileName(savedFileName); 236 | } 237 | }; 238 | 239 | const syncCanvasAcrossTabs = (event) => { 240 | if (event.key === 'savedCanvas') { 241 | loadCanvasData(); 242 | } 243 | }; 244 | 245 | const handleViewModeChange = (mode) => { 246 | setViewMode(mode); 247 | setIsSettingsOpen(false); 248 | }; 249 | 250 | const undo = () => { 251 | if (currentHistoryIndex >= 0) { 252 | setCurrentHistoryIndex(currentHistoryIndex - 1); 253 | const canvas = canvasRef.current; 254 | const context = contextRef.current; 255 | const image = new Image(); 256 | image.src = canvasHistory[currentHistoryIndex]; 257 | image.onload = () => { 258 | context.clearRect(0, 0, canvas.width, canvas.height); 259 | context.drawImage(image, 0, 0, canvas.width / 2, canvas.height / 2); 260 | }; 261 | } 262 | }; 263 | 264 | const redo = () => { 265 | if (currentHistoryIndex < canvasHistory.length - 1) { 266 | setCurrentHistoryIndex(currentHistoryIndex + 1); 267 | const canvas = canvasRef.current; 268 | const context = contextRef.current; 269 | const image = new Image(); 270 | image.src = canvasHistory[currentHistoryIndex]; 271 | image.onload = () => { 272 | context.clearRect(0, 0, canvas.width, canvas.height); 273 | context.drawImage(image, 0, 0, canvas.width / 2, canvas.height / 2); 274 | }; 275 | } 276 | }; 277 | 278 | return ( 279 |
280 |

Collaborative Drawing Canvas

281 |
282 |
283 |
284 | 290 | 296 | 302 | 308 | 309 | 317 | 327 | 328 | 329 | 330 | { 335 | setFileName(e.target.value); 336 | saveFileName(e.target.value); 337 | }} 338 | /> 339 |
340 | 341 | {isDropdownOpen && ( 342 |
    343 |
  • handleDownload('png')}>Download PNG
  • 344 |
  • handleDownload('jpeg')}>Download JPEG
  • 345 |
  • handleDownload('pdf')}>Download PDF
  • 346 |
347 | )} 348 |
349 |
350 |
351 |
352 | 359 |
360 |
361 |
362 | 363 | {isSettingsOpen && ( 364 |
    365 |
  • handleViewModeChange('grid')}>Grid View
  • 366 |
  • handleViewModeChange('list')}>List View
  • 367 |
368 | )} 369 |
370 |
371 | ); 372 | } 373 | 374 | export default App; -------------------------------------------------------------------------------- /src/assets/react.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | :root { 2 | font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; 3 | line-height: 1.5; 4 | font-weight: 400; 5 | 6 | color-scheme: light dark; 7 | color: rgba(255, 255, 255, 0.87); 8 | background-color: #242424; 9 | 10 | font-synthesis: none; 11 | text-rendering: optimizeLegibility; 12 | -webkit-font-smoothing: antialiased; 13 | -moz-osx-font-smoothing: grayscale; 14 | } 15 | 16 | a { 17 | font-weight: 500; 18 | color: #646cff; 19 | text-decoration: inherit; 20 | } 21 | a:hover { 22 | color: #535bf2; 23 | } 24 | 25 | body { 26 | margin: 0; 27 | display: flex; 28 | place-items: center; 29 | min-width: 320px; 30 | min-height: 100vh; 31 | } 32 | 33 | h1 { 34 | font-size: 3.2em; 35 | line-height: 1.1; 36 | } 37 | 38 | button { 39 | border-radius: 8px; 40 | border: 1px solid transparent; 41 | padding: 0.6em 1.2em; 42 | font-size: 1em; 43 | font-weight: 500; 44 | font-family: inherit; 45 | cursor: pointer; 46 | transition: border-color 0.25s; 47 | } 48 | button.selected { 49 | background-color: black; 50 | color: white; 51 | } 52 | 53 | button.selected-light { 54 | background-color: black; 55 | color: white; 56 | 57 | } 58 | 59 | button.selected-dark { 60 | background-color: white; 61 | color: black; 62 | border: 1px solid black; 63 | } 64 | 65 | button:hover { 66 | border-color: #646cff; 67 | } 68 | button:focus, 69 | button:focus-visible { 70 | outline: 4px auto -webkit-focus-ring-color; 71 | } 72 | 73 | @media (prefers-color-scheme: light) { 74 | :root { 75 | color: #213547; 76 | background-color: #ffffff; 77 | } 78 | a:hover { 79 | color: #747bff; 80 | } 81 | button { 82 | background-color: #f9f9f9; 83 | } 84 | } 85 | 86 | -------------------------------------------------------------------------------- /src/main.jsx: -------------------------------------------------------------------------------- 1 | import { StrictMode } from 'react' 2 | import { createRoot } from 'react-dom/client' 3 | import App from './App.jsx' 4 | import './index.css' 5 | 6 | createRoot(document.getElementById('root')).render( 7 | 8 | 9 | , 10 | ) 11 | -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import react from '@vitejs/plugin-react' 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | plugins: [react()], 7 | }) 8 | --------------------------------------------------------------------------------