asdf98 commited on
Commit
db7a938
verified
1 Parent(s): 3aaa12e

fix: align canvas drag-drop image support with library formats

Browse files
Files changed (1) hide show
  1. src/components/Canvas.tsx +8 -13
src/components/Canvas.tsx CHANGED
@@ -5,8 +5,8 @@ import { TextNoteNode } from './TextNoteNode';
5
  import { invoke } from '@tauri-apps/api/core';
6
  import { getCurrentWebview } from '@tauri-apps/api/webview';
7
 
8
- const IMAGE_EXTS = ['.png', '.jpg', '.jpeg', '.webp', '.gif', '.bmp'];
9
- const SUPPORTED_IMAGE_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp', 'image/gif', 'image/bmp']);
10
  const MIN_ANNOTATION_POINT_DISTANCE = 1.5;
11
  function isImagePath(path: string) { const p = path.toLowerCase(); return IMAGE_EXTS.some(ext => p.endsWith(ext)); }
12
  function toast(msg: string) { window.dispatchEvent(new CustomEvent('lumaref:toast', { detail: msg })); }
@@ -19,8 +19,6 @@ export const Canvas = () => {
19
  const [isDrawing, setIsDrawing] = useState(false);
20
  const [currentPath, setCurrentPath] = useState<{x: number, y: number}[]>([]);
21
  const [isDropActive, setIsDropActive] = useState(false);
22
- // FIX: Store the zoom level at which the current stroke was started, so we can render
23
- // the in-progress stroke at the correct visual width regardless of zoom changes mid-stroke
24
  const strokeZoomRef = useRef(zoom);
25
 
26
  const addLibraryItemToCanvas = useCallback((item: any, cssX?: number, cssY?: number) => {
@@ -46,6 +44,8 @@ export const Canvas = () => {
46
  if (payload.type === 'drop') {
47
  setIsDropActive(false);
48
  const paths: string[] = (payload.paths || []).filter(isImagePath);
 
 
49
  const dpr = window.devicePixelRatio || 1;
50
  const pos = payload.position ? { x: payload.position.x / dpr, y: payload.position.y / dpr } : undefined;
51
  let imported = 0;
@@ -70,7 +70,8 @@ export const Canvas = () => {
70
  if (libraryData) { try { const payload = JSON.parse(libraryData); if (payload.data_url || payload.dataUrl) { addLibraryItemToCanvas(payload, e.clientX, e.clientY); toast('Added image to canvas'); return; } } catch {} }
71
  if (e.dataTransfer?.files?.length) {
72
  for (const file of Array.from(e.dataTransfer.files)) {
73
- if (!SUPPORTED_IMAGE_TYPES.has(file.type)) { toast(`Unsupported image type: ${file.name}`); continue; }
 
74
  const reader = new FileReader();
75
  reader.onload = async (ev) => {
76
  const dataUrl = ev.target?.result as string;
@@ -107,7 +108,7 @@ export const Canvas = () => {
107
  e.currentTarget.setPointerCapture(e.pointerId);
108
  } else if (isAnnotationMode && e.button === 0) {
109
  setIsDrawing(true);
110
- strokeZoomRef.current = zoom; // FIX: Capture zoom at stroke start
111
  setCurrentPath([toWorld(e)]);
112
  e.currentTarget.setPointerCapture(e.pointerId);
113
  } else {
@@ -138,11 +139,6 @@ export const Canvas = () => {
138
  if (isDrawing) {
139
  setIsDrawing(false);
140
  if (currentPath.length > 1 && !isEraser) {
141
- // FIX: Store strokeWidth as a fixed world-space value based on annotationSize.
142
- // Previously divided by zoom which made strokes appear different at different zoom levels.
143
- // Now we store a consistent base width: annotationSize is the visual px size at zoom=1.
144
- // The stroke renders in world coordinates inside the scaled SVG, so it automatically
145
- // scales with the canvas transform. We use annotationSize directly as the world-space width.
146
  const baseStrokeWidth = annotationSize * (isHighlighter ? 3 : 1);
147
  setAnnotations(prev => [...prev, {
148
  id: crypto.randomUUID(),
@@ -157,11 +153,10 @@ export const Canvas = () => {
157
  try { e.currentTarget.releasePointerCapture(e.pointerId); } catch {}
158
  };
159
 
160
- // FIX: Calculate current stroke preview width consistently with how it will be stored
161
  const currentStrokeWidth = annotationSize * (isHighlighter ? 3 : 1);
162
 
163
  return <div ref={containerRef} className={`absolute inset-0 w-full h-full overflow-hidden bg-[var(--canvas-bg)] ${isSpaceDown ? 'cursor-grab' : isAnnotationMode ? 'cursor-crosshair' : 'cursor-default'} ${isDraggingCanvas ? '!cursor-grabbing' : ''}`} onWheel={handleWheel} onPointerDown={handlePointerDown} onPointerMove={handlePointerMove} onPointerUp={finishPointer} onPointerCancel={finishPointer} onPointerLeave={e => { if (isDrawing || isDraggingCanvas) finishPointer(e); }} onContextMenu={(e) => { e.preventDefault(); setContextMenu({ x: e.clientX, y: e.clientY, imageId: null }); }}>
164
- {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-[var(--accent)] rounded-2xl bg-[var(--accent)]/5" /><div className="relative z-10 bg-[var(--panel-surface)] border border-[var(--accent)] 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="var(--accent)" 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-[var(--accent)] font-semibold text-sm">Drop images to add to canvas</span><span className="text-[var(--ui-secondary)] text-xs">Images will be saved to your Asset Library</span></div></div>}
165
  {showGrid && <div className="absolute inset-0 pointer-events-none opacity-[0.06]" style={{ backgroundImage: 'radial-gradient(circle, var(--ui-primary) 1px, transparent 1px)', backgroundSize: `${24 * zoom}px ${24 * zoom}px`, backgroundPosition: `${pan.x % (24 * zoom)}px ${pan.y % (24 * zoom)}px` }} />}
166
  <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">
167
  {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 { getCurrentWebview } from '@tauri-apps/api/webview';
7
 
8
+ const IMAGE_EXTS = ['.png', '.jpg', '.jpeg', '.webp', '.gif', '.bmp', '.ico', '.tif', '.tiff'];
9
+ const SUPPORTED_IMAGE_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp', 'image/gif', 'image/bmp', 'image/x-icon', 'image/vnd.microsoft.icon', 'image/tiff']);
10
  const MIN_ANNOTATION_POINT_DISTANCE = 1.5;
11
  function isImagePath(path: string) { const p = path.toLowerCase(); return IMAGE_EXTS.some(ext => p.endsWith(ext)); }
12
  function toast(msg: string) { window.dispatchEvent(new CustomEvent('lumaref:toast', { detail: msg })); }
 
19
  const [isDrawing, setIsDrawing] = useState(false);
20
  const [currentPath, setCurrentPath] = useState<{x: number, y: number}[]>([]);
21
  const [isDropActive, setIsDropActive] = useState(false);
 
 
22
  const strokeZoomRef = useRef(zoom);
23
 
24
  const addLibraryItemToCanvas = useCallback((item: any, cssX?: number, cssY?: number) => {
 
44
  if (payload.type === 'drop') {
45
  setIsDropActive(false);
46
  const paths: string[] = (payload.paths || []).filter(isImagePath);
47
+ const skipped = (payload.paths || []).length - paths.length;
48
+ if (skipped > 0) toast(`Skipped ${skipped} unsupported file${skipped === 1 ? '' : 's'}`);
49
  const dpr = window.devicePixelRatio || 1;
50
  const pos = payload.position ? { x: payload.position.x / dpr, y: payload.position.y / dpr } : undefined;
51
  let imported = 0;
 
70
  if (libraryData) { try { const payload = JSON.parse(libraryData); if (payload.data_url || payload.dataUrl) { addLibraryItemToCanvas(payload, e.clientX, e.clientY); toast('Added image to canvas'); return; } } catch {} }
71
  if (e.dataTransfer?.files?.length) {
72
  for (const file of Array.from(e.dataTransfer.files)) {
73
+ const looksSupported = SUPPORTED_IMAGE_TYPES.has(file.type) || isImagePath(file.name);
74
+ if (!looksSupported) { toast(`Unsupported image type: ${file.name}`); continue; }
75
  const reader = new FileReader();
76
  reader.onload = async (ev) => {
77
  const dataUrl = ev.target?.result as string;
 
108
  e.currentTarget.setPointerCapture(e.pointerId);
109
  } else if (isAnnotationMode && e.button === 0) {
110
  setIsDrawing(true);
111
+ strokeZoomRef.current = zoom;
112
  setCurrentPath([toWorld(e)]);
113
  e.currentTarget.setPointerCapture(e.pointerId);
114
  } else {
 
139
  if (isDrawing) {
140
  setIsDrawing(false);
141
  if (currentPath.length > 1 && !isEraser) {
 
 
 
 
 
142
  const baseStrokeWidth = annotationSize * (isHighlighter ? 3 : 1);
143
  setAnnotations(prev => [...prev, {
144
  id: crypto.randomUUID(),
 
153
  try { e.currentTarget.releasePointerCapture(e.pointerId); } catch {}
154
  };
155
 
 
156
  const currentStrokeWidth = annotationSize * (isHighlighter ? 3 : 1);
157
 
158
  return <div ref={containerRef} className={`absolute inset-0 w-full h-full overflow-hidden bg-[var(--canvas-bg)] ${isSpaceDown ? 'cursor-grab' : isAnnotationMode ? 'cursor-crosshair' : 'cursor-default'} ${isDraggingCanvas ? '!cursor-grabbing' : ''}`} onWheel={handleWheel} onPointerDown={handlePointerDown} onPointerMove={handlePointerMove} onPointerUp={finishPointer} onPointerCancel={finishPointer} onPointerLeave={e => { if (isDrawing || isDraggingCanvas) finishPointer(e); }} onContextMenu={(e) => { e.preventDefault(); setContextMenu({ x: e.clientX, y: e.clientY, imageId: null }); }}>
159
+ {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-[var(--accent)] rounded-2xl bg-[var(--accent)]/5" /><div className="relative z-10 bg-[var(--panel-surface)] border border-[var(--accent)] 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="var(--accent)" 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-[var(--accent)] font-semibold text-sm">Drop images to add to canvas</span><span className="text-[var(--ui-secondary)] text-xs">PNG JPG WEBP GIF 路 BMP 路 ICO 路 TIFF</span></div></div>}
160
  {showGrid && <div className="absolute inset-0 pointer-events-none opacity-[0.06]" style={{ backgroundImage: 'radial-gradient(circle, var(--ui-primary) 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>; })}