Skip to content

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

Pre-v1.0 — internal monorepo.