From 7a317c40ad760e2c784c05696810c890995a09ee Mon Sep 17 00:00:00 2001 From: Abhrxdi4p Date: Thu, 5 Feb 2026 21:17:25 +0530 Subject: [PATCH 1/2] Added whiteboard --- .../virtual-whiteboard/VirtualWhiteboard.jsx | 202 ++++++++ .../virtual-whiteboard/components/Canvas.jsx | 260 ++++++++++ .../components/LayerPanel.jsx | 146 ++++++ .../virtual-whiteboard/components/Toolbar.jsx | 162 +++++++ src/plays/virtual-whiteboard/readme.md | 119 +++++ src/plays/virtual-whiteboard/styles.css | 454 ++++++++++++++++++ 6 files changed, 1343 insertions(+) create mode 100644 src/plays/virtual-whiteboard/VirtualWhiteboard.jsx create mode 100644 src/plays/virtual-whiteboard/components/Canvas.jsx create mode 100644 src/plays/virtual-whiteboard/components/LayerPanel.jsx create mode 100644 src/plays/virtual-whiteboard/components/Toolbar.jsx create mode 100644 src/plays/virtual-whiteboard/readme.md create mode 100644 src/plays/virtual-whiteboard/styles.css diff --git a/src/plays/virtual-whiteboard/VirtualWhiteboard.jsx b/src/plays/virtual-whiteboard/VirtualWhiteboard.jsx new file mode 100644 index 000000000..55b6de29c --- /dev/null +++ b/src/plays/virtual-whiteboard/VirtualWhiteboard.jsx @@ -0,0 +1,202 @@ +import React, { useState, useRef, useCallback } from 'react'; +import PlayHeader from 'common/playlists/PlayHeader'; +import Toolbar from './components/Toolbar'; +import Canvas from './components/Canvas'; +import LayerPanel from './components/LayerPanel'; +import './styles.css'; + +function VirtualWhiteboard(props) { + // Drawing state + const [tool, setTool] = useState('pen'); + const [color, setColor] = useState('#000000'); + const [lineWidth, setLineWidth] = useState(2); + const [fillColor, setFillColor] = useState('#ffffff'); + const [fontSize, setFontSize] = useState(16); + + // Layer state + const [layers, setLayers] = useState([ + { id: 1, name: 'Layer 1', visible: true, locked: false, data: [] } + ]); + const [activeLayerId, setActiveLayerId] = useState(1); + + // History state for undo/redo + const [history, setHistory] = useState([]); + const [historyStep, setHistoryStep] = useState(-1); + + // Refs + const canvasRef = useRef(null); + + // Save state to history + const saveToHistory = useCallback(() => { + const newHistory = history.slice(0, historyStep + 1); + newHistory.push(JSON.parse(JSON.stringify(layers))); + setHistory(newHistory); + setHistoryStep(newHistory.length - 1); + }, [history, historyStep, layers]); + + // Undo + const handleUndo = useCallback(() => { + if (historyStep > 0) { + setHistoryStep(historyStep - 1); + setLayers(JSON.parse(JSON.stringify(history[historyStep - 1]))); + } + }, [historyStep, history]); + + // Redo + const handleRedo = useCallback(() => { + if (historyStep < history.length - 1) { + setHistoryStep(historyStep + 1); + setLayers(JSON.parse(JSON.stringify(history[historyStep + 1]))); + } + }, [historyStep, history]); + + // Layer management + const addLayer = () => { + const newLayer = { + id: Date.now(), + name: `Layer ${layers.length + 1}`, + visible: true, + locked: false, + data: [] + }; + setLayers([...layers, newLayer]); + setActiveLayerId(newLayer.id); + }; + + const deleteLayer = (layerId) => { + if (layers.length === 1) return; // Keep at least one layer + const newLayers = layers.filter((layer) => layer.id !== layerId); + setLayers(newLayers); + if (activeLayerId === layerId) { + setActiveLayerId(newLayers[0].id); + } + }; + + const toggleLayerVisibility = (layerId) => { + setLayers( + layers.map((layer) => (layer.id === layerId ? { ...layer, visible: !layer.visible } : layer)) + ); + }; + + const toggleLayerLock = (layerId) => { + setLayers( + layers.map((layer) => (layer.id === layerId ? { ...layer, locked: !layer.locked } : layer)) + ); + }; + + const renameLayer = (layerId, newName) => { + setLayers(layers.map((layer) => (layer.id === layerId ? { ...layer, name: newName } : layer))); + }; + + // Update layer data + const updateLayerData = (layerId, newData) => { + setLayers(layers.map((layer) => (layer.id === layerId ? { ...layer, data: newData } : layer))); + }; + + // Clear canvas + const handleClear = () => { + if (window.confirm('Clear all layers? This cannot be undone.')) { + setLayers([{ id: Date.now(), name: 'Layer 1', visible: true, locked: false, data: [] }]); + setHistory([]); + setHistoryStep(-1); + } + }; + + // Export to image + const handleExportImage = () => { + if (canvasRef.current) { + const canvas = canvasRef.current; + const link = document.createElement('a'); + link.download = `whiteboard-${Date.now()}.png`; + link.href = canvas.toDataURL(); + link.click(); + } + }; + + // Export to PDF (using html2canvas approach) + const handleExportPDF = async () => { + if (canvasRef.current) { + try { + const canvas = canvasRef.current; + const imgData = canvas.toDataURL('image/png'); + + // Create a simple PDF export using a new window + const pdfWindow = window.open('', '_blank'); + pdfWindow.document.write(` + + Whiteboard Export + + + + + + `); + pdfWindow.document.close(); + } catch (error) { + console.error('Export failed:', error); + alert('Export failed. Please try again.'); + } + } + }; + + return ( +
+ +
+
+ 0} + color={color} + fillColor={fillColor} + fontSize={fontSize} + lineWidth={lineWidth} + setColor={setColor} + setFillColor={setFillColor} + setFontSize={setFontSize} + setLineWidth={setLineWidth} + setTool={setTool} + tool={tool} + onClear={handleClear} + onExportImage={handleExportImage} + onExportPDF={handleExportPDF} + onRedo={handleRedo} + onUndo={handleUndo} + /> + +
+ + + +
+
+
+
+ ); +} + +export default VirtualWhiteboard; diff --git a/src/plays/virtual-whiteboard/components/Canvas.jsx b/src/plays/virtual-whiteboard/components/Canvas.jsx new file mode 100644 index 000000000..3e0fb7c01 --- /dev/null +++ b/src/plays/virtual-whiteboard/components/Canvas.jsx @@ -0,0 +1,260 @@ +import React, { useEffect, useState, forwardRef } from 'react'; + +const Canvas = forwardRef( + ( + { + layers, + activeLayerId, + tool, + color, + lineWidth, + fillColor, + fontSize, + updateLayerData, + saveToHistory + }, + ref + ) => { + const [isDrawing, setIsDrawing] = useState(false); + const [startPos, setStartPos] = useState({ x: 0, y: 0 }); + const [currentShape, setCurrentShape] = useState(null); + + const activeLayer = layers.find((layer) => layer.id === activeLayerId); + + useEffect(() => { + const canvas = ref.current; + if (!canvas) return; + + const ctx = canvas.getContext('2d'); + ctx.clearRect(0, 0, canvas.width, canvas.height); + + // Draw all visible layers + layers.forEach((layer) => { + if (layer.visible) { + layer.data.forEach((item) => { + drawItem(ctx, item); + }); + } + }); + + // Draw current shape being created + if (currentShape) { + drawItem(ctx, currentShape); + } + }, [layers, currentShape]); + + // Keyboard shortcuts + useEffect(() => { + const handleKeyDown = (e) => { + if ((e.ctrlKey || e.metaKey) && e.key === 'z') { + e.preventDefault(); + // Undo handled in parent + } + if ((e.ctrlKey || e.metaKey) && e.key === 'y') { + e.preventDefault(); + // Redo handled in parent + } + }; + + window.addEventListener('keydown', handleKeyDown); + + return () => window.removeEventListener('keydown', handleKeyDown); + }, []); + + const drawItem = (ctx, item) => { + ctx.strokeStyle = item.color; + ctx.lineWidth = item.lineWidth; + ctx.fillStyle = item.fillColor || 'transparent'; + ctx.lineCap = 'round'; + ctx.lineJoin = 'round'; + + switch (item.type) { + case 'pen': + ctx.beginPath(); + item.points.forEach((point, index) => { + if (index === 0) { + ctx.moveTo(point.x, point.y); + } else { + ctx.lineTo(point.x, point.y); + } + }); + ctx.stroke(); + + break; + + case 'eraser': + ctx.save(); + ctx.globalCompositeOperation = 'destination-out'; + ctx.beginPath(); + item.points.forEach((point, index) => { + if (index === 0) { + ctx.moveTo(point.x, point.y); + } else { + ctx.lineTo(point.x, point.y); + } + }); + ctx.lineWidth = item.lineWidth * 2; + ctx.stroke(); + ctx.restore(); + + break; + + case 'line': + ctx.beginPath(); + ctx.moveTo(item.startX, item.startY); + ctx.lineTo(item.endX, item.endY); + ctx.stroke(); + + break; + + case 'rectangle': { + const width = item.endX - item.startX; + const height = item.endY - item.startY; + if (item.fillColor && item.fillColor !== '#ffffff') { + ctx.fillRect(item.startX, item.startY, width, height); + } + ctx.strokeRect(item.startX, item.startY, width, height); + + break; + } + + case 'circle': { + const radius = Math.sqrt( + Math.pow(item.endX - item.startX, 2) + Math.pow(item.endY - item.startY, 2) + ); + ctx.beginPath(); + ctx.arc(item.startX, item.startY, radius, 0, 2 * Math.PI); + if (item.fillColor && item.fillColor !== '#ffffff') { + ctx.fill(); + } + ctx.stroke(); + + break; + } + + case 'text': + ctx.font = `${item.fontSize}px Arial`; + ctx.fillStyle = item.color; + ctx.fillText(item.text, item.x, item.y); + + break; + + default: + break; + } + }; + + const getMousePos = (e) => { + const canvas = ref.current; + const rect = canvas.getBoundingClientRect(); + + return { + x: e.clientX - rect.left, + y: e.clientY - rect.top + }; + }; + + const handleMouseDown = (e) => { + if (!activeLayer || activeLayer.locked) return; + + const pos = getMousePos(e); + setIsDrawing(true); + setStartPos(pos); + + if (tool === 'pen' || tool === 'eraser') { + const newItem = { + type: tool, + color: color, + lineWidth: lineWidth, + points: [pos] + }; + setCurrentShape(newItem); + } else if (tool === 'text') { + const text = prompt('Enter text:'); + if (text) { + const newItem = { + type: 'text', + text: text, + x: pos.x, + y: pos.y, + color: color, + fontSize: fontSize + }; + const newData = [...activeLayer.data, newItem]; + updateLayerData(activeLayerId, newData); + saveToHistory(); + } + } + }; + + const handleMouseMove = (e) => { + if (!isDrawing || !activeLayer || activeLayer.locked) return; + + const pos = getMousePos(e); + + if (tool === 'pen' || tool === 'eraser') { + setCurrentShape((prev) => ({ + ...prev, + points: [...prev.points, pos] + })); + } else if (tool === 'line' || tool === 'rectangle' || tool === 'circle') { + setCurrentShape({ + type: tool, + startX: startPos.x, + startY: startPos.y, + endX: pos.x, + endY: pos.y, + color: color, + lineWidth: lineWidth, + fillColor: fillColor + }); + } + }; + + const handleMouseUp = () => { + if (!isDrawing || !currentShape || !activeLayer || activeLayer.locked) { + setIsDrawing(false); + setCurrentShape(null); + + return; + } + + const newData = [...activeLayer.data, currentShape]; + updateLayerData(activeLayerId, newData); + saveToHistory(); + + setIsDrawing(false); + setCurrentShape(null); + }; + + const handleMouseLeave = () => { + if (isDrawing) { + handleMouseUp(); + } + }; + + return ( +
+ + {activeLayer && activeLayer.locked && ( +
+
🔒 Layer is locked
+
+ )} +
+ ); + } +); + +Canvas.displayName = 'Canvas'; + +export default Canvas; diff --git a/src/plays/virtual-whiteboard/components/LayerPanel.jsx b/src/plays/virtual-whiteboard/components/LayerPanel.jsx new file mode 100644 index 000000000..a8d4e35c8 --- /dev/null +++ b/src/plays/virtual-whiteboard/components/LayerPanel.jsx @@ -0,0 +1,146 @@ +import React, { useState } from 'react'; +import { FaPlus, FaTrash, FaEye, FaEyeSlash, FaLock, FaUnlock, FaEdit } from 'react-icons/fa'; + +const LayerPanel = ({ + layers, + activeLayerId, + setActiveLayerId, + onAddLayer, + onDeleteLayer, + onToggleVisibility, + onToggleLock, + onRenameLayer +}) => { + const [editingLayerId, setEditingLayerId] = useState(null); + const [editName, setEditName] = useState(''); + + const handleStartEdit = (layer) => { + setEditingLayerId(layer.id); + setEditName(layer.name); + }; + + const handleFinishEdit = (layerId) => { + if (editName.trim()) { + onRenameLayer(layerId, editName.trim()); + } + setEditingLayerId(null); + setEditName(''); + }; + + const handleKeyDown = (e, layerId) => { + if (e.key === 'Enter') { + handleFinishEdit(layerId); + } else if (e.key === 'Escape') { + setEditingLayerId(null); + setEditName(''); + } + }; + + return ( +
+
+

Layers

+ +
+ +
+ {[...layers].reverse().map((layer) => ( +
!layer.locked && setActiveLayerId(layer.id)} + > +
+ + + +
+ +
+ {editingLayerId === layer.id ? ( + handleFinishEdit(layer.id)} + onChange={(e) => setEditName(e.target.value)} + onClick={(e) => e.stopPropagation()} + onKeyDown={(e) => handleKeyDown(e, layer.id)} + /> + ) : ( + {layer.name} + )} + +
+ {layer.data.length} object{layer.data.length !== 1 ? 's' : ''} +
+
+ +
+ + + {layers.length > 1 && ( + + )} +
+
+ ))} +
+ +
+

Layer Tips

+
    +
  • Click a layer to make it active
  • +
  • Use 👁️ to show/hide layers
  • +
  • Use 🔒 to lock/unlock layers
  • +
  • Locked layers can't be edited
  • +
+
+
+ ); +}; + +export default LayerPanel; diff --git a/src/plays/virtual-whiteboard/components/Toolbar.jsx b/src/plays/virtual-whiteboard/components/Toolbar.jsx new file mode 100644 index 000000000..44125eb2c --- /dev/null +++ b/src/plays/virtual-whiteboard/components/Toolbar.jsx @@ -0,0 +1,162 @@ +import React from 'react'; +import { + FaPen, + FaEraser, + FaSquare, + FaCircle, + FaArrowRight, + FaFont, + FaUndo, + FaRedo, + FaTrash, + FaDownload, + FaFilePdf +} from 'react-icons/fa'; + +const Toolbar = ({ + tool, + setTool, + color, + setColor, + lineWidth, + setLineWidth, + fillColor, + setFillColor, + fontSize, + setFontSize, + onUndo, + onRedo, + onClear, + onExportImage, + onExportPDF, + canUndo, + canRedo +}) => { + const tools = [ + { id: 'pen', icon: , label: 'Pen' }, + { id: 'eraser', icon: , label: 'Eraser' }, + { id: 'line', icon: , label: 'Line' }, + { id: 'rectangle', icon: , label: 'Rectangle' }, + { id: 'circle', icon: , label: 'Circle' }, + { id: 'text', icon: , label: 'Text' } + ]; + + return ( +
+
+

Tools

+
+ {tools.map((t) => ( + + ))} +
+
+ +
+

Colors

+
+
+ + setColor(e.target.value)} + /> +
+
+ + setFillColor(e.target.value)} + /> +
+
+
+ +
+

