asdf98 commited on
Commit
e3f857f
·
verified ·
1 Parent(s): 26e115f

fix: canonical Tauri OS file drag-drop handler with correct v2 payload and drop position

Browse files
Files changed (1) hide show
  1. src/components/Canvas.tsx +79 -94
src/components/Canvas.tsx CHANGED
@@ -5,6 +5,9 @@ import { TextNoteNode } from './TextNoteNode';
5
  import { invoke } from '@tauri-apps/api/core';
6
  import { getCurrentWindow } from '@tauri-apps/api/window';
7
 
 
 
 
8
  export const Canvas = () => {
9
  const { images, setImages, textNotes, annotations, setAnnotations, palettes, setPalettes, zoom, setZoom, pan, setPan, setSelectedNodeIds, setContextMenu, isAnnotationMode, annotationColor, annotationSize, isEraser, isHighlighter } = useAppStore();
10
  const containerRef = useRef<HTMLDivElement>(null);
@@ -15,67 +18,89 @@ export const Canvas = () => {
15
  const [showGrid, setShowGrid] = useState(true);
16
  const [isDropActive, setIsDropActive] = useState(false);
17
 
18
- // ─── OS-level file drop via Tauri (fires for native file drags from Explorer/Finder) ───
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  useEffect(() => {
20
- const unlisten = getCurrentWindow().onDragDropEvent(async (event) => {
21
- if (event.payload.type === 'over') { setIsDropActive(true); }
22
- else if (event.payload.type === 'leave' || event.payload.type === 'cancel') { setIsDropActive(false); }
23
- else if (event.payload.type === 'drop') {
24
- setIsDropActive(false);
25
- const paths = event.payload.paths;
26
- if (!paths || paths.length === 0) return;
27
- const imageExts = ['.png', '.jpg', '.jpeg', '.webp', '.gif', '.bmp', '.avif', '.tiff'];
28
- for (const filePath of paths) {
29
- const lower = filePath.toLowerCase();
30
- if (!imageExts.some(ext => lower.endsWith(ext))) continue;
31
- try {
32
- const item: any = await invoke('library_import_local', { path: filePath });
33
- const w = Math.min(600, item.width || 400);
34
- const h = item.height ? w * (item.height / item.width) : w;
35
- setImages(prev => [...prev, {
36
- id: crypto.randomUUID(),
37
- url: item.data_url,
38
- x: (-pan.x + window.innerWidth / 2 - w / 2 + Math.random() * 80 - 40) / zoom,
39
- y: (-pan.y + window.innerHeight / 2 - h / 2 + Math.random() * 80 - 40) / zoom,
40
- width: w, height: h, aspectRatio: w / h,
41
- }]);
42
- } catch (err) {
43
- console.error(`[Canvas] Failed to import ${filePath}:`, err);
44
  }
45
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
46
  }
47
- });
48
- return () => { unlisten.then(fn => fn()); };
49
- }, [pan, zoom, setImages]);
 
50
 
51
- // ─── HTML5 drop handler: library items + browser file drops ───
 
52
  useEffect(() => {
53
  const handleDragOver = (e: DragEvent) => { e.preventDefault(); if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'; };
54
  const handleDrop = async (e: DragEvent) => {
55
  e.preventDefault();
56
  setIsDropActive(false);
57
 
58
- // Check for library drag data first
59
  const museData = e.dataTransfer?.getData('application/x-muse-library-item') || e.dataTransfer?.getData('text/plain');
60
  if (museData) {
61
  try {
62
  const payload = JSON.parse(museData);
63
- if (payload.data_url) {
64
- const w = Math.min(600, payload.width || 400);
65
- const h = payload.height ? w * (payload.height / payload.width) : w;
66
- setImages(prev => [...prev, {
67
- id: crypto.randomUUID(),
68
- url: payload.data_url,
69
- x: (-pan.x + (e.clientX || window.innerWidth / 2) - w / 2) / zoom,
70
- y: (-pan.y + (e.clientY || window.innerHeight / 2) - h / 2) / zoom,
71
- width: w, height: h, aspectRatio: w / h,
72
- }]);
73
  return;
74
  }
75
  } catch {}
76
  }
77
 
78
- // Fallback: browser file drop import through Rust for persistent data URLs
79
  if (e.dataTransfer?.files?.length) {
80
  for (const file of Array.from(e.dataTransfer.files)) {
81
  if (!file.type.startsWith('image/')) continue;
@@ -85,24 +110,12 @@ export const Canvas = () => {
85
  if (!dataUrl) return;
86
  try {
87
  const item: any = await invoke('library_import_data_url', { dataUrl, title: file.name.replace(/\.[^/.]+$/, '') });
88
- const w = Math.min(600, item.width || 400);
89
- const h = item.height ? w * (item.width ? item.height / item.width : 1) : w;
90
- setImages(prev => [...prev, {
91
- id: crypto.randomUUID(),
92
- url: item.data_url,
93
- x: (-pan.x + (e.clientX || window.innerWidth / 2) - w / 2 + Math.random() * 60 - 30) / zoom,
94
- y: (-pan.y + (e.clientY || window.innerHeight / 2) - h / 2 + Math.random() * 60 - 30) / zoom,
95
- width: w, height: h, aspectRatio: w / h,
96
- }]);
97
  } catch (err) {
98
- console.error('[Canvas] Import failed:', err);
99
- // Ultimate fallback: use blob URL (won't persist across sessions but works immediately)
100
- const img = new Image(); img.src = dataUrl;
101
- img.onload = () => {
102
- const ratio = img.width / img.height;
103
- const tw = Math.min(600, img.width); const th = tw / ratio;
104
- setImages(prev => [...prev, { id: crypto.randomUUID(), url: dataUrl, x: (-pan.x + (e.clientX || window.innerWidth / 2) - tw / 2) / zoom, y: (-pan.y + (e.clientY || window.innerHeight / 2) - th / 2) / zoom, width: tw, height: th, aspectRatio: ratio }]);
105
- };
106
  }
107
  };
108
  reader.readAsDataURL(file);
@@ -110,53 +123,25 @@ export const Canvas = () => {
110
  }
111
  };
112
  const handleDragEnter = (e: DragEvent) => { e.preventDefault(); setIsDropActive(true); };
113
- const handleDragLeave = (e: DragEvent) => { if (e.relatedTarget === null || !(e.currentTarget as Node)?.contains(e.relatedTarget as Node)) setIsDropActive(false); };
114
-
115
  document.addEventListener('dragover', handleDragOver);
116
  document.addEventListener('drop', handleDrop);
117
  document.addEventListener('dragenter', handleDragEnter);
118
  document.addEventListener('dragleave', handleDragLeave);
119
  return () => { document.removeEventListener('dragover', handleDragOver); document.removeEventListener('drop', handleDrop); document.removeEventListener('dragenter', handleDragEnter); document.removeEventListener('dragleave', handleDragLeave); };
120
- }, [pan, zoom, setImages]);
121
-
122
- useEffect(() => {
123
- const handler = (e: Event) => setShowGrid((e as CustomEvent).detail);
124
- window.addEventListener('muse:toggle-grid', handler);
125
- return () => window.removeEventListener('muse:toggle-grid', handler);
126
- }, []);
127
 
128
- useEffect(() => {
129
- const up = (e: KeyboardEvent) => { if (e.code === 'Space') setIsSpaceDown(false); };
130
- 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); } };
131
- window.addEventListener('keydown', down); window.addEventListener('keyup', up);
132
- return () => { window.removeEventListener('keydown', down); window.removeEventListener('keyup', up); };
133
- }, []);
134
 
135
  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); };
136
-
137
- const handlePointerDown = (e: React.PointerEvent) => {
138
- setContextMenu(null);
139
- if (isSpaceDown || e.button === 1) { setIsDraggingCanvas(true); e.currentTarget.setPointerCapture(e.pointerId); }
140
- 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); }
141
- else { if (e.target === e.currentTarget || (e.target as HTMLElement).id === 'canvas-inner') setSelectedNodeIds([]); }
142
- };
143
-
144
- const handlePointerMove = (e: React.PointerEvent) => {
145
- if (isDraggingCanvas) { setPan(p => ({ x: p.x + e.movementX, y: p.y + e.movementY })); }
146
- 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 }]); } }
147
- };
148
-
149
- const handlePointerUp = (e: React.PointerEvent) => {
150
- setIsDraggingCanvas(false);
151
- 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([]); }
152
- try { e.currentTarget.releasePointerCapture(e.pointerId); } catch {}
153
- };
154
 
