asdf98 commited on
Commit
c1780a2
Β·
verified Β·
1 Parent(s): 6f44ee8

fix: Canvas drop handler - support library drag items + import OS files via Rust for persistent data URLs

Browse files
Files changed (1) hide show
  1. src/components/Canvas.tsx +173 -81
src/components/Canvas.tsx CHANGED
@@ -1,81 +1,173 @@
1
- import React, { useRef, useEffect, useState, useCallback } from 'react';
2
- import { useAppStore } from '../store';
3
- import { RefImageNode } from './RefImageNode';
4
- import { TextNoteNode } from './TextNoteNode';
5
-
6
- export const Canvas = () => {
7
- const { images, setImages, textNotes, annotations, setAnnotations, palettes, setPalettes, zoom, setZoom, pan, setPan, setSelectedNodeIds, setContextMenu, isAnnotationMode, annotationColor, annotationSize, isEraser, isHighlighter } = useAppStore();
8
- const containerRef = useRef<HTMLDivElement>(null);
9
- const [isSpaceDown, setIsSpaceDown] = useState(false);
10
- const [isDraggingCanvas, setIsDraggingCanvas] = useState(false);
11
- const [isDrawing, setIsDrawing] = useState(false);
12
- const [currentPath, setCurrentPath] = useState<{x: number, y: number}[]>([]);
13
- const [showGrid, setShowGrid] = useState(true);
14
-
15
- useEffect(() => {
16
- const handleDragOver = (e: DragEvent) => e.preventDefault();
17
- const handleDrop = (e: DragEvent) => {
18
- e.preventDefault();
19
- if (e.dataTransfer?.files?.length) {
20
- Array.from(e.dataTransfer.files).forEach(file => {
21
- if (file.type.startsWith('image/')) {
22
- const url = URL.createObjectURL(file);
23
- const img = new Image(); img.src = url;
24
- img.onload = () => { const ratio = img.width / img.height; const tw = Math.min(600, img.width); const th = tw / ratio; setImages(prev => [...prev, { id: crypto.randomUUID(), url, 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 }]); };
25
- }
26
- });
27
- }
28
- };
29
- document.addEventListener('dragover', handleDragOver); document.addEventListener('drop', handleDrop);
30
- return () => { document.removeEventListener('dragover', handleDragOver); document.removeEventListener('drop', handleDrop); };
31
- }, [pan, zoom, setImages]);
32
-
33
- useEffect(() => {
34
- const handler = (e: Event) => setShowGrid((e as CustomEvent).detail);
35
- window.addEventListener('muse:toggle-grid', handler);
36
- return () => window.removeEventListener('muse:toggle-grid', handler);
37
- }, []);
38
-
39
- useEffect(() => {
40
- const up = (e: KeyboardEvent) => { if (e.code === 'Space') setIsSpaceDown(false); };
41
- 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); } };
42
- window.addEventListener('keydown', down); window.addEventListener('keyup', up);
43
- return () => { window.removeEventListener('keydown', down); window.removeEventListener('keyup', up); };
44
- }, []);
45
-
46
- 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); };
47
-
48
- const handlePointerDown = (e: React.PointerEvent) => {
49
- setContextMenu(null);
50
- if (isSpaceDown || e.button === 1) { setIsDraggingCanvas(true); e.currentTarget.setPointerCapture(e.pointerId); }
51
- 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); }
52
- else { if (e.target === e.currentTarget || (e.target as HTMLElement).id === 'canvas-inner') setSelectedNodeIds([]); }
53
- };
54
-
55
- const handlePointerMove = (e: React.PointerEvent) => {
56
- if (isDraggingCanvas) { setPan(p => ({ x: p.x + e.movementX, y: p.y + e.movementY })); }
57
- 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 }]); } }
58
- };
59
-
60
- const handlePointerUp = (e: React.PointerEvent) => {
61
- setIsDraggingCanvas(false);
62
- 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([]); }
63
- try { e.currentTarget.releasePointerCapture(e.pointerId); } catch {}
64
- };
65
-
66
- return (
67
- <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 }); }}>
68
- {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` }} />}
69
- <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">
70
- {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>; })}
71
- {images.map(img => <RefImageNode key={img.id} image={img} />)}
72
- {textNotes?.map(note => <TextNoteNode key={note.id} note={note} />)}
73
- {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>); })}
74
- <svg className="absolute inset-0 w-full h-full pointer-events-none z-[9999]" style={{ overflow: 'visible' }}>
75
- {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} />)}
76
- {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} />}
77
- </svg>
78
- </div>
79
- </div>
80
- );
81
- };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useRef, useEffect, useState, useCallback } from 'react';
2
+ import { useAppStore } from '../store';
3
+ import { RefImageNode } from './RefImageNode';
4
+ 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);
11
+ const [isSpaceDown, setIsSpaceDown] = useState(false);
12
+ const [isDraggingCanvas, setIsDraggingCanvas] = useState(false);
13
+ const [isDrawing, setIsDrawing] = useState(false);
14
+ const [currentPath, setCurrentPath] = useState<{x: number, y: number}[]>([]);
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;
82
+ const reader = new FileReader();
83
+ reader.onload = async (ev) => {
84
+ const dataUrl = ev.target?.result as string;
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);
109
+ }
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>; })}
163
+ {images.map(img => <RefImageNode key={img.id} image={img} />)}
164
+ {textNotes?.map(note => <TextNoteNode key={note.id} note={note} />)}
165
+ {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>); })}
166
+ <svg className="absolute inset-0 w-full h-full pointer-events-none z-[9999]" style={{ overflow: 'visible' }}>
167
+ {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} />)}
168
+ {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} />}
169
+ </svg>
170
+ </div>
171
+ </div>
172
+ );
173
+ };