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; };