From 8ed79cbd0ba3a3973c8ba516ea46902afd9a7a54 Mon Sep 17 00:00:00 2001 From: Coffee In Date: Sat, 13 Dec 2025 09:24:22 +0000 Subject: [PATCH] Imported skeleton for Cloudflare app --- .editorconfig | 12 + .gitignore | 167 + .prettierrc | 6 + game.html | 1 - german.html | 11 - index.html | 1 - package.json | 15 + public/game.html | 1 + public/german.html | 11 + public/index.html | 1 + public/snapmark/.gitignore | 24 + public/snapmark/App.tsx | 280 + public/snapmark/README.md | 20 + public/snapmark/components/EditorCanvas.tsx | 419 + .../snapmark/components/PropertiesPanel.tsx | 136 + public/snapmark/components/Toolbar.tsx | 101 + public/snapmark/index.html | 55 + public/snapmark/index.tsx | 15 + public/snapmark/metadata.json | 5 + public/snapmark/package-lock.json | 1904 +++ public/snapmark/package.json | 25 + public/snapmark/tsconfig.json | 29 + public/snapmark/types.ts | 46 + public/snapmark/utils.ts | 3 + public/snapmark/vite.config.ts | 24 + src/snapmark-app/assets/index-cSUFPhev.js | 167 + src/snapmark-app/index.html | 56 + tsconfig.json | 43 + worker-configuration.d.ts | 10850 ++++++++++++++++ wrangler.jsonc | 63 + 30 files changed, 14478 insertions(+), 13 deletions(-) create mode 100644 .editorconfig create mode 100644 .gitignore create mode 100644 .prettierrc delete mode 100644 game.html delete mode 100644 german.html delete mode 100644 index.html create mode 100644 package.json create mode 100644 public/game.html create mode 100644 public/german.html create mode 100644 public/index.html create mode 100644 public/snapmark/.gitignore create mode 100644 public/snapmark/App.tsx create mode 100644 public/snapmark/README.md create mode 100644 public/snapmark/components/EditorCanvas.tsx create mode 100644 public/snapmark/components/PropertiesPanel.tsx create mode 100644 public/snapmark/components/Toolbar.tsx create mode 100644 public/snapmark/index.html create mode 100644 public/snapmark/index.tsx create mode 100644 public/snapmark/metadata.json create mode 100644 public/snapmark/package-lock.json create mode 100644 public/snapmark/package.json create mode 100644 public/snapmark/tsconfig.json create mode 100644 public/snapmark/types.ts create mode 100644 public/snapmark/utils.ts create mode 100644 public/snapmark/vite.config.ts create mode 100644 src/snapmark-app/assets/index-cSUFPhev.js create mode 100644 src/snapmark-app/index.html create mode 100644 tsconfig.json create mode 100644 worker-configuration.d.ts create mode 100644 wrangler.jsonc diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..a727df3 --- /dev/null +++ b/.editorconfig @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4138168 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..5c7b5d3 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,6 @@ +{ + "printWidth": 140, + "singleQuote": true, + "semi": true, + "useTabs": true +} diff --git a/game.html b/game.html deleted file mode 100644 index f28ea64..0000000 --- a/game.html +++ /dev/null @@ -1 +0,0 @@ -HEX-A-ROW! 5-in-line madness! ๐Ÿ‘พ | cffy.in
๐ŸŽฒ
๐Ÿ‘พ
๐ŸŽฎ
๐Ÿคฏ

๐Ÿ‘พ HEX-A-ROW! ๐Ÿ‘พ

The classic 5-in-line game, but twisty! ๐ŸŒ€

๐ŸŽฎ GAME SETTINGS

Waiting to start...

\ No newline at end of file diff --git a/german.html b/german.html deleted file mode 100644 index 417428a..0000000 --- a/german.html +++ /dev/null @@ -1,11 +0,0 @@ -German Word Trainer | cffy.in
๐Ÿ‡ฉ๐Ÿ‡ช
โœจ
๐Ÿ‘

Lern mit mir!

German Word Trainer

Score
0

LEARNING PROGRESS

Correct answers move a word to the next box. Incorrect answers move it back. Words in higher boxes appear less frequently.

The english word is:

Loading...

Type the German translation:

\ No newline at end of file diff --git a/index.html b/index.html deleted file mode 100644 index d40939a..0000000 --- a/index.html +++ /dev/null @@ -1 +0,0 @@ -cffyin | cffy.in
โ˜•
โœจ
๐ŸŽจ

CFFYIN

โ˜• โœจ ๐ŸŽจ

Welcome to the Playground

A collection of small, delightful web experiments where education meets fun meets art.
No ads. No tracking. Just vibes.

โ˜•

โ†’ THE SITES โ†

Pick your adventure!

โ˜• โค๏ธ โœจ

Remember when the internet was fun? We do.
No tracking, no ads, no BS. Just pure, caffeinated creativity.

ยฉ 2025 cffy.in โ€ข Best viewed with coffee in hand โ˜•

\ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..6a89065 --- /dev/null +++ b/package.json @@ -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" + } +} \ No newline at end of file diff --git a/public/game.html b/public/game.html new file mode 100644 index 0000000..6ff3925 --- /dev/null +++ b/public/game.html @@ -0,0 +1 @@ +HEX-A-ROW! 5-in-line madness! ๐Ÿ‘พ | cffy.in
๐ŸŽฒ
๐Ÿ‘พ
๐ŸŽฎ
๐Ÿคฏ

๐Ÿ‘พ HEX-A-ROW! ๐Ÿ‘พ

The classic 5-in-line game, but twisty! ๐ŸŒ€

