sprintify-ui 0.11.27 → 0.11.29

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.
@@ -7,6 +7,7 @@ type __VLS_Props = {
7
7
  beforeUpload?: () => boolean;
8
8
  twButton?: string;
9
9
  maxSize?: number;
10
+ maxImageSizeBeforeResize?: number;
10
11
  accept?: string;
11
12
  acceptedExtensions?: string[];
12
13
  cropper?: BaseCropperConfig | Record<string, any> | boolean | null;
@@ -64,6 +65,7 @@ declare const __VLS_component: import("vue").DefineComponent<__VLS_Props, {}, {}
64
65
  accept: string;
65
66
  acceptedExtensions: string[];
66
67
  beforeUpload: () => boolean;
68
+ maxImageSizeBeforeResize: number;
67
69
  }, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
68
70
  declare const _default: __VLS_WithSlots<typeof __VLS_component, __VLS_Slots>;
69
71
  export default _default;
@@ -52,6 +52,10 @@ declare const __VLS_component: import("vue").DefineComponent<import("vue").Extra
52
52
  default: number;
53
53
  type: NumberConstructor;
54
54
  };
55
+ maxImageSizeBeforeResize: {
56
+ default: undefined;
57
+ type: NumberConstructor;
58
+ };
55
59
  accept: {
56
60
  default: undefined;
57
61
  type: StringConstructor;
@@ -127,6 +131,10 @@ declare const __VLS_component: import("vue").DefineComponent<import("vue").Extra
127
131
  default: number;
128
132
  type: NumberConstructor;
129
133
  };
134
+ maxImageSizeBeforeResize: {
135
+ default: undefined;
136
+ type: NumberConstructor;
137
+ };
130
138
  accept: {
131
139
  default: undefined;
132
140
  type: StringConstructor;
@@ -195,6 +203,7 @@ declare const __VLS_component: import("vue").DefineComponent<import("vue").Extra
195
203
  maxSize: number;
196
204
  accept: string;
197
205
  acceptedExtensions: string[];
206
+ maxImageSizeBeforeResize: number;
198
207
  currentMedia: Media[];
199
208
  uploadUrl: string;
200
209
  pickerComponent: "BaseFilePicker" | "BaseFilePickerCrop";
@@ -1,8 +1,9 @@
1
1
  import toHumanList from './toHumanList';
2
2
  import fileSizeFormat from './fileSizeFormat';
3
3
  import resizeImageFromURI from './resizeImageFromURI';
4
+ import resizeImageForUpload from './resizeImageForUpload';
4
5
  import { disableScroll, enableScroll } from './scrollPreventer';
5
6
  import { blobToBase64, validateBase64, base64ToBlob } from './blob';
6
7
  import { getColorConfig } from './colors';
7
8
  import { getInitials, getAvatarColor } from './avatar';
8
- export { toHumanList, fileSizeFormat, disableScroll, enableScroll, resizeImageFromURI, blobToBase64, validateBase64, base64ToBlob, getColorConfig, getInitials, getAvatarColor, };
9
+ export { toHumanList, fileSizeFormat, disableScroll, enableScroll, resizeImageFromURI, resizeImageForUpload, blobToBase64, validateBase64, base64ToBlob, getColorConfig, getInitials, getAvatarColor, };
@@ -0,0 +1,7 @@
1
+ type ResizeImageForUploadOptions = {
2
+ maxBytes: number;
3
+ outputMimeType?: string;
4
+ quality?: number;
5
+ };
6
+ export default function resizeImageForUpload(file: Blob, options: ResizeImageForUploadOptions): Promise<Blob>;
7
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sprintify-ui",
3
- "version": "0.11.27",
3
+ "version": "0.11.29",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "generate-llm-txt": "node scripts/generate-llm-txt.js",
@@ -35,6 +35,7 @@ import { UploadedFile } from '@/types/UploadedFile';
35
35
  import { useSnackbarsStore } from '@/stores/snackbars';
36
36
  import BaseLoadingCover from '@/components/BaseLoadingCover.vue';
37
37
  import { BaseCropperConfig } from '@/types';
38
+ import { resizeImageForUpload } from '@/utils';
38
39
  import BaseFilePicker from './BaseFilePicker.vue';
39
40
  import BaseFilePickerCrop from './BaseFilePickerCrop.vue';
40
41
  import { t } from '@/i18n';
@@ -51,6 +52,7 @@ const props = withDefaults(
51
52
  beforeUpload?: () => boolean;
52
53
  twButton?: string;
53
54
  maxSize?: number;
55
+ maxImageSizeBeforeResize?: number;
54
56
  accept?: string;
55
57
  acceptedExtensions?: string[];
56
58
  cropper?: BaseCropperConfig | Record<string, any> | boolean | null;
@@ -66,6 +68,7 @@ const props = withDefaults(
66
68
  },
67
69
  twButton: '',
68
70
  maxSize: undefined,
71
+ maxImageSizeBeforeResize: undefined,
69
72
  accept: undefined,
70
73
  acceptedExtensions: undefined,
71
74
  cropper: true,
@@ -149,15 +152,16 @@ async function onFileSelect(files: File | File[]) {
149
152
  }
150
153
 
151
154
  async function processFileUpload(file: File): Promise<UploadedFile> {
155
+ const preparedFile = await processFileBeforeUpload(file);
152
156
 
153
157
  const formData = new FormData();
154
158
 
155
- formData.append('file', file);
159
+ formData.append('file', preparedFile);
156
160
 
157
161
  const response = await http.post(props.url ?? config.upload_url, formData);
158
162
 
159
163
  const payload = response.data as UploadedFile;
160
- payload.original_file = file;
164
+ payload.original_file = preparedFile;
161
165
 
162
166
  // Read file if image, add add data_url to payload
163
167
 
@@ -175,11 +179,81 @@ async function processFileUpload(file: File): Promise<UploadedFile> {
175
179
  };
176
180
 
177
181
  if (payload.mime_type.includes('image')) {
178
- reader.readAsDataURL(file);
182
+ reader.readAsDataURL(preparedFile);
179
183
  } else {
180
184
  resolve(payload);
181
185
  }
182
186
  });
183
187
  }
