@limetech/lime-elements 39.41.0 → 39.42.1
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/CHANGELOG.md +20 -0
- package/dist/cjs/{image-resize-FAwzj92q.js → image-resize-CG2fitJT.js} +97 -14
- package/dist/cjs/index.cjs.js +1 -1
- package/dist/cjs/lime-elements.cjs.js +1 -1
- package/dist/cjs/limel-action-bar_3.cjs.entry.js +1 -1
- package/dist/cjs/limel-ai-avatar.cjs.entry.js +1 -1
- package/dist/cjs/limel-callout.cjs.entry.js +1 -1
- package/dist/cjs/limel-chart.cjs.entry.js +1 -1
- package/dist/cjs/limel-chip-set.cjs.entry.js +1 -1
- package/dist/cjs/limel-chip_2.cjs.entry.js +1 -1
- package/dist/cjs/limel-code-diff.cjs.entry.js +1 -1
- package/dist/cjs/limel-code-editor.cjs.entry.js +1 -1
- package/dist/cjs/limel-collapsible-section.cjs.entry.js +1 -1
- package/dist/cjs/limel-drag-handle.cjs.entry.js +1 -1
- package/dist/cjs/limel-email-viewer.cjs.entry.js +1 -1
- package/dist/cjs/limel-file-viewer.cjs.entry.js +1 -1
- package/dist/cjs/limel-file.cjs.entry.js +84 -18
- package/dist/cjs/limel-flatpickr-adapter.cjs.entry.js +1 -1
- package/dist/cjs/limel-list-item.cjs.entry.js +1 -1
- package/dist/cjs/limel-picker.cjs.entry.js +1 -1
- package/dist/cjs/limel-profile-picture.cjs.entry.js +12 -5
- package/dist/cjs/limel-prosemirror-adapter.cjs.entry.js +1 -1
- package/dist/cjs/limel-snackbar.cjs.entry.js +1 -1
- package/dist/cjs/limel-table.cjs.entry.js +1 -1
- package/dist/cjs/limel-text-editor-link-menu.cjs.entry.js +1 -1
- package/dist/cjs/loader.cjs.js +1 -1
- package/dist/cjs/{translations-VEsa81QY.js → translations-Bnw00bkf.js} +8 -0
- package/dist/collection/components/file/file.js +119 -18
- package/dist/collection/components/profile-picture/profile-picture.js +12 -5
- package/dist/collection/translations/da.js +1 -0
- package/dist/collection/translations/de.js +1 -0
- package/dist/collection/translations/en.js +1 -0
- package/dist/collection/translations/fi.js +1 -0
- package/dist/collection/translations/fr.js +1 -0
- package/dist/collection/translations/nl.js +1 -0
- package/dist/collection/translations/no.js +1 -0
- package/dist/collection/translations/sv.js +1 -0
- package/dist/collection/util/image-resize.js +97 -14
- package/dist/esm/{image-resize-DPcww8rE.js → image-resize-BTSG--2-.js} +97 -14
- package/dist/esm/index.js +1 -1
- package/dist/esm/lime-elements.js +1 -1
- package/dist/esm/limel-action-bar_3.entry.js +1 -1
- package/dist/esm/limel-ai-avatar.entry.js +1 -1
- package/dist/esm/limel-callout.entry.js +1 -1
- package/dist/esm/limel-chart.entry.js +1 -1
- package/dist/esm/limel-chip-set.entry.js +1 -1
- package/dist/esm/limel-chip_2.entry.js +1 -1
- package/dist/esm/limel-code-diff.entry.js +1 -1
- package/dist/esm/limel-code-editor.entry.js +1 -1
- package/dist/esm/limel-collapsible-section.entry.js +1 -1
- package/dist/esm/limel-drag-handle.entry.js +1 -1
- package/dist/esm/limel-email-viewer.entry.js +1 -1
- package/dist/esm/limel-file-viewer.entry.js +1 -1
- package/dist/esm/limel-file.entry.js +84 -18
- package/dist/esm/limel-flatpickr-adapter.entry.js +1 -1
- package/dist/esm/limel-list-item.entry.js +1 -1
- package/dist/esm/limel-picker.entry.js +1 -1
- package/dist/esm/limel-profile-picture.entry.js +12 -5
- package/dist/esm/limel-prosemirror-adapter.entry.js +1 -1
- package/dist/esm/limel-snackbar.entry.js +1 -1
- package/dist/esm/limel-table.entry.js +1 -1
- package/dist/esm/limel-text-editor-link-menu.entry.js +1 -1
- package/dist/esm/loader.js +1 -1
- package/dist/esm/{translations-EPnIW0K5.js → translations-Cb9R_zmF.js} +8 -0
- package/dist/lime-elements/index.esm.js +1 -1
- package/dist/lime-elements/lime-elements.esm.js +1 -1
- package/dist/lime-elements/{p-068e7087.entry.js → p-19c81ded.entry.js} +2 -2
- package/dist/lime-elements/{p-56781dc7.entry.js → p-1cc3b516.entry.js} +1 -1
- package/dist/lime-elements/{p-ca0a8a5a.entry.js → p-1e3cdfa1.entry.js} +1 -1
- package/dist/lime-elements/{p-2531a103.entry.js → p-2284caa7.entry.js} +1 -1
- package/dist/lime-elements/{p-d8cb7d42.entry.js → p-2f223e46.entry.js} +1 -1
- package/dist/lime-elements/{p-a3a2aafe.entry.js → p-4a09fb2e.entry.js} +1 -1
- package/dist/lime-elements/{p-f197036b.entry.js → p-552d0733.entry.js} +1 -1
- package/dist/lime-elements/{p-fd78537c.entry.js → p-6fbf20c6.entry.js} +1 -1
- package/dist/lime-elements/{p-5571f0de.entry.js → p-758939be.entry.js} +1 -1
- package/dist/lime-elements/{p-19347686.entry.js → p-7f7e2180.entry.js} +1 -1
- package/dist/lime-elements/p-8893e1df.entry.js +1 -0
- package/dist/lime-elements/p-8aeeeb53.entry.js +1 -0
- package/dist/lime-elements/{p-79caf3f6.entry.js → p-9ae7d11b.entry.js} +1 -1
- package/dist/lime-elements/p-BTSG--2-.js +1 -0
- package/dist/lime-elements/p-Cb9R_zmF.js +1 -0
- package/dist/lime-elements/{p-6edf13b1.entry.js → p-a089b580.entry.js} +1 -1
- package/dist/lime-elements/{p-04b4e903.entry.js → p-aa080a8f.entry.js} +1 -1
- package/dist/lime-elements/{p-54961e0e.entry.js → p-c1ceade7.entry.js} +1 -1
- package/dist/lime-elements/{p-e08f770e.entry.js → p-c8116a01.entry.js} +1 -1
- package/dist/lime-elements/{p-36ccc68e.entry.js → p-c8982eef.entry.js} +1 -1
- package/dist/lime-elements/{p-d98168a1.entry.js → p-ca0b86ee.entry.js} +1 -1
- package/dist/lime-elements/{p-1525274d.entry.js → p-d7bb4310.entry.js} +1 -1
- package/dist/lime-elements/{p-bb201b0d.entry.js → p-e545c9e8.entry.js} +1 -1
- package/dist/types/components/file/file.d.ts +20 -0
- package/dist/types/components/profile-picture/profile-picture.d.ts +2 -0
- package/dist/types/components.d.ts +22 -4
- package/dist/types/translations/da.d.ts +1 -0
- package/dist/types/translations/de.d.ts +1 -0
- package/dist/types/translations/en.d.ts +1 -0
- package/dist/types/translations/fi.d.ts +1 -0
- package/dist/types/translations/fr.d.ts +1 -0
- package/dist/types/translations/nl.d.ts +1 -0
- package/dist/types/translations/no.d.ts +1 -0
- package/dist/types/translations/sv.d.ts +1 -0
- package/dist/types/util/image-resize.d.ts +41 -16
- package/package.json +1 -1
- package/dist/lime-elements/p-2a871a95.entry.js +0 -1
- package/dist/lime-elements/p-5cf7464f.entry.js +0 -1
- package/dist/lime-elements/p-DPcww8rE.js +0 -1
- package/dist/lime-elements/p-EPnIW0K5.js +0 -1
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import translate from "../../global/translations";
|
|
2
|
-
import { h, Host } from "@stencil/core";
|
|
2
|
+
import { h, Host, } from "@stencil/core";
|
|
3
3
|
import { getFileBackgroundColor, getFileColor, getFileExtensionTitle, getFileIcon, } from "../../util/file-metadata";
|
|
4
4
|
import { formatBytes } from "../../util/format-bytes";
|
|
5
|
+
import { resizeImage as resizeImageFile, } from "../../util/image-resize";
|
|
5
6
|
const DEFAULT_FILE_CHIP = {
|
|
6
7
|
id: null,
|
|
7
8
|
text: null,
|
|
@@ -46,6 +47,8 @@ const DEFAULT_FILE_CHIP = {
|
|
|
46
47
|
* @exampleComponent limel-example-file-per-file-status
|
|
47
48
|
* @exampleComponent limel-example-file-menu-items
|
|
48
49
|
* @exampleComponent limel-example-file-accepted-types
|
|
50
|
+
* @exampleComponent limel-example-file-resize-image
|
|
51
|
+
* @exampleComponent limel-example-file-resize-mixed
|
|
49
52
|
* @exampleComponent limel-example-file-composite
|
|
50
53
|
*/
|
|
51
54
|
export class File {
|
|
@@ -85,14 +88,51 @@ export class File {
|
|
|
85
88
|
this.dropZoneTip = () => {
|
|
86
89
|
return this.getTranslation('file.drag-and-drop-tips');
|
|
87
90
|
};
|
|
88
|
-
this.handleNewFiles = (event) => {
|
|
91
|
+
this.handleNewFiles = async (event) => {
|
|
89
92
|
this.preventAndStop(event);
|
|
90
|
-
|
|
93
|
+
const file = event.detail[0];
|
|
94
|
+
let out = file;
|
|
95
|
+
const content = file === null || file === void 0 ? void 0 : file.fileContent;
|
|
96
|
+
// `instanceof Blob` (not `File`) guards against a value with no real
|
|
97
|
+
// binary and narrows to the DOM file type; `File` here is the class.
|
|
98
|
+
if (this.resizeImage &&
|
|
99
|
+
content instanceof Blob &&
|
|
100
|
+
this.isResizableImage(content)) {
|
|
101
|
+
// Show the selected file with a loading badge while it resizes.
|
|
102
|
+
// This object doubles as the in-flight token: if it is cleared or
|
|
103
|
+
// replaced during the await (e.g. the file is removed), the resize
|
|
104
|
+
// result is stale and must be dropped.
|
|
105
|
+
const pending = Object.assign(Object.assign({}, file), { loading: true, statusText: this.getTranslation('file.optimizing') });
|
|
106
|
+
this.resizingFile = pending;
|
|
107
|
+
try {
|
|
108
|
+
const processed = await resizeImageFile(content, this.resizeImage);
|
|
109
|
+
out = Object.assign(Object.assign({}, file), { filename: processed.name, size: processed.size, contentType: processed.type, fileContent: processed });
|
|
110
|
+
}
|
|
111
|
+
catch (_a) {
|
|
112
|
+
// Best-effort: on any decode or encode failure emit the
|
|
113
|
+
// original file unchanged.
|
|
114
|
+
}
|
|
115
|
+
// Bail if the selection changed while we were resizing, so a
|
|
116
|
+
// removed or superseded file is neither re-shown nor re-emitted.
|
|
117
|
+
if (this.resizingFile !== pending) {
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
// Keep the result on the same chip until `value` catches up.
|
|
121
|
+
this.resizingFile = out;
|
|
122
|
+
}
|
|
123
|
+
this.change.emit(out);
|
|
91
124
|
};
|
|
92
125
|
this.handleChipSetChange = (event) => {
|
|
93
126
|
event.stopPropagation();
|
|
94
127
|
const file = event.detail.length === 0 ? event.detail[0] : null;
|
|
95
128
|
if (!file) {
|
|
129
|
+
// Removing the chip cancels any in-flight resize: drop the
|
|
130
|
+
// transient so the chip disappears immediately, and the token
|
|
131
|
+
// guard in `handleNewFiles` discards the resize result instead of
|
|
132
|
+
// re-emitting the removed file once it finishes. Without this, a
|
|
133
|
+
// removal during resize is swallowed, because `value` is already
|
|
134
|
+
// `undefined` so the `@Watch('value')` clear never fires.
|
|
135
|
+
this.resizingFile = undefined;
|
|
96
136
|
this.change.emit(file);
|
|
97
137
|
}
|
|
98
138
|
};
|
|
@@ -101,12 +141,23 @@ export class File {
|
|
|
101
141
|
this.interact.emit(event.detail.id);
|
|
102
142
|
};
|
|
103
143
|
}
|
|
144
|
+
/** The committed `value`, or the file currently being resized. */
|
|
145
|
+
get displayedFile() {
|
|
146
|
+
var _a;
|
|
147
|
+
return (_a = this.value) !== null && _a !== void 0 ? _a : this.resizingFile;
|
|
148
|
+
}
|
|
149
|
+
// Clear the transient file once `value` reflects the selection, keeping the
|
|
150
|
+
// same chip mounted across the hand-off (see file.spec.tsx) so only its
|
|
151
|
+
// badge changes instead of the field flashing empty.
|
|
152
|
+
handleValueChange() {
|
|
153
|
+
this.resizingFile = undefined;
|
|
154
|
+
}
|
|
104
155
|
render() {
|
|
105
|
-
return (h(Host, { key: '
|
|
156
|
+
return (h(Host, { key: '3f314e0a075c61a8b1e2b4a51451193abc53e12f', "aria-busy": this.isBusy ? 'true' : 'false' }, h("limel-file-dropzone", { key: '8374175635679e2082b4f80d431686f8fd76af61', disabled: this.disabled || this.readonly || !!this.displayedFile, accept: this.accept, onFilesSelected: this.handleNewFiles }, this.renderChipset()), this.renderDragAndDropTip(), this.renderSpinner()));
|
|
106
157
|
}
|
|
107
158
|
get statusText() {
|
|
108
159
|
var _a, _b, _c;
|
|
109
|
-
return (_c = (_b = (_a = this.
|
|
160
|
+
return (_c = (_b = (_a = this.displayedFile) === null || _a === void 0 ? void 0 : _a.statusText) === null || _b === void 0 ? void 0 : _b.trim()) !== null && _c !== void 0 ? _c : '';
|
|
110
161
|
}
|
|
111
162
|
/**
|
|
112
163
|
* The component is busy for any reason: its own `loading`, or a file that
|
|
@@ -117,8 +168,8 @@ export class File {
|
|
|
117
168
|
get isBusy() {
|
|
118
169
|
var _a, _b;
|
|
119
170
|
return (this.loading ||
|
|
120
|
-
Boolean((_a = this.
|
|
121
|
-
((_b = this.
|
|
171
|
+
Boolean((_a = this.displayedFile) === null || _a === void 0 ? void 0 : _a.loading) ||
|
|
172
|
+
((_b = this.displayedFile) === null || _b === void 0 ? void 0 : _b.progress) !== undefined);
|
|
122
173
|
}
|
|
123
174
|
renderSpinner() {
|
|
124
175
|
if (!this.isBusy) {
|
|
@@ -127,35 +178,47 @@ export class File {
|
|
|
127
178
|
return h("limel-spinner", null);
|
|
128
179
|
}
|
|
129
180
|
renderDragAndDropTip() {
|
|
130
|
-
if (this.
|
|
181
|
+
if (this.displayedFile ||
|
|
182
|
+
this.disabled ||
|
|
183
|
+
this.readonly ||
|
|
184
|
+
this.isBusy) {
|
|
131
185
|
return;
|
|
132
186
|
}
|
|
133
187
|
return (h("div", { class: "drag-and-drop-tip" }, h("span", { class: "invisible-label-mock", role: "presentation" }, this.label), h("span", { class: "tip" }, this.dropZoneTip())));
|
|
134
188
|
}
|
|
189
|
+
// Cheap pre-filter; the decode inside `resizeImage` is the authoritative
|
|
190
|
+
// check and throws (caught above) for anything that does not decode.
|
|
191
|
+
isResizableImage(file) {
|
|
192
|
+
var _a;
|
|
193
|
+
return (Boolean((_a = file.type) === null || _a === void 0 ? void 0 : _a.startsWith('image/')) &&
|
|
194
|
+
file.type !== 'image/svg+xml');
|
|
195
|
+
}
|
|
135
196
|
getChipArray() {
|
|
136
|
-
|
|
197
|
+
const file = this.displayedFile;
|
|
198
|
+
if (!file) {
|
|
137
199
|
return [];
|
|
138
200
|
}
|
|
139
201
|
return [
|
|
140
|
-
Object.assign(Object.assign({}, DEFAULT_FILE_CHIP), { text:
|
|
141
|
-
name: getFileIcon(
|
|
142
|
-
title: getFileExtensionTitle(
|
|
143
|
-
color: getFileColor(
|
|
144
|
-
backgroundColor: getFileBackgroundColor(
|
|
145
|
-
}, badge: this.getBadge(), href:
|
|
202
|
+
Object.assign(Object.assign({}, DEFAULT_FILE_CHIP), { text: file.filename, id: file.id, icon: {
|
|
203
|
+
name: getFileIcon(file),
|
|
204
|
+
title: getFileExtensionTitle(file),
|
|
205
|
+
color: getFileColor(file),
|
|
206
|
+
backgroundColor: getFileBackgroundColor(file),
|
|
207
|
+
}, badge: this.getBadge(), href: file.href, menuItems: file.menuItems, loading: file.loading, progress: file.progress, invalid: file.invalid }),
|
|
146
208
|
];
|
|
147
209
|
}
|
|
148
210
|
getBadge() {
|
|
211
|
+
var _a;
|
|
149
212
|
if (this.statusText) {
|
|
150
213
|
return this.statusText;
|
|
151
214
|
}
|
|
152
|
-
if (typeof this.
|
|
153
|
-
return formatBytes(this.
|
|
215
|
+
if (typeof ((_a = this.displayedFile) === null || _a === void 0 ? void 0 : _a.size) === 'number') {
|
|
216
|
+
return formatBytes(this.displayedFile.size);
|
|
154
217
|
}
|
|
155
218
|
return undefined;
|
|
156
219
|
}
|
|
157
220
|
renderChipset() {
|
|
158
|
-
const chipset = (h("limel-chip-set", { disabled: this.disabled, readonly: this.readonly, invalid: this.invalid, label: this.label, helperText: this.helperText, leadingIcon: "upload_to_cloud", language: this.language, onChange: this.handleChipSetChange, onInteract: this.handleChipInteract, required: this.required, type: "input", value: this.getChipArray() }));
|
|
221
|
+
const chipset = (h("limel-chip-set", { disabled: this.disabled, readonly: this.readonly, invalid: this.invalid, clearAllButton: false, label: this.label, helperText: this.helperText, leadingIcon: "upload_to_cloud", language: this.language, onChange: this.handleChipSetChange, onInteract: this.handleChipInteract, required: this.required, type: "input", value: this.getChipArray() }));
|
|
159
222
|
if (this.value) {
|
|
160
223
|
return chipset;
|
|
161
224
|
}
|
|
@@ -364,6 +427,33 @@ export class File {
|
|
|
364
427
|
"attribute": "accept",
|
|
365
428
|
"defaultValue": "'*'"
|
|
366
429
|
},
|
|
430
|
+
"resizeImage": {
|
|
431
|
+
"type": "unknown",
|
|
432
|
+
"mutable": false,
|
|
433
|
+
"complexType": {
|
|
434
|
+
"original": "ResizeOptions",
|
|
435
|
+
"resolved": "{ width?: number; height?: number; fit?: \"cover\" | \"contain\"; type?: \"image/jpeg\" | \"image/png\"; quality?: number; rename?: (originalName: string) => string; }",
|
|
436
|
+
"references": {
|
|
437
|
+
"ResizeOptions": {
|
|
438
|
+
"location": "import",
|
|
439
|
+
"path": "../../util/image-resize",
|
|
440
|
+
"id": "src/util/image-resize.ts::ResizeOptions",
|
|
441
|
+
"referenceLocation": "ResizeOptions"
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
},
|
|
445
|
+
"required": false,
|
|
446
|
+
"optional": true,
|
|
447
|
+
"docs": {
|
|
448
|
+
"tags": [{
|
|
449
|
+
"name": "beta",
|
|
450
|
+
"text": undefined
|
|
451
|
+
}],
|
|
452
|
+
"text": "Optional client-side image resize, applied before the `change` event is\nemitted: a selected image is downscaled and re-encoded on the user's\ndevice. Only decodable raster images are resized; all other files pass\nthrough unchanged. See the examples for details and caveats."
|
|
453
|
+
},
|
|
454
|
+
"getter": false,
|
|
455
|
+
"setter": false
|
|
456
|
+
},
|
|
367
457
|
"language": {
|
|
368
458
|
"type": "string",
|
|
369
459
|
"mutable": false,
|
|
@@ -393,6 +483,11 @@ export class File {
|
|
|
393
483
|
}
|
|
394
484
|
};
|
|
395
485
|
}
|
|
486
|
+
static get states() {
|
|
487
|
+
return {
|
|
488
|
+
"resizingFile": {}
|
|
489
|
+
};
|
|
490
|
+
}
|
|
396
491
|
static get events() {
|
|
397
492
|
return [{
|
|
398
493
|
"method": "change",
|
|
@@ -433,4 +528,10 @@ export class File {
|
|
|
433
528
|
}
|
|
434
529
|
}];
|
|
435
530
|
}
|
|
531
|
+
static get watchers() {
|
|
532
|
+
return [{
|
|
533
|
+
"propName": "value",
|
|
534
|
+
"methodName": "handleValueChange"
|
|
535
|
+
}];
|
|
536
|
+
}
|
|
436
537
|
}
|
|
@@ -5,6 +5,13 @@ import translate from "../../global/translations";
|
|
|
5
5
|
import { createRandomString } from "../../util/random-string";
|
|
6
6
|
import { resizeImage } from "../../util/image-resize";
|
|
7
7
|
import { ImageTemplate } from "../../util/image.template";
|
|
8
|
+
/**
|
|
9
|
+
* Avatar output defaults. These live in the component rather than in the shared
|
|
10
|
+
* `resizeImage` util (which is deliberately unopinionated): avatars are stored
|
|
11
|
+
* as a compact JPEG at a fixed quality. Callers can override them via `resize`.
|
|
12
|
+
*/
|
|
13
|
+
const AVATAR_OUTPUT_TYPE = 'image/jpeg';
|
|
14
|
+
const AVATAR_JPEG_QUALITY = 0.85;
|
|
8
15
|
/**
|
|
9
16
|
* This component displays a profile picture, while allowing the user
|
|
10
17
|
* to change it via a file input or drag-and-drop.
|
|
@@ -80,7 +87,7 @@ export class ProfilePicture {
|
|
|
80
87
|
return (h("limel-tooltip", { elementId: this.browseButtonId, label: this.helperText }));
|
|
81
88
|
};
|
|
82
89
|
this.handleNewFiles = async (event) => {
|
|
83
|
-
var _a, _b;
|
|
90
|
+
var _a, _b, _c, _d;
|
|
84
91
|
event.stopPropagation();
|
|
85
92
|
if (this.disabled) {
|
|
86
93
|
return;
|
|
@@ -99,10 +106,10 @@ export class ProfilePicture {
|
|
|
99
106
|
// Optional client-side resize
|
|
100
107
|
if (this.resize && file.fileContent instanceof File) {
|
|
101
108
|
try {
|
|
102
|
-
const processed = await resizeImage(file.fileContent, Object.assign(Object.assign({}, this.resize), { fit: (_b = this.resize.fit) !== null && _b !== void 0 ? _b : this.imageFit }));
|
|
109
|
+
const processed = await resizeImage(file.fileContent, Object.assign(Object.assign({}, this.resize), { fit: (_b = this.resize.fit) !== null && _b !== void 0 ? _b : this.imageFit, type: (_c = this.resize.type) !== null && _c !== void 0 ? _c : AVATAR_OUTPUT_TYPE, quality: (_d = this.resize.quality) !== null && _d !== void 0 ? _d : AVATAR_JPEG_QUALITY }));
|
|
103
110
|
out = Object.assign(Object.assign({}, file), { filename: processed.name, size: processed.size, contentType: processed.type, fileContent: processed });
|
|
104
111
|
}
|
|
105
|
-
catch (
|
|
112
|
+
catch (_e) {
|
|
106
113
|
// Fall back to original file if resize fails
|
|
107
114
|
out = file;
|
|
108
115
|
}
|
|
@@ -542,7 +549,7 @@ export class ProfilePicture {
|
|
|
542
549
|
"mutable": false,
|
|
543
550
|
"complexType": {
|
|
544
551
|
"original": "ResizeOptions",
|
|
545
|
-
"resolved": "{ width
|
|
552
|
+
"resolved": "{ width?: number; height?: number; fit?: \"cover\" | \"contain\"; type?: \"image/jpeg\" | \"image/png\"; quality?: number; rename?: (originalName: string) => string; }",
|
|
546
553
|
"references": {
|
|
547
554
|
"ResizeOptions": {
|
|
548
555
|
"location": "import",
|
|
@@ -556,7 +563,7 @@ export class ProfilePicture {
|
|
|
556
563
|
"optional": true,
|
|
557
564
|
"docs": {
|
|
558
565
|
"tags": [],
|
|
559
|
-
"text": "Optional client-side resize before emitting the file.\nIf provided, the selected image will be resized on the client device.\n:::note\nHEIC may not decode in all browsers; when decoding fails, the original\nfile will be emitted. See the examples for more info.\n:::"
|
|
566
|
+
"text": "Optional client-side resize before emitting the file.\nIf provided, the selected image will be resized on the client device.\nOmitted options fall back to avatar defaults: `image/jpeg`, `quality`\n`0.85`, and the component's `imageFit`.\n:::note\nHEIC may not decode in all browsers; when decoding fails, the original\nfile will be emitted. See the examples for more info.\n:::"
|
|
560
567
|
},
|
|
561
568
|
"getter": false,
|
|
562
569
|
"setter": false
|
|
@@ -29,6 +29,7 @@ export default {
|
|
|
29
29
|
'chip-set.clear-all': 'Ryd alle',
|
|
30
30
|
'snackbar.dismiss': 'Luk',
|
|
31
31
|
'file.drag-and-drop-tips': 'Træk & slip filen her, eller klik for at gennemse.',
|
|
32
|
+
'file.optimizing': 'Optimerer…',
|
|
32
33
|
'file-viewer.message.unsupported-filetype': 'Kan ikke vise en forhåndsvisning af denne filtype.',
|
|
33
34
|
'file-viewer.message.try-other-options': 'Hent filen eller åbn den i en ny fane.',
|
|
34
35
|
'file-viewer.message.file-not-found': 'Filen blev ikke fundet',
|
|
@@ -29,6 +29,7 @@ export default {
|
|
|
29
29
|
'chip-set.clear-all': 'Alles löschen',
|
|
30
30
|
'snackbar.dismiss': 'Schließen',
|
|
31
31
|
'file.drag-and-drop-tips': 'Ziehen Sie Ihre Datei hierher oder klicken Sie, um zu durchsuchen.',
|
|
32
|
+
'file.optimizing': 'Optimieren…',
|
|
32
33
|
'file-viewer.message.unsupported-filetype': 'Für diesen Dateityp kann keine Vorschau angezeigt werden.',
|
|
33
34
|
'file-viewer.message.try-other-options': 'Laden Sie die Datei herunter oder öffnen Sie sie in einem neuen Tab.',
|
|
34
35
|
'file-viewer.message.file-not-found': 'Datei nicht gefunden',
|
|
@@ -29,6 +29,7 @@ export default {
|
|
|
29
29
|
'chip-set.clear-all': 'Clear all',
|
|
30
30
|
'snackbar.dismiss': 'Dismiss',
|
|
31
31
|
'file.drag-and-drop-tips': 'Drag & drop your file here, or click to browse.',
|
|
32
|
+
'file.optimizing': 'Optimizing…',
|
|
32
33
|
'file-viewer.message.unsupported-filetype': 'Cannot display a preview for this file type.',
|
|
33
34
|
'file-viewer.message.try-other-options': 'Download the file or open it in a new tab.',
|
|
34
35
|
'file-viewer.message.file-not-found': 'File not found',
|
|
@@ -29,6 +29,7 @@ export default {
|
|
|
29
29
|
'chip-set.clear-all': 'Tyhjennä kaikki',
|
|
30
30
|
'snackbar.dismiss': 'Sulje',
|
|
31
31
|
'file.drag-and-drop-tips': 'Vedä & pudota tiedostosi tähän, tai napsauta selataksesi.',
|
|
32
|
+
'file.optimizing': 'Optimoidaan…',
|
|
32
33
|
'file-viewer.message.unsupported-filetype': 'Esikatselua ei voi näyttää tälle tiedostotyypille.',
|
|
33
34
|
'file-viewer.message.try-other-options': 'Lataa tiedosto tai avaa se uudessa välilehdessä.',
|
|
34
35
|
'file-viewer.message.file-not-found': 'Tiedostoa ei löydy',
|
|
@@ -29,6 +29,7 @@ export default {
|
|
|
29
29
|
'chip-set.clear-all': 'Tout effacer',
|
|
30
30
|
'snackbar.dismiss': 'Fermer',
|
|
31
31
|
'file.drag-and-drop-tips': 'Glissez-déposez votre fichier ici, ou cliquez pour parcourir.',
|
|
32
|
+
'file.optimizing': 'Optimisation…',
|
|
32
33
|
'file-viewer.message.unsupported-filetype': "Impossible d'afficher un aperçu pour ce type de fichier.",
|
|
33
34
|
'file-viewer.message.try-other-options': 'Téléchargez le fichier ou ouvrez-le dans un nouvel onglet.',
|
|
34
35
|
'file-viewer.message.file-not-found': 'Fichier introuvable',
|
|
@@ -29,6 +29,7 @@ export default {
|
|
|
29
29
|
'chip-set.clear-all': 'Alles wissen',
|
|
30
30
|
'snackbar.dismiss': 'Sluiten',
|
|
31
31
|
'file.drag-and-drop-tips': 'Sleep een bestand hierheen of klik om te bladeren.',
|
|
32
|
+
'file.optimizing': 'Optimaliseren…',
|
|
32
33
|
'file-viewer.message.unsupported-filetype': 'Kan geen voorbeeld weergeven voor dit bestandstype.',
|
|
33
34
|
'file-viewer.message.try-other-options': 'Download het bestand of open het in een nieuw tabblad.',
|
|
34
35
|
'file-viewer.message.file-not-found': 'Bestand niet gevonden',
|
|
@@ -28,6 +28,7 @@ export default {
|
|
|
28
28
|
'drag-handle.drag-to-reorder': 'Dra for å omorganisere',
|
|
29
29
|
'chip-set.clear-all': 'Fjern alle',
|
|
30
30
|
'file.drag-and-drop-tips': 'Dra & slipp filen her, eller klikk for å bla gjennom.',
|
|
31
|
+
'file.optimizing': 'Optimaliserer…',
|
|
31
32
|
'file-viewer.message.unsupported-filetype': 'Kan ikke vise en forhåndsvisning av denne filtypen.',
|
|
32
33
|
'file-viewer.message.try-other-options': 'Last ned filen eller åpne den i en ny fane.',
|
|
33
34
|
'file-viewer.message.file-not-found': 'Filen ble ikke funnet',
|
|
@@ -29,6 +29,7 @@ export default {
|
|
|
29
29
|
'chip-set.clear-all': 'Rensa alla',
|
|
30
30
|
'snackbar.dismiss': 'Stäng',
|
|
31
31
|
'file.drag-and-drop-tips': 'Dra & släpp filen här eller klicka om du vill bläddra.',
|
|
32
|
+
'file.optimizing': 'Optimerar…',
|
|
32
33
|
'file-viewer.message.unsupported-filetype': 'Kan inte visa en förhandsvisning för denna filtyp.',
|
|
33
34
|
'file-viewer.message.try-other-options': 'Ladda ner filen eller öppna den i en ny flik.',
|
|
34
35
|
'file-viewer.message.file-not-found': 'Filen hittades inte',
|
|
@@ -12,15 +12,17 @@
|
|
|
12
12
|
*
|
|
13
13
|
* Why resize client-side?
|
|
14
14
|
* - Faster perceived uploads and lower bandwidth usage
|
|
15
|
-
* - Consistent
|
|
15
|
+
* - Consistent output sizes and formats (e.g., a 400x400 thumbnail)
|
|
16
16
|
* - No server-side transformation required for common cases
|
|
17
17
|
*
|
|
18
18
|
* Fit strategies
|
|
19
|
-
* - `cover
|
|
20
|
-
*
|
|
19
|
+
* - `cover`: The image is scaled to cover the target rectangle, and the excess
|
|
20
|
+
* parts are center-cropped. Good for avatars.
|
|
21
21
|
* - `contain`: The image is scaled to fit entirely within the target rectangle
|
|
22
22
|
* without cropping, letterboxing if needed. Good when you must preserve the
|
|
23
23
|
* entire image.
|
|
24
|
+
* - omitted: scales the whole image to fit without cropping (no pixels are
|
|
25
|
+
* discarded).
|
|
24
26
|
*
|
|
25
27
|
* Decoding & EXIF orientation
|
|
26
28
|
* EXIF orientation is a piece of metadata stored inside image files
|
|
@@ -46,9 +48,11 @@
|
|
|
46
48
|
* library or WASM module; this utility intentionally avoids extra dependencies.
|
|
47
49
|
*
|
|
48
50
|
* Output type & quality
|
|
49
|
-
* -
|
|
50
|
-
*
|
|
51
|
-
*
|
|
51
|
+
* - When `type` is omitted the source format is kept if the canvas can encode
|
|
52
|
+
* it: a PNG stays a PNG (preserving transparency) and everything else is
|
|
53
|
+
* encoded as `image/jpeg`. Set `type` explicitly to force a format.
|
|
54
|
+
* - JPEG `quality` is optional; when omitted the browser's native encoding
|
|
55
|
+
* quality is used rather than an imposed value.
|
|
52
56
|
* - The output filename extension is adjusted to match the chosen MIME type by
|
|
53
57
|
* default (e.g., `.jpg` or `.png`). You can override naming via the `rename`
|
|
54
58
|
* option.
|
|
@@ -73,9 +77,9 @@
|
|
|
73
77
|
* const processed = await resizeImage(file, {
|
|
74
78
|
* width: 400,
|
|
75
79
|
* height: 400,
|
|
76
|
-
* fit: 'cover', //
|
|
77
|
-
* type: 'image/jpeg', //
|
|
78
|
-
* quality: 0.85, //
|
|
80
|
+
* fit: 'cover', // center-crops; omit to fit without cropping
|
|
81
|
+
* type: 'image/jpeg', // omit to keep the source format
|
|
82
|
+
* quality: 0.85, // omit to use the browser's native quality
|
|
79
83
|
* });
|
|
80
84
|
* // Upload `processed` instead of the original file
|
|
81
85
|
* ```
|
|
@@ -104,6 +108,17 @@
|
|
|
104
108
|
* ```
|
|
105
109
|
*/
|
|
106
110
|
// (Removed exported ResizeFit to avoid forcing a public symbol.)
|
|
111
|
+
/**
|
|
112
|
+
* The image formats the canvas can reliably encode, mapped to their filename
|
|
113
|
+
* extension. Single source of truth for what this util can output: both the
|
|
114
|
+
* output-type resolution and the output filename extension derive from it. The
|
|
115
|
+
* `ResizeOptions.type` union below mirrors these keys — keep the two in sync
|
|
116
|
+
* when adding a format.
|
|
117
|
+
*/
|
|
118
|
+
const ENCODABLE_TYPES = {
|
|
119
|
+
'image/jpeg': 'jpg',
|
|
120
|
+
'image/png': 'png',
|
|
121
|
+
};
|
|
107
122
|
/**
|
|
108
123
|
* Resize an image file on the client using Canvas/OffscreenCanvas.
|
|
109
124
|
* Returns a new File with the requested format and dimensions.
|
|
@@ -118,9 +133,22 @@
|
|
|
118
133
|
* @param options - Configuration for the resize operation.
|
|
119
134
|
*/
|
|
120
135
|
export async function resizeImage(file, options) {
|
|
121
|
-
|
|
136
|
+
var _a;
|
|
137
|
+
// This shared utility carries no opinionated defaults: an omitted option
|
|
138
|
+
// preserves the source file's own property rather than imposing a value.
|
|
139
|
+
// Consumers that want a specific default (e.g. limel-profile-picture) apply
|
|
140
|
+
// it themselves. See the `ResizeOptions` doc for the per-option behavior.
|
|
141
|
+
const { fit, quality } = options;
|
|
142
|
+
// When no output type is requested, keep the source file's format if the
|
|
143
|
+
// canvas can encode it (PNG stays PNG, preserving transparency); anything
|
|
144
|
+
// else falls back to JPEG.
|
|
145
|
+
const type = resolveOutputType(file.type, options.type);
|
|
146
|
+
const rename = (_a = options.rename) !== null && _a !== void 0 ? _a : ((name) => renameWithType(name, type));
|
|
122
147
|
const source = await loadSource(file);
|
|
123
|
-
const
|
|
148
|
+
const sourceWidth = source.width;
|
|
149
|
+
const sourceHeight = source.height;
|
|
150
|
+
const { width, height } = resolveTargetSize(sourceWidth, sourceHeight, options.width, options.height);
|
|
151
|
+
const { sx, sy, sw, sh, dx, dy, dw, dh } = computeRects(sourceWidth, sourceHeight, width, height, fit);
|
|
124
152
|
const canvas = createCanvas(width, height);
|
|
125
153
|
const ctx = get2dContext(canvas);
|
|
126
154
|
ctx.clearRect(0, 0, width, height);
|
|
@@ -129,6 +157,43 @@ export async function resizeImage(file, options) {
|
|
|
129
157
|
const name = rename(file.name);
|
|
130
158
|
return new File([blob], name, { type });
|
|
131
159
|
}
|
|
160
|
+
/**
|
|
161
|
+
* Resolve the target dimensions from the requested options, falling back to the
|
|
162
|
+
* source. A missing dimension is derived from the given one to preserve the
|
|
163
|
+
* source aspect ratio; when both are missing the source size is kept, so the
|
|
164
|
+
* image is only re-encoded. Non-positive requests are treated as missing.
|
|
165
|
+
*
|
|
166
|
+
* @param sourceWidth - Decoded source width
|
|
167
|
+
* @param sourceHeight - Decoded source height
|
|
168
|
+
* @param width - Requested target width, if any
|
|
169
|
+
* @param height - Requested target height, if any
|
|
170
|
+
*/
|
|
171
|
+
function resolveTargetSize(sourceWidth, sourceHeight, width, height) {
|
|
172
|
+
// A dimension counts as "requested" only when it's a positive number;
|
|
173
|
+
// `undefined`, `0`, negatives and `NaN` are all treated as missing.
|
|
174
|
+
const hasWidth = typeof width === 'number' && width > 0;
|
|
175
|
+
const hasHeight = typeof height === 'number' && height > 0;
|
|
176
|
+
// Both requested: use them as-is.
|
|
177
|
+
if (hasWidth && hasHeight) {
|
|
178
|
+
return { width, height };
|
|
179
|
+
}
|
|
180
|
+
// Only width requested: derive the height to keep the aspect ratio.
|
|
181
|
+
if (hasWidth) {
|
|
182
|
+
return {
|
|
183
|
+
width,
|
|
184
|
+
height: Math.round(width * (sourceHeight / sourceWidth)),
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
// Only height requested: derive the width to keep the aspect ratio.
|
|
188
|
+
if (hasHeight) {
|
|
189
|
+
return {
|
|
190
|
+
width: Math.round(height * (sourceWidth / sourceHeight)),
|
|
191
|
+
height,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
// Neither requested: keep the source size and only re-encode.
|
|
195
|
+
return { width: sourceWidth, height: sourceHeight };
|
|
196
|
+
}
|
|
132
197
|
/** Whether OffscreenCanvas is available in the current environment. */
|
|
133
198
|
function supportsOffscreen() {
|
|
134
199
|
try {
|
|
@@ -167,7 +232,7 @@ function get2dContext(canvas) {
|
|
|
167
232
|
* Convert the canvas content to a Blob, supporting both canvas types.
|
|
168
233
|
* @param canvas - The source canvas
|
|
169
234
|
* @param type - Output MIME type
|
|
170
|
-
* @param quality - JPEG quality (0..1)
|
|
235
|
+
* @param quality - JPEG quality (0..1); omit to use the browser default
|
|
171
236
|
*/
|
|
172
237
|
function canvasToBlob(canvas, type, quality) {
|
|
173
238
|
if ('convertToBlob' in canvas) {
|
|
@@ -253,7 +318,8 @@ async function loadImageElement(file) {
|
|
|
253
318
|
* @param sh - Source height
|
|
254
319
|
* @param tw - Target width
|
|
255
320
|
* @param th - Target height
|
|
256
|
-
* @param fit - Fit mode (
|
|
321
|
+
* @param fit - Fit mode; `cover` center-crops, anything else (including
|
|
322
|
+
* omitted) scales the whole image to fit without cropping
|
|
257
323
|
*/
|
|
258
324
|
function computeRects(sw, sh, tw, th, fit) {
|
|
259
325
|
const sRatio = sw / sh;
|
|
@@ -291,13 +357,30 @@ function computeRects(sw, sh, tw, th, fit) {
|
|
|
291
357
|
const dy = (th - drawH) / 2;
|
|
292
358
|
return { sx: 0, sy: 0, sw, sh, dx, dy, dw: drawW, dh: drawH };
|
|
293
359
|
}
|
|
360
|
+
/**
|
|
361
|
+
* Resolve the output MIME type. An explicit request wins; otherwise the source
|
|
362
|
+
* file's format is kept when the canvas can encode it (see `ENCODABLE_TYPES`),
|
|
363
|
+
* so a PNG stays a PNG (preserving transparency) while every other input
|
|
364
|
+
* (WebP, HEIC, …) falls back to JPEG.
|
|
365
|
+
*
|
|
366
|
+
* @param sourceType - MIME type of the input file
|
|
367
|
+
* @param requested - Explicitly requested output type, if any
|
|
368
|
+
*/
|
|
369
|
+
function resolveOutputType(sourceType, requested) {
|
|
370
|
+
if (requested) {
|
|
371
|
+
return requested;
|
|
372
|
+
}
|
|
373
|
+
// Keep the source format when the canvas can encode it; otherwise JPEG.
|
|
374
|
+
const canEncodeSource = Object.prototype.hasOwnProperty.call(ENCODABLE_TYPES, sourceType);
|
|
375
|
+
return canEncodeSource ? sourceType : 'image/jpeg';
|
|
376
|
+
}
|
|
294
377
|
/**
|
|
295
378
|
* Update filename extension to match the desired MIME type.
|
|
296
379
|
* @param name - Original filename
|
|
297
380
|
* @param type - Output MIME type
|
|
298
381
|
*/
|
|
299
382
|
function renameWithType(name, type) {
|
|
300
|
-
const ext = type
|
|
383
|
+
const ext = ENCODABLE_TYPES[type];
|
|
301
384
|
const idx = name.lastIndexOf('.');
|
|
302
385
|
const base = idx > 0 ? name.slice(0, idx) : name;
|
|
303
386
|
return `${base}.${ext}`;
|