ngwr 14.5.1 → 14.6.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/avatar/styles/_index.scss +46 -0
- package/fesm2022/ngwr-avatar.mjs +58 -7
- package/fesm2022/ngwr-avatar.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 +98 -5
- package/fesm2022/ngwr-image-cropper.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 +233 -35
- 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/llms-full.txt +4 -4
- package/package.json +1 -1
- package/schematics/use/symbol-map.json +1 -0
- package/select/styles/_index.scss +34 -0
- package/skills/ngwr/references/catalog.md +1 -1
- package/types/ngwr-avatar.d.ts +36 -3
- package/types/ngwr-image-cropper-testing.d.ts +25 -3
- package/types/ngwr-image-cropper.d.ts +88 -2
- package/types/ngwr-select-testing.d.ts +18 -1
- package/types/ngwr-select.d.ts +181 -10
- package/types/ngwr-version.d.ts +1 -1
|
@@ -36,7 +36,44 @@
|
|
|
36
36
|
--wr-avatar-ring: transparent;
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
// The projected fallback. `display: contents` keeps whatever is projected a
|
|
40
|
+
// flex item of the host, laid out by the host's centring (and by any flex
|
|
41
|
+
// setting a consumer put on the host) exactly as before this wrapper existed.
|
|
42
|
+
&__content {
|
|
43
|
+
display: contents;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Once loaded, how the fallback leaves the paint follows whether the image is
|
|
47
|
+
// NAMED, which the template states as `aria-hidden` on the wrapper. Named, the
|
|
48
|
+
// initials are already out of the accessibility tree, so `visibility` (which
|
|
49
|
+
// bare text nodes inherit) takes them out of the paint as well.
|
|
50
|
+
&--loaded &__content[aria-hidden='true'] {
|
|
51
|
+
visibility: hidden;
|
|
52
|
+
// Held until the image's own fade has finished, so the photo crossfades in
|
|
53
|
+
// over the initials instead of over an empty box.
|
|
54
|
+
transition: visibility 0s linear var(--wr-duration-base);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Unnamed, the image is only "Avatar" and the initials are what identifies the
|
|
58
|
+
// person, so they are clipped rather than hidden and stay announced. A clip
|
|
59
|
+
// needs a box, hence the switch from `contents`; the host has a fixed size,
|
|
60
|
+
// so taking them out of flow moves nothing around it.
|
|
61
|
+
&--loaded &__content:not([aria-hidden='true']) {
|
|
62
|
+
display: block;
|
|
63
|
+
position: absolute;
|
|
64
|
+
width: 1px;
|
|
65
|
+
height: 1px;
|
|
66
|
+
overflow: hidden;
|
|
67
|
+
clip: rect(0 0 0 0);
|
|
68
|
+
clip-path: inset(50%);
|
|
69
|
+
white-space: nowrap;
|
|
70
|
+
}
|
|
71
|
+
|
|
39
72
|
&__img {
|
|
73
|
+
// Out of flow, so it covers the fallback rather than sharing the box with it
|
|
74
|
+
// — in flow, the initials were squeezed beside the loaded photo.
|
|
75
|
+
position: absolute;
|
|
76
|
+
inset: 0;
|
|
40
77
|
display: block;
|
|
41
78
|
width: 100%;
|
|
42
79
|
height: 100%;
|
|
@@ -54,4 +91,13 @@
|
|
|
54
91
|
color: var(--wr-color-on-surface-muted);
|
|
55
92
|
--wr-spinner-size: 40%;
|
|
56
93
|
}
|
|
94
|
+
|
|
95
|
+
// With a fallback projected, the spinner is dropped: both are centred, so at
|
|
96
|
+
// 40% of a 32px avatar its arc is drawn straight through the letters and
|
|
97
|
+
// neither reads. The initials are placeholder enough while the photo loads.
|
|
98
|
+
// `:empty` ignores comment nodes, so a projected `@if` that renders nothing
|
|
99
|
+
// still gets the spinner.
|
|
100
|
+
&__content:not(:empty) ~ &__spin {
|
|
101
|
+
display: none;
|
|
102
|
+
}
|
|
57
103
|
}
|
package/fesm2022/ngwr-avatar.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as i0 from '@angular/core';
|
|
2
|
-
import { input, signal, effect, untracked, computed, ViewEncapsulation, Component } from '@angular/core';
|
|
2
|
+
import { input, signal, viewChild, effect, untracked, afterNextRender, computed, ViewEncapsulation, Component } from '@angular/core';
|
|
3
3
|
import { useI18nText } from 'ngwr/i18n';
|
|
4
4
|
import { WrSpinner } from 'ngwr/spinner';
|
|
5
5
|
import { resolveCssSize } from 'ngwr/utils';
|
|
@@ -14,19 +14,25 @@ const DEFAULT_SIZE = '6rem';
|
|
|
14
14
|
/**
|
|
15
15
|
* Image avatar with configurable corner treatment.
|
|
16
16
|
*
|
|
17
|
-
*
|
|
17
|
+
* Projected content (typically initials) is the FALLBACK: it shows with no
|
|
18
|
+
* `url`, while the image loads and after it fails, and leaves the paint once the
|
|
19
|
+
* image has loaded. It is projected into a `.wr-avatar__content` wrapper, so a
|
|
20
|
+
* `wr-avatar > …` child selector no longer reaches it. While it is there, no
|
|
21
|
+
* spinner is drawn over it; with nothing projected, the spinner marks the
|
|
22
|
+
* loading state instead. See `alt` for what a screen reader hears.
|
|
18
23
|
*
|
|
19
24
|
* @example
|
|
20
25
|
* ```html
|
|
21
26
|
* <wr-avatar url="/me.png" alt="Roman" size="3rem" shape="circle" />
|
|
22
|
-
* <wr-avatar url="
|
|
27
|
+
* <wr-avatar [url]="photo" alt="Roman Khegay" [size]="48">RK</wr-avatar>
|
|
23
28
|
* ```
|
|
24
29
|
*
|
|
25
30
|
* @see https://ngwr.dev/reference/components/avatar
|
|
26
31
|
*/
|
|
27
32
|
class WrAvatar {
|
|
28
33
|
/**
|
|
29
|
-
* Image URL.
|
|
34
|
+
* Image URL. Projected content (e.g. initials) shows while it is unset,
|
|
35
|
+
* loading or broken, and gives way once the image has loaded.
|
|
30
36
|
*
|
|
31
37
|
* @default null
|
|
32
38
|
*/
|
|
@@ -36,6 +42,11 @@ class WrAvatar {
|
|
|
36
42
|
* Alt text for the image. Falls back to `avatar.alt`, then `'Avatar'` — pass
|
|
37
43
|
* the person's name where you have it, since that is what a reader wants.
|
|
38
44
|
*
|
|
45
|
+
* With an `alt`, projected initials are hidden from assistive technology while
|
|
46
|
+
* an image is in play, so the name is announced once. Without one, the image
|
|
47
|
+
* carries only the generic name, so the initials stay announced beside it —
|
|
48
|
+
* visually hidden once it has loaded, never dropped.
|
|
49
|
+
*
|
|
39
50
|
* @default null
|
|
40
51
|
*/
|
|
41
52
|
alt = input(null, /* @ts-ignore */
|
|
@@ -65,6 +76,8 @@ class WrAvatar {
|
|
|
65
76
|
*/
|
|
66
77
|
failed = signal(false, /* @ts-ignore */
|
|
67
78
|
...(ngDevMode ? [{ debugName: "failed" }] : /* istanbul ignore next */ []));
|
|
79
|
+
img = viewChild('img', /* @ts-ignore */
|
|
80
|
+
...(ngDevMode ? [{ debugName: "img" }] : /* istanbul ignore next */ []));
|
|
68
81
|
constructor() {
|
|
69
82
|
// A new URL is a new attempt: after a failure the fallback would otherwise
|
|
70
83
|
// stay put for an image that loads perfectly well.
|
|
@@ -75,6 +88,31 @@ class WrAvatar {
|
|
|
75
88
|
this.failed.set(false);
|
|
76
89
|
});
|
|
77
90
|
});
|
|
91
|
+
afterNextRender(() => this.adoptSettledImage());
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* A prerendered `<img>` starts loading when the browser parses the HTML, and
|
|
95
|
+
* `(load)` / `(error)` are only attached at hydration — so on a slow bundle the
|
|
96
|
+
* image settles first, both events go nowhere, and the avatar sits on its
|
|
97
|
+
* fallback over a photo it already has (or names a broken image it never
|
|
98
|
+
* learns about). Only the FIRST render can race: every later `<img>` is
|
|
99
|
+
* created on the client with its listeners in place before `src` is set.
|
|
100
|
+
*/
|
|
101
|
+
adoptSettledImage() {
|
|
102
|
+
const img = this.img()?.nativeElement;
|
|
103
|
+
if (!img?.complete || this.loaded() || this.failed())
|
|
104
|
+
return;
|
|
105
|
+
if (img.naturalWidth > 0) {
|
|
106
|
+
this.loaded.set(true);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
// Zero wide is a broken image, or an SVG with no intrinsic size in an engine
|
|
110
|
+
// that reports it as 0 — only the element can tell. Setting `src` to the
|
|
111
|
+
// value it already has makes it fire `load` or `error` again, to listeners
|
|
112
|
+
// that exist now.
|
|
113
|
+
const src = img.getAttribute('src');
|
|
114
|
+
if (src !== null)
|
|
115
|
+
img.setAttribute('src', src);
|
|
78
116
|
}
|
|
79
117
|
resolved = computed(() => {
|
|
80
118
|
const result = resolveCssSize(this.size(), { defaultValue: DEFAULT_SIZE });
|
|
@@ -84,6 +122,19 @@ class WrAvatar {
|
|
|
84
122
|
return result;
|
|
85
123
|
}, /* @ts-ignore */
|
|
86
124
|
...(ngDevMode ? [{ debugName: "resolved" }] : /* istanbul ignore next */ []));
|
|
125
|
+
/**
|
|
126
|
+
* Whether the projected fallback is `aria-hidden`: an `<img>` is in the DOM
|
|
127
|
+
* (loading or loaded) AND the consumer named it. Then the image's `alt` owns
|
|
128
|
+
* the name, announced once and unchanged at the moment the photo arrives.
|
|
129
|
+
* Without a real `alt` the image is only "Avatar", and hiding the initials
|
|
130
|
+
* would drop the one thing that says who this is.
|
|
131
|
+
*
|
|
132
|
+
* The stylesheet keys on the same attribute once `--loaded`: a hidden fallback
|
|
133
|
+
* leaves the paint through `visibility`, a named-by-initials one is clipped and
|
|
134
|
+
* stays in the accessibility tree. While loading it is painted either way.
|
|
135
|
+
*/
|
|
136
|
+
fallbackHidden = computed(() => !!this.url() && !this.failed() && !!this.alt()?.trim(), /* @ts-ignore */
|
|
137
|
+
...(ngDevMode ? [{ debugName: "fallbackHidden" }] : /* istanbul ignore next */ []));
|
|
87
138
|
cssSize = computed(() => this.resolved().cssValue, /* @ts-ignore */
|
|
88
139
|
...(ngDevMode ? [{ debugName: "cssSize" }] : /* istanbul ignore next */ []));
|
|
89
140
|
pxSize = computed(() => this.resolved().pxValue, /* @ts-ignore */
|
|
@@ -105,7 +156,7 @@ class WrAvatar {
|
|
|
105
156
|
this.failed.set(true);
|
|
106
157
|
}
|
|
107
158
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.6", ngImport: i0, type: WrAvatar, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
108
|
-
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.6", type: WrAvatar, isStandalone: true, selector: "wr-avatar", inputs: { url: { classPropertyName: "url", publicName: "url", isSignal: true, isRequired: false, transformFunction: null }, alt: { classPropertyName: "alt", publicName: "alt", isSignal: true, isRequired: false, transformFunction: null }, shape: { classPropertyName: "shape", publicName: "shape", isSignal: true, isRequired: false, transformFunction: null }, size: { classPropertyName: "size", publicName: "size", isSignal: true, isRequired: false, transformFunction: null } }, host: { properties: { "class": "classes()", "style.width": "cssSize()", "style.height": "cssSize()" } }, ngImport: i0, template: "<ng-content
|
|
159
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.6", type: WrAvatar, isStandalone: true, selector: "wr-avatar", inputs: { url: { classPropertyName: "url", publicName: "url", isSignal: true, isRequired: false, transformFunction: null }, alt: { classPropertyName: "alt", publicName: "alt", isSignal: true, isRequired: false, transformFunction: null }, shape: { classPropertyName: "shape", publicName: "shape", isSignal: true, isRequired: false, transformFunction: null }, size: { classPropertyName: "size", publicName: "size", isSignal: true, isRequired: false, transformFunction: null } }, host: { properties: { "class": "classes()", "style.width": "cssSize()", "style.height": "cssSize()" } }, viewQueries: [{ propertyName: "img", first: true, predicate: ["img"], descendants: true, isSignal: true }], ngImport: i0, template: "<span class=\"wr-avatar__content\" [attr.aria-hidden]=\"fallbackHidden() ? 'true' : null\"><ng-content /></span>\n\n@if (url(); as src) {\n @if (!failed()) {\n <img\n #img\n class=\"wr-avatar__img\"\n [src]=\"src\"\n [alt]=\"resolvedAlt()\"\n [attr.width]=\"pxSize()\"\n [attr.height]=\"pxSize()\"\n (load)=\"onImageLoad()\"\n (error)=\"onImageError()\"\n decoding=\"async\"\n />\n\n @if (!loaded()) {\n <wr-spinner class=\"wr-avatar__spin\" />\n }\n }\n}\n", dependencies: [{ kind: "component", type: WrSpinner, selector: "wr-spinner", inputs: ["size", "ariaLabel"] }], encapsulation: i0.ViewEncapsulation.None });
|
|
109
160
|
}
|
|
110
161
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.6", ngImport: i0, type: WrAvatar, decorators: [{
|
|
111
162
|
type: Component,
|
|
@@ -113,8 +164,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.6", ngImpor
|
|
|
113
164
|
'[class]': 'classes()',
|
|
114
165
|
'[style.width]': 'cssSize()',
|
|
115
166
|
'[style.height]': 'cssSize()',
|
|
116
|
-
}, imports: [WrSpinner], template: "<ng-content
|
|
117
|
-
}], ctorParameters: () => [], propDecorators: { url: [{ type: i0.Input, args: [{ isSignal: true, alias: "url", required: false }] }], alt: [{ type: i0.Input, args: [{ isSignal: true, alias: "alt", required: false }] }], shape: [{ type: i0.Input, args: [{ isSignal: true, alias: "shape", required: false }] }], size: [{ type: i0.Input, args: [{ isSignal: true, alias: "size", required: false }] }] } });
|
|
167
|
+
}, imports: [WrSpinner], template: "<span class=\"wr-avatar__content\" [attr.aria-hidden]=\"fallbackHidden() ? 'true' : null\"><ng-content /></span>\n\n@if (url(); as src) {\n @if (!failed()) {\n <img\n #img\n class=\"wr-avatar__img\"\n [src]=\"src\"\n [alt]=\"resolvedAlt()\"\n [attr.width]=\"pxSize()\"\n [attr.height]=\"pxSize()\"\n (load)=\"onImageLoad()\"\n (error)=\"onImageError()\"\n decoding=\"async\"\n />\n\n @if (!loaded()) {\n <wr-spinner class=\"wr-avatar__spin\" />\n }\n }\n}\n" }]
|
|
168
|
+
}], ctorParameters: () => [], propDecorators: { url: [{ type: i0.Input, args: [{ isSignal: true, alias: "url", required: false }] }], alt: [{ type: i0.Input, args: [{ isSignal: true, alias: "alt", required: false }] }], shape: [{ type: i0.Input, args: [{ isSignal: true, alias: "shape", required: false }] }], size: [{ type: i0.Input, args: [{ isSignal: true, alias: "size", required: false }] }], img: [{ type: i0.ViewChild, args: ['img', { isSignal: true }] }] } });
|
|
118
169
|
|
|
119
170
|
/**
|
|
120
171
|
* Generated bundle index. Do not edit.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ngwr-avatar.mjs","sources":["../../../projects/lib/avatar/avatar.ts","../../../projects/lib/avatar/avatar.html","../../../projects/lib/avatar/ngwr-avatar.ts"],"sourcesContent":["/**\n * @license\n *\n * Use of this source code is governed by an MIT-style license that can be\n * found in the LICENSE file at https://github.com/thekhegay/ngwr/blob/main/LICENSE\n */\n\nimport { Component, ViewEncapsulation, computed, effect, input, signal, untracked } from '@angular/core';\n\nimport { useI18nText } from 'ngwr/i18n';\nimport { WrSpinner } from 'ngwr/spinner';\nimport { resolveCssSize, type ResolvedCssSize } from 'ngwr/utils';\n\nimport type { WrAvatarShape, WrAvatarSize } from './interfaces';\n\nconst DEFAULT_SIZE: WrAvatarSize = '6rem';\n\n/**\n * Image avatar with configurable corner treatment.\n *\n * A spinner is shown while the image loads, then crossfades in.\n *\n * @example\n * ```html\n * <wr-avatar url=\"/me.png\" alt=\"Roman\" size=\"3rem\" shape=\"circle\" />\n * <wr-avatar url=\"/me.png\" alt=\"Roman\" [size]=\"48\" />\n * ```\n *\n * @see https://ngwr.dev/reference/components/avatar\n */\n@Component({\n selector: 'wr-avatar',\n templateUrl: './avatar.html',\n encapsulation: ViewEncapsulation.None,\n host: {\n '[class]': 'classes()',\n '[style.width]': 'cssSize()',\n '[style.height]': 'cssSize()',\n },\n imports: [WrSpinner],\n})\nexport class WrAvatar {\n /**\n * Image URL. When unset, only projected content (e.g. initials) renders.\n *\n * @default null\n */\n readonly url = input<string | null>(null);\n\n /**\n * Alt text for the image. Falls back to `avatar.alt`, then `'Avatar'` — pass\n * the person's name where you have it, since that is what a reader wants.\n *\n * @default null\n */\n readonly alt = input<string | null>(null);\n\n protected readonly resolvedAlt = useI18nText(this.alt, 'avatar.alt', 'Avatar');\n\n /**\n * Corner treatment. `rounded` (default) is a soft rounded square,\n * `circle` is the classic profile avatar, `squircle` is the iOS look.\n *\n * @default 'rounded'\n */\n readonly shape = input<WrAvatarShape>('rounded');\n\n /**\n * Box size. See {@link WrAvatarSize} for accepted values.\n *\n * @default '6rem'\n */\n readonly size = input<WrAvatarSize>(DEFAULT_SIZE);\n\n protected readonly loaded = signal(false);\n\n /**\n * Whether the image failed. Without this the spinner spun forever on a broken\n * URL — the one state where an avatar is guaranteed to have projected initials\n * worth showing instead.\n */\n protected readonly failed = signal(false);\n\n constructor() {\n // A new URL is a new attempt: after a failure the fallback would otherwise\n // stay put for an image that loads perfectly well.\n effect(() => {\n this.url();\n untracked(() => {\n this.loaded.set(false);\n this.failed.set(false);\n });\n });\n }\n\n protected readonly resolved = computed<ResolvedCssSize>(() => {\n const result = resolveCssSize(this.size(), { defaultValue: DEFAULT_SIZE });\n if (result.pxValue == null || result.pxValue <= 0) {\n return resolveCssSize(DEFAULT_SIZE, { defaultValue: DEFAULT_SIZE });\n }\n return result;\n });\n\n protected readonly cssSize = computed(() => this.resolved().cssValue);\n protected readonly pxSize = computed(() => this.resolved().pxValue);\n\n protected readonly classes = computed(() => {\n const parts = ['wr-avatar'];\n const shape = this.shape();\n if (shape !== 'rounded') parts.push(`wr-avatar--${shape}`);\n if (this.loaded()) parts.push('wr-avatar--loaded');\n return parts.join(' ');\n });\n\n protected onImageLoad(): void {\n this.loaded.set(true);\n }\n\n protected onImageError(): void {\n this.failed.set(true);\n }\n}\n","<ng-content />\n\n@if (url(); as src) {\n @if (!failed()) {\n <img\n class=\"wr-avatar__img\"\n [src]=\"src\"\n [alt]=\"resolvedAlt()\"\n [attr.width]=\"pxSize()\"\n [attr.height]=\"pxSize()\"\n (load)=\"onImageLoad()\"\n (error)=\"onImageError()\"\n decoding=\"async\"\n />\n\n @if (!loaded()) {\n <wr-spinner class=\"wr-avatar__spin\" />\n }\n }\n}\n","/**\n * Generated bundle index. Do not edit.\n */\n\nexport * from './public-api';\n"],"names":[],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"ngwr-avatar.mjs","sources":["../../../projects/lib/avatar/avatar.ts","../../../projects/lib/avatar/avatar.html","../../../projects/lib/avatar/ngwr-avatar.ts"],"sourcesContent":["/**\n * @license\n *\n * Use of this source code is governed by an MIT-style license that can be\n * found in the LICENSE file at https://github.com/thekhegay/ngwr/blob/main/LICENSE\n */\n\nimport {\n Component,\n type ElementRef,\n ViewEncapsulation,\n afterNextRender,\n computed,\n effect,\n input,\n signal,\n untracked,\n viewChild,\n} from '@angular/core';\n\nimport { useI18nText } from 'ngwr/i18n';\nimport { WrSpinner } from 'ngwr/spinner';\nimport { resolveCssSize, type ResolvedCssSize } from 'ngwr/utils';\n\nimport type { WrAvatarShape, WrAvatarSize } from './interfaces';\n\nconst DEFAULT_SIZE: WrAvatarSize = '6rem';\n\n/**\n * Image avatar with configurable corner treatment.\n *\n * Projected content (typically initials) is the FALLBACK: it shows with no\n * `url`, while the image loads and after it fails, and leaves the paint once the\n * image has loaded. It is projected into a `.wr-avatar__content` wrapper, so a\n * `wr-avatar > …` child selector no longer reaches it. While it is there, no\n * spinner is drawn over it; with nothing projected, the spinner marks the\n * loading state instead. See `alt` for what a screen reader hears.\n *\n * @example\n * ```html\n * <wr-avatar url=\"/me.png\" alt=\"Roman\" size=\"3rem\" shape=\"circle\" />\n * <wr-avatar [url]=\"photo\" alt=\"Roman Khegay\" [size]=\"48\">RK</wr-avatar>\n * ```\n *\n * @see https://ngwr.dev/reference/components/avatar\n */\n@Component({\n selector: 'wr-avatar',\n templateUrl: './avatar.html',\n encapsulation: ViewEncapsulation.None,\n host: {\n '[class]': 'classes()',\n '[style.width]': 'cssSize()',\n '[style.height]': 'cssSize()',\n },\n imports: [WrSpinner],\n})\nexport class WrAvatar {\n /**\n * Image URL. Projected content (e.g. initials) shows while it is unset,\n * loading or broken, and gives way once the image has loaded.\n *\n * @default null\n */\n readonly url = input<string | null>(null);\n\n /**\n * Alt text for the image. Falls back to `avatar.alt`, then `'Avatar'` — pass\n * the person's name where you have it, since that is what a reader wants.\n *\n * With an `alt`, projected initials are hidden from assistive technology while\n * an image is in play, so the name is announced once. Without one, the image\n * carries only the generic name, so the initials stay announced beside it —\n * visually hidden once it has loaded, never dropped.\n *\n * @default null\n */\n readonly alt = input<string | null>(null);\n\n protected readonly resolvedAlt = useI18nText(this.alt, 'avatar.alt', 'Avatar');\n\n /**\n * Corner treatment. `rounded` (default) is a soft rounded square,\n * `circle` is the classic profile avatar, `squircle` is the iOS look.\n *\n * @default 'rounded'\n */\n readonly shape = input<WrAvatarShape>('rounded');\n\n /**\n * Box size. See {@link WrAvatarSize} for accepted values.\n *\n * @default '6rem'\n */\n readonly size = input<WrAvatarSize>(DEFAULT_SIZE);\n\n protected readonly loaded = signal(false);\n\n /**\n * Whether the image failed. Without this the spinner spun forever on a broken\n * URL — the one state where an avatar is guaranteed to have projected initials\n * worth showing instead.\n */\n protected readonly failed = signal(false);\n\n private readonly img = viewChild<ElementRef<HTMLImageElement>>('img');\n\n constructor() {\n // A new URL is a new attempt: after a failure the fallback would otherwise\n // stay put for an image that loads perfectly well.\n effect(() => {\n this.url();\n untracked(() => {\n this.loaded.set(false);\n this.failed.set(false);\n });\n });\n\n afterNextRender(() => this.adoptSettledImage());\n }\n\n /**\n * A prerendered `<img>` starts loading when the browser parses the HTML, and\n * `(load)` / `(error)` are only attached at hydration — so on a slow bundle the\n * image settles first, both events go nowhere, and the avatar sits on its\n * fallback over a photo it already has (or names a broken image it never\n * learns about). Only the FIRST render can race: every later `<img>` is\n * created on the client with its listeners in place before `src` is set.\n */\n private adoptSettledImage(): void {\n const img = this.img()?.nativeElement;\n if (!img?.complete || this.loaded() || this.failed()) return;\n\n if (img.naturalWidth > 0) {\n this.loaded.set(true);\n return;\n }\n\n // Zero wide is a broken image, or an SVG with no intrinsic size in an engine\n // that reports it as 0 — only the element can tell. Setting `src` to the\n // value it already has makes it fire `load` or `error` again, to listeners\n // that exist now.\n const src = img.getAttribute('src');\n if (src !== null) img.setAttribute('src', src);\n }\n\n protected readonly resolved = computed<ResolvedCssSize>(() => {\n const result = resolveCssSize(this.size(), { defaultValue: DEFAULT_SIZE });\n if (result.pxValue == null || result.pxValue <= 0) {\n return resolveCssSize(DEFAULT_SIZE, { defaultValue: DEFAULT_SIZE });\n }\n return result;\n });\n\n /**\n * Whether the projected fallback is `aria-hidden`: an `<img>` is in the DOM\n * (loading or loaded) AND the consumer named it. Then the image's `alt` owns\n * the name, announced once and unchanged at the moment the photo arrives.\n * Without a real `alt` the image is only \"Avatar\", and hiding the initials\n * would drop the one thing that says who this is.\n *\n * The stylesheet keys on the same attribute once `--loaded`: a hidden fallback\n * leaves the paint through `visibility`, a named-by-initials one is clipped and\n * stays in the accessibility tree. While loading it is painted either way.\n */\n protected readonly fallbackHidden = computed(() => !!this.url() && !this.failed() && !!this.alt()?.trim());\n\n protected readonly cssSize = computed(() => this.resolved().cssValue);\n protected readonly pxSize = computed(() => this.resolved().pxValue);\n\n protected readonly classes = computed(() => {\n const parts = ['wr-avatar'];\n const shape = this.shape();\n if (shape !== 'rounded') parts.push(`wr-avatar--${shape}`);\n if (this.loaded()) parts.push('wr-avatar--loaded');\n return parts.join(' ');\n });\n\n protected onImageLoad(): void {\n this.loaded.set(true);\n }\n\n protected onImageError(): void {\n this.failed.set(true);\n }\n}\n","<span class=\"wr-avatar__content\" [attr.aria-hidden]=\"fallbackHidden() ? 'true' : null\"><ng-content /></span>\n\n@if (url(); as src) {\n @if (!failed()) {\n <img\n #img\n class=\"wr-avatar__img\"\n [src]=\"src\"\n [alt]=\"resolvedAlt()\"\n [attr.width]=\"pxSize()\"\n [attr.height]=\"pxSize()\"\n (load)=\"onImageLoad()\"\n (error)=\"onImageError()\"\n decoding=\"async\"\n />\n\n @if (!loaded()) {\n <wr-spinner class=\"wr-avatar__spin\" />\n }\n }\n}\n","/**\n * Generated bundle index. Do not edit.\n */\n\nexport * from './public-api';\n"],"names":[],"mappings":"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"}
|
|
@@ -44,9 +44,10 @@ const ARROWS = {
|
|
|
44
44
|
* arrives on the `(cropped)` output, which emits a `Blob` after each drag end or
|
|
45
45
|
* each run of arrow keys, and a consumer who wants a one-off read calls `toBlob()` /
|
|
46
46
|
* `toDataUrl()` — or `cropRect()` for the geometry — on the component through a
|
|
47
|
-
* `viewChild`.
|
|
48
|
-
*
|
|
49
|
-
* rather than
|
|
47
|
+
* `viewChild`. The same goes for `(loadError)` and `status()`, which is why
|
|
48
|
+
* {@link isFailed} reads the failure off the host class instead. Outputs and instance
|
|
49
|
+
* members are component API rather than DOM, so all of it is outside what a harness
|
|
50
|
+
* can reach at all, which is worth knowing rather than discovering.
|
|
50
51
|
*
|
|
51
52
|
* @example
|
|
52
53
|
* ```ts
|
|
@@ -118,6 +119,36 @@ class WrImageCropperHarness extends ComponentHarness {
|
|
|
118
119
|
}
|
|
119
120
|
await image.dispatchEvent('load');
|
|
120
121
|
}
|
|
122
|
+
/**
|
|
123
|
+
* Fire the image's `error` event — what a browser does with a source it cannot
|
|
124
|
+
* decode, and the only way to get one in a unit test, where nothing is decoded.
|
|
125
|
+
*
|
|
126
|
+
* Afterwards {@link isFailed} is `true` and the component has emitted
|
|
127
|
+
* `(loadError)`; a spec that wants the payload binds that output on its host.
|
|
128
|
+
*/
|
|
129
|
+
async dispatchImageError() {
|
|
130
|
+
const image = await this.image();
|
|
131
|
+
if (!image) {
|
|
132
|
+
throw new Error('WrImageCropperHarness.dispatchImageError(): there is no image — the cropper has no `src`, so it is ' +
|
|
133
|
+
'showing its empty state and there is nothing to fail.');
|
|
134
|
+
}
|
|
135
|
+
await image.dispatchEvent('error');
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Whether the browser failed to decode the current source.
|
|
139
|
+
*
|
|
140
|
+
* Read off the `wr-image-cropper--failed` host modifier — the part of the failure
|
|
141
|
+
* the DOM shows. It is a third answer beside {@link isEmpty} and {@link isReady},
|
|
142
|
+
* not a combination of them: a failed cropper has a `src` (so it is not empty) and
|
|
143
|
+
* no crop window (so it is not ready), which is also exactly what a cropper still
|
|
144
|
+
* LOADING looks like. Asking those two would call a broken image a slow one.
|
|
145
|
+
*
|
|
146
|
+
* The component renders no message for it, so there is no text to read here; the
|
|
147
|
+
* host's own message is the host's to assert.
|
|
148
|
+
*/
|
|
149
|
+
async isFailed() {
|
|
150
|
+
return (await this.host()).hasClass('wr-image-cropper--failed');
|
|
151
|
+
}
|
|
121
152
|
/**
|
|
122
153
|
* Where the crop window sits over the displayed image, in CSS pixels.
|
|
123
154
|
*
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ngwr-image-cropper-testing.mjs","sources":["../../../projects/lib/image-cropper/testing/wr-image-cropper-harness.ts","../../../projects/lib/image-cropper/testing/ngwr-image-cropper-testing.ts"],"sourcesContent":["/**\n * @license\n *\n * Use of this source code is governed by an MIT-style license that can be\n * found in the LICENSE file at https://github.com/thekhegay/ngwr/blob/main/LICENSE\n */\n\nimport { ComponentHarness, HarnessPredicate, TestKey, type TestElement } from '@angular/cdk/testing';\n\nimport type { WrCropHandle } from 'ngwr/image-cropper';\n\nimport type { WrImageCropperHarnessFilters } from './interfaces';\n\nconst ARROWS = {\n left: TestKey.LEFT_ARROW,\n right: TestKey.RIGHT_ARROW,\n up: TestKey.UP_ARROW,\n down: TestKey.DOWN_ARROW,\n} as const;\n\n/** The four directions the crop window's keyboard model understands. */\nexport type WrCropArrow = keyof typeof ARROWS;\n\n/** Where the crop window sits over the DISPLAYED image, in CSS pixels. */\nexport interface WrCropWindowBox {\n readonly x: number;\n readonly y: number;\n readonly width: number;\n readonly height: number;\n}\n\n/**\n * Test harness for `<wr-image-cropper>`.\n *\n * **This is the smallest honest surface of any harness here, and the reason is worth\n * reading before you reach for a method that is not on it.** Almost everything the\n * cropper does is measured: it reads the `<img>`'s rendered box and its\n * `naturalWidth`, converts between display and source pixels, and moves the crop\n * window with pointer deltas. jsdom measures nothing — every rect is 0×0 and\n * `naturalWidth` is 0 — so an image never \"loads\", the crop UI never renders, and a\n * synthetic drag divides by zero. There is deliberately no drag method: it would\n * write `NaN` and report success.\n *\n * What a spec CAN do is give the image a box and a natural size itself, then call\n * {@link dispatchImageLoad} — the two numbers `onImageLoad` reads are the whole\n * stub, and from there {@link getCropBox} and {@link getHandles} are real answers,\n * because the component writes the crop window's geometry as inline pixels. The\n * KEYBOARD path needs the same stub to get the crop UI up, but nothing beyond it:\n * its step is a constant rather than a pointer delta, so {@link pressArrow} lands\n * on a number a spec can name.\n *\n * ```ts\n * // In your spec, before dispatching the load:\n * const img = fixture.nativeElement.querySelector('.wr-image-cropper__image');\n * img.getBoundingClientRect = () => ({ width: 400, height: 400, x: 0, y: 0 }) as DOMRect;\n * Object.defineProperty(img, 'naturalWidth', { value: 800 });\n * Object.defineProperty(img, 'naturalHeight', { value: 800 });\n * ```\n *\n * **There is nothing to click.** The cropper renders no buttons: the result\n * arrives on the `(cropped)` output, which emits a `Blob` after each drag end or\n * each run of arrow keys, and a consumer who wants a one-off read calls `toBlob()` /\n * `toDataUrl()` — or `cropRect()` for the geometry — on the component through a\n * `viewChild`. An output and three instance members: component API rather than DOM,\n * so all of it is outside what a harness can reach at all, which is worth knowing\n * rather than discovering.\n *\n * @example\n * ```ts\n * const cropper = await loader.getHarness(WrImageCropperHarness);\n *\n * expect(await cropper.isEmpty()).toBe(false);\n * expect(await cropper.isReady()).toBe(false); // nothing measured yet\n *\n * await cropper.dispatchImageLoad();\n * expect(await cropper.getCropBox()).toEqual({ x: 80, y: 80, width: 240, height: 240 });\n * ```\n *\n * @see https://ngwr.dev/guides/testing\n */\nexport class WrImageCropperHarness extends ComponentHarness {\n static hostSelector = 'wr-image-cropper';\n\n /** Build a predicate that narrows the query — pass to `getHarness` / `getAllHarnesses`. */\n static with(options: WrImageCropperHarnessFilters = {}): HarnessPredicate<WrImageCropperHarness> {\n return new HarnessPredicate(WrImageCropperHarness, options).addOption(\n 'empty',\n options.empty,\n async (harness, empty) => (await harness.isEmpty()) === empty\n );\n }\n\n private readonly image = this.locatorForOptional('.wr-image-cropper__image');\n private readonly cropWindow = this.locatorForOptional('.wr-image-cropper__window');\n\n /** Whether the cropper has no source at all, and is showing its placeholder. */\n async isEmpty(): Promise<boolean> {\n return (await this.locatorForOptional('.wr-image-cropper__empty')()) !== null;\n }\n\n /** The placeholder's text, or `null` when there is an image. Comes from the catalog. */\n async getEmptyText(): Promise<string | null> {\n const empty = await this.locatorForOptional('.wr-image-cropper__empty')();\n return empty ? empty.text() : null;\n }\n\n /**\n * Whether the crop UI is up.\n *\n * Not the same question as {@link isEmpty}: a cropper with a perfectly good `src`\n * shows nothing but the image until it has been MEASURED, because the window's size\n * is a fraction of the rendered box. In a browser that happens on load; in a unit\n * test it happens when the spec stubs the box and calls\n * {@link dispatchImageLoad}.\n */\n async isReady(): Promise<boolean> {\n return (await this.cropWindow()) !== null;\n }\n\n /**\n * What the `<img>` is pointing at.\n *\n * A `File` or `Blob` source becomes an object URL, so this is `blob:…` for those\n * and the plain string for a string `src`. Comparing the two is not useful; asking\n * whether it CHANGED is.\n */\n async getImageSrc(): Promise<string | null> {\n const image = await this.image();\n return image ? image.getProperty<string>('src') : null;\n }\n\n /**\n * Fire the image's `load` event.\n *\n * The event, not a real load: jsdom fetches nothing, and the handler reads\n * `getBoundingClientRect()` and `naturalWidth` off the element. Stub those first —\n * see this class's docs — or the crop window will still not render and\n * {@link isReady} will stay `false`, which is exactly what a real cropper does with\n * a zero-sized image.\n */\n async dispatchImageLoad(): Promise<void> {\n const image = await this.image();\n if (!image) {\n throw new Error(\n 'WrImageCropperHarness.dispatchImageLoad(): there is no image — the cropper has no `src`, so it is ' +\n 'showing its empty state.'\n );\n }\n await image.dispatchEvent('load');\n }\n\n /**\n * Where the crop window sits over the displayed image, in CSS pixels.\n *\n * From the inline styles the component writes — a measured box would be 0×0 — and\n * in DISPLAY pixels, which is not what a consumer receives: `cropRect()` converts\n * to the image's natural pixels, and that conversion is the component's own contract\n * rather than something the DOM shows.\n */\n async getCropBox(): Promise<WrCropWindowBox> {\n const window_ = await this.requireReady('getCropBox');\n return {\n x: Number.parseFloat(await window_.getCssValue('left')),\n y: Number.parseFloat(await window_.getCssValue('top')),\n width: Number.parseFloat(await window_.getCssValue('width')),\n height: Number.parseFloat(await window_.getCssValue('height')),\n };\n }\n\n /** The eight resize handles, by their compass suffix. Throws before the crop UI is up. */\n async getHandles(): Promise<WrCropHandle[]> {\n await this.requireReady('getHandles');\n const handles = await this.locatorForAll('.wr-image-cropper__handle')();\n\n return Promise.all(\n handles.map(async handle => {\n const classes = (await handle.getAttribute('class')) ?? '';\n const found = classes.split(/\\s+/).find(name => name.startsWith('wr-image-cropper__handle--'));\n return (found?.slice('wr-image-cropper__handle--'.length) ?? '') as WrCropHandle;\n })\n );\n }\n\n /** Whether the dimming backdrop with the cut-out is drawn. */\n async hasBackdrop(): Promise<boolean> {\n return (await this.locatorForOptional('.wr-image-cropper__backdrop')()) !== null;\n }\n\n /**\n * Press an arrow key on the crop window — one display pixel, or ten with\n * `shift`; `alt` resizes from the east / south edge instead of moving.\n *\n * Sent to the crop window, where the component listens, so it does not depend on\n * jsdom having managed to focus anything. `sendKeys` fires keyup as well as\n * keydown, which is what makes `(cropped)` emit — the component treats a run of\n * arrows as one gesture, the way a drag is one.\n */\n async pressArrow(arrow: WrCropArrow, modifiers: { alt?: boolean; shift?: boolean } = {}): Promise<void> {\n const window_ = await this.requireReady('pressArrow');\n const keys = ARROWS[arrow];\n await (modifiers.alt || modifiers.shift ? window_.sendKeys(modifiers, keys) : window_.sendKeys(keys));\n }\n\n /** The crop window's accessible name. Comes from the catalog. */\n async getCropWindowLabel(): Promise<string | null> {\n return (await this.requireReady('getCropWindowLabel')).getAttribute('aria-label');\n }\n\n /**\n * The last thing the live region announced — the crop rect in SOURCE pixels,\n * as `x, y, width × height`.\n *\n * Empty until a key has moved the crop: a pointer drag deliberately says nothing\n * (it would flood the region and the sighted user can see it), and the initial\n * crop is not news. Two questions, not one — {@link getCropBox} reads the window's\n * DISPLAY geometry, and a component that moved the box without announcing it\n * would still pass that one.\n */\n async getAnnouncement(): Promise<string> {\n await this.requireReady('getAnnouncement');\n const status = await this.locatorForOptional('[role=\"status\"]')();\n return status ? status.text() : '';\n }\n\n private async requireReady(method: string): Promise<TestElement> {\n const window_ = await this.cropWindow();\n if (window_) return window_;\n\n throw new Error(\n `WrImageCropperHarness.${method}(): the crop window is not rendered. The cropper sizes it from the ` +\n \"image's MEASURED box, and a unit test has none — give the <img> a `getBoundingClientRect` and a \" +\n '`naturalWidth`, then call dispatchImageLoad().'\n );\n }\n}\n","/**\n * Generated bundle index. Do not edit.\n */\n\nexport * from './public-api';\n"],"names":[],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"ngwr-image-cropper-testing.mjs","sources":["../../../projects/lib/image-cropper/testing/wr-image-cropper-harness.ts","../../../projects/lib/image-cropper/testing/ngwr-image-cropper-testing.ts"],"sourcesContent":["/**\n * @license\n *\n * Use of this source code is governed by an MIT-style license that can be\n * found in the LICENSE file at https://github.com/thekhegay/ngwr/blob/main/LICENSE\n */\n\nimport { ComponentHarness, HarnessPredicate, TestKey, type TestElement } from '@angular/cdk/testing';\n\nimport type { WrCropHandle } from 'ngwr/image-cropper';\n\nimport type { WrImageCropperHarnessFilters } from './interfaces';\n\nconst ARROWS = {\n left: TestKey.LEFT_ARROW,\n right: TestKey.RIGHT_ARROW,\n up: TestKey.UP_ARROW,\n down: TestKey.DOWN_ARROW,\n} as const;\n\n/** The four directions the crop window's keyboard model understands. */\nexport type WrCropArrow = keyof typeof ARROWS;\n\n/** Where the crop window sits over the DISPLAYED image, in CSS pixels. */\nexport interface WrCropWindowBox {\n readonly x: number;\n readonly y: number;\n readonly width: number;\n readonly height: number;\n}\n\n/**\n * Test harness for `<wr-image-cropper>`.\n *\n * **This is the smallest honest surface of any harness here, and the reason is worth\n * reading before you reach for a method that is not on it.** Almost everything the\n * cropper does is measured: it reads the `<img>`'s rendered box and its\n * `naturalWidth`, converts between display and source pixels, and moves the crop\n * window with pointer deltas. jsdom measures nothing — every rect is 0×0 and\n * `naturalWidth` is 0 — so an image never \"loads\", the crop UI never renders, and a\n * synthetic drag divides by zero. There is deliberately no drag method: it would\n * write `NaN` and report success.\n *\n * What a spec CAN do is give the image a box and a natural size itself, then call\n * {@link dispatchImageLoad} — the two numbers `onImageLoad` reads are the whole\n * stub, and from there {@link getCropBox} and {@link getHandles} are real answers,\n * because the component writes the crop window's geometry as inline pixels. The\n * KEYBOARD path needs the same stub to get the crop UI up, but nothing beyond it:\n * its step is a constant rather than a pointer delta, so {@link pressArrow} lands\n * on a number a spec can name.\n *\n * ```ts\n * // In your spec, before dispatching the load:\n * const img = fixture.nativeElement.querySelector('.wr-image-cropper__image');\n * img.getBoundingClientRect = () => ({ width: 400, height: 400, x: 0, y: 0 }) as DOMRect;\n * Object.defineProperty(img, 'naturalWidth', { value: 800 });\n * Object.defineProperty(img, 'naturalHeight', { value: 800 });\n * ```\n *\n * **There is nothing to click.** The cropper renders no buttons: the result\n * arrives on the `(cropped)` output, which emits a `Blob` after each drag end or\n * each run of arrow keys, and a consumer who wants a one-off read calls `toBlob()` /\n * `toDataUrl()` — or `cropRect()` for the geometry — on the component through a\n * `viewChild`. The same goes for `(loadError)` and `status()`, which is why\n * {@link isFailed} reads the failure off the host class instead. Outputs and instance\n * members are component API rather than DOM, so all of it is outside what a harness\n * can reach at all, which is worth knowing rather than discovering.\n *\n * @example\n * ```ts\n * const cropper = await loader.getHarness(WrImageCropperHarness);\n *\n * expect(await cropper.isEmpty()).toBe(false);\n * expect(await cropper.isReady()).toBe(false); // nothing measured yet\n *\n * await cropper.dispatchImageLoad();\n * expect(await cropper.getCropBox()).toEqual({ x: 80, y: 80, width: 240, height: 240 });\n * ```\n *\n * @see https://ngwr.dev/guides/testing\n */\nexport class WrImageCropperHarness extends ComponentHarness {\n static hostSelector = 'wr-image-cropper';\n\n /** Build a predicate that narrows the query — pass to `getHarness` / `getAllHarnesses`. */\n static with(options: WrImageCropperHarnessFilters = {}): HarnessPredicate<WrImageCropperHarness> {\n return new HarnessPredicate(WrImageCropperHarness, options).addOption(\n 'empty',\n options.empty,\n async (harness, empty) => (await harness.isEmpty()) === empty\n );\n }\n\n private readonly image = this.locatorForOptional('.wr-image-cropper__image');\n private readonly cropWindow = this.locatorForOptional('.wr-image-cropper__window');\n\n /** Whether the cropper has no source at all, and is showing its placeholder. */\n async isEmpty(): Promise<boolean> {\n return (await this.locatorForOptional('.wr-image-cropper__empty')()) !== null;\n }\n\n /** The placeholder's text, or `null` when there is an image. Comes from the catalog. */\n async getEmptyText(): Promise<string | null> {\n const empty = await this.locatorForOptional('.wr-image-cropper__empty')();\n return empty ? empty.text() : null;\n }\n\n /**\n * Whether the crop UI is up.\n *\n * Not the same question as {@link isEmpty}: a cropper with a perfectly good `src`\n * shows nothing but the image until it has been MEASURED, because the window's size\n * is a fraction of the rendered box. In a browser that happens on load; in a unit\n * test it happens when the spec stubs the box and calls\n * {@link dispatchImageLoad}.\n */\n async isReady(): Promise<boolean> {\n return (await this.cropWindow()) !== null;\n }\n\n /**\n * What the `<img>` is pointing at.\n *\n * A `File` or `Blob` source becomes an object URL, so this is `blob:…` for those\n * and the plain string for a string `src`. Comparing the two is not useful; asking\n * whether it CHANGED is.\n */\n async getImageSrc(): Promise<string | null> {\n const image = await this.image();\n return image ? image.getProperty<string>('src') : null;\n }\n\n /**\n * Fire the image's `load` event.\n *\n * The event, not a real load: jsdom fetches nothing, and the handler reads\n * `getBoundingClientRect()` and `naturalWidth` off the element. Stub those first —\n * see this class's docs — or the crop window will still not render and\n * {@link isReady} will stay `false`, which is exactly what a real cropper does with\n * a zero-sized image.\n */\n async dispatchImageLoad(): Promise<void> {\n const image = await this.image();\n if (!image) {\n throw new Error(\n 'WrImageCropperHarness.dispatchImageLoad(): there is no image — the cropper has no `src`, so it is ' +\n 'showing its empty state.'\n );\n }\n await image.dispatchEvent('load');\n }\n\n /**\n * Fire the image's `error` event — what a browser does with a source it cannot\n * decode, and the only way to get one in a unit test, where nothing is decoded.\n *\n * Afterwards {@link isFailed} is `true` and the component has emitted\n * `(loadError)`; a spec that wants the payload binds that output on its host.\n */\n async dispatchImageError(): Promise<void> {\n const image = await this.image();\n if (!image) {\n throw new Error(\n 'WrImageCropperHarness.dispatchImageError(): there is no image — the cropper has no `src`, so it is ' +\n 'showing its empty state and there is nothing to fail.'\n );\n }\n await image.dispatchEvent('error');\n }\n\n /**\n * Whether the browser failed to decode the current source.\n *\n * Read off the `wr-image-cropper--failed` host modifier — the part of the failure\n * the DOM shows. It is a third answer beside {@link isEmpty} and {@link isReady},\n * not a combination of them: a failed cropper has a `src` (so it is not empty) and\n * no crop window (so it is not ready), which is also exactly what a cropper still\n * LOADING looks like. Asking those two would call a broken image a slow one.\n *\n * The component renders no message for it, so there is no text to read here; the\n * host's own message is the host's to assert.\n */\n async isFailed(): Promise<boolean> {\n return (await this.host()).hasClass('wr-image-cropper--failed');\n }\n\n /**\n * Where the crop window sits over the displayed image, in CSS pixels.\n *\n * From the inline styles the component writes — a measured box would be 0×0 — and\n * in DISPLAY pixels, which is not what a consumer receives: `cropRect()` converts\n * to the image's natural pixels, and that conversion is the component's own contract\n * rather than something the DOM shows.\n */\n async getCropBox(): Promise<WrCropWindowBox> {\n const window_ = await this.requireReady('getCropBox');\n return {\n x: Number.parseFloat(await window_.getCssValue('left')),\n y: Number.parseFloat(await window_.getCssValue('top')),\n width: Number.parseFloat(await window_.getCssValue('width')),\n height: Number.parseFloat(await window_.getCssValue('height')),\n };\n }\n\n /** The eight resize handles, by their compass suffix. Throws before the crop UI is up. */\n async getHandles(): Promise<WrCropHandle[]> {\n await this.requireReady('getHandles');\n const handles = await this.locatorForAll('.wr-image-cropper__handle')();\n\n return Promise.all(\n handles.map(async handle => {\n const classes = (await handle.getAttribute('class')) ?? '';\n const found = classes.split(/\\s+/).find(name => name.startsWith('wr-image-cropper__handle--'));\n return (found?.slice('wr-image-cropper__handle--'.length) ?? '') as WrCropHandle;\n })\n );\n }\n\n /** Whether the dimming backdrop with the cut-out is drawn. */\n async hasBackdrop(): Promise<boolean> {\n return (await this.locatorForOptional('.wr-image-cropper__backdrop')()) !== null;\n }\n\n /**\n * Press an arrow key on the crop window — one display pixel, or ten with\n * `shift`; `alt` resizes from the east / south edge instead of moving.\n *\n * Sent to the crop window, where the component listens, so it does not depend on\n * jsdom having managed to focus anything. `sendKeys` fires keyup as well as\n * keydown, which is what makes `(cropped)` emit — the component treats a run of\n * arrows as one gesture, the way a drag is one.\n */\n async pressArrow(arrow: WrCropArrow, modifiers: { alt?: boolean; shift?: boolean } = {}): Promise<void> {\n const window_ = await this.requireReady('pressArrow');\n const keys = ARROWS[arrow];\n await (modifiers.alt || modifiers.shift ? window_.sendKeys(modifiers, keys) : window_.sendKeys(keys));\n }\n\n /** The crop window's accessible name. Comes from the catalog. */\n async getCropWindowLabel(): Promise<string | null> {\n return (await this.requireReady('getCropWindowLabel')).getAttribute('aria-label');\n }\n\n /**\n * The last thing the live region announced — the crop rect in SOURCE pixels,\n * as `x, y, width × height`.\n *\n * Empty until a key has moved the crop: a pointer drag deliberately says nothing\n * (it would flood the region and the sighted user can see it), and the initial\n * crop is not news. Two questions, not one — {@link getCropBox} reads the window's\n * DISPLAY geometry, and a component that moved the box without announcing it\n * would still pass that one.\n */\n async getAnnouncement(): Promise<string> {\n await this.requireReady('getAnnouncement');\n const status = await this.locatorForOptional('[role=\"status\"]')();\n return status ? status.text() : '';\n }\n\n private async requireReady(method: string): Promise<TestElement> {\n const window_ = await this.cropWindow();\n if (window_) return window_;\n\n throw new Error(\n `WrImageCropperHarness.${method}(): the crop window is not rendered. The cropper sizes it from the ` +\n \"image's MEASURED box, and a unit test has none — give the <img> a `getBoundingClientRect` and a \" +\n '`naturalWidth`, then call dispatchImageLoad().'\n );\n }\n}\n","/**\n * Generated bundle index. Do not edit.\n */\n\nexport * from './public-api';\n"],"names":[],"mappings":";;AAAA;;;;;AAKG;AAQH,MAAM,MAAM,GAAG;IACb,IAAI,EAAE,OAAO,CAAC,UAAU;IACxB,KAAK,EAAE,OAAO,CAAC,WAAW;IAC1B,EAAE,EAAE,OAAO,CAAC,QAAQ;IACpB,IAAI,EAAE,OAAO,CAAC,UAAU;CAChB;AAaV;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAiDG;AACG,MAAO,qBAAsB,SAAQ,gBAAgB,CAAA;AACzD,IAAA,OAAO,YAAY,GAAG,kBAAkB;;AAGxC,IAAA,OAAO,IAAI,CAAC,OAAA,GAAwC,EAAE,EAAA;AACpD,QAAA,OAAO,IAAI,gBAAgB,CAAC,qBAAqB,EAAE,OAAO,CAAC,CAAC,SAAS,CACnE,OAAO,EACP,OAAO,CAAC,KAAK,EACb,OAAO,OAAO,EAAE,KAAK,KAAK,CAAC,MAAM,OAAO,CAAC,OAAO,EAAE,MAAM,KAAK,CAC9D;IACH;AAEiB,IAAA,KAAK,GAAG,IAAI,CAAC,kBAAkB,CAAC,0BAA0B,CAAC;AAC3D,IAAA,UAAU,GAAG,IAAI,CAAC,kBAAkB,CAAC,2BAA2B,CAAC;;AAGlF,IAAA,MAAM,OAAO,GAAA;AACX,QAAA,OAAO,CAAC,MAAM,IAAI,CAAC,kBAAkB,CAAC,0BAA0B,CAAC,EAAE,MAAM,IAAI;IAC/E;;AAGA,IAAA,MAAM,YAAY,GAAA;QAChB,MAAM,KAAK,GAAG,MAAM,IAAI,CAAC,kBAAkB,CAAC,0BAA0B,CAAC,EAAE;AACzE,QAAA,OAAO,KAAK,GAAG,KAAK,CAAC,IAAI,EAAE,GAAG,IAAI;IACpC;AAEA;;;;;;;;AAQG;AACH,IAAA,MAAM,OAAO,GAAA;QACX,OAAO,CAAC,MAAM,IAAI,CAAC,UAAU,EAAE,MAAM,IAAI;IAC3C;AAEA;;;;;;AAMG;AACH,IAAA,MAAM,WAAW,GAAA;AACf,QAAA,MAAM,KAAK,GAAG,MAAM,IAAI,CAAC,KAAK,EAAE;AAChC,QAAA,OAAO,KAAK,GAAG,KAAK,CAAC,WAAW,CAAS,KAAK,CAAC,GAAG,IAAI;IACxD;AAEA;;;;;;;;AAQG;AACH,IAAA,MAAM,iBAAiB,GAAA;AACrB,QAAA,MAAM,KAAK,GAAG,MAAM,IAAI,CAAC,KAAK,EAAE;QAChC,IAAI,CAAC,KAAK,EAAE;YACV,MAAM,IAAI,KAAK,CACb,oGAAoG;AAClG,gBAAA,0BAA0B,CAC7B;QACH;AACA,QAAA,MAAM,KAAK,CAAC,aAAa,CAAC,MAAM,CAAC;IACnC;AAEA;;;;;;AAMG;AACH,IAAA,MAAM,kBAAkB,GAAA;AACtB,QAAA,MAAM,KAAK,GAAG,MAAM,IAAI,CAAC,KAAK,EAAE;QAChC,IAAI,CAAC,KAAK,EAAE;YACV,MAAM,IAAI,KAAK,CACb,qGAAqG;AACnG,gBAAA,uDAAuD,CAC1D;QACH;AACA,QAAA,MAAM,KAAK,CAAC,aAAa,CAAC,OAAO,CAAC;IACpC;AAEA;;;;;;;;;;;AAWG;AACH,IAAA,MAAM,QAAQ,GAAA;AACZ,QAAA,OAAO,CAAC,MAAM,IAAI,CAAC,IAAI,EAAE,EAAE,QAAQ,CAAC,0BAA0B,CAAC;IACjE;AAEA;;;;;;;AAOG;AACH,IAAA,MAAM,UAAU,GAAA;QACd,MAAM,OAAO,GAAG,MAAM,IAAI,CAAC,YAAY,CAAC,YAAY,CAAC;QACrD,OAAO;AACL,YAAA,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC,MAAM,OAAO,CAAC,WAAW,CAAC,MAAM,CAAC,CAAC;AACvD,YAAA,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC,MAAM,OAAO,CAAC,WAAW,CAAC,KAAK,CAAC,CAAC;AACtD,YAAA,KAAK,EAAE,MAAM,CAAC,UAAU,CAAC,MAAM,OAAO,CAAC,WAAW,CAAC,OAAO,CAAC,CAAC;AAC5D,YAAA,MAAM,EAAE,MAAM,CAAC,UAAU,CAAC,MAAM,OAAO,CAAC,WAAW,CAAC,QAAQ,CAAC,CAAC;SAC/D;IACH;;AAGA,IAAA,MAAM,UAAU,GAAA;AACd,QAAA,MAAM,IAAI,CAAC,YAAY,CAAC,YAAY,CAAC;QACrC,MAAM,OAAO,GAAG,MAAM,IAAI,CAAC,aAAa,CAAC,2BAA2B,CAAC,EAAE;AAEvE,QAAA,OAAO,OAAO,CAAC,GAAG,CAChB,OAAO,CAAC,GAAG,CAAC,OAAM,MAAM,KAAG;AACzB,YAAA,MAAM,OAAO,GAAG,CAAC,MAAM,MAAM,CAAC,YAAY,CAAC,OAAO,CAAC,KAAK,EAAE;YAC1D,MAAM,KAAK,GAAG,OAAO,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC,IAAI,CAAC,IAAI,IAAI,IAAI,CAAC,UAAU,CAAC,4BAA4B,CAAC,CAAC;AAC9F,YAAA,QAAQ,KAAK,EAAE,KAAK,CAAC,4BAA4B,CAAC,MAAM,CAAC,IAAI,EAAE;QACjE,CAAC,CAAC,CACH;IACH;;AAGA,IAAA,MAAM,WAAW,GAAA;AACf,QAAA,OAAO,CAAC,MAAM,IAAI,CAAC,kBAAkB,CAAC,6BAA6B,CAAC,EAAE,MAAM,IAAI;IAClF;AAEA;;;;;;;;AAQG;AACH,IAAA,MAAM,UAAU,CAAC,KAAkB,EAAE,YAAgD,EAAE,EAAA;QACrF,MAAM,OAAO,GAAG,MAAM,IAAI,CAAC,YAAY,CAAC,YAAY,CAAC;AACrD,QAAA,MAAM,IAAI,GAAG,MAAM,CAAC,KAAK,CAAC;AAC1B,QAAA,OAAO,SAAS,CAAC,GAAG,IAAI,SAAS,CAAC,KAAK,GAAG,OAAO,CAAC,QAAQ,CAAC,SAAS,EAAE,IAAI,CAAC,GAAG,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC;IACvG;;AAGA,IAAA,MAAM,kBAAkB,GAAA;AACtB,QAAA,OAAO,CAAC,MAAM,IAAI,CAAC,YAAY,CAAC,oBAAoB,CAAC,EAAE,YAAY,CAAC,YAAY,CAAC;IACnF;AAEA;;;;;;;;;AASG;AACH,IAAA,MAAM,eAAe,GAAA;AACnB,QAAA,MAAM,IAAI,CAAC,YAAY,CAAC,iBAAiB,CAAC;QAC1C,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,kBAAkB,CAAC,iBAAiB,CAAC,EAAE;AACjE,QAAA,OAAO,MAAM,GAAG,MAAM,CAAC,IAAI,EAAE,GAAG,EAAE;IACpC;IAEQ,MAAM,YAAY,CAAC,MAAc,EAAA;AACvC,QAAA,MAAM,OAAO,GAAG,MAAM,IAAI,CAAC,UAAU,EAAE;AACvC,QAAA,IAAI,OAAO;AAAE,YAAA,OAAO,OAAO;AAE3B,QAAA,MAAM,IAAI,KAAK,CACb,CAAA,sBAAA,EAAyB,MAAM,CAAA,mEAAA,CAAqE;YAClG,kGAAkG;AAClG,YAAA,gDAAgD,CACnD;IACH;;;AC5QF;;AAEG;;;;"}
|
|
@@ -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
|
|
@@ -31,12 +42,18 @@ const KEY_STEP_COARSE = 10;
|
|
|
31
42
|
* freshly-rendered `Blob` of the cropped region. For one-off reads use
|
|
32
43
|
* `toBlob()` / `toDataUrl()`.
|
|
33
44
|
*
|
|
45
|
+
* A source the browser cannot decode fires `(loadError)` once, sets `status()` to
|
|
46
|
+
* `'failed'` and puts `wr-image-cropper--failed` on the host. The component renders
|
|
47
|
+
* no message of its own for it — what went wrong and what to do next is the host's
|
|
48
|
+
* to say.
|
|
49
|
+
*
|
|
34
50
|
* @example
|
|
35
51
|
* ```html
|
|
36
52
|
* <wr-image-cropper
|
|
37
53
|
* [src]="file"
|
|
38
54
|
* [aspectRatio]="1"
|
|
39
55
|
* (cropped)="onBlob($event)"
|
|
56
|
+
* (loadError)="onUnreadable($event)"
|
|
40
57
|
* />
|
|
41
58
|
* ```
|
|
42
59
|
*
|
|
@@ -59,8 +76,23 @@ class WrImageCropper {
|
|
|
59
76
|
/** JPEG / WebP quality for `(cropped)` in [0, 1]. @default 0.92 */
|
|
60
77
|
outputQuality = input(0.92, /* @ts-ignore */
|
|
61
78
|
...(ngDevMode ? [{ debugName: "outputQuality" }] : /* istanbul ignore next */ []));
|
|
79
|
+
/**
|
|
80
|
+
* Longest side of the exported image, in pixels — for `(cropped)`, `toBlob()` and
|
|
81
|
+
* `toDataUrl()` alike. A crop larger than this is scaled down to fit, keeping its
|
|
82
|
+
* aspect ratio; a smaller one is exported as it is, never upscaled. The crop itself
|
|
83
|
+
* — `cropRect()`, in source pixels — is not affected. `null` (or anything that is
|
|
84
|
+
* not a positive number) exports at the source image's own resolution.
|
|
85
|
+
* @default null
|
|
86
|
+
*/
|
|
87
|
+
maxOutputSize = input(null, { ...(ngDevMode ? { debugName: "maxOutputSize" } : /* istanbul ignore next */ {}), transform: coerceMaxOutputSize });
|
|
62
88
|
/** Emits a Blob after each drag end. */
|
|
63
89
|
cropped = output();
|
|
90
|
+
/**
|
|
91
|
+
* Fires once when the browser cannot decode the current `src` — a format it does
|
|
92
|
+
* not support, a broken file, a URL that fails. Nothing is rendered for it; show
|
|
93
|
+
* your own message. A new `src` clears the failure.
|
|
94
|
+
*/
|
|
95
|
+
loadError = output();
|
|
64
96
|
imgEl = viewChild.required('img', /* @ts-ignore */
|
|
65
97
|
...(ngDevMode ? [{ debugName: "imgEl" }] : /* istanbul ignore next */ []));
|
|
66
98
|
/** Resolved object URL for `src` (so File / Blob render in `<img>`). */
|
|
@@ -97,6 +129,14 @@ class WrImageCropper {
|
|
|
97
129
|
startPointer = { x: 0, y: 0 };
|
|
98
130
|
startRect = { x: 0, y: 0, w: 0, h: 0 };
|
|
99
131
|
destroyRef = inject(DestroyRef);
|
|
132
|
+
statusState = signal('empty', /* @ts-ignore */
|
|
133
|
+
...(ngDevMode ? [{ debugName: "statusState" }] : /* istanbul ignore next */ []));
|
|
134
|
+
/**
|
|
135
|
+
* Where the source stands: `'empty'`, `'loading'`, `'loaded'` or `'failed'`. Reset
|
|
136
|
+
* to `'loading'` (or `'empty'`) whenever `src` changes, so a failure never outlives
|
|
137
|
+
* the source that caused it.
|
|
138
|
+
*/
|
|
139
|
+
status = this.statusState.asReadonly();
|
|
100
140
|
/** Resolved crop rect in natural (source) pixel coordinates. */
|
|
101
141
|
cropRect = computed(() => {
|
|
102
142
|
const display = this.display();
|
|
@@ -132,8 +172,13 @@ class WrImageCropper {
|
|
|
132
172
|
this.resetGeometry();
|
|
133
173
|
if (!src) {
|
|
134
174
|
this.objectUrl.set(null);
|
|
175
|
+
this.statusState.set('empty');
|
|
135
176
|
return;
|
|
136
177
|
}
|
|
178
|
+
// A new source starts over: a failure belongs to the source that caused it, and
|
|
179
|
+
// left in place it would mark a perfectly good next image as broken until the
|
|
180
|
+
// browser answered for that one too.
|
|
181
|
+
this.statusState.set('loading');
|
|
137
182
|
if (typeof src === 'string') {
|
|
138
183
|
this.objectUrl.set(src);
|
|
139
184
|
}
|
|
@@ -175,11 +220,38 @@ class WrImageCropper {
|
|
|
175
220
|
w: img.naturalWidth || display.w,
|
|
176
221
|
h: img.naturalHeight || display.h,
|
|
177
222
|
};
|
|
223
|
+
this.statusState.set('loaded');
|
|
178
224
|
this.natural.set(natural);
|
|
179
225
|
this.display.set(display);
|
|
180
226
|
this.cropDisplay.set(this.initialCrop(display));
|
|
181
227
|
this.watchResize(img);
|
|
182
228
|
}
|
|
229
|
+
/**
|
|
230
|
+
* The `<img>` could not decode its source.
|
|
231
|
+
*
|
|
232
|
+
* Before this handler existed a failed decode left the component exactly as it
|
|
233
|
+
* looks while loading — an image with no size, no crop window, nothing emitted —
|
|
234
|
+
* so a host had no way to tell "still decoding" from "never going to", and a user
|
|
235
|
+
* who picked a `.heic` the browser cannot read saw an empty box and no reason.
|
|
236
|
+
*
|
|
237
|
+
* Once per source: `status` is reset by a new `src` and by nothing else, so a
|
|
238
|
+
* second `error` for the same source says nothing new and is not re-emitted.
|
|
239
|
+
*/
|
|
240
|
+
onImageError() {
|
|
241
|
+
const url = this.objectUrl();
|
|
242
|
+
if (!url || this.statusState() === 'failed')
|
|
243
|
+
return;
|
|
244
|
+
this.statusState.set('failed');
|
|
245
|
+
this.resetGeometry();
|
|
246
|
+
const src = this.src();
|
|
247
|
+
const isBlob = typeof src !== 'string' && src !== null;
|
|
248
|
+
this.loadError.emit({
|
|
249
|
+
url,
|
|
250
|
+
name: typeof File !== 'undefined' && src instanceof File ? src.name : null,
|
|
251
|
+
type: isBlob ? src.type : null,
|
|
252
|
+
size: isBlob ? src.size : null,
|
|
253
|
+
});
|
|
254
|
+
}
|
|
183
255
|
/**
|
|
184
256
|
* Keep `display` on the box the image is actually rendered at.
|
|
185
257
|
*
|
|
@@ -432,12 +504,30 @@ class WrImageCropper {
|
|
|
432
504
|
await this.emitCropped();
|
|
433
505
|
}
|
|
434
506
|
// Internals
|
|
507
|
+
/**
|
|
508
|
+
* The exported image's pixel size: the crop in source pixels, scaled down so its
|
|
509
|
+
* longer side fits `maxOutputSize`. `Math.min(1, …)` is the never-upscale rule — a
|
|
510
|
+
* crop already inside the cap keeps its own size, which is also the whole of the
|
|
511
|
+
* uncapped path.
|
|
512
|
+
*/
|
|
513
|
+
outputSize(c) {
|
|
514
|
+
const cap = this.maxOutputSize();
|
|
515
|
+
const longest = Math.max(c.width, c.height);
|
|
516
|
+
const scale = cap === null || longest === 0 ? 1 : Math.min(1, cap / longest);
|
|
517
|
+
return {
|
|
518
|
+
width: Math.max(1, Math.round(c.width * scale)),
|
|
519
|
+
height: Math.max(1, Math.round(c.height * scale)),
|
|
520
|
+
};
|
|
521
|
+
}
|
|
435
522
|
toCanvas() {
|
|
436
523
|
const img = this.imgEl().nativeElement;
|
|
437
524
|
const c = this.cropRect();
|
|
438
525
|
const canvas = document.createElement('canvas');
|
|
439
|
-
|
|
440
|
-
canvas.
|
|
526
|
+
const size = this.outputSize(c);
|
|
527
|
+
canvas.width = size.width;
|
|
528
|
+
canvas.height = size.height;
|
|
529
|
+
// The SOURCE rect stays the whole crop and the destination is the canvas, so a
|
|
530
|
+
// capped export is the same region drawn smaller rather than a smaller region.
|
|
441
531
|
const ctx = canvas.getContext('2d');
|
|
442
532
|
if (ctx)
|
|
443
533
|
ctx.drawImage(img, c.x, c.y, c.width, c.height, 0, 0, canvas.width, canvas.height);
|
|
@@ -453,12 +543,15 @@ class WrImageCropper {
|
|
|
453
543
|
}
|
|
454
544
|
}
|
|
455
545
|
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
|
|
546
|
+
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 (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 ></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
547
|
}
|
|
458
548
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.6", ngImport: i0, type: WrImageCropper, decorators: [{
|
|
459
549
|
type: Component,
|
|
460
|
-
args: [{ selector: 'wr-image-cropper', encapsulation: ViewEncapsulation.None, host: {
|
|
461
|
-
|
|
550
|
+
args: [{ selector: 'wr-image-cropper', encapsulation: ViewEncapsulation.None, host: {
|
|
551
|
+
class: 'wr-image-cropper',
|
|
552
|
+
'[class.wr-image-cropper--failed]': "status() === 'failed'",
|
|
553
|
+
}, 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 (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 ></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" }]
|
|
554
|
+
}], 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
555
|
|
|
463
556
|
/**
|
|
464
557
|
* Generated bundle index. Do not edit.
|