diff --git a/CLAUDE.md b/CLAUDE.md index 4d68a492..9eb83656 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -112,7 +112,9 @@ Every container start runs, in order: `collectstatic` → `makemigrations` → ` ### Image handling -`image_cropping` + `easy_thumbnails` work together: cropping defines the crop box, easy_thumbnails generates sized variants. `THUMBNAIL_PROCESSORS` is configured so crop_corners runs before the default chain. Image processing requires ImageMagick (installed in the Dockerfile) and a custom `imagemagick-policy.xml` is mounted into `/etc/ImageMagick-6/policy.xml` to enable PDF processing (see issue #974). +`image_cropping` + `easy_thumbnails` work together: cropping defines the crop box (stored as an `"x1,y1,x2,y2"` string by `ImageRatioField`), easy_thumbnails generates sized variants. `THUMBNAIL_PROCESSORS` is configured so `crop_corners` runs before the default chain, applying the stored box to any `{% thumbnail … box=obj.cropping %}` render. Image processing requires ImageMagick (installed in the Dockerfile) and a custom `imagemagick-policy.xml` is mounted into `/etc/ImageMagick-6/policy.xml` to enable PDF processing (see issue #974). + +**`image_cropping` is an in-repo fork**, not the PyPI `django-image-cropping` (which was EOL Jcrop+jQuery, Django ≤4.0). Like `sortedm2m_filter_horizontal_widget`, the top-level `image_cropping/` package is project source code and shadows/replaces the dropped dependency. Its admin widget is **Cropper.js** (vendored static, no build step): editors preview and crop client-side *before* the first save (#1299/#1269). The data layer is intentionally unchanged — `ImageRatioField` is still a `CharField` whose `deconstruct()` returns `image_cropping.fields.ImageRatioField`, so the gitignored per-environment migrations that `import image_cropping.fields` keep working and the DB column is untouched (a regression test pins this path). See `image_cropping/README.md`. To bump Cropper.js, replace the vendored `static/image_cropping/cropper.min.{js,css}` (stay on the v1.x API; v2 is a different API). ### Rich text diff --git a/image_cropping/README.md b/image_cropping/README.md new file mode 100644 index 00000000..5137f09e --- /dev/null +++ b/image_cropping/README.md @@ -0,0 +1,62 @@ +# image_cropping (in-repo fork) + +A small local fork of [django-image-cropping](https://github.com/jonasundderwolf/django-image-cropping), +treated as project source code (like `sortedm2m_filter_horizontal_widget`). +**It is in `INSTALLED_APPS` and shadows the PyPI package, which is no longer a +dependency.** + +## Why we forked + +Upstream `django-image-cropping` v1.7 (Feb 2022) is unmaintained for our needs: +it bundles **Jcrop + jQuery**, its classifiers stop at **Django 4.0**, and its +workflow is "upload the image, **Save and continue editing**, scroll back up, +*then* crop" — because Jcrop crops against the already-saved file on the server. +See issue #1299 (instant preview/crop) and #1269 (de-risking dependencies ahead +of Django 6.1 LTS). + +## What we changed + +- **New admin widget on [Cropper.js](https://github.com/fengyuanchen/cropperjs) + v1.6.2** (MIT, vendored as static files, no build step). It previews and + crops the image **client-side, before the first save**, with a **live + WYSIWYG preview** of the cropped result. Precise, keyboard-accessible + numeric X/Y/W/H inputs live in a collapsed "Adjust crop precisely" + disclosure so they stay out of the common drag-to-crop flow. See + `widgets.py` + `static/image_cropping/ml_cropper.js`. +- **Removed** the pluggable backend / `django-appconf` config layer; easy_thumbnails + is wired directly. +- Dropped upstream's unused pieces for our codebase (ForeignKey-image cropping, + the `cropped_thumbnail` template tag — Banner now uses the same + `{% thumbnail … box=banner.cropping %}` idiom as everywhere else). + +## What we deliberately kept identical + +- `ImageRatioField` is still a `CharField` storing `"x1,y1,x2,y2"`, and its + `deconstruct()` still returns `image_cropping.fields.ImageRatioField`. + **This is a migration-safety contract** (pinned by + `website/tests/test_image_cropping.py`): `website/migrations/` is gitignored + and regenerated per environment, existing migration files + `import image_cropping.fields`, and deploys are push-only with no server shell + to repair a broken `migrate`. Keeping the package name and field path is what + makes the swap a no-op at the database layer. +- `crop_corners` (the easy_thumbnails processor) is unchanged, so every + `{% thumbnail … box=obj.cropping %}` call site keeps working. + +## Layout + +``` +image_cropping/ + __init__.py exports ImageRatioField, ImageCroppingMixin + fields.py ImageRatioField (+ max_cropping helper) + thumbnail_processors.py crop_corners (easy_thumbnails processor) + admin.py ImageCroppingMixin + widgets.py CropImageWidget (Cropper.js) + static/image_cropping/ cropper.min.{js,css} (vendored) + ml_cropper.{js,css} +``` + +## Upgrading Cropper.js + +Replace `static/image_cropping/cropper.min.{js,css}` with a newer 1.x build and +re-run `collectstatic`. The glue in `ml_cropper.js` targets the Cropper.js v1 +API (`getData`/`setData` in natural pixels); v2 is a web-components rewrite with +a different API and would require rewriting the glue. diff --git a/image_cropping/__init__.py b/image_cropping/__init__.py new file mode 100644 index 00000000..958c29cb --- /dev/null +++ b/image_cropping/__init__.py @@ -0,0 +1,39 @@ +""" +image_cropping - in-repo fork of django-image-cropping (#1299 / #1269). + +Why this package exists +----------------------- +We replaced the PyPI ``django-image-cropping`` (v1.7, Feb 2022; Jcrop + +jQuery; classifiers stop at Django 4.0; "save first, *then* crop") with this +small local fork. It is treated as project source code, exactly like +``sortedm2m_filter_horizontal_widget``. + +What changed vs. upstream +------------------------- +- The admin widget is rewritten on **Cropper.js** (vendored, v1.6.2, MIT, no + build step). It previews and crops the image *client-side, before the first + save* -- closing the long-standing "upload, save, scroll up, crop" friction + (#1299). +- The pluggable backend / ``django-appconf`` config layer is removed; we wire + easy_thumbnails directly. + +What deliberately did NOT change +-------------------------------- +- ``ImageRatioField`` is still a ``CharField`` storing an ``"x1,y1,x2,y2"`` + box, and its ``deconstruct()`` still returns + ``image_cropping.fields.ImageRatioField``. That keeps the DB column and every + gitignored, per-environment migration that ``import image_cropping.fields`` + working -- critical because deploys are push-only with no server shell to + repair a broken ``migrate`` (see CLAUDE.md). +- ``crop_corners`` still feeds the stored box to easy_thumbnails, so every + ``{% thumbnail ... box=obj.cropping %}`` call site is untouched. + +Keeping the package name ``image_cropping`` (rather than renaming) is +intentional: it is what preserves those migration imports. +""" + +from .admin import ImageCroppingMixin +from .fields import ImageRatioField + +__all__ = ["ImageCroppingMixin", "ImageRatioField"] +__version__ = "2.0.0-makelab" diff --git a/image_cropping/admin.py b/image_cropping/admin.py new file mode 100644 index 00000000..dcd42ae3 --- /dev/null +++ b/image_cropping/admin.py @@ -0,0 +1,19 @@ +""" +Admin mixin that swaps the crop widget onto cropped ImageFields. + +Add :class:`ImageCroppingMixin` to a ``ModelAdmin`` whose model has one or more +:class:`~image_cropping.fields.ImageRatioField`. For each ImageField referenced +by a ratio field, the file input is rendered with :class:`CropImageWidget` +(Cropper.js); the ratio field renders as a plain text input carrying the crop +box. ``ml_cropper.js`` wires the two together in the browser. +""" + +from .widgets import CropImageWidget + + +class ImageCroppingMixin: + def formfield_for_dbfield(self, db_field, request, **kwargs): + crop_fields = getattr(self.model, "crop_fields", {}) + if db_field.name in crop_fields: + kwargs["widget"] = CropImageWidget + return super().formfield_for_dbfield(db_field, request, **kwargs) diff --git a/image_cropping/fields.py b/image_cropping/fields.py new file mode 100644 index 00000000..630191d1 --- /dev/null +++ b/image_cropping/fields.py @@ -0,0 +1,194 @@ +""" +ImageRatioField - stores a crop box for an associated ImageField. + +A thin ``CharField`` that holds an ``"x1,y1,x2,y2"`` rectangle (original-image +pixel coordinates). It does not store an image itself; it records *how* a +sibling ``ImageField`` should be cropped, and easy_thumbnails renders the crop +on demand via :func:`image_cropping.thumbnail_processors.crop_corners`. + +Ported from upstream django-image-cropping with the appconf/backend layer +removed. The constructor signature, ``deconstruct()`` output, and the +``crop_fields`` / ``ratio_fields`` model metadata are kept identical to +upstream so existing (gitignored, per-environment) migrations and the admin +mixin keep working unchanged. See the package docstring for the full rationale. +""" + +from django import forms +from django.db import models +from django.db.models import signals + + +def max_cropping(width, height, image_width, image_height, free_crop=False): + """ + Return the largest centered box of aspect ratio ``width/height`` that fits + inside an ``image_width`` x ``image_height`` image, as ``[x1, y1, x2, y2]``. + + Used to seed a sensible default crop when none has been set yet. + """ + if free_crop: + return [0, 0, image_width, image_height] + + ratio = width / float(height) + if image_width < image_height * ratio: + # width fits fully, height needs to be cropped + offset = int(round((image_height - (image_width / ratio)) / 2)) + return [0, offset, image_width, image_height - offset] + + # height fits fully, width needs to be cropped + offset = int(round((image_width - (image_height * ratio)) / 2)) + return [offset, 0, image_width - offset, image_height] + + +def _image_size(image): + """Return (width, height) for an image file, honoring EXIF orientation.""" + try: + return image.width, image.height + except AttributeError: + # Fall back to opening the file (e.g. freshly uploaded, not yet saved). + from easy_thumbnails.source_generators import pil_image + return pil_image(image).size + + +class ImageRatioField(models.CharField): + """ + Store the crop boundaries for ``image_field`` at a fixed aspect ratio. + + Args: + image_field: name of the sibling ``ImageField`` to crop. (Upstream also + supported ``"fk_field__image"`` for cropping an image on a related + object; that path is unused here but the kwarg is still accepted.) + size: ``"WIDTHxHEIGHT"`` -- defines both the aspect ratio and the + minimum acceptable crop size. + free_crop: if True, allow any aspect ratio. + size_warning: if True, warn in the admin when the chosen crop is + smaller than ``size``. + + ``adapt_rotation``, ``allow_fullsize``, and ``hide_image_field`` are + accepted for migration/back-compat but are not otherwise used. + """ + + def __init__( + self, + image_field, + size="0x0", + free_crop=False, + adapt_rotation=False, + allow_fullsize=False, + verbose_name=None, + help_text=None, + hide_image_field=False, + size_warning=False, + ): + if "__" in image_field: + self.image_field, self.image_fk_field = image_field.split("__") + else: + self.image_field, self.image_fk_field = image_field, None + self.width, self.height = list(map(int, size.split("x"))) + self.free_crop = free_crop + self.adapt_rotation = adapt_rotation + self.allow_fullsize = allow_fullsize + self.size_warning = size_warning + self.hide_image_field = hide_image_field + super().__init__( + max_length=255, + default="", + blank=True, + verbose_name=verbose_name, + help_text=help_text, + ) + + def deconstruct(self): + """ + Return migration-serialization data. + + IMPORTANT: the returned path is hard-pinned to + ``"image_cropping.fields.ImageRatioField"``. Existing migration files + (gitignored, regenerated per environment) reference exactly this path; + changing it would break ``migrate`` on the next push-to-deploy with no + way to fix it server-side. Pinned by a regression test. + """ + if self.image_fk_field: + image_field = "%s__%s" % (self.image_field, self.image_fk_field) + else: + image_field = self.image_field + + args = (image_field, "%dx%d" % (self.width, self.height)) + kwargs = { + "free_crop": self.free_crop, + "adapt_rotation": self.adapt_rotation, + "allow_fullsize": self.allow_fullsize, + "verbose_name": self.verbose_name, + "help_text": self.help_text, + "hide_image_field": self.hide_image_field, + "size_warning": self.size_warning, + } + return self.name, "image_cropping.fields.ImageRatioField", args, kwargs + + def contribute_to_class(self, cls, name, **kwargs): + super().contribute_to_class(cls, name, **kwargs) + if not cls._meta.abstract: + # Record which ImageFields are cropped (so the admin mixin can swap + # in the crop widget) and which ratio fields exist on the model. + if not hasattr(cls, "crop_fields"): + cls.add_to_class("crop_fields", {}) + cls.crop_fields[self.image_field] = { + "fk_field": self.image_fk_field, + "hidden": self.hide_image_field, + } + + if not hasattr(cls, "ratio_fields"): + cls.add_to_class("ratio_fields", []) + cls.ratio_fields.append(name) + + signals.pre_save.connect(self.initial_cropping, sender=cls) + + def initial_cropping(self, sender, instance, *args, **kwargs): + """ + Seed an empty ratio field with a centered max-area box on save, so a + sensible crop exists even if the editor never touched the widget (or + had JavaScript disabled). + """ + for ratiofieldname in getattr(instance, "ratio_fields", []): + if getattr(instance, ratiofieldname): + continue # cropping already set + + ratiofield = instance._meta.get_field(ratiofieldname) + image = getattr(instance, ratiofield.image_field) + if ratiofield.image_fk_field and image: # image is on a ForeignKey + image = getattr(image, ratiofield.image_fk_field) + if not image: + continue + + try: + width, height = _image_size(image) + box = max_cropping( + ratiofield.width, + ratiofield.height, + width, + height, + free_crop=ratiofield.free_crop, + ) + box = ",".join(map(str, box)) + except (IOError, OSError): + box = "" + setattr(instance, ratiofieldname, box) + + def formfield(self, **kwargs): + """ + Render as a text input carrying the data-* attributes that + ``ml_cropper.js`` reads to drive Cropper.js. The input itself holds the + ``"x1,y1,x2,y2"`` value the JS writes back. + """ + ratio = 0 if self.free_crop else self.width / float(self.height) + kwargs["widget"] = forms.TextInput( + attrs={ + "class": "image-ratio", + "data-image-field": self.image_field, + "data-my-name": self.name, + "data-ratio": str(ratio), + "data-min-width": self.width, + "data-min-height": self.height, + "data-size-warning": str(self.size_warning).lower(), + } + ) + return super().formfield(**kwargs) diff --git a/image_cropping/static/image_cropping/cropper.min.css b/image_cropping/static/image_cropping/cropper.min.css new file mode 100644 index 00000000..8e34d751 --- /dev/null +++ b/image_cropping/static/image_cropping/cropper.min.css @@ -0,0 +1,9 @@ +/*! + * Cropper.js v1.6.2 + * https://fengyuanchen.github.io/cropperjs + * + * Copyright 2015-present Chen Fengyuan + * Released under the MIT license + * + * Date: 2024-04-21T07:43:02.731Z + */.cropper-container{-webkit-touch-callout:none;direction:ltr;font-size:0;line-height:0;position:relative;-ms-touch-action:none;touch-action:none;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.cropper-container img{backface-visibility:hidden;display:block;height:100%;image-orientation:0deg;max-height:none!important;max-width:none!important;min-height:0!important;min-width:0!important;width:100%}.cropper-canvas,.cropper-crop-box,.cropper-drag-box,.cropper-modal,.cropper-wrap-box{bottom:0;left:0;position:absolute;right:0;top:0}.cropper-canvas,.cropper-wrap-box{overflow:hidden}.cropper-drag-box{background-color:#fff;opacity:0}.cropper-modal{background-color:#000;opacity:.5}.cropper-view-box{display:block;height:100%;outline:1px solid #39f;outline-color:rgba(51,153,255,.75);overflow:hidden;width:100%}.cropper-dashed{border:0 dashed #eee;display:block;opacity:.5;position:absolute}.cropper-dashed.dashed-h{border-bottom-width:1px;border-top-width:1px;height:33.33333%;left:0;top:33.33333%;width:100%}.cropper-dashed.dashed-v{border-left-width:1px;border-right-width:1px;height:100%;left:33.33333%;top:0;width:33.33333%}.cropper-center{display:block;height:0;left:50%;opacity:.75;position:absolute;top:50%;width:0}.cropper-center:after,.cropper-center:before{background-color:#eee;content:" ";display:block;position:absolute}.cropper-center:before{height:1px;left:-3px;top:0;width:7px}.cropper-center:after{height:7px;left:0;top:-3px;width:1px}.cropper-face,.cropper-line,.cropper-point{display:block;height:100%;opacity:.1;position:absolute;width:100%}.cropper-face{background-color:#fff;left:0;top:0}.cropper-line{background-color:#39f}.cropper-line.line-e{cursor:ew-resize;right:-3px;top:0;width:5px}.cropper-line.line-n{cursor:ns-resize;height:5px;left:0;top:-3px}.cropper-line.line-w{cursor:ew-resize;left:-3px;top:0;width:5px}.cropper-line.line-s{bottom:-3px;cursor:ns-resize;height:5px;left:0}.cropper-point{background-color:#39f;height:5px;opacity:.75;width:5px}.cropper-point.point-e{cursor:ew-resize;margin-top:-3px;right:-3px;top:50%}.cropper-point.point-n{cursor:ns-resize;left:50%;margin-left:-3px;top:-3px}.cropper-point.point-w{cursor:ew-resize;left:-3px;margin-top:-3px;top:50%}.cropper-point.point-s{bottom:-3px;cursor:s-resize;left:50%;margin-left:-3px}.cropper-point.point-ne{cursor:nesw-resize;right:-3px;top:-3px}.cropper-point.point-nw{cursor:nwse-resize;left:-3px;top:-3px}.cropper-point.point-sw{bottom:-3px;cursor:nesw-resize;left:-3px}.cropper-point.point-se{bottom:-3px;cursor:nwse-resize;height:20px;opacity:1;right:-3px;width:20px}@media (min-width:768px){.cropper-point.point-se{height:15px;width:15px}}@media (min-width:992px){.cropper-point.point-se{height:10px;width:10px}}@media (min-width:1200px){.cropper-point.point-se{height:5px;opacity:.75;width:5px}}.cropper-point.point-se:before{background-color:#39f;bottom:-50%;content:" ";display:block;height:200%;opacity:0;position:absolute;right:-50%;width:200%}.cropper-invisible{opacity:0}.cropper-bg{background-image:url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQAQMAAAAlPW0iAAAAA3NCSVQICAjb4U/gAAAABlBMVEXMzMz////TjRV2AAAACXBIWXMAAArrAAAK6wGCiw1aAAAAHHRFWHRTb2Z0d2FyZQBBZG9iZSBGaXJld29ya3MgQ1M26LyyjAAAABFJREFUCJlj+M/AgBVhF/0PAH6/D/HkDxOGAAAAAElFTkSuQmCC")}.cropper-hide{display:block;height:0;position:absolute;width:0}.cropper-hidden{display:none!important}.cropper-move{cursor:move}.cropper-crop{cursor:crosshair}.cropper-disabled .cropper-drag-box,.cropper-disabled .cropper-face,.cropper-disabled .cropper-line,.cropper-disabled .cropper-point{cursor:not-allowed} \ No newline at end of file diff --git a/image_cropping/static/image_cropping/cropper.min.js b/image_cropping/static/image_cropping/cropper.min.js new file mode 100644 index 00000000..3102cb54 --- /dev/null +++ b/image_cropping/static/image_cropping/cropper.min.js @@ -0,0 +1,10 @@ +/*! + * Cropper.js v1.6.2 + * https://fengyuanchen.github.io/cropperjs + * + * Copyright 2015-present Chen Fengyuan + * Released under the MIT license + * + * Date: 2024-04-21T07:43:05.335Z + */ +!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t="undefined"!=typeof globalThis?globalThis:t||self).Cropper=e()}(this,function(){"use strict";function C(e,t){var i,a=Object.keys(e);return Object.getOwnPropertySymbols&&(i=Object.getOwnPropertySymbols(e),t&&(i=i.filter(function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable})),a.push.apply(a,i)),a}function S(a){for(var t=1;tt.length)&&(e=t.length);for(var i=0,a=new Array(e);it.width?3===i?o=t.height*e:h=t.width/e:3===i?h=t.width/e:o=t.height*e,{aspectRatio:e,naturalWidth:n,naturalHeight:a,width:o,height:h});this.canvasData=e,this.limited=1===i||2===i,this.limitCanvas(!0,!0),e.width=Math.min(Math.max(e.width,e.minWidth),e.maxWidth),e.height=Math.min(Math.max(e.height,e.minHeight),e.maxHeight),e.left=(t.width-e.width)/2,e.top=(t.height-e.height)/2,e.oldLeft=e.left,e.oldTop=e.top,this.initialCanvasData=g({},e)},limitCanvas:function(t,e){var i=this.options,a=this.containerData,n=this.canvasData,o=this.cropBoxData,h=i.viewMode,r=n.aspectRatio,s=this.cropped&&o;t&&(t=Number(i.minCanvasWidth)||0,i=Number(i.minCanvasHeight)||0,1=a.width&&(n.minLeft=Math.min(0,r),n.maxLeft=Math.max(0,r)),n.height>=a.height)&&(n.minTop=Math.min(0,t),n.maxTop=Math.max(0,t))):(n.minLeft=-n.width,n.minTop=-n.height,n.maxLeft=a.width,n.maxTop=a.height))},renderCanvas:function(t,e){var i,a,n,o,h=this.canvasData,r=this.imageData;e&&(e={width:r.naturalWidth*Math.abs(r.scaleX||1),height:r.naturalHeight*Math.abs(r.scaleY||1),degree:r.rotate||0},r=e.width,o=e.height,e=e.degree,i=90==(e=Math.abs(e)%180)?{width:o,height:r}:(a=e%90*Math.PI/180,i=Math.sin(a),n=r*(a=Math.cos(a))+o*i,r=r*i+o*a,90h.maxWidth||h.widthh.maxHeight||h.heighte.width?a.height=a.width/i:a.width=a.height*i),this.cropBoxData=a,this.limitCropBox(!0,!0),a.width=Math.min(Math.max(a.width,a.minWidth),a.maxWidth),a.height=Math.min(Math.max(a.height,a.minHeight),a.maxHeight),a.width=Math.max(a.minWidth,a.width*t),a.height=Math.max(a.minHeight,a.height*t),a.left=e.left+(e.width-a.width)/2,a.top=e.top+(e.height-a.height)/2,a.oldLeft=a.left,a.oldTop=a.top,this.initialCropBoxData=g({},a)},limitCropBox:function(t,e){var i,a,n=this.options,o=this.containerData,h=this.canvasData,r=this.cropBoxData,s=this.limited,c=n.aspectRatio;t&&(t=Number(n.minCropBoxWidth)||0,n=Number(n.minCropBoxHeight)||0,i=s?Math.min(o.width,h.width,h.width+h.left,o.width-h.left):o.width,a=s?Math.min(o.height,h.height,h.height+h.top,o.height-h.top):o.height,t=Math.min(t,o.width),n=Math.min(n,o.height),c&&(t&&n?ti.maxWidth||i.widthi.maxHeight||i.height=e.width&&i.height>=e.height?q:I),f(this.cropBox,g({width:i.width,height:i.height},x({translateX:i.left,translateY:i.top}))),this.cropped&&this.limited&&this.limitCanvas(!0,!0),this.disabled||this.output()},output:function(){this.preview(),y(this.element,tt,this.getData())}},i={initPreview:function(){var t=this.element,i=this.crossOrigin,e=this.options.preview,a=i?this.crossOriginUrl:this.url,n=t.alt||"The image to preview",o=document.createElement("img");i&&(o.crossOrigin=i),o.src=a,o.alt=n,this.viewBox.appendChild(o),this.viewBoxImage=o,e&&("string"==typeof(o=e)?o=t.ownerDocument.querySelectorAll(e):e.querySelector&&(o=[e]),z(this.previews=o,function(t){var e=document.createElement("img");w(t,m,{width:t.offsetWidth,height:t.offsetHeight,html:t.innerHTML}),i&&(e.crossOrigin=i),e.src=a,e.alt=n,e.style.cssText='display:block;width:100%;height:auto;min-width:0!important;min-height:0!important;max-width:none!important;max-height:none!important;image-orientation:0deg!important;"',t.innerHTML="",t.appendChild(e)}))},resetPreview:function(){z(this.previews,function(e){var i=Bt(e,m),i=(f(e,{width:i.width,height:i.height}),e.innerHTML=i.html,e),e=m;if(o(i[e]))try{delete i[e]}catch(t){i[e]=void 0}else if(i.dataset)try{delete i.dataset[e]}catch(t){i.dataset[e]=void 0}else i.removeAttribute("data-".concat(Dt(e)))})},preview:function(){var h=this.imageData,t=this.canvasData,e=this.cropBoxData,r=e.width,s=e.height,c=h.width,d=h.height,l=e.left-t.left-h.left,p=e.top-t.top-h.top;this.cropped&&!this.disabled&&(f(this.viewBoxImage,g({width:c,height:d},x(g({translateX:-l,translateY:-p},h)))),z(this.previews,function(t){var e=Bt(t,m),i=e.width,e=e.height,a=i,n=e,o=1;r&&(n=s*(o=i/r)),s&&eMath.abs(a-1)?i:a)&&(t.restore&&(o=this.getCanvasData(),h=this.getCropBoxData()),this.render(),t.restore)&&(this.setCanvasData(z(o,function(t,e){o[e]=t*n})),this.setCropBoxData(z(h,function(t,e){h[e]=t*n}))))},dblclick:function(){var t,e;this.disabled||this.options.dragMode===_||this.setDragMode((t=this.dragBox,e=Q,(t.classList?t.classList.contains(e):-1y&&(D.x=y-f);break;case k:p+D.xx&&(D.y=x-v)}}var i,a,o,n=this.options,h=this.canvasData,r=this.containerData,s=this.cropBoxData,c=this.pointers,d=this.action,l=n.aspectRatio,p=s.left,m=s.top,u=s.width,g=s.height,f=p+u,v=m+g,w=0,b=0,y=r.width,x=r.height,M=!0,C=(!l&&t.shiftKey&&(l=u&&g?u/g:1),this.limited&&(w=s.minLeft,b=s.minTop,y=w+Math.min(r.width,h.width,h.left+h.width),x=b+Math.min(r.height,h.height,h.top+h.height)),c[Object.keys(c)[0]]),D={x:C.endX-C.startX,y:C.endY-C.startY};switch(d){case I:p+=D.x,m+=D.y;break;case B:0<=D.x&&(y<=f||l&&(m<=b||x<=v))?M=!1:(e(B),(u+=D.x)<0&&(d=k,p-=u=-u),l&&(m+=(s.height-(g=u/l))/2));break;case T:D.y<=0&&(m<=b||l&&(p<=w||y<=f))?M=!1:(e(T),g-=D.y,m+=D.y,g<0&&(d=O,m-=g=-g),l&&(p+=(s.width-(u=g*l))/2));break;case k:D.x<=0&&(p<=w||l&&(m<=b||x<=v))?M=!1:(e(k),u-=D.x,p+=D.x,u<0&&(d=B,p-=u=-u),l&&(m+=(s.height-(g=u/l))/2));break;case O:0<=D.y&&(x<=v||l&&(p<=w||y<=f))?M=!1:(e(O),(g+=D.y)<0&&(d=T,m-=g=-g),l&&(p+=(s.width-(u=g*l))/2));break;case E:if(l){if(D.y<=0&&(m<=b||y<=f)){M=!1;break}e(T),g-=D.y,m+=D.y,u=g*l}else e(T),e(B),!(0<=D.x)||fMath.abs(o)&&(o=i)})}),o),t),M=!1;break;case U:D.x&&D.y?(i=Wt(this.cropper),p=C.startX-i.left,m=C.startY-i.top,u=s.minWidth,g=s.minHeight,0 or element.");this.element=t,this.options=g({},ut,u(e)&&e),this.cropped=!1,this.disabled=!1,this.pointers={},this.ready=!1,this.reloading=!1,this.replaced=!1,this.sized=!1,this.sizing=!1,this.init()}return t=n,i=[{key:"noConflict",value:function(){return window.Cropper=Pt,n}},{key:"setDefaults",value:function(t){g(ut,u(t)&&t)}}],(e=[{key:"init",value:function(){var t,e=this.element,i=e.tagName.toLowerCase();if(!e[c]){if(e[c]=this,"img"===i){if(this.isImg=!0,t=e.getAttribute("src")||"",!(this.originalUrl=t))return;t=e.src}else"canvas"===i&&window.HTMLCanvasElement&&(t=e.toDataURL());this.load(t)}}},{key:"load",value:function(t){var e,i,a,n,o,h,r=this;t&&(this.url=t,this.imageData={},e=this.element,(i=this.options).rotatable||i.scalable||(i.checkOrientation=!1),i.checkOrientation&&window.ArrayBuffer?lt.test(t)?pt.test(t)?this.read((h=(h=t).replace(Xt,""),a=atob(h),h=new ArrayBuffer(a.length),z(n=new Uint8Array(h),function(t,e){n[e]=a.charCodeAt(e)}),h)):this.clone():(o=new XMLHttpRequest,h=this.clone.bind(this),this.reloading=!0,(this.xhr=o).onabort=h,o.onerror=h,o.ontimeout=h,o.onprogress=function(){o.getResponseHeader("content-type")!==ct&&o.abort()},o.onload=function(){r.read(o.response)},o.onloadend=function(){r.reloading=!1,r.xhr=null},i.checkCrossOrigin&&Lt(t)&&e.crossOrigin&&(t=zt(t)),o.open("GET",t,!0),o.responseType="arraybuffer",o.withCredentials="use-credentials"===e.crossOrigin,o.send()):this.clone())}},{key:"read",value:function(t){var e=this.options,i=this.imageData,a=Rt(t),n=0,o=1,h=1;1
',o=(n=n.querySelector(".".concat(c,"-container"))).querySelector(".".concat(c,"-canvas")),h=n.querySelector(".".concat(c,"-drag-box")),s=(r=n.querySelector(".".concat(c,"-crop-box"))).querySelector(".".concat(c,"-face")),this.container=a,this.cropper=n,this.canvas=o,this.dragBox=h,this.cropBox=r,this.viewBox=n.querySelector(".".concat(c,"-view-box")),this.face=s,o.appendChild(i),v(t,L),a.insertBefore(n,t.nextSibling),X(i,Z),this.initPreview(),this.bind(),e.initialAspectRatio=Math.max(0,e.initialAspectRatio)||NaN,e.aspectRatio=Math.max(0,e.aspectRatio)||NaN,e.viewMode=Math.max(0,Math.min(3,Math.round(e.viewMode)))||0,v(r,L),e.guides||v(r.getElementsByClassName("".concat(c,"-dashed")),L),e.center||v(r.getElementsByClassName("".concat(c,"-center")),L),e.background&&v(n,"".concat(c,"-bg")),e.highlight||v(s,G),e.cropBoxMovable&&(v(s,V),w(s,d,I)),e.cropBoxResizable||(v(r.getElementsByClassName("".concat(c,"-line")),L),v(r.getElementsByClassName("".concat(c,"-point")),L)),this.render(),this.ready=!0,this.setDragMode(e.dragMode),e.autoCrop&&this.crop(),this.setData(e.data),l(e.ready)&&b(t,"ready",e.ready,{once:!0}),y(t,"ready"))}},{key:"unbuild",value:function(){var t;this.ready&&(this.ready=!1,this.unbind(),this.resetPreview(),(t=this.cropper.parentNode)&&t.removeChild(this.cropper),X(this.element,L))}},{key:"uncreate",value:function(){this.ready?(this.unbuild(),this.ready=!1,this.cropped=!1):this.sizing?(this.sizingImage.onload=null,this.sizing=!1,this.sized=!1):this.reloading?(this.xhr.onabort=null,this.xhr.abort()):this.image&&this.stop()}}])&&A(t.prototype,e),i&&A(t,i),Object.defineProperty(t,"prototype",{writable:!1}),t;var t,e,i}();return g(It.prototype,t,i,e,St,jt,At),It}); \ No newline at end of file diff --git a/image_cropping/static/image_cropping/ml_cropper.css b/image_cropping/static/image_cropping/ml_cropper.css new file mode 100644 index 00000000..21f16423 --- /dev/null +++ b/image_cropping/static/image_cropping/ml_cropper.css @@ -0,0 +1,117 @@ +/* + * ml_cropper.css — styling for the admin Cropper.js widget (#1299). + * Cropper.js needs its target to be block-level inside a sized box. + */ + +.ml-cropper { + margin: 8px 0 16px 0; + max-width: 640px; +} + +/* The raw "x1,y1,x2,y2" text field is kept in the DOM as the value holder but + hidden from view; the cropper UI replaces it. */ +.ml-cropper__hidden-row { + display: none !important; +} + +.ml-cropper__stage { + max-width: 100%; + max-height: 420px; + background: #f3f3f3; + border: 1px solid #ccc; + border-radius: 4px; + overflow: hidden; +} + +.ml-cropper__stage:not(.ml-cropper__stage--active) { + display: none; +} + +/* Cropper.js resizes/wraps this image; constrain it so a tall original does + not blow out the admin layout. */ +.ml-cropper__image { + display: block; + max-width: 100%; + max-height: 420px; +} + +.ml-cropper__warning { + margin: 6px 0 0 0; + color: #8a6d3b; + font-size: 0.85em; +} + +/* Live result preview. Cropper.js injects an and fills this box's width + with the current crop, so overflow must be hidden. */ +.ml-cropper__preview-wrap { + display: flex; + align-items: center; + gap: 10px; + margin-top: 10px; +} + +.ml-cropper__preview-label { + font-size: 0.85em; + font-weight: 600; + color: #555; +} + +.ml-cropper__preview { + overflow: hidden; /* required by Cropper.js preview */ + border: 1px solid #ccc; + border-radius: 4px; + background: #f3f3f3; + flex: none; +} + +/* Collapsed "adjust precisely" disclosure wrapping the numeric controls. */ +.ml-cropper__advanced { + margin-top: 10px; +} + +.ml-cropper__summary { + cursor: pointer; + font-size: 0.85em; + color: #417690; /* Django admin link blue */ + user-select: none; + width: max-content; +} + +.ml-cropper__summary:hover { + color: #205067; +} + +.ml-cropper__controls { + margin-top: 8px; + padding: 8px 10px; + border: 1px solid #e0e0e0; + border-radius: 4px; + display: flex; + flex-wrap: nowrap; /* keep X / Y / Width / Height on a single row */ + gap: 8px 14px; + align-items: center; +} + +.ml-cropper__num { + font-size: 0.85em; + /* These are