File size: 12,737 Bytes
c0a3d64 e696850 87679d6 e696850 92f47c3 8eac621 87679d6 e696850 73ff76e e696850 92f47c3 8eac621 87679d6 e696850 c0a3d64 87679d6 92f47c3 8eac621 87679d6 92f47c3 8eac621 87679d6 92f47c3 e696850 73ff76e c0a3d64 73ff76e e696850 c0a3d64 e696850 92f47c3 e696850 c0a3d64 e696850 73ff76e e696850 c0a3d64 73ff76e e696850 73ff76e e696850 87679d6 e696850 | 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 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 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 | 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]);
// Persist preferences
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; };
|