Imported skeleton for Cloudflare app

This commit is contained in:
Coffee In 2025-12-13 09:24:22 +00:00
parent 0fbb8adc21
commit 8ed79cbd0b
30 changed files with 14478 additions and 13 deletions

12
.editorconfig Normal file
View File

@ -0,0 +1,12 @@
# http://editorconfig.org
root = true
[*]
indent_style = tab
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.yml]
indent_style = space

167
.gitignore vendored Normal file
View File

@ -0,0 +1,167 @@
# Logs
logs
_.log
npm-debug.log_
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# Runtime data
pids
_.pid
_.seed
\*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
\*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
\*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
\*.tgz
# Yarn Integrity file
.yarn-integrity
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.\*
# wrangler project
.dev.vars*
!.dev.vars.example
.env*
!.env.example
.wrangler/

6
.prettierrc Normal file
View File

@ -0,0 +1,6 @@
{
"printWidth": 140,
"singleQuote": true,
"semi": true,
"useTabs": true
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

15
package.json Normal file
View File

@ -0,0 +1,15 @@
{
"name": "muddy-frost-c06d",
"version": "0.0.0",
"private": true,
"scripts": {
"deploy": "wrangler deploy",
"dev": "wrangler dev",
"start": "wrangler dev",
"cf-typegen": "wrangler types"
},
"devDependencies": {
"typescript": "^5.5.2",
"wrangler": "^4.54.0"
}
}

1
public/game.html Normal file

File diff suppressed because one or more lines are too long

11
public/german.html Normal file

File diff suppressed because one or more lines are too long

1
public/index.html Normal file

File diff suppressed because one or more lines are too long

24
public/snapmark/.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

280
public/snapmark/App.tsx Normal file
View File

@ -0,0 +1,280 @@
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;

20
public/snapmark/README.md Normal file
View File

@ -0,0 +1,20 @@
<div align="center">
<img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" />
</div>
# Run and deploy your AI Studio app
This contains everything you need to run your app locally.
View your app in AI Studio: https://aistudio-preprod.corp.google.com/apps/drive/1Gnsn6mysur_ehem97si93qZBHSCa0_fm
## Run Locally
**Prerequisites:** Node.js
1. Install dependencies:
`npm install`
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
3. Run the app:
`npm run dev`

View File

@ -0,0 +1,419 @@
import React, { useRef, useEffect } from 'react';
import { Stage, Layer, Image as KonvaImage, Rect, Arrow, Text, Transformer } from 'react-konva';
import useImage from 'use-image';
import Konva from 'konva';
import { Shape, ToolType } from '../types';
// Helper component to load the image
const URLImage = ({ src, onLoaded }: { src: string; onLoaded: (w: number, h: number) => void }) => {
const [image] = useImage(src);
useEffect(() => {
if (image) {
onLoaded(image.width, image.height);
}
}, [image, onLoaded]);
return <KonvaImage image={image} />;
};
interface EditorCanvasProps {
imageSrc: string | null;
shapes: Shape[];
setShapes: React.Dispatch<React.SetStateAction<Shape[]>>;
selectedId: string | null;
setSelectedId: (id: string | null) => void;
tool: ToolType;
setTool: (tool: ToolType) => void;
stageRef: React.RefObject<Konva.Stage>;
onImageDimensionsChange: (width: number, height: number) => void;
canvasWidth?: number;
canvasHeight?: number;
}
const EditorCanvas: React.FC<EditorCanvasProps> = ({
imageSrc,
shapes,
setShapes,
selectedId,
setSelectedId,
tool,
setTool,
stageRef,
onImageDimensionsChange,
canvasWidth,
canvasHeight,
}) => {
const isDrawing = useRef(false);
const transformerRef = useRef<Konva.Transformer>(null);
// Attach transformer to selected shape
useEffect(() => {
if (selectedId && transformerRef.current && stageRef.current) {
const node = stageRef.current.findOne('#' + selectedId);
if (node) {
transformerRef.current.nodes([node]);
transformerRef.current.getLayer()?.batchDraw();
}
}
}, [selectedId, shapes]);
const handleTextDblClick = (e: Konva.KonvaEventObject<MouseEvent>) => {
const textNode = e.target as Konva.Text;
const stage = textNode.getStage();
if (!stage) return;
// Hide node while editing
textNode.hide();
// Determine position
const textPosition = textNode.absolutePosition();
const stageBox = stage.container().getBoundingClientRect();
const areaPosition = {
x: stageBox.left + textPosition.x,
y: stageBox.top + textPosition.y,
};
// Create textarea
const textarea = document.createElement('textarea');
document.body.appendChild(textarea);
textarea.value = textNode.text();
textarea.style.position = 'absolute';
textarea.style.top = `${areaPosition.y + window.scrollY}px`;
textarea.style.left = `${areaPosition.x + window.scrollX}px`;
textarea.style.width = `${textNode.width() - textNode.padding() * 2}px`;
textarea.style.height = `${textNode.height() - textNode.padding() * 2 + 5}px`;
textarea.style.fontSize = `${textNode.fontSize()}px`;
textarea.style.border = 'none';
textarea.style.padding = '0px';
textarea.style.margin = '0px';
textarea.style.overflow = 'hidden';
textarea.style.background = 'none';
textarea.style.outline = 'none';
textarea.style.resize = 'none';
textarea.style.lineHeight = textNode.lineHeight().toString();
textarea.style.fontFamily = textNode.fontFamily();
textarea.style.fontWeight = textNode.fontStyle();
textarea.style.transformOrigin = 'left top';
textarea.style.textAlign = textNode.align();
textarea.style.color = textNode.fill();
textarea.style.zIndex = '1000';
const rotation = textNode.rotation();
let transform = '';
if (rotation) {
transform += `rotateZ(${rotation}deg)`;
}
textarea.style.transform = transform;
// Auto-height
textarea.style.height = 'auto';
textarea.style.height = textarea.scrollHeight + 3 + 'px';
textarea.focus();
const removeTextarea = () => {
if (textarea.parentNode) {
textarea.parentNode.removeChild(textarea);
}
textNode.show();
};
const handleBlur = () => {
const newText = textarea.value;
const id = textNode.id();
setShapes(prev => prev.map(s => s.id === id ? { ...s, text: newText } : s));
removeTextarea();
};
const handleKeydown = (e: KeyboardEvent) => {
// Enter without shift updates text
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
textarea.blur();
}
// Escape cancels
if (e.key === 'Escape') {
removeTextarea();
}
};
textarea.addEventListener('blur', handleBlur);
textarea.addEventListener('keydown', handleKeydown);
};
const handleMouseDown = (e: Konva.KonvaEventObject<MouseEvent>) => {
if (tool === 'select') {
const clickedOnEmpty = e.target === e.target.getStage();
if (clickedOnEmpty) {
setSelectedId(null);
transformerRef.current?.nodes([]);
}
return;
}
isDrawing.current = true;
const stage = e.target.getStage();
const pos = stage?.getRelativePointerPosition();
if (!pos) return;
// Create new shape based on tool
const baseShape = {
id: Math.random().toString(36).substr(2, 9),
x: pos.x,
y: pos.y,
stroke: '#be185d', // Default to Pink (cffyin vibes)
strokeWidth: 4,
};
let newShape: Shape;
if (tool === 'rect') {
newShape = {
...baseShape,
type: 'rect',
width: 0,
height: 0,
fill: 'transparent',
cornerRadius: 15,
};
} else if (tool === 'arrow') {
newShape = {
...baseShape,
type: 'arrow',
points: [0, 0, 0, 0], // Relative points
};
} else if (tool === 'text') {
newShape = {
...baseShape,
type: 'text',
text: 'Double Click to Edit',
fontSize: 32,
fill: '#be185d',
};
// Text doesn't drag-create, it just places
isDrawing.current = false;
setShapes([...shapes, newShape]);
setSelectedId(newShape.id);
setTool('select');
return;
} else {
return;
}
setShapes([...shapes, newShape]);
setSelectedId(newShape.id);
};
const handleMouseMove = (e: Konva.KonvaEventObject<MouseEvent>) => {
if (!isDrawing.current || tool === 'select' || tool === 'text') return;
const stage = e.target.getStage();
const pos = stage?.getRelativePointerPosition();
if (!pos) return;
// Update the last shape
const lastShape = shapes[shapes.length - 1];
// Safety check to ensure we are updating the shape we just created
if(lastShape.id !== selectedId) return;
const newShapes = shapes.slice(0, shapes.length - 1);
if (lastShape.type === 'rect') {
newShapes.push({
...lastShape,
width: pos.x - lastShape.x,
height: pos.y - lastShape.y,
});
} else if (lastShape.type === 'arrow') {
const relX = pos.x - lastShape.x;
const relY = pos.y - lastShape.y;
newShapes.push({
...lastShape,
points: [0, 0, relX, relY],
});
}
setShapes(newShapes);
};
const handleMouseUp = () => {
if(isDrawing.current) {
isDrawing.current = false;
// Switch back to select tool after drawing usually improves UX for annotation tools
setTool('select');
}
};
const handleDragEnd = (e: Konva.KonvaEventObject<DragEvent>, id: string) => {
// Update shape position in state
const shapeIndex = shapes.findIndex(s => s.id === id);
if (shapeIndex === -1) return;
const updatedShapes = [...shapes];
updatedShapes[shapeIndex] = {
...updatedShapes[shapeIndex],
x: e.target.x(),
y: e.target.y(),
};
setShapes(updatedShapes);
};
const handleTransformEnd = (e: Konva.KonvaEventObject<Event>) => {
// transform end logic to save scale/rotation back to state
const node = e.target;
const id = node.id();
const shapeIndex = shapes.findIndex(s => s.id === id);
if (shapeIndex === -1) return;
const scaleX = node.scaleX();
const scaleY = node.scaleY();
// Reset scale to 1 and adjust width/height/points to prevent compounding scales
node.scaleX(1);
node.scaleY(1);
const updatedShapes = [...shapes];
const shape = updatedShapes[shapeIndex];
if (shape.type === 'rect') {
updatedShapes[shapeIndex] = {
...shape,
x: node.x(),
y: node.y(),
rotation: node.rotation(),
width: Math.max(5, node.width() * scaleX), // Prevent negative width
height: Math.max(5, node.height() * scaleY),
};
} else if (shape.type === 'text') {
updatedShapes[shapeIndex] = {
...shape,
x: node.x(),
y: node.y(),
rotation: node.rotation(),
fontSize: shape.fontSize * scaleX, // Approximation for text scaling
};
} else if (shape.type === 'arrow') {
updatedShapes[shapeIndex] = {
...shape,
x: node.x(),
y: node.y(),
rotation: node.rotation(),
};
// Re-apply the scale to the node so visual doesn't jump
node.scaleX(scaleX);
node.scaleY(scaleY);
}
setShapes(updatedShapes);
};
return (
<Stage
ref={stageRef}
width={canvasWidth || window.innerWidth}
height={canvasHeight || window.innerHeight}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
className="bg-white"
style={{ cursor: tool === 'select' ? 'default' : 'crosshair' }}
>
<Layer>
{imageSrc && (
<URLImage
src={imageSrc}
onLoaded={onImageDimensionsChange}
/>
)}
{shapes.map((shape) => {
const commonProps = {
key: shape.id,
id: shape.id,
x: shape.x,
y: shape.y,
rotation: shape.rotation || 0,
draggable: tool === 'select',
onClick: () => {
if(tool === 'select') setSelectedId(shape.id);
},
onTap: () => {
if(tool === 'select') setSelectedId(shape.id);
},
onDragEnd: (e: any) => handleDragEnd(e, shape.id),
onTransformEnd: handleTransformEnd,
};
if (shape.type === 'rect') {
return (
<Rect
{...commonProps}
width={shape.width}
height={shape.height}
stroke={shape.stroke}
strokeWidth={shape.strokeWidth}
fill={shape.fill}
cornerRadius={shape.cornerRadius}
/>
);
} else if (shape.type === 'arrow') {
return (
<Arrow
{...commonProps}
points={shape.points}
stroke={shape.stroke}
strokeWidth={shape.strokeWidth}
fill={shape.stroke} // Arrow head fill
pointerLength={10}
pointerWidth={10}
/>
);
} else if (shape.type === 'text') {
return (
<Text
{...commonProps}
text={shape.text}
fontSize={shape.fontSize}
fill={shape.stroke}
stroke={'#FFFF00'} // Radiant yellow outline
strokeWidth={2} // Outline thickness
strokeJoin="round" // For better outline appearance
fontFamily="Comic Sans MS, Comic Sans, cursive"
fontStyle="bold"
onDblClick={handleTextDblClick}
onDblTap={handleTextDblClick}
/>
);
}
return null;
})}
{selectedId && (
<Transformer
ref={transformerRef}
boundBoxFunc={(oldBox, newBox) => {
// limit resize
if (newBox.width < 5 || newBox.height < 5) {
return oldBox;
}
return newBox;
}}
rotateEnabled={true}
borderStroke="#d97706" // amber-600
anchorStroke="#451a03" // amber-950
anchorFill="#fcd34d" // amber-300
anchorSize={10}
anchorCornerRadius={2}
/>
)}
</Layer>
</Stage>
);
};
export default EditorCanvas;

