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!

\ 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!

\ 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 +

+