cffy.in/public/snapmark/App.tsx

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;