View File

@ -0,0 +1,136 @@
import React from 'react';
import { Shape, COLORS, STROKE_WIDTHS, FONT_SIZES } from '../types';
interface PropertiesPanelProps {
selectedShape: Shape | null;
updateShape: (id: string, attrs: Partial<Shape>) => void;
}
const PropertiesPanel: React.FC<PropertiesPanelProps> = ({
selectedShape,
updateShape,
}) => {
if (!selectedShape) {
return (
<div className="w-72 bg-amber-100 border-l-4 border-amber-900 p-6 flex flex-col items-center justify-center text-center text-amber-800/60 font-comic shrink-0">
<span className="text-4xl mb-4 opacity-50">👆</span>
<p className="font-bold text-lg">Select something!</p>
<p className="text-sm">Click an item to tweak it.</p>
</div>
);
}
return (
<div className="w-72 bg-amber-100 border-l-4 border-amber-900 p-6 flex flex-col gap-8 overflow-y-auto shrink-0 font-comic">
<div className="border-b-2 border-amber-900/20 pb-4">
<h2 className="font-impact text-2xl text-amber-900 uppercase tracking-wide">
Edit {selectedShape.type}
</h2>
</div>
<div>
<h3 className="text-sm font-bold text-amber-900 uppercase tracking-wider mb-3 flex items-center gap-2">
Color <span className="text-xs bg-amber-200 px-1 border border-amber-900 rounded-sm text-amber-900 font-normal normal-case">Pick one</span>
</h3>
<div className="grid grid-cols-6 gap-3">
{COLORS.map((color) => (
<button
key={color}
onClick={() => updateShape(selectedShape.id, { stroke: color, fill: selectedShape.type === 'text' ? color : undefined })}
className={`w-8 h-8 rounded-full border-2 focus:outline-none transition-transform hover:scale-110 ${
selectedShape.stroke === color
? 'ring-2 ring-offset-2 ring-amber-900 border-black scale-110'
: 'border-amber-900/30 hover:border-amber-900'
}`}
style={{ backgroundColor: color }}
title={color}
/>
))}
</div>
</div>
{(selectedShape.type === 'rect' || selectedShape.type === 'arrow') && (
<div>
<h3 className="text-sm font-bold text-amber-900 uppercase tracking-wider mb-3">
Thickness
</h3>
<div className="flex gap-2 bg-amber-50 p-2 border-2 border-amber-900 shadow-[2px_2px_0px_0px_rgba(69,26,3,0.1)]">
{STROKE_WIDTHS.map((width) => (
<button
key={width}
onClick={() => updateShape(selectedShape.id, { strokeWidth: width })}
className={`flex-1 py-1 text-sm font-bold border-2 transition-all ${
selectedShape.strokeWidth === width
? 'bg-amber-900 text-white border-black shadow-sm'
: 'bg-white text-amber-900 border-transparent hover:border-amber-200'
}`}
>
{width}
</button>
))}
</div>
</div>
)}
{selectedShape.type === 'text' && (
<>
<div>
<h3 className="text-sm font-bold text-amber-900 uppercase tracking-wider mb-3">
Size
</h3>
<select
value={(selectedShape as any).fontSize}
onChange={(e) =>
updateShape(selectedShape.id, { fontSize: Number(e.target.value) })
}
className="w-full bg-white border-2 border-amber-900 text-amber-900 font-bold p-2 shadow-[4px_4px_0px_0px_rgba(69,26,3,0.1)] focus:outline-none focus:shadow-[2px_2px_0px_0px_rgba(69,26,3,0.1)] focus:translate-x-[2px] focus:translate-y-[2px]"
>
{FONT_SIZES.map((size) => (
<option key={size} value={size}>
{size}px
</option>
))}
</select>
</div>
<div>
<h3 className="text-sm font-bold text-amber-900 uppercase tracking-wider mb-3">
Text
</h3>
<textarea
rows={4}
value={(selectedShape as any).text}
onChange={(e) => updateShape(selectedShape.id, { text: e.target.value })}
className="w-full bg-white border-2 border-amber-900 text-amber-900 p-2 font-comic shadow-[4px_4px_0px_0px_rgba(69,26,3,0.1)] focus:outline-none focus:shadow-[2px_2px_0px_0px_rgba(69,26,3,0.1)] focus:translate-x-[2px] focus:translate-y-[2px]"
/>
</div>
</>
)}
{selectedShape.type === 'rect' && (
<div>
<h3 className="text-sm font-bold text-amber-900 uppercase tracking-wider mb-3">
Fill
</h3>
<div className="flex flex-col gap-2">
<button
onClick={() => updateShape(selectedShape.id, { fill: 'transparent' })}
className={`px-3 py-2 text-sm font-bold border-2 text-left flex items-center gap-2 ${selectedShape.fill === 'transparent' ? 'bg-white border-amber-900 text-amber-900 shadow-[2px_2px_0px_0px_rgba(69,26,3,0.2)]' : 'border-transparent text-amber-800/70 hover:bg-amber-200/50'}`}
>
<div className="w-4 h-4 border border-amber-900 bg-white"></div>
Empty (Transparent)
</button>
<button
onClick={() => updateShape(selectedShape.id, { fill: selectedShape.stroke + '40' })}
className={`px-3 py-2 text-sm font-bold border-2 text-left flex items-center gap-2 ${selectedShape.fill !== 'transparent' ? 'bg-white border-amber-900 text-amber-900 shadow-[2px_2px_0px_0px_rgba(69,26,3,0.2)]' : 'border-transparent text-amber-800/70 hover:bg-amber-200/50'}`}
>
<div className="w-4 h-4 border border-amber-900 bg-current opacity-40"></div>
Filled (Transparent)
</button>
</div>
</div>
)}
</div>
);
};
export default PropertiesPanel;

