Files
docuseal/app/javascript/submission_form/signature_step.vue
T

977 lines
28 KiB
Vue
Raw Normal View History

2023-05-25 22:47:52 +03:00
<template>
2024-05-12 12:08:18 +03:00
<div
dir="auto"
class="relative"
>
2024-03-09 18:24:21 +02:00
<div
2025-02-02 12:30:01 +02:00
class="flex justify-between items-end w-full mb-3.5 md:mb-4"
2024-03-09 18:24:21 +02:00
:class="{ 'mb-2': !field.description }"
>
2023-06-15 01:05:04 +03:00
<label
v-if="showFieldNames"
2025-03-18 17:34:11 +02:00
class="label text-xl sm:text-2xl py-0 field-name-label"
2024-03-09 18:24:21 +02:00
>
<MarkdownContent
v-if="field.title"
:string="field.title"
/>
<template v-else>
{{ field.name || t('signature') }}
2024-03-09 18:24:21 +02:00
</template>
</label>
<div class="space-x-2 flex flex-none">
<span
2025-07-31 11:25:30 +03:00
v-if="isTextSignature && format !== 'typed_or_upload' && format !== 'typed' && format !== 'upload'"
2025-12-15 10:22:27 +02:00
class="md:tooltip"
2023-11-18 13:57:49 +02:00
:data-tip="t('draw_signature')"
>
2026-04-16 12:41:42 +03:00
<button
2023-08-20 12:42:47 +03:00
id="type_text_button"
2026-06-23 09:09:31 +03:00
ref="typeTextButton"
2026-04-16 12:41:42 +03:00
type="button"
:aria-label="t('draw_signature')"
2025-03-18 17:34:11 +02:00
class="btn btn-outline btn-sm font-medium type-text-button"
2026-04-16 12:41:42 +03:00
@click="[toggleTextInput(), hideQr()]"
2023-11-18 13:57:49 +02:00
>
2026-04-16 12:41:42 +03:00
<IconSignature
:width="16"
aria-hidden="true"
/>
2023-11-18 13:57:49 +02:00
<span class="hidden sm:inline">
{{ t('draw') }}
</span>
2026-04-16 12:41:42 +03:00
</button>
2023-11-18 13:57:49 +02:00
</span>
<span
2025-07-31 11:25:30 +03:00
v-else-if="withTypedSignature && format !== 'drawn_or_upload' && format !== 'typed_or_upload' && format !== 'typed' && format !== 'drawn' && format !== 'upload'"
2025-12-15 10:22:27 +02:00
class="md:tooltip ml-2"
2025-01-31 20:11:57 +02:00
:class="{ 'hidden sm:inline': modelValue || computedPreviousValue }"
2023-11-18 13:57:49 +02:00
:data-tip="t('type_text')"
>
2026-04-16 12:41:42 +03:00
<button
2023-11-18 13:57:49 +02:00
id="type_text_button"
2026-06-23 09:09:31 +03:00
ref="typeTextButton"
2026-04-16 12:41:42 +03:00
type="button"
:aria-label="t('type_text')"
2025-03-18 17:34:11 +02:00
class="btn btn-outline btn-sm font-medium inline-flex flex-nowrap type-text-button"
2026-04-16 12:41:42 +03:00
@click="[toggleTextInput(), hideQr()]"
>
2026-04-16 12:41:42 +03:00
<IconTextSize
:width="16"
aria-hidden="true"
/>
2023-11-18 13:57:49 +02:00
<span class="hidden sm:inline">
{{ t('type') }}
</span>
2026-04-16 12:41:42 +03:00
</button>
</span>
<span
2024-12-19 00:24:16 +02:00
v-if="format !== 'typed' && format !== 'drawn' && format !== 'upload' && format !== 'drawn_or_typed'"
2025-12-15 10:22:27 +02:00
class="md:tooltip"
2025-01-31 20:11:57 +02:00
:class="{ 'hidden sm:inline': modelValue || computedPreviousValue }"
2024-01-29 19:53:27 +02:00
:data-tip="t('take_photo')"
>
2026-04-17 14:49:14 +03:00
<button
type="button"
2026-06-23 09:09:31 +03:00
:aria-label="t('upload')"
2026-04-16 12:41:42 +03:00
class="btn btn-outline btn-sm font-medium inline-flex flex-nowrap upload-image-button"
2026-04-17 14:49:14 +03:00
@click="$refs.takePhotoInput.click()"
2026-04-16 12:41:42 +03:00
>
<IconCamera
:width="16"
aria-hidden="true"
/>
<input
2024-02-18 22:12:28 +02:00
:key="uploadImageInputKey"
2026-04-17 14:49:14 +03:00
ref="takePhotoInput"
type="file"
hidden
accept="image/*"
@change="drawImage"
>
2023-11-18 13:57:49 +02:00
<span class="hidden sm:inline">
{{ t('upload') }}
</span>
2026-04-17 14:49:14 +03:00
</button>
</span>
2026-04-16 12:41:42 +03:00
<button
v-if="modelValue || computedPreviousValue"
2026-04-16 12:41:42 +03:00
type="button"
2025-03-18 17:34:11 +02:00
class="btn btn-outline btn-sm font-medium reupload-button"
2026-04-16 12:41:42 +03:00
@click="remove"
>
2026-04-16 12:41:42 +03:00
<IconReload
:width="16"
aria-hidden="true"
/>
2024-12-19 00:24:16 +02:00
{{ t(format === 'upload' ? 'reupload' : 'redraw') }}
2026-04-16 12:41:42 +03:00
</button>
2024-06-06 16:54:16 +03:00
<span
2025-07-31 11:25:30 +03:00
v-if="withQrButton && !modelValue && !computedPreviousValue && format !== 'typed_or_upload' && format !== 'typed' && format !== 'upload'"
2025-12-15 10:22:27 +02:00
class="md:tooltip before:translate-x-[-90%]"
2025-12-11 10:28:07 +02:00
:data-tip="t('sign_on_the_touchscreen')"
>
2026-04-16 12:41:42 +03:00
<button
type="button"
:aria-label="t('sign_on_the_touchscreen')"
2024-06-06 16:54:16 +03:00
class="btn btn-sm btn-neutral font-medium hidden md:flex"
:class="{ 'btn-outline': !isShowQr, 'text-white': isShowQr }"
2026-04-16 12:41:42 +03:00
@click="isShowQr ? hideQr() : [isTextSignature = false, showQr()]"
2024-06-06 16:54:16 +03:00
>
<IconQrcode
:width="19"
:height="19"
2026-04-16 12:41:42 +03:00
aria-hidden="true"
2024-06-06 16:54:16 +03:00
/>
2026-04-16 12:41:42 +03:00
</button>
2024-06-06 16:54:16 +03:00
</span>
2026-04-16 12:41:42 +03:00
<button
type="button"
2024-02-19 23:52:47 +02:00
:title="t('minimize')"
2026-04-16 12:41:42 +03:00
:aria-label="t('minimize')"
2023-07-17 22:48:01 +03:00
class="py-1.5 inline md:hidden"
2026-04-16 12:41:42 +03:00
@click="$emit('minimize')"
2023-07-17 22:48:01 +03:00
>
<IconArrowsDiagonalMinimize2
:width="20"
:height="20"
2026-04-16 12:41:42 +03:00
aria-hidden="true"
2023-07-17 22:48:01 +03:00
/>
2026-04-16 12:41:42 +03:00
</button>
</div>
2023-06-15 01:05:04 +03:00
</div>
2024-03-09 18:24:21 +02:00
<div
v-if="field.description"
2026-04-16 12:41:42 +03:00
:id="field.uuid + '-desc'"
2024-03-09 18:24:21 +02:00
dir="auto"
2025-03-18 17:34:11 +02:00
class="mb-3 px-1 field-description-text"
2024-03-09 18:24:21 +02:00
>
<MarkdownContent :string="field.description" />
</div>
2024-01-06 17:21:15 +02:00
<AppearsOn :field="field" />
2023-05-25 22:47:52 +03:00
<input
:value="modelValue || computedPreviousValue"
2023-05-25 22:47:52 +03:00
type="hidden"
:name="`values[${field.uuid}]`"
>
2026-02-02 12:46:34 +02:00
<input
v-if="isTouchAttachment"
:value="touchAttachmentUuid"
type="hidden"
name="touch_attachment_uuid"
>
2023-05-25 22:47:52 +03:00
<img
v-if="modelValue || computedPreviousValue"
:src="attachmentsIndex[modelValue || computedPreviousValue].url"
2026-04-16 12:41:42 +03:00
:alt="field.name || t('signature')"
2024-06-11 18:19:21 +03:00
class="mx-auto bg-white border border-base-300 rounded max-h-44"
2023-05-25 22:47:52 +03:00
>
2024-12-19 00:24:16 +02:00
<FileDropzone
2025-06-09 17:06:20 +03:00
v-if="format === 'upload' && !modelValue && !computedPreviousValue"
2024-12-19 00:24:16 +02:00
:message="`${t('upload')} ${field.name || t('signature')}`"
:submitter-slug="submitterSlug"
:dry-run="dryRun"
:accept="'image/*'"
@upload="[$emit('attached', $event[0]), $emit('update:model-value', $event[0].uuid)]"
/>
<div
v-else
2025-12-15 10:22:27 +02:00
class="relative select-none"
2024-12-19 00:24:16 +02:00
>
2024-06-02 14:50:37 +03:00
<div
2024-06-06 16:54:16 +03:00
v-if="!modelValue && !computedPreviousValue && !isShowQr && !isTextSignature && isSignatureStarted"
class="absolute top-0.5 right-0.5"
2024-06-02 14:50:37 +03:00
>
2026-04-16 12:41:42 +03:00
<button
type="button"
2024-06-06 16:54:16 +03:00
class="btn btn-ghost font-medium btn-xs md:btn-sm"
2026-04-16 12:41:42 +03:00
@click="[clear(), hideQr()]"
2024-06-02 14:50:37 +03:00
>
2026-04-16 12:41:42 +03:00
<IconReload
:width="16"
aria-hidden="true"
/>
2024-06-06 16:54:16 +03:00
{{ t('clear') }}
2026-04-16 12:41:42 +03:00
</button>
2024-06-02 14:50:37 +03:00
</div>
2024-10-31 16:43:43 +02:00
<div
v-if="isTextSignature"
class="absolute top-0 right-0 left-0 bottom-0"
/>
2024-06-02 14:50:37 +03:00
<canvas
v-show="!modelValue && !computedPreviousValue"
ref="canvas"
2026-06-23 09:09:31 +03:00
role="img"
2026-04-16 12:41:42 +03:00
:aria-label="t('signature_drawing_area')"
2024-06-02 14:50:37 +03:00
style="padding: 1px; 0"
2025-03-18 17:34:11 +02:00
class="bg-white border border-base-300 rounded-2xl w-full draw-canvas"
2024-06-02 14:50:37 +03:00
/>
2024-06-06 16:54:16 +03:00
<div
v-if="isShowQr"
class="top-0 bottom-0 right-0 left-0 absolute bg-white rounded-2xl m-0.5"
/>
2024-06-02 14:50:37 +03:00
<div
2024-07-20 08:54:02 +03:00
v-if="isShowQr"
2024-06-02 14:50:37 +03:00
class="top-0 bottom-0 right-0 left-0 absolute bg-base-content/10 rounded-2xl"
>
2026-01-20 17:21:14 +02:00
<div class="absolute top-1.5 right-1.5">
2026-04-16 12:41:42 +03:00
<button
type="button"
2024-06-02 14:50:37 +03:00
class="btn btn-sm btn-circle btn-normal btn-outline"
2026-04-16 12:41:42 +03:00
:aria-label="t('close')"
@click="hideQr"
2024-06-02 14:50:37 +03:00
>
2026-04-16 12:41:42 +03:00
<IconX aria-hidden="true" />
</button>
2024-06-02 14:50:37 +03:00
</div>
<div class="flex items-center justify-center w-full h-full p-4">
<div
class="bg-white p-4 rounded-xl h-full"
>
<canvas
ref="qrCanvas"
2026-06-25 15:26:52 +03:00
role="img"
:aria-label="t('scan_the_qr_code_with_the_camera_app_to_open_the_form_on_mobile_and_draw_your_signature')"
2024-06-02 14:50:37 +03:00
class="h-full"
2024-07-20 08:54:02 +03:00
width="132"
height="132"
2024-06-02 14:50:37 +03:00
/>
</div>
</div>
</div>
</div>
<input
2025-06-30 11:41:07 +03:00
v-if="isTextSignature && !modelValue && !computedPreviousValue"
2023-08-20 12:42:47 +03:00
id="signature_text_input"
ref="textInput"
class="base-input !text-2xl w-full mt-6"
2024-02-16 18:22:40 +02:00
:required="field.required && !isSignatureStarted"
2026-06-25 15:26:52 +03:00
:aria-label="field.name || t('signature')"
2026-07-22 08:45:20 +03:00
:placeholder="`${t('type_signature_here')}${field.required ? '...' : ' (' + t('optional') + ')'}`"
type="text"
@input="updateWrittenSignature"
>
2024-08-06 00:19:33 +03:00
<select
2025-11-11 13:42:01 +02:00
v-if="withSigningReason && !isOtherReason"
2024-08-06 00:19:33 +03:00
class="select base-input !text-2xl w-full mt-6 text-center"
2024-10-10 19:49:31 +03:00
:class="{ 'text-gray-300': !reason }"
2024-08-06 00:19:33 +03:00
required
2026-06-25 15:26:52 +03:00
:aria-label="t('select_a_reason')"
2024-08-06 00:19:33 +03:00
:name="`values[${field.preferences.reason_field_uuid}]`"
@change="$event.target.value === 'other' ? [reason = '', isOtherReason = true] : $emit('update:reason', $event.target.value)"
>
<option
value=""
disabled
:selected="!reason"
2024-10-10 19:49:31 +03:00
class="text-gray-300"
2024-08-06 00:19:33 +03:00
>
{{ t('select_a_reason') }}
</option>
2025-11-11 13:42:01 +02:00
<template v-if="field.preferences?.reasons">
<option
v-for="option in field.preferences.reasons"
:key="option"
:value="option"
:selected="reason === option"
class="text-base-content"
>
{{ option }}
</option>
</template>
<template v-else>
<option
v-for="(label, option) in defaultReasons"
:key="option"
:value="option"
:selected="reason === option"
class="text-base-content"
>
{{ label }}
</option>
<option
value="other"
class="text-base-content"
>
{{ t('other') }}
</option>
</template>
2024-08-06 00:19:33 +03:00
</select>
<input
2025-11-11 13:42:01 +02:00
v-if="withSigningReason && isOtherReason"
2024-08-06 00:19:33 +03:00
class="base-input !text-2xl w-full mt-6"
required
:name="`values[${field.preferences.reason_field_uuid}]`"
2026-06-25 15:26:52 +03:00
:aria-label="t('select_a_reason')"
2024-08-06 00:19:33 +03:00
:placeholder="t('type_here_')"
:value="reason"
type="text"
@input="$emit('update:reason', $event.target.value)"
>
<input
2025-11-11 13:42:01 +02:00
v-if="withSigningReason"
2024-08-06 00:19:33 +03:00
hidden
name="with_reason"
:value="field.preferences.reason_field_uuid"
>
2024-05-12 12:08:18 +03:00
<div
2024-06-02 14:50:37 +03:00
v-if="isShowQr"
dir="auto"
class="text-base-content/60 text-xs text-center w-full mt-1"
>
{{ t('scan_the_qr_code_with_the_camera_app_to_open_the_form_on_mobile_and_draw_your_signature') }}
</div>
<div
v-else-if="withDisclosure"
2024-05-12 12:08:18 +03:00
dir="auto"
2025-12-15 10:22:27 +02:00
class="text-base-content/60 text-xs text-center w-full mt-1 select-none"
2024-05-12 12:08:18 +03:00
>
{{ t('by_clicking_you_agree_to_the').replace('{button}', buttonText.charAt(0).toUpperCase() + buttonText.slice(1)) }} <a
2024-11-29 22:03:09 +02:00
href="https://www.docuseal.com/esign-disclosure"
2024-05-12 12:08:18 +03:00
target="_blank"
>
<span class="inline md:hidden">
{{ t('esignature_disclosure') }}
</span>
<span class="hidden md:inline">
{{ t('electronic_signature_disclosure') }}
</span>
</a>
</div>
2024-06-02 14:50:37 +03:00
<div
v-else
class="mt-5 md:mt-7"
/>
2023-05-25 22:47:52 +03:00
</div>
</template>
<script>
2024-06-02 14:50:37 +03:00
import { IconReload, IconCamera, IconSignature, IconTextSize, IconArrowsDiagonalMinimize2, IconQrcode, IconX } from '@tabler/icons-vue'
2023-09-22 21:50:39 +03:00
import { cropCanvasAndExportToPNG } from './crop_canvas'
2026-02-21 09:53:03 +02:00
import { isValidSignatureCanvas, isCanvasBlocked } from './validate_signature'
2023-10-22 23:26:43 +03:00
import SignaturePad from 'signature_pad'
2024-01-06 17:21:15 +02:00
import AppearsOn from './appears_on'
2024-12-19 00:24:16 +02:00
import FileDropzone from './dropzone'
2024-03-09 18:24:21 +02:00
import MarkdownContent from './markdown_content'
2024-08-06 00:19:33 +03:00
import { v4 } from 'uuid'
2023-05-25 22:47:52 +03:00
let fontLoadPromise = null
2023-10-28 16:03:19 +03:00
2024-02-09 22:33:18 +02:00
const scale = 3
2023-05-25 22:47:52 +03:00
export default {
name: 'SignatureStep',
2023-06-15 01:05:04 +03:00
components: {
2024-01-06 17:21:15 +02:00
AppearsOn,
IconReload,
2024-12-19 00:24:16 +02:00
FileDropzone,
IconCamera,
2024-06-02 14:50:37 +03:00
IconQrcode,
2024-03-09 18:24:21 +02:00
MarkdownContent,
2024-06-02 14:50:37 +03:00
IconX,
2023-07-17 22:48:01 +03:00
IconTextSize,
2023-11-18 13:57:49 +02:00
IconSignature,
2023-07-17 22:48:01 +03:00
IconArrowsDiagonalMinimize2
2023-06-15 01:05:04 +03:00
},
2023-09-20 00:13:14 +03:00
inject: ['baseUrl', 't'],
2023-05-25 22:47:52 +03:00
props: {
field: {
type: Object,
required: true
},
2026-02-02 12:46:34 +02:00
values: {
type: Object,
required: true
},
2024-08-06 00:19:33 +03:00
requireSigningReason: {
type: Boolean,
required: false,
default: false
},
2024-06-15 15:55:09 +03:00
submitter: {
type: Object,
2023-05-25 22:47:52 +03:00
required: true
},
2024-01-24 23:20:53 +02:00
showFieldNames: {
type: Boolean,
required: false,
default: true
},
2024-07-02 20:27:25 +03:00
dryRun: {
type: Boolean,
required: false,
default: false
},
2024-05-12 12:08:18 +03:00
withDisclosure: {
type: Boolean,
required: false,
default: false
},
2024-06-02 14:50:37 +03:00
withQrButton: {
type: Boolean,
required: false,
default: false
},
2024-05-12 12:08:18 +03:00
buttonText: {
type: String,
required: false,
default: 'Submit'
},
withTypedSignature: {
type: Boolean,
required: false,
default: true
},
2024-06-15 15:55:09 +03:00
rememberSignature: {
type: Boolean,
required: false,
default: false
},
2023-05-25 22:47:52 +03:00
attachmentsIndex: {
type: Object,
required: false,
default: () => ({})
},
previousValue: {
type: String,
required: false,
default: ''
},
2026-02-02 12:46:34 +02:00
touchAttachmentUuid: {
type: String,
required: false,
default: ''
},
2024-08-06 00:19:33 +03:00
reason: {
type: String,
required: false,
default: ''
},
2026-04-11 11:50:41 +03:00
signatureText: {
type: String,
required: false,
default: ''
},
signatureSrc: {
type: String,
required: false,
default: ''
},
2023-05-25 22:47:52 +03:00
modelValue: {
type: String,
required: false,
default: ''
}
},
2026-02-02 12:46:34 +02:00
emits: ['attached', 'update:model-value', 'start', 'minimize', 'update:reason', 'touch-attachment'],
2023-06-24 17:15:36 +03:00
data () {
return {
2025-06-30 11:41:07 +03:00
isSignatureStarted: false,
2024-06-02 14:50:37 +03:00
isShowQr: false,
2024-08-06 00:19:33 +03:00
isOtherReason: false,
isUsePreviousValue: true,
2026-02-02 12:46:34 +02:00
isTouchAttachment: false,
2026-04-11 11:50:41 +03:00
isTextSignature: !this.signatureSrc && (!!this.signatureText || this.field.preferences?.format === 'typed' || this.field.preferences?.format === 'typed_or_upload'),
2024-02-18 22:12:28 +02:00
uploadImageInputKey: Math.random().toString()
2023-06-24 17:15:36 +03:00
}
},
computed: {
2024-06-15 15:55:09 +03:00
submitterSlug () {
return this.submitter.slug
},
2024-12-19 00:24:16 +02:00
format () {
return this.field.preferences?.format
},
2025-11-11 13:42:01 +02:00
withSigningReason () {
return this.requireSigningReason || this.field.preferences?.reasons?.length
},
2024-08-06 00:19:33 +03:00
defaultReasons () {
return {
[this.t('approved_by')]: this.t('approved'),
[this.t('reviewed_by')]: this.t('reviewed'),
[this.t('authored_by')]: this.t('authored_by_me')
}
},
computedPreviousValue () {
2025-05-28 21:41:32 +03:00
if (this.isUsePreviousValue && this.field.required === true) {
return this.previousValue
} else {
return null
}
}
},
2024-08-06 00:19:33 +03:00
created () {
2025-06-30 11:41:07 +03:00
this.isSignatureStarted = !!this.computedPreviousValue
2025-11-11 13:42:01 +02:00
if (this.withSigningReason) {
2024-08-06 00:19:33 +03:00
this.field.preferences ||= {}
this.field.preferences.reason_field_uuid ||= v4()
2025-11-11 13:42:01 +02:00
this.isOtherReason = this.reason && !this.defaultReasons[this.reason] &&
(!this.field.preferences?.reasons?.length || !this.field.preferences.reasons.includes(this.reason))
2024-08-06 00:19:33 +03:00
}
},
2023-06-24 18:31:12 +03:00
async mounted () {
2026-01-20 17:21:14 +02:00
this.$nextTick(() => this.setCanvasSize())
2023-06-15 01:05:04 +03:00
2023-09-20 00:13:14 +03:00
if (this.$refs.canvas) {
this.pad = new SignaturePad(this.$refs.canvas)
2023-06-24 17:15:36 +03:00
2024-11-10 12:48:53 +02:00
if (this.field.preferences?.color) {
this.pad.penColor = this.field.preferences.color
}
2024-06-06 16:54:16 +03:00
this.pad.addEventListener('endStroke', () => {
2023-09-20 00:13:14 +03:00
this.isSignatureStarted = true
2023-07-17 22:48:01 +03:00
2023-09-20 00:13:14 +03:00
this.$emit('start')
})
2024-02-07 01:47:57 +02:00
2026-01-20 17:21:14 +02:00
this.intersectionObserver = new IntersectionObserver((entries) => {
2024-02-07 01:47:57 +02:00
entries.forEach(entry => {
if (entry.isIntersecting) {
2026-01-20 17:21:14 +02:00
this.setCanvasSize()
2024-02-08 12:11:10 +02:00
2026-04-11 11:50:41 +03:00
if (this.signatureSrc) {
this.$nextTick(() => this.drawSignatureSrc())
} else if (this.isTextSignature) {
this.$nextTick(() => {
2026-07-22 08:45:20 +03:00
if (this.$refs.textInput && this.field.required === true) {
this.initTypedSignature()
}
})
}
2024-02-08 12:11:10 +02:00
this.intersectionObserver?.disconnect()
2024-02-07 01:47:57 +02:00
}
})
2024-02-08 12:11:10 +02:00
})
this.intersectionObserver.observe(this.$refs.canvas)
2026-01-20 17:21:14 +02:00
this.resizeObserver = new ResizeObserver(() => {
2026-01-26 12:06:41 +02:00
requestAnimationFrame(() => {
if (!this.$refs.canvas) return
2026-04-30 14:51:24 +03:00
if (!this.$refs.canvas.parentNode?.clientWidth) return
2026-01-20 17:21:14 +02:00
2026-01-26 12:06:41 +02:00
const { width, height } = this.$refs.canvas
2026-01-20 17:21:14 +02:00
2026-01-26 12:06:41 +02:00
this.setCanvasSize()
if (this.$refs.canvas.width !== width || this.$refs.canvas.height !== height) {
this.redrawCanvas(width, height)
}
})
2026-01-20 17:21:14 +02:00
})
this.resizeObserver.observe(this.$refs.canvas.parentNode)
if (this.isTextSignature) {
this.loadFont()
}
2023-09-20 00:13:14 +03:00
}
2023-05-25 22:47:52 +03:00
},
2024-02-07 01:47:57 +02:00
beforeUnmount () {
this.intersectionObserver?.disconnect()
2026-01-20 17:21:14 +02:00
this.resizeObserver?.disconnect()
2024-06-02 14:50:37 +03:00
this.stopCheckSignature()
2024-02-07 01:47:57 +02:00
},
2023-05-25 22:47:52 +03:00
methods: {
2026-01-20 17:21:14 +02:00
setCanvasSize () {
const canvas = this.$refs.canvas
if (canvas) {
const width = canvas.parentNode.clientWidth
const height = width / 3
if (canvas.width !== width * scale || canvas.height !== height * scale) {
canvas.width = width * scale
canvas.height = height * scale
canvas.getContext('2d').scale(scale, scale)
}
}
},
redrawCanvas (oldWidth, oldHeight) {
const canvas = this.$refs.canvas
2026-04-30 14:42:22 +03:00
if (this.pad && !this.isTextSignature && !this.pad.isEmpty() && oldWidth > 0 && oldHeight > 0 && canvas.width > 0 && canvas.height > 0) {
2026-01-20 17:21:14 +02:00
const sx = canvas.width / oldWidth
const sy = canvas.height / oldHeight
const scaledData = this.pad.toData().map((stroke) => ({
...stroke,
points: stroke.points.map((p) => ({ ...p, x: p.x * sx, y: p.y * sy }))
}))
this.pad.fromData(scaledData)
} else if (this.isTextSignature && this.$refs.textInput) {
this.updateWrittenSignature({ target: this.$refs.textInput })
2026-01-20 17:21:14 +02:00
}
},
2023-05-25 22:47:52 +03:00
remove () {
this.$emit('update:model-value', '')
this.isUsePreviousValue = false
this.isSignatureStarted = false
2023-05-25 22:47:52 +03:00
},
2023-10-28 16:03:19 +03:00
loadFont () {
if (!fontLoadPromise) {
2024-01-29 17:44:55 +02:00
const font = new FontFace('Dancing Script', `url(${this.baseUrl}/fonts/DancingScript-Regular.otf) format("opentype")`)
2023-10-28 16:03:19 +03:00
fontLoadPromise = font.load().then((loadedFont) => {
2023-10-28 16:03:19 +03:00
document.fonts.add(loadedFont)
}).catch((error) => {
console.error('Font loading failed:', error)
})
}
return fontLoadPromise
2023-10-28 16:03:19 +03:00
},
2024-06-02 14:50:37 +03:00
showQr () {
this.isShowQr = true
2024-07-20 08:54:02 +03:00
this.$nextTick(() => {
import('qr-creator').then(({ default: Qr }) => {
if (this.$refs.qrCanvas) {
Qr.render({
text: `${document.location.origin}/p/${this.submitterSlug}?f=${this.field.uuid.split('-')[0]}`,
radius: 0.0,
ecLevel: 'H',
background: null,
size: 132
}, this.$refs.qrCanvas)
}
})
})
2024-06-02 14:50:37 +03:00
this.startCheckSignature()
},
hideQr () {
this.isShowQr = false
this.stopCheckSignature()
},
startCheckSignature () {
const after = JSON.stringify(new Date())
this.checkSignatureInterval = setInterval(() => {
this.checkSignature({ after })
}, 2000)
},
stopCheckSignature () {
if (this.checkSignatureInterval) {
clearInterval(this.checkSignatureInterval)
}
},
checkSignature (params = {}) {
return fetch(this.baseUrl + '/s/' + this.submitterSlug + '/values?field_uuid=' + this.field.uuid + '&after=' + params.after, {
method: 'GET'
}).then(async (resp) => {
const { attachment } = await resp.json()
if (attachment?.uuid) {
this.$emit('attached', attachment)
this.$emit('update:model-value', attachment.uuid)
this.hideQr()
}
})
},
2023-05-25 22:47:52 +03:00
clear () {
this.pad.clear()
2023-06-24 17:15:36 +03:00
this.isSignatureStarted = false
if (this.$refs.textInput) {
this.$refs.textInput.value = ''
2024-06-06 16:54:16 +03:00
this.$refs.textInput.focus()
}
},
updateWrittenSignature (e) {
2024-02-16 18:22:40 +02:00
this.isSignatureStarted = !!e.target.value
const canvas = this.$refs.canvas
const context = canvas.getContext('2d')
2023-10-28 16:03:19 +03:00
const fontFamily = 'Dancing Script'
const initialFontSize = 44
const fontStyle = 'italic'
const fontWeight = ''
const setFontSize = (size) => {
context.font = `${fontStyle} ${fontWeight} ${size}px ${fontFamily}`
}
const adjustFontSizeToFit = (text, maxWidth, initialSize) => {
let size = initialSize
setFontSize(size)
while (context.measureText(text).width > maxWidth && size > 1) {
size -= 1
setFontSize(size)
}
}
adjustFontSizeToFit(e.target.value, canvas.width / scale, initialFontSize)
context.textAlign = 'center'
2024-02-09 22:33:18 +02:00
context.clearRect(0, 0, canvas.width / scale, canvas.height / scale)
context.fillText(e.target.value, canvas.width / 2 / scale, canvas.height / 2 / scale + 11)
},
toggleTextInput () {
2023-07-17 22:48:01 +03:00
this.remove()
2024-01-26 17:12:08 +02:00
this.clear()
this.isTextSignature = !this.isTextSignature
2026-06-23 09:09:31 +03:00
this.$nextTick(() => {
if (this.isTextSignature) {
if (this.$refs.textInput) {
2026-06-23 09:09:31 +03:00
if (this.submitter.name) {
this.$refs.typeTextButton?.focus()
} else {
2026-02-04 17:05:12 +02:00
this.$refs.textInput.focus()
}
2023-07-17 22:48:01 +03:00
this.initTypedSignature()
2023-10-28 16:03:19 +03:00
this.$emit('start')
}
2026-06-23 09:09:31 +03:00
} else {
this.$refs.typeTextButton?.focus()
}
})
},
async initTypedSignature () {
2026-04-11 11:50:41 +03:00
if (this.signatureText) {
this.$refs.textInput.value = this.signatureText
} else if (this.submitter.name) {
this.$refs.textInput.value = this.submitter.name
}
await this.loadFont()
if (this.$refs.textInput.value) {
this.updateWrittenSignature({ target: this.$refs.textInput })
}
},
2026-04-11 11:50:41 +03:00
drawSignatureSrc () {
const canvas = this.$refs.canvas
if (!canvas) return
const img = new Image()
img.crossOrigin = 'anonymous'
img.onload = () => {
const context = canvas.getContext('2d')
const aspectRatio = img.width / img.height
const canvasWidth = canvas.width / scale
const canvasHeight = canvas.height / scale
let targetWidth = canvasWidth
let targetHeight = canvasHeight
if (canvasWidth / canvasHeight > aspectRatio) {
targetWidth = canvasHeight * aspectRatio
} else {
targetHeight = canvasWidth / aspectRatio
}
const x = (canvasWidth - targetWidth) / 2
const y = (canvasHeight - targetHeight) / 2
context.clearRect(0, 0, canvasWidth, canvasHeight)
context.drawImage(img, x, y, targetWidth, targetHeight)
this.isSignatureStarted = true
this.$emit('start')
}
img.onerror = () => {
console.error(`Failed to load signature image from ${this.signatureSrc}. The remote server must send an Access-Control-Allow-Origin header to allow CORS access.`)
}
img.src = this.signatureSrc
},
drawImage (event) {
2023-07-17 22:48:01 +03:00
this.remove()
2025-03-29 15:44:19 +02:00
this.clear()
this.isSignatureStarted = true
2024-08-20 16:15:49 +03:00
this.drawOnCanvas(event.target.files[0], this.$refs.canvas)
2024-08-19 21:35:26 +03:00
this.uploadImageInputKey = Math.random().toString()
},
2024-08-20 16:15:49 +03:00
drawOnCanvas (file, canvas) {
if (file && file.type.match('image.*')) {
const reader = new FileReader()
reader.onload = (event) => {
const img = new Image()
img.src = event.target.result
img.onload = () => {
const context = canvas.getContext('2d')
const aspectRatio = img.width / img.height
2024-02-09 22:33:18 +02:00
const canvasWidth = canvas.width / scale
const canvasHeight = canvas.height / scale
2024-02-09 22:33:18 +02:00
let targetWidth = canvasWidth
let targetHeight = canvasHeight
2024-02-09 22:33:18 +02:00
if (canvasWidth / canvasHeight > aspectRatio) {
targetWidth = canvasHeight * aspectRatio
} else {
2024-02-09 22:33:18 +02:00
targetHeight = canvasWidth / aspectRatio
}
if (targetHeight > targetWidth) {
const scale = targetHeight / targetWidth
targetWidth = targetWidth * scale
targetHeight = targetHeight * scale
}
2024-02-09 22:33:18 +02:00
const x = (canvasWidth - targetWidth) / 2
const y = (canvasHeight - targetHeight) / 2
2024-02-20 00:59:52 +02:00
setTimeout(() => {
context.clearRect(0, 0, canvasWidth, canvasHeight)
context.drawImage(img, x, y, targetWidth, targetHeight)
2023-07-17 22:48:01 +03:00
2024-02-20 00:59:52 +02:00
this.$emit('start')
}, 50)
}
}
reader.readAsDataURL(file)
}
},
2024-06-15 15:55:09 +03:00
maybeSetSignedUuid (signedUuid) {
try {
if (window.localStorage && signedUuid && this.rememberSignature) {
const values = window.localStorage.getItem('signed_signature_uuids')
let data
if (values) {
data = JSON.parse(values)
} else {
data = {}
}
data[this.submitter.email] = signedUuid
window.localStorage.setItem('signed_signature_uuids', JSON.stringify(data))
}
} catch (e) {
console.error(e)
}
},
2023-06-24 18:31:12 +03:00
async submit () {
if (this.modelValue || this.computedPreviousValue) {
2026-02-02 12:46:34 +02:00
if (this.touchAttachmentUuid && this.computedPreviousValue === this.touchAttachmentUuid && !Object.values(this.values).includes(this.touchAttachmentUuid)) {
this.isTouchAttachment = true
this.$emit('touch-attachment', this.touchAttachmentUuid)
} else {
this.isTouchAttachment = false
}
if (this.computedPreviousValue) {
this.$emit('update:model-value', this.computedPreviousValue)
}
2023-05-25 22:47:52 +03:00
return Promise.resolve({})
}
2025-03-29 13:40:25 +02:00
if (this.isSignatureStarted && this.pad.toData().length > 0 && !isValidSignatureCanvas(this.pad.toData())) {
2025-04-17 22:06:27 +03:00
if (this.field.required === true || this.pad.toData().length > 0) {
2026-02-21 09:53:03 +02:00
if (isCanvasBlocked()) {
alert(this.t('browser_privacy_settings_block_canvas'))
if (window.Rollbar) {
window.Rollbar.info('Canvas blocked')
}
} else {
alert(this.t('signature_is_too_small_or_simple_please_redraw'))
}
2025-03-29 13:40:25 +02:00
2025-04-17 22:06:27 +03:00
return Promise.reject(new Error('Image too small or simple'))
} else {
Promise.resolve({})
}
2025-03-29 13:40:25 +02:00
}
2025-04-17 22:06:27 +03:00
return new Promise((resolve, reject) => {
cropCanvasAndExportToPNG(this.$refs.canvas, { errorOnTooSmall: true }).then(async (blob) => {
2023-05-25 22:47:52 +03:00
const file = new File([blob], 'signature.png', { type: 'image/png' })
2024-07-02 20:27:25 +03:00
if (this.dryRun) {
const reader = new FileReader()
2024-04-06 15:00:30 +03:00
2024-07-02 20:27:25 +03:00
reader.readAsDataURL(file)
2024-04-06 15:00:30 +03:00
2024-07-02 20:27:25 +03:00
reader.onloadend = () => {
const attachment = { url: reader.result, uuid: Math.random().toString() }
2024-04-06 15:00:30 +03:00
2024-07-02 20:27:25 +03:00
this.$emit('attached', attachment)
this.$emit('update:model-value', attachment.uuid)
2024-06-15 15:55:09 +03:00
2024-07-02 20:27:25 +03:00
resolve(attachment)
}
} else {
const formData = new FormData()
formData.append('file', file)
formData.append('submitter_slug', this.submitterSlug)
formData.append('name', 'attachments')
formData.append('remember_signature', this.rememberSignature)
2025-06-26 11:04:25 +03:00
formData.append('type', 'signature')
2024-07-02 20:27:25 +03:00
return fetch(this.baseUrl + '/api/attachments', {
method: 'POST',
body: formData
2025-06-26 11:04:25 +03:00
}).then(async (resp) => {
if (resp.status === 422 || resp.status === 500) {
const data = await resp.json()
return Promise.reject(new Error(data.error))
}
const attachment = await resp.json()
2024-07-02 20:27:25 +03:00
this.$emit('attached', attachment)
this.$emit('update:model-value', attachment.uuid)
this.maybeSetSignedUuid(attachment.signed_uuid)
return resolve(attachment)
})
}
2025-04-17 22:06:27 +03:00
}).catch((error) => {
if (this.field.required === true) {
2026-02-21 09:53:03 +02:00
if (isCanvasBlocked()) {
alert(this.t('browser_privacy_settings_block_canvas'))
if (window.Rollbar) {
window.Rollbar.info('Canvas blocked')
}
} else {
alert(this.t('signature_is_too_small_or_simple_please_redraw'))
}
2025-04-17 22:06:27 +03:00
return reject(error)
} else {
return resolve({})
}
})
2023-05-25 22:47:52 +03:00
})
}
}
}
</script>