/* ============================================================================= CANVAS MODULE - Whiteboard/Drawing Application ============================================================================= */ (function () { "use strict"; // ============================================================================= // STATE // ============================================================================= const state = { canvasId: null, canvasName: "Untitled Canvas", tool: "select", color: "#000000", strokeWidth: 2, fillColor: "transparent", fontSize: 16, fontFamily: "Inter", zoom: 1, panX: 0, panY: 0, isDrawing: false, isPanning: false, startX: 0, startY: 0, elements: [], selectedElement: null, clipboard: null, history: [], historyIndex: -1, gridEnabled: true, snapToGrid: true, gridSize: 20, }; let canvas = null; let ctx = null; // ============================================================================= // INITIALIZATION // ============================================================================= function init() { canvas = document.getElementById("canvas"); if (!canvas) { console.warn("Canvas element not found"); return; } ctx = canvas.getContext("2d"); resizeCanvas(); bindEvents(); loadFromUrl(); render(); console.log("Canvas module initialized"); } function resizeCanvas() { if (!canvas) return; const container = canvas.parentElement; if (container) { canvas.width = container.clientWidth || 1200; canvas.height = container.clientHeight || 800; } } function bindEvents() { if (!canvas) return; canvas.addEventListener("mousedown", handleMouseDown); canvas.addEventListener("mousemove", handleMouseMove); canvas.addEventListener("mouseup", handleMouseUp); canvas.addEventListener("mouseleave", handleMouseUp); canvas.addEventListener("wheel", handleWheel); canvas.addEventListener("dblclick", handleDoubleClick); document.addEventListener("keydown", handleKeyDown); window.addEventListener("resize", () => { resizeCanvas(); render(); }); // Touch support canvas.addEventListener("touchstart", handleTouchStart); canvas.addEventListener("touchmove", handleTouchMove); canvas.addEventListener("touchend", handleTouchEnd); } // ============================================================================= // TOOL SELECTION // ============================================================================= function selectTool(tool) { state.tool = tool; // Update UI document.querySelectorAll(".tool-btn").forEach((btn) => { btn.classList.toggle("active", btn.dataset.tool === tool); }); // Update cursor const cursors = { select: "default", pan: "grab", pencil: "crosshair", brush: "crosshair", eraser: "crosshair", rectangle: "crosshair", ellipse: "crosshair", line: "crosshair", arrow: "crosshair", text: "text", sticky: "crosshair", image: "crosshair", connector: "crosshair", frame: "crosshair", }; canvas.style.cursor = cursors[tool] || "default"; } // ============================================================================= // MOUSE HANDLERS // ============================================================================= function handleMouseDown(e) { const rect = canvas.getBoundingClientRect(); const x = (e.clientX - rect.left - state.panX) / state.zoom; const y = (e.clientY - rect.top - state.panY) / state.zoom; state.startX = x; state.startY = y; if (state.tool === "pan") { state.isPanning = true; canvas.style.cursor = "grabbing"; return; } if (state.tool === "select") { const element = findElementAt(x, y); selectElement(element); if (element) { state.isDrawing = true; // For dragging } return; } state.isDrawing = true; if (state.tool === "text") { createTextElement(x, y); state.isDrawing = false; return; } if (state.tool === "sticky") { createStickyNote(x, y); state.isDrawing = false; return; } if (state.tool === "pencil" || state.tool === "brush") { const element = { id: generateId(), type: "path", points: [{ x, y }], color: state.color, strokeWidth: state.tool === "brush" ? state.strokeWidth * 3 : state.strokeWidth, }; state.elements.push(element); state.selectedElement = element; } } function handleMouseMove(e) { const rect = canvas.getBoundingClientRect(); const x = (e.clientX - rect.left - state.panX) / state.zoom; const y = (e.clientY - rect.top - state.panY) / state.zoom; if (state.isPanning) { state.panX += e.movementX; state.panY += e.movementY; render(); return; } if (!state.isDrawing) return; if (state.tool === "pencil" || state.tool === "brush") { if (state.selectedElement && state.selectedElement.points) { state.selectedElement.points.push({ x, y }); render(); } return; } if (state.tool === "eraser") { const element = findElementAt(x, y); if (element) { state.elements = state.elements.filter((el) => el.id !== element.id); render(); } return; } if (state.tool === "select" && state.selectedElement) { const dx = x - state.startX; const dy = y - state.startY; state.selectedElement.x += dx; state.selectedElement.y += dy; state.startX = x; state.startY = y; render(); return; } // Preview shape while drawing render(); drawPreviewShape(state.startX, state.startY, x, y); } function handleMouseUp(e) { if (state.isPanning) { state.isPanning = false; canvas.style.cursor = state.tool === "pan" ? "grab" : "default"; return; } if (!state.isDrawing) return; const rect = canvas.getBoundingClientRect(); const x = (e.clientX - rect.left - state.panX) / state.zoom; const y = (e.clientY - rect.top - state.panY) / state.zoom; if ( ["rectangle", "ellipse", "line", "arrow", "frame"].includes(state.tool) ) { const element = createShapeElement( state.tool, state.startX, state.startY, x, y, ); state.elements.push(element); saveToHistory(); } if (state.tool === "pencil" || state.tool === "brush") { saveToHistory(); } state.isDrawing = false; render(); } function handleWheel(e) { e.preventDefault(); const delta = e.deltaY > 0 ? -0.1 : 0.1; const newZoom = Math.max(0.1, Math.min(5, state.zoom + delta)); state.zoom = newZoom; updateZoomDisplay(); render(); } function handleDoubleClick(e) { const rect = canvas.getBoundingClientRect(); const x = (e.clientX - rect.left - state.panX) / state.zoom; const y = (e.clientY - rect.top - state.panY) / state.zoom; const element = findElementAt(x, y); if (element && element.type === "text") { editTextElement(element); } } // ============================================================================= // TOUCH HANDLERS // ============================================================================= function handleTouchStart(e) { e.preventDefault(); const touch = e.touches[0]; handleMouseDown({ clientX: touch.clientX, clientY: touch.clientY }); } function handleTouchMove(e) { e.preventDefault(); const touch = e.touches[0]; handleMouseMove({ clientX: touch.clientX, clientY: touch.clientY, movementX: 0, movementY: 0, }); } function handleTouchEnd(e) { e.preventDefault(); const touch = e.changedTouches[0]; handleMouseUp({ clientX: touch.clientX, clientY: touch.clientY }); } // ============================================================================= // KEYBOARD HANDLERS // ============================================================================= function handleKeyDown(e) { const isMod = e.ctrlKey || e.metaKey; // Tool shortcuts if (!isMod && !e.target.matches("input, textarea")) { const toolKeys = { v: "select", h: "pan", p: "pencil", b: "brush", e: "eraser", r: "rectangle", o: "ellipse", l: "line", a: "arrow", t: "text", s: "sticky", i: "image", c: "connector", f: "frame", }; if (toolKeys[e.key.toLowerCase()]) { selectTool(toolKeys[e.key.toLowerCase()]); return; } } if (isMod && e.key === "z") { e.preventDefault(); if (e.shiftKey) { redo(); } else { undo(); } } else if (isMod && e.key === "y") { e.preventDefault(); redo(); } else if (isMod && e.key === "c") { e.preventDefault(); copyElement(); } else if (isMod && e.key === "v") { e.preventDefault(); pasteElement(); } else if (isMod && e.key === "x") { e.preventDefault(); cutElement(); } else if (isMod && e.key === "a") { e.preventDefault(); selectAll(); } else if (e.key === "Delete" || e.key === "Backspace") { if (state.selectedElement && !e.target.matches("input, textarea")) { e.preventDefault(); deleteSelected(); } } else if (e.key === "Escape") { selectElement(null); } else if (e.key === "+" || e.key === "=") { if (isMod) { e.preventDefault(); zoomIn(); } } else if (e.key === "-") { if (isMod) { e.preventDefault(); zoomOut(); } } else if (e.key === "0" && isMod) { e.preventDefault(); resetZoom(); } } // ============================================================================= // ELEMENT CREATION // ============================================================================= function createShapeElement(type, x1, y1, x2, y2) { const minX = Math.min(x1, x2); const minY = Math.min(y1, y2); const width = Math.abs(x2 - x1); const height = Math.abs(y2 - y1); return { id: generateId(), type: type, x: minX, y: minY, width: width, height: height, x1: x1, y1: y1, x2: x2, y2: y2, color: state.color, fillColor: state.fillColor, strokeWidth: state.strokeWidth, }; } function createTextElement(x, y) { const text = prompt("Enter text:"); if (!text) return; const element = { id: generateId(), type: "text", x: x, y: y, text: text, color: state.color, fontSize: state.fontSize, fontFamily: state.fontFamily, }; state.elements.push(element); saveToHistory(); render(); } function createStickyNote(x, y) { const element = { id: generateId(), type: "sticky", x: x, y: y, width: 200, height: 200, text: "Double-click to edit", color: "#ffeb3b", }; state.elements.push(element); saveToHistory(); render(); } function editTextElement(element) { const newText = prompt("Edit text:", element.text); if (newText !== null) { element.text = newText; saveToHistory(); render(); } } // ============================================================================= // ELEMENT SELECTION & MANIPULATION // ============================================================================= function findElementAt(x, y) { for (let i = state.elements.length - 1; i >= 0; i--) { const el = state.elements[i]; if (isPointInElement(x, y, el)) { return el; } } return null; } function isPointInElement(x, y, el) { const margin = 5; switch (el.type) { case "rectangle": case "frame": case "sticky": return ( x >= el.x - margin && x <= el.x + el.width + margin && y >= el.y - margin && y <= el.y + el.height + margin ); case "ellipse": const cx = el.x + el.width / 2; const cy = el.y + el.height / 2; const rx = el.width / 2 + margin; const ry = el.height / 2 + margin; return (x - cx) ** 2 / rx ** 2 + (y - cy) ** 2 / ry ** 2 <= 1; case "text": return ( x >= el.x - margin && x <= el.x + 200 && y >= el.y - el.fontSize && y <= el.y + margin ); case "line": case "arrow": return ( distanceToLine(x, y, el.x1, el.y1, el.x2, el.y2) <= margin + el.strokeWidth ); case "path": if (!el.points) return false; for (const pt of el.points) { if (Math.abs(pt.x - x) < margin && Math.abs(pt.y - y) < margin) { return true; } } return false; default: return false; } } function distanceToLine(x, y, x1, y1, x2, y2) { const A = x - x1; const B = y - y1; const C = x2 - x1; const D = y2 - y1; const dot = A * C + B * D; const lenSq = C * C + D * D; let param = lenSq !== 0 ? dot / lenSq : -1; let xx, yy; if (param < 0) { xx = x1; yy = y1; } else if (param > 1) { xx = x2; yy = y2; } else { xx = x1 + param * C; yy = y1 + param * D; } const dx = x - xx; const dy = y - yy; return Math.sqrt(dx * dx + dy * dy); } function selectElement(element) { state.selectedElement = element; render(); } function selectAll() { // Select all - for now just render all as selected render(); } function deleteSelected() { if (!state.selectedElement) return; state.elements = state.elements.filter( (el) => el.id !== state.selectedElement.id, ); state.selectedElement = null; saveToHistory(); render(); } function copyElement() { if (state.selectedElement) { state.clipboard = JSON.parse(JSON.stringify(state.selectedElement)); } } function cutElement() { copyElement(); deleteSelected(); } function pasteElement() { if (!state.clipboard) return; const newElement = JSON.parse(JSON.stringify(state.clipboard)); newElement.id = generateId(); newElement.x = (newElement.x || 0) + 20; newElement.y = (newElement.y || 0) + 20; if (newElement.x1 !== undefined) { newElement.x1 += 20; newElement.y1 += 20; newElement.x2 += 20; newElement.y2 += 20; } state.elements.push(newElement); state.selectedElement = newElement; saveToHistory(); render(); } // ============================================================================= // RENDERING // ============================================================================= function render() { if (!ctx || !canvas) return; ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.save(); ctx.translate(state.panX, state.panY); ctx.scale(state.zoom, state.zoom); // Draw grid if (state.gridEnabled) { drawGrid(); } // Draw elements for (const element of state.elements) { drawElement(element); } // Draw selection if (state.selectedElement) { drawSelection(state.selectedElement); } ctx.restore(); } function drawGrid() { const gridSize = state.gridSize; const width = canvas.width / state.zoom; const height = canvas.height / state.zoom; ctx.strokeStyle = "#e0e0e0"; ctx.lineWidth = 0.5; for (let x = 0; x < width; x += gridSize) { ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, height); ctx.stroke(); } for (let y = 0; y < height; y += gridSize) { ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(width, y); ctx.stroke(); } } function drawElement(el) { ctx.strokeStyle = el.color || state.color; ctx.fillStyle = el.fillColor || "transparent"; ctx.lineWidth = el.strokeWidth || state.strokeWidth; ctx.lineCap = "round"; ctx.lineJoin = "round"; switch (el.type) { case "rectangle": case "frame": ctx.beginPath(); ctx.rect(el.x, el.y, el.width, el.height); if (el.fillColor && el.fillColor !== "transparent") { ctx.fill(); } ctx.stroke(); break; case "ellipse": ctx.beginPath(); ctx.ellipse( el.x + el.width / 2, el.y + el.height / 2, el.width / 2, el.height / 2, 0, 0, Math.PI * 2, ); if (el.fillColor && el.fillColor !== "transparent") { ctx.fill(); } ctx.stroke(); break; case "line": ctx.beginPath(); ctx.moveTo(el.x1, el.y1); ctx.lineTo(el.x2, el.y2); ctx.stroke(); break; case "arrow": drawArrow(el.x1, el.y1, el.x2, el.y2); break; case "path": if (el.points && el.points.length > 0) { ctx.beginPath(); ctx.moveTo(el.points[0].x, el.points[0].y); for (let i = 1; i < el.points.length; i++) { ctx.lineTo(el.points[i].x, el.points[i].y); } ctx.stroke(); } break; case "text": ctx.font = `${el.fontSize || 16}px ${el.fontFamily || "Inter"}`; ctx.fillStyle = el.color || "#000000"; ctx.fillText(el.text, el.x, el.y); break; case "sticky": ctx.fillStyle = el.color || "#ffeb3b"; ctx.fillRect(el.x, el.y, el.width, el.height); ctx.strokeStyle = "#c0a000"; ctx.strokeRect(el.x, el.y, el.width, el.height); ctx.fillStyle = "#000000"; ctx.font = "14px Inter"; wrapText(el.text, el.x + 10, el.y + 25, el.width - 20, 18); break; } } function drawArrow(x1, y1, x2, y2) { const headLength = 15; const angle = Math.atan2(y2 - y1, x2 - x1); ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke(); ctx.beginPath(); ctx.moveTo(x2, y2); ctx.lineTo( x2 - headLength * Math.cos(angle - Math.PI / 6), y2 - headLength * Math.sin(angle - Math.PI / 6), ); ctx.moveTo(x2, y2); ctx.lineTo( x2 - headLength * Math.cos(angle + Math.PI / 6), y2 - headLength * Math.sin(angle + Math.PI / 6), ); ctx.stroke(); } function drawPreviewShape(x1, y1, x2, y2) { ctx.save(); ctx.translate(state.panX, state.panY); ctx.scale(state.zoom, state.zoom); ctx.strokeStyle = state.color; ctx.lineWidth = state.strokeWidth; ctx.setLineDash([5, 5]); switch (state.tool) { case "rectangle": case "frame": ctx.strokeRect( Math.min(x1, x2), Math.min(y1, y2), Math.abs(x2 - x1), Math.abs(y2 - y1), ); break; case "ellipse": ctx.beginPath(); ctx.ellipse( (x1 + x2) / 2, (y1 + y2) / 2, Math.abs(x2 - x1) / 2, Math.abs(y2 - y1) / 2, 0, 0, Math.PI * 2, ); ctx.stroke(); break; case "line": ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke(); break; case "arrow": drawArrow(x1, y1, x2, y2); break; } ctx.restore(); } function drawSelection(el) { ctx.strokeStyle = "#2196f3"; ctx.lineWidth = 2 / state.zoom; ctx.setLineDash([5 / state.zoom, 5 / state.zoom]); let x, y, w, h; if (el.type === "line" || el.type === "arrow") { x = Math.min(el.x1, el.x2) - 5; y = Math.min(el.y1, el.y2) - 5; w = Math.abs(el.x2 - el.x1) + 10; h = Math.abs(el.y2 - el.y1) + 10; } else if (el.type === "path") { const bounds = getPathBounds(el.points); x = bounds.minX - 5; y = bounds.minY - 5; w = bounds.maxX - bounds.minX + 10; h = bounds.maxY - bounds.minY + 10; } else { x = el.x - 5; y = el.y - 5; w = (el.width || 100) + 10; h = (el.height || 20) + 10; } ctx.strokeRect(x, y, w, h); ctx.setLineDash([]); } function getPathBounds(points) { if (!points || points.length === 0) { return { minX: 0, minY: 0, maxX: 0, maxY: 0 }; } let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; for (const pt of points) { minX = Math.min(minX, pt.x); minY = Math.min(minY, pt.y); maxX = Math.max(maxX, pt.x); maxY = Math.max(maxY, pt.y); } return { minX, minY, maxX, maxY }; } function wrapText(text, x, y, maxWidth, lineHeight) { const words = text.split(" "); let line = ""; for (const word of words) { const testLine = line + word + " "; const metrics = ctx.measureText(testLine); if (metrics.width > maxWidth && line !== "") { ctx.fillText(line, x, y); line = word + " "; y += lineHeight; } else { line = testLine; } } ctx.fillText(line, x, y); } // ============================================================================= // ZOOM CONTROLS // ============================================================================= function zoomIn() { state.zoom = Math.min(5, state.zoom + 0.1); updateZoomDisplay(); render(); } function zoomOut() { state.zoom = Math.max(0.1, state.zoom - 0.1); updateZoomDisplay(); render(); } function resetZoom() { state.zoom = 1; state.panX = 0; state.panY = 0; updateZoomDisplay(); render(); } function fitToScreen() { // Calculate bounds of all elements if (state.elements.length === 0) { resetZoom(); return; } let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; for (const el of state.elements) { if (el.x !== undefined) { minX = Math.min(minX, el.x); minY = Math.min(minY, el.y); maxX = Math.max(maxX, el.x + (el.width || 100)); maxY = Math.max(maxY, el.y + (el.height || 50)); } } const contentWidth = maxX - minX + 100; const contentHeight = maxY - minY + 100; const scaleX = canvas.width / contentWidth; const scaleY = canvas.height / contentHeight; state.zoom = Math.min(scaleX, scaleY, 1); state.panX = -minX * state.zoom + 50; state.panY = -minY * state.zoom + 50; updateZoomDisplay(); render(); } function updateZoomDisplay() { const el = document.getElementById("zoom-level"); if (el) { el.textContent = Math.round(state.zoom * 100) + "%"; } } // ============================================================================= // HISTORY (UNDO/REDO) // ============================================================================= function saveToHistory() { // Remove any redo states state.history = state.history.slice(0, state.historyIndex + 1); // Save current state state.history.push(JSON.stringify(state.elements)); state.historyIndex = state.history.length - 1; // Limit history size if (state.history.length > 50) { state.history.shift(); state.historyIndex--; } } function undo() { if (state.historyIndex > 0) { state.historyIndex--; state.elements = JSON.parse(state.history[state.historyIndex]); state.selectedElement = null; render(); } } function redo() { if (state.historyIndex < state.history.length - 1) { state.historyIndex++; state.elements = JSON.parse(state.history[state.historyIndex]); state.selectedElement = null; render(); } } // ============================================================================= // CLEAR CANVAS // ============================================================================= function clearCanvas() { if (!confirm("Clear the entire canvas? This cannot be undone.")) return; state.elements = []; state.selectedElement = null; saveToHistory(); render(); } // ============================================================================= // COLOR & STYLE // ============================================================================= function setColor(color) { state.color = color; if (state.selectedElement) { state.selectedElement.color = color; saveToHistory(); render(); } } function setFillColor(color) { state.fillColor = color; if (state.selectedElement) { state.selectedElement.fillColor = color; saveToHistory(); render(); } } function setStrokeWidth(width) { state.strokeWidth = parseInt(width); if (state.selectedElement) { state.selectedElement.strokeWidth = state.strokeWidth; saveToHistory(); render(); } } function toggleGrid() { state.gridEnabled = !state.gridEnabled; render(); } // ============================================================================= // SAVE/LOAD // ============================================================================= function loadFromUrl() { const urlParams = new URLSearchParams(window.location.search); const canvasId = urlParams.get("id"); if (canvasId) { loadCanvas(canvasId); } } async function loadCanvas(canvasId) { try { const response = await fetch(`/api/canvas/${canvasId}`); if (response.ok) { const data = await response.json(); state.canvasId = canvasId; state.canvasName = data.name || "Untitled Canvas"; state.elements = data.elements || []; saveToHistory(); render(); } } catch (e) { console.error("Failed to load canvas:", e); } } async function saveCanvas() { try { const response = await fetch( "/api/canvas" + (state.canvasId ? `/${state.canvasId}` : ""), { method: state.canvasId ? "PUT" : "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: state.canvasName, elements: state.elements, }), }, ); if (response.ok) { const data = await response.json(); if (data.id) { state.canvasId = data.id; window.history.replaceState({}, "", `?id=${state.canvasId}`); } showNotification("Canvas saved", "success"); } } catch (e) { console.error("Failed to save canvas:", e); showNotification("Failed to save canvas", "error"); } } function exportCanvas(format) { if (format === "png" || format === "jpg") { const dataUrl = canvas.toDataURL(`image/${format}`); const link = document.createElement("a"); link.download = `${state.canvasName}.${format}`; link.href = dataUrl; link.click(); } else if (format === "json") { const data = JSON.stringify( { name: state.canvasName, elements: state.elements }, null, 2, ); const blob = new Blob([data], { type: "application/json" }); const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.download = `${state.canvasName}.json`; link.href = url; link.click(); URL.revokeObjectURL(url); } } // ============================================================================= // SHARING & COLLABORATION // ============================================================================= function shareCanvas() { if (!state.canvasId) { // Save canvas first if not saved saveCanvas().then(() => { showShareDialog(); }); } else { showShareDialog(); } } function showShareDialog() { const modal = document.getElementById("share-modal"); if (modal) { if (modal.showModal) { modal.showModal(); } else { modal.classList.add("open"); modal.style.display = "flex"; } // Generate share link const shareUrl = `${window.location.origin}/canvas?id=${state.canvasId}`; const shareLinkInput = document.getElementById("share-link"); if (shareLinkInput) { shareLinkInput.value = shareUrl; } } else { // Fallback: copy link to clipboard const shareUrl = `${window.location.origin}/canvas?id=${state.canvasId || "new"}`; navigator.clipboard .writeText(shareUrl) .then(() => { showNotification("Share link copied to clipboard", "success"); }) .catch(() => { showNotification( "Canvas ID: " + (state.canvasId || "unsaved"), "info", ); }); } } // ============================================================================= // PROPERTIES PANEL // ============================================================================= function togglePropertiesPanel() { const panel = document.getElementById("properties-panel"); if (panel) { panel.classList.toggle("collapsed"); const isCollapsed = panel.classList.contains("collapsed"); // Update toggle button icon if needed const toggleBtn = panel.querySelector(".panel-toggle span"); if (toggleBtn) { toggleBtn.textContent = isCollapsed ? "⚙️" : "✕"; } } } // ============================================================================= // LAYERS MANAGEMENT // ============================================================================= let layers = [ { id: "layer_1", name: "Layer 1", visible: true, locked: false }, ]; let activeLayerId = "layer_1"; function addLayer() { const newId = "layer_" + (layers.length + 1); const newLayer = { id: newId, name: "Layer " + (layers.length + 1), visible: true, locked: false, }; layers.push(newLayer); activeLayerId = newId; renderLayers(); showNotification("Layer added", "success"); } function renderLayers() { const layersList = document.getElementById("layers-list"); if (!layersList) return; layersList.innerHTML = layers .map( (layer) => `
${layer.visible ? "👁️" : "👁️‍🗨️"} ${layer.name} ${layer.locked ? "🔒" : "🔓"}
`, ) .join(""); } function selectLayer(layerId) { activeLayerId = layerId; renderLayers(); } function toggleLayerVisibility(layerId) { const layer = layers.find((l) => l.id === layerId); if (layer) { layer.visible = !layer.visible; renderLayers(); render(); } } function toggleLayerLock(layerId) { const layer = layers.find((l) => l.id === layerId); if (layer) { layer.locked = !layer.locked; renderLayers(); } } // ============================================================================= // CLIPBOARD & DUPLICATE // ============================================================================= function duplicateSelected() { if (!state.selectedElement) { showNotification("No element selected", "warning"); return; } const original = state.selectedElement; const duplicate = JSON.parse(JSON.stringify(original)); duplicate.id = generateId(); // Offset the duplicate slightly if (duplicate.x !== undefined) duplicate.x += 20; if (duplicate.y !== undefined) duplicate.y += 20; state.elements.push(duplicate); state.selectedElement = duplicate; saveToHistory(); render(); showNotification("Element duplicated", "success"); } function copySelected() { if (!state.selectedElement) { showNotification("No element selected", "warning"); return; } state.clipboard = JSON.parse(JSON.stringify(state.selectedElement)); showNotification("Element copied", "success"); } function pasteClipboard() { if (!state.clipboard) { showNotification("Nothing to paste", "warning"); return; } const pasted = JSON.parse(JSON.stringify(state.clipboard)); pasted.id = generateId(); // Offset the pasted element if (pasted.x !== undefined) pasted.x += 20; if (pasted.y !== undefined) pasted.y += 20; state.elements.push(pasted); state.selectedElement = pasted; saveToHistory(); render(); showNotification("Element pasted", "success"); } // ============================================================================= // ELEMENT ORDERING // ============================================================================= function bringToFront() { if (!state.selectedElement) return; const index = state.elements.findIndex( (e) => e.id === state.selectedElement.id, ); if (index !== -1 && index < state.elements.length - 1) { state.elements.splice(index, 1); state.elements.push(state.selectedElement); saveToHistory(); render(); } } function sendToBack() { if (!state.selectedElement) return; const index = state.elements.findIndex( (e) => e.id === state.selectedElement.id, ); if (index > 0) { state.elements.splice(index, 1); state.elements.unshift(state.selectedElement); saveToHistory(); render(); } } // ============================================================================= // EXPORT MODAL // ============================================================================= function showExportModal() { const modal = document.getElementById("export-modal"); if (modal) { if (modal.showModal) { modal.showModal(); } else { modal.classList.add("open"); modal.style.display = "flex"; } } } function closeExportModal() { const modal = document.getElementById("export-modal"); if (modal) { if (modal.close) { modal.close(); } else { modal.classList.remove("open"); modal.style.display = "none"; } } } function doExport() { const formatSelect = document.getElementById("export-format"); const format = formatSelect ? formatSelect.value : "png"; exportCanvas(format); closeExportModal(); } // ============================================================================= // UTILITIES // ============================================================================= function generateId() { return "el_" + Math.random().toString(36).substr(2, 9); } function showNotification(message, type) { if (typeof window.showNotification === "function") { window.showNotification(message, type); } else if (typeof window.GBAlerts !== "undefined") { if (type === "success") window.GBAlerts.success("Canvas", message); else if (type === "error") window.GBAlerts.error("Canvas", message); else window.GBAlerts.info("Canvas", message); } else { console.log(`[${type}] ${message}`); } } // ============================================================================= // EXPORT TO WINDOW // ============================================================================= window.selectTool = selectTool; window.zoomIn = zoomIn; window.zoomOut = zoomOut; window.resetZoom = resetZoom; window.fitToScreen = fitToScreen; window.undo = undo; window.redo = redo; window.clearCanvas = clearCanvas; window.setColor = setColor; window.setFillColor = setFillColor; window.setStrokeWidth = setStrokeWidth; window.toggleGrid = toggleGrid; window.saveCanvas = saveCanvas; window.exportCanvas = exportCanvas; window.deleteSelected = deleteSelected; window.copyElement = copyElement; window.cutElement = cutElement; window.pasteElement = pasteElement; // Sharing & Collaboration window.shareCanvas = shareCanvas; // Properties Panel window.togglePropertiesPanel = togglePropertiesPanel; // Layers window.addLayer = addLayer; window.selectLayer = selectLayer; window.toggleLayerVisibility = toggleLayerVisibility; window.toggleLayerLock = toggleLayerLock; // Clipboard & Duplicate window.duplicateSelected = duplicateSelected; window.copySelected = copySelected; window.pasteClipboard = pasteClipboard; // Element Ordering window.bringToFront = bringToFront; window.sendToBack = sendToBack; // Export Modal window.showExportModal = showExportModal; window.closeExportModal = closeExportModal; window.doExport = doExport; // ============================================================================= // INITIALIZE // ============================================================================= if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", init); } else { init(); } })();