Size

+ {tool === 'text' ? ( +
+ + setFontSize(Number(e.target.value))} + /> +
+ ) : ( +
+ + setLineWidth(Number(e.target.value))} + /> +
+ )} +
+ +
+

Actions

+
+ + + +
+
+ +
+

Export

+
+ + +
+
+ +
+
+

Shortcuts

+
    +
  • + Ctrl+Z Undo +
  • +
  • + Ctrl+Y Redo +
  • +
  • + Del Clear +
  • +
+
+
+
+ ); +}; + +export default Toolbar; diff --git a/src/plays/virtual-whiteboard/readme.md b/src/plays/virtual-whiteboard/readme.md new file mode 100644 index 000000000..09519e128 --- /dev/null +++ b/src/plays/virtual-whiteboard/readme.md @@ -0,0 +1,119 @@ +# Virtual Whiteboard + +A powerful virtual whiteboard with real-time drawing capabilities, layer management, and export functionality. Perfect for brainstorming, teaching, or collaborative design work. + +## Play Demographic + +- Language: js +- Level: Advanced + +## Creator Information + +- User: Abhrxdip +- Github Link: https://github.com/Abhrxdip +- Blog: +- Video: + +## Features + +### 🎨 Drawing Tools +- **Pen Tool**: Freehand drawing with customizable colors and widths +- **Eraser**: Remove unwanted strokes +- **Line Tool**: Draw straight lines +- **Rectangle Tool**: Create rectangles with fill options +- **Circle Tool**: Draw circles with customizable appearance +- **Text Tool**: Add text with custom font sizes + +### 🎯 Advanced Features +- **Layer Management**: + - Create multiple layers for organized drawing + - Show/hide layers individually + - Lock layers to prevent accidental edits + - Rename layers for better organization + - Delete unnecessary layers + - See object count per layer + +- **Undo/Redo**: + - Full history tracking + - Unlimited undo/redo steps + - Keyboard shortcuts (Ctrl+Z, Ctrl+Y) + +- **Export Options**: + - Export as PNG image + - Export as PDF (print-friendly) + - High-quality output + +- **Customization**: + - Adjustable stroke colors + - Fill colors for shapes + - Line width control (1-20px) + - Font size control (12-72px) + +## Implementation Details + +### React Concepts Used +- **useState**: Managing drawing state, tool selection, colors, and layers +- **useRef**: Canvas reference for drawing operations +- **useCallback**: Optimizing undo/redo functions +- **useEffect**: Canvas rendering and keyboard event handling +- **forwardRef**: Canvas component ref forwarding + +### Technical Implementation +- **Canvas API**: HTML5 Canvas for all drawing operations +- **Layer System**: Array-based layer management with visibility and lock states +- **History Management**: JSON-based state snapshots for undo/redo +- **Event Handling**: Mouse events for drawing interactions +- **Export Functionality**: Canvas.toDataURL() for image export + +### Architecture +``` +VirtualWhiteboard (Parent) +├── Toolbar (Tool selection & controls) +├── Canvas (Drawing surface) +└── LayerPanel (Layer management) +``` + +## Usage Instructions + +1. **Select a Tool**: Click on pen, eraser, line, rectangle, circle, or text tool +2. **Customize**: Adjust colors, line width, or font size as needed +3. **Draw**: Click and drag on the canvas to create your artwork +4. **Manage Layers**: + - Click "+" to add a new layer + - Click on a layer to make it active + - Use eye icon to show/hide + - Use lock icon to prevent editing +5. **Undo/Redo**: Use toolbar buttons or keyboard shortcuts +6. **Export**: Click PNG or PDF to save your work + +## Keyboard Shortcuts +- `Ctrl+Z` - Undo +- `Ctrl+Y` - Redo +- `Del` - Clear all (with confirmation) + +## Potential Enhancements +- WebSocket integration for real-time collaboration +- Cloud save/load functionality +- More shape tools (triangle, polygon, etc.) +- Background image support +- Grid/ruler overlay +- Color palette presets +- Brush texture options +- Layer opacity control +- Transform tools (rotate, scale) + +## Considerations +- Canvas size is fixed at 1200x700px for optimal performance +- History is stored in memory (cleared on page refresh) +- Export quality depends on canvas resolution +- Locked layers prevent all editing operations +- At least one layer must exist at all times + +## Resources +- [HTML5 Canvas API](https://developer.mozilla.org/en-US/docs/Web/API/Canvas_API) +- [React Icons](https://react-icons.github.io/react-icons/) +- [Canvas Drawing Tutorial](https://developer.mozilla.org/en-US/docs/Web/API/Canvas_API/Tutorial) + +--- + +[@Abhrxdip](https://github.com/Abhrxdip) diff --git a/src/plays/virtual-whiteboard/styles.css b/src/plays/virtual-whiteboard/styles.css new file mode 100644 index 000000000..c461ceaf2 --- /dev/null +++ b/src/plays/virtual-whiteboard/styles.css @@ -0,0 +1,454 @@ +.virtual-whiteboard { + width: 100%; + min-height: 100vh; + background: #f5f5f5; +} + +.whiteboard-container { + display: flex; + flex-direction: column; + height: 100%; + padding: 20px; + gap: 20px; +} + +/* Toolbar Styles */ +.toolbar { + background: white; + border-radius: 12px; + padding: 20px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + display: flex; + flex-wrap: wrap; + gap: 30px; + align-items: flex-start; +} + +.toolbar-section { + display: flex; + flex-direction: column; + gap: 10px; +} + +.toolbar-title { + font-size: 14px; + font-weight: 600; + color: #333; + margin: 0; + margin-bottom: 8px; +} + +.tool-buttons { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.tool-btn { + width: 44px; + height: 44px; + border: 2px solid #e0e0e0; + background: white; + border-radius: 8px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; + color: #666; + transition: all 0.2s; +} + +.tool-btn:hover { + border-color: #4a90e2; + color: #4a90e2; + transform: translateY(-2px); +} + +.tool-btn.active { + background: #4a90e2; + border-color: #4a90e2; + color: white; +} + +.color-controls { + display: flex; + gap: 15px; +} + +.control-group { + display: flex; + flex-direction: column; + gap: 5px; +} + +.control-group label { + font-size: 12px; + color: #666; + font-weight: 500; +} + +.color-picker { + width: 60px; + height: 40px; + border: 2px solid #e0e0e0; + border-radius: 8px; + cursor: pointer; + padding: 2px; +} + +.slider { + width: 150px; + cursor: pointer; +} + +.action-buttons, +.export-buttons { + display: flex; + gap: 8px; +} + +.action-btn, +.export-btn { + padding: 10px 16px; + border: 2px solid #e0e0e0; + background: white; + border-radius: 8px; + cursor: pointer; + font-size: 16px; + color: #666; + transition: all 0.2s; + display: flex; + align-items: center; + gap: 6px; +} + +.action-btn:hover:not(:disabled), +.export-btn:hover { + border-color: #4a90e2; + color: #4a90e2; + transform: translateY(-2px); +} + +.action-btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.action-btn.danger:hover { + border-color: #e74c3c; + color: #e74c3c; +} + +.export-btn { + font-size: 14px; + font-weight: 500; +} + +.keyboard-shortcuts { + padding: 12px; + background: #f9f9f9; + border-radius: 8px; + font-size: 12px; +} + +.keyboard-shortcuts h4 { + margin: 0 0 8px 0; + font-size: 13px; + color: #333; +} + +.keyboard-shortcuts ul { + list-style: none; + padding: 0; + margin: 0; +} + +.keyboard-shortcuts li { + margin: 4px 0; + color: #666; +} + +.keyboard-shortcuts kbd { + background: white; + border: 1px solid #ddd; + border-radius: 4px; + padding: 2px 6px; + font-size: 11px; + font-family: monospace; + margin-right: 6px; +} + +/* Main Area */ +.whiteboard-main { + display: flex; + gap: 20px; + height: calc(100vh - 280px); +} + +/* Canvas Styles */ +.canvas-container { + flex: 1; + background: white; + border-radius: 12px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + display: flex; + align-items: center; + justify-content: center; + position: relative; + overflow: auto; + padding: 20px; +} + +.whiteboard-canvas { + border: 2px solid #e0e0e0; + border-radius: 8px; + cursor: crosshair; + background: white; +} + +.canvas-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.05); + display: flex; + align-items: center; + justify-content: center; + border-radius: 12px; + pointer-events: none; +} + +.lock-message { + background: rgba(0, 0, 0, 0.8); + color: white; + padding: 12px 24px; + border-radius: 8px; + font-size: 14px; + font-weight: 500; +} + +/* Layer Panel Styles */ +.layer-panel { + width: 280px; + background: white; + border-radius: 12px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + display: flex; + flex-direction: column; + overflow: hidden; +} + +.layer-panel-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px 20px; + border-bottom: 2px solid #f0f0f0; +} + +.layer-panel-header h3 { + margin: 0; + font-size: 16px; + color: #333; +} + +.add-layer-btn { + width: 32px; + height: 32px; + border: none; + background: #4a90e2; + color: white; + border-radius: 6px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + font-size: 14px; + transition: all 0.2s; +} + +.add-layer-btn:hover { + background: #357abd; + transform: scale(1.05); +} + +.layer-list { + flex: 1; + overflow-y: auto; + padding: 12px; +} + +.layer-item { + background: #f9f9f9; + border: 2px solid #e0e0e0; + border-radius: 8px; + padding: 12px; + margin-bottom: 8px; + cursor: pointer; + transition: all 0.2s; + display: flex; + gap: 10px; + align-items: center; +} + +.layer-item:hover { + border-color: #4a90e2; + transform: translateX(2px); +} + +.layer-item.active { + background: #e3f2fd; + border-color: #4a90e2; +} + +.layer-item.locked { + opacity: 0.7; +} + +.layer-controls { + display: flex; + flex-direction: column; + gap: 6px; +} + +.layer-control-btn { + width: 28px; + height: 28px; + border: none; + background: white; + border-radius: 4px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + color: #666; + transition: all 0.2s; +} + +.layer-control-btn:hover { + color: #4a90e2; + transform: scale(1.1); +} + +.layer-info { + flex: 1; + min-width: 0; +} + +.layer-name { + font-size: 14px; + font-weight: 500; + color: #333; + display: block; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.layer-name-input { + width: 100%; + padding: 4px 8px; + border: 2px solid #4a90e2; + border-radius: 4px; + font-size: 14px; + font-weight: 500; + outline: none; +} + +.layer-stats { + font-size: 11px; + color: #999; + margin-top: 2px; +} + +.layer-actions { + display: flex; + gap: 4px; +} + +.layer-action-btn { + width: 28px; + height: 28px; + border: none; + background: white; + border-radius: 4px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + color: #666; + transition: all 0.2s; +} + +.layer-action-btn:hover { + color: #4a90e2; + transform: scale(1.1); +} + +.layer-action-btn.danger:hover { + color: #e74c3c; +} + +.layer-info-box { + padding: 16px; + background: #f9f9f9; + border-top: 2px solid #f0f0f0; +} + +.layer-info-box h4 { + margin: 0 0 8px 0; + font-size: 13px; + color: #333; +} + +.layer-info-box ul { + list-style: none; + padding: 0; + margin: 0; + font-size: 12px; + color: #666; +} + +.layer-info-box li { + margin: 4px 0; +} + +/* Responsive Design */ +@media (max-width: 1200px) { + .whiteboard-main { + flex-direction: column; + height: auto; + } + + .layer-panel { + width: 100%; + } + + .canvas-container { + min-height: 500px; + } +} + +@media (max-width: 768px) { + .toolbar { + gap: 15px; + } + + .toolbar-section { + width: 100%; + } + + .whiteboard-canvas { + width: 100% !important; + height: 400px !important; + } + + .slider { + width: 100%; + } +} From 30e87110761feed5fce62a85da357381863bf391 Mon Sep 17 00:00:00 2001 From: Abhrxdi4p Date: Sat, 7 Feb 2026 20:18:02 +0530 Subject: [PATCH 2/2] added svg optimizer --- src/plays/svg-optimizer/COVER_IMAGE_NOTE.md | 10 + src/plays/svg-optimizer/OptimizationPanel.jsx | 91 +++++ src/plays/svg-optimizer/PreviewPanel.jsx | 73 ++++ src/plays/svg-optimizer/Readme.md | 78 +++++ src/plays/svg-optimizer/SVGOptimizer.jsx | 322 ++++++++++++++++++ src/plays/svg-optimizer/styles.css | 313 +++++++++++++++++ 6 files changed, 887 insertions(+) create mode 100644 src/plays/svg-optimizer/COVER_IMAGE_NOTE.md create mode 100644 src/plays/svg-optimizer/OptimizationPanel.jsx create mode 100644 src/plays/svg-optimizer/PreviewPanel.jsx create mode 100644 src/plays/svg-optimizer/Readme.md create mode 100644 src/plays/svg-optimizer/SVGOptimizer.jsx create mode 100644 src/plays/svg-optimizer/styles.css diff --git a/src/plays/svg-optimizer/COVER_IMAGE_NOTE.md b/src/plays/svg-optimizer/COVER_IMAGE_NOTE.md new file mode 100644 index 000000000..bccad233f --- /dev/null +++ b/src/plays/svg-optimizer/COVER_IMAGE_NOTE.md @@ -0,0 +1,10 @@ +# Cover Image Placeholder + +Please add a `cover.png` file to this directory. + +Recommended specifications: +- Format: PNG +- Dimensions: Approximately 800x600 pixels (or similar aspect ratio) +- Content: A representative screenshot or icon for the SVG Optimizer play + +You can use a screenshot of the SVG Optimizer interface or create a custom graphic that represents the tool's functionality. diff --git a/src/plays/svg-optimizer/OptimizationPanel.jsx b/src/plays/svg-optimizer/OptimizationPanel.jsx new file mode 100644 index 000000000..97832b245 --- /dev/null +++ b/src/plays/svg-optimizer/OptimizationPanel.jsx @@ -0,0 +1,91 @@ +import React from 'react'; + +function OptimizationPanel({ options, onChange }) { + const handleToggle = (optionKey) => { + onChange({ + ...options, + [optionKey]: !options[optionKey] + }); + }; + + return ( +
+

