280 lines
11 KiB
TypeScript
280 lines
11 KiB
TypeScript
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
|
import Konva from 'konva';
|
|
import Toolbar from './components/Toolbar';
|
|
import PropertiesPanel from './components/PropertiesPanel';
|
|
import EditorCanvas from './components/EditorCanvas';
|
|
import { Shape, ToolType } from './types';
|
|
import { Upload, Home } from 'lucide-react';
|
|
|
|
const App: React.FC = () => {
|
|
const [imageSrc, setImageSrc] = useState<string | null>(null);
|
|
const [imageDimensions, setImageDimensions] = useState({ width: 0, height: 0 });
|
|
const [shapes, setShapes] = useState<Shape[]>([]);
|
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
|
const [tool, setTool] = useState<ToolType>('select');
|
|
|
|
const stageRef = useRef<Konva.Stage>(null);
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
// Handle global paste
|
|
useEffect(() => {
|
|
const handlePaste = (e: ClipboardEvent) => {
|
|
const items = e.clipboardData?.items;
|
|
if (!items) return;
|
|
|
|
for (const item of items) {
|
|
if (item.type.indexOf('image') !== -1) {
|
|
const blob = item.getAsFile();
|
|
if (blob) {
|
|
const url = URL.createObjectURL(blob);
|
|
setImageSrc(url);
|
|
setShapes([]); // Clear shapes on new image
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
window.addEventListener('paste', handlePaste);
|
|
return () => window.removeEventListener('paste', handlePaste);
|
|
}, []);
|
|
|
|
// Handle keyboard delete
|
|
useEffect(() => {
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
if ((e.key === 'Delete' || e.key === 'Backspace') && selectedId) {
|
|
// Don't delete if editing text in a textarea
|
|
const activeTag = document.activeElement?.tagName.toLowerCase();
|
|
if (activeTag === 'input' || activeTag === 'textarea') return;
|
|
|
|
setShapes((prev) => prev.filter((s) => s.id !== selectedId));
|
|
setSelectedId(null);
|
|
}
|
|
};
|
|
window.addEventListener('keydown', handleKeyDown);
|
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
|
}, [selectedId]);
|
|
|
|
const handleExport = () => {
|
|
if (stageRef.current) {
|
|
// Deselect before export to hide transformer
|
|
const currentSelected = selectedId;
|
|
setSelectedId(null);
|
|
|
|
// We need to wait for the state update and re-render of Konva layer (transformer removal)
|
|
requestAnimationFrame(() => {
|
|
if (!stageRef.current) return;
|
|
|
|
// Export pixel ratio 2 for better quality
|
|
const uri = stageRef.current.toDataURL({ pixelRatio: 2 });
|
|
const link = document.createElement('a');
|
|
link.download = `snapmark-export-${Date.now()}.png`;
|
|
link.href = uri;
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
document.body.removeChild(link);
|
|
|
|
// Restore selection
|
|
setSelectedId(currentSelected);
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleCopy = () => {
|
|
if (stageRef.current) {
|
|
const currentSelected = selectedId;
|
|
setSelectedId(null);
|
|
|
|
// Wait for re-render to remove transformer handles
|
|
setTimeout(() => {
|
|
if (!stageRef.current) return;
|
|
|
|
stageRef.current.toBlob({
|
|
pixelRatio: 2,
|
|
callback: (blob) => {
|
|
if (blob) {
|
|
navigator.clipboard.write([
|
|
new ClipboardItem({ 'image/png': blob })
|
|
]).then(() => {
|
|
// Could show a toast here, but for now we just restore state
|
|
}).catch((err) => {
|
|
console.error('Failed to copy to clipboard', err);
|
|
alert('Failed to copy image to clipboard');
|
|
});
|
|
}
|
|
setSelectedId(currentSelected);
|
|
}
|
|
});
|
|
}, 50);
|
|
}
|
|
};
|
|
|
|
const handleUpdateShape = (id: string, attrs: Partial<Shape>) => {
|
|
setShapes((prev) =>
|
|
prev.map((s) => (s.id === id ? { ...s, ...attrs } : s))
|
|
);
|
|
};
|
|
|
|
// Prevent infinite loop by checking if values actually changed
|
|
const onImageLoad = useCallback((width: number, height: number) => {
|
|
setImageDimensions(prev => {
|
|
if (prev.width === width && prev.height === height) {
|
|
return prev;
|
|
}
|
|
return { width, height };
|
|
});
|
|
}, []);
|
|
|
|
const handleClear = () => {
|
|
if(window.confirm('Clear all annotations and image?')) {
|
|
setImageSrc(null);
|
|
setShapes([]);
|
|
setImageDimensions({ width: 0, height: 0 });
|
|
}
|
|
}
|
|
|
|
const handleDeleteSelected = () => {
|
|
if (selectedId) {
|
|
setShapes((prev) => prev.filter((s) => s.id !== selectedId));
|
|
setSelectedId(null);
|
|
}
|
|
}
|
|
|
|
const handleBackgroundClick = (e: React.MouseEvent) => {
|
|
// If clicking strictly on the background container (not the canvas or its children)
|
|
if (e.target === e.currentTarget) {
|
|
setSelectedId(null);
|
|
}
|
|
}
|
|
|
|
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const file = e.target.files?.[0];
|
|
if (file) {
|
|
const url = URL.createObjectURL(file);
|
|
setImageSrc(url);
|
|
setShapes([]);
|
|
}
|
|
};
|
|
|
|
const selectedShape = shapes.find((s) => s.id === selectedId) || null;
|
|
|
|
return (
|
|
<div className="flex h-screen w-screen bg-amber-50 text-amber-900 overflow-hidden font-comic">
|
|
{/* Toolbar */}
|
|
<Toolbar
|
|
currentTool={tool}
|
|
setTool={setTool}
|
|
onExport={handleExport}
|
|
onCopy={handleCopy}
|
|
onClear={handleClear}
|
|
hasImage={!!imageSrc}
|
|
onDeleteSelected={handleDeleteSelected}
|
|
hasSelection={!!selectedId}
|
|
/>
|
|
|
|
{/* Main Area */}
|
|
<main className="flex-1 relative overflow-hidden flex flex-col">
|
|
{/* Header/Info */}
|
|
<div className="h-20 bg-white/70 backdrop-blur-sm border-b-4 border-amber-900 flex items-center px-6 justify-between shrink-0 z-10">
|
|
<div className="flex items-center gap-4">
|
|
<a href="../index.html" className="p-2 hover:bg-amber-200 rounded-full transition-colors flex items-center justify-center border-2 border-transparent hover:border-amber-900" title="Back to Home">
|
|
<Home size={24} className="text-amber-900" strokeWidth={2.5} />
|
|
</a>
|
|
<h1 className="flex items-center gap-3">
|
|
<div className="bg-amber-900 text-amber-50 p-2 transform -rotate-3 border-2 border-black shadow-[4px_4px_0px_0px_rgba(0,0,0,0.2)]">
|
|
<span className="font-impact text-2xl tracking-wide">SM</span>
|
|
</div>
|
|
<div className="flex flex-col">
|
|
<span className="font-impact text-3xl text-amber-900 leading-none">SNAPMARK</span>
|
|
<span className="text-xs font-bold text-amber-700 tracking-wider">Highlight • Annotate • Export</span>
|
|
</div>
|
|
</h1>
|
|
</div>
|
|
|
|
<div className="text-lg font-bold text-amber-800/50 hidden md:block">
|
|
{imageSrc ? '✨ Make it pop! ✨' : 'Waiting for image...'}
|
|
</div>
|
|
|
|
<div>
|
|
<button
|
|
onClick={() => fileInputRef.current?.click()}
|
|
className="font-impact text-lg bg-cyan-400 border-2 border-black text-black px-6 py-2 transition-all hover:-translate-y-1 hover:shadow-[4px_4px_0px_0px_black] active:translate-y-0 active:shadow-none flex items-center gap-2"
|
|
>
|
|
<Upload size={20} strokeWidth={2.5} /> OPEN IMAGE
|
|
</button>
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
accept="image/*"
|
|
className="hidden"
|
|
onChange={handleFileUpload}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Canvas Scroll Area */}
|
|
<div
|
|
className="flex-1 overflow-auto flex items-center justify-center p-8 relative"
|
|
onClick={handleBackgroundClick}
|
|
>
|
|
|
|
{/* Decorative background elements */}
|
|
<div className="absolute top-10 left-10 text-6xl opacity-20 animate-bounce pointer-events-none select-none">☕</div>
|
|
<div className="absolute bottom-20 right-20 text-4xl opacity-20 animate-pulse pointer-events-none select-none">✨</div>
|
|
|
|
{imageSrc ? (
|
|
<div
|
|
className="border-4 border-amber-900 bg-white shadow-[8px_8px_0px_0px_rgba(69,26,3,0.2)]"
|
|
style={{
|
|
width: imageDimensions.width,
|
|
height: imageDimensions.height,
|
|
// Ensure it doesn't collapse if width is 0 during loading
|
|
minWidth: imageDimensions.width || 300,
|
|
minHeight: imageDimensions.height || 200,
|
|
}}
|
|
>
|
|
<EditorCanvas
|
|
imageSrc={imageSrc}
|
|
shapes={shapes}
|
|
setShapes={setShapes}
|
|
selectedId={selectedId}
|
|
setSelectedId={setSelectedId}
|
|
tool={tool}
|
|
setTool={setTool}
|
|
stageRef={stageRef}
|
|
onImageDimensionsChange={onImageLoad}
|
|
canvasWidth={imageDimensions.width}
|
|
canvasHeight={imageDimensions.height}
|
|
/>
|
|
</div>
|
|
) : (
|
|
<div
|
|
onClick={(e) => {
|
|
e.stopPropagation(); // Prevent background click logic
|
|
fileInputRef.current?.click();
|
|
}}
|
|
className="group flex flex-col items-center justify-center bg-white border-4 border-dashed border-amber-900/40 rounded-3xl w-[500px] h-[350px] p-8 transition-all hover:border-amber-900 hover:bg-amber-100/50 hover:scale-105 cursor-pointer relative overflow-hidden"
|
|
>
|
|
<div className="absolute inset-0 bg-[radial-gradient(#78350f_1px,transparent_1px)] [background-size:16px_16px] opacity-5"></div>
|
|
<div className="bg-amber-100 p-6 rounded-full mb-6 border-4 border-amber-200 group-hover:border-amber-400 group-hover:bg-amber-200 transition-colors">
|
|
<Upload size={64} className="text-amber-700" strokeWidth={2.5} />
|
|
</div>
|
|
<h2 className="font-impact text-4xl text-amber-900 mb-2">PASTE IMAGE</h2>
|
|
<p className="text-xl font-bold text-amber-700/70 group-hover:text-amber-800">Ctrl+V / Cmd+V</p>
|
|
<p className="mt-6 text-sm font-bold bg-amber-900 text-amber-50 px-3 py-1 rounded-full opacity-0 group-hover:opacity-100 transition-opacity transform translate-y-2 group-hover:translate-y-0">
|
|
or click to browse
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</main>
|
|
|
|
{/* Properties Panel */}
|
|
<PropertiesPanel
|
|
selectedShape={selectedShape}
|
|
updateShape={handleUpdateShape}
|
|
/>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default App; |