@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.
Files changed (106) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/dist/cjs/{image-resize-FAwzj92q.js → image-resize-CG2fitJT.js} +97 -14
  3. package/dist/cjs/index.cjs.js +1 -1
  4. package/dist/cjs/lime-elements.cjs.js +1 -1
  5. package/dist/cjs/limel-action-bar_3.cjs.entry.js +1 -1
  6. package/dist/cjs/limel-ai-avatar.cjs.entry.js +1 -1
  7. package/dist/cjs/limel-callout.cjs.entry.js +1 -1
  8. package/dist/cjs/limel-chart.cjs.entry.js +1 -1
  9. package/dist/cjs/limel-chip-set.cjs.entry.js +1 -1
  10. package/dist/cjs/limel-chip_2.cjs.entry.js +1 -1
  11. package/dist/cjs/limel-code-diff.cjs.entry.js +1 -1
  12. package/dist/cjs/limel-code-editor.cjs.entry.js +1 -1
  13. package/dist/cjs/limel-collapsible-section.cjs.entry.js +1 -1
  14. package/dist/cjs/limel-drag-handle.cjs.entry.js +1 -1
  15. package/dist/cjs/limel-email-viewer.cjs.entry.js +1 -1
  16. package/dist/cjs/limel-file-viewer.cjs.entry.js +1 -1
  17. package/dist/cjs/limel-file.cjs.entry.js +84 -18
  18. package/dist/cjs/limel-flatpickr-adapter.cjs.entry.js +1 -1
  19. package/dist/cjs/limel-list-item.cjs.entry.js +1 -1
  20. package/dist/cjs/limel-picker.cjs.entry.js +1 -1
  21. package/dist/cjs/limel-profile-picture.cjs.entry.js +12 -5
  22. package/dist/cjs/limel-prosemirror-adapter.cjs.entry.js +1 -1
  23. package/dist/cjs/limel-snackbar.cjs.entry.js +1 -1
  24. package/dist/cjs/limel-table.cjs.entry.js +1 -1
  25. package/dist/cjs/limel-text-editor-link-menu.cjs.entry.js +1 -1
  26. package/dist/cjs/loader.cjs.js +1 -1
  27. package/dist/cjs/{translations-VEsa81QY.js → translations-Bnw00bkf.js} +8 -0
  28. package/dist/collection/components/file/file.js +119 -18
  29. package/dist/collection/components/profile-picture/profile-picture.js +12 -5
  30. package/dist/collection/translations/da.js +1 -0
  31. package/dist/collection/translations/de.js +1 -0
  32. package/dist/collection/translations/en.js +1 -0
  33. package/dist/collection/translations/fi.js +1 -0
  34. package/dist/collection/translations/fr.js +1 -0
  35. package/dist/collection/translations/nl.js +1 -0
  36. package/dist/collection/translations/no.js +1 -0
  37. package/dist/collection/translations/sv.js +1 -0
  38. package/dist/collection/util/image-resize.js +97 -14
  39. package/dist/esm/{image-resize-DPcww8rE.js → image-resize-BTSG--2-.js} +97 -14
  40. package/dist/esm/index.js +1 -1
  41. package/dist/esm/lime-elements.js +1 -1
  42. package/dist/esm/limel-action-bar_3.entry.js +1 -1
  43. package/dist/esm/limel-ai-avatar.entry.js +1 -1
  44. package/dist/esm/limel-callout.entry.js +1 -1
  45. package/dist/esm/limel-chart.entry.js +1 -1
  46. package/dist/esm/limel-chip-set.entry.js +1 -1
  47. package/dist/esm/limel-chip_2.entry.js +1 -1
  48. package/dist/esm/limel-code-diff.entry.js +1 -1
  49. package/dist/esm/limel-code-editor.entry.js +1 -1
  50. package/dist/esm/limel-collapsible-section.entry.js +1 -1
  51. package/dist/esm/limel-drag-handle.entry.js +1 -1
  52. package/dist/esm/limel-email-viewer.entry.js +1 -1
  53. package/dist/esm/limel-file-viewer.entry.js +1 -1
  54. package/dist/esm/limel-file.entry.js +84 -18
  55. package/dist/esm/limel-flatpickr-adapter.entry.js +1 -1
  56. package/dist/esm/limel-list-item.entry.js +1 -1
  57. package/dist/esm/limel-picker.entry.js +1 -1
  58. package/dist/esm/limel-profile-picture.entry.js +12 -5
  59. package/dist/esm/limel-prosemirror-adapter.entry.js +1 -1
  60. package/dist/esm/limel-snackbar.entry.js +1 -1
  61. package/dist/esm/limel-table.entry.js +1 -1
  62. package/dist/esm/limel-text-editor-link-menu.entry.js +1 -1
  63. package/dist/esm/loader.js +1 -1
  64. package/dist/esm/{translations-EPnIW0K5.js → translations-Cb9R_zmF.js} +8 -0
  65. package/dist/lime-elements/index.esm.js +1 -1
  66. package/dist/lime-elements/lime-elements.esm.js +1 -1
  67. package/dist/lime-elements/{p-068e7087.entry.js → p-19c81ded.entry.js} +2 -2
  68. package/dist/lime-elements/{p-56781dc7.entry.js → p-1cc3b516.entry.js} +1 -1
  69. package/dist/lime-elements/{p-ca0a8a5a.entry.js → p-1e3cdfa1.entry.js} +1 -1
  70. package/dist/lime-elements/{p-2531a103.entry.js → p-2284caa7.entry.js} +1 -1
  71. package/dist/lime-elements/{p-d8cb7d42.entry.js → p-2f223e46.entry.js} +1 -1
  72. package/dist/lime-elements/{p-a3a2aafe.entry.js → p-4a09fb2e.entry.js} +1 -1
  73. package/dist/lime-elements/{p-f197036b.entry.js → p-552d0733.entry.js} +1 -1
  74. package/dist/lime-elements/{p-fd78537c.entry.js → p-6fbf20c6.entry.js} +1 -1
  75. package/dist/lime-elements/{p-5571f0de.entry.js → p-758939be.entry.js} +1 -1
  76. package/dist/lime-elements/{p-19347686.entry.js → p-7f7e2180.entry.js} +1 -1
  77. package/dist/lime-elements/p-8893e1df.entry.js +1 -0
  78. package/dist/lime-elements/p-8aeeeb53.entry.js +1 -0
  79. package/dist/lime-elements/{p-79caf3f6.entry.js → p-9ae7d11b.entry.js} +1 -1
  80. package/dist/lime-elements/p-BTSG--2-.js +1 -0
  81. package/dist/lime-elements/p-Cb9R_zmF.js +1 -0
  82. package/dist/lime-elements/{p-6edf13b1.entry.js → p-a089b580.entry.js} +1 -1
  83. package/dist/lime-elements/{p-04b4e903.entry.js → p-aa080a8f.entry.js} +1 -1
  84. package/dist/lime-elements/{p-54961e0e.entry.js → p-c1ceade7.entry.js} +1 -1
  85. package/dist/lime-elements/{p-e08f770e.entry.js → p-c8116a01.entry.js} +1 -1
  86. package/dist/lime-elements/{p-36ccc68e.entry.js → p-c8982eef.entry.js} +1 -1
  87. package/dist/lime-elements/{p-d98168a1.entry.js → p-ca0b86ee.entry.js} +1 -1
  88. package/dist/lime-elements/{p-1525274d.entry.js → p-d7bb4310.entry.js} +1 -1
  89. package/dist/lime-elements/{p-bb201b0d.entry.js → p-e545c9e8.entry.js} +1 -1
  90. package/dist/types/components/file/file.d.ts +20 -0
  91. package/dist/types/components/profile-picture/profile-picture.d.ts +2 -0
  92. package/dist/types/components.d.ts +22 -4
  93. package/dist/types/translations/da.d.ts +1 -0
  94. package/dist/types/translations/de.d.ts +1 -0
  95. package/dist/types/translations/en.d.ts +1 -0
  96. package/dist/types/translations/fi.d.ts +1 -0
  97. package/dist/types/translations/fr.d.ts +1 -0
  98. package/dist/types/translations/nl.d.ts +1 -0
  99. package/dist/types/translations/no.d.ts +1 -0
  100. package/dist/types/translations/sv.d.ts +1 -0
  101. package/dist/types/util/image-resize.d.ts +41 -16
  102. package/package.json +1 -1
  103. package/dist/lime-elements/p-2a871a95.entry.js +0 -1
  104. package/dist/lime-elements/p-5cf7464f.entry.js +0 -1
  105. package/dist/lime-elements/p-DPcww8rE.js +0 -1
  106. 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
