asdf98 commited on
Commit
c0a3d64
·
verified ·
1 Parent(s): 7a58b0e

fix: board image event listener registered once and dedupes by captureId/url

Browse files
Files changed (1) hide show
  1. src/store.tsx +22 -8
src/store.tsx CHANGED
@@ -1,4 +1,4 @@
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';
@@ -37,7 +37,6 @@ interface AppState {
37
 
38
  const AppContext = createContext<AppState | undefined>(undefined);
39
  function emitError(msg: string) { window.dispatchEvent(new CustomEvent('muse:error', { detail: msg })); }
40
-
41
  function normalizeUrl(v: any): string { return String(v || '').trim(); }
42
 
43
  export const AppProvider = ({ children }: { children: ReactNode }) => {
@@ -72,27 +71,42 @@ export const AppProvider = ({ children }: { children: ReactNode }) => {
72
  const [activeProjectId, setActiveProjectId] = useState<string | null>(null);
73
  const [boardTitle, setBoardTitle] = useState('Untitled Board');
74
 
 
 
 
 
 
 
75
  useEffect(() => {
76
  const un = listen<any>('board://image_added', (event) => {
77
  const p = event.payload || {};
78
  const dataUrl = normalizeUrl(p.dataUrl || p.data_url || p.url);
79
  const originalUrl = normalizeUrl(p.url || p.originalUrl || p.original_url || dataUrl);
80
  const sourceUrl = normalizeUrl(p.sourceUrl || p.source_url || p.source || p.pageUrl || p.page_url);
 
81
  if (!dataUrl) return;
82
  const width = Number(p.width || 300);
83
  const height = Number(p.height || 200);
84
  const ratio = width > 0 && height > 0 ? width / height : 1.5;
85
  const targetW = Math.min(480, Math.max(160, width > 0 ? width / 3 : 300));
86
  const targetH = targetW / ratio;
 
 
87
  setImages(prev => {
88
- // Strong dedupe: same source/original/data URL within board means same capture.
89
- if (prev.some(img => normalizeUrl(img.url) === dataUrl || normalizeUrl(img.url) === originalUrl || normalizeUrl(img.sourceUrl) === sourceUrl || normalizeUrl(img.sourceUrl) === originalUrl)) return prev;
 
 
 
 
 
90
  return [...prev, {
91
  id: crypto.randomUUID(),
 
92
  url: dataUrl,
93
  sourceUrl: sourceUrl || originalUrl,
94
- x: (-pan.x + window.innerWidth / 2 - targetW / 2) / zoom,
95
- y: (-pan.y + window.innerHeight / 2 - targetH / 2) / zoom,
96
  width: Math.round(targetW),
97
  height: Math.round(targetH),
98
  aspectRatio: ratio,
@@ -101,11 +115,11 @@ export const AppProvider = ({ children }: { children: ReactNode }) => {
101
  setCurrentScreen('board');
102
  });
103
  return () => { un.then(fn => fn()); };
104
- }, [pan, zoom]);
105
 
106
  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(() => {}); }, []);
107
 
108
- const saveFailedRef = React.useRef(false);
109
  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]);
110
 
111
  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]);
 
1
+ import React, { createContext, useContext, useState, ReactNode, useEffect, useCallback, useRef } 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';
 
37
 
38
  const AppContext = createContext<AppState | undefined>(undefined);
39
  function emitError(msg: string) { window.dispatchEvent(new CustomEvent('muse:error', { detail: msg })); }
 
40
  function normalizeUrl(v: any): string { return String(v || '').trim(); }
41
 
42
  export const AppProvider = ({ children }: { children: ReactNode }) => {
 
71
  const [activeProjectId, setActiveProjectId] = useState<string | null>(null);
72
  const [boardTitle, setBoardTitle] = useState('Untitled Board');
73
 
74
+ const panRef = useRef(pan);
75
+ const zoomRef = useRef(zoom);
76
+ useEffect(() => { panRef.current = pan; }, [pan]);
77
+ useEffect(() => { zoomRef.current = zoom; }, [zoom]);
78
+
79
+ // Register exactly once. Use refs for current pan/zoom to avoid duplicate listeners.
80
  useEffect(() => {
81
  const un = listen<any>('board://image_added', (event) => {
82
  const p = event.payload || {};
83
  const dataUrl = normalizeUrl(p.dataUrl || p.data_url || p.url);
84
  const originalUrl = normalizeUrl(p.url || p.originalUrl || p.original_url || dataUrl);
85
  const sourceUrl = normalizeUrl(p.sourceUrl || p.source_url || p.source || p.pageUrl || p.page_url);
86
+ const captureId = normalizeUrl(p.captureId || p.capture_id);
87
  if (!dataUrl) return;
88
  const width = Number(p.width || 300);
89
  const height = Number(p.height || 200);
90
  const ratio = width > 0 && height > 0 ? width / height : 1.5;
91
  const targetW = Math.min(480, Math.max(160, width > 0 ? width / 3 : 300));
92
  const targetH = targetW / ratio;
93
+ const currentPan = panRef.current;
94
+ const currentZoom = zoomRef.current;
95
  setImages(prev => {
96
+ if (prev.some(img =>
97
+ (captureId && img.captureId === captureId) ||
98
+ normalizeUrl(img.url) === dataUrl ||
99
+ normalizeUrl(img.url) === originalUrl ||
100
+ (sourceUrl && normalizeUrl(img.sourceUrl) === sourceUrl) ||
101
+ normalizeUrl(img.sourceUrl) === originalUrl
102
+ )) return prev;
103
  return [...prev, {
104
  id: crypto.randomUUID(),
105
+ captureId,
106
  url: dataUrl,
107
  sourceUrl: sourceUrl || originalUrl,
108
+ x: (-currentPan.x + window.innerWidth / 2 - targetW / 2) / currentZoom,
109
+ y: (-currentPan.y + window.innerHeight / 2 - targetH / 2) / currentZoom,
110
  width: Math.round(targetW),
111
  height: Math.round(targetH),
112
  aspectRatio: ratio,
 
115
  setCurrentScreen('board');
116
  });
117
  return () => { un.then(fn => fn()); };
118
+ }, []);
119
 
120
  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(() => {}); }, []);
121
 
122
+ const saveFailedRef = useRef(false);
123
  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]);
124
 
125
  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]);