asdf98 commited on
Commit
d58fa0e
·
verified ·
1 Parent(s): 8eac621

fix: grid visibility controlled by Settings store, not toolbar event

Browse files
Files changed (1) hide show
  1. src/components/Canvas.tsx +24 -90
src/components/Canvas.tsx CHANGED
@@ -9,13 +9,12 @@ const IMAGE_EXTS = ['.png', '.jpg', '.jpeg', '.webp', '.gif', '.bmp', '.avif', '
9
  function isImagePath(path: string) { const p = path.toLowerCase(); return IMAGE_EXTS.some(ext => p.endsWith(ext)); }
10
 
11
  export const Canvas = () => {
12
- const { images, setImages, textNotes, annotations, setAnnotations, palettes, setPalettes, zoom, setZoom, pan, setPan, setSelectedNodeIds, setContextMenu, isAnnotationMode, annotationColor, annotationSize, isEraser, isHighlighter } = useAppStore();
13
  const containerRef = useRef<HTMLDivElement>(null);
14
  const [isSpaceDown, setIsSpaceDown] = useState(false);
15
  const [isDraggingCanvas, setIsDraggingCanvas] = useState(false);
16
  const [isDrawing, setIsDrawing] = useState(false);
17
  const [currentPath, setCurrentPath] = useState<{x: number, y: number}[]>([]);
18
- const [showGrid, setShowGrid] = useState(true);
19
  const [isDropActive, setIsDropActive] = useState(false);
20
 
21
  const addLibraryItemToCanvas = useCallback((item: any, cssX?: number, cssY?: number) => {
@@ -24,37 +23,18 @@ export const Canvas = () => {
24
  const clientY = cssY ?? (rect ? rect.top + rect.height / 2 : window.innerHeight / 2);
25
  const w = Math.min(600, item.width || 400);
26
  const h = item.height && item.width ? w * (item.height / item.width) : w;
27
- setImages(prev => [...prev, {
28
- id: crypto.randomUUID(),
29
- url: item.data_url || item.dataUrl || item.url,
30
- sourceUrl: item.source_url || item.sourceUrl || item.url,
31
- x: (-pan.x + clientX - (rect?.left || 0) - w / 2) / zoom,
32
- y: (-pan.y + clientY - (rect?.top || 0) - h / 2) / zoom,
33
- width: w,
34
- height: h,
35
- aspectRatio: w / h,
36
- }]);
37
  window.dispatchEvent(new CustomEvent('muse:library-refresh'));
38
  }, [pan, zoom, setImages]);
39
 
40
- // Tauri v2 native file drag-drop.
41
- // IMPORTANT: on Windows/WebView2 use getCurrentWebview(), not getCurrentWindow().
42
- // payload.type: enter|over|drop|leave. paths only exists on enter/drop.
43
  useEffect(() => {
44
- let disposed = false;
45
- let unlisten: (() => void) | undefined;
46
  async function setup() {
47
  try {
48
  unlisten = await getCurrentWebview().onDragDropEvent(async (event) => {
49
  const payload: any = event.payload;
50
- if (payload.type === 'enter' || payload.type === 'over') {
51
- setIsDropActive(true);
52
- return;
53
- }
54
- if (payload.type === 'leave') {
55
- setIsDropActive(false);
56
- return;
57
- }
58
  if (payload.type === 'drop') {
59
  setIsDropActive(false);
60
  const paths: string[] = (payload.paths || []).filter(isImagePath);
@@ -62,93 +42,47 @@ export const Canvas = () => {
62
  const dpr = window.devicePixelRatio || 1;
63
  const pos = payload.position ? { x: payload.position.x / dpr, y: payload.position.y / dpr } : undefined;
64
  for (const filePath of paths) {
65
- try {
66
- console.log('[Canvas] native drop import:', filePath, pos);
67
- const item: any = await invoke('library_import_local', { path: filePath });
68
- addLibraryItemToCanvas(item, pos?.x, pos?.y);
69
- } catch (err) {
70
- console.error('[Canvas] native drop import failed:', filePath, err);
71
- }
72
  }
73
  }
74
  });
75
  if (disposed && unlisten) unlisten();
76
- } catch (err) {
77
- console.error('[Canvas] failed to register native webview drop listener:', err);
78
- }
79
  }
80
- setup();
81
- return () => { disposed = true; if (unlisten) unlisten(); };
82
  }, [addLibraryItemToCanvas]);
83
 
84
- // HTML5 drag/drop for internal LibraryPanel -> Canvas drags, plus fallback file drops.
85
  useEffect(() => {
86
  const handleDragOver = (e: DragEvent) => { e.preventDefault(); if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'; };
87
  const handleDrop = async (e: DragEvent) => {
88
- e.preventDefault();
89
- setIsDropActive(false);
90
  const museData = e.dataTransfer?.getData('application/x-muse-library-item') || e.dataTransfer?.getData('text/plain');
91
- if (museData) {
92
- try {
93
- const payload = JSON.parse(museData);
94
- if (payload.data_url || payload.dataUrl) {
95
- addLibraryItemToCanvas(payload, e.clientX, e.clientY);
96
- return;
97
- }
98
- } catch {}
99
- }
100
- if (e.dataTransfer?.files?.length) {
101
- for (const file of Array.from(e.dataTransfer.files)) {
102
- if (!file.type.startsWith('image/')) continue;
103
- const reader = new FileReader();
104
- reader.onload = async (ev) => {
105
- const dataUrl = ev.target?.result as string;
106
- if (!dataUrl) return;
107
- try {
108
- const item: any = await invoke('library_import_data_url', { dataUrl, title: file.name.replace(/\.[^/.]+$/, '') });
109
- addLibraryItemToCanvas(item, e.clientX, e.clientY);
110
- } catch (err) {
111
- console.error('[Canvas] HTML5 drop import failed, direct fallback:', err);
112
- const img = new Image();
113
- img.onload = () => addLibraryItemToCanvas({ url: dataUrl, data_url: dataUrl, width: img.width, height: img.height }, e.clientX, e.clientY);
114
- img.src = dataUrl;
115
- }
116
- };
117
- reader.readAsDataURL(file);
118
- }
119
- }
120
  };
121
  const handleDragEnter = (e: DragEvent) => { e.preventDefault(); setIsDropActive(true); };
122
  const handleDragLeave = () => setIsDropActive(false);
123
- document.addEventListener('dragover', handleDragOver);
124
- document.addEventListener('drop', handleDrop);
125
- document.addEventListener('dragenter', handleDragEnter);
126
- document.addEventListener('dragleave', handleDragLeave);
127
  return () => { document.removeEventListener('dragover', handleDragOver); document.removeEventListener('drop', handleDrop); document.removeEventListener('dragenter', handleDragEnter); document.removeEventListener('dragleave', handleDragLeave); };
128
  }, [addLibraryItemToCanvas]);
129
 
130
- useEffect(() => { const handler = (e: Event) => setShowGrid((e as CustomEvent).detail); window.addEventListener('muse:toggle-grid', handler); return () => window.removeEventListener('muse:toggle-grid', handler); }, []);
131
  useEffect(() => { const up = (e: KeyboardEvent) => { if (e.code === 'Space') setIsSpaceDown(false); }; const down = (e: KeyboardEvent) => { if (e.code === 'Space' && !(e.target instanceof HTMLInputElement) && !(e.target instanceof HTMLTextAreaElement) && !(e.target as HTMLElement)?.isContentEditable) { e.preventDefault(); setIsSpaceDown(true); } }; window.addEventListener('keydown', down); window.addEventListener('keyup', up); return () => { window.removeEventListener('keydown', down); window.removeEventListener('keyup', up); }; }, []);
132
 
133
  const handleWheel = (e: React.WheelEvent) => { e.preventDefault(); const s = 1 - Math.sign(e.deltaY) * 0.1; const nz = Math.max(0.1, Math.min(zoom * s, 10)); if (containerRef.current) { const r = containerRef.current.getBoundingClientRect(); const mx = e.clientX - r.left, my = e.clientY - r.top; setPan({ x: mx - (mx - pan.x) * (nz / zoom), y: my - (my - pan.y) * (nz / zoom) }); } setZoom(nz); };
134
  const handlePointerDown = (e: React.PointerEvent) => { setContextMenu(null); if (isSpaceDown || e.button === 1) { setIsDraggingCanvas(true); e.currentTarget.setPointerCapture(e.pointerId); } else if (isAnnotationMode && e.button === 0) { setIsDrawing(true); const r = (e.currentTarget as HTMLElement).getBoundingClientRect(); setCurrentPath([{ x: (e.clientX - r.left - pan.x) / zoom, y: (e.clientY - r.top - pan.y) / zoom }]); e.currentTarget.setPointerCapture(e.pointerId); } else { if (e.target === e.currentTarget || (e.target as HTMLElement).id === 'canvas-inner') setSelectedNodeIds([]); } };
135
  const handlePointerMove = (e: React.PointerEvent) => { if (isDraggingCanvas) { setPan(p => ({ x: p.x + e.movementX, y: p.y + e.movementY })); } else if (isDrawing) { const r = (e.currentTarget as HTMLElement).getBoundingClientRect(); const x = (e.clientX - r.left - pan.x) / zoom, y = (e.clientY - r.top - pan.y) / zoom; if (isEraser) { const er = annotationSize / zoom * 2; setAnnotations(prev => prev.filter(ann => !ann.points.some(p => Math.hypot(p.x - x, p.y - y) <= er + ann.strokeWidth))); } else { setCurrentPath(prev => [...prev, { x, y }]); } } };
136
- const handlePointerUp = (e: React.PointerEvent) => { setIsDraggingCanvas(false); if (isDrawing) { setIsDrawing(false); if (currentPath.length > 1 && !isEraser) { setAnnotations(prev => [...prev, { id: crypto.randomUUID(), points: currentPath, color: annotationColor, strokeWidth: (annotationSize / zoom) * (isHighlighter ? 3 : 1), isHighlighter: isHighlighter || undefined }]); } setCurrentPath([]); } try { e.currentTarget.releasePointerCapture(e.pointerId); } catch {} };
137
 
138
- return (
139
- <div ref={containerRef} className={`absolute inset-0 w-full h-full overflow-hidden ${isSpaceDown ? 'cursor-grab' : isAnnotationMode ? 'cursor-crosshair' : 'cursor-default'} ${isDraggingCanvas ? '!cursor-grabbing' : ''}`} onWheel={handleWheel} onPointerDown={handlePointerDown} onPointerMove={handlePointerMove} onPointerUp={handlePointerUp} onContextMenu={(e) => { e.preventDefault(); setContextMenu({ x: e.clientX, y: e.clientY, imageId: null }); }}>
140
- {isDropActive && <div className="absolute inset-0 z-[100] pointer-events-none flex items-center justify-center"><div className="absolute inset-4 border-2 border-dashed border-[#0A84FF] rounded-2xl bg-[#0A84FF]/5" /><div className="relative z-10 bg-[#2A2A2E] border border-[#0A84FF] rounded-xl px-6 py-4 shadow-2xl flex flex-col items-center gap-2"><svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="#0A84FF" strokeWidth="2"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3"/></svg><span className="text-[#0A84FF] font-semibold text-sm">Drop images to add to canvas</span><span className="text-[#808080] text-xs">Images will be saved to your Asset Library</span></div></div>}
141
- {showGrid && <div className="absolute inset-0 pointer-events-none opacity-[0.06]" style={{ backgroundImage: 'radial-gradient(circle, #fff 1px, transparent 1px)', backgroundSize: `${24 * zoom}px ${24 * zoom}px`, backgroundPosition: `${pan.x % (24 * zoom)}px ${pan.y % (24 * zoom)}px` }} />}
142
- <div id="canvas-inner" style={{ transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`, transformOrigin: '0 0' }} className="w-full h-full absolute top-0 left-0 z-10">
143
- {Array.from(new Set(images.filter(img => img.groupId).map(img => img.groupId!))).map(gid => { const gi = images.filter(img => img.groupId === gid); if (!gi.length) return null; const minX = Math.min(...gi.map(i => i.x)), minY = Math.min(...gi.map(i => i.y)), maxX = Math.max(...gi.map(i => i.x + i.width)), maxY = Math.max(...gi.map(i => i.y + i.height)); return <div key={`g-${gid}`} className="absolute bg-white/5 border border-white/20 rounded-xl pointer-events-none" style={{ left: minX-20, top: minY-40, width: maxX-minX+40, height: maxY-minY+60, zIndex: 0 }}><div className="text-gray-500 text-xs font-semibold uppercase tracking-wider pl-4 pt-2">Group</div></div>; })}
144
- {images.map(img => <RefImageNode key={img.id} image={img} />)}
145
- {textNotes?.map(note => <TextNoteNode key={note.id} note={note} />)}
146
- {palettes.map(p => { const img = images.find(i => i.id === p.imageId); if (!img) return null; const ss = Math.max(18, Math.min(42, img.width / Math.max(6, p.colors.length + 1))); return (<div key={`pal-${p.imageId}`} className="absolute flex flex-row gap-1 bg-[#1C1C1E] p-1.5 rounded-lg shadow-xl cursor-default pointer-events-auto" style={{ left: img.x, top: img.y + img.height + 10, zIndex: 8000 }} onPointerDown={e => e.stopPropagation()}>{p.colors.map(c => (<div key={c} className="rounded-md cursor-pointer hover:scale-110 transition-transform shadow-inner flex items-center justify-center group" style={{ backgroundColor: c, width: ss, height: ss }} title={c} onClick={e => { e.stopPropagation(); navigator.clipboard.writeText(c); }}><div className="opacity-0 group-hover:opacity-100 bg-black/60 text-white text-[9px] px-1 rounded backdrop-blur">COPY</div></div>))}<div className="flex items-center justify-center text-gray-500 hover:text-white cursor-pointer" style={{ width: Math.max(18, ss*0.7), height: ss }} onClick={() => setPalettes(prev => prev.filter(x => x.imageId !== p.imageId))}>×</div></div>); })}
147
- <svg className="absolute inset-0 w-full h-full pointer-events-none z-[9999]" style={{ overflow: 'visible' }}>
148
- {annotations.map(ann => <polyline key={ann.id} points={ann.points.map(p => `${p.x},${p.y}`).join(' ')} fill="none" stroke={ann.color} strokeWidth={ann.strokeWidth} strokeLinecap={ann.isHighlighter ? 'square' : 'round'} strokeLinejoin={ann.isHighlighter ? 'miter' : 'round'} opacity={ann.isHighlighter ? 0.35 : 1} style={ann.isHighlighter ? { mixBlendMode: 'screen' } : undefined} />)}
149
- {isDrawing && currentPath.length > 0 && !isEraser && <polyline points={currentPath.map(p => `${p.x},${p.y}`).join(' ')} fill="none" stroke={annotationColor} strokeWidth={(annotationSize / zoom) * (isHighlighter ? 3 : 1)} strokeLinecap={isHighlighter ? 'square' : 'round'} strokeLinejoin={isHighlighter ? 'miter' : 'round'} opacity={isHighlighter ? 0.35 : 1} style={isHighlighter ? { mixBlendMode: 'screen' } : undefined} />}
150
- </svg>
151
- </div>
152
  </div>
153
- );
154
  };
 
9
  function isImagePath(path: string) { const p = path.toLowerCase(); return IMAGE_EXTS.some(ext => p.endsWith(ext)); }
10
 
11
  export const Canvas = () => {
12
+ const { images, setImages, textNotes, annotations, setAnnotations, palettes, setPalettes, zoom, setZoom, pan, setPan, setSelectedNodeIds, setContextMenu, isAnnotationMode, annotationColor, annotationSize, isEraser, isHighlighter, showGrid } = useAppStore();
13
  const containerRef = useRef<HTMLDivElement>(null);
14
  const [isSpaceDown, setIsSpaceDown] = useState(false);
15
  const [isDraggingCanvas, setIsDraggingCanvas] = useState(false);
16
  const [isDrawing, setIsDrawing] = useState(false);
17
  const [currentPath, setCurrentPath] = useState<{x: number, y: number}[]>([]);
 
18
  const [isDropActive, setIsDropActive] = useState(false);
19
 
20
  const addLibraryItemToCanvas = useCallback((item: any, cssX?: number, cssY?: number) => {
 
23
  const clientY = cssY ?? (rect ? rect.top + rect.height / 2 : window.innerHeight / 2);
24
  const w = Math.min(600, item.width || 400);
25
  const h = item.height && item.width ? w * (item.height / item.width) : w;
26
+ setImages(prev => [...prev, { id: crypto.randomUUID(), url: item.data_url || item.dataUrl || item.url, sourceUrl: item.source_url || item.sourceUrl || item.url, x: (-pan.x + clientX - (rect?.left || 0) - w / 2) / zoom, y: (-pan.y + clientY - (rect?.top || 0) - h / 2) / zoom, width: w, height: h, aspectRatio: w / h }]);
 
 
 
 
 
 
 
 
 
27
  window.dispatchEvent(new CustomEvent('muse:library-refresh'));
28
  }, [pan, zoom, setImages]);
29
 
 
 
 
30
  useEffect(() => {
31
+ let disposed = false; let unlisten: (() => void) | undefined;
 
32
  async function setup() {
33
  try {
34
  unlisten = await getCurrentWebview().onDragDropEvent(async (event) => {
35
  const payload: any = event.payload;
36
+ if (payload.type === 'enter' || payload.type === 'over') { setIsDropActive(true); return; }
37
+ if (payload.type === 'leave') { setIsDropActive(false); return; }
 
 
 
 
 
 
38
  if (payload.type === 'drop') {
39
  setIsDropActive(false);
40
  const paths: string[] = (payload.paths || []).filter(isImagePath);
 
42
  const dpr = window.devicePixelRatio || 1;
43
  const pos = payload.position ? { x: payload.position.x / dpr, y: payload.position.y / dpr } : undefined;
44
  for (const filePath of paths) {
45
+ try { const item: any = await invoke('library_import_local', { path: filePath }); addLibraryItemToCanvas(item, pos?.x, pos?.y); }
46
+ catch (err) { console.error('[Canvas] native drop import failed:', filePath, err); }
 
 
 
 
 
47
  }
48
  }
49
  });
50
  if (disposed && unlisten) unlisten();
51
+ } catch (err) { console.error('[Canvas] failed to register native webview drop listener:', err); }
 
 
52
  }
53
+ setup(); return () => { disposed = true; if (unlisten) unlisten(); };
 
54
  }, [addLibraryItemToCanvas]);
55
 
 
56
  useEffect(() => {
57
  const handleDragOver = (e: DragEvent) => { e.preventDefault(); if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'; };
58
  const handleDrop = async (e: DragEvent) => {
59
+ e.preventDefault(); setIsDropActive(false);
 
60
  const museData = e.dataTransfer?.getData('application/x-muse-library-item') || e.dataTransfer?.getData('text/plain');
61
+ if (museData) { try { const payload = JSON.parse(museData); if (payload.data_url || payload.dataUrl) { addLibraryItemToCanvas(payload, e.clientX, e.clientY); return; } } catch {} }
62
+ if (e.dataTransfer?.files?.length) for (const file of Array.from(e.dataTransfer.files)) { if (!file.type.startsWith('image/')) continue; const reader = new FileReader(); reader.onload = async (ev) => { const dataUrl = ev.target?.result as string; if (!dataUrl) return; try { const item: any = await invoke('library_import_data_url', { dataUrl, title: file.name.replace(/\.[^/.]+$/, '') }); addLibraryItemToCanvas(item, e.clientX, e.clientY); } catch (err) { const img = new Image(); img.onload = () => addLibraryItemToCanvas({ url: dataUrl, data_url: dataUrl, width: img.width, height: img.height }, e.clientX, e.clientY); img.src = dataUrl; } }; reader.readAsDataURL(file); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
63
  };
64
  const handleDragEnter = (e: DragEvent) => { e.preventDefault(); setIsDropActive(true); };
65
  const handleDragLeave = () => setIsDropActive(false);
66
+ document.addEventListener('dragover', handleDragOver); document.addEventListener('drop', handleDrop); document.addEventListener('dragenter', handleDragEnter); document.addEventListener('dragleave', handleDragLeave);
 
 
 
67
  return () => { document.removeEventListener('dragover', handleDragOver); document.removeEventListener('drop', handleDrop); document.removeEventListener('dragenter', handleDragEnter); document.removeEventListener('dragleave', handleDragLeave); };
68
  }, [addLibraryItemToCanvas]);
69
 
 
70
  useEffect(() => { const up = (e: KeyboardEvent) => { if (e.code === 'Space') setIsSpaceDown(false); }; const down = (e: KeyboardEvent) => { if (e.code === 'Space' && !(e.target instanceof HTMLInputElement) && !(e.target instanceof HTMLTextAreaElement) && !(e.target as HTMLElement)?.isContentEditable) { e.preventDefault(); setIsSpaceDown(true); } }; window.addEventListener('keydown', down); window.addEventListener('keyup', up); return () => { window.removeEventListener('keydown', down); window.removeEventListener('keyup', up); }; }, []);
71
 
72
  const handleWheel = (e: React.WheelEvent) => { e.preventDefault(); const s = 1 - Math.sign(e.deltaY) * 0.1; const nz = Math.max(0.1, Math.min(zoom * s, 10)); if (containerRef.current) { const r = containerRef.current.getBoundingClientRect(); const mx = e.clientX - r.left, my = e.clientY - r.top; setPan({ x: mx - (mx - pan.x) * (nz / zoom), y: my - (my - pan.y) * (nz / zoom) }); } setZoom(nz); };
73
  const handlePointerDown = (e: React.PointerEvent) => { setContextMenu(null); if (isSpaceDown || e.button === 1) { setIsDraggingCanvas(true); e.currentTarget.setPointerCapture(e.pointerId); } else if (isAnnotationMode && e.button === 0) { setIsDrawing(true); const r = (e.currentTarget as HTMLElement).getBoundingClientRect(); setCurrentPath([{ x: (e.clientX - r.left - pan.x) / zoom, y: (e.clientY - r.top - pan.y) / zoom }]); e.currentTarget.setPointerCapture(e.pointerId); } else { if (e.target === e.currentTarget || (e.target as HTMLElement).id === 'canvas-inner') setSelectedNodeIds([]); } };
74
  const handlePointerMove = (e: React.PointerEvent) => { if (isDraggingCanvas) { setPan(p => ({ x: p.x + e.movementX, y: p.y + e.movementY })); } else if (isDrawing) { const r = (e.currentTarget as HTMLElement).getBoundingClientRect(); const x = (e.clientX - r.left - pan.x) / zoom, y = (e.clientY - r.top - pan.y) / zoom; if (isEraser) { const er = annotationSize / zoom * 2; setAnnotations(prev => prev.filter(ann => !ann.points.some(p => Math.hypot(p.x - x, p.y - y) <= er + ann.strokeWidth))); } else { setCurrentPath(prev => [...prev, { x, y }]); } } };
75
+ const handlePointerUp = (e: React.PointerEvent) => { setIsDraggingCanvas(false); if (isDrawing) { setIsDrawing(false); if (currentPath.length > 1 && !isEraser) setAnnotations(prev => [...prev, { id: crypto.randomUUID(), points: currentPath, color: annotationColor, strokeWidth: (annotationSize / zoom) * (isHighlighter ? 3 : 1), isHighlighter: isHighlighter || undefined }]); setCurrentPath([]); } try { e.currentTarget.releasePointerCapture(e.pointerId); } catch {} };
76
 
77
+ return <div ref={containerRef} className={`absolute inset-0 w-full h-full overflow-hidden ${isSpaceDown ? 'cursor-grab' : isAnnotationMode ? 'cursor-crosshair' : 'cursor-default'} ${isDraggingCanvas ? '!cursor-grabbing' : ''}`} onWheel={handleWheel} onPointerDown={handlePointerDown} onPointerMove={handlePointerMove} onPointerUp={handlePointerUp} onContextMenu={(e) => { e.preventDefault(); setContextMenu({ x: e.clientX, y: e.clientY, imageId: null }); }}>
78
+ {isDropActive && <div className="absolute inset-0 z-[100] pointer-events-none flex items-center justify-center"><div className="absolute inset-4 border-2 border-dashed border-[#0A84FF] rounded-2xl bg-[#0A84FF]/5" /><div className="relative z-10 bg-[#2A2A2E] border border-[#0A84FF] rounded-xl px-6 py-4 shadow-2xl flex flex-col items-center gap-2"><svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="#0A84FF" strokeWidth="2"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3"/></svg><span className="text-[#0A84FF] font-semibold text-sm">Drop images to add to canvas</span><span className="text-[#808080] text-xs">Images will be saved to your Asset Library</span></div></div>}
79
+ {showGrid && <div className="absolute inset-0 pointer-events-none opacity-[0.06]" style={{ backgroundImage: 'radial-gradient(circle, #fff 1px, transparent 1px)', backgroundSize: `${24 * zoom}px ${24 * zoom}px`, backgroundPosition: `${pan.x % (24 * zoom)}px ${pan.y % (24 * zoom)}px` }} />}
80
+ <div id="canvas-inner" style={{ transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`, transformOrigin: '0 0' }} className="w-full h-full absolute top-0 left-0 z-10">
81
+ {Array.from(new Set(images.filter(img => img.groupId).map(img => img.groupId!))).map(gid => { const gi = images.filter(img => img.groupId === gid); if (!gi.length) return null; const minX = Math.min(...gi.map(i => i.x)), minY = Math.min(...gi.map(i => i.y)), maxX = Math.max(...gi.map(i => i.x + i.width)), maxY = Math.max(...gi.map(i => i.y + i.height)); return <div key={`g-${gid}`} className="absolute bg-white/5 border border-white/20 rounded-xl pointer-events-none" style={{ left: minX-20, top: minY-40, width: maxX-minX+40, height: maxY-minY+60, zIndex: 0 }}><div className="text-gray-500 text-xs font-semibold uppercase tracking-wider pl-4 pt-2">Group</div></div>; })}
82
+ {images.map(img => <RefImageNode key={img.id} image={img} />)}
83
+ {textNotes?.map(note => <TextNoteNode key={note.id} note={note} />)}
84
+ {palettes.map(p => { const img = images.find(i => i.id === p.imageId); if (!img) return null; const ss = Math.max(18, Math.min(42, img.width / Math.max(6, p.colors.length + 1))); return <div key={`pal-${p.imageId}`} className="absolute flex flex-row gap-1 bg-[#1C1C1E] p-1.5 rounded-lg shadow-xl cursor-default pointer-events-auto" style={{ left: img.x, top: img.y + img.height + 10, zIndex: 8000 }} onPointerDown={e => e.stopPropagation()}>{p.colors.map(c => <div key={c} className="rounded-md cursor-pointer hover:scale-110 transition-transform shadow-inner flex items-center justify-center group" style={{ backgroundColor: c, width: ss, height: ss }} title={c} onClick={e => { e.stopPropagation(); navigator.clipboard.writeText(c); }}><div className="opacity-0 group-hover:opacity-100 bg-black/60 text-white text-[9px] px-1 rounded backdrop-blur">COPY</div></div>)}<div className="flex items-center justify-center text-gray-500 hover:text-white cursor-pointer" style={{ width: Math.max(18, ss*0.7), height: ss }} onClick={() => setPalettes(prev => prev.filter(x => x.imageId !== p.imageId))}>×</div></div>; })}
85
+ <svg className="absolute inset-0 w-full h-full pointer-events-none z-[9999]" style={{ overflow: 'visible' }}>{annotations.map(ann => <polyline key={ann.id} points={ann.points.map(p => `${p.x},${p.y}`).join(' ')} fill="none" stroke={ann.color} strokeWidth={ann.strokeWidth} strokeLinecap={ann.isHighlighter ? 'square' : 'round'} strokeLinejoin={ann.isHighlighter ? 'miter' : 'round'} opacity={ann.isHighlighter ? 0.35 : 1} style={ann.isHighlighter ? { mixBlendMode: 'screen' } : undefined} />)}{isDrawing && currentPath.length > 0 && !isEraser && <polyline points={currentPath.map(p => `${p.x},${p.y}`).join(' ')} fill="none" stroke={annotationColor} strokeWidth={(annotationSize / zoom) * (isHighlighter ? 3 : 1)} strokeLinecap={isHighlighter ? 'square' : 'round'} strokeLinejoin={isHighlighter ? 'miter' : 'round'} opacity={isHighlighter ? 0.35 : 1} style={isHighlighter ? { mixBlendMode: 'screen' } : undefined} />}</svg>
 
 
 
 
 
86
  </div>
87
+ </div>;
88
  };