function NoteCanvas({ note, token, settings, onBack }) { const [contentBlocks, setContentBlocks] = useState(() => { // Initial layout for blocks without position const blocks = note.content_blocks || []; const allZero = blocks.every(b => !b.x_pos && !b.y_pos); if (allZero && blocks.length > 0) { return blocks.map((b, i) => ({ ...b, x_pos: 50, y_pos: 50 + (i * 250), // Standard height + gap width: b.width || 400 })); } return blocks; }); const [draggedBlock, setDraggedBlock] = useState(null); const [expandedBlockId, setExpandedBlockId] = useState(null); const [expandedBlockPosition, setExpandedBlockPosition] = useState(null); const [extractionStatus, setExtractionStatus] = useState(note.note.extraction_completed ? 'completed' : 'pending'); const [loading, setLoading] = useState(false); const [showExtractModal, setShowExtractModal] = useState(false); const [showTextInput, setShowTextInput] = useState(false); const [newTextContent, setNewTextContent] = useState(''); const [showAudioModal, setShowAudioModal] = useState(false); const [audioMode, setAudioMode] = useState(null); // 'upload' or 'record' const [viewMode, setViewMode] = useState('canvas'); // 'canvas' or 'document' const [highlightedBlockId, setHighlightedBlockId] = useState(null); // Zoom and Pan state const [zoom, setZoom] = useState(1); const [panOffset, setPanOffset] = useState({ x: 0, y: 0 }); const [isPanning, setIsPanning] = useState(false); const [panStart, setPanStart] = useState(null); // Canvas Drag Logic const handleMouseDown = (e, block) => { if (viewMode !== 'canvas') return; // prevent drag if clicking buttons inputs or links if (['BUTTON', 'INPUT', 'TEXTAREA', 'A'].includes(e.target.tagName)) return; e.preventDefault(); setDraggedBlock({ id: block.id, startX: e.clientX, startY: e.clientY, initialX: block.x_pos || 0, initialY: block.y_pos || 0 }); }; const handleMouseMove = (e) => { if (!draggedBlock) return; const dx = e.clientX - draggedBlock.startX; const dy = e.clientY - draggedBlock.startY; setContentBlocks(blocks => blocks.map(b => { if (b.id === draggedBlock.id) { return { ...b, x_pos: draggedBlock.initialX + dx, y_pos: draggedBlock.initialY + dy }; } return b; })); }; const handleMouseUp = async () => { if (!draggedBlock) return; const block = contentBlocks.find(b => b.id === draggedBlock.id); if (block) { try { await fetch(`${API_URL}/notes/${note.note.id}/content/${block.id}/position`, { method: 'PATCH', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ x: block.x_pos, y: block.y_pos }) }); } catch (err) { console.error('Failed to save position', err); } } setDraggedBlock(null); }; // Zoom and Pan handlers const handleZoomIn = () => setZoom(z => Math.min(2, z + 0.1)); const handleZoomOut = () => setZoom(z => Math.max(0.5, z - 0.1)); const handleZoomReset = () => { setZoom(1); setPanOffset({ x: 0, y: 0 }); }; const handleWheel = (e) => { if (e.ctrlKey || e.metaKey) { e.preventDefault(); const delta = e.deltaY * -0.001; setZoom(z => Math.min(2, Math.max(0.5, z + delta))); } }; const handlePanStart = (e) => { if (e.button === 1 || e.shiftKey || isPanning) { // Middle mouse or Shift+drag setIsPanning(true); setPanStart({ x: e.clientX - panOffset.x, y: e.clientY - panOffset.y }); e.preventDefault(); } }; const handlePanMove = (e) => { if (isPanning && panStart) { setPanOffset({ x: e.clientX - panStart.x, y: e.clientY - panStart.y }); } }; const handlePanEnd = () => { setIsPanning(false); setPanStart(null); }; // Keyboard shortcuts React.useEffect(() => { const handleKeyDown = (e) => { if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return; if (e.key === '+' || e.key === '=') handleZoomIn(); if (e.key === '-' || e.key === '_') handleZoomOut(); if (e.key === '0') handleZoomReset(); }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, []); const addTextBlock = () => { setShowTextInput(true); }; const saveTextBlock = async () => { if (!newTextContent.trim()) { setShowTextInput(false); setNewTextContent(''); return; } try { // Smart positioning: find empty spot const BLOCK_WIDTH = 350, BLOCK_HEIGHT = 200, SPACING = 30, START_X = 50, START_Y = 50, GRID_COLS = 3; let newX = START_X, newY = START_Y; // Try grid positions outerLoop: for (let row = 0; row < 10; row++) { for (let col = 0; col < GRID_COLS; col++) { const testX = START_X + (col * (BLOCK_WIDTH + SPACING)); const testY = START_Y + (row * (BLOCK_HEIGHT + SPACING)); const hasOverlap = contentBlocks.some(b => Math.abs(testX - (b.x_pos || 0)) < (BLOCK_WIDTH - 50) && Math.abs(testY - (b.y_pos || 0)) < (BLOCK_HEIGHT - 50)); if (!hasOverlap) { newX = testX; newY = testY; break outerLoop; } } } const response = await fetch(`${API_URL}/notes/${note.note.id}/content/text`, { method: 'POST', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ content_type: 'text', content_data: { text: newTextContent }, position: contentBlocks.length, x: newX, y: newY }) }); if (response.ok) { const data = await response.json(); setContentBlocks([...contentBlocks, { id: data.block_id, content_type: 'text', content_data: { text: newTextContent }, x_pos: newX, y_pos: newY }]); setNewTextContent(''); setShowTextInput(false); } } catch (err) { alert('Failed to add text block'); } }; const addFileBlock = async (file) => { const formData = new FormData(); formData.append('file', file); formData.append('position', contentBlocks.length); // Smart positioning const BLOCK_WIDTH = 350, BLOCK_HEIGHT = 200, SPACING = 30, START_X = 50, START_Y = 50, GRID_COLS = 3; let newX = START_X, newY = START_Y; outerLoop: for (let row = 0; row < 10; row++) { for (let col = 0; col < GRID_COLS; col++) { const testX = START_X + (col * (BLOCK_WIDTH + SPACING)); const testY = START_Y + (row * (BLOCK_HEIGHT + SPACING)); const hasOverlap = contentBlocks.some(b => Math.abs(testX - (b.x_pos || 0)) < (BLOCK_WIDTH - 50) && Math.abs(testY - (b.y_pos || 0)) < (BLOCK_HEIGHT - 50)); if (!hasOverlap) { newX = testX; newY = testY; break outerLoop; } } } try { setLoading(true); const response = await fetch(`${API_URL}/notes/${note.note.id}/content/file`, { method: 'POST', headers: { 'Authorization': `Bearer ${token}` }, body: formData }); if (response.ok) { const data = await response.json(); setContentBlocks([...contentBlocks, { id: data.block_id, content_type: file.type.startsWith('image/') ? 'image' : file.type === 'application/pdf' ? 'pdf' : 'audio', filename: data.filename, file_id: data.file_id, file_path: data.file_path, x_pos: newX, y_pos: newY }]); // Immediately save position await fetch(`${API_URL}/notes/${note.note.id}/content/${data.block_id}/position`, { method: 'PATCH', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ x: newX, y: newY }) }); } } catch (err) { alert('Failed to upload file'); } finally { setLoading(false); } }; const addLinkBlock = async () => { const url = prompt('Enter URL:'); if (!url) return; try { // Smart positioning const BLOCK_WIDTH = 350, BLOCK_HEIGHT = 200, SPACING = 30, START_X = 50, START_Y = 50, GRID_COLS = 3; let newX = START_X, newY = START_Y; outerLoop: for (let row = 0; row < 10; row++) { for (let col = 0; col < GRID_COLS; col++) { const testX = START_X + (col * (BLOCK_WIDTH + SPACING)); const testY = START_Y + (row * (BLOCK_HEIGHT + SPACING)); const hasOverlap = contentBlocks.some(b => Math.abs(testX - (b.x_pos || 0)) < (BLOCK_WIDTH - 50) && Math.abs(testY - (b.y_pos || 0)) < (BLOCK_HEIGHT - 50)); if (!hasOverlap) { newX = testX; newY = testY; break outerLoop; } } } const formData = new FormData(); formData.append('url', url); formData.append('position', contentBlocks.length); const response = await fetch(`${API_URL}/notes/${note.note.id}/content/link`, { method: 'POST', headers: { 'Authorization': `Bearer ${token}` }, body: formData }); if (response.ok) { const data = await response.json(); setContentBlocks([...contentBlocks, { id: data.block_id, content_type: 'link', content_data: { url }, x_pos: newX, y_pos: newY }]); await fetch(`${API_URL}/notes/${note.note.id}/content/${data.block_id}/position`, { method: 'PATCH', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ x: newX, y: newY }) }); } } catch (err) { alert('Failed to add link'); } }; const deleteBlock = async (blockId) => { if (!confirm('Delete this block?')) return; try { const response = await fetch(`${API_URL}/notes/${note.note.id}/content/${blockId}`, { method: 'DELETE', headers: { 'Authorization': `Bearer ${token}` } }); if (response.ok) { setContentBlocks(contentBlocks.filter(b => b.id !== blockId)); } } catch (err) { alert('Failed to delete block'); } }; const extractContent = async (extractNow) => { try { setLoading(true); setShowExtractModal(false); const response = await fetch(`${API_URL}/notes/${note.note.id}/extract`, { method: 'POST', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ extract_now: extractNow, force: false }) }); const data = await response.json(); if (extractNow) { setExtractionStatus('processing'); const interval = setInterval(async () => { const statusRes = await fetch(`${API_URL}/notes/${note.note.id}/extraction-status`, { headers: { 'Authorization': `Bearer ${token}` } }); const statusData = await statusRes.json(); if (statusData.status === 'completed') { setExtractionStatus('completed'); clearInterval(interval); alert('Extraction completed!'); } else if (statusData.status === 'failed') { setExtractionStatus('failed'); clearInterval(interval); alert('Extraction failed'); } }, 2000); } else { alert('Content will be extracted later'); } } catch (err) { alert('Failed to start extraction'); } finally { setLoading(false); } }; const handleViewInCanvas = (blockId) => { setViewMode('canvas'); setHighlightedBlockId(blockId); setTimeout(() => { const element = document.getElementById(`block-${blockId}`); if (element) { // Smooth scroll manually because position absolute element.scrollIntoView({ behavior: 'smooth', block: 'center' }); } setTimeout(() => setHighlightedBlockId(null), 2000); }, 100); }; return (
Supported: MP3, WAV, M4A, OGG, FLAC, AAC, WMA (Max: 15MB)
AudioRecorder component not loaded. Please refresh the page.
Empty canvas
Add text, upload files, or add links to start building your note
AI will extract text from images, transcribe audio, and summarize all content.
Note: Original files will be deleted after extraction (storage is disabled)
}