184
188
 
189
+ async function processFileBeforeUpload(file: File): Promise<File> {
190
+ if (!shouldAutoResize(file)) {
191
+ return file;
192
+ }
193
+
194
+ return await autoResizeImage(file);
195
+ }
196
+
197
+ function shouldAutoResize(file: File): boolean {
198
+ if (!props.maxImageSizeBeforeResize || props.maxImageSizeBeforeResize <= 0) {
199
+ return false;
200
+ }
201
+
202
+ if (!file.type.includes('image')) {
203
+ return false;
204
+ }
205
+
206
+ if (['image/gif', 'image/svg+xml'].includes(file.type)) {
207
+ return false;
208
+ }
209
+
210
+ if (file.size <= props.maxImageSizeBeforeResize) {
211
+ return false;
212
+ }
213
+
214
+ return true;
215
+ }
216
+
217
+ async function autoResizeImage(file: File): Promise<File> {
218
+ if (!props.maxImageSizeBeforeResize) {
219
+ return file;
220
+ }
221
+
222
+ try {
223
+ const resizedBlob = await resizeImageForUpload(file, {
224
+ maxBytes: props.maxImageSizeBeforeResize,
225
+ });
226
+
227
+ if (resizedBlob.size >= file.size) {
228
+ return file;
229
+ }
230
+
231
+ const mimeType = resizedBlob.type || file.type;
232
+
233
+ return new File(
234
+ [resizedBlob],
235
+ getResizedFileName(file.name, mimeType),
236
+ {
237
+ type: mimeType,
238
+ lastModified: file.lastModified,
239
+ }
240
+ );
241
+ } catch (error) {
242
+ console.error(error);
243
+ return file;
244
+ }
245
+ }
246
+
247
+ function getResizedFileName(filename: string, mimeType: string): string {
248
+ if (mimeType != 'image/jpeg') {
249
+ return filename;
250
+ }
251
+
252
+ if (filename.includes('.')) {
253
+ return filename.replace(/\.[^.]+$/, '.jpg');
254
+ }
255
+
256
+ return `${filename}.jpg`;
257
+ }
258
+
185
259
  </script>
