import { useEffect, useState, useRef, lazy, Suspense } from 'react'; import { AppProvider, useAppStore } from './store'; import { Canvas } from './components/Canvas'; import { Toolbar } from './components/Toolbar'; import { ContextMenu } from './components/ContextMenu'; import { Minimap } from './components/Minimap'; import { StarterHub } from './components/StarterHub'; import { getCurrentWindow } from '@tauri-apps/api/window'; import { invoke } from '@tauri-apps/api/core'; import { listen } from '@tauri-apps/api/event'; import { isEditableTarget, loadShortcutSettings, matchesShortcut } from './shortcutSystem'; const BrowserPanel = lazy(() => import('./components/BrowserPanel').then(m => ({ default: m.BrowserPanel }))); const LibraryPanel = lazy(() => import('./components/LibraryPanel').then(m => ({ default: m.LibraryPanel }))); const SettingsPanel = lazy(() => import('./components/SettingsPanel').then(m => ({ default: m.SettingsPanel }))); const appWindow = getCurrentWindow(); const MainApp = () => { const { currentScreen, setIsBrowserOpen, setIsLibraryOpen, isBrowserOpen, isLibraryOpen, isSettingsOpen, setIsSettingsOpen, isAlwaysOnTop, setIsAlwaysOnTop, bgOpacity, isAnnotationMode, setIsAnnotationMode, globalDesaturate, setGlobalDesaturate, setZoom, setPan, pan, zoom, images, setImages, setTextNotes, selectedNodeIds, setSelectedNodeIds, annotationColor, setAnnotationColor, annotationSize, setAnnotationSize, isEraser, setIsEraser, isHighlighter, setIsHighlighter, undo, redo, focusedImageId, setFocusedImageId, valueMirrorIds, setValueMirrorIds, isZoomLensActive, setIsZoomLensActive, isWhisperBrowser, setIsWhisperBrowser, showMinimap } = useAppStore(); const [toastMsg, setToastMsg] = useState(null); const [isCapturing, setIsCapturing] = useState(false); const lastBPress = useRef(0); useEffect(() => { if (toastMsg) { const t = setTimeout(() => setToastMsg(null), 3000); return () => clearTimeout(t); } }, [toastMsg]); useEffect(() => { appWindow.setAlwaysOnTop(isAlwaysOnTop).catch(() => {}); }, [isAlwaysOnTop]); useEffect(() => { const unlisten = listen('board://image_added', (event) => { const data = event.payload; if (!data || !data.url) return; const img = new Image(); const add = (w0:number,h0:number) => { const ratio = w0 / h0; const w = Math.min(400, w0); const h = w / ratio; setImages(prev => [...prev, { id: data.id || crypto.randomUUID(), url: data.url, x: (-pan.x + window.innerWidth / 3 + Math.random() * 80) / zoom, y: (-pan.y + window.innerHeight / 3 + Math.random() * 80) / zoom, width: w, height: h, aspectRatio: ratio }]); setToastMsg('✓ Image added'); }; img.onload = () => add(img.width,img.height); img.onerror = () => add(data.width || 300, data.height || 200); img.src = data.url; }); return () => { unlisten.then(fn => fn()); }; }, [pan, zoom, setImages]); const doScreenCapture = async () => { setIsCapturing(true); setToastMsg('Capturing...'); try { await appWindow.minimize(); await new Promise(r => setTimeout(r, 300)); const dataUrl = await invoke('screen_capture_full'); await appWindow.unminimize(); await appWindow.setFocus(); if (dataUrl) { const img = new Image(); img.onload = () => { const ratio = img.width / img.height; const w = Math.min(800, img.width); const h = w / ratio; setImages(prev => [...prev, { id: crypto.randomUUID(), url: dataUrl, x: (-pan.x + window.innerWidth/2 - w/2)/zoom, y: (-pan.y + window.innerHeight/2 - h/2)/zoom, width: w, height: h, aspectRatio: ratio }]); setToastMsg('Screenshot captured!'); }; img.src = dataUrl; } } catch { setToastMsg('Capture failed'); await appWindow.unminimize().catch(() => {}); } setIsCapturing(false); }; useEffect(() => { const onKeyDown = (e: KeyboardEvent) => { if (isEditableTarget(e.target)) return; const settings = loadShortcutSettings(); const run = (id: Parameters[0]) => matchesShortcut(id, e, settings); if (run('edit.undo')) { e.preventDefault(); undo(); return; } if (run('edit.redo')) { e.preventDefault(); redo(); return; } if (run('file.save')) { e.preventDefault(); setToastMsg('Saved'); return; } if (run('capture.screen')) { e.preventDefault(); doScreenCapture(); return; } if (run('view.focus')) { if (focusedImageId) setFocusedImageId(null); else if (selectedNodeIds.length > 0) setFocusedImageId(selectedNodeIds[0]); return; } if (run('view.valueMirror') && selectedNodeIds.length > 0) { setValueMirrorIds(prev => { const all = selectedNodeIds.every(id => prev.includes(id)); return all ? prev.filter(id => !selectedNodeIds.includes(id)) : [...new Set([...prev, ...selectedNodeIds])]; }); return; } if (run('selection.flipH') && selectedNodeIds.length > 0) { setImages(prev => prev.map(i => selectedNodeIds.includes(i.id) ? { ...i, isFlippedH: !i.isFlippedH } : i)); return; } if (run('view.zoomLens')) { setIsZoomLensActive(true); return; } if (run('selection.duplicate')) { e.preventDefault(); if (selectedNodeIds.length > 0) { const dupes = images.filter(i => selectedNodeIds.includes(i.id)).map(i => ({ ...i, id: crypto.randomUUID(), x: i.x + 30, y: i.y + 30 })); setImages(prev => [...prev, ...dupes]); setSelectedNodeIds(dupes.map(d => d.id)); setToastMsg(`Duplicated ${dupes.length}`); } return; } if (run('selection.group')) { e.preventDefault(); if (selectedNodeIds.length > 1) { const gid = crypto.randomUUID(); setImages(prev => prev.map(i => selectedNodeIds.includes(i.id) ? { ...i, groupId: gid } : i)); setTextNotes(prev => prev.map(n => selectedNodeIds.includes(n.id) ? { ...n, groupId: gid } : n)); setToastMsg('Grouped'); } return; } if (run('selection.ungroup')) { e.preventDefault(); setImages(prev => prev.map(i => selectedNodeIds.includes(i.id) ? { ...i, groupId: undefined } : i)); setTextNotes(prev => prev.map(n => selectedNodeIds.includes(n.id) ? { ...n, groupId: undefined } : n)); setToastMsg('Ungrouped'); return; } if (focusedImageId && (e.key === 'ArrowLeft' || e.key === 'ArrowRight')) { const idx = images.findIndex(i => i.id === focusedImageId); if (idx >= 0) { const next = e.key === 'ArrowRight' ? (idx + 1) % images.length : (idx - 1 + images.length) % images.length; setFocusedImageId(images[next].id); setSelectedNodeIds([images[next].id]); } return; } if (run('panel.browser')) { const now = Date.now(); if (now - lastBPress.current < 300) { setIsWhisperBrowser(true); setIsBrowserOpen(true); } else { setIsBrowserOpen(p => !p); setIsWhisperBrowser(false); } lastBPress.current = now; return; } if (run('panel.library')) { setIsLibraryOpen(p => !p); return; } if (run('panel.settings')) { e.preventDefault(); setIsSettingsOpen(p => !p); return; } if (run('tool.annotate')) { setIsAnnotationMode(p => !p); return; } if (run('tool.globalDesaturate')) { setGlobalDesaturate(p => !p); return; } if (run('selection.desaturate')) { if (selectedNodeIds.length > 0) setImages(prev => prev.map(i => selectedNodeIds.includes(i.id) ? { ...i, isDesaturated: !i.isDesaturated } : i)); return; } if (run('window.alwaysOnTop')) { setIsAlwaysOnTop(p => !p); return; } if (run('panel.closeAll')) { setIsBrowserOpen(false); setIsLibraryOpen(false); setIsSettingsOpen(false); setSelectedNodeIds([]); setFocusedImageId(null); return; } if (run('selection.delete')) { if (selectedNodeIds.length > 0) { setImages(prev => prev.filter(i => !selectedNodeIds.includes(i.id))); setTextNotes(prev => prev.filter(n => !selectedNodeIds.includes(n.id))); setSelectedNodeIds([]); } return; } if (run('selection.selectAll')) { e.preventDefault(); setSelectedNodeIds(images.map(i => i.id)); return; } if (run('view.fitAll')) { e.preventDefault(); if (images.length === 0) { setZoom(1); setPan({ x: 0, y: 0 }); return; } let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; images.forEach(i => { minX = Math.min(minX, i.x); minY = Math.min(minY, i.y); maxX = Math.max(maxX, i.x + i.width); maxY = Math.max(maxY, i.y + i.height); }); const pad = 100, w = maxX - minX + pad*2, h = maxY - minY + pad*2; const nz = Math.max(0.1, Math.min(window.innerWidth/w, window.innerHeight/h)); setZoom(nz); setPan({ x: -((minX+maxX)/2)*nz + window.innerWidth/2, y: -((minY+maxY)/2)*nz + window.innerHeight/2 }); return; } if (run('view.zoom100')) { e.preventDefault(); setZoom(1); return; } }; const onKeyUp = (e: KeyboardEvent) => { if (matchesShortcut('view.zoomLens', e)) setIsZoomLensActive(false); }; window.addEventListener('keydown', onKeyDown); window.addEventListener('keyup', onKeyUp); return () => { window.removeEventListener('keydown', onKeyDown); window.removeEventListener('keyup', onKeyUp); }; }, [selectedNodeIds, images, pan, zoom, focusedImageId, undo, redo]); const handleCtxMenu = (e: React.MouseEvent) => { if (!(e.target instanceof HTMLInputElement) && !(e.target instanceof HTMLTextAreaElement) && !(e.target as HTMLElement).isContentEditable) e.preventDefault(); }; if (currentScreen === 'hub') return
{isSettingsOpen && }
; return
{(isBrowserOpen || isLibraryOpen || isSettingsOpen) &&
} {isBrowserOpen && }{isLibraryOpen && }{isSettingsOpen && } {showMinimap && }
Space+Drag Pan · Scroll Zoom · B Browser · L Library · A Annotate · Shift+S Screenshot
{toastMsg &&
{toastMsg}
} {globalDesaturate &&
Desaturation Active (Shift+D)
} {focusedImageId &&
Focus Mode — ←→ cycle, F/Esc exit
} {isCapturing &&
Capturing...
}
; }; export default function App() { return ; }