fix: listen for browser hover ADD events and add captured image to active canvas
Browse files- src/store.tsx +156 -145
src/store.tsx
CHANGED
|
@@ -1,145 +1,156 @@
|
|
| 1 |
-
import React, { createContext, useContext, useState, ReactNode, useEffect, useCallback } from 'react';
|
| 2 |
-
import { invoke } from '@tauri-apps/api/core';
|
| 3 |
-
import {
|
| 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 |
-
const [
|
| 46 |
-
const [
|
| 47 |
-
const [
|
| 48 |
-
const [
|
| 49 |
-
const [
|
| 50 |
-
const [
|
| 51 |
-
const [
|
| 52 |
-
const [
|
| 53 |
-
const [
|
| 54 |
-
const [
|
| 55 |
-
const [
|
| 56 |
-
const [
|
| 57 |
-
const [
|
| 58 |
-
const [
|
| 59 |
-
const [
|
| 60 |
-
const [
|
| 61 |
-
const [
|
| 62 |
-
const [
|
| 63 |
-
const [
|
| 64 |
-
const [
|
| 65 |
-
const [
|
| 66 |
-
const [
|
| 67 |
-
const [
|
| 68 |
-
const [
|
| 69 |
-
const [
|
| 70 |
-
const [
|
| 71 |
-
const [
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
if (
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { createContext, useContext, useState, ReactNode, useEffect, useCallback } from 'react';
|
| 2 |
+
import { invoke } from '@tauri-apps/api/core';
|
| 3 |
+
import { listen } from '@tauri-apps/api/event';
|
| 4 |
+
import { RefImage, ContextMenuState, AnnotationPath, Palette, TextNote } from './types';
|
| 5 |
+
|
| 6 |
+
interface AppState {
|
| 7 |
+
textNotes: TextNote[]; setTextNotes: React.Dispatch<React.SetStateAction<TextNote[]>>;
|
| 8 |
+
images: RefImage[]; setImages: React.Dispatch<React.SetStateAction<RefImage[]>>;
|
| 9 |
+
annotations: AnnotationPath[]; setAnnotations: React.Dispatch<React.SetStateAction<AnnotationPath[]>>;
|
| 10 |
+
palettes: Palette[]; setPalettes: React.Dispatch<React.SetStateAction<Palette[]>>;
|
| 11 |
+
zoom: number; setZoom: React.Dispatch<React.SetStateAction<number>>;
|
| 12 |
+
pan: { x: number; y: number }; setPan: React.Dispatch<React.SetStateAction<{ x: number; y: number }>>;
|
| 13 |
+
isSettingsOpen: boolean; setIsSettingsOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
| 14 |
+
isBrowserOpen: boolean; setIsBrowserOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
| 15 |
+
isLibraryOpen: boolean; setIsLibraryOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
| 16 |
+
selectedNodeIds: string[]; setSelectedNodeIds: React.Dispatch<React.SetStateAction<string[]>>;
|
| 17 |
+
globalDesaturate: boolean; setGlobalDesaturate: React.Dispatch<React.SetStateAction<boolean>>;
|
| 18 |
+
contextMenu: ContextMenuState; setContextMenu: React.Dispatch<React.SetStateAction<ContextMenuState>>;
|
| 19 |
+
isAlwaysOnTop: boolean; setIsAlwaysOnTop: React.Dispatch<React.SetStateAction<boolean>>;
|
| 20 |
+
bgOpacity: number; setBgOpacity: React.Dispatch<React.SetStateAction<number>>;
|
| 21 |
+
isClickThrough: boolean; setIsClickThrough: React.Dispatch<React.SetStateAction<boolean>>;
|
| 22 |
+
isAnnotationMode: boolean; setIsAnnotationMode: React.Dispatch<React.SetStateAction<boolean>>;
|
| 23 |
+
annotationColor: string; setAnnotationColor: React.Dispatch<React.SetStateAction<string>>;
|
| 24 |
+
annotationSize: number; setAnnotationSize: React.Dispatch<React.SetStateAction<number>>;
|
| 25 |
+
isEraser: boolean; setIsEraser: React.Dispatch<React.SetStateAction<boolean>>;
|
| 26 |
+
isHighlighter: boolean; setIsHighlighter: React.Dispatch<React.SetStateAction<boolean>>;
|
| 27 |
+
undo: () => void; redo: () => void;
|
| 28 |
+
currentScreen: 'hub' | 'board'; setCurrentScreen: React.Dispatch<React.SetStateAction<'hub' | 'board'>>;
|
| 29 |
+
updateSelectedNodes: (dx: number, dy: number, explicitTargetId: string) => void;
|
| 30 |
+
focusedImageId: string | null; setFocusedImageId: React.Dispatch<React.SetStateAction<string | null>>;
|
| 31 |
+
valueMirrorIds: string[]; setValueMirrorIds: React.Dispatch<React.SetStateAction<string[]>>;
|
| 32 |
+
isZoomLensActive: boolean; setIsZoomLensActive: React.Dispatch<React.SetStateAction<boolean>>;
|
| 33 |
+
isWhisperBrowser: boolean; setIsWhisperBrowser: React.Dispatch<React.SetStateAction<boolean>>;
|
| 34 |
+
activeProjectId: string | null; setActiveProjectId: React.Dispatch<React.SetStateAction<string | null>>;
|
| 35 |
+
boardTitle: string; setBoardTitle: React.Dispatch<React.SetStateAction<string>>;
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
const AppContext = createContext<AppState | undefined>(undefined);
|
| 39 |
+
function emitError(msg: string) { window.dispatchEvent(new CustomEvent('muse:error', { detail: msg })); }
|
| 40 |
+
|
| 41 |
+
export const AppProvider = ({ children }: { children: ReactNode }) => {
|
| 42 |
+
const [textNotes, setTextNotes] = useState<TextNote[]>([]);
|
| 43 |
+
const [images, setImages] = useState<RefImage[]>([]);
|
| 44 |
+
const [annotations, setAnnotations] = useState<AnnotationPath[]>([]);
|
| 45 |
+
const [palettes, setPalettes] = useState<Palette[]>([]);
|
| 46 |
+
const [zoom, setZoom] = useState(1);
|
| 47 |
+
const [pan, setPan] = useState({ x: 0, y: 0 });
|
| 48 |
+
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
| 49 |
+
const [isBrowserOpen, setIsBrowserOpen] = useState(false);
|
| 50 |
+
const [isLibraryOpen, setIsLibraryOpen] = useState(false);
|
| 51 |
+
const [selectedNodeIds, setSelectedNodeIds] = useState<string[]>([]);
|
| 52 |
+
const [globalDesaturate, setGlobalDesaturate] = useState(false);
|
| 53 |
+
const [contextMenu, setContextMenu] = useState<ContextMenuState>(null);
|
| 54 |
+
const [isAlwaysOnTop, setIsAlwaysOnTop] = useState(false);
|
| 55 |
+
const [bgOpacity, setBgOpacity] = useState(50);
|
| 56 |
+
const [isClickThrough, setIsClickThrough] = useState(false);
|
| 57 |
+
const [isAnnotationMode, setIsAnnotationMode] = useState(false);
|
| 58 |
+
const [annotationColor, setAnnotationColor] = useState('#FF453A');
|
| 59 |
+
const [annotationSize, setAnnotationSize] = useState(4);
|
| 60 |
+
const [isEraser, setIsEraser] = useState(false);
|
| 61 |
+
const [isHighlighter, setIsHighlighter] = useState(false);
|
| 62 |
+
const [history, setHistory] = useState<any[]>([]);
|
| 63 |
+
const [historyIndex, setHistoryIndex] = useState(-1);
|
| 64 |
+
const [isUndoing, setIsUndoing] = useState(false);
|
| 65 |
+
const [currentScreen, setCurrentScreen] = useState<'hub' | 'board'>('hub');
|
| 66 |
+
const [focusedImageId, setFocusedImageId] = useState<string | null>(null);
|
| 67 |
+
const [valueMirrorIds, setValueMirrorIds] = useState<string[]>([]);
|
| 68 |
+
const [isZoomLensActive, setIsZoomLensActive] = useState(false);
|
| 69 |
+
const [isWhisperBrowser, setIsWhisperBrowser] = useState(false);
|
| 70 |
+
const [activeProjectId, setActiveProjectId] = useState<string | null>(null);
|
| 71 |
+
const [boardTitle, setBoardTitle] = useState('Untitled Board');
|
| 72 |
+
|
| 73 |
+
// Browser hover overlay -> Rust -> board://image_added -> active canvas state.
|
| 74 |
+
// The native browser capture path cannot directly mutate this React store, so this is the bridge.
|
| 75 |
+
useEffect(() => {
|
| 76 |
+
const un = listen<any>('board://image_added', (event) => {
|
| 77 |
+
const p = event.payload || {};
|
| 78 |
+
const width = Number(p.width || 300);
|
| 79 |
+
const height = Number(p.height || 200);
|
| 80 |
+
const ratio = width > 0 && height > 0 ? width / height : 1.5;
|
| 81 |
+
const targetW = Math.min(480, Math.max(160, width > 0 ? width / 3 : 300));
|
| 82 |
+
const targetH = targetW / ratio;
|
| 83 |
+
setImages(prev => {
|
| 84 |
+
if (prev.some(img => img.url === p.url || img.url === p.dataUrl || img.id === p.id)) return prev;
|
| 85 |
+
return [...prev, {
|
| 86 |
+
id: crypto.randomUUID(),
|
| 87 |
+
url: p.dataUrl || p.data_url || p.url,
|
| 88 |
+
sourceUrl: p.sourceUrl || p.source_url || p.source || p.originalUrl || p.original_url,
|
| 89 |
+
x: (-pan.x + window.innerWidth / 2 - targetW / 2) / zoom,
|
| 90 |
+
y: (-pan.y + window.innerHeight / 2 - targetH / 2) / zoom,
|
| 91 |
+
width: Math.round(targetW),
|
| 92 |
+
height: Math.round(targetH),
|
| 93 |
+
aspectRatio: ratio,
|
| 94 |
+
}];
|
| 95 |
+
});
|
| 96 |
+
setCurrentScreen('board');
|
| 97 |
+
});
|
| 98 |
+
return () => { un.then(fn => fn()); };
|
| 99 |
+
}, [pan, zoom]);
|
| 100 |
+
|
| 101 |
+
useEffect(() => {
|
| 102 |
+
invoke<string | null>('projects_get_active_id').then(id => {
|
| 103 |
+
if (!id) return;
|
| 104 |
+
setActiveProjectId(id);
|
| 105 |
+
invoke<string>('project_load', { id }).then(json => {
|
| 106 |
+
try {
|
| 107 |
+
const saved = JSON.parse(json);
|
| 108 |
+
if (saved.images) setImages(saved.images);
|
| 109 |
+
if (saved.textNotes) setTextNotes(saved.textNotes);
|
| 110 |
+
if (saved.annotations) setAnnotations(saved.annotations);
|
| 111 |
+
if (saved.palettes) setPalettes(saved.palettes);
|
| 112 |
+
if (saved.zoom) setZoom(saved.zoom);
|
| 113 |
+
if (saved.pan) setPan(saved.pan);
|
| 114 |
+
if (saved.title) setBoardTitle(saved.title);
|
| 115 |
+
if (saved.valueMirrorIds) setValueMirrorIds(saved.valueMirrorIds);
|
| 116 |
+
setCurrentScreen('board');
|
| 117 |
+
} catch (e) { console.error('Failed to parse project state:', e); emitError('Failed to load project — file may be corrupted'); }
|
| 118 |
+
}).catch(e => { console.error('project_load failed:', e); emitError(`Failed to load project: ${e}`); });
|
| 119 |
+
}).catch(() => {});
|
| 120 |
+
}, []);
|
| 121 |
+
|
| 122 |
+
const saveFailedRef = React.useRef(false);
|
| 123 |
+
useEffect(() => {
|
| 124 |
+
if (isUndoing) { setIsUndoing(false); return; }
|
| 125 |
+
if (!activeProjectId) return;
|
| 126 |
+
const timer = setTimeout(() => {
|
| 127 |
+
const stateToSave = { textNotes, images, annotations, palettes, zoom, pan, valueMirrorIds, title: boardTitle };
|
| 128 |
+
invoke('project_save', { id: activeProjectId, state: JSON.stringify(stateToSave), title: boardTitle })
|
| 129 |
+
.then(() => { saveFailedRef.current = false; })
|
| 130 |
+
.catch(e => { if (!saveFailedRef.current) { emitError(`Auto-save failed: ${e}`); saveFailedRef.current = true; } console.error('Auto-save failed:', e); });
|
| 131 |
+
setHistory(prev => {
|
| 132 |
+
const nh = prev.slice(0, historyIndex + 1 > 0 ? historyIndex + 1 : undefined);
|
| 133 |
+
if (JSON.stringify(nh[nh.length - 1]) !== JSON.stringify(stateToSave)) { nh.push(stateToSave); if (nh.length > 200) nh.shift(); setHistoryIndex(nh.length - 1); }
|
| 134 |
+
return nh;
|
| 135 |
+
});
|
| 136 |
+
}, 800);
|
| 137 |
+
return () => clearTimeout(timer);
|
| 138 |
+
}, [textNotes, images, annotations, palettes, zoom, pan, valueMirrorIds, activeProjectId, boardTitle]);
|
| 139 |
+
|
| 140 |
+
const undo = useCallback(() => { if (historyIndex > 0) { setIsUndoing(true); const s = history[historyIndex - 1]; setTextNotes(s.textNotes); setImages(s.images); setAnnotations(s.annotations); setPalettes(s.palettes); setZoom(s.zoom); setPan(s.pan); if (s.valueMirrorIds) setValueMirrorIds(s.valueMirrorIds); setHistoryIndex(historyIndex - 1); } }, [history, historyIndex]);
|
| 141 |
+
const redo = useCallback(() => { if (historyIndex < history.length - 1) { setIsUndoing(true); const s = history[historyIndex + 1]; setTextNotes(s.textNotes); setImages(s.images); setAnnotations(s.annotations); setPalettes(s.palettes); setZoom(s.zoom); setPan(s.pan); if (s.valueMirrorIds) setValueMirrorIds(s.valueMirrorIds); setHistoryIndex(historyIndex + 1); } }, [history, historyIndex]);
|
| 142 |
+
|
| 143 |
+
const updateSelectedNodes = useCallback((dx: number, dy: number, explicitTargetId: string) => {
|
| 144 |
+
const ids = new Set([...selectedNodeIds, explicitTargetId]);
|
| 145 |
+
setImages(prev => { const gids = new Set<string>(); prev.forEach(i => { if (ids.has(i.id) && i.groupId) gids.add(i.groupId); }); return prev.map(i => (ids.has(i.id) || (i.groupId && gids.has(i.groupId))) ? { ...i, x: i.x + dx, y: i.y + dy } : i); });
|
| 146 |
+
setTextNotes(prev => { const gids = new Set<string>(); prev.forEach(i => { if (ids.has(i.id) && i.groupId) gids.add(i.groupId); }); return prev.map(i => (ids.has(i.id) || (i.groupId && gids.has(i.groupId))) ? { ...i, x: i.x + dx, y: i.y + dy } : i); });
|
| 147 |
+
}, [selectedNodeIds]);
|
| 148 |
+
|
| 149 |
+
return (
|
| 150 |
+
<AppContext.Provider value={{ textNotes, setTextNotes, images, setImages, annotations, setAnnotations, palettes, setPalettes, zoom, setZoom, pan, setPan, isSettingsOpen, setIsSettingsOpen, isBrowserOpen, setIsBrowserOpen, isLibraryOpen, setIsLibraryOpen, selectedNodeIds, setSelectedNodeIds, globalDesaturate, setGlobalDesaturate, contextMenu, setContextMenu, isAlwaysOnTop, setIsAlwaysOnTop, bgOpacity, setBgOpacity, isClickThrough, setIsClickThrough, isAnnotationMode, setIsAnnotationMode, annotationColor, setAnnotationColor, annotationSize, setAnnotationSize, isEraser, setIsEraser, isHighlighter, setIsHighlighter, undo, redo, currentScreen, setCurrentScreen, updateSelectedNodes, focusedImageId, setFocusedImageId, valueMirrorIds, setValueMirrorIds, isZoomLensActive, setIsZoomLensActive, isWhisperBrowser, setIsWhisperBrowser, activeProjectId, setActiveProjectId, boardTitle, setBoardTitle }}>
|
| 151 |
+
{children}
|
| 152 |
+
</AppContext.Provider>
|
| 153 |
+
);
|
| 154 |
+
};
|
| 155 |
+
|
| 156 |
+
export const useAppStore = () => { const ctx = useContext(AppContext); if (!ctx) throw new Error('useAppStore must be used within AppProvider'); return ctx; };
|