diff --git a/components/image-zoom/README.md b/components/image-zoom/README.md new file mode 100644 index 0000000..f4f5914 --- /dev/null +++ b/components/image-zoom/README.md @@ -0,0 +1,26 @@ +# imageZoom + +A custom [Retool](https://retool.com/) component that adds image zoom and magnification capabilities to your Retool applications. + +## Features + +- **Normal mode** — Click to zoom in/out on the image; drag to pan while zoomed in. +- **Glass mode** — A circular magnifier lens follows the cursor over the image. + +## Retool State Properties + +| Property | Type | Default | Description | +|---|---|---|---| +| `imageUrl` | `string` | Sample flower image | URL of the image to display | +| `imageAlt` | `string` | `""` | Alt text for the image | +| `zoomMode` | `string` | `"normal"` | Zoom mode: `normal`, `glass`, `self`, or `side` | +| `zoomLevel` | `number` | `3` | Magnification multiplier | + +## Zoom Modes + +### `normal` +Click anywhere on the image to zoom in to the configured `zoomLevel`. Click again to zoom back out. While zoomed in, drag the image to pan. + +### `glass` +A circular magnifier lens (180×180 px by default) follows the mouse cursor and shows the area under it at `zoomLevel` magnification. + diff --git a/components/image-zoom/cover.png b/components/image-zoom/cover.png new file mode 100644 index 0000000..d3f5a12 --- /dev/null +++ b/components/image-zoom/cover.png @@ -0,0 +1 @@ + diff --git a/components/image-zoom/metadata.json b/components/image-zoom/metadata.json new file mode 100644 index 0000000..a68d504 --- /dev/null +++ b/components/image-zoom/metadata.json @@ -0,0 +1,7 @@ +{ + "id": "image-zoom", + "title": "Image Zoom", + "author": "@MiguelOrtiz", + "shortDescription": "A component that adds image zoom and magnification capabilities to your Retool applications.", + "tags": ["Custom", "React", "UI Components"] +} diff --git a/components/image-zoom/package.json b/components/image-zoom/package.json new file mode 100644 index 0000000..b4515dd --- /dev/null +++ b/components/image-zoom/package.json @@ -0,0 +1,42 @@ +{ + "name": "my-react-app", + "version": "0.1.0", + "private": true, + "dependencies": { + "@tryretool/custom-component-support": "latest", + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "engines": { + "node": ">=20.0.0" + }, +"scripts": { + "dev": "npx retool-ccl dev", + "deploy": "npx retool-ccl deploy", + "test": "vitest" + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + }, + "devDependencies": { + "@types/react": "^18.2.55", + "@types/react-dom": "^18.2.19", + "@typescript-eslint/eslint-plugin": "^7.3.1", + "@typescript-eslint/parser": "^7.3.1", + "eslint": "^8.57.0", + "eslint-plugin-react": "^7.34.1", + "postcss-modules": "^6.0.0", + "prettier": "^3.0.3", + "typescript": "^5.4.0", + "vitest": "^4.0.17" + } +} diff --git a/components/image-zoom/source/components/imageMagnifier.tsx b/components/image-zoom/source/components/imageMagnifier.tsx new file mode 100644 index 0000000..5c4a805 --- /dev/null +++ b/components/image-zoom/source/components/imageMagnifier.tsx @@ -0,0 +1,189 @@ +import React, { useRef, useState } from 'react' + +const ImageMagnifier = ({ + src, + className = '', + width, + height, + alt, + mode = 'glass', + magnifierHeight = 150, + magnifierWidth = 150, + zoomLevel = 3 +}) => { + const [showMagnifier, setShowMagnifier] = useState(false) + const [[imgWidth, imgHeight], setSize] = useState([0, 0]) + const [[x, y], setXY] = useState([0, 0]) + + const mouseEnter = (e) => { + const el = e.currentTarget + + const { width, height } = el.getBoundingClientRect() + setSize([width, height]) + setShowMagnifier(true) + } + + const mouseLeave = (e) => { + e.preventDefault() + setShowMagnifier(false) + } + + const mouseMove = (e) => { + const el = e.currentTarget + const { top, left } = el.getBoundingClientRect() + + const x = e.pageX - left - window.scrollX + const y = e.pageY - top - window.scrollY + + setXY([x, y]) + } + + return ( +
+
+ {alt} mouseEnter(e)} + onMouseLeave={(e) => mouseLeave(e)} + onMouseMove={(e) => mouseMove(e)} + /> + {mode === 'glass' ? ( + <> +
+
+ + ) : mode === 'side' ? ( + <> +
+
+ + ) : mode === 'self' ? ( +
+ ) : null} +
+ {mode === 'side' ? ( +
+ ) : null} +
+ ) +} + +export default ImageMagnifier diff --git a/components/image-zoom/source/index.tsx b/components/image-zoom/source/index.tsx new file mode 100644 index 0000000..246a2cf --- /dev/null +++ b/components/image-zoom/source/index.tsx @@ -0,0 +1,157 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import React, { useEffect, useRef, useState } from 'react' +import { type FC } from 'react' +import { Retool } from '@tryretool/custom-component-support' +import ImageMagnifier from './components/ImageMagnifier' + +Retool.useComponentSettings({ + defaultHeight: 50, + defaultWidth: 7, + }); + +const ZoomImage: FC = () => { + const [imageUrl, _setImageUrl] = Retool.useStateString({ + name: 'imageUrl', + initialValue: 'https://justifiedgrid.com/wp-content/gallery/life/flowers/167527802.jpg' + }) + const [imageAlt, setImageAlt] = Retool.useStateString({ name: 'imageAlt' }) + const [zoomMode] = Retool.useStateString({ + name: 'zoomMode', + initialValue: 'normal', + description: 'Choose between normal or glass magnifier modes', + }) + const [zoomLevel, setZoomLevel] = Retool.useStateNumber({ + name: 'zoomLevel', + initialValue: 3 + }) + + const [zoomInfo, setZoomInfo] = useState({ x: 0, y: 0, level: 1 }) + const [isClicking, setIsClicking] = useState(false) + const [isDragging, setIsDragging] = useState(false) + const [startDragPosition, setStartDragPosition] = useState<{ + x: number + y: number + } | null>(null) + + const [cursorPosition, setCursorPosition] = useState<{ + x: number + y: number + } | null>(null) + + const containerRef = useRef(null) + + // Start dragging + const handleMouseDown = (event: React.MouseEvent) => { + if (zoomInfo.level > 1) { + setIsClicking(true) + setStartDragPosition({ + x: event.clientX - zoomInfo.x, + y: event.clientY - zoomInfo.y + }) + } + } + + // Drag the image + const handleMouseMove = (event: React.MouseEvent) => { + if (isClicking && startDragPosition) { + const newPosX = event.clientX - startDragPosition.x + const newPosY = event.clientY - startDragPosition.y + setZoomInfo((prev) => ({ ...prev, x: newPosX, y: newPosY })) + setIsDragging(true) + } + + if (!containerRef.current) return + const container = containerRef.current + const rect = container.getBoundingClientRect() + setCursorPosition({ + x: event.clientX - rect.left, + y: event.clientY - rect.top + }) + } + + // Stop dragging + const handleMouseUp = (event: React.MouseEvent) => { + setIsClicking(false) + if (!isDragging) { + const container = containerRef.current + if (!container) return + const rect = container.getBoundingClientRect() + const cursorX = event.clientX - rect.left + const cursorY = event.clientY - rect.top + setZoomInfo((prev) => ({ + level: prev.level === 1 ? zoomLevel : 1, + x: + prev.level === 1 + ? cursorX - ((cursorX - prev.x) * zoomLevel) / prev.level + : 0, + y: + prev.level === 1 + ? cursorY - ((cursorY - prev.y) * zoomLevel) / prev.level + : 0 + })) + } + setIsDragging(false) + } + + return ( +
1 && isDragging ? 'grab' : 'default' + }} + // onWheel={handleZoomScroll} + onMouseDown={handleMouseDown} + onMouseMove={handleMouseMove} + onMouseUp={handleMouseUp} + // onMouseLeave={handleMouseUp} + > + {zoomMode !== 'normal' && cursorPosition && containerRef.current ? ( + + ) : imageUrl ? ( + {imageAlt} + ) : ( +
No image available
+ )} +
+ ) + +} + +export default ZoomImage;