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.
- package/dist/sprintify-ui.es.js +8664 -8389
- package/dist/types/components/BaseHasManyFetch.vue.d.ts +3 -3
- package/dist/types/components/BaseSignature.vue.d.ts +29 -0
- package/dist/types/components/index.d.ts +2 -1
- package/dist/types/index.d.ts +6 -0
- package/dist/types/types/index.d.ts +5 -0
- package/package.json +1 -1
- package/src/components/BaseDisplayRelativeTime.vue +1 -0
- package/src/components/BaseHasManyFetch.vue +1 -1
- package/src/components/BaseSignature.mdx +119 -0
- package/src/components/BaseSignature.stories.js +232 -0
- package/src/components/BaseSignature.vue +544 -0
- package/src/components/index.ts +2 -0
- package/src/lang/en.json +3 -0
- package/src/lang/fr.json +3 -0
- package/src/types/index.ts +7 -0
|
@@ -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>
|
package/src/components/index.ts
CHANGED
|
@@ -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",
|
package/src/types/index.ts
CHANGED
|
@@ -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',
|