- this.change.emit(event.detail[0]);
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: 'efdeb4e071a7bf484780eb239a1117a0e0fd0472', "aria-busy": this.isBusy ? 'true' : 'false' }, h("limel-file-dropzone", { key: '88d3924f5db23d1f3b07321bc7dc35663feeac8f', disabled: this.disabled || this.readonly || !!this.value, accept: this.accept, onFilesSelected: this.handleNewFiles }, this.renderChipset()), this.renderDragAndDropTip(), this.renderSpinner()));
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.value) === null || _a === void 0 ? void 0 : _a.statusText) === null || _b === void 0 ? void 0 : _b.trim()) !== null && _c !== void 0 ? _c : '';
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.value) === null || _a === void 0 ? void 0 : _a.loading) ||
121
- ((_b = this.value) === null || _b === void 0 ? void 0 : _b.progress) !== undefined);
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.value || this.disabled || this.readonly || this.isBusy) {
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
- if (!this.value) {
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: this.value.filename, id: this.value.id, icon: {
141
- name: getFileIcon(this.value),
142
- title: getFileExtensionTitle(this.value),
143
- color: getFileColor(this.value),
144
- backgroundColor: getFileBackgroundColor(this.value),
145
- }, badge: this.getBadge(), href: this.value.href, menuItems: this.value.menuItems, loading: this.value.loading, progress: this.value.progress, invalid: this.value.invalid }),
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.value.size === 'number') {
153
- return formatBytes(this.value.size);
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 (_c) {
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: number; height: number; fit?: \"cover\" | \"contain\"; type?: \"image/jpeg\" | \"image/png\"; quality?: number; rename?: (originalName: string) => string; }",
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 avatar sizes and formats (e.g., JPEG 400x400)
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` (default): The image is scaled to cover the target rectangle, and
20
- * the excess parts are center-cropped. Good for avatars.
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
- * - Default output is `image/jpeg` with `quality=0.85`, which is typically
50
- * appropriate for avatars. You can switch to `image/png` to preserve
51
- * transparency.
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', // default; center-crops
77
- * type: 'image/jpeg', // default
78
- * quality: 0.85, // default
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
- const { width, height, fit = 'cover', type = 'image/jpeg', quality = 0.85, rename = (name) => renameWithType(name, type), } = options;
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 { sx, sy, sw, sh, dx, dy, dw, dh } = computeRects(source.width, source.height, width, height, fit);
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 (cover/contain)
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 === 'image/png' ? 'png' : 'jpg';
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}`;