@@ -3,6 +3,7 @@
3
3
  <BaseFileUploader
4
4
  :component="pickerComponent"
5
5
  :max-size="maxSize"
6
+ :max-image-size-before-resize="maxImageSizeBeforeResize"
6
7
  :disabled="disabledInternal"
7
8
  class="w-full"
8
9
  tw-button="w-full"
@@ -142,6 +143,10 @@ const props = defineProps({
142
143
  default: 20 * 1024 * 1024,
143
144
  type: Number,
144
145
  },
146
+ maxImageSizeBeforeResize: {
147
+ default: undefined,
148
+ type: Number,
149
+ },
145
150
  accept: {
146
151
  default: undefined,
147
152
  type: String,
@@ -11,7 +11,7 @@
11
11
  class="fixed inset-0 z-modal w-full overflow-y-auto overflow-x-hidden"
12
12
  >
13
13
  <div class="flex min-h-full w-full pt-20 sm:pt-0">
14
- <div class="min-h-full grow">
14
+ <div class="min-h-full overflow-hidden grow">
15
15
  <transition
16
16
  appear
17
17
  enter-active-class="duration-200 ease-out"
@@ -1,6 +1,7 @@
1
1
  import toHumanList from './toHumanList';
2
2
  import fileSizeFormat from './fileSizeFormat';
3
3
  import resizeImageFromURI from './resizeImageFromURI';
4
+ import resizeImageForUpload from './resizeImageForUpload';
4
5
  import { disableScroll, enableScroll } from './scrollPreventer';
5
6
  import { blobToBase64, validateBase64, base64ToBlob } from './blob';
6
7
  import { getColorConfig } from './colors';
@@ -12,6 +13,7 @@ export {
12
13
  disableScroll,
13
14
  enableScroll,
14
15
  resizeImageFromURI,
16
+ resizeImageForUpload,
15
17
  blobToBase64,
16
18
  validateBase64,
17
19
  base64ToBlob,
@@ -0,0 +1,181 @@
1
+ type ResizeImageForUploadOptions = {
2
+ maxBytes: number;
3
+ outputMimeType?: string;
4
+ quality?: number;
5
+ };
6
+
7
+ type DecodedImage = {
8
+ source: CanvasImageSource;
9
+ width: number;
10
+ height: number;
11
+ cleanup: () => void;
12
+ };
13
+
14
+ const DEFAULT_OUTPUT_QUALITY = 0.9;
15
+
16
+ export default async function resizeImageForUpload(
17
+ file: Blob,
18
+ options: ResizeImageForUploadOptions
19
+ ): Promise<Blob> {
20
+ if (options.maxBytes <= 0 || file.size <= options.maxBytes) {
21
+ return file;
22
+ }
23
+
24
+ const decodedImage = await decodeImage(file);
25
+ const outputMimeType =
26
+ options.outputMimeType ?? getOutputMimeType((file as File).type);
27
+ const quality = clamp(options.quality ?? DEFAULT_OUTPUT_QUALITY, 0.1, 1);
28
+
29
+ let canvas: HTMLCanvasElement | null = null;
30
+
31
+ try {
32
+ const resizeRatio = Math.min(1, Math.sqrt(options.maxBytes / file.size));
33
+ const targetWidth = Math.max(1, Math.floor(decodedImage.width * resizeRatio));
34
+ const targetHeight = Math.max(
35
+ 1,
36
+ Math.floor(decodedImage.height * resizeRatio)
37
+ );
38
+
39
+ canvas = createScaledCanvas(
40
+ decodedImage.source,
41
+ decodedImage.width,
42
+ decodedImage.height,
43
+ targetWidth,
44
+ targetHeight
45
+ );
46
+
47
+ if (!supportsQualityEncoding(outputMimeType)) {
48
+ return await canvasToBlob(canvas, outputMimeType);
49
+ }
50
+
51
+ return await canvasToBlob(canvas, outputMimeType, quality);
52
+ } finally {
53
+ decodedImage.cleanup();
54
+
55
+ if (canvas) {
56
+ releaseCanvas(canvas);
57
+ }
58
+ }
59
+ }
60
+
61
+ function getOutputMimeType(inputMimeType: string): string {
62
+ if (inputMimeType === 'image/webp') {
63
+ return 'image/webp';
64
+ }
65
+
66
+ return 'image/jpeg';
67
+ }
68
+
69
+ async function decodeImage(file: Blob): Promise<DecodedImage> {
70
+ if (typeof createImageBitmap === 'function') {
71
+ try {
72
+ const bitmap = await createImageBitmap(file);
73
+
74
+ return {
75
+ source: bitmap,
76
+ width: bitmap.width,
77
+ height: bitmap.height,
78
+ cleanup: () => bitmap.close(),
79
+ };
80
+ } catch {
81
+ // Fallback to Image decode when createImageBitmap is not supported.
82
+ }
83
+ }
84
+
85
+ const objectUrl = URL.createObjectURL(file);
86
+
87
+ try {
88
+ const image = await loadImage(objectUrl);
89
+
90
+ return {
91
+ source: image,
92
+ width: image.naturalWidth || image.width,
93
+ height: image.naturalHeight || image.height,
94
+ cleanup: () => URL.revokeObjectURL(objectUrl),
95
+ };
96
+ } catch (error) {
97
+ URL.revokeObjectURL(objectUrl);
98
+ throw error;
99
+ }
100
+ }
101
+
102
+ async function loadImage(src: string): Promise<HTMLImageElement> {
103
+ const image = new Image();
104
+ image.decoding = 'async';
105
+ image.src = src;
106
+
107
+ if (typeof image.decode === 'function') {
108
+ try {
109
+ await image.decode();
110
+ return image;
111
+ } catch {
112
+ // decode() may reject if called before enough data is available.
113
+ }
114
+ }
115
+
116
+ return await new Promise((resolve, reject) => {
117
+ image.onload = () => resolve(image);
118
+ image.onerror = () => reject(new Error('Failed to decode image'));
119
+ });
120
+ }
121
+
122
+ function createScaledCanvas(
123
+ source: CanvasImageSource,
124
+ sourceWidth: number,
125
+ sourceHeight: number,
126
+ width: number,
127
+ height: number
128
+ ): HTMLCanvasElement {
129
+ const canvas = document.createElement('canvas');
130
+ canvas.width = width;
131
+ canvas.height = height;
132
+
133
+ const context = canvas.getContext('2d', {
134
+ alpha: false,
135
+ desynchronized: true,
136
+ });
137
+
138
+ if (!context) {
139
+ throw new Error('Could not acquire context 2d');
140
+ }
141
+
142
+ context.imageSmoothingEnabled = true;
143
+ context.imageSmoothingQuality = 'high';
144
+ context.drawImage(source, 0, 0, sourceWidth, sourceHeight, 0, 0, width, height);
145
+
146
+ return canvas;
147
+ }
148
+
149
+ function supportsQualityEncoding(mimeType: string): boolean {
150
+ return mimeType === 'image/jpeg' || mimeType === 'image/webp';
151
+ }
152
+
153
+ async function canvasToBlob(
154
+ canvas: HTMLCanvasElement,
155
+ mimeType: string,
156
+ quality?: number
157
+ ): Promise<Blob> {
158
+ return await new Promise((resolve, reject) => {
159
+ canvas.toBlob(
160
+ (blob) => {
161
+ if (blob) {
162
+ resolve(blob);
163
+ return;
164
+ }
165
+
166
+ reject(new Error('Could not create resized image blob'));
167
+ },
168
+ mimeType,
169
+ quality
170
+ );
171
+ });
172
+ }
173
+
174
+ function releaseCanvas(canvas: HTMLCanvasElement) {
175
+ canvas.width = 0;
176
+ canvas.height = 0;
177
+ }
178
+
179
+ function clamp(value: number, min: number, max: number): number {
180
+ return Math.min(max, Math.max(min, value));
181
+ }