Optimization Options

+
+ + + + + + + + + + + + + + + +
+
+ ); +} + +export default OptimizationPanel; diff --git a/src/plays/svg-optimizer/PreviewPanel.jsx b/src/plays/svg-optimizer/PreviewPanel.jsx new file mode 100644 index 000000000..ee3a4eab4 --- /dev/null +++ b/src/plays/svg-optimizer/PreviewPanel.jsx @@ -0,0 +1,73 @@ +import React, { useState } from 'react'; + +function PreviewPanel({ originalSvg, optimizedSvg }) { + const [activePreview, setActivePreview] = useState('optimized'); + + const renderSVG = (svgString) => { + if (!svgString) { + return
No SVG to preview
; + } + + try { + return
; + } catch (error) { + return
Error rendering SVG preview
; + } + }; + + return ( +
+
+

Visual Preview

+
+ + + +
+
+ +
+ {activePreview === 'original' && ( +
{renderSVG(originalSvg)}
+ )} + + {activePreview === 'optimized' && ( +
{renderSVG(optimizedSvg)}
+ )} + + {activePreview === 'comparison' && ( +
+
+

Original

+ {renderSVG(originalSvg)} +
+
+

Optimized

+ {renderSVG(optimizedSvg)} +
+
+ )} +
+
+ ); +} + +export default PreviewPanel; diff --git a/src/plays/svg-optimizer/Readme.md b/src/plays/svg-optimizer/Readme.md new file mode 100644 index 000000000..a96203447 --- /dev/null +++ b/src/plays/svg-optimizer/Readme.md @@ -0,0 +1,78 @@ +# SVG Optimizer + +A powerful React-based SVG optimizer that allows users to paste or upload SVG code and optimize it by removing unnecessary elements while preserving visual output. This tool helps reduce file sizes significantly without compromising quality. + +## Play Demographic + +- Language: js +- Level: Intermediate + +## Creator Information + +- User: Abhrxdip +- Github Link: https://github.com/Abhrxdip +- Blog: +- Video: + +## Implementation Details + +This SVG Optimizer is built using React.js with the following features and concepts: + +### React Concepts Used: +- **Functional Components**: All components are functional components using modern React syntax +- **React Hooks**: + - `useState` for managing component state (SVG input, optimization options, file sizes) + - `useEffect` for automatically optimizing SVG when input or options change +- **Controlled Inputs**: Text areas and checkboxes are fully controlled components +- **Conditional Rendering**: Error messages, preview modes, and button states render conditionally +- **Component Composition**: Reusable components (OptimizationPanel, PreviewPanel) + +### Key Features: +1. **Multiple Input Methods**: + - Paste SVG code directly + - Upload SVG files + - Load sample SVG for testing + +2. **Optimization Options**: + - Remove comments + - Remove metadata (title, desc, metadata tags) + - Remove hidden elements + - Remove empty attributes + - Minify colors (hex shortening, named colors to hex) + - Remove default attribute values + - Optional XMLNS removal + - Code prettification + +3. **Real-time Processing**: + - Automatic optimization on input change + - Live file size calculation + - Percentage reduction display + +4. **Visual Preview**: + - Original SVG preview + - Optimized SVG preview + - Side-by-side comparison view + +5. **Export Options**: + - Copy to clipboard + - Download optimized SVG file + +### Technical Implementation: +- **Client-side Processing**: All optimization happens in the browser with no backend required +- **File API**: Uses FileReader for handling file uploads +- **Blob API**: Creates downloadable files without server interaction +- **Clipboard API**: Enables one-click copying of optimized code +- **Regular Expressions**: Pattern matching for removing unnecessary SVG elements + +## Considerations + +- This is a client-side optimizer and doesn't perform advanced path optimization or vector calculations +- Very complex SVG files with thousands of elements may require additional processing time +- Some optimization options might affect specific SVG features (test thoroughly before use) +- The tool preserves the main visual output but may remove accessibility features (like title/desc tags) if selected + +## Resources + +- [MDN SVG Documentation](https://developer.mozilla.org/en-US/docs/Web/SVG) +- [SVG Optimization Guidelines](https://www.w3.org/TR/SVG11/) +- [SVGO - SVG Optimizer Library](https://github.com/svg/svgo) (for reference) diff --git a/src/plays/svg-optimizer/SVGOptimizer.jsx b/src/plays/svg-optimizer/SVGOptimizer.jsx new file mode 100644 index 000000000..ac53ccd6e --- /dev/null +++ b/src/plays/svg-optimizer/SVGOptimizer.jsx @@ -0,0 +1,322 @@ +import PlayHeader from 'common/playlists/PlayHeader'; +import './styles.css'; +import { useState, useEffect } from 'react'; +import OptimizationPanel from './OptimizationPanel'; +import PreviewPanel from './PreviewPanel'; + +function SVGOptimizer(props) { + const [svgInput, setSvgInput] = useState(''); + const [optimizedSvg, setOptimizedSvg] = useState(''); + const [originalSize, setOriginalSize] = useState(0); + const [optimizedSize, setOptimizedSize] = useState(0); + const [error, setError] = useState(''); + const [optimizationOptions, setOptimizationOptions] = useState({ + removeComments: true, + removeMetadata: true, + removeHiddenElements: true, + removeEmptyAttributes: true, + minifyColors: true, + removeDefaultAttributes: true, + removeXMLNS: false, + prettify: false + }); + + // Sample SVG for demo + const sampleSVG = ` + + + + + image/svg+xml + + + + Sample SVG Icon + A colorful circle and rectangle + + + + + + +`; + + useEffect(() => { + if (svgInput) { + optimizeSVG(); + } else { + setOptimizedSvg(''); + setOriginalSize(0); + setOptimizedSize(0); + } + }, [svgInput, optimizationOptions]); + + const calculateSize = (str) => { + return new Blob([str]).size; + }; + + const getPercentageReduction = () => { + if (originalSize === 0) return 0; + return (((originalSize - optimizedSize) / originalSize) * 100).toFixed(2); + }; + + const optimizeSVG = () => { + try { + setError(''); + let svg = svgInput.trim(); + + if (!svg) { + setOptimizedSvg(''); + return; + } + + // Check if it's valid SVG + if (!svg.includes(' element'); + return; + } + + setOriginalSize(calculateSize(svg)); + + // Remove XML comments + if (optimizationOptions.removeComments) { + svg = svg.replace(//g, ''); + } + + // Remove metadata tags + if (optimizationOptions.removeMetadata) { + svg = svg.replace(//gi, ''); + svg = svg.replace(//gi, ''); + svg = svg.replace(//gi, ''); + svg = svg.replace(/\s*<\/defs>/gi, ''); + } + + // Remove hidden elements + if (optimizationOptions.removeHiddenElements) { + svg = svg.replace(/]*class=["']hidden["'][^>]*>[\s\S]*?<\/g>/gi, ''); + svg = svg.replace( + /<[^>]+(?:display\s*:\s*none|visibility\s*:\s*hidden)[^>]*>[\s\S]*?<\/[^>]+>/gi, + '' + ); + } + + // Remove empty attributes + if (optimizationOptions.removeEmptyAttributes) { + svg = svg.replace(/\s+[a-zA-Z-]+=""\s*/g, ' '); + } + + // Minify colors + if (optimizationOptions.minifyColors) { + // Convert named colors to hex + const colorMap = { + red: '#f00', + blue: '#00f', + green: '#0f0', + white: '#fff', + black: '#000' + }; + Object.keys(colorMap).forEach((colorName) => { + const regex = new RegExp(`(fill|stroke)="${colorName}"`, 'gi'); + svg = svg.replace(regex, `$1="${colorMap[colorName]}"`); + }); + + // Shorten hex colors where possible (#AABBCC -> #ABC) + svg = svg.replace(/#([0-9a-f])\1([0-9a-f])\2([0-9a-f])\3/gi, '#$1$2$3'); + } + + // Remove default attribute values + if (optimizationOptions.removeDefaultAttributes) { + svg = svg.replace(/\s+opacity="1(\.0)?"/g, ''); + svg = svg.replace(/\s+fill-opacity="1(\.0)?"/g, ''); + svg = svg.replace(/\s+stroke-opacity="1(\.0)?"/g, ''); + } + + // Remove xmlns if specified + if (optimizationOptions.removeXMLNS) { + svg = svg.replace(/\s+xmlns(:[a-zA-Z]+)?="[^"]*"/g, ''); + } + + // Clean up extra whitespace + if (!optimizationOptions.prettify) { + svg = svg.replace(/>\s+<'); + svg = svg.replace(/\s{2,}/g, ' '); + svg = svg.trim(); + } else { + // Simple prettify + svg = svg.replace(/>\n<'); + const lines = svg.split('\n'); + let indentLevel = 0; + svg = lines + .map((line) => { + line = line.trim(); + if (line.startsWith('')) { + if (!line.match(/<[^>]+>.*<\/[^>]+>/)) { + indentLevel++; + } + } + return indented; + }) + .join('\n'); + } + + setOptimizedSvg(svg); + setOptimizedSize(calculateSize(svg)); + } catch (err) { + setError(`Error optimizing SVG: ${err.message}`); + } + }; + + const handleFileUpload = (e) => { + const file = e.target.files[0]; + if (file) { + if (file.type !== 'image/svg+xml' && !file.name.endsWith('.svg')) { + setError('Please upload a valid SVG file'); + return; + } + const reader = new FileReader(); + reader.onload = (event) => { + setSvgInput(event.target.result); + }; + reader.onerror = () => { + setError('Error reading file'); + }; + reader.readAsText(file); + } + }; + + const handleDownload = () => { + if (!optimizedSvg) return; + const blob = new Blob([optimizedSvg], { type: 'image/svg+xml' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'optimized.svg'; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + }; + + const handleCopy = async () => { + if (!optimizedSvg) return; + try { + await navigator.clipboard.writeText(optimizedSvg); + alert('Copied to clipboard!'); + } catch (err) { + setError('Failed to copy to clipboard'); + } + }; + + const loadSample = () => { + setSvgInput(sampleSVG); + }; + + const clearAll = () => { + setSvgInput(''); + setOptimizedSvg(''); + setError(''); + }; + + return ( + <> +
+ +
+
+
+

SVG Optimizer

+

Paste or upload your SVG code to optimize and reduce file size

+
+ + {error &&
{error}
} + +
+ + + +
+ + + +
+
+
+

Input SVG

+ {originalSize} bytes +
+