asdf98 commited on
Commit
a47ec1e
·
verified ·
1 Parent(s): 74d13f2

fix: render minimap only when enabled in Settings; default off

Browse files
Files changed (1) hide show
  1. src/App.tsx +108 -137
src/App.tsx CHANGED
@@ -1,137 +1,108 @@
1
- import { useEffect, useState, useRef, lazy, Suspense } from 'react';
2
- import { AppProvider, useAppStore } from './store';
3
- import { Canvas } from './components/Canvas';
4
- import { Toolbar } from './components/Toolbar';
5
- import { ContextMenu } from './components/ContextMenu';
6
- import { Minimap } from './components/Minimap';
7
- import { StarterHub } from './components/StarterHub';
8
- import { getCurrentWindow } from '@tauri-apps/api/window';
9
- import { invoke } from '@tauri-apps/api/core';
10
- import { listen } from '@tauri-apps/api/event';
11
-
12
- // Lazy-load heavy panels only mount when user opens them
13
- const BrowserPanel = lazy(() => import('./components/BrowserPanel').then(m => ({ default: m.BrowserPanel })));
14
- const LibraryPanel = lazy(() => import('./components/LibraryPanel').then(m => ({ default: m.LibraryPanel })));
15
- const SettingsPanel = lazy(() => import('./components/SettingsPanel').then(m => ({ default: m.SettingsPanel })));
16
-
17
- const appWindow = getCurrentWindow();
18
-
19
- const MainApp = () => {
20
- const {
21
- currentScreen, setIsBrowserOpen, setIsLibraryOpen, isBrowserOpen, isLibraryOpen,
22
- isSettingsOpen, setIsSettingsOpen, isAlwaysOnTop, setIsAlwaysOnTop, bgOpacity,
23
- isAnnotationMode, setIsAnnotationMode, globalDesaturate, setGlobalDesaturate,
24
- setZoom, setPan, pan, zoom, images, setImages, setTextNotes, selectedNodeIds, setSelectedNodeIds,
25
- annotationColor, setAnnotationColor, annotationSize, setAnnotationSize,
26
- isEraser, setIsEraser, isHighlighter, setIsHighlighter, undo, redo,
27
- focusedImageId, setFocusedImageId, valueMirrorIds, setValueMirrorIds,
28
- isZoomLensActive, setIsZoomLensActive, isWhisperBrowser, setIsWhisperBrowser,
29
- } = useAppStore();
30
- const [toastMsg, setToastMsg] = useState<string | null>(null);
31
- const [isCapturing, setIsCapturing] = useState(false);
32
- const lastBPress = useRef(0);
33
-
34
- useEffect(() => { if (toastMsg) { const t = setTimeout(() => setToastMsg(null), 3000); return () => clearTimeout(t); } }, [toastMsg]);
35
- useEffect(() => { appWindow.setAlwaysOnTop(isAlwaysOnTop).catch(() => {}); }, [isAlwaysOnTop]);
36
-
37
- // Listen for images captured from hover overlay
38
- useEffect(() => {
39
- const unlisten = listen<any>('board://image_added', (event) => {
40
- const data = event.payload;
41
- if (!data || !data.url) return;
42
- const img = new Image();
43
- img.onload = () => { const ratio = img.width / img.height; const w = Math.min(400, img.width); 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'); };
44
- img.onerror = () => { const w = Math.min(400, data.width || 300); const h = data.height ? w * (data.height / data.width) : w; 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: w / h }]); setToastMsg('✓ Image added'); };
45
- img.src = data.url;
46
- });
47
- return () => { unlisten.then(fn => fn()); };
48
- }, [pan, zoom, setImages]);
49
-
50
- // Screen capture
51
- const doScreenCapture = async () => {
52
- setIsCapturing(true); setToastMsg('Capturing...');
53
- 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(() => {}); }
54
- setIsCapturing(false);
55
- };
56
-
57
- // Keyboard shortcuts
58
- useEffect(() => {
59
- const onKeyDown = (e: KeyboardEvent) => {
60
- if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement || (e.target as HTMLElement)?.isContentEditable) return;
61
- const key = e.key.toLowerCase(); const ctrl = e.ctrlKey || e.metaKey;
62
- if (ctrl && key === 'z') { e.preventDefault(); if (e.shiftKey) redo(); else undo(); return; }
63
- if (ctrl && key === 's') { e.preventDefault(); setToastMsg('Saved'); return; }
64
- if (e.shiftKey && key === 's' && !ctrl) { e.preventDefault(); doScreenCapture(); return; }
65
- if (key === 'f' && !ctrl && !e.shiftKey) { if (focusedImageId) setFocusedImageId(null); else if (selectedNodeIds.length > 0) setFocusedImageId(selectedNodeIds[0]); return; }
66
- if (key === 'v' && !ctrl && !e.shiftKey && 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; }
67
- if (key === 'h' && !ctrl && !e.shiftKey && selectedNodeIds.length > 0) { setImages(prev => prev.map(i => selectedNodeIds.includes(i.id) ? { ...i, isFlippedH: !i.isFlippedH } : i)); return; }
68
- if (key === 'z' && !ctrl && !e.shiftKey) { setIsZoomLensActive(true); return; }
69
- if (ctrl && key === 'd') { 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; }
70
- if (ctrl && key === 'g') { e.preventDefault(); if (e.shiftKey) { 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'); } else 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; }
71
- 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; }
72
- if (key === 'b' && !ctrl && !e.shiftKey) { const now = Date.now(); if (now - lastBPress.current < 300) { setIsWhisperBrowser(true); setIsBrowserOpen(true); } else { setIsBrowserOpen(p => !p); setIsWhisperBrowser(false); } lastBPress.current = now; return; }
73
- if (key === 'l' && !ctrl && !e.shiftKey) { setIsLibraryOpen(p => !p); return; }
74
- if (ctrl && key === ',') { e.preventDefault(); setIsSettingsOpen(p => !p); return; }
75
- if (key === 'a' && !ctrl && !e.shiftKey) { setIsAnnotationMode(p => !p); return; }
76
- if (key === 'g' && !ctrl && !e.shiftKey) { window.dispatchEvent(new CustomEvent('muse:toggle-grid', { detail: true })); return; }
77
- if (key === 'd' && !ctrl) { if (e.shiftKey) setGlobalDesaturate(p => !p); else if (selectedNodeIds.length > 0) setImages(prev => prev.map(i => selectedNodeIds.includes(i.id) ? { ...i, isDesaturated: !i.isDesaturated } : i)); return; }
78
- if (key === 't' && !ctrl && !e.shiftKey) { setIsAlwaysOnTop(p => !p); return; }
79
- if (e.key === 'Escape') { setIsBrowserOpen(false); setIsLibraryOpen(false); setIsSettingsOpen(false); setSelectedNodeIds([]); setFocusedImageId(null); return; }
80
- if (e.key === 'Delete' || e.key === 'Backspace') { if (selectedNodeIds.length > 0) { setImages(prev => prev.filter(i => !selectedNodeIds.includes(i.id))); setTextNotes(prev => prev.filter(n => !selectedNodeIds.includes(n.id))); setSelectedNodeIds([]); } return; }
81
- if (ctrl && key === 'a') { e.preventDefault(); setSelectedNodeIds(images.map(i => i.id)); return; }
82
- if (ctrl && key === '0') { 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 }); }
83
- if (ctrl && key === '1') { e.preventDefault(); setZoom(1); }
84
- };
85
- const onKeyUp = (e: KeyboardEvent) => { if (e.key.toLowerCase() === 'z') setIsZoomLensActive(false); };
86
- window.addEventListener('keydown', onKeyDown); window.addEventListener('keyup', onKeyUp);
87
- return () => { window.removeEventListener('keydown', onKeyDown); window.removeEventListener('keyup', onKeyUp); };
88
- }, [selectedNodeIds, images, pan, zoom, focusedImageId, undo, redo]);
89
-
90
- const handleCtxMenu = (e: React.MouseEvent) => { if (!(e.target instanceof HTMLInputElement) && !(e.target instanceof HTMLTextAreaElement) && !(e.target as HTMLElement).isContentEditable) e.preventDefault(); };
91
-
92
- if (currentScreen === 'hub') return (
93
- <div className="w-screen h-screen relative overflow-hidden bg-[#0A0A0B]" onContextMenu={handleCtxMenu}>
94
- <StarterHub />
95
- <Suspense fallback={null}>{isSettingsOpen && <SettingsPanel />}</Suspense>
96
- </div>
97
- );
98
-
99
- return (
100
- <div className="w-screen h-screen relative overflow-hidden" style={{ backgroundColor: `rgba(28, 28, 30, ${isAlwaysOnTop ? bgOpacity / 100 : 1})` }} onContextMenu={handleCtxMenu}>
101
- <Canvas />
102
- {(isBrowserOpen || isLibraryOpen || isSettingsOpen) && <div className="absolute inset-0 bg-black/40 pointer-events-none z-30 transition-opacity" />}
103
- <Toolbar />
104
- {/* Lazy-mounted panels: only load JS + render DOM when opened */}
105
- <Suspense fallback={null}>
106
- {isBrowserOpen && <BrowserPanel />}
107
- {isLibraryOpen && <LibraryPanel />}
108
- {isSettingsOpen && <SettingsPanel />}
109
- </Suspense>
110
- <Minimap />
111
- <ContextMenu />
112
- <div className="absolute bottom-4 left-4 text-[11px] opacity-40 text-white pointer-events-none select-none z-10 mix-blend-difference">Space+Drag Pan · Scroll Zoom · B Browser · L Library · A Annotate · G Grid · Shift+S Screenshot</div>
113
- {toastMsg && <div className="absolute top-16 left-1/2 -translate-x-1/2 z-50 pointer-events-none"><div className="bg-[#1C1C1E]/90 border border-white/10 text-white font-medium px-4 py-2 rounded-full shadow-2xl backdrop-blur text-xs">{toastMsg}</div></div>}
114
- {globalDesaturate && <div className="absolute top-16 left-1/2 -translate-x-1/2 z-50 pointer-events-none"><div className="bg-yellow-500/20 border border-yellow-500 text-white px-3 py-1 rounded text-xs shadow-lg backdrop-blur">Desaturation Active (Shift+D)</div></div>}
115
- {focusedImageId && <div className="absolute top-4 left-1/2 -translate-x-1/2 z-50 pointer-events-none"><div className="bg-[#0A84FF]/20 border border-[#0A84FF] text-white px-3 py-1 rounded text-xs shadow-lg backdrop-blur">Focus Mode — ←→ cycle, F/Esc exit</div></div>}
116
- {isAnnotationMode && (
117
- <div className="absolute bottom-6 left-1/2 -translate-x-1/2 bg-[#2A2A2E]/90 backdrop-blur-md border border-[#3A3A3E] shadow-2xl rounded-xl px-4 py-3 flex items-center gap-3 z-50 transition-all">
118
- <div className="flex gap-1 items-center">
119
- <button onClick={() => { setIsEraser(false); setIsHighlighter(false); }} className={`p-2 rounded-lg transition-all ${!isEraser && !isHighlighter ? 'bg-white/15 text-white' : 'text-gray-500 hover:bg-white/5 hover:text-white'}`} title="Pen"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"/></svg></button>
120
- <button onClick={() => { setIsEraser(false); setIsHighlighter(true); }} className={`p-2 rounded-lg transition-all ${!isEraser && isHighlighter ? 'bg-white/15 text-white' : 'text-gray-500 hover:bg-white/5 hover:text-white'}`} title="Highlighter"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="m9 11-6 6v3h9l3-3"/><path d="m22 12-4.6 4.6a2 2 0 0 1-2.8 0l-5.2-5.2a2 2 0 0 1 0-2.8L14 4"/></svg></button>
121
- <button onClick={() => setIsEraser(true)} className={`p-2 rounded-lg transition-all ${isEraser ? 'bg-white/15 text-white' : 'text-gray-500 hover:bg-white/5 hover:text-white'}`} title="Eraser"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="m7 21-4.3-4.3c-1-1-1-2.5 0-3.4l9.6-9.6c1-1 2.5-1 3.4 0l5.6 5.6c1 1 1 2.5 0 3.4L13 21"/><path d="M22 21H7"/><path d="m5 11 9 9"/></svg></button>
122
- </div>
123
- <div className="w-px h-6 bg-[#3A3A3E] mx-1" />
124
- <div className="flex gap-2 items-center">{['#FFFFFF','#0A84FF','#30D158','#FFD60A','#FF453A'].map(c => <button key={c} className={`w-6 h-6 rounded-full transition-all ${annotationColor === c && !isEraser ? 'scale-110 ring-[2px] ring-offset-2 ring-offset-[#2A2A2E] ring-white' : 'opacity-60 hover:opacity-100 hover:scale-105'}`} style={{ backgroundColor: c }} onClick={() => { setAnnotationColor(c); if (isEraser) setIsEraser(false); }} />)}</div>
125
- <div className="w-px h-6 bg-[#3A3A3E] mx-1" />
126
- <div className="flex items-center gap-1.5 px-1">{[4,10,20,30].map(s => <button key={s} className={`w-6 h-6 flex items-center justify-center rounded-md transition-colors ${annotationSize === s ? 'bg-white/15 text-white' : 'text-gray-500 hover:bg-white/10 hover:text-white'}`} onClick={() => setAnnotationSize(s)}><div className="bg-current rounded-full" style={{ width: Math.max(3, s * 0.4), height: Math.max(3, s * 0.4) }} /></button>)}</div>
127
- <div className="w-px h-6 bg-[#3A3A3E] mx-1" />
128
- <button className="px-3 py-1.5 rounded-lg text-sm font-medium text-[#FF453A] bg-[#FF453A]/10 hover:bg-[#FF453A]/20 transition-colors ml-1" onClick={() => setIsAnnotationMode(false)}>Close</button>
129
- </div>
130
- )}
131
- {isZoomLensActive && <div className="absolute top-4 right-4 z-50 pointer-events-none"><div className="bg-[#2A2A2E]/80 border border-[#3A3A3E] text-white px-2 py-1 rounded text-[10px] backdrop-blur">🔍 Zoom Lens (release Z)</div></div>}
132
- {isCapturing && <div className="absolute inset-0 bg-black/50 flex items-center justify-center z-[100] pointer-events-none"><div className="bg-[#2A2A2E] border border-[#3A3A3E] rounded-xl px-6 py-4 shadow-2xl text-white text-sm">Capturing...</div></div>}
133
- </div>
134
- );
135
- };
136
-
137
- export default function App() { return <AppProvider><MainApp /></AppProvider>; }
 