View File

@ -0,0 +1,101 @@
import React from 'react';
import { MousePointer2, Square, MoveRight, Type, Download, Trash2, ImagePlus, Copy } from 'lucide-react';
import { ToolType } from '../types';
interface ToolbarProps {
currentTool: ToolType;
setTool: (tool: ToolType) => void;
onExport: () => void;
onCopy: () => void;
onClear: () => void;
hasImage: boolean;
onDeleteSelected: () => void;
hasSelection: boolean;
}
const Toolbar: React.FC<ToolbarProps> = ({
currentTool,
setTool,
onExport,
onCopy,
onClear,
hasImage,
onDeleteSelected,
hasSelection,
}) => {
const tools = [
{ id: 'select', icon: MousePointer2, label: 'Select' },
{ id: 'rect', icon: Square, label: 'Box' },
{ id: 'arrow', icon: MoveRight, label: 'Arrow' },
{ id: 'text', icon: Type, label: 'Text' },
] as const;
return (
<div className="bg-amber-100 border-r-4 border-amber-900 w-20 flex flex-col items-center py-6 gap-4 z-20 shrink-0">
<div className="flex flex-col gap-3 w-full px-2">
{tools.map((tool) => (
<button
key={tool.id}
onClick={() => setTool(tool.id)}
className={`p-3 rounded-none border-2 transition-all duration-200 flex flex-col items-center justify-center group relative shadow-[4px_4px_0px_0px_rgba(69,26,3,0.3)] hover:translate-x-[1px] hover:translate-y-[1px] hover:shadow-[3px_3px_0px_0px_rgba(69,26,3,0.3)] active:translate-x-[4px] active:translate-y-[4px] active:shadow-none ${
currentTool === tool.id
? 'bg-cyan-400 border-black text-black'
: 'bg-white border-amber-900 text-amber-900 hover:bg-amber-50'
}`}
title={tool.label}
>
<tool.icon size={24} strokeWidth={2.5} />
</button>
))}
</div>
<div className="h-1 w-12 bg-amber-900/20 my-2 rounded-full" />
<div className="flex flex-col gap-3 w-full px-2">
<button
onClick={onDeleteSelected}
disabled={!hasSelection}
className={`p-3 border-2 rounded-none transition-all duration-200 flex flex-col items-center justify-center group relative shadow-[4px_4px_0px_0px_rgba(190,24,93,0.3)] hover:translate-x-[1px] hover:translate-y-[1px] hover:shadow-[3px_3px_0px_0px_rgba(190,24,93,0.3)] active:translate-x-[4px] active:translate-y-[4px] active:shadow-none ${
!hasSelection
? 'bg-gray-100 border-gray-400 text-gray-400 cursor-not-allowed shadow-none'
: 'bg-pink-400 border-black text-black hover:bg-pink-300'
}`}
title="Delete Selected"
>
<Trash2 size={24} strokeWidth={2.5} />
</button>
<button
onClick={onClear}
disabled={!hasImage}
className="p-3 border-2 rounded-none bg-white border-amber-900 text-amber-900 hover:bg-red-50 hover:text-red-700 hover:border-red-700 transition-all duration-200 flex flex-col items-center justify-center group relative shadow-[4px_4px_0px_0px_rgba(69,26,3,0.3)] hover:shadow-[3px_3px_0px_0px_rgba(69,26,3,0.3)] active:translate-x-[4px] active:translate-y-[4px] active:shadow-none disabled:opacity-50 disabled:cursor-not-allowed disabled:shadow-none"
title="Clear All"
>
<ImagePlus size={24} strokeWidth={2.5} />
</button>
<div className="mt-auto flex flex-col gap-3 w-full">
<button
onClick={onCopy}
disabled={!hasImage}
className="p-3 border-2 rounded-none bg-violet-400 border-black text-black hover:bg-violet-300 transition-all duration-200 flex flex-col items-center justify-center group relative shadow-[4px_4px_0px_0px_rgba(69,26,3,0.3)] hover:translate-x-[1px] hover:translate-y-[1px] hover:shadow-[3px_3px_0px_0px_rgba(69,26,3,0.3)] active:translate-x-[4px] active:translate-y-[4px] active:shadow-none disabled:opacity-50 disabled:cursor-not-allowed disabled:shadow-none"
title="Copy to Clipboard"
>
<Copy size={24} strokeWidth={2.5} />
</button>
<button
onClick={onExport}
disabled={!hasImage}
className="p-3 border-2 rounded-none bg-lime-400 border-black text-black hover:bg-lime-300 transition-all duration-200 flex flex-col items-center justify-center group relative shadow-[4px_4px_0px_0px_rgba(69,26,3,0.3)] hover:translate-x-[1px] hover:translate-y-[1px] hover:shadow-[3px_3px_0px_0px_rgba(69,26,3,0.3)] active:translate-x-[4px] active:translate-y-[4px] active:shadow-none disabled:opacity-50 disabled:cursor-not-allowed disabled:shadow-none"
title="Export PNG"
>
<Download size={24} strokeWidth={2.5} />
</button>
</div>
</div>
</div>
);
};
export default Toolbar;

