sprintify-ui 0.12.15 → 0.12.17

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.
@@ -31,6 +31,29 @@ const signature = ref<string | null>(null);
31
31
 
32
32
  `update:modelValue` is emitted after each completed stroke. Clearing the pad emits `update:modelValue` with `null`, followed by the `clear` event.
33
33
 
34
+ ## Text signatures
35
+
36
+ Pass `signature-text` when the signer’s name is entered elsewhere in the form. The component then shows a translated **Apply my signature** button. Clicking it clears the canvas internally and renders the name with the bundled Dancing Script font, before emitting the resulting white-background PNG through `v-model`. It does not emit `clear` because the signature is replaced, not removed.
37
+
38
+ ```vue
39
+ <script setup lang="ts">
40
+ import { ref } from 'vue';
41
+ import { BaseInput, BaseSignature } from 'sprintify-ui';
42
+
43
+ const name = ref('François Lévesque');
44
+ const signature = ref<string | null>(null);
45
+ </script>
46
+
47
+ <template>
48
+ <BaseInput v-model="name" label="Full name" />
49
+ <BaseSignature v-model="signature" :signature-text="name" />
50
+ </template>
51
+ ```
52
+
53
+ The text action is optional: drawing with a mouse, touch, or pen remains available. Blank text (including spaces only) hides the button. Text is rendered on one line and scales down to fit the available canvas space.
54
+
55
+ <Canvas of={BaseSignatureStories.TextSignature} />
56
+
34
57
  ## Existing signatures and resizing
35
58
 
36
59
  Only complete PNG Data URLs are accepted, for example `data:image/png;base64,...`. The image is inspected to find its visible, non-white pixels. That content is then scaled proportionally, centered, and rendered with 8% horizontal and vertical padding.
@@ -53,7 +76,7 @@ JPEG, WebP, SVG, malformed Data URLs, and invalid PNG payloads are rejected. The
53
76
 
54
77
  ## Disabled state
55
78
 
56
- The `disabled` prop prevents pointer input and disables the built-in clear button. Exposed methods remain available to the parent component.
79
+ The `disabled` prop prevents pointer input and disables all built-in actions. Exposed methods remain available to the parent component.
57
80
 
58
81
  <Canvas of={BaseSignatureStories.Disabled} />
59
82
 
@@ -66,8 +89,9 @@ The `disabled` prop prevents pointer input and disables the built-in clear butto
66
89
  | `modelValue` | `string \| null` | `null` | Complete PNG Data URL containing the current signature. |
67
90
  | `width` | `number` | `400` | Maximum visual width of the signature pad in pixels. The pad can shrink with its parent. |
68
91
  | `height` | `number` | `180` | Visual height used to determine the pad aspect ratio. |
69
- | `disabled` | `boolean` | `false` | Prevents drawing and clearing through the built-in button. |
92
+ | `disabled` | `boolean` | `false` | Prevents drawing and disables built-in actions. |
70
93
  | `showClearButton` | `boolean` | `true` | Shows or hides the built-in translated clear button. |
94
+ | `signatureText` | `string` | `''` | When non-blank, shows an action that renders the text as a Dancing Script PNG signature. |
71
95
 
72
96
  ## Events
73
97
 
@@ -43,6 +43,7 @@ export default {
43
43
  height: 180,
44
44
  disabled: false,
45
45
  showClearButton: true,
46
+ signatureText: '',
46
47
  },
