Appearance
Cropper — live demo
This is the real @rozie-ui/cropper-vue package running on this page (VitePress is itself a Vue app). Drag the crop box, resize it, use the controls — then Export to see the cropped result. Everything below is driven by the same Cropper.rozie source that compiles to all six frameworks.
The crop box is two-way bound with v-model:data — the readout above updates live as you drag, and the buttons drive the imperative handle (setAspectRatio, rotateBy, scaleX/scaleY, reset, getCroppedDataURL). See the full API for the complete prop/event/handle surface.
One source, six outputs
You author the component once as a .rozie file:
html
<!--
Cropper.rozie — data-bound port of Cropper.js v1 (fengyuanchen/cropperjs@^1).
Cropper.js is the de-facto vanilla-JS image-cropping engine. Per-framework
wrappers are LOPSIDED: React has the deep, maintained `react-cropper`; Vue has
the older `vue-cropperjs`; but Angular, Svelte, Solid and Lit have nothing
comparable (thin, stale, or absent). ONE Rozie source ships six idiomatic
packages — so Angular/Svelte/Solid/Lit consumers get a category-leading cropper
for free. That market gap (React served, the rest stranded) is exactly what
Rozie's write-once-ship-six thesis exists to close.
WHY v1 (1.6.x) and not v2 (2.x / `latest`): Cropper.js v2 was rewritten as a
set of Web Components (<cropper-canvas>, <cropper-image>, …) with a completely
different API — it is already "cross-framework" via custom elements and would be
redundant to wrap (especially into Lit). v1's imperative `new Cropper(img, opts)`
class IS the agnostic vanilla engine Rozie liberates, and it is what the
competing wrappers (react-cropper) target. Pin `cropperjs@^1`.
Usage:
<Cropper
:src="$data.imageUrl"
r-model:data="$data.crop" (two-way { x, y, width, height, rotate, scaleX, scaleY })
:aspect-ratio="16 / 9"
view-mode="1"
@crop="onCrop"
/>
Consumers must import Cropper's CSS themselves (the .rozie <style> is scoped, so
it cannot ship the .cropper-* selectors — the engine builds its crop UI as
sibling DOM that never carries Rozie's [data-rozie-s-*] scope attribute). Either:
- `import 'cropperjs/dist/cropper.css'` at the app entry, or
- <link> the CDN copy in index.html.
KEY Cropper.js facts that drive this port:
- The engine attaches to an <img> element: `new Cropper(imgEl, options)`. It
hides the original <img> and builds its own canvas/crop-box overlay as
siblings. We render the <img> (ref="imageEl"), bind its :src, and construct
in $onMount. $refs.imageEl is read ONLY inside $onMount (ROZ123).
- COLLISION DISCIPLINE — this engine names things three dangerous ways:
· `crop` and `zoom` are BOTH emitted events AND instance methods. A bare
`crop`/`zoom` $expose verb collides with the same-named emit (ROZ121,
the TipTap focus/blur event⇄verb class). So the imperative crop/zoom are
exposed under collision-free names: `showCropBox` (→ instance.crop()),
`zoomTo`/`zoomBy` (→ instance.zoomTo / instance.zoom). No bare `crop`/
`zoom` verb is exposed.
· `data` is the lone two-way model prop, so the React emitter auto-
generates an internal `setData` setter — a `setData` $expose verb would
collide (ROZ524, the MapLibre setCenter/setZoom class). So NO `setData`
verb is exposed; consumers set the crop box via the two-way `data`
binding, and `getData` (read-only, no auto-gen clash) is exposed.
- Few options are runtime-reconcilable: v1 ships setters only for aspectRatio
(setAspectRatio), dragMode (setDragMode), the crop box (setData) and
enable/disable; the source (replace). Everything else is CONSTRUCTION-TIME —
applied once in buildCropper(), documented "set at construction" (the
Runtime-updatable? column in docs/guide/cropper.md is No for them). The
`options` passthrough covers any v1 option not surfaced as a first-class
prop (modal, restore, minCropBox*, wheelZoomRatio, …).
- Two-way `data`: the continuous `crop` event carries the live
{ x, y, width, height, rotate, scaleX, scaleY }; we echo it into $model.data
and emit it. The reverse $watch is round-trip-guarded (sameData vs
getData()) so a consumer-driven setData → crop → $model.data → $watch loop
settles instead of oscillating.
-->
<rozie name="Cropper" adopt-document-styles>
<props>
{
// image source — bound onto the <img> AND reconciled at runtime via replace().
src: {
type: String,
default: '',
docs: {
description:
'The image URL the cropper attaches to. Bound onto the `<img>` and reconciled at runtime — changing it calls the engine `replace(url)`.',
example: '<Cropper :src="imageUrl" r-model:data="crop" />',
},
},
// two-way crop box { x, y, width, height, rotate, scaleX, scaleY }. Untyped (no
// `type:`) so it emits as `unknown` and `default: undefined` merges cleanly
// under the strict framework-typecheck harness (the MapLibre maxBounds idiom; a
// typed Object + default undefined would emit `T | undefined` and clash).
data: {
default: undefined,
model: true,
docs: {
description:
'The crop box — `{ x, y, width, height, rotate, scaleX, scaleY }`. The lone two-way `model: true` prop: dragging or resizing the crop box writes the new box back (round-trip-guarded so a programmatic write does not ping-pong), and a consumer write `setData`s the live cropper.',
},
},
// NaN aspectRatio = free ratio (Cropper's own sentinel). Runtime-reconciled via
// setAspectRatio().
aspectRatio: {
type: Number,
default: NaN,
docs: {
description:
'The crop box aspect ratio. `NaN` (the default) is Cropper\'s sentinel for a free ratio. Reconciled at runtime via `setAspectRatio`.',
},
},
// 0|1|2|3 — construction-time (v1 has no setViewMode).
viewMode: {
type: Number,
default: 0,
docs: {
description:
'The view constraint mode (`0`–`3`) that governs how the crop box is restricted to the canvas. Construction-only — Cropper.js v1 has no `setViewMode`.',
},
},
// 'crop' | 'move' | 'none' — runtime-reconciled via setDragMode().
dragMode: {
type: String,
default: 'crop',
docs: {
description:
'The drag behavior: `\'crop\'` draws a new box, `\'move\'` pans the canvas, `\'none\'` disables dragging. Reconciled at runtime via `setDragMode`.',
},
},
// enable/disable the whole cropper — runtime-reconciled.
disabled: {
type: Boolean,
default: false,
docs: {
description:
'Freeze the cropper so it no longer responds to user interaction. Reconciled at runtime via `enable()` / `disable()`.',
},
},
// construction-time interaction/appearance toggles (no v1 runtime setters).
guides: {
type: Boolean,
default: true,
docs: {
description:
'Show the dashed guide lines over the crop box. Construction-only — Cropper.js v1 has no runtime setter.',
},
},
center: {
type: Boolean,
default: true,
docs: {
description:
'Show the center indicator inside the crop box. Construction-only — Cropper.js v1 has no runtime setter.',
},
},
background: {
type: Boolean,
default: true,
docs: {
description:
'Show the grid background behind the image. Construction-only — Cropper.js v1 has no runtime setter.',
},
},
movable: {
type: Boolean,
default: true,
docs: {
description:
'Allow moving (panning) the image. Construction-only — Cropper.js v1 has no runtime setter.',
},
},
rotatable: {
type: Boolean,
default: true,
docs: {
description:
'Allow rotating the image. Construction-only — Cropper.js v1 has no runtime setter.',
},
},
scalable: {
type: Boolean,
default: true,
docs: {
description:
'Allow scaling (flipping) the image. Construction-only — Cropper.js v1 has no runtime setter.',
},
},
zoomable: {
type: Boolean,
default: true,
docs: {
description:
'Allow zooming the image. Construction-only — Cropper.js v1 has no runtime setter.',
},
},
zoomOnWheel: {
type: Boolean,
default: true,
docs: {
description:
'Allow zooming the image via the mouse wheel. Construction-only — Cropper.js v1 has no runtime setter.',
},
},
cropBoxMovable: {
type: Boolean,
default: true,
docs: {
description:
'Allow moving the crop box. Construction-only — Cropper.js v1 has no runtime setter.',
},
},
cropBoxResizable: {
type: Boolean,
default: true,
docs: {
description:
'Allow resizing the crop box. Construction-only — Cropper.js v1 has no runtime setter.',
},
},
autoCrop: {
type: Boolean,
default: true,
docs: {
description:
'Render a crop box automatically when the cropper initializes. Construction-only — Cropper.js v1 has no runtime setter.',
},
},
autoCropArea: {
type: Number,
default: 0.8,
docs: {
description:
'The initial crop-box size as a fraction of the canvas (`0`–`1`). Construction-only — Cropper.js v1 has no runtime setter.',
},
},
responsive: {
type: Boolean,
default: true,
docs: {
description:
'Re-render the cropper on window resize to keep it responsive. Construction-only — Cropper.js v1 has no runtime setter.',
},
},
// Cropper.js `preview` (selector string OR element ref(s)) — live crop thumbnail(s).
// Construction-time (v1 has no setPreview); read DIRECTLY in buildCropper (NOT through
// the $snapshot(options) path — structuredClone throws on DOM elements). On Lit an
// element ref is more robust than a selector (a document selector can't cross the
// wrapper's shadow boundary). Untyped like `data` so it accepts string | element(s).
preview: {
default: undefined,
docs: {
description:
'Live crop-thumbnail target(s) — a selector string or element ref(s) (`HTMLElement`, array, or `NodeList`). Construction-only (v1 has no `setPreview`). On Lit prefer an element ref: a document selector cannot cross the wrapper\'s shadow boundary.',
},
},
// passthrough — spread into the Cropper constructor BEFORE the curated keys
// (explicit props win), so power users can set any v1 option not surfaced here.
options: {
type: Object,
default: () => ({}),
docs: {
description:
'Raw Cropper.js `Options` passthrough — spread into the constructor before the curated keys (explicit props win). Use it for any v1 option not surfaced as a first-class prop (`modal`, `restore`, `minCropBoxWidth`, `wheelZoomRatio`, …).',
},
},
}
</props>
<script>
// The engine default-import is aliased `CropperEngine` — a bare `import Cropper`
// would collide with the component name `Cropper` (the rozie `name`), which the
// emitters declare as a local `Cropper` class/function across React/Solid/Lit
// (TS2440 import-conflict + a cascade of "not newable" errors). MapLibre dodged
// this for free (its import was `maplibregl` ≠ `MapLibre`); same-named single-word
// engines must alias.
import CropperEngine from 'cropperjs'
// null-lets so the bundled-leaf typeNeutralize pass annotates them `any`:
// instance is the Cropper (whose strict Options/Data types the loosely-typed
// .rozie props don't satisfy), and imgEl holds the <img> the engine attaches to
// (queried from the ref'd container in $onMount). Both are the `let x = null`
// idiom the engine-wrapper recipe relies on.
let instance = null
let imgEl = null
// Gate that suppresses the engine's SETUP-time `crop` events from writing the
// two-way `$model.data`. Cropper fires an initial `crop` with its OWN default box
// (autoCropArea) BEFORE the `ready` callback runs, and the `setData($props.data)`
// inside `ready` fires another. Writing those transient engine-internal boxes to
// `$model.data` is wrong — and on unified-model targets (Vue defineModel / Svelte
// $bindable / Angular model() signal, where the model read and write share ONE
// local) the pre-ready write CLOBBERS the very `$props.data` that `ready` then
// reads, so the consumer's initial `:data` crop box is lost and the default box is
// applied instead. (React/Solid read the external prop and Lit's property binding
// is controlled, so the write doesn't change their read — which is why only the
// template-emit family regressed.) We flip this true at the END of `ready`, after
// the initial box is applied, so only genuine post-init user crops drive the model.
let cropReady = false
// pure crop-box equality (rounded px + exact transform) — no sigils, safe at top
// level. The round-trip guard that stops the setData→crop→$model.data→$watch loop.
const sameData = (a, b) => {
if (!a || !b) return false
return (
Math.round(a.x) === Math.round(b.x) &&
Math.round(a.y) === Math.round(b.y) &&
Math.round(a.width) === Math.round(b.width) &&
Math.round(a.height) === Math.round(b.height) &&
a.rotate === b.rotate &&
a.scaleX === b.scaleX &&
a.scaleY === b.scaleY
)
}
// Construct (or, on a future option change, re-construct) the engine. The whole
// options object is a null-let `any` so the constructor's 2nd arg is unchecked —
// the event-callback `e` params (CustomEvent) would otherwise fail the strict
// react/solid/lit tsc against Cropper's Options callback types (the MapLibre
// mapOptions idiom). restoreData re-applies the crop box if we ever rebuild.
const buildCropper = (restoreData) => {
let cfg = null
cfg = {
...$snapshot($props.options),
aspectRatio: $props.aspectRatio,
viewMode: $props.viewMode,
dragMode: $props.dragMode,
guides: $props.guides,
center: $props.center,
background: $props.background,
movable: $props.movable,
rotatable: $props.rotatable,
scalable: $props.scalable,
zoomable: $props.zoomable,
zoomOnWheel: $props.zoomOnWheel,
cropBoxMovable: $props.cropBoxMovable,
cropBoxResizable: $props.cropBoxResizable,
autoCrop: $props.autoCrop,
autoCropArea: $props.autoCropArea,
responsive: $props.responsive,
// construction-time only — read DIRECTLY (NOT $snapshot'd): structuredClone
// throws on the DOM element(s) a `preview` selector/ref resolves to.
preview: $props.preview,
ready: (e) => {
if (restoreData) instance.setData(restoreData)
else if ($props.data) instance.setData($snapshot($props.data))
if ($props.disabled) instance.disable()
// The engine's setup-time `crop` events (the default box fired BEFORE this
// `ready`, and the `setData` echo just above) are suppressed by the `cropReady`
// gate so they can't clobber the consumer's initial `:data` on unified-model
// targets (Vue defineModel / Svelte $bindable / Angular model()). But two-way
// consumers still need to READ the initial box, so echo the now-applied box
// exactly ONCE here (after `$props.data` has been read for setData — no clobber),
// then open the gate so genuine post-init user crops drive the model.
$model.data = instance.getData()
cropReady = true
$emit('ready')
},
cropstart: (e) => $emit('cropstart', { action: e.detail && e.detail.action }),
cropmove: (e) => $emit('cropmove', { action: e.detail && e.detail.action }),
cropend: (e) => $emit('cropend', { action: e.detail && e.detail.action }),
// continuous crop → emit + drive the two-way model (guarded reverse $watch).
crop: (e) => {
// Suppress the engine's setup-time crops (the default box before `ready`, and
// the `setData($props.data)` echo). Propagating them would (a) emit a spurious
// pre-init `crop` and (b) on unified-model targets clobber the consumer's
// initial `:data`. Genuine user crops fire after `cropReady`.
if (!cropReady) return
$emit('crop', e.detail)
if (e.detail) $model.data = e.detail
},
zoom: (e) =>
$emit('zoom', { ratio: e.detail && e.detail.ratio, oldRatio: e.detail && e.detail.oldRatio }),
}
instance = new CropperEngine(imgEl, cfg)
}
$onMount(() => {
// Ref the <img> directly — the engine's attach target (the flatpickr/codemirror
// pattern). $refs is read ONLY here (ROZ123). The React emitter types an `img`
// ref as HTMLElement (not HTMLImageElement) — a strict-tsc mismatch fixed by a
// codegen type-aid (scripts/codegen.mjs), NOT an emitter edit (scope fence).
imgEl = $refs.imageEl
buildCropper(null)
return () => {
if (instance) instance.destroy()
}
})
// ─── reconcile the runtime-reconcilable props (no rebuild) ───────────────────
$watch(() => $props.src, (v) => {
if (instance && typeof v === 'string' && v) instance.replace(v)
})
$watch(() => $props.aspectRatio, (v) => {
if (instance) instance.setAspectRatio(v)
})
$watch(() => $props.dragMode, (v) => {
if (instance && typeof v === 'string') instance.setDragMode(v)
})
$watch(() => $props.disabled, (v) => {
if (!instance) return
if (v) instance.disable()
else instance.enable()
})
$watch(() => $props.data, (v) => {
if (!instance || !v) return
if (sameData(v, instance.getData())) return
instance.setData($snapshot(v))
})
// ─── imperative handle (Phase 21 $expose) ───────────────────────────────────
// 27 verbs, all collision-clear across the three classes documented at the top:
// no bare `crop`/`zoom` (event⇄verb ROZ121 — exposed as showCropBox/zoomTo/zoomBy),
// no `setData` (React data-model auto-setter ROZ524 — set via two-way `data`; the new
// setCanvasData/setCropBoxData are DISTINCT names, NOT the model auto-setter), and
// none match a Lit reserved lifecycle name (update/render/firstUpdated/updated/
// willUpdate/requestUpdate). The added geometry getters (getCanvasData/getCropBoxData/
// getImageData/getContainerData) and movement setters (setCanvasData/setCropBoxData/
// moveTo/move/scale) expose v1's full canvas/crop-box geometry surface; getData and
// zoomTo gain their optional v1 args (rounded, pivot).
function getCropper() { return instance }
function getData(rounded) { return instance ? instance.getData(rounded) : null }
function getCanvasData() { return instance ? instance.getCanvasData() : null }
function getCropBoxData() { return instance ? instance.getCropBoxData() : null }
function getImageData() { return instance ? instance.getImageData() : null }
function getContainerData() { return instance ? instance.getContainerData() : null }
function getCroppedCanvas(opts) { return instance ? instance.getCroppedCanvas(opts) : null }
function getCroppedDataURL(opts) {
if (!instance) return null
const canvas = instance.getCroppedCanvas(opts)
return canvas ? canvas.toDataURL() : null
}
function reset() { if (instance) instance.reset() }
function clear() { if (instance) instance.clear() }
function showCropBox() { if (instance) instance.crop() }
function replace(url) { if (instance) instance.replace(url) }
function rotateTo(deg) { if (instance) instance.rotateTo(deg) }
function rotateBy(deg) { if (instance) instance.rotate(deg) }
function zoomTo(ratio, pivot) { if (instance) instance.zoomTo(ratio, pivot) }
function zoomBy(ratio) { if (instance) instance.zoom(ratio) }
function scaleX(n) { if (instance) instance.scaleX(n) }
function scaleY(n) { if (instance) instance.scaleY(n) }
function scale(x, y) { if (instance) instance.scale(x, y) }
function setCanvasData(d) { if (instance) instance.setCanvasData(d) }
function setCropBoxData(d) { if (instance) instance.setCropBoxData(d) }
function moveTo(x, y) { if (instance) instance.moveTo(x, y) }
function move(offsetX, offsetY) { if (instance) instance.move(offsetX, offsetY) }
function enable() { if (instance) instance.enable() }
function disable() { if (instance) instance.disable() }
function setAspectRatio(ratio) { if (instance) instance.setAspectRatio(ratio) }
function setDragMode(mode) { if (instance) instance.setDragMode(mode) }
$expose({
getCropper, getData, getCanvasData, getCropBoxData, getImageData, getContainerData,
getCroppedCanvas, getCroppedDataURL,
reset, clear, showCropBox, replace,
rotateTo, rotateBy, zoomTo, zoomBy, scaleX, scaleY, scale,
setCanvasData, setCropBoxData, moveTo, move,
enable, disable, setAspectRatio, setDragMode,
})
</script>
<template>
<div class="rozie-cropper">
<img class="rozie-cropper-img" ref="imageEl" :src="$props.src" alt="" />
</div>
</template>
<style>
.rozie-cropper {
max-width: 100%;
}
/* Cropper.js requires the target <img> to be block-level and width-constrained;
the engine then hides it and builds its .cropper-* overlay (styled by the
consumer-imported cropperjs/dist/cropper.css — the scoped .rozie <style> cannot
reach that engine-created DOM). */
.rozie-cropper-img {
display: block;
max-width: 100%;
}
</style>
</rozie>…and Rozie compiles it to six idiomatic, framework-native components. Switch the tabs to see the actual generated output for each target (this is exactly what ships in @rozie-ui/cropper-{react,vue,svelte,angular,solid,lit}):
tsx
import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react';
import { clsx, useControllableState } from '@rozie/runtime-react';
import './Cropper.css';
// The engine default-import is aliased `CropperEngine` — a bare `import Cropper`
// would collide with the component name `Cropper` (the rozie `name`), which the
// emitters declare as a local `Cropper` class/function across React/Solid/Lit
// (TS2440 import-conflict + a cascade of "not newable" errors). MapLibre dodged
// this for free (its import was `maplibregl` ≠ `MapLibre`); same-named single-word
// engines must alias.
import CropperEngine from 'cropperjs';
// null-lets so the bundled-leaf typeNeutralize pass annotates them `any`:
// instance is the Cropper (whose strict Options/Data types the loosely-typed
// .rozie props don't satisfy), and imgEl holds the <img> the engine attaches to
// (queried from the ref'd container in $onMount). Both are the `let x = null`
// idiom the engine-wrapper recipe relies on.
interface CropperProps {
/**
* The image URL the cropper attaches to. Bound onto the `<img>` and reconciled at runtime — changing it calls the engine `replace(url)`.
* @example
* <Cropper :src="imageUrl" r-model:data="crop" />
*/
src?: string;
/**
* The crop box — `{ x, y, width, height, rotate, scaleX, scaleY }`. The lone two-way `model: true` prop: dragging or resizing the crop box writes the new box back (round-trip-guarded so a programmatic write does not ping-pong), and a consumer write `setData`s the live cropper.
*/
data?: unknown;
defaultData?: unknown;
onDataChange?: (data: unknown) => void;
/**
* The crop box aspect ratio. `NaN` (the default) is Cropper's sentinel for a free ratio. Reconciled at runtime via `setAspectRatio`.
*/
aspectRatio?: number;
/**
* The view constraint mode (`0`–`3`) that governs how the crop box is restricted to the canvas. Construction-only — Cropper.js v1 has no `setViewMode`.
*/
viewMode?: number;
/**
* The drag behavior: `'crop'` draws a new box, `'move'` pans the canvas, `'none'` disables dragging. Reconciled at runtime via `setDragMode`.
*/
dragMode?: string;
/**
* Freeze the cropper so it no longer responds to user interaction. Reconciled at runtime via `enable()` / `disable()`.
*/
disabled?: boolean;
/**
* Show the dashed guide lines over the crop box. Construction-only — Cropper.js v1 has no runtime setter.
*/
guides?: boolean;
/**
* Show the center indicator inside the crop box. Construction-only — Cropper.js v1 has no runtime setter.
*/
center?: boolean;
/**
* Show the grid background behind the image. Construction-only — Cropper.js v1 has no runtime setter.
*/
background?: boolean;
/**
* Allow moving (panning) the image. Construction-only — Cropper.js v1 has no runtime setter.
*/
movable?: boolean;
/**
* Allow rotating the image. Construction-only — Cropper.js v1 has no runtime setter.
*/
rotatable?: boolean;
/**
* Allow scaling (flipping) the image. Construction-only — Cropper.js v1 has no runtime setter.
*/
scalable?: boolean;
/**
* Allow zooming the image. Construction-only — Cropper.js v1 has no runtime setter.
*/
zoomable?: boolean;
/**
* Allow zooming the image via the mouse wheel. Construction-only — Cropper.js v1 has no runtime setter.
*/
zoomOnWheel?: boolean;
/**
* Allow moving the crop box. Construction-only — Cropper.js v1 has no runtime setter.
*/
cropBoxMovable?: boolean;
/**
* Allow resizing the crop box. Construction-only — Cropper.js v1 has no runtime setter.
*/
cropBoxResizable?: boolean;
/**
* Render a crop box automatically when the cropper initializes. Construction-only — Cropper.js v1 has no runtime setter.
*/
autoCrop?: boolean;
/**
* The initial crop-box size as a fraction of the canvas (`0`–`1`). Construction-only — Cropper.js v1 has no runtime setter.
*/
autoCropArea?: number;
/**
* Re-render the cropper on window resize to keep it responsive. Construction-only — Cropper.js v1 has no runtime setter.
*/
responsive?: boolean;
/**
* Live crop-thumbnail target(s) — a selector string or element ref(s) (`HTMLElement`, array, or `NodeList`). Construction-only (v1 has no `setPreview`). On Lit prefer an element ref: a document selector cannot cross the wrapper's shadow boundary.
*/
preview?: unknown;
/**
* Raw Cropper.js `Options` passthrough — spread into the constructor before the curated keys (explicit props win). Use it for any v1 option not surfaced as a first-class prop (`modal`, `restore`, `minCropBoxWidth`, `wheelZoomRatio`, …).
*/
options?: Record<string, any>;
onReady?: (...args: any[]) => void;
onCropstart?: (...args: any[]) => void;
onCropmove?: (...args: any[]) => void;
onCropend?: (...args: any[]) => void;
onCrop?: (...args: any[]) => void;
onZoom?: (...args: any[]) => void;
}
export interface CropperHandle {
getCropper: (...args: any[]) => any;
getData: (...args: any[]) => any;
getCanvasData: (...args: any[]) => any;
getCropBoxData: (...args: any[]) => any;
getImageData: (...args: any[]) => any;
getContainerData: (...args: any[]) => any;
getCroppedCanvas: (...args: any[]) => any;
getCroppedDataURL: (...args: any[]) => any;
reset: (...args: any[]) => any;
clear: (...args: any[]) => any;
showCropBox: (...args: any[]) => any;
replace: (...args: any[]) => any;
rotateTo: (...args: any[]) => any;
rotateBy: (...args: any[]) => any;
zoomTo: (...args: any[]) => any;
zoomBy: (...args: any[]) => any;
scaleX: (...args: any[]) => any;
scaleY: (...args: any[]) => any;
scale: (...args: any[]) => any;
setCanvasData: (...args: any[]) => any;
setCropBoxData: (...args: any[]) => any;
moveTo: (...args: any[]) => any;
move: (...args: any[]) => any;
enable: (...args: any[]) => any;
disable: (...args: any[]) => any;
setAspectRatio: (...args: any[]) => any;
setDragMode: (...args: any[]) => any;
}
const Cropper = forwardRef<CropperHandle, CropperProps>(function Cropper(_props: CropperProps, ref): JSX.Element {
const __defaultOptions = useState(() => (() => ({}))())[0];
const props: Omit<CropperProps, 'src' | 'aspectRatio' | 'viewMode' | 'dragMode' | 'disabled' | 'guides' | 'center' | 'background' | 'movable' | 'rotatable' | 'scalable' | 'zoomable' | 'zoomOnWheel' | 'cropBoxMovable' | 'cropBoxResizable' | 'autoCrop' | 'autoCropArea' | 'responsive' | 'preview' | 'options'> & { src: string; aspectRatio: number; viewMode: number; dragMode: string; disabled: boolean; guides: boolean; center: boolean; background: boolean; movable: boolean; rotatable: boolean; scalable: boolean; zoomable: boolean; zoomOnWheel: boolean; cropBoxMovable: boolean; cropBoxResizable: boolean; autoCrop: boolean; autoCropArea: number; responsive: boolean; preview: unknown; options: Record<string, any> } = {
..._props,
src: _props.src ?? '',
aspectRatio: _props.aspectRatio ?? NaN,
viewMode: _props.viewMode ?? 0,
dragMode: _props.dragMode ?? 'crop',
disabled: _props.disabled ?? false,
guides: _props.guides ?? true,
center: _props.center ?? true,
background: _props.background ?? true,
movable: _props.movable ?? true,
rotatable: _props.rotatable ?? true,
scalable: _props.scalable ?? true,
zoomable: _props.zoomable ?? true,
zoomOnWheel: _props.zoomOnWheel ?? true,
cropBoxMovable: _props.cropBoxMovable ?? true,
cropBoxResizable: _props.cropBoxResizable ?? true,
autoCrop: _props.autoCrop ?? true,
autoCropArea: _props.autoCropArea ?? 0.8,
responsive: _props.responsive ?? true,
preview: _props.preview ?? undefined,
options: _props.options ?? __defaultOptions,
};
const attrs: Record<string, unknown> = (() => {
const { src, data, aspectRatio, viewMode, dragMode, disabled, guides, center, background, movable, rotatable, scalable, zoomable, zoomOnWheel, cropBoxMovable, cropBoxResizable, autoCrop, autoCropArea, responsive, preview, options, defaultValue, onDataChange, defaultData, ...rest } = _props as CropperProps & Record<string, unknown>;
void src; void data; void aspectRatio; void viewMode; void dragMode; void disabled; void guides; void center; void background; void movable; void rotatable; void scalable; void zoomable; void zoomOnWheel; void cropBoxMovable; void cropBoxResizable; void autoCrop; void autoCropArea; void responsive; void preview; void options; void defaultValue; void onDataChange; void defaultData;
return rest;
})();
const imgEl = useRef<any>(null);
const instance = useRef<any>(null);
const cropReady = useRef(false);
const [data, setData] = useControllableState({
value: props.data,
defaultValue: props.defaultData ?? undefined,
onValueChange: props.onDataChange,
});
const imageEl = useRef<HTMLImageElement | null>(null);
const _watch0First = useRef(true);
const _watch1First = useRef(true);
const _watch2First = useRef(true);
const _watch3First = useRef(true);
const _watch4First = useRef(true);
function sameData(a: any, b: any) {
if (!a || !b) return false;
return Math.round(a.x) === Math.round(b.x) && Math.round(a.y) === Math.round(b.y) && Math.round(a.width) === Math.round(b.width) && Math.round(a.height) === Math.round(b.height) && a.rotate === b.rotate && a.scaleX === b.scaleX && a.scaleY === b.scaleY;
}
const { onCrop: _rozieProp_onCrop, onCropend: _rozieProp_onCropend, onCropmove: _rozieProp_onCropmove, onCropstart: _rozieProp_onCropstart, onReady: _rozieProp_onReady, onZoom: _rozieProp_onZoom } = props;
const buildCropper = useCallback((restoreData: any) => {
let cfg: any = null;
cfg = {
...props.options,
aspectRatio: props.aspectRatio,
viewMode: props.viewMode,
dragMode: props.dragMode,
guides: props.guides,
center: props.center,
background: props.background,
movable: props.movable,
rotatable: props.rotatable,
scalable: props.scalable,
zoomable: props.zoomable,
zoomOnWheel: props.zoomOnWheel,
cropBoxMovable: props.cropBoxMovable,
cropBoxResizable: props.cropBoxResizable,
autoCrop: props.autoCrop,
autoCropArea: props.autoCropArea,
responsive: props.responsive,
// construction-time only — read DIRECTLY (NOT $snapshot'd): structuredClone
// throws on the DOM element(s) a `preview` selector/ref resolves to.
preview: props.preview,
ready: (e: any) => {
if (restoreData) instance.current.setData(restoreData);else if (data) instance.current.setData(data);
if (props.disabled) instance.current.disable();
// The engine's setup-time `crop` events (the default box fired BEFORE this
// `ready`, and the `setData` echo just above) are suppressed by the `cropReady`
// gate so they can't clobber the consumer's initial `:data` on unified-model
// targets (Vue defineModel / Svelte $bindable / Angular model()). But two-way
// consumers still need to READ the initial box, so echo the now-applied box
// exactly ONCE here (after `$props.data` has been read for setData — no clobber),
// then open the gate so genuine post-init user crops drive the model.
setData(instance.current.getData());
cropReady.current = true;
_rozieProp_onReady && _rozieProp_onReady();
},
cropstart: (e: any) => _rozieProp_onCropstart && _rozieProp_onCropstart({
action: e.detail && e.detail.action
}),
cropmove: (e: any) => _rozieProp_onCropmove && _rozieProp_onCropmove({
action: e.detail && e.detail.action
}),
cropend: (e: any) => _rozieProp_onCropend && _rozieProp_onCropend({
action: e.detail && e.detail.action
}),
// continuous crop → emit + drive the two-way model (guarded reverse $watch).
crop: (e: any) => {
// Suppress the engine's setup-time crops (the default box before `ready`, and
// the `setData($props.data)` echo). Propagating them would (a) emit a spurious
// pre-init `crop` and (b) on unified-model targets clobber the consumer's
// initial `:data`. Genuine user crops fire after `cropReady`.
if (!cropReady.current) return;
_rozieProp_onCrop && _rozieProp_onCrop(e.detail);
if (e.detail) setData(e.detail);
},
zoom: (e: any) => _rozieProp_onZoom && _rozieProp_onZoom({
ratio: e.detail && e.detail.ratio,
oldRatio: e.detail && e.detail.oldRatio
})
};
instance.current = new CropperEngine(imgEl.current, cfg);
}, [_rozieProp_onCrop, _rozieProp_onCropend, _rozieProp_onCropmove, _rozieProp_onCropstart, _rozieProp_onReady, _rozieProp_onZoom, data, props.aspectRatio, props.autoCrop, props.autoCropArea, props.background, props.center, props.cropBoxMovable, props.cropBoxResizable, props.disabled, props.dragMode, props.guides, props.movable, props.options, props.preview, props.responsive, props.rotatable, props.scalable, props.viewMode, props.zoomOnWheel, props.zoomable, setData]);
// ─── imperative handle (Phase 21 $expose) ───────────────────────────────────
// 27 verbs, all collision-clear across the three classes documented at the top:
// no bare `crop`/`zoom` (event⇄verb ROZ121 — exposed as showCropBox/zoomTo/zoomBy),
// no `setData` (React data-model auto-setter ROZ524 — set via two-way `data`; the new
// setCanvasData/setCropBoxData are DISTINCT names, NOT the model auto-setter), and
// none match a Lit reserved lifecycle name (update/render/firstUpdated/updated/
// willUpdate/requestUpdate). The added geometry getters (getCanvasData/getCropBoxData/
// getImageData/getContainerData) and movement setters (setCanvasData/setCropBoxData/
// moveTo/move/scale) expose v1's full canvas/crop-box geometry surface; getData and
// zoomTo gain their optional v1 args (rounded, pivot).
function getCropper() {
return instance.current;
}
function getData(rounded: any) {
return instance.current ? instance.current.getData(rounded) : null;
}
function getCanvasData() {
return instance.current ? instance.current.getCanvasData() : null;
}
function getCropBoxData() {
return instance.current ? instance.current.getCropBoxData() : null;
}
function getImageData() {
return instance.current ? instance.current.getImageData() : null;
}
function getContainerData() {
return instance.current ? instance.current.getContainerData() : null;
}
function getCroppedCanvas(opts: any) {
return instance.current ? instance.current.getCroppedCanvas(opts) : null;
}
function getCroppedDataURL(opts: any) {
if (!instance.current) return null;
const canvas = instance.current.getCroppedCanvas(opts);
return canvas ? canvas.toDataURL() : null;
}
function reset() {
if (instance.current) instance.current.reset();
}
function clear() {
if (instance.current) instance.current.clear();
}
function showCropBox() {
if (instance.current) instance.current.crop();
}
function replace(url: any) {
if (instance.current) instance.current.replace(url);
}
function rotateTo(deg: any) {
if (instance.current) instance.current.rotateTo(deg);
}
function rotateBy(deg: any) {
if (instance.current) instance.current.rotate(deg);
}
function zoomTo(ratio: any, pivot: any) {
if (instance.current) instance.current.zoomTo(ratio, pivot);
}
function zoomBy(ratio: any) {
if (instance.current) instance.current.zoom(ratio);
}
function scaleX(n: any) {
if (instance.current) instance.current.scaleX(n);
}
function scaleY(n: any) {
if (instance.current) instance.current.scaleY(n);
}
function scale(x: any, y: any) {
if (instance.current) instance.current.scale(x, y);
}
function setCanvasData(d: any) {
if (instance.current) instance.current.setCanvasData(d);
}
function setCropBoxData(d: any) {
if (instance.current) instance.current.setCropBoxData(d);
}
function moveTo(x: any, y: any) {
if (instance.current) instance.current.moveTo(x, y);
}
function move(offsetX: any, offsetY: any) {
if (instance.current) instance.current.move(offsetX, offsetY);
}
function enable() {
if (instance.current) instance.current.enable();
}
function disable() {
if (instance.current) instance.current.disable();
}
function setAspectRatio(ratio: any) {
if (instance.current) instance.current.setAspectRatio(ratio);
}
function setDragMode(mode: any) {
if (instance.current) instance.current.setDragMode(mode);
}
useEffect(() => {
// Ref the <img> directly — the engine's attach target (the flatpickr/codemirror
// pattern). $refs is read ONLY here (ROZ123). The React emitter types an `img`
// ref as HTMLElement (not HTMLImageElement) — a strict-tsc mismatch fixed by a
// codegen type-aid (scripts/codegen.mjs), NOT an emitter edit (scope fence).
imgEl.current = imageEl.current;
buildCropper(null);
return () => {
if (instance.current) instance.current.destroy();
};
}, []); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (_watch0First.current) { _watch0First.current = false; return; }
const v = props.src;
if (instance.current && typeof v === 'string' && v) instance.current.replace(v);
}, [props.src]);
useEffect(() => {
if (_watch1First.current) { _watch1First.current = false; return; }
const v = props.aspectRatio;
if (instance.current) instance.current.setAspectRatio(v);
}, [props.aspectRatio]);
useEffect(() => {
if (_watch2First.current) { _watch2First.current = false; return; }
const v = props.dragMode;
if (instance.current && typeof v === 'string') instance.current.setDragMode(v);
}, [props.dragMode]);
useEffect(() => {
if (_watch3First.current) { _watch3First.current = false; return; }
const v = props.disabled;
if (!instance.current) return;
if (v) instance.current.disable();else instance.current.enable();
}, [props.disabled]);
useEffect(() => {
if (_watch4First.current) { _watch4First.current = false; return; }
const v = data;
if (!instance.current || !v) return;
if (sameData(v, instance.current.getData())) return;
instance.current.setData(v);
}, [data]); // eslint-disable-line react-hooks/exhaustive-deps
const _rozieExposeRef = useRef({ getCropper, getData, getCanvasData, getCropBoxData, getImageData, getContainerData, getCroppedCanvas, getCroppedDataURL, reset, clear, showCropBox, replace, rotateTo, rotateBy, zoomTo, zoomBy, scaleX, scaleY, scale, setCanvasData, setCropBoxData, moveTo, move, enable, disable, setAspectRatio, setDragMode });
_rozieExposeRef.current = { getCropper, getData, getCanvasData, getCropBoxData, getImageData, getContainerData, getCroppedCanvas, getCroppedDataURL, reset, clear, showCropBox, replace, rotateTo, rotateBy, zoomTo, zoomBy, scaleX, scaleY, scale, setCanvasData, setCropBoxData, moveTo, move, enable, disable, setAspectRatio, setDragMode };
useImperativeHandle(ref, () => ({ getCropper: (...args: Parameters<typeof getCropper>): ReturnType<typeof getCropper> => _rozieExposeRef.current.getCropper(...args), getData: (...args: Parameters<typeof getData>): ReturnType<typeof getData> => _rozieExposeRef.current.getData(...args), getCanvasData: (...args: Parameters<typeof getCanvasData>): ReturnType<typeof getCanvasData> => _rozieExposeRef.current.getCanvasData(...args), getCropBoxData: (...args: Parameters<typeof getCropBoxData>): ReturnType<typeof getCropBoxData> => _rozieExposeRef.current.getCropBoxData(...args), getImageData: (...args: Parameters<typeof getImageData>): ReturnType<typeof getImageData> => _rozieExposeRef.current.getImageData(...args), getContainerData: (...args: Parameters<typeof getContainerData>): ReturnType<typeof getContainerData> => _rozieExposeRef.current.getContainerData(...args), getCroppedCanvas: (...args: Parameters<typeof getCroppedCanvas>): ReturnType<typeof getCroppedCanvas> => _rozieExposeRef.current.getCroppedCanvas(...args), getCroppedDataURL: (...args: Parameters<typeof getCroppedDataURL>): ReturnType<typeof getCroppedDataURL> => _rozieExposeRef.current.getCroppedDataURL(...args), reset: (...args: Parameters<typeof reset>): ReturnType<typeof reset> => _rozieExposeRef.current.reset(...args), clear: (...args: Parameters<typeof clear>): ReturnType<typeof clear> => _rozieExposeRef.current.clear(...args), showCropBox: (...args: Parameters<typeof showCropBox>): ReturnType<typeof showCropBox> => _rozieExposeRef.current.showCropBox(...args), replace: (...args: Parameters<typeof replace>): ReturnType<typeof replace> => _rozieExposeRef.current.replace(...args), rotateTo: (...args: Parameters<typeof rotateTo>): ReturnType<typeof rotateTo> => _rozieExposeRef.current.rotateTo(...args), rotateBy: (...args: Parameters<typeof rotateBy>): ReturnType<typeof rotateBy> => _rozieExposeRef.current.rotateBy(...args), zoomTo: (...args: Parameters<typeof zoomTo>): ReturnType<typeof zoomTo> => _rozieExposeRef.current.zoomTo(...args), zoomBy: (...args: Parameters<typeof zoomBy>): ReturnType<typeof zoomBy> => _rozieExposeRef.current.zoomBy(...args), scaleX: (...args: Parameters<typeof scaleX>): ReturnType<typeof scaleX> => _rozieExposeRef.current.scaleX(...args), scaleY: (...args: Parameters<typeof scaleY>): ReturnType<typeof scaleY> => _rozieExposeRef.current.scaleY(...args), scale: (...args: Parameters<typeof scale>): ReturnType<typeof scale> => _rozieExposeRef.current.scale(...args), setCanvasData: (...args: Parameters<typeof setCanvasData>): ReturnType<typeof setCanvasData> => _rozieExposeRef.current.setCanvasData(...args), setCropBoxData: (...args: Parameters<typeof setCropBoxData>): ReturnType<typeof setCropBoxData> => _rozieExposeRef.current.setCropBoxData(...args), moveTo: (...args: Parameters<typeof moveTo>): ReturnType<typeof moveTo> => _rozieExposeRef.current.moveTo(...args), move: (...args: Parameters<typeof move>): ReturnType<typeof move> => _rozieExposeRef.current.move(...args), enable: (...args: Parameters<typeof enable>): ReturnType<typeof enable> => _rozieExposeRef.current.enable(...args), disable: (...args: Parameters<typeof disable>): ReturnType<typeof disable> => _rozieExposeRef.current.disable(...args), setAspectRatio: (...args: Parameters<typeof setAspectRatio>): ReturnType<typeof setAspectRatio> => _rozieExposeRef.current.setAspectRatio(...args), setDragMode: (...args: Parameters<typeof setDragMode>): ReturnType<typeof setDragMode> => _rozieExposeRef.current.setDragMode(...args) }), []);
return (
<>
<div {...attrs} className={clsx("rozie-cropper", (attrs.className as string | undefined))} data-rozie-s-cddf3b42="">
<img className={"rozie-cropper-img"} ref={imageEl} src={props.src} alt="" data-rozie-s-cddf3b42="" />
</div>
</>
);
});
export default Cropper;vue
<template>
<div class="rozie-cropper" v-bind="$attrs">
<img class="rozie-cropper-img" ref="imageElRef" :src="props.src" alt="" />
</div>
</template>
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
const props = withDefaults(
defineProps<{
/**
* The image URL the cropper attaches to. Bound onto the `<img>` and reconciled at runtime — changing it calls the engine `replace(url)`.
* @example
* <Cropper :src="imageUrl" r-model:data="crop" />
*/
src?: string;
/**
* The crop box aspect ratio. `NaN` (the default) is Cropper's sentinel for a free ratio. Reconciled at runtime via `setAspectRatio`.
*/
aspectRatio?: number;
/**
* The view constraint mode (`0`–`3`) that governs how the crop box is restricted to the canvas. Construction-only — Cropper.js v1 has no `setViewMode`.
*/
viewMode?: number;
/**
* The drag behavior: `'crop'` draws a new box, `'move'` pans the canvas, `'none'` disables dragging. Reconciled at runtime via `setDragMode`.
*/
dragMode?: string;
/**
* Freeze the cropper so it no longer responds to user interaction. Reconciled at runtime via `enable()` / `disable()`.
*/
disabled?: boolean;
/**
* Show the dashed guide lines over the crop box. Construction-only — Cropper.js v1 has no runtime setter.
*/
guides?: boolean;
/**
* Show the center indicator inside the crop box. Construction-only — Cropper.js v1 has no runtime setter.
*/
center?: boolean;
/**
* Show the grid background behind the image. Construction-only — Cropper.js v1 has no runtime setter.
*/
background?: boolean;
/**
* Allow moving (panning) the image. Construction-only — Cropper.js v1 has no runtime setter.
*/
movable?: boolean;
/**
* Allow rotating the image. Construction-only — Cropper.js v1 has no runtime setter.
*/
rotatable?: boolean;
/**
* Allow scaling (flipping) the image. Construction-only — Cropper.js v1 has no runtime setter.
*/
scalable?: boolean;
/**
* Allow zooming the image. Construction-only — Cropper.js v1 has no runtime setter.
*/
zoomable?: boolean;
/**
* Allow zooming the image via the mouse wheel. Construction-only — Cropper.js v1 has no runtime setter.
*/
zoomOnWheel?: boolean;
/**
* Allow moving the crop box. Construction-only — Cropper.js v1 has no runtime setter.
*/
cropBoxMovable?: boolean;
/**
* Allow resizing the crop box. Construction-only — Cropper.js v1 has no runtime setter.
*/
cropBoxResizable?: boolean;
/**
* Render a crop box automatically when the cropper initializes. Construction-only — Cropper.js v1 has no runtime setter.
*/
autoCrop?: boolean;
/**
* The initial crop-box size as a fraction of the canvas (`0`–`1`). Construction-only — Cropper.js v1 has no runtime setter.
*/
autoCropArea?: number;
/**
* Re-render the cropper on window resize to keep it responsive. Construction-only — Cropper.js v1 has no runtime setter.
*/
responsive?: boolean;
/**
* Live crop-thumbnail target(s) — a selector string or element ref(s) (`HTMLElement`, array, or `NodeList`). Construction-only (v1 has no `setPreview`). On Lit prefer an element ref: a document selector cannot cross the wrapper's shadow boundary.
*/
preview?: unknown;
/**
* Raw Cropper.js `Options` passthrough — spread into the constructor before the curated keys (explicit props win). Use it for any v1 option not surfaced as a first-class prop (`modal`, `restore`, `minCropBoxWidth`, `wheelZoomRatio`, …).
*/
options?: Record<string, any>;
}>(),
{ src: '', aspectRatio: NaN, viewMode: 0, dragMode: 'crop', disabled: false, guides: true, center: true, background: true, movable: true, rotatable: true, scalable: true, zoomable: true, zoomOnWheel: true, cropBoxMovable: true, cropBoxResizable: true, autoCrop: true, autoCropArea: 0.8, responsive: true, preview: undefined, options: () => ({}) }
);
/**
* The crop box — `{ x, y, width, height, rotate, scaleX, scaleY }`. The lone two-way `model: true` prop: dragging or resizing the crop box writes the new box back (round-trip-guarded so a programmatic write does not ping-pong), and a consumer write `setData`s the live cropper.
*/
const data = defineModel<unknown>('data', { default: undefined });
const emit = defineEmits<{
ready: [...args: any[]];
cropstart: [...args: any[]];
cropmove: [...args: any[]];
cropend: [...args: any[]];
crop: [...args: any[]];
zoom: [...args: any[]];
}>();
const imageElRef = ref<HTMLElement>();
// The engine default-import is aliased `CropperEngine` — a bare `import Cropper`
// would collide with the component name `Cropper` (the rozie `name`), which the
// emitters declare as a local `Cropper` class/function across React/Solid/Lit
// (TS2440 import-conflict + a cascade of "not newable" errors). MapLibre dodged
// this for free (its import was `maplibregl` ≠ `MapLibre`); same-named single-word
// engines must alias.
import CropperEngine from 'cropperjs';
// null-lets so the bundled-leaf typeNeutralize pass annotates them `any`:
// instance is the Cropper (whose strict Options/Data types the loosely-typed
// .rozie props don't satisfy), and imgEl holds the <img> the engine attaches to
// (queried from the ref'd container in $onMount). Both are the `let x = null`
// idiom the engine-wrapper recipe relies on.
// null-lets so the bundled-leaf typeNeutralize pass annotates them `any`:
// instance is the Cropper (whose strict Options/Data types the loosely-typed
// .rozie props don't satisfy), and imgEl holds the <img> the engine attaches to
// (queried from the ref'd container in $onMount). Both are the `let x = null`
// idiom the engine-wrapper recipe relies on.
let instance: any = null;
let imgEl: any = null;
// Gate that suppresses the engine's SETUP-time `crop` events from writing the
// two-way `$model.data`. Cropper fires an initial `crop` with its OWN default box
// (autoCropArea) BEFORE the `ready` callback runs, and the `setData($props.data)`
// inside `ready` fires another. Writing those transient engine-internal boxes to
// `$model.data` is wrong — and on unified-model targets (Vue defineModel / Svelte
// $bindable / Angular model() signal, where the model read and write share ONE
// local) the pre-ready write CLOBBERS the very `$props.data` that `ready` then
// reads, so the consumer's initial `:data` crop box is lost and the default box is
// applied instead. (React/Solid read the external prop and Lit's property binding
// is controlled, so the write doesn't change their read — which is why only the
// template-emit family regressed.) We flip this true at the END of `ready`, after
// the initial box is applied, so only genuine post-init user crops drive the model.
// Gate that suppresses the engine's SETUP-time `crop` events from writing the
// two-way `$model.data`. Cropper fires an initial `crop` with its OWN default box
// (autoCropArea) BEFORE the `ready` callback runs, and the `setData($props.data)`
// inside `ready` fires another. Writing those transient engine-internal boxes to
// `$model.data` is wrong — and on unified-model targets (Vue defineModel / Svelte
// $bindable / Angular model() signal, where the model read and write share ONE
// local) the pre-ready write CLOBBERS the very `$props.data` that `ready` then
// reads, so the consumer's initial `:data` crop box is lost and the default box is
// applied instead. (React/Solid read the external prop and Lit's property binding
// is controlled, so the write doesn't change their read — which is why only the
// template-emit family regressed.) We flip this true at the END of `ready`, after
// the initial box is applied, so only genuine post-init user crops drive the model.
let cropReady = false;
// pure crop-box equality (rounded px + exact transform) — no sigils, safe at top
// level. The round-trip guard that stops the setData→crop→$model.data→$watch loop.
// pure crop-box equality (rounded px + exact transform) — no sigils, safe at top
// level. The round-trip guard that stops the setData→crop→$model.data→$watch loop.
const sameData = (a: any, b: any) => {
if (!a || !b) return false;
return Math.round(a.x) === Math.round(b.x) && Math.round(a.y) === Math.round(b.y) && Math.round(a.width) === Math.round(b.width) && Math.round(a.height) === Math.round(b.height) && a.rotate === b.rotate && a.scaleX === b.scaleX && a.scaleY === b.scaleY;
};
// Construct (or, on a future option change, re-construct) the engine. The whole
// options object is a null-let `any` so the constructor's 2nd arg is unchecked —
// the event-callback `e` params (CustomEvent) would otherwise fail the strict
// react/solid/lit tsc against Cropper's Options callback types (the MapLibre
// mapOptions idiom). restoreData re-applies the crop box if we ever rebuild.
// Construct (or, on a future option change, re-construct) the engine. The whole
// options object is a null-let `any` so the constructor's 2nd arg is unchecked —
// the event-callback `e` params (CustomEvent) would otherwise fail the strict
// react/solid/lit tsc against Cropper's Options callback types (the MapLibre
// mapOptions idiom). restoreData re-applies the crop box if we ever rebuild.
const buildCropper = (restoreData: any) => {
let cfg: any = null;
cfg = {
...props.options,
aspectRatio: props.aspectRatio,
viewMode: props.viewMode,
dragMode: props.dragMode,
guides: props.guides,
center: props.center,
background: props.background,
movable: props.movable,
rotatable: props.rotatable,
scalable: props.scalable,
zoomable: props.zoomable,
zoomOnWheel: props.zoomOnWheel,
cropBoxMovable: props.cropBoxMovable,
cropBoxResizable: props.cropBoxResizable,
autoCrop: props.autoCrop,
autoCropArea: props.autoCropArea,
responsive: props.responsive,
// construction-time only — read DIRECTLY (NOT $snapshot'd): structuredClone
// throws on the DOM element(s) a `preview` selector/ref resolves to.
preview: props.preview,
ready: (e: any) => {
if (restoreData) instance.setData(restoreData);else if (data.value) instance.setData(data.value);
if (props.disabled) instance.disable();
// The engine's setup-time `crop` events (the default box fired BEFORE this
// `ready`, and the `setData` echo just above) are suppressed by the `cropReady`
// gate so they can't clobber the consumer's initial `:data` on unified-model
// targets (Vue defineModel / Svelte $bindable / Angular model()). But two-way
// consumers still need to READ the initial box, so echo the now-applied box
// exactly ONCE here (after `$props.data` has been read for setData — no clobber),
// then open the gate so genuine post-init user crops drive the model.
data.value = instance.getData();
cropReady = true;
emit('ready');
},
cropstart: (e: any) => emit('cropstart', {
action: e.detail && e.detail.action
}),
cropmove: (e: any) => emit('cropmove', {
action: e.detail && e.detail.action
}),
cropend: (e: any) => emit('cropend', {
action: e.detail && e.detail.action
}),
// continuous crop → emit + drive the two-way model (guarded reverse $watch).
crop: (e: any) => {
// Suppress the engine's setup-time crops (the default box before `ready`, and
// the `setData($props.data)` echo). Propagating them would (a) emit a spurious
// pre-init `crop` and (b) on unified-model targets clobber the consumer's
// initial `:data`. Genuine user crops fire after `cropReady`.
if (!cropReady) return;
emit('crop', e.detail);
if (e.detail) data.value = e.detail;
},
zoom: (e: any) => emit('zoom', {
ratio: e.detail && e.detail.ratio,
oldRatio: e.detail && e.detail.oldRatio
})
};
instance = new CropperEngine(imgEl, cfg);
};
// ─── imperative handle (Phase 21 $expose) ───────────────────────────────────
// 27 verbs, all collision-clear across the three classes documented at the top:
// no bare `crop`/`zoom` (event⇄verb ROZ121 — exposed as showCropBox/zoomTo/zoomBy),
// no `setData` (React data-model auto-setter ROZ524 — set via two-way `data`; the new
// setCanvasData/setCropBoxData are DISTINCT names, NOT the model auto-setter), and
// none match a Lit reserved lifecycle name (update/render/firstUpdated/updated/
// willUpdate/requestUpdate). The added geometry getters (getCanvasData/getCropBoxData/
// getImageData/getContainerData) and movement setters (setCanvasData/setCropBoxData/
// moveTo/move/scale) expose v1's full canvas/crop-box geometry surface; getData and
// zoomTo gain their optional v1 args (rounded, pivot).
function getCropper() {
return instance;
}
function getData(rounded: any) {
return instance ? instance.getData(rounded) : null;
}
function getCanvasData() {
return instance ? instance.getCanvasData() : null;
}
function getCropBoxData() {
return instance ? instance.getCropBoxData() : null;
}
function getImageData() {
return instance ? instance.getImageData() : null;
}
function getContainerData() {
return instance ? instance.getContainerData() : null;
}
function getCroppedCanvas(opts: any) {
return instance ? instance.getCroppedCanvas(opts) : null;
}
function getCroppedDataURL(opts: any) {
if (!instance) return null;
const canvas = instance.getCroppedCanvas(opts);
return canvas ? canvas.toDataURL() : null;
}
function reset() {
if (instance) instance.reset();
}
function clear() {
if (instance) instance.clear();
}
function showCropBox() {
if (instance) instance.crop();
}
function replace(url: any) {
if (instance) instance.replace(url);
}
function rotateTo(deg: any) {
if (instance) instance.rotateTo(deg);
}
function rotateBy(deg: any) {
if (instance) instance.rotate(deg);
}
function zoomTo(ratio: any, pivot: any) {
if (instance) instance.zoomTo(ratio, pivot);
}
function zoomBy(ratio: any) {
if (instance) instance.zoom(ratio);
}
function scaleX(n: any) {
if (instance) instance.scaleX(n);
}
function scaleY(n: any) {
if (instance) instance.scaleY(n);
}
function scale(x: any, y: any) {
if (instance) instance.scale(x, y);
}
function setCanvasData(d: any) {
if (instance) instance.setCanvasData(d);
}
function setCropBoxData(d: any) {
if (instance) instance.setCropBoxData(d);
}
function moveTo(x: any, y: any) {
if (instance) instance.moveTo(x, y);
}
function move(offsetX: any, offsetY: any) {
if (instance) instance.move(offsetX, offsetY);
}
function enable() {
if (instance) instance.enable();
}
function disable() {
if (instance) instance.disable();
}
function setAspectRatio(ratio: any) {
if (instance) instance.setAspectRatio(ratio);
}
function setDragMode(mode: any) {
if (instance) instance.setDragMode(mode);
}
let _cleanup_0: (() => void) | undefined;
onMounted(() => {
// Ref the <img> directly — the engine's attach target (the flatpickr/codemirror
// pattern). $refs is read ONLY here (ROZ123). The React emitter types an `img`
// ref as HTMLElement (not HTMLImageElement) — a strict-tsc mismatch fixed by a
// codegen type-aid (scripts/codegen.mjs), NOT an emitter edit (scope fence).
imgEl = imageElRef.value;
buildCropper(null);
_cleanup_0 = () => {
if (instance) instance.destroy();
};
});
onBeforeUnmount(() => { _cleanup_0?.(); });
watch(() => props.src, (v: any) => {
if (instance && typeof v === 'string' && v) instance.replace(v);
});
watch(() => props.aspectRatio, (v: any) => {
if (instance) instance.setAspectRatio(v);
});
watch(() => props.dragMode, (v: any) => {
if (instance && typeof v === 'string') instance.setDragMode(v);
});
watch(() => props.disabled, (v: any) => {
if (!instance) return;
if (v) instance.disable();else instance.enable();
});
watch(() => data.value, (v: any) => {
if (!instance || !v) return;
if (sameData(v, instance.getData())) return;
instance.setData(v);
});
defineExpose({ getCropper, getData, getCanvasData, getCropBoxData, getImageData, getContainerData, getCroppedCanvas, getCroppedDataURL, reset, clear, showCropBox, replace, rotateTo, rotateBy, zoomTo, zoomBy, scaleX, scaleY, scale, setCanvasData, setCropBoxData, moveTo, move, enable, disable, setAspectRatio, setDragMode });
</script>
<style scoped>
.rozie-cropper {
max-width: 100%;
}
.rozie-cropper-img {
display: block;
max-width: 100%;
}
</style>svelte
<script lang="ts">
import { applyListeners } from '@rozie/runtime-svelte';
import { onMount, untrack } from 'svelte';
interface Props {
/**
* The image URL the cropper attaches to. Bound onto the `<img>` and reconciled at runtime — changing it calls the engine `replace(url)`.
* @example
* <Cropper :src="imageUrl" r-model:data="crop" />
*/
src?: string;
/**
* The crop box — `{ x, y, width, height, rotate, scaleX, scaleY }`. The lone two-way `model: true` prop: dragging or resizing the crop box writes the new box back (round-trip-guarded so a programmatic write does not ping-pong), and a consumer write `setData`s the live cropper.
*/
data?: unknown;
/**
* The crop box aspect ratio. `NaN` (the default) is Cropper's sentinel for a free ratio. Reconciled at runtime via `setAspectRatio`.
*/
aspectRatio?: number;
/**
* The view constraint mode (`0`–`3`) that governs how the crop box is restricted to the canvas. Construction-only — Cropper.js v1 has no `setViewMode`.
*/
viewMode?: number;
/**
* The drag behavior: `'crop'` draws a new box, `'move'` pans the canvas, `'none'` disables dragging. Reconciled at runtime via `setDragMode`.
*/
dragMode?: string;
/**
* Freeze the cropper so it no longer responds to user interaction. Reconciled at runtime via `enable()` / `disable()`.
*/
disabled?: boolean;
/**
* Show the dashed guide lines over the crop box. Construction-only — Cropper.js v1 has no runtime setter.
*/
guides?: boolean;
/**
* Show the center indicator inside the crop box. Construction-only — Cropper.js v1 has no runtime setter.
*/
center?: boolean;
/**
* Show the grid background behind the image. Construction-only — Cropper.js v1 has no runtime setter.
*/
background?: boolean;
/**
* Allow moving (panning) the image. Construction-only — Cropper.js v1 has no runtime setter.
*/
movable?: boolean;
/**
* Allow rotating the image. Construction-only — Cropper.js v1 has no runtime setter.
*/
rotatable?: boolean;
/**
* Allow scaling (flipping) the image. Construction-only — Cropper.js v1 has no runtime setter.
*/
scalable?: boolean;
/**
* Allow zooming the image. Construction-only — Cropper.js v1 has no runtime setter.
*/
zoomable?: boolean;
/**
* Allow zooming the image via the mouse wheel. Construction-only — Cropper.js v1 has no runtime setter.
*/
zoomOnWheel?: boolean;
/**
* Allow moving the crop box. Construction-only — Cropper.js v1 has no runtime setter.
*/
cropBoxMovable?: boolean;
/**
* Allow resizing the crop box. Construction-only — Cropper.js v1 has no runtime setter.
*/
cropBoxResizable?: boolean;
/**
* Render a crop box automatically when the cropper initializes. Construction-only — Cropper.js v1 has no runtime setter.
*/
autoCrop?: boolean;
/**
* The initial crop-box size as a fraction of the canvas (`0`–`1`). Construction-only — Cropper.js v1 has no runtime setter.
*/
autoCropArea?: number;
/**
* Re-render the cropper on window resize to keep it responsive. Construction-only — Cropper.js v1 has no runtime setter.
*/
responsive?: boolean;
/**
* Live crop-thumbnail target(s) — a selector string or element ref(s) (`HTMLElement`, array, or `NodeList`). Construction-only (v1 has no `setPreview`). On Lit prefer an element ref: a document selector cannot cross the wrapper's shadow boundary.
*/
preview?: unknown;
/**
* Raw Cropper.js `Options` passthrough — spread into the constructor before the curated keys (explicit props win). Use it for any v1 option not surfaced as a first-class prop (`modal`, `restore`, `minCropBoxWidth`, `wheelZoomRatio`, …).
*/
options?: any;
onready?: (...args: unknown[]) => void;
oncropstart?: (...args: unknown[]) => void;
oncropmove?: (...args: unknown[]) => void;
oncropend?: (...args: unknown[]) => void;
oncrop?: (...args: unknown[]) => void;
onzoom?: (...args: unknown[]) => void;
[key: string]: unknown;
}
let __defaultOptions = (() => ({}))();
let {
src = '',
data = $bindable(undefined),
aspectRatio = NaN,
viewMode = 0,
dragMode = 'crop',
disabled = false,
guides = true,
center = true,
background = true,
movable = true,
rotatable = true,
scalable = true,
zoomable = true,
zoomOnWheel = true,
cropBoxMovable = true,
cropBoxResizable = true,
autoCrop = true,
autoCropArea = 0.8,
responsive = true,
preview = undefined,
options = __defaultOptions,
onready,
oncropstart,
oncropmove,
oncropend,
oncrop,
onzoom,
...__rozieAttrs
}: Props = $props();
let imageEl = $state<HTMLElement | undefined>(undefined);
// The engine default-import is aliased `CropperEngine` — a bare `import Cropper`
// would collide with the component name `Cropper` (the rozie `name`), which the
// emitters declare as a local `Cropper` class/function across React/Solid/Lit
// (TS2440 import-conflict + a cascade of "not newable" errors). MapLibre dodged
// this for free (its import was `maplibregl` ≠ `MapLibre`); same-named single-word
// engines must alias.
import CropperEngine from 'cropperjs';
// null-lets so the bundled-leaf typeNeutralize pass annotates them `any`:
// instance is the Cropper (whose strict Options/Data types the loosely-typed
// .rozie props don't satisfy), and imgEl holds the <img> the engine attaches to
// (queried from the ref'd container in $onMount). Both are the `let x = null`
// idiom the engine-wrapper recipe relies on.
// null-lets so the bundled-leaf typeNeutralize pass annotates them `any`:
// instance is the Cropper (whose strict Options/Data types the loosely-typed
// .rozie props don't satisfy), and imgEl holds the <img> the engine attaches to
// (queried from the ref'd container in $onMount). Both are the `let x = null`
// idiom the engine-wrapper recipe relies on.
let instance: any = null;
let imgEl: any = null;
// Gate that suppresses the engine's SETUP-time `crop` events from writing the
// two-way `$model.data`. Cropper fires an initial `crop` with its OWN default box
// (autoCropArea) BEFORE the `ready` callback runs, and the `setData($props.data)`
// inside `ready` fires another. Writing those transient engine-internal boxes to
// `$model.data` is wrong — and on unified-model targets (Vue defineModel / Svelte
// $bindable / Angular model() signal, where the model read and write share ONE
// local) the pre-ready write CLOBBERS the very `$props.data` that `ready` then
// reads, so the consumer's initial `:data` crop box is lost and the default box is
// applied instead. (React/Solid read the external prop and Lit's property binding
// is controlled, so the write doesn't change their read — which is why only the
// template-emit family regressed.) We flip this true at the END of `ready`, after
// the initial box is applied, so only genuine post-init user crops drive the model.
// Gate that suppresses the engine's SETUP-time `crop` events from writing the
// two-way `$model.data`. Cropper fires an initial `crop` with its OWN default box
// (autoCropArea) BEFORE the `ready` callback runs, and the `setData($props.data)`
// inside `ready` fires another. Writing those transient engine-internal boxes to
// `$model.data` is wrong — and on unified-model targets (Vue defineModel / Svelte
// $bindable / Angular model() signal, where the model read and write share ONE
// local) the pre-ready write CLOBBERS the very `$props.data` that `ready` then
// reads, so the consumer's initial `:data` crop box is lost and the default box is
// applied instead. (React/Solid read the external prop and Lit's property binding
// is controlled, so the write doesn't change their read — which is why only the
// template-emit family regressed.) We flip this true at the END of `ready`, after
// the initial box is applied, so only genuine post-init user crops drive the model.
let cropReady = false;
// pure crop-box equality (rounded px + exact transform) — no sigils, safe at top
// level. The round-trip guard that stops the setData→crop→$model.data→$watch loop.
// pure crop-box equality (rounded px + exact transform) — no sigils, safe at top
// level. The round-trip guard that stops the setData→crop→$model.data→$watch loop.
const sameData = (a: any, b: any) => {
if (!a || !b) return false;
return Math.round(a.x) === Math.round(b.x) && Math.round(a.y) === Math.round(b.y) && Math.round(a.width) === Math.round(b.width) && Math.round(a.height) === Math.round(b.height) && a.rotate === b.rotate && a.scaleX === b.scaleX && a.scaleY === b.scaleY;
};
// Construct (or, on a future option change, re-construct) the engine. The whole
// options object is a null-let `any` so the constructor's 2nd arg is unchecked —
// the event-callback `e` params (CustomEvent) would otherwise fail the strict
// react/solid/lit tsc against Cropper's Options callback types (the MapLibre
// mapOptions idiom). restoreData re-applies the crop box if we ever rebuild.
// Construct (or, on a future option change, re-construct) the engine. The whole
// options object is a null-let `any` so the constructor's 2nd arg is unchecked —
// the event-callback `e` params (CustomEvent) would otherwise fail the strict
// react/solid/lit tsc against Cropper's Options callback types (the MapLibre
// mapOptions idiom). restoreData re-applies the crop box if we ever rebuild.
const buildCropper = (restoreData: any) => {
let cfg: any = null;
cfg = {
...$state.snapshot(options),
aspectRatio: aspectRatio,
viewMode: viewMode,
dragMode: dragMode,
guides: guides,
center: center,
background: background,
movable: movable,
rotatable: rotatable,
scalable: scalable,
zoomable: zoomable,
zoomOnWheel: zoomOnWheel,
cropBoxMovable: cropBoxMovable,
cropBoxResizable: cropBoxResizable,
autoCrop: autoCrop,
autoCropArea: autoCropArea,
responsive: responsive,
// construction-time only — read DIRECTLY (NOT $snapshot'd): structuredClone
// throws on the DOM element(s) a `preview` selector/ref resolves to.
preview: preview,
ready: (e: any) => {
if (restoreData) instance.setData(restoreData);else if (data) instance.setData($state.snapshot(data));
if (disabled) instance.disable();
// The engine's setup-time `crop` events (the default box fired BEFORE this
// `ready`, and the `setData` echo just above) are suppressed by the `cropReady`
// gate so they can't clobber the consumer's initial `:data` on unified-model
// targets (Vue defineModel / Svelte $bindable / Angular model()). But two-way
// consumers still need to READ the initial box, so echo the now-applied box
// exactly ONCE here (after `$props.data` has been read for setData — no clobber),
// then open the gate so genuine post-init user crops drive the model.
data = instance.getData();
cropReady = true;
onready?.();
},
cropstart: (e: any) => oncropstart?.({
action: e.detail && e.detail.action
}),
cropmove: (e: any) => oncropmove?.({
action: e.detail && e.detail.action
}),
cropend: (e: any) => oncropend?.({
action: e.detail && e.detail.action
}),
// continuous crop → emit + drive the two-way model (guarded reverse $watch).
crop: (e: any) => {
// Suppress the engine's setup-time crops (the default box before `ready`, and
// the `setData($props.data)` echo). Propagating them would (a) emit a spurious
// pre-init `crop` and (b) on unified-model targets clobber the consumer's
// initial `:data`. Genuine user crops fire after `cropReady`.
if (!cropReady) return;
oncrop?.(e.detail);
if (e.detail) data = e.detail;
},
zoom: (e: any) => onzoom?.({
ratio: e.detail && e.detail.ratio,
oldRatio: e.detail && e.detail.oldRatio
})
};
instance = new CropperEngine(imgEl, cfg);
};
// ─── imperative handle (Phase 21 $expose) ───────────────────────────────────
// 27 verbs, all collision-clear across the three classes documented at the top:
// no bare `crop`/`zoom` (event⇄verb ROZ121 — exposed as showCropBox/zoomTo/zoomBy),
// no `setData` (React data-model auto-setter ROZ524 — set via two-way `data`; the new
// setCanvasData/setCropBoxData are DISTINCT names, NOT the model auto-setter), and
// none match a Lit reserved lifecycle name (update/render/firstUpdated/updated/
// willUpdate/requestUpdate). The added geometry getters (getCanvasData/getCropBoxData/
// getImageData/getContainerData) and movement setters (setCanvasData/setCropBoxData/
// moveTo/move/scale) expose v1's full canvas/crop-box geometry surface; getData and
// zoomTo gain their optional v1 args (rounded, pivot).
export function getCropper() {
return instance;
}
export function getData(rounded: any) {
return instance ? instance.getData(rounded) : null;
}
export function getCanvasData() {
return instance ? instance.getCanvasData() : null;
}
export function getCropBoxData() {
return instance ? instance.getCropBoxData() : null;
}
export function getImageData() {
return instance ? instance.getImageData() : null;
}
export function getContainerData() {
return instance ? instance.getContainerData() : null;
}
export function getCroppedCanvas(opts: any) {
return instance ? instance.getCroppedCanvas(opts) : null;
}
export function getCroppedDataURL(opts: any) {
if (!instance) return null;
const canvas = instance.getCroppedCanvas(opts);
return canvas ? canvas.toDataURL() : null;
}
export function reset() {
if (instance) instance.reset();
}
export function clear() {
if (instance) instance.clear();
}
export function showCropBox() {
if (instance) instance.crop();
}
export function replace(url: any) {
if (instance) instance.replace(url);
}
export function rotateTo(deg: any) {
if (instance) instance.rotateTo(deg);
}
export function rotateBy(deg: any) {
if (instance) instance.rotate(deg);
}
export function zoomTo(ratio: any, pivot: any) {
if (instance) instance.zoomTo(ratio, pivot);
}
export function zoomBy(ratio: any) {
if (instance) instance.zoom(ratio);
}
export function scaleX(n: any) {
if (instance) instance.scaleX(n);
}
export function scaleY(n: any) {
if (instance) instance.scaleY(n);
}
export function scale(x: any, y: any) {
if (instance) instance.scale(x, y);
}
export function setCanvasData(d: any) {
if (instance) instance.setCanvasData(d);
}
export function setCropBoxData(d: any) {
if (instance) instance.setCropBoxData(d);
}
export function moveTo(x: any, y: any) {
if (instance) instance.moveTo(x, y);
}
export function move(offsetX: any, offsetY: any) {
if (instance) instance.move(offsetX, offsetY);
}
export function enable() {
if (instance) instance.enable();
}
export function disable() {
if (instance) instance.disable();
}
export function setAspectRatio(ratio: any) {
if (instance) instance.setAspectRatio(ratio);
}
export function setDragMode(mode: any) {
if (instance) instance.setDragMode(mode);
}
onMount(() => {
// Ref the <img> directly — the engine's attach target (the flatpickr/codemirror
// pattern). $refs is read ONLY here (ROZ123). The React emitter types an `img`
// ref as HTMLElement (not HTMLImageElement) — a strict-tsc mismatch fixed by a
// codegen type-aid (scripts/codegen.mjs), NOT an emitter edit (scope fence).
imgEl = imageEl;
buildCropper(null);
return () => {
if (instance) instance.destroy();
};
});
let __rozieWatchInitial_0 = true;
$effect(() => { const __watchVal = (() => src)(); untrack(() => { if (__rozieWatchInitial_0) { __rozieWatchInitial_0 = false; return; } ((v: any) => {
if (instance && typeof v === 'string' && v) instance.replace(v);
})(__watchVal); }); });
let __rozieWatchInitial_1 = true;
$effect(() => { const __watchVal = (() => aspectRatio)(); untrack(() => { if (__rozieWatchInitial_1) { __rozieWatchInitial_1 = false; return; } ((v: any) => {
if (instance) instance.setAspectRatio(v);
})(__watchVal); }); });
let __rozieWatchInitial_2 = true;
$effect(() => { const __watchVal = (() => dragMode)(); untrack(() => { if (__rozieWatchInitial_2) { __rozieWatchInitial_2 = false; return; } ((v: any) => {
if (instance && typeof v === 'string') instance.setDragMode(v);
})(__watchVal); }); });
let __rozieWatchInitial_3 = true;
$effect(() => { const __watchVal = (() => disabled)(); untrack(() => { if (__rozieWatchInitial_3) { __rozieWatchInitial_3 = false; return; } ((v: any) => {
if (!instance) return;
if (v) instance.disable();else instance.enable();
})(__watchVal); }); });
let __rozieWatchInitial_4 = true;
$effect(() => { const __watchVal = (() => data)(); untrack(() => { if (__rozieWatchInitial_4) { __rozieWatchInitial_4 = false; return; } ((v: any) => {
if (!instance || !v) return;
if (sameData(v, instance.getData())) return;
instance.setData($state.snapshot(v));
})(__watchVal); }); });
</script>
<div {...__rozieAttrs} class={["rozie-cropper", (__rozieAttrs)?.class]} use:applyListeners={__rozieAttrs} data-rozie-s-cddf3b42><img class="rozie-cropper-img" bind:this={imageEl} src={src} alt="" data-rozie-s-cddf3b42 /></div>
<style>
:global {
.rozie-cropper[data-rozie-s-cddf3b42] {
max-width: 100%;
}
.rozie-cropper-img[data-rozie-s-cddf3b42] {
display: block;
max-width: 100%;
}
}
</style>ts
import { Component, DestroyRef, ElementRef, Renderer2, ViewEncapsulation, afterRenderEffect, effect, forwardRef, inject, input, model, output, signal, untracked, viewChild } from '@angular/core';
import { NG_VALUE_ACCESSOR } from '@angular/forms';
// The engine default-import is aliased `CropperEngine` — a bare `import Cropper`
// would collide with the component name `Cropper` (the rozie `name`), which the
// emitters declare as a local `Cropper` class/function across React/Solid/Lit
// (TS2440 import-conflict + a cascade of "not newable" errors). MapLibre dodged
// this for free (its import was `maplibregl` ≠ `MapLibre`); same-named single-word
// engines must alias.
import CropperEngine from 'cropperjs';
// null-lets so the bundled-leaf typeNeutralize pass annotates them `any`:
// instance is the Cropper (whose strict Options/Data types the loosely-typed
// .rozie props don't satisfy), and imgEl holds the <img> the engine attaches to
// (queried from the ref'd container in $onMount). Both are the `let x = null`
// idiom the engine-wrapper recipe relies on.
@Component({
selector: 'rozie-cropper',
standalone: true,
template: `
<div class="rozie-cropper" #rozieSpread_0 #rozieListenersTarget_1>
<img class="rozie-cropper-img" #imageEl [src]="src()" alt="" />
</div>
`,
styles: [`
.rozie-cropper {
max-width: 100%;
}
.rozie-cropper-img {
display: block;
max-width: 100%;
}
`],
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => Cropper),
multi: true,
},
],
host: { '(focusout)': '__rozieCvaOnTouched()' },
})
export class Cropper {
/**
* The image URL the cropper attaches to. Bound onto the `<img>` and reconciled at runtime — changing it calls the engine `replace(url)`.
* @example
* <Cropper :src="imageUrl" r-model:data="crop" />
*/
src = input<string>('');
/**
* The crop box — `{ x, y, width, height, rotate, scaleX, scaleY }`. The lone two-way `model: true` prop: dragging or resizing the crop box writes the new box back (round-trip-guarded so a programmatic write does not ping-pong), and a consumer write `setData`s the live cropper.
*/
data = model<unknown>(undefined);
/**
* The crop box aspect ratio. `NaN` (the default) is Cropper's sentinel for a free ratio. Reconciled at runtime via `setAspectRatio`.
*/
aspectRatio = input<number>(NaN);
/**
* The view constraint mode (`0`–`3`) that governs how the crop box is restricted to the canvas. Construction-only — Cropper.js v1 has no `setViewMode`.
*/
viewMode = input<number>(0);
/**
* The drag behavior: `'crop'` draws a new box, `'move'` pans the canvas, `'none'` disables dragging. Reconciled at runtime via `setDragMode`.
*/
dragMode = input<string>('crop');
/**
* Freeze the cropper so it no longer responds to user interaction. Reconciled at runtime via `enable()` / `disable()`.
*/
disabled = input<boolean>(false);
/**
* Show the dashed guide lines over the crop box. Construction-only — Cropper.js v1 has no runtime setter.
*/
guides = input<boolean>(true);
/**
* Show the center indicator inside the crop box. Construction-only — Cropper.js v1 has no runtime setter.
*/
center = input<boolean>(true);
/**
* Show the grid background behind the image. Construction-only — Cropper.js v1 has no runtime setter.
*/
background = input<boolean>(true);
/**
* Allow moving (panning) the image. Construction-only — Cropper.js v1 has no runtime setter.
*/
movable = input<boolean>(true);
/**
* Allow rotating the image. Construction-only — Cropper.js v1 has no runtime setter.
*/
rotatable = input<boolean>(true);
/**
* Allow scaling (flipping) the image. Construction-only — Cropper.js v1 has no runtime setter.
*/
scalable = input<boolean>(true);
/**
* Allow zooming the image. Construction-only — Cropper.js v1 has no runtime setter.
*/
zoomable = input<boolean>(true);
/**
* Allow zooming the image via the mouse wheel. Construction-only — Cropper.js v1 has no runtime setter.
*/
zoomOnWheel = input<boolean>(true);
/**
* Allow moving the crop box. Construction-only — Cropper.js v1 has no runtime setter.
*/
cropBoxMovable = input<boolean>(true);
/**
* Allow resizing the crop box. Construction-only — Cropper.js v1 has no runtime setter.
*/
cropBoxResizable = input<boolean>(true);
/**
* Render a crop box automatically when the cropper initializes. Construction-only — Cropper.js v1 has no runtime setter.
*/
autoCrop = input<boolean>(true);
/**
* The initial crop-box size as a fraction of the canvas (`0`–`1`). Construction-only — Cropper.js v1 has no runtime setter.
*/
autoCropArea = input<number>(0.8);
/**
* Re-render the cropper on window resize to keep it responsive. Construction-only — Cropper.js v1 has no runtime setter.
*/
responsive = input<boolean>(true);
/**
* Live crop-thumbnail target(s) — a selector string or element ref(s) (`HTMLElement`, array, or `NodeList`). Construction-only (v1 has no `setPreview`). On Lit prefer an element ref: a document selector cannot cross the wrapper's shadow boundary.
*/
preview = input<unknown>(undefined);
/**
* Raw Cropper.js `Options` passthrough — spread into the constructor before the curated keys (explicit props win). Use it for any v1 option not surfaced as a first-class prop (`modal`, `restore`, `minCropBoxWidth`, `wheelZoomRatio`, …).
*/
options = input<Record<string, any>>((() => ({}))());
imageEl = viewChild<ElementRef<HTMLElement>>('imageEl');
ready = output<void>();
cropstart = output<unknown>();
cropmove = output<unknown>();
cropend = output<unknown>();
crop = output<unknown>();
zoom = output<unknown>();
private __rozieDestroyRef = inject(DestroyRef);
private __rozieWatchInitial_0 = true;
private __rozieWatchInitial_1 = true;
private __rozieWatchInitial_2 = true;
private __rozieWatchInitial_3 = true;
private __rozieWatchInitial_4 = true;
constructor() {
effect(() => { const __watchVal = (() => this.src())(); untracked(() => { if (this.__rozieWatchInitial_0) { this.__rozieWatchInitial_0 = false; return; } ((v: any) => {
if (this.instance && typeof v === 'string' && v) this.instance.replace(v);
})(__watchVal); }); });
effect(() => { const __watchVal = (() => this.aspectRatio())(); untracked(() => { if (this.__rozieWatchInitial_1) { this.__rozieWatchInitial_1 = false; return; } ((v: any) => {
if (this.instance) this.instance.setAspectRatio(v);
})(__watchVal); }); });
effect(() => { const __watchVal = (() => this.dragMode())(); untracked(() => { if (this.__rozieWatchInitial_2) { this.__rozieWatchInitial_2 = false; return; } ((v: any) => {
if (this.instance && typeof v === 'string') this.instance.setDragMode(v);
})(__watchVal); }); });
effect(() => { const __watchVal = (() => (this.disabled() || this.__rozieCvaDisabled()))(); untracked(() => { if (this.__rozieWatchInitial_3) { this.__rozieWatchInitial_3 = false; return; } ((v: any) => {
if (!this.instance) return;
if (v) this.instance.disable();else this.instance.enable();
})(__watchVal); }); });
effect(() => { const __watchVal = (() => this.data())(); untracked(() => { if (this.__rozieWatchInitial_4) { this.__rozieWatchInitial_4 = false; return; } ((v: any) => {
if (!this.instance || !v) return;
if (this.sameData(v, this.instance.getData())) return;
this.instance.setData(v);
})(__watchVal); }); });
}
ngAfterViewInit() {
// Ref the <img> directly — the engine's attach target (the flatpickr/codemirror
// pattern). $refs is read ONLY here (ROZ123). The React emitter types an `img`
// ref as HTMLElement (not HTMLImageElement) — a strict-tsc mismatch fixed by a
// codegen type-aid (scripts/codegen.mjs), NOT an emitter edit (scope fence).
this.imgEl = this.imageEl()?.nativeElement;
this.buildCropper(null);
this.__rozieDestroyRef.onDestroy(() => {
if (this.instance) this.instance.destroy();
});
}
instance: any = null;
imgEl: any = null;
cropReady = false;
sameData = (a: any, b: any) => {
if (!a || !b) return false;
return Math.round(a.x) === Math.round(b.x) && Math.round(a.y) === Math.round(b.y) && Math.round(a.width) === Math.round(b.width) && Math.round(a.height) === Math.round(b.height) && a.rotate === b.rotate && a.scaleX === b.scaleX && a.scaleY === b.scaleY;
};
buildCropper = (restoreData: any) => {
let cfg: any = null;
cfg = {
...this.options(),
aspectRatio: this.aspectRatio(),
viewMode: this.viewMode(),
dragMode: this.dragMode(),
guides: this.guides(),
center: this.center(),
background: this.background(),
movable: this.movable(),
rotatable: this.rotatable(),
scalable: this.scalable(),
zoomable: this.zoomable(),
zoomOnWheel: this.zoomOnWheel(),
cropBoxMovable: this.cropBoxMovable(),
cropBoxResizable: this.cropBoxResizable(),
autoCrop: this.autoCrop(),
autoCropArea: this.autoCropArea(),
responsive: this.responsive(),
// construction-time only — read DIRECTLY (NOT $snapshot'd): structuredClone
// throws on the DOM element(s) a `preview` selector/ref resolves to.
preview: this.preview(),
ready: (e: any) => {
if (restoreData) this.instance.setData(restoreData);else if (this.data()) this.instance.setData(this.data());
if ((this.disabled() || this.__rozieCvaDisabled())) this.instance.disable();
// The engine's setup-time `crop` events (the default box fired BEFORE this
// `ready`, and the `setData` echo just above) are suppressed by the `cropReady`
// gate so they can't clobber the consumer's initial `:data` on unified-model
// targets (Vue defineModel / Svelte $bindable / Angular model()). But two-way
// consumers still need to READ the initial box, so echo the now-applied box
// exactly ONCE here (after `$props.data` has been read for setData — no clobber),
// then open the gate so genuine post-init user crops drive the model.
this.data.set(this.instance.getData()), this.__rozieCvaOnChange(this.instance.getData());
this.cropReady = true;
this.ready.emit();
},
cropstart: (e: any) => this.cropstart.emit({
action: e.detail && e.detail.action
}),
cropmove: (e: any) => this.cropmove.emit({
action: e.detail && e.detail.action
}),
cropend: (e: any) => this.cropend.emit({
action: e.detail && e.detail.action
}),
// continuous crop → emit + drive the two-way model (guarded reverse $watch).
crop: (e: any) => {
// Suppress the engine's setup-time crops (the default box before `ready`, and
// the `setData($props.data)` echo). Propagating them would (a) emit a spurious
// pre-init `crop` and (b) on unified-model targets clobber the consumer's
// initial `:data`. Genuine user crops fire after `cropReady`.
if (!this.cropReady) return;
this.crop.emit(e.detail);
if (e.detail) this.data.set(e.detail), this.__rozieCvaOnChange(e.detail);
},
zoom: (e: any) => this.zoom.emit({
ratio: e.detail && e.detail.ratio,
oldRatio: e.detail && e.detail.oldRatio
})
};
this.instance = new CropperEngine(this.imgEl, cfg);
};
getCropper = () => {
return this.instance;
};
getData = (rounded: any) => {
return this.instance ? this.instance.getData(rounded) : null;
};
getCanvasData = () => {
return this.instance ? this.instance.getCanvasData() : null;
};
getCropBoxData = () => {
return this.instance ? this.instance.getCropBoxData() : null;
};
getImageData = () => {
return this.instance ? this.instance.getImageData() : null;
};
getContainerData = () => {
return this.instance ? this.instance.getContainerData() : null;
};
getCroppedCanvas = (opts: any) => {
return this.instance ? this.instance.getCroppedCanvas(opts) : null;
};
getCroppedDataURL = (opts: any) => {
if (!this.instance) return null;
const canvas = this.instance.getCroppedCanvas(opts);
return canvas ? canvas.toDataURL() : null;
};
reset = () => {
if (this.instance) this.instance.reset();
};
clear = () => {
if (this.instance) this.instance.clear();
};
showCropBox = () => {
if (this.instance) this.instance.crop();
};
replace = (url: any) => {
if (this.instance) this.instance.replace(url);
};
rotateTo = (deg: any) => {
if (this.instance) this.instance.rotateTo(deg);
};
rotateBy = (deg: any) => {
if (this.instance) this.instance.rotate(deg);
};
zoomTo = (ratio: any, pivot: any) => {
if (this.instance) this.instance.zoomTo(ratio, pivot);
};
zoomBy = (ratio: any) => {
if (this.instance) this.instance.zoom(ratio);
};
scaleX = (n: any) => {
if (this.instance) this.instance.scaleX(n);
};
scaleY = (n: any) => {
if (this.instance) this.instance.scaleY(n);
};
scale = (x: any, y: any) => {
if (this.instance) this.instance.scale(x, y);
};
setCanvasData = (d: any) => {
if (this.instance) this.instance.setCanvasData(d);
};
setCropBoxData = (d: any) => {
if (this.instance) this.instance.setCropBoxData(d);
};
moveTo = (x: any, y: any) => {
if (this.instance) this.instance.moveTo(x, y);
};
move = (offsetX: any, offsetY: any) => {
if (this.instance) this.instance.move(offsetX, offsetY);
};
enable = () => {
if (this.instance) this.instance.enable();
};
disable = () => {
if (this.instance) this.instance.disable();
};
setAspectRatio = (ratio: any) => {
if (this.instance) this.instance.setAspectRatio(ratio);
};
setDragMode = (mode: any) => {
if (this.instance) this.instance.setDragMode(mode);
};
private __rozieCvaOnChange: (v: unknown) => void = () => {};
private __rozieCvaOnTouchedFn: () => void = () => {};
protected __rozieCvaDisabled = signal(false);
writeValue(v: unknown | null): void {
this.data.set(v ?? undefined);
}
registerOnChange(fn: (v: unknown) => void): void {
this.__rozieCvaOnChange = fn;
}
registerOnTouched(fn: () => void): void {
this.__rozieCvaOnTouchedFn = fn;
}
setDisabledState(isDisabled: boolean): void {
this.__rozieCvaDisabled.set(isDisabled);
}
__rozieCvaOnTouched(): void {
this.__rozieCvaOnTouchedFn();
}
private rozieSpread_0 = viewChild<ElementRef>('rozieSpread_0');
private __rozieApplyAttrs = (() => {
const renderer = inject(Renderer2);
const prevKeysByElement = new WeakMap<HTMLElement, string[]>();
const prevClassTokensByElement = new WeakMap<HTMLElement, string[]>();
const prevStylePropsByElement = new WeakMap<HTMLElement, string[]>();
const parseClassTokens = (value: unknown): string[] => {
if (typeof value !== 'string') return [];
const out: string[] = [];
for (const tok of value.split(/\s+/)) {
if (tok.length > 0) out.push(tok);
}
return out;
};
const parseStyleDecls = (value: unknown): Array<[string, string]> => {
if (typeof value !== 'string') return [];
const out: Array<[string, string]> = [];
for (const decl of value.split(';')) {
const colon = decl.indexOf(':');
if (colon < 0) continue;
const prop = decl.slice(0, colon).trim();
const val = decl.slice(colon + 1).trim();
if (prop.length > 0) out.push([prop, val]);
}
return out;
};
const applyClassMerge = (el: HTMLElement, value: unknown) => {
const next = parseClassTokens(value);
const prev = prevClassTokensByElement.get(el) ?? [];
const nextSet = new Set(next);
for (const tok of prev) {
if (!nextSet.has(tok)) el.classList.remove(tok);
}
for (const tok of next) el.classList.add(tok);
prevClassTokensByElement.set(el, next);
};
const applyStyleMerge = (el: HTMLElement, value: unknown) => {
const next = parseStyleDecls(value);
const prev = prevStylePropsByElement.get(el) ?? [];
const nextProps = next.map(([p]) => p);
const nextSet = new Set(nextProps);
for (const prop of prev) {
if (!nextSet.has(prop)) el.style.removeProperty(prop);
}
for (const [prop, val] of next) el.style.setProperty(prop, val, 'important');
prevStylePropsByElement.set(el, nextProps);
};
return (el: HTMLElement, obj: Record<string, unknown> | null | undefined) => {
const safeObj: Record<string, unknown> = obj ?? {};
const prevKeys = prevKeysByElement.get(el) ?? [];
for (const k of prevKeys) {
if (k === 'class' || k === 'style') continue;
if (!(k in safeObj)) renderer.removeAttribute(el, k);
}
if (!('class' in safeObj) && prevClassTokensByElement.has(el)) {
applyClassMerge(el, '');
}
if (!('style' in safeObj) && prevStylePropsByElement.has(el)) {
applyStyleMerge(el, '');
}
for (const [k, v] of Object.entries(safeObj)) {
if (k === 'class') {
applyClassMerge(el, v);
} else if (k === 'style') {
applyStyleMerge(el, v);
} else if (v === null || v === false) {
renderer.removeAttribute(el, k);
} else {
renderer.setAttribute(el, k, String(v));
}
}
prevKeysByElement.set(el, Object.keys(safeObj));
};
})();
private __rozieGetHostAttrs = (() => {
const host = inject(ElementRef);
return () => {
const el = host.nativeElement as HTMLElement;
const out: Record<string, unknown> = {};
for (const a of Array.from(el.attributes)) out[a.name] = a.value;
return out;
};
})();
private __rozieSpread_0_effect = afterRenderEffect(() => {
const el = this.rozieSpread_0()?.nativeElement;
if (!el) return;
this.__rozieApplyAttrs(el, this.__rozieGetHostAttrs());
});
private rozieListenersTarget_1 = viewChild<ElementRef>('rozieListenersTarget_1');
private __rozieListenersRenderer = inject(Renderer2);
private __rozieListenersDisposers_1: Array<() => void> = [];
private __rozieListenersDestroyRegistered_1 = false;
private __rozieListenersEffect_1 = effect(() => {
const el = this.rozieListenersTarget_1()?.nativeElement;
if (!el) return;
for (const off of this.__rozieListenersDisposers_1) off();
this.__rozieListenersDisposers_1 = [];
const obj: Record<string, unknown> = {};
for (const [k, v] of Object.entries(obj)) {
if (k === '__proto__' || k === 'constructor' || k === 'prototype') continue;
if (typeof v !== 'function') continue;
const norm = k.startsWith('on') ? k.slice(2).toLowerCase() : k;
const dispose = this.__rozieListenersRenderer.listen(el, norm, v as EventListener);
this.__rozieListenersDisposers_1.push(dispose);
}
if (!this.__rozieListenersDestroyRegistered_1) {
this.__rozieListenersDestroyRegistered_1 = true;
this.__rozieDestroyRef.onDestroy(() => {
for (const off of this.__rozieListenersDisposers_1) off();
this.__rozieListenersDisposers_1 = [];
});
}
});
}
export default Cropper;tsx
import type { JSX } from 'solid-js';
import { createEffect, mergeProps, on, onCleanup, onMount, splitProps, untrack } from 'solid-js';
import { __rozieInjectStyle, createControllableSignal } from '@rozie/runtime-solid';
// The engine default-import is aliased `CropperEngine` — a bare `import Cropper`
// would collide with the component name `Cropper` (the rozie `name`), which the
// emitters declare as a local `Cropper` class/function across React/Solid/Lit
// (TS2440 import-conflict + a cascade of "not newable" errors). MapLibre dodged
// this for free (its import was `maplibregl` ≠ `MapLibre`); same-named single-word
// engines must alias.
import CropperEngine from 'cropperjs';
// null-lets so the bundled-leaf typeNeutralize pass annotates them `any`:
// instance is the Cropper (whose strict Options/Data types the loosely-typed
// .rozie props don't satisfy), and imgEl holds the <img> the engine attaches to
// (queried from the ref'd container in $onMount). Both are the `let x = null`
// idiom the engine-wrapper recipe relies on.
__rozieInjectStyle('Cropper-cddf3b42', `.rozie-cropper[data-rozie-s-cddf3b42] {
max-width: 100%;
}
.rozie-cropper-img[data-rozie-s-cddf3b42] {
display: block;
max-width: 100%;
}`);
interface CropperProps {
/**
* The image URL the cropper attaches to. Bound onto the `<img>` and reconciled at runtime — changing it calls the engine `replace(url)`.
* @example
* <Cropper :src="imageUrl" r-model:data="crop" />
*/
src?: string;
/**
* The crop box — `{ x, y, width, height, rotate, scaleX, scaleY }`. The lone two-way `model: true` prop: dragging or resizing the crop box writes the new box back (round-trip-guarded so a programmatic write does not ping-pong), and a consumer write `setData`s the live cropper.
*/
data?: unknown;
defaultData?: unknown;
onDataChange?: (data: unknown) => void;
/**
* The crop box aspect ratio. `NaN` (the default) is Cropper's sentinel for a free ratio. Reconciled at runtime via `setAspectRatio`.
*/
aspectRatio?: number;
/**
* The view constraint mode (`0`–`3`) that governs how the crop box is restricted to the canvas. Construction-only — Cropper.js v1 has no `setViewMode`.
*/
viewMode?: number;
/**
* The drag behavior: `'crop'` draws a new box, `'move'` pans the canvas, `'none'` disables dragging. Reconciled at runtime via `setDragMode`.
*/
dragMode?: string;
/**
* Freeze the cropper so it no longer responds to user interaction. Reconciled at runtime via `enable()` / `disable()`.
*/
disabled?: boolean;
/**
* Show the dashed guide lines over the crop box. Construction-only — Cropper.js v1 has no runtime setter.
*/
guides?: boolean;
/**
* Show the center indicator inside the crop box. Construction-only — Cropper.js v1 has no runtime setter.
*/
center?: boolean;
/**
* Show the grid background behind the image. Construction-only — Cropper.js v1 has no runtime setter.
*/
background?: boolean;
/**
* Allow moving (panning) the image. Construction-only — Cropper.js v1 has no runtime setter.
*/
movable?: boolean;
/**
* Allow rotating the image. Construction-only — Cropper.js v1 has no runtime setter.
*/
rotatable?: boolean;
/**
* Allow scaling (flipping) the image. Construction-only — Cropper.js v1 has no runtime setter.
*/
scalable?: boolean;
/**
* Allow zooming the image. Construction-only — Cropper.js v1 has no runtime setter.
*/
zoomable?: boolean;
/**
* Allow zooming the image via the mouse wheel. Construction-only — Cropper.js v1 has no runtime setter.
*/
zoomOnWheel?: boolean;
/**
* Allow moving the crop box. Construction-only — Cropper.js v1 has no runtime setter.
*/
cropBoxMovable?: boolean;
/**
* Allow resizing the crop box. Construction-only — Cropper.js v1 has no runtime setter.
*/
cropBoxResizable?: boolean;
/**
* Render a crop box automatically when the cropper initializes. Construction-only — Cropper.js v1 has no runtime setter.
*/
autoCrop?: boolean;
/**
* The initial crop-box size as a fraction of the canvas (`0`–`1`). Construction-only — Cropper.js v1 has no runtime setter.
*/
autoCropArea?: number;
/**
* Re-render the cropper on window resize to keep it responsive. Construction-only — Cropper.js v1 has no runtime setter.
*/
responsive?: boolean;
/**
* Live crop-thumbnail target(s) — a selector string or element ref(s) (`HTMLElement`, array, or `NodeList`). Construction-only (v1 has no `setPreview`). On Lit prefer an element ref: a document selector cannot cross the wrapper's shadow boundary.
*/
preview?: unknown;
/**
* Raw Cropper.js `Options` passthrough — spread into the constructor before the curated keys (explicit props win). Use it for any v1 option not surfaced as a first-class prop (`modal`, `restore`, `minCropBoxWidth`, `wheelZoomRatio`, …).
*/
options?: Record<string, any>;
onReady?: (...args: unknown[]) => void;
onCropstart?: (...args: unknown[]) => void;
onCropmove?: (...args: unknown[]) => void;
onCropend?: (...args: unknown[]) => void;
onCrop?: (...args: unknown[]) => void;
onZoom?: (...args: unknown[]) => void;
ref?: (h: CropperHandle) => void;
}
export interface CropperHandle {
getCropper: (...args: any[]) => any;
getData: (...args: any[]) => any;
getCanvasData: (...args: any[]) => any;
getCropBoxData: (...args: any[]) => any;
getImageData: (...args: any[]) => any;
getContainerData: (...args: any[]) => any;
getCroppedCanvas: (...args: any[]) => any;
getCroppedDataURL: (...args: any[]) => any;
reset: (...args: any[]) => any;
clear: (...args: any[]) => any;
showCropBox: (...args: any[]) => any;
replace: (...args: any[]) => any;
rotateTo: (...args: any[]) => any;
rotateBy: (...args: any[]) => any;
zoomTo: (...args: any[]) => any;
zoomBy: (...args: any[]) => any;
scaleX: (...args: any[]) => any;
scaleY: (...args: any[]) => any;
scale: (...args: any[]) => any;
setCanvasData: (...args: any[]) => any;
setCropBoxData: (...args: any[]) => any;
moveTo: (...args: any[]) => any;
move: (...args: any[]) => any;
enable: (...args: any[]) => any;
disable: (...args: any[]) => any;
setAspectRatio: (...args: any[]) => any;
setDragMode: (...args: any[]) => any;
}
export default function Cropper(_props: CropperProps): JSX.Element {
const _merged = mergeProps({ src: '', aspectRatio: NaN, viewMode: 0, dragMode: 'crop', disabled: false, guides: true, center: true, background: true, movable: true, rotatable: true, scalable: true, zoomable: true, zoomOnWheel: true, cropBoxMovable: true, cropBoxResizable: true, autoCrop: true, autoCropArea: 0.8, responsive: true, preview: undefined, options: (() => ({}))() }, _props);
const [local, attrs] = splitProps(_merged, ['src', 'data', 'aspectRatio', 'viewMode', 'dragMode', 'disabled', 'guides', 'center', 'background', 'movable', 'rotatable', 'scalable', 'zoomable', 'zoomOnWheel', 'cropBoxMovable', 'cropBoxResizable', 'autoCrop', 'autoCropArea', 'responsive', 'preview', 'options', 'ref']);
onMount(() => { local.ref?.({ getCropper, getData, getCanvasData, getCropBoxData, getImageData, getContainerData, getCroppedCanvas, getCroppedDataURL, reset, clear, showCropBox, replace, rotateTo, rotateBy, zoomTo, zoomBy, scaleX, scaleY, scale, setCanvasData, setCropBoxData, moveTo, move, enable, disable, setAspectRatio, setDragMode }); });
const [data, setData] = createControllableSignal<unknown>(_props as unknown as Record<string, unknown>, 'data', undefined);
onMount(() => {
const _cleanup = (() => {
// Ref the <img> directly — the engine's attach target (the flatpickr/codemirror
// pattern). $refs is read ONLY here (ROZ123). The React emitter types an `img`
// ref as HTMLElement (not HTMLImageElement) — a strict-tsc mismatch fixed by a
// codegen type-aid (scripts/codegen.mjs), NOT an emitter edit (scope fence).
imgEl = imageElRef;
buildCropper(null);
})() as unknown;
if (_cleanup) onCleanup(_cleanup as () => void);
onCleanup(() => {
if (instance) instance.destroy();
});
});
createEffect(on(() => (() => local.src)(), (v) => untrack(() => ((v: any) => {
if (instance && typeof v === 'string' && v) instance.replace(v);
})(v)), { defer: true }));
createEffect(on(() => (() => local.aspectRatio)(), (v) => untrack(() => ((v: any) => {
if (instance) instance.setAspectRatio(v);
})(v)), { defer: true }));
createEffect(on(() => (() => local.dragMode)(), (v) => untrack(() => ((v: any) => {
if (instance && typeof v === 'string') instance.setDragMode(v);
})(v)), { defer: true }));
createEffect(on(() => (() => local.disabled)(), (v) => untrack(() => ((v: any) => {
if (!instance) return;
if (v) instance.disable();else instance.enable();
})(v)), { defer: true }));
createEffect(on(() => (() => data())(), (v) => untrack(() => ((v: any) => {
if (!instance || !v) return;
if (sameData(v, instance.getData())) return;
instance.setData(v);
})(v)), { defer: true }));
let imageElRef: HTMLElement | null = null;
// null-lets so the bundled-leaf typeNeutralize pass annotates them `any`:
// instance is the Cropper (whose strict Options/Data types the loosely-typed
// .rozie props don't satisfy), and imgEl holds the <img> the engine attaches to
// (queried from the ref'd container in $onMount). Both are the `let x = null`
// idiom the engine-wrapper recipe relies on.
let instance: any = null;
let imgEl: any = null;
// Gate that suppresses the engine's SETUP-time `crop` events from writing the
// two-way `$model.data`. Cropper fires an initial `crop` with its OWN default box
// (autoCropArea) BEFORE the `ready` callback runs, and the `setData($props.data)`
// inside `ready` fires another. Writing those transient engine-internal boxes to
// `$model.data` is wrong — and on unified-model targets (Vue defineModel / Svelte
// $bindable / Angular model() signal, where the model read and write share ONE
// local) the pre-ready write CLOBBERS the very `$props.data` that `ready` then
// reads, so the consumer's initial `:data` crop box is lost and the default box is
// applied instead. (React/Solid read the external prop and Lit's property binding
// is controlled, so the write doesn't change their read — which is why only the
// template-emit family regressed.) We flip this true at the END of `ready`, after
// the initial box is applied, so only genuine post-init user crops drive the model.
let cropReady = false;
// pure crop-box equality (rounded px + exact transform) — no sigils, safe at top
// level. The round-trip guard that stops the setData→crop→$model.data→$watch loop.
function sameData(a: any, b: any) {
if (!a || !b) return false;
return Math.round(a.x) === Math.round(b.x) && Math.round(a.y) === Math.round(b.y) && Math.round(a.width) === Math.round(b.width) && Math.round(a.height) === Math.round(b.height) && a.rotate === b.rotate && a.scaleX === b.scaleX && a.scaleY === b.scaleY;
}
// Construct (or, on a future option change, re-construct) the engine. The whole
// options object is a null-let `any` so the constructor's 2nd arg is unchecked —
// the event-callback `e` params (CustomEvent) would otherwise fail the strict
// react/solid/lit tsc against Cropper's Options callback types (the MapLibre
// mapOptions idiom). restoreData re-applies the crop box if we ever rebuild.
function buildCropper(restoreData: any) {
let cfg: any = null;
cfg = {
...local.options,
aspectRatio: local.aspectRatio,
viewMode: local.viewMode,
dragMode: local.dragMode,
guides: local.guides,
center: local.center,
background: local.background,
movable: local.movable,
rotatable: local.rotatable,
scalable: local.scalable,
zoomable: local.zoomable,
zoomOnWheel: local.zoomOnWheel,
cropBoxMovable: local.cropBoxMovable,
cropBoxResizable: local.cropBoxResizable,
autoCrop: local.autoCrop,
autoCropArea: local.autoCropArea,
responsive: local.responsive,
// construction-time only — read DIRECTLY (NOT $snapshot'd): structuredClone
// throws on the DOM element(s) a `preview` selector/ref resolves to.
preview: local.preview,
ready: (e: any) => {
if (restoreData) instance.setData(restoreData);else if (data()) instance.setData(data());
if (local.disabled) instance.disable();
// The engine's setup-time `crop` events (the default box fired BEFORE this
// `ready`, and the `setData` echo just above) are suppressed by the `cropReady`
// gate so they can't clobber the consumer's initial `:data` on unified-model
// targets (Vue defineModel / Svelte $bindable / Angular model()). But two-way
// consumers still need to READ the initial box, so echo the now-applied box
// exactly ONCE here (after `$props.data` has been read for setData — no clobber),
// then open the gate so genuine post-init user crops drive the model.
setData(instance.getData());
cropReady = true;
_props.onReady?.();
},
cropstart: (e: any) => _props.onCropstart?.({
action: e.detail && e.detail.action
}),
cropmove: (e: any) => _props.onCropmove?.({
action: e.detail && e.detail.action
}),
cropend: (e: any) => _props.onCropend?.({
action: e.detail && e.detail.action
}),
// continuous crop → emit + drive the two-way model (guarded reverse $watch).
crop: (e: any) => {
// Suppress the engine's setup-time crops (the default box before `ready`, and
// the `setData($props.data)` echo). Propagating them would (a) emit a spurious
// pre-init `crop` and (b) on unified-model targets clobber the consumer's
// initial `:data`. Genuine user crops fire after `cropReady`.
if (!cropReady) return;
_props.onCrop?.(e.detail);
if (e.detail) setData(e.detail);
},
zoom: (e: any) => _props.onZoom?.({
ratio: e.detail && e.detail.ratio,
oldRatio: e.detail && e.detail.oldRatio
})
};
instance = new CropperEngine(imgEl, cfg);
}
// ─── imperative handle (Phase 21 $expose) ───────────────────────────────────
// 27 verbs, all collision-clear across the three classes documented at the top:
// no bare `crop`/`zoom` (event⇄verb ROZ121 — exposed as showCropBox/zoomTo/zoomBy),
// no `setData` (React data-model auto-setter ROZ524 — set via two-way `data`; the new
// setCanvasData/setCropBoxData are DISTINCT names, NOT the model auto-setter), and
// none match a Lit reserved lifecycle name (update/render/firstUpdated/updated/
// willUpdate/requestUpdate). The added geometry getters (getCanvasData/getCropBoxData/
// getImageData/getContainerData) and movement setters (setCanvasData/setCropBoxData/
// moveTo/move/scale) expose v1's full canvas/crop-box geometry surface; getData and
// zoomTo gain their optional v1 args (rounded, pivot).
function getCropper() {
return instance;
}
function getData(rounded: any) {
return instance ? instance.getData(rounded) : null;
}
function getCanvasData() {
return instance ? instance.getCanvasData() : null;
}
function getCropBoxData() {
return instance ? instance.getCropBoxData() : null;
}
function getImageData() {
return instance ? instance.getImageData() : null;
}
function getContainerData() {
return instance ? instance.getContainerData() : null;
}
function getCroppedCanvas(opts: any) {
return instance ? instance.getCroppedCanvas(opts) : null;
}
function getCroppedDataURL(opts: any) {
if (!instance) return null;
const canvas = instance.getCroppedCanvas(opts);
return canvas ? canvas.toDataURL() : null;
}
function reset() {
if (instance) instance.reset();
}
function clear() {
if (instance) instance.clear();
}
function showCropBox() {
if (instance) instance.crop();
}
function replace(url: any) {
if (instance) instance.replace(url);
}
function rotateTo(deg: any) {
if (instance) instance.rotateTo(deg);
}
function rotateBy(deg: any) {
if (instance) instance.rotate(deg);
}
function zoomTo(ratio: any, pivot: any) {
if (instance) instance.zoomTo(ratio, pivot);
}
function zoomBy(ratio: any) {
if (instance) instance.zoom(ratio);
}
function scaleX(n: any) {
if (instance) instance.scaleX(n);
}
function scaleY(n: any) {
if (instance) instance.scaleY(n);
}
function scale(x: any, y: any) {
if (instance) instance.scale(x, y);
}
function setCanvasData(d: any) {
if (instance) instance.setCanvasData(d);
}
function setCropBoxData(d: any) {
if (instance) instance.setCropBoxData(d);
}
function moveTo(x: any, y: any) {
if (instance) instance.moveTo(x, y);
}
function move(offsetX: any, offsetY: any) {
if (instance) instance.move(offsetX, offsetY);
}
function enable() {
if (instance) instance.enable();
}
function disable() {
if (instance) instance.disable();
}
function setAspectRatio(ratio: any) {
if (instance) instance.setAspectRatio(ratio);
}
function setDragMode(mode: any) {
if (instance) instance.setDragMode(mode);
}
return (
<>
<div {...attrs} class={"rozie-cropper" + (((attrs as unknown as Record<string, unknown>).class as string | undefined) ? " " + ((attrs as unknown as Record<string, unknown>).class as string | undefined) : "")} data-rozie-s-cddf3b42="">
<img class={"rozie-cropper-img"} ref={(el) => { imageElRef = el as HTMLElement; }} src={local.src} alt="" data-rozie-s-cddf3b42="" />
</div>
</>
);
}ts
import { LitElement, css, html } from 'lit';
import { customElement, property, query } from 'lit/decorators.js';
import { SignalWatcher, effect, untracked } from '@lit-labs/preact-signals';
import { adoptDocumentStyles, createLitControllableProperty, rozieListeners, rozieSpread } from '@rozie/runtime-lit';
// The engine default-import is aliased `CropperEngine` — a bare `import Cropper`
// would collide with the component name `Cropper` (the rozie `name`), which the
// emitters declare as a local `Cropper` class/function across React/Solid/Lit
// (TS2440 import-conflict + a cascade of "not newable" errors). MapLibre dodged
// this for free (its import was `maplibregl` ≠ `MapLibre`); same-named single-word
// engines must alias.
import CropperEngine from 'cropperjs';
// null-lets so the bundled-leaf typeNeutralize pass annotates them `any`:
// instance is the Cropper (whose strict Options/Data types the loosely-typed
// .rozie props don't satisfy), and imgEl holds the <img> the engine attaches to
// (queried from the ref'd container in $onMount). Both are the `let x = null`
// idiom the engine-wrapper recipe relies on.
@customElement('rozie-cropper')
export default class Cropper extends SignalWatcher(LitElement) {
static styles = css`
.rozie-cropper[data-rozie-s-cddf3b42] {
max-width: 100%;
}
.rozie-cropper-img[data-rozie-s-cddf3b42] {
display: block;
max-width: 100%;
}
`;
/**
* The image URL the cropper attaches to. Bound onto the `<img>` and reconciled at runtime — changing it calls the engine `replace(url)`.
* @example
* <Cropper :src="imageUrl" r-model:data="crop" />
*/
@property({ type: String, reflect: true }) src: string = '';
/**
* The crop box — `{ x, y, width, height, rotate, scaleX, scaleY }`. The lone two-way `model: true` prop: dragging or resizing the crop box writes the new box back (round-trip-guarded so a programmatic write does not ping-pong), and a consumer write `setData`s the live cropper.
*/
@property({ type: Object, attribute: 'data' }) _data_attr: unknown = undefined;
private _dataControllable = createLitControllableProperty<unknown>({ host: this, eventName: 'data-change', defaultValue: undefined, initialControlledValue: undefined });
/**
* The crop box aspect ratio. `NaN` (the default) is Cropper's sentinel for a free ratio. Reconciled at runtime via `setAspectRatio`.
*/
@property({ type: Number, reflect: true }) aspectRatio: number = NaN;
/**
* The view constraint mode (`0`–`3`) that governs how the crop box is restricted to the canvas. Construction-only — Cropper.js v1 has no `setViewMode`.
*/
@property({ type: Number, reflect: true }) viewMode: number = 0;
/**
* The drag behavior: `'crop'` draws a new box, `'move'` pans the canvas, `'none'` disables dragging. Reconciled at runtime via `setDragMode`.
*/
@property({ type: String, reflect: true }) dragMode: string = 'crop';
/**
* Freeze the cropper so it no longer responds to user interaction. Reconciled at runtime via `enable()` / `disable()`.
*/
@property({ type: Boolean, reflect: true }) disabled: boolean = false;
/**
* Show the dashed guide lines over the crop box. Construction-only — Cropper.js v1 has no runtime setter.
*/
@property({ type: Boolean, reflect: true }) guides: boolean = true;
/**
* Show the center indicator inside the crop box. Construction-only — Cropper.js v1 has no runtime setter.
*/
@property({ type: Boolean, reflect: true }) center: boolean = true;
/**
* Show the grid background behind the image. Construction-only — Cropper.js v1 has no runtime setter.
*/
@property({ type: Boolean, reflect: true }) background: boolean = true;
/**
* Allow moving (panning) the image. Construction-only — Cropper.js v1 has no runtime setter.
*/
@property({ type: Boolean, reflect: true }) movable: boolean = true;
/**
* Allow rotating the image. Construction-only — Cropper.js v1 has no runtime setter.
*/
@property({ type: Boolean, reflect: true }) rotatable: boolean = true;
/**
* Allow scaling (flipping) the image. Construction-only — Cropper.js v1 has no runtime setter.
*/
@property({ type: Boolean, reflect: true }) scalable: boolean = true;
/**
* Allow zooming the image. Construction-only — Cropper.js v1 has no runtime setter.
*/
@property({ type: Boolean, reflect: true }) zoomable: boolean = true;
/**
* Allow zooming the image via the mouse wheel. Construction-only — Cropper.js v1 has no runtime setter.
*/
@property({ type: Boolean, reflect: true }) zoomOnWheel: boolean = true;
/**
* Allow moving the crop box. Construction-only — Cropper.js v1 has no runtime setter.
*/
@property({ type: Boolean, reflect: true }) cropBoxMovable: boolean = true;
/**
* Allow resizing the crop box. Construction-only — Cropper.js v1 has no runtime setter.
*/
@property({ type: Boolean, reflect: true }) cropBoxResizable: boolean = true;
/**
* Render a crop box automatically when the cropper initializes. Construction-only — Cropper.js v1 has no runtime setter.
*/
@property({ type: Boolean, reflect: true }) autoCrop: boolean = true;
/**
* The initial crop-box size as a fraction of the canvas (`0`–`1`). Construction-only — Cropper.js v1 has no runtime setter.
*/
@property({ type: Number, reflect: true }) autoCropArea: number = 0.8;
/**
* Re-render the cropper on window resize to keep it responsive. Construction-only — Cropper.js v1 has no runtime setter.
*/
@property({ type: Boolean, reflect: true }) responsive: boolean = true;
/**
* Live crop-thumbnail target(s) — a selector string or element ref(s) (`HTMLElement`, array, or `NodeList`). Construction-only (v1 has no `setPreview`). On Lit prefer an element ref: a document selector cannot cross the wrapper's shadow boundary.
*/
@property({ type: Object }) preview: unknown = undefined;
/**
* Raw Cropper.js `Options` passthrough — spread into the constructor before the curated keys (explicit props win). Use it for any v1 option not surfaced as a first-class prop (`modal`, `restore`, `minCropBoxWidth`, `wheelZoomRatio`, …).
*/
@property({ type: Object }) options: any = {};
@query('[data-rozie-ref="imageEl"]') private _refImageEl!: HTMLElement;
private __rozieWatchInitial_4 = true;
private __rozieFirstUpdateDone = false;
private _disconnectCleanups: Array<() => void> = [];
// Re-parenting guard: set true once the deferred teardown has actually
// run (a genuine un-mount), so a subsequent reconnect knows to re-arm.
private _rozieTornDown = false;
firstUpdated(): void {
adoptDocumentStyles(this);
this._disconnectCleanups.push((() => {
if (this.instance) this.instance.destroy();
}));
this._disconnectCleanups.push(effect(() => { const __watchVal = (() => this.data)(); untracked(() => { if (this.__rozieWatchInitial_4) { this.__rozieWatchInitial_4 = false; return; } ((v: any) => {
if (!this.instance || !v) return;
if (this.sameData(v, this.instance.getData())) return;
this.instance.setData(v);
})(__watchVal); }); }));
// Ref the <img> directly — the engine's attach target (the flatpickr/codemirror
// pattern). $refs is read ONLY here (ROZ123). The React emitter types an `img`
// ref as HTMLElement (not HTMLImageElement) — a strict-tsc mismatch fixed by a
// codegen type-aid (scripts/codegen.mjs), NOT an emitter edit (scope fence).
this.imgEl = this._refImageEl;
this.buildCropper(null);
}
updated(changedProperties: Map<string, unknown>): void {
if (this.__rozieFirstUpdateDone && (changedProperties.has('src'))) { const __watchVal = (() => this.src)(); ((v: any) => {
if (this.instance && typeof v === 'string' && v) this.instance.replace(v);
})(__watchVal); }
if (this.__rozieFirstUpdateDone && (changedProperties.has('aspectRatio'))) { const __watchVal = (() => this.aspectRatio)(); ((v: any) => {
if (this.instance) this.instance.setAspectRatio(v);
})(__watchVal); }
if (this.__rozieFirstUpdateDone && (changedProperties.has('dragMode'))) { const __watchVal = (() => this.dragMode)(); ((v: any) => {
if (this.instance && typeof v === 'string') this.instance.setDragMode(v);
})(__watchVal); }
if (this.__rozieFirstUpdateDone && (changedProperties.has('disabled'))) { const __watchVal = (() => this.disabled)(); ((v: any) => {
if (!this.instance) return;
if (v) this.instance.disable();else this.instance.enable();
})(__watchVal); }
this.__rozieFirstUpdateDone = true;
}
disconnectedCallback(): void {
super.disconnectedCallback();
queueMicrotask(() => {
if (this.isConnected || this._rozieTornDown) return;
this._rozieTornDown = true;
for (const fn of this._disconnectCleanups) fn();
this._disconnectCleanups = [];
});
}
attributeChangedCallback(name: string, old: string | null, value: string | null): void {
super.attributeChangedCallback(name, old, value);
if (name === 'data') this._dataControllable.notifyAttributeChange(value as unknown as unknown);
}
render() {
return html`
<div class="rozie-cropper" ${rozieSpread(this.$attrs)} ${rozieListeners(this.$listeners)} data-rozie-s-cddf3b42>
<img class="rozie-cropper-img" src=${this.src} alt="" data-rozie-ref="imageEl" data-rozie-s-cddf3b42 />
</div>
`;
}
instance: any = null;
imgEl: any = null;
cropReady = false;
sameData = (a: any, b: any) => {
if (!a || !b) return false;
return Math.round(a.x) === Math.round(b.x) && Math.round(a.y) === Math.round(b.y) && Math.round(a.width) === Math.round(b.width) && Math.round(a.height) === Math.round(b.height) && a.rotate === b.rotate && a.scaleX === b.scaleX && a.scaleY === b.scaleY;
};
buildCropper = (restoreData: any) => {
let cfg: any = null;
cfg = {
...this.options,
aspectRatio: this.aspectRatio,
viewMode: this.viewMode,
dragMode: this.dragMode,
guides: this.guides,
center: this.center,
background: this.background,
movable: this.movable,
rotatable: this.rotatable,
scalable: this.scalable,
zoomable: this.zoomable,
zoomOnWheel: this.zoomOnWheel,
cropBoxMovable: this.cropBoxMovable,
cropBoxResizable: this.cropBoxResizable,
autoCrop: this.autoCrop,
autoCropArea: this.autoCropArea,
responsive: this.responsive,
// construction-time only — read DIRECTLY (NOT $snapshot'd): structuredClone
// throws on the DOM element(s) a `preview` selector/ref resolves to.
preview: this.preview,
ready: (e: any) => {
if (restoreData) this.instance.setData(restoreData);else if (this.data) this.instance.setData(this.data);
if (this.disabled) this.instance.disable();
// The engine's setup-time `crop` events (the default box fired BEFORE this
// `ready`, and the `setData` echo just above) are suppressed by the `cropReady`
// gate so they can't clobber the consumer's initial `:data` on unified-model
// targets (Vue defineModel / Svelte $bindable / Angular model()). But two-way
// consumers still need to READ the initial box, so echo the now-applied box
// exactly ONCE here (after `$props.data` has been read for setData — no clobber),
// then open the gate so genuine post-init user crops drive the model.
this._dataControllable.write(this.instance.getData());
this.cropReady = true;
this.dispatchEvent(new CustomEvent("ready", {
detail: undefined,
bubbles: true,
composed: true
}));
},
cropstart: (e: any) => this.dispatchEvent(new CustomEvent("cropstart", {
detail: {
action: e.detail && e.detail.action
},
bubbles: true,
composed: true
})),
cropmove: (e: any) => this.dispatchEvent(new CustomEvent("cropmove", {
detail: {
action: e.detail && e.detail.action
},
bubbles: true,
composed: true
})),
cropend: (e: any) => this.dispatchEvent(new CustomEvent("cropend", {
detail: {
action: e.detail && e.detail.action
},
bubbles: true,
composed: true
})),
// continuous crop → emit + drive the two-way model (guarded reverse $watch).
crop: (e: any) => {
// Suppress the engine's setup-time crops (the default box before `ready`, and
// the `setData($props.data)` echo). Propagating them would (a) emit a spurious
// pre-init `crop` and (b) on unified-model targets clobber the consumer's
// initial `:data`. Genuine user crops fire after `cropReady`.
if (!this.cropReady) return;
this.dispatchEvent(new CustomEvent("crop", {
detail: e.detail,
bubbles: true,
composed: true
}));
if (e.detail) this._dataControllable.write(e.detail);
},
zoom: (e: any) => this.dispatchEvent(new CustomEvent("zoom", {
detail: {
ratio: e.detail && e.detail.ratio,
oldRatio: e.detail && e.detail.oldRatio
},
bubbles: true,
composed: true
}))
};
this.instance = new CropperEngine(this.imgEl, cfg);
};
getCropper() {
return this.instance;
}
getData(rounded: any) {
return this.instance ? this.instance.getData(rounded) : null;
}
getCanvasData() {
return this.instance ? this.instance.getCanvasData() : null;
}
getCropBoxData() {
return this.instance ? this.instance.getCropBoxData() : null;
}
getImageData() {
return this.instance ? this.instance.getImageData() : null;
}
getContainerData() {
return this.instance ? this.instance.getContainerData() : null;
}
getCroppedCanvas(opts: any) {
return this.instance ? this.instance.getCroppedCanvas(opts) : null;
}
getCroppedDataURL(opts: any) {
if (!this.instance) return null;
const canvas = this.instance.getCroppedCanvas(opts);
return canvas ? canvas.toDataURL() : null;
}
reset() {
if (this.instance) this.instance.reset();
}
clear() {
if (this.instance) this.instance.clear();
}
showCropBox() {
if (this.instance) this.instance.crop();
}
replace(url: any) {
if (this.instance) this.instance.replace(url);
}
rotateTo(deg: any) {
if (this.instance) this.instance.rotateTo(deg);
}
rotateBy(deg: any) {
if (this.instance) this.instance.rotate(deg);
}
zoomTo(ratio: any, pivot: any) {
if (this.instance) this.instance.zoomTo(ratio, pivot);
}
zoomBy(ratio: any) {
if (this.instance) this.instance.zoom(ratio);
}
scaleX(n: any) {
if (this.instance) this.instance.scaleX(n);
}
scaleY(n: any) {
if (this.instance) this.instance.scaleY(n);
}
scale(x: any, y: any) {
if (this.instance) this.instance.scale(x, y);
}
setCanvasData(d: any) {
if (this.instance) this.instance.setCanvasData(d);
}
setCropBoxData(d: any) {
if (this.instance) this.instance.setCropBoxData(d);
}
moveTo(x: any, y: any) {
if (this.instance) this.instance.moveTo(x, y);
}
move(offsetX: any, offsetY: any) {
if (this.instance) this.instance.move(offsetX, offsetY);
}
enable() {
if (this.instance) this.instance.enable();
}
disable() {
if (this.instance) this.instance.disable();
}
setAspectRatio(ratio: any) {
if (this.instance) this.instance.setAspectRatio(ratio);
}
setDragMode(mode: any) {
if (this.instance) this.instance.setDragMode(mode);
}
get data(): unknown { return this._dataControllable.read(); }
set data(v: unknown) { this._dataControllable.notifyPropertyWrite(v); }
/**
* Plan 14-05 — cross-framework attribute fallthrough source. Reads the
* host custom element's attributes on each call so a consumer-side bound
* attribute flows through on every render. The `rozieSpread` directive
* (D-02) does the cross-render diff downstream.
*
* Phase 15 follow-up Bug A — declared-prop attribute names are filtered
* out so `$attrs` returns "rest after declared props" (semantic parity
* with React/Vue/Svelte/Solid/Angular). Both Lit attribute-naming
* forms are folded into the skip set: kebab-case for model props
* (explicit `attribute:`) AND lowercased property name (Lit's default).
*/
private get $attrs(): Record<string, string> {
const __skip = new Set<string>(['src', 'data', 'aspect-ratio', 'aspectratio', 'view-mode', 'viewmode', 'drag-mode', 'dragmode', 'disabled', 'guides', 'center', 'background', 'movable', 'rotatable', 'scalable', 'zoomable', 'zoom-on-wheel', 'zoomonwheel', 'crop-box-movable', 'cropboxmovable', 'crop-box-resizable', 'cropboxresizable', 'auto-crop', 'autocrop', 'auto-crop-area', 'autocroparea', 'responsive', 'preview', 'options']);
const out: Record<string, string> = {};
for (const a of Array.from(this.attributes)) {
if (__skip.has(a.name)) continue;
out[a.name] = a.value;
}
return out;
}
/**
* Phase 15 D-19 — consumer-passed listener cluster placeholder.
* Lit attaches event listeners directly on the host element via
* `addEventListener` (no per-instance prop rest binding), so the
* runtime value is undefined; the `rozieListeners` directive's
* nullish coercion (`obj ?? {}`) handles the no-op cleanly.
* The declaration exists to satisfy `tsc --noEmit` on consumer
* projects with strict mode — bare `$listeners` in `render()`
* would otherwise raise TS2304 (Cannot find name).
*/
private get $listeners(): Record<string, EventListener> | undefined {
return undefined;
}
}Each is a real, idiomatic component for its framework — React forwardRef + hooks, Vue <script setup> + defineModel, Svelte 5 runes, an Angular standalone component with a ControlValueAccessor, a Solid component, and a Lit custom element. Same props, same events, same imperative handle, all from the one source above.
See also
- Cropper — showcase & API — install, quick starts for all six frameworks, and the full reference.
- Cropper libraries comparison — how
@rozie-ui/cropperstacks up against the per-framework wrappers.