File size: 12,348 Bytes
c1780a2 2fad479 c1780a2 e3f857f c1780a2 d58fa0e c1780a2 2fad479 e3f857f 2fad479 e3f857f d58fa0e e3f857f c1780a2 d58fa0e 680ab59 d58fa0e e3f857f c1780a2 680ab59 c1780a2 e3f857f d58fa0e c1780a2 e3f857f c1780a2 e3f857f c1780a2 e3f857f d58fa0e c1780a2 680ab59 d58fa0e 680ab59 d58fa0e c1780a2 d58fa0e c1780a2 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 | import React, { useRef, useEffect, useState, useCallback } from 'react';
import { useAppStore } from '../store';
import { RefImageNode } from './RefImageNode';
import { TextNoteNode } from './TextNoteNode';
import { invoke } from '@tauri-apps/api/core';
import { getCurrentWebview } from '@tauri-apps/api/webview';
const IMAGE_EXTS = ['.png', '.jpg', '.jpeg', '.webp', '.gif', '.bmp', '.avif', '.tif', '.tiff'];
function isImagePath(path: string) { const p = path.toLowerCase(); return IMAGE_EXTS.some(ext => p.endsWith(ext)); }
export const Canvas = () => {
const { images, setImages, textNotes, annotations, setAnnotations, palettes, setPalettes, zoom, setZoom, pan, setPan, setSelectedNodeIds, setContextMenu, isAnnotationMode, annotationColor, annotationSize, isEraser, isHighlighter, showGrid } = useAppStore();
const containerRef = useRef<HTMLDivElement>(null);
const [isSpaceDown, setIsSpaceDown] = useState(false);
const [isDraggingCanvas, setIsDraggingCanvas] = useState(false);
const [isDrawing, setIsDrawing] = useState(false);
const [currentPath, setCurrentPath] = useState<{x: number, y: number}[]>([]);
const [isDropActive, setIsDropActive] = useState(false);
const addLibraryItemToCanvas = useCallback((item: any, cssX?: number, cssY?: number) => {
const rect = containerRef.current?.getBoundingClientRect();
const clientX = cssX ?? (rect ? rect.left + rect.width / 2 : window.innerWidth / 2);
const clientY = cssY ?? (rect ? rect.top + rect.height / 2 : window.innerHeight / 2);
const w = Math.min(600, item.width || 400);
const h = item.height && item.width ? w * (item.height / item.width) : w;
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 }]);
window.dispatchEvent(new CustomEvent('muse:library-refresh'));
}, [pan, zoom, setImages]);
useEffect(() => {
let disposed = false; let unlisten: (() => void) | undefined;
async function setup() { try { unlisten = await getCurrentWebview().onDragDropEvent(async (event) => { const payload: any = event.payload; if (payload.type === 'enter' || payload.type === 'over') { setIsDropActive(true); return; } if (payload.type === 'leave') { setIsDropActive(false); return; } if (payload.type === 'drop') { setIsDropActive(false); const paths: string[] = (payload.paths || []).filter(isImagePath); const dpr = window.devicePixelRatio || 1; const pos = payload.position ? { x: payload.position.x / dpr, y: payload.position.y / dpr } : undefined; for (const filePath of paths) { try { const item: any = await invoke('library_import_local', { path: filePath }); addLibraryItemToCanvas(item, pos?.x, pos?.y); } catch (err) { console.error('[Canvas] native drop import failed:', filePath, err); } } } }); if (disposed && unlisten) unlisten(); } catch (err) { console.error('[Canvas] failed to register native webview drop listener:', err); } }
setup(); return () => { disposed = true; if (unlisten) unlisten(); };
}, [addLibraryItemToCanvas]);
useEffect(() => {
const handleDragOver = (e: DragEvent) => { e.preventDefault(); if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'; };
const handleDrop = async (e: DragEvent) => { e.preventDefault(); setIsDropActive(false); const museData = e.dataTransfer?.getData('application/x-muse-library-item') || e.dataTransfer?.getData('text/plain'); if (museData) { try { const payload = JSON.parse(museData); if (payload.data_url || payload.dataUrl) { addLibraryItemToCanvas(payload, e.clientX, e.clientY); return; } } catch {} } 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 { 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); } };
const handleDragEnter = (e: DragEvent) => { e.preventDefault(); setIsDropActive(true); };
const handleDragLeave = () => setIsDropActive(false);
document.addEventListener('dragover', handleDragOver); document.addEventListener('drop', handleDrop); document.addEventListener('dragenter', handleDragEnter); document.addEventListener('dragleave', handleDragLeave);
return () => { document.removeEventListener('dragover', handleDragOver); document.removeEventListener('drop', handleDrop); document.removeEventListener('dragenter', handleDragEnter); document.removeEventListener('dragleave', handleDragLeave); };
}, [addLibraryItemToCanvas]);
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); }; }, []);
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); };
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([]); } };
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 }]); } } };
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 {} };
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={handlePointerUp} onContextMenu={(e) => { e.preventDefault(); setContextMenu({ x: e.clientX, y: e.clientY, imageId: null }); }}>
{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>}
{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` }} />}
<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">
{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>; })}
{images.map(img => <RefImageNode key={img.id} image={img} />)}{textNotes?.map(note => <TextNoteNode key={note.id} note={note} />)}
{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-[var(--panel-bg)] 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-[var(--ui-secondary)] hover:text-[var(--ui-primary)] cursor-pointer" style={{ width: Math.max(18, ss*0.7), height: ss }} onClick={() => setPalettes(prev => prev.filter(x => x.imageId !== p.imageId))}>×</div></div>; })}
<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>
</div>
</div>;
};
|