File size: 11,362 Bytes
a47ec1e da47a90 a47ec1e 79c663f a47ec1e da47a90 a47ec1e da47a90 a47ec1e da47a90 a47ec1e da47a90 a47ec1e da47a90 a47ec1e 79c663f a47ec1e 79c663f a47ec1e | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 | 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<string | null>(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<any>('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<string>('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<typeof matchesShortcut>[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 <div className="w-screen h-screen relative overflow-hidden bg-[var(--panel-bg)]" onContextMenu={handleCtxMenu}><StarterHub /><Suspense fallback={null}>{isSettingsOpen && <SettingsPanel />}</Suspense></div>;
return <div className="w-screen h-screen relative overflow-hidden bg-[var(--canvas-bg)]" style={{ opacity: isAlwaysOnTop ? bgOpacity / 100 : 1 }} onContextMenu={handleCtxMenu}>
<Canvas />
{(isBrowserOpen || isLibraryOpen || isSettingsOpen) && <div className="absolute inset-0 bg-black/40 pointer-events-none z-30 transition-opacity" />}
<Toolbar />
<Suspense fallback={null}>{isBrowserOpen && <BrowserPanel />}{isLibraryOpen && <LibraryPanel />}{isSettingsOpen && <SettingsPanel />}</Suspense>
{showMinimap && <Minimap />}
<ContextMenu />
<div className="absolute bottom-4 left-4 text-[11px] opacity-40 text-[var(--ui-primary)] pointer-events-none select-none z-10 mix-blend-difference">Space+Drag Pan · Scroll Zoom · B Browser · L Library · A Annotate · Shift+S Screenshot</div>
{toastMsg && <div className="absolute top-16 left-1/2 -translate-x-1/2 z-50 pointer-events-none"><div className="bg-[var(--panel-bg)]/90 border border-white/10 text-[var(--ui-primary)] font-medium px-4 py-2 rounded-full shadow-2xl backdrop-blur text-xs">{toastMsg}</div></div>}
{globalDesaturate && <div className="absolute top-16 left-1/2 -translate-x-1/2 z-50 pointer-events-none"><div className="bg-[var(--accent-amber)]/20 border border-[var(--accent-amber)] text-[var(--ui-primary)] px-3 py-1 rounded text-xs shadow-lg backdrop-blur">Desaturation Active (Shift+D)</div></div>}
{focusedImageId && <div className="absolute top-4 left-1/2 -translate-x-1/2 z-50 pointer-events-none"><div className="bg-[var(--accent)]/20 border border-[var(--accent)] text-[var(--ui-primary)] px-3 py-1 rounded text-xs shadow-lg backdrop-blur">Focus Mode — ←→ cycle, F/Esc exit</div></div>}
{isCapturing && <div className="absolute inset-0 bg-black/50 flex items-center justify-center z-[100] pointer-events-none"><div className="bg-[var(--panel-surface)] border border-[var(--panel-border)] rounded-xl px-6 py-4 shadow-2xl text-[var(--ui-primary)] text-sm">Capturing...</div></div>}
</div>;
};
export default function App() { return <AppProvider><MainApp /></AppProvider>; }
|