View File

@ -0,0 +1,55 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SnapMark | cffy.in</title>
<script src="https://cdn.tailwindcss.com"></script>
<style>
.font-impact {
font-family: Impact, Haettenschweiler, 'Arial Narrow Bold', sans-serif;
}
.font-comic {
font-family: "Comic Sans MS", "Comic Sans", cursive;
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 12px;
height: 12px;
}
::-webkit-scrollbar-track {
background: #fffbeb; /* amber-50 */
border-left: 2px solid #78350f; /* amber-900 */
}
::-webkit-scrollbar-thumb {
background: #fcd34d; /* amber-300 */
border: 2px solid #78350f; /* amber-900 */
border-radius: 0;
}
::-webkit-scrollbar-thumb:hover {
background: #fbbf24; /* amber-400 */
}
</style>
<script type="importmap">
{
"imports": {
"react-dom/": "https://aistudiocdn.com/react-dom@^19.2.1/",
"react/": "https://aistudiocdn.com/react@^19.2.1/",
"react": "https://aistudiocdn.com/react@^19.2.1",
"lucide-react": "https://aistudiocdn.com/lucide-react@^0.556.0",
"react-konva": "https://aistudiocdn.com/react-konva@^19.2.1",
"konva": "https://aistudiocdn.com/konva@^10.0.12",
"use-image": "https://aistudiocdn.com/use-image@^1.1.4"
}
}
</script>
</head>
<body class="bg-amber-50 text-amber-900 h-screen w-screen overflow-hidden selection:bg-cyan-300 selection:text-amber-900">
<!-- Background Pattern -->
<div style="background-image:radial-gradient(circle,#78350f 1px,transparent 1px);background-size:20px 20px" class="absolute inset-0 opacity-10 pointer-events-none z-0"></div>
<div id="root" class="h-full w-full relative z-10"></div>
<script type="module" src="/index.tsx"></script>
</body>
</html>

