sprintify-ui 0.11.28 → 0.11.30
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/dist/sprintify-ui.es.js +3649 -3563
- package/dist/types/components/BaseAvatar.vue.d.ts +36 -4
- package/dist/types/utils/index.d.ts +2 -1
- package/dist/types/utils/resizeImageForUpload.d.ts +7 -0
- package/package.json +1 -1
- package/src/components/BaseAvatar.vue +46 -11
- package/src/components/BaseButton.vue +1 -0
- package/src/components/BaseFileUploader.vue +8 -64
- package/src/utils/index.ts +2 -0
- package/src/utils/resizeImageForUpload.ts +181 -0
|
@@ -1,10 +1,26 @@
|
|
|
1
1
|
import { PropType } from 'vue';
|
|
2
2
|
import { User } from '@/types/User';
|
|
3
3
|
import { RouteLocationRaw } from 'vue-router';
|
|
4
|
+
type AvatarUserInput = Partial<User> & {
|
|
5
|
+
name?: string;
|
|
6
|
+
label?: string;
|
|
7
|
+
};
|
|
4
8
|
declare const _default: import("vue").DefineComponent<import("vue").ExtractPropTypes<{
|
|
5
9
|
user: {
|
|
6
|
-
|
|
7
|
-
type: PropType<
|
|
10
|
+
default: undefined;
|
|
11
|
+
type: PropType<AvatarUserInput>;
|
|
12
|
+
};
|
|
13
|
+
name: {
|
|
14
|
+
default: undefined;
|
|
15
|
+
type: StringConstructor;
|
|
16
|
+
};
|
|
17
|
+
email: {
|
|
18
|
+
default: undefined;
|
|
19
|
+
type: StringConstructor;
|
|
20
|
+
};
|
|
21
|
+
src: {
|
|
22
|
+
default: undefined;
|
|
23
|
+
type: StringConstructor;
|
|
8
24
|
};
|
|
9
25
|
size: {
|
|
10
26
|
default: string;
|
|
@@ -32,8 +48,20 @@ declare const _default: import("vue").DefineComponent<import("vue").ExtractPropT
|
|
|
32
48
|
};
|
|
33
49
|
}>, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<import("vue").ExtractPropTypes<{
|
|
34
50
|
user: {
|
|
35
|
-
|
|
36
|
-
type: PropType<
|
|
51
|
+
default: undefined;
|
|
52
|
+
type: PropType<AvatarUserInput>;
|
|
53
|
+
};
|
|
54
|
+
name: {
|
|
55
|
+
default: undefined;
|
|
56
|
+
type: StringConstructor;
|
|
57
|
+
};
|
|
58
|
+
email: {
|
|
59
|
+
default: undefined;
|
|
60
|
+
type: StringConstructor;
|
|
61
|
+
};
|
|
62
|
+
src: {
|
|
63
|
+
default: undefined;
|
|
64
|
+
type: StringConstructor;
|
|
37
65
|
};
|
|
38
66
|
size: {
|
|
39
67
|
default: string;
|
|
@@ -61,9 +89,13 @@ declare const _default: import("vue").DefineComponent<import("vue").ExtractPropT
|
|
|
61
89
|
};
|
|
62
90
|
}>> & Readonly<{}>, {
|
|
63
91
|
class: string;
|
|
92
|
+
email: string;
|
|
64
93
|
to: string | import("vue-router").RouteLocationAsRelativeGeneric | import("vue-router").RouteLocationAsPathGeneric;
|
|
65
94
|
size: string;
|
|
95
|
+
name: string;
|
|
66
96
|
tooltip: boolean;
|
|
97
|
+
user: AvatarUserInput;
|
|
98
|
+
src: string;
|
|
67
99
|
showDetails: boolean;
|
|
68
100
|
detailsPosition: "left" | "right";
|
|
69
101
|
}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
|
|
@@ -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, };
|
package/package.json
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<BaseTooltip
|
|
3
|
-
v-if="user"
|
|
4
3
|
:visible="tooltip"
|
|
5
4
|
:text="tooltipText"
|
|
6
5
|
:class="classInternal"
|
|
@@ -11,8 +10,8 @@
|
|
|
11
10
|
class="flex items-center"
|
|
12
11
|
>
|
|
13
12
|
<img
|
|
14
|
-
v-if="
|
|
15
|
-
:src="
|
|
13
|
+
v-if="resolvedUrl"
|
|
14
|
+
:src="resolvedUrl"
|
|
16
15
|
:class="[sizeClass, detailsPosition == 'left' ? 'order-2' : 'order-1']"
|
|
17
16
|
class="shrink-0 block overflow-hidden rounded-full object-cover object-center"
|
|
18
17
|
>
|
|
@@ -54,10 +53,10 @@
|
|
|
54
53
|
}"
|
|
55
54
|
>
|
|
56
55
|
<div class="truncate">
|
|
57
|
-
{{
|
|
56
|
+
{{ resolvedName }}
|
|
58
57
|
</div>
|
|
59
58
|
<div class="truncate opacity-50">
|
|
60
|
-
{{
|
|
59
|
+
{{ resolvedEmail }}
|
|
61
60
|
</div>
|
|
62
61
|
</div>
|
|
63
62
|
</component>
|
|
@@ -76,10 +75,27 @@ defineOptions({
|
|
|
76
75
|
inheritAttrs: false,
|
|
77
76
|
})
|
|
78
77
|
|
|
78
|
+
type AvatarUserInput = Partial<User> & {
|
|
79
|
+
name?: string;
|
|
80
|
+
label?: string;
|
|
81
|
+
};
|
|
82
|
+
|
|
79
83
|
const props = defineProps({
|
|
80
84
|
user: {
|
|
81
|
-
|
|
82
|
-
type: Object as PropType<
|
|
85
|
+
default: undefined,
|
|
86
|
+
type: Object as PropType<AvatarUserInput>,
|
|
87
|
+
},
|
|
88
|
+
name: {
|
|
89
|
+
default: undefined,
|
|
90
|
+
type: String,
|
|
91
|
+
},
|
|
92
|
+
email: {
|
|
93
|
+
default: undefined,
|
|
94
|
+
type: String,
|
|
95
|
+
},
|
|
96
|
+
src: {
|
|
97
|
+
default: undefined,
|
|
98
|
+
type: String,
|
|
83
99
|
},
|
|
84
100
|
size: {
|
|
85
101
|
default: 'base',
|
|
@@ -107,8 +123,23 @@ const props = defineProps({
|
|
|
107
123
|
},
|
|
108
124
|
});
|
|
109
125
|
|
|
110
|
-
const
|
|
111
|
-
|
|
126
|
+
const resolvedName = computed(() => {
|
|
127
|
+
return (
|
|
128
|
+
[props.name, props.user?.name, props.user?.full_name, props.user?.label]
|
|
129
|
+
.find((value) => Boolean(value && value.trim()))?.trim() || ''
|
|
130
|
+
);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
const resolvedEmail = computed(() => {
|
|
134
|
+
return (props.email || props.user?.email || '').trim();
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
const resolvedUrl = computed(() => {
|
|
138
|
+
return (props.src || props.user?.avatar_url || '').trim();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
const initials = computed(() => getInitials(resolvedName.value));
|
|
142
|
+
const avatarColor = computed(() => getAvatarColor(resolvedName.value, resolvedEmail.value));
|
|
112
143
|
|
|
113
144
|
const classInternal = computed(() => {
|
|
114
145
|
return twMerge(
|
|
@@ -125,11 +156,15 @@ const tooltipText = computed(() => {
|
|
|
125
156
|
return null;
|
|
126
157
|
}
|
|
127
158
|
|
|
159
|
+
if (!resolvedName.value && !resolvedEmail.value) {
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
|
|
128
163
|
return `<p class="font-medium leading-tight">
|
|
129
|
-
${
|
|
164
|
+
${resolvedName.value}
|
|
130
165
|
</p>
|
|
131
166
|
<p class="text-slate-500 leading-tight">
|
|
132
|
-
${
|
|
167
|
+
${resolvedEmail.value}
|
|
133
168
|
</p>`;
|
|
134
169
|
})
|
|
135
170
|
|
|
@@ -35,7 +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 {
|
|
38
|
+
import { resizeImageForUpload } from '@/utils';
|
|
39
39
|
import BaseFilePicker from './BaseFilePicker.vue';
|
|
40
40
|
import BaseFilePickerCrop from './BaseFilePickerCrop.vue';
|
|
41
41
|
import { t } from '@/i18n';
|
|
@@ -220,49 +220,21 @@ async function autoResizeImage(file: File): Promise<File> {
|
|
|
220
220
|
}
|
|
221
221
|
|
|
222
222
|
try {
|
|
223
|
-
const
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
const resizeRatio = Math.sqrt(props.maxImageSizeBeforeResize / file.size);
|
|
227
|
-
|
|
228
|
-
let targetWidth = Math.max(1, Math.floor(dimensions.width * resizeRatio));
|
|
229
|
-
let targetHeight = Math.max(1, Math.floor(dimensions.height * resizeRatio));
|
|
230
|
-
|
|
231
|
-
let resizedBlob = await createResizedBlob(
|
|
232
|
-
source,
|
|
233
|
-
targetHeight,
|
|
234
|
-
targetWidth,
|
|
235
|
-
file.type
|
|
236
|
-
);
|
|
237
|
-
|
|
238
|
-
let attempts = 0;
|
|
239
|
-
|
|
240
|
-
while (
|
|
241
|
-
resizedBlob.size > props.maxImageSizeBeforeResize &&
|
|
242
|
-
attempts < 5 &&
|
|
243
|
-
targetWidth > 1 &&
|
|
244
|
-
targetHeight > 1
|
|
245
|
-
) {
|
|
246
|
-
targetWidth = Math.max(1, Math.floor(targetWidth * 0.8));
|
|
247
|
-
targetHeight = Math.max(1, Math.floor(targetHeight * 0.8));
|
|
248
|
-
resizedBlob = await createResizedBlob(
|
|
249
|
-
source,
|
|
250
|
-
targetHeight,
|
|
251
|
-
targetWidth,
|
|
252
|
-
file.type
|
|
253
|
-
);
|
|
254
|
-
attempts++;
|
|
255
|
-
}
|
|
223
|
+
const resizedBlob = await resizeImageForUpload(file, {
|
|
224
|
+
maxBytes: props.maxImageSizeBeforeResize,
|
|
225
|
+
});
|
|
256
226
|
|
|
257
227
|
if (resizedBlob.size >= file.size) {
|
|
258
228
|
return file;
|
|
259
229
|
}
|
|
260
230
|
|
|
231
|
+
const mimeType = resizedBlob.type || file.type;
|
|
232
|
+
|
|
261
233
|
return new File(
|
|
262
234
|
[resizedBlob],
|
|
263
|
-
getResizedFileName(file.name,
|
|
235
|
+
getResizedFileName(file.name, mimeType),
|
|
264
236
|
{
|
|
265
|
-
type:
|
|
237
|
+
type: mimeType,
|
|
266
238
|
lastModified: file.lastModified,
|
|
267
239
|
}
|
|
268
240
|
);
|
|
@@ -272,34 +244,6 @@ async function autoResizeImage(file: File): Promise<File> {
|
|
|
272
244
|
}
|
|
273
245
|
}
|
|
274
246
|
|
|
275
|
-
async function createResizedBlob(
|
|
276
|
-
source: string,
|
|
277
|
-
height: number,
|
|
278
|
-
width: number,
|
|
279
|
-
mimeType: string
|
|
280
|
-
): Promise<Blob> {
|
|
281
|
-
const resizedSource = await resizeImageFromURI(source, height, width);
|
|
282
|
-
return await base64ToBlob(resizedSource, `data:${mimeType}`);
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
async function getImageDimensions(
|
|
286
|
-
source: string
|
|
287
|
-
): Promise<{ width: number; height: number }> {
|
|
288
|
-
return await new Promise((resolve, reject) => {
|
|
289
|
-
const image = new Image();
|
|
290
|
-
image.onload = () => {
|
|
291
|
-
resolve({
|
|
292
|
-
width: image.width,
|
|
293
|
-
height: image.height,
|
|
294
|
-
});
|
|
295
|
-
};
|
|
296
|
-
image.onerror = () => {
|
|
297
|
-
reject(new Error('Failed to load image dimensions'));
|
|
298
|
-
};
|
|
299
|
-
image.src = source;
|
|
300
|
-
});
|
|
301
|
-
}
|
|
302
|
-
|
|
303
247
|
function getResizedFileName(filename: string, mimeType: string): string {
|
|
304
248
|
if (mimeType != 'image/jpeg') {
|
|
305
249
|
return filename;
|
package/src/utils/index.ts
CHANGED
|
@@ -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
|
+
}
|