fix: align canvas drag-drop image support with library formats
Browse files- 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 |
-
|
|
|
|
| 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;
|
| 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">
|
| 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>; })}
|