15
public/snapmark/index.tsx Normal file
View File

@ -0,0 +1,15 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
const rootElement = document.getElementById('root');
if (!rootElement) {
throw new Error("Could not find root element to mount to");
}
const root = ReactDOM.createRoot(rootElement);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);

View File

@ -0,0 +1,5 @@
{
"name": "SnapMark",
"description": "A vector-based image annotation tool. Paste images, add editable highlights, arrows, and text, then export as PNG.",
"requestFramePermissions": []
}

1904
public/snapmark/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,25 @@
{
"name": "snapmark",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react-dom": "^19.2.1",
"react": "^19.2.1",
"lucide-react": "^0.556.0",
"react-konva": "^19.2.1",
"konva": "^10.0.12",
"use-image": "^1.1.4"
},
"devDependencies": {
"@types/node": "^22.14.0",
"@vitejs/plugin-react": "^5.0.0",
"typescript": "~5.8.2",
"vite": "^6.2.0"
}
}

View File

@ -0,0 +1,29 @@
{
"compilerOptions": {
"target": "ES2022",
"experimentalDecorators": true,
"useDefineForClassFields": false,
"module": "ESNext",
"lib": [
"ES2022",
"DOM",
"DOM.Iterable"
],
"skipLibCheck": true,
"types": [
"node"
],
"moduleResolution": "bundler",
"isolatedModules": true,
"moduleDetection": "force",
"allowJs": true,
"jsx": "react-jsx",
"paths": {
"@/*": [
"./*"
]
},
"allowImportingTsExtensions": true,
"noEmit": true
}
}

