/* @component-map * App — Main container, toolbar, canvas orchestration [app.jsx] * Whiteboard — Canvas drawing surface and element rendering [components/Whiteboard.jsx] * Toolbar — Drawing tools, colors, and actions [components/Toolbar.jsx] * StickyNoteModal — Modal for creating/editing sticky notes [components/StickyNoteModal.jsx] * TextModal — Modal for placing text on the board [components/TextModal.jsx] * PresenceBar — Shows who's currently on the board [components/PresenceBar.jsx] * @end-component-map */ import { useState, useCallback, useEffect, useMemo } from 'react'; import { useBroadcast, useIdentity, useCollection } from '@deplixo/sdk'; import { Whiteboard } from './components/Whiteboard.jsx'; import { Toolbar } from './components/Toolbar.jsx'; import { StickyNoteModal } from './components/StickyNoteModal.jsx'; import { TextModal } from './components/TextModal.jsx'; import { PresenceBar } from './components/PresenceBar.jsx'; function App() { const { user, loading: identityLoading } = useIdentity(); const [tool, setTool] = useState('draw'); const [color, setColor] = useState('#0D47A1'); const [strokeWidth, setStrokeWidth] = useState(3); const [showStickyModal, setShowStickyModal] = useState(false); const [showTextModal, setShowTextModal] = useState(false); const [placementPos, setPlacementPos] = useState(null); const [cursorUsers, setCursorUsers] = useState({}); const [boardName, setBoardName] = useState(''); const [selectedBoardId, setSelectedBoardId] = useState(''); const [boardLoadError, setBoardLoadError] = useState(''); const { items: savedBoards, loading: boardsLoading, add, update } = useCollection('board-states', { personal: true }); const [elements, setElements] = useState([]); const boardExportRef = useRef(null); const normalizeBoardPayload = useCallback((payload) => { if (!payload) return null; return { name: payload.name || 'Untitled board', elements: Array.isArray(payload.elements) ? payload.elements : [], color: payload.color || '#0D47A1', strokeWidth: typeof payload.strokeWidth === 'number' ? payload.strokeWidth : 3, updatedAt: payload.updatedAt || Date.now(), createdAt: payload.createdAt || Date.now(), ownerId: payload.ownerId || user?.id || null, }; }, [user?.id]); const handleCursorMove = useCallback((cursorData, authorId) => { if (!cursorData || !authorId) return; setCursorUsers((prev) => ({ ...prev, [authorId]: { id: authorId, x: typeof cursorData.x === 'number' ? cursorData.x : 0, y: typeof cursorData.y === 'number' ? cursorData.y : 0, name: cursorData.name || 'Guest', avatar: cursorData.avatar || null, updatedAt: Date.now(), }, })); }, []); const { send: sendCursor } = useBroadcast('board-cursor', handleCursorMove); const handleCanvasClick = useCallback((pos) => { if (tool === 'sticky') { setPlacementPos(pos); setShowStickyModal(true); } else if (tool === 'text') { setPlacementPos(pos); setShowTextModal(true); } }, [tool]); const handleCanvasMove = useCallback((pos) => { if (!user || !pos) return; sendCursor({ x: pos.x, y: pos.y, name: user.name || 'Guest', avatar: user.avatar || null, }); setCursorUsers((prev) => ({ ...prev, [user.id]: { id: user.id, x: pos.x, y: pos.y, name: user.name || 'Guest', avatar: user.avatar || null, updatedAt: Date.now(), }, })); }, [sendCursor, user]); const handleExportPNG = useCallback(async () => { const root = boardExportRef.current; if (!root) return; const downloadDataUrl = (dataUrl, filename) => { const link = document.createElement('a'); link.href = dataUrl; link.download = filename; document.body.appendChild(link); link.click(); document.body.removeChild(link); }; const createCanvasFromNode = async (node) => { const rect = node.getBoundingClientRect(); const canvas = document.createElement('canvas'); const scale = window.devicePixelRatio || 1; canvas.width = Math.max(1, Math.round(rect.width * scale)); canvas.height = Math.max(1, Math.round(rect.height * scale)); const context = canvas.getContext('2d'); if (!context) return null; context.scale(scale, scale); context.fillStyle = '#ffffff'; context.fillRect(0, 0, rect.width, rect.height); const svgToDataUrl = (svg) => `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; const clone = node.cloneNode(true); clone.querySelectorAll('canvas').forEach((canvasEl) => { try { const replacement = document.createElement('img'); replacement.src = canvasEl.toDataURL(); replacement.width = canvasEl.width; replacement.height = canvasEl.height; canvasEl.parentNode?.replaceChild(replacement, canvasEl); } catch (e) { /* ignore canvas serialization issues */ } }); const serializer = new XMLSerializer(); const svg = `
${serializer.serializeToString(clone)}
`; return new Promise((resolve) => { const img = new Image(); img.onload = () => { context.drawImage(img, 0, 0, rect.width, rect.height); resolve(canvas.toDataURL('image/png')); }; img.onerror = () => resolve(null); img.src = svgToDataUrl(svg); }); }; const png = await createCanvasFromNode(root); if (png) downloadDataUrl(png, `${(boardName || 'board').trim().replace(/\s+/g, '_') || 'board'}.png`); }, [boardName]); useEffect(() => { if (!savedBoards?.length) return; if (selectedBoardId) return; const first = savedBoards[0]; if (first?.id) setSelectedBoardId(first.id); }, [savedBoards, selectedBoardId]); const selectedBoard = useMemo(() => { return savedBoards?.find((board) => board.id === selectedBoardId) || null; }, [savedBoards, selectedBoardId]); useEffect(() => { const normalized = normalizeBoardPayload(selectedBoard?.data || selectedBoard); if (!normalized) return; setBoardName(normalized.name || ''); setElements(normalized.elements || []); if (normalized.color) setColor(normalized.color); if (typeof normalized.strokeWidth === 'number') setStrokeWidth(normalized.strokeWidth); }, [selectedBoard, normalizeBoardPayload]); const handleSaveBoard = useCallback(async () => { setBoardLoadError(''); const name = boardName.trim(); if (!name) { setBoardLoadError('Please enter a board name before saving.'); return; } const payload = { name, elements, color, strokeWidth, updatedAt: Date.now(), ownerId: user?.id || null, }; try { if (selectedBoard?.id) { await update(selectedBoard.id, { data: payload }); } else { const created = await add({ name, data: payload, }); if (created?.id) setSelectedBoardId(created.id); } } catch (error) { setBoardLoadError('Unable to save board right now. Please try again.'); } }, [add, boardName, color, elements, selectedBoard?.id, strokeWidth, update, user?.id]); const handleLoadBoard = useCallback((boardId) => { setBoardLoadError(''); const board = savedBoards?.find((item) => item.id === boardId); const normalized = normalizeBoardPayload(board?.data || board); if (!normalized) { setBoardLoadError('Could not load that board.'); return; } setSelectedBoardId(boardId); setBoardName(normalized.name || 'Untitled board'); setElements(normalized.elements || []); if (normalized.color) setColor(normalized.color); if (typeof normalized.strokeWidth === 'number') setStrokeWidth(normalized.strokeWidth); }, [normalizeBoardPayload, savedBoards]); if (identityLoading) { return (

Preparing your whiteboard…

); } return (
setBoardName(e.target.value)} placeholder="Enter a board name" />
{boardLoadError &&
{boardLoadError}
}
{showStickyModal && ( { setShowStickyModal(false); setTool('draw'); }} user={user} /> )} {showTextModal && ( { setShowTextModal(false); setTool('draw'); }} user={user} /> )}
); } ReactDOM.createRoot(document.getElementById("root")).render();