47
48
  argTypes: {
48
49
  modelValue: {
@@ -65,6 +66,10 @@ export default {
65
66
  control: "boolean",
66
67
  description: "Shows the built-in clear button below the pad.",
67
68
  },
69
+ signatureText: {
70
+ control: "text",
71
+ description: "Text rendered as a Dancing Script PNG when the built-in button is pressed.",
72
+ },
68
73
  "update:modelValue": {
69
74
  action: "update:modelValue",
70
75
  table: { category: "Events" },
@@ -201,6 +206,41 @@ Compact.args = {
201
206
  height: 120,
202
207
  };
203
208
 
209
+ const TextSignatureTemplate = (args) => ({
210
+ components: { BaseSignature },
211
+ setup() {
212
+ const signature = ref(null);
213
+ const signatureText = ref(args.signatureText);
214
+
215
+ return { args, signature, signatureText };
216
+ },
217
+ template: `
218
+ <div class="space-y-3">
219
+ <label class="block max-w-md space-y-1 text-sm font-medium text-slate-700">
220
+ Signature text
221
+ <input
222
+ v-model="signatureText"
223
+ type="text"
224
+ class="block w-full rounded-md border-slate-300 shadow-sm"
225
+ >
226
+ </label>
227
+ <BaseSignature
228
+ v-bind="args"
229
+ v-model="signature"
230
+ :signature-text="signatureText"
231
+ />
232
+ <p class="max-w-lg text-sm text-slate-600">
233
+ Applying the text replaces the current canvas content with a PNG signature.
234
+ </p>
235
+ </div>
236
+ `,
237
+ });
238
+
239
+ export const TextSignature = TextSignatureTemplate.bind({});
240
+ TextSignature.args = {
241
+ signatureText: 'François Lévesque',
242
+ };
243
+
204
244
  export const Disabled = ExistingSignatureTemplate.bind({});
205
245
  Disabled.args = {
206
246
  disabled: true,
@@ -25,7 +25,7 @@
25
25
  </div>
26
26
 
27
27
  <div
28
- v-if="showClearButton || errorMessage"
28
+ v-if="showApplySignatureButton || showClearButton || errorMessage"
29
29
  class="flex items-start justify-between gap-3"
30
30
  >
31
31
  <p
@@ -37,23 +37,38 @@
37
37
  </p>
38
38
  <span v-else />
39
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>
40
+ <div class="flex flex-wrap justify-end gap-2">
41
+ <BaseButton
42
+ v-if="showApplySignatureButton"
43
+ type="button"
44
+ size="sm"
45
+ color="secondary-outline"
46
+ :disabled="disabled || applyingTextSignature"
47
+ :loading="applyingTextSignature"
48
+ @click="applyTextSignature"
49
+ >
50
+ {{ t('sui.apply_signature') }}
51
+ </BaseButton>
52
+
53
+ <BaseButton
54
+ v-if="showClearButton"
55
+ type="button"
56
+ size="sm"
57
+ color="secondary-outline"
58
+ icon="heroicons:trash"
59
+ :disabled="disabled || !canClear"
60
+ @click="clear"
61
+ >
62
+ {{ t('sui.clear') }}
63
+ </BaseButton>
64
+ </div>
51
65
  </div>
52
66
  </div>
53
67
  </template>
54
68
 
55
69
  <script lang="ts" setup>
56
70
  import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
71
+ import '@fontsource/dancing-script/400.css';
57
72
  import { BaseButton } from '.';
58
73
  import { t } from '@/i18n';
59
74
  import type { SignatureError, SignatureErrorCode } from '@/types';
@@ -71,12 +86,14 @@ const props = withDefaults(defineProps<{
71
86
  height?: number;
72
87
  disabled?: boolean;
73
88
  showClearButton?: boolean;
89
+ signatureText?: string;
74
90
  }>(), {
75
91
  modelValue: null,
76
92
  width: 400,
77
93
  height: 180,
78
94
  disabled: false,
79
95
  showClearButton: true,
96
+ signatureText: '',
80
97
  });
81
98
 
82
99
  const emit = defineEmits<{
@@ -93,6 +110,7 @@ const PNG_SIGNATURE = [137, 80, 78, 71, 13, 10, 26, 10];
93
110
  const canvas = ref<HTMLCanvasElement | null>(null);
94
111
  const errorMessage = ref<string | null>(null);
95
112
  const filled = ref(false);
113
+ const applyingTextSignature = ref(false);
96
114
 
97
115
  let context: CanvasRenderingContext2D | null = null;
98
116
  let activePointerId: number | null = null;
@@ -107,6 +125,8 @@ const normalizedHeight = computed(() => Math.max(1, Math.round(props.height)));
107
125
  const canClear = computed(() => {
108
126
  return filled.value || Boolean(props.modelValue) || Boolean(errorMessage.value);
109
127
  });
128
+ const signatureText = computed(() => props.signatureText.trim());
129
+ const showApplySignatureButton = computed(() => Boolean(signatureText.value));
110
130
 
111
131
  const containerStyle = computed(() => ({
112
132
  width: `${normalizedWidth.value}px`,
@@ -189,6 +209,69 @@ function resetCanvas() {
189
209
  filled.value = false;
190
210
  }
191
211
 
212
+ async function applyTextSignature() {
213
+ if (!context || props.disabled || !signatureText.value) return;
214
+
215
+ const text = signatureText.value;
216
+ const requestId = ++loadRequestId;
217
+
218
+ cancelDrawing();
219
+ errorMessage.value = null;
220
+ resetCanvas();
221
+ applyingTextSignature.value = true;
222
+
223
+ try {
224
+ await document.fonts?.load('400 16px "Dancing Script"', text);
225
+
226
+ if (requestId !== loadRequestId || props.disabled) return;
227
+
228
+ drawTextSignature(text);
229
+ emitSignature();
230
+ } finally {
231
+ applyingTextSignature.value = false;
232
+ }
233
+ }
234
+
235
+ function drawTextSignature(text: string) {
236
+ if (!context) return;
237
+
238
+ const availableWidth = normalizedWidth.value * (1 - SIGNATURE_PADDING_RATIO * 2);
239
+ const availableHeight = normalizedHeight.value * (1 - SIGNATURE_PADDING_RATIO * 2);
240
+ const maximumFontSize = Math.max(1, availableHeight * 2);
241
+ let minimumFontSize = 1;
242
+ let maximumFittingFontSize = maximumFontSize;
243
+
244
+ for (let index = 0; index < 20; index += 1) {
245
+ const fontSize = (minimumFontSize + maximumFittingFontSize) / 2;
246
+
247
+ context.font = `400 ${fontSize}px "Dancing Script", cursive`;
248
+
249
+ const metrics = context.measureText(text);
250
+ const textHeight = metrics.actualBoundingBoxAscent + metrics.actualBoundingBoxDescent;
251
+ const fits = metrics.width <= availableWidth && textHeight <= availableHeight;
252
+
253
+ if (fits) {
254
+ minimumFontSize = fontSize;
255
+ } else {
256
+ maximumFittingFontSize = fontSize;
257
+ }
258
+ }
259
+
260
+ context.font = `400 ${minimumFontSize}px "Dancing Script", cursive`;
261
+
262
+ const metrics = context.measureText(text);
263
+ const textHeight = metrics.actualBoundingBoxAscent + metrics.actualBoundingBoxDescent;
264
+ const x = (normalizedWidth.value - metrics.width) / 2;
265
+ const y = (normalizedHeight.value - textHeight) / 2 + metrics.actualBoundingBoxAscent;
266
+
267
+ context.save();
268
+ context.fillStyle = '#000000';
269
+ context.textBaseline = 'alphabetic';
270
+ context.fillText(text, x, y);
271
+ context.restore();
272
+ filled.value = true;
273
+ }
274
+
192
275
  async function renderModelValue(modelValue: string | null | undefined) {
193
276
  const requestId = ++loadRequestId;
194
277
  errorMessage.value = null;
@@ -356,6 +439,7 @@ function startDrawing(event: PointerEvent) {
356
439
 
357
440
  event.preventDefault();
358
441
  loadRequestId += 1;
442
+ applyingTextSignature.value = false;
359
443
  activePointerId = event.pointerId;
360
444
  drawing = true;
361
445
  errorMessage.value = null;
package/src/lang/en.json CHANGED
@@ -12,6 +12,7 @@
12
12
  "address_2_description": "Apartment, suite, unit, building",
13
13
  "and": "and",
14
14
  "apply": "Apply",
15
+ "apply_signature": "Apply my signature",
15
16
  "apply_filters": "Apply filters",
16
17
  "authentication_code": "Authentication code",
17
18
  "autocomplete_placeholder": "Search...",
package/src/lang/fr.json CHANGED
@@ -12,6 +12,7 @@
12
12
  "address_2_description": "Appartement, suite, unité, immeuble",
13
13
  "and": "et",
14
14
  "apply": "Appliquer",
15
+ "apply_signature": "Appliquer ma signature",
15
16
  "apply_filters": "Appliquer les filtres",
16
17
  "authentication_code": "Code d'authentification",
17
18
  "autocomplete_placeholder": "Rechercher...",