| <!DOCTYPE html> |
| <html lang="en"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>CollabBoard - Online Whiteboard</title> |
| <script src="https://cdn.tailwindcss.com"></script> |
| <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"> |
| <style> |
| #whiteboard { |
| touch-action: none; |
| background-image: linear-gradient(#e5e7eb 1px, transparent 1px), linear-gradient(90deg, #e5e7eb 1px, transparent 1px); |
| background-size: 20px 20px; |
| cursor: crosshair; |
| } |
| |
| .tool-btn.active { |
| @apply bg-blue-100 border-blue-500; |
| } |
| |
| .color-option { |
| width: 24px; |
| height: 24px; |
| border-radius: 50%; |
| cursor: pointer; |
| transition: transform 0.2s; |
| } |
| |
| .color-option:hover { |
| transform: scale(1.2); |
| } |
| |
| .color-option.selected { |
| transform: scale(1.3); |
| box-shadow: 0 0 0 2px white, 0 0 0 3px #3b82f6; |
| } |
| |
| .sticky-note { |
| min-width: 150px; |
| min-height: 150px; |
| resize: both; |
| overflow: hidden; |
| box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); |
| } |
| |
| .sticky-note textarea { |
| background: transparent; |
| resize: none; |
| outline: none; |
| } |
| |
| .shape-preview { |
| position: absolute; |
| pointer-events: none; |
| opacity: 0.7; |
| } |
| |
| .board-thumbnail { |
| transition: transform 0.2s, box-shadow 0.2s; |
| } |
| |
| .board-thumbnail:hover { |
| transform: translateY(-2px); |
| box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); |
| } |
| |
| |
| ::-webkit-scrollbar { |
| width: 8px; |
| height: 8px; |
| } |
| |
| ::-webkit-scrollbar-track { |
| background: #f1f1f1; |
| } |
| |
| ::-webkit-scrollbar-thumb { |
| background: #c1c1c1; |
| border-radius: 4px; |
| } |
| |
| ::-webkit-scrollbar-thumb:hover { |
| background: #a8a8a8; |
| } |
| |
| |
| .selection-rect { |
| position: absolute; |
| border: 2px dashed #3b82f6; |
| background-color: rgba(59, 130, 246, 0.1); |
| pointer-events: none; |
| } |
| |
| |
| .resize-handle { |
| position: absolute; |
| width: 8px; |
| height: 8px; |
| background-color: #3b82f6; |
| border: 1px solid white; |
| border-radius: 50%; |
| pointer-events: all; |
| cursor: pointer; |
| z-index: 10; |
| } |
| |
| .resize-handle.nw { |
| top: -4px; |
| left: -4px; |
| cursor: nw-resize; |
| } |
| |
| .resize-handle.ne { |
| top: -4px; |
| right: -4px; |
| cursor: ne-resize; |
| } |
| |
| .resize-handle.sw { |
| bottom: -4px; |
| left: -4px; |
| cursor: sw-resize; |
| } |
| |
| .resize-handle.se { |
| bottom: -4px; |
| right: -4px; |
| cursor: se-resize; |
| } |
| |
| .resize-handle.n { |
| top: -4px; |
| left: 50%; |
| margin-left: -4px; |
| cursor: n-resize; |
| } |
| |
| .resize-handle.s { |
| bottom: -4px; |
| left: 50%; |
| margin-left: -4px; |
| cursor: s-resize; |
| } |
| |
| .resize-handle.e { |
| right: -4px; |
| top: 50%; |
| margin-top: -4px; |
| cursor: e-resize; |
| } |
| |
| .resize-handle.w { |
| left: -4px; |
| top: 50%; |
| margin-top: -4px; |
| cursor: w-resize; |
| } |
| </style> |
| </head> |
| <body class="bg-gray-50 h-screen flex flex-col"> |
| |
| <header class="bg-white shadow-sm py-3 px-4 flex items-center justify-between"> |
| <div class="flex items-center space-x-2"> |
| <div class="bg-blue-600 text-white p-2 rounded-lg"> |
| <i class="fas fa-chalkboard text-xl"></i> |
| </div> |
| <h1 class="text-xl font-bold text-gray-800">CollabBoard</h1> |
| </div> |
| |
| <div class="flex items-center space-x-4"> |
| <button id="presentation-btn" class="flex items-center space-x-1 bg-blue-50 hover:bg-blue-100 text-blue-700 px-3 py-1.5 rounded-lg text-sm font-medium"> |
| <i class="fas fa-expand"></i> |
| <span>Presentation</span> |
| </button> |
| |
| <button id="share-btn" class="flex items-center space-x-1 bg-green-50 hover:bg-green-100 text-green-700 px-3 py-1.5 rounded-lg text-sm font-medium"> |
| <i class="fas fa-share-alt"></i> |
| <span>Share</span> |
| </button> |
| |
| <div class="relative"> |
| <button id="user-menu-btn" class="flex items-center space-x-1 bg-gray-100 hover:bg-gray-200 px-3 py-1.5 rounded-lg text-sm font-medium"> |
| <i class="fas fa-user-circle text-gray-700"></i> |
| <span>User</span> |
| </button> |
| <div id="user-menu" class="hidden absolute right-0 mt-2 w-48 bg-white rounded-md shadow-lg py-1 z-50"> |
| <a href="#" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">Account Settings</a> |
| <a href="#" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">Sign Out</a> |
| </div> |
| </div> |
| </div> |
| </header> |
| |
| <div class="flex flex-1 overflow-hidden"> |
| |
| <div id="sidebar" class="w-64 bg-white border-r border-gray-200 flex flex-col"> |
| <div class="p-4 border-b border-gray-200"> |
| <button id="new-board-btn" class="w-full bg-blue-600 hover:bg-blue-700 text-white py-2 px-4 rounded-lg font-medium flex items-center justify-center space-x-2"> |
| <i class="fas fa-plus"></i> |
| <span>New Board</span> |
| </button> |
| </div> |
| |
| <div class="p-4 border-b border-gray-200"> |
| <div class="flex items-center justify-between mb-2"> |
| <h3 class="font-medium text-gray-700">Your Boards</h3> |
| </div> |
| |
| <div class="space-y-2" id="boards-list"> |
| |
| </div> |
| </div> |
| |
| <div class="p-4 border-b border-gray-200"> |
| <h3 class="font-medium text-gray-700 mb-2">Tags</h3> |
| <div class="space-y-1"> |
| <div class="flex items-center space-x-2 p-1.5 rounded hover:bg-gray-100 cursor-pointer"> |
| <div class="w-3 h-3 rounded-full bg-red-500"></div> |
| <span class="text-sm">Urgent</span> |
| </div> |
| <div class="flex items-center space-x-2 p-1.5 rounded hover:bg-gray-100 cursor-pointer"> |
| <div class="w-3 h-3 rounded-full bg-yellow-500"></div> |
| <span class="text-sm">Ideas</span> |
| </div> |
| <div class="flex items-center space-x-2 p-1.5 rounded hover:bg-gray-100 cursor-pointer"> |
| <div class="w-3 h-3 rounded-full bg-green-500"></div> |
| <span class="text-sm">Completed</span> |
| </div> |
| </div> |
| </div> |
| |
| <div class="p-4"> |
| <h3 class="font-medium text-gray-700 mb-2">Collaborators</h3> |
| <div class="space-y-2"> |
| <div class="flex items-center space-x-2"> |
| <div class="w-8 h-8 rounded-full bg-blue-100 flex items-center justify-center text-blue-600"> |
| <i class="fas fa-user"></i> |
| </div> |
| <span class="text-sm">You</span> |
| </div> |
| </div> |
| </div> |
| </div> |
| |
| |
| <div class="flex-1 flex flex-col overflow-hidden"> |
| |
| <div class="bg-white border-b border-gray-200 p-2 flex items-center justify-between"> |
| <div class="flex items-center space-x-2 overflow-x-auto py-1"> |
| |
| <div class="flex items-center space-x-1 border-r border-gray-200 pr-2"> |
| <button id="undo-btn" class="p-2 border rounded hover:bg-gray-100" title="Undo (Ctrl+Z)"> |
| <i class="fas fa-undo"></i> |
| </button> |
| <button id="redo-btn" class="p-2 border rounded hover:bg-gray-100" title="Redo (Ctrl+Y)"> |
| <i class="fas fa-redo"></i> |
| </button> |
| </div> |
| |
| |
| <div class="flex items-center space-x-1 border-r border-gray-200 pr-2"> |
| <button id="select-tool" class="tool-btn p-2 border rounded hover:bg-gray-100 active" title="Select (V)"> |
| <i class="fas fa-mouse-pointer"></i> |
| </button> |
| <button id="pen-tool" class="tool-btn p-2 border rounded hover:bg-gray-100" title="Pen (P)"> |
| <i class="fas fa-pen"></i> |
| </button> |
| <button id="highlighter-tool" class="tool-btn p-2 border rounded hover:bg-gray-100" title="Highlighter (H)"> |
| <i class="fas fa-highlighter"></i> |
| </button> |
| <button id="eraser-tool" class="tool-btn p-2 border rounded hover:bg-gray-100" title="Eraser (E)"> |
| <i class="fas fa-eraser"></i> |
| </button> |
| <button id="text-tool" class="tool-btn p-2 border rounded hover:bg-gray-100" title="Text (T)"> |
| <i class="fas fa-font"></i> |
| </button> |
| <button id="shape-tool" class="tool-btn p-2 border rounded hover:bg-gray-100" title="Shapes (S)"> |
| <i class="fas fa-shapes"></i> |
| </button> |
| <button id="sticky-tool" class="tool-btn p-2 border rounded hover:bg-gray-100" title="Sticky Note (N)"> |
| <i class="fas fa-sticky-note"></i> |
| </button> |
| <button id="laser-tool" class="tool-btn p-2 border rounded hover:bg-gray-100" title="Laser Pointer (L)"> |
| <i class="fas fa-location-arrow"></i> |
| </button> |
| </div> |
| |
| |
| <div class="flex items-center space-x-1 border-r border-gray-200 pr-2"> |
| <span class="text-sm text-gray-600">Size:</span> |
| <input id="brush-size" type="range" min="1" max="30" value="3" class="w-20"> |
| <span id="brush-size-value" class="text-xs text-gray-600 w-6 text-center">3</span> |
| </div> |
| |
| |
| <div class="flex items-center space-x-1 border-r border-gray-200 pr-2"> |
| <div class="color-option bg-black selected" data-color="#000000"></div> |
| <div class="color-option bg-red-500" data-color="#ef4444"></div> |
| <div class="color-option bg-yellow-500" data-color="#eab308"></div> |
| <div class="color-option bg-green-500" data-color="#22c55e"></div> |
| <div class="color-option bg-blue-500" data-color="#3b82f6"></div> |
| <div class="color-option bg-purple-500" data-color="#a855f7"></div> |
| <div class="color-option bg-pink-500" data-color="#ec4899"></div> |
| <div class="color-option bg-white border border-gray-300" data-color="#ffffff"></div> |
| </div> |
| |
| |
| <div class="flex items-center space-x-1"> |
| <input type="color" id="custom-color" value="#000000" class="w-6 h-6 border border-gray-300 rounded cursor-pointer"> |
| <span class="text-sm text-gray-600">Custom</span> |
| </div> |
| </div> |
| |
| <div class="flex items-center space-x-2"> |
| <button id="clear-btn" class="p-2 border rounded hover:bg-gray-100 text-red-500" title="Clear Board"> |
| <i class="fas fa-trash-alt"></i> |
| </button> |
| <button id="save-btn" class="p-2 border rounded hover:bg-gray-100 text-green-500" title="Save Board"> |
| <i class="fas fa-save"></i> |
| </button> |
| </div> |
| </div> |
| |
| |
| <div class="flex-1 overflow-auto relative"> |
| <canvas id="whiteboard" class="absolute top-0 left-0"></canvas> |
| <div id="selection-rect" class="selection-rect hidden"></div> |
| |
| </div> |
| |
| |
| <div class="bg-white border-t border-gray-200 p-2 flex items-center justify-between"> |
| <div class="flex items-center space-x-2"> |
| <button id="zoom-out" class="p-1.5 border rounded hover:bg-gray-100"> |
| <i class="fas fa-search-minus"></i> |
| </button> |
| <span id="zoom-level" class="text-sm font-medium">100%</span> |
| <button id="zoom-in" class="p-1.5 border rounded hover:bg-gray-100"> |
| <i class="fas fa-search-plus"></i> |
| </button> |
| <button id="zoom-fit" class="p-1.5 border rounded hover:bg-gray-100 text-sm ml-2"> |
| Fit to Screen |
| </button> |
| </div> |
| <div class="text-sm text-gray-500"> |
| <span id="cursor-position">0, 0</span> |
| </div> |
| </div> |
| </div> |
| </div> |
|
|
| <script> |
| document.addEventListener('DOMContentLoaded', function() { |
| |
| const canvas = document.getElementById('whiteboard'); |
| const ctx = canvas.getContext('2d'); |
| const selectionRect = document.getElementById('selection-rect'); |
| let isDrawing = false; |
| let isSelecting = false; |
| let isMoving = false; |
| let isResizing = false; |
| let currentTool = 'select'; |
| let currentColor = '#000000'; |
| let brushSize = 3; |
| let zoomLevel = 1; |
| let offsetX = 0; |
| let offsetY = 0; |
| let startX, startY; |
| let actionHistory = []; |
| let historyIndex = -1; |
| let boards = []; |
| let currentBoardIndex = -1; |
| let selectedActions = []; |
| let selectionStartX, selectionStartY; |
| let selectionEndX, selectionEndY; |
| let moveOffsetX = 0, moveOffsetY = 0; |
| let resizeHandle = null; |
| let selectionBounds = null; |
| |
| |
| function initCanvas() { |
| const container = canvas.parentElement; |
| canvas.width = container.clientWidth; |
| canvas.height = container.clientHeight; |
| redrawCanvas(); |
| } |
| |
| |
| function redrawCanvas() { |
| ctx.clearRect(0, 0, canvas.width, canvas.height); |
| |
| |
| ctx.fillStyle = '#e5e7eb'; |
| const gridSize = 20 * zoomLevel; |
| for (let x = 0; x < canvas.width; x += gridSize) { |
| ctx.fillRect(x - offsetX % gridSize, 0, 1, canvas.height); |
| } |
| for (let y = 0; y < canvas.height; y += gridSize) { |
| ctx.fillRect(0, y - offsetY % gridSize, canvas.width, 1); |
| } |
| |
| |
| if (currentBoardIndex >= 0 && boards[currentBoardIndex]) { |
| const board = boards[currentBoardIndex]; |
| for (let i = 0; i <= historyIndex; i++) { |
| drawAction(board.actions[i]); |
| } |
| } |
| |
| |
| updateSelectionHandles(); |
| } |
| |
| |
| function drawAction(action) { |
| if (!action) return; |
| |
| ctx.strokeStyle = action.color || currentColor; |
| ctx.fillStyle = action.color || currentColor; |
| ctx.lineWidth = (action.size || brushSize) * zoomLevel; |
| ctx.lineCap = 'round'; |
| ctx.lineJoin = 'round'; |
| |
| switch (action.type) { |
| case 'freehand': |
| ctx.beginPath(); |
| ctx.moveTo( |
| action.points[0].x * zoomLevel + offsetX, |
| action.points[0].y * zoomLevel + offsetY |
| ); |
| for (let i = 1; i < action.points.length; i++) { |
| ctx.lineTo( |
| action.points[i].x * zoomLevel + offsetX, |
| action.points[i].y * zoomLevel + offsetY |
| ); |
| } |
| ctx.stroke(); |
| break; |
| case 'erase': |
| ctx.globalCompositeOperation = 'destination-out'; |
| ctx.beginPath(); |
| ctx.moveTo( |
| action.points[0].x * zoomLevel + offsetX, |
| action.points[0].y * zoomLevel + offsetY |
| ); |
| for (let i = 1; i < action.points.length; i++) { |
| ctx.lineTo( |
| action.points[i].x * zoomLevel + offsetX, |
| action.points[i].y * zoomLevel + offsetY |
| ); |
| } |
| ctx.stroke(); |
| ctx.globalCompositeOperation = 'source-over'; |
| break; |
| } |
| } |
| |
| |
| function getActionBounds(action) { |
| if (!action.points || action.points.length === 0) { |
| return { minX: 0, minY: 0, maxX: 0, maxY: 0 }; |
| } |
| |
| let minX = action.points[0].x; |
| let minY = action.points[0].y; |
| let maxX = action.points[0].x; |
| let maxY = action.points[0].y; |
| |
| for (let i = 1; i < action.points.length; i++) { |
| minX = Math.min(minX, action.points[i].x); |
| minY = Math.min(minY, action.points[i].y); |
| maxX = Math.max(maxX, action.points[i].x); |
| maxY = Math.max(maxY, action.points[i].y); |
| } |
| |
| |
| const padding = (action.size || brushSize) / 2; |
| return { |
| minX: minX - padding, |
| minY: minY - padding, |
| maxX: maxX + padding, |
| maxY: maxY + padding |
| }; |
| } |
| |
| |
| function getSelectionBounds() { |
| if (selectedActions.length === 0) return null; |
| |
| let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; |
| |
| selectedActions.forEach(action => { |
| const bounds = getActionBounds(action); |
| minX = Math.min(minX, bounds.minX); |
| minY = Math.min(minY, bounds.minY); |
| maxX = Math.max(maxX, bounds.maxX); |
| maxY = Math.max(maxY, bounds.maxY); |
| }); |
| |
| return { minX, minY, maxX, maxY }; |
| } |
| |
| |
| function isPointInAction(x, y, action) { |
| const bounds = getActionBounds(action); |
| return x >= bounds.minX && x <= bounds.maxX && y >= bounds.minY && y <= bounds.maxY; |
| } |
| |
| |
| function isPointInSelection(x, y) { |
| if (!isSelecting) return false; |
| |
| const minX = Math.min(selectionStartX, selectionEndX); |
| const maxX = Math.max(selectionStartX, selectionEndX); |
| const minY = Math.min(selectionStartY, selectionEndY); |
| const maxY = Math.max(selectionStartY, selectionEndY); |
| |
| return x >= minX && x <= maxX && y >= minY && y <= maxY; |
| } |
| |
| |
| function selectActionsInRect() { |
| if (!isSelecting) return; |
| |
| const minX = Math.min(selectionStartX, selectionEndX); |
| const maxX = Math.max(selectionStartX, selectionEndX); |
| const minY = Math.min(selectionStartY, selectionEndY); |
| const maxY = Math.max(selectionStartY, selectionEndY); |
| |
| selectedActions = []; |
| |
| if (currentBoardIndex >= 0 && boards[currentBoardIndex]) { |
| const board = boards[currentBoardIndex]; |
| for (let i = 0; i <= historyIndex; i++) { |
| const action = board.actions[i]; |
| if (action && (action.type === 'freehand' || action.type === 'erase')) { |
| const bounds = getActionBounds(action); |
| |
| |
| if (bounds.maxX >= minX && bounds.minX <= maxX && |
| bounds.maxY >= minY && bounds.minY <= maxY) { |
| selectedActions.push(action); |
| } |
| } |
| } |
| } |
| |
| updateSelectionHandles(); |
| redrawCanvas(); |
| } |
| |
| |
| function moveSelectedActions(dx, dy) { |
| if (selectedActions.length === 0) return; |
| |
| selectedActions.forEach(action => { |
| action.points.forEach(point => { |
| point.x += dx / zoomLevel; |
| point.y += dy / zoomLevel; |
| }); |
| }); |
| |
| updateSelectionHandles(); |
| redrawCanvas(); |
| } |
| |
| |
| function scaleSelectedActions(scaleX, scaleY, originX, originY) { |
| if (selectedActions.length === 0) return; |
| |
| selectedActions.forEach(action => { |
| action.points.forEach(point => { |
| |
| const dx = point.x - originX; |
| const dy = point.y - originY; |
| |
| |
| point.x = originX + dx * scaleX; |
| point.y = originY + dy * scaleY; |
| }); |
| |
| |
| if (action.size) { |
| action.size = action.size * ((scaleX + scaleY) / 2); |
| } |
| }); |
| |
| updateSelectionHandles(); |
| redrawCanvas(); |
| } |
| |
| |
| function updateSelectionHandles() { |
| |
| document.querySelectorAll('.resize-handle').forEach(handle => handle.remove()); |
| |
| if (selectedActions.length === 0) { |
| selectionRect.classList.add('hidden'); |
| return; |
| } |
| |
| |
| selectionBounds = getSelectionBounds(); |
| if (!selectionBounds) return; |
| |
| |
| const left = selectionBounds.minX * zoomLevel + offsetX; |
| const top = selectionBounds.minY * zoomLevel + offsetY; |
| const width = (selectionBounds.maxX - selectionBounds.minX) * zoomLevel; |
| const height = (selectionBounds.maxY - selectionBounds.minY) * zoomLevel; |
| |
| selectionRect.style.left = `${left}px`; |
| selectionRect.style.top = `${top}px`; |
| selectionRect.style.width = `${width}px`; |
| selectionRect.style.height = `${height}px`; |
| selectionRect.classList.remove('hidden'); |
| |
| |
| const handles = [ |
| { class: 'nw', x: left, y: top }, |
| { class: 'ne', x: left + width, y: top }, |
| { class: 'sw', x: left, y: top + height }, |
| { class: 'se', x: left + width, y: top + height }, |
| { class: 'n', x: left + width/2, y: top }, |
| { class: 's', x: left + width/2, y: top + height }, |
| { class: 'e', x: left + width, y: top + height/2 }, |
| { class: 'w', x: left, y: top + height/2 } |
| ]; |
| |
| handles.forEach(handle => { |
| const handleElement = document.createElement('div'); |
| handleElement.className = `resize-handle ${handle.class}`; |
| handleElement.style.left = `${handle.x}px`; |
| handleElement.style.top = `${handle.y}px`; |
| handleElement.dataset.handle = handle.class; |
| |
| handleElement.addEventListener('mousedown', (e) => { |
| e.stopPropagation(); |
| isResizing = true; |
| resizeHandle = handle.class; |
| startX = e.clientX; |
| startY = e.clientY; |
| }); |
| |
| canvas.parentElement.appendChild(handleElement); |
| }); |
| } |
| |
| |
| function handleResize(e) { |
| if (!isResizing || !resizeHandle || !selectionBounds) return; |
| |
| const rect = canvas.getBoundingClientRect(); |
| const mouseX = (e.clientX - rect.left - offsetX) / zoomLevel; |
| const mouseY = (e.clientY - rect.top - offsetY) / zoomLevel; |
| |
| const originX = selectionBounds.minX + (selectionBounds.maxX - selectionBounds.minX) / 2; |
| const originY = selectionBounds.minY + (selectionBounds.maxY - selectionBounds.minY) / 2; |
| |
| let scaleX = 1, scaleY = 1; |
| |
| |
| switch (resizeHandle) { |
| case 'nw': |
| scaleX = (selectionBounds.maxX - mouseX) / (selectionBounds.maxX - selectionBounds.minX); |
| scaleY = (selectionBounds.maxY - mouseY) / (selectionBounds.maxY - selectionBounds.minY); |
| break; |
| case 'ne': |
| scaleX = (mouseX - selectionBounds.minX) / (selectionBounds.maxX - selectionBounds.minX); |
| scaleY = (selectionBounds.maxY - mouseY) / (selectionBounds.maxY - selectionBounds.minY); |
| break; |
| case 'sw': |
| scaleX = (selectionBounds.maxX - mouseX) / (selectionBounds.maxX - selectionBounds.minX); |
| scaleY = (mouseY - selectionBounds.minY) / (selectionBounds.maxY - selectionBounds.minY); |
| break; |
| case 'se': |
| scaleX = (mouseX - selectionBounds.minX) / (selectionBounds.maxX - selectionBounds.minX); |
| scaleY = (mouseY - selectionBounds.minY) / (selectionBounds.maxY - selectionBounds.minY); |
| break; |
| case 'n': |
| scaleY = (selectionBounds.maxY - mouseY) / (selectionBounds.maxY - selectionBounds.minY); |
| break; |
| case 's': |
| scaleY = (mouseY - selectionBounds.minY) / (selectionBounds.maxY - selectionBounds.minY); |
| break; |
| case 'e': |
| scaleX = (mouseX - selectionBounds.minX) / (selectionBounds.maxX - selectionBounds.minX); |
| break; |
| case 'w': |
| scaleX = (selectionBounds.maxX - mouseX) / (selectionBounds.maxX - selectionBounds.minX); |
| break; |
| } |
| |
| |
| scaleX = Math.max(0.1, scaleX); |
| scaleY = Math.max(0.1, scaleY); |
| |
| scaleSelectedActions(scaleX, scaleY, originX, originY); |
| } |
| |
| |
| function saveToHistory(action) { |
| if (currentBoardIndex >= 0) { |
| |
| if (historyIndex < boards[currentBoardIndex].actions.length - 1) { |
| boards[currentBoardIndex].actions = boards[currentBoardIndex].actions.slice(0, historyIndex + 1); |
| } |
| |
| boards[currentBoardIndex].actions.push(action); |
| historyIndex++; |
| updateUndoRedoButtons(); |
| } |
| } |
| |
| |
| function undo() { |
| if (historyIndex >= 0) { |
| historyIndex--; |
| selectedActions = []; |
| updateSelectionHandles(); |
| redrawCanvas(); |
| updateUndoRedoButtons(); |
| } |
| } |
| |
| |
| function redo() { |
| if (currentBoardIndex >= 0 && historyIndex < boards[currentBoardIndex].actions.length - 1) { |
| historyIndex++; |
| drawAction(boards[currentBoardIndex].actions[historyIndex]); |
| updateUndoRedoButtons(); |
| } |
| } |
| |
| |
| function updateUndoRedoButtons() { |
| document.getElementById('undo-btn').disabled = historyIndex < 0; |
| document.getElementById('redo-btn').disabled = |
| currentBoardIndex < 0 || historyIndex >= boards[currentBoardIndex].actions.length - 1; |
| } |
| |
| |
| function createNewBoard() { |
| const newBoard = { |
| id: Date.now(), |
| title: `Board ${boards.length + 1}`, |
| actions: [], |
| createdAt: new Date() |
| }; |
| |
| boards.push(newBoard); |
| currentBoardIndex = boards.length - 1; |
| historyIndex = -1; |
| selectedActions = []; |
| |
| |
| addBoardToSidebar(newBoard); |
| |
| |
| redrawCanvas(); |
| updateUndoRedoButtons(); |
| } |
| |
| |
| function addBoardToSidebar(board) { |
| const boardElement = document.createElement('div'); |
| boardElement.className = 'board-thumbnail bg-white p-2 rounded-lg border border-gray-200 cursor-pointer'; |
| boardElement.innerHTML = ` |
| <div class="bg-gray-100 h-24 rounded-md mb-2 flex items-center justify-center"> |
| <i class="fas fa-chalkboard text-gray-400 text-2xl"></i> |
| </div> |
| <h4 class="font-medium text-sm truncate">${board.title}</h4> |
| <p class="text-xs text-gray-500">Just now</p> |
| `; |
| |
| boardElement.addEventListener('click', () => { |
| |
| const index = boards.findIndex(b => b.id === board.id); |
| if (index >= 0) { |
| currentBoardIndex = index; |
| historyIndex = boards[currentBoardIndex].actions.length - 1; |
| selectedActions = []; |
| redrawCanvas(); |
| updateUndoRedoButtons(); |
| } |
| }); |
| |
| document.getElementById('boards-list').prepend(boardElement); |
| } |
| |
| |
| canvas.addEventListener('mousedown', startDrawing); |
| canvas.addEventListener('mousemove', draw); |
| canvas.addEventListener('mouseup', stopDrawing); |
| canvas.addEventListener('mouseout', stopDrawing); |
| |
| |
| canvas.addEventListener('touchstart', handleTouchStart); |
| canvas.addEventListener('touchmove', handleTouchMove); |
| canvas.addEventListener('touchend', handleTouchEnd); |
| |
| function startDrawing(e) { |
| const rect = canvas.getBoundingClientRect(); |
| const x = (e.clientX - rect.left - offsetX) / zoomLevel; |
| const y = (e.clientY - rect.top - offsetY) / zoomLevel; |
| |
| if (currentTool === 'select') { |
| |
| let clickedOnSelection = false; |
| for (const action of selectedActions) { |
| if (isPointInAction(x, y, action)) { |
| clickedOnSelection = true; |
| break; |
| } |
| } |
| |
| if (clickedOnSelection) { |
| isMoving = true; |
| moveOffsetX = 0; |
| moveOffsetY = 0; |
| } else { |
| isSelecting = true; |
| selectionStartX = x; |
| selectionStartY = y; |
| selectionEndX = x; |
| selectionEndY = y; |
| selectedActions = []; |
| |
| |
| selectionRect.style.left = `${x * zoomLevel + offsetX}px`; |
| selectionRect.style.top = `${y * zoomLevel + offsetY}px`; |
| selectionRect.style.width = '0'; |
| selectionRect.style.height = '0'; |
| selectionRect.classList.remove('hidden'); |
| } |
| } else if (currentTool === 'pen' || currentTool === 'highlighter' || currentTool === 'eraser') { |
| isDrawing = true; |
| startX = x; |
| startY = y; |
| |
| const action = { |
| type: currentTool === 'eraser' ? 'erase' : 'freehand', |
| color: currentTool === 'highlighter' ? currentColor + '80' : currentColor, |
| size: brushSize, |
| points: [{ x: startX, y: startY }] |
| }; |
| |
| if (currentBoardIndex >= 0) { |
| boards[currentBoardIndex].actions.push(action); |
| historyIndex = boards[currentBoardIndex].actions.length - 1; |
| } |
| } |
| |
| draw(e); |
| } |
| |
| function draw(e) { |
| const rect = canvas.getBoundingClientRect(); |
| const x = (e.clientX - rect.left - offsetX) / zoomLevel; |
| const y = (e.clientY - rect.top - offsetY) / zoomLevel; |
| |
| |
| document.getElementById('cursor-position').textContent = |
| `${Math.round(x)}, ${Math.round(y)}`; |
| |
| if (currentTool === 'select') { |
| if (isSelecting) { |
| selectionEndX = x; |
| selectionEndY = y; |
| |
| |
| const left = Math.min(selectionStartX, selectionEndX) * zoomLevel + offsetX; |
| const top = Math.min(selectionStartY, selectionEndY) * zoomLevel + offsetY; |
| const width = Math.abs(selectionEndX - selectionStartX) * zoomLevel; |
| const height = Math.abs(selectionEndY - selectionStartY) * zoomLevel; |
| |
| selectionRect.style.left = `${left}px`; |
| selectionRect.style.top = `${top}px`; |
| selectionRect.style.width = `${width}px`; |
| selectionRect.style.height = `${height}px`; |
| |
| |
| selectActionsInRect(); |
| } else if (isMoving) { |
| const dx = (x - selectionStartX) * zoomLevel - moveOffsetX; |
| const dy = (y - selectionStartY) * zoomLevel - moveOffsetY; |
| moveOffsetX += dx; |
| moveOffsetY += dy; |
| |
| moveSelectedActions(dx, dy); |
| } else if (isResizing) { |
| handleResize(e); |
| } |
| } else if ((currentTool === 'pen' || currentTool === 'highlighter' || currentTool === 'eraser') && isDrawing) { |
| if (currentBoardIndex >= 0 && boards[currentBoardIndex].actions.length > 0) { |
| const lastAction = boards[currentBoardIndex].actions[historyIndex]; |
| if (lastAction && (lastAction.type === 'freehand' || lastAction.type === 'erase')) { |
| lastAction.points.push({ x, y }); |
| redrawCanvas(); |
| } |
| } |
| } |
| } |
| |
| function stopDrawing() { |
| if (currentTool === 'select') { |
| if (isSelecting) { |
| isSelecting = false; |
| selectionRect.classList.add('hidden'); |
| selectActionsInRect(); |
| } else if (isMoving) { |
| isMoving = false; |
| } else if (isResizing) { |
| isResizing = false; |
| resizeHandle = null; |
| } |
| } else { |
| isDrawing = false; |
| } |
| } |
| |
| |
| function handleTouchStart(e) { |
| e.preventDefault(); |
| const touch = e.touches[0]; |
| const mouseEvent = new MouseEvent('mousedown', { |
| clientX: touch.clientX, |
| clientY: touch.clientY |
| }); |
| startDrawing(mouseEvent); |
| } |
| |
| function handleTouchMove(e) { |
| e.preventDefault(); |
| const touch = e.touches[0]; |
| const mouseEvent = new MouseEvent('mousemove', { |
| clientX: touch.clientX, |
| clientY: touch.clientY |
| }); |
| draw(mouseEvent); |
| } |
| |
| function handleTouchEnd(e) { |
| e.preventDefault(); |
| const mouseEvent = new MouseEvent('mouseup', {}); |
| stopDrawing(mouseEvent); |
| } |
| |
| |
| document.querySelectorAll('.tool-btn').forEach(btn => { |
| btn.addEventListener('click', function() { |
| document.querySelectorAll('.tool-btn').forEach(b => b.classList.remove('active')); |
| this.classList.add('active'); |
| |
| switch (this.id) { |
| case 'select-tool': currentTool = 'select'; break; |
| case 'pen-tool': currentTool = 'pen'; break; |
| case 'highlighter-tool': currentTool = 'highlighter'; break; |
| case 'eraser-tool': currentTool = 'eraser'; break; |
| case 'text-tool': currentTool = 'text'; break; |
| case 'shape-tool': currentTool = 'shape'; break; |
| case 'sticky-tool': currentTool = 'sticky'; break; |
| case 'laser-tool': currentTool = 'laser'; break; |
| } |
| |
| |
| if (currentTool === 'pen' || currentTool === 'highlighter') { |
| canvas.style.cursor = 'crosshair'; |
| } else if (currentTool === 'eraser') { |
| canvas.style.cursor = 'url("data:image/svg+xml;utf8,<svg xmlns=\'http://www.w3.org/2000/svg\' width=\'16\' height=\'16\' viewBox=\'0 0 16 16\'><circle cx=\'8\' cy=\'8\' r=\'8\' fill=\'black\'/></svg>") 8 8, auto'; |
| } else if (currentTool === 'select') { |
| canvas.style.cursor = 'default'; |
| } else { |
| canvas.style.cursor = 'default'; |
| } |
| |
| |
| if (currentTool !== 'select') { |
| selectedActions = []; |
| updateSelectionHandles(); |
| redrawCanvas(); |
| } |
| }); |
| }); |
| |
| |
| document.querySelectorAll('.color-option').forEach(option => { |
| option.addEventListener('click', function() { |
| document.querySelectorAll('.color-option').forEach(o => o.classList.remove('selected')); |
| this.classList.add('selected'); |
| currentColor = this.dataset.color; |
| document.getElementById('custom-color').value = currentColor; |
| }); |
| }); |
| |
| |
| document.getElementById('custom-color').addEventListener('input', function() { |
| currentColor = this.value; |
| |
| let found = false; |
| document.querySelectorAll('.color-option').forEach(option => { |
| if (option.dataset.color.toLowerCase() === currentColor.toLowerCase()) { |
| option.classList.add('selected'); |
| found = true; |
| } else { |
| option.classList.remove('selected'); |
| } |
| }); |
| |
| if (!found) { |
| document.querySelectorAll('.color-option').forEach(o => o.classList.remove('selected')); |
| } |
| }); |
| |
| |
| document.getElementById('brush-size').addEventListener('input', function() { |
| brushSize = this.value; |
| document.getElementById('brush-size-value').textContent = brushSize; |
| }); |
| |
| |
| document.getElementById('zoom-in').addEventListener('click', function() { |
| zoomLevel = Math.min(zoomLevel * 1.2, 5); |
| updateZoomDisplay(); |
| redrawCanvas(); |
| }); |
| |
| document.getElementById('zoom-out').addEventListener('click', function() { |
| zoomLevel = Math.max(zoomLevel / 1.2, 0.2); |
| updateZoomDisplay(); |
| redrawCanvas(); |
| }); |
| |
| document.getElementById('zoom-fit').addEventListener('click', function() { |
| zoomLevel = 1; |
| offsetX = 0; |
| offsetY = 0; |
| updateZoomDisplay(); |
| redrawCanvas(); |
| }); |
| |
| function updateZoomDisplay() { |
| document.getElementById('zoom-level').textContent = `${Math.round(zoomLevel * 100)}%`; |
| } |
| |
| |
| document.getElementById('undo-btn').addEventListener('click', undo); |
| document.getElementById('redo-btn').addEventListener('click', redo); |
| |
| |
| document.addEventListener('keydown', function(e) { |
| |
| if (e.ctrlKey && e.key === 'z') { |
| e.preventDefault(); |
| undo(); |
| } |
| |
| else if (e.ctrlKey && e.key === 'y') { |
| e.preventDefault(); |
| redo(); |
| } |
| |
| else if (e.key === 'Escape') { |
| selectedActions = []; |
| updateSelectionHandles(); |
| redrawCanvas(); |
| } |
| |
| else if (e.key === 'Delete' && selectedActions.length > 0) { |
| if (currentBoardIndex >= 0) { |
| const board = boards[currentBoardIndex]; |
| board.actions = board.actions.filter(action => !selectedActions.includes(action)); |
| historyIndex = board.actions.length - 1; |
| selectedActions = []; |
| updateSelectionHandles(); |
| redrawCanvas(); |
| updateUndoRedoButtons(); |
| } |
| } |
| }); |
| |
| |
| document.getElementById('new-board-btn').addEventListener('click', createNewBoard); |
| |
| |
| document.getElementById('clear-btn').addEventListener('click', function() { |
| if (confirm('Are you sure you want to clear the board?')) { |
| if (currentBoardIndex >= 0) { |
| boards[currentBoardIndex].actions = []; |
| historyIndex = -1; |
| selectedActions = []; |
| updateSelectionHandles(); |
| redrawCanvas(); |
| updateUndoRedoButtons(); |
| } |
| } |
| }); |
| |
| |
| document.getElementById('save-btn').addEventListener('click', function() { |
| alert('Board saved!'); |
| |
| }); |
| |
| |
| initCanvas(); |
| window.addEventListener('resize', initCanvas); |
| |
| |
| createNewBoard(); |
| }); |
| </script> |
| </html> |