155
  return (
156
  <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 }); }}>
157
- {/* Drop indicator overlay */}
158
  {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>}
159
-
160
  {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` }} />}
161
  <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">
162
  {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>; })}
 
5
  import { invoke } from '@tauri-apps/api/core';
6
  import { getCurrentWindow } from '@tauri-apps/api/window';
7
 
8
+ const IMAGE_EXTS = ['.png', '.jpg', '.jpeg', '.webp', '.gif', '.bmp', '.avif', '.tif', '.tiff'];
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);
 
18
  const [showGrid, setShowGrid] = useState(true);
19
  const [isDropActive, setIsDropActive] = useState(false);
20
 
21
+ const addLibraryItemToCanvas = useCallback((item: any, clientX?: number, clientY?: number) => {
22
+ const rect = containerRef.current?.getBoundingClientRect();
23
+ const cx = clientX ?? (rect ? rect.left + rect.width / 2 : window.innerWidth / 2);
24
+ const cy = clientY ?? (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 + cx - (rect?.left || 0) - w / 2) / zoom,
32
+ y: (-pan.y + cy - (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
+ // Canonical OS-level file drop handler via Tauri v2.
41
+ // Important: payload types are enter|over|drop|leave. paths exists only on enter/drop.
42
  useEffect(() => {
43
+ let disposed = false;
44
+ let unlisten: (() => void) | undefined;
45
+
46
+ async function setup() {
47
+ try {
48
+ unlisten = await getCurrentWindow().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);
61
+ if (!paths.length) return;
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] importing local image', filePath);
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] failed to import dropped file', filePath, err);
71
+ }
72
+ }
73
+ }
74
+ });
75
+ if (disposed && unlisten) unlisten();
76
+ } catch (err) {
77
+ console.error('[Canvas] failed to register Tauri drag-drop listener', err);
78
  }
79
+ }
80
+ setup();
81
+ return () => { disposed = true; if (unlisten) unlisten(); };
82
+ }, [addLibraryItemToCanvas]);
83
 
84
+ // HTML5 drag/drop for dragging items from the Library panel onto the Canvas.
85
+ // This does NOT depend on Tauri native file drop.
86
  useEffect(() => {
87
  const handleDragOver = (e: DragEvent) => { e.preventDefault(); if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'; };
88
  const handleDrop = async (e: DragEvent) => {
89
  e.preventDefault();
90
  setIsDropActive(false);
91
 
 
92
  const museData = e.dataTransfer?.getData('application/x-muse-library-item') || e.dataTransfer?.getData('text/plain');
93
  if (museData) {
94
  try {
95
  const payload = JSON.parse(museData);
96
+ if (payload.data_url || payload.dataUrl) {
97
+ addLibraryItemToCanvas(payload, e.clientX, e.clientY);
 
 
 
 
 
 
 
 
98
  return;
99
  }
100
  } catch {}
101
  }
102
 
103
+ // Browser fallback when dragDropEnabled is disabled or for web-origin file drops.
104
  if (e.dataTransfer?.files?.length) {
105
  for (const file of Array.from(e.dataTransfer.files)) {
106
  if (!file.type.startsWith('image/')) continue;
 
110
  if (!dataUrl) return;
111
  try {
112
  const item: any = await invoke('library_import_data_url', { dataUrl, title: file.name.replace(/\.[^/.]+$/, '') });
113
+ addLibraryItemToCanvas(item, e.clientX, e.clientY);
 
 
 
 
 
 
 
 
114
  } catch (err) {
115
+ console.error('[Canvas] data-url import failed, using direct data URL fallback', err);
116
+ const img = new Image();
117
+ img.onload = () => addLibraryItemToCanvas({ url: dataUrl, data_url: dataUrl, width: img.width, height: img.height }, e.clientX, e.clientY);
118
+ img.src = dataUrl;
 
 
 
 
119
  }
120
  };
121
  reader.readAsDataURL(file);
 
123
  }
124
  };
125
  const handleDragEnter = (e: DragEvent) => { e.preventDefault(); setIsDropActive(true); };
126
+ const handleDragLeave = () => setIsDropActive(false);
 
127
  document.addEventListener('dragover', handleDragOver);
128
  document.addEventListener('drop', handleDrop);
129
  document.addEventListener('dragenter', handleDragEnter);
130
  document.addEventListener('dragleave', handleDragLeave);
131
  return () => { document.removeEventListener('dragover', handleDragOver); document.removeEventListener('drop', handleDrop); document.removeEventListener('dragenter', handleDragEnter); document.removeEventListener('dragleave', handleDragLeave); };
132
+ }, [addLibraryItemToCanvas]);
 
 
 
 
 
 
133
 
134
+ useEffect(() => { const handler = (e: Event) => setShowGrid((e as CustomEvent).detail); window.addEventListener('muse:toggle-grid', handler); return () => window.removeEventListener('muse:toggle-grid', handler); }, []);
135
+ 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); }; }, []);
 
 
 
 
136
 
137
  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); };
138
+ 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([]); } };
139
+ 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 }]); } } };
140
+ 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 {} };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
141
 
142
  return (
143
  <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 }); }}>
 
144
  {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>}
 
145
  {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` }} />}
146
  <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">
147
  {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>; })}