1
+ import { useEffect, useState, useRef, lazy, Suspense } from 'react';
2
+ import { AppProvider, useAppStore } from './store';
3
+ import { Canvas } from './components/Canvas';
4
+ import { Toolbar } from './components/Toolbar';
5
+ import { ContextMenu } from './components/ContextMenu';
6
+ import { Minimap } from './components/Minimap';
7
+ import { StarterHub } from './components/StarterHub';
8
+ import { getCurrentWindow } from '@tauri-apps/api/window';
9
+ import { invoke } from '@tauri-apps/api/core';
10
+ import { listen } from '@tauri-apps/api/event';
11
+
12
+ const BrowserPanel = lazy(() => import('./components/BrowserPanel').then(m => ({ default: m.BrowserPanel })));
13
+ const LibraryPanel = lazy(() => import('./components/LibraryPanel').then(m => ({ default: m.LibraryPanel })));
14
+ const SettingsPanel = lazy(() => import('./components/SettingsPanel').then(m => ({ default: m.SettingsPanel })));
15
+
16
+ const appWindow = getCurrentWindow();
17
+
18
+ const MainApp = () => {
19
+ const {
20
+ currentScreen, setIsBrowserOpen, setIsLibraryOpen, isBrowserOpen, isLibraryOpen,
21
+ isSettingsOpen, setIsSettingsOpen, isAlwaysOnTop, setIsAlwaysOnTop, bgOpacity,
22
+ isAnnotationMode, setIsAnnotationMode, globalDesaturate, setGlobalDesaturate,
23
+ setZoom, setPan, pan, zoom, images, setImages, setTextNotes, selectedNodeIds, setSelectedNodeIds,
24
+ annotationColor, setAnnotationColor, annotationSize, setAnnotationSize,
25
+ isEraser, setIsEraser, isHighlighter, setIsHighlighter, undo, redo,
26
+ focusedImageId, setFocusedImageId, valueMirrorIds, setValueMirrorIds,
27
+ isZoomLensActive, setIsZoomLensActive, isWhisperBrowser, setIsWhisperBrowser,
28
+ showMinimap,
29
+ } = useAppStore();
30
+ const [toastMsg, setToastMsg] = useState<string | null>(null);
31
+ const [isCapturing, setIsCapturing] = useState(false);
32
+ const lastBPress = useRef(0);
33
+
34
+ useEffect(() => { if (toastMsg) { const t = setTimeout(() => setToastMsg(null), 3000); return () => clearTimeout(t); } }, [toastMsg]);
35
+ useEffect(() => { appWindow.setAlwaysOnTop(isAlwaysOnTop).catch(() => {}); }, [isAlwaysOnTop]);
36
+
37
+ useEffect(() => {
38
+ const unlisten = listen<any>('board://image_added', (event) => {
39
+ const data = event.payload;
40
+ if (!data || !data.url) return;
41
+ const img = new Image();
42
+ img.onload = () => { const ratio = img.width / img.height; const w = Math.min(400, img.width); 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'); };
43
+ img.onerror = () => { const w = Math.min(400, data.width || 300); const h = data.height ? w * (data.height / data.width) : w; 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: w / h }]); setToastMsg('✓ Image added'); };
44
+ img.src = data.url;
45
+ });
46
+ return () => { unlisten.then(fn => fn()); };
47
+ }, [pan, zoom, setImages]);
48
+
49
+ const doScreenCapture = async () => {
50
+ setIsCapturing(true); setToastMsg('Capturing...');
51
+ 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(() => {}); }
52
+ setIsCapturing(false);
53
+ };
54
+
55
+ useEffect(() => {
56
+ const onKeyDown = (e: KeyboardEvent) => {
57
+ if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement || (e.target as HTMLElement)?.isContentEditable) return;
58
+ const key = e.key.toLowerCase(); const ctrl = e.ctrlKey || e.metaKey;
59
+ if (ctrl && key === 'z') { e.preventDefault(); if (e.shiftKey) redo(); else undo(); return; }
60
+ if (ctrl && key === 's') { e.preventDefault(); setToastMsg('Saved'); return; }
61
+ if (e.shiftKey && key === 's' && !ctrl) { e.preventDefault(); doScreenCapture(); return; }
62
+ if (key === 'f' && !ctrl && !e.shiftKey) { if (focusedImageId) setFocusedImageId(null); else if (selectedNodeIds.length > 0) setFocusedImageId(selectedNodeIds[0]); return; }
63
+ if (key === 'v' && !ctrl && !e.shiftKey && 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; }
64
+ if (key === 'h' && !ctrl && !e.shiftKey && selectedNodeIds.length > 0) { setImages(prev => prev.map(i => selectedNodeIds.includes(i.id) ? { ...i, isFlippedH: !i.isFlippedH } : i)); return; }
65
+ if (key === 'z' && !ctrl && !e.shiftKey) { setIsZoomLensActive(true); return; }
66
+ if (ctrl && key === 'd') { 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; }
67
+ if (ctrl && key === 'g') { e.preventDefault(); if (e.shiftKey) { 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'); } else 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; }
68
+ 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; }
69
+ if (key === 'b' && !ctrl && !e.shiftKey) { const now = Date.now(); if (now - lastBPress.current < 300) { setIsWhisperBrowser(true); setIsBrowserOpen(true); } else { setIsBrowserOpen(p => !p); setIsWhisperBrowser(false); } lastBPress.current = now; return; }
70
+ if (key === 'l' && !ctrl && !e.shiftKey) { setIsLibraryOpen(p => !p); return; }
71
+ if (ctrl && key === ',') { e.preventDefault(); setIsSettingsOpen(p => !p); return; }
72
+ if (key === 'a' && !ctrl && !e.shiftKey) { setIsAnnotationMode(p => !p); return; }
73
+ if (key === 'g' && !ctrl && !e.shiftKey) { window.dispatchEvent(new CustomEvent('muse:toggle-grid', { detail: true })); return; }
74
+ if (key === 'd' && !ctrl) { if (e.shiftKey) setGlobalDesaturate(p => !p); else if (selectedNodeIds.length > 0) setImages(prev => prev.map(i => selectedNodeIds.includes(i.id) ? { ...i, isDesaturated: !i.isDesaturated } : i)); return; }
75
+ if (key === 't' && !ctrl && !e.shiftKey) { setIsAlwaysOnTop(p => !p); return; }
76
+ if (e.key === 'Escape') { setIsBrowserOpen(false); setIsLibraryOpen(false); setIsSettingsOpen(false); setSelectedNodeIds([]); setFocusedImageId(null); return; }
77
+ if (e.key === 'Delete' || e.key === 'Backspace') { if (selectedNodeIds.length > 0) { setImages(prev => prev.filter(i => !selectedNodeIds.includes(i.id))); setTextNotes(prev => prev.filter(n => !selectedNodeIds.includes(n.id))); setSelectedNodeIds([]); } return; }
78
+ if (ctrl && key === 'a') { e.preventDefault(); setSelectedNodeIds(images.map(i => i.id)); return; }
79
+ if (ctrl && key === '0') { 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 }); }
80
+ if (ctrl && key === '1') { e.preventDefault(); setZoom(1); }
81
+ };
82
+ const onKeyUp = (e: KeyboardEvent) => { if (e.key.toLowerCase() === 'z') setIsZoomLensActive(false); };
83
+ window.addEventListener('keydown', onKeyDown); window.addEventListener('keyup', onKeyUp);
84
+ return () => { window.removeEventListener('keydown', onKeyDown); window.removeEventListener('keyup', onKeyUp); };
85
+ }, [selectedNodeIds, images, pan, zoom, focusedImageId, undo, redo]);
86
+
87
+ const handleCtxMenu = (e: React.MouseEvent) => { if (!(e.target instanceof HTMLInputElement) && !(e.target instanceof HTMLTextAreaElement) && !(e.target as HTMLElement).isContentEditable) e.preventDefault(); };
88
+
89
+ if (currentScreen === 'hub') return <div className="w-screen h-screen relative overflow-hidden bg-[#0A0A0B]" onContextMenu={handleCtxMenu}><StarterHub /><Suspense fallback={null}>{isSettingsOpen && <SettingsPanel />}</Suspense></div>;
90
+
91
+ return (
92
+ <div className="w-screen h-screen relative overflow-hidden" style={{ backgroundColor: `rgba(28, 28, 30, ${isAlwaysOnTop ? bgOpacity / 100 : 1})` }} onContextMenu={handleCtxMenu}>
93
+ <Canvas />
94
+ {(isBrowserOpen || isLibraryOpen || isSettingsOpen) && <div className="absolute inset-0 bg-black/40 pointer-events-none z-30 transition-opacity" />}
95
+ <Toolbar />
96
+ <Suspense fallback={null}>{isBrowserOpen && <BrowserPanel />}{isLibraryOpen && <LibraryPanel />}{isSettingsOpen && <SettingsPanel />}</Suspense>
97
+ {showMinimap && <Minimap />}
98
+ <ContextMenu />
99
+ <div className="absolute bottom-4 left-4 text-[11px] opacity-40 text-white pointer-events-none select-none z-10 mix-blend-difference">Space+Drag Pan · Scroll Zoom · B Browser · L Library · A Annotate · G Grid · Shift+S Screenshot</div>
100
+ {toastMsg && <div className="absolute top-16 left-1/2 -translate-x-1/2 z-50 pointer-events-none"><div className="bg-[#1C1C1E]/90 border border-white/10 text-white font-medium px-4 py-2 rounded-full shadow-2xl backdrop-blur text-xs">{toastMsg}</div></div>}
101
+ {globalDesaturate && <div className="absolute top-16 left-1/2 -translate-x-1/2 z-50 pointer-events-none"><div className="bg-yellow-500/20 border border-yellow-500 text-white px-3 py-1 rounded text-xs shadow-lg backdrop-blur">Desaturation Active (Shift+D)</div></div>}
102
+ {focusedImageId && <div className="absolute top-4 left-1/2 -translate-x-1/2 z-50 pointer-events-none"><div className="bg-[#0A84FF]/20 border border-[#0A84FF] text-white px-3 py-1 rounded text-xs shadow-lg backdrop-blur">Focus Mode — ←→ cycle, F/Esc exit</div></div>}
103
+ {isCapturing && <div className="absolute inset-0 bg-black/50 flex items-center justify-center z-[100] pointer-events-none"><div className="bg-[#2A2A2E] border border-[#3A3A3E] rounded-xl px-6 py-4 shadow-2xl text-white text-sm">Capturing...</div></div>}
104
+ </div>
105
+ );
106
+ };
107
+
108
+ export default function App() { return <AppProvider><MainApp /></AppProvider>; }