๐ŸŽฎ GAME SETTINGS

Waiting to start...

\ No newline at end of file diff --git a/public/german.html b/public/german.html new file mode 100644 index 0000000..9464351 --- /dev/null +++ b/public/german.html @@ -0,0 +1,11 @@ +German Word Trainer | cffy.in
๐Ÿ‡ฉ๐Ÿ‡ช
โœจ
๐Ÿ‘

Lern mit mir!

German Word Trainer

Score
0

LEARNING PROGRESS

Correct answers move a word to the next box. Incorrect answers move it back. Words in higher boxes appear less frequently.

The english word is:

Loading...

Type the German translation:

\ No newline at end of file diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..0187e7f --- /dev/null +++ b/public/index.html @@ -0,0 +1 @@ +cffyin | cffy.in
โ˜•
โœจ
๐ŸŽจ

CFFYIN

โ˜• โœจ ๐ŸŽจ

Welcome to the Playground

A collection of small, delightful web experiments where education meets fun meets art.
No ads. No tracking. Just vibes.

โ˜•

โ†’ THE SITES โ†

Pick your adventure!

โ˜• โค๏ธ โœจ

Remember when the internet was fun? We do.
No tracking, no ads, no BS. Just pure, caffeinated creativity.

ยฉ 2025 cffy.in โ€ข Best viewed with coffee in hand โ˜•

\ No newline at end of file diff --git a/public/snapmark/.gitignore b/public/snapmark/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/public/snapmark/.gitignore @@ -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? diff --git a/public/snapmark/App.tsx b/public/snapmark/App.tsx new file mode 100644 index 0000000..eb6d556 --- /dev/null +++ b/public/snapmark/App.tsx @@ -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(null); + const [imageDimensions, setImageDimensions] = useState({ width: 0, height: 0 }); + const [shapes, setShapes] = useState([]); + const [selectedId, setSelectedId] = useState(null); + const [tool, setTool] = useState('select'); + + const stageRef = useRef(null); + const fileInputRef = useRef(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) => { + 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) => { + 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 ( +
+ {/* Toolbar */} + + + {/* Main Area */} +
+ {/* Header/Info */} +
+
+ + + +

+
+ SM +
+
+ SNAPMARK + Highlight โ€ข Annotate โ€ข Export +
+

+
+ +
+ {imageSrc ? 'โœจ Make it pop! โœจ' : 'Waiting for image...'} +
+ +
+ + +
+
+ + {/* Canvas Scroll Area */} +
+ + {/* Decorative background elements */} +
โ˜•
+
โœจ
+ + {imageSrc ? ( +
+ +
+ ) : ( +
{ + 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" + > +
+
+ +
+

PASTE IMAGE

+

Ctrl+V / Cmd+V

+

+ or click to browse +

+
+ )} +
+
+ + {/* Properties Panel */} + +
+ ); +}; + +export default App; \ No newline at end of file diff --git a/public/snapmark/README.md b/public/snapmark/README.md new file mode 100644 index 0000000..35d6592 --- /dev/null +++ b/public/snapmark/README.md @@ -0,0 +1,20 @@ +
+GHBanner +
+ +# 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` diff --git a/public/snapmark/components/EditorCanvas.tsx b/public/snapmark/components/EditorCanvas.tsx new file mode 100644 index 0000000..d0fa175 --- /dev/null +++ b/public/snapmark/components/EditorCanvas.tsx @@ -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 ; +}; + +interface EditorCanvasProps { + imageSrc: string | null; + shapes: Shape[]; + setShapes: React.Dispatch>; + selectedId: string | null; + setSelectedId: (id: string | null) => void; + tool: ToolType; + setTool: (tool: ToolType) => void; + stageRef: React.RefObject; + onImageDimensionsChange: (width: number, height: number) => void; + canvasWidth?: number; + canvasHeight?: number; +} + +const EditorCanvas: React.FC = ({ + imageSrc, + shapes, + setShapes, + selectedId, + setSelectedId, + tool, + setTool, + stageRef, + onImageDimensionsChange, + canvasWidth, + canvasHeight, +}) => { + const isDrawing = useRef(false); + const transformerRef = useRef(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) => { + 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) => { + 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) => { + 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, 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) => { + // 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 ( + + + {imageSrc && ( + + )} + + {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 ( + + ); + } else if (shape.type === 'arrow') { + return ( + + ); + } else if (shape.type === 'text') { + return ( + + ); + } + return null; + })} + + {selectedId && ( + { + // 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} + /> + )} + + + ); +}; + +export default EditorCanvas; \ No newline at end of file diff --git a/public/snapmark/components/PropertiesPanel.tsx b/public/snapmark/components/PropertiesPanel.tsx new file mode 100644 index 0000000..7dd611e --- /dev/null +++ b/public/snapmark/components/PropertiesPanel.tsx @@ -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) => void; +} + +const PropertiesPanel: React.FC = ({ + selectedShape, + updateShape, +}) => { + if (!selectedShape) { + return ( +
+ ๐Ÿ‘† +

Select something!

+

Click an item to tweak it.

+
+ ); + } + + return ( +
+
+

+ Edit {selectedShape.type} +

+
+ +
+

+ Color Pick one +

+
+ {COLORS.map((color) => ( +
+
+ + {(selectedShape.type === 'rect' || selectedShape.type === 'arrow') && ( +
+

+ Thickness +

+
+ {STROKE_WIDTHS.map((width) => ( + + ))} +
+
+ )} + + {selectedShape.type === 'text' && ( + <> +
+

+ Size +

+ +
+
+

+ Text +

+