46
public/snapmark/types.ts Normal file
View File

@ -0,0 +1,46 @@
export type ToolType = 'select' | 'rect' | 'arrow' | 'text';
export interface BaseShape {
id: string;
type: ToolType;
x: number;
y: number;
rotation?: number;
stroke: string;
strokeWidth: number;
}
export interface RectShape extends BaseShape {
type: 'rect';
width: number;
height: number;
fill: string;
cornerRadius: number;
}
export interface ArrowShape extends BaseShape {
type: 'arrow';
points: number[]; // [x1, y1, x2, y2, ...] relative to x,y
}
export interface TextShape extends BaseShape {
type: 'text';
text: string;
fontSize: number;
fill: string;
}
export type Shape = RectShape | ArrowShape | TextShape;
export const COLORS = [
'#be185d', // pink-700
'#0891b2', // cyan-600
'#65a30d', // lime-600
'#d97706', // amber-600
'#451a03', // amber-950 (coffee black)
'#ffffff', // white
'#FFFF00', // radiant yellow
];
export const STROKE_WIDTHS = [2, 4, 6, 8];
export const FONT_SIZES = [16, 24, 32, 48, 64];

3
public/snapmark/utils.ts Normal file
View File

@ -0,0 +1,3 @@
export const generateId = () => {
return Math.random().toString(36).substring(2, 9);
};

View File

@ -0,0 +1,24 @@
import path from 'path';
import { defineConfig, loadEnv } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, '.', '');
return {
base: './',
server: {
port: 3000,
host: '0.0.0.0',
},
plugins: [react()],
define: {
'process.env.API_KEY': JSON.stringify(env.GEMINI_API_KEY),
'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY)
},
resolve: {
alias: {
'@': path.resolve(__dirname, '.'),
}
}
};
});

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,56 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SnapMark | cffy.in</title>
<script src="https://cdn.tailwindcss.com"></script>
<style>
.font-impact {
font-family: Impact, Haettenschweiler, 'Arial Narrow Bold', sans-serif;
}
.font-comic {
font-family: "Comic Sans MS", "Comic Sans", cursive;
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 12px;
height: 12px;
}
::-webkit-scrollbar-track {
background: #fffbeb; /* amber-50 */
border-left: 2px solid #78350f; /* amber-900 */
}
::-webkit-scrollbar-thumb {
background: #fcd34d; /* amber-300 */
border: 2px solid #78350f; /* amber-900 */
border-radius: 0;
}
::-webkit-scrollbar-thumb:hover {
background: #fbbf24; /* amber-400 */
}
</style>
<script type="importmap">
{
"imports": {
"react-dom/": "https://aistudiocdn.com/react-dom@^19.2.1/",
"react/": "https://aistudiocdn.com/react@^19.2.1/",
"react": "https://aistudiocdn.com/react@^19.2.1",
"lucide-react": "https://aistudiocdn.com/lucide-react@^0.556.0",
"react-konva": "https://aistudiocdn.com/react-konva@^19.2.1",
"konva": "https://aistudiocdn.com/konva@^10.0.12",
"use-image": "https://aistudiocdn.com/use-image@^1.1.4"
}
}
</script>
<script type="module" crossorigin src="./assets/index-cSUFPhev.js"></script>
</head>
<body class="bg-amber-50 text-amber-900 h-screen w-screen overflow-hidden selection:bg-cyan-300 selection:text-amber-900">
<!-- Background Pattern -->
<div style="background-image:radial-gradient(circle,#78350f 1px,transparent 1px);background-size:20px 20px" class="absolute inset-0 opacity-10 pointer-events-none z-0"></div>
<div id="root" class="h-full w-full relative z-10"></div>
</body>
</html>

43
tsconfig.json Normal file
View File

@ -0,0 +1,43 @@
{
"compilerOptions": {
/* Visit https://aka.ms/tsconfig.json to read more about this file */
/* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
"target": "es2024",
/* Specify a set of bundled library declaration files that describe the target runtime environment. */
"lib": ["es2024"],
/* Specify what JSX code is generated. */
"jsx": "react-jsx",
/* Specify what module code is generated. */
"module": "es2022",
/* Specify how TypeScript looks up a file from a given module specifier. */
"moduleResolution": "node",
/* Enable importing .json files */
"resolveJsonModule": true,
/* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */
"allowJs": true,
/* Enable error reporting in type-checked JavaScript files. */
"checkJs": false,
/* Disable emitting files from a compilation. */
"noEmit": true,
/* Ensure that each file can be safely transpiled without relying on other imports. */
"isolatedModules": true,
/* Allow 'import x from y' when a module doesn't have a default export. */
"allowSyntheticDefaultImports": true,
/* Ensure that casing is correct in imports. */
"forceConsistentCasingInFileNames": true,
/* Enable all strict type-checking options. */
"strict": true,
/* Skip type checking all .d.ts files. */
"skipLibCheck": true,
"types": [
"./worker-configuration.d.ts"
]
}
}

10850
worker-configuration.d.ts vendored Normal file

File diff suppressed because it is too large Load Diff

63
wrangler.jsonc Normal file
View File

@ -0,0 +1,63 @@
/**
* For more details on how to configure Wrangler, refer to:
* https://developers.cloudflare.com/workers/wrangler/configuration/
*/
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "muddy-frost-c06d",
"main": "src/index.ts",
"compatibility_date": "2025-12-13",
"migrations": [
{
"new_sqlite_classes": [
"MyDurableObject"
],
"tag": "v1"
}
],
"assets": {
// The path to the directory containing the `index.html` file to be served at `/`
"directory": "./public"
},
"durable_objects": {
"bindings": [
{
"class_name": "MyDurableObject",
"name": "MY_DURABLE_OBJECT"
}
]
},
"observability": {
"enabled": true
}
/**
* Smart Placement
* Docs: https://developers.cloudflare.com/workers/configuration/smart-placement/#smart-placement
*/
// "placement": { "mode": "smart" }
/**
* Bindings
* Bindings allow your Worker to interact with resources on the Cloudflare Developer Platform, including
* databases, object storage, AI inference, real-time communication and more.
* https://developers.cloudflare.com/workers/runtime-apis/bindings/
*/
/**
* Environment Variables
* https://developers.cloudflare.com/workers/wrangler/configuration/#environment-variables
*/
// "vars": { "MY_VARIABLE": "production_value" }
/**
* Note: Use secrets to store sensitive data.
* https://developers.cloudflare.com/workers/configuration/secrets/
*/
/**
* Static Assets
* https://developers.cloudflare.com/workers/static-assets/binding/
*/
// "assets": { "directory": "./public/", "binding": "ASSETS" }
/**
* Service Bindings (communicate between multiple Workers)
* https://developers.cloudflare.com/workers/wrangler/configuration/#service-bindings
*/
// "services": [{ "binding": "MY_SERVICE", "service": "my-service" }]
}