@scaleflex/crop 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +452 -0
  3. package/dist/a11y/aria.d.ts +5 -0
  4. package/dist/a11y/keyboard.d.ts +13 -0
  5. package/dist/animation/lerp.d.ts +15 -0
  6. package/dist/animation/spring.d.ts +15 -0
  7. package/dist/canvas/bleed-layer.d.ts +6 -0
  8. package/dist/canvas/crop-frame.d.ts +32 -0
  9. package/dist/canvas/grid-layer.d.ts +7 -0
  10. package/dist/canvas/hit-test.d.ts +10 -0
  11. package/dist/canvas/image-layer.d.ts +28 -0
  12. package/dist/canvas/overlay-layer.d.ts +6 -0
  13. package/dist/canvas/renderer.d.ts +34 -0
  14. package/dist/chunks/sfx-crop-1LGASewd.cjs +353 -0
  15. package/dist/chunks/sfx-crop-CEe6OfTZ.js +2030 -0
  16. package/dist/core/config.d.ts +10 -0
  17. package/dist/core/crop-controller.d.ts +65 -0
  18. package/dist/core/types.d.ts +270 -0
  19. package/dist/define.cjs +1194 -0
  20. package/dist/define.d.ts +1 -0
  21. package/dist/define.js +1746 -0
  22. package/dist/elements/base.d.ts +17 -0
  23. package/dist/elements/icons.d.ts +21 -0
  24. package/dist/elements/parse-shapes.d.ts +13 -0
  25. package/dist/elements/popover-anchor.d.ts +20 -0
  26. package/dist/elements/sfx-crop-canvas.d.ts +24 -0
  27. package/dist/elements/sfx-crop-canvas.styles.d.ts +1 -0
  28. package/dist/elements/sfx-crop-rotate.d.ts +42 -0
  29. package/dist/elements/sfx-crop-rotate.styles.d.ts +5 -0
  30. package/dist/elements/sfx-crop-shapes.d.ts +67 -0
  31. package/dist/elements/sfx-crop-shapes.styles.d.ts +6 -0
  32. package/dist/elements/sfx-crop-toolbar.d.ts +64 -0
  33. package/dist/elements/sfx-crop-toolbar.styles.d.ts +7 -0
  34. package/dist/elements/sfx-crop-zoom.d.ts +66 -0
  35. package/dist/elements/sfx-crop-zoom.styles.d.ts +7 -0
  36. package/dist/elements/sfx-crop.d.ts +134 -0
  37. package/dist/elements/sfx-crop.styles.d.ts +9 -0
  38. package/dist/export/exporter.d.ts +19 -0
  39. package/dist/index.cjs +2 -0
  40. package/dist/index.d.ts +22 -0
  41. package/dist/index.js +65 -0
  42. package/dist/interactions/drag-crop.d.ts +10 -0
  43. package/dist/interactions/pinch-zoom.d.ts +14 -0
  44. package/dist/interactions/pointer-tracker.d.ts +29 -0
  45. package/dist/interactions/resize-handles.d.ts +13 -0
  46. package/dist/interactions/wheel-zoom.d.ts +12 -0
  47. package/dist/react/define-CVJd5aYk.cjs +1545 -0
  48. package/dist/react/define-t4Z6KaLY.js +2590 -0
  49. package/dist/react/index-B-csHwK2.cjs +2 -0
  50. package/dist/react/index-CktjrogS.js +1468 -0
  51. package/dist/react/index.cjs +2 -0
  52. package/dist/react/index.d.ts +21 -0
  53. package/dist/react/index.js +10 -0
  54. package/dist/react/sfx-crop.d.ts +86 -0
  55. package/dist/react/use-sfx-crop-controller.d.ts +74 -0
  56. package/dist/react/use-sfx-crop.d.ts +31 -0
  57. package/dist/styles/shared.css.d.ts +20 -0
  58. package/dist/transforms/constrain.d.ts +68 -0
  59. package/dist/transforms/matrix.d.ts +23 -0
  60. package/dist/transforms/transform-state.d.ts +12 -0
  61. package/dist/utils/events.d.ts +16 -0
  62. package/dist/utils/math.d.ts +12 -0
  63. package/package.json +108 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 scaleflex
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,452 @@
1
+ <p align="center">
2
+ <a href="https://www.scaleflex.com/en/home">
3
+ <img width="350" src="https://scaleflex.cloudimg.io/v7/plugins/scaleflex/logo.png?vh=b0a502&radius=25&w=700" alt="Scaleflex logo">
4
+ </a>
5
+ </p>
6
+
7
+ <h1 align="center">Cloudimage Crop</h1>
8
+
9
+ <p align="center">
10
+ <strong>An interactive image-crop editor web component — rotation, fine tilt, flip, zoom and shape selection</strong>
11
+ </p>
12
+
13
+ <p align="center">
14
+ <a href="https://www.npmjs.com/package/@scaleflex/crop">
15
+ <img src="https://img.shields.io/npm/v/@scaleflex/crop.svg" alt="Release">
16
+ </a>
17
+ <a href="https://bundlephobia.com/package/@scaleflex/crop">
18
+ <img src="https://img.shields.io/bundlephobia/minzip/@scaleflex/crop.svg" alt="Size">
19
+ </a>
20
+ <a href="https://www.npmjs.com/package/@scaleflex/crop">
21
+ <img src="https://img.shields.io/npm/dm/@scaleflex/crop.svg" alt="Downloads">
22
+ </a>
23
+ <a href="https://opensource.org/licenses/MIT">
24
+ <img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="License">
25
+ </a>
26
+ <a href="https://www.cloudimage.io/en/home">
27
+ <img src="https://img.shields.io/badge/Powered%20by-Cloudimage-blue" alt="Cloudimage">
28
+ </a>
29
+ </p>
30
+
31
+ <p align="center">
32
+ <a href="https://scaleflex.github.io/image-crop/">View Demo</a> ·
33
+ <a href="https://codesandbox.io/p/sandbox/github/scaleflex/image-crop/tree/master/codesandbox/react">React CodeSandbox</a> ·
34
+ <a href="https://codesandbox.io/p/sandbox/github/scaleflex/image-crop/tree/master/codesandbox/vanilla">Vanilla CodeSandbox</a> ·
35
+ <a href="https://github.com/scaleflex/image-crop/issues">Report Bug</a>
36
+ </p>
37
+
38
+ ## Table of Contents
39
+
40
+ - [Overview](#overview)
41
+ - [Features](#features)
42
+ - [Requirements](#requirements)
43
+ - [Installation](#installation)
44
+ - [Quick Start](#quick-start)
45
+ - [Configuration](#configuration)
46
+ - [Variants](#variants)
47
+ - [Public Methods](#public-methods)
48
+ - [Events](#events)
49
+ - [React API](#react-api)
50
+ - [Theming](#theming)
51
+ - [Types Reference](#types-reference)
52
+ - [Browser Support](#browser-support)
53
+ - [Release](#release)
54
+ - [Claude Code Integration](#claude-code-integration)
55
+ - [License](#license)
56
+
57
+ ## Overview
58
+
59
+ `@scaleflex/crop` ships `<sfx-crop>`, a Lit-based custom element that renders a canvas-backed crop editor with rotation, fine tilt (±45°), horizontal/vertical flip, zoom, pan, and a configurable shape palette (free, square, circle, rounded-rect, plus arbitrary `W:H` ratio strings). The same engine is exposed three ways:
60
+
61
+ - a ready-to-mount custom element (`<sfx-crop>`);
62
+ - a React component (`<SfxCrop>`) plus hooks (`useSfxCrop`, `useSfxCropController`);
63
+ - a headless `createCropController({ canvas, host, config })` factory that drives a consumer-owned `<canvas>` with zero built-in UI.
64
+
65
+ ## Features
66
+
67
+ - Two display variants: `classic` (movable / resizable crop frame over the photo) and `fixed` (the editor box *is* the crop frame — the photo is cover-fit and panned underneath, e.g. avatar / phone-style cropping). See [Variants](#variants).
68
+ - Rotation in 90° increments and fine tilt slider (-45°…+45°), horizontal flip, pinch / wheel / button zoom, keyboard shortcuts.
69
+ - Built-in shape presets (`free`, `square`, `circle`, `rounded-rect`, `16:9`, `4:3`, `3:2`, `5:4`, `2:1`, `9:16`, `3:4`, `2:3`, `4:5`, `1:2`) plus on-the-fly `"W:H"` ratios.
70
+ - Optional bleed-margin guides for print workflows.
71
+ - Themeable via a single `theme="light|dark"` attribute or fine-grained `--sfx-cr-*` CSS custom properties (~50 tokens).
72
+ - Per-icon SVG overrides via the `icons` property.
73
+ - Export to `HTMLCanvasElement`, `Blob`, data URL, or a serialisable `TransformParams` object suitable for server-side processing.
74
+ - Three packaging entry points so consumers pay for only what they use.
75
+
76
+ ## Requirements
77
+
78
+ Modern evergreen browsers with Canvas 2D, Pointer Events, ResizeObserver, CSS container queries, and Custom Elements v1. React 18+ for the React entry. Node 18+ recommended for tooling.
79
+
80
+ ## Installation
81
+
82
+ ### npm / yarn / pnpm
83
+
84
+ ```bash
85
+ npm install @scaleflex/crop
86
+ # or
87
+ yarn add @scaleflex/crop
88
+ # or
89
+ pnpm add @scaleflex/crop
90
+ ```
91
+
92
+ ### CDN
93
+
94
+ ```html
95
+ <script type="module"
96
+ src="https://cdn.jsdelivr.net/npm/@scaleflex/crop/dist/define.js"></script>
97
+ ```
98
+
99
+ ### Package exports
100
+
101
+ | Specifier | Purpose |
102
+ |---|---|
103
+ | `@scaleflex/crop` | Side-effect-free entry. Exports `SfxCropElement`, `createCropController`, `mergeConfig`, `DEFAULT_CONFIG`, and all public types. |
104
+ | `@scaleflex/crop/define` | Side-effectful — registers the `<sfx-crop>` custom element. Import once at bootstrap. |
105
+ | `@scaleflex/crop/react` | React component `<SfxCrop>`, `useSfxCrop` / `useSfxCropController` hooks, plus re-exports of `createCropController`, `mergeConfig`, `DEFAULT_CONFIG`, and the public types. |
106
+
107
+ ## Quick Start
108
+
109
+ ### Vanilla JS / Web Component
110
+
111
+ ```html
112
+ <script type="module">
113
+ import '@scaleflex/crop/define';
114
+ </script>
115
+
116
+ <sfx-crop
117
+ src="https://cdn.example.com/photo.jpg"
118
+ crop-shape="16:9"
119
+ theme="light"
120
+ show-bleed-margin
121
+ ></sfx-crop>
122
+
123
+ <script type="module">
124
+ const crop = document.querySelector('sfx-crop');
125
+ crop.addEventListener('sfx-crop-ready', () => console.log('ready'));
126
+ crop.addEventListener('sfx-crop-save', (e) => {
127
+ const { blob, dataURL, params } = e.detail;
128
+ // upload `blob` or POST `params` to your backend
129
+ });
130
+ </script>
131
+ ```
132
+
133
+ ### React
134
+
135
+ ```tsx
136
+ import { SfxCrop, type SfxCropElement } from '@scaleflex/crop/react';
137
+ import { useRef } from 'react';
138
+
139
+ export function Editor() {
140
+ const ref = useRef<SfxCropElement>(null);
141
+ return (
142
+ <SfxCrop
143
+ ref={ref}
144
+ src="https://cdn.example.com/photo.jpg"
145
+ cropShape="square"
146
+ theme="dark"
147
+ onReady={({ element }) => console.log('ready', element)}
148
+ onSave={({ blob, params }) => upload(blob, params)}
149
+ />
150
+ );
151
+ }
152
+ ```
153
+
154
+ ### CDN
155
+
156
+ ```html
157
+ <script type="module" src="https://cdn.jsdelivr.net/npm/@scaleflex/crop/dist/define.js"></script>
158
+ <sfx-crop src="https://cdn.example.com/photo.jpg" crop-shape="square"></sfx-crop>
159
+ ```
160
+
161
+ ## Configuration
162
+
163
+ All options below are exposed as both HTML attributes (kebab-case) and DOM properties (camelCase) on `<sfx-crop>`. Object/array options should be set as DOM properties; primitives can be set either way.
164
+
165
+ ### Attributes / Properties
166
+
167
+ #### Image & shape
168
+
169
+ | Attribute / Property | Type | Default | Description |
170
+ |---|---|---|---|
171
+ | `src` | `string` | `''` | Image URL to load. Setting after mount triggers a re-load. |
172
+ | `variant` | `'classic' \| 'fixed'` | `'classic'` | Display mode. `classic` = movable/resizable crop frame over the photo. `fixed` = the editor box itself is the crop frame (sized to `crop-shape`), photo cover-fit and panned underneath, toolbar overlaid. Switchable at runtime. See [Variants](#variants). |
173
+ | `crop-shape` / `cropShape` | `CropShapeName` | `'16:9'` | Built-in preset or any `"W:H"` ratio string. |
174
+ | `available-shapes` / `availableShapes` | `CropShapeName[] \| string` | `['free','square','16:9','4:3','3:2','5:4','2:1','9:16','3:4','2:3','4:5','1:2']` | Restricts the shape palette in the toolbar. JSON-stringified array works as an attribute. `circle` and `rounded-rect` are valid built-ins but omitted from the default — pass them explicitly to enable. |
175
+ | `initial-crop` / `initialCrop` | `CropRect \| string \| null` | `null` | Starting crop rect in normalised `[0,1]` image coords. |
176
+ | `initial-rotation` / `initialRotation` | `number` | `0` | Starting fine rotation, degrees. |
177
+ | `initial-scale` / `initialScale` | `number` | `1` | Starting zoom level. |
178
+
179
+ #### Constraints
180
+
181
+ | Attribute / Property | Type | Default | Description |
182
+ |---|---|---|---|
183
+ | `min-scale` / `minScale` | `number` | `0.5` | Minimum zoom level. |
184
+ | `max-scale` / `maxScale` | `number` | `5` | Maximum zoom level. |
185
+ | `min-crop-size` / `minCropSize` | `number` | `20` | Minimum crop edge in canvas pixels. |
186
+ | `handle-size` / `handleSize` | `number` | `12` | Resize-handle radius. |
187
+ | `border-radius` / `borderRadius` | `number` | `20` | Corner radius for the `rounded-rect` shape. |
188
+
189
+ #### Theme & colours
190
+
191
+ | Attribute / Property | Type | Default | Description |
192
+ |---|---|---|---|
193
+ | `theme` | `'light' \| 'dark'` | `'light'` | Switches the bundled palette. Override individual tokens via CSS variables. |
194
+ | `handle-color` / `handleColor` | `string` | `'#ffffff'` | Frame handle fill. |
195
+ | `overlay-color` / `overlayColor` | `string` | `'rgba(0,0,0,0.55)'` | Mask covering the area outside the crop. |
196
+ | `bleed-margin-color` / `bleedMarginColor` | `string` | `'rgba(255,0,0,0.5)'` | Print bleed guide colour. |
197
+ | `bleed-margin-size` / `bleedMarginSize` | `number` | `10` | Bleed inset in pixels. |
198
+ | `show-bleed-margin` / `showBleedMargin` | `boolean` | `false` | Toggles the print bleed guides. |
199
+
200
+ > **Print bleed guides.** In print, artwork is printed slightly larger than the
201
+ > final size and trimmed at the cut line; the *bleed* is the strip near the edge
202
+ > that gets cut off. `show-bleed-margin` draws a dashed **safe-area** rectangle
203
+ > inset `bleed-margin-size` pixels from every edge of the crop, so important
204
+ > content (faces, text, logos) can be kept clear of the trim. It is a
205
+ > **visual guide only** — it is *not* baked into the output: `toCanvas()`,
206
+ > `toBlob()`, `toDataURL()`, and the `sfx-crop-save` payload never contain the
207
+ > dashed line and the crop is not inset by it.
208
+
209
+ #### UI toggles
210
+
211
+ | Attribute / Property | Type | Default | Description |
212
+ |---|---|---|---|
213
+ | `show-toolbar` / `showToolbar` | `boolean` | `true` | Renders the built-in toolbar. |
214
+ | `toolbar-position` / `toolbarPosition` | `'top' \| 'bottom'` | `'top'` | Toolbar placement. |
215
+ | `show-rotate-button` / `showRotateButton` | `boolean` | `true` | 90° rotate-left button. |
216
+ | `show-flip-button` / `showFlipButton` | `boolean` | `true` | Horizontal flip button. |
217
+ | `show-rotate-slider` / `showRotateSlider` | `boolean` | `true` | Fine tilt slider (±45°). |
218
+ | `show-zoom-slider` / `showZoomSlider` | `boolean` | `true` | Zoom slider. |
219
+ | `show-shape-selector` / `showShapeSelector` | `boolean` | `true` | Shape dropdown. |
220
+ | `show-grid` / `showGrid` | `boolean \| 'interaction'` | `'interaction'` | Rule-of-thirds overlay; `'interaction'` shows it only while dragging. |
221
+
222
+ #### Output
223
+
224
+ | Attribute / Property | Type | Default | Description |
225
+ |---|---|---|---|
226
+ | `output-type` / `outputType` | `string` | `'image/png'` | MIME type for `toBlob` / `toDataURL`. |
227
+ | `output-quality` / `outputQuality` | `number` | `0.92` | Quality 0–1 for lossy types. |
228
+ | `max-output-width` / `maxOutputWidth` | `number` | `0` | `0` = original. |
229
+ | `max-output-height` / `maxOutputHeight`| `number` | `0` | `0` = original. |
230
+
231
+ #### Behaviour
232
+
233
+ | Attribute / Property | Type | Default | Description |
234
+ |---|---|---|---|
235
+ | `keyboard` | `boolean` | `true` | Arrow-key nudge / shift-zoom. |
236
+ | `pinch-zoom` / `pinchZoom` | `boolean` | `true` | Two-finger touch zoom. |
237
+ | `wheel-zoom` / `wheelZoom` | `boolean` | `true` | Mouse-wheel zoom. |
238
+ | `enable-animations` / `enableAnimations` | `boolean` | `true` | Spring/lerp transitions. |
239
+ | `animation-speed` / `animationSpeed` | `number` | `1.0` | Multiplier on the default spring. |
240
+ | `icons` (property only) | `CropIconOverrides` | `{}` | SVG-string slot overrides — see `CropIconOverrides` in `src/core/types.ts`. |
241
+
242
+ ## Variants
243
+
244
+ The `variant` attribute switches how the crop is presented. Both share the same engine, tools, events, and export.
245
+
246
+ ### `classic` (default)
247
+
248
+ The photo fills the editor at its own aspect ratio and a **movable / resizable crop frame** floats over it (resize handles + a move-handle), with the area outside the frame dimmed by `overlay-color`. Drag inside the frame to pan the photo, drag the handles to resize.
249
+
250
+ ### `fixed`
251
+
252
+ The **editor box itself is the crop frame**, sized to the `crop-shape` aspect and centred (portrait, landscape, square, circle, rounded-rect — anything). The photo is **cover-fit** and panned/zoomed/rotated underneath; there are no resize handles, and the toolbar is overlaid on the frame. This is the avatar- / phone-style "fixed window, moving photo" pattern.
253
+
254
+ ```html
255
+ <sfx-crop src="/photo.jpg" variant="fixed" crop-shape="1:1"></sfx-crop>
256
+ ```
257
+
258
+ ### Cover guarantee
259
+
260
+ In **both** variants the photo is constrained to always fully cover the crop frame — zoom and pan are clamped (and the minimum zoom is raised) so the exported image never contains transparent gaps. The one exception is a 90°/270° turn in `classic`, which intentionally letterboxes the rotated photo to fit the frame.
261
+
262
+ ### Built-in "Done" button
263
+
264
+ The toolbar renders a primary **Done** button pinned to the right edge. It calls [`save()`](#public-methods) — building `blob` + `dataURL` + `params` and dispatching `sfx-crop-save` — so a host app can commit the crop without wiring its own button. Handle `sfx-crop-save` to upload / persist / close. (Hide the whole toolbar with `show-toolbar="false"` if you prefer to drive everything imperatively.)
265
+
266
+ ## Public Methods
267
+
268
+ All methods live on the `<sfx-crop>` element instance. They throw if invoked before `sfx-crop-ready` fires.
269
+
270
+ | Method | Returns | Description |
271
+ |---|---|---|
272
+ | `loadImage(src)` | `Promise<void>` | Load (or re-load) an image URL. |
273
+ | `getTransformState()` | `TransformState` | Snapshot of rotation, flip, scale, pan, crop. |
274
+ | `getCropRect()` | `CropRect` | Current crop in normalised `[0,1]` coords. |
275
+ | `setCropRect(rect)` | `void` | Programmatic crop update. |
276
+ | `setCropShape(shape)` | `void` | Built-in preset or `"W:H"` ratio. |
277
+ | `rotateLeft()` | `void` | 90° counter-clockwise. |
278
+ | `flipHorizontal()` | `void` | Mirror around vertical axis. |
279
+ | `setRotation(deg)` | `void` | Fine tilt -45…+45. |
280
+ | `setScale(scale)` | `void` | Zoom level (clamped to `min`/`max-scale`). |
281
+ | `reset()` | `void` | Restore initial state. |
282
+ | `toCanvas()` | `HTMLCanvasElement` | Render the current crop into a fresh canvas. |
283
+ | `toBlob(type?, quality?)` | `Promise<Blob>` | Like `HTMLCanvasElement.toBlob` for the cropped output. |
284
+ | `toDataURL(type?, quality?)` | `string` | Like `HTMLCanvasElement.toDataURL`. |
285
+ | `toTransformParams()` | `TransformParams` | Serialisable description of the transform — pass to a server-side resizer. |
286
+ | `save(type?, quality?)` | `Promise<void>` | Convenience: builds blob + dataURL + params and dispatches `sfx-crop-save`. |
287
+ | `cancel()` | `void` | Dispatches `sfx-crop-cancel`. |
288
+
289
+ ## Events
290
+
291
+ All events bubble and cross shadow boundaries (`bubbles: true, composed: true`).
292
+
293
+ | Event | `detail` | Fires on |
294
+ |---|---|---|
295
+ | `sfx-crop-ready` | `{ element: SfxCropElement }` | Controller initialised. |
296
+ | `sfx-crop-image-load` | `{ image: HTMLImageElement }` | Image decoded and rendered. |
297
+ | `sfx-crop-change` | `TransformState` | Any transform mutation. |
298
+ | `sfx-crop-crop-change` | `CropRect` | Crop rect changed. |
299
+ | `sfx-crop-save` | `{ blob, dataURL, params }` | `.save()` resolved. |
300
+ | `sfx-crop-cancel` | `undefined` | `.cancel()` invoked. |
301
+ | `sfx-crop-error` | `{ error: Error }` | Image-load or export error. |
302
+
303
+ ## React API
304
+
305
+ ### `<SfxCrop>` component
306
+
307
+ `forwardRef` component that mirrors the element's attributes as camelCase props and bridges every `sfx-crop-*` event into a matching `on*` callback.
308
+
309
+ ```tsx
310
+ import { SfxCrop } from '@scaleflex/crop/react';
311
+
312
+ <SfxCrop
313
+ src="..."
314
+ cropShape="circle"
315
+ theme="dark"
316
+ showBleedMargin
317
+ availableShapes={['free', 'square', 'circle', '16:9']}
318
+ onReady={({ element }) => {}}
319
+ onImageLoad={({ image }) => {}}
320
+ onChange={(state) => {}}
321
+ onCropChange={(crop) => {}}
322
+ onSave={({ blob, dataURL, params }) => {}}
323
+ onCancel={() => {}}
324
+ onError={({ error }) => {}}
325
+ />
326
+ ```
327
+
328
+ The `ref` resolves to the underlying `SfxCropElement`, so every imperative method above is callable directly.
329
+
330
+ ### `useSfxCrop()` hook
331
+
332
+ For consumers who prefer to render `<sfx-crop>` themselves and pull stable callables off a hook:
333
+
334
+ ```tsx
335
+ import { useSfxCrop } from '@scaleflex/crop/react';
336
+
337
+ const { ref, ready, save, reset, toBlob, getTransformState } = useSfxCrop();
338
+
339
+ return <sfx-crop ref={ref} src="..." />;
340
+ ```
341
+
342
+ `ready` flips to `true` after `sfx-crop-ready`. All callables are no-ops before then.
343
+
344
+ ### `useSfxCropController()` hook (headless)
345
+
346
+ Drives the same controller against a consumer-owned `<canvas>`. Use this when the built-in toolbar isn't a fit and you need to render every UI affordance yourself. See `CropControllerState`, `CropControllerActions`, and `CropControllerApi` in `src/react/use-sfx-crop-controller.ts`.
347
+
348
+ ## Theming
349
+
350
+ ### Brand colour
351
+
352
+ The fastest way to recolour the editor is to override one variable on the host:
353
+
354
+ ```html
355
+ <sfx-crop style="--sfx-cr-primary:#ff3366"></sfx-crop>
356
+ ```
357
+
358
+ ### CSS Custom Properties
359
+
360
+ Every visual surface is keyed off `--sfx-cr-*` tokens. The full list (see `src/styles/shared.css.ts` for the canonical defaults):
361
+
362
+ #### Colours
363
+
364
+ `--sfx-cr-primary`, `--sfx-cr-primary-hover`, `--sfx-cr-primary-mid`, `--sfx-cr-primary-bg`, `--sfx-cr-primary-glow`, `--sfx-cr-success`, `--sfx-cr-error`, `--sfx-cr-text`, `--sfx-cr-text-secondary`, `--sfx-cr-text-muted`, `--sfx-cr-border`, `--sfx-cr-border-light`, `--sfx-cr-bg`, `--sfx-cr-surface`, `--sfx-cr-canvas-bg`.
365
+
366
+ #### Canvas & frame
367
+
368
+ `--sfx-cr-overlay-color`, `--sfx-cr-frame-color`, `--sfx-cr-frame-shadow`, `--sfx-cr-handle-fill`, `--sfx-cr-handle-stroke`, `--sfx-cr-ruler-ink`, `--sfx-cr-ruler-halo`, `--sfx-cr-ring`, `--sfx-cr-shadow`.
369
+
370
+ `--sfx-cr-ruler-ink` and `--sfx-cr-ruler-halo` colour the fine-tilt ruler (ticks, centre indicator, degree readout). The ruler floats directly over the photo, whose brightness is unknown, so it can't track the theme: the ink defaults to a near-white core and the halo to a dark glow wrapped around it, so the white core reads over dark images while the halo reads over bright ones (the trick subtitles use). Override both together if you want a different ink/halo pairing.
371
+
372
+ #### Toolbar & controls
373
+
374
+ `--sfx-cr-toolbar-bg`, `--sfx-cr-toolbar-color`, `--sfx-cr-toolbar-border`, `--sfx-cr-toolbar-shadow`, `--sfx-cr-btn-size`, `--sfx-cr-btn-radius`, `--sfx-cr-btn-hover-bg`, `--sfx-cr-btn-active-bg`, `--sfx-cr-separator-color`, `--sfx-cr-slider-track`, `--sfx-cr-slider-fill`, `--sfx-cr-slider-thumb`, `--sfx-cr-dropdown-bg`, `--sfx-cr-dropdown-hover`, `--sfx-cr-dropdown-shadow`, `--sfx-cr-zoom-bar-bg`.
375
+
376
+ #### Typography & radius
377
+
378
+ `--sfx-cr-font`, `--sfx-cr-radius`, `--sfx-cr-card-shadow`, `--sfx-cr-transition`.
379
+
380
+ A `[theme="dark"]` selector on the host re-binds the same variables to the dark palette — no other configuration needed.
381
+
382
+ ### Shadow parts
383
+
384
+ Style internal regions from light DOM:
385
+
386
+ ```css
387
+ sfx-crop::part(toolbar) { /* ... */ }
388
+ sfx-crop::part(canvas-host) { /* ... */ }
389
+ sfx-crop::part(loading) { /* ... */ }
390
+ sfx-crop::part(error) { /* ... */ }
391
+ sfx-crop::part(container) { /* ... */ }
392
+ ```
393
+
394
+ ## Types Reference
395
+
396
+ All types live in `src/core/types.ts` and are re-exported from both `@scaleflex/crop` and `@scaleflex/crop/react`.
397
+
398
+ - `CropShapeName` — `'free' | 'square' | 'circle' | 'rounded-rect' | '16:9' | …` plus any `"W:H"` string.
399
+ - `CropRect` — `{ x, y, width, height }` in normalised `[0,1]` image coordinates.
400
+ - `TransformState` — full runtime state (`quarterTurns`, `rotation`, `flipH`, `flipV`, `scale`, `panX/Y`, `cropRect`, `rotationPivot?`).
401
+ - `TransformParams` — serialisable export shape (`rotation`, `flipH`, `flipV`, `scale`, `crop` in original-image pixels, `outputWidth`, `outputHeight`).
402
+ - `CropIconOverrides` — per-slot SVG-string overrides for toolbar icons.
403
+ - `SfxCropConfig` — the internal config shape consumed by `createCropController`. Element attributes mirror this 1:1.
404
+
405
+ ## Browser Support
406
+
407
+ Latest two versions of Chrome, Firefox, Safari, and Edge. Requires Custom Elements v1, Canvas 2D, Pointer Events, ResizeObserver, and CSS container queries. No IE11 support.
408
+
409
+ ## Release
410
+
411
+ See [`CHANGELOG.md`](./CHANGELOG.md) for version history. The project follows [Semantic Versioning](https://semver.org/) and the [Keep a Changelog](https://keepachangelog.com/) format. The full technical specification lives in [`SPECIFICATION.md`](./SPECIFICATION.md).
412
+
413
+ ### npm scripts
414
+
415
+ | Script | Purpose |
416
+ |---|---|
417
+ | `npm run dev` | Vite dev server for the demo SPA at `http://localhost:5173`. |
418
+ | `npm run build` | Build the bundle and the React wrapper. |
419
+ | `npm run build:bundle` | Web-component bundle only (`dist/`). |
420
+ | `npm run build:react` | React wrapper only (`dist/react/`). |
421
+ | `npm run build:demo` | Production demo site. |
422
+ | `npm run typecheck` | `tsc --noEmit`. |
423
+ | `npm test` | Vitest run. |
424
+ | `npm run test:watch` | Vitest watch. |
425
+ | `npm run test:coverage` | Vitest with coverage. |
426
+ | `npm run lint` | ESLint over `src/` and `tests/`. |
427
+
428
+ ## Claude Code Integration
429
+
430
+ This repository ships rules and prompts that make [Claude Code](https://claude.ai/code) productive on the codebase out of the box.
431
+
432
+ ### Option 1: Project-level (recommended)
433
+
434
+ Drop a `CLAUDE.md` at the repo root describing the project conventions, then add project-scoped rules under `.claude/` (gitignored). Anyone with Claude Code installed picks them up automatically when they `cd` into the repo.
435
+
436
+ ### Option 2: Global (personal)
437
+
438
+ Add personal rules at `~/.claude/CLAUDE.md` so they apply across every project you open.
439
+
440
+ ### Usage
441
+
442
+ Once configured, run Claude Code from the repo root:
443
+
444
+ ```bash
445
+ claude
446
+ ```
447
+
448
+ Then ask things like *"add a `setFlipVertical()` public method"* or *"explain how the controller settles after a 90° rotation"* and Claude will follow the project's conventions.
449
+
450
+ ## License
451
+
452
+ [MIT](./LICENSE) © 2026 Scaleflex
@@ -0,0 +1,5 @@
1
+ import { TransformState } from '../core/types';
2
+ /** Set up ARIA attributes on the container. */
3
+ export declare function setupAria(container: HTMLElement): void;
4
+ /** Update ARIA live region with current state. */
5
+ export declare function announceState(container: HTMLElement, state: TransformState, cropShape?: string): void;
@@ -0,0 +1,13 @@
1
+ export interface KeyboardCallbacks {
2
+ onRotateLeft(): void;
3
+ onFlipH(): void;
4
+ onZoomIn(): void;
5
+ onZoomOut(): void;
6
+ onResetZoom(): void;
7
+ onMoveCrop(dx: number, dy: number): void;
8
+ onRotateFine(delta: number): void;
9
+ }
10
+ export interface KeyboardHandle {
11
+ destroy(): void;
12
+ }
13
+ export declare function setupKeyboard(container: HTMLElement, callbacks: KeyboardCallbacks): KeyboardHandle;
@@ -0,0 +1,15 @@
1
+ import { LerpConfig } from '../core/types';
2
+ export declare const LERP_DEFAULT: LerpConfig;
3
+ export declare const LERP_CROP_MORPH: LerpConfig;
4
+ export declare const LERP_GRID_FADE: LerpConfig;
5
+ export declare const LERP_TOOLBAR_ENTRY: LerpConfig;
6
+ export interface LerpState {
7
+ value: number;
8
+ target: number;
9
+ }
10
+ export declare function createLerp(initial?: number): LerpState;
11
+ /**
12
+ * Advance a lerp by one step.
13
+ * Returns true if the lerp is still animating.
14
+ */
15
+ export declare function stepLerp(state: LerpState, config: LerpConfig): boolean;
@@ -0,0 +1,15 @@
1
+ import { SpringConfig } from '../core/types';
2
+ export declare const SPRING_ROTATE: SpringConfig;
3
+ export declare const SPRING_FLIP: SpringConfig;
4
+ export declare const SPRING_BOUNCE_HANDLE: SpringConfig;
5
+ export interface SpringState {
6
+ value: number;
7
+ velocity: number;
8
+ target: number;
9
+ }
10
+ export declare function createSpring(config: SpringConfig, initial?: number): SpringState;
11
+ /**
12
+ * Advance a spring by one time step.
13
+ * Returns true if the spring is still animating.
14
+ */
15
+ export declare function stepSpring(state: SpringState, config: SpringConfig, dt: number): boolean;
@@ -0,0 +1,6 @@
1
+ export declare function drawBleedMargin(ctx: CanvasRenderingContext2D, cropRect: {
2
+ x: number;
3
+ y: number;
4
+ width: number;
5
+ height: number;
6
+ }, bleedSize: number, color: string): void;
@@ -0,0 +1,32 @@
1
+ export interface FrameTheme {
2
+ frame: string;
3
+ frameShadow: string;
4
+ handleFill: string;
5
+ handleStroke: string;
6
+ }
7
+ export declare function drawCropFrame(ctx: CanvasRenderingContext2D, cropRect: {
8
+ x: number;
9
+ y: number;
10
+ width: number;
11
+ height: number;
12
+ }, shapeType?: 'rect' | 'circle' | 'rounded-rect', borderRadius?: number, theme?: FrameTheme,
13
+ /**
14
+ * Draw the resize corner handles + move-handle. The `'fixed'` variant turns
15
+ * these off — its frame can't be resized or moved, only the photo moves.
16
+ */
17
+ handles?: boolean): void;
18
+ /** Get the hit areas for handles. Returns rectangles around each handle (44×44px minimum). */
19
+ export declare function getHandleRects(cropRect: {
20
+ x: number;
21
+ y: number;
22
+ width: number;
23
+ height: number;
24
+ }): {
25
+ target: string;
26
+ rect: {
27
+ x: number;
28
+ y: number;
29
+ w: number;
30
+ h: number;
31
+ };
32
+ }[];
@@ -0,0 +1,7 @@
1
+ /** Draw rule-of-thirds grid inside the crop area. */
2
+ export declare function drawGrid(ctx: CanvasRenderingContext2D, cropRect: {
3
+ x: number;
4
+ y: number;
5
+ width: number;
6
+ height: number;
7
+ }, opacity: number): void;
@@ -0,0 +1,10 @@
1
+ import { HitTarget, CursorStyle } from '../core/types';
2
+ /** Determine what's under the cursor. */
3
+ export declare function hitTest(px: number, py: number, cropRect: {
4
+ x: number;
5
+ y: number;
6
+ width: number;
7
+ height: number;
8
+ }): HitTarget;
9
+ /** Map hit target to CSS cursor style. */
10
+ export declare function getCursor(target: HitTarget, isDragging: boolean): CursorStyle;
@@ -0,0 +1,28 @@
1
+ import { DisplayState } from '../core/types';
2
+ /**
3
+ * Aspect-preserving COVER size (in the container's CSS-px units) for drawing
4
+ * `imageW×imageH` so it fully covers `containerW×containerH` with no gaps.
5
+ *
6
+ * `quarterTurns` is accounted for: on an odd 90° turn the image is rotated in
7
+ * place, so the un-rotated draw rect must cover the *swapped* container box
8
+ * (`containerH×containerW`) for the rotated footprint to cover the frame.
9
+ *
10
+ * Shared by the live renderer (fixed variant) and the exporter so the framing
11
+ * matches the canvas pixel-for-pixel.
12
+ */
13
+ export declare function computeCoverDraw(containerW: number, containerH: number, imageW: number, imageH: number, quarterTurns: number): {
14
+ drawW: number;
15
+ drawH: number;
16
+ };
17
+ export declare function drawImageLayer(ctx: CanvasRenderingContext2D, image: HTMLImageElement, imgRect: {
18
+ x: number;
19
+ y: number;
20
+ w: number;
21
+ h: number;
22
+ }, state: DisplayState,
23
+ /**
24
+ * Fixed variant: draw the photo COVER-fit (preserving its natural aspect) so
25
+ * it fills the frame-shaped editor box. In classic mode the box already has
26
+ * the photo's aspect, so the photo is stretched edge-to-edge as before.
27
+ */
28
+ cover?: boolean): void;
@@ -0,0 +1,6 @@
1
+ export declare function drawOverlayLayer(ctx: CanvasRenderingContext2D, canvasWidth: number, canvasHeight: number, cropRect: {
2
+ x: number;
3
+ y: number;
4
+ width: number;
5
+ height: number;
6
+ }, shapeType?: 'rect' | 'circle' | 'rounded-rect', borderRadius?: number, overlayColor?: string): void;
@@ -0,0 +1,34 @@
1
+ import { DisplayState } from '../core/types';
2
+ export type CropShapeType = 'rect' | 'circle' | 'rounded-rect';
3
+ export interface BleedConfig {
4
+ show: boolean;
5
+ size: number;
6
+ color: string;
7
+ }
8
+ export interface RendererContext {
9
+ canvas: HTMLCanvasElement;
10
+ ctx: CanvasRenderingContext2D;
11
+ image: HTMLImageElement;
12
+ imageWidth: number;
13
+ imageHeight: number;
14
+ }
15
+ export interface RendererHandle {
16
+ markDirty(): void;
17
+ startLoop(): void;
18
+ stopLoop(): void;
19
+ setDisplayState(state: DisplayState): void;
20
+ getDisplayState(): DisplayState;
21
+ getCanvasCropRect(): {
22
+ x: number;
23
+ y: number;
24
+ width: number;
25
+ height: number;
26
+ };
27
+ setScaleBounds(min: number, max: number): void;
28
+ setBleedConfig(config: BleedConfig): void;
29
+ /** Toggle the `'fixed'` variant: cover-fit photo, no resize/move handles. */
30
+ setFixedFrame(fixed: boolean): void;
31
+ resize(): void;
32
+ destroy(): void;
33
+ }
34
+ export declare function createRenderer(canvas: HTMLCanvasElement, image: HTMLImageElement, getCropShapeType: () => CropShapeType, borderRadius?: number, reducedMotion?: boolean, layoutContainer?: HTMLElement): RendererHandle;