ngwr 14.5.1 → 14.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/alert/styles/_index.scss +14 -3
- package/avatar/styles/_index.scss +90 -0
- package/cascader/styles/_rules.scss +0 -4
- package/collapse/styles/_index.scss +0 -1
- package/fesm2022/ngwr-avatar.mjs +76 -8
- package/fesm2022/ngwr-avatar.mjs.map +1 -1
- package/fesm2022/ngwr-cascader.mjs +2 -2
- package/fesm2022/ngwr-cascader.mjs.map +1 -1
- package/fesm2022/ngwr-image-cropper-testing.mjs +34 -3
- package/fesm2022/ngwr-image-cropper-testing.mjs.map +1 -1
- package/fesm2022/ngwr-image-cropper.mjs +245 -65
- package/fesm2022/ngwr-image-cropper.mjs.map +1 -1
- package/fesm2022/ngwr-lightbox.mjs +2 -2
- package/fesm2022/ngwr-lightbox.mjs.map +1 -1
- package/fesm2022/ngwr-pagination.mjs +1 -1
- package/fesm2022/ngwr-pagination.mjs.map +1 -1
- package/fesm2022/ngwr-schema-form.mjs +1 -1
- package/fesm2022/ngwr-schema-form.mjs.map +1 -1
- package/fesm2022/ngwr-select-testing.mjs +21 -2
- package/fesm2022/ngwr-select-testing.mjs.map +1 -1
- package/fesm2022/ngwr-select.mjs +299 -50
- package/fesm2022/ngwr-select.mjs.map +1 -1
- package/fesm2022/ngwr-version.mjs +1 -1
- package/fesm2022/ngwr-version.mjs.map +1 -1
- package/image-cropper/styles/_index.scss +7 -0
- package/input/styles/_index.scss +8 -2
- package/lightbox/styles/_rules.scss +7 -1
- package/llms-full.txt +4 -4
- package/package.json +1 -1
- package/schematics/use/symbol-map.json +1 -0
- package/select/styles/_index.scss +50 -4
- package/skills/ngwr/references/catalog.md +1 -1
- package/table/styles/_index.scss +55 -0
- package/toast/styles/_index.scss +9 -3
- package/tree/styles/_index.scss +0 -3
- package/types/ngwr-avatar.d.ts +51 -4
- package/types/ngwr-image-cropper-testing.d.ts +25 -3
- package/types/ngwr-image-cropper.d.ts +136 -2
- package/types/ngwr-select-testing.d.ts +18 -1
- package/types/ngwr-select.d.ts +238 -10
- package/types/ngwr-version.d.ts +1 -1
|
@@ -2,7 +2,7 @@ import { coerceNumberProperty } from '@angular/cdk/coercion';
|
|
|
2
2
|
import * as i0 from '@angular/core';
|
|
3
3
|
import { signal, input, output, viewChild, inject, DestroyRef, computed, effect, ViewEncapsulation, Component } from '@angular/core';
|
|
4
4
|
import { useI18nText } from 'ngwr/i18n';
|
|
5
|
-
import {
|
|
5
|
+
import { clamp, randomId } from 'ngwr/utils';
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* @license
|
|
@@ -10,6 +10,17 @@ import { randomId, clamp } from 'ngwr/utils';
|
|
|
10
10
|
* Use of this source code is governed by an MIT-style license that can be
|
|
11
11
|
* found in the LICENSE file at https://github.com/thekhegay/ngwr/blob/main/LICENSE
|
|
12
12
|
*/
|
|
13
|
+
/**
|
|
14
|
+
* `[maxOutputSize]`'s transform. Anything that is not a positive, finite number —
|
|
15
|
+
* `null`, an empty attribute, `0`, a typo — means "no cap" rather than a 1px export:
|
|
16
|
+
* a bad config should cost resolution nobody asked to lose, not the whole image.
|
|
17
|
+
*/
|
|
18
|
+
function coerceMaxOutputSize(value) {
|
|
19
|
+
if (value === null || value === undefined || value === '')
|
|
20
|
+
return null;
|
|
21
|
+
const n = coerceNumberProperty(value, Number.NaN);
|
|
22
|
+
return Number.isFinite(n) && n > 0 ? n : null;
|
|
23
|
+
}
|
|
13
24
|
/**
|
|
14
25
|
* Stands in for the consumer input `useI18nText` expects. None of this
|
|
15
26
|
* component's three strings has a `*Label` input behind it — the catalog is the
|
|
@@ -19,6 +30,67 @@ const NO_OVERRIDE = signal(null).asReadonly();
|
|
|
19
30
|
/** Keyboard step in display pixels, and what Shift promotes it to. */
|
|
20
31
|
const KEY_STEP = 1;
|
|
21
32
|
const KEY_STEP_COARSE = 10;
|
|
33
|
+
const NO_SIDES = { left: 0, top: 0, right: 0, bottom: 0 };
|
|
34
|
+
/**
|
|
35
|
+
* The rect a resize gesture produces: a pure function of the rect the gesture started
|
|
36
|
+
* from and the TOTAL pointer offset, so the same pointer position always gives the same
|
|
37
|
+
* rect however the pointer got there.
|
|
38
|
+
*
|
|
39
|
+
* The anchor never moves. It is the edge or corner opposite the handle — for `se` the
|
|
40
|
+
* top-left corner, for `e` the west edge's line. The old version clamped `x`, `y`, `w`
|
|
41
|
+
* and `h` one at a time after deriving the second axis, so once the derived axis ran
|
|
42
|
+
* into the image edge the rect slid AWAY from its anchor and, past the far bound, off
|
|
43
|
+
* the image entirely.
|
|
44
|
+
*
|
|
45
|
+
* With a ratio the size is the largest along that ratio which fits from the anchor —
|
|
46
|
+
* the smaller of the width-limited and height-limited sizes — and no smaller than both
|
|
47
|
+
* minimums allow. Where the image cannot fit the minimum from this anchor, the bound
|
|
48
|
+
* wins: a crop past the edge of the image is worse than one under the minimum.
|
|
49
|
+
*
|
|
50
|
+
* An edge handle with a ratio also sizes the axis it does not drive, and that axis has
|
|
51
|
+
* the image's WHOLE extent to grow into: the rect stays centred on its old midline where
|
|
52
|
+
* it can and slides along that axis only as far as it must to stay on the image. Capping
|
|
53
|
+
* the size at twice the distance from the midline to the nearer side instead would leave
|
|
54
|
+
* a square crop resting against the top edge unable to widen at all.
|
|
55
|
+
*/
|
|
56
|
+
function resizeFrom(r, handle, dx, dy, bounds, min, ratio) {
|
|
57
|
+
const east = handle.includes('e');
|
|
58
|
+
const west = handle.includes('w');
|
|
59
|
+
const south = handle.includes('s');
|
|
60
|
+
const north = handle.includes('n');
|
|
61
|
+
const right = r.x + r.w;
|
|
62
|
+
const bottom = r.y + r.h;
|
|
63
|
+
if (!ratio || ratio <= 0) {
|
|
64
|
+
let { x, y, w, h } = r;
|
|
65
|
+
if (east)
|
|
66
|
+
w = clamp(r.w + dx, min.w, bounds.w - r.x);
|
|
67
|
+
if (west) {
|
|
68
|
+
w = clamp(r.w - dx, min.w, right);
|
|
69
|
+
x = right - w;
|
|
70
|
+
}
|
|
71
|
+
if (south)
|
|
72
|
+
h = clamp(r.h + dy, min.h, bounds.h - r.y);
|
|
73
|
+
if (north) {
|
|
74
|
+
h = clamp(r.h - dy, min.h, bottom);
|
|
75
|
+
y = bottom - h;
|
|
76
|
+
}
|
|
77
|
+
return { x, y, w, h };
|
|
78
|
+
}
|
|
79
|
+
// Room from the anchor to the image edge on each axis the handle drives; the whole
|
|
80
|
+
// image on an axis it does not.
|
|
81
|
+
const roomW = east ? bounds.w - r.x : west ? right : bounds.w;
|
|
82
|
+
const roomH = south ? bounds.h - r.y : north ? bottom : bounds.h;
|
|
83
|
+
// Corners follow the horizontal delta, as they always have; `n` / `s` the vertical one.
|
|
84
|
+
const wanted = east ? r.w + dx : west ? r.w - dx : (south ? r.h + dy : r.h - dy) * ratio;
|
|
85
|
+
const w = clamp(wanted, Math.max(min.w, min.h * ratio), Math.min(roomW, roomH * ratio));
|
|
86
|
+
const h = w / ratio;
|
|
87
|
+
return {
|
|
88
|
+
x: east ? r.x : west ? right - w : clamp(r.x + r.w / 2 - w / 2, 0, bounds.w - w),
|
|
89
|
+
y: south ? r.y : north ? bottom - h : clamp(r.y + r.h / 2 - h / 2, 0, bounds.h - h),
|
|
90
|
+
w,
|
|
91
|
+
h,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
22
94
|
/**
|
|
23
95
|
* Image crop UI. Pass `[src]` (URL, `File`, or `Blob`); the user drags
|
|
24
96
|
* the crop window or any of its eight handles. Optionally lock the crop
|
|
@@ -31,12 +103,18 @@ const KEY_STEP_COARSE = 10;
|
|
|
31
103
|
* freshly-rendered `Blob` of the cropped region. For one-off reads use
|
|
32
104
|
* `toBlob()` / `toDataUrl()`.
|
|
33
105
|
*
|
|
106
|
+
* A source the browser cannot decode fires `(loadError)` once, sets `status()` to
|
|
107
|
+
* `'failed'` and puts `wr-image-cropper--failed` on the host. The component renders
|
|
108
|
+
* no message of its own for it — what went wrong and what to do next is the host's
|
|
109
|
+
* to say.
|
|
110
|
+
*
|
|
34
111
|
* @example
|
|
35
112
|
* ```html
|
|
36
113
|
* <wr-image-cropper
|
|
37
114
|
* [src]="file"
|
|
38
115
|
* [aspectRatio]="1"
|
|
39
116
|
* (cropped)="onBlob($event)"
|
|
117
|
+
* (loadError)="onUnreadable($event)"
|
|
40
118
|
* />
|
|
41
119
|
* ```
|
|
42
120
|
*
|
|
@@ -59,8 +137,23 @@ class WrImageCropper {
|
|
|
59
137
|
/** JPEG / WebP quality for `(cropped)` in [0, 1]. @default 0.92 */
|
|
60
138
|
outputQuality = input(0.92, /* @ts-ignore */
|
|
61
139
|
...(ngDevMode ? [{ debugName: "outputQuality" }] : /* istanbul ignore next */ []));
|
|
140
|
+
/**
|
|
141
|
+
* Longest side of the exported image, in pixels — for `(cropped)`, `toBlob()` and
|
|
142
|
+
* `toDataUrl()` alike. A crop larger than this is scaled down to fit, keeping its
|
|
143
|
+
* aspect ratio; a smaller one is exported as it is, never upscaled. The crop itself
|
|
144
|
+
* — `cropRect()`, in source pixels — is not affected. `null` (or anything that is
|
|
145
|
+
* not a positive number) exports at the source image's own resolution.
|
|
146
|
+
* @default null
|
|
147
|
+
*/
|
|
148
|
+
maxOutputSize = input(null, { ...(ngDevMode ? { debugName: "maxOutputSize" } : /* istanbul ignore next */ {}), transform: coerceMaxOutputSize });
|
|
62
149
|
/** Emits a Blob after each drag end. */
|
|
63
150
|
cropped = output();
|
|
151
|
+
/**
|
|
152
|
+
* Fires once when the browser cannot decode the current `src` — a format it does
|
|
153
|
+
* not support, a broken file, a URL that fails. Nothing is rendered for it; show
|
|
154
|
+
* your own message. A new `src` clears the failure.
|
|
155
|
+
*/
|
|
156
|
+
loadError = output();
|
|
64
157
|
imgEl = viewChild.required('img', /* @ts-ignore */
|
|
65
158
|
...(ngDevMode ? [{ debugName: "imgEl" }] : /* istanbul ignore next */ []));
|
|
66
159
|
/** Resolved object URL for `src` (so File / Blob render in `<img>`). */
|
|
@@ -96,7 +189,21 @@ class WrImageCropper {
|
|
|
96
189
|
active = null;
|
|
97
190
|
startPointer = { x: 0, y: 0 };
|
|
98
191
|
startRect = { x: 0, y: 0, w: 0, h: 0 };
|
|
192
|
+
/** The element the live drag set pointer capture on, so the end releases that one. */
|
|
193
|
+
captureTarget = null;
|
|
194
|
+
/** The pointer the live drag belongs to. Every other pointer's events are ignored. */
|
|
195
|
+
pointerId = null;
|
|
196
|
+
/** How far past each side of the crop the live resize was pressed — see `onPointerMove`. */
|
|
197
|
+
overhang = NO_SIDES;
|
|
99
198
|
destroyRef = inject(DestroyRef);
|
|
199
|
+
statusState = signal('empty', /* @ts-ignore */
|
|
200
|
+
...(ngDevMode ? [{ debugName: "statusState" }] : /* istanbul ignore next */ []));
|
|
201
|
+
/**
|
|
202
|
+
* Where the source stands: `'empty'`, `'loading'`, `'loaded'` or `'failed'`. Reset
|
|
203
|
+
* to `'loading'` (or `'empty'`) whenever `src` changes, so a failure never outlives
|
|
204
|
+
* the source that caused it.
|
|
205
|
+
*/
|
|
206
|
+
status = this.statusState.asReadonly();
|
|
100
207
|
/** Resolved crop rect in natural (source) pixel coordinates. */
|
|
101
208
|
cropRect = computed(() => {
|
|
102
209
|
const display = this.display();
|
|
@@ -132,8 +239,13 @@ class WrImageCropper {
|
|
|
132
239
|
this.resetGeometry();
|
|
133
240
|
if (!src) {
|
|
134
241
|
this.objectUrl.set(null);
|
|
242
|
+
this.statusState.set('empty');
|
|
135
243
|
return;
|
|
136
244
|
}
|
|
245
|
+
// A new source starts over: a failure belongs to the source that caused it, and
|
|
246
|
+
// left in place it would mark a perfectly good next image as broken until the
|
|
247
|
+
// browser answered for that one too.
|
|
248
|
+
this.statusState.set('loading');
|
|
137
249
|
if (typeof src === 'string') {
|
|
138
250
|
this.objectUrl.set(src);
|
|
139
251
|
}
|
|
@@ -175,11 +287,38 @@ class WrImageCropper {
|
|
|
175
287
|
w: img.naturalWidth || display.w,
|
|
176
288
|
h: img.naturalHeight || display.h,
|
|
177
289
|
};
|
|
290
|
+
this.statusState.set('loaded');
|
|
178
291
|
this.natural.set(natural);
|
|
179
292
|
this.display.set(display);
|
|
180
293
|
this.cropDisplay.set(this.initialCrop(display));
|
|
181
294
|
this.watchResize(img);
|
|
182
295
|
}
|
|
296
|
+
/**
|
|
297
|
+
* The `<img>` could not decode its source.
|
|
298
|
+
*
|
|
299
|
+
* Before this handler existed a failed decode left the component exactly as it
|
|
300
|
+
* looks while loading — an image with no size, no crop window, nothing emitted —
|
|
301
|
+
* so a host had no way to tell "still decoding" from "never going to", and a user
|
|
302
|
+
* who picked a `.heic` the browser cannot read saw an empty box and no reason.
|
|
303
|
+
*
|
|
304
|
+
* Once per source: `status` is reset by a new `src` and by nothing else, so a
|
|
305
|
+
* second `error` for the same source says nothing new and is not re-emitted.
|
|
306
|
+
*/
|
|
307
|
+
onImageError() {
|
|
308
|
+
const url = this.objectUrl();
|
|
309
|
+
if (!url || this.statusState() === 'failed')
|
|
310
|
+
return;
|
|
311
|
+
this.statusState.set('failed');
|
|
312
|
+
this.resetGeometry();
|
|
313
|
+
const src = this.src();
|
|
314
|
+
const isBlob = typeof src !== 'string' && src !== null;
|
|
315
|
+
this.loadError.emit({
|
|
316
|
+
url,
|
|
317
|
+
name: typeof File !== 'undefined' && src instanceof File ? src.name : null,
|
|
318
|
+
type: isBlob ? src.type : null,
|
|
319
|
+
size: isBlob ? src.size : null,
|
|
320
|
+
});
|
|
321
|
+
}
|
|
183
322
|
/**
|
|
184
323
|
* Keep `display` on the box the image is actually rendered at.
|
|
185
324
|
*
|
|
@@ -258,27 +397,98 @@ class WrImageCropper {
|
|
|
258
397
|
event.preventDefault();
|
|
259
398
|
event.stopPropagation();
|
|
260
399
|
this.active = handle;
|
|
400
|
+
this.pointerId = event.pointerId;
|
|
261
401
|
this.startPointer = { x: event.clientX, y: event.clientY };
|
|
262
402
|
this.startRect = { ...this.cropDisplay() };
|
|
263
|
-
event.currentTarget
|
|
403
|
+
this.captureTarget = event.currentTarget;
|
|
404
|
+
this.captureTarget.setPointerCapture(event.pointerId);
|
|
405
|
+
this.overhang = handle === 'move' ? NO_SIDES : this.pastCrop(event);
|
|
264
406
|
}
|
|
407
|
+
/**
|
|
408
|
+
* A resize ENDS when the pointer leaves the image, exactly as `pointerup` would end it:
|
|
409
|
+
* the move that carried it out is applied — clamped to the image like any other — and
|
|
410
|
+
* then the drag is over. Nothing after that resumes it or changes the crop, pointer
|
|
411
|
+
* back over the image or not, until a new `pointerdown`. A move drag is not affected:
|
|
412
|
+
* its size is fixed, so it simply rests against the edge.
|
|
413
|
+
*
|
|
414
|
+
* The move that leaves is applied rather than dropped because pointer moves are
|
|
415
|
+
* SAMPLES. Dropped, a quick drag toward a corner would stop wherever its last sample
|
|
416
|
+
* inside happened to fall — a single move from a handle to past the image would change
|
|
417
|
+
* nothing at all — and even a slow one would stop a fraction of a pixel short of the
|
|
418
|
+
* edge. A drag that leaves across one edge still ends there: heading for a corner at a
|
|
419
|
+
* slant that crosses the bottom first, the crop's right edge stays where it was then.
|
|
420
|
+
*
|
|
421
|
+
* "The image" is the `<img>`'s own box — the bounds `applyMove` / `applyResize` clamp
|
|
422
|
+
* the crop to — with its edges counting as inside, since the crop may reach them. The
|
|
423
|
+
* box is re-measured on every move and compared with `clientX` / `clientY`: both are
|
|
424
|
+
* viewport coordinates, so a scroll mid-drag cannot put them out of step. The moves
|
|
425
|
+
* outside reach this handler at all only because the drag holds pointer capture.
|
|
426
|
+
*
|
|
427
|
+
* One allowance, fixed at `pointerdown`: the box is widened on each side by however far
|
|
428
|
+
* past that side of the CROP the press landed — at most as far as the pressed handle
|
|
429
|
+
* reaches past it. Half of an edge handle, and three quarters of a corner handle, lies
|
|
430
|
+
* outside the crop, so a press there already has the pointer ahead of the edge it drags.
|
|
431
|
+
* Measured without the allowance, that pointer would leave the image while the crop was
|
|
432
|
+
* still short of the image's edge by the same distance, with nothing able to close the
|
|
433
|
+
* gap; and a handle resting on the image's edge, pressed on its outer part, would begin
|
|
434
|
+
* its drag already outside. A press on a handle's inner part widens nothing.
|
|
435
|
+
*/
|
|
265
436
|
onPointerMove(event) {
|
|
266
|
-
if (!this.active)
|
|
437
|
+
if (!this.active || event.pointerId !== this.pointerId)
|
|
267
438
|
return;
|
|
268
439
|
const dx = event.clientX - this.startPointer.x;
|
|
269
440
|
const dy = event.clientY - this.startPointer.y;
|
|
270
|
-
if (this.active === 'move')
|
|
441
|
+
if (this.active === 'move') {
|
|
271
442
|
this.applyMove(dx, dy);
|
|
272
|
-
|
|
273
|
-
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
this.applyResize(this.active, dx, dy);
|
|
446
|
+
if (!this.withinImage(event))
|
|
447
|
+
this.onPointerUp(event);
|
|
274
448
|
}
|
|
449
|
+
/**
|
|
450
|
+
* Ends the drag — for `pointerup`, `pointercancel`, a capture lost to something else,
|
|
451
|
+
* and a resize whose pointer left the image. Once per drag: the capture released here
|
|
452
|
+
* fires `lostpointercapture` afterwards, and by then there is nothing left to end.
|
|
453
|
+
*
|
|
454
|
+
* Only for the pointer that started it. A second finger is refused a drag of its own at
|
|
455
|
+
* `pointerdown`, but its moves and its lifting reach these handlers all the same, and
|
|
456
|
+
* would otherwise drive or end the first finger's drag.
|
|
457
|
+
*/
|
|
275
458
|
onPointerUp(event) {
|
|
276
|
-
if (!this.active)
|
|
459
|
+
if (!this.active || event.pointerId !== this.pointerId)
|
|
277
460
|
return;
|
|
278
|
-
|
|
461
|
+
const target = this.captureTarget;
|
|
279
462
|
this.active = null;
|
|
463
|
+
this.pointerId = null;
|
|
464
|
+
this.captureTarget = null;
|
|
465
|
+
this.overhang = NO_SIDES;
|
|
466
|
+
// Asked first: a capture that is already gone has nothing to release, and releasing
|
|
467
|
+
// one for a pointer that is no longer active throws.
|
|
468
|
+
if (target?.hasPointerCapture(event.pointerId))
|
|
469
|
+
target.releasePointerCapture(event.pointerId);
|
|
280
470
|
void this.emitCropped();
|
|
281
471
|
}
|
|
472
|
+
/** How far past each side of the crop window the pointer is, in CSS pixels. */
|
|
473
|
+
pastCrop(event) {
|
|
474
|
+
const box = this.imgEl().nativeElement.getBoundingClientRect();
|
|
475
|
+
const c = this.startRect;
|
|
476
|
+
return {
|
|
477
|
+
left: Math.max(0, box.left + c.x - event.clientX),
|
|
478
|
+
top: Math.max(0, box.top + c.y - event.clientY),
|
|
479
|
+
right: Math.max(0, event.clientX - (box.left + c.x + c.w)),
|
|
480
|
+
bottom: Math.max(0, event.clientY - (box.top + c.y + c.h)),
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
/** Whether the pointer is on the image, edges and the press's `overhang` included. */
|
|
484
|
+
withinImage(event) {
|
|
485
|
+
const box = this.imgEl().nativeElement.getBoundingClientRect();
|
|
486
|
+
const o = this.overhang;
|
|
487
|
+
return (event.clientX >= box.left - o.left &&
|
|
488
|
+
event.clientX <= box.right + o.right &&
|
|
489
|
+
event.clientY >= box.top - o.top &&
|
|
490
|
+
event.clientY <= box.bottom + o.bottom);
|
|
491
|
+
}
|
|
282
492
|
// Keyboard
|
|
283
493
|
/** Whether a key has moved the crop since the last `keyup`. */
|
|
284
494
|
keyboardDirty = false;
|
|
@@ -356,59 +566,8 @@ class WrImageCropper {
|
|
|
356
566
|
this.cropDisplay.set(next);
|
|
357
567
|
}
|
|
358
568
|
applyResize(handle, dx, dy) {
|
|
359
|
-
const display = this.display();
|
|
360
569
|
const min = { w: this.minWidth(), h: this.minHeight() };
|
|
361
|
-
|
|
362
|
-
const r = this.startRect;
|
|
363
|
-
let x = r.x;
|
|
364
|
-
let y = r.y;
|
|
365
|
-
let w = r.w;
|
|
366
|
-
let h = r.h;
|
|
367
|
-
if (handle.includes('e'))
|
|
368
|
-
w = clamp(r.w + dx, min.w, display.w - r.x);
|
|
369
|
-
if (handle.includes('w')) {
|
|
370
|
-
const nextW = clamp(r.w - dx, min.w, r.x + r.w);
|
|
371
|
-
x = r.x + (r.w - nextW);
|
|
372
|
-
w = nextW;
|
|
373
|
-
}
|
|
374
|
-
if (handle.includes('s'))
|
|
375
|
-
h = clamp(r.h + dy, min.h, display.h - r.y);
|
|
376
|
-
if (handle.includes('n')) {
|
|
377
|
-
const nextH = clamp(r.h - dy, min.h, r.y + r.h);
|
|
378
|
-
y = r.y + (r.h - nextH);
|
|
379
|
-
h = nextH;
|
|
380
|
-
}
|
|
381
|
-
if (ratio && ratio > 0) {
|
|
382
|
-
// Lock the orthogonal axis to maintain ratio. For corner handles,
|
|
383
|
-
// pick whichever delta drives the larger change so we don't bounce.
|
|
384
|
-
if (handle === 'e' || handle === 'w') {
|
|
385
|
-
const newH = w / ratio;
|
|
386
|
-
const dh = newH - r.h;
|
|
387
|
-
if (handle === 'e' || handle === 'w') {
|
|
388
|
-
y = r.y - dh / 2;
|
|
389
|
-
h = newH;
|
|
390
|
-
}
|
|
391
|
-
}
|
|
392
|
-
else if (handle === 'n' || handle === 's') {
|
|
393
|
-
const newW = h * ratio;
|
|
394
|
-
const dw = newW - r.w;
|
|
395
|
-
x = r.x - dw / 2;
|
|
396
|
-
w = newW;
|
|
397
|
-
}
|
|
398
|
-
else {
|
|
399
|
-
// Corner — drive by width, then derive height.
|
|
400
|
-
const newH = w / ratio;
|
|
401
|
-
if (handle.includes('n'))
|
|
402
|
-
y = r.y + r.h - newH;
|
|
403
|
-
h = newH;
|
|
404
|
-
}
|
|
405
|
-
// Re-clamp after ratio adjustment so we never spill out of the canvas.
|
|
406
|
-
x = clamp(x, 0, display.w - w);
|
|
407
|
-
y = clamp(y, 0, display.h - h);
|
|
408
|
-
w = clamp(w, min.w, display.w - x);
|
|
409
|
-
h = clamp(h, min.h, display.h - y);
|
|
410
|
-
}
|
|
411
|
-
this.cropDisplay.set({ x, y, w, h });
|
|
570
|
+
this.cropDisplay.set(resizeFrom(this.startRect, handle, dx, dy, this.display(), min, this.aspectRatio()));
|
|
412
571
|
}
|
|
413
572
|
// Public API
|
|
414
573
|
/** Render the current crop as a Blob. */
|
|
@@ -432,12 +591,30 @@ class WrImageCropper {
|
|
|
432
591
|
await this.emitCropped();
|
|
433
592
|
}
|
|
434
593
|
// Internals
|
|
594
|
+
/**
|
|
595
|
+
* The exported image's pixel size: the crop in source pixels, scaled down so its
|
|
596
|
+
* longer side fits `maxOutputSize`. `Math.min(1, …)` is the never-upscale rule — a
|
|
597
|
+
* crop already inside the cap keeps its own size, which is also the whole of the
|
|
598
|
+
* uncapped path.
|
|
599
|
+
*/
|
|
600
|
+
outputSize(c) {
|
|
601
|
+
const cap = this.maxOutputSize();
|
|
602
|
+
const longest = Math.max(c.width, c.height);
|
|
603
|
+
const scale = cap === null || longest === 0 ? 1 : Math.min(1, cap / longest);
|
|
604
|
+
return {
|
|
605
|
+
width: Math.max(1, Math.round(c.width * scale)),
|
|
606
|
+
height: Math.max(1, Math.round(c.height * scale)),
|
|
607
|
+
};
|
|
608
|
+
}
|
|
435
609
|
toCanvas() {
|
|
436
610
|
const img = this.imgEl().nativeElement;
|
|
437
611
|
const c = this.cropRect();
|
|
438
612
|
const canvas = document.createElement('canvas');
|
|
439
|
-
|
|
440
|
-
canvas.
|
|
613
|
+
const size = this.outputSize(c);
|
|
614
|
+
canvas.width = size.width;
|
|
615
|
+
canvas.height = size.height;
|
|
616
|
+
// The SOURCE rect stays the whole crop and the destination is the canvas, so a
|
|
617
|
+
// capped export is the same region drawn smaller rather than a smaller region.
|
|
441
618
|
const ctx = canvas.getContext('2d');
|
|
442
619
|
if (ctx)
|
|
443
620
|
ctx.drawImage(img, c.x, c.y, c.width, c.height, 0, 0, canvas.width, canvas.height);
|
|
@@ -453,12 +630,15 @@ class WrImageCropper {
|
|
|
453
630
|
}
|
|
454
631
|
}
|
|
455
632
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.6", ngImport: i0, type: WrImageCropper, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
456
|
-
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.6", type: WrImageCropper, isStandalone: true, selector: "wr-image-cropper", inputs: { src: { classPropertyName: "src", publicName: "src", isSignal: true, isRequired: false, transformFunction: null }, aspectRatio: { classPropertyName: "aspectRatio", publicName: "aspectRatio", isSignal: true, isRequired: false, transformFunction: null }, minWidth: { classPropertyName: "minWidth", publicName: "minWidth", isSignal: true, isRequired: false, transformFunction: null }, minHeight: { classPropertyName: "minHeight", publicName: "minHeight", isSignal: true, isRequired: false, transformFunction: null }, outputType: { classPropertyName: "outputType", publicName: "outputType", isSignal: true, isRequired: false, transformFunction: null }, outputQuality: { classPropertyName: "outputQuality", publicName: "outputQuality", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { cropped: "cropped" }, host: { classAttribute: "wr-image-cropper" }, viewQueries: [{ propertyName: "imgEl", first: true, predicate: ["img"], descendants: true, isSignal: true }], ngImport: i0, template: "@if (objectUrl(); as url) {\n <div class=\"wr-image-cropper__canvas\">\n <img
|
|
633
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.6", type: WrImageCropper, isStandalone: true, selector: "wr-image-cropper", inputs: { src: { classPropertyName: "src", publicName: "src", isSignal: true, isRequired: false, transformFunction: null }, aspectRatio: { classPropertyName: "aspectRatio", publicName: "aspectRatio", isSignal: true, isRequired: false, transformFunction: null }, minWidth: { classPropertyName: "minWidth", publicName: "minWidth", isSignal: true, isRequired: false, transformFunction: null }, minHeight: { classPropertyName: "minHeight", publicName: "minHeight", isSignal: true, isRequired: false, transformFunction: null }, outputType: { classPropertyName: "outputType", publicName: "outputType", isSignal: true, isRequired: false, transformFunction: null }, outputQuality: { classPropertyName: "outputQuality", publicName: "outputQuality", isSignal: true, isRequired: false, transformFunction: null }, maxOutputSize: { classPropertyName: "maxOutputSize", publicName: "maxOutputSize", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { cropped: "cropped", loadError: "loadError" }, host: { properties: { "class.wr-image-cropper--failed": "status() === 'failed'" }, classAttribute: "wr-image-cropper" }, viewQueries: [{ propertyName: "imgEl", first: true, predicate: ["img"], descendants: true, isSignal: true }], ngImport: i0, template: "@if (objectUrl(); as url) {\n <div class=\"wr-image-cropper__canvas\">\n <img\n #img\n class=\"wr-image-cropper__image\"\n [src]=\"url\"\n alt=\"\"\n draggable=\"false\"\n (load)=\"onImageLoad()\"\n (error)=\"onImageError()\"\n />\n\n @if (display().w > 0) {\n <!-- Semi-transparent backdrop with a cut-out for the crop window. -->\n <div\n class=\"wr-image-cropper__backdrop\"\n [style.clip-path]=\"`polygon(0 0, 100% 0, 100% 100%, 0 100%, 0 ${cropDisplay().y}px, ${cropDisplay().x}px ${cropDisplay().y}px, ${cropDisplay().x}px ${cropDisplay().y + cropDisplay().h}px, ${cropDisplay().x + cropDisplay().w}px ${cropDisplay().y + cropDisplay().h}px, ${cropDisplay().x + cropDisplay().w}px ${cropDisplay().y}px, 0 ${cropDisplay().y}px)`\"\n ></div>\n\n <!--\n Crop window \u2014 drag to move, with eight resize handles, and the ONE\n keyboard control the component has (see `onWindowKeydown`). The eight\n handles stay pointer-only and `aria-hidden`: eight extra tab stops per\n cropper would be hostile. The keys resize from the east and south edges\n only, so a west or north drag comes out as a resize plus a move rather\n than as one gesture.\n\n `role=\"group\"` rather than no role at all, because `aria-label` is\n prohibited on a roleless `<div>` and this needs a name; and rather than\n `role=\"application\"`, which would switch the reader out of its own\n browse mode for the whole subtree \u2014 far more than one rectangle is worth.\n -->\n <div\n class=\"wr-image-cropper__window\"\n role=\"group\"\n tabindex=\"0\"\n [attr.aria-label]=\"resolvedWindowLabel()\"\n [attr.aria-describedby]=\"helpId\"\n [style.left.px]=\"cropDisplay().x\"\n [style.top.px]=\"cropDisplay().y\"\n [style.width.px]=\"cropDisplay().w\"\n [style.height.px]=\"cropDisplay().h\"\n (pointerdown)=\"onPointerDown('move', $event)\"\n (pointermove)=\"onPointerMove($event)\"\n (pointerup)=\"onPointerUp($event)\"\n (pointercancel)=\"onPointerUp($event)\"\n (lostpointercapture)=\"onPointerUp($event)\"\n (keydown)=\"onWindowKeydown($event)\"\n (keyup)=\"onWindowKeyup()\"\n >\n @for (h of handles; track h) {\n <span\n aria-hidden=\"true\"\n [class]=\"`wr-image-cropper__handle wr-image-cropper__handle--${h}`\"\n (pointerdown)=\"onPointerDown(h, $event)\"\n (pointermove)=\"onPointerMove($event)\"\n (pointerup)=\"onPointerUp($event)\"\n (pointercancel)=\"onPointerUp($event)\"\n (lostpointercapture)=\"onPointerUp($event)\"\n ></span>\n }\n </div>\n\n <p class=\"wr-image-cropper__sr-only\" [id]=\"helpId\">{{ resolvedKeyHelp() }}</p>\n\n <!--\n The crop rect in SOURCE pixels, announced after a keyboard change only \u2014\n a pointer drag already gives sighted feedback and would flood the region,\n and the initial crop is not news. Deliberately bare numbers: a sentence\n here would be a translated string the catalog does not carry, and the\n reader has just been told what the four numbers mean by `helpId`.\n -->\n <p class=\"wr-image-cropper__sr-only\" role=\"status\">{{ announcement() }}</p>\n }\n </div>\n} @else {\n <div class=\"wr-image-cropper__empty\">{{ emptyText() }}</div>\n}\n", encapsulation: i0.ViewEncapsulation.None });
|
|
457
634
|
}
|
|
458
635
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.6", ngImport: i0, type: WrImageCropper, decorators: [{
|
|
459
636
|
type: Component,
|
|
460
|
-
args: [{ selector: 'wr-image-cropper', encapsulation: ViewEncapsulation.None, host: {
|
|
461
|
-
|
|
637
|
+
args: [{ selector: 'wr-image-cropper', encapsulation: ViewEncapsulation.None, host: {
|
|
638
|
+
class: 'wr-image-cropper',
|
|
639
|
+
'[class.wr-image-cropper--failed]': "status() === 'failed'",
|
|
640
|
+
}, template: "@if (objectUrl(); as url) {\n <div class=\"wr-image-cropper__canvas\">\n <img\n #img\n class=\"wr-image-cropper__image\"\n [src]=\"url\"\n alt=\"\"\n draggable=\"false\"\n (load)=\"onImageLoad()\"\n (error)=\"onImageError()\"\n />\n\n @if (display().w > 0) {\n <!-- Semi-transparent backdrop with a cut-out for the crop window. -->\n <div\n class=\"wr-image-cropper__backdrop\"\n [style.clip-path]=\"`polygon(0 0, 100% 0, 100% 100%, 0 100%, 0 ${cropDisplay().y}px, ${cropDisplay().x}px ${cropDisplay().y}px, ${cropDisplay().x}px ${cropDisplay().y + cropDisplay().h}px, ${cropDisplay().x + cropDisplay().w}px ${cropDisplay().y + cropDisplay().h}px, ${cropDisplay().x + cropDisplay().w}px ${cropDisplay().y}px, 0 ${cropDisplay().y}px)`\"\n ></div>\n\n <!--\n Crop window \u2014 drag to move, with eight resize handles, and the ONE\n keyboard control the component has (see `onWindowKeydown`). The eight\n handles stay pointer-only and `aria-hidden`: eight extra tab stops per\n cropper would be hostile. The keys resize from the east and south edges\n only, so a west or north drag comes out as a resize plus a move rather\n than as one gesture.\n\n `role=\"group\"` rather than no role at all, because `aria-label` is\n prohibited on a roleless `<div>` and this needs a name; and rather than\n `role=\"application\"`, which would switch the reader out of its own\n browse mode for the whole subtree \u2014 far more than one rectangle is worth.\n -->\n <div\n class=\"wr-image-cropper__window\"\n role=\"group\"\n tabindex=\"0\"\n [attr.aria-label]=\"resolvedWindowLabel()\"\n [attr.aria-describedby]=\"helpId\"\n [style.left.px]=\"cropDisplay().x\"\n [style.top.px]=\"cropDisplay().y\"\n [style.width.px]=\"cropDisplay().w\"\n [style.height.px]=\"cropDisplay().h\"\n (pointerdown)=\"onPointerDown('move', $event)\"\n (pointermove)=\"onPointerMove($event)\"\n (pointerup)=\"onPointerUp($event)\"\n (pointercancel)=\"onPointerUp($event)\"\n (lostpointercapture)=\"onPointerUp($event)\"\n (keydown)=\"onWindowKeydown($event)\"\n (keyup)=\"onWindowKeyup()\"\n >\n @for (h of handles; track h) {\n <span\n aria-hidden=\"true\"\n [class]=\"`wr-image-cropper__handle wr-image-cropper__handle--${h}`\"\n (pointerdown)=\"onPointerDown(h, $event)\"\n (pointermove)=\"onPointerMove($event)\"\n (pointerup)=\"onPointerUp($event)\"\n (pointercancel)=\"onPointerUp($event)\"\n (lostpointercapture)=\"onPointerUp($event)\"\n ></span>\n }\n </div>\n\n <p class=\"wr-image-cropper__sr-only\" [id]=\"helpId\">{{ resolvedKeyHelp() }}</p>\n\n <!--\n The crop rect in SOURCE pixels, announced after a keyboard change only \u2014\n a pointer drag already gives sighted feedback and would flood the region,\n and the initial crop is not news. Deliberately bare numbers: a sentence\n here would be a translated string the catalog does not carry, and the\n reader has just been told what the four numbers mean by `helpId`.\n -->\n <p class=\"wr-image-cropper__sr-only\" role=\"status\">{{ announcement() }}</p>\n }\n </div>\n} @else {\n <div class=\"wr-image-cropper__empty\">{{ emptyText() }}</div>\n}\n" }]
|
|
641
|
+
}], ctorParameters: () => [], propDecorators: { src: [{ type: i0.Input, args: [{ isSignal: true, alias: "src", required: false }] }], aspectRatio: [{ type: i0.Input, args: [{ isSignal: true, alias: "aspectRatio", required: false }] }], minWidth: [{ type: i0.Input, args: [{ isSignal: true, alias: "minWidth", required: false }] }], minHeight: [{ type: i0.Input, args: [{ isSignal: true, alias: "minHeight", required: false }] }], outputType: [{ type: i0.Input, args: [{ isSignal: true, alias: "outputType", required: false }] }], outputQuality: [{ type: i0.Input, args: [{ isSignal: true, alias: "outputQuality", required: false }] }], maxOutputSize: [{ type: i0.Input, args: [{ isSignal: true, alias: "maxOutputSize", required: false }] }], cropped: [{ type: i0.Output, args: ["cropped"] }], loadError: [{ type: i0.Output, args: ["loadError"] }], imgEl: [{ type: i0.ViewChild, args: ['img', { isSignal: true }] }] } });
|
|
462
642
|
|
|
463
643
|
/**
|
|
464
644
|
* Generated bundle index. Do not edit.
|