| import React, { createContext, useContext, useState, ReactNode, useEffect, useCallback, useRef } from 'react'; |
| import { invoke } from '@tauri-apps/api/core'; |
| import { listen } from '@tauri-apps/api/event'; |
| import { RefImage, ContextMenuState, AnnotationPath, Palette, TextNote } from './types'; |
|
|
| export type ThemeId = 'dark-canvas' | 'warm-studio' | 'midnight'; |
|
|
| interface AppState { |
| textNotes: TextNote[]; setTextNotes: React.Dispatch<React.SetStateAction<TextNote[]>>; |
| images: RefImage[]; setImages: React.Dispatch<React.SetStateAction<RefImage[]>>; |
| annotations: AnnotationPath[]; setAnnotations: React.Dispatch<React.SetStateAction<AnnotationPath[]>>; |
| palettes: Palette[]; setPalettes: React.Dispatch<React.SetStateAction<Palette[]>>; |
| zoom: number; setZoom: React.Dispatch<React.SetStateAction<number>>; |
| pan: { x: number; y: number }; setPan: React.Dispatch<React.SetStateAction<{ x: number; y: number }>>; |
| isSettingsOpen: boolean; setIsSettingsOpen: React.Dispatch<React.SetStateAction<boolean>>; |
| isBrowserOpen: boolean; setIsBrowserOpen: React.Dispatch<React.SetStateAction<boolean>>; |
| isLibraryOpen: boolean; setIsLibraryOpen: React.Dispatch<React.SetStateAction<boolean>>; |
| selectedNodeIds: string[]; setSelectedNodeIds: React.Dispatch<React.SetStateAction<string[]>>; |
| globalDesaturate: boolean; setGlobalDesaturate: React.Dispatch<React.SetStateAction<boolean>>; |
| contextMenu: ContextMenuState; setContextMenu: React.Dispatch<React.SetStateAction<ContextMenuState>>; |
| isAlwaysOnTop: boolean; setIsAlwaysOnTop: React.Dispatch<React.SetStateAction<boolean>>; |
| bgOpacity: number; setBgOpacity: React.Dispatch<React.SetStateAction<number>>; |
| isClickThrough: boolean; setIsClickThrough: React.Dispatch<React.SetStateAction<boolean>>; |
| isAnnotationMode: boolean; setIsAnnotationMode: React.Dispatch<React.SetStateAction<boolean>>; |
| annotationColor: string; setAnnotationColor: React.Dispatch<React.SetStateAction<string>>; |
| annotationSize: number; setAnnotationSize: React.Dispatch<React.SetStateAction<number>>; |
| isEraser: boolean; setIsEraser: React.Dispatch<React.SetStateAction<boolean>>; |
| isHighlighter: boolean; setIsHighlighter: React.Dispatch<React.SetStateAction<boolean>>; |
| showMinimap: boolean; setShowMinimap: React.Dispatch<React.SetStateAction<boolean>>; |
| showGrid: boolean; setShowGrid: React.Dispatch<React.SetStateAction<boolean>>; |
| theme: ThemeId; setTheme: React.Dispatch<React.SetStateAction<ThemeId>>; |
| undo: () => void; redo: () => void; |
| currentScreen: 'hub' | 'board'; setCurrentScreen: React.Dispatch<React.SetStateAction<'hub' | 'board'>>; |
| updateSelectedNodes: (dx: number, dy: number, explicitTargetId: string) => void; |
| focusedImageId: string | null; setFocusedImageId: React.Dispatch<React.SetStateAction<string | null>>; |
| valueMirrorIds: string[]; setValueMirrorIds: React.Dispatch<React.SetStateAction<string[]>>; |
| isZoomLensActive: boolean; setIsZoomLensActive: React.Dispatch<React.SetStateAction<boolean>>; |
| isWhisperBrowser: boolean; setIsWhisperBrowser: React.Dispatch<React.SetStateAction<boolean>>; |
| activeProjectId: string | null; setActiveProjectId: React.Dispatch<React.SetStateAction<string | null>>; |
| boardTitle: string; setBoardTitle: React.Dispatch<React.SetStateAction<string>>; |
| } |
|
|
| const AppContext = createContext<AppState | undefined>(undefined); |
| function emitError(msg: string) { window.dispatchEvent(new CustomEvent('muse:error', { detail: msg })); } |
| function normalizeUrl(v: any): string { return String(v || '').trim(); } |
|
|
| export const AppProvider = ({ children }: { children: ReactNode }) => { |
| const [textNotes, setTextNotes] = useState<TextNote[]>([]); |
| const [images, setImages] = useState<RefImage[]>([]); |
| const [annotations, setAnnotations] = useState<AnnotationPath[]>([]); |
| const [palettes, setPalettes] = useState<Palette[]>([]); |
| const [zoom, setZoom] = useState(1); |
| const [pan, setPan] = useState({ x: 0, y: 0 }); |
| const [isSettingsOpen, setIsSettingsOpen] = useState(false); |
| const [isBrowserOpen, setIsBrowserOpen] = useState(false); |
| const [isLibraryOpen, setIsLibraryOpen] = useState(false); |
| const [selectedNodeIds, setSelectedNodeIds] = useState<string[]>([]); |
| const [globalDesaturate, setGlobalDesaturate] = useState(false); |
| const [contextMenu, setContextMenu] = useState<ContextMenuState>(null); |
| const [isAlwaysOnTop, setIsAlwaysOnTop] = useState(false); |
| const [bgOpacity, setBgOpacity] = useState(50); |
| const [isClickThrough, setIsClickThrough] = useState(false); |
| const [isAnnotationMode, setIsAnnotationMode] = useState(false); |
| const [annotationColor, setAnnotationColor] = useState('#FF453A'); |
| const [annotationSize, setAnnotationSize] = useState(4); |
| const [isEraser, setIsEraser] = useState(false); |
| const [isHighlighter, setIsHighlighter] = useState(false); |
| const [showMinimap, setShowMinimap] = useState(false); |
| const [showGrid, setShowGrid] = useState(true); |
| const [theme, setTheme] = useState<ThemeId>('dark-canvas'); |
| const [history, setHistory] = useState<any[]>([]); |
| const [historyIndex, setHistoryIndex] = useState(-1); |
| const [isUndoing, setIsUndoing] = useState(false); |
| const [currentScreen, setCurrentScreen] = useState<'hub' | 'board'>('hub'); |
| const [focusedImageId, setFocusedImageId] = useState<string | null>(null); |
| const [valueMirrorIds, setValueMirrorIds] = useState<string[]>([]); |
| const [isZoomLensActive, setIsZoomLensActive] = useState(false); |
| const [isWhisperBrowser, setIsWhisperBrowser] = useState(false); |
| const [activeProjectId, setActiveProjectId] = useState<string | null>(null); |
| const [boardTitle, setBoardTitle] = useState('Untitled Board'); |
|
|
| const panRef = useRef(pan); |
| const zoomRef = useRef(zoom); |
| useEffect(() => { panRef.current = pan; }, [pan]); |
| useEffect(() => { zoomRef.current = zoom; }, [zoom]); |
|
|
| |
| useEffect(() => { |
| if (localStorage.getItem('refstudio.showMinimap') === 'true') setShowMinimap(true); |
| const grid = localStorage.getItem('refstudio.showGrid'); |
| if (grid !== null) setShowGrid(grid === 'true'); |
| const savedTheme = localStorage.getItem('refstudio.theme') as ThemeId | null; |
| if (savedTheme && ['dark-canvas', 'warm-studio', 'midnight'].includes(savedTheme)) setTheme(savedTheme); |
| }, []); |
| useEffect(() => { localStorage.setItem('refstudio.showMinimap', String(showMinimap)); }, [showMinimap]); |
| useEffect(() => { localStorage.setItem('refstudio.showGrid', String(showGrid)); }, [showGrid]); |
| useEffect(() => { localStorage.setItem('refstudio.theme', theme); document.documentElement.setAttribute('data-theme', theme); }, [theme]); |
|
|
| useEffect(() => { |
| const un = listen<any>('board://image_added', (event) => { |
| const p = event.payload || {}; |
| const dataUrl = normalizeUrl(p.dataUrl || p.data_url || p.url); |
| const originalUrl = normalizeUrl(p.url || p.originalUrl || p.original_url || dataUrl); |
| const sourceUrl = normalizeUrl(p.sourceUrl || p.source_url || p.source || p.pageUrl || p.page_url); |
| const captureId = normalizeUrl(p.captureId || p.capture_id); |
| if (!dataUrl) return; |
| const width = Number(p.width || 300); |
| const height = Number(p.height || 200); |
| const ratio = width > 0 && height > 0 ? width / height : 1.5; |
| const targetW = Math.min(480, Math.max(160, width > 0 ? width / 3 : 300)); |
| const targetH = targetW / ratio; |
| const currentPan = panRef.current; |
| const currentZoom = zoomRef.current; |
| setImages(prev => { |
| if (prev.some(img => (captureId && img.captureId === captureId) || normalizeUrl(img.url) === dataUrl || normalizeUrl(img.url) === originalUrl || (sourceUrl && normalizeUrl(img.sourceUrl) === sourceUrl) || normalizeUrl(img.sourceUrl) === originalUrl)) return prev; |
| return [...prev, { id: crypto.randomUUID(), captureId, url: dataUrl, sourceUrl: sourceUrl || originalUrl, x: (-currentPan.x + window.innerWidth / 2 - targetW / 2) / currentZoom, y: (-currentPan.y + window.innerHeight / 2 - targetH / 2) / currentZoom, width: Math.round(targetW), height: Math.round(targetH), aspectRatio: ratio }]; |
| }); |
| setCurrentScreen('board'); |
| }); |
| return () => { un.then(fn => fn()); }; |
| }, []); |
|
|
| useEffect(() => { invoke<string | null>('projects_get_active_id').then(id => { if (!id) return; setActiveProjectId(id); invoke<string>('project_load', { id }).then(json => { try { const saved = JSON.parse(json); if (saved.images) setImages(saved.images); if (saved.textNotes) setTextNotes(saved.textNotes); if (saved.annotations) setAnnotations(saved.annotations); if (saved.palettes) setPalettes(saved.palettes); if (saved.zoom) setZoom(saved.zoom); if (saved.pan) setPan(saved.pan); if (saved.title) setBoardTitle(saved.title); if (saved.valueMirrorIds) setValueMirrorIds(saved.valueMirrorIds); setCurrentScreen('board'); } catch (e) { console.error('Failed to parse project state:', e); emitError('Failed to load project — file may be corrupted'); } }).catch(e => { console.error('project_load failed:', e); emitError(`Failed to load project: ${e}`); }); }).catch(() => {}); }, []); |
|
|
| const saveFailedRef = useRef(false); |
| useEffect(() => { if (isUndoing) { setIsUndoing(false); return; } if (!activeProjectId) return; const timer = setTimeout(() => { const stateToSave = { textNotes, images, annotations, palettes, zoom, pan, valueMirrorIds, title: boardTitle }; invoke('project_save', { id: activeProjectId, state: JSON.stringify(stateToSave), title: boardTitle }).then(() => { saveFailedRef.current = false; }).catch(e => { if (!saveFailedRef.current) { emitError(`Auto-save failed: ${e}`); saveFailedRef.current = true; } console.error('Auto-save failed:', e); }); setHistory(prev => { const nh = prev.slice(0, historyIndex + 1 > 0 ? historyIndex + 1 : undefined); if (JSON.stringify(nh[nh.length - 1]) !== JSON.stringify(stateToSave)) { nh.push(stateToSave); if (nh.length > 200) nh.shift(); setHistoryIndex(nh.length - 1); } return nh; }); }, 800); return () => clearTimeout(timer); }, [textNotes, images, annotations, palettes, zoom, pan, valueMirrorIds, activeProjectId, boardTitle]); |
|
|
| 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]); |
| 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]); |
| const updateSelectedNodes = useCallback((dx: number, dy: number, explicitTargetId: string) => { const ids = new Set([...selectedNodeIds, explicitTargetId]); 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); }); 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); }); }, [selectedNodeIds]); |
|
|
| return <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, showMinimap, setShowMinimap, showGrid, setShowGrid, theme, setTheme, undo, redo, currentScreen, setCurrentScreen, updateSelectedNodes, focusedImageId, setFocusedImageId, valueMirrorIds, setValueMirrorIds, isZoomLensActive, setIsZoomLensActive, isWhisperBrowser, setIsWhisperBrowser, activeProjectId, setActiveProjectId, boardTitle, setBoardTitle }}>{children}</AppContext.Provider>; |
| }; |
|
|
| export const useAppStore = () => { const ctx = useContext(AppContext); if (!ctx) throw new Error('useAppStore must be used within AppProvider'); return ctx; }; |
|
|