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>;
};