sprintify-ui 0.12.10 → 0.12.12

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.
@@ -0,0 +1,544 @@
1
+ <template>
2
+ <div
3
+ class="inline-flex max-w-full flex-col gap-2"
4
+ :style="containerStyle"
5
+ >
6
+ <div
7
+ class="relative w-full overflow-hidden rounded-md border border-slate-300 bg-white shadow-sm transition-colors"
8
+ :class="{
9
+ 'cursor-crosshair hover:border-slate-400': !disabled,
10
+ 'cursor-not-allowed bg-slate-50 opacity-70': disabled,
11
+ }"
12
+ :style="signaturePadStyle"
13
+ >
14
+ <canvas
15
+ ref="canvas"
16
+ class="absolute inset-0 h-full w-full touch-none select-none"
17
+ :aria-label="t('sui.signature_pad')"
18
+ :aria-disabled="disabled"
19
+ @pointerdown="startDrawing"
20
+ @pointermove="continueDrawing"
21
+ @pointerup="finishDrawing"
22
+ @pointercancel="finishDrawing"
23
+ @lostpointercapture="finishDrawing"
24
+ />
25
+ </div>
26
+
27
+ <div
28
+ v-if="showClearButton || errorMessage"
29
+ class="flex items-start justify-between gap-3"
30
+ >
31
+ <p
32
+ v-if="errorMessage"
33
+ role="alert"
34
+ class="pt-1 text-sm leading-tight text-red-600"
35
+ >
36
+ {{ errorMessage }}
37
+ </p>
38
+ <span v-else />
39
+
40
+ <BaseButton
41
+ v-if="showClearButton"
42
+ type="button"
43
+ size="sm"
44
+ color="secondary-outline"
45
+ icon="heroicons:trash"
46
+ :disabled="disabled || !canClear"
47
+ @click="clear"
48
+ >
49
+ {{ t('sui.clear') }}
50
+ </BaseButton>
51
+ </div>
52
+ </div>
53
+ </template>
54
+
55
+ <script lang="ts" setup>
56
+ import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
57
+ import { BaseButton } from '.';
58
+ import { t } from '@/i18n';
59
+ import type { SignatureError, SignatureErrorCode } from '@/types';
60
+
61
+ type Point = {
62
+ x: number;
63
+ y: number;
64
+ time: number;
65
+ width: number;
66
+ };
67
+
68
+ const props = withDefaults(defineProps<{
69
+ modelValue?: string | null;
70
+ width?: number;
71
+ height?: number;
72
+ disabled?: boolean;
73
+ showClearButton?: boolean;
74
+ }>(), {
75
+ modelValue: null,
76
+ width: 400,
77
+ height: 180,
78
+ disabled: false,
79
+ showClearButton: true,
80
+ });
81
+
82
+ const emit = defineEmits<{
83
+ (event: 'update:modelValue', value: string | null): void;
84
+ (event: 'clear'): void;
85
+ (event: 'error', error: SignatureError): void;
86
+ }>();
87
+
88
+ const SIGNATURE_PADDING_RATIO = 0.08;
89
+ const MAX_ANALYSIS_SIZE = 2048;
90
+ const PNG_DATA_URL_PATTERN = /^data:image\/png(?:;[^,]*)?;base64,([a-z\d+/=\s]+)$/i;
91
+ const PNG_SIGNATURE = [137, 80, 78, 71, 13, 10, 26, 10];
92
+
93
+ const canvas = ref<HTMLCanvasElement | null>(null);
94
+ const errorMessage = ref<string | null>(null);
95
+ const filled = ref(false);
96
+
97
+ let context: CanvasRenderingContext2D | null = null;
98
+ let activePointerId: number | null = null;
99
+ let drawing = false;
100
+ let loadRequestId = 0;
101
+ let locallyRenderedValue: string | null | undefined;
102
+ let previousPoint: Point | null = null;
103
+ let previousMidpoint: Pick<Point, 'x' | 'y'> | null = null;
104
+
105
+ const normalizedWidth = computed(() => Math.max(1, Math.round(props.width)));
106
+ const normalizedHeight = computed(() => Math.max(1, Math.round(props.height)));
107
+ const canClear = computed(() => {
108
+ return filled.value || Boolean(props.modelValue) || Boolean(errorMessage.value);
109
+ });
110
+
111
+ const containerStyle = computed(() => ({
112
+ width: `${normalizedWidth.value}px`,
113
+ }));
114
+
115
+ const signaturePadStyle = computed(() => ({
116
+ aspectRatio: `${normalizedWidth.value} / ${normalizedHeight.value}`,
117
+ }));
118
+
119
+ onMounted(async () => {
120
+ configureCanvas();
121
+ await renderModelValue(props.modelValue);
122
+ });
123
+
124
+ onBeforeUnmount(() => {
125
+ loadRequestId += 1;
126
+ cancelDrawing();
127
+ });
128
+
129
+ watch(
130
+ () => props.modelValue,
131
+ async (modelValue) => {
132
+ if (modelValue === locallyRenderedValue) {
133
+ return;
134
+ }
135
+
136
+ locallyRenderedValue = undefined;
137
+ cancelDrawing();
138
+ await renderModelValue(modelValue);
139
+ }
140
+ );
141
+
142
+ watch(
143
+ [normalizedWidth, normalizedHeight],
144
+ async () => {
145
+ const currentSignature = filled.value && canvas.value
146
+ ? canvas.value.toDataURL('image/png')
147
+ : props.modelValue;
148
+
149
+ cancelDrawing();
150
+ configureCanvas();
151
+ await renderModelValue(currentSignature);
152
+ }
153
+ );
154
+
155
+ watch(
156
+ () => props.disabled,
157
+ (disabled) => {
158
+ if (disabled) {
159
+ finishDrawing();
160
+ }
161
+ }
162
+ );
163
+
164
+ function configureCanvas() {
165
+ if (!canvas.value) return;
166
+
167
+ const pixelRatio = Math.max(1, window.devicePixelRatio || 1);
168
+
169
+ canvas.value.width = Math.round(normalizedWidth.value * pixelRatio);
170
+ canvas.value.height = Math.round(normalizedHeight.value * pixelRatio);
171
+
172
+ context = canvas.value.getContext('2d');
173
+
174
+ if (!context) return;
175
+
176
+ context.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
177
+ context.imageSmoothingEnabled = true;
178
+ context.imageSmoothingQuality = 'high';
179
+ resetCanvas();
180
+ }
181
+
182
+ function resetCanvas() {
183
+ if (!context) return;
184
+
185
+ context.save();
186
+ context.fillStyle = '#ffffff';
187
+ context.fillRect(0, 0, normalizedWidth.value, normalizedHeight.value);
188
+ context.restore();
189
+ filled.value = false;
190
+ }
191
+
192
+ async function renderModelValue(modelValue: string | null | undefined) {
193
+ const requestId = ++loadRequestId;
194
+ errorMessage.value = null;
195
+ resetCanvas();
196
+
197
+ if (!modelValue) return;
198
+
199
+ if (!isPngDataUrl(modelValue)) {
200
+ reportError('invalid_png');
201
+ return;
202
+ }
203
+
204
+ try {
205
+ const image = await loadImage(modelValue);
206
+
207
+ if (requestId !== loadRequestId) return;
208
+
209
+ drawImageContained(image);
210
+ } catch {
211
+ if (requestId !== loadRequestId) return;
212
+
213
+ reportError('load_failed');
214
+ }
215
+ }
216
+
217
+ function isPngDataUrl(value: string) {
218
+ const match = value.match(PNG_DATA_URL_PATTERN);
219
+
220
+ if (!match) return false;
221
+
222
+ try {
223
+ const bytes = atob(match[1].replace(/\s/g, '').slice(0, 12));
224
+
225
+ return PNG_SIGNATURE.every((byte, index) => bytes.charCodeAt(index) === byte);
226
+ } catch {
227
+ return false;
228
+ }
229
+ }
230
+
231
+ function loadImage(source: string) {
232
+ return new Promise<HTMLImageElement>((resolve, reject) => {
233
+ const image = new Image();
234
+
235
+ image.onload = () => resolve(image);
236
+ image.onerror = () => reject(new Error('Unable to load signature image'));
237
+ image.src = source;
238
+ });
239
+ }
240
+
241
+ function drawImageContained(image: HTMLImageElement) {
242
+ if (!context || image.naturalWidth === 0 || image.naturalHeight === 0) {
243
+ reportError('load_failed');
244
+ return;
245
+ }
246
+
247
+ const bounds = findContentBounds(image);
248
+
249
+ if (!bounds) {
250
+ filled.value = false;
251
+ return;
252
+ }
253
+
254
+ const availableWidth = normalizedWidth.value * (1 - SIGNATURE_PADDING_RATIO * 2);
255
+ const availableHeight = normalizedHeight.value * (1 - SIGNATURE_PADDING_RATIO * 2);
256
+ const scale = Math.min(
257
+ availableWidth / bounds.width,
258
+ availableHeight / bounds.height
259
+ );
260
+ const destinationWidth = bounds.width * scale;
261
+ const destinationHeight = bounds.height * scale;
262
+ const destinationX = (normalizedWidth.value - destinationWidth) / 2;
263
+ const destinationY = (normalizedHeight.value - destinationHeight) / 2;
264
+
265
+ context.drawImage(
266
+ image,
267
+ bounds.x,
268
+ bounds.y,
269
+ bounds.width,
270
+ bounds.height,
271
+ destinationX,
272
+ destinationY,
273
+ destinationWidth,
274
+ destinationHeight
275
+ );
276
+
277
+ filled.value = true;
278
+ }
279
+
280
+ function findContentBounds(image: HTMLImageElement) {
281
+ const analysisScale = Math.min(
282
+ 1,
283
+ MAX_ANALYSIS_SIZE / image.naturalWidth,
284
+ MAX_ANALYSIS_SIZE / image.naturalHeight
285
+ );
286
+ const analysisWidth = Math.max(1, Math.round(image.naturalWidth * analysisScale));
287
+ const analysisHeight = Math.max(1, Math.round(image.naturalHeight * analysisScale));
288
+ const analysisCanvas = document.createElement('canvas');
289
+ const analysisContext = analysisCanvas.getContext('2d', { willReadFrequently: true });
290
+
291
+ analysisCanvas.width = analysisWidth;
292
+ analysisCanvas.height = analysisHeight;
293
+
294
+ if (!analysisContext) return null;
295
+
296
+ analysisContext.drawImage(image, 0, 0, analysisWidth, analysisHeight);
297
+
298
+ const pixels = analysisContext.getImageData(0, 0, analysisWidth, analysisHeight).data;
299
+ let minX = analysisWidth;
300
+ let minY = analysisHeight;
301
+ let maxX = -1;
302
+ let maxY = -1;
303
+
304
+ for (let y = 0; y < analysisHeight; y += 1) {
305
+ for (let x = 0; x < analysisWidth; x += 1) {
306
+ const index = (y * analysisWidth + x) * 4;
307
+ const alpha = pixels[index + 3];
308
+ const isVisible = alpha > 10;
309
+ const isNotWhite = pixels[index] < 245
310
+ || pixels[index + 1] < 245
311
+ || pixels[index + 2] < 245;
312
+
313
+ if (!isVisible || !isNotWhite) continue;
314
+
315
+ minX = Math.min(minX, x);
316
+ minY = Math.min(minY, y);
317
+ maxX = Math.max(maxX, x);
318
+ maxY = Math.max(maxY, y);
319
+ }
320
+ }
321
+
322
+ if (maxX < minX || maxY < minY) return null;
323
+
324
+ const sourceScaleX = image.naturalWidth / analysisWidth;
325
+ const sourceScaleY = image.naturalHeight / analysisHeight;
326
+ const x = Math.max(0, (minX - 1) * sourceScaleX);
327
+ const y = Math.max(0, (minY - 1) * sourceScaleY);
328
+ const right = Math.min(image.naturalWidth, (maxX + 2) * sourceScaleX);
329
+ const bottom = Math.min(image.naturalHeight, (maxY + 2) * sourceScaleY);
330
+
331
+ return {
332
+ x,
333
+ y,
334
+ width: right - x,
335
+ height: bottom - y,
336
+ };
337
+ }
338
+
339
+ function reportError(code: SignatureErrorCode) {
340
+ const translationKey = code === 'invalid_png'
341
+ ? 'sui.signature_must_be_png'
342
+ : 'sui.signature_load_failed';
343
+ const error: SignatureError = {
344
+ code,
345
+ message: t(translationKey),
346
+ };
347
+
348
+ errorMessage.value = error.message;
349
+ emit('error', error);
350
+ }
351
+
352
+ function startDrawing(event: PointerEvent) {
353
+ if (props.disabled || drawing) return;
354
+ if (event.pointerType === 'mouse' && event.button !== 0) return;
355
+ if (!canvas.value || !context) return;
356
+
357
+ event.preventDefault();
358
+ loadRequestId += 1;
359
+ activePointerId = event.pointerId;
360
+ drawing = true;
361
+ errorMessage.value = null;
362
+ previousPoint = null;
363
+ previousMidpoint = null;
364
+ canvas.value.setPointerCapture(event.pointerId);
365
+
366
+ addPoint(event);
367
+ filled.value = true;
368
+ }
369
+
370
+ function continueDrawing(event: PointerEvent) {
371
+ if (!drawing || event.pointerId !== activePointerId) return;
372
+
373
+ event.preventDefault();
374
+
375
+ const events = event.getCoalescedEvents?.() ?? [];
376
+ const points = events.length > 0 ? events : [event];
377
+
378
+ points.forEach(addPoint);
379
+ }
380
+
381
+ function finishDrawing(event?: PointerEvent) {
382
+ if (!drawing) return;
383
+ if (event && event.pointerId !== activePointerId) return;
384
+
385
+ event?.preventDefault();
386
+ finishStroke();
387
+
388
+ const pointerId = activePointerId;
389
+
390
+ drawing = false;
391
+ activePointerId = null;
392
+ previousPoint = null;
393
+ previousMidpoint = null;
394
+
395
+ if (
396
+ canvas.value
397
+ && pointerId !== null
398
+ && canvas.value.hasPointerCapture(pointerId)
399
+ ) {
400
+ canvas.value.releasePointerCapture(pointerId);
401
+ }
402
+
403
+ emitSignature();
404
+ }
405
+
406
+ function cancelDrawing() {
407
+ const pointerId = activePointerId;
408
+
409
+ drawing = false;
410
+ activePointerId = null;
411
+ previousPoint = null;
412
+ previousMidpoint = null;
413
+
414
+ if (
415
+ canvas.value
416
+ && pointerId !== null
417
+ && canvas.value.hasPointerCapture(pointerId)
418
+ ) {
419
+ canvas.value.releasePointerCapture(pointerId);
420
+ }
421
+ }
422
+
423
+ function addPoint(event: PointerEvent) {
424
+ if (!context) return;
425
+
426
+ const point = getPoint(event);
427
+
428
+ if (!previousPoint || !previousMidpoint) {
429
+ drawDot(point);
430
+ previousPoint = point;
431
+ previousMidpoint = { x: point.x, y: point.y };
432
+ return;
433
+ }
434
+
435
+ const midpoint = {
436
+ x: (previousPoint.x + point.x) / 2,
437
+ y: (previousPoint.y + point.y) / 2,
438
+ };
439
+
440
+ context.beginPath();
441
+ context.moveTo(previousMidpoint.x, previousMidpoint.y);
442
+ context.quadraticCurveTo(
443
+ previousPoint.x,
444
+ previousPoint.y,
445
+ midpoint.x,
446
+ midpoint.y
447
+ );
448
+ context.lineWidth = (previousPoint.width + point.width) / 2;
449
+ context.lineCap = 'round';
450
+ context.lineJoin = 'round';
451
+ context.strokeStyle = '#000000';
452
+ context.stroke();
453
+
454
+ previousPoint = point;
455
+ previousMidpoint = midpoint;
456
+ }
457
+
458
+ function finishStroke() {
459
+ if (!context || !previousPoint || !previousMidpoint) return;
460
+
461
+ context.beginPath();
462
+ context.moveTo(previousMidpoint.x, previousMidpoint.y);
463
+ context.quadraticCurveTo(
464
+ previousPoint.x,
465
+ previousPoint.y,
466
+ previousPoint.x,
467
+ previousPoint.y
468
+ );
469
+ context.lineWidth = previousPoint.width;
470
+ context.lineCap = 'round';
471
+ context.strokeStyle = '#000000';
472
+ context.stroke();
473
+ }
474
+
475
+ function drawDot(point: Point) {
476
+ if (!context) return;
477
+
478
+ context.beginPath();
479
+ context.arc(point.x, point.y, point.width / 2, 0, Math.PI * 2);
480
+ context.fillStyle = '#000000';
481
+ context.fill();
482
+ }
483
+
484
+ function getPoint(event: PointerEvent): Point {
485
+ const rectangle = canvas.value?.getBoundingClientRect();
486
+ const scaleX = rectangle ? normalizedWidth.value / rectangle.width : 1;
487
+ const scaleY = rectangle ? normalizedHeight.value / rectangle.height : 1;
488
+ const x = rectangle ? (event.clientX - rectangle.left) * scaleX : 0;
489
+ const y = rectangle ? (event.clientY - rectangle.top) * scaleY : 0;
490
+ const time = event.timeStamp;
491
+ const strokeScale = Math.max(
492
+ 0.75,
493
+ Math.min(2.5, Math.min(normalizedWidth.value, normalizedHeight.value) / 180)
494
+ );
495
+ const minimumWidth = 1.15 * strokeScale;
496
+ const maximumWidth = 3.4 * strokeScale;
497
+ let width = maximumWidth;
498
+
499
+ if (previousPoint) {
500
+ if (event.pointerType === 'pen' && event.pressure > 0) {
501
+ width = minimumWidth + (maximumWidth - minimumWidth) * event.pressure;
502
+ } else {
503
+ const distance = Math.hypot(x - previousPoint.x, y - previousPoint.y);
504
+ const elapsed = Math.max(1, time - previousPoint.time);
505
+ const velocity = distance / elapsed;
506
+ const velocityWeight = 1 / (1 + velocity * 1.7);
507
+ const targetWidth = minimumWidth
508
+ + (maximumWidth - minimumWidth) * velocityWeight;
509
+
510
+ width = previousPoint.width * 0.65 + targetWidth * 0.35;
511
+ }
512
+ }
513
+
514
+ return { x, y, time, width };
515
+ }
516
+
517
+ function emitSignature() {
518
+ if (!canvas.value) return;
519
+
520
+ const value = canvas.value.toDataURL('image/png');
521
+
522
+ locallyRenderedValue = value;
523
+ emit('update:modelValue', value);
524
+ }
525
+
526
+ function clear() {
527
+ loadRequestId += 1;
528
+ cancelDrawing();
529
+ errorMessage.value = null;
530
+ resetCanvas();
531
+ locallyRenderedValue = null;
532
+ emit('update:modelValue', null);
533
+ emit('clear');
534
+ }
535
+
536
+ function isFilled() {
537
+ return filled.value;
538
+ }
539
+
540
+ defineExpose({
541
+ clear,
542
+ isFilled,
543
+ });
544
+ </script>
@@ -76,6 +76,7 @@ import BaseRadioGroup from './BaseRadioGroup.vue';
76
76
  import BaseReadMore from './BaseReadMore.vue';
77
77
  import BaseRichText from './BaseRichText.vue';
78
78
  import BaseSelect from './BaseSelect.vue';
79
+ import BaseSignature from './BaseSignature.vue';
79
80
  import BaseShortcut from './BaseShortcut.vue';
80
81
  import BaseSideNavigation from './BaseSideNavigation.vue';
81
82
  import BaseSideNavigationItem from './BaseSideNavigationItem.vue';
@@ -188,6 +189,7 @@ export {
188
189
  BaseReadMore,
189
190
  BaseRichText,
190
191
  BaseSelect,
192
+ BaseSignature,
191
193
  BaseShortcut,
192
194
  BaseSideNavigation,
193
195
  BaseSideNavigationItem,
package/src/lang/en.json CHANGED
@@ -75,6 +75,9 @@
75
75
  "select_all": "Select all",
76
76
  "select_an_item": "Select an item",
77
77
  "select_an_option": "Select an option",
78
+ "signature_load_failed": "The signature image could not be loaded.",
79
+ "signature_must_be_png": "The signature must be a valid PNG image.",
80
+ "signature_pad": "Signature pad",
78
81
  "success": "Success",
79
82
  "the_file_size_must_not_exceed_x": "The file size must not exceed {x}",
80
83
  "the_file_type_is_invalid": "The file type is invalid",
package/src/lang/fr.json CHANGED
@@ -75,6 +75,9 @@
75
75
  "select_all": "Sélectionnez tout",
76
76
  "select_an_item": "Sélectionner un élément",
77
77
  "select_an_option": "Sélectionner une option",
78
+ "signature_load_failed": "L’image de la signature n’a pas pu être chargée.",
79
+ "signature_must_be_png": "La signature doit être une image PNG valide.",
80
+ "signature_pad": "Zone de signature",
78
81
  "success": "Succès",
79
82
  "the_file_size_must_not_exceed_x": "La taille du fichier ne doit pas dépasser {x}",
80
83
  "the_file_type_is_invalid": "Le type de fichier n'est pas valide",
@@ -11,6 +11,13 @@ import { ToolbarOption } from './ToolbarOption';
11
11
 
12
12
  export type Locales = { [locale: string]: string };
13
13
 
14
+ export type SignatureErrorCode = 'invalid_png' | 'load_failed';
15
+
16
+ export interface SignatureError {
17
+ code: SignatureErrorCode;
18
+ message: string;
19
+ }
20
+
14
21
  export enum Method {
15
22
  post = 'post',
16
23
  put = 'put',