Imported skeleton for Cloudflare app
This commit is contained in:
parent
0fbb8adc21
commit
8ed79cbd0b
12
.editorconfig
Normal file
12
.editorconfig
Normal 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
167
.gitignore
vendored
Normal 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
6
.prettierrc
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"printWidth": 140,
|
||||
"singleQuote": true,
|
||||
"semi": true,
|
||||
"useTabs": true
|
||||
}
|
||||
11
german.html
11
german.html
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
15
package.json
Normal 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
1
public/game.html
Normal file
File diff suppressed because one or more lines are too long
11
public/german.html
Normal file
11
public/german.html
Normal file
File diff suppressed because one or more lines are too long
1
public/index.html
Normal file
1
public/index.html
Normal file
File diff suppressed because one or more lines are too long
24
public/snapmark/.gitignore
vendored
Normal file
24
public/snapmark/.gitignore
vendored
Normal 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
280
public/snapmark/App.tsx
Normal 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
20
public/snapmark/README.md
Normal 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`
|
||||
419
public/snapmark/components/EditorCanvas.tsx
Normal file
419
public/snapmark/components/EditorCanvas.tsx
Normal 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;
|
||||
136
public/snapmark/components/PropertiesPanel.tsx
Normal file
136
public/snapmark/components/PropertiesPanel.tsx
Normal 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;
|
||||
101
public/snapmark/components/Toolbar.tsx
Normal file
101
public/snapmark/components/Toolbar.tsx
Normal 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;
|
||||
55
public/snapmark/index.html
Normal file
55
public/snapmark/index.html
Normal 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
15
public/snapmark/index.tsx
Normal 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>
|
||||
);
|
||||
5
public/snapmark/metadata.json
Normal file
5
public/snapmark/metadata.json
Normal 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
1904
public/snapmark/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
25
public/snapmark/package.json
Normal file
25
public/snapmark/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
29
public/snapmark/tsconfig.json
Normal file
29
public/snapmark/tsconfig.json
Normal 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
46
public/snapmark/types.ts
Normal 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
3
public/snapmark/utils.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export const generateId = () => {
|
||||
return Math.random().toString(36).substring(2, 9);
|
||||
};
|
||||
24
public/snapmark/vite.config.ts
Normal file
24
public/snapmark/vite.config.ts
Normal 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, '.'),
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
167
src/snapmark-app/assets/index-cSUFPhev.js
Normal file
167
src/snapmark-app/assets/index-cSUFPhev.js
Normal file
File diff suppressed because one or more lines are too long
56
src/snapmark-app/index.html
Normal file
56
src/snapmark-app/index.html
Normal 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
43
tsconfig.json
Normal 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
10850
worker-configuration.d.ts
vendored
Normal file
File diff suppressed because it is too large
Load Diff
63
wrangler.jsonc
Normal file
63
wrangler.jsonc
Normal 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" }]
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user