{"_id":"@bartgarbiak/image-cropper","_rev":"2-ac4aa47b7d524e8904a84553c33124e7","name":"@bartgarbiak/image-cropper","dist-tags":{"latest":"1.1.0"},"versions":{"1.0.0":{"name":"@bartgarbiak/image-cropper","version":"1.0.0","keywords":["react","image","cropper","rotation","crop"],"license":"MIT","_id":"@bartgarbiak/image-cropper@1.0.0","maintainers":[{"name":"bartgarbiak","email":"dev@log.fea.st"}],"homepage":"https://github.com/bartgarbiak/image-cropper#readme","bugs":{"url":"https://github.com/bartgarbiak/image-cropper/issues"},"dist":{"shasum":"1e051b3a8e8792ef3d67281a6731e5859b69425d","tarball":"https://registry.npmjs.org/@bartgarbiak/image-cropper/-/image-cropper-1.0.0.tgz","fileCount":10,"integrity":"sha512-AWilJ3DxI1X2AGayc7/Ka2K8jjSusXHqR6xYuQA4v6fbG2CrQ6G2hgGEBHIJ1BYHeNQqx9WJx8t99hoc+GGIeQ==","signatures":[{"sig":"MEUCIQDze8iJ9lj8+NHy4C3ceMPcNmfDvSBOGPRTr5dR9j3MxgIgXT/ZA/RasTDwTYmciId2P+f5sev34bmi019slrwId/o=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":72507},"main":"dist/index.cjs","type":"module","types":"dist/index.d.ts","module":"dist/index.mjs","exports":{".":{"import":{"types":"./dist/index.d.ts","default":"./dist/index.mjs"},"require":{"types":"./dist/index.d.ts","default":"./dist/index.cjs"}},"./style.css":"./dist/style.css"},"gitHead":"3ad1bb1dd3162bdbabf0d83822ed4c883a875d5b","scripts":{"dev":"vite serve demo","build":"vite build && tsc -p tsconfig.build.json","preview":"vite preview","typecheck":"tsc --noEmit","prepublishOnly":"npm run build"},"_npmUser":{"name":"bartgarbiak","email":"dev@log.fea.st"},"repository":{"url":"git+https://github.com/bartgarbiak/image-cropper.git","type":"git"},"_npmVersion":"8.19.4","description":"A React component for interactive image rotation and cropping","directories":{},"sideEffects":["**/*.css"],"_nodeVersion":"16.20.2","_hasShrinkwrap":false,"devDependencies":{"vite":"^4.5.0","react":"^18.2.0","react-dom":"^18.2.0","typescript":"^5.9.3","@types/react":"^19.2.14","@types/react-dom":"^19.2.3","@vitejs/plugin-react":"^3.1.0"},"peerDependencies":{"react":">=17.0.0","react-dom":">=17.0.0"},"_npmOperationalInternal":{"tmp":"tmp/image-cropper_1.0.0_1771889253302_0.39152698566802324","host":"s3://npm-registry-packages-npm-production"}},"1.1.0":{"name":"@bartgarbiak/image-cropper","version":"1.1.0","license":"MIT","type":"module","description":"A React component for interactive image rotation and cropping","keywords":["react","image","cropper","rotation","crop"],"repository":{"type":"git","url":"git+https://github.com/bartgarbiak/image-cropper.git"},"main":"dist/index.cjs","module":"dist/index.mjs","types":"dist/index.d.ts","exports":{".":{"import":{"types":"./dist/index.d.ts","default":"./dist/index.mjs"},"require":{"types":"./dist/index.d.ts","default":"./dist/index.cjs"}},"./style.css":"./dist/style.css"},"sideEffects":["**/*.css"],"scripts":{"dev":"vite serve demo","test":"vitest","build":"vite build && tsc -p tsconfig.build.json","preview":"vite preview","typecheck":"tsc --noEmit","prepublishOnly":"npm run build"},"peerDependencies":{"react":">=17.0.0","react-dom":">=17.0.0"},"devDependencies":{"@testing-library/jest-dom":"^6.9.1","@testing-library/react":"^14.3.1","@types/react":"^19.2.14","@types/react-dom":"^19.2.3","@vitejs/plugin-react":"^3.1.0","jsdom":"^22.1.0","react":"^18.2.0","react-dom":"^18.2.0","typescript":"^5.9.3","vite":"^4.5.0","vitest":"^0.34.6"},"vitest":{"test":{"environment":"jsdom"}},"gitHead":"c1aefc24f21c0297d27b1a6debf3144c2cadd26a","bugs":{"url":"https://github.com/bartgarbiak/image-cropper/issues"},"homepage":"https://github.com/bartgarbiak/image-cropper#readme","_id":"@bartgarbiak/image-cropper@1.1.0","_nodeVersion":"16.20.2","_npmVersion":"8.19.4","dist":{"integrity":"sha512-tEuMyA+RXWUJS3BV6yyQA4WTes0W6yWjCKxLgT8/qnEoWsHRD7PcZDbQquD5N3Ysg99rHiDc4MpAdDf+GfykIQ==","shasum":"c30f32f8eb3f4c0084a74e8cbcc076534bd4ea3e","tarball":"https://registry.npmjs.org/@bartgarbiak/image-cropper/-/image-cropper-1.1.0.tgz","fileCount":16,"unpackedSize":117209,"signatures":[{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEYCIQCcmsgr8MteZOoClSMQ4OUNcvlaE6CT+2WVjupa6VpXaAIhAJ9Oz+cJeUYSVFSRqHERcds58LfUhEiLUNAeBAq1wCEp"}]},"_npmUser":{"name":"bartgarbiak","email":"dev@log.fea.st"},"directories":{},"maintainers":[{"name":"bartgarbiak","email":"dev@log.fea.st"}],"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/image-cropper_1.1.0_1772006066206_0.49016810827889"},"_hasShrinkwrap":false}},"time":{"created":"2026-02-23T23:27:33.209Z","modified":"2026-02-25T07:54:26.488Z","1.0.0":"2026-02-23T23:27:33.486Z","1.1.0":"2026-02-25T07:54:26.374Z"},"bugs":{"url":"https://github.com/bartgarbiak/image-cropper/issues"},"license":"MIT","homepage":"https://github.com/bartgarbiak/image-cropper#readme","keywords":["react","image","cropper","rotation","crop"],"repository":{"type":"git","url":"git+https://github.com/bartgarbiak/image-cropper.git"},"description":"A React component for interactive image rotation and cropping","maintainers":[{"name":"bartgarbiak","email":"dev@log.fea.st"}],"readme":"# ImageCropper — Documentation\n\nA React component for interactive image rotation and cropping. Built with TypeScript, React 18, pure CSS, and Vite.\n\n---\n\n## Quick Start\n\n```bash\nnpm install\nnpm run dev        # http://localhost:5173\nnpm run build      # production build → dist/\nnpm run preview    # preview production build\n```\n\nRequires **Node ≥ 18**.\n\n---\n\n## Project Structure\n\n```\ncropper/\n├── package.json\n├── tsconfig.json\n├── tsconfig.build.json\n├── vite.config.js\n├── README.md                        # ← you are here\n├── demo/                            # Demo app\n│   ├── index.html\n│   ├── main.tsx\n│   └── App.tsx\n├── test/                            # Vitest test suites\n│   ├── helpers.spec.ts\n│   ├── useHistory.spec.ts\n│   └── ImageCropper.spec.tsx\n└── src/\n    ├── index.ts                     # Barrel exports\n    ├── utils/\n    │   └── useHistory.ts            # Undo/redo history hook\n    └── components/\n        ├── ImageCropper.tsx          # Core component\n        ├── ImageCropper.types.ts     # Type definitions\n        ├── ImageCropper.css          # Component styles\n        └── helpers/                  # Geometry helpers\n            ├── computeCropSize.ts\n            ├── clampCropDims.ts\n            ├── findMaxRotation.ts\n            └── clampOffset.ts\n```\n\n---\n\n## `<ImageCropper>` Component\n\n### Props\n\n| Prop               | Type                                    | Default   | Description                                                              |\n| ------------------ | --------------------------------------- | --------- | ------------------------------------------------------------------------ |\n| `imageSrc`         | `string \\| null`                        | `null`    | Image source URL or data URL to crop.                                    |\n| `minCropWidth`     | `number`                                | `250`     | Minimum crop rectangle width in pixels.                                  |\n| `minCropHeight`    | `number`                                | `250`     | Minimum crop rectangle height in pixels.                                 |\n| `labels`           | `ImageCropperLabels`                    | See below | Override any UI label string.                                            |\n| `onCrop`           | `(data: CropData) => void`              | —         | Fired after the user stops cropping for 500 ms.                          |\n| `onRotate`         | `(data: RotationData) => void`          | —         | Fired after the user stops rotating for 500 ms.                          |\n| `onChange`         | `(data: ChangeData) => void`            | —         | Fired after any crop or rotation commit (same 500 ms debounce).          |\n| `onHistoryChange`  | `(canUndo: boolean, canRedo: boolean) => void` | — | Fired whenever the undo/redo availability changes. Use this to keep external buttons in sync. |\n| `ref`              | `React.Ref<ImageCropperRef>`            | —         | Forward ref giving access to `undo`, `redo`, `canUndo`, `canRedo`, `getHistory`. |\n\n#### `ImageCropperLabels`\n\nAll fields are optional. Any omitted key falls back to the English default.\n\n| Key              | Type     | Default                            |\n| ---------------- | -------- | ---------------------------------- |\n| `rotation`       | `string` | `\"Rotation\"`                       |\n| `rotate90`       | `string` | `\"Rotate 90°\"`                     |\n| `rotate180`      | `string` | `\"Rotate 180°\"`                    |\n| `resetRotation`  | `string` | `\"Reset Rotation\"`                 |\n| `resetCrop`      | `string` | `\"Reset Crop\"`                     |\n| `emptyState`     | `string` | `\"Open an image to get started\"`   |\n\n#### Event Data Types\n\n**`CropData`**\n```ts\n{\n  x: number;      // horizontal position of top-left corner\n  y: number;      // vertical position of top-left corner\n  width: number;  // width of the cropped area\n  height: number; // height of the cropped area\n}\n```\n\n**`RotationData`**\n```ts\n{\n  rotation: number;      // fine-tune rotation (-45° to 45°)\n  baseRotation: number;  // coarse rotation (0°, 90°, 180°, 270°)\n}\n```\n\n**`ChangeData`**\n```ts\n{\n  action: 'rotate' | 'crop';\n  crop: CropData;\n  rotation: RotationData;\n}\n```\n\n### Usage\n\n```tsx\nimport { ImageCropper, type ImageCropperRef } from '@bartgarbiak/image-cropper';\nimport '@bartgarbiak/image-cropper/style.css';\nimport { useRef, useState } from 'react';\n\nfunction MyComponent() {\n  const [imageSrc, setImageSrc] = useState<string | null>(null);\n  const [canUndo, setCanUndo] = useState(false);\n  const [canRedo, setCanRedo] = useState(false);\n  const cropperRef = useRef<ImageCropperRef>(null);\n\n  const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {\n    const file = e.target.files?.[0];\n    if (file) setImageSrc(URL.createObjectURL(file));\n  };\n\n  return (\n    <>\n      <input type=\"file\" accept=\"image/*\" onChange={handleFileUpload} />\n\n      <button onClick={() => cropperRef.current?.undo()} disabled={!canUndo}>Undo</button>\n      <button onClick={() => cropperRef.current?.redo()} disabled={!canRedo}>Redo</button>\n\n      <ImageCropper\n        ref={cropperRef}\n        imageSrc={imageSrc}\n        onHistoryChange={(u, r) => { setCanUndo(u); setCanRedo(r); }}\n        onCrop={(crop) => console.log('Crop:', crop)}\n        onRotate={(rotation) => console.log('Rotation:', rotation)}\n        onChange={(data) => console.log('Change:', data)}\n      />\n    </>\n  );\n}\n```\n\n> **Note on event timing** — `onCrop`, `onRotate`, and `onChange` are debounced: they fire only after the user has stopped interacting (dragging a corner, moving the crop, or adjusting the slider) for **500 ms**. Discrete button actions (Rotate 90°, Rotate 180°, Reset Rotation, Reset Crop) commit immediately without the delay. This same debounce controls when undo/redo history entries are created, so every entry represents a \"resting\" state rather than a frame in the middle of a drag.\n\n**Custom min crop size**\n```tsx\n<ImageCropper imageSrc={imageSrc} minCropWidth={100} minCropHeight={100} />\n```\n\n**Custom labels (i18n)**\n```tsx\n<ImageCropper\n  imageSrc={imageSrc}\n  labels={{\n    rotation: 'Rotación',\n    rotate90: 'Girar 90°',\n    rotate180: 'Girar 180°',\n    resetRotation: 'Restablecer rotación',\n    resetCrop: 'Restablecer recorte',\n    emptyState: 'Abra una imagen para comenzar',\n  }}\n/>\n```\n\n---\n\n## Features\n\n### Image Upload\n\nClick the file input to load any image. The image is displayed inside a centred workspace area, scaled to fit within 70 % of the available space.\n\n### Rotation (−45° to +45°)\n\nA slider controls the rotation angle in 0.1° increments. The allowed range shrinks automatically when the current crop dimensions would become smaller than the minimum — the slider limits are recalculated via binary search (`findMaxRotation`).\n\n### Crop Overlay\n\nAn axis-aligned rectangle is drawn on top of the rotated image. At zero rotation and no manual resize it matches the full image size. As rotation increases the crop shrinks to remain inside the image boundary.\n\n### Corner Resize (drag)\n\nEach corner has a 12 × 12 px hit target. Dragging a corner resizes the crop symmetrically (the crop stays centred when resizing). Dimensions are clamped so:\n\n- They never go below `minCropWidth` / `minCropHeight`.\n- All four corners remain inside the rotated image boundary.\n\nResizing resets the crop offset to the centre.\n\n### Crop Move (drag)\n\nClicking and dragging inside the crop area moves the entire crop rectangle. The offset is clamped in real-time so that every corner of the crop stays within the rotated image boundary (`clampOffset`).\n\n### 90° / 180° Rotation\n\nTwo buttons allow coarse rotation in 90° and 180° increments. The 90° button is automatically disabled when the resulting (swapped) image dimensions would violate the minimum crop size. Each coarse rotation resets the fine-tune slider and crop to their defaults.\n\n### Undo / Redo\n\nEvery \"settled\" interaction is pushed to a history stack. An interaction is considered settled when the user stops acting for **500 ms** (slider, drag corner, drag move). Discrete actions (Rotate 90°, 180°, Reset Rotation, Reset Crop) commit to history immediately.\n\nControl undo/redo from outside the component via a forwarded ref:\n\n```tsx\nconst ref = useRef<ImageCropperRef>(null);\n\n// call these from external buttons\nref.current?.undo();\nref.current?.redo();\n\n// read availability\nref.current?.canUndo; // boolean\nref.current?.canRedo; // boolean\n\n// inspect the full stack\nref.current?.getHistory(); // { past, present, future }\n```\n\nUse `onHistoryChange` to keep external UI (e.g. toolbar buttons) in sync without polling the ref:\n\n```tsx\n<ImageCropper\n  ref={ref}\n  onHistoryChange={(canUndo, canRedo) => {\n    setCanUndo(canUndo);\n    setCanRedo(canRedo);\n  }}\n/>\n```\n\n### Reset Buttons\n\n- **Reset Rotation** — sets both coarse and fine rotation to 0° and resets crop size + position.\n- **Reset Crop** — restores the default (full-image) crop size and centres it.\n\n---\n\n## Geometry Model\n\nAll constraint math operates in a coordinate system centred on the image centre.\n\n### Rotated-image containment\n\nA screen-space point $(p_x, p_y)$ is inside the rotated image iff:\n\n$$\n|p_x \\cos\\theta + p_y \\sin\\theta| \\le \\frac{W}{2}\n\\quad\\text{and}\\quad\n|-p_x \\sin\\theta + p_y \\cos\\theta| \\le \\frac{H}{2}\n$$\n\nwhere $W \\times H$ is the displayed image size and $\\theta$ is the rotation angle.\n\n### Key functions\n\n| Function | Purpose |\n| --- | --- |\n| `computeCropSize(W, H, θ)` | Largest axis-aligned rectangle with the image's aspect ratio that fits inside the rotated image (centred). |\n| `clampCropDims(cW, cH, iW, iH, θ, minW, minH)` | Clamp arbitrary crop dimensions so a centred crop of that size fits inside the rotated image, respecting minimums. |\n| `findMaxRotation(iW, iH, crop, minW, minH)` | Binary search (50 iterations) for the largest $|\\theta| \\le 45°$ where the effective crop still meets the minimum size constraints. |\n| `clampOffset(ox, oy, cW, cH, iW, iH, θ)` | Iteratively push an offset $(o_x, o_y)$ inward until all four corners of the offset crop rectangle pass the containment test above. |\n\n---\n\n## Types\n\nAll types are exported from the package entry point.\n\n```ts\ninterface ImageCropperProps {\n  imageSrc?: string | null;\n  minCropWidth?: number;\n  minCropHeight?: number;\n  labels?: ImageCropperLabels;\n  onCrop?: (data: CropData) => void;\n  onRotate?: (data: RotationData) => void;\n  onChange?: (data: ChangeData) => void;\n  /** Fired when canUndo / canRedo change — use this to drive external buttons. */\n  onHistoryChange?: (canUndo: boolean, canRedo: boolean) => void;\n}\n\n/** Exposed via forwardRef — access with useRef<ImageCropperRef>(null) */\ninterface ImageCropperRef {\n  undo: () => void;\n  redo: () => void;\n  canUndo: boolean;\n  canRedo: boolean;\n  getHistory: () => {\n    past: CropperState[];\n    present: CropperState;\n    future: CropperState[];\n  };\n}\n\ninterface CropperState {\n  rotation: number;\n  baseRotation: number;\n  cropSize: { width: number; height: number } | null;\n  cropOffset: { x: number; y: number };\n}\n\ninterface ImageCropperLabels {\n  rotation?: string;\n  rotate90?: string;\n  rotate180?: string;\n  resetRotation?: string;\n  resetCrop?: string;\n  emptyState?: string;\n}\n\ninterface CropData {\n  x: number;\n  y: number;\n  width: number;\n  height: number;\n}\n\ninterface RotationData {\n  rotation: number;\n  baseRotation: number;\n}\n\ninterface ChangeData {\n  action: 'rotate' | 'crop';\n  crop: CropData;\n  rotation: RotationData;\n}\n\ninterface Size  { width: number; height: number; }\ninterface Point { x: number;     y: number;      }\n```\n\n---\n\n## Tech Stack\n\n| Layer       | Library                |\n| ----------- | ---------------------- |\n| UI          | React 18               |\n| Styling     | Pure CSS (custom props) |\n| Language    | TypeScript 5 (strict)  |\n| Bundler     | Vite 4 (library mode)  |\n\n---\n\n## License\n\n[MIT](LICENSE)\n","